17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-19 09:39:19 +02:00

Merge branch 'master' into feat/gl46-migration

This commit is contained in:
2026-10-17 20:57:15 +02:00
21 changed files with 8289 additions and 64 deletions

View File

@@ -77,6 +77,7 @@ option(WITH_ZMQ "Compile with cppzmq" OFF)
option(WITH_CRASHPAD "Compile with crashpad" OFF)
option(WITH_DISCORD_RPC "Compile with DISCORD RICH PRESENCE" ON)
option(USE_LTO "Use link-time optimization" OFF)
option(WITH_TESTS "Build and run the unit tests with the game, a failing test fails the build" ON)
option(WITH_BETTER_RENDERER "Experimental multi-backend renderer based on NVRHI" OFF)
option(GENERATE_PDB "Generate executable with program debugging symbols" ON)
option(ENABLE_MCC "Enable multicore compilation" ON)
@@ -119,15 +120,10 @@ set(SOURCES
"vehicle/Gauge.cpp"
"utilities/Globals.cpp"
"utilities/Logs.cpp"
"McZapkie/friction.cpp"
"McZapkie/hamulce.cpp"
"McZapkie/Mover.cpp"
"McZapkie/Oerlikon_ESt.cpp"
"model/MdlMngr.cpp"
"world/MemCell.cpp"
"model/Model3d.cpp"
"world/mtable.cpp"
"utilities/parser.cpp"
"rendering/nullrenderer.cpp"
"rendering/renderer.cpp"
"model/ResourceManager.cpp"
@@ -161,7 +157,6 @@ set(SOURCES
"audio/audio.cpp"
"audio/audiorenderer.cpp"
"utilities/motiontelemetry.cpp"
"utilities/utilities.cpp"
"application/uitranscripts.cpp"
"world/station.cpp"
"application/application.cpp"
@@ -225,7 +220,6 @@ set(SOURCES
"utilities/dictionary.cpp"
"rendering/particles.cpp"
"utilities/headtrack.cpp"
"scripting/ladderlogic.cpp"
"rendering/geometrybank.cpp"
"simulation/simulationsounds.cpp"
"rendering/openglcamera.cpp"
@@ -454,11 +448,34 @@ if (${CMAKE_CXX_COMPILER_ID} STREQUAL MSVC)
add_compile_options("/utf-8")
endif()
# the build settings of the game: include dirs, definitions, compile options and libraries. the game,
# its core library and the unit tests all build with the same ones
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
set(CORE_SOURCES
"McZapkie/friction.cpp"
"McZapkie/hamulce.cpp"
"McZapkie/Mover.cpp"
"McZapkie/Oerlikon_ESt.cpp"
"utilities/parser.cpp"
"utilities/utilities.cpp"
"scripting/ladderlogic.cpp"
"vehicle/handover.cpp"
)
add_library(eu07_core STATIC ${CORE_SOURCES})
target_link_libraries(eu07_core PUBLIC eu07_settings)
set_target_properties(eu07_core PROPERTIES
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib"
FOLDER "libraries")
add_executable(${PROJECT_NAME} ${SOURCES} ${HEADERS})
target_link_libraries(${PROJECT_NAME} eu07_core)
target_compile_definitions(${PROJECT_NAME} PRIVATE ${DEFINITIONS})
target_compile_definitions(eu07_settings INTERFACE ${DEFINITIONS})
target_include_directories(${PROJECT_NAME} PRIVATE
target_include_directories(eu07_settings INTERFACE
"."
"Console"
"McZapkie"
@@ -479,7 +496,7 @@ if (${CMAKE_CXX_COMPILER_ID} STREQUAL MSVC)
# /wd4996: disable "deprecation" warnings
# /wd4244: disable warnings for conversion with possible loss of data
# /wd5033: disable because it is all over Python headers
set_target_properties(${PROJECT_NAME} PROPERTIES COMPILE_FLAGS "/wd4996 /wd4244 /wd5033")
target_compile_options(eu07_settings INTERFACE /wd4996 /wd4244 /wd5033)
set_target_properties(${PROJECT_NAME} PROPERTIES LINK_FLAGS "/LARGEADDRESSAWARE")
endif()
@@ -495,7 +512,7 @@ set_target_properties( ${PROJECT_NAME}
check_cxx_symbol_exists(__GNUC__ "" COMPILER_HAVE_GNUC)
if (COMPILER_HAVE_GNUC)
set_target_properties(${PROJECT_NAME} PROPERTIES COMPILE_FLAGS "-fvisibility=hidden")
target_compile_options(eu07_settings INTERFACE -fvisibility=hidden)
endif()
if(NOT ${CMAKE_VERSION} VERSION_LESS "3.16.0")
@@ -503,6 +520,7 @@ if(NOT ${CMAKE_VERSION} VERSION_LESS "3.16.0")
if (USE_PCH)
target_precompile_headers(${PROJECT_NAME} PRIVATE "$<$<COMPILE_LANGUAGE:CXX>:${CMAKE_CURRENT_SOURCE_DIR}/stdafx.h>")
target_precompile_headers(eu07_core PRIVATE "$<$<COMPILE_LANGUAGE:CXX>:${CMAKE_CURRENT_SOURCE_DIR}/stdafx.h>")
set_source_files_properties("/glad/src/glad.c" "ref/dds-ktx/src/dds-ktx.c" "ref/zstd/zstd.c" "stb/stb_image.c"
"imgui/imgui.cpp" "imgui/imgui_demo.cpp" "imgui/imgui_draw.cpp" "imgui/imgui_widgets.cpp" "imgui/imgui_tables.cpp" "imgui/imgui_impl_glfw.cpp" "imgui/ImGuizmo.cpp"
PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE)
@@ -510,12 +528,12 @@ if(NOT ${CMAKE_VERSION} VERSION_LESS "3.16.0")
endif()
if (USE_LTO)
set_property(TARGET ${PROJECT_NAME} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
set_property(TARGET ${PROJECT_NAME} eu07_core PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
endif()
if (WITH_OPENVR)
target_include_directories(${PROJECT_NAME} PRIVATE ${OPENVR_INCLUDE_DIR})
target_link_libraries(${PROJECT_NAME} ${OPENVR_LIBRARY})
target_include_directories(eu07_settings INTERFACE ${OPENVR_INCLUDE_DIR})
target_link_libraries(eu07_settings INTERFACE ${OPENVR_LIBRARY})
endif()
if (WITH_CRASHPAD)
@@ -530,12 +548,12 @@ if (WITH_CRASHPAD)
endif()
endif()
target_link_libraries(${PROJECT_NAME} glfw)
target_link_libraries(eu07_settings INTERFACE glfw)
if(WITH_PYTHON)
find_package(Python3 3.14 REQUIRED COMPONENTS Development)
target_include_directories(${PROJECT_NAME} PRIVATE ${Python3_INCLUDE_DIRS})
target_link_libraries(${PROJECT_NAME} ${Python3_LIBRARIES})
target_include_directories(eu07_settings INTERFACE ${Python3_INCLUDE_DIRS})
target_link_libraries(eu07_settings INTERFACE ${Python3_LIBRARIES})
if(WIN32)
# Python3_ROOT_DIR is only an input hint and is not populated by
@@ -553,78 +571,78 @@ endif()
if (NOT WIN32)
find_package(PNG 1.6 REQUIRED)
target_link_libraries(${PROJECT_NAME} PNG::PNG)
target_link_libraries(eu07_settings INTERFACE PNG::PNG)
else()
include_directories(${PNG_INCLUDE_DIRS})
target_link_libraries(${PROJECT_NAME} ${PNG_LIBRARIES})
target_link_libraries(eu07_settings INTERFACE ${PNG_LIBRARIES})
endif()
find_package(Threads REQUIRED)
target_link_libraries(${PROJECT_NAME} Threads::Threads)
target_link_libraries(eu07_settings INTERFACE Threads::Threads)
find_package(GLM REQUIRED)
target_include_directories(${PROJECT_NAME} PRIVATE ${GLM_INCLUDE_DIR})
target_include_directories(eu07_settings INTERFACE ${GLM_INCLUDE_DIR})
# JSON lib
add_subdirectory(ref/json)
target_link_libraries(${PROJECT_NAME} nlohmann_json::nlohmann_json)
target_include_directories(${PROJECT_NAME} PRIVATE ${NLOHMANN_JSON_INCLUDE_INSTALL_DIR})
target_link_libraries(eu07_settings INTERFACE nlohmann_json::nlohmann_json)
target_include_directories(eu07_settings INTERFACE ${NLOHMANN_JSON_INCLUDE_INSTALL_DIR})
if (WITH_DISCORD_RPC)
add_subdirectory(ref/discord-rpc)
set_target_properties(discord-rpc PROPERTIES FOLDER "libraries")
target_link_libraries(${PROJECT_NAME} discord-rpc)
target_link_libraries(eu07_settings INTERFACE discord-rpc)
# Add the include directory so the compiler can find discord_rpc.h
target_include_directories(${PROJECT_NAME} PUBLIC
target_include_directories(eu07_settings INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/ref/discord-rpc/include"
)
target_compile_definitions(${PROJECT_NAME} PUBLIC WITH_DISCORD_RPC=1)
target_compile_definitions(eu07_settings INTERFACE WITH_DISCORD_RPC=1)
else()
target_compile_definitions(${PROJECT_NAME} PUBLIC WITH_DISCORD_RPC=0)
target_compile_definitions(eu07_settings INTERFACE WITH_DISCORD_RPC=0)
endif()
find_package(OpenAL REQUIRED)
if (TARGET OpenAL::OpenAL)
target_link_libraries(${PROJECT_NAME} OpenAL::OpenAL)
target_link_libraries(eu07_settings INTERFACE OpenAL::OpenAL)
else()
target_include_directories(${PROJECT_NAME} PRIVATE ${OPENAL_INCLUDE_DIR})
target_link_libraries(${PROJECT_NAME} ${OPENAL_LIBRARY})
target_include_directories(eu07_settings INTERFACE ${OPENAL_INCLUDE_DIR})
target_link_libraries(eu07_settings INTERFACE ${OPENAL_LIBRARY})
endif()
find_package(SndFile REQUIRED)
target_link_libraries(${PROJECT_NAME} SndFile::sndfile)
target_link_libraries(eu07_settings INTERFACE SndFile::sndfile)
if (WITH_LUA)
find_package(LuaJIT REQUIRED)
target_include_directories(${PROJECT_NAME} PRIVATE ${LUAJIT_INCLUDE_DIR})
target_link_libraries(${PROJECT_NAME} ${LUAJIT_LIBRARIES})
target_include_directories(eu07_settings INTERFACE ${LUAJIT_INCLUDE_DIR})
target_link_libraries(eu07_settings INTERFACE ${LUAJIT_LIBRARIES})
endif()
if (WITH_UART)
find_package(libserialport REQUIRED)
target_include_directories(${PROJECT_NAME} PRIVATE ${libserialport_INCLUDE_DIR})
target_link_libraries(${PROJECT_NAME} ${libserialport_LIBRARY})
target_include_directories(eu07_settings INTERFACE ${libserialport_INCLUDE_DIR})
target_link_libraries(eu07_settings INTERFACE ${libserialport_LIBRARY})
endif()
find_package(ASIO REQUIRED)
target_link_libraries(${PROJECT_NAME} asio::asio)
target_link_libraries(eu07_settings INTERFACE asio::asio)
if (WIN32)
target_link_libraries(${PROJECT_NAME} ws2_32)
target_link_libraries(eu07_settings INTERFACE ws2_32)
endif()
# http client of the editor's geoportal orthophoto layer: WinHTTP on Windows, libcurl elsewhere when available
if (WIN32)
target_link_libraries(${PROJECT_NAME} winhttp)
target_link_libraries(eu07_settings INTERFACE winhttp)
else()
find_package(CURL QUIET)
if (CURL_FOUND)
target_link_libraries(${PROJECT_NAME} CURL::libcurl)
target_compile_definitions(${PROJECT_NAME} PRIVATE EU07_WITH_CURL)
target_link_libraries(eu07_settings INTERFACE CURL::libcurl)
target_compile_definitions(eu07_settings INTERFACE EU07_WITH_CURL)
else()
message(STATUS "libcurl not found: the editor's orthophoto layer will only show cached tiles")
endif()
@@ -632,8 +650,8 @@ endif()
if (WITH_ZMQ)
find_package(cppzmq REQUIRED)
target_include_directories(${PROJECT_NAME} PRIVATE ${cppzmq_INCLUDE_DIR})
target_link_libraries(${PROJECT_NAME} ${cppzmq_LIBRARY})
target_include_directories(eu07_settings INTERFACE ${cppzmq_INCLUDE_DIR})
target_link_libraries(eu07_settings INTERFACE ${cppzmq_LIBRARY})
endif()
if (WIN32)
@@ -665,7 +683,7 @@ endif ()
if (NOT TARGET yaml-cpp)
add_subdirectory("betterRenderer/thirdparty/yaml-cpp" "${CMAKE_CURRENT_BINARY_DIR}/yaml-cpp" EXCLUDE_FROM_ALL)
endif ()
target_link_libraries(${PROJECT_NAME} yaml-cpp)
target_link_libraries(eu07_settings INTERFACE yaml-cpp)
# Enable compiling with debug symbols
@@ -680,7 +698,7 @@ if(GENERATE_PDB)
add_link_options(/DEBUG /MAP)
# Ensure that the PCH is built with the correct runtime library for each configuration
set_target_properties(${PROJECT_NAME} PROPERTIES
set_target_properties(${PROJECT_NAME} eu07_core PROPERTIES
COMPILE_OPTIONS "$<$<CONFIG:Debug>:/MDd>$<$<CONFIG:Release>:/MD>")
else()
# GCC/Clang (ensure -g is set for all configurations)
@@ -696,8 +714,27 @@ endif()
if(ENABLE_AVX2)
message(STATUS "Enabling AVX2 instruction set.")
if (MSVC)
target_compile_options(${PROJECT_NAME} PRIVATE /arch:AVX2)
target_compile_options(eu07_settings INTERFACE /arch:AVX2)
else()
target_compile_options(${PROJECT_NAME} PRIVATE -mavx2)
target_compile_options(eu07_settings INTERFACE -mavx2)
endif()
endif()
if(WITH_TESTS)
enable_testing()
add_subdirectory(tests/unit)
# the unit tests run at the end of building the game, once it's linked: a failing one fails the build
# (and removes the game binary, so the next build links it again and reruns them)
add_dependencies(${PROJECT_NAME} eu07_unit_tests)
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DTESTS=$<TARGET_FILE:eu07_unit_tests>
-DGAME=$<TARGET_FILE:${PROJECT_NAME}>
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/run_tests.cmake"
COMMENT "Running the unit tests"
VERBATIM)
# a change of the tests alone links the game again, so they run (Ninja and Makefile generators;
# Visual Studio builds rerun them with the next change of the game)
set_property(TARGET ${PROJECT_NAME} APPEND PROPERTY LINK_DEPENDS
$<TARGET_FILE:eu07_unit_tests> ${UNIT_TEST_DATA} "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/run_tests.cmake")
endif()

View File

@@ -13,6 +13,8 @@ http://mozilla.org/MPL/2.0/.
#include <map>
#include "hamulce.h"
#include "scripting/ladderlogic.h"
class cParser;
/*
MaSzyna EU07 locomotive simulator
Copyright (C) 2001-2004 Maciej Czapkiewicz and others
@@ -2534,10 +2536,12 @@ class TMoverParameters
/*funkcje ladujace pliki opisujace pojazd*/
bool LoadFIZ(std::string const &chkpath); // Q 20160717 bool LoadChkFile(std::string chkpath);
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;
private:
bool LoadFIZ_Parse(cParser &Parser); // reads the definition from either of the above
void LoadFIZ_Param(std::string const &line);
void LoadFIZ_Load(std::string const &line);
void LoadFIZ_Dimensions(std::string const &line);

View File

@@ -9559,6 +9559,37 @@ bool TMoverParameters::LoadFIZ(std::string const &chkpath)
chkPath = chkpath; // assign class path for reloading
ConversionError = 666;
std::string file = TypeName + ".fiz";
WriteLog("LOAD FIZ FROM " + file);
/*
std::ifstream in(file);
if (!in.is_open())
{
WriteLog("E8 - FIZ FILE NOT EXIST.");
return false;
}
*/
cParser fizparser(file, cParser::buffer_FILE, chkpath);
if (false == fizparser.ok())
{
WriteLog("E8 - FIZ FILE NOT EXIST.");
return false;
}
return LoadFIZ_Parse(fizparser);
}
// reads the vehicle definition from text in the .fiz format, e.g. one made up by a unit test
bool TMoverParameters::LoadFIZ_Text(std::string const &Text)
{
ConversionError = 666;
cParser fizparser(Text, cParser::buffer_TEXT);
return LoadFIZ_Parse(fizparser);
}
bool TMoverParameters::LoadFIZ_Parse(cParser &fizparser) // NOSONAR the former body of LoadFIZ(), moved as it was; splitting it up is a change of its own
{
LISTLINE = 0;
startBPT = false;
startMPT = false;
@@ -9577,23 +9608,6 @@ bool TMoverParameters::LoadFIZ(std::string const &chkpath)
startDimmerList = false;
startLIGHTSLIST = false;
startCOMPRESSORLIST = false;
std::string file = TypeName + ".fiz";
WriteLog("LOAD FIZ FROM " + file);
/*
std::ifstream in(file);
if (!in.is_open())
{
WriteLog("E8 - FIZ FILE NOT EXIST.");
return false;
}
*/
cParser fizparser(file, cParser::buffer_FILE, chkpath);
if (false == fizparser.ok())
{
WriteLog("E8 - FIZ FILE NOT EXIST.");
return false;
}
ConversionError = 0;

21
ref/doctest/LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016-2023 Viktor Kirilov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

7106
ref/doctest/doctest.h Normal file

File diff suppressed because it is too large Load Diff

31
tests/unit/CMakeLists.txt Normal file
View File

@@ -0,0 +1,31 @@
# Unit tests of the simulation, built and run without the game data: the test vehicles are JSON
# definitions in data/ (see vehicle.h), loaded from memory by the game's own vehicle loader. The code
# under test is the game's core library (eu07_core), the very objects the game links; the rest of the
# engine it reaches for is replaced with stubs (engine_stubs.cpp). They run as part of the build,
# from the IDE, or with ctest.
set(UNIT_TESTS_NAME eu07_unit_tests)
# a new test_*.cpp file or data/*.json definition is picked up on its own
file(GLOB UNIT_TEST_CASES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/test_*.cpp")
file(GLOB UNIT_TEST_DATA CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/data/*.json")
add_executable(${UNIT_TESTS_NAME}
main.cpp
vehicle.cpp
engine_stubs.cpp
${UNIT_TEST_CASES})
# the core library brings the game's build settings along
target_link_libraries(${UNIT_TESTS_NAME} PRIVATE eu07_core)
target_include_directories(${UNIT_TESTS_NAME} PRIVATE "${DEPS_DIR}/doctest")
target_compile_definitions(${UNIT_TESTS_NAME} PRIVATE EU07_UNIT_TESTS_DATA="${CMAKE_CURRENT_SOURCE_DIR}/data")
if (MSVC)
target_compile_options(${UNIT_TESTS_NAME} PRIVATE /bigobj)
endif()
set_target_properties(${UNIT_TESTS_NAME} PROPERTIES FOLDER "tests")
add_test(NAME ${UNIT_TESTS_NAME} COMMAND ${UNIT_TESTS_NAME})
# for running the tests at the end of building the game, see the main CMakeLists.txt
set(UNIT_TEST_DATA ${UNIT_TEST_DATA} PARENT_SCOPE)

71
tests/unit/README.md Normal file
View File

@@ -0,0 +1,71 @@
# Unit tests
C++ tests of the simulation logic: set up the conditions, run the code, check the outcome. They run
as part of every build of the game, in CI and locally alike, and from the IDE. They need no game
data and no window: the test vehicles are JSON definitions in `data/`, loaded from memory by the
game's own vehicle loader.
The code under test is `eu07_core`, the library of the simulation parts the game links as well
(physics, vehicle definition loader, ...): built once, with the game's settings, and tested as the
game uses it. The python library gets linked along with the game's settings, but the tests don't
use it and run without it.
## Running
- **With the build**: building the game (`WITH_TESTS`, on by default) runs them at the end, once the
game is linked, and prints `Unit tests: 11/11 passed (80 checks)`. A failing test prints the full
report and fails the build; the game binary gets removed, so the next build links it again and
reruns them. They also run when only the tests, a definition in `data/` or `run_tests.cmake`
changed (Ninja and Makefile generators). `-DWITH_TESTS=OFF` builds the game without them.
- **From the IDE**: run or debug the `eu07_unit_tests` target. CLion recognises doctest and lists the
test cases, so a single one can be run or debugged from the gutter.
- **By hand**: `ctest --test-dir <build dir> --build-config Release`, or the binary itself, which takes
[doctest options](https://github.com/doctest/doctest/blob/master/doc/markdown/commandline.md), e.g.
`eu07_unit_tests -tc="brakes*"`. `EU07_UNIT_TESTS_LOG=1` shows the engine log, quiet otherwise.
## Writing a test
Add a `test_<topic>.cpp` here, it's picked up on its own.
```cpp
#include "stdafx.h"
#include "doctest.h"
#include "vehicle.h"
TEST_CASE("brakes: service braking stops a heavier wagon too") {
// conditions: the wagon from data/wagon.json, heavier, running at 40 km/h
test::vehicle wagon{test::definition::load("wagon").set("Param", "M", 60000), 40.0 /* km/h */};
// action: full service application, as a coupled locomotive would make it
wagon.hold_brake_pipe(3.5); // bar
wagon.run(40.0 /* s */, test::track{0.15 /* friction */, 0.0 /* gradient */});
// outcome
CHECK(std::abs(wagon.speed()) < 0.1);
}
```
- `definition::load("<name>")` reads `data/<name>.json`, with the sections and keys of the game's
vehicle definitions; `set()` and `remove()` change it for the test. Table sections give their
rows as `"rows": [[...], ...]`, keys starting with `_` are comments. A section the loader doesn't
know is an error (the table of them is in `vehicle.cpp`); a misspelt key isn't caught, so check
in the test that the value the test is about made it to the vehicle. Keep the definitions
physically sensible: some parts need keys others don't (the ESt brake valves need `Brake.Size`),
and a test on a half-defined vehicle tests the definition rather than the simulation.
- The random numbers of the simulation are reseeded before every test case, so a test gives the
same result alone or among the others.
- Logic which lives in classes too big to set up in a test (the AI driver, the cab) goes into free
functions on the vehicle state the class calls, and the test calls them the same way: see
`vehicle/handover.h`, which `TController::TakeControl()` uses, and `test_handover.cpp`.
- `vehicle` creates the vehicle the way `TDynamicObject::Init()` does and steps it the way the
simulation loop does, on a straight track of the given friction and gradient. Coupled vehicles
aren't supported yet.
- Check behaviour (speeds, pressures, states the player sees) rather than internal flags, and make
the test check its own preconditions (`REQUIRE` that the braking skidded, before checking what a
skid leads to).
## Code to test, and when the tests fail to link
A source joins the tested code by moving from `SOURCES` to `CORE_SOURCES` in the main
`CMakeLists.txt`; the game links it from the library from then on. The library reaches for a little
of the rest of the engine, which `engine_stubs.cpp` stands in for in the tests (logs, settings,
scenery layers). If a change makes it use something new, either move the real source into the
library too, when it stands on its own, or add a stub to `engine_stubs.cpp`.

View File

@@ -0,0 +1,34 @@
{
"_comment": "The wagon with the cab devices the driver uses: sandbox, light preset selector and modern headlight dimmer, both tables made after the Vectron's. No traction: enough for the logic of the cab controls.",
"Param": { "Category": "train", "M": 42200, "Vmax": 120, "SandCap": 500 },
"Dimensions": { "L": 26.8, "H": 4.7, "W": 2.88, "Cx": 0.15 },
"Wheels": { "D": 0.96, "Tw": 1.435, "Axle": "2'2'", "Ad": 2.5, "Bd": 19.5, "Rmin": 80, "BearingType": "Roll" },
"Brake": {
"BrakeValve": "KE", "NBpA": 6, "MBF": 15, "MaxBP": 3.8, "TareMaxBP": 2.3,
"BCN": 12, "BCR": 0.127, "BCD": 0.04, "BCM": 1.5, "BCS": 1.2, "BSA": 0, "BM": "Disk1", "BVV": 125
},
"BuffCoupl": { "CType": "Screw", "kB": 9200, "DmaxB": 0.11, "FmaxB": 320, "kC": 4500, "DmaxC": 0.1, "FmaxC": 850, "beta": 0.58, "AllowedFlag": 115 },
"Cntrl": { "BrakeSystem": "Pneumatic", "LocalBrake": "ManualBrake", "BrakeDelays": "P", "MaxBPMass": 63 },
"Switches": { "ModernDimmer": "Yes" },
"_lights": "light bits: 1 headlight left, 2 red marker left, 4 headlight upper, 16 headlight right, 32 red marker right",
"LightsList": {
"Size": 4, "Wrap": "No", "Default": 1,
"rows": [
[0, 0],
[21, 34],
[34, 0],
[17, 0]
]
},
"_dimmer": "rows: high beam, dimmed, off",
"DimmerList": {
"Size": 5, "Cycle": "no", "DefaultPos": 3,
"rows": [
["yes", "no", "no"],
["yes", "yes", "no"],
["no", "no", "yes"],
["no", "no", "no"],
["no", "yes", "no"]
]
}
}

View File

@@ -0,0 +1,12 @@
{
"_comment": "Passenger wagon with a KE distributor valve, made after a real one so the defaults make physical sense. Sections and keys are those of the game's vehicle definitions; tests change only the keys they're about.",
"Param": { "Category": "train", "M": 42200, "Vmax": 120 },
"Dimensions": { "L": 26.8, "H": 4.7, "W": 2.88, "Cx": 0.15 },
"Wheels": { "D": 0.96, "Tw": 1.435, "Axle": "2'2'", "Ad": 2.5, "Bd": 19.5, "Rmin": 80, "BearingType": "Roll" },
"Brake": {
"BrakeValve": "KE", "NBpA": 6, "MBF": 15, "MaxBP": 3.8, "TareMaxBP": 2.3,
"BCN": 12, "BCR": 0.127, "BCD": 0.04, "BCM": 1.5, "BCS": 1.2, "BSA": 0, "BM": "Disk1", "BVV": 125
},
"BuffCoupl": { "CType": "Screw", "kB": 9200, "DmaxB": 0.11, "FmaxB": 320, "kC": 4500, "DmaxC": 0.1, "FmaxC": 850, "beta": 0.58, "AllowedFlag": 115 },
"Cntrl": { "BrakeSystem": "Pneumatic", "LocalBrake": "ManualBrake", "BrakeDelays": "P", "MaxBPMass": 63 }
}

View File

@@ -0,0 +1,81 @@
// stand-ins for the parts of the engine which the core library (eu07_core) links against, but which the
// unit tests don't exercise. when a change makes the library use something new from the engine and the
// test build fails to link, either move the real source into the library (CORE_SOURCES in the main
// CMakeLists.txt, if it stands on its own) or add a stub here.
#include "stdafx.h"
#include "utilities/Globals.h"
#include "utilities/Logs.h"
#include "scene/scenenodegroups.h"
#include "scene/scenelayers.h"
#include <cstdlib>
#include <iostream>
// settings ---------------------------------------------------------------------------------------
global_settings &GetGlobalSettings() {
// defaults, as before reading eu07.ini
static global_settings settings;
return settings;
}
// logs: quiet, unless EU07_UNIT_TESTS_LOG is set --------------------------------------------------
namespace {
bool log_enabled() {
static bool const enabled{std::getenv("EU07_UNIT_TESTS_LOG") != nullptr};
return enabled;
}
} // namespace
void WriteLog(const char *str, logtype const, bool const isError) {
if (log_enabled() && str != nullptr) {
(isError ? std::cerr : std::cout) << str << "\n";
}
}
void WriteLog(const std::string &str, logtype const Type) {
WriteLog(str.c_str(), Type, false);
}
void ErrorLog(const std::string &str, logtype const Type) {
WriteLog(str.c_str(), Type, true);
}
// scenery structure, which the parser reports include directives to --------------------------------
// the declarations come from the engine's headers, the stubs can't change them (hence the NOSONARs)
namespace scene {
node_groups Groups; // NOSONAR the engine's extern global, scene/scenenodegroups.h
node_layers Layers; // NOSONAR the engine's extern global, scene/scenelayers.h
group_handle node_groups::create(bool const) { // NOSONAR signature from scene/scenenodegroups.h
return null_handle;
}
group_handle node_groups::close() { // NOSONAR signature from scene/scenenodegroups.h
return null_handle;
}
layer_handle node_layers::open(std::string const &, include_site) { // NOSONAR signature from scene/scenelayers.h
return null_handle;
}
layer_handle node_layers::close() { // NOSONAR signature from scene/scenelayers.h
return null_handle;
}
void node_layers::instance_begin(std::string const &, source_span const &) { // NOSONAR signature from scene/scenelayers.h
// no layers in the tests, so nothing to keep track of
}
// basic_node has no key function for the Itanium C++ ABI (tooltip() is declared plain but defined inline), so
// GCC and Clang emit its vtable wherever scenenode.h is used, here too, and the vtable wants radius_().
// the same as the game's, in scene/scenenode.cpp
float basic_node::radius_() {
return 0.f;
}
} // namespace scene

40
tests/unit/main.cpp Normal file
View File

@@ -0,0 +1,40 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "stdafx.h"
#include "doctest.h"
#include "utilities/Globals.h"
namespace {
// the simulation draws random numbers (brake delays, pressures, wheel damage) from engines shared by
// everything. reseeding them before every run of a test case, subcases included, gives each test the
// same numbers whether it runs alone from the IDE or among all the others
class deterministic_random : public doctest::IReporter {
public:
explicit deterministic_random(doctest::ContextOptions const &) {}
void test_case_start(doctest::TestCaseData const &) override { reseed(); }
void test_case_reenter(doctest::TestCaseData const &) override { reseed(); }
// the rest of doctest's reporter interface: a listener reports nothing, the console reporter does that
void report_query(doctest::QueryData const &) override { /* nothing to report */ }
void test_run_start() override { /* nothing to report */ }
void test_run_end(doctest::TestRunStats const &) override { /* nothing to report */ }
void test_case_end(doctest::CurrentTestCaseStats const &) override { /* nothing to report */ }
void test_case_exception(doctest::TestCaseException const &) override { /* nothing to report */ }
void subcase_start(doctest::SubcaseSignature const &) override { /* nothing to report */ }
void subcase_end() override { /* nothing to report */ }
void log_assert(doctest::AssertData const &) override { /* nothing to report */ }
void log_message(doctest::MessageData const &) override { /* nothing to report */ }
void test_case_skipped(doctest::TestCaseData const &) override { /* nothing to report */ }
private:
static void reseed() {
Global.random_engine.seed(1);
Global.local_random_engine.seed(1);
}
};
} // namespace
DOCTEST_REGISTER_LISTENER("deterministic_random", 1, deterministic_random);

View File

@@ -0,0 +1,27 @@
# runs the unit tests at the end of building the game: cmake -DTESTS=<test binary> -DGAME=<game binary> -P run_tests.cmake
# prints a one line summary when they pass, the full report when they don't. a failure fails the build and
# removes the game binary, so the next build links it again and reruns the tests
execute_process(
COMMAND "${TESTS}" --no-intro --no-version
RESULT_VARIABLE result
OUTPUT_VARIABLE report
ERROR_VARIABLE report)
string(REGEX MATCH "test cases: *([0-9]+) *\\| *([0-9]+) passed *\\| *([0-9]+) failed" _ "${report}")
set(cases "${CMAKE_MATCH_1}")
set(passed "${CMAKE_MATCH_2}")
set(failed "${CMAKE_MATCH_3}")
string(REGEX MATCH "assertions: *([0-9]+)" _ "${report}")
set(checks "${CMAKE_MATCH_1}")
if(result EQUAL 0)
message("Unit tests: ${passed}/${cases} passed (${checks} checks)")
else()
message("${report}")
file(REMOVE "${GAME}")
if(cases STREQUAL "")
message(FATAL_ERROR "Unit tests: didn't run (${result}), see above. -DWITH_TESTS=OFF builds the game without them")
endif()
message(FATAL_ERROR "Unit tests: ${failed} of ${cases} FAILED, see above. -DWITH_TESTS=OFF builds the game without them")
endif()

View File

@@ -0,0 +1,93 @@
#include "stdafx.h"
#include "doctest.h"
#include "vehicle.h"
using test::definition;
using test::vehicle;
// the brake pipe of a lone wagon is held at a pressure the way a coupled locomotive would hold it.
// pressures are in bar, the brake pipe runs at 5.0, a 1.5 bar reduction is a full service application.
TEST_CASE("brakes: KE wagon applies up to its tare limit on a service application and releases again") {
vehicle wagon{definition::load("wagon")}; // empty wagon, TareMaxBP=2.3
wagon.hold_brake_pipe(5.0);
wagon.run(60.0);
CHECK(wagon->BrakePress < 0.1);
CHECK(wagon->Hamulec->GetBRP() > 4.5); // auxiliary reservoir charged
wagon.hold_brake_pipe(3.5);
wagon.run(10.0);
CHECK(wagon->BrakePress == doctest::Approx(2.3).epsilon(0.05));
wagon.hold_brake_pipe(5.0);
wagon.run(60.0);
CHECK(wagon->BrakePress < 0.1);
}
TEST_CASE("brakes: a deeper brake pipe reduction brakes harder") {
auto applied = [](double Pipe) {
vehicle wagon{definition::load("wagon").set("Brake", "TareMaxBP", 3.8)}; // no tare limit in the way
wagon.hold_brake_pipe(5.0);
wagon.run(60.0);
wagon.hold_brake_pipe(Pipe);
wagon.run(20.0);
return wagon->BrakePress;
};
auto const light{applied(4.6)};
auto const medium{applied(4.2)};
auto const full{applied(3.5)};
CHECK(light > 0.1);
CHECK(light < medium);
CHECK(medium < full);
CHECK(full <= 3.8 + 0.1); // MaxBP
}
// every distributor valve the engine implements goes through the same cycle; the ESt family needs the size
// of its reservoirs, as in the real .fiz files using it
TEST_CASE("brakes: every implemented distributor valve charges, applies and releases") {
for (auto const *valve : {"W", "K", "KE", "ESt", "ESt3", "NESt3", "ESt4", "ESt3AL2", "LSt", "EStED", "EP1", "EP2", "CV1"}) {
CAPTURE(valve);
vehicle wagon{definition::load("wagon").set("Brake", "BrakeValve", valve).set("Brake", "Size", 16)};
wagon.hold_brake_pipe(5.0);
wagon.run(90.0);
CHECK(wagon->BrakePress < 0.1);
wagon.hold_brake_pipe(3.5);
wagon.run(20.0);
CHECK(wagon->BrakePress > 1.0);
CHECK(wagon->BrakePress <= 3.8 + 0.1); // MaxBP
wagon.hold_brake_pipe(5.0);
wagon.run(90.0);
CHECK(wagon->BrakePress < 0.1);
}
}
TEST_CASE("brakes: service braking stops a wagon running at 60 km/h and keeps it stopped") {
vehicle wagon{definition::load("wagon"), 60.0};
wagon.hold_brake_pipe(5.0);
wagon.run(10.0);
REQUIRE(wagon.speed() > 50.0); // still running before the braking
wagon.hold_brake_pipe(3.5);
wagon.run(30.0);
CHECK(std::abs(wagon.speed()) < 0.1);
wagon.run(30.0);
CHECK(std::abs(wagon.speed()) < 0.1);
}
TEST_CASE("brakes: a released wagon rolls on") {
vehicle wagon{definition::load("wagon"), 60.0};
wagon.hold_brake_pipe(5.0);
wagon.run(30.0);
CHECK(wagon.speed() > 50.0);
CHECK(wagon.speed() < 60.0); // running resistance slows it down a little
}
// the example in README.md
TEST_CASE("brakes: service braking stops a heavier wagon too") {
// the default wagon, with the keys the test is about changed
test::vehicle wagon{test::definition::load("wagon").set("Param", "M", 60000), 40.0 /* km/h */};
wagon.hold_brake_pipe(3.5); // bar, as a coupled locomotive would hold it
wagon.run(40.0 /* s */, test::track{0.15 /* friction */, 0.0 /* gradient */});
CHECK(std::abs(wagon.speed()) < 0.1);
}

View File

@@ -0,0 +1,45 @@
#include "stdafx.h"
#include "doctest.h"
#include "vehicle.h"
using test::definition;
using test::vehicle;
TEST_CASE("definition: the wagon from data/wagon.json loads with its values") {
vehicle wagon{definition::load("wagon")};
CHECK(wagon->Mass == doctest::Approx(42200.0));
CHECK(wagon->Vmax == doctest::Approx(120.0));
CHECK(wagon->Dim.L == doctest::Approx(26.8));
CHECK(wagon->WheelDiameter == doctest::Approx(0.96));
CHECK(wagon->NAxles == 4);
CHECK(wagon->MaxBrakePress[3] == doctest::Approx(3.8));
CHECK(wagon->BrakeSystem == TBrakeSystem::Pneumatic);
}
TEST_CASE("definition: keys set by the test reach the vehicle") {
vehicle heavy{definition::load("wagon").set("Param", "M", 60000).set("Wheels", "D", 1.0)};
CHECK(heavy->Mass == doctest::Approx(60000.0));
CHECK(heavy->WheelDiameter == doctest::Approx(1.0));
}
TEST_CASE("definition: an unknown test vehicle is an error, not a silently empty one") {
CHECK_THROWS(definition::load("no such vehicle"));
}
TEST_CASE("definition: a misspelt section is an error, not silently skipped by the loader") {
CHECK_THROWS(definition::load("wagon").set("Brakes", "MBF", 30));
}
TEST_CASE("definition: rows of a table section reach the vehicle") {
auto const pc1{light::headlight_left | light::headlight_upper | light::headlight_right};
vehicle wagon{definition::load("wagon")
.set("LightsList", "Size", 2)
.set("LightsList", "Default", 1)
.set("LightsList", "rows", {{0, 0}, {pc1, 0}})};
CHECK(wagon->LightsPosNo == 2);
CHECK(wagon->LightsPos == 1);
CHECK(wagon->Lights[end::front][1] == pc1);
}

View File

@@ -0,0 +1,169 @@
#include "stdafx.h"
#include "doctest.h"
#include "vehicle.h"
#include "vehicle/handover.h"
using test::definition;
using test::vehicle;
// what the human driver gets when taking a vehicle over from the AI (Q key). the AI operates some controls
// directly and only the AI ever released them, so the player inherited e.g. a horn blowing forever.
namespace {
int const pc1{light::headlight_left | light::headlight_upper | light::headlight_right};
int const allheadlights{pc1 | light::redmarker_left | light::redmarker_right};
vehicle cab_vehicle() {
vehicle cab{definition::load("cab_vehicle")};
cab->DirActive = 1; // the AI drives: reverser set
return cab;
}
} // namespace
TEST_CASE("handover: the horn the AI was sounding goes quiet (eu07/maszyna#511)") {
auto cab{cab_vehicle()};
SUBCASE("low tone") { cab->WarningSignal = 1; }
SUBCASE("high tone") { cab->WarningSignal = 2; }
SUBCASE("both tones") { cab->WarningSignal = 1 | 2; }
REQUIRE(cab->WarningSignal != 0);
handover::release_ai_controls(cab.mover(), cab.mover());
CHECK(cab->WarningSignal == 0);
}
TEST_CASE("handover: sanding the AI started stops") {
auto cab{cab_vehicle()};
cab->Sandbox(true); // the way the AI sands, bypassing the sanding button
REQUIRE(cab->SandDose);
handover::release_ai_controls(cab.mover(), cab.mover());
CHECK_FALSE(cab->SandDose);
}
TEST_CASE("handover: sanding the player or the automatic system asked for goes on") {
auto cab{cab_vehicle()};
SUBCASE("sanding button held") { cab->SandboxManual(true); }
SUBCASE("automatic sanding") {
cab->SandboxAutoAllow(true);
cab->SandboxAuto(true);
}
REQUIRE(cab->SandDose);
handover::release_ai_controls(cab.mover(), cab.mover());
CHECK(cab->SandDose);
}
TEST_CASE("handover: the departure signal the AI was giving stops") {
auto cab{cab_vehicle()};
cab->signal_departure(true);
REQUIRE(cab->DepartureSignal);
handover::release_ai_controls(cab.mover(), cab.mover());
CHECK_FALSE(cab->DepartureSignal);
}
TEST_CASE("handover: the brake releaser the AI was holding lets go") {
auto cab{cab_vehicle()};
cab->BrakeReleaser(1);
REQUIRE(cab->Hamulec->Releaser());
handover::release_ai_controls(cab.mover(), cab.mover());
CHECK_FALSE(cab->Hamulec->Releaser());
}
TEST_CASE("handover: nothing the AI didn't latch changes") {
auto cab{cab_vehicle()};
handover::release_ai_controls(cab.mover(), cab.mover());
CHECK(cab->WarningSignal == 0);
CHECK_FALSE(cab->SandDose);
CHECK_FALSE(cab->DepartureSignal);
CHECK_FALSE(cab->Hamulec->Releaser());
}
TEST_CASE("handover: the light selector moves to the position giving the head lights the AI has set") {
auto cab{cab_vehicle()};
REQUIRE(cab->LightsPos == 1); // untouched selector: lights off
SUBCASE("Pc1") {
CHECK(handover::light_selector_position(cab.mover(), end::front, pc1, allheadlights) == 2);
}
SUBCASE("lights off") {
CHECK(handover::light_selector_position(cab.mover(), end::front, 0, allheadlights) == 1);
}
SUBCASE("vehicle without the upper headlight, which the AI's Pc1 can't light") {
auto const inventory{allheadlights & ~light::headlight_upper};
CHECK(handover::light_selector_position(cab.mover(), end::front, pc1 & inventory, inventory) == 2);
}
SUBCASE("no position matches: the selector stays") {
CHECK(handover::light_selector_position(cab.mover(), end::front, light::headlight_left, allheadlights) == 1);
}
}
TEST_CASE("handover: the light selector stays within its table") {
auto cab{cab_vehicle()};
SUBCASE("selector bigger than the table, as a vehicle definition can declare") {
// only the positions the table holds are looked at, the rest can't match
for (auto &table : cab->Lights) {
std::fill(std::begin(table) + cab->LightsPosNo, std::end(table), 0);
}
cab->LightsPosNo = 100;
CHECK(handover::light_selector_position(cab.mover(), end::front, pc1, allheadlights) == 2);
CHECK(handover::light_selector_position(cab.mover(), end::front, light::headlight_left, allheadlights) == 1);
}
SUBCASE("table other than the front or rear one: the selector stays") {
CHECK(handover::light_selector_position(cab.mover(), -1, pc1, allheadlights) == 1);
CHECK(handover::light_selector_position(cab.mover(), 2, pc1, allheadlights) == 1);
}
}
TEST_CASE("handover: the light selector already matching the lights stays") {
auto cab{cab_vehicle()};
cab->LightsPos = 2; // Pc1
CHECK(handover::light_selector_position(cab.mover(), end::front, pc1, allheadlights) == 2);
}
TEST_CASE("handover: the headlight dimmer leaves the off position when the head lights are lit") {
auto cab{cab_vehicle()};
REQUIRE(cab->enableModernDimmer);
REQUIRE(cab->dimPositions.size() == 5);
cab->modernDimmerPosition = 2; // off, left there by the player before the AI took over
SUBCASE("to the default position") {
CHECK(handover::dimmer_position(cab.mover(), true) == 3);
}
SUBCASE("to the first regular position, when the default one is off as well") {
cab->modernDimmerDefaultPosition = 2;
CHECK(handover::dimmer_position(cab.mover(), true) == 3);
}
SUBCASE("unless the head lights are off anyway") {
CHECK(handover::dimmer_position(cab.mover(), false) == 2);
}
}
TEST_CASE("handover: the headlight dimmer in any other position stays") {
auto cab{cab_vehicle()};
for (auto const position : {0, 1, 3, 4}) {
CAPTURE(position);
cab->modernDimmerPosition = position;
CHECK(handover::dimmer_position(cab.mover(), true) == position);
}
}
TEST_CASE("handover: a vehicle without the modern dimmer keeps its dimmer state") {
vehicle wagon{definition::load("wagon")};
REQUIRE_FALSE(wagon->enableModernDimmer);
CHECK(handover::dimmer_position(wagon.mover(), true) == wagon->modernDimmerPosition);
}

187
tests/unit/vehicle.cpp Normal file
View File

@@ -0,0 +1,187 @@
#include "stdafx.h"
#include "vehicle.h"
#include <fstream>
#include <stdexcept>
namespace test {
namespace {
struct section_format {
char separator; // the game's definitions end section names with either '.' or ':'
char const *table_end{nullptr}; // the line closing the rows of a table section
};
// the sections TMoverParameters::LoadFIZ() reads, see its issection() calls. a section missing here
// would be silently skipped by the loader, and the test would run on the defaults instead
std::map<std::string, section_format, std::less<>> const sections{
{"Param", {'.'}},
{"Load", {':'}},
{"Dimensions", {':'}},
{"Wheels", {':'}},
{"Brake", {':'}},
{"Doors", {':'}},
{"BuffCoupl", {'.'}},
{"BuffCoupl1", {'.'}},
{"BuffCoupl2", {'.'}},
{"TurboPos", {':'}},
{"Cntrl", {'.'}},
{"Blending", {':'}},
{"DCEMUED", {':'}},
{"SpringBrake", {':'}},
{"Light", {':'}},
{"Headlights", {':'}},
{"Clima", {':'}},
{"Power", {':'}},
{"SpeedControl", {':'}},
{"Engine", {':'}},
{"Switches", {':'}},
{"Security", {':'}},
{"Circuit", {':'}},
{"AI", {':'}},
{"LightsList", {':', "endL"}},
{"DimmerList", {':', "endDimmerList"}},
{"WiperList", {':', "endwl"}},
{"CompressorList", {':', "endCL"}},
};
section_format const &format_of(std::string const &Section) {
auto const lookup{sections.find(Section)};
if (lookup == sections.end()) {
throw definition_error("unknown vehicle definition section " + Section + ", see tests/unit/vehicle.cpp");
}
return lookup->second;
}
std::string value_text(nlohmann::ordered_json const &Value) {
if (Value.is_string()) {
return Value.get<std::string>();
}
if (Value.is_boolean()) {
return Value.get<bool>() ? "Yes" : "No";
}
return Value.dump();
}
// the rows of a table section, each a list of values, and the line closing them
std::string table_text(std::string const &Section, section_format const &Format, nlohmann::ordered_json const &Rows) {
if (Format.table_end == nullptr) {
throw definition_error("vehicle definition section " + Section + " has no rows");
}
std::string text;
for (auto const &row : Rows) {
for (auto const &value : row) {
text += value_text(value) + " ";
}
text += "\n";
}
return text + Format.table_end + "\n";
}
} // namespace
definition definition::load(std::string const &Name) {
auto const path{std::string{EU07_UNIT_TESTS_DATA} + "/" + Name + ".json"};
std::ifstream file(path);
if (false == file.is_open()) {
throw definition_error("no test vehicle definition " + path);
}
definition result;
// not brace-initialised: json{other} makes an array of other with GCC and Clang (the initializer list constructor wins)
auto const json = nlohmann::ordered_json::parse(file, nullptr, true, true);
if (false == json.is_object()) {
throw definition_error("test vehicle definition " + path + " isn't a JSON object");
}
for (auto const &[section, keys] : json.items()) {
// keys starting with _ are comments
if (section.starts_with('_')) {
continue;
}
format_of(section);
result.m_sections[section] = keys;
}
return result;
}
definition &definition::set(std::string const &Section, std::string const &Key, nlohmann::ordered_json const &Value) {
format_of(Section);
m_sections[Section][Key] = Value;
return *this;
}
definition &definition::remove(std::string const &Section) {
m_sections.erase(Section);
return *this;
}
definition &definition::remove(std::string const &Section, std::string const &Key) {
if (m_sections.contains(Section)) {
m_sections[Section].erase(Key);
}
return *this;
}
std::string definition::text() const {
std::string text;
for (auto const &[section, keys] : m_sections.items()) {
auto const &format{format_of(section)};
text += section + format.separator;
for (auto const &[key, value] : keys.items()) {
if (key != "rows") {
text += " " + key + "=" + value_text(value);
}
}
text += "\n";
if (keys.contains("rows")) {
text += table_text(section, format, keys["rows"]);
}
}
return text;
}
vehicle::vehicle(definition const &Definition, double Velocity, std::string const &Name) {
// the same steps as TDynamicObject::Init()
m_mover = std::make_unique<TMoverParameters>(Velocity, Name, Name, 0);
if (false == m_mover->LoadFIZ_Text(Definition.text())) {
throw definition_error("the test vehicle definition didn't load");
}
m_mover->AssignLoad("", 0.f);
m_mover->ComputeMass();
auto const driveractive{Velocity != 0.0};
if (false == m_mover->CheckLocomotiveParameters(driveractive, Velocity >= 0.0 ? 1 : -1)) {
throw definition_error("parameters mismatch in the test vehicle definition");
}
m_mover->MainCtrlPos = m_mover->MainCtrlNoPowerPos();
m_mover->LocalBrakePosA = 0.0;
m_mover->BrakeCtrlPos = static_cast<int>(std::floor(m_mover->Handle->GetPos(driveractive ? bh_RP : bh_NP)));
m_mover->BrakeLevelSet(m_mover->BrakeCtrlPos);
}
void vehicle::step(track const &Track, double Dt) {
TTrackShape shape;
shape.Len = 1000.0;
shape.dHtrack = Track.gradient;
TTrackParam param;
param.Width = 1.435;
param.friction = Track.friction;
param.CategoryFlag = 1;
TTractionParam const traction;
TLocation const location{0.0, 0.0, 0.0};
TRotation const rotation{0.0, 0.0, 0.0};
if (m_pipepressure >= 0.0) {
m_mover->Pipe->CreatePress(m_pipepressure);
}
m_mover->ComputeTotalForce(Dt);
m_mover->ComputeMovement(Dt, Dt, shape, param, traction, location, rotation);
}
void vehicle::run(double Seconds, track const &Track, double Dt) {
// whole steps: summing up Dt would make the step count depend on the rounding
auto const steps{std::lround(Seconds / Dt)};
for (long i = 0; i < steps; ++i) {
step(Track, Dt);
}
}
} // namespace test

71
tests/unit/vehicle.h Normal file
View File

@@ -0,0 +1,71 @@
#pragma once
#include <memory>
#include <stdexcept>
#include <string>
#include <nlohmann/json.hpp>
#include "McZapkie/MOVER.h"
namespace test {
// thrown for a test vehicle definition which can't be used: missing, malformed, or not taken by the loader
class definition_error : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// definition of a test vehicle: one of the JSON files in tests/unit/data, with the sections and keys
// of the game's vehicle definitions, changed by the test where it needs to:
// auto heavy = definition::load("wagon").set("Param", "M", 60000).set("Brake", "BrakeValve", "ESt3");
class definition {
public:
// tests/unit/data/<Name>.json
static definition load(std::string const &Name);
// sets the key of the section, adding either one when it's missing
definition &set(std::string const &Section, std::string const &Key, nlohmann::ordered_json const &Value);
// drops the whole section, or one key of it
definition &remove(std::string const &Section);
definition &remove(std::string const &Section, std::string const &Key);
// the definition in the form the game's vehicle loader reads
std::string text() const;
private:
nlohmann::ordered_json m_sections;
};
// conditions the vehicle runs in: straight track of the given friction and gradient (rise/run)
struct track {
double friction{0.15};
double gradient{0.0};
};
// one vehicle created the way TDynamicObject::Init() creates it, from the given definition
class vehicle {
public:
explicit vehicle(definition const &Definition, double Velocity = 0.0, std::string const &Name = "test");
TMoverParameters &mover() { return *m_mover; }
TMoverParameters *operator->() { return m_mover.get(); }
// keeps the brake pipe at the given pressure [bar], as a locomotive coupled to the vehicle would;
// a negative value leaves the pipe alone
void hold_brake_pipe(double Pressure) { m_pipepressure = Pressure; }
// advances the physics by one step of Dt, the way the simulation loop does
void step(track const &Track = {}, double Dt = 0.01);
// advances the physics by Seconds in steps of Dt
void run(double Seconds, track const &Track = {}, double Dt = 0.01);
// vehicle speed in kilometres per hour, negative when moving backwards
double speed() const { return m_mover->V * 3.6; }
private:
std::unique_ptr<TMoverParameters> m_mover;
double m_pipepressure{-1.0};
};
} // namespace test

View File

@@ -19,6 +19,7 @@ http://mozilla.org/MPL/2.0/.
#include "utilities/translation.h"
#include "utilities/Logs.h"
#include "vehicle/Train.h"
#include "vehicle/handover.h"
#include "world/mtable.h"
#include "vehicle/DynObj.h"
#include "world/Event.h"
@@ -5561,6 +5562,12 @@ void TController::TakeControl( bool const Aidriver, bool const Forcevehiclecheck
{ // teraz AI prowadzi
AIControllFlag = AIdriver;
pVehicle->Controller = AIdriver;
// the AI doesn't operate the headlight dimmer, drop the state left by the human driver
// so the lights set by the AI aren't hidden (dimmer in off position) or dimmed
pVehicle->HeadlightsAoff = false;
pVehicle->HeadlightsBoff = false;
pVehicle->DimHeadlights = false;
pVehicle->HighBeamLights = false;
control_lights(); // reinicjalizacja swiatel
mvOccupied->CabActivisation(true);
iDirection = 0; // kierunek jazdy trzeba dopiero zgadnąć
@@ -5598,10 +5605,23 @@ void TController::TakeControl( bool const Aidriver, bool const Forcevehiclecheck
}
else
{ // a teraz użytkownik
if (!is_train() || !is_car()) // gasimy swiatla jesli przejmujemy odstawione
if (eAction == TAction::actSleep || OrderCurrentGet() == Wait_for_orders) // gasimy swiatla jesli przejmujemy odstawione
pVehicle->RaLightsSet(0, 0);
auto const aiwasdriving { AIControllFlag == AIdriver };
if( aiwasdriving ) {
// the horn, sandbox, departure signal and brake releaser the AI may have latched are momentary for the human driver
handover::release_ai_controls( *mvOccupied, *mvControlling );
fWarningDuration = 0.0;
remove_hint( driver_hint::hornon );
// the AI sets the lights directly, leave the selector in matching position so it doesn't revert them later
sync_lights_selector();
}
AIControllFlag = Humandriver;
pVehicle->Controller = Humandriver;
if( aiwasdriving ) {
// same with the headlight dimmer, which is only applied for the human driver
sync_lights_dimmer();
}
if( eAction == TAction::actSleep ) {
eAction = TAction::actUnknown;
}
@@ -6329,6 +6349,57 @@ TController::control_handles() {
} } // enginetype
}
// moves the light preset selector to the position matching the lights set directly by the AI,
// otherwise the next SetLights() call (e.g. from CheckVehicles()) reverts them to the untouched selector position
void
TController::sync_lights_selector() {
if( mvOccupied->LightsPosNo <= 0 ) { return; }
auto const *headvehicle { pVehicles[ end::front ] };
if( headvehicle == nullptr ) { return; }
// same choice of the light table as in TDynamicObject::SetLights()
auto const lightstable { ( mvOccupied->CabOccupied * pVehicle->DirectionGet() >= 0 ? end::front : end::rear ) };
// same choice of the lit vehicle end as in TDynamicObject::RaLightsSet()
auto const headend { ( headvehicle->iDirection > 0 ? end::front : end::rear ) };
auto const position { handover::light_selector_position(
*mvOccupied, lightstable, headvehicle->MoverParameters->iLights[ headend ], headvehicle->LightList( headend ) ) };
if( position == mvOccupied->LightsPos ) { return; }
mvOccupied->LightsPos = position;
// visual feedback
auto *train { simulation::Trains.find( pVehicle->name() ) };
if( train != nullptr ) {
train->ggLightsButton.PutValue( static_cast<float>( position - 1 ) );
}
}
// re-applies the headlight dimmer state after the AI handed the vehicle over to the human driver,
// moving the dimmer out of the off position if it'd hide the head lights set by the AI
void
TController::sync_lights_dimmer() {
if( mvOccupied->dimPositions.empty() ) { return; }
auto const *headvehicle { pVehicles[ end::front ] };
auto const headend { ( headvehicle != nullptr && headvehicle->iDirection > 0 ? end::front : end::rear ) };
auto const headlightson {
headvehicle != nullptr
&& ( headvehicle->MoverParameters->iLights[ headend ] & ( light::headlight_left | light::headlight_right | light::headlight_upper ) ) != 0 };
auto const position { handover::dimmer_position( *mvOccupied, headlightson ) };
if( position != mvOccupied->modernDimmerPosition ) {
mvOccupied->modernDimmerPosition = position;
// visual feedback
auto *train { simulation::Trains.find( pVehicle->name() ) };
if( train != nullptr ) {
train->ggModernLightDimSw.PutValue( static_cast<float>( position ) );
}
}
if( position < 0 || position >= static_cast<int>( mvOccupied->dimPositions.size() ) ) { return; } // SetLightDimmings() doesn't check the range
pVehicle->SetLightDimmings();
}
// activate consist lamp codes
void
TController::control_lights() {

View File

@@ -324,6 +324,8 @@ private:
void control_security_system( double const Timedelta );
void control_handles();
void control_lights();
void sync_lights_selector();
void sync_lights_dimmer();
void control_compartment_lights();
void control_doors();
void UpdateCommand();

76
vehicle/handover.cpp Normal file
View File

@@ -0,0 +1,76 @@
/*
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/handover.h"
#include "McZapkie/MOVER.h"
namespace handover {
void release_ai_controls(TMoverParameters &Occupied, TMoverParameters &Controlling) {
// the AI's hornoff action touches the horn only while the AI drives
Occupied.WarningSignal = 0;
// the AI drives the sandbox directly, bypassing the manual and automatic sanding states
if (Controlling.SandDose && false == (Controlling.SandDoseManual || Controlling.SandDoseAuto)) {
Controlling.Sandbox(false);
}
if (Occupied.DepartureSignal) {
Occupied.signal_departure(false);
}
if (Occupied.Hamulec->Releaser()) {
Occupied.BrakeReleaser(0);
}
}
int light_selector_position(TMoverParameters const &Occupied, int const Lightstable, int const Headlights, int const Inventory) {
// the selector size comes from the vehicle definition, the table holds a fixed number of positions
auto const positioncount{std::min(Occupied.LightsPosNo, static_cast<int>(std::size(Occupied.Lights[0])))};
if (positioncount <= 0 || Lightstable < 0 || Lightstable >= static_cast<int>(std::size(Occupied.Lights))) {
return Occupied.LightsPos;
}
auto const &lights{Occupied.Lights[Lightstable]};
auto const lightsmatch{[&lights, Inventory, Headlights](int const Position) { return (lights[Position - 1] & Inventory) == Headlights; }};
if (Occupied.LightsPos >= 1 && Occupied.LightsPos <= positioncount && lightsmatch(Occupied.LightsPos)) {
return Occupied.LightsPos;
}
for (auto position = 1; position <= positioncount; ++position) {
if (lightsmatch(position)) {
return position;
}
}
return Occupied.LightsPos;
}
int dimmer_position(TMoverParameters const &Occupied, bool const Headlightson) {
auto const &positions{Occupied.dimPositions};
auto const current{Occupied.modernDimmerPosition};
auto const count{static_cast<int>(positions.size())};
auto const isoff{[&positions, count](int const Position) { return Position < 0 || Position >= count || positions[Position].isOff; }};
if (false == Occupied.enableModernDimmer || false == Headlightson || false == isoff(current)) {
return current;
}
if (false == isoff(Occupied.modernDimmerDefaultPosition)) {
return Occupied.modernDimmerDefaultPosition;
}
for (auto position = 0; position < count; ++position) {
if (false == (positions[position].isOff || positions[position].isDimmed || positions[position].isHighBeam)) {
return position;
}
}
for (auto position = 0; position < count; ++position) {
if (false == positions[position].isOff) {
return position;
}
}
return current;
}
} // namespace handover

33
vehicle/handover.h Normal file
View File

@@ -0,0 +1,33 @@
/*
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;
// what changes about a vehicle when the AI hands it over to the human driver. the AI operates some
// controls directly, bypassing the cab controls a human uses, and only the AI ever releases them;
// the human gets them in the state matching the cab controls instead.
namespace handover {
// releases what the AI latches, but which is held only while pressed for the human driver:
// the horn, the sandbox, the departure signal and the brake releaser
void release_ai_controls(TMoverParameters &Occupied, TMoverParameters &Controlling);
// position of the light preset selector [1..LightsPosNo] for the human driver: the current one when it
// gives the head lights the AI has set, else the first one which does, else the current one.
// Lightstable is the end of the selector's table in use, Inventory the lights the head end has
int light_selector_position(TMoverParameters const &Occupied, int Lightstable, int Headlights, int Inventory);
// position of the headlight dimmer for the human driver: the current one, unless it's the off position
// which would hide the head lights the AI has lit. then the default one, else the first regular one,
// else any other than off
int dimmer_position(TMoverParameters const &Occupied, bool Headlightson);
} // namespace handover