From 28cf5fe276ccc61087da9d1800b9c5738094af6f Mon Sep 17 00:00:00 2001 From: jerrrrycho Date: Thu, 15 Oct 2026 22:26:13 +0200 Subject: [PATCH 1/3] fix(gfx): a vehicle taken off the tracks stops smoking (#90) The smoke sources of a vehicle stay in the particle manager when the vehicle is taken off the tracks: one gone through a portal, or a road vehicle waiting in the pool of its spawn point. They kept spawning particles where the vehicle had vanished, for good. A source of a disabled vehicle spawns no more particles now, the ones it let out fade away as usual, and a pooled road vehicle puffs again once it's back on the road. Checked on td.scn with a portal added at the dead end of tdo_n585 and an SM42 driven into it by the AI: on master its smoke source kept about 100 particles where it vanished for the rest of the run (over 200 s); with the fix they were gone within a second. --- rendering/particles.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/rendering/particles.cpp b/rendering/particles.cpp index 5f7158e0c..6719eb6df 100644 --- a/rendering/particles.cpp +++ b/rendering/particles.cpp @@ -162,8 +162,15 @@ smoke_source::update( double const Timedelta, bool const Onlydespawn ) { glm::dvec3{ std::numeric_limits::max() }, glm::dvec3{ std::numeric_limits::lowest() } }; + // a vehicle taken off the tracks (gone through a portal, or waiting in the pool of the road traffic) puffs no more, + // the particles it already let out fade away as usual + auto const nospawn { + false == Global.Smoke + || true == Onlydespawn + || ( m_ownertype == owner_type::vehicle && false == m_owner.vehicle->bEnabled ) }; + m_spawncount = ( - ( ( false == Global.Smoke ) || ( true == Onlydespawn ) ) ? + ( true == nospawn ) ? 0.f : std::min( static_cast(m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity )), @@ -179,7 +186,7 @@ smoke_source::update( double const Timedelta, bool const Onlydespawn ) { if (m_owner.vehicle->MoverParameters->dizel_spinup == true) { m_spawncount = - ((false == Global.Smoke) || (true == Onlydespawn)) ? + (true == nospawn) ? 0.f : std::min( // m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity ), @@ -191,7 +198,7 @@ smoke_source::update( double const Timedelta, bool const Onlydespawn ) { m_owner.vehicle->MoverParameters->Im == 0) { m_spawncount = - ((false == Global.Smoke) || (true == Onlydespawn)) ? + (true == nospawn) ? 0.f : std::min( // m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity ), @@ -201,7 +208,7 @@ smoke_source::update( double const Timedelta, bool const Onlydespawn ) { { m_spawncount = - ((false == Global.Smoke) || (true == Onlydespawn)) ? + (true == nospawn) ? 0.f : std::min( // m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity ), From f94b3080629523d991683fabff7f6ec3584534d8 Mon Sep 17 00:00:00 2001 From: jerrrrycho Date: Thu, 15 Oct 2026 22:26:13 +0200 Subject: [PATCH 2/3] fix(sound): check for the player train in the outer noise test of a vehicle The test whether a vehicle belongs to the player train dereferenced simulation::Train without a null check (commented out long ago). The driver mode switches to the free camera in the same frame the player train goes through a portal, before the sounds are updated, so it isn't known to crash, but nothing else guards it. --- vehicle/DynObj.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vehicle/DynObj.cpp b/vehicle/DynObj.cpp index c6793e454..6f151f0e8 100644 --- a/vehicle/DynObj.cpp +++ b/vehicle/DynObj.cpp @@ -4863,7 +4863,7 @@ void TDynamicObject::RenderSounds() { #endif && ( // compound test whether the vehicle belongs to user-driven consist (as these don't emit outer noise in cab view) FreeFlyModeFlag ? true : // in external view all vehicles emit outer noise - // Global.pWorld->train() == nullptr ? true : // (can skip this check, with no player train the external view is a given) + simulation::Train == nullptr ? true : // no player train, e.g. it went through a portal and the view isn't switched yet ctOwner == nullptr ? true : // standalone vehicle, can't be part of user-driven train ctOwner != simulation::Train->Dynamic()->ctOwner ? true : // confirmed isn't a part of the user-driven train Global.CabWindowOpen ? true : // sticking head out we get to hear outer noise From 4489dd7469e16437a125d1bc399d7127f54237dc Mon Sep 17 00:00:00 2001 From: jerrrrycho Date: Sat, 7 Nov 2026 11:38:49 +0100 Subject: [PATCH 3/3] test(gfx): unit tests of the smoke of a vehicle taken off the tracks (#90) The spawn count of the smoke source of a vehicle moves out of smoke_source::update() into smoke::vehicle_spawn_count() (vehicle/smoke.h), a free function on the mover state, which joins the core library so the unit tests can call it the way the source does. The rates are the same as before, the steady rate of the source still counted in before the share of the engine. Tests: tests/unit/test_smoke.cpp. A vehicle taken off the tracks spawns no smoke and drops the pending particles, with the engine starting, idling or under load; a road vehicle back from the pool puffs again; the engine adds to the steady rate; the pending count stays within the room of the source. Two of the four fail without the fix. Checked again on td.scn with the SM42 driven into a portal: the smoke source keeps puffing on the run and drops to 0 particles once the vehicle is taken off the tracks. --- CMakeLists.txt | 1 + rendering/particles.cpp | 75 +++++++++++----------------------- tests/unit/test_smoke.cpp | 85 +++++++++++++++++++++++++++++++++++++++ vehicle/smoke.cpp | 46 +++++++++++++++++++++ vehicle/smoke.h | 30 ++++++++++++++ 5 files changed, 186 insertions(+), 51 deletions(-) create mode 100644 tests/unit/test_smoke.cpp create mode 100644 vehicle/smoke.cpp create mode 100644 vehicle/smoke.h diff --git a/CMakeLists.txt b/CMakeLists.txt index dfe58b66d..9d19e844c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -463,6 +463,7 @@ set(CORE_SOURCES "scripting/ladderlogic.cpp" "vehicle/handover.cpp" "vehicle/horn.cpp" +"vehicle/smoke.cpp" ) add_library(eu07_core STATIC ${CORE_SOURCES}) target_link_libraries(eu07_core PUBLIC eu07_settings) diff --git a/rendering/particles.cpp b/rendering/particles.cpp index 6719eb6df..8ccb728dd 100644 --- a/rendering/particles.cpp +++ b/rendering/particles.cpp @@ -15,6 +15,7 @@ http://mozilla.org/MPL/2.0/. #include "model/AnimModel.h" #include "simulation/simulationenvironment.h" #include "utilities/Logs.h" +#include "vehicle/smoke.h" void @@ -162,60 +163,32 @@ smoke_source::update( double const Timedelta, bool const Onlydespawn ) { glm::dvec3{ std::numeric_limits::max() }, glm::dvec3{ std::numeric_limits::lowest() } }; - // a vehicle taken off the tracks (gone through a portal, or waiting in the pool of the road traffic) puffs no more, - // the particles it already let out fade away as usual - auto const nospawn { - false == Global.Smoke - || true == Onlydespawn - || ( m_ownertype == owner_type::vehicle && false == m_owner.vehicle->bEnabled ) }; + auto const spawning { ( true == Global.Smoke ) && ( false == Onlydespawn ) }; - m_spawncount = ( - ( true == nospawn ) ? - 0.f : - std::min( - static_cast(m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity )), - static_cast(m_max_particles) ) ); - // consider special spawn rate cases if( m_ownertype == owner_type::vehicle ) { - if (Global.AirTemperature <= 5.f || m_owner.vehicle->MoverParameters->dizel_heat.Ts < 45.f) - { - m_emitter.color = glm::vec3{128, 128, 128}; - } - - if (m_owner.vehicle->MoverParameters->dizel_spinup == true) - { - m_spawncount = - (true == nospawn) ? - 0.f : - std::min( - // m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity ), - static_cast(m_spawncount +(m_spawnrate * Timedelta * Global.SmokeFidelity * (((m_owner.vehicle->MoverParameters->enrot) / 4 ) * 0.01))), static_cast(m_max_particles)); - } - else - { - if (m_owner.vehicle->MoverParameters->DirAbsolute == 0 || - m_owner.vehicle->MoverParameters->Im == 0) - { - m_spawncount = - (true == nospawn) ? - 0.f : - std::min( - // m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity ), - static_cast(m_spawncount + (m_spawnrate * Timedelta * Global.SmokeFidelity * ((((m_owner.vehicle->MoverParameters->DElist[m_owner.vehicle->MoverParameters->MainCtrlPosNo].RPM -m_owner.vehicle->MoverParameters->enrot) /60) *0.02) *(m_owner.vehicle->MoverParameters->EnginePower * 0.005)))), static_cast(m_max_particles)); - } - else - { - - m_spawncount = - (true == nospawn) ? - 0.f : - std::min( - // m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity ), - static_cast(m_spawncount + (m_spawnrate * Timedelta * Global.SmokeFidelity * ((((m_owner.vehicle->MoverParameters->DElist[m_owner.vehicle->MoverParameters->MainCtrlPosNo].RPM -m_owner.vehicle->MoverParameters->enrot) /60) * (sqrt(m_owner.vehicle->MoverParameters->Im) * 0.01) * 0.02) *(m_owner.vehicle->MoverParameters->EnginePower * 0.005)))), static_cast(m_max_particles)); - } - } - } + auto const &mover { *m_owner.vehicle->MoverParameters }; + if( Global.AirTemperature <= 5.f || mover.dizel_heat.Ts < 45.f ) { + m_emitter.color = glm::vec3{ 128, 128, 128 }; + } + // a vehicle taken off the tracks (gone through a portal, or waiting in the pool of the road traffic) puffs no more, + // the particles it already let out fade away as usual + m_spawncount = ( + ( true == spawning ) ? + smoke::vehicle_spawn_count( + mover, m_owner.vehicle->bEnabled, + { m_spawncount, m_spawnrate, Global.SmokeFidelity, static_cast( m_max_particles ) }, + Timedelta ) : + 0.f ); + } + else { + m_spawncount = ( + ( true == spawning ) ? + std::min( + static_cast(m_spawncount + ( m_spawnrate * Timedelta * Global.SmokeFidelity )), + static_cast(m_max_particles) ) : + 0.f ); + } // update spawned particles for( auto particleiterator { std::begin( m_particles ) }; particleiterator != std::end( m_particles ); ++particleiterator ) { diff --git a/tests/unit/test_smoke.cpp b/tests/unit/test_smoke.cpp new file mode 100644 index 000000000..05fb8ae8b --- /dev/null +++ b/tests/unit/test_smoke.cpp @@ -0,0 +1,85 @@ +#include "stdafx.h" +#include "doctest.h" + +#include "vehicle.h" + +#include "vehicle/smoke.h" + +// the smoke of a vehicle's engine: how many particles the smoke source of the vehicle's model spawns in an update + +namespace { + +// a source of 20 particles per second, with room for 100 +smoke::spawn_rate const source{0.f, 20.f, 1.f, 100.f}; +double const update{0.1}; // s + +// a diesel at work, its engine in the state of one of the cases below +test::vehicle diesel() { + test::vehicle vehicle{test::definition::load("wagon")}; + vehicle->MainCtrlPosNo = 1; + vehicle->DElist[1].RPM = 1500.0; + vehicle->enrot = 300.0; + vehicle->EnginePower = 400.0; + return vehicle; +} + +} // namespace + +TEST_CASE("smoke: a vehicle taken off the tracks puffs no more (#90)") { + auto vehicle{diesel()}; + SUBCASE("engine starting") { vehicle->dizel_spinup = true; } + SUBCASE("engine idling") { vehicle->DirAbsolute = 0; } + SUBCASE("engine under load") { + vehicle->DirAbsolute = 1; + vehicle->Im = 400.0; + } + // on the tracks the engine puffs + REQUIRE(smoke::vehicle_spawn_count(vehicle.mover(), true, source, update) >= 1.f); + + // gone through a portal, the pending particles are dropped too + auto pending{source}; + pending.pending = 5.f; + CHECK(smoke::vehicle_spawn_count(vehicle.mover(), false, pending, update) == 0.f); +} + +TEST_CASE("smoke: a road vehicle back on the road from the pool puffs again") { + auto vehicle{diesel()}; + // waiting in the pool, the count stays at zero update after update + auto rate{source}; + for (auto i{0}; i < 10; ++i) { + rate.pending = smoke::vehicle_spawn_count(vehicle.mover(), false, rate, update); + } + REQUIRE(rate.pending == 0.f); + + CHECK(smoke::vehicle_spawn_count(vehicle.mover(), true, rate, update) >= 1.f); +} + +TEST_CASE("smoke: the engine adds to the steady rate of the source") { + auto vehicle{diesel()}; + vehicle->DirAbsolute = 0; + auto const steady{source.rate * update}; // the rate of a source of the scenery + + vehicle->EnginePower = 0.0; + CHECK(smoke::vehicle_spawn_count(vehicle.mover(), true, source, update) == doctest::Approx(steady)); + + vehicle->EnginePower = 400.0; + auto const working{smoke::vehicle_spawn_count(vehicle.mover(), true, source, update)}; + CHECK(working > steady); + + // the faster the starting engine turns, the more it puffs + vehicle->dizel_spinup = true; + vehicle->enrot = 100.0; + auto const slow{smoke::vehicle_spawn_count(vehicle.mover(), true, source, update)}; + vehicle->enrot = 400.0; + CHECK(smoke::vehicle_spawn_count(vehicle.mover(), true, source, update) > slow); +} + +TEST_CASE("smoke: the pending particles never outgrow the room of the source") { + auto vehicle{diesel()}; + vehicle->dizel_spinup = true; + vehicle->enrot = 100000.0; + auto rate{source}; + rate.pending = 99.f; + + CHECK(smoke::vehicle_spawn_count(vehicle.mover(), true, rate, 10.0) == rate.limit); +} diff --git a/vehicle/smoke.cpp b/vehicle/smoke.cpp new file mode 100644 index 000000000..b6e923222 --- /dev/null +++ b/vehicle/smoke.cpp @@ -0,0 +1,46 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "vehicle/smoke.h" + +#include "McZapkie/MOVER.h" + +namespace smoke { + +namespace { + +// how hard the engine works, scaling the spawn rate of the source +double engine_load(TMoverParameters const &Mover) { + if (Mover.dizel_spinup) { + // starting up, it puffs harder the faster it turns + return ((Mover.enrot / 4) * 0.01); + } + auto const rpmrise{(Mover.DElist[Mover.MainCtrlPosNo].RPM - Mover.enrot) / 60}; + if (Mover.DirAbsolute == 0 || Mover.Im == 0) { + // idling: speeding up to the revolutions of the master controller + return ((rpmrise * 0.02) * (Mover.EnginePower * 0.005)); + } + // under load: the more current the traction motors draw, the more smoke + return ((rpmrise * (std::sqrt(Mover.Im) * 0.01) * 0.02) * (Mover.EnginePower * 0.005)); +} + +} // namespace + +float vehicle_spawn_count(TMoverParameters const &Mover, bool const Ontracks, spawn_rate const &Rate, double const Timedelta) { + if (false == Ontracks) { + return 0.f; + } + // the steady rate of the source, the way a source of the scenery spawns... + auto const steady{std::min(static_cast(Rate.pending + (Rate.rate * Timedelta * Rate.fidelity)), Rate.limit)}; + // ...and on top of it the share of the engine + return std::min(static_cast(steady + (Rate.rate * Timedelta * Rate.fidelity * engine_load(Mover))), Rate.limit); +} + +} // namespace smoke diff --git a/vehicle/smoke.h b/vehicle/smoke.h new file mode 100644 index 000000000..e9dac2622 --- /dev/null +++ b/vehicle/smoke.h @@ -0,0 +1,30 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +class TMoverParameters; + +// the smoke of a vehicle's engine, let out by the smoke sources of its model (rendering/particles.h) +namespace smoke { + +// what a smoke source spawns, before the engine of its vehicle is taken into account +struct spawn_rate { + float pending{0.f}; // particles left to spawn from the previous updates + float rate{0.f}; // particles per second + float fidelity{1.f}; // multiplier of the smoke settings + float limit{0.f}; // most particles the source can have +}; + +// number of particles the smoke source of a vehicle has to spawn after Timedelta seconds: the steady rate of the +// source, plus a share which grows with the work of its engine. a vehicle taken off the tracks (Ontracks false: gone through a portal, or waiting in the pool of the road +// traffic) puffs no more, the particles it already let out fade away as usual +float vehicle_spawn_count(TMoverParameters const &Mover, bool Ontracks, spawn_rate const &Rate, double Timedelta); + +} // namespace smoke