diff --git a/.github/workflows/build_bin.yml b/.github/workflows/build_bin.yml index 617e146a..406f5099 100644 --- a/.github/workflows/build_bin.yml +++ b/.github/workflows/build_bin.yml @@ -45,6 +45,15 @@ jobs: python-version: "3.14" architecture: "x64" + # the cab screens are drawn with Pillow; CMake copies this Python's Lib, site-packages included, next to the + # executable as python64\Lib, so Pillow has to be installed here to come with the build (as on Linux) + - name: Install Pillow for the screens + shell: pwsh + run: | + $pythonExe = "${{ steps.python.outputs.python-path }}" + & $pythonExe -m pip install --no-cache-dir Pillow + & $pythonExe -c "from PIL import Image; print('Pillow:', Image.__version__)" + - name: Show environment shell: pwsh run: | @@ -114,6 +123,18 @@ jobs: -Recurse ` -Force + # the Python 3 version of the module every cab screen builds on; the game data still has the Python 2 one + New-Item ` + -ItemType Directory ` + -Force ` + -Path "$output/python/local" | Out-Null + + Copy-Item ` + -Path "staticFiles/python/local/*" ` + -Destination "$output/python/local" ` + -Recurse ` + -Force + - name: Upload Windows build uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -250,9 +271,11 @@ jobs: - name: Copy shaders and translations run: | - mkdir -p build/bin/shaders build/bin/lang + mkdir -p build/bin/shaders build/bin/lang build/bin/python/local cp -R shaders/. build/bin/shaders/ cp -R lang/. build/bin/lang/ + # the Python 3 version of the module every cab screen builds on; the game data still has the Python 2 one + cp -R staticFiles/python/local/. build/bin/python/local/ - name: Upload Linux build uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.gitmodules b/.gitmodules index 273c15eb..0fd7ef90 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,3 +32,7 @@ [submodule "ref/glslang"] path = ref/glslang url = https://github.com/KhronosGroup/glslang.git +[submodule "ref/wamr"] + path = ref/wamr + url = https://github.com/bytecodealliance/wasm-micro-runtime + shallow = true diff --git a/CLAUDE.md b/CLAUDE.md index c1709be8..283058cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,16 @@ Where no GCC is at hand, read the new code for what MSVC lets through and GCC do The same behaviour needs portable code. Don't rely on the order arguments are evaluated in, on the signedness of `char`, on the size of `wchar_t`, or on `long` being 32 bits. -## SonarCloud: every remark on the changed code gets fixed +## SonarCloud: the PR passes the quality gate -SonarCloud (`sonar.yml`, `sonar-project.properties`) analyses each PR. A PR counts as finished only when every remark Sonar raises on -the code it changes is fixed. That means the code smells too, not just the bugs, the vulnerabilities and the quality gate. The analysis -runs on the GCC build, so a PR that fails to build with GCC gets no analysis at all. +SonarCloud (`sonar.yml`, `sonar-project.properties`) analyses each PR. A PR has to pass Sonar's quality gate. The gate rates the new +code: reliability, security and maintainability have to be A, duplication is at most 3% and the security hotspots are reviewed. +Any bug or vulnerability on the new code lowers its rating below A, so each one gets fixed (or gets a NOSONAR when it is a false +positive). Code smells count only together, through the maintainability rating, so a single one rarely fails the gate, but many in a +large PR can. A hotspot is reviewed by marking it in SonarCloud as safe or fixed, which is the user's step there, not a reason to +rewrite the code. A remark that doesn't fail the gate doesn't have to be fixed. Leave large restructurings that only answer such a +remark (splitting a class or a file, rewriting a working algorithm) out of the PR. The analysis runs on the GCC build, so a PR that +fails to build with GCC gets no analysis at all, and so it doesn't pass the gate. Fixing a remark must not change what the code does: @@ -41,7 +46,7 @@ Fixing a remark must not change what the code does: - `// NOSONAR: ` is only for a real false positive, or where a fix would change behaviour. Use it rarely, always with the reason. -Write new code so that it doesn't raise the rules that keep coming up: +To keep the gate passing, write new code so that it doesn't raise the rules that keep coming up: - **S134** (nesting deeper than 3 `if` / `for` / `while` / `switch` / `try`) and **S3776** (cognitive complexity over 25): - use early `continue` / `return`; diff --git a/CMakeLists.txt b/CMakeLists.txt index df7270a4..c3828564 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,7 @@ option(WITH_DLSS "Compile the Vulkan renderer with NVIDIA DLSS (gfx.antialiasing 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 script 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) @@ -198,6 +199,7 @@ set(SOURCES "world/station.cpp" "application/application.cpp" "simulation/simulationtime.cpp" +"simulation/simulationtest.cpp" "scene/sceneeditor.cpp" "rendering/screenshot.cpp" "input/driverkeyboardinput.cpp" @@ -425,6 +427,16 @@ if (WITH_LUA) set(SOURCES ${SOURCES} "scripting/lua.cpp") endif() +# the script screens (scripting/scriptscreens.cpp), with the Lua and the WebAssembly runtime when they're built +set(SOURCES ${SOURCES} "scripting/scriptscreens.cpp") +if (WITH_LUA) + set(SOURCES ${SOURCES} "scripting/luaprogram.cpp") +endif() +if (WITH_WASM) + set(DEFINITIONS ${DEFINITIONS} "WITH_WASM") + set(SOURCES ${SOURCES} "scripting/wasmprogram.cpp") +endif() + if (WITHDUMPGEN) set(DEFINITIONS ${DEFINITIONS} "WITHDUMPGEN") endif() @@ -678,7 +690,7 @@ endif() add_library(eu07_settings INTERFACE) # the parts of the simulation the game shares with the unit tests: the vehicle physics, the vehicle -# definition parser and the helpers they use. built once, and tested as the game uses them +# definition parser, the helpers they use and the rules of the test mode. built once, and tested as the game uses them set(CORE_SOURCES "McZapkie/friction.cpp" "McZapkie/hamulce.cpp" @@ -687,8 +699,14 @@ set(CORE_SOURCES "utilities/parser.cpp" "utilities/utilities.cpp" "scripting/ladderlogic.cpp" +"scripting/scriptscreenrules.cpp" +"scripting/scriptscreenkernels.cpp" +"scripting/scriptscreendrawing.cpp" +"simulation/simulationtestrules.cpp" +"vehicle/enginestart.cpp" "vehicle/handover.cpp" "vehicle/horn.cpp" +"vehicle/linebreaker.cpp" "vehicle/smoke.cpp" ) add_library(eu07_core STATIC ${CORE_SOURCES}) @@ -932,6 +950,11 @@ if (MSVC AND BUNDLE_VC_RUNTIME) endif() endif() +if (WITH_WASM) + add_subdirectory("CMake_modules/wamr" "${CMAKE_CURRENT_BINARY_DIR}/wamr") + target_link_libraries(${PROJECT_NAME} eu07_wamr) +endif () + # yaml-cpp, for the descriptions of scenery templates edited in the scenery editor and for the heightmap terrain add_subdirectory("ref/yaml-cpp" "${CMAKE_CURRENT_BINARY_DIR}/yaml-cpp" EXCLUDE_FROM_ALL) target_link_libraries(eu07_settings INTERFACE yaml-cpp) diff --git a/CMake_modules/wamr/CMakeLists.txt b/CMake_modules/wamr/CMakeLists.txt new file mode 100644 index 00000000..d12d48cd --- /dev/null +++ b/CMake_modules/wamr/CMakeLists.txt @@ -0,0 +1,67 @@ +# The WebAssembly Micro Runtime (ref/wamr) for the WebAssembly script screens (scripting/wasmprogram.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/EU07.cpp b/EU07.cpp index e1e03061..e8d226b7 100644 --- a/EU07.cpp +++ b/EU07.cpp @@ -19,9 +19,15 @@ Stele, firleju, szociu, hunter, ZiomalCl, OLI_EU and others #include "stdafx.h" #include "application/application.h" +#include "simulation/simulationtest.h" +#include "utilities/Globals.h" #include "utilities/Logs.h" #include "utilities/crashdump.h" #include +#include +#include +#include +#include #ifdef _MSC_VER #ifdef EU07_CPU_PREFLIGHT @@ -48,6 +54,55 @@ static void stop_log_service() it->second.join(); } +namespace +{ + +// the log is written by a thread of its own, which the closing of the application stops and waits for. on the ways +// out that skip the closing (a failed start, an exception) what was logged last would be lost with the process +void finish_log() +{ + StopLogService(); + if (auto const logservice{Global.threads.find("LogService")}; + logservice != Global.threads.end() && logservice->second.joinable() && logservice->second.get_id() != std::this_thread::get_id()) + logservice->second.join(); +} + +// ends a test run with its verdict on the standard output and in the exit code. a test the simulator didn't end +// (a failed start, an exception) ends with the error and Reason +[[noreturn]] void end_test_run(std::string_view const Reason) +{ + simulation::test().conclude(simulation::test_result::error, Reason); + finish_log(); + fflush(stdout); + fflush(stderr); + // skip destructors, there are ordering errors which causes segfaults + std::_Exit(simulation::test().exit_code()); +} + +// a test run (--test): whatever stops it, it ends with its verdict. any other run never gets here and ends as +// it always has +[[noreturn]] void run_test(int argc, char *argv[]) +{ + // for what doesn't reach the catch below: an exception leaving a noexcept function, or on Linux one leaving + // a thread (on Windows the handler is the main thread's only) + std::set_terminate([] { end_test_run(simulation::describe_exception(std::current_exception())); }); + try + { + if (Application.init(argc, argv) == 0) + { + Application.run(); + Application.exit(); + } + } + catch (...) + { + end_test_run(simulation::describe_exception(std::current_exception())); + } + end_test_run("the simulator stopped before the test ended"); +} + +} // namespace + int main(int argc, char *argv[]) { crashdump_install(); @@ -95,6 +150,11 @@ int main(int argc, char *argv[]) fflush(stderr); std::_Exit(result); } + // unattended test run of a scenery, which says its result in the exit code (eu07/maszyna#541) + else if (simulation::parse_test_command_line(std::vector(argv, argv + argc)).enabled) + { + run_test(argc, argv); + } else { try diff --git a/McZapkie/MOVER.h b/McZapkie/MOVER.h index 95ce2534..6bea69b0 100644 --- a/McZapkie/MOVER.h +++ b/McZapkie/MOVER.h @@ -10,7 +10,9 @@ http://mozilla.org/MPL/2.0/. #pragma once //--------------------------------------------------------------------------- // Q: 20160805 - odlaczenie pliku fizyki .pas od kompilacji +#include #include +#include #include "hamulce.h" #include "scripting/ladderlogic.h" @@ -263,6 +265,22 @@ enum class range_t /// Affects the entire consist. consist }; +/// State of the door closure signalling (SOD) of a vehicle or a consist, ordered from the least to the most +/// severe: the state of a consist is the most severe state of its vehicles. +enum class door_closure_t +{ + /// No doors worked by the crew: the vehicle doesn't take part in the signalling. + none, + /// All doors closed and locked: they can't be opened. + locked, + /// All doors closed, but released: a permit is given, or no lock holds them shut. + released, + /// A door is open, or not fully closed yet. + open +}; +/// Whether the door closure signalling lamp is lit: steadily for locked doors, in the lit phase of the +/// blinking for released ones, never with a door open or with no doors signalled. +bool door_closure_lamp(door_closure_t const State, bool const BlinkLit); /// Possible settings for an enable/disable input pair (mutually exclusive). enum class operation_t { @@ -326,6 +344,21 @@ enum light highbeamlight_right = 1 << 10 }; +/// The cab interior light a position of the light preset selector sets (eu07/maszyna#529). +struct lights_preset_cablight +{ + /// Bits of the optional third column of a LightsList row. + enum flag + { + /// The cab light is on. + light_on = 1 << 0, + /// The cab light is dimmed. + light_dimmed = 1 << 1 + }; + bool light{false}; + bool dim{false}; +}; + /// Door operation method (who controls them and how) — mutually exclusive. enum control_t { @@ -1133,6 +1166,7 @@ class TSecuritySystem bool is_sifa = false; // Sifa-like pedal device, with inverted input for convenient keyboard usage bool separate_acknowledge = false; // cabsignal reset button is separate from vigilance bool cabsignal_lock = false; + bool vigilance_suspended = false; // the vigilance device switched off by the shunt mode of the vehicle, see AwareOffInShuntMode double vigilance_timer = 0.0; double alert_timer = 0.0; @@ -1148,6 +1182,7 @@ class TSecuritySystem double EmergencyBrakeDelay = 5.0; double MaxHoldTime = 1.5; bool CabDependent = false; + bool AwareOffInShuntMode = false; // the shunt mode of the vehicle switches the vigilance device off, as on the SM42 public: void set_enabled(bool e); @@ -1157,6 +1192,7 @@ class TSecuritySystem void update(double dt, double Vel, bool pwr, int cab); void set_cabsignal(); void set_cabsignal_lock(bool); + void set_shunt_mode(bool State); bool is_blinking() const; bool is_vigilance_blinking() const; bool is_cabsignal_blinking() const; @@ -1441,6 +1477,7 @@ class TMoverParameters float auto_velocity{-1.f}; // automatic door closure velocity threshold bool auto_include_remote{false}; // automatic door closure applies also to remote control bool permit_needed{false}; + bool closure_signal{false}; // the door closure lamp (SOD) of the cab signals the doors of the whole consist in three states std::vector permit_presets; // permit presets selectable with preset switch float voltage{0.f}; // power type required for door movement // ld inputs @@ -1592,6 +1629,9 @@ class TMoverParameters double EpForce = 0.0; /*Poziom zadanej sily EP*/ bool Signalling = false; /*Czy jest zalaczona sygnalizacja hamowania ostatniego wagonu*/ bool Radio = false; /*Czy jest zalaczony radiotelefon*/ + // the cut-off in the radio's power supply: a fuse in the cabinet, a lever on the desk or the switch on the radio's power + // supply (eu07/maszyna#438). Closed unless the driver opens it, so vehicles without such a switch work as before + bool RadioFuse = true; float NominalBatteryVoltage = 0.f; /*Winger - baterie w elektrykach*/ TDimension Dim; /*wymiary*/ double Cx = 0.0; /*wsp. op. aerodyn.*/ @@ -1705,6 +1745,13 @@ class TMoverParameters int LightsDefPos = 1; bool LightsWrap = false; int Lights[2][18]; // pozycje świateł, przód - tył, 1 .. 16 (17 do lightsset) + // the cab interior light each light preset sets, from the optional third column of LightsList (bits of + // lights_preset_cablight::flag); -1: the preset leaves the cab light as it is (eu07/maszyna#529) + std::array LightsCabLight{[] { + std::array positions; + positions.fill(-1); + return positions; + }()}; int ScndInMain{0}; /*zaleznosc bocznika od nastawnika*/ bool MBrake = false; /*Czy jest hamulec reczny*/ double maxTachoSpeed = {0.0}; // maksymalna predkosc na tarczce predkosciomierza analogowego @@ -1729,6 +1776,10 @@ class TMoverParameters bool UniCtrlIntegratedBrakeCtrl = false; /*zintegrowany nastawnik JH obsluguje hamowanie*/ bool UniCtrlIntegratedLocalBrakeCtrl = false; /*zintegrowany nastawnik JH obsluguje hamowanie hamulcem pomocniczym*/ int UniCtrlNoPowerPos{0}; // cached highesr position not generating traction force + // UCList CtrlValStep: the universal controller (EIMCtrlType 3) changes its setting in steps of this size (e.g. 0.05 for the + // 5% steps of the 6Dg), one at once when moved to a raising or lowering position, then one each time its SpeedUp / SpeedDown + // covers a step; 0 (the default) changes it continuously + double UniCtrlValStep{0.0}; std::pair> PantsPreset{"0132", {0, 0}}; // pantograph preset switches; .first holds possible setups as chars, .second holds currently selected preset in each cab /*-sekcja parametrow dla lokomotywy elektrycznej*/ @@ -1945,6 +1996,7 @@ class TMoverParameters double MirrorVelClose{5.0}; bool MirrorForbidden{false}; /*czy jest pozwolenie na otworzenie lusterek (przycisk)*/ bool ScndS = false; /*Czy jest bocznikowanie na szeregowej*/ + bool ScndCtrlResistiveLock{false}; // the shunt controller can't be moved, up or down, while the master controller is at zero or on a resistive position, and drops to 0 when the master controller gets there (ScndCtrlResistiveLock=Yes in Cntrl.) bool SpeedCtrl = false; /*czy jest tempomat*/ speed_control SpeedCtrlUnit; /*parametry tempomatu*/ double SpeedCtrlButtons[10]{30, 40, 50, 60, 70, 80, 90, 100, 110, 120}; /*przyciski prędkości*/ @@ -2095,6 +2147,9 @@ class TMoverParameters double MainsInitTime{0.0}; // config, initialization time (in seconds) of the main circuit after it receives power, before it can be closed double MainsInitTimeCountdown{0.0}; // current state of main circuit initialization, remaining time (in seconds) until it's ready start_t MainsStart{start_t::manual}; + // config, Engine:JointEngineStart. whether the cab's line breaker / engine start switch works the vehicles in multiple + // too; when it doesn't, the cab has separate switches for them (mainb_*, mainc_*) + bool JointEngineStart{true}; bool LineBreakerClosesOnlyAtNoPowerPos{false}; bool ControlPressureSwitch{false}; // activates if the main pipe and/or brake cylinder pressure aren't within operational levels bool HasControlPressureSwitch{true}; @@ -2173,6 +2228,10 @@ class TMoverParameters bool dizel_startup{false}; // engine startup procedure request indicator bool dizel_ignition{false}; // engine ignition request indicator bool dizel_spinup{false}; // engine spin up to idle speed flag + // the starter turns the engine a while before it fires: smoke comes out only after SmokeDelay (the Engine section of the vehicle's + // .fiz, seconds from the start of the spin up) has passed. 0, the default, lets it out as soon as the starter turns, as before eu07/maszyna#523 + double dizel_SmokeDelay{0.0}; + double dizel_FiringCountdown{0.0}; // seconds left until the engine being started fires, 0 once it runs on its own double dizel_engagedeltaomega = 0.0; /*roznica predkosci katowych tarcz sprzegla*/ double dizel_n_old = 0.0; /*poredkosc na potrzeby obliczen sprzegiel*/ double dizel_Torque = 0.0; /*aktualny moment obrotowy silnika spalinowego*/ @@ -2201,12 +2260,14 @@ class TMoverParameters double eimic = 0; /*aktualna pozycja zintegrowanego sterowania jazda i hamowaniem*/ double eimic_analog = 0; /*pozycja zadajnika analogowa*/ double eimic_real = 0; /*faktycznie uzywana pozycja zintegrowanego sterowania jazda i hamowaniem*/ + double eimic_step_progress{0.0}; // the part of the next UniCtrlValStep the controller has covered so far double eim_localbrake = 0; /*nastawa hamowania dodatkowego pneumatycznego lokomotywy*/ int EIMCtrlType = 0; /*rodzaj wariantu zadajnika jazdy*/ bool SpeedCtrlTypeTime = false; /*czy tempomat sterowany czasowo*/ int SpeedCtrlAutoTurnOffFlag = 0; /*czy tempomat sam się wyłącza*/ bool EIMCtrlAdditionalZeros = false; /*czy ma dodatkowe zero jazdy i zero hamowania */ bool EIMCtrlEmergency = false; /*czy ma dodatkowe zero jazdy i zero hamowania */ + bool EIMCtrlMainEmergency = false; /*czy zadajnik typu 2 (Elf) ma pozycje hamowania naglego ponizej swojej pozycji 0*/ double eimv_pr = 0; /*realizowany procent dostepnej sily rozruchu/hamowania*/ double eimv[21]; static std::vector const eimv_labels; @@ -2288,6 +2349,14 @@ class TMoverParameters // bool modernContainOffPos{true}; bool enableModernDimmer{false}; bool modernDimmerCanCycle{false}; + // the given dimmer position brought into the DimmerList, to its first or last entry; 0 for an empty list + int clampDimmerPosition(int Position) const; + // the entry of the current dimmer position, or a plain one (not off, not dimmed, no high beam) when the + // position is outside the DimmerList: the .fiz can give a short or an empty list, or a DefaultPos past its end + dimPosition currentDimPosition() const; + // moves the dimmer by Step positions within the DimmerList, around to its other end when it can cycle; + // a position outside the list is brought into it first. false when the dimmer doesn't move + bool stepDimmerPosition(int Step); // Barwa reflektora int refR{255}; // Czerwony @@ -2302,6 +2371,9 @@ class TMoverParameters plc::basic_controller m_plc; int AIHintPantstate{0}; // suggested pantograph setup + // suggested pantograph setup in shunting (AI: ShuntPantstate=, eu07/maszyna#461), the bits as in Pantstate: 1 front, + // 2 rear, 3 both; 0 (the default) shunts with the pantographs handled as in train mode + int AIHintShuntPantstate{0}; bool AIHintPantUpIfIdle{true}; // whether raise both pantographs if idling for a while double AIHintLocalBrakeAccFactor{1.05}; // suggested acceleration weight for local brake operation @@ -2386,10 +2458,24 @@ class TMoverParameters int MainCtrlNoPowerPos() const; // highest setting of master controller which won't cause engine to generate extra power int MainCtrlActualPowerPos() const; // current actual setting of master controller, relative to the highest setting not generating extra power int MainCtrlPowerPos() const; // current setting of master controller, relative to the highest setting not generating extra power + // positions the master controller of the EIMCtrlType 2 (Elf) has below its own position 0, the emergency brake (0 or 1) + int EIMCtrlEmergencyPositions() const; + // whether the master controller is in its emergency brake position (EIMCtrlType 2 with EIMCtrlMainEmergency) + bool IsMainCtrlEmergencyPos() const; + // the master controller position sent to the coupled vehicles ("MainCtrl"), the same with and without + // EIMCtrlMainEmergency: the Elf's positions without the emergency brake one, which goes as the full braking + int MainCtrlPosForCoupled() const; + // the master controller position of this vehicle for one received from a coupled vehicle ("MainCtrl") + int MainCtrlPosFromCoupled(int Position) const; /*! pomocniczy nastawnik:*/ bool IncScndCtrl(int CtrlSpeed); bool DecScndCtrl(int CtrlSpeed); int GetVirtualScndPos() const; + // whether ScndCtrlResistiveLock keeps the shunt controller from being moved at the current master controller position + bool IsScndCtrlLockedByMainCtrl() const; + // ScndCtrlResistiveLock: the shunt controller drops to 0 once the master controller is at zero or on a resistive position, so it + // can't stay engaged there, locked in place (eu07/maszyna#402) + void ScndCtrlResistiveLockCheck(); bool IsScndCtrlNoPowerPos() const; bool IsScndCtrlMaxPowerPos() const; @@ -2431,6 +2517,8 @@ class TMoverParameters bool BrakeReleaser(int state); bool UniversalBrakeButton(int button, int state); /*uniwersalny przycisk hamulca*/ bool SwitchEPBrake(int state); + // whether the EP brake control of the cab has power: battery, the reverser out of neutral and the EP fuse on + bool EPBrakeControlPowered() const; bool AntiSlippingButton(void); /*! reczny wlacznik urzadzen antyposlizgowych*/ /*funkcje dla ukladow pneumatycznych*/ @@ -2471,6 +2559,14 @@ class TMoverParameters // whether the automatic couplers at the End of this vehicle and the OtherEnd of the other one can catch each other on // contact, rather than the vehicles colliding (with crash damage enabled, only at a closing speed which doesn't derail them) bool AutomaticCouplingSafe(int const End, TMoverParameters const &Other, int const OtherEnd) const; + // whether the control coupling (multiple-unit control) can join the End of this vehicle and the OtherEnd of the other + // one: not between an electric and a diesel traction vehicle, unless both couplers name the same control system + // (ControlType). Vehicles without own traction (cars, control trailers) take any. Global.control_coupling_engine_check + // off allows it always (eu07/maszyna#520) + bool ControlCouplingAllowed(int const End, TMoverParameters const &Other, int const OtherEnd) const; + // the CouplingType to join the End of this vehicle and the OtherEnd of the other one with, without the control coupling + // when ControlCouplingAllowed() rules it out. a permanent coupling (of the vehicles of one unit) is kept whole + int CompatibleCoupling(int const End, TMoverParameters const &Other, int const OtherEnd, int const CouplingType) const; // the horns of the Signal (WarningSignal bits) which have the air or the voltage they need to sound int SoundingHorns(int const Signal) const; // whether the signal lamps of the main circuit (line contactors, ground relay, motor overload relays) can light: @@ -2490,6 +2586,12 @@ class TMoverParameters } return false == StLinFlag && MainCircuitLampsEnabled(); } + // the vehicles the line breaker / engine start switch of this vehicle works, and its engine stopping on its own + // stops: the whole consist, or with JointEngineStart=No this vehicle alone (eu07/maszyna#80) + range_t MainSwitchRange() const + { + return JointEngineStart ? range_t::consist : range_t::local; + } // whether the cab command opening the main circuit opens it where the only cab switch of the circuit is the button // closing it: on a diesel that's the starter button, and the command is the one way to stop an engine whose fuel pump // starts on its own (eu07/maszyna#427). the other vehicles with such a button keep ignoring the command @@ -2504,10 +2606,10 @@ class TMoverParameters auto const oilmanual{OilPump.start_type != start_t::automatic}; return (fuelmanual || oilmanual) && (false == fuelmanual || FuelPump.is_enabled) && (false == oilmanual || OilPump.is_enabled); } - // whether the radio is switched on and has the low voltage to work + // whether the radio is switched on, its power supply isn't cut off and it has the low voltage to work bool RadioPowered() const { - return Radio && (Power24vIsAvailable || Power110vIsAvailable); + return Radio && RadioFuse && (Power24vIsAvailable || Power110vIsAvailable); } /*obrot kol uwzgledniajacy poslizg*/ double ComputeRotatingWheel(double WForce, double dt, double n) const; @@ -2611,6 +2713,10 @@ class TMoverParameters bool LockDoors(bool const State, range_t const Notify = range_t::consist); bool signal_departure(bool const State, range_t const Notify = range_t::consist); // toggles departure warning void update_doors(double const Deltatime); // door controller update + door_closure_t door_closure() const; // state of the door closure signalling of this vehicle + // whether the door closure lamp in the cab of this vehicle is lit: with DoorClosureSignal, for the given state of the + // consist; without it, for the lock of the vehicle's own doors, as the lamp always worked + bool door_closure_lamp_lit(door_closure_t const Consist, bool const BlinkLit) const; /* funkcje dla samochodow*/ bool ChangeOffsetH(double DeltaOffset); @@ -2620,6 +2726,8 @@ class TMoverParameters bool LoadFIZ_Text(std::string const &Text); // the same, from text in the .fiz format bool CheckLocomotiveParameters(bool ReadyFlag, int Dir); std::string EngineDescription(int what) const; + // the cab interior light the light preset at given position (1-based, as LightsPos) sets, if it sets one + std::optional LightsPresetCabLight(int Position) const; private: bool LoadFIZ_Parse(cParser &Parser); // reads the definition from either of the above diff --git a/McZapkie/Mover.cpp b/McZapkie/Mover.cpp index 730a7466..94917b15 100644 --- a/McZapkie/Mover.cpp +++ b/McZapkie/Mover.cpp @@ -18,6 +18,7 @@ http://mozilla.org/MPL/2.0/. #include "utilities/parser.h" #include "simulation/simulation.h" +#include #include #include //--------------------------------------------------------------------------- @@ -153,11 +154,23 @@ void TSecuritySystem::update(double dt, double vel, bool pwr, int cab) velocity = vel; cabactive = cab; - if (vigilance_enabled && velocity > AwareMinSpeed) - vigilance_timer += dt; + if (vigilance_suspended) + { + // the vigilance device is switched off: no time counts towards a new alarm, of the alerter or of the button held + // too long. an alarm already on, with the emergency braking it leads to, stays until it's acknowledged as usual + if (vigilance_timer <= AwareDelay) + vigilance_timer = 0.0; + if (press_timer <= MaxHoldTime) + press_timer = 0.0; + } + else + { + if (vigilance_enabled && velocity > AwareMinSpeed) + vigilance_timer += dt; - if (pressed && (!is_sifa || velocity > AwareMinSpeed)) - press_timer += dt; + if (pressed && (!is_sifa || velocity > AwareMinSpeed)) + press_timer += dt; + } if (vigilance_timer > AwareDelay || press_timer > MaxHoldTime || cabsignal_active) alert_timer += dt; @@ -232,6 +245,11 @@ void TSecuritySystem::set_cabsignal_lock(bool v) cabsignal_lock = v; } +void TSecuritySystem::set_shunt_mode(bool const State) +{ + vigilance_suspended = State && AwareOffInShuntMode; +} + bool TSecuritySystem::is_engine_blocked() const { if (!is_sifa) @@ -261,6 +279,7 @@ void TSecuritySystem::load(std::string const &line, double Vmax) extract_value(radiostop_enabled, "RadioStop", line, ""); extract_value(MagnetLocation, "MagnetLocation", line, ""); extract_value(CabDependent, "CabDependent", line, ""); + extract_value(AwareOffInShuntMode, "AwareOffInShuntMode", line, ""); } double TableInterpolation(std::map &Map, double Parameter) @@ -600,7 +619,8 @@ bool TMoverParameters::Attach(int ConnectNo, int ConnectToNr, TMoverParameters * } auto &thiscoupler{Couplers[ConnectNo]}; - auto &othercoupler = ConnectTo->Couplers[ConnectToNr != 2 ? ConnectToNr : thiscoupler.ConnectedNr]; + auto const otherend{ConnectToNr != 2 ? ConnectToNr : thiscoupler.ConnectedNr}; + auto &othercoupler = ConnectTo->Couplers[otherend]; auto const distance{CouplerDist(this, ConnectTo) - (thiscoupler.adapter_length + othercoupler.adapter_length)}; @@ -609,6 +629,18 @@ bool TMoverParameters::Attach(int ConnectNo, int ConnectToNr, TMoverParameters * return false; } + // no multiple-unit control between an electric and a diesel locomotive, whoever asks for it: the scenario, the AI + // or the player (eu07/maszyna#520) + if (auto const compatible{CompatibleCoupling(ConnectNo, *ConnectTo, otherend, CouplingType)}; compatible != CouplingType) + { + WriteLog(std::format("Vehicles \"{}\" and \"{}\" can't share their control, coupled without the control connection", Name, ConnectTo->Name)); + CouplingType = compatible; + if (CouplingType == coupling::faux) + { + return false; + } + } + // stykaja sie zderzaki i kompatybilne typy sprzegow, chyba że łączenie na starcie if (thiscoupler.CouplingFlag == coupling::faux) { @@ -810,6 +842,19 @@ bool TMoverParameters::ChangeCab(int direction) // rozruch wysoki (true) albo niski (false) bool TMoverParameters::CurrentSwitch(bool const State) { + // TBD, TODO: split off shunt mode toggle into a separate command? It doesn't make much sense to have these two together like that + // the diesels with a shunt mode have no motor overload relay threshold to switch, the switch sets their shunt mode alone. + // MaxCurrentSwitch() takes any change of the setting, so asked first it took the first move of the switch (eu07/maszyna#497) + if (true == ShuntModeAllow && (EngineType == TEngineType::DieselEngine || EngineType == TEngineType::DieselElectric)) + { + // dla 2Ls150 przed ustawieniem kierunku, for SM42/SP42 at the controller's zero position + if (EngineType == TEngineType::DieselEngine ? DirActive != 0 : false == IsMainCtrlNoPowerPos()) + { + return false; + } + ShuntMode = State; + return true; + } if (MaxCurrentSwitch(State)) { @@ -819,20 +864,6 @@ bool TMoverParameters::CurrentSwitch(bool const State) } return true; } - // TBD, TODO: split off shunt mode toggle into a separate command? It doesn't make much sense to have these two together like that - // dla 2Ls150 - if (EngineType == TEngineType::DieselEngine && true == ShuntModeAllow && DirActive == 0) - { - // przed ustawieniem kierunku - ShuntMode = State; - return true; - } - // for SM42/SP42 - if (EngineType == TEngineType::DieselElectric && true == ShuntModeAllow && IsMainCtrlNoPowerPos()) - { - ShuntMode = State; - return true; - } return false; }; @@ -1207,6 +1238,59 @@ bool TMoverParameters::AutomaticCouplingSafe(int const End, TMoverParameters con return false == Global.crash_damage || CollisionVelocity(End, Other, OtherEnd) <= SafeCollisionVelocity; } +namespace +{ + +// the kind of traction a vehicle's engine stands for, as far as its multiple-unit control goes +enum class traction_kind +{ + none, + electric, + diesel +}; + +traction_kind traction_of(TEngineType const Engine) +{ + switch (Engine) + { + case TEngineType::ElectricSeriesMotor: + case TEngineType::ElectricInductionMotor: + return traction_kind::electric; + case TEngineType::DieselEngine: + case TEngineType::DieselElectric: + return traction_kind::diesel; + default: + return traction_kind::none; + } +} + +} // namespace + +bool TMoverParameters::ControlCouplingAllowed(int const End, TMoverParameters const &Other, int const OtherEnd) const +{ + if (false == Global.control_coupling_engine_check) + { + return true; + } + // the same control system named on both couplers is the vehicle authors' word that they work together + if (auto const &controltype{Couplers[End].control_type}; false == controltype.empty() && controltype == Other.Couplers[OtherEnd].control_type) + { + return true; + } + auto const traction{traction_of(EngineType)}; + auto const othertraction{traction_of(Other.EngineType)}; + return traction == traction_kind::none || othertraction == traction_kind::none || traction == othertraction; +} + +int TMoverParameters::CompatibleCoupling(int const End, TMoverParameters const &Other, int const OtherEnd, int const CouplingType) const +{ + if ((CouplingType & coupling::control) == 0 || (CouplingType & coupling::permanent) != 0 || ControlCouplingAllowed(End, Other, OtherEnd)) + { + return CouplingType; + } + return CouplingType & ~coupling::control; +} + int TMoverParameters::SoundingHorns(int const Signal) const { auto const supplied{[this](horn_supply const Supply) { @@ -1634,6 +1718,8 @@ void TMoverParameters::compute_movement_(double const Deltatime) BRVentilators = false; } + // the master controller set directly, not through IncMainCtrl()/DecMainCtrl() (AI, consist): the locked shunt follows it as well + ScndCtrlResistiveLockCheck(); // automatyczny rozruch if (EngineType == TEngineType::ElectricSeriesMotor && AutoRelayCheck()) { @@ -2540,7 +2626,8 @@ bool TMoverParameters::IncMainCtrl(int CtrlSpeed) if (true == OK) { - SendCtrlToNext("MainCtrl", MainCtrlPos, CabActive); //??? + ScndCtrlResistiveLockCheck(); // e.g. from the last series position on to the resistive parallel ones + SendCtrlToNext("MainCtrl", MainCtrlPosForCoupled(), CabActive); //??? SendCtrlToNext("ScndCtrl", ScndCtrlPos, CabActive); } @@ -2665,7 +2752,8 @@ bool TMoverParameters::DecMainCtrl(int CtrlSpeed) if (OK) { - /*OK:=*/SendCtrlToNext("MainCtrl", MainCtrlPos, CabActive); // hmmmm...???!!! + ScndCtrlResistiveLockCheck(); // the shunt drops when the master controller leaves a resistance-free position + /*OK:=*/SendCtrlToNext("MainCtrl", MainCtrlPosForCoupled(), CabActive); // hmmmm...???!!! /*OK:=*/SendCtrlToNext("ScndCtrl", ScndCtrlPos, CabActive); } } @@ -2713,7 +2801,7 @@ int TMoverParameters::MainCtrlNoPowerPos() const } case 2: { - return 3; + return 3 + EIMCtrlEmergencyPositions(); } case 3: { @@ -2738,6 +2826,27 @@ int TMoverParameters::MainCtrlPowerPos() const return MainCtrlPos - MainCtrlNoPowerPos(); } +int TMoverParameters::EIMCtrlEmergencyPositions() const +{ + return (EIMCtrlType == 2 && EIMCtrlMainEmergency) ? 1 : 0; +} + +bool TMoverParameters::IsMainCtrlEmergencyPos() const +{ + return MainCtrlPos < EIMCtrlEmergencyPositions(); +} + +int TMoverParameters::MainCtrlPosForCoupled() const +{ + // the emergency brake itself reaches the train through the brake pipe, vented by the vehicle driven from + return std::max(0, MainCtrlPos - EIMCtrlEmergencyPositions()); +} + +int TMoverParameters::MainCtrlPosFromCoupled(int const Position) const +{ + return Position + EIMCtrlEmergencyPositions(); +} + // ************************************************************************************************* // Q: 20160710 // zwiększenie bocznika @@ -2754,7 +2863,7 @@ bool TMoverParameters::IncScndCtrl(int CtrlSpeed) { // if (RList[MainCtrlPos].R=0) and (MainCtrlPos>0) and (ScndCtrlPos 0 && CabActive != 0) { - if (ScndCtrlPos > 0 && !CoupledCtrl && (EngineType != TEngineType::DieselElectric || !AutoRelayFlag)) + if (ScndCtrlPos > 0 && !CoupledCtrl && (EngineType != TEngineType::DieselElectric || !AutoRelayFlag) && !IsScndCtrlLockedByMainCtrl()) { if (CtrlSpeed == 1) { @@ -2841,7 +2950,7 @@ bool TMoverParameters::DecScndCtrl(int CtrlSpeed) OK = false; if (OK) { - /*OK:=*/SendCtrlToNext("MainCtrl", MainCtrlPos, CabActive); //??? + /*OK:=*/SendCtrlToNext("MainCtrl", MainCtrlPosForCoupled(), CabActive); //??? /*OK:=*/SendCtrlToNext("ScndCtrl", ScndCtrlPos, CabActive); } } @@ -2875,6 +2984,22 @@ bool TMoverParameters::DecScndCtrl(int CtrlSpeed) return OK; } +bool TMoverParameters::IsScndCtrlLockedByMainCtrl() const +{ + // ET21, ET22, EP09: the field shunting engages only on the resistance-free positions of the master controller (eu07/maszyna#402) + return ScndCtrlResistiveLock && (IsMainCtrlNoPowerPos() || RList[MainCtrlPos].R >= 0.01); +} + +void TMoverParameters::ScndCtrlResistiveLockCheck() +{ + // the shunt controller, locked in both directions on zero and the resistive positions, would otherwise stay engaged there once the + // master controller is taken back (or set from elsewhere, e.g. by the AI or another vehicle of the consist) with the shunt on + if (ScndCtrlPos > 0 && !CoupledCtrl && IsScndCtrlLockedByMainCtrl()) + { + ScndCtrlPos = 0; + } +} + int TMoverParameters::GetVirtualScndPos() const { if (TrainType == dt_ET42 && DynamicBrakeFlag && !ScndCtrlPos) @@ -3146,9 +3271,11 @@ void TMoverParameters::SecuritySystemReset(void) // zbijanie czuwaka/SHP void TMoverParameters::SecuritySystemCheck(double dt) { bool isPower = Power24vIsAvailable || Power110vIsAvailable; + // the shunt mode of the SM42 switches its vigilance device (CA) off, on the vehicles with AwareOffInShuntMode=Yes (eu07/maszyna#457) + SecuritySystem.set_shunt_mode(ShuntMode); SecuritySystem.update(dt, Vel, isPower, CabActive); - if (!Battery || !Radio) + if (!Battery || !Radio || !RadioFuse) { // wyłączenie baterii deaktywuje sprzęt RadiostopSwitch(false); } @@ -4165,7 +4292,8 @@ bool TMoverParameters::SwitchEPBrake(int state) OK = false; if (BrakeHandle == TBrakeHandle::St113 && CabOccupied != 0) { - if (state > 0) + // the handle works the EP contactors pneumatically, their supply is cut as the Oerlikon handle's is + if (state > 0 && EPBrakeControlPowered()) EpForce = Handle->GetEP(); // TODO: przetlumaczyc else EpForce = 0; @@ -4177,6 +4305,11 @@ bool TMoverParameters::SwitchEPBrake(int state) return OK; } +bool TMoverParameters::EPBrakeControlPowered() const +{ + return Power24vIsAvailable && DirActive != 0 && EpFuse; +} + // ************************************************************************************************* // Q: 20160711 // zwiększenie ciśnienia hamowania @@ -4584,8 +4717,9 @@ void TMoverParameters::UpdatePipePressure(double dt) // EngDmgFlag 32 (load destroyed) used to trigger this too; dropped, it has nothing to do with - // the emergency brake (a broken coupler sets AlarmChainFlag instead) - if (RadioStopFlag || AlarmChainFlag || (EIMCtrlEmergency && LocalBrakePosA >= 1.0) || SecuritySystem.is_braking() || + // the emergency brake (a broken coupler sets AlarmChainFlag instead). the Elf's emergency brake position + // vents the pipe only from the vehicle driven from, whatever the controllers of the others show + if (RadioStopFlag || AlarmChainFlag || (EIMCtrlEmergency && LocalBrakePosA >= 1.0) || (IsMainCtrlEmergencyPos() && IsCabMaster()) || SecuritySystem.is_braking() || (SpringBrakeDriveEmergencyVel >= 0 && Vel > SpringBrakeDriveEmergencyVel && SpringBrake.IsActive) || (0 == CabActive && (InactiveCabFlag & activation::emergencybrake) != 0)) { @@ -4714,7 +4848,7 @@ void TMoverParameters::UpdatePipePressure(double dt) if ((BrakeHandle == TBrakeHandle::FVel6 || BrakeHandle == TBrakeHandle::FVE408) && CabOccupied != 0) { - if (Power24vIsAvailable && DirActive != 0 && EpFuse) // tu powinien byc jeszcze bezpiecznik EP i baterie - + if (EPBrakeControlPowered()) // tu powinien byc jeszcze bezpiecznik EP i baterie - // temp = (Handle as TFVel6).GetCP EpForce = Handle->GetEP(); else @@ -4733,6 +4867,11 @@ void TMoverParameters::UpdatePipePressure(double dt) // Ra 2014-11: na tym się wysypuje, ale nie wiem, w jakich warunkach SendCtrlToNext("Brake", EpForce, CabActive); } + else if (BrakeHandle == TBrakeHandle::St113 && CabOccupied != 0 && std::abs(EpForce) > 0.001 && !EPBrakeControlPowered()) + { + // the EP brake switched on with the button drops out with the control's supply + SwitchEPBrake(0); + } Pipe->Act(); PipePress = Pipe->P(); @@ -5480,6 +5619,7 @@ double TMoverParameters::CouplerForce(int const End, double dt) { couplingtype &= ~coupling::control; } + couplingtype = CompatibleCoupling(End, *othervehicle, otherend, couplingtype); // too fast for the couplers to catch, the vehicles collide instead (and may derail, see CollisionDetect()) auto const safe{AutomaticCouplingSafe(End, *othervehicle, otherend)}; @@ -7316,6 +7456,60 @@ bool TMoverParameters::DropAllPantographs(bool const State, range_t const Notify return State != initialstate; } +namespace +{ +// the setting of the universal controller one UniCtrlValStep further from Value towards Limit, never past it +double next_uc_step(double const Value, double const Limit, double const Step) +{ + constexpr double epsilon{1e-6}; + if (Limit > Value) + { + return std::min((std::floor(Value / Step + epsilon) + 1.0) * Step, Limit); + } + return std::max((std::ceil(Value / Step - epsilon) - 1.0) * Step, Limit); +} + +// the universal controller with UniCtrlValStep: the setting the continuous ramp got to (Ramped) turned into whole steps. +// a move of the controller onto a position which changes the setting makes one step at once, the ramp then one step each +// time it covers a step's length; the steps stop at the position's set value (or its bound, past which the ramp doesn't go) +double stepped_uc_value(double const Current, double const Ramped, TUniversalCtrl const &Position, double const Step, bool const Moved, double &Progress) +{ + if (Moved) + { + Progress = 0.0; + } + double const delta{Ramped - Current}; + if (std::abs(delta) < 1e-9) + { + Progress = 0.0; + return Ramped; + } + double limit{0.0}; + if (delta > 0.0) + { + limit = Position.SetCtrlVal > Current ? Position.SetCtrlVal : Position.MaxCtrlVal; + } + else + { + limit = Position.SetCtrlVal < Current ? Position.SetCtrlVal : Position.MinCtrlVal; + } + Progress = Moved ? Step : Progress + std::abs(delta); + double value{Current}; + bool reached{std::abs(value - limit) < 1e-9}; + while (!reached && Progress >= Step - 1e-9) + { + value = next_uc_step(value, limit, Step); + Progress -= Step; + reached = std::abs(value - limit) < 1e-9; + } + if (reached) + { + Progress = 0.0; + } + return safe_clamp(value, Position.MinCtrlVal, Position.MaxCtrlVal); +} +} // namespace + void TMoverParameters::CheckEIMIC(double dt) { double offset = EIMCtrlAdditionalZeros ? 1.0 : 0.0; @@ -7383,10 +7577,16 @@ void TMoverParameters::CheckEIMIC(double dt) eimic = 0; break; case 2: - if (MainCtrlActualPos != MainCtrlPos || LastRelayTime > InitialCtrlDelay) + if (IsMainCtrlEmergencyPos()) + { + // hamowanie nagle: pelne hamowanie od razu, przewod glowny oproznia UpdatePipePressure() + eimic = -1.0; + } + else if (MainCtrlActualPos != MainCtrlPos || LastRelayTime > InitialCtrlDelay) { double delta = MainCtrlActualPos == MainCtrlPos ? dt * CtrlDelay : 0.01; - switch (MainCtrlPos) + // the positions below are those of the controller without the emergency brake position + switch (MainCtrlPos - EIMCtrlEmergencyPositions()) { case 0: case 1: @@ -7440,11 +7640,14 @@ void TMoverParameters::CheckEIMIC(double dt) if (MainCtrlActualPos != MainCtrlPos || LastRelayTime > InitialCtrlDelay) { - eimic -= safe_clamp(-UniCtrlList[MainCtrlPos].SetCtrlVal + eimic, 0.0, - MainCtrlActualPos == MainCtrlPos ? dt * UniCtrlList[MainCtrlPos].SpeedDown : sign(UniCtrlList[MainCtrlPos].SpeedDown) * 0.01); // odejmuj do X - eimic += safe_clamp(UniCtrlList[MainCtrlPos].SetCtrlVal - eimic, 0.0, - MainCtrlActualPos == MainCtrlPos ? dt * UniCtrlList[MainCtrlPos].SpeedUp : sign(UniCtrlList[MainCtrlPos].SpeedUp) * 0.01); // dodawaj do X - eimic = safe_clamp(eimic, UniCtrlList[MainCtrlPos].MinCtrlVal, UniCtrlList[MainCtrlPos].MaxCtrlVal); + auto const &position{UniCtrlList[MainCtrlPos]}; + double ramped{eimic}; + ramped -= safe_clamp(-position.SetCtrlVal + ramped, 0.0, + MainCtrlActualPos == MainCtrlPos ? dt * position.SpeedDown : sign(position.SpeedDown) * 0.01); // odejmuj do X + ramped += safe_clamp(position.SetCtrlVal - ramped, 0.0, + MainCtrlActualPos == MainCtrlPos ? dt * position.SpeedUp : sign(position.SpeedUp) * 0.01); // dodawaj do X + ramped = safe_clamp(ramped, position.MinCtrlVal, position.MaxCtrlVal); + eimic = UniCtrlValStep > 0.0 ? stepped_uc_value(eimic, ramped, position, UniCtrlValStep, MainCtrlActualPos != MainCtrlPos, eimic_step_progress) : ramped; } if (MainCtrlActualPos == MainCtrlPos) LastRelayTime += dt; @@ -7869,6 +8072,9 @@ bool TMoverParameters::dizel_Update(double dt) WaterHeaterCheck(dt); OilPumpCheck(dt); FuelPumpCheck(dt); + // the engine being started fires once the starter has turned it a while; one stopped meanwhile starts over + dizel_FiringCountdown = (Mains ? std::max(0.0, dizel_FiringCountdown - dt) : 0.0); + if (true == dizel_startup && true == dizel_StartupCheck()) { dizel_ignition = true; @@ -7884,6 +8090,7 @@ bool TMoverParameters::dizel_Update(double dt) Timer::subsystem.switching.note(Timer::switching_counter::diesel, this, &TypeName, &Name); Mains = true; dizel_spinup = true; + dizel_FiringCountdown = dizel_SmokeDelay; enrot = std::max(enrot, 0.35 * ( // TODO: dac zaleznie od temperatury i baterii EngineType == TEngineType::DieselEngine ? dizel_nmin : DElist[0].RPM / 60.0)); @@ -7895,7 +8102,8 @@ bool TMoverParameters::dizel_Update(double dt) { // knock out the engine if the fuel pump isn't feeding it // TBD, TODO: grace period before the engine is starved for fuel and knocked out - MainSwitch(false); + // an engine started on its own stops on its own + MainSwitch(false, MainSwitchRange()); } bool DU{false}; @@ -8186,7 +8394,13 @@ double TMoverParameters::dizel_Momentum(double Fill, double n, double dt) if (enrot <= 0 && false == dizel_spinup) { - MainSwitch(false); + // an engine stalling stops those started together with it, once. one already stopped, from its own switch or + // another vehicle's, or not started at all, has nothing to stop: it stays quiet, rather than stop every frame + // the engines started on their own or from the separate switches (eu07/maszyna#80) + if (true == Mains) + { + MainSwitch(false, MainSwitchRange()); + } enrot = 0; } @@ -8725,6 +8939,48 @@ bool TMoverParameters::signal_departure(bool const State, range_t const Notify) return true; } +// state of the door closure signalling (SOD) of this vehicle: open while a door isn't fully closed, released while +// the closed doors can be opened (a permit given, or, for doors that need none, no lock engaged), locked otherwise. +// doors the crew doesn't close (autonomous) aren't signalled, the same as for the door lights of the consist +door_closure_t TMoverParameters::door_closure() const +{ + if (Doors.range == 0.f || Doors.close_control == control_t::autonomous) + { + return door_closure_t::none; + } + if (std::ranges::any_of(Doors.instances, [](basic_door const &Door) { return false == Door.is_closed; })) + { + return door_closure_t::open; + } + if (Doors.is_locked) + { + return door_closure_t::locked; + } + if (Doors.permit_needed && std::ranges::none_of(Doors.instances, [](basic_door const &Door) { return Door.open_permit; })) + { + return door_closure_t::locked; + } + return door_closure_t::released; +} + +bool TMoverParameters::door_closure_lamp_lit(door_closure_t const Consist, bool const BlinkLit) const +{ + return Doors.closure_signal ? door_closure_lamp(Consist, BlinkLit) : Doors.is_locked; +} + +bool door_closure_lamp(door_closure_t const State, bool const BlinkLit) +{ + switch (State) + { + case door_closure_t::locked: + return true; + case door_closure_t::released: + return BlinkLit; + default: + return false; + } +} + // automatic door controller update void TMoverParameters::update_doors(double const Deltatime) { @@ -9488,6 +9744,52 @@ bool TMoverParameters::readDimmerList(std::string const &line) return true; } +int TMoverParameters::clampDimmerPosition(int const Position) const +{ + if (dimPositions.empty()) + { + return 0; + } + return std::clamp(Position, 0, static_cast(dimPositions.size()) - 1); +} + +TMoverParameters::dimPosition TMoverParameters::currentDimPosition() const +{ + if (modernDimmerPosition < 0 || modernDimmerPosition >= static_cast(dimPositions.size())) + { + return {}; + } + return dimPositions[modernDimmerPosition]; +} + +bool TMoverParameters::stepDimmerPosition(int const Step) +{ + if (dimPositions.empty()) + { + return false; + } + auto const last{static_cast(dimPositions.size()) - 1}; + auto const current{clampDimmerPosition(modernDimmerPosition)}; + auto next{current + Step}; + if (next < 0 || next > last) + { + if (false == modernDimmerCanCycle) + { + next = current; + } + else + { + next = (next < 0 ? last : 0); + } + } + if (next == modernDimmerPosition) + { + return false; + } + modernDimmerPosition = next; + return true; +} + // parsowanie WWList bool TMoverParameters::readWWList(std::string const &line) { @@ -9533,10 +9835,38 @@ bool TMoverParameters::readLightsList(std::string const &Input) return false; } parser >> Lights[0][idx] >> Lights[1][idx]; + // optional third column: the cab interior light the preset sets (eu07/maszyna#529). anything but a whole number + // (some rows carry stray notes after the two columns) leaves the cab light to its own switch + LightsCabLight[idx] = -1; + if (true == parser.getTokens(1, false)) + { + std::string cablight; + parser >> cablight; + int flags{-1}; + auto const [end, error]{std::from_chars(cablight.data(), cablight.data() + cablight.size(), flags)}; + if (error == std::errc{} && end == cablight.data() + cablight.size() && flags >= 0) + { + LightsCabLight[idx] = flags; + } + } return true; } +std::optional TMoverParameters::LightsPresetCabLight(int const Position) const +{ + if (Position < 1 || Position > static_cast(LightsCabLight.size())) + { + return std::nullopt; + } + auto const flags{LightsCabLight[Position - 1]}; + if (flags < 0) + { + return std::nullopt; + } + return lights_preset_cablight{(flags & lights_preset_cablight::light_on) != 0, (flags & lights_preset_cablight::light_dimmed) != 0}; +} + bool TMoverParameters::readCompressorList(std::string const &Input) { @@ -10289,6 +10619,9 @@ bool TMoverParameters::LoadFIZ_Parse(cParser &fizparser) // NOSONAR the former b result = false; // ustawiamy domyslna pozycje dimmera + // the DefaultPos of a short or an empty DimmerList would put the dimmer outside of it + modernDimmerDefaultPosition = clampDimmerPosition(modernDimmerDefaultPosition); + modernDimmerPosition = clampDimmerPosition(modernDimmerPosition); if (!enableModernDimmer) { modernDimmerPosition = modernDimmerDefaultPosition; @@ -10616,6 +10949,8 @@ void TMoverParameters::LoadFIZ_Doors(std::string const &line) extract_value(Doors.auto_include_remote, "DoorAutoCloseRemote", line, ""); // operation permit extract_value(Doors.permit_needed, "DoorNeedPermit", line, ""); + // the door closure lamp (SOD) of the cab signals the doors of the whole consist + extract_value(Doors.closure_signal, "DoorClosureSignal", line, ""); { auto permitpresets = Split(extract_value("DoorPermitList", line), '|'); for (auto const &permit : permitpresets) @@ -10936,8 +11271,19 @@ void TMoverParameters::LoadFIZ_Cntrl(std::string const &line) extract_value(LocHandleTimeTraxx, "LocalBrakeTraxx", line, ""); extract_value(EIMCtrlAdditionalZeros, "EIMCtrlAddZeros", line, ""); extract_value(EIMCtrlEmergency, "EIMCtrlEmergency", line, ""); + // Elf (EIMCtrlType 2): an emergency brake position below the controller's position 0, which shifts the other positions up by one + extract_value(EIMCtrlMainEmergency, "EIMCtrlMainEmergency", line, ""); + if (auto const elfpositions{4 + EIMCtrlEmergencyPositions()}; EIMCtrlEmergencyPositions() > 0 && MainCtrlPosNo < elfpositions) + { + // the old positions in the file: without the fix-up the controller would have no power position, and the + // reverser would stop working at neutral (DirChangeMaxPos read before, in the old positions) + MainCtrlMaxDirChangePos += EIMCtrlEmergencyPositions(); + ErrorLog(std::format("Bad fiz: EIMCtrlMainEmergency needs MCPN={} in {}, DirChangeMaxPos taken as {}", elfpositions, TypeName, MainCtrlMaxDirChangePos)); + MainCtrlPosNo = elfpositions; + } extract_value(ScndS, "ScndS", line, ""); // brak pozycji rownoleglej przy niskiej nastawie PSR + extract_value(ScndCtrlResistiveLock, "ScndCtrlResistiveLock", line, ""); // bocznikowanie tylko na pozycjach bezoporowych extract_value(InitialCtrlDelay, "IniCDelay", line, ""); extract_value(CtrlDelay, "SCDelay", line, ""); @@ -11198,6 +11544,7 @@ void TMoverParameters::LoadFIZ_Engine(std::string const &Input) { EngineType = LoadFIZ_EngineDecode(extract_value("EngineType", Input)); + extract_value(JointEngineStart, "JointEngineStart", Input, ""); if (std::string transmission = extract_value("Trans", Input); false == transmission.empty()) { @@ -11406,6 +11753,9 @@ void TMoverParameters::LoadFIZ_Engine(std::string const &Input) // oil pump extract_value(OilPump.pressure_minimum, "OilMinPressure", Input, ""); extract_value(OilPump.pressure_maximum, "OilMaxPressure", Input, ""); + // the time the starter turns the engine before it fires and smokes (eu07/maszyna#523) + extract_value(dizel_SmokeDelay, "SmokeDelay", Input, "0"); + dizel_SmokeDelay = std::max(0.0, dizel_SmokeDelay); // engine cooling factore extract_value(dizel_heat.kw, "HeatKW", Input, ""); extract_value(dizel_heat.kv, "HeatKV", Input, ""); @@ -11431,7 +11781,7 @@ void TMoverParameters::LoadFIZ_Engine(std::string const &Input) // water heater extract_value(WaterHeater.config.temp_min, "HeaterMinTemperature", Input, ""); extract_value(WaterHeater.config.temp_max, "HeaterMaxTemperature", Input, ""); - float pf; + float pf{ 1235.f }; extract_value(pf, "NominalCoolingPower", Input, "1235"); dizel_heat.powerfactor = 1235 / pf; } @@ -11515,6 +11865,7 @@ void TMoverParameters::LoadFIZ_AI(std::string const &Input) { extract_value(AIHintPantstate, "Pantstate", Input, ""); + extract_value(AIHintShuntPantstate, "ShuntPantstate", Input, ""); extract_value(AIHintPantUpIfIdle, "IdlePantUp", Input, ""); extract_value(AIHintLocalBrakeAccFactor, "LocalBrakeAccFactor", Input, ""); } @@ -11556,6 +11907,8 @@ void TMoverParameters::LoadFIZ_UCList(std::string const &Input) extract_value(UniCtrlIntegratedBrakeCtrl, "IntegratedBrake", Input, ""); extract_value(UniCtrlIntegratedLocalBrakeCtrl, "IntegratedLocBrake", Input, ""); extract_value(UniCtrlIntegratedBrakePNCtrl, "IntegratedBrakePN", Input, ""); + extract_value(UniCtrlValStep, "CtrlValStep", Input, ""); + UniCtrlValStep = std::max(UniCtrlValStep, 0.0); } void TMoverParameters::LoadFIZ_DList(std::string const &Input) @@ -12273,9 +12626,10 @@ bool TMoverParameters::RunCommand(std::string const &Command, double CValue1, do if (Command == "MainCtrl") { - if (MainCtrlPosNo >= floor(CValue1)) + // sent as MainCtrlPosForCoupled(), passed on unchanged + if (auto const position{MainCtrlPosFromCoupled(static_cast(floor(CValue1)))}; MainCtrlPosNo >= position) { - MainCtrlPos = static_cast(floor(CValue1)); + MainCtrlPos = position; if (DelayCtrlFlag) { if (LastRelayTime >= InitialCtrlDelay && MainCtrlPos == 1) diff --git a/McZapkie/Oerlikon_ESt.cpp b/McZapkie/Oerlikon_ESt.cpp index 1e7a5be6..0717eff5 100644 --- a/McZapkie/Oerlikon_ESt.cpp +++ b/McZapkie/Oerlikon_ESt.cpp @@ -379,8 +379,7 @@ void TNESt3::CheckState(double const BCP, double & /*dV1*/) // glowny przyrzad r Przys_blok = false; // sprawdzanie stanu - double const cel{ CelBCP( CVP, VVP ) }; - if (BCP < cel - 0.01 * BVM && Przys_blok) + if (double const cel{ CelBCP( CVP, VVP ) }; BCP < cel - 0.01 * BVM && Przys_blok) BrakeStatus |= b_on | b_hld; // hamowanie stopniowe; else if (BCP - 0.1 > cel + 0.01 * BVM) BrakeStatus &= ~( b_on | b_hld ); // luzowanie; @@ -486,6 +485,18 @@ void TNESt3::SetLBP(double const P) LBP = P; } +void TNESt3::ScaleNozzlesForAL2() // dysze cylindra przy przekladniku AL2 +{ + if( PrzekCiagly == nullptr ) { + return; + } + // dysze cylindra skalibrowane na komore impulsowa przekladnika AL2 (1 l) wg czasow z tablicy 3-9 + Nozzles[dON] *= 0.914; + Nozzles[dTN] *= 0.885; + Nozzles[dOO] *= 0.82; + Nozzles[dTO] *= 0.8; +} + void TNESt3::SetSize( int const size, std::string const ¶ms ) // ustawianie dysz (rozmiaru ZR) { static double const dNO1l = 1.250; @@ -673,13 +684,7 @@ void TNESt3::SetSize( int const size, std::string const ¶ms ) // ustawianie Nozzles[dSd] = 1.1; Nozzles[dSm] = 0.9; - if( PrzekCiagly != nullptr ) { - // dysze cylindra skalibrowane na komore impulsowa przekladnika AL2 (1 l) wg czasow z tablicy 3-9 - Nozzles[dON] *= 0.914; - Nozzles[dTN] *= 0.885; - Nozzles[dOO] *= 0.82; - Nozzles[dTO] *= 0.8; - } + ScaleNozzlesForAL2(); // przeliczanie z mm^2 na l/m for (auto &nozzle : Nozzles) diff --git a/McZapkie/Oerlikon_ESt.h b/McZapkie/Oerlikon_ESt.h index 02d65a28..eaae2c03 100644 --- a/McZapkie/Oerlikon_ESt.h +++ b/McZapkie/Oerlikon_ESt.h @@ -201,6 +201,7 @@ class TNESt3 : public TBrake { void CheckState(double const BCP, double &dV1); // glowny przyrzad rozrzadczy void CheckReleaser(double const dt); // odluzniacz double CVs(double const BP) const; // napelniacz sterujacego + void ScaleNozzlesForAL2(); // dysze cylindra przy przekladniku AL2 double BVs(double const BCP) const; // napelniacz pomocniczego void SetSize( int const size, std::string const ¶ms ); // ustawianie dysz (rozmiaru ZR), przekladniki void PLC(double const mass) override; // wspolczynnik cisnienia przystawki wazacej diff --git a/McZapkie/hamulce.cpp b/McZapkie/hamulce.cpp index c0a7d2a0..9411dc42 100644 --- a/McZapkie/hamulce.cpp +++ b/McZapkie/hamulce.cpp @@ -968,6 +968,28 @@ double TESt::BVs(double const BCP) const return 0; } +/// +/// Refills the auxiliary reservoir (ZP) from the valve pre-chamber through the +/// check valve of the slide valve, opened as far as says for the +/// given cylinder (or impulse-chamber) pressure, while the pre-chamber is more +/// than 0.05 bar above the reservoir. The flows wait for the caller's Act(). +/// +/// Brake cylinder (or impulse-chamber) pressure. +/// Time step [s]. +/// Volume taken into the auxiliary reservoir, 0.96 of it drawn from the brake pipe. +double TESt::FillBrakeRes(double const BCP, double const dt) const +{ + double const BVP{BrakeRes->P()}; + double const VVP{ValveRes->P()}; + double const temp{BVs(BCP)}; + double dv{0.0}; + if (VVP - 0.05 > BVP) + dv = PF(BVP, VVP, 0.02 * SizeBR * temp / 1.87) * dt; + BrakeRes->Flow(dv); + ValveRes->Flow(-0.04 * dv); + return dv; +} + /// /// One-step Oerlikon ESt distributor advance: runs CheckState/CheckReleaser, /// integrates the ZS <-> PG, ZP <-> cylinder, ZP <-> pre-chamber and @@ -1021,15 +1043,7 @@ double TESt::GetPF(double const PP, double const dt, double const Vel) BrakeCyl->Flow(-dv); // przeplyw ZP <-> rozdzielacz - temp = BVs(BCP); - // if(BrakeStatus and b_hld)=b_off then - if (VVP - 0.05 > BVP) - dv = PF(BVP, VVP, 0.02 * SizeBR * temp / 1.87) * dt; - else - dv = 0; - BrakeRes->Flow(dv); - dV1 = dV1 + dv * 0.96; - ValveRes->Flow(-0.04 * dv); + dV1 = dV1 + FillBrakeRes(BCP, dt) * 0.96; // przeplyw PG <-> rozdzielacz dv = PF(PP, VVP, 0.01) * dt; ValveRes->Flow(-dv); @@ -1191,6 +1205,10 @@ double TEStEP2::GetPF(double const PP, double const dt, double const Vel) ValveRes->Flow(-0.04 * dv); dV1 = dV1 - 0.96 * dv; + // przeplyw ZP <-> rozdzielacz: as in every ESt, the auxiliary reservoir also charges from the brake pipe, + // not from the main reservoir pipe alone, so a unit hauled with the brake hose alone charges and brakes + dV1 = dV1 + FillBrakeRes(BCP, dt) * 0.96; + // hamulec EP EPCalc(dt); diff --git a/McZapkie/hamulce.h b/McZapkie/hamulce.h index 30b7bba7..2539250a 100644 --- a/McZapkie/hamulce.h +++ b/McZapkie/hamulce.h @@ -628,6 +628,15 @@ class TESt : public TBrake /// Brake cylinder pressure. /// Dimensionless opening coefficient. double BVs(double BCP) const; // napelniacz pomocniczego + /// + /// Refills the auxiliary reservoir (ZP) from the valve pre-chamber through the + /// check valve of the slide valve, opened as far as says, while + /// the pre-chamber is above the reservoir. The flows wait for the caller's Act(). + /// + /// Brake cylinder (or impulse-chamber) pressure for . + /// Time step [s]. + /// Volume taken into the auxiliary reservoir, 0.96 of it drawn from the brake pipe. + double FillBrakeRes(double BCP, double dt) const; // napelnianie pomocniczego z rozdzielacza /// Vents the valve, brake and control reservoirs to zero. void ForceEmptiness() override; // wymuszenie bycia pustym diff --git a/README.md b/README.md index 0994cd44..d733db76 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ MaSzyna compiles and runs natively on **Linux** and **Windows**. Other platforms ## Windows -Install **Python 3.14 x64** from [python.org](https://www.python.org/downloads/windows/) and include the optional **Python native development** feature (headers and `.lib` for embedding). Pass `-DPython3_ROOT_DIR="C:\Path\To\Python314"` on the `cmake` line when you want the layout under that directory used for embedding and for copying `Lib` / `DLLs` next to the executable. +Install **Python 3.14 x64** from [python.org](https://www.python.org/downloads/windows/) and include the optional **Python native development** feature (headers and `.lib` for embedding). Pass `-DPython3_ROOT_DIR="C:\Path\To\Python314"` on the `cmake` line when you want the layout under that directory used for embedding and for copying `Lib` / `DLLs` next to the executable. The cab screens are drawn with [Pillow](https://pypi.org/project/pillow/): install it into that Python (`python -m pip install Pillow`) before building, so it is copied along with `Lib`. **AppVeyor CI** builds with the worker image’s preinstalled Python at `C:/Python314-x64` (see [`appveyor.yml`](./appveyor.yml): `-DPython3_ROOT_DIR=...`). diff --git a/application/application.cpp b/application/application.cpp index a13dd3b8..01b2ce9a 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -29,10 +29,13 @@ http://mozilla.org/MPL/2.0/. #include "vehicle/Train.h" #include "utilities/Timer.h" #include "utilities/memorystats.h" +#include "utilities/framepacer.h" #include "utilities/dictionary.h" #include "version_info.h" +#include "simulation/simulationtest.h" #include #include +#include #include "utilities/translation.h" #if WITH_DISCORD_RPC @@ -343,6 +346,9 @@ int eu07_application::init(int Argc, char *Argv[]) { int result{0}; + // a test run is known before anything is logged: whoever started it reads its result on the standard output, + // which the log isn't written to then (eu07/maszyna#541) + Global.test.enabled = simulation::parse_test_command_line(std::vector(Argv, Argv + Argc)).enabled; // start logging service std::thread sLoggingService(LogService); Global.threads.emplace("LogService", std::move(sLoggingService)); @@ -409,7 +415,7 @@ int eu07_application::init(int Argc, char *Argv[]) ErrorLog("Failed to init UI!"); return result; } - if (crashreport_is_pending()) + if (crashreport_is_pending() && false == Global.test.enabled) { // run crashgui as early as possible: it's drawn by the renderer if ((result = run_crashgui()) != 0) { @@ -432,6 +438,16 @@ int eu07_application::init(int Argc, char *Argv[]) { m_taskqueue.init(); } + crashreport_add_info("luascreens_enabled", Global.luascreens_enabled ? "yes" : "no"); + crashreport_add_info("wasm_enabled", Global.wasm_enabled ? "yes" : "no"); + if (Global.luascreens_enabled || Global.wasm_enabled) + { + m_screenqueue.init(); + if (Global.screens_gpupost) + { + m_screenqueue.set_post_processor(GfxRenderer->Screen_post_processor()); + } + } if ((result = init_modes()) != 0) { ErrorLog("Failed to initialize game modes"); @@ -441,8 +457,12 @@ int eu07_application::init(int Argc, char *Argv[]) #if WITH_DISCORD_RPC // Run DiscordRPC service - std::thread sDiscordRPC(&eu07_application::DiscordRPCService, this); - Global.threads.emplace("DiscordRPC", std::move(sDiscordRPC)); + // not for a test run: it would show the test scenery as the player's presence, and hold up the closing + if (false == Global.test.enabled) + { + std::thread sDiscordRPC(&eu07_application::DiscordRPCService, this); + Global.threads.emplace("DiscordRPC", std::move(sDiscordRPC)); + } #endif if (!init_network()) @@ -492,9 +512,14 @@ bool eu07_application::is_client() const int eu07_application::run() { auto frame{0}; + frame_pacer framepacer; + Timer::subsystem.mainloop_period.start(); // main application loop while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty()) { + // the time from one frame's start to the next, the wait for fpslimit included + Timer::subsystem.mainloop_period.stop(); + Timer::subsystem.mainloop_period.start(); Timer::subsystem.mainloop_total.start(); GfxRenderer->Frame_Start(); Timer::subsystem.loop_input.start(); @@ -641,6 +666,7 @@ int eu07_application::run() Timer::subsystem.loop_tasks.start(); m_taskqueue.update(); + m_screenqueue.update(); Timer::subsystem.loop_tasks.stop(); if (!GfxRenderer->Render()) @@ -662,10 +688,11 @@ int eu07_application::run() if (m_network) m_network->update(); - auto const frametime{Timer::subsystem.mainloop_total.stop()}; - if (Global.minframetime.count() != 0.0f && (Global.minframetime - frametime).count() > 0.0f) + Timer::subsystem.mainloop_total.stop(); + // fpslimit: wait for the next frame's slot (eu07/maszyna#416) + if (auto const nextframe{framepacer.next_frame(frame_pacer::clock::now(), Global.minframetime)}) { - std::this_thread::sleep_for(Global.minframetime - frametime); + std::this_thread::sleep_until(*nextframe); } } // Handled by application.exit @@ -725,6 +752,8 @@ void eu07_application::exit() mode.reset(); step("the renderer"); + // the screens' post-processing goes with the renderer + m_screenqueue.set_post_processor(nullptr); GfxRenderer->Shutdown(); m_network.reset(); @@ -737,6 +766,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_screenqueue.exit(); step("the windows"); for (auto *window : m_windows) { @@ -977,14 +1007,17 @@ GLFWwindow *eu07_application::window(int const Windowindex, bool visible, int wi } // for index -1 create a new child window - auto const *vmode{glfwGetVideoMode(monitor ? monitor : glfwGetPrimaryMonitor())}; - - // match requested video mode to current to allow for - // fullwindow creation when resolution is the same - glfwWindowHint(GLFW_RED_BITS, vmode->redBits); - glfwWindowHint(GLFW_GREEN_BITS, vmode->greenBits); - glfwWindowHint(GLFW_BLUE_BITS, vmode->blueBits); - glfwWindowHint(GLFW_REFRESH_RATE, vmode->refreshRate); + auto *videomonitor{monitor ? monitor : glfwGetPrimaryMonitor()}; + // a machine without a display (e.g. a test runner) has no monitor to match + if (auto const *vmode{videomonitor ? glfwGetVideoMode(videomonitor) : nullptr}) + { + // match requested video mode to current to allow for + // fullwindow creation when resolution is the same + glfwWindowHint(GLFW_RED_BITS, vmode->redBits); + glfwWindowHint(GLFW_GREEN_BITS, vmode->greenBits); + glfwWindowHint(GLFW_BLUE_BITS, vmode->blueBits); + glfwWindowHint(GLFW_REFRESH_RATE, vmode->refreshRate); + } glfwWindowHint(GLFW_VISIBLE, visible); @@ -1067,6 +1100,13 @@ void eu07_application::init_debug() void eu07_application::init_console() { #ifdef _WIN32 + if (Global.test.enabled) + { + // a test is started by whoever waits for its result, often reading its output: no console of its own + // (showsystemconsole) pops up and takes stdout away from them, and a console it was started from isn't hidden + return; + } + HWND consoleWnd = ::GetConsoleWindow(); const bool hadConsole = consoleWnd != nullptr; @@ -1167,9 +1207,30 @@ int eu07_application::init_settings(int Argc, char *Argv[]) { Global.asVersion = VERSION_INFO; + // a test run is known before the rest of the command line is read (Global.test.enabled, see init()), so a + // command line it can't take ends with the error code of the test, not with 0, which would pass it + auto const testcommandline{simulation::parse_test_command_line(std::vector(Argv, Argv + Argc))}; + if (false == testcommandline.error.empty()) + { + simulation::test().conclude(simulation::test_result::error, testcommandline.error); + return -1; + } + if (testcommandline.timeout.has_value()) + { + Global.test.timeout = *testcommandline.timeout; + Global.test.timeout_from_command_line = true; + } + if (testcommandline.wall_timeout.has_value()) + { + Global.test.wall_timeout = testcommandline.wall_timeout; + Global.test.wall_timeout_from_command_line = true; + } + fs::path iniPath = user_config_path("eu07.ini"); - if (!iniPath.empty() && fs::exists(iniPath)) + // a test reads the settings of the game folder only (and those given with -ini), not the player's own + // (%APPDATA%/MaSzyna, ~/.config/MaSzyna), so it runs the same for whoever runs it + if (false == Global.test.enabled && !iniPath.empty() && fs::exists(iniPath)) { Global.LoadIniFile(iniPath.string().c_str()); } @@ -1228,18 +1289,51 @@ int eu07_application::init_settings(int Argc, char *Argv[]) WriteLog(std::format("seed given on the command line: {} -> {}", Argv[i], Global.random_seed)); } } + else if (token == "--test") + { + // integration test: the scenery runs unattended and the exit code is the result (eu07/maszyna#541), + // taken from the command line above + } + else if (token == "--test-timeout" || token == "--test-wall-timeout") + { + // taken from the command line above, with its value + ++i; + } + else if (token == "-ini") + { + // extra settings file read on top of eu07.ini, so a run can override a few keys + // (window size, physicslog...) without touching the user's own config + if (i + 1 < Argc) + { + Global.LoadIniFile(Argv[++i]); + } + } else { - std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" + // a test's standard output carries its result only + (Global.test.enabled ? std::cerr : std::cout) << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" << " [-v vehiclename]" << " [-edit [sceneryfilepath]]" - << " [-seed number|word]" << std::endl; + << " [-seed number|word]" + << " [-ini settingsfile]" + << " [--test [--test-timeout seconds] [--test-wall-timeout seconds]]" << std::endl; + simulation::test().conclude(simulation::test_result::error, std::format("unknown command line argument {}", token)); return -1; } } if (editwithoutscenery && false == sceneryfromcommandline) Global.SceneryFile.clear(); + if (Global.test.enabled) + { + if (false == sceneryfromcommandline || Global.SceneryFile.empty()) + { + simulation::test().conclude(simulation::test_result::error, "--test needs a scenery given with -s"); + return -1; + } + simulation::apply_test_mode_settings(); + } + return 0; } @@ -1300,6 +1394,11 @@ void eu07_application::select_gfx_backend() Global.GfxRenderer = "default"; } } + else if (Global.GfxRenderer == "null") + { + // draws nothing and loads no textures (test mode) + m_gfxbackend = factory->backend("null"); + } if (m_gfxbackend == nullptr) { @@ -1337,8 +1436,9 @@ int eu07_application::create_main_window() Global.bFullScreen = true; } - // a selftest of the editor run while the computer is in use for something else draws into a window never shown nor focused - bool const shown{std::getenv("EU07_EDITOR_SELFTEST_HIDDEN") == nullptr}; + // a selftest of the editor run while the computer is in use for something else draws into a window never shown nor focused, + // and test mode keeps its window hidden: glfw, the ui and the input still need one, but nothing is shown in it + bool const shown{std::getenv("EU07_EDITOR_SELFTEST_HIDDEN") == nullptr && false == Global.test.enabled}; auto *mainwindow = window(-1, shown, Global.window_size.x, Global.window_size.y, Global.bFullScreen && shown ? monitor : nullptr, true, false); if (mainwindow == nullptr) @@ -1370,7 +1470,9 @@ int eu07_application::create_main_window() #ifdef _WIN32 // setup wrapper for base glfw window proc, to handle copydata messages Hwnd = glfwGetWin32Window(mainwindow); - BaseWindowProc = (WNDPROC)::SetWindowLongPtr(Hwnd, GWLP_WNDPROC, (LONG_PTR)WndProc); + // commands sent by other programs are input from outside, which a test doesn't take + if (false == Global.test.enabled) + BaseWindowProc = (WNDPROC)::SetWindowLongPtr(Hwnd, GWLP_WNDPROC, (LONG_PTR)WndProc); // switch off the topmost flag ::SetWindowPos(Hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); #endif @@ -1401,13 +1503,18 @@ void eu07_application::init_callbacks() auto *window{m_windows.front()}; glfwSetWindowSizeCallback(window, window_resize_callback); glfwSetFramebufferSizeCallback(window, framebuffer_resize_callback); + glfwSetWindowFocusCallback(window, focus_callback); + glfwSetCursorEnterCallback(window, cursor_enter_callback); + if (Global.test.enabled) + { + // a test takes no input: keys and clicks sent to its hidden window would change the run + return; + } glfwSetCursorPosCallback(window, cursor_pos_callback); glfwSetMouseButtonCallback(window, mouse_button_callback); glfwSetKeyCallback(window, key_callback); glfwSetScrollCallback(window, scroll_callback); glfwSetCharCallback(window, char_callback); - glfwSetWindowFocusCallback(window, focus_callback); - glfwSetCursorEnterCallback(window, cursor_enter_callback); } int eu07_application::init_ui() diff --git a/application/application.h b/application/application.h index f3434cb2..9f947fe7 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/scriptscreens.h" #include "network/manager.h" #include "utilities/headtrack.h" #ifdef WITH_UART @@ -51,6 +52,10 @@ public: // frees the python gil and swaps out the main thread void release_python_lock(); + // the screens drawn by scripts (Lua, WebAssembly) + script_screen_queue & + screens() { + return m_screenqueue; } void exit(); void @@ -129,6 +134,7 @@ private: modeptr_array m_modes { nullptr }; // collection of available application behaviour modes mode_stack m_modestack; // current behaviour mode python_taskqueue m_taskqueue; + script_screen_queue m_screenqueue; std::vector m_windows; gfx_backend_desc const *m_gfxbackend { nullptr }; // owned by gfx_renderer_factory diff --git a/application/driverhints.cpp b/application/driverhints.cpp index bcfd7dbd..ae39ede7 100644 --- a/application/driverhints.cpp +++ b/application/driverhints.cpp @@ -138,6 +138,16 @@ TController::cue_action( driver_hint const Action, float const Actionparameter ) return mvOccupied->Radio == true; } ); break; } + case driver_hint::radiofuseon: { + if( AIControllFlag ) { + mvOccupied->RadioFuse = true; + } + hint( + Action, + [this](float const /*Parameter*/) { + return mvOccupied->RadioFuse == true; } ); + break; + } case driver_hint::radiochannel: { if( AIControllFlag ) { iRadioChannel = static_cast( Actionparameter ); diff --git a/application/driverhints_def.h b/application/driverhints_def.h index f59ed734..2ce89667 100644 --- a/application/driverhints_def.h +++ b/application/driverhints_def.h @@ -2,6 +2,7 @@ DRIVER_HINT_DEF(batteryon, STRN("Switch on battery")) DRIVER_HINT_DEF(batteryoff, STRN("Switch off battery")) DRIVER_HINT_DEF(radioon, STRN("Switch on radio")) DRIVER_HINT_DEF(radiooff, STRN("Switch off radio")) +DRIVER_HINT_DEF(radiofuseon, STRN("Close radio fuse")) DRIVER_HINT_DEF(radiochannel, STRN("Tune into channel %.0f")) DRIVER_HINT_DEF(oilpumpon, STRN("Switch on oil pump")) DRIVER_HINT_DEF(oilpumpoff, STRN("Switch off oil pump")) diff --git a/application/drivermode.cpp b/application/drivermode.cpp index dcd11960..71a343d6 100644 --- a/application/drivermode.cpp +++ b/application/drivermode.cpp @@ -17,6 +17,7 @@ http://mozilla.org/MPL/2.0/. #include "simulation/simulation.h" #include "simulation/simulationtime.h" #include "simulation/simulationenvironment.h" +#include "simulation/simulationtest.h" #include "scene/scene.h" #include "rendering/lightarray.h" #include "rendering/particles.h" @@ -317,6 +318,11 @@ bool driver_mode::update() Timer::subsystem.sim_lights.start(); simulation::Lights.update(); Timer::subsystem.sim_lights.stop(); + + if (Global.test.enabled && simulation::is_ready) + { + simulation::test().update(deltatime); + } } // render time routines follow: @@ -507,6 +513,13 @@ void driver_mode::enter() Timer::ResetTimers(); + if (Global.test.enabled) + { + // the test runs as fast as it can compute, in steps of fixed length, so that runs on different machines agree + Timer::set_fixed_step(simulation::test_manager::frame_step); + simulation::test().start(); + } + set_picking(!Global.captureonstart); m_editorpending = Global.editor_session; diff --git a/application/driveruipanels.cpp b/application/driveruipanels.cpp index 7f2194dd..3b4e6c9c 100644 --- a/application/driveruipanels.cpp +++ b/application/driveruipanels.cpp @@ -208,6 +208,24 @@ drivingaid_panel::update() { } } +void +drivingaid_panel::render_contents() { + ImGui::Checkbox(STR_C("expand"), &is_expanded); + + ImGui::SameLine(); + + auto const *driver = (simulation::Train && simulation::Train->Dynamic()) ? simulation::Train->Dynamic()->Mechanik : nullptr; + if (driver && (false == Global.RealisticControlMode || DebugModeFlag)) { + bool autopilot = driver->AIControllFlag; + if (ImGui::Checkbox(STR_C("autopilot"), &autopilot)) { + command_relay commandrelay; + commandrelay.post(autopilot ? user_command::aidriverenable : user_command::aidriverdisable, 0.0, 0.0, GLFW_PRESS, 0); + } + } + + ui_panel::render_contents(); +} + void scenario_panel::update() { @@ -829,7 +847,7 @@ debug_panel::update_section_vehicle( std::vector &Output ) { mover.HeatingAllow ? 'h' : '.', mover.Hamulec->Releaser() ? '^' : '.', - std::string( m_input.mechanik ? STR(" R") + ( mover.Radio ? std::to_string( m_input.mechanik->iRadioChannel ) : "-" ) : "" ).c_str(), + std::string( m_input.mechanik ? STR(" R") + ( mover.Radio && mover.RadioFuse ? std::to_string( m_input.mechanik->iRadioChannel ) : "-" ) : "" ).c_str(), std::string( isdieselenginepowered ? STR(" oil pressure: ") + to_string( mover.OilPump.pressure, 2 ) : "" ).c_str(), // power transfers // 3000v diff --git a/application/driveruipanels.h b/application/driveruipanels.h index dd43275c..ed971f9f 100644 --- a/application/driveruipanels.h +++ b/application/driveruipanels.h @@ -21,6 +21,7 @@ public: {} void update() override; + void render_contents() override; private: // members diff --git a/application/editormode.cpp b/application/editormode.cpp index e29cb6b5..61f1405f 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -6525,8 +6525,12 @@ void editor_mode::render_gizmo() snapvalue = glm::vec3(0.1f); float const *snap = Global.ctrlState && snapvalue.x > 0.0f ? glm::value_ptr(snapvalue) : nullptr; + // the change of this frame alone: a turn is taken from it, not from the angles the whole matrix decomposes into, + // which can't tell a turn of more than 90 degrees about the vertical axis (it comes out as 180 - angle with the + // other two axes flipped, and the model turned back and forth while dragged one way) + glm::mat4 delta(1.0f); ImGuizmo::Manipulate(glm::value_ptr(view), glm::value_ptr(projection), - operation, mode, glm::value_ptr(matrix), nullptr, snap); + operation, mode, glm::value_ptr(matrix), glm::value_ptr(delta), snap); if (ImGuizmo::IsUsing()) { @@ -6543,9 +6547,11 @@ void editor_mode::render_gizmo() if (operation == ImGuizmo::ROTATE && model) { - // apply the rotation delta relative to the model's current orientation - glm::vec3 const newangles(newrotation[0], newrotation[1], newrotation[2]); - auto const turned{newangles - model->Angles()}; + // the turn of this frame, small, so its angles decompose with no ambiguity + float deltatranslation[3], deltarotation[3], deltascale[3]; + ImGuizmo::DecomposeMatrixToComponents(glm::value_ptr(delta), deltatranslation, deltarotation, deltascale); + auto const wrapped = [](float Angle) { return Angle - 360.0f * std::floor((Angle + 180.0f) / 360.0f); }; + glm::vec3 const turned{wrapped(deltarotation[0]), wrapped(deltarotation[1]), wrapped(deltarotation[2])}; m_editor.rotate(model, turned, 0.0f); selection_follow(glm::dvec3(0.0), turned, model->location()); } diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index f8f06ac9..20fa46d3 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -14,6 +14,7 @@ http://mozilla.org/MPL/2.0/. #include "simulation/simulation.h" #include "simulation/simulationtime.h" #include "simulation/simulationenvironment.h" +#include "simulation/simulationtest.h" #include "application/application.h" #include "application/scenarioloaderuilayer.h" #include "rendering/renderer.h" @@ -58,6 +59,10 @@ bool scenarioloader_mode::update() { catch (std::runtime_error const &e) { ErrorLog(std::format("Bad init: scenario \"{}\" couldn't be opened ({})", Global.SceneryFile, e.what())); state.reset(); + if (Global.test.enabled) { + simulation::test().finish(simulation::test_result::error, "scenario couldn't be opened"); + return true; + } Global.SceneryFile.clear(); Application.pop_mode(); Application.push_mode(eu07_application::mode::launcher); @@ -73,6 +78,11 @@ bool scenarioloader_mode::update() { // back to the launcher as when the scenario can't be opened, not on to the driver mode with the scenery half loaded ErrorLog( "Bad init: scenario loading failed" ); state.reset(); + if( Global.test.enabled ) { + // there's nothing to test; the exit code says the run went wrong + simulation::test().finish( simulation::test_result::error, "scenario loading failed" ); + return true; + } Global.SceneryFile.clear(); Application.pop_mode(); Application.push_mode(eu07_application::mode::launcher); diff --git a/application/uilayer.cpp b/application/uilayer.cpp index 27ecdb2e..84ec775b 100644 --- a/application/uilayer.cpp +++ b/application/uilayer.cpp @@ -296,6 +296,11 @@ bool ui_layer::init(GLFWwindow *Window) IMGUI_CHECKVERSION(); ImGui::CreateContext(); m_imguiio = &ImGui::GetIO(); + if (Global.test.enabled) + { + // a test leaves the player's window layout alone (imgui.ini in the game folder), runs in parallel included + m_imguiio->IniFilename = nullptr; + } ImGui::GetCurrentContext()->ConfigNavWindowingKeyNext = ImGuiKey_None; ImGui::GetCurrentContext()->ConfigNavWindowingKeyPrev = ImGuiKey_None; // imgui.ini is read with the first frame, which may be of another mode than the editor @@ -416,6 +421,8 @@ bool ui_layer::init(GLFWwindow *Window) if (!gamepadui) gamepadui = ""; auto const viewportsupport{(m_imguiio->BackendFlags & ImGuiBackendFlags_PlatformHasViewports) && (m_imguiio->BackendFlags & ImGuiBackendFlags_RendererHasViewports) && glfwGetPlatform() != GLFW_PLATFORM_WAYLAND && std::string_view{desktop} != "gamescope" && std::string_view{gamepadui} != "1"}; + // a tooltip as long as a paragraph is wrapped: in one line it can run past the screen + m_imguiio->ConfigTooltipsWrapWidth = std::round(Global.ui_fontsize * 40.0f); if (Global.ui_viewports && viewportsupport) { m_imguiio->ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; diff --git a/imgui/imgui.cpp b/imgui/imgui.cpp index a9026b40..2579bf4d 100644 --- a/imgui/imgui.cpp +++ b/imgui/imgui.cpp @@ -1508,6 +1508,7 @@ ImGuiIO::ImGuiIO() ConfigWindowsCopyContentsWithCtrlC = false; ConfigScrollbarScrollByPage = true; ConfigMemoryCompactTimer = 60.0f; + ConfigTooltipsWrapWidth = 0.0f; ConfigDebugIsDebuggerPresent = false; ConfigDebugHighlightIdConflicts = true; ConfigDebugHighlightIdConflictsShowItemPicker = true; @@ -12112,7 +12113,14 @@ void ImGui::SetTooltipV(const char* fmt, va_list args) { if (!BeginTooltipEx(ImGuiTooltipFlags_OverridePrevious, ImGuiWindowFlags_None)) return; + // MaSzyna addition: see io.ConfigTooltipsWrapWidth + ImGuiContext& g = *GImGui; + const bool wrap = g.IO.ConfigTooltipsWrapWidth > 0.0f; + if (wrap) + PushTextWrapPos(GetCursorPosX() + g.IO.ConfigTooltipsWrapWidth); TextV(fmt, args); + if (wrap) + PopTextWrapPos(); EndTooltip(); } @@ -16375,9 +16383,11 @@ static void ImGui::WindowSelectViewport(ImGuiWindow* window) } // Fallback: merge in default viewport if z-order matches, otherwise create a new viewport + // MaSzyna addition: with io.ConfigViewportsNoAutoDetach the tooltips and popups stay in the main viewport here too if (window->Viewport == NULL) if (!UpdateTryMergeWindowIntoHostViewport(window, main_viewport)) - window->Viewport = WindowStaysInHostViewport(window, main_viewport) ? main_viewport : AddUpdateViewport(window, window->ID, window->Pos, window->Size, ImGuiViewportFlags_None); + window->Viewport = (WindowStaysInHostViewport(window, main_viewport) || (g.IO.ConfigViewportsNoAutoDetach && (flags & (ImGuiWindowFlags_Popup | ImGuiWindowFlags_Tooltip)))) + ? main_viewport : AddUpdateViewport(window, window->ID, window->Pos, window->Size, ImGuiViewportFlags_None); // Mark window as allowed to protrude outside of its viewport and into the current monitor if (!lock_viewport) @@ -16393,6 +16403,10 @@ static void ImGui::WindowSelectViewport(ImGuiWindow* window) window->ViewportAllowPlatformMonitorExtend = FindPlatformMonitorForPos((use_mouse_ref && mouse_valid) ? mouse_ref : NavCalcPreferredRefPos()); else window->ViewportAllowPlatformMonitorExtend = window->Viewport->PlatformMonitor; + // MaSzyna addition: see io.ConfigViewportsNoAutoDetach. Over the main viewport they stay inside it: the late creation of a + // viewport in Begin() needs the extent of the monitor + if (g.IO.ConfigViewportsNoAutoDetach && window->Viewport == main_viewport) + window->ViewportAllowPlatformMonitorExtend = -1; } else if (window->Viewport && window != window->Viewport->Window && window->Viewport->Window && !(flags & ImGuiWindowFlags_ChildWindow) && window->DockNode == NULL) { diff --git a/imgui/imgui.h b/imgui/imgui.h index 6bbc42de..6988a012 100644 --- a/imgui/imgui.h +++ b/imgui/imgui.h @@ -2360,7 +2360,7 @@ struct ImGuiIO bool ConfigViewportsNoTaskBarIcon; // = false // Disable default OS task bar icon flag for secondary viewports. When a viewport doesn't want a task bar icon, ImGuiViewportFlags_NoTaskBarIcon will be set on it. bool ConfigViewportsNoDecoration; // = true // Disable default OS window decoration flag for secondary viewports. When a viewport doesn't want window decorations, ImGuiViewportFlags_NoDecoration will be set on it. Enabling decoration can create subsequent issues at OS levels (e.g. minimum window size). bool ConfigViewportsNoDefaultParent; // = false // Disable default OS parenting to main viewport for secondary viewports. By default, viewports are marked with ParentViewportId = , expecting the platform backend to setup a parent/child relationship between the OS windows (some backend may ignore this). Set to true if you want the default to be 0, then all viewports will be top-level OS windows. - bool ConfigViewportsNoAutoDetach; // = false // MaSzyna addition: a floating window stays inside its host viewport: a resize stops at its edge, and a window left past it, or too big for it, is moved and shrunk to fit. It gets a viewport of its own only when dragged with the mouse cursor past the host viewport. Popups and tooltips are not affected. + bool ConfigViewportsNoAutoDetach; // = false // MaSzyna addition: a floating window stays inside its host viewport: a resize stops at its edge, and a window left past it, or too big for it, is moved and shrunk to fit. It gets a viewport of its own only when dragged with the mouse cursor past the host viewport. Tooltips and popups shown over the main viewport, or begun outside any window, get no viewport of their own either: they are placed inside the main viewport as with the viewports off. // Miscellaneous options // (you can visualize and interact with all options in 'Demo->Configuration') @@ -2375,6 +2375,7 @@ struct ImGuiIO bool ConfigWindowsCopyContentsWithCtrlC; // = false // [EXPERIMENTAL] CTRL+C copy the contents of focused window into the clipboard. Experimental because: (1) has known issues with nested Begin/End pairs (2) text output quality varies (3) text output is in submission order rather than spatial order. bool ConfigScrollbarScrollByPage; // = true // Enable scrolling page by page when clicking outside the scrollbar grab. When disabled, always scroll to clicked location. When enabled, Shift+Click scrolls to clicked location. float ConfigMemoryCompactTimer; // = 60.0f // Timer (in seconds) to free transient windows/tables memory buffers when unused. Set to -1.0f to disable. + float ConfigTooltipsWrapWidth; // = 0.0f // MaSzyna addition: the text of SetTooltip() and SetItemTooltip() wraps at this width, in pixels; 0: it doesn't wrap. BeginTooltip() is not affected. // Inputs Behaviors // (other variables, ones which are expected to be tweaked within UI code, are exposed in ImGuiStyle) diff --git a/input/command.cpp b/input/command.cpp index d87ac132..b9e3eaa1 100644 --- a/input/command.cpp +++ b/input/command.cpp @@ -158,6 +158,9 @@ commanddescription_sequence Commands_descriptions = { {"radiovolumeincrease", command_target::vehicle, command_mode::oneoff}, {"radiovolumedecrease", command_target::vehicle, command_mode::oneoff}, {"radiovolumeset", command_target::vehicle, command_mode::oneoff}, + {"radiofusetoggle", command_target::vehicle, command_mode::oneoff}, + {"radiofuseenable", command_target::vehicle, command_mode::oneoff}, + {"radiofusedisable", command_target::vehicle, command_mode::oneoff}, {"cabchangeforward", command_target::vehicle, command_mode::oneoff}, {"cabchangebackward", command_target::vehicle, command_mode::oneoff}, {"modernlightdimmerdecrease", command_target::vehicle, command_mode::oneoff}, @@ -413,6 +416,12 @@ commanddescription_sequence Commands_descriptions = { {"fmradiotunedecrease", command_target::vehicle, command_mode::oneoff}, {"fmradiotuneset", command_target::vehicle, command_mode::oneoff}, {"oilfuelpumptoggle", command_target::vehicle, command_mode::oneoff}, + {"linebreakertoggleb", command_target::vehicle, command_mode::oneoff}, + {"linebreakeropenb", command_target::vehicle, command_mode::oneoff}, + {"linebreakercloseb", command_target::vehicle, command_mode::oneoff}, + {"linebreakertogglec", command_target::vehicle, command_mode::oneoff}, + {"linebreakeropenc", command_target::vehicle, command_mode::oneoff}, + {"linebreakerclosec", command_target::vehicle, command_mode::oneoff}, }; // Maps of command and coresponding strings @@ -551,6 +560,9 @@ std::unordered_map const commandMap = { {"radiovolumeincrease", user_command::radiovolumeincrease}, {"radiovolumedecrease", user_command::radiovolumedecrease}, {"radiovolumeset", user_command::radiovolumeset}, + {"radiofusetoggle", user_command::radiofusetoggle}, + {"radiofuseenable", user_command::radiofuseenable}, + {"radiofusedisable", user_command::radiofusedisable}, {"cabchangeforward", user_command::cabchangeforward}, {"cabchangebackward", user_command::cabchangebackward}, {"viewturn", user_command::viewturn}, @@ -805,6 +817,12 @@ std::unordered_map const commandMap = { {"fmradiotunedecrease", user_command::fmradiotunedecrease}, {"fmradiotuneset", user_command::fmradiotuneset}, {"oilfuelpumptoggle", user_command::oilfuelpumptoggle}, + {"linebreakertoggleb", user_command::linebreakertoggleb}, + {"linebreakeropenb", user_command::linebreakeropenb}, + {"linebreakercloseb", user_command::linebreakercloseb}, + {"linebreakertogglec", user_command::linebreakertogglec}, + {"linebreakeropenc", user_command::linebreakeropenc}, + {"linebreakerclosec", user_command::linebreakerclosec}, {"none", user_command::none}}; } // namespace simulation diff --git a/input/command.h b/input/command.h index 6e7e7714..ae2f461c 100644 --- a/input/command.h +++ b/input/command.h @@ -151,6 +151,9 @@ enum class user_command radiovolumeincrease, radiovolumedecrease, radiovolumeset, + radiofusetoggle, + radiofuseenable, + radiofusedisable, cabchangeforward, cabchangebackward, @@ -411,6 +414,12 @@ enum class user_command fmradiotunedecrease, fmradiotuneset, oilfuelpumptoggle, + linebreakertoggleb, + linebreakeropenb, + linebreakercloseb, + linebreakertogglec, + linebreakeropenc, + linebreakerclosec, none = -1 }; diff --git a/input/driverkeyboardinput.cpp b/input/driverkeyboardinput.cpp index e7e636b3..047f3678 100644 --- a/input/driverkeyboardinput.cpp +++ b/input/driverkeyboardinput.cpp @@ -160,6 +160,9 @@ driverkeyboard_input::default_bindings() { // radiovolumeincrease, // radiovolumedecrease, // radiovolumeset, + // radiofusetoggle, + // radiofuseenable, + // radiofusedisable, { cabchangeforward, {GLFW_KEY_HOME, ""} }, { cabchangebackward, {GLFW_KEY_END, ""} }, // viewturn, diff --git a/input/drivermouseinput.cpp b/input/drivermouseinput.cpp index 1964df56..9749024a 100644 --- a/input/drivermouseinput.cpp +++ b/input/drivermouseinput.cpp @@ -597,6 +597,18 @@ drivermouse_input::default_bindings() { { "main_on_bt:",{ linebreakerclose, none } }, + { "mainb_off_bt:", { + linebreakeropenb, + none } }, + { "mainb_on_bt:", { + linebreakercloseb, + none } }, + { "mainc_off_bt:", { + linebreakeropenc, + none } }, + { "mainc_on_bt:", { + linebreakerclosec, + none } }, { "security_reset_bt:", { alerteracknowledge, none } }, @@ -774,6 +786,12 @@ drivermouse_input::default_bindings() { { "main_sw:", { linebreakertoggle, none } }, + { "mainb_sw:", { + linebreakertoggleb, + none } }, + { "mainc_sw:", { + linebreakertogglec, + none } }, { "radio_sw:", { radiotoggle, none } }, @@ -783,6 +801,15 @@ drivermouse_input::default_bindings() { { "radiooff_sw:", { radiodisable, none } }, + { "radiofuse_sw:", { + radiofusetoggle, + none } }, + { "radiofuseon_sw:", { + radiofuseenable, + none } }, + { "radiofuseoff_sw:", { + radiofusedisable, + none } }, { "radiochannel_sw:", { radiochannelincrease, radiochanneldecrease } }, diff --git a/model/Texture.cpp b/model/Texture.cpp index 14f78661..338b1fb6 100644 --- a/model/Texture.cpp +++ b/model/Texture.cpp @@ -29,6 +29,8 @@ http://mozilla.org/MPL/2.0/. #include "dds-ktx/dds-ktx.h" #include "winheaders.h" +#include + #define EU07_DEFERRED_TEXTURE_UPLOAD #ifndef FOURCC_DX10 @@ -336,6 +338,20 @@ bool bptc_supported() { }(); return supported; } +// format of a compressed texture read back from the gpu, given as the image would have it: the driver tells the srgb variant +// of the format, which the mapping doesn't take. with the components hint of the texture the linear one gives the same gl +// format again on the next upload +GLint readback_format( GLint const Internalformat ) { + switch( Internalformat ) { + case GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC: { return GL_COMPRESSED_RGBA8_ETC2_EAC; } + case GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT: { return GL_COMPRESSED_RGBA_S3TC_DXT1_EXT; } + case GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT: { return GL_COMPRESSED_RGBA_S3TC_DXT3_EXT; } + case GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT: { return GL_COMPRESSED_RGBA_S3TC_DXT5_EXT; } + case GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM: { return GL_COMPRESSED_RGBA_BPTC_UNORM; } + default: { return Internalformat; } + } +} + } // namespace std::array opengl_texture::units = { 0 }; @@ -648,6 +664,10 @@ void texture_source::make_request() { auto const components { Split( name, '?' ) }; + if( components.front().ends_with( ".lua" ) || components.front().ends_with( ".wasm" ) ) { + // script destination signs are drawn by the vehicles showing them (script_screen_queue) + return; + } auto dictionary = std::make_shared( components.back() ); @@ -1427,29 +1447,13 @@ opengl_texture::release() { } else { ::glBindTexture(target, id ); - GLint datasize {}; GLint iscompressed {}; ::glGetTexLevelParameteriv(target, 0, GL_TEXTURE_COMPRESSED, &iscompressed ); - if( iscompressed == GL_TRUE ) { - // texture is compressed on the gpu side - // query texture details needed to perform the backup... - ::glGetTexLevelParameteriv(target, 0, GL_TEXTURE_INTERNAL_FORMAT, &data_format ); - ::glGetTexLevelParameteriv(target, 0, GL_TEXTURE_COMPRESSED_IMAGE_SIZE, &datasize ); - data.resize( datasize ); - // ...fetch the data... - ::glGetCompressedTexImage(target, 0, &data[ 0 ] ); + if( ( iscompressed != GL_TRUE ) + || ( false == read_back_compressed() ) ) { + // the texture didn't get compressed during upload, or got a format the upload doesn't take + read_back_uncompressed(); } - else { - // for whatever reason texture didn't get compressed during upload - // fallback on plain rgba storage... - data_format = GL_RGBA; - data_type = GL_UNSIGNED_BYTE; - data.resize( data_width * data_height * 4 ); - // ...fetch the data... - ::glGetTexImage(target, 0, data_format, GL_UNSIGNED_BYTE, &data[ 0 ] ); - } - // ...and update texture object state - data_mapcount = 1; // we keep copy of only top mipmap level data_state = resource_state::good; } } @@ -1461,6 +1465,56 @@ opengl_texture::release() { return; } +// copies all mipmaps of the compressed gl texture into the data, in the layout of a dds file. returns false, leaving the data as +// it was, when the format can't be uploaded again as it is. all levels are kept since the driver may not generate mipmaps +// of a compressed format; without them the texture would be incomplete and drawn black +bool +opengl_texture::read_back_compressed() { + + GLint internalformat {}; + ::glGetTexLevelParameteriv( target, 0, GL_TEXTURE_INTERNAL_FORMAT, &internalformat ); + auto const format { readback_format( internalformat ) }; + if( ( false == precompressed_formats.contains( format ) ) + || ( false == mapping.contains( format ) ) ) { + return false; + } + auto const levelcount { static_cast( std::bit_width( std::max( data_width, data_height ) ) ) }; + std::vector leveldata; + auto mapcount { 0 }; + while( mapcount < levelcount ) { + GLint levelwidth {}; + GLint levelsize {}; + ::glGetTexLevelParameteriv( target, mapcount, GL_TEXTURE_WIDTH, &levelwidth ); + ::glGetTexLevelParameteriv( target, mapcount, GL_TEXTURE_COMPRESSED_IMAGE_SIZE, &levelsize ); + if( ( levelwidth <= 0 ) || ( levelsize <= 0 ) ) { + // the levels past the last one present weren't made + break; + } + auto const offset { leveldata.size() }; + leveldata.resize( offset + static_cast( levelsize ) ); + ::glGetCompressedTexImage( target, mapcount, &leveldata[ offset ] ); + ++mapcount; + } + if( mapcount == 0 ) { + return false; + } + data.swap( leveldata ); + data_format = format; + data_mapcount = static_cast( mapcount ); + return true; +} + +// copies the top level of the gl texture into the data as plain rgba, the mipmaps are generated again on upload +void +opengl_texture::read_back_uncompressed() { + + data_format = GL_RGBA; + data_type = GL_UNSIGNED_BYTE; + data.resize( data_width * data_height * 4 ); + ::glGetTexImage( target, 0, data_format, GL_UNSIGNED_BYTE, &data[ 0 ] ); + data_mapcount = 1; +} + void opengl_texture::alloc_rendertarget( GLint format, GLint components, int width, int height, int l, int s, GLint wrap ) { diff --git a/model/Texture.h b/model/Texture.h index 3f24a13c..b072169d 100644 --- a/model/Texture.h +++ b/model/Texture.h @@ -126,6 +126,9 @@ public: void prefetch( glm::dvec3 const *Origin = nullptr ); // the data is read from the file when it's needed and not kept after the upload (gfx.textures.streaming) bool streamed() const; + // with gfx.resource.move, copies the gl texture back into the data before it's released. requires the texture bound + bool read_back_compressed(); + void read_back_uncompressed(); // members std::shared_ptr request; // data being read by a worker thread 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/rendering/nullrenderer.cpp b/rendering/nullrenderer.cpp index 332d357c..04eb0a9e 100644 --- a/rendering/nullrenderer.cpp +++ b/rendering/nullrenderer.cpp @@ -1,9 +1,58 @@ #include "stdafx.h" #include "rendering/nullrenderer.h" +#include "application/application.h" +#include "utilities/Logs.h" + +namespace +{ + +// the null renderer draws nothing, but parts of the ui make textures with gl calls of their own. the window has no hints, +// so any context will do for them: a machine without a graphics driver (a test runner) has only the basic one of the system +bool null_after_window(GLFWwindow *Window) +{ + glfwMakeContextCurrent(Window); + if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) + { + ErrorLog("Bad init: failed to initialize glad"); + return false; + } + return true; +} + +gfx_backend_desc null_backend_desc() +{ + gfx_backend_desc desc; + desc.name = "null"; + desc.create = &null_renderer::create_func; + desc.after_window = &null_after_window; + desc.gl_context = true; + return desc; +} + +} // namespace + +void null_imgui_renderer::BeginFrame() +{ + auto &io { ImGui::GetIO() }; + if( false == io.Fonts->IsBuilt() ) { + unsigned char *pixels { nullptr }; + int width { 0 }; + int height { 0 }; + io.Fonts->GetTexDataAsAlpha8( &pixels, &width, &height ); + } +} + +bool null_renderer::Render() +{ + // the ui frame begun by the application has to be ended, or imgui won't start the next one + Application.render_ui(); + return true; +} + std::unique_ptr null_renderer::create_func() { return std::make_unique(); } -bool null_renderer::renderer_register = gfx_renderer_factory::get_instance()->register_backend("null", null_renderer::create_func); +bool null_renderer::renderer_register = gfx_renderer_factory::get_instance()->register_backend("null", null_backend_desc()); diff --git a/rendering/nullrenderer.h b/rendering/nullrenderer.h index bc29be7b..a40c7462 100644 --- a/rendering/nullrenderer.h +++ b/rendering/nullrenderer.h @@ -35,7 +35,18 @@ private: release_() override { /* null implementation, nothing to do */ } }; +// user interface backend which draws nothing: imgui still builds its frames, so the ui code runs as usual +class null_imgui_renderer final : public imgui_renderer { +public: + bool Init() override { return true; } + void Shutdown() override { /* null implementation, nothing to do */ } + // imgui wants its font atlas built before a frame starts; that's done on the cpu, no texture is made of it + void BeginFrame() override; + void Render() override { /* null implementation, nothing to do */ } +}; + // bare-bones render controller, in lack of anything better yet +// it's the renderer of the test mode (--test): models are loaded for the physics, textures aren't class null_renderer : public gfx_renderer { public: @@ -49,7 +60,7 @@ public: Init( GLFWwindow *Window ) override { return true; } // main draw call. returns false on error bool - Render() override { return true; } + Render() override; void SwapBuffers() override { /* null implementation, nothing to do */ } inline @@ -105,20 +116,22 @@ public: void Bind_Material( material_handle const Material, TSubModel const *sm = nullptr, lighting_data const *lighting = nullptr ) override { /* null implementation, nothing to do */ } IMaterial const * - Material( material_handle const Material ) const override { return m_materials.at(Material - 1).get(); } + Material( material_handle const Material ) const override { + return ( Material > 0 && static_cast( Material ) <= m_materials.size() ? m_materials[ Material - 1 ].get() : IMaterial::null_material() ); } // shader methods auto Fetch_Shader( std::string const &name ) -> std::shared_ptr override { throw std::runtime_error("not impl"); } // texture methods + // no textures are loaded; whoever asks gets the null texture texture_handle - Fetch_Texture( std::string const &Filename, bool const Loadnow = true, GLint format_hint = GL_SRGB_ALPHA ) override { throw std::runtime_error("not impl"); } + Fetch_Texture( std::string const &Filename, bool const Loadnow = true, GLint format_hint = GL_SRGB_ALPHA ) override { return null_handle; } void Bind_Texture( texture_handle const Texture ) override { /* null implementation, nothing to do */ } void Bind_Texture( std::size_t const Unit, texture_handle const Texture ) override { /* null implementation, nothing to do */ } - opengl_texture & - Texture( texture_handle const Texture ) override { throw std::runtime_error("not impl"); } - opengl_texture const & - Texture( texture_handle const Texture ) const override { throw std::runtime_error("not impl"); } + ITexture & + Texture( texture_handle const Texture ) override { return *ITexture::null_texture(); } + ITexture const & + Texture( texture_handle const Texture ) const override { return *ITexture::null_texture(); } // utility methods void Pick_Control_Callback( std::function Callback ) override { /* null implementation, nothing to do */ } @@ -152,11 +165,12 @@ public: imgui_renderer *GetImguiRenderer() override { - return nullptr; + return &m_imgui; } private: std::string empty_str; + null_imgui_renderer m_imgui; gfx::geometrybank_manager m_geometry; std::vector> m_materials; }; diff --git a/rendering/renderer.h b/rendering/renderer.h index 7b6c40dc..51f611fd 100644 --- a/rendering/renderer.h +++ b/rendering/renderer.h @@ -24,6 +24,11 @@ namespace gfx class shader_program; } +namespace script_screens +{ +class post_processor; +} + namespace gfx { // material painted over the heightmap terrain, and the size (metres) its textures are repeated at; 0: the size given by the material, or 8 m @@ -193,6 +198,9 @@ public: // seen in perspective from the front, side and above and filling the image less Margin pixels at each edge. // Image receives rgb rows, top row first. returns false if the backend can't make previews or there's nothing to draw virtual auto Render_Preview( TAnimModel *Instance, int const Size, int const Margin, bool const Shadows, std::vector &Image ) -> bool { return false; } + // runs the post-processing of the script screens (image:led_post, image:resize) on the gpu. nullptr when the backend + // can't; the screens then do it on the cpu + virtual auto Screen_post_processor() -> script_screens::post_processor * { return nullptr; } }; // what the application has to know about a backend before its renderer exists: whether it can run here at all, and how its diff --git a/script_screens_doc.md b/script_screens_doc.md new file mode 100644 index 00000000..52ccc9ce --- /dev/null +++ b/script_screens_doc.md @@ -0,0 +1,210 @@ +# Script screens + +Screens drawn by scripts, alongside the Python screens (`pyscreen:`, `pydestinationsign:`): Lua scripts, run by +LuaJIT, and WebAssembly modules, run by the WAMR interpreter (`ref/wamr`). A script 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. + +A Lua script is text, kept with the vehicles and the `scripts/` folder like the Python screens, and changed with any +editor. A WebAssembly module is compiled from another language (Rust, C, Zig and the like). + +Both run sandboxed: a script reaches only the functions of `eu07` below, and reads files only from its own folder and +below. A Lua script gets no `io`, `os`, `ffi`, `debug` or `require`, and no precompiled code is loaded. + +Unlike a Python screen, which refreshes after a time (`python.updatetime`, `updatetime:`), a script screen refreshes +every so many rendered frames, set in `eu07.ini`. A call into a script may run at most 200 million instructions +(Lua runs without the JIT compiler so that the limit holds). A script that fails, or runs over, is switched off, and +the reason goes to the log. + +## eu07.ini + +| key | default | meaning | +|--------------------------|---------|--------------------------------------------------------------------------------| +| `luascreens.enabled` | 1 | 0: no Lua screens; the vehicles show what they did before | +| `wasm.enabled` | 1 | 0: no WebAssembly screens; the vehicles show what they did before | +| `screens.cabframes` | 4 | a screen in the cab is refreshed every so many frames (1-1000) | +| `screens.externalframes` | 30 | a screen outside the cab (destination sign) is refreshed every so many frames | +| `screens.gpupost` | 1 | 0: `led_post` and `resize` of the Lua screens always run on the cpu, as they did before | + +`wasm.cabframes` and `wasm.externalframes`, the first names of the last two, are read as well. + +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:`: + +``` +luascreen: { ekran.lua target: screen01 frames: 6 parameters: id=1&mode=full } +wasmscreen: { ekran.wasm target: screen02 } +``` + +| key | meaning | +|---------------|---------------------------------------------------------------------------------------------------| +| (first token) | the script, 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 `screens.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:`: + +``` +luadestinationsign: { scripts/led_111a20.lua parameters: kod_e=IC&kod_p=TLK&kod_o=R instance: name } +destinationsignbackground: make:scripts/led_111a20.lua?dummy=1 +``` + +`luadestinationsign:` and `wasmdestinationsign:` take the keys 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 blank sign (`background:`, or the older `destinationsignbackground:`) named `make: