diff --git a/.clang-tidy b/.clang-tidy index d630063315..92500ad04d 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,18 +1,15 @@ Checks: > -*, - boost-*, portability-*, clang-analyzer-*, - -clang-analyzer-optin*, + -clang-analyzer-optin.*, -clang-analyzer-cplusplus.NewDeleteLeaks, + -clang-analyzer-cplusplus.NewDelete, -clang-analyzer-core.CallAndMessage, - -modernize-avoid-bind -WarningsAsErrors: > - -*, - boost-*, - portability-*, - clang-analyzer-*, - -clang-analyzer-optin*, - -clang-analyzer-cplusplus.NewDeleteLeaks, - -clang-analyzer-core.CallAndMessage -HeaderFilterRegex: '^(apps|components)' + modernize-avoid-bind, + readability-identifier-naming +WarningsAsErrors: '*' +HeaderFilterRegex: '(apps|components)/' +CheckOptions: +- key: readability-identifier-naming.ConceptCase + value: CamelCase diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c910a0c4ed..ad17f34af7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -632,12 +632,16 @@ macOS14_Xcode15_arm64: - | if (Get-ChildItem -Recurse *.pdb) { 7z a -tzip "..\..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}_${CI_JOB_ID}_symbols.zip"))" '*.pdb' CI-ID.txt + if(!$?) { Exit $LASTEXITCODE } if (Test-Path env:AWS_ACCESS_KEY_ID) { aws --endpoint-url https://rgw.ctrl-c.liu.se s3 cp "..\..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}_${CI_JOB_ID}_symbols.zip"))" s3://openmw-artifacts/${artifactDirectory} + if(!$?) { Exit $LASTEXITCODE } } Push-Location .. ..\CI\Store-Symbols.ps1 -SkipCompress + if(!$?) { Exit $LASTEXITCODE } 7z a -tzip "..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}_${CI_JOB_ID}_sym_store.zip"))" '.\SymStore\*' $config\CI-ID.txt + if(!$?) { Exit $LASTEXITCODE } Pop-Location Get-ChildItem -Recurse *.pdb | Remove-Item } @@ -645,13 +649,20 @@ macOS14_Xcode15_arm64: - | if (Test-Path env:AWS_ACCESS_KEY_ID) { aws --endpoint-url https://rgw.ctrl-c.liu.se s3 cp "..\..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}.zip"))" s3://openmw-artifacts/${artifactDirectory} + if(!$?) { Exit $LASTEXITCODE } + } + - | + if ($executables) { + foreach ($exe in $executables.Split(',')) { + & .\$exe + if(!$?) { Exit $LASTEXITCODE } + } } - - if ($executables) { foreach ($exe in $executables.Split(',')) { & .\$exe } } after_script: - Get-Volume - Copy-Item C:\ProgramData\chocolatey\logs\chocolatey.log cache: - key: ninja-2022-v11 + key: ninja-2022-v12 paths: - ccache - deps @@ -779,12 +790,16 @@ macOS14_Xcode15_arm64: - | if (Get-ChildItem -Recurse *.pdb) { 7z a -tzip "..\..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}_${CI_JOB_ID}_symbols.zip"))" '*.pdb' CI-ID.txt + if(!$?) { Exit $LASTEXITCODE } if (Test-Path env:AWS_ACCESS_KEY_ID) { aws --endpoint-url https://rgw.ctrl-c.liu.se s3 cp "..\..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}_${CI_JOB_ID}_symbols.zip"))" s3://openmw-artifacts/${artifactDirectory} + if(!$?) { Exit $LASTEXITCODE } } Push-Location .. ..\CI\Store-Symbols.ps1 -SkipCompress + if(!$?) { Exit $LASTEXITCODE } 7z a -tzip "..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}_${CI_JOB_ID}_sym_store.zip"))" '.\SymStore\*' $config\CI-ID.txt + if(!$?) { Exit $LASTEXITCODE } Pop-Location Get-ChildItem -Recurse *.pdb | Remove-Item } @@ -792,13 +807,20 @@ macOS14_Xcode15_arm64: - | if (Test-Path env:AWS_ACCESS_KEY_ID) { aws --endpoint-url https://rgw.ctrl-c.liu.se s3 cp "..\..\$(Make-SafeFileName("OpenMW_MSVC2022_64_${config}_${CI_COMMIT_REF_NAME}.zip"))" s3://openmw-artifacts/${artifactDirectory} + if(!$?) { Exit $LASTEXITCODE } + } + - | + if ($executables) { + foreach ($exe in $executables.Split(',')) { + & .\$exe + if(!$?) { Exit $LASTEXITCODE } + } } - - if ($executables) { foreach ($exe in $executables.Split(',')) { & .\$exe } } after_script: - Get-Volume - Copy-Item C:\ProgramData\chocolatey\logs\chocolatey.log cache: - key: msbuild-2022-v11 + key: msbuild-2022-v12 paths: - deps - MSVC2022_64/deps/Qt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae65cbcc36..8656f792df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -229,6 +229,9 @@ Bug #8299: Crash while smoothing landscape Bug #8364: Crash when clicking scrollbar without handle (divide by zero) Bug #8378: Korean bitmap fonts are unusable + Bug #8439: Creatures without models can crash the game + Bug #8441: Freeze when using video main menu replacers + Bug #8462: Crashes when resizing the window on macOS Feature #1415: Infinite fall failsafe Feature #2566: Handle NAM9 records for manual cell references Feature #3501: OpenMW-CS: Instance Editing - Shortcuts for axial locking diff --git a/CI/before_script.linux.sh b/CI/before_script.linux.sh index 2589c2807e..c6fd306e25 100755 --- a/CI/before_script.linux.sh +++ b/CI/before_script.linux.sh @@ -38,7 +38,7 @@ fi if [[ $CI_CLANG_TIDY ]]; then CMAKE_CONF_OPTS+=( - -DCMAKE_CXX_CLANG_TIDY="clang-tidy;--warnings-as-errors=*" + -DCMAKE_CXX_CLANG_TIDY=clang-tidy -DBUILD_COMPONENTS_TESTS=ON -DBUILD_OPENMW_TESTS=ON -DBUILD_OPENCS_TESTS=ON diff --git a/CI/install_debian_deps.sh b/CI/install_debian_deps.sh index d29f16f55f..3ba66133ca 100755 --- a/CI/install_debian_deps.sh +++ b/CI/install_debian_deps.sh @@ -95,7 +95,7 @@ declare -rA GROUPED_DEPS=( [libasan6]="libasan6" [android]="binutils build-essential cmake ccache curl unzip git pkg-config" - + [openmw-clang-format]=" clang-format-14 git-core @@ -126,10 +126,24 @@ export APT_CACHE_DIR="${PWD}/apt-cache" export DEBIAN_FRONTEND=noninteractive set -x mkdir -pv "$APT_CACHE_DIR" -apt-get update -yqq + +while true; do + apt-get update -yqq && break +done + apt-get -qq -o dir::cache::archives="$APT_CACHE_DIR" install -y --no-install-recommends software-properties-common gnupg >/dev/null -add-apt-repository -y ppa:openmw/openmw -add-apt-repository -y ppa:openmw/openmw-daily -add-apt-repository -y ppa:openmw/staging + +while true; do + add-apt-repository -y ppa:openmw/openmw && break +done + +while true; do + add-apt-repository -y ppa:openmw/openmw-daily && break +done + +while true; do + add-apt-repository -y ppa:openmw/staging && break +done + apt-get -qq -o dir::cache::archives="$APT_CACHE_DIR" install -y --no-install-recommends "${deps[@]}" >/dev/null apt list --installed diff --git a/CI/run_integration_tests.sh b/CI/run_integration_tests.sh index e79408926a..6cc3bd55bf 100755 --- a/CI/run_integration_tests.sh +++ b/CI/run_integration_tests.sh @@ -9,7 +9,7 @@ git checkout FETCH_HEAD cd .. xvfb-run --auto-servernum --server-args='-screen 0 640x480x24x60' \ - scripts/integration_tests.py --omw build/install/bin/openmw --workdir integration_tests_output example-suite/ + scripts/integration_tests.py --verbose --omw build/install/bin/openmw --workdir integration_tests_output example-suite/ ls integration_tests_output/*.osg_stats.log | while read v; do scripts/osg_stats.py --stats '.*' --regexp_match < "${v}" diff --git a/CMakeLists.txt b/CMakeLists.txt index 1805ea6fea..94b52e0156 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,7 +82,7 @@ message(STATUS "Configuring OpenMW...") set(OPENMW_VERSION_MAJOR 0) set(OPENMW_VERSION_MINOR 49) set(OPENMW_VERSION_RELEASE 0) -set(OPENMW_LUA_API_REVISION 71) +set(OPENMW_LUA_API_REVISION 72) set(OPENMW_POSTPROCESSING_API_REVISION 2) set(OPENMW_VERSION_COMMITHASH "") @@ -860,7 +860,6 @@ if (OPENMW_OSX_DEPLOYMENT AND APPLE) set(BU_CHMOD_BUNDLE_ITEMS ON) set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH}) include(BundleUtilities) - cmake_minimum_required(VERSION 3.1) " COMPONENT Runtime) set(ABSOLUTE_PLUGINS "") diff --git a/apps/benchmarks/detournavigator/navmeshtilescache.cpp b/apps/benchmarks/detournavigator/navmeshtilescache.cpp index 26873d9a03..eabd757796 100644 --- a/apps/benchmarks/detournavigator/navmeshtilescache.cpp +++ b/apps/benchmarks/detournavigator/navmeshtilescache.cpp @@ -179,7 +179,7 @@ namespace generateKeys(std::back_inserter(keys), keys.size() * (100 - hitPercentage) / 100, random); std::size_t n = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { const auto& key = keys[n++ % keys.size()]; auto result = cache.get(key.mAgentBounds, key.mTilePosition, key.mRecastMesh); diff --git a/apps/benchmarks/esm/benchrefid.cpp b/apps/benchmarks/esm/benchrefid.cpp index b12f494ab9..3f38177ca7 100644 --- a/apps/benchmarks/esm/benchrefid.cpp +++ b/apps/benchmarks/esm/benchrefid.cpp @@ -104,7 +104,7 @@ namespace std::minstd_rand random; std::vector refIds = generateStringRefIds(state.range(0), random); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(refIds[i].serialize()); if (++i >= refIds.size()) @@ -118,7 +118,7 @@ namespace std::vector serializedRefIds = generateSerializedStringRefIds(state.range(0), random, [](ESM::RefId v) { return v.serialize(); }); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(ESM::RefId::deserialize(serializedRefIds[i])); if (++i >= serializedRefIds.size()) @@ -131,7 +131,7 @@ namespace std::minstd_rand random; std::vector refIds = generateStringRefIds(state.range(0), random); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(refIds[i].serializeText()); if (++i >= refIds.size()) @@ -145,7 +145,7 @@ namespace std::vector serializedRefIds = generateSerializedStringRefIds(state.range(0), random, [](ESM::RefId v) { return v.serializeText(); }); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(ESM::RefId::deserializeText(serializedRefIds[i])); if (++i >= serializedRefIds.size()) @@ -158,7 +158,7 @@ namespace std::minstd_rand random; std::vector refIds = generateGeneratedRefIds(random); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(refIds[i].serializeText()); if (++i >= refIds.size()) @@ -172,7 +172,7 @@ namespace std::vector serializedRefIds = generateSerializedGeneratedRefIds(random, [](ESM::RefId v) { return v.serializeText(); }); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(ESM::RefId::deserializeText(serializedRefIds[i])); if (++i >= serializedRefIds.size()) @@ -185,7 +185,7 @@ namespace std::minstd_rand random; std::vector refIds = generateIndexRefIds(random); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(refIds[i].serializeText()); if (++i >= refIds.size()) @@ -199,7 +199,7 @@ namespace std::vector serializedRefIds = generateSerializedIndexRefIds(random, [](ESM::RefId v) { return v.serializeText(); }); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(ESM::RefId::deserializeText(serializedRefIds[i])); if (++i >= serializedRefIds.size()) @@ -212,7 +212,7 @@ namespace std::minstd_rand random; std::vector refIds = generateESM3ExteriorCellRefIds(random); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(refIds[i].serializeText()); if (++i >= refIds.size()) @@ -226,7 +226,7 @@ namespace std::vector serializedRefIds = generateSerializedESM3ExteriorCellRefIds(random, [](ESM::RefId v) { return v.serializeText(); }); std::size_t i = 0; - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(ESM::RefId::deserializeText(serializedRefIds[i])); if (++i >= serializedRefIds.size()) diff --git a/apps/benchmarks/settings/access.cpp b/apps/benchmarks/settings/access.cpp index 7660d0d55e..9e6999dd1f 100644 --- a/apps/benchmarks/settings/access.cpp +++ b/apps/benchmarks/settings/access.cpp @@ -9,7 +9,7 @@ namespace { void settingsManager(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(Settings::Manager::getFloat("sky blending start", "Fog")); } @@ -17,7 +17,7 @@ namespace void settingsManager2(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(Settings::Manager::getFloat("near clip", "Camera")); benchmark::DoNotOptimize(Settings::Manager::getBool("transparent postpass", "Post Processing")); @@ -26,7 +26,7 @@ namespace void settingsManager3(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(Settings::Manager::getFloat("near clip", "Camera")); benchmark::DoNotOptimize(Settings::Manager::getBool("transparent postpass", "Post Processing")); @@ -36,7 +36,7 @@ namespace void localStatic(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { static float v = Settings::Manager::getFloat("sky blending start", "Fog"); benchmark::DoNotOptimize(v); @@ -45,7 +45,7 @@ namespace void localStatic2(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { static float v1 = Settings::Manager::getFloat("near clip", "Camera"); static bool v2 = Settings::Manager::getBool("transparent postpass", "Post Processing"); @@ -56,7 +56,7 @@ namespace void localStatic3(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { static float v1 = Settings::Manager::getFloat("near clip", "Camera"); static bool v2 = Settings::Manager::getBool("transparent postpass", "Post Processing"); @@ -69,7 +69,7 @@ namespace void settingsStorage(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { float v = Settings::fog().mSkyBlendingStart.get(); benchmark::DoNotOptimize(v); @@ -78,7 +78,7 @@ namespace void settingsStorage2(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { bool v1 = Settings::postProcessing().mTransparentPostpass.get(); float v2 = Settings::camera().mNearClip.get(); @@ -89,7 +89,7 @@ namespace void settingsStorage3(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { bool v1 = Settings::postProcessing().mTransparentPostpass.get(); float v2 = Settings::camera().mNearClip.get(); @@ -102,7 +102,7 @@ namespace void settingsStorageGet(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(Settings::get("Fog", "sky blending start")); } @@ -110,7 +110,7 @@ namespace void settingsStorageGet2(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(Settings::get("Post Processing", "transparent postpass")); benchmark::DoNotOptimize(Settings::get("Camera", "near clip")); @@ -119,7 +119,7 @@ namespace void settingsStorageGet3(benchmark::State& state) { - for (auto _ : state) + for ([[maybe_unused]] auto _ : state) { benchmark::DoNotOptimize(Settings::get("Post Processing", "transparent postpass")); benchmark::DoNotOptimize(Settings::get("Camera", "near clip")); diff --git a/apps/bulletobjecttool/main.cpp b/apps/bulletobjecttool/main.cpp index 190eb3364d..5cb275a53d 100644 --- a/apps/bulletobjecttool/main.cpp +++ b/apps/bulletobjecttool/main.cpp @@ -53,8 +53,6 @@ namespace bpo::options_description makeOptionsDescription() { - using Fallback::FallbackMap; - bpo::options_description result; auto addOption = result.add_options(); addOption("help", "print help message"); @@ -87,7 +85,8 @@ namespace "\n\twin1251 - Cyrillic alphabet such as Russian, Bulgarian, Serbian Cyrillic and other languages\n" "\n\twin1252 - Western European (Latin) alphabet, used by default"); - addOption("fallback", bpo::value()->default_value(FallbackMap(), "")->multitoken()->composing(), + addOption("fallback", + bpo::value()->default_value(Fallback::FallbackMap(), "")->multitoken()->composing(), "fallback values"); Files::ConfigurationManager::addCommonOptions(result); diff --git a/apps/components_tests/detournavigator/asyncnavmeshupdater.cpp b/apps/components_tests/detournavigator/asyncnavmeshupdater.cpp index ea9efc3df2..3094e1cea6 100644 --- a/apps/components_tests/detournavigator/asyncnavmeshupdater.cpp +++ b/apps/components_tests/detournavigator/asyncnavmeshupdater.cpp @@ -5,7 +5,9 @@ #include #include #include +#include #include +#include #include @@ -372,6 +374,106 @@ namespace } } + TEST_F(DetourNavigatorAsyncNavMeshUpdaterTest, should_write_debug_recast_mesh) + { + mRecastMeshManager.setWorldspace(mWorldspace, nullptr); + addHeightFieldPlane(mRecastMeshManager); + mSettings.mEnableWriteRecastMeshToFile = true; + const std::filesystem::path dir = TestingOpenMW::outputDirPath("DetourNavigatorAsyncNavMeshUpdaterTest"); + mSettings.mRecastMeshPathPrefix = Files::pathToUnicodeString(dir) + "/"; + Log(Debug::Verbose) << mSettings.mRecastMeshPathPrefix; + AsyncNavMeshUpdater updater(mSettings, mRecastMeshManager, mOffMeshConnectionsManager, nullptr); + const auto navMeshCacheItem = std::make_shared(1, mSettings); + const std::map changedTiles{ { TilePosition{ 0, 0 }, ChangeType::add } }; + updater.post(mAgentBounds, navMeshCacheItem, mPlayerTile, mWorldspace, changedTiles); + updater.wait(WaitConditionType::allJobsDone, &mListener); + EXPECT_TRUE(std::filesystem::exists(dir / "0.0.recastmesh.obj")); + } + + TEST_F(DetourNavigatorAsyncNavMeshUpdaterTest, should_write_debug_recast_mesh_with_revision) + { + mRecastMeshManager.setWorldspace(mWorldspace, nullptr); + addHeightFieldPlane(mRecastMeshManager); + mSettings.mEnableWriteRecastMeshToFile = true; + mSettings.mEnableRecastMeshFileNameRevision = true; + const std::filesystem::path dir = TestingOpenMW::outputDirPath("DetourNavigatorAsyncNavMeshUpdaterTest"); + mSettings.mRecastMeshPathPrefix = Files::pathToUnicodeString(dir) + "/"; + Log(Debug::Verbose) << mSettings.mRecastMeshPathPrefix; + AsyncNavMeshUpdater updater(mSettings, mRecastMeshManager, mOffMeshConnectionsManager, nullptr); + const auto navMeshCacheItem = std::make_shared(1, mSettings); + const std::map changedTiles{ { TilePosition{ 0, 0 }, ChangeType::add } }; + updater.post(mAgentBounds, navMeshCacheItem, mPlayerTile, mWorldspace, changedTiles); + updater.wait(WaitConditionType::allJobsDone, &mListener); + EXPECT_TRUE(std::filesystem::exists(dir / "0.0.recastmesh.1.2.obj")); + } + + TEST_F(DetourNavigatorAsyncNavMeshUpdaterTest, writing_recast_mesh_to_absent_file_should_not_fail_tile_generation) + { + mRecastMeshManager.setWorldspace(mWorldspace, nullptr); + addHeightFieldPlane(mRecastMeshManager); + mSettings.mEnableWriteRecastMeshToFile = true; + const std::filesystem::path dir = TestingOpenMW::outputDir() / "absent"; + mSettings.mRecastMeshPathPrefix = Files::pathToUnicodeString(dir) + "/"; + Log(Debug::Verbose) << mSettings.mRecastMeshPathPrefix; + AsyncNavMeshUpdater updater(mSettings, mRecastMeshManager, mOffMeshConnectionsManager, nullptr); + const auto navMeshCacheItem = std::make_shared(1, mSettings); + const std::map changedTiles{ { TilePosition{ 0, 0 }, ChangeType::add } }; + updater.post(mAgentBounds, navMeshCacheItem, mPlayerTile, mWorldspace, changedTiles); + updater.wait(WaitConditionType::allJobsDone, &mListener); + EXPECT_NE(navMeshCacheItem->lockConst()->getImpl().getTileRefAt(0, 0, 0), 0u); + EXPECT_FALSE(std::filesystem::exists(dir)); + } + + TEST_F(DetourNavigatorAsyncNavMeshUpdaterTest, should_write_debug_navmesh) + { + mRecastMeshManager.setWorldspace(mWorldspace, nullptr); + addHeightFieldPlane(mRecastMeshManager); + mSettings.mEnableWriteNavMeshToFile = true; + const std::filesystem::path dir = TestingOpenMW::outputDirPath("DetourNavigatorAsyncNavMeshUpdaterTest"); + mSettings.mNavMeshPathPrefix = Files::pathToUnicodeString(dir) + "/"; + Log(Debug::Verbose) << mSettings.mRecastMeshPathPrefix; + AsyncNavMeshUpdater updater(mSettings, mRecastMeshManager, mOffMeshConnectionsManager, nullptr); + const auto navMeshCacheItem = std::make_shared(1, mSettings); + const std::map changedTiles{ { TilePosition{ 0, 0 }, ChangeType::add } }; + updater.post(mAgentBounds, navMeshCacheItem, mPlayerTile, mWorldspace, changedTiles); + updater.wait(WaitConditionType::allJobsDone, &mListener); + EXPECT_TRUE(std::filesystem::exists(dir / "all_tiles_navmesh.bin")); + } + + TEST_F(DetourNavigatorAsyncNavMeshUpdaterTest, should_write_debug_navmesh_with_revision) + { + mRecastMeshManager.setWorldspace(mWorldspace, nullptr); + addHeightFieldPlane(mRecastMeshManager); + mSettings.mEnableWriteNavMeshToFile = true; + mSettings.mEnableNavMeshFileNameRevision = true; + const std::filesystem::path dir = TestingOpenMW::outputDirPath("DetourNavigatorAsyncNavMeshUpdaterTest"); + mSettings.mNavMeshPathPrefix = Files::pathToUnicodeString(dir) + "/"; + Log(Debug::Verbose) << mSettings.mRecastMeshPathPrefix; + AsyncNavMeshUpdater updater(mSettings, mRecastMeshManager, mOffMeshConnectionsManager, nullptr); + const auto navMeshCacheItem = std::make_shared(1, mSettings); + const std::map changedTiles{ { TilePosition{ 0, 0 }, ChangeType::add } }; + updater.post(mAgentBounds, navMeshCacheItem, mPlayerTile, mWorldspace, changedTiles); + updater.wait(WaitConditionType::allJobsDone, &mListener); + EXPECT_TRUE(std::filesystem::exists(dir / "all_tiles_navmesh.1.1.bin")); + } + + TEST_F(DetourNavigatorAsyncNavMeshUpdaterTest, writing_navmesh_to_absent_file_should_not_fail_tile_generation) + { + mRecastMeshManager.setWorldspace(mWorldspace, nullptr); + addHeightFieldPlane(mRecastMeshManager); + mSettings.mEnableWriteNavMeshToFile = true; + const std::filesystem::path dir = TestingOpenMW::outputDir() / "absent"; + mSettings.mNavMeshPathPrefix = Files::pathToUnicodeString(dir) + "/"; + Log(Debug::Verbose) << mSettings.mRecastMeshPathPrefix; + AsyncNavMeshUpdater updater(mSettings, mRecastMeshManager, mOffMeshConnectionsManager, nullptr); + const auto navMeshCacheItem = std::make_shared(1, mSettings); + const std::map changedTiles{ { TilePosition{ 0, 0 }, ChangeType::add } }; + updater.post(mAgentBounds, navMeshCacheItem, mPlayerTile, mWorldspace, changedTiles); + updater.wait(WaitConditionType::allJobsDone, &mListener); + EXPECT_NE(navMeshCacheItem->lockConst()->getImpl().getTileRefAt(0, 0, 0), 0u); + EXPECT_FALSE(std::filesystem::exists(dir)); + } + struct DetourNavigatorSpatialJobQueueTest : Test { const AgentBounds mAgentBounds{ CollisionShapeType::Aabb, osg::Vec3f(1, 1, 1) }; diff --git a/apps/components_tests/esm3/readerscache.cpp b/apps/components_tests/esm3/readerscache.cpp index f222a29bf8..1cb9a85fb6 100644 --- a/apps/components_tests/esm3/readerscache.cpp +++ b/apps/components_tests/esm3/readerscache.cpp @@ -88,4 +88,31 @@ namespace EXPECT_EQ(reader->getFileOffset(), sInitialOffset); } } + + TEST_F(ESM3ReadersCacheWithContentFile, CachedSizeAndName) + { + ESM::ReadersCache readers(2); + { + readers.get(0)->openRaw(std::make_unique("123"), "closed0.omwaddon"); + readers.get(1)->openRaw(std::make_unique("12345"), "closed1.omwaddon"); + readers.get(2)->openRaw(std::make_unique("1234567"), "free.omwaddon"); + } + auto busy = readers.get(3); + busy->openRaw(std::make_unique("123456789"), "busy.omwaddon"); + + EXPECT_EQ(readers.getFileSize(0), 3); + EXPECT_EQ(readers.getName(0), "closed0.omwaddon"); + + EXPECT_EQ(readers.getFileSize(1), 5); + EXPECT_EQ(readers.getName(1), "closed1.omwaddon"); + + EXPECT_EQ(readers.getFileSize(2), 7); + EXPECT_EQ(readers.getName(2), "free.omwaddon"); + + EXPECT_EQ(readers.getFileSize(3), 9); + EXPECT_EQ(readers.getName(3), "busy.omwaddon"); + + // not-yet-seen indices give zero for their size + EXPECT_EQ(readers.getFileSize(4), 0); + } } diff --git a/apps/components_tests/files/hash.cpp b/apps/components_tests/files/hash.cpp index 793965112b..f94cb1969d 100644 --- a/apps/components_tests/files/hash.cpp +++ b/apps/components_tests/files/hash.cpp @@ -30,7 +30,7 @@ namespace TEST(FilesGetHash, shouldClearErrors) { - const auto fileName = temporaryFilePath("fileName"); + const auto fileName = outputFilePath("fileName"); std::string content; std::fill_n(std::back_inserter(content), 1, 'a'); std::istringstream stream(content); @@ -41,7 +41,7 @@ namespace TEST_P(FilesGetHash, shouldReturnHashForStringStream) { - const auto fileName = temporaryFilePath("fileName"); + const auto fileName = outputFilePath("fileName"); std::string content; std::fill_n(std::back_inserter(content), GetParam().mSize, 'a'); std::istringstream stream(content); diff --git a/apps/components_tests/main.cpp b/apps/components_tests/main.cpp index dcfb2e9ba9..c1b41d184a 100644 --- a/apps/components_tests/main.cpp +++ b/apps/components_tests/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include @@ -24,5 +25,9 @@ int main(int argc, char** argv) Settings::StaticValues::init(); testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); + + const int result = RUN_ALL_TESTS(); + if (result == 0) + std::filesystem::remove_all(TestingOpenMW::outputDir()); + return result; } diff --git a/apps/components_tests/shader/shadermanager.cpp b/apps/components_tests/shader/shadermanager.cpp index 5b11d31a44..b80839d0ec 100644 --- a/apps/components_tests/shader/shadermanager.cpp +++ b/apps/components_tests/shader/shadermanager.cpp @@ -16,7 +16,7 @@ namespace ShaderManager mManager; ShaderManager::DefineMap mDefines; - ShaderManagerTest() { mManager.setShaderPath("tests_output"); } + ShaderManagerTest() { mManager.setShaderPath(TestingOpenMW::outputDir()); } template void withShaderFile(const std::string& content, F&& f) diff --git a/apps/launcher/datafilespage.cpp b/apps/launcher/datafilespage.cpp index 9bb54fdb8e..c1bcc8f944 100644 --- a/apps/launcher/datafilespage.cpp +++ b/apps/launcher/datafilespage.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -351,9 +352,17 @@ void Launcher::DataFilesPage::populateFileViews(const QString& contentModelName) if (!resourcesVfs.isEmpty()) directories.insert(0, { resourcesVfs }); + QIcon containsDataIcon(":/images/openmw-plugin.png"); + + QProgressDialog progressBar("Adding data directories", {}, 0, directories.count(), this); + progressBar.setWindowModality(Qt::WindowModal); + progressBar.setValue(0); + std::unordered_set visitedDirectories; for (const Config::SettingValue& currentDir : directories) { + progressBar.setValue(progressBar.value() + 1); + if (!visitedDirectories.insert(currentDir.value).second) continue; @@ -402,7 +411,7 @@ void Launcher::DataFilesPage::populateFileViews(const QString& contentModelName) // Add a "data file" icon if the directory contains a content file if (mSelector->containsDataFiles(currentDir.value)) { - item->setIcon(QIcon(":/images/openmw-plugin.png")); + item->setIcon(containsDataIcon); tooltip << tr("Contains content file(s)"); } @@ -765,7 +774,7 @@ void Launcher::DataFilesPage::addSubdirectories(bool append) return; QString rootPath = QFileDialog::getExistingDirectory( - this, tr("Select Directory"), QDir::homePath(), QFileDialog::ShowDirsOnly | QFileDialog::Option::ReadOnly); + this, tr("Select Directory"), {}, QFileDialog::ShowDirsOnly | QFileDialog::Option::ReadOnly); if (rootPath.isEmpty()) return; diff --git a/apps/navmeshtool/CMakeLists.txt b/apps/navmeshtool/CMakeLists.txt index 090fc00f36..f056c93685 100644 --- a/apps/navmeshtool/CMakeLists.txt +++ b/apps/navmeshtool/CMakeLists.txt @@ -1,20 +1,16 @@ -set(NAVMESHTOOL +set(NAVMESHTOOL_LIB worldspacedata.cpp navmesh.cpp - main.cpp ) -source_group(apps\\navmeshtool FILES ${NAVMESHTOOL}) -add_library(openmw-navmeshtool-lib STATIC - ${NAVMESHTOOL} -) +source_group(apps\\navmeshtool FILES ${NAVMESHTOOL_LIB} main.cpp) + +add_library(openmw-navmeshtool-lib STATIC ${NAVMESHTOOL_LIB}) if (ANDROID) - add_library(openmw-navmeshtool SHARED - main.cpp - ) + add_library(openmw-navmeshtool SHARED main.cpp) else() - openmw_add_executable(openmw-navmeshtool ${NAVMESHTOOL}) + openmw_add_executable(openmw-navmeshtool main.cpp) endif() target_link_libraries(openmw-navmeshtool openmw-navmeshtool-lib) diff --git a/apps/navmeshtool/main.cpp b/apps/navmeshtool/main.cpp index 27f84104ac..e148e60d54 100644 --- a/apps/navmeshtool/main.cpp +++ b/apps/navmeshtool/main.cpp @@ -62,8 +62,6 @@ namespace NavMeshTool bpo::options_description makeOptionsDescription() { - using Fallback::FallbackMap; - bpo::options_description result; auto addOption = result.add_options(); addOption("help", "print help message"); @@ -225,7 +223,8 @@ namespace NavMeshTool Resource::SceneManager sceneManager(&vfs, &imageManager, &nifFileManager, &bgsmFileManager, expiryDelay); Resource::BulletShapeManager bulletShapeManager(&vfs, &sceneManager, &nifFileManager, expiryDelay); DetourNavigator::RecastGlobalAllocator::init(); - DetourNavigator::Settings navigatorSettings = DetourNavigator::makeSettingsFromSettingsManager(); + DetourNavigator::Settings navigatorSettings + = DetourNavigator::makeSettingsFromSettingsManager(Debug::getRecastMaxLogLevel()); navigatorSettings.mRecast.mSwimHeightScale = EsmLoader::getGameSetting(esmData.mGameSettings, "fSwimHeightScale").getFloat(); diff --git a/apps/opencs/editor.cpp b/apps/opencs/editor.cpp index 4cab88e5f2..8781f54154 100644 --- a/apps/opencs/editor.cpp +++ b/apps/opencs/editor.cpp @@ -38,8 +38,6 @@ #include "view/doc/viewmanager.hpp" -using namespace Fallback; - CS::Editor::Editor(int argc, char** argv) : mConfigVariables(readConfiguration()) , mSettingsState(mCfgMgr) @@ -124,7 +122,10 @@ boost::program_options::variables_map CS::Editor::readConfiguration() ->default_value(std::vector(), "fallback-archive") ->multitoken()); addOption("fallback", - boost::program_options::value()->default_value(FallbackMap(), "")->multitoken()->composing(), + boost::program_options::value() + ->default_value(Fallback::FallbackMap(), "") + ->multitoken() + ->composing(), "fallback values"); Files::ConfigurationManager::addCommonOptions(desc); @@ -141,7 +142,7 @@ std::pair> CS::Editor::readConfig { boost::program_options::variables_map& variables = mConfigVariables; - Fallback::Map::init(variables["fallback"].as().mMap); + Fallback::Map::init(variables["fallback"].as().mMap); mEncodingName = variables["encoding"].as(); mDocumentManager.setEncoding(ToUTF8::calculateEncoding(mEncodingName)); diff --git a/apps/opencs_tests/main.cpp b/apps/opencs_tests/main.cpp index fd7d4900c8..fed1cd2bb1 100644 --- a/apps/opencs_tests/main.cpp +++ b/apps/opencs_tests/main.cpp @@ -1,4 +1,5 @@ #include +#include #include @@ -7,5 +8,9 @@ int main(int argc, char* argv[]) Log::sMinDebugLevel = Debug::getDebugLevel(); testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); + + const int result = RUN_ALL_TESTS(); + if (result == 0) + std::filesystem::remove_all(TestingOpenMW::outputDir()); + return result; } diff --git a/apps/openmw/engine.cpp b/apps/openmw/engine.cpp index 2736f339e4..7dc372f269 100644 --- a/apps/openmw/engine.cpp +++ b/apps/openmw/engine.cpp @@ -373,11 +373,15 @@ OMW::Engine::Engine(Files::ConfigurationManager& configurationManager) , mScriptConsoleMode(false) , mActivationDistanceOverride(-1) , mGrab(true) + , mExportFonts(false) , mRandomSeed(0) , mNewGame(false) , mCfgMgr(configurationManager) , mGlMaxTextureImageUnits(0) { +#if SDL_VERSION_ATLEAST(2, 24, 0) + SDL_SetHint(SDL_HINT_MAC_OPENGL_ASYNC_DISPATCH, "1"); +#endif SDL_SetHint(SDL_HINT_ACCELEROMETER_AS_JOYSTICK, "0"); // We use only gamepads Uint32 flags @@ -807,7 +811,7 @@ void OMW::Engine::prepareEngine() rootNode->addChild(guiRoot); mWindowManager = std::make_unique(mWindow, mViewer, guiRoot, mResourceSystem.get(), - mWorkQueue.get(), mCfgMgr.getLogPath(), mScriptConsoleMode, mTranslationDataStorage, mEncoding, + mWorkQueue.get(), mCfgMgr.getLogPath(), mScriptConsoleMode, mTranslationDataStorage, mEncoding, mExportFonts, Version::getOpenmwVersionDescription(), shadersSupported, mCfgMgr); mEnvironment.setWindowManager(*mWindowManager); @@ -847,7 +851,7 @@ void OMW::Engine::prepareEngine() } listener->loadingOff(); - mWorld->init(mViewer, std::move(rootNode), mWorkQueue.get(), *mUnrefQueue); + mWorld->init(mMaxRecastLogLevel, mViewer, std::move(rootNode), mWorkQueue.get(), *mUnrefQueue); mEnvironment.setWorldScene(mWorld->getWorldScene()); mWorld->setupPlayer(); mWorld->setRandomSeed(mRandomSeed); @@ -1109,6 +1113,11 @@ void OMW::Engine::setWarningsMode(int mode) mWarningsMode = mode; } +void OMW::Engine::enableFontExport(bool exportFonts) +{ + mExportFonts = exportFonts; +} + void OMW::Engine::setSaveGameFile(const std::filesystem::path& savegame) { mSaveGameFile = savegame; diff --git a/apps/openmw/engine.hpp b/apps/openmw/engine.hpp index 39056af560..38e95ea7c8 100644 --- a/apps/openmw/engine.hpp +++ b/apps/openmw/engine.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -171,7 +172,9 @@ namespace OMW // Grab mouse? bool mGrab; + bool mExportFonts; unsigned int mRandomSeed; + Debug::Level mMaxRecastLogLevel = Debug::Error; Compiler::Extensions mExtensions; std::unique_ptr mScriptContext; @@ -180,6 +183,9 @@ namespace OMW Translation::Storage mTranslationDataStorage; bool mNewGame; + Files::ConfigurationManager& mCfgMgr; + int mGlMaxTextureImageUnits; + // not implemented Engine(const Engine&); Engine& operator=(const Engine&); @@ -251,14 +257,14 @@ namespace OMW void setWarningsMode(int mode); + void enableFontExport(bool exportFonts); + /// Set the save game file to load after initialising the engine. void setSaveGameFile(const std::filesystem::path& savegame); void setRandomSeed(unsigned int seed); - private: - Files::ConfigurationManager& mCfgMgr; - int mGlMaxTextureImageUnits; + void setRecastMaxLogLevel(Debug::Level value) { mMaxRecastLogLevel = value; } }; } diff --git a/apps/openmw/main.cpp b/apps/openmw/main.cpp index 5b89bca960..812a6ffd85 100644 --- a/apps/openmw/main.cpp +++ b/apps/openmw/main.cpp @@ -28,8 +28,6 @@ extern "C" __declspec(dllexport) DWORD AmdPowerXpressRequestHighPerformance = 0x #include #endif -using namespace Fallback; - /** * \brief Parses application command line and calls \ref Cfg::ConfigurationManager * to parse configuration files. @@ -152,9 +150,10 @@ bool parseOptions(int argc, char** argv, OMW::Engine& engine, Files::Configurati engine.setSaveGameFile(variables["load-savegame"].as().u8string()); // other settings - Fallback::Map::init(variables["fallback"].as().mMap); + Fallback::Map::init(variables["fallback"].as().mMap); engine.setSoundUsage(!variables["no-sound"].as()); engine.setActivationDistanceOverride(variables["activate-dist"].as()); + engine.enableFontExport(variables["export-fonts"].as()); engine.setRandomSeed(variables["random-seed"].as()); return true; @@ -220,6 +219,8 @@ int runApplication(int argc, char* argv[]) Files::ConfigurationManager cfgMgr; std::unique_ptr engine = std::make_unique(cfgMgr); + engine->setRecastMaxLogLevel(Debug::getRecastMaxLogLevel()); + if (parseOptions(argc, argv, *engine, cfgMgr)) { if (!Misc::checkRequiredOSGPluginsArePresent()) diff --git a/apps/openmw/mwbase/mechanicsmanager.hpp b/apps/openmw/mwbase/mechanicsmanager.hpp index 4883fa2000..23d79c1a6b 100644 --- a/apps/openmw/mwbase/mechanicsmanager.hpp +++ b/apps/openmw/mwbase/mechanicsmanager.hpp @@ -200,7 +200,7 @@ namespace MWBase ///< Skip the animation for the given MW-reference for one frame. Calls to this function for /// references that are currently not in the scene should be ignored. - virtual bool checkAnimationPlaying(const MWWorld::Ptr& ptr, const std::string& groupName) = 0; + virtual bool checkAnimationPlaying(const MWWorld::Ptr& ptr, std::string_view groupName) = 0; virtual bool checkScriptedAnimationPlaying(const MWWorld::Ptr& ptr) const = 0; diff --git a/apps/openmw/mwbase/windowmanager.hpp b/apps/openmw/mwbase/windowmanager.hpp index df334bbfe8..8164501b4b 100644 --- a/apps/openmw/mwbase/windowmanager.hpp +++ b/apps/openmw/mwbase/windowmanager.hpp @@ -363,7 +363,7 @@ namespace MWBase void windowVisibilityChange(bool visible) override = 0; void windowResized(int x, int y) override = 0; void windowClosed() override = 0; - virtual bool isWindowVisible() = 0; + virtual bool isWindowVisible() const = 0; virtual void watchActor(const MWWorld::Ptr& ptr) = 0; virtual MWWorld::Ptr getWatchedActor() const = 0; diff --git a/apps/openmw/mwclass/container.cpp b/apps/openmw/mwclass/container.cpp index 8327904ecd..c8b1f05972 100644 --- a/apps/openmw/mwclass/container.cpp +++ b/apps/openmw/mwclass/container.cpp @@ -237,7 +237,12 @@ namespace MWClass bool Container::hasToolTip(const MWWorld::ConstPtr& ptr) const { if (const MWWorld::CustomData* data = ptr.getRefData().getCustomData()) - return !canBeHarvested(ptr) || data->asContainerCustomData().mStore.hasVisibleItems(); + { + if (!canBeHarvested(ptr)) + return true; + const MWWorld::ContainerStore& store = data->asContainerCustomData().mStore; + return !store.isResolved() || store.hasVisibleItems(); + } return true; } diff --git a/apps/openmw/mwgui/bookpage.cpp b/apps/openmw/mwgui/bookpage.cpp index 1966442513..47e85b1f4b 100644 --- a/apps/openmw/mwgui/bookpage.cpp +++ b/apps/openmw/mwgui/bookpage.cpp @@ -1343,12 +1343,13 @@ namespace MWGui return codePoint == '\r'; } + // Normal no-break space (0x00A0) is ignored here + // because Morrowind compatibility requires us to render its glyph static bool ucsSpace(int codePoint) { switch (codePoint) { case 0x0020: // SPACE - case 0x00A0: // NO-BREAK SPACE case 0x1680: // OGHAM SPACE MARK case 0x180E: // MONGOLIAN VOWEL SEPARATOR case 0x2000: // EN QUAD @@ -1373,12 +1374,14 @@ namespace MWGui } } + // No-break spaces (0x00A0, 0x202F, 0xFEFF - normal, narrow, zero width) + // are ignored here for obvious reasons + // Figure space (0x2007) is not a breaking space either static bool ucsBreakingSpace(int codePoint) { switch (codePoint) { case 0x0020: // SPACE - // case 0x00A0: // NO-BREAK SPACE case 0x1680: // OGHAM SPACE MARK case 0x180E: // MONGOLIAN VOWEL SEPARATOR case 0x2000: // EN QUAD @@ -1388,15 +1391,12 @@ namespace MWGui case 0x2004: // THREE-PER-EM SPACE case 0x2005: // FOUR-PER-EM SPACE case 0x2006: // SIX-PER-EM SPACE - case 0x2007: // FIGURE SPACE case 0x2008: // PUNCTUATION SPACE case 0x2009: // THIN SPACE case 0x200A: // HAIR SPACE case 0x200B: // ZERO WIDTH SPACE - case 0x202F: // NARROW NO-BREAK SPACE case 0x205F: // MEDIUM MATHEMATICAL SPACE case 0x3000: // IDEOGRAPHIC SPACE - // case 0xFEFF: // ZERO WIDTH NO-BREAK SPACE return true; default: return false; diff --git a/apps/openmw/mwgui/mainmenu.cpp b/apps/openmw/mwgui/mainmenu.cpp index da747dd7a2..1b3619bd9f 100644 --- a/apps/openmw/mwgui/mainmenu.cpp +++ b/apps/openmw/mwgui/mainmenu.cpp @@ -29,11 +29,26 @@ namespace MWGui { Misc::FrameRateLimiter frameRateLimiter = Misc::makeFrameRateLimiter(MWBase::Environment::get().getFrameRateLimit()); + const MWBase::WindowManager& windowManager = *MWBase::Environment::get().getWindowManager(); + bool paused = false; while (mRunning) { - // If finished playing, start again - if (!mVideo->update()) - mVideo->playVideo("video\\menu_background.bik"); + if (windowManager.isWindowVisible()) + { + if (paused) + { + mVideo->resume(); + paused = false; + } + // If finished playing, start again + if (!mVideo->update()) + mVideo->playVideo("video\\menu_background.bik"); + } + else if (!paused) + { + paused = true; + mVideo->pause(); + } frameRateLimiter.limit(); } } diff --git a/apps/openmw/mwgui/windowmanagerimp.cpp b/apps/openmw/mwgui/windowmanagerimp.cpp index e51350d19f..565fb43127 100644 --- a/apps/openmw/mwgui/windowmanagerimp.cpp +++ b/apps/openmw/mwgui/windowmanagerimp.cpp @@ -146,7 +146,7 @@ namespace MWGui WindowManager::WindowManager(SDL_Window* window, osgViewer::Viewer* viewer, osg::Group* guiRoot, Resource::ResourceSystem* resourceSystem, SceneUtil::WorkQueue* workQueue, const std::filesystem::path& logpath, bool consoleOnlyScripts, Translation::Storage& translationDataStorage, ToUTF8::FromType encoding, - const std::string& versionDescription, bool useShaders, Files::ConfigurationManager& cfgMgr) + bool exportFonts, const std::string& versionDescription, bool useShaders, Files::ConfigurationManager& cfgMgr) : mOldUpdateMask(0) , mOldCullMask(0) , mStore(nullptr) @@ -215,7 +215,8 @@ namespace MWGui MyGUI::LanguageManager::getInstance().eventRequestTag = MyGUI::newDelegate(this, &WindowManager::onRetrieveTag); // Load fonts - mFontLoader = std::make_unique(encoding, resourceSystem->getVFS(), mScalingFactor); + mFontLoader + = std::make_unique(encoding, resourceSystem->getVFS(), mScalingFactor, exportFonts); // Register own widgets with MyGUI MyGUI::FactoryManager::getInstance().registerFactory("Widget"); @@ -729,6 +730,9 @@ namespace MWGui return; } + if (mGuiModes.empty()) + return; + GuiModeState& state = mGuiModeStates[mGuiModes.back()]; for (const auto& window : state.mWindows) { @@ -1214,7 +1218,7 @@ namespace MWGui // TODO: check if any windows are now off-screen and move them back if so } - bool WindowManager::isWindowVisible() + bool WindowManager::isWindowVisible() const { return mWindowVisible; } diff --git a/apps/openmw/mwgui/windowmanagerimp.hpp b/apps/openmw/mwgui/windowmanagerimp.hpp index 052a269188..03902e21c4 100644 --- a/apps/openmw/mwgui/windowmanagerimp.hpp +++ b/apps/openmw/mwgui/windowmanagerimp.hpp @@ -128,7 +128,7 @@ namespace MWGui WindowManager(SDL_Window* window, osgViewer::Viewer* viewer, osg::Group* guiRoot, Resource::ResourceSystem* resourceSystem, SceneUtil::WorkQueue* workQueue, const std::filesystem::path& logpath, bool consoleOnlyScripts, Translation::Storage& translationDataStorage, - ToUTF8::FromType encoding, const std::string& versionDescription, bool useShaders, + ToUTF8::FromType encoding, bool exportFonts, const std::string& versionDescription, bool useShaders, Files::ConfigurationManager& cfgMgr); virtual ~WindowManager(); @@ -290,7 +290,7 @@ namespace MWGui void windowVisibilityChange(bool visible) override; void windowResized(int x, int y) override; void windowClosed() override; - bool isWindowVisible() override; + bool isWindowVisible() const override; void watchActor(const MWWorld::Ptr& ptr) override; MWWorld::Ptr getWatchedActor() const override; diff --git a/apps/openmw/mwlua/nearbybindings.cpp b/apps/openmw/mwlua/nearbybindings.cpp index df317ffeba..a6d762499a 100644 --- a/apps/openmw/mwlua/nearbybindings.cpp +++ b/apps/openmw/mwlua/nearbybindings.cpp @@ -213,6 +213,7 @@ namespace MWLua { "NavMeshNotFound", DetourNavigator::Status::NavMeshNotFound }, { "StartPolygonNotFound", DetourNavigator::Status::StartPolygonNotFound }, { "EndPolygonNotFound", DetourNavigator::Status::EndPolygonNotFound }, + { "TargetPolygonNotFound", DetourNavigator::Status::TargetPolygonNotFound }, { "MoveAlongSurfaceFailed", DetourNavigator::Status::MoveAlongSurfaceFailed }, { "FindPathOverPolygonsFailed", DetourNavigator::Status::FindPathOverPolygonsFailed }, { "InitNavMeshQueryFailed", DetourNavigator::Status::InitNavMeshQueryFailed }, diff --git a/apps/openmw/mwmechanics/activespells.cpp b/apps/openmw/mwmechanics/activespells.cpp index 3cee16f29f..9e64996e46 100644 --- a/apps/openmw/mwmechanics/activespells.cpp +++ b/apps/openmw/mwmechanics/activespells.cpp @@ -289,8 +289,9 @@ namespace MWMechanics const ESM::RefId& enchantmentId = slot->getClass().getEnchantment(*slot); if (enchantmentId.empty()) continue; - const ESM::Enchantment* enchantment = world->getStore().get().find(enchantmentId); - if (enchantment->mData.mType != ESM::Enchantment::ConstantEffect) + const ESM::Enchantment* enchantment + = world->getStore().get().search(enchantmentId); + if (enchantment == nullptr || enchantment->mData.mType != ESM::Enchantment::ConstantEffect) continue; if (std::find_if(mSpells.begin(), mSpells.end(), [&](const ActiveSpellParams& params) { diff --git a/apps/openmw/mwmechanics/actors.cpp b/apps/openmw/mwmechanics/actors.cpp index bb3273981d..1e62cc4a21 100644 --- a/apps/openmw/mwmechanics/actors.cpp +++ b/apps/openmw/mwmechanics/actors.cpp @@ -2028,7 +2028,7 @@ namespace MWMechanics iter->second->getCharacterController().skipAnim(); } - bool Actors::checkAnimationPlaying(const MWWorld::Ptr& ptr, const std::string& groupName) const + bool Actors::checkAnimationPlaying(const MWWorld::Ptr& ptr, std::string_view groupName) const { const auto iter = mIndex.find(ptr.mRef); if (iter != mIndex.end()) diff --git a/apps/openmw/mwmechanics/actors.hpp b/apps/openmw/mwmechanics/actors.hpp index b575ec2827..3e34ed0d67 100644 --- a/apps/openmw/mwmechanics/actors.hpp +++ b/apps/openmw/mwmechanics/actors.hpp @@ -119,7 +119,7 @@ namespace MWMechanics std::string_view startKey, std::string_view stopKey, bool forceLoop); void enableLuaAnimations(const MWWorld::Ptr& ptr, bool enable); void skipAnimation(const MWWorld::Ptr& ptr) const; - bool checkAnimationPlaying(const MWWorld::Ptr& ptr, const std::string& groupName) const; + bool checkAnimationPlaying(const MWWorld::Ptr& ptr, std::string_view groupName) const; bool checkScriptedAnimationPlaying(const MWWorld::Ptr& ptr) const; void persistAnimationStates() const; void clearAnimationQueue(const MWWorld::Ptr& ptr, bool clearScripted); diff --git a/apps/openmw/mwmechanics/aiwander.cpp b/apps/openmw/mwmechanics/aiwander.cpp index 3c299c1490..42667a406a 100644 --- a/apps/openmw/mwmechanics/aiwander.cpp +++ b/apps/openmw/mwmechanics/aiwander.cpp @@ -40,15 +40,15 @@ namespace MWMechanics static const std::size_t MAX_IDLE_SIZE = 8; - const std::string AiWander::sIdleSelectToGroupName[GroupIndex_MaxIdle - GroupIndex_MinIdle + 1] = { - std::string("idle2"), - std::string("idle3"), - std::string("idle4"), - std::string("idle5"), - std::string("idle6"), - std::string("idle7"), - std::string("idle8"), - std::string("idle9"), + const std::string_view AiWander::sIdleSelectToGroupName[GroupIndex_MaxIdle - GroupIndex_MinIdle + 1] = { + "idle2", + "idle3", + "idle4", + "idle5", + "idle6", + "idle7", + "idle8", + "idle9", }; namespace @@ -680,7 +680,7 @@ namespace MWMechanics { if ((GroupIndex_MinIdle <= idleSelect) && (idleSelect <= GroupIndex_MaxIdle)) { - const std::string& groupName = sIdleSelectToGroupName[idleSelect - GroupIndex_MinIdle]; + const std::string_view groupName = sIdleSelectToGroupName[idleSelect - GroupIndex_MinIdle]; return MWBase::Environment::get().getMechanicsManager()->playAnimationGroup(actor, groupName, 0, 1); } else @@ -695,7 +695,7 @@ namespace MWMechanics { if ((GroupIndex_MinIdle <= idleSelect) && (idleSelect <= GroupIndex_MaxIdle)) { - const std::string& groupName = sIdleSelectToGroupName[idleSelect - GroupIndex_MinIdle]; + const std::string_view groupName = sIdleSelectToGroupName[idleSelect - GroupIndex_MinIdle]; return MWBase::Environment::get().getMechanicsManager()->checkAnimationPlaying(actor, groupName); } else diff --git a/apps/openmw/mwmechanics/aiwander.hpp b/apps/openmw/mwmechanics/aiwander.hpp index aed7214f4d..01a02096a4 100644 --- a/apps/openmw/mwmechanics/aiwander.hpp +++ b/apps/openmw/mwmechanics/aiwander.hpp @@ -3,6 +3,7 @@ #include "typedaipackage.hpp" +#include #include #include "aitemporarybase.hpp" @@ -181,9 +182,7 @@ namespace MWMechanics const ESM::Pathgrid::Point& start, const ESM::Pathgrid::Point& end, AiWanderStorage& storage); /// lookup table for converting idleSelect value to groupName - static const std::string sIdleSelectToGroupName[GroupIndex_MaxIdle - GroupIndex_MinIdle + 1]; - - static int OffsetToPreventOvercrowding(); + static const std::string_view sIdleSelectToGroupName[GroupIndex_MaxIdle - GroupIndex_MinIdle + 1]; }; } diff --git a/apps/openmw/mwmechanics/mechanicsmanagerimp.cpp b/apps/openmw/mwmechanics/mechanicsmanagerimp.cpp index 46f6440ae6..47c49a8861 100644 --- a/apps/openmw/mwmechanics/mechanicsmanagerimp.cpp +++ b/apps/openmw/mwmechanics/mechanicsmanagerimp.cpp @@ -778,7 +778,8 @@ namespace MWMechanics else mObjects.skipAnimation(ptr); } - bool MechanicsManager::checkAnimationPlaying(const MWWorld::Ptr& ptr, const std::string& groupName) + + bool MechanicsManager::checkAnimationPlaying(const MWWorld::Ptr& ptr, std::string_view groupName) { if (ptr.getClass().isActor()) return mActors.checkAnimationPlaying(ptr, groupName); diff --git a/apps/openmw/mwmechanics/mechanicsmanagerimp.hpp b/apps/openmw/mwmechanics/mechanicsmanagerimp.hpp index 4b0126cd34..93af89863b 100644 --- a/apps/openmw/mwmechanics/mechanicsmanagerimp.hpp +++ b/apps/openmw/mwmechanics/mechanicsmanagerimp.hpp @@ -146,7 +146,7 @@ namespace MWMechanics std::string_view startKey, std::string_view stopKey, bool forceLoop) override; void enableLuaAnimations(const MWWorld::Ptr& ptr, bool enable) override; void skipAnimation(const MWWorld::Ptr& ptr) override; - bool checkAnimationPlaying(const MWWorld::Ptr& ptr, const std::string& groupName) override; + bool checkAnimationPlaying(const MWWorld::Ptr& ptr, std::string_view groupName) override; bool checkScriptedAnimationPlaying(const MWWorld::Ptr& ptr) const override; void persistAnimationStates() override; void clearAnimationQueue(const MWWorld::Ptr& ptr, bool clearScripted) override; diff --git a/apps/openmw/mwrender/actoranimation.cpp b/apps/openmw/mwrender/actoranimation.cpp index f4bafdb48b..05c722f486 100644 --- a/apps/openmw/mwrender/actoranimation.cpp +++ b/apps/openmw/mwrender/actoranimation.cpp @@ -161,7 +161,7 @@ namespace MWRender bool ActorAnimation::updateCarriedLeftVisible(const int weaptype) const { - if (Settings::game().mShieldSheathing) + if (Settings::game().mShieldSheathing && mObjectRoot) { const MWWorld::Class& cls = mPtr.getClass(); MWMechanics::CreatureStats& stats = cls.getCreatureStats(mPtr); diff --git a/apps/openmw/mwrender/animation.cpp b/apps/openmw/mwrender/animation.cpp index 0a2ef7bef8..f07a325f7c 100644 --- a/apps/openmw/mwrender/animation.cpp +++ b/apps/openmw/mwrender/animation.cpp @@ -1695,7 +1695,7 @@ namespace MWRender mGlowUpdater->setColor(color); mGlowUpdater->setDuration(glowDuration); } - else + else if (mObjectRoot) mGlowUpdater = SceneUtil::addEnchantedGlow(mObjectRoot, mResourceSystem, color, glowDuration); } } @@ -1869,7 +1869,7 @@ namespace MWRender void Animation::setAlpha(float alpha) { - if (alpha == mAlpha) + if (alpha == mAlpha || !mObjectRoot) return; mAlpha = alpha; diff --git a/apps/openmw/mwrender/creatureanimation.cpp b/apps/openmw/mwrender/creatureanimation.cpp index f84e58e4bc..fb839cfa3a 100644 --- a/apps/openmw/mwrender/creatureanimation.cpp +++ b/apps/openmw/mwrender/creatureanimation.cpp @@ -259,7 +259,8 @@ namespace MWRender void CreatureWeaponAnimation::addControllers() { Animation::addControllers(); - WeaponAnimation::addControllers(mNodeMap, mActiveControllers, mObjectRoot.get()); + if (mObjectRoot) + WeaponAnimation::addControllers(mNodeMap, mActiveControllers, mObjectRoot.get()); } osg::Vec3f CreatureWeaponAnimation::runAnimation(float duration) diff --git a/apps/openmw/mwrender/ripples.cpp b/apps/openmw/mwrender/ripples.cpp index 309fc7b305..ab982d0c55 100644 --- a/apps/openmw/mwrender/ripples.cpp +++ b/apps/openmw/mwrender/ripples.cpp @@ -49,18 +49,6 @@ namespace MWRender && exts.glslLanguageVersion >= minimumGLVersionRequiredForCompute; #endif - static bool pipelineLogged = false; - - if (!pipelineLogged) - { - if (mUseCompute) - Log(Debug::Info) << "Initialized compute shader pipeline for water ripples"; - else - Log(Debug::Info) << "Initialized fallback fragment shader pipeline for water ripples"; - - pipelineLogged = true; - } - for (size_t i = 0; i < mState.size(); ++i) { osg::ref_ptr stateset = new osg::StateSet; @@ -98,6 +86,18 @@ namespace MWRender else setupFragmentPipeline(); + if (mProgramBlobber != nullptr) + { + static bool pipelineLogged = [&] { + if (mUseCompute) + Log(Debug::Info) << "Initialized compute shader pipeline for water ripples"; + else + Log(Debug::Info) << "Initialized fallback fragment shader pipeline for water ripples"; + return true; + }(); + (void)pipelineLogged; + } + setCullCallback(new osg::NodeCallback); setUpdateCallback(new osg::NodeCallback); } @@ -109,21 +109,36 @@ namespace MWRender Shader::ShaderManager::DefineMap defineMap = { { "rippleMapSize", std::to_string(sRTTSize) + ".0" } }; osg::ref_ptr vertex = shaderManager.getShader("fullscreen_tri.vert", {}, osg::Shader::VERTEX); + osg::ref_ptr blobber + = shaderManager.getShader("ripples_blobber.frag", defineMap, osg::Shader::FRAGMENT); + osg::ref_ptr simulate + = shaderManager.getShader("ripples_simulate.frag", defineMap, osg::Shader::FRAGMENT); + if (vertex == nullptr || blobber == nullptr || simulate == nullptr) + { + Log(Debug::Error) << "Failed to load shaders required for fragment shader ripple pipeline"; + return; + } - mProgramBlobber = shaderManager.getProgram( - vertex, shaderManager.getShader("ripples_blobber.frag", defineMap, osg::Shader::FRAGMENT)); - mProgramSimulation = shaderManager.getProgram( - std::move(vertex), shaderManager.getShader("ripples_simulate.frag", defineMap, osg::Shader::FRAGMENT)); + mProgramBlobber = shaderManager.getProgram(vertex, std::move(blobber)); + mProgramSimulation = shaderManager.getProgram(std::move(vertex), std::move(simulate)); } void RipplesSurface::setupComputePipeline() { auto& shaderManager = mResourceSystem->getSceneManager()->getShaderManager(); - mProgramBlobber = shaderManager.getProgram( - nullptr, shaderManager.getShader("core/ripples_blobber.comp", {}, osg::Shader::COMPUTE)); - mProgramSimulation = shaderManager.getProgram( - nullptr, shaderManager.getShader("core/ripples_simulate.comp", {}, osg::Shader::COMPUTE)); + osg::ref_ptr blobber + = shaderManager.getShader("core/ripples_blobber.comp", {}, osg::Shader::COMPUTE); + osg::ref_ptr simulate + = shaderManager.getShader("core/ripples_simulate.comp", {}, osg::Shader::COMPUTE); + if (blobber == nullptr || simulate == nullptr) + { + Log(Debug::Error) << "Failed to load shaders required for compute shader ripple pipeline"; + return; + } + + mProgramBlobber = shaderManager.getProgram(nullptr, std::move(blobber)); + mProgramSimulation = shaderManager.getProgram(nullptr, std::move(simulate)); } void RipplesSurface::updateState(const osg::FrameStamp& frameStamp, State& state) @@ -191,6 +206,9 @@ namespace MWRender void RipplesSurface::drawImplementation(osg::RenderInfo& renderInfo) const { + if (mProgramBlobber == nullptr || mProgramSimulation == nullptr) + return; + osg::State& state = *renderInfo.getState(); const std::size_t currentFrame = state.getFrameStamp()->getFrameNumber() % 2; const State& frameState = mState[currentFrame]; diff --git a/apps/openmw/mwworld/containerstore.hpp b/apps/openmw/mwworld/containerstore.hpp index fb2722dde8..6bfbd78493 100644 --- a/apps/openmw/mwworld/containerstore.hpp +++ b/apps/openmw/mwworld/containerstore.hpp @@ -406,8 +406,7 @@ namespace MWWorld template ContainerStoreIteratorBase(const ContainerStoreIteratorBase& other) { - char CANNOT_CONVERT_CONST_ITERATOR_TO_ITERATOR[IsConvertible::value ? 1 : -1]; - ((void)CANNOT_CONVERT_CONST_ITERATOR_TO_ITERATOR); + static_assert(IsConvertible::value); copy(other); } diff --git a/apps/openmw/mwworld/worldimp.cpp b/apps/openmw/mwworld/worldimp.cpp index 7b2f178343..57d794c535 100644 --- a/apps/openmw/mwworld/worldimp.cpp +++ b/apps/openmw/mwworld/worldimp.cpp @@ -290,14 +290,14 @@ namespace MWWorld mSwimHeightScale = mStore.get().find("fSwimHeightScale")->mValue.getFloat(); } - void World::init(osgViewer::Viewer* viewer, osg::ref_ptr rootNode, SceneUtil::WorkQueue* workQueue, - SceneUtil::UnrefQueue& unrefQueue) + void World::init(Debug::Level maxRecastLogLevel, osgViewer::Viewer* viewer, osg::ref_ptr rootNode, + SceneUtil::WorkQueue* workQueue, SceneUtil::UnrefQueue& unrefQueue) { mPhysics = std::make_unique(mResourceSystem, rootNode); if (Settings::navigator().mEnable) { - auto navigatorSettings = DetourNavigator::makeSettingsFromSettingsManager(); + auto navigatorSettings = DetourNavigator::makeSettingsFromSettingsManager(maxRecastLogLevel); navigatorSettings.mRecast.mSwimHeightScale = mSwimHeightScale; mNavigator = DetourNavigator::makeNavigator(navigatorSettings, mUserDataPath); } diff --git a/apps/openmw/mwworld/worldimp.hpp b/apps/openmw/mwworld/worldimp.hpp index 6f06812e20..983682a98f 100644 --- a/apps/openmw/mwworld/worldimp.hpp +++ b/apps/openmw/mwworld/worldimp.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -201,8 +202,8 @@ namespace MWWorld Loading::Listener* listener); // Must be called after `loadData`. - void init(osgViewer::Viewer* viewer, osg::ref_ptr rootNode, SceneUtil::WorkQueue* workQueue, - SceneUtil::UnrefQueue& unrefQueue); + void init(Debug::Level maxRecastLogLevel, osgViewer::Viewer* viewer, osg::ref_ptr rootNode, + SceneUtil::WorkQueue* workQueue, SceneUtil::UnrefQueue& unrefQueue); virtual ~World(); diff --git a/apps/openmw_tests/main.cpp b/apps/openmw_tests/main.cpp index 6b7298596a..485298c863 100644 --- a/apps/openmw_tests/main.cpp +++ b/apps/openmw_tests/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include @@ -24,5 +25,9 @@ int main(int argc, char* argv[]) Settings::StaticValues::init(); testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); + + const int result = RUN_ALL_TESTS(); + if (result == 0) + std::filesystem::remove_all(TestingOpenMW::outputDir()); + return result; } diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index b6734e6fc6..7659de0ffd 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -59,7 +59,7 @@ list(APPEND COMPONENT_FILES "${OpenMW_BINARY_DIR}/${OSG_PLUGIN_CHECKER_CPP_FILE} add_component_dir (lua luastate scriptscontainer asyncpackage utilpackage serialization configuration l10n storage utf8 - shapes/box inputactions yamlloader scripttracker + shapes/box inputactions yamlloader scripttracker luastateptr ) add_component_dir (l10n diff --git a/components/config/gamesettings.cpp b/components/config/gamesettings.cpp index f318cec4a4..36373f8f35 100644 --- a/components/config/gamesettings.cpp +++ b/components/config/gamesettings.cpp @@ -1,6 +1,7 @@ #include "gamesettings.hpp" #include +#include #include #include @@ -37,8 +38,13 @@ void Config::GameSettings::validatePaths() mDataDirs.clear(); + QProgressDialog progressBar("Validating paths", {}, 0, paths.count() + 1); + progressBar.setWindowModality(Qt::WindowModal); + progressBar.setValue(0); + for (const auto& dataDir : paths) { + progressBar.setValue(progressBar.value() + 1); if (QDir(dataDir.value).exists()) { SettingValue copy = dataDir; @@ -50,6 +56,8 @@ void Config::GameSettings::validatePaths() // Do the same for data-local const QString& local = mSettings.value(QString("data-local")).value; + progressBar.setValue(progressBar.value() + 1); + if (!local.isEmpty() && QDir(local).exists()) { mDataLocal = QDir(local).canonicalPath(); diff --git a/components/contentselector/model/contentmodel.cpp b/components/contentselector/model/contentmodel.cpp index fe26d37b97..5f0d41d38c 100644 --- a/components/contentselector/model/contentmodel.cpp +++ b/components/contentselector/model/contentmodel.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -78,14 +79,10 @@ ContentSelectorModel::EsmFile* ContentSelectorModel::ContentModel::item(int row) } const ContentSelectorModel::EsmFile* ContentSelectorModel::ContentModel::item(const QString& name) const { - EsmFile::FileProperty fp = EsmFile::FileProperty_FileName; - - if (name.contains('/')) - fp = EsmFile::FileProperty_FilePath; - + bool path = name.contains('/'); for (const EsmFile* file : mFiles) { - if (name.compare(file->fileProperty(fp).toString(), Qt::CaseInsensitive) == 0) + if (name.compare(path ? file->filePath() : file->fileName(), Qt::CaseInsensitive) == 0) return file; } return nullptr; @@ -310,7 +307,6 @@ bool ContentSelectorModel::ContentModel::setData(const QModelIndex& index, const { setCheckState(file->filePath(), success); emit dataChanged(index, index); - checkForLoadOrderErrors(); } else return success; @@ -425,7 +421,6 @@ bool ContentSelectorModel::ContentModel::dropMimeData( dataChanged(index(minRow, 0), index(maxRow, 0)); // at this point we know that drag and drop has finished. - checkForLoadOrderErrors(); return true; } @@ -552,15 +547,13 @@ void ContentSelectorModel::ContentModel::addFiles(const QString& path, bool newf bool ContentSelectorModel::ContentModel::containsDataFiles(const QString& path) { - QDir dir(path); QStringList filters; filters << "*.esp" << "*.esm" << "*.omwgame" << "*.omwaddon"; - dir.setNameFilters(filters); - - return dir.entryList().count() != 0; + QDirIterator it(path, filters, QDir::Files | QDir::NoDotAndDotDot); + return it.hasNext(); } void ContentSelectorModel::ContentModel::clearFiles() @@ -707,12 +700,13 @@ void ContentSelectorModel::ContentModel::setNonUserContent(const QStringList& fi bool ContentSelectorModel::ContentModel::isLoadOrderError(const EsmFile* file) const { - return mPluginsWithLoadOrderError.contains(file->filePath()); + int index = indexFromItem(file).row(); + auto errors = checkForLoadOrderErrors(file, index); + return !errors.empty(); } void ContentSelectorModel::ContentModel::setContentList(const QStringList& fileList) { - mPluginsWithLoadOrderError.clear(); int previousPosition = -1; for (const QString& filepath : fileList) { @@ -725,7 +719,6 @@ void ContentSelectorModel::ContentModel::setContentList(const QStringList& fileL if (filePosition < previousPosition) { mFiles.move(filePosition, previousPosition); - emit dataChanged(index(filePosition, 0, QModelIndex()), index(previousPosition, 0, QModelIndex())); } else { @@ -733,24 +726,7 @@ void ContentSelectorModel::ContentModel::setContentList(const QStringList& fileL } } } - checkForLoadOrderErrors(); -} - -void ContentSelectorModel::ContentModel::checkForLoadOrderErrors() -{ - for (int row = 0; row < mFiles.count(); ++row) - { - EsmFile* file = mFiles.at(row); - bool isRowInError = checkForLoadOrderErrors(file, row).count() != 0; - if (isRowInError) - { - mPluginsWithLoadOrderError.insert(file->filePath()); - } - else - { - mPluginsWithLoadOrderError.remove(file->filePath()); - } - } + emit dataChanged(index(0, 0), index(rowCount(), columnCount())); } QList ContentSelectorModel::ContentModel::checkForLoadOrderErrors( @@ -791,11 +767,12 @@ QList ContentSelectorModel::ContentModel:: QString ContentSelectorModel::ContentModel::toolTip(const EsmFile* file) const { - if (isLoadOrderError(file)) + int index = indexFromItem(file).row(); + auto errors = checkForLoadOrderErrors(file, index); + if (!errors.empty()) { QString text(""); - int index = indexFromItem(item(file->filePath())).row(); - for (const LoadOrderError& error : checkForLoadOrderErrors(file, index)) + for (const LoadOrderError& error : errors) { assert(error.errorCode() != LoadOrderError::ErrorCode::ErrorCode_None); @@ -900,7 +877,6 @@ ContentSelectorModel::ContentFileList ContentSelectorModel::ContentModel::checke void ContentSelectorModel::ContentModel::uncheckAll() { - emit layoutAboutToBeChanged(); mCheckedFiles.clear(); - emit layoutChanged(); + emit dataChanged(index(0, 0), index(rowCount(), columnCount()), { Qt::CheckStateRole, Qt::UserRole + 1 }); } diff --git a/components/contentselector/model/contentmodel.hpp b/components/contentselector/model/contentmodel.hpp index 467a9c032a..3eba939125 100644 --- a/components/contentselector/model/contentmodel.hpp +++ b/components/contentselector/model/contentmodel.hpp @@ -69,9 +69,6 @@ namespace ContentSelectorModel void refreshModel(); - /// Checks all plug-ins for load order errors and updates mPluginsWithLoadOrderError with plug-ins with issues - void checkForLoadOrderErrors(); - private: void addFile(EsmFile* file); @@ -89,7 +86,6 @@ namespace ContentSelectorModel QStringList mNonUserContent; std::set mCheckedFiles; QHash mNewFiles; - QSet mPluginsWithLoadOrderError; QString mEncoding; QIcon mWarningIcon; QIcon mErrorIcon; diff --git a/components/contentselector/model/esmfile.hpp b/components/contentselector/model/esmfile.hpp index 28b4bd2822..d040dbd04d 100644 --- a/components/contentselector/model/esmfile.hpp +++ b/components/contentselector/model/esmfile.hpp @@ -48,18 +48,18 @@ namespace ContentSelectorModel void addGameFile(const QString& name) { mGameFiles.append(name); } QVariant fileProperty(const FileProperty prop) const; - QString fileName() const { return mFileName; } - QString author() const { return mAuthor; } + const QString& fileName() const { return mFileName; } + const QString& author() const { return mAuthor; } QDateTime modified() const { return mModified; } - QString formatVersion() const { return mVersion; } - QString filePath() const { return mPath; } + const QString& formatVersion() const { return mVersion; } + const QString& filePath() const { return mPath; } bool builtIn() const { return mBuiltIn; } bool fromAnotherConfigFile() const { return mFromAnotherConfigFile; } bool isMissing() const { return mPath.isEmpty(); } /// @note Contains file names, not paths. const QStringList& gameFiles() const { return mGameFiles; } - QString description() const { return mDescription; } + const QString& description() const { return mDescription; } QString toolTip() const { if (isMissing()) diff --git a/components/contentselector/view/contentselector.cpp b/components/contentselector/view/contentselector.cpp index bce136b335..0be6e7c023 100644 --- a/components/contentselector/view/contentselector.cpp +++ b/components/contentselector/view/contentselector.cpp @@ -211,7 +211,6 @@ void ContentSelectorView::ContentSelector::addFiles(const QString& path, bool ne ui->gameFileView->setCurrentIndex(0); mContentModel->uncheckAll(); - mContentModel->checkForLoadOrderErrors(); } void ContentSelectorView::ContentSelector::sortFiles() @@ -254,7 +253,6 @@ void ContentSelectorView::ContentSelector::slotCurrentGameFileIndexChanged(int i oldIndex = index; setGameFileSelected(index, true); - mContentModel->checkForLoadOrderErrors(); } emit signalCurrentGamefileIndexChanged(index); diff --git a/components/crashcatcher/windows_crashcatcher.cpp b/components/crashcatcher/windows_crashcatcher.cpp index 23dfac549c..d9ef00ef77 100644 --- a/components/crashcatcher/windows_crashcatcher.cpp +++ b/components/crashcatcher/windows_crashcatcher.cpp @@ -237,7 +237,7 @@ namespace Crash // must remain until monitor has finished waitMonitor(); - std::string message = "OpenMW has encountered a fatal error.\nCrash log saved to '" + std::string message = "OpenMW has encountered a fatal error.\nCrash dump saved to '" + Misc::StringUtils::u8StringToString(getCrashDumpPath(*mShm).u8string()) + "'.\nPlease report this to https://gitlab.com/OpenMW/openmw/issues !"; SDL_ShowSimpleMessageBox(0, "Fatal Error", message.c_str(), nullptr); diff --git a/components/crashcatcher/windows_crashcatcher.hpp b/components/crashcatcher/windows_crashcatcher.hpp index 89678c9ada..bcf1ed688d 100644 --- a/components/crashcatcher/windows_crashcatcher.hpp +++ b/components/crashcatcher/windows_crashcatcher.hpp @@ -21,6 +21,7 @@ namespace Crash // the main openmw process in task manager. static constexpr const int CrashCatcherTimeout = 2500; + static constexpr const int CrashCatcherThawTimeout = 250; struct CrashSHM; diff --git a/components/crashcatcher/windows_crashmonitor.cpp b/components/crashcatcher/windows_crashmonitor.cpp index 3708283efa..8398528ec9 100644 --- a/components/crashcatcher/windows_crashmonitor.cpp +++ b/components/crashcatcher/windows_crashmonitor.cpp @@ -87,9 +87,10 @@ namespace Crash SetEvent(mSignalAppEvent); } - bool CrashMonitor::waitApp() const + bool CrashMonitor::waitApp(bool thawMode) const { - return WaitForSingleObject(mSignalMonitorEvent, CrashCatcherTimeout) == WAIT_OBJECT_0; + return WaitForSingleObject(mSignalMonitorEvent, thawMode ? CrashCatcherThawTimeout : CrashCatcherTimeout) + == WAIT_OBJECT_0; } bool CrashMonitor::isAppAlive() const @@ -185,7 +186,7 @@ namespace Crash frozen = false; } - if (!mFreezeAbort && waitApp()) + if (!mFreezeAbort && waitApp(frozen)) { shmLock(); @@ -215,7 +216,7 @@ namespace Crash { handleCrash(true); TerminateProcess(mAppProcessHandle, 0xDEAD); - std::string message = "OpenMW appears to have frozen.\nCrash log saved to '" + std::string message = "OpenMW has frozen.\nCrash dump saved to '" + Misc::StringUtils::u8StringToString(getFreezeDumpPath(*mShm).u8string()) + "'.\nPlease report this to https://gitlab.com/OpenMW/openmw/issues !"; SDL_ShowSimpleMessageBox(0, "Fatal Error", message.c_str(), nullptr); @@ -289,10 +290,10 @@ namespace Crash { std::thread messageBoxThread([&]() { SDL_MessageBoxButtonData button = { SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT, 0, "Abort" }; - SDL_MessageBoxData messageBoxData = { SDL_MESSAGEBOX_ERROR, nullptr, "OpenMW appears to have frozen", - "OpenMW appears to have frozen. Press Abort to terminate it and generate a crash dump.\nIf OpenMW " - "hasn't actually frozen, this message box will disappear a within a few seconds of it becoming " - "responsive.", + SDL_MessageBoxData messageBoxData = { SDL_MESSAGEBOX_ERROR, nullptr, "OpenMW has frozen", + "OpenMW has frozen. This should never happen. Press Abort to terminate it and generate a crash dump to " + "help diagnose the problem.\nOpenMW may unfreeze if you wait, and this message box will disappear " + "after it becomes responsive.", 1, &button, nullptr }; int buttonId; diff --git a/components/crashcatcher/windows_crashmonitor.hpp b/components/crashcatcher/windows_crashmonitor.hpp index 16e173169e..25ee710fd3 100644 --- a/components/crashcatcher/windows_crashmonitor.hpp +++ b/components/crashcatcher/windows_crashmonitor.hpp @@ -41,7 +41,7 @@ namespace Crash void signalApp() const; - bool waitApp() const; + bool waitApp(bool thawMode) const; bool isAppAlive() const; diff --git a/components/debug/debugging.cpp b/components/debug/debugging.cpp index 32aec8f0fc..2ba7b2072e 100644 --- a/components/debug/debugging.cpp +++ b/components/debug/debugging.cpp @@ -106,94 +106,96 @@ namespace Debug logListener = std::move(listener); } - class DebugOutputBase : public boost::iostreams::sink - { - public: - virtual std::streamsize write(const char* str, std::streamsize size) - { - if (size <= 0) - return size; - std::string_view msg{ str, static_cast(size) }; - - // Skip debug level marker - Level level = All; - if (Log::sWriteLevel) - { - level = getLevelMarker(msg[0]); - msg = msg.substr(1); - } - - char prefix[32]; - std::size_t prefixSize; - { - prefix[0] = '['; - const auto now = std::chrono::system_clock::now(); - const auto time = std::chrono::system_clock::to_time_t(now); - tm time_info{}; -#ifdef _WIN32 - (void)localtime_s(&time_info, &time); -#else - (void)localtime_r(&time, &time_info); -#endif - prefixSize = std::strftime(prefix + 1, sizeof(prefix) - 1, "%T", &time_info) + 1; - char levelLetter = " EWIVD*"[int(level)]; - const auto ms = std::chrono::duration_cast(now.time_since_epoch()).count(); - prefixSize += snprintf(prefix + prefixSize, sizeof(prefix) - prefixSize, ".%03u %c] ", - static_cast(ms % 1000), levelLetter); - } - - while (!msg.empty()) - { - if (msg[0] == 0) - break; - size_t lineSize = 1; - while (lineSize < msg.size() && msg[lineSize - 1] != '\n') - lineSize++; - writeImpl(prefix, prefixSize, level); - writeImpl(msg.data(), lineSize, level); - if (logListener) - logListener(level, std::string_view(prefix, prefixSize), std::string_view(msg.data(), lineSize)); - msg = msg.substr(lineSize); - } - - return size; - } - - virtual ~DebugOutputBase() = default; - - protected: - static Level getLevelMarker(char marker) - { - if (0 <= marker && static_cast(marker) < static_cast(All)) - return static_cast(marker); - return All; - } - - virtual std::streamsize writeImpl(const char* str, std::streamsize size, Level debugLevel) - { - return size; - } - }; - -#if defined _WIN32 && defined _DEBUG - class DebugOutput : public DebugOutputBase - { - public: - std::streamsize writeImpl(const char* str, std::streamsize size, Level debugLevel) - { - // Make a copy for null termination - std::string tmp(str, static_cast(size)); - // Write string to Visual Studio Debug output - OutputDebugString(tmp.c_str()); - return size; - } - - virtual ~DebugOutput() = default; - }; -#else - namespace { + class DebugOutputBase : public boost::iostreams::sink + { + public: + virtual std::streamsize write(const char* str, std::streamsize size) + { + if (size <= 0) + return size; + std::string_view msg{ str, static_cast(size) }; + + // Skip debug level marker + Level level = All; + if (Log::sWriteLevel) + { + level = getLevelMarker(msg[0]); + msg = msg.substr(1); + } + + char prefix[32]; + std::size_t prefixSize; + { + prefix[0] = '['; + const auto now = std::chrono::system_clock::now(); + const auto time = std::chrono::system_clock::to_time_t(now); + tm time_info{}; +#ifdef _WIN32 + (void)localtime_s(&time_info, &time); +#else + (void)localtime_r(&time, &time_info); +#endif + prefixSize = std::strftime(prefix + 1, sizeof(prefix) - 1, "%T", &time_info) + 1; + char levelLetter = " EWIVD*"[int(level)]; + const auto ms + = std::chrono::duration_cast(now.time_since_epoch()).count(); + prefixSize += snprintf(prefix + prefixSize, sizeof(prefix) - prefixSize, ".%03u %c] ", + static_cast(ms % 1000), levelLetter); + } + + while (!msg.empty()) + { + if (msg[0] == 0) + break; + size_t lineSize = 1; + while (lineSize < msg.size() && msg[lineSize - 1] != '\n') + lineSize++; + writeImpl(prefix, prefixSize, level); + writeImpl(msg.data(), lineSize, level); + if (logListener) + logListener( + level, std::string_view(prefix, prefixSize), std::string_view(msg.data(), lineSize)); + msg = msg.substr(lineSize); + } + + return size; + } + + virtual ~DebugOutputBase() = default; + + protected: + static Level getLevelMarker(char marker) + { + if (0 <= marker && static_cast(marker) < static_cast(All)) + return static_cast(marker); + return All; + } + + virtual std::streamsize writeImpl(const char* str, std::streamsize size, Level debugLevel) + { + return size; + } + }; + +#if defined _WIN32 && defined _DEBUG + class DebugOutput : public DebugOutputBase + { + public: + std::streamsize writeImpl(const char* str, std::streamsize size, Level debugLevel) + { + // Make a copy for null termination + std::string tmp(str, static_cast(size)); + // Write string to Visual Studio Debug output + OutputDebugString(tmp.c_str()); + return size; + } + + virtual ~DebugOutput() = default; + }; +#else + struct Record { std::string mValue; @@ -324,22 +326,38 @@ namespace Debug First mFirst; Second mSecond; }; - } #endif - static std::unique_ptr rawStdout = nullptr; - static std::unique_ptr rawStderr = nullptr; - static std::unique_ptr rawStderrMutex = nullptr; - static std::ofstream logfile; + Level toLevel(std::string_view value) + { + if (value == "ERROR") + return Error; + if (value == "WARNING") + return Warning; + if (value == "INFO") + return Info; + if (value == "VERBOSE") + return Verbose; + if (value == "DEBUG") + return Debug; + + return Verbose; + } + + static std::unique_ptr rawStdout = nullptr; + static std::unique_ptr rawStderr = nullptr; + static std::unique_ptr rawStderrMutex = nullptr; + static std::ofstream logfile; #if defined(_WIN32) && defined(_DEBUG) - static boost::iostreams::stream_buffer sb; + static boost::iostreams::stream_buffer sb; #else - static boost::iostreams::stream_buffer> standardOut; - static boost::iostreams::stream_buffer> standardErr; - static boost::iostreams::stream_buffer> bufferedOut; - static boost::iostreams::stream_buffer> bufferedErr; + static boost::iostreams::stream_buffer> standardOut; + static boost::iostreams::stream_buffer> standardErr; + static boost::iostreams::stream_buffer> bufferedOut; + static boost::iostreams::stream_buffer> bufferedErr; #endif + } std::ostream& getRawStdout() { @@ -359,23 +377,19 @@ namespace Debug Level getDebugLevel() { if (const char* env = getenv("OPENMW_DEBUG_LEVEL")) - { - const std::string_view value(env); - if (value == "ERROR") - return Error; - if (value == "WARNING") - return Warning; - if (value == "INFO") - return Info; - if (value == "VERBOSE") - return Verbose; - if (value == "DEBUG") - return Debug; - } + return toLevel(env); return Verbose; } + Level getRecastMaxLogLevel() + { + if (const char* env = getenv("OPENMW_RECAST_MAX_LOG_LEVEL")) + return toLevel(env); + + return Error; + } + void setupLogging(const std::filesystem::path& logDir, std::string_view appName) { Log::sMinDebugLevel = getDebugLevel(); diff --git a/components/debug/debugging.hpp b/components/debug/debugging.hpp index 68cdec93a2..2f9d7aa2d7 100644 --- a/components/debug/debugging.hpp +++ b/components/debug/debugging.hpp @@ -36,6 +36,8 @@ namespace Debug Level getDebugLevel(); + Level getRecastMaxLogLevel(); + // Redirect cout and cerr to the log file void setupLogging(const std::filesystem::path& logDir, std::string_view appName); diff --git a/components/detournavigator/asyncnavmeshupdater.cpp b/components/detournavigator/asyncnavmeshupdater.cpp index 71c8bbc2d3..5f82c85cf9 100644 --- a/components/detournavigator/asyncnavmeshupdater.cpp +++ b/components/detournavigator/asyncnavmeshupdater.cpp @@ -453,9 +453,9 @@ namespace DetourNavigator Misc::setCurrentThreadIdlePriority(); while (!mShouldStop) { - try + if (JobIt job = getNextJob(); job != mJobs.end()) { - if (JobIt job = getNextJob(); job != mJobs.end()) + try { const JobStatus status = processJob(*job); Log(Debug::Debug) << "Processed job " << job->mId << " with status=" << status @@ -480,12 +480,20 @@ namespace DetourNavigator } } } - else - cleanupLastUpdates(); + catch (const std::exception& e) + { + Log(Debug::Warning) << "Failed to process navmesh job " << job->mId + << " for worldspace=" << job->mWorldspace << " agent=" << job->mAgentBounds + << " changedTile=(" << job->mChangedTile << ")" + << " changeType=" << job->mChangeType + << " by thread=" << std::this_thread::get_id() << ": " << e.what(); + unlockTile(job->mId, job->mAgentBounds, job->mChangedTile); + removeJob(job); + } } - catch (const std::exception& e) + else { - Log(Debug::Error) << "AsyncNavMeshUpdater::process exception: " << e.what(); + cleanupLastUpdates(); } } Log(Debug::Debug) << "Stop navigator jobs processing by thread=" << std::this_thread::get_id(); @@ -493,7 +501,8 @@ namespace DetourNavigator JobStatus AsyncNavMeshUpdater::processJob(Job& job) { - Log(Debug::Debug) << "Processing job " << job.mId << " for agent=(" << job.mAgentBounds << ")" + Log(Debug::Debug) << "Processing job " << job.mId << " for worldspace=" << job.mWorldspace + << " agent=" << job.mAgentBounds << "" << " changedTile=(" << job.mChangedTile << ")" << " changeType=" << job.mChangeType << " by thread=" << std::this_thread::get_id(); @@ -543,7 +552,14 @@ namespace DetourNavigator return JobStatus::Done; } - writeDebugRecastMesh(mSettings, job.mChangedTile, *recastMesh); + try + { + writeDebugRecastMesh(mSettings, job.mChangedTile, *recastMesh); + } + catch (const std::exception& e) + { + Log(Debug::Warning) << "Failed to write debug recast mesh: " << e.what(); + } NavMeshTilesCache::Value cachedNavMeshData = mNavMeshTilesCache.get(job.mAgentBounds, job.mChangedTile, *recastMesh); @@ -666,12 +682,19 @@ namespace DetourNavigator mPresentTiles.insert(std::make_tuple(job.mAgentBounds, job.mChangedTile)); } - writeDebugNavMesh(mSettings, navMeshCacheItem, navMeshVersion); + try + { + writeDebugNavMesh(mSettings, navMeshCacheItem, navMeshVersion); + } + catch (const std::exception& e) + { + Log(Debug::Warning) << "Failed to write debug navmesh: " << e.what(); + } return isSuccess(status) ? JobStatus::Done : JobStatus::Fail; } - JobIt AsyncNavMeshUpdater::getNextJob() + JobIt AsyncNavMeshUpdater::getNextJob() noexcept { std::unique_lock lock(mMutex); @@ -746,7 +769,7 @@ namespace DetourNavigator return mJobs.size(); } - void AsyncNavMeshUpdater::cleanupLastUpdates() + void AsyncNavMeshUpdater::cleanupLastUpdates() noexcept { const auto now = std::chrono::steady_clock::now(); diff --git a/components/detournavigator/asyncnavmeshupdater.hpp b/components/detournavigator/asyncnavmeshupdater.hpp index 7877ff8082..95919ed770 100644 --- a/components/detournavigator/asyncnavmeshupdater.hpp +++ b/components/detournavigator/asyncnavmeshupdater.hpp @@ -244,7 +244,7 @@ namespace DetourNavigator inline JobStatus handleUpdateNavMeshStatus(UpdateNavMeshStatus status, const Job& job, const GuardedNavMeshCacheItem& navMeshCacheItem, const RecastMesh& recastMesh); - JobIt getNextJob(); + inline JobIt getNextJob() noexcept; void postThreadJob(JobIt job, std::deque& queue); @@ -254,7 +254,7 @@ namespace DetourNavigator inline std::size_t getTotalJobs() const; - void cleanupLastUpdates(); + inline void cleanupLastUpdates() noexcept; inline void waitUntilJobsDoneForNotPresentTiles(Loading::Listener* listener); diff --git a/components/detournavigator/makenavmesh.cpp b/components/detournavigator/makenavmesh.cpp index f037da69f8..fe63d27a1e 100644 --- a/components/detournavigator/makenavmesh.cpp +++ b/components/detournavigator/makenavmesh.cpp @@ -523,7 +523,7 @@ namespace DetourNavigator std::unique_ptr prepareNavMeshTileData(const RecastMesh& recastMesh, ESM::RefId worldspace, const TilePosition& tilePosition, const AgentBounds& agentBounds, const RecastSettings& settings) { - RecastContext context(worldspace, tilePosition, agentBounds); + RecastContext context(worldspace, tilePosition, agentBounds, recastMesh.getVersion(), settings.mMaxLogLevel); const auto [minZ, maxZ] = getBoundsByZ(recastMesh, agentBounds.mHalfExtents.z(), settings); diff --git a/components/detournavigator/recastcontext.cpp b/components/detournavigator/recastcontext.cpp index 225f251f4d..732517e423 100644 --- a/components/detournavigator/recastcontext.cpp +++ b/components/detournavigator/recastcontext.cpp @@ -1,7 +1,7 @@ #include "recastcontext.hpp" #include "debug.hpp" -#include "components/debug/debuglog.hpp" +#include #include @@ -23,25 +23,30 @@ namespace DetourNavigator return Debug::Debug; } - std::string formatPrefix( - ESM::RefId worldspace, const TilePosition& tilePosition, const AgentBounds& agentBounds) + std::string formatPrefix(ESM::RefId worldspace, const TilePosition& tilePosition, + const AgentBounds& agentBounds, const Version& version) { std::ostringstream stream; stream << "Worldspace: " << worldspace << "; tile position: " << tilePosition.x() << ", " - << tilePosition.y() << "; agent bounds: " << agentBounds << "; "; + << tilePosition.y() << "; agent bounds: " << agentBounds << "; version: " << version << "; "; return stream.str(); } } - RecastContext::RecastContext( - ESM::RefId worldspace, const TilePosition& tilePosition, const AgentBounds& agentBounds) - : mPrefix(formatPrefix(worldspace, tilePosition, agentBounds)) + RecastContext::RecastContext(ESM::RefId worldspace, const TilePosition& tilePosition, + const AgentBounds& agentBounds, const Version& version, Debug::Level maxLogLevel) + : mMaxLogLevel(maxLogLevel) + , mPrefix(formatPrefix(worldspace, tilePosition, agentBounds, version)) { } void RecastContext::doLog(const rcLogCategory category, const char* msg, const int len) { - if (len > 0) - Log(getLogLevel(category)) << mPrefix << std::string_view(msg, static_cast(len)); + if (msg == nullptr || len <= 0) + return; + const Debug::Level level = getLogLevel(category); + if (level > mMaxLogLevel) + return; + Log(level) << mPrefix << std::string_view(msg, static_cast(len)); } } diff --git a/components/detournavigator/recastcontext.hpp b/components/detournavigator/recastcontext.hpp index 8e75f50b34..b36c4b9842 100644 --- a/components/detournavigator/recastcontext.hpp +++ b/components/detournavigator/recastcontext.hpp @@ -3,6 +3,7 @@ #include "tileposition.hpp" +#include #include #include @@ -12,15 +13,18 @@ namespace DetourNavigator { struct AgentBounds; + struct Version; class RecastContext final : public rcContext { public: - explicit RecastContext(ESM::RefId worldspace, const TilePosition& tilePosition, const AgentBounds& agentBounds); + explicit RecastContext(ESM::RefId worldspace, const TilePosition& tilePosition, const AgentBounds& agentBounds, + const Version& version, Debug::Level maxLogLevel); const std::string& getPrefix() const { return mPrefix; } private: + Debug::Level mMaxLogLevel; std::string mPrefix; void doLog(rcLogCategory category, const char* msg, int len) override; diff --git a/components/detournavigator/settings.cpp b/components/detournavigator/settings.cpp index 5e555050f7..d71b3d12bc 100644 --- a/components/detournavigator/settings.cpp +++ b/components/detournavigator/settings.cpp @@ -44,7 +44,7 @@ namespace DetourNavigator }; } - RecastSettings makeRecastSettingsFromSettingsManager() + RecastSettings makeRecastSettingsFromSettingsManager(Debug::Level maxLogLevel) { RecastSettings result; @@ -63,6 +63,7 @@ namespace DetourNavigator result.mRegionMergeArea = ::Settings::navigator().mRegionMergeArea; result.mRegionMinArea = ::Settings::navigator().mRegionMinArea; result.mTileSize = ::Settings::navigator().mTileSize; + result.mMaxLogLevel = maxLogLevel; return result; } @@ -80,11 +81,11 @@ namespace DetourNavigator } } - Settings makeSettingsFromSettingsManager() + Settings makeSettingsFromSettingsManager(Debug::Level maxLogLevel) { Settings result; - result.mRecast = makeRecastSettingsFromSettingsManager(); + result.mRecast = makeRecastSettingsFromSettingsManager(maxLogLevel); result.mDetour = makeDetourSettingsFromSettingsManager(); const NavMeshLimits limits = getNavMeshTileLimits(result.mDetour); diff --git a/components/detournavigator/settings.hpp b/components/detournavigator/settings.hpp index 1d1f6f5847..ecd9cf073b 100644 --- a/components/detournavigator/settings.hpp +++ b/components/detournavigator/settings.hpp @@ -1,6 +1,8 @@ #ifndef OPENMW_COMPONENTS_DETOURNAVIGATOR_SETTINGS_H #define OPENMW_COMPONENTS_DETOURNAVIGATOR_SETTINGS_H +#include + #include #include @@ -23,6 +25,7 @@ namespace DetourNavigator int mRegionMergeArea = 0; int mRegionMinArea = 0; int mTileSize = 0; + Debug::Level mMaxLogLevel = Debug::Error; }; struct DetourSettings @@ -55,7 +58,7 @@ namespace DetourNavigator inline constexpr std::int64_t navMeshFormatVersion = 2; - Settings makeSettingsFromSettingsManager(); + Settings makeSettingsFromSettingsManager(Debug::Level maxLogLevel); } #endif diff --git a/components/esm/luascripts.cpp b/components/esm/luascripts.cpp index 81cc867aff..f724331548 100644 --- a/components/esm/luascripts.cpp +++ b/components/esm/luascripts.cpp @@ -1,8 +1,9 @@ #include "luascripts.hpp" -#include "components/esm3/esmreader.hpp" -#include "components/esm3/esmwriter.hpp" +#include +#include +#include #include // List of all records, that are related to Lua. @@ -102,13 +103,16 @@ void ESM::LuaScriptsCfg::adjustRefNums(const ESMReader& esm) throw std::runtime_error("Incorrect contentFile index"); }; - lua_State* L = luaL_newstate(); + LuaUtil::LuaStatePtr state(luaL_newstate()); + if (state == nullptr) + throw std::runtime_error("Failed to create Lua runtime"); + LuaUtil::BasicSerializer serializer(adjustRefNumFn); auto adjustLuaData = [&](std::string& data) { if (data.empty()) return; - sol::object luaData = LuaUtil::deserialize(L, data, &serializer); + sol::object luaData = LuaUtil::deserialize(state.get(), data, &serializer); data = LuaUtil::serialize(luaData, &serializer); }; @@ -123,7 +127,6 @@ void ESM::LuaScriptsCfg::adjustRefNums(const ESMReader& esm) refCfg.mRefnumContentFile = adjustRefNumFn(refCfg.mRefnumContentFile); } } - lua_close(L); } void ESM::LuaScriptsCfg::save(ESMWriter& esm) const diff --git a/components/esm3/esmreader.cpp b/components/esm3/esmreader.cpp index 4f69b8edef..adb0d1c103 100644 --- a/components/esm3/esmreader.cpp +++ b/components/esm3/esmreader.cpp @@ -73,10 +73,10 @@ namespace ESM int index = getIndex(); for (int i = 0; i < getIndex(); i++) { - const ESM::ReadersCache::BusyItem reader = readers.get(static_cast(i)); - if (reader->getFileSize() == 0) + if (readers.getFileSize(static_cast(i)) == 0) continue; // Content file in non-ESM format - const auto fnamecandidate = Files::pathToUnicodeString(reader->getName().filename()); + const auto fnamecandidate + = Files::pathToUnicodeString(readers.getName(static_cast(i)).filename()); if (Misc::StringUtils::ciEqual(fname, fnamecandidate)) { index = i; diff --git a/components/esm3/readerscache.cpp b/components/esm3/readerscache.cpp index 732a2a22ba..34ceff5ffd 100644 --- a/components/esm3/readerscache.cpp +++ b/components/esm3/readerscache.cpp @@ -47,6 +47,7 @@ namespace ESM { it->mReader.open(*it->mName); it->mName.reset(); + it->mFileSize.reset(); } mBusyItems.splice(mBusyItems.end(), mClosedItems, it); break; @@ -57,6 +58,46 @@ namespace ESM return BusyItem(*this, it); } + const std::filesystem::path& ReadersCache::getName(std::size_t index) const + { + const auto indexIt = mIndex.find(index); + if (indexIt == mIndex.end()) + throw std::logic_error("ESMReader at index " + std::to_string(index) + " has not been created yet"); + switch (indexIt->second->mState) + { + case State::Busy: + case State::Free: + return indexIt->second->mReader.getName(); + case State::Closed: + if (indexIt->second->mName) + return *indexIt->second->mName; + throw std::logic_error("ESMReader at index " + std::to_string(index) + " has forgotten its filename"); + default: + throw std::logic_error("ESMReader at index " + std::to_string(index) + " in unknown state"); + } + } + + std::size_t ReadersCache::getFileSize(std::size_t index) + { + const auto indexIt = mIndex.find(index); + if (indexIt == mIndex.end()) + return 0; + switch (indexIt->second->mState) + { + case State::Busy: + case State::Free: + if (!indexIt->second->mReader.getName().empty()) + return indexIt->second->mReader.getFileSize(); + throw std::logic_error("ESMReader at index " + std::to_string(index) + " has not been opened yet"); + case State::Closed: + if (indexIt->second->mFileSize) + return *indexIt->second->mFileSize; + throw std::logic_error("ESMReader at index " + std::to_string(index) + " has forgotten its file size"); + default: + throw std::logic_error("ESMReader at index " + std::to_string(index) + " in unknown state"); + } + } + void ReadersCache::closeExtraReaders() { while (!mFreeItems.empty() && mBusyItems.size() + mFreeItems.size() + 1 > mCapacity) @@ -65,6 +106,7 @@ namespace ESM if (it->mReader.isOpen()) { it->mName = it->mReader.getName(); + it->mFileSize = it->mReader.getFileSize(); it->mReader.close(); } mClosedItems.splice(mClosedItems.end(), mFreeItems, it); diff --git a/components/esm3/readerscache.hpp b/components/esm3/readerscache.hpp index 80ca5d1ef3..3f6ac01874 100644 --- a/components/esm3/readerscache.hpp +++ b/components/esm3/readerscache.hpp @@ -26,6 +26,7 @@ namespace ESM State mState = State::Busy; ESMReader mReader; std::optional mName; + std::optional mFileSize; Item() = default; }; @@ -55,6 +56,10 @@ namespace ESM BusyItem get(std::size_t index); + const std::filesystem::path& getName(std::size_t index) const; + + std::size_t getFileSize(std::size_t index); + void clear(); private: diff --git a/components/esm4/loadqust.cpp b/components/esm4/loadqust.cpp index 27c23d92f1..bdbc94e07a 100644 --- a/components/esm4/loadqust.cpp +++ b/components/esm4/loadqust.cpp @@ -114,6 +114,9 @@ void ESM4::Quest::load(ESM4::Reader& reader) case ESM::fourCC("NNAM"): // FO3 case ESM::fourCC("QOBJ"): // FO3 case ESM::fourCC("NAM0"): // FO3 + case ESM::fourCC("SLSD"): // FO3 + case ESM::fourCC("SCVR"): // FO3 + case ESM::fourCC("SCRV"): // FO3 case ESM::fourCC("ANAM"): // TES5 case ESM::fourCC("DNAM"): // TES5 case ESM::fourCC("ENAM"): // TES5 diff --git a/components/fallback/validate.hpp b/components/fallback/validate.hpp index b48dff50d1..9540c85654 100644 --- a/components/fallback/validate.hpp +++ b/components/fallback/validate.hpp @@ -24,8 +24,6 @@ namespace Fallback }; // Parses and validates a fallback map from boost program_options. - // Note: for boost to pick up the validate function, you need to pull in the namespace e.g. - // by using namespace Fallback; void validate(boost::any& v, std::vector const& tokens, FallbackMap*, int); } diff --git a/components/files/windowspath.cpp b/components/files/windowspath.cpp index bbe0325b58..6fb9845976 100644 --- a/components/files/windowspath.cpp +++ b/components/files/windowspath.cpp @@ -36,12 +36,14 @@ namespace Files { std::filesystem::path userPath = std::filesystem::current_path(); - WCHAR path[MAX_PATH + 1] = {}; + PWSTR cString; + HRESULT result = SHGetKnownFolderPath(FOLDERID_Documents, 0, nullptr, &cString); + if (SUCCEEDED(result)) + userPath = std::filesystem::path(cString); + else + Log(Debug::Error) << "Error " << result << " when getting Documents path"; - if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_PERSONAL | CSIDL_FLAG_CREATE, nullptr, 0, path))) - { - userPath = std::filesystem::path(path); - } + CoTaskMemFree(cString); return userPath / "My Games" / mName; } @@ -54,14 +56,19 @@ namespace Files std::filesystem::path WindowsPath::getGlobalConfigPath() const { + // The concept of a global config path is absurd on Windows. + // Always use local config instead. + // The virtual base class requires that we provide this, though. std::filesystem::path globalPath = std::filesystem::current_path(); - WCHAR path[MAX_PATH + 1] = {}; + PWSTR cString; + HRESULT result = SHGetKnownFolderPath(FOLDERID_ProgramFiles, 0, nullptr, &cString); + if (SUCCEEDED(result)) + globalPath = std::filesystem::path(cString); + else + Log(Debug::Error) << "Error " << result << " when getting Program Files path"; - if (SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_PROGRAM_FILES | CSIDL_FLAG_CREATE, nullptr, 0, path))) - { - globalPath = std::filesystem::path(path); - } + CoTaskMemFree(cString); return globalPath / mName; } diff --git a/components/fontloader/fontloader.cpp b/components/fontloader/fontloader.cpp index 637967e510..c9003f3aa8 100644 --- a/components/fontloader/fontloader.cpp +++ b/components/fontloader/fontloader.cpp @@ -227,9 +227,10 @@ namespace namespace Gui { - FontLoader::FontLoader(ToUTF8::FromType encoding, const VFS::Manager* vfs, float scalingFactor) + FontLoader::FontLoader(ToUTF8::FromType encoding, const VFS::Manager* vfs, float scalingFactor, bool exportFonts) : mVFS(vfs) , mScalingFactor(scalingFactor) + , mExportFonts(exportFonts) { if (encoding == ToUTF8::WINDOWS_1252) mEncoding = ToUTF8::CP437; @@ -407,7 +408,8 @@ namespace Gui file.reset(); // Create the font texture - std::string bitmapFilename = "fonts/" + std::string(name_) + ".tex"; + const std::string name(name_); + const std::string bitmapFilename = "fonts/" + name + ".tex"; Files::IStreamPtr bitmapFile = mVFS->get(bitmapFilename); @@ -429,6 +431,19 @@ namespace Gui << bitmapFile->gcount() << "/" << (width * height * 4) << " bytes)"; bitmapFile.reset(); + if (mExportFonts) + { + osg::ref_ptr image = new osg::Image; + image->allocateImage(width, height, 1, GL_RGBA, GL_UNSIGNED_BYTE); + assert(image->isDataContiguous()); + memcpy(image->data(), textureData.data(), textureData.size()); + // Convert to OpenGL origin for sensible output + image->flipVertical(); + + Log(Debug::Info) << "Writing " << name + ".png"; + osgDB::writeImageFile(*image, name + ".png"); + } + MyGUI::ITexture* tex = MyGUI::RenderManager::getInstance().createTexture(bitmapFilename); tex->createManual(width, height, MyGUI::TextureUsage::Write, MyGUI::PixelFormat::R8G8B8A8); unsigned char* texData = reinterpret_cast(tex->lock(MyGUI::TextureUsage::Write)); @@ -626,6 +641,13 @@ namespace Gui code->addAttribute("size", "0 0"); } + if (mExportFonts) + { + Log(Debug::Info) << "Writing " << name + ".xml"; + xmlDocument.createDeclaration(); + xmlDocument.save(name + ".xml"); + } + // Register the font with MyGUI MyGUI::ResourceManualFont* font = static_cast( MyGUI::FactoryManager::getInstance().createObject("Resource", "ResourceManualFont")); diff --git a/components/fontloader/fontloader.hpp b/components/fontloader/fontloader.hpp index 7e9220d58d..a7269f1a56 100644 --- a/components/fontloader/fontloader.hpp +++ b/components/fontloader/fontloader.hpp @@ -25,7 +25,8 @@ namespace Gui class FontLoader { public: - FontLoader(ToUTF8::FromType encoding, const VFS::Manager* vfs, float scalingFactor); + /// @param exportFonts export the converted fonts (Images and XML with glyph metrics) to files? + FontLoader(ToUTF8::FromType encoding, const VFS::Manager* vfs, float scalingFactor, bool exportFonts); void overrideLineHeight(MyGUI::xml::ElementPtr _node, std::string_view _file, MyGUI::Version _version); @@ -35,6 +36,7 @@ namespace Gui ToUTF8::FromType mEncoding; const VFS::Manager* mVFS; float mScalingFactor; + bool mExportFonts; void loadFonts(); void loadFont(const std::string& fontName, const std::string& fontId); diff --git a/components/lua/luastate.cpp b/components/lua/luastate.cpp index de876f2c5c..f959263153 100644 --- a/components/lua/luastate.cpp +++ b/components/lua/luastate.cpp @@ -11,6 +11,7 @@ #include #include +#include "luastateptr.hpp" #include "scriptscontainer.hpp" #include "utf8.hpp" @@ -151,37 +152,37 @@ namespace LuaUtil return newPtr; } - lua_State* LuaState::createLuaRuntime(LuaState* luaState) + LuaStatePtr LuaState::createLuaRuntime(LuaState* luaState) { if (sProfilerEnabled) { Log(Debug::Info) << "Initializing LuaUtil::LuaState with profiler"; - lua_State* L = lua_newstate(&trackingAllocator, luaState); - if (L) - return L; - else - { - sProfilerEnabled = false; - Log(Debug::Error) - << "Failed to initialize LuaUtil::LuaState with custom allocator; disabling Lua profiler"; - } + LuaStatePtr state(lua_newstate(&trackingAllocator, luaState)); + if (state != nullptr) + return state; + sProfilerEnabled = false; + Log(Debug::Error) << "Failed to initialize LuaUtil::LuaState with custom allocator; disabling Lua profiler"; } Log(Debug::Info) << "Initializing LuaUtil::LuaState without profiler"; - lua_State* L = luaL_newstate(); - if (!L) - throw std::runtime_error("Can't create Lua runtime"); - return L; + LuaStatePtr state(luaL_newstate()); + if (state == nullptr) + throw std::runtime_error("Failed to create Lua runtime"); + return state; } LuaState::LuaState(const VFS::Manager* vfs, const ScriptsConfiguration* conf, const LuaStateSettings& settings) : mSettings(settings) - , mLuaHolder(createLuaRuntime(this)) - , mSol(mLuaHolder.get()) + , mLuaState([&] { + LuaStatePtr state = createLuaRuntime(this); + sol::set_default_state(state.get()); + return state; + }()) + , mSol(mLuaState.get()) , mConf(conf) , mVFS(vfs) { if (sProfilerEnabled) - lua_sethook(mLuaHolder.get(), &countHook, LUA_MASKCOUNT, countHookStep); + lua_sethook(mLuaState.get(), &countHook, LUA_MASKCOUNT, countHookStep); protectedCall([&](LuaView& view) { auto& sol = view.sol(); diff --git a/components/lua/luastate.hpp b/components/lua/luastate.hpp index cf8e62690a..d842478cb1 100644 --- a/components/lua/luastate.hpp +++ b/components/lua/luastate.hpp @@ -10,6 +10,7 @@ #include #include "configuration.hpp" +#include "luastateptr.hpp" namespace VFS { @@ -188,7 +189,7 @@ namespace LuaUtil static void countHook(lua_State* L, lua_Debug* ar); static void* trackingAllocator(void* ud, void* ptr, size_t osize, size_t nsize); - lua_State* createLuaRuntime(LuaState* luaState); + static LuaStatePtr createLuaRuntime(LuaState* luaState); struct AllocOwner { @@ -206,25 +207,8 @@ namespace LuaUtil uint64_t mSmallAllocMemoryUsage = 0; std::vector mMemoryUsage; - class LuaStateHolder - { - public: - LuaStateHolder(lua_State* L) - : L(L) - { - sol::set_default_state(L); - } - ~LuaStateHolder() { lua_close(L); } - LuaStateHolder(const LuaStateHolder&) = delete; - LuaStateHolder(LuaStateHolder&&) = delete; - lua_State* get() { return L; } - - private: - lua_State* L; - }; - // Must be declared before mSol and all sol-related objects. Then on exit it will be destructed the last. - LuaStateHolder mLuaHolder; + LuaStatePtr mLuaState; sol::state_view mSol; const ScriptsConfiguration* mConf; diff --git a/components/lua/luastateptr.hpp b/components/lua/luastateptr.hpp new file mode 100644 index 0000000000..09733b0754 --- /dev/null +++ b/components/lua/luastateptr.hpp @@ -0,0 +1,18 @@ +#ifndef OPENMW_COMPONENTS_LUA_LUASTATEPTR_H +#define OPENMW_COMPONENTS_LUA_LUASTATEPTR_H + +#include + +#include + +namespace LuaUtil +{ + struct CloseLuaState + { + void operator()(lua_State* state) noexcept { lua_close(state); } + }; + + using LuaStatePtr = std::unique_ptr; +} + +#endif diff --git a/components/nifosg/nifloader.cpp b/components/nifosg/nifloader.cpp index d817ed2c9f..07eb342221 100644 --- a/components/nifosg/nifloader.cpp +++ b/components/nifosg/nifloader.cpp @@ -391,6 +391,7 @@ namespace NifOsg osg::ref_ptr skel = new SceneUtil::Skeleton; skel->setStateSet(created->getStateSet()); skel->setName(created->getName()); + skel->setUserDataContainer(created->getUserDataContainer()); for (unsigned int i = 0; i < created->getNumChildren(); ++i) skel->addChild(created->getChild(i)); created->removeChildren(0, created->getNumChildren()); diff --git a/components/sceneutil/lightutil.cpp b/components/sceneutil/lightutil.cpp index 08992b1ae3..90dc8237f2 100644 --- a/components/sceneutil/lightutil.cpp +++ b/components/sceneutil/lightutil.cpp @@ -119,7 +119,8 @@ namespace SceneUtil osg::ref_ptr light(new osg::Light); lightSource->setNodeMask(lightMask); - float radius = esmLight.mRadius; + // The minimum scene light radius is 16 in Morrowind + const float radius = std::max(esmLight.mRadius, 16.f); lightSource->setRadius(radius); configureLight(light, radius, isExterior); diff --git a/components/stereo/frustum.hpp b/components/stereo/frustum.hpp index 35e3adf95a..56740c125a 100644 --- a/components/stereo/frustum.hpp +++ b/components/stereo/frustum.hpp @@ -2,10 +2,7 @@ #define STEREO_FRUSTUM_H #include -#include #include -#include -#include #include #include @@ -13,6 +10,7 @@ namespace osg { + class Camera; class FrameBufferObject; class Texture2D; class Texture2DMultisample; @@ -24,7 +22,7 @@ namespace osgViewer class Viewer; } -namespace usgUtil +namespace osgUtil { class CullVisitor; } diff --git a/components/testing/util.hpp b/components/testing/util.hpp index 941f495458..53331d6d37 100644 --- a/components/testing/util.hpp +++ b/components/testing/util.hpp @@ -1,6 +1,7 @@ #ifndef OPENMW_COMPONENTS_TESTING_UTIL_H #define OPENMW_COMPONENTS_TESTING_UTIL_H +#include #include #include #include @@ -14,26 +15,37 @@ namespace TestingOpenMW { - inline std::filesystem::path outputFilePath(const std::string name) + inline std::filesystem::path outputDir() { - std::filesystem::path dir("tests_output"); - std::filesystem::create_directory(dir); + static const std::string run + = std::to_string(std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())); + std::filesystem::path dir = std::filesystem::temp_directory_path() / "openmw" / "tests" / run; + std::filesystem::create_directories(dir); + return dir; + } + + inline std::filesystem::path outputFilePath(std::string_view name) + { + std::filesystem::path dir = outputDir(); return dir / Misc::StringUtils::stringToU8String(name); } + inline std::filesystem::path outputDirPath(const std::filesystem::path& subpath) + { + std::filesystem::path path = outputDir(); + path /= subpath; + std::filesystem::create_directories(path); + return path; + } + inline std::filesystem::path outputFilePathWithSubDir(const std::filesystem::path& subpath) { - std::filesystem::path path("tests_output"); + std::filesystem::path path = outputDir(); path /= subpath; std::filesystem::create_directories(path.parent_path()); return path; } - inline std::filesystem::path temporaryFilePath(const std::string name) - { - return std::filesystem::temp_directory_path() / name; - } - class VFSTestFile : public VFS::File { public: diff --git a/docs/source/manuals/installation/install-openmw.rst b/docs/source/manuals/installation/install-openmw.rst index 50efe7542d..2ef72abfd5 100644 --- a/docs/source/manuals/installation/install-openmw.rst +++ b/docs/source/manuals/installation/install-openmw.rst @@ -28,13 +28,8 @@ A `Launchpad PPA `_ is available. Add it and install OpenMW:: $ sudo add-apt-repository ppa:openmw/openmw - $ sudo apt-get update - $ sudo apt-get install openmw openmw-launcher - -.. note:: - OpenMW-CS must be installed separately by typing:: - - $ sudo apt-get install openmw-cs + $ sudo apt update + $ sudo apt install openmw The Arch Linux Way ================== diff --git a/docs/source/reference/modding/font.rst b/docs/source/reference/modding/font.rst index ae2a457e44..2c67a940d1 100644 --- a/docs/source/reference/modding/font.rst +++ b/docs/source/reference/modding/font.rst @@ -15,6 +15,8 @@ Morrowind .fnt fonts Morrowind uses a custom ``.fnt`` file format. It is not compatible with the Windows Font File ``.fnt`` format. To our knowledge, the format is undocumented. OpenMW can load this format and convert it on the fly into something usable (see font loader `source code `_). +You can use --export-fonts command line option to write the converted font +(a PNG image and an XML file describing the position of each glyph in the image) to the current directory. They can be used instead of TrueType fonts if needed by specifying their ``.fnt`` files names in the ``openmw.cfg``. For example: diff --git a/extern/.clang-tidy b/extern/.clang-tidy new file mode 100644 index 0000000000..bc766e0a9e --- /dev/null +++ b/extern/.clang-tidy @@ -0,0 +1,3 @@ +Checks: >- + -clang-analyzer-core.NullDereference, + -clang-analyzer-cplusplus.NewDelete diff --git a/files/data/fonts/MysticCards.omwfont b/files/data/fonts/MysticCards.omwfont index 3458614ddc..b0dccb1445 100644 --- a/files/data/fonts/MysticCards.omwfont +++ b/files/data/fonts/MysticCards.omwfont @@ -2,13 +2,13 @@ - + - + diff --git a/files/data/fonts/MysticCards.ttf b/files/data/fonts/MysticCards.ttf index 1b93f5aadc..c98597d749 100644 Binary files a/files/data/fonts/MysticCards.ttf and b/files/data/fonts/MysticCards.ttf differ diff --git a/files/data/scripts/omw/crimes.lua b/files/data/scripts/omw/crimes.lua index 58e043964b..8539471973 100644 --- a/files/data/scripts/omw/crimes.lua +++ b/files/data/scripts/omw/crimes.lua @@ -24,7 +24,7 @@ return { interface = { --- Interface version -- @field [parent=#Crimes] #number version - version = 1, + version = 2, --- -- Commits a crime as if done through an in-game action. Can only be used in global context. @@ -42,7 +42,7 @@ return { "faction id passed to commitCrime must be a string or nil") assert(type(options.arg) == "number" or options.arg == nil, "arg value passed to commitCrime must be a number or nil") - assert(type(options.victimAware) == "number" or options.victimAware == nil, + assert(type(options.victimAware) == "boolean" or options.victimAware == nil, "victimAware value passed to commitCrime must be a boolean or nil") assert(options.type ~= nil, "crime type passed to commitCrime cannot be nil") diff --git a/files/lua_api/openmw/animation.lua b/files/lua_api/openmw/animation.lua index 46f3ff20fc..d15241afff 100644 --- a/files/lua_api/openmw/animation.lua +++ b/files/lua_api/openmw/animation.lua @@ -221,14 +221,14 @@ -- Can be used only in local scripts on self. Can also be evoked by sending an AddVfx event to the target actor. -- @function [parent=#animation] addVfx -- @param openmw.core#GameObject actor --- @param #string model #string model path (normally taken from a record such as @{openmw.types#StaticRecord.model} or similar) +-- @param #string model path (normally taken from a record such as @{openmw.types#StaticRecord.model} or similar) -- @param #table options optional table of parameters. Can contain: -- --- * `loop` - boolean, if true the effect will loop until removed (default: 0). +-- * `loop` - boolean, if true the effect will loop until removed (default: false). -- * `boneName` - name of the bone to attach the vfx to. (default: "") -- * `particleTextureOverride` - name of the particle texture to use. (default: "") -- * `vfxId` - a string ID that can be used to remove the effect later, using #removeVfx, and to avoid duplicate effects. The default value of "" can have duplicates. To avoid interaction with the engine, use unique identifiers unrelated to magic effect IDs. The engine uses this identifier to add and remove magic effects based on what effects are active on the actor. If this is set equal to the @{openmw.core#MagicEffectId} identifier of the magic effect being added, for example core.magic.EFFECT_TYPE.FireDamage, then the engine will remove it once the fire damage effect on the actor reaches 0. (Default: ""). --- * `useAmbientLighting` - boolean, vfx get a white ambient light attached in Morrowind. If false don't attach this. (default: 1) +-- * `useAmbientLighting` - boolean, vfx get a white ambient light attached in Morrowind. If false don't attach this. (default: true) -- -- @usage local mgef = core.magic.effects.records[myEffectName] -- anim.addVfx(self, 'VFX_Hands', {boneName = 'Bip01 L Hand', particleTextureOverride = mgef.particle, loop = mgef.continuousVfx, vfxId = mgef.id..'_myuniquenamehere'}) diff --git a/files/lua_api/openmw/nearby.lua b/files/lua_api/openmw/nearby.lua index ea1b8738bc..f2a3dbbc37 100644 --- a/files/lua_api/openmw/nearby.lua +++ b/files/lua_api/openmw/nearby.lua @@ -59,7 +59,7 @@ -- @field [parent=#nearby] #COLLISION_TYPE COLLISION_TYPE --- --- Result of raycasing +-- Result of raycasting -- @type RayCastingResult -- @field [parent=#RayCastingResult] #boolean hit Is there a collision? (true/false) -- @field [parent=#RayCastingResult] openmw.util#Vector3 hitPos Position of the collision point (nil if no collision) @@ -69,7 +69,7 @@ --- -- A table of parameters for @{#nearby.castRay} -- @type CastRayOptions --- @field openmw.core#GameObject ignore An object to ignore (specify here the source of the ray) +-- @field #any ignore An @{openmw.core#GameObject} or @{openmw.core#ObjectList} to ignore (specify here the source of the ray, or other objects which should not collide) -- @field #number collisionType Object types to work with (see @{openmw.nearby#COLLISION_TYPE}) -- @field #number radius The radius of the ray (zero by default). If not zero then castRay actually casts a sphere with given radius. -- NOTE: currently `ignore` is not supported if `radius>0`. @@ -92,7 +92,7 @@ --- -- A table of parameters for @{#nearby.castRenderingRay} and @{#nearby.asyncCastRenderingRay} -- @type CastRenderingRayOptions --- @field #table ignore A list of @{openmw.core#GameObject} to ignore while doing the ray cast +-- @field #any ignore A @{openmw.core#GameObject} or @{openmw.core#ObjectList} to ignore while doing the ray cast --- -- Cast ray from one point to another and find the first visual intersection with anything in the scene. @@ -102,7 +102,7 @@ -- @function [parent=#nearby] castRenderingRay -- @param openmw.util#Vector3 from Start point of the ray. -- @param openmw.util#Vector3 to End point of the ray. --- @param #CastRenderingRayOptions +-- @param #CastRenderingRayOptions options An optional table with additional optional arguments -- @return #RayCastingResult --- diff --git a/files/lua_api/openmw/self.lua b/files/lua_api/openmw/self.lua index 4da5fa3ee9..3a45bd68d8 100644 --- a/files/lua_api/openmw/self.lua +++ b/files/lua_api/openmw/self.lua @@ -45,7 +45,7 @@ -- @field [parent=#ActorControls] #boolean run true - run, false - walk -- @field [parent=#ActorControls] #boolean sneak If true - sneak -- @field [parent=#ActorControls] #boolean jump If true - initiate a jump --- @field [parent=#ActorControls] #ATTACK_TYPE use Activates the readied weapon/spell according to a provided value. For weapons, keeping this value modified will charge the attack until set to @{#ATTACK_TYPE.NoAttack}. If an @#ATTACK_TYPE} not appropriate for a currently equipped weapon provided - an appropriate @{#ATTACK_TYPE} will be used instead. +-- @field [parent=#ActorControls] #ATTACK_TYPE use Activates the readied weapon/spell according to a provided value. For weapons, keeping this value modified will charge the attack until set to @{#ATTACK_TYPE.NoAttack}. If an @{#ATTACK_TYPE} not appropriate for a currently equipped weapon provided - an appropriate @{#ATTACK_TYPE} will be used instead. --- -- Enables or disables standard AI (enabled by default). diff --git a/files/lua_api/openmw/types.lua b/files/lua_api/openmw/types.lua index e1b814f8b3..0012a51c41 100644 --- a/files/lua_api/openmw/types.lua +++ b/files/lua_api/openmw/types.lua @@ -1123,7 +1123,7 @@ -- @field #string id The record ID of the NPC -- @field #string name -- @field #string race --- @field #string class Name of the NPC's class (e. g. Acrobat) +-- @field #string class ID of the NPC's class (e.g. acrobat) -- @field #string model Path to the model associated with this NPC, used for animations. -- @field #string mwscript MWScript on this NPC (can be nil) -- @field #string hair Path to the hair body part model diff --git a/scripts/data/integration_tests/test_lua_api/test.lua b/scripts/data/integration_tests/test_lua_api/global.lua similarity index 79% rename from scripts/data/integration_tests/test_lua_api/test.lua rename to scripts/data/integration_tests/test_lua_api/global.lua index ff2cd9bb33..225660b858 100644 --- a/scripts/data/integration_tests/test_lua_api/test.lua +++ b/scripts/data/integration_tests/test_lua_api/global.lua @@ -7,7 +7,7 @@ local vfs = require('openmw.vfs') local world = require('openmw.world') local I = require('openmw.interfaces') -local function testTimers() +testing.registerGlobalTest('timers', function() testing.expectAlmostEqual(core.getGameTimeScale(), 30, 'incorrect getGameTimeScale() result') testing.expectAlmostEqual(core.getSimulationTimeScale(), 1, 'incorrect getSimulationTimeScale result') @@ -39,9 +39,10 @@ local function testTimers() testing.expectGreaterOrEqual(ts1, 0.5, 'async:newSimulationTimer failed') testing.expectGreaterOrEqual(th2, 72, 'async:newUnsavableGameTimer failed') testing.expectGreaterOrEqual(ts2, 1, 'async:newUnsavableSimulationTimer failed') -end +end) -local function testTeleport() +testing.registerGlobalTest('teleport', function() + local player = world.players[1] player:teleport('', util.vector3(100, 50, 500), util.transform.rotateZ(math.rad(90))) coroutine.yield() testing.expect(player.cell.isExterior, 'teleport to exterior failed') @@ -71,16 +72,16 @@ local function testTeleport() testing.expectEqualWithDelta(player.position.x, 50, 1, 'incorrect position after teleporting') testing.expectEqualWithDelta(player.position.y, -100, 1, 'incorrect position after teleporting') testing.expectEqualWithDelta(player.rotation:getYaw(), math.rad(-90), 0.05, 'teleporting changes rotation') -end +end) -local function testGetGMST() +testing.registerGlobalTest('getGMST', function() testing.expectEqual(core.getGMST('non-existed gmst'), nil) testing.expectEqual(core.getGMST('Water_RippleFrameCount'), 4) testing.expectEqual(core.getGMST('Inventory_DirectionalDiffuseR'), 0.5) testing.expectEqual(core.getGMST('Level_Up_Level2'), 'something') -end +end) -local function testMWScript() +testing.registerGlobalTest('MWScript', function() local variableStoreCount = 18 local variableStore = world.mwscript.getGlobalVariables(player) testing.expectEqual(variableStoreCount, #variableStore) @@ -100,7 +101,7 @@ local function testMWScript() indexCheck = indexCheck + 1 end testing.expectEqual(variableStoreCount, indexCheck) -end +end) local function testRecordStore(store, storeName, skipPairs) testing.expect(store.records) @@ -121,7 +122,7 @@ local function testRecordStore(store, storeName, skipPairs) testing.expectEqual(status, true, storeName) end -local function testRecordStores() +testing.registerGlobalTest('record stores', function() for key, type in pairs(types) do if type.records then testRecordStore(type, key) @@ -140,9 +141,9 @@ local function testRecordStores() testRecordStore(types.NPC.classes, "classes") testRecordStore(types.NPC.races, "races") testRecordStore(types.Player.birthSigns, "birthSigns") -end +end) -local function testRecordCreation() +testing.registerGlobalTest('record creation', function() local newLight = { isCarriable = true, isDynamic = true, @@ -165,9 +166,9 @@ local function testRecordCreation() for key, value in pairs(newLight) do testing.expectEqual(record[key], value) end -end +end) -local function testUTF8Chars() +testing.registerGlobalTest('UTF-8 characters', function() testing.expectEqual(utf8.codepoint("😀"), 0x1F600) local chars = {} @@ -192,9 +193,9 @@ local function testUTF8Chars() testing.expectEqual(utf8.codepoint(char), codepoint) testing.expectEqual(utf8.len(char), 1) end -end +end) -local function testUTF8Strings() +testing.registerGlobalTest('UTF-8 strings', function() local utf8str = "Hello, 你好, 🌎!" local str = "" @@ -205,9 +206,9 @@ local function testUTF8Strings() testing.expectEqual(utf8.len(utf8str), 13) testing.expectEqual(utf8.offset(utf8str, 9), 11) -end +end) -local function testMemoryLimit() +testing.registerGlobalTest('memory limit', function() local ok, err = pcall(function() local t = {} local n = 1 @@ -218,14 +219,16 @@ local function testMemoryLimit() end) testing.expectEqual(ok, false, 'Script reaching memory limit should fail') testing.expectEqual(err, 'not enough memory') -end +end) local function initPlayer() + local player = world.players[1] player:teleport('', util.vector3(4096, 4096, 1745), util.transform.identity) coroutine.yield() + return player end -local function testVFS() +testing.registerGlobalTest('vfs', function() local file = 'test_vfs_dir/lines.txt' local nosuchfile = 'test_vfs_dir/nosuchfile' testing.expectEqual(vfs.fileExists(file), true, 'lines.txt should exist') @@ -269,12 +272,11 @@ local function testVFS() for _,v in pairs(expectedLines) do testing.expectEqual(getLine(), v) end -end +end) -local function testCommitCrime() - initPlayer() - local player = world.players[1] - testing.expectEqual(player == nil, false, 'A viable player reference should exist to run `testCommitCrime`') +testing.registerGlobalTest('commit crime', function() + local player = initPlayer() + testing.expectEqual(player == nil, false, 'A viable player reference should exist to run `commit crime`') testing.expectEqual(I.Crimes == nil, false, 'Crimes interface should be available in global contexts') -- Reset crime level to have a clean slate @@ -292,82 +294,59 @@ local function testCommitCrime() types.Player.setCrimeLevel(player, 0) testing.expectEqual(I.Crimes.commitCrime(player, { victim = victim, type = types.Player.OFFENSE_TYPE.Theft, arg = 50 }).wasCrimeSeen, true, "Running a crime with a valid victim should notify them when the player is not sneaking, even if it's not explicitly passed in") testing.expectEqual(types.Player.getCrimeLevel(player), 0, "Crime level should not change if the victim's alarm value is low and there's no other witnesses") -end +end) -local function testRecordModelProperty() - initPlayer() +testing.registerGlobalTest('record model property', function() local player = world.players[1] testing.expectEqual(types.NPC.record(player).model, 'meshes/basicplayer.dae') +end) + +local function registerPlayerTest(name) + testing.registerGlobalTest(name, function() + local player = initPlayer() + testing.runLocalTest(player, name) + end) end -tests = { - {'timers', testTimers}, - {'rotating player with controls.yawChange should change rotation', function() - initPlayer() - testing.runLocalTest(player, 'playerYawRotation') - end}, - {'rotating player with controls.pitchChange should change rotation', function() - initPlayer() - testing.runLocalTest(player, 'playerPitchRotation') - end}, - {'rotating player with controls.pitchChange and controls.yawChange should change rotation', function() - initPlayer() - testing.runLocalTest(player, 'playerPitchAndYawRotation') - end}, - {'rotating player should not lead to nan rotation', function() - initPlayer() - testing.runLocalTest(player, 'playerRotation') - end}, - {'playerForwardRunning', function() - initPlayer() - testing.runLocalTest(player, 'playerForwardRunning') - end}, - {'playerDiagonalWalking', function() - initPlayer() - testing.runLocalTest(player, 'playerDiagonalWalking') - end}, - {'findPath', function() - initPlayer() - testing.runLocalTest(player, 'findPath') - end}, - {'findRandomPointAroundCircle', function() - initPlayer() - testing.runLocalTest(player, 'findRandomPointAroundCircle') - end}, - {'castNavigationRay', function() - initPlayer() - testing.runLocalTest(player, 'castNavigationRay') - end}, - {'findNearestNavMeshPosition', function() - initPlayer() - testing.runLocalTest(player, 'findNearestNavMeshPosition') - end}, - {'teleport', testTeleport}, - {'getGMST', testGetGMST}, - {'recordStores', testRecordStores}, - {'recordCreation', testRecordCreation}, - {'utf8Chars', testUTF8Chars}, - {'utf8Strings', testUTF8Strings}, - {'mwscript', testMWScript}, - {'testMemoryLimit', testMemoryLimit}, - {'playerMemoryLimit', function() - initPlayer() - testing.runLocalTest(player, 'playerMemoryLimit') - end}, - {'player with equipped weapon on attack should damage health of other actors', function() - initPlayer() - world.createObject('basic_dagger1h', 1):moveInto(player) - testing.runLocalTest(player, 'playerWeaponAttack') - end}, - {'vfs', testVFS}, - {'testCommitCrime', testCommitCrime}, - {'recordModelProperty', testRecordModelProperty}, -} +registerPlayerTest('player yaw rotation') +registerPlayerTest('player pitch rotation') +registerPlayerTest('player pitch and yaw rotation') +registerPlayerTest('player rotation') +registerPlayerTest('player forward running') +registerPlayerTest('player diagonal walking') +registerPlayerTest('findPath') +registerPlayerTest('findRandomPointAroundCircle') +registerPlayerTest('castNavigationRay') +registerPlayerTest('findNearestNavMeshPosition') +registerPlayerTest('player memory limit') + +testing.registerGlobalTest('player weapon attack', function() + local player = initPlayer() + world.createObject('basic_dagger1h', 1):moveInto(player) + testing.runLocalTest(player, 'player weapon attack') +end) + +testing.registerGlobalTest('load while teleporting - init player', function() + local player = world.players[1] + player:teleport('Museum of Wonders', util.vector3(0, -1500, 111), util.transform.rotateZ(math.rad(180))) +end) + +testing.registerGlobalTest('load while teleporting - teleport', function() + local player = world.players[1] + local landracer = world.createObject('landracer') + landracer:teleport(player.cell, player.position + util.vector3(0, 500, 0)) + coroutine.yield() + + local door = world.getObjectByFormId(core.getFormId('the_hub.omwaddon', 26)) + door:activateBy(player) + coroutine.yield() + + landracer:teleport(player.cell, player.position) +end) return { engineHandlers = { - onUpdate = testing.testRunner(tests), - onPlayerAdded = function(p) player = p end, + onUpdate = testing.updateGlobal, }, - eventHandlers = testing.eventHandlers, + eventHandlers = testing.globalEventHandlers, } diff --git a/scripts/data/integration_tests/test_lua_api/menu.lua b/scripts/data/integration_tests/test_lua_api/menu.lua new file mode 100644 index 0000000000..8c6895a5d8 --- /dev/null +++ b/scripts/data/integration_tests/test_lua_api/menu.lua @@ -0,0 +1,94 @@ +local testing = require('testing_util') +local matchers = require('matchers') +local menu = require('openmw.menu') + +testing.registerMenuTest('save and load', function() + menu.newGame() + coroutine.yield() + menu.saveGame('save and load') + coroutine.yield() + + local directorySaves = {} + directorySaves['save_and_load.omwsave'] = { + playerName = '', + playerLevel = 1, + timePlayed = 0, + description = 'save and load', + contentFiles = { + 'builtin.omwscripts', + 'template.omwgame', + 'landracer.omwaddon', + 'the_hub.omwaddon', + 'test_lua_api.omwscripts', + }, + creationTime = matchers.isAny(), + } + local expectedAllSaves = {} + expectedAllSaves[' - 1'] = directorySaves + + testing.expectThat(menu.getAllSaves(), matchers.equalTo(expectedAllSaves)) + + menu.loadGame(' - 1', 'save_and_load.omwsave') + coroutine.yield() + + menu.deleteGame(' - 1', 'save_and_load.omwsave') + testing.expectThat(menu.getAllSaves(), matchers.equalTo({})) +end) + +testing.registerMenuTest('load while teleporting', function() + menu.newGame() + coroutine.yield() + + testing.runGlobalTest('load while teleporting - init player') + + menu.saveGame('load while teleporting') + coroutine.yield() + + testing.runGlobalTest('load while teleporting - teleport') + + menu.loadGame(' - 1', 'load_while_teleporting.omwsave') + coroutine.yield() + + menu.deleteGame(' - 1', 'load_while_teleporting.omwsave') +end) + +local function registerGlobalTest(name, description) + testing.registerMenuTest(description or name, function() + menu.newGame() + coroutine.yield() + testing.runGlobalTest(name) + end) +end + +registerGlobalTest('timers') +registerGlobalTest('teleport') +registerGlobalTest('getGMST') +registerGlobalTest('MWScript') +registerGlobalTest('record stores') +registerGlobalTest('record creation') +registerGlobalTest('UTF-8 characters') +registerGlobalTest('UTF-8 strings') +registerGlobalTest('memory limit') +registerGlobalTest('vfs') +registerGlobalTest('commit crime') +registerGlobalTest('record model property') + +registerGlobalTest('player yaw rotation', 'rotating player with controls.yawChange should change rotation') +registerGlobalTest('player pitch rotation', 'rotating player with controls.pitchChange should change rotation') +registerGlobalTest('player pitch and yaw rotation', 'rotating player with controls.pitchChange and controls.yawChange should change rotation') +registerGlobalTest('player rotation', 'rotating player should not lead to nan rotation') +registerGlobalTest('player forward running') +registerGlobalTest('player diagonal walking') +registerGlobalTest('findPath') +registerGlobalTest('findRandomPointAroundCircle') +registerGlobalTest('castNavigationRay') +registerGlobalTest('findNearestNavMeshPosition') +registerGlobalTest('player memory limit') +registerGlobalTest('player weapon attack', 'player with equipped weapon on attack should damage health of other actors') + +return { + engineHandlers = { + onFrame = testing.makeUpdateMenu(), + }, + eventHandlers = testing.menuEventHandlers, +} diff --git a/scripts/data/integration_tests/test_lua_api/openmw.cfg b/scripts/data/integration_tests/test_lua_api/openmw.cfg index 68e50644a0..efd1cf331c 100644 --- a/scripts/data/integration_tests/test_lua_api/openmw.cfg +++ b/scripts/data/integration_tests/test_lua_api/openmw.cfg @@ -1,4 +1,4 @@ -content=test.omwscripts +content=test_lua_api.omwscripts # Needed to test `core.getGMST` fallback=Water_RippleFrameCount,4 diff --git a/scripts/data/integration_tests/test_lua_api/player.lua b/scripts/data/integration_tests/test_lua_api/player.lua index 16f0f2eea3..0b481fba2b 100644 --- a/scripts/data/integration_tests/test_lua_api/player.lua +++ b/scripts/data/integration_tests/test_lua_api/player.lua @@ -6,6 +6,7 @@ local input = require('openmw.input') local types = require('openmw.types') local nearby = require('openmw.nearby') local camera = require('openmw.camera') +local matchers = require('matchers') types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Controls, false) types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Fighting, false) @@ -40,7 +41,7 @@ local function rotateByPitch(object, target) rotate(object, target, nil) end -testing.registerLocalTest('playerYawRotation', +testing.registerLocalTest('player yaw rotation', function() local initialAlphaXZ, initialGammaXZ = self.rotation:getAnglesXZ() local initialAlphaZYX, initialBetaZYX, initialGammaZYX = self.rotation:getAnglesZYX() @@ -60,7 +61,7 @@ testing.registerLocalTest('playerYawRotation', testing.expectEqualWithDelta(gamma2, initialGammaZYX, 0.05, 'Gamma rotation in ZYX convention should not change') end) -testing.registerLocalTest('playerPitchRotation', +testing.registerLocalTest('player pitch rotation', function() local initialAlphaXZ, initialGammaXZ = self.rotation:getAnglesXZ() local initialAlphaZYX, initialBetaZYX, initialGammaZYX = self.rotation:getAnglesZYX() @@ -80,7 +81,7 @@ testing.registerLocalTest('playerPitchRotation', testing.expectEqualWithDelta(gamma2, targetPitch, 0.05, 'Incorrect gamma rotation in ZYX convention') end) -testing.registerLocalTest('playerPitchAndYawRotation', +testing.registerLocalTest('player pitch and yaw rotation', function() local targetPitch = math.rad(-30) local targetYaw = math.rad(-60) @@ -99,7 +100,7 @@ testing.registerLocalTest('playerPitchAndYawRotation', testing.expectEqualWithDelta(gamma2, math.rad(-16), 0.05, 'Incorrect gamma rotation in ZYX convention') end) -testing.registerLocalTest('playerRotation', +testing.registerLocalTest('player rotation', function() local rotation = math.sqrt(2) local endTime = core.getSimulationTime() + 3 @@ -113,17 +114,17 @@ testing.registerLocalTest('playerRotation', coroutine.yield() local alpha1, gamma1 = self.rotation:getAnglesXZ() - testing.expectThat(alpha1, testing.isNotNan(), 'Alpha rotation in XZ convention is nan') - testing.expectThat(gamma1, testing.isNotNan(), 'Gamma rotation in XZ convention is nan') + testing.expectThat(alpha1, matchers.isNotNan(), 'Alpha rotation in XZ convention is nan') + testing.expectThat(gamma1, matchers.isNotNan(), 'Gamma rotation in XZ convention is nan') local alpha2, beta2, gamma2 = self.rotation:getAnglesZYX() - testing.expectThat(alpha2, testing.isNotNan(), 'Alpha rotation in ZYX convention is nan') - testing.expectThat(beta2, testing.isNotNan(), 'Beta rotation in ZYX convention is nan') - testing.expectThat(gamma2, testing.isNotNan(), 'Gamma rotation in ZYX convention is nan') + testing.expectThat(alpha2, matchers.isNotNan(), 'Alpha rotation in ZYX convention is nan') + testing.expectThat(beta2, matchers.isNotNan(), 'Beta rotation in ZYX convention is nan') + testing.expectThat(gamma2, matchers.isNotNan(), 'Gamma rotation in ZYX convention is nan') end end) -testing.registerLocalTest('playerForwardRunning', +testing.registerLocalTest('player forward running', function() local startPos = self.position local endTime = core.getSimulationTime() + 1 @@ -141,7 +142,7 @@ testing.registerLocalTest('playerForwardRunning', testing.expectEqualWithDelta(direction.y, 1, 0.1, 'Run forward, Y coord') end) -testing.registerLocalTest('playerDiagonalWalking', +testing.registerLocalTest('player diagonal walking', function() local startPos = self.position local endTime = core.getSimulationTime() + 1 @@ -220,7 +221,7 @@ testing.registerLocalTest('findNearestNavMeshPosition', 'Navigation mesh position ' .. testing.formatActualExpected(result, expected)) end) -testing.registerLocalTest('playerMemoryLimit', +testing.registerLocalTest('player memory limit', function() local ok, err = pcall(function() local str = 'a' @@ -232,7 +233,7 @@ testing.registerLocalTest('playerMemoryLimit', testing.expectEqual(err, 'not enough memory') end) -testing.registerLocalTest('playerWeaponAttack', +testing.registerLocalTest('player weapon attack', function() camera.setMode(camera.MODE.ThirdPerson) @@ -346,5 +347,5 @@ return { engineHandlers = { onFrame = testing.updateLocal, }, - eventHandlers = testing.eventHandlers + eventHandlers = testing.localEventHandlers, } diff --git a/scripts/data/integration_tests/test_lua_api/test.omwscripts b/scripts/data/integration_tests/test_lua_api/test.omwscripts deleted file mode 100644 index 80507392f7..0000000000 --- a/scripts/data/integration_tests/test_lua_api/test.omwscripts +++ /dev/null @@ -1,2 +0,0 @@ -GLOBAL: test.lua -PLAYER: player.lua diff --git a/scripts/data/integration_tests/test_lua_api/test_lua_api.omwscripts b/scripts/data/integration_tests/test_lua_api/test_lua_api.omwscripts new file mode 100644 index 0000000000..4ce925e61d --- /dev/null +++ b/scripts/data/integration_tests/test_lua_api/test_lua_api.omwscripts @@ -0,0 +1,3 @@ +MENU: menu.lua +GLOBAL: global.lua +PLAYER: player.lua diff --git a/scripts/data/integration_tests/testing_util/matchers.lua b/scripts/data/integration_tests/testing_util/matchers.lua new file mode 100644 index 0000000000..c7643af206 --- /dev/null +++ b/scripts/data/integration_tests/testing_util/matchers.lua @@ -0,0 +1,218 @@ +local module = {} + +--- +-- Matcher verifying that distance between given value and expected is not greater than maxDistance. +-- @function elementsAreArray +-- @param expected#vector. +-- @usage +-- expectThat(util.vector2(0, 0), closeToVector(util.vector2(0, 1), 1)) +function module.closeToVector(expected, maxDistance) + return function(actual) + local distance = (expected - actual):length() + if distance <= maxDistance then + return '' + end + return string.format('%s is too far from expected %s: %s > %s', actual, expected, distance, maxDistance) + end +end + +--- +-- Matcher verifying that given value is an array each element of which matches elements of expected. +-- @function elementsAreArray +-- @param expected#array of values or matcher functions. +-- @usage +-- local t = {42, 13} +-- local matcher = function(actual) +-- if actual ~= 42 then +-- return string.format('%s is not 42', actual) +-- end +-- return '' +-- end +-- expectThat({42, 13}, elementsAreArray({matcher, 13})) +function module.elementsAreArray(expected) + local expected_matchers = {} + for i, v in ipairs(expected) do + if type(v) == 'function' then + expected_matchers[i] = v + else + expected_matchers[i] = function (other) + if expected[i].__eq(expected[i], other) then + return '' + end + return string.format('%s element %s does no match expected: %s', i, other, expected[i]) + end + end + end + return function(actual) + if #actual < #expected_matchers then + return string.format('number of elements is less than expected: %s < %s', #actual, #expected_matchers) + end + local message = '' + for i, v in ipairs(actual) do + if i > #expected_matchers then + message = string.format('%s\n%s element is out of expected range: %s', message, i, #expected_matchers) + break + end + local match_message = expected_matchers[i](v) + if match_message ~= '' then + message = string.format('%s\n%s', message, match_message) + end + end + return message + end +end + +--- +-- Matcher verifying that given number is not a nan. +-- @function isNotNan +-- @usage +-- expectThat(value, isNotNan()) +function module.isNotNan() + return function(actual) + if actual ~= actual then + return 'actual value is nan, expected to be not nan' + end + return '' + end +end + +--- +-- Matcher accepting any value. +-- @function isAny +-- @usage +-- expectThat(value, isAny()) +function module.isAny() + return function(actual) + return '' + end +end + +local function serializeArray(a) + local result = nil + for _, v in ipairs(a) do + if result == nil then + result = string.format('{%s', serialize(v)) + else + result = string.format('%s, %s', result, serialize(v)) + end + end + if result == nil then + return '{}' + end + return string.format('%s}', result) +end + +local function serializeTable(t) + local result = nil + for k, v in pairs(t) do + if result == nil then + result = string.format('{%q = %s', k, serialize(v)) + else + result = string.format('%s, %q = %s', result, k, serialize(v)) + end + end + if result == nil then + return '{}' + end + return string.format('%s}', result) +end + +local function isArray(t) + local i = 1 + for _ in pairs(t) do + if t[i] == nil then + return false + end + i = i + 1 + end + return true +end + +function serialize(v) + local t = type(v) + if t == 'string' then + return string.format('%q', v) + elseif t == 'table' then + if isArray(v) then + return serializeArray(v) + end + return serializeTable(v) + end + return string.format('%s', v) +end + +local function compareScalars(v1, v2) + if v1 == v2 then + return '' + end + if type(v1) == 'string' then + return string.format('%q ~= %q', v1, v2) + end + return string.format('%s ~= %s', v1, v2) +end + +local function collectKeys(t) + local result = {} + for key in pairs(t) do + table.insert(result, key) + end + table.sort(result) + return result +end + +local function compareTables(t1, t2) + local keys1 = collectKeys(t1) + local keys2 = collectKeys(t2) + if #keys1 ~= #keys2 then + return string.format('table size mismatch: %d ~= %d', #keys1, #keys2) + end + for i = 1, #keys1 do + local key1 = keys1[i] + local key2 = keys2[i] + if key1 ~= key2 then + return string.format('table keys mismatch: %q ~= %q', key1, key2) + end + local d = compare(t1[key1], t2[key2]) + if d ~= '' then + return string.format('table values mismatch at key %s: %s', serialize(key1), d) + end + end + return '' +end + +function compare(v1, v2) + local type1 = type(v1) + local type2 = type(v2) + if type2 == 'function' then + return v2(v1) + end + if type1 ~= type2 then + return string.format('types mismatch: %s ~= %s', type1, type2) + end + if type1 == 'nil' then + return '' + elseif type1 == 'table' then + return compareTables(v1, v2) + elseif type1 == 'nil' or type1 == 'boolean' or type1 == 'number' or type1 == 'string' then + return compareScalars(v1, v2) + end + error('unsupported type: %s', type1) +end + +--- +-- Matcher verifying that given value is equal to expected. Accepts nil, boolean, number, string and table or matcher +-- function. +-- @function equalTo +-- @usage +-- expectThat({a = {42, 'foo', {b = true}}}, equalTo({a = {42, 'foo', {b = true}}})) +function module.equalTo(expected) + return function(actual) + local diff = compare(actual, expected) + if diff == '' then + return '' + end + return string.format('%s; actual: %s; expected: %s', diff, serialize(actual, ''), serialize(expected, '')) + end +end + +return module diff --git a/scripts/data/integration_tests/testing_util/testing_util.lua b/scripts/data/integration_tests/testing_util/testing_util.lua index 7b886636ed..c894fa96f4 100644 --- a/scripts/data/integration_tests/testing_util/testing_util.lua +++ b/scripts/data/integration_tests/testing_util/testing_util.lua @@ -2,23 +2,22 @@ local core = require('openmw.core') local util = require('openmw.util') local M = {} + +local menuTestsOrder = {} +local menuTests = {} + +local globalTestsOrder = {} +local globalTests = {} +local globalTestRunner = nil +local currentGlobalTest = nil +local currentGlobalTestError = nil + +local localTests = {} +local localTestRunner = nil local currentLocalTest = nil local currentLocalTestError = nil -function M.testRunner(tests) - local fn = function() - for i, test in ipairs(tests) do - local name, fn = unpack(test) - print('TEST_START', i, name) - local status, err = pcall(fn) - if status then - print('TEST_OK', i, name) - else - print('TEST_FAILED', i, name, err) - end - end - core.quit() - end +local function makeTestCoroutine(fn) local co = coroutine.create(fn) return function() if coroutine.status(co) ~= 'dead' then @@ -27,6 +26,64 @@ function M.testRunner(tests) end end +local function runTests(tests) + for i, test in ipairs(tests) do + local name, fn = unpack(test) + print('TEST_START', i, name) + local status, err = pcall(fn) + if status then + print('TEST_OK', i, name) + else + print('TEST_FAILED', i, name, err) + end + end + core.quit() +end + +function M.makeUpdateMenu() + return makeTestCoroutine(function() + print('Running menu tests...') + runTests(menuTestsOrder) + end) +end + +function M.makeUpdateGlobal() + return makeTestCoroutine(function() + print('Running global tests...') + runTests(globalTestsOrder) + end) +end + +function M.registerMenuTest(name, fn) + menuTests[name] = fn + table.insert(menuTestsOrder, {name, fn}) +end + +function M.runGlobalTest(name) + currentGlobalTest = name + currentGlobalTestError = nil + core.sendGlobalEvent('runGlobalTest', name) + while currentGlobalTest do + coroutine.yield() + end + if currentGlobalTestError then + error(currentGlobalTestError, 2) + end +end + +function M.registerGlobalTest(name, fn) + globalTests[name] = fn + table.insert(globalTestsOrder, {name, fn}) +end + +function M.updateGlobal() + if globalTestRunner and coroutine.status(globalTestRunner) ~= 'dead' then + coroutine.resume(globalTestRunner) + else + globalTestRunner = nil + end +end + function M.runLocalTest(obj, name) currentLocalTest = name currentLocalTestError = nil @@ -39,7 +96,21 @@ function M.runLocalTest(obj, name) end end -function M.expect(cond, delta, msg) +function M.registerLocalTest(name, fn) + localTests[name] = fn +end + +function M.updateLocal() + if localTestRunner and coroutine.status(localTestRunner) ~= 'dead' then + if not core.isWorldPaused() then + coroutine.resume(localTestRunner) + end + else + localTestRunner = nil + end +end + +function M.expect(cond, msg) if not cond then error(msg or '"true" expected', 2) end @@ -87,76 +158,6 @@ function M.expectNotEqual(v1, v2, msg) end end -function M.closeToVector(expected, maxDistance) - return function(actual) - local distance = (expected - actual):length() - if distance <= maxDistance then - return '' - end - return string.format('%s is too far from expected %s: %s > %s', actual, expected, distance, maxDistance) - end -end - ---- --- Matcher verifying that given value is an array each element of which matches elements of expected. --- @function elementsAreArray --- @param expected#array of values or matcher functions. --- @usage --- local t = {42, 13} --- local matcher = function(actual) --- if actual ~= 42 then --- return string.format('%s is not 42', actual) --- end --- return '' --- end --- expectThat({42, 13}, elementsAreArray({matcher, 13})) -function M.elementsAreArray(expected) - local expected_matchers = {} - for i, v in ipairs(expected) do - if type(v) == 'function' then - expected_matchers[i] = v - else - expected_matchers[i] = function (other) - if expected[i].__eq(expected[i], other) then - return '' - end - return string.format('%s element %s does no match expected: %s', i, other, expected[i]) - end - end - end - return function(actual) - if #actual < #expected_matchers then - return string.format('number of elements is less than expected: %s < %s', #actual, #expected_matchers) - end - local message = '' - for i, v in ipairs(actual) do - if i > #expected_matchers then - message = string.format('%s\n%s element is out of expected range: %s', message, i, #expected_matchers) - break - end - local match_message = expected_matchers[i](v) - if match_message ~= '' then - message = string.format('%s\n%s', message, match_message) - end - end - return message - end -end - ---- --- Matcher verifying that given number is not a nan. --- @function isNotNan --- @usage --- expectThat(value, isNotNan()) -function M.isNotNan(expected) - return function(actual) - if actual ~= actual then - return 'actual value is nan, expected to be not nan' - end - return '' - end -end - --- -- Verifies that given value matches provided matcher. -- @function expectThat @@ -182,28 +183,50 @@ function M.formatActualExpected(actual, expected) return string.format('actual: %s, expected: %s', actual, expected) end -local localTests = {} -local localTestRunner = nil - -function M.registerLocalTest(name, fn) - localTests[name] = fn -end - -function M.updateLocal() - if localTestRunner and coroutine.status(localTestRunner) ~= 'dead' then - if not core.isWorldPaused() then - coroutine.resume(localTestRunner) +-- used only in menu scripts +M.menuEventHandlers = { + globalTestFinished = function(data) + if data.name ~= currentGlobalTest then + error(string.format('globalTestFinished with incorrect name %s, expected %s', data.name, currentGlobalTest), 2) end - else - localTestRunner = nil - end -end + currentGlobalTest = nil + currentGlobalTestError = data.errMsg + end, +} -M.eventHandlers = { - runLocalTest = function(name) -- used only in local scripts +-- used only in global scripts +M.globalEventHandlers = { + runGlobalTest = function(name) + fn = globalTests[name] + local types = require('openmw.types') + local world = require('openmw.world') + if not fn then + types.Player.sendMenuEvent(world.players[1], 'globalTestFinished', {name=name, errMsg='Global test is not found'}) + return + end + globalTestRunner = coroutine.create(function() + local status, err = pcall(fn) + if status then + err = nil + end + types.Player.sendMenuEvent(world.players[1], 'globalTestFinished', {name=name, errMsg=err}) + end) + end, + localTestFinished = function(data) + if data.name ~= currentLocalTest then + error(string.format('localTestFinished with incorrect name %s, expected %s', data.name, currentLocalTest), 2) + end + currentLocalTest = nil + currentLocalTestError = data.errMsg + end, +} + +-- used only in local scripts +M.localEventHandlers = { + runLocalTest = function(name) fn = localTests[name] if not fn then - core.sendGlobalEvent('localTestFinished', {name=name, errMsg='Test not found'}) + core.sendGlobalEvent('localTestFinished', {name=name, errMsg='Local test is not found'}) return end localTestRunner = coroutine.create(function() @@ -214,13 +237,6 @@ M.eventHandlers = { core.sendGlobalEvent('localTestFinished', {name=name, errMsg=err}) end) end, - localTestFinished = function(data) -- used only in global scripts - if data.name ~= currentLocalTest then - error(string.format('localTestFinished with incorrect name %s, expected %s', data.name, currentLocalTest)) - end - currentLocalTest = nil - currentLocalTestError = data.errMsg - end, } return M diff --git a/scripts/data/morrowind_tests/global.lua b/scripts/data/morrowind_tests/global.lua index fb7113d1b1..f17e72cda9 100644 --- a/scripts/data/morrowind_tests/global.lua +++ b/scripts/data/morrowind_tests/global.lua @@ -8,28 +8,13 @@ if not core.contentFiles.has('Morrowind.esm') then error('This test requires Morrowind.esm') end -function makeTests(modules) - local tests = {} - - for _, moduleName in ipairs(modules) do - local module = require(moduleName) - for _, v in ipairs(module) do - table.insert(tests, {string.format('[%s] %s', moduleName, v[1]), v[2]}) - end - end - - return tests -end - -local testModules = { - 'global_issues', - 'global_dialogues', - 'global_mwscript', -} +require('global_issues') +require('global_dialogues') +require('global_mwscript') return { engineHandlers = { - onUpdate = testing.testRunner(makeTests(testModules)), + onUpdate = testing.makeUpdateGlobal(), }, - eventHandlers = testing.eventHandlers, + eventHandlers = testing.globalEventHandlers, } diff --git a/scripts/data/morrowind_tests/global_dialogues.lua b/scripts/data/morrowind_tests/global_dialogues.lua index 397eb8461c..68f4f4a747 100644 --- a/scripts/data/morrowind_tests/global_dialogues.lua +++ b/scripts/data/morrowind_tests/global_dialogues.lua @@ -13,35 +13,37 @@ function iterateOverRecords(records) return firstRecordId, lastRecordId, count end -return { - {'Should support iteration over journal dialogues', function() - local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.journal.records) - testing.expectEqual(firstRecordId, '11111 test journal') - testing.expectEqual(lastRecordId, 'va_vamprich') - testing.expectEqual(count, 632) - end}, - {'Should support iteration over topic dialogues', function() - local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.topic.records) - testing.expectEqual(firstRecordId, '1000-drake pledge') - testing.expectEqual(lastRecordId, 'zenithar') - testing.expectEqual(count, 1698) - end}, - {'Should support iteration over greeting dialogues', function() - local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.greeting.records) - testing.expectEqual(firstRecordId, 'greeting 0') - testing.expectEqual(lastRecordId, 'greeting 9') - testing.expectEqual(count, 10) - end}, - {'Should support iteration over persuasion dialogues', function() - local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.persuasion.records) - testing.expectEqual(firstRecordId, 'admire fail') - testing.expectEqual(lastRecordId, 'taunt success') - testing.expectEqual(count, 10) - end}, - {'Should support iteration over voice dialogues', function() - local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.voice.records) - testing.expectEqual(firstRecordId, 'alarm') - testing.expectEqual(lastRecordId, 'thief') - testing.expectEqual(count, 8) - end}, -} +testing.registerGlobalTest('[dialogues] Should support iteration over journal dialogues', function() + local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.journal.records) + testing.expectEqual(firstRecordId, '11111 test journal') + testing.expectEqual(lastRecordId, 'va_vamprich') + testing.expectEqual(count, 632) +end) + +testing.registerGlobalTest('[dialogues] Should support iteration over topic dialogues', function() + local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.topic.records) + testing.expectEqual(firstRecordId, '1000-drake pledge') + testing.expectEqual(lastRecordId, 'zenithar') + testing.expectEqual(count, 1698) +end) + +testing.registerGlobalTest('[dialogues] Should support iteration over greeting dialogues', function() + local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.greeting.records) + testing.expectEqual(firstRecordId, 'greeting 0') + testing.expectEqual(lastRecordId, 'greeting 9') + testing.expectEqual(count, 10) +end) + +testing.registerGlobalTest('[dialogues] Should support iteration over persuasion dialogues', function() + local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.persuasion.records) + testing.expectEqual(firstRecordId, 'admire fail') + testing.expectEqual(lastRecordId, 'taunt success') + testing.expectEqual(count, 10) +end) + +testing.registerGlobalTest('[dialogues] Should support iteration over voice dialogues', function() + local firstRecordId, lastRecordId, count = iterateOverRecords(core.dialogue.voice.records) + testing.expectEqual(firstRecordId, 'alarm') + testing.expectEqual(lastRecordId, 'thief') + testing.expectEqual(count, 8) +end) diff --git a/scripts/data/morrowind_tests/global_issues.lua b/scripts/data/morrowind_tests/global_issues.lua index 2afad085b0..a3400da87c 100644 --- a/scripts/data/morrowind_tests/global_issues.lua +++ b/scripts/data/morrowind_tests/global_issues.lua @@ -4,37 +4,37 @@ local world = require('openmw.world') local core = require('openmw.core') local types = require('openmw.types') -return { - {'Player should be able to walk up stairs in Ebonheart docks (#4247)', function() - world.players[1]:teleport('', util.vector3(19867, -102180, -79), util.transform.rotateZ(math.rad(91))) - coroutine.yield() - testing.runLocalTest(world.players[1], 'Player should be able to walk up stairs in Ebonheart docks (#4247)') - end}, - {'Guard in Imperial Prison Ship should find path (#7241)', function() - world.players[1]:teleport('Imperial Prison Ship', util.vector3(61, -135, -105), util.transform.rotateZ(math.rad(-20))) - coroutine.yield() - testing.runLocalTest(world.players[1], 'Guard in Imperial Prison Ship should find path (#7241)') - end}, - {'Should keep reference to an object moved into container (#7663)', function() - world.players[1]:teleport('ToddTest', util.vector3(2176, 3648, -191), util.transform.rotateZ(math.rad(0))) - coroutine.yield() - local barrel = world.createObject('barrel_01', 1) - local fargothRing = world.createObject('ring_keley', 1) - coroutine.yield() - testing.expectEqual(types.Container.inventory(barrel):find('ring_keley'), nil) - fargothRing:moveInto(types.Container.inventory(barrel)) - coroutine.yield() - testing.expectEqual(fargothRing.recordId, 'ring_keley') - local isFargothRing = function(actual) - if actual == nil then - return 'ring_keley is not found' - end - if actual.id ~= fargothRing.id then - return 'found ring_keley id does not match expected: actual=' .. tostring(actual.id) - .. ', expected=' .. tostring(fargothRing.id) - end - return '' +testing.registerGlobalTest('[issues] Player should be able to walk up stairs in Ebonheart docks (#4247)', function() + world.players[1]:teleport('', util.vector3(19867, -102180, -79), util.transform.rotateZ(math.rad(91))) + coroutine.yield() + testing.runLocalTest(world.players[1], 'Player should be able to walk up stairs in Ebonheart docks (#4247)') +end) + +testing.registerGlobalTest('[issues] Guard in Imperial Prison Ship should find path (#7241)', function() + world.players[1]:teleport('Imperial Prison Ship', util.vector3(61, -135, -105), util.transform.rotateZ(math.rad(-20))) + coroutine.yield() + testing.runLocalTest(world.players[1], 'Guard in Imperial Prison Ship should find path (#7241)') +end) + +testing.registerGlobalTest('[issues] Should keep reference to an object moved into container (#7663)', function() + world.players[1]:teleport('ToddTest', util.vector3(2176, 3648, -191), util.transform.rotateZ(math.rad(0))) + coroutine.yield() + local barrel = world.createObject('barrel_01', 1) + local fargothRing = world.createObject('ring_keley', 1) + coroutine.yield() + testing.expectEqual(types.Container.inventory(barrel):find('ring_keley'), nil) + fargothRing:moveInto(types.Container.inventory(barrel)) + coroutine.yield() + testing.expectEqual(fargothRing.recordId, 'ring_keley') + local isFargothRing = function(actual) + if actual == nil then + return 'ring_keley is not found' end - testing.expectThat(types.Container.inventory(barrel):find('ring_keley'), isFargothRing) - end}, -} + if actual.id ~= fargothRing.id then + return 'found ring_keley id does not match expected: actual=' .. tostring(actual.id) + .. ', expected=' .. tostring(fargothRing.id) + end + return '' + end + testing.expectThat(types.Container.inventory(barrel):find('ring_keley'), isFargothRing) +end) diff --git a/scripts/data/morrowind_tests/global_mwscript.lua b/scripts/data/morrowind_tests/global_mwscript.lua index a4347ea66e..fec9fcdba5 100644 --- a/scripts/data/morrowind_tests/global_mwscript.lua +++ b/scripts/data/morrowind_tests/global_mwscript.lua @@ -14,38 +14,38 @@ function iterateOverVariables(variables) return first, last, count end -return { - {'Should support iteration over an empty set of script variables', function() - local mainVars = world.mwscript.getGlobalScript('main').variables - local first, last, count = iterateOverVariables(mainVars) - testing.expectEqual(first, nil) - testing.expectEqual(last, nil) - testing.expectEqual(count, 0) - testing.expectEqual(count, #mainVars) - end}, - {'Should support iteration of script variables', function() - local jiub = world.getObjectByFormId(core.getFormId('Morrowind.esm', 172867)) - local jiubVars = world.mwscript.getLocalScript(jiub).variables - local first, last, count = iterateOverVariables(jiubVars) +testing.registerGlobalTest('[mwscript] Should support iteration over an empty set of script variables', function() + local mainVars = world.mwscript.getGlobalScript('main').variables + local first, last, count = iterateOverVariables(mainVars) + testing.expectEqual(first, nil) + testing.expectEqual(last, nil) + testing.expectEqual(count, 0) + testing.expectEqual(count, #mainVars) +end) - testing.expectEqual(first, 'state') - testing.expectEqual(last, 'timer') - testing.expectEqual(count, 3) - testing.expectEqual(count, #jiubVars) - end}, - {'Should support numeric and string indices for getting and setting', function() - local jiub = world.getObjectByFormId(core.getFormId('Morrowind.esm', 172867)) - local jiubVars = world.mwscript.getLocalScript(jiub).variables +testing.registerGlobalTest('[mwscript] Should support iteration of script variables', function() + local jiub = world.getObjectByFormId(core.getFormId('Morrowind.esm', 172867)) + local jiubVars = world.mwscript.getLocalScript(jiub).variables + local first, last, count = iterateOverVariables(jiubVars) - testing.expectEqual(jiubVars[1], jiubVars.state) - testing.expectEqual(jiubVars[2], jiubVars.wandering) - testing.expectEqual(jiubVars[3], jiubVars.timer) + testing.expectEqual(first, 'state') + testing.expectEqual(last, 'timer') + testing.expectEqual(count, 3) + testing.expectEqual(count, #jiubVars) +end) - jiubVars[1] = 123; - testing.expectEqual(jiubVars.state, 123) - jiubVars.wandering = 42; - testing.expectEqual(jiubVars[2], 42) - jiubVars[3] = 1.25; - testing.expectEqual(jiubVars.timer, 1.25) - end}, -} +testing.registerGlobalTest('[mwscript] Should support numeric and string indices for getting and setting', function() + local jiub = world.getObjectByFormId(core.getFormId('Morrowind.esm', 172867)) + local jiubVars = world.mwscript.getLocalScript(jiub).variables + + testing.expectEqual(jiubVars[1], jiubVars.state) + testing.expectEqual(jiubVars[2], jiubVars.wandering) + testing.expectEqual(jiubVars[3], jiubVars.timer) + + jiubVars[1] = 123; + testing.expectEqual(jiubVars.state, 123) + jiubVars.wandering = 42; + testing.expectEqual(jiubVars[2], 42) + jiubVars[3] = 1.25; + testing.expectEqual(jiubVars.timer, 1.25) +end) diff --git a/scripts/data/morrowind_tests/player.lua b/scripts/data/morrowind_tests/player.lua index 7435b49553..fcb1126c1b 100644 --- a/scripts/data/morrowind_tests/player.lua +++ b/scripts/data/morrowind_tests/player.lua @@ -5,6 +5,7 @@ local testing = require('testing_util') local util = require('openmw.util') local types = require('openmw.types') local nearby = require('openmw.nearby') +local matchers = require('matchers') types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Fighting, false) types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Jumping, false) @@ -45,33 +46,33 @@ testing.registerLocalTest('Guard in Imperial Prison Ship should find path (#7241 testing.expectLessOrEqual((util.vector2(path[#path].x, path[#path].y) - util.vector2(dst.x, dst.y)):length(), 1, 'Last path point x, y') testing.expectLessOrEqual(path[#path].z - dst.z, 20, 'Last path point z') if agentBounds.shapeType == nearby.COLLISION_SHAPE_TYPE.Aabb then - testing.expectThat(path, testing.elementsAreArray({ - testing.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), - testing.closeToVector(util.vector3(15, 1102, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-112, 1110, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-67.99993896484375, 1421.2000732421875, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-33.999935150146484375, 1414.4000244140625, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-6.79993534088134765625, 1380.4000244140625, 85.094573974609375), 1e-1), - testing.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(83.552001953125, 42.26399993896484375, -104.58989715576171875), 1e-1), - testing.closeToVector(util.vector3(89, -105, -98.72841644287109375), 1e-1), - testing.closeToVector(util.vector3(90, -90, -99.7056884765625), 1e-1), + testing.expectThat(path, matchers.elementsAreArray({ + matchers.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), + matchers.closeToVector(util.vector3(15, 1102, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-112, 1110, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-67.99993896484375, 1421.2000732421875, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-33.999935150146484375, 1414.4000244140625, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-6.79993534088134765625, 1380.4000244140625, 85.094573974609375), 1e-1), + matchers.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(83.552001953125, 42.26399993896484375, -104.58989715576171875), 1e-1), + matchers.closeToVector(util.vector3(89, -105, -98.72841644287109375), 1e-1), + matchers.closeToVector(util.vector3(90, -90, -99.7056884765625), 1e-1), })) elseif agentBounds.shapeType == nearby.COLLISION_SHAPE_TYPE.Cylinder then - testing.expectThat(path, testing.elementsAreArray({ - testing.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), - testing.closeToVector(util.vector3(-13.5999355316162109375, 1060.800048828125, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-27.1999359130859375, 1081.2000732421875, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-81.59993743896484375, 1128.800048828125, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-101.99993896484375, 1156.0001220703125, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(7, 1470, 114.73973846435546875), 1e-1), - testing.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(95, 27, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(90, -90, -104.83390045166015625), 1e-1), + testing.expectThat(path, matchers.elementsAreArray({ + matchers.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), + matchers.closeToVector(util.vector3(-13.5999355316162109375, 1060.800048828125, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-27.1999359130859375, 1081.2000732421875, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-81.59993743896484375, 1128.800048828125, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-101.99993896484375, 1156.0001220703125, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(7, 1470, 114.73973846435546875), 1e-1), + matchers.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(95, 27, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(90, -90, -104.83390045166015625), 1e-1), })) end end) @@ -80,5 +81,5 @@ return { engineHandlers = { onFrame = testing.updateLocal, }, - eventHandlers = testing.eventHandlers + eventHandlers = testing.localEventHandlers, } diff --git a/scripts/integration_tests.py b/scripts/integration_tests.py index 17f89fbe2f..80c97f8b73 100755 --- a/scripts/integration_tests.py +++ b/scripts/integration_tests.py @@ -1,6 +1,13 @@ #!/usr/bin/env python3 -import argparse, datetime, os, subprocess, sys, shutil +import argparse +import datetime +import os +import shutil +import subprocess +import sys +import time + from pathlib import Path parser = argparse.ArgumentParser(description="OpenMW integration tests.") @@ -40,12 +47,13 @@ testing_util_dir = tests_dir / "testing_util" time_str = datetime.datetime.now().strftime("%Y-%m-%d-%H.%M.%S") -def runTest(name): - print(f"Start {name}") +def run_test(test_name): + start = time.time() + print(f'[----------] Running tests from {test_name}') shutil.rmtree(config_dir, ignore_errors=True) config_dir.mkdir() shutil.copyfile(example_suite_dir / "settings.cfg", config_dir / "settings.cfg") - test_dir = tests_dir / name + test_dir = tests_dir / test_name with open(config_dir / "openmw.cfg", "w", encoding="utf-8") as omw_cfg: for path in content_paths: omw_cfg.write(f'data="{path.parent}"\n') @@ -58,10 +66,8 @@ def runTest(name): ) for path in content_paths: omw_cfg.write(f'content={path.name}\n') - if (test_dir / "openmw.cfg").exists(): - omw_cfg.write(open(test_dir / "openmw.cfg").read()) - elif (test_dir / "test.omwscripts").exists(): - omw_cfg.write("content=test.omwscripts\n") + with open(test_dir / "openmw.cfg") as stream: + omw_cfg.write(stream.read()) with open(config_dir / "settings.cfg", "a", encoding="utf-8") as settings_cfg: settings_cfg.write( "[Video]\n" @@ -74,61 +80,76 @@ def runTest(name): f"memory limit = {1024 * 1024 * 256}\n" ) stdout_lines = list() - exit_ok = True test_success = True + fatal_errors = list() with subprocess.Popen( - [openmw_binary, "--replace=config", "--config", config_dir, "--skip-menu", "--no-grab", "--no-sound"], + [openmw_binary, "--replace=config", "--config", config_dir, "--no-grab"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", env={ - "OPENMW_OSG_STATS_FILE": str(work_dir / f"{name}.{time_str}.osg_stats.log"), + "OPENMW_OSG_STATS_FILE": str(work_dir / f"{test_name}.{time_str}.osg_stats.log"), "OPENMW_OSG_STATS_LIST": "times", **os.environ, }, ) as process: quit_requested = False + running_test_number = None + running_test_name = None + count = 0 + failed_tests = list() + test_start = None for line in process.stdout: if args.verbose: sys.stdout.write(line) else: stdout_lines.append(line) - words = line.split(" ") - if len(words) > 1 and words[1] == "E]": - print(line, end="") - elif "Quit requested by a Lua script" in line: + if "Quit requested by a Lua script" in line: quit_requested = True elif "TEST_START" in line: - w = line.split("TEST_START")[1].split("\t") - print(f"TEST {w[2].strip()}\t\t", end="") + test_start = time.time() + number, name = line.split("TEST_START")[1].strip().split("\t", maxsplit=1) + running_test_number = int(number) + running_test_name = name + count += 1 + print(f"[ RUN ] {running_test_name}") elif "TEST_OK" in line: - print(f"OK") + duration = (time.time() - test_start) * 1000 + number, name = line.split("TEST_OK")[1].strip().split("\t", maxsplit=1) + assert running_test_number == int(number) + print(f"[ OK ] {running_test_name} ({duration:.3f} ms)") elif "TEST_FAILED" in line: - w = line.split("TEST_FAILED")[1].split("\t") - print(f"FAILED {w[3]}\t\t") - test_success = False + duration = (time.time() - test_start) * 1000 + number, name, error = line.split("TEST_FAILED")[1].strip().split("\t", maxsplit=2) + assert running_test_number == int(number) + print(error) + print(f"[ FAILED ] {running_test_name} ({duration:.3f} ms)") + failed_tests.append(running_test_name) process.wait(5) if not quit_requested: - print("ERROR: Unexpected termination") - exit_ok = False + fatal_errors.append("unexpected termination") if process.returncode != 0: - print(f"ERROR: openmw exited with code {process.returncode}") - exit_ok = False + fatal_errors.append(f"openmw exited with code {process.returncode}") if os.path.exists(config_dir / "openmw.log"): - shutil.copyfile(config_dir / "openmw.log", work_dir / f"{name}.{time_str}.log") - if not exit_ok and not args.verbose: + shutil.copyfile(config_dir / "openmw.log", work_dir / f"{test_name}.{time_str}.log") + if fatal_errors and not args.verbose: sys.stdout.writelines(stdout_lines) - if test_success and exit_ok: - print(f"{name} succeeded") - else: - print(f"{name} failed") - return test_success and exit_ok + total_duration = (time.time() - start) * 1000 + print(f'\n[----------] {count} tests from {test_name} ({total_duration:.3f} ms total)') + print(f"[ PASSED ] {count - len(failed_tests)} tests.") + if fatal_errors: + print(f"[ FAILED ] fatal error: {'; '.join(fatal_errors)}") + if failed_tests: + print(f"[ FAILED ] {len(failed_tests)} tests, listed below:") + for failed_test in failed_tests: + print(f"[ FAILED ] {failed_test}") + return len(failed_tests) == 0 and not fatal_errors status = 0 for entry in tests_dir.glob("test_*"): if entry.is_dir(): - if not runTest(entry.name): + if not run_test(entry.name): status = -1 if status == 0: shutil.rmtree(config_dir, ignore_errors=True)