Преглед изворни кода

Remove 'engines_path' and add user/project_settings.json support (#14270)

* remove engines_path and related functionality

Signed-off-by: Alex Peterson <[email protected]>

* Add comment about projectJsonPath resolution

Signed-off-by: Alex Peterson <[email protected]>

* Warn and continue when invalid engine.json found

Signed-off-by: Alex Peterson <[email protected]>

* add project_properties --user and --engine_path

Signed-off-by: Alex Peterson <[email protected]>

* add 'engine_finder_cmake' and engine version check

Signed-off-by: Alex Peterson <[email protected]>

* refactor

Signed-off-by: Alex Peterson <[email protected]>

* add engine finder cmake to edit-project-properties

Signed-off-by: Alex Peterson <[email protected]>

* update templates EngineFinder.cmake scripts

Signed-off-by: Alex Peterson <[email protected]>

* test engine name with version specifier setting

Signed-off-by: Alex Peterson <[email protected]>

* use O3DE_ prefix

Signed-off-by: Alex Peterson <[email protected]>

* fix engine version not printing in message

Signed-off-by: Alex Peterson <[email protected]>

* add engine_finder_cmake_path logic to templates

Signed-off-by: Alex Peterson <[email protected]>

* SettingsRegistry engine_path and compatibility

Signed-off-by: Alex Peterson <[email protected]>

* fix error reporting, modifying reg during visit

Signed-off-by: Alex Peterson <[email protected]>

* Improve errors before launching project manager

Signed-off-by: Alex Peterson <[email protected]>

* fix dependency regex & user/project.json overrides

Signed-off-by: Alex Peterson <[email protected]>

* comment clarification re user/project.json

Signed-off-by: Alex Peterson <[email protected]>

* WIP FindEngineRoot unit tests

Signed-off-by: Alex Peterson <[email protected]>

* fix no surfacing errors in user project.json

Signed-off-by: Alex Peterson <[email protected]>

* Lessen frequency of recording mode changes

Signed-off-by: Alex Peterson <[email protected]>

* fix engine_finder_path, refactor verbosity logic

- use STATUS
- move for loop to function
- use STREQUAL for ===

Signed-off-by: Alex Peterson <[email protected]>

* remove engineRoot append, fix asset bundle mode

the engine root should be set to the project path when in asset bundle mode e.g. when running the game launcher with .pak files

Signed-off-by: Alex Peterson <[email protected]>

* Error if ToolsApplication engine root not found

Signed-off-by: Alex Peterson <[email protected]>

* MergeSettingsFile returns AZ::Outcome

MergeSettingsFileInternal also returns an AZ::Outcome with error string on AZ::Failure

Signed-off-by: Alex Peterson <[email protected]>

* fix registry tests using MergeSettingsFile

Signed-off-by: Alex Peterson <[email protected]>

* use Outcome from MergeSettingsFile

Signed-off-by: Alex Peterson <[email protected]>

* simpler method to get engine path

Signed-off-by: Alex Peterson <[email protected]>

* fix "unknown option dummyString"

Signed-off-by: Alex Peterson <[email protected]>

* nit size_t types

Signed-off-by: Alex Peterson <[email protected]>

* Warn user about engine with same name and version

Signed-off-by: Alex Peterson <[email protected]>

* fix empty path defaulting to current working dir

any function that used QString_To_Py_Path() and provided an empty string was getting converted to the current working directory in Python instead of the Python None type, e.g. when getting all gems an empty path is provided, but to Python that looks like you're passing the current working directory as a project path - which throws a warning.

Signed-off-by: Alex Peterson <[email protected]>

* Use find_package() for version detection

- Move VersionUtils.cmake into engine and remove from project templates
- Add o3deConfig.cmake and o3deConfigVersion.cmake which checks if a project is compatible
- Update EngineFinder.cmake in all templates to use find_package() to detect a compatible o3de package,
instead of having all the compatibility logic in the project where it is hard to update.

Signed-off-by: Alex Peterson <[email protected]>

* add project engine detection in findo3de.cmake

If the project's cmake code does not set O3DE_ENGINE_NAME_TO_USE or LY_ENGINE_NAME_TO_USE, attempt to find that information by looking in the project.json and <project>/user/project.json

Signed-off-by: Alex Peterson <[email protected]>

* fix spelling mistake and end of file newline

Signed-off-by: Alex Peterson <[email protected]>

* remove comment that is no longer valid

Signed-off-by: Alex Peterson <[email protected]>

* remove unused variable

Signed-off-by: Alex Peterson <[email protected]>

* Fix comment

Signed-off-by: Alex Peterson <[email protected]>

* remove unused warn mode

Signed-off-by: Alex Peterson <[email protected]>

* fix include function stomping, ignore old engines

The functions defined in VersionUtils were getting stomped because of how cmake deals with functions that try to use the same name.
Moving the function logic into o3deConfigVersion.cmake solves the issue
Also, remove logic that lets a user register an upgraded project with an old engine that does no version compatibility checks.
Users can revert to the old EngineFinder.cmake from the old engine or create a project with the old engine if they want to do this.

Signed-off-by: Alex Peterson <[email protected]>

* Findo3de.cmake checks PACKAGE_VERSION_COMPATIBLE

New projects should check version compatibility, old projects just use engine name.  When a simple project upgrade path is available, a message will be added telling users how to upgrade their project.

Signed-off-by: Alex Peterson <[email protected]>

* Surface JSON errors in user/project.json

Signed-off-by: Alex Peterson <[email protected]>

* Fix missing command line args QtEditorApplication

Signed-off-by: Alex Peterson <[email protected]>

* fix engine finder path append, better JSON err msg

Signed-off-by: Alex Peterson <[email protected]>

* Fix variable name typo in error message

Signed-off-by: Alex Peterson <[email protected]>

* Use simpler more accurate function name

Signed-off-by: Alex Peterson <[email protected]>

* use auto for test outcomes

Signed-off-by: Alex Peterson <[email protected]>

* make errors in user/project.json fatal

It was found in testing that errors in user/project.json were missed as warnings.  This change makes them fatal and provides more helpful information.

Signed-off-by: Alex Peterson <[email protected]>

* fix: unused 'using' statement

Signed-off-by: Alex Peterson <[email protected]>

* Fix incorrect mock method return type

Signed-off-by: Alex Peterson <[email protected]>

* fix get_project_engine_path compatibility check

get_project_engine_path was not returning the most compatible engine or taking into account <project_path>/user/project.json settings so Project Manager and the get-registered could return the wrong engine path

Signed-off-by: Alex Peterson <[email protected]>

* Fix debug assert when sort result is ambiguous

Signed-off-by: Alex Peterson <[email protected]>

* Make FindEngineRoot() like FindProjectRoot()

- moved common code to some helpers
- add test for scan up case

Signed-off-by: Alex Peterson <[email protected]>

* Don't verify test file buffer when read fails

Ran into a case where read was failing, and VerifyTestFile spams a failure line for every u32 in the whole buffer (thousands of lines).

Signed-off-by: Alex Peterson <[email protected]>

* fix comments

Signed-off-by: Alex Peterson <[email protected]>

* Fix test failures from missing user/project.json

Signed-off-by: Alex Peterson <[email protected]>

* remove unused internal function

Signed-off-by: Alex Peterson <[email protected]>

* use command line not bootstrap for engine_path

Signed-off-by: Alex Peterson <[email protected]>

* fix unit test - ::Run returns false with no params

The Run command actually returns false when there are no command line params.

Signed-off-by: Alex Peterson <[email protected]>

* Remove missing project-path bootstrap message

Not all applications need project path, and the bootstrap key should not be used, also main applications like Editor and AP that do need a project path have better messaging already, higher up.

Signed-off-by: Alex Peterson <[email protected]>

* use simpler scan up path instead of command line

Signed-off-by: Alex Peterson <[email protected]>

* fix linux unix test path

Signed-off-by: Alex Peterson <[email protected]>

---------

Signed-off-by: Alex Peterson <[email protected]>
Alex Peterson пре 2 година
родитељ
комит
7f464f1fb4
52 измењених фајлова са 2012 додато и 763 уклоњено
  1. 28 2
      AutomatedTesting/CMakeLists.txt
  2. 117 44
      AutomatedTesting/cmake/EngineFinder.cmake
  3. 12 4
      Code/Editor/Core/QtEditorApplication.cpp
  4. 2 2
      Code/Editor/CryEdit.cpp
  5. 1 1
      Code/Framework/AzCore/AzCore/Component/ComponentApplication.cpp
  6. 2 0
      Code/Framework/AzCore/AzCore/Component/ComponentApplication.h
  7. 1 1
      Code/Framework/AzCore/AzCore/Component/ComponentApplicationLifecycle.cpp
  8. 28 13
      Code/Framework/AzCore/AzCore/Dependency/Dependency.h
  9. 33 9
      Code/Framework/AzCore/AzCore/Dependency/Dependency.inl
  10. 2 2
      Code/Framework/AzCore/AzCore/Dependency/Version.h
  11. 3 2
      Code/Framework/AzCore/AzCore/Settings/SettingsRegistry.h
  12. 32 31
      Code/Framework/AzCore/AzCore/Settings/SettingsRegistryImpl.cpp
  13. 2 2
      Code/Framework/AzCore/AzCore/Settings/SettingsRegistryImpl.h
  14. 438 255
      Code/Framework/AzCore/AzCore/Settings/SettingsRegistryMergeUtils.cpp
  15. 1 1
      Code/Framework/AzCore/AzCore/UnitTest/Mocks/MockSettingsRegistry.h
  16. 1 1
      Code/Framework/AzCore/AzCore/Utils/Utils.cpp
  17. 3 0
      Code/Framework/AzCore/AzCore/azcore_files.cmake
  18. 319 0
      Code/Framework/AzCore/Tests/Settings/SettingsRegistryMergeUtilsTests.cpp
  19. 26 40
      Code/Framework/AzCore/Tests/Settings/SettingsRegistryTests.cpp
  20. 23 7
      Code/Framework/AzCore/Tests/StreamerTests.cpp
  21. 27 8
      Code/Framework/AzFramework/AzFramework/ProjectManager/ProjectManager.cpp
  22. 0 3
      Code/Framework/AzFramework/AzFramework/azframework_files.cmake
  23. 10 0
      Code/Framework/AzToolsFramework/AzToolsFramework/Application/ToolsApplication.cpp
  24. 1 14
      Code/Framework/AzToolsFramework/AzToolsFramework/UI/DocumentPropertyEditor/SettingsRegistrar.cpp
  25. 1 1
      Code/Tools/AssetProcessor/native/utilities/PlatformConfiguration.cpp
  26. 14 6
      Code/Tools/ProjectManager/Source/ProjectsScreen.cpp
  27. 1 1
      Code/Tools/ProjectManager/Source/PythonBindings.cpp
  28. 2 2
      Code/Tools/PythonBindingsExample/tests/ApplicationTests.cpp
  29. 3 4
      Gems/AssetValidation/Code/Tests/AssetValidationTestShared.h
  30. 6 7
      Gems/AtomLyIntegration/TechnicalArt/DccScriptingInterface/__init__.py
  31. 10 10
      Gems/LmbrCentral/Code/Source/Builders/CopyDependencyBuilder/XmlBuilderWorker/XmlBuilderWorker.cpp
  32. 4 4
      Gems/LmbrCentral/Code/Source/Builders/CopyDependencyBuilder/XmlBuilderWorker/XmlBuilderWorker.h
  33. 27 1
      Templates/DefaultProject/Template/CMakeLists.txt
  34. 117 44
      Templates/DefaultProject/Template/cmake/EngineFinder.cmake
  35. 27 1
      Templates/MinimalProject/Template/CMakeLists.txt
  36. 117 44
      Templates/MinimalProject/Template/cmake/EngineFinder.cmake
  37. 27 14
      cmake/Findo3de.cmake
  38. 10 0
      cmake/o3deConfig.cmake
  39. 158 0
      cmake/o3deConfigVersion.cmake
  40. 70 1
      scripts/o3de/o3de/compatibility.py
  41. 45 16
      scripts/o3de/o3de/manifest.py
  42. 68 31
      scripts/o3de/o3de/project_properties.py
  43. 1 56
      scripts/o3de/o3de/register.py
  44. 4 0
      scripts/o3de/o3de/validation.py
  45. 9 6
      scripts/o3de/tests/test_disable_gem.py
  46. 5 5
      scripts/o3de/tests/test_download.py
  47. 19 8
      scripts/o3de/tests/test_enable_gem.py
  48. 67 24
      scripts/o3de/tests/test_manifest.py
  49. 4 5
      scripts/o3de/tests/test_print_registration.py
  50. 67 20
      scripts/o3de/tests/test_project_properties.py
  51. 16 8
      scripts/o3de/tests/test_register.py
  52. 1 2
      scripts/o3de/tests/test_repo.py

+ 28 - 2
AutomatedTesting/CMakeLists.txt

@@ -8,11 +8,37 @@
 
 if(NOT PROJECT_NAME)
     cmake_minimum_required(VERSION 3.22)
-    include(cmake/EngineFinder.cmake OPTIONAL)
+
+    # Utility function to look for an optional 'engine_finder_cmake_path'setting 
+    function(get_engine_finder_cmake_path project_json_file_path path_value)
+        if(NOT ${path_value} AND EXISTS "${project_json_file_path}")
+            file(READ "${project_json_file_path}" project_json_data)
+            string(JSON engine_finder_cmake_value ERROR_VARIABLE json_error GET ${project_json_data} "engine_finder_cmake_path")
+            cmake_path(APPEND CMAKE_CURRENT_SOURCE_DIR "${engine_finder_cmake_value}" engine_finder_cmake_value)
+            if(NOT json_error AND EXISTS "${engine_finder_cmake_value}")
+                set(${path_value} "${engine_finder_cmake_value}" PARENT_SCOPE)
+            elseif(json_error AND ${engine_finder_cmake_value} STREQUAL "NOTFOUND")
+                # When the error value is just NOTFOUND that means there is a JSON
+                # parsing error, and not simply a missing key 
+                message(WARNING "Unable to read 'engine_finder_cmake_path'.\nError: ${json_error} ${engine_finder_cmake_value}")
+            endif()
+        endif()
+    endfunction()
+    
+    # Check for optional 'engine_finder_cmake_path' in order of preference
+    # We support per-project customization to make it easier to upgrade 
+    # or revert to a custom EngineFinder.cmake 
+    get_engine_finder_cmake_path("${CMAKE_CURRENT_SOURCE_DIR}/user/project.json" engine_finder_cmake_path)
+    get_engine_finder_cmake_path("${CMAKE_CURRENT_SOURCE_DIR}/project.json" engine_finder_cmake_path)
+    if(NOT engine_finder_cmake_path)
+        set(engine_finder_cmake_path cmake/EngineFinder.cmake)
+    endif()
+
+    include(${engine_finder_cmake_path} OPTIONAL)
     find_package(o3de REQUIRED)
     project(AutomatedTesting
         LANGUAGES C CXX
         VERSION 1.0.0.0
     )
     o3de_initialize()
-endif()
+endif()

+ 117 - 44
AutomatedTesting/cmake/EngineFinder.cmake

@@ -7,35 +7,62 @@
 #
 #
 # {END_LICENSE}
-# This file is copied during engine registration. Edits to this file will be lost next
-# time a registration happens.
+# Edits to this file may be lost in upgrades. Instead of changing this file, use 
+# the 'engine_finder_cmake_path' key in your project.json or user/project.json to specify 
+# an alternate .cmake file to use instead of this one.
 
 include_guard()
 
-# Read the engine name from the project_json file
-file(READ ${CMAKE_CURRENT_SOURCE_DIR}/project.json project_json)
 set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/project.json)
 
-string(JSON LY_ENGINE_NAME_TO_USE ERROR_VARIABLE json_error GET ${project_json} engine)
-if(json_error)
-    message(FATAL_ERROR "Unable to read key 'engine' from 'project.json'\nError: ${json_error}")
-endif()
-
+# Option 1: Use engine manually set in CMAKE_MODULE_PATH
+# CMAKE_MODULE_PATH must contain a path to an engine's cmake folder 
 if(CMAKE_MODULE_PATH)
     foreach(module_path ${CMAKE_MODULE_PATH})
-        if(EXISTS ${module_path}/Findo3de.cmake)
-            file(READ ${module_path}/../engine.json engine_json)
-            string(JSON engine_name ERROR_VARIABLE json_error GET ${engine_json} engine_name)
-            if(json_error)
-                message(FATAL_ERROR "Unable to read key 'engine_name' from 'engine.json'\nError: ${json_error}")
-            endif()
-            if(LY_ENGINE_NAME_TO_USE STREQUAL engine_name)
-                return() # Engine being forced through CMAKE_MODULE_PATH
+        cmake_path(SET module_engine_version_cmake_path "${module_path}/o3deConfigVersion.cmake")
+        if(EXISTS "${module_engine_version_cmake_path}")
+            include("${module_engine_version_cmake_path}")
+            if(PACKAGE_VERSION_COMPATIBLE)
+                message(STATUS "Selecting engine from CMAKE_MODULE_PATH '${module_path}'")
+                return()
+            else()
+                message(WARNING "Not using engine from CMAKE_MODULE_PATH '${module_path}' because it is not compatible with this project.")
             endif()
         endif()
     endforeach()
+    message(VERBOSE "No compatible engine found from CMAKE_MODULE_PATH '${CMAKE_MODULE_PATH}'.")
 endif()
 
+# Option 2: Use the engine from the 'engine_path' field in <project>/user/project.json
+cmake_path(SET O3DE_USER_PROJECT_JSON_PATH ${CMAKE_CURRENT_SOURCE_DIR}/user/project.json)
+if(EXISTS "${O3DE_USER_PROJECT_JSON_PATH}")
+    file(READ "${O3DE_USER_PROJECT_JSON_PATH}" user_project_json)
+    if(user_project_json)
+        string(JSON user_project_engine_path ERROR_VARIABLE json_error GET ${user_project_json} engine_path)
+        if(user_project_engine_path AND NOT json_error)
+            cmake_path(SET user_engine_version_cmake_path "${user_project_engine_path}/cmake/o3deConfigVersion.cmake")
+            if(EXISTS "${user_engine_version_cmake_path}")
+                include("${user_engine_version_cmake_path}")
+                if(PACKAGE_VERSION_COMPATIBLE)
+                    message(STATUS "Selecting engine '${user_project_engine_path}' from 'engine_path' in '<project>/user/project.json'.")
+                    list(APPEND CMAKE_MODULE_PATH "${user_project_engine_path}/cmake")
+                    return()
+                else()
+                    message(FATAL_ERROR "The engine at '${user_project_engine_path}' from 'engine_path' in '${O3DE_USER_PROJECT_JSON_PATH}' is not compatible with this project. Please register this project with a compatible engine, or remove the local override by running:\nscripts\\o3de edit-project-properties -pp ${CMAKE_CURRENT_SOURCE_DIR} --user --engine-path \"\"")
+                endif()
+            else()
+                message(FATAL_ERROR "This project cannot use the engine at '${user_project_engine_path}' because the version cmake file '${user_engine_version_cmake_path}' needed to check compatibility is missing.  \nPlease register this project with a compatible engine, or remove the local override by running:\nscripts\\o3de edit-project-properties -pp ${CMAKE_CURRENT_SOURCE_DIR} --user --engine-path \"\"\nIf you want this project to use an older engine(not recommended), provide a custom EngineFinder.cmake using the o3de CLI's --engine-finder-cmake-path option. ")
+            endif()
+        elseif(json_error AND ${user_project_engine_path} STREQUAL "NOTFOUND")
+            # When the value is just NOTFOUND that means there is a JSON
+            # parsing error, and not simply a missing key 
+            message(FATAL_ERROR "Unable to read 'engine_path' from '${user_project_engine_path}'\nError: ${json-error}")
+        endif()
+    endif()
+endif()
+
+
+# Option 3: Find a compatible engine registered in ~/.o3de/o3de_manifest.json 
 if(DEFINED ENV{USERPROFILE} AND EXISTS $ENV{USERPROFILE})
     set(manifest_path $ENV{USERPROFILE}/.o3de/o3de_manifest.json) # Windows
 else()
@@ -43,50 +70,96 @@ else()
 endif()
 
 set(registration_error [=[
+To enable more verbose logging, run the cmake command again with '--log-level VERBOSE'
+
 Engine registration is required before configuring a project.
 Run 'scripts/o3de register --this-engine' from the engine root.
 ]=])
 
-# Read the ~/.o3de/o3de_manifest.json file and look through the 'engines_path' object.
-# Find a key that matches LY_ENGINE_NAME_TO_USE and use that as the engine path.
+# Create a list of all engines
 if(EXISTS ${manifest_path})
     file(READ ${manifest_path} manifest_json)
     set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${manifest_path})
 
-    string(JSON engines_path_count ERROR_VARIABLE json_error LENGTH ${manifest_json} engines_path)
+    string(JSON engines_count ERROR_VARIABLE json_error LENGTH ${manifest_json} engines)
     if(json_error)
-        message(FATAL_ERROR "Unable to read key 'engines_path' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
+        message(FATAL_ERROR "Unable to read key 'engines' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
     endif()
 
-    string(JSON engines_path_type ERROR_VARIABLE json_error TYPE ${manifest_json} engines_path)
-    if(json_error OR NOT ${engines_path_type} STREQUAL "OBJECT")
-        message(FATAL_ERROR "Type of 'engines_path' in '${manifest_path}' is not a JSON Object\nError: ${json_error}")
+    string(JSON engines_type ERROR_VARIABLE json_error TYPE ${manifest_json} engines)
+    if(json_error OR NOT ${engines_type} STREQUAL "ARRAY")
+        message(FATAL_ERROR "Type of 'engines' in '${manifest_path}' is not a JSON ARRAY\nError: ${json_error}\n${registration_error}")
     endif()
 
-    math(EXPR engines_path_count "${engines_path_count}-1")
-    foreach(engine_path_index RANGE ${engines_path_count})
-        string(JSON engine_name ERROR_VARIABLE json_error MEMBER ${manifest_json} engines_path ${engine_path_index})
+    math(EXPR engines_count "${engines_count}-1")
+    foreach(array_index RANGE ${engines_count})
+        string(JSON manifest_engine_path ERROR_VARIABLE json_error GET ${manifest_json} engines "${array_index}")
         if(json_error)
-            message(FATAL_ERROR "Unable to read 'engines_path/${engine_path_index}' from '${manifest_path}'\nError: ${json_error}")
+            message(FATAL_ERROR "Unable to read 'engines/${array_index}' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
         endif()
+        list(APPEND O3DE_ENGINE_PATHS ${manifest_engine_path})
+    endforeach()
+elseif(NOT CMAKE_MODULE_PATH)
+    message(FATAL_ERROR "O3DE Manifest file not found at '${manifest_path}'.\n${registration_error}")
+endif()
 
-        if(LY_ENGINE_NAME_TO_USE STREQUAL engine_name)
-            string(JSON engine_path ERROR_VARIABLE json_error GET ${manifest_json} engines_path ${engine_name})
-            if(json_error)
-                message(FATAL_ERROR "Unable to read value from 'engines_path/${engine_name}'\nError: ${json_error}")
-            endif()
+# We cannot just run find_package() on the list of engine paths because
+# CMAKE_FIND_PACKAGE_SORT_ORDER sorts based on file name and chooses
+# the first package that returns PACKAGE_VERSION_COMPATIBLE 
+set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "")
+set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION "")
+foreach(manifest_engine_path IN LISTS O3DE_ENGINE_PATHS) 
+    # Does this engine have a config version cmake file?
+    cmake_path(SET version_cmake_path "${manifest_engine_path}/cmake/o3deConfigVersion.cmake")
+    if(NOT EXISTS "${version_cmake_path}") 
+        message(VERBOSE "Ignoring '${manifest_engine_path}' because no config version cmake file was found at '${version_cmake_path}'")
+        continue()
+    endif()
 
-            if(engine_path)
-                list(APPEND CMAKE_MODULE_PATH "${engine_path}/cmake")
-                return()
-            endif()
+    unset(PACKAGE_VERSION)
+    unset(PACKAGE_VERSION_COMPATIBLE)
+    include("${version_cmake_path}")
+
+    # Follow the version checking convention from find_package(CONFIG)
+    if(PACKAGE_VERSION_COMPATIBLE)
+        if(NOT O3DE_MOST_COMPATIBLE_ENGINE_PATH) 
+            set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "${manifest_engine_path}") 
+            set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION ${PACKAGE_VERSION}) 
+            message(VERBOSE "Found compatible engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}'")
+        elseif(${PACKAGE_VERSION} VERSION_GREATER ${O3DE_MOST_COMPATIBLE_ENGINE_VERSION})
+            set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "${manifest_engine_path}")
+            set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION ${PACKAGE_VERSION})
+            message(VERBOSE "Found more compatible engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}' because it has a greater version number.")
+        else()
+            message(VERBOSE "Not using engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}' because it doesn't have a greater version number or has a different engine name.")
         endif()
-    endforeach()
-    
-    message(FATAL_ERROR "The project.json uses engine name '${LY_ENGINE_NAME_TO_USE}' but no engine with that name has been registered.\n${registration_error}")
-else()
-    # If the user is passing CMAKE_MODULE_PATH we assume thats where we will find the engine
-    if(NOT CMAKE_MODULE_PATH)
-        message(FATAL_ERROR "O3DE Manifest file not found.\n${registration_error}")
+    else()
+        message(VERBOSE "Ignoring '${manifest_engine_path}' because it is not a compatible engine.")
     endif()
+endforeach()
+
+if(O3DE_MOST_COMPATIBLE_ENGINE_PATH)
+    message(STATUS "Selecting engine '${O3DE_MOST_COMPATIBLE_ENGINE_PATH}'")
+    list(APPEND CMAKE_MODULE_PATH "${O3DE_MOST_COMPATIBLE_ENGINE_PATH}/cmake")
+    return()
+endif()
+
+# No compatible engine was found.
+# Read the 'engine' field in project.json or user/project.json for more helpful messages 
+if(user_project_json)
+    string(JSON user_project_engine ERROR_VARIABLE json_error GET ${user_project_json} engine)
+endif()
+
+if(NOT user_project_engine)
+    file(READ ${CMAKE_CURRENT_SOURCE_DIR}/project.json o3de_project_json)
+    string(JSON project_engine ERROR_VARIABLE json_error GET ${o3de_project_json} engine)
+    if(json_error)
+        message(FATAL_ERROR "Unable to read key 'engine' from 'project.json'\nError: ${json_error}")
+    endif()
+endif()
+
+if(user_project_engine)
+    message(FATAL_ERROR "The local '${O3DE_USER_PROJECT_JSON_PATH}' engine is '${user_project_engine}' but no compatible engine with that name and version was found.  Please register the compatible engine, or remove the local engine override.\n${registration_error}")
+else()
+    message(FATAL_ERROR "The project.json engine is '${project_engine}' but no engine with that name and version was found.\n${registration_error}")
 endif()

+ 12 - 4
Code/Editor/Core/QtEditorApplication.cpp

@@ -239,10 +239,18 @@ namespace Editor
         // Initialize our stylesheet here to allow Gems to register stylesheets when their system components activate.
         AZ::IO::FixedMaxPath engineRootPath;
         {
-            // Create a ComponentApplication to initialize the AZ::SystemAllocator and initialize the SettingsRegistry
-            AZ::ComponentApplication application(argc, argv);
-            auto settingsRegistry = AZ::SettingsRegistry::Get();
-            settingsRegistry->Get(engineRootPath.Native(), AZ::SettingsRegistryMergeUtils::FilePathKey_EngineRootFolder);
+            using namespace AZ::SettingsRegistryMergeUtils;
+            constexpr bool executeRegDumpCommands = false;
+            AZ::SettingsRegistryImpl settingsRegistry;
+            AZ::CommandLine commandLine;
+            commandLine.Parse(argc, argv);
+
+            ParseCommandLine(commandLine);
+            StoreCommandLineToRegistry(settingsRegistry, commandLine);
+            MergeSettingsToRegistry_CommandLine(settingsRegistry, commandLine, executeRegDumpCommands);
+            MergeSettingsToRegistry_AddRuntimeFilePaths(settingsRegistry);
+
+            settingsRegistry.Get(engineRootPath.Native(), AZ::SettingsRegistryMergeUtils::FilePathKey_EngineRootFolder);
         }
         m_stylesheet->initialize(this, engineRootPath);
     }

+ 2 - 2
Code/Editor/CryEdit.cpp

@@ -523,8 +523,8 @@ public:
             {{"runpythonargs", "Command-line argument string to pass to the python script if --runpython or --runpythontest was used.", "runpythonargs"}, m_pythonArgs},
             {{"pythontestcase", "Test case name of python test script if --runpythontest was used.", "pythontestcase"}, m_pythonTestCase},
             {{"exec", "cfg file to run on startup, used for systems like automation", "exec"}, m_execFile},
-            {{"rhi", "Command-line argument to force which rhi to use", "dummyString"}, dummyString },
-            {{"rhi-device-validation", "Command-line argument to configure rhi validation", "dummyString"}, dummyString },
+            {{"rhi", "Command-line argument to force which rhi to use", "rhi"}, dummyString },
+            {{"rhi-device-validation", "Command-line argument to configure rhi validation", "rhi-device-validation"}, dummyString },
             {{"exec_line", "command to run on startup, used for systems like automation", "exec_line"}, m_execLineCmd},
             {{"regset", "Command-line argument to override settings registry values", "regset"}, dummyString},
             {{"regremove", "Deletes a value within the global settings registry at the JSON pointer path @key", "regremove"}, dummyString},

+ 1 - 1
Code/Framework/AzCore/AzCore/Component/ComponentApplication.cpp

@@ -728,7 +728,7 @@ namespace AZ
         AZ::TickBus::QueueFunction(AZStd::move(RegisterOnFirstTick));
     }
 
-    void ReportBadEngineRoot()
+    void ComponentApplication::ReportBadEngineRoot()
     {
         AZStd::string errorMessage = {"Unable to determine a valid path to the engine.\n"
                                       "Check parameters such as --project-path and --engine-path and make sure they are valid.\n"};

+ 2 - 0
Code/Framework/AzCore/AzCore/Component/ComponentApplication.h

@@ -314,6 +314,8 @@ namespace AZ
         //! application classes to specialize settings for those applications.
         virtual void SetSettingsRegistrySpecializations(SettingsRegistryInterface::Specializations& specializations);
 
+        void ReportBadEngineRoot();
+
         /**
          * This is the function that will be called instantly after the memory
          * manager is created. This is where we should register all core component

+ 1 - 1
Code/Framework/AzCore/AzCore/Component/ComponentApplicationLifecycle.cpp

@@ -28,7 +28,7 @@ namespace AZ::ComponentApplicationLifecycle
 
         if (!ValidateEvent(settingsRegistry, eventName))
         {
-            AZ_Warning("ComponentApplicationLifecycle", false, R"(Cannot signal event %.*s. Name does is not a field of object "%.*s".)"
+            AZ_Warning("ComponentApplicationLifecycle", false, R"(Cannot signal event %.*s because it is not a field of object "%.*s".)"
                 R"( Please make sure the entry exists in the '<engine-root>/Registry/application_lifecycle_events.setreg")"
                 " or in *.setreg within the project", AZ_STRING_ARG(eventName), AZ_STRING_ARG(ApplicationLifecycleEventRegistrationKey));
             return false;

+ 28 - 13
Code/Framework/AzFramework/AzFramework/Dependency/Dependency.h → Code/Framework/AzCore/AzCore/Dependency/Dependency.h

@@ -11,13 +11,12 @@
 #include <AzCore/Outcome/Outcome.h>
 #include <AzCore/std/containers/vector.h>
 #include <AzCore/std/string/regex.h>
+#include <AzCore/Dependency/Version.h>
 
-#include <AzFramework/Dependency/Version.h>
-
-namespace AzFramework
+namespace AZ 
 {
     /**
-     * Specifies a particular Gem instance (by ID and version).
+     * Specifies a particular object instance by UUID and version.
      */
     template <size_t N>
     struct Specifier
@@ -30,7 +29,7 @@ namespace AzFramework
     };
 
     /**
-     * Defines a Gem's dependency upon another Gem.
+     * Defines a dependency upon another versioned object.
      */
     template <size_t N>
     class Dependency
@@ -49,7 +48,7 @@ namespace AzFramework
                 GreaterThan = 1 << 0,
                 LessThan = 1 << 1,
                 EqualTo = 1 << 2,
-                // Special operators
+                // Special operators ~> and ~=
                 TwiddleWakka = 1 << 3
             };
 
@@ -110,19 +109,33 @@ namespace AzFramework
         ~Dependency() = default;
 
         /**
-         * Gets the ID of the Gem depended on.
+         * Gets the ID of the object depended on.
          *
-         * \returns             The ID of the Gem depended on.
+         * \returns             The ID of the object depended on.
          */
         const AZ::Uuid& GetID() const;
 
         /**
-         * Set the ID of the Gem depended on.
+         * Set the ID of the object depended on.
          *
          * \params[in] id       The ID of the dependency
          */
         void SetID(const AZ::Uuid& id);
 
+        /**
+         * Gets the name of the object depended on.
+         *
+         * \returns             The name of the object depended on.
+         */
+        const AZStd::string& GetName() const;
+
+        /**
+         * Set the name of the object depended on.
+         *
+         * \params[in] name       The name of the dependency
+         */
+        void SetName(const AZStd::string& name);
+
         /**
          * Gets the bounds that the dependence's version must fulfill.
          *
@@ -146,8 +159,8 @@ namespace AzFramework
          * Parses version bounds from a list of strings.
          *
          * Each string should fit the pattern [OPERATOR][VERSION],
-         * where [OPERATOR] is >, >=, <, <=, ==, or ~>,
-         * and [VERSION] is a valid version string, parsable by Gems::Version<N>.
+         * where [OPERATOR] is >, >=, <, <=, ==, ~> or ~=,
+         * and [VERSION] is a valid version string, parsable by AZ::Version<N>.
          *
          * \params[in] deps     The list of bound strings to parse.
          *
@@ -156,6 +169,7 @@ namespace AzFramework
         AZ::Outcome<void, AZStd::string> ParseVersions(const AZStd::vector<AZStd::string>& deps);
 
         AZ::Uuid m_id = AZ::Uuid::CreateNull();
+        AZStd::string m_name;
         AZStd::vector<Bound> m_bounds;
 
     private:
@@ -163,6 +177,7 @@ namespace AzFramework
         AZ::Outcome<AZ::u8> ParseVersion(AZStd::string str, Version<N>& ver);
 
         AZStd::regex m_dependencyRegex;
+        AZStd::regex m_namedDependencyRegex;
         AZStd::regex m_versionRegex;
     };
 
@@ -176,11 +191,11 @@ namespace AzFramework
     inline Ty operator~(Ty left)                { return ((Ty) ~(int)left); }
 
     BITMASK_OPS(Dependency< Version<4>::parts_count>::Bound::Comparison)
-        BITMASK_OPS(Dependency<SemanticVersion::parts_count>::Bound::Comparison)
+    BITMASK_OPS(Dependency<SemanticVersion::parts_count>::Bound::Comparison)
 
 #undef BITMASK_OPS
 
 
 } // namespace AzFramework
 
-#include <AzFramework/Dependency/Dependency.inl>
+#include <AzCore/Dependency/Dependency.inl>

+ 33 - 9
Code/Framework/AzFramework/AzFramework/Dependency/Dependency.inl → Code/Framework/AzCore/AzCore/Dependency/Dependency.inl

@@ -8,7 +8,7 @@
 
 #include <AzCore/StringFunc/StringFunc.h>
 
-namespace AzFramework
+namespace AZ
 {
     //////////////////////////////////////////////////////////////////////////
     // Specifier
@@ -39,7 +39,7 @@ namespace AzFramework
             AZ_Assert(m_parseDepth >= 2, "Internal Error: There should be "
                 "or more than 2 parts to a TwiddleWakka dependency.");
 
-            AZStd::string version = AZStd::string::format("~>%llu", m_version.m_parts[0]);
+            AZStd::string version = AZStd::string::format("~=%llu", m_version.m_parts[0]);
             for (AZ::u8 i = 1; i < m_parseDepth; ++i)
             {
                 version += AZStd::string::format(".%llu", m_version.m_parts[i]);
@@ -116,7 +116,8 @@ namespace AzFramework
     //////////////////////////////////////////////////////////////////////////
     template <size_t N>
     Dependency<N>::Dependency()
-        : m_dependencyRegex("(?:(~>|[>=<]{1,2}) *([0-9]+(?:\\.[0-9]+)*))")
+        : m_dependencyRegex("(?:(~>|~=|==|===|[>=<]{1,2}) *([0-9]+(?:\\.[0-9]+)*))") // ?: denotes a non-capture group
+        , m_namedDependencyRegex("(?:([^~>=<]*)(~>|~=|==|===|[>=<]{1,2}) *([0-9]+(?:\\.[0-9]+)*))")
         , m_versionRegex("([0-9]+)(?:\\.(.*)){0,1}")
     {
     }
@@ -142,6 +143,18 @@ namespace AzFramework
         m_id = id;
     }
 
+    template <size_t N>
+    const AZStd::string& Dependency<N>::GetName() const
+    {
+        return m_name;
+    }
+
+    template <size_t N>
+    void Dependency<N>::SetName(const AZStd::string& name)
+    {
+        m_name = name;
+    }
+
     template <size_t N>
     const AZStd::vector<typename Dependency<N>::Bound>& Dependency<N>::GetBounds() const
     {
@@ -176,9 +189,9 @@ namespace AzFramework
                 upper.m_comparison = Comp::LessThan;
                 upper.m_version = lower.m_version;
                 upper.m_parseDepth = bound.m_parseDepth;
-                // ~>1.0    becomes >=1.0   <2.0
-                // ~>1.2.0  becomes >=1.2.0 <1.3.0
-                // ~>1.2.3  becomes >=1.2.3 <1.3.0
+                // ~=1.0    becomes >=1.0   <2.0
+                // ~=1.2.0  becomes >=1.2.0 <1.3.0
+                // ~=1.2.3  becomes >=1.2.3 <1.3.0
                 upper.m_version.m_parts[lower.m_parseDepth - 1] = 0;
                 upper.m_version.m_parts[lower.m_parseDepth - 2]++;
 
@@ -219,7 +232,17 @@ namespace AzFramework
                 m_bounds.clear();
                 return AZ::Success();
             }
-            else if (AZStd::regex_match(depStr, match, m_dependencyRegex) && match.size() >= 3)
+
+            if (AZStd::regex_match(depStr, match, m_namedDependencyRegex) && match.size() >= 4)
+            {
+                m_name = match[1].str();
+
+                // remove the name prefix before parsing op and version
+                AZ::StringFunc::LChop(depStr, m_name.length());
+                AZ::StringFunc::Strip(depStr, " \t");
+            }
+
+            if (AZStd::regex_match(depStr, match, m_dependencyRegex) && match.size() >= 3)
             {
                 AZStd::string op = match[1].str();
                 AZStd::string versionStr = match[2].str();
@@ -238,7 +261,7 @@ namespace AzFramework
                 }
 
                 // Check for twiddle wakka, it's a special case
-                if (op == "~>")
+                if (op == "~=" || op == "~>")
                 {
                     // Lower bound
                     Bound bound;
@@ -247,7 +270,7 @@ namespace AzFramework
                     auto parseOutcome = ParseVersion(versionStr, bound.m_version);
                     if (!parseOutcome.IsSuccess() || parseOutcome.GetValue() < 2)
                     {
-                        // ~>1 not allowed, must specifiy ~>1.0
+                        // ~=1 not allowed, must specifiy ~=1.0
                         goto invalid_version_str;
                     }
                     bound.m_parseDepth = parseOutcome.GetValue();
@@ -284,6 +307,7 @@ namespace AzFramework
                     }
 
                     // since "=" is a valid comparsion string, we want to standardize it to ==
+                    // Note: this also converts === to ==
                     if (current.m_comparison == Comp::EqualTo)
                     {
                         op = "==";

+ 2 - 2
Code/Framework/AzFramework/AzFramework/Dependency/Version.h → Code/Framework/AzCore/AzCore/Dependency/Version.h

@@ -18,7 +18,7 @@
 #include <initializer_list>
 #include <sstream>
 
-namespace AzFramework
+namespace AZ
 {
 #define VERSION_SEPARATOR_CHAR '.'
 #define VERSION_SEPARATOR_STR "."
@@ -202,4 +202,4 @@ namespace AzFramework
     inline bool operator==(const Version<N>& a, const Version<N>& b) { return Version<N>::Compare(a, b) == 0; }
     template <size_t N>
     inline bool operator!=(const Version<N>& a, const Version<N>& b) { return Version<N>::Compare(a, b) != 0; }
-} // namespace AzFramework
+} // namespace AZ

+ 3 - 2
Code/Framework/AzCore/AzCore/Settings/SettingsRegistry.h

@@ -19,6 +19,7 @@
 #include <AzCore/std/string/string.h>
 #include <AzCore/std/string/string_view.h>
 #include <AzCore/StringFunc/StringFunc.h>
+#include <AzCore/Outcome/Outcome.h>
 
 namespace AZ
 {
@@ -379,8 +380,8 @@ namespace AZ
         //! @param anchorKey The key where the content of the settings file will be anchored.
         //! @param scratchBuffer An optional buffer that's used to load the file into. Use this when loading multiple patches to
         //!     reduce the number of intermediate memory allocations.
-        //! @return True if the registry file was successfully merged, otherwise false.
-        virtual bool MergeSettingsFile(AZStd::string_view path, Format format, AZStd::string_view anchorKey = "",
+        //! @return An AZ::Success if the registry file was successfully merged, or AZ::Failure with an error message.
+        virtual AZ::Outcome<void, AZStd::string> MergeSettingsFile(AZStd::string_view path, Format format, AZStd::string_view anchorKey = "",
             AZStd::vector<char>* scratchBuffer = nullptr) = 0;
         //! Loads all settings files in a folder and merges them into the registry.
         //!     With the specializations "a" and "b" and platform "c" the files would be loaded in the order:

+ 32 - 31
Code/Framework/AzCore/AzCore/Settings/SettingsRegistryImpl.cpp

@@ -802,15 +802,14 @@ namespace AZ
         return true;
     }
 
-    bool SettingsRegistryImpl::MergeSettingsFile(AZStd::string_view path, Format format, AZStd::string_view rootKey,
+    AZ::Outcome<void, AZStd::string> SettingsRegistryImpl::MergeSettingsFile(AZStd::string_view path, Format format, AZStd::string_view rootKey,
         AZStd::vector<char>* scratchBuffer)
     {
         using namespace rapidjson;
 
         if (path.empty())
         {
-            AZ_Error("Settings Registry", false, "Path provided for MergeSettingsFile is empty.");
-            return false;
+            return AZ::Failure("Path provided for MergeSettingsFile is empty.");
         }
 
         AZStd::vector<char> buffer;
@@ -819,7 +818,7 @@ namespace AZ
             scratchBuffer = &buffer;
         }
 
-        bool result = false;
+        AZ::Outcome<void, AZStd::string> result;
         if (path[path.length()] == 0)
         {
             result = MergeSettingsFileInternal(path.data(), format, rootKey, *scratchBuffer);
@@ -838,7 +837,9 @@ namespace AZ
                 pointer.Create(m_settings, m_settings.GetAllocator()).SetObject()
                     .AddMember(StringRef("Error"), StringRef("Unable to read registry file."), m_settings.GetAllocator())
                     .AddMember(StringRef("Path"), AZStd::move(pathValue), m_settings.GetAllocator());
-                return false;
+                return AZ::Failure(AZStd::string::format(
+                    R"(Path "%.*s" is too long. Either make sure that the provided path is terminated or use a shorter path.)",
+                    static_cast<int>(path.length()), path.data()));
             }
             AZ::IO::FixedMaxPathString filePath(path);
             result = MergeSettingsFileInternal(filePath.c_str(), format, rootKey, *scratchBuffer);
@@ -1314,7 +1315,7 @@ namespace AZ
         }
     }
 
-    bool SettingsRegistryImpl::MergeSettingsFileInternal(const char* path, Format format, AZStd::string_view rootKey,
+    AZ::Outcome<void, AZStd::string>  SettingsRegistryImpl::MergeSettingsFileInternal(const char* path, Format format, AZStd::string_view rootKey,
         AZStd::vector<char>& scratchBuffer)
     {
         using namespace AZ::IO;
@@ -1325,33 +1326,30 @@ namespace AZ
         FileReader fileReader(m_useFileIo ? AZ::IO::FileIOBase::GetInstance() : nullptr, path);
         if (!fileReader.IsOpen())
         {
-            AZ_Error("Settings Registry", false, R"(Unable to open registry file "%s".)", path);
             pointer.Create(m_settings, m_settings.GetAllocator()).SetObject()
                 .AddMember(StringRef("Error"), StringRef("Unable to open registry file."), m_settings.GetAllocator())
                 .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator());
-            return false;
+            return AZ::Failure(AZStd::string::format(R"(Unable to open registry file "%s".)", path));
         }
 
         u64 fileSize = fileReader.Length();
         if (fileSize == 0)
         {
-            AZ_Warning("Settings Registry", false, R"(Registry file "%s" is 0 bytes in length. There is no nothing to merge)", path);
             pointer.Create(m_settings, m_settings.GetAllocator())
                 .SetObject()
                 .AddMember(StringRef("Error"), StringRef("registry file is 0 bytes."), m_settings.GetAllocator())
                 .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator());
-            return false;
+            return AZ::Failure(AZStd::string::format(R"(Registry file "%s" is 0 bytes in length. There is no nothing to merge)", path));
         }
 
         scratchBuffer.clear();
         scratchBuffer.resize_no_construct(fileSize + 1);
         if (fileReader.Read(fileSize, scratchBuffer.data()) != fileSize)
         {
-            AZ_Error("Settings Registry", false, R"(Unable to read registry file "%s".)", path);
             pointer.Create(m_settings, m_settings.GetAllocator()).SetObject()
                 .AddMember(StringRef("Error"), StringRef("Unable to read registry file."), m_settings.GetAllocator())
                 .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator());
-            return false;
+            return AZ::Failure(AZStd::string::format(R"(Unable to read registry file "%s".)", path));
         }
         scratchBuffer[fileSize] = 0;
 
@@ -1360,18 +1358,22 @@ namespace AZ
         jsonPatch.ParseInsitu<flags>(scratchBuffer.data());
         if (jsonPatch.HasParseError())
         {
+            AZ::Outcome<void, AZStd::string> result;
             auto nativeUI = AZ::Interface<NativeUI::NativeUIRequests>::Get();
             if (jsonPatch.GetParseError() == rapidjson::kParseErrorDocumentEmpty)
             {
-                AZ_Warning("Settings Registry", false, R"(Unable to parse registry file "%s" due to json error "%s" at offset %zu.)",
-                    path, GetParseError_En(jsonPatch.GetParseError()), jsonPatch.GetErrorOffset());
+                result = AZ::Failure(AZStd::string::format(
+                    R"(Unable to parse registry file "%s" due to json error "%s" at offset %zu.)",
+                    path,
+                    GetParseError_En(jsonPatch.GetParseError()),
+                    jsonPatch.GetErrorOffset()));
             }
             else
             {
                 using ErrorString = AZStd::fixed_string<4096>;
                 auto jsonError = ErrorString::format(R"(Unable to parse registry file "%s" due to json error "%s" at offset %zu.)", path,
                     GetParseError_En(jsonPatch.GetParseError()), jsonPatch.GetErrorOffset());
-                AZ_Error("Settings Registry", false, "%s", jsonError.c_str());
+                result = AZ::Failure(jsonError.c_str());
 
                 if (nativeUI)
                 {
@@ -1385,7 +1387,7 @@ namespace AZ
                 .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator())
                 .AddMember(StringRef("Message"), StringRef(GetParseError_En(jsonPatch.GetParseError())), m_settings.GetAllocator())
                 .AddMember(StringRef("Offset"), aznumeric_cast<uint64_t>(jsonPatch.GetErrorOffset()), m_settings.GetAllocator());
-            return false;
+            return result;
         }
 
         JsonMergeApproach mergeApproach;
@@ -1398,25 +1400,24 @@ namespace AZ
             mergeApproach = JsonMergeApproach::JsonMergePatch;
             if (!jsonPatch.IsObject())
             {
-                AZ_Error("Settings Registry", false, R"(Attempting to merge the settings registry file "%s" where the root element is a)"
-                    R"( non-JSON Object using the JSON MergePatch approach. The JSON MergePatch algorithm would therefore)"
-                    R"( overwrite all settings at the supplied root-key path and therefore merging has been)"
-                    R"( disallowed to prevent field destruction.)" "\n"
-                    R"(To merge the supplied settings registry file, the settings within it must be placed within a JSON Object '{}')"
-                    R"( in order to allow moving of its fields using the root-key as an anchor.)", path);
-
                 AZStd::scoped_lock lock(LockForWriting());
                 pointer.Create(m_settings, m_settings.GetAllocator()).SetObject()
                     .AddMember(StringRef("Error"), StringRef("Cannot merge registry file with a root which is not a JSON Object,"
                         " an empty root key and a merge approach of JsonMergePatch. Otherwise the Settings Registry would be overridden."
                         " See RFC 7386 for more information"), m_settings.GetAllocator())
                     .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator());
-                return false;
+
+                return AZ::Failure(AZStd::string::format( R"(Attempting to merge the settings registry file "%s" where the root element is a)"
+                    R"( non-JSON Object using the JSON MergePatch approach. The JSON MergePatch algorithm would therefore)"
+                    R"( overwrite all settings at the supplied root-key path and therefore merging has been)"
+                    R"( disallowed to prevent field destruction.)" "\n"
+                    R"(To merge the supplied settings registry file, the settings within it must be placed within a JSON Object '{}')"
+                    R"( in order to allow moving of its fields using the root-key as an anchor.)", path));
             }
             break;
         default:
             AZ_Assert(false, "Provided format for merging settings into the Setting Registry is unsupported.");
-            return false;
+            return AZ::Failure("Provided format for merging settings into the Setting Registry is unsupported.");
         }
 
         // Add a reporting callback to capture JSON patch operations while merging if the merge operations notify
@@ -1482,23 +1483,23 @@ namespace AZ
             }
             else
             {
-                AZ_Error("Settings Registry", false, R"(Failed to root path "%.*s" is invalid.)",
-                    aznumeric_cast<int>(rootKey.length()), rootKey.data());
                 AZStd::scoped_lock lock(LockForWriting());
                 pointer.Create(m_settings, m_settings.GetAllocator()).SetObject()
                     .AddMember(StringRef("Error"), StringRef("Invalid root key."), m_settings.GetAllocator())
                     .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator());
-                return false;
+
+                return AZ::Failure(AZStd::string::format(R"(Failed to root path "%.*s" is invalid.)",
+                    aznumeric_cast<int>(rootKey.length()), rootKey.data()));
             }
         }
         if (mergeResult.GetProcessing() != JsonSerializationResult::Processing::Completed)
         {
-            AZ_Error("Settings Registry", false, R"(Failed to fully merge registry file "%s".)", path);
             AZStd::scoped_lock lock(LockForWriting());
             pointer.Create(m_settings, m_settings.GetAllocator()).SetObject()
                 .AddMember(StringRef("Error"), StringRef("Failed to fully merge registry file."), m_settings.GetAllocator())
                 .AddMember(StringRef("Path"), Value(path, m_settings.GetAllocator()), m_settings.GetAllocator());
-            return false;
+            
+           return AZ::Failure(AZStd::string::format(R"(Failed to fully merge registry file "%s".)", path));
         }
 
         {
@@ -1514,7 +1515,7 @@ namespace AZ
 
         SignalNotifier(rootKey, anchorType);
 
-        return true;
+        return AZ::Success();
     }
 
     void SettingsRegistryImpl::SetNotifyForMergeOperations(bool notify)

+ 2 - 2
Code/Framework/AzCore/AzCore/Settings/SettingsRegistryImpl.h

@@ -80,7 +80,7 @@ namespace AZ
         bool MergeCommandLineArgument(AZStd::string_view argument, AZStd::string_view anchorKey,
             const CommandLineArgumentSettings& commandLineSettings) override;
         bool MergeSettings(AZStd::string_view data, Format format, AZStd::string_view anchorKey = "") override;
-        bool MergeSettingsFile(AZStd::string_view path, Format format, AZStd::string_view anchorKey = "",
+        AZ::Outcome<void, AZStd::string> MergeSettingsFile(AZStd::string_view path, Format format, AZStd::string_view anchorKey = "",
             AZStd::vector<char>* scratchBuffer = nullptr) override;
         bool MergeSettingsFolder(AZStd::string_view path, const Specializations& specializations,
             AZStd::string_view platform, AZStd::string_view anchorKey = "", AZStd::vector<char>* scratchBuffer = nullptr) override;
@@ -114,7 +114,7 @@ namespace AZ
         bool IsLessThan(bool& collisionFound, const RegistryFile& lhs, const RegistryFile& rhs, const Specializations& specializations,
             const rapidjson::Pointer& historyPointer, AZStd::string_view folderPath);
         bool ExtractFileDescription(RegistryFile& output, AZStd::string_view filename, const Specializations& specializations);
-        bool MergeSettingsFileInternal(const char* path, Format format, AZStd::string_view rootKey, AZStd::vector<char>& scratchBuffer);
+        AZ::Outcome<void, AZStd::string> MergeSettingsFileInternal(const char* path, Format format, AZStd::string_view rootKey, AZStd::vector<char>& scratchBuffer);
 
         void SignalNotifier(AZStd::string_view jsonPath, SettingsType type);
 

+ 438 - 255
Code/Framework/AzCore/AzCore/Settings/SettingsRegistryMergeUtils.cpp

@@ -22,6 +22,8 @@
 #include <AzCore/Settings/SettingsRegistryVisitorUtils.h>
 #include <AzCore/std/string/conversions.h>
 #include <AzCore/Utils/Utils.h>
+#include <AzCore/Dependency/Dependency.h>
+#include <AzCore/Outcome/Outcome.h>
 
 #include <cinttypes>
 #include <locale>
@@ -43,6 +45,11 @@ namespace AZ::Internal
     //! This is /Users/<username>/.o3de/Registry on MacOS = $HOME
     static constexpr AZStd::string_view SetregFileProjectRootKey{ "/Amazon/AzCore/Bootstrap/project_path" };
 
+    //! References the settings key to set the engine path via *.setreg(patch) file
+    //! Lowest Priority: Will be overridden by the Engine Scan Up Key value
+    //! This setting shouldn't be used be at all - see note on project path root key above
+    static constexpr AZStd::string_view SetregFileEngineRootKey{ "/Amazon/AzCore/Bootstrap/engine_path" };
+
     //! Represents the settings key storing the value of locating the project path by scanning upwards
     //! Middle Priority: Overrides any project path set via .setreg(patch) file
     //!
@@ -50,13 +57,23 @@ namespace AZ::Internal
     //! without the need to specify the --project-path argument.
     static constexpr AZStd::string_view ScanUpProjectRootKey{ "/O3DE/Runtime/Internal/project_root_scan_up_path" };
 
+    //! Represents the settings key storing the value of locating the engine path by scanning upwards
+    //! Middle Priority: Overrides any engine path set via .setreg(patch) file
+    //!
+    //! This setting is used when running in an engine-centric workflow to locate the engine root directory
+    //! without the need to specify the --engine-path argument.
+    static constexpr AZStd::string_view ScanUpEngineRootKey{ "/O3DE/Runtime/Internal/engine_root_scan_up_path" };
+
     //! References the settings key where the command line value for the --project-path option would be stored
     //! Highest Priority: Overrides any project paths specified in the "/Amazon/AzCore/Bootstrap/project_path" key
     //! or found via scanning upwards from the nearest executable directory to locate a project.json file
     //!
     //! This setting should be used when using running an O3DE application from a location where a project.json file
     //! cannot be found by scanning upwards.
-    static constexpr AZStd::string_view CommandLineProjectRootKey{ "/O3DE/Runtime/CommandLine" };
+    static constexpr AZStd::string_view CommandLineKey{ "/O3DE/Runtime/CommandLine" };
+
+    static constexpr AZStd::string_view CommandLineEngineOptionName{ "engine-path" };
+    static constexpr AZStd::string_view CommandLineProjectOptionName{ "project-path" };
 
     static constexpr AZStd::string_view EngineJsonFilename = "engine.json";
     static constexpr AZStd::string_view GemJsonFilename = "gem.json";
@@ -65,159 +82,426 @@ namespace AZ::Internal
 
     static constexpr const char* ProductCacheDirectoryName = "Cache";
 
-    AZ::SettingsRegistryInterface::FixedValueString GetEngineMonikerForProject(
-        SettingsRegistryInterface& settingsRegistry, const AZ::IO::FixedMaxPath& projectJsonPath)
+    AZ::Outcome<void, AZStd::string> MergeEngineAndProjectSettings(
+        AZ::SettingsRegistryInterface& settingsRegistry,
+        const AZ::IO::FixedMaxPath& engineJsonPath,
+        const AZ::IO::FixedMaxPath& projectJsonPath,
+        const AZ::IO::FixedMaxPath& projectUserJsonPath = {})
+    {
+        static constexpr AZStd::string_view InternalProjectJsonPathKey{ "/O3DE/Runtime/Internal/project_json_path" };
+
+        using namespace AZ::SettingsRegistryMergeUtils;
+        constexpr auto format = AZ::SettingsRegistryInterface::Format::JsonMergePatch;
+
+        if (auto outcome = settingsRegistry.MergeSettingsFile(engineJsonPath.LexicallyNormal().c_str(), format, EngineSettingsRootKey);
+            !outcome)
+        {
+            return outcome;
+        }
+
+        // Check if the currently merged project is the same
+        AZ::IO::FixedMaxPath mergedProjectJsonPath;
+        if (settingsRegistry.Get(mergedProjectJsonPath.Native(), InternalProjectJsonPathKey); mergedProjectJsonPath == projectJsonPath)
+        {
+            return AZ::Success();
+        }
+
+        if (auto outcome = settingsRegistry.MergeSettingsFile(projectJsonPath.LexicallyNormal().c_str(), format, ProjectSettingsRootKey);
+            !outcome)
+        {
+            return outcome;
+        }
+
+        // '<project-root>/user/project.json' file overrides are optional
+        if (!projectUserJsonPath.empty())
+        {
+            settingsRegistry.MergeSettingsFile(projectUserJsonPath.LexicallyNormal().c_str(), format, ProjectSettingsRootKey);
+        }
+
+        // Set the InternalProjectJsonPathKey to avoid loading again
+        settingsRegistry.Set(InternalProjectJsonPathKey, projectJsonPath.Native());
+
+        return AZ::Success();
+    }
+
+    AZ::IO::FixedMaxPath GetCommandLineOption(
+        AZ::SettingsRegistryInterface& settingsRegistry, AZStd::string_view optionName)
+    {
+        using FixedValueString = SettingsRegistryInterface::FixedValueString;
+        AZ::IO::FixedMaxPath optionPath;
+
+        //  Parse Command Line
+        auto VisitCommandLineOptions = [&optionPath, optionName](const AZ::SettingsRegistryInterface::VisitArgs& visitArgs)
+        {
+            // Lookup the "/O3DE/Runtime/CommandLine/%u/Option" for each command line parameter to
+            // see if the key and value are available, and if they are, retrieve the value.
+            auto cmdPathKey = FixedValueString::format("%.*s/Option", AZ_STRING_ARG(visitArgs.m_jsonKeyPath));
+            if (FixedValueString cmdOptionName;
+                visitArgs.m_registry.Get(cmdOptionName, cmdPathKey) && cmdOptionName == optionName)
+            {
+                // Updated the existing cmdPathKey to read the value from the command line
+                cmdPathKey = FixedValueString::format("%.*s/Value", AZ_STRING_ARG(visitArgs.m_jsonKeyPath));
+                visitArgs.m_registry.Get(optionPath.Native(), cmdPathKey);
+            }
+
+            // Continue to visit command line parameters, in case there is a additional matching options
+            return AZ::SettingsRegistryInterface::VisitResponse::Skip;
+        };
+        SettingsRegistryVisitorUtils::VisitArray(settingsRegistry, VisitCommandLineOptions, Internal::CommandLineKey);
+
+        return optionPath;
+    }
+
+
+    AZ::IO::FixedMaxPath ScanUpRootLocator(AZStd::string_view rootFileToLocate)
+    {
+        AZ::IO::FixedMaxPath rootCandidate{ AZ::Utils::GetExecutableDirectory() };
+
+        bool rootPathVisited = false;
+        do
+        {
+            if (AZ::IO::SystemFile::Exists((rootCandidate / rootFileToLocate).c_str()))
+            {
+                return rootCandidate;
+            }
+
+            // Note for posix filesystems the parent directory of '/' is '/' and for windows
+            // the parent directory of 'C:\\' is 'C:\\'
+
+            // Validate that the parent directory isn't itself, that would imply
+            // that it is the filesystem root path
+            AZ::IO::PathView parentPath = rootCandidate.ParentPath();
+            rootPathVisited = (rootCandidate == parentPath);
+            // Recurse upwards one directory
+            rootCandidate = AZStd::move(parentPath);
+
+        } while (!rootPathVisited);
+
+        return {};
+    }
+
+    void SetScanUpRootKey(AZ::SettingsRegistryInterface& settingsRegistry, AZStd::string_view key, AZStd::string_view fileLocator)
+    {
+        using Type = SettingsRegistryInterface::Type;
+        if (settingsRegistry.GetType(key) == Type::NoType)
+        {
+            // We can scan up from exe directory to find fileLocator file, use that for the root if it exists.
+            AZ::IO::FixedMaxPath rootPath = Internal::ScanUpRootLocator(fileLocator);
+            if (!rootPath.empty() && rootPath.IsRelative())
+            {
+                if (auto rootAbsPath = AZ::Utils::ConvertToAbsolutePath(rootPath.Native()); rootAbsPath.has_value())
+                {
+                    rootPath = AZStd::move(*rootAbsPath);
+                }
+            }
+
+            settingsRegistry.Set(key, rootPath.Native());
+        }
+    }
+
+    AZ::Outcome<void, AZStd::string> EngineIsCompatible(
+        AZ::SettingsRegistryInterface& settingsRegistry,
+        const AZ::IO::FixedMaxPath& engineJsonPath,
+        const AZ::IO::FixedMaxPath& projectJsonPath,
+        const AZ::IO::FixedMaxPath& projectUserJsonPath = {}
+        )
     {
-        // projectPath needs to be an absolute path here.
+        using FixedValueString = AZ::SettingsRegistryInterface::FixedValueString;
         using namespace AZ::SettingsRegistryMergeUtils;
-        bool projectJsonMerged = settingsRegistry.MergeSettingsFile(
-            projectJsonPath.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, ProjectSettingsRootKey);
 
-        AZ::SettingsRegistryInterface::FixedValueString engineMoniker;
-        if (projectJsonMerged)
+        if (auto outcome = MergeEngineAndProjectSettings(settingsRegistry, engineJsonPath,
+            projectJsonPath, projectUserJsonPath);
+            !outcome)
+        {
+            return outcome;
+        }
+
+        // In project.json look for the "engine" key.
+        FixedValueString projectEngineMoniker;
+        const auto projectEngineKey = FixedValueString::format("%s/engine", ProjectSettingsRootKey);
+        if (!settingsRegistry.Get(projectEngineMoniker, projectEngineKey) || projectEngineMoniker.empty())
+        {
+            return AZ::Failure("Could not find an 'engine' key in 'project.json'.\n"
+                "Please verify the project is registered with a compatible engine.");
+        }
+
+        FixedValueString engineName;
+        const auto engineNameKey = FixedValueString::format("%s/engine_name", EngineSettingsRootKey);
+        if (!settingsRegistry.Get(engineName, engineNameKey) || engineName.empty())
+        {
+            return AZ::Failure(AZStd::string::format(
+                "Could not find an 'engine_name' key in '%s'.\n"
+                "Please verify the 'engine.json' file is not corrupt "
+                "and that the engine is installed and registered.",
+                engineJsonPath.c_str()));
+        }
+
+        FixedValueString projectEngineMonikerWithSpecifier = projectEngineMoniker;
+
+        // Extract dependency specifier from engine moniker
+        AZ::Dependency<SemanticVersion::parts_count> engineDependency;
+        if (engineDependency.ParseVersions({ projectEngineMoniker.c_str() }) &&
+            !engineDependency.GetName().empty())
         {
-            // In project.json look for the "engine" key.
-            auto engineMonikerKey = AZ::SettingsRegistryInterface::FixedValueString::format("%s/engine", ProjectSettingsRootKey);
-            settingsRegistry.Get(engineMoniker, engineMonikerKey);
+            projectEngineMoniker = engineDependency.GetName();
         }
 
-        return engineMoniker;
+        if (projectEngineMoniker != engineName)
+        {
+            return AZ::Failure(AZStd::string::format(
+                "Engine name '%s' in project.json does not match the engine_name '%s' found in '%s'.\n"
+                "Please verify the project has been registered to the correct engine.",
+                projectEngineMoniker.c_str(), engineName.c_str(), engineJsonPath.c_str()));
+        }
+
+        if (engineDependency.GetBounds().empty())
+        {
+            // There is no version specifier to satisfy
+            return AZ::Success();
+        }
+
+        // If the engine has no version information or is not known incompatible then assume compatible 
+        const auto engineVersionKey = FixedValueString::format("%s/version", EngineSettingsRootKey);
+        if(FixedValueString engineVersionValue; settingsRegistry.Get(engineVersionValue, engineVersionKey))
+        {
+            using SemanticSpecifier = AZ::Specifier<AZ::SemanticVersion::parts_count>;
+            AZ::SemanticVersion engineVersion;
+            if (auto parseOutcome = AZ::SemanticVersion::ParseFromString(engineVersionValue.c_str()); parseOutcome)
+            {
+                engineVersion = parseOutcome.TakeValue();
+                if(!engineDependency.IsFullfilledBy(SemanticSpecifier(AZ::Uuid::CreateNull(), engineVersion)))
+                {
+                    return AZ::Failure(AZStd::string::format(
+                        "Engine version '%s' in '%s' does not satisfy the project.json engine constraints '%s'.\n"
+                        "Please verify you have a compatible engine installed and that the project is registered  "
+                        " to the correct engine.",
+                        engineVersionValue.c_str(), engineJsonPath.c_str(), projectEngineMonikerWithSpecifier.c_str()));
+                }
+            }
+        }
+
+        return AZ::Success();
+    }
+
+    AZ::Outcome<AZ::IO::FixedMaxPath, AZStd::string> ReconcileEngineRootFromProjectUserPath(
+        SettingsRegistryInterface& settingsRegistry, const AZ::IO::FixedMaxPath& projectPath)
+    {
+        using namespace AZ::SettingsRegistryMergeUtils;
+        using FixedValueString = AZ::SettingsRegistryInterface::FixedValueString;
+        constexpr auto format = AZ::SettingsRegistryInterface::Format::JsonMergePatch;
+        AZ::IO::FixedMaxPath engineRoot{};
+
+        AZ::IO::FixedMaxPath projectUserPath;
+        if (!settingsRegistry.Get(projectUserPath.Native(), FilePathKey_ProjectUserPath) ||
+            projectUserPath.empty())
+        {
+            return AZ::Success(engineRoot);
+        }
+
+        const auto projectUserJsonPath = (projectUserPath / Internal::ProjectJsonFilename).LexicallyNormal();
+        if (auto outcome = settingsRegistry.MergeSettingsFile(projectUserJsonPath.c_str(), format, ProjectSettingsRootKey);
+            !outcome)
+        {
+            return AZ::Success(engineRoot);
+        }
+
+        settingsRegistry.Get(engineRoot.Native(), FixedValueString::format("%s/engine_path", ProjectSettingsRootKey));
+        if (!engineRoot.empty())
+        {
+            if (engineRoot.IsRelative())
+            {
+                if (auto engineRootAbsPath = AZ::Utils::ConvertToAbsolutePath(engineRoot.Native());
+                    engineRootAbsPath.has_value())
+                {
+                    engineRoot = AZStd::move(*engineRootAbsPath);
+                }
+            }
+
+            AZ::IO::FixedMaxPath engineJsonPath{ engineRoot / Internal::EngineJsonFilename };
+            AZ::IO::FixedMaxPath projectJsonPath{ projectPath / Internal::ProjectJsonFilename };
+            if (auto isCompatible = Internal::EngineIsCompatible(settingsRegistry, engineJsonPath,
+                projectJsonPath, projectUserJsonPath);
+                !isCompatible)
+            {
+                return AZ::Failure(isCompatible.GetError());
+            }
+        }
+
+        return AZ::Success(engineRoot);
     }
 
     AZ::IO::FixedMaxPath ReconcileEngineRootFromProjectPath(SettingsRegistryInterface& settingsRegistry, const AZ::IO::FixedMaxPath& projectPath)
     {
-        // Find the engine root via the engine manifest file and project.json
+        // Find the engine root via '<project-root>/user/project.json', engine manifest and project.json
         // Locate the engine manifest file and merge it to settings registry.
         // Visit over the engine paths list and merge the engine.json files to settings registry.
-        // Merge project.json to settings registry.  That will give us an "engine" key.
-        // When we find a match for "engine_name" value against the "engine" value from before, we can stop and use that engine root.
-        // Finally set the BootstrapSettingsRootKey/engine_path setting so that subsequent calls to GetEngineRoot will use that
-        // and avoid all this logic.
+        // Merge project.json to settings registry.  The "engine" key contains the engine name and optional version specifier.
+        // When we find a match for "engine_name" value against the "engine" we check if the engine "version" is compatible
+        // with any version specifier in the project's "engine" key. 
+        // If the engine is compatible we check if it is more compatible than the previously found most compatible engine.
+        // Finally, merge in the engine and project settings for the most compatible engine into the registry and
+        // return the path of the most compatible engine
 
         using namespace AZ::SettingsRegistryMergeUtils;
         using FixedValueString = AZ::SettingsRegistryInterface::FixedValueString;
 
         AZ::IO::FixedMaxPath engineRoot;
-        if (auto o3deManifestPath = AZ::Utils::GetO3deManifestPath(); !o3deManifestPath.empty())
+        if (auto o3deManifestPath = AZ::Utils::GetO3deManifestPath(&settingsRegistry); !o3deManifestPath.empty())
         {
-            bool manifestLoaded{false};
-
-            if (AZ::IO::SystemFile::Exists(o3deManifestPath.c_str()))
-            {
-                manifestLoaded = settingsRegistry.MergeSettingsFile(
-                    o3deManifestPath, AZ::SettingsRegistryInterface::Format::JsonMergePatch, O3deManifestSettingsRootKey);
-            }
+            const auto manifestLoaded = settingsRegistry.MergeSettingsFile(o3deManifestPath,
+                    AZ::SettingsRegistryInterface::Format::JsonMergePatch, O3deManifestSettingsRootKey);
 
             struct EngineInfo
             {
                 AZ::IO::FixedMaxPath m_path;
-                FixedValueString m_moniker;
+                FixedValueString m_name;
+                AZ::SemanticVersion m_version;
             };
 
-            struct EnginePathsVisitor : public AZ::SettingsRegistryInterface::Visitor
+            AZStd::set<AZ::IO::FixedMaxPath> missingProjectJsonPaths;
+            AZStd::vector<EngineInfo> searchedEngineInfo;
+
+            if (manifestLoaded)
             {
-                using AZ::SettingsRegistryInterface::Visitor::Visit;
-                void Visit(
-                    const AZ::SettingsRegistryInterface::VisitArgs& visitArgs, AZStd::string_view value) override
-                {
-                    m_enginePaths.emplace_back(EngineInfo{ AZ::IO::FixedMaxPath{value}.LexicallyNormal(), FixedValueString{visitArgs.m_fieldName} });
-                    // Make sure any engine paths read from the manifest are absolute
-                    AZ::IO::FixedMaxPath& recentEnginePath = m_enginePaths.back().m_path;
-                    if (recentEnginePath.IsRelative())
+                const auto engineVersionKey = FixedValueString::format("%s/version", EngineSettingsRootKey);
+                const auto engineNameKey = FixedValueString::format("%s/engine_name", EngineSettingsRootKey);
+                const auto enginesKey = FixedValueString::format("%s/engines", O3deManifestSettingsRootKey);
+
+                // Avoid modifying the SettingsRegistry while visiting which may invalidate iterators and cause crashes
+                AZ::SettingsRegistryVisitorUtils::VisitArray(settingsRegistry,
+                    [&](const AZ::SettingsRegistryInterface::VisitArgs& visitArgs)
                     {
-                        if (auto engineRootAbsPath = AZ::Utils::ConvertToAbsolutePath(recentEnginePath.Native());
-                            engineRootAbsPath.has_value())
+                        EngineInfo engineInfo;
+                        visitArgs.m_registry.Get(engineInfo.m_path.Native(), visitArgs.m_jsonKeyPath);
+                        if (engineInfo.m_path.IsRelative())
                         {
-                            recentEnginePath = AZStd::move(*engineRootAbsPath);
+                            if (auto engineRootAbsPath = AZ::Utils::ConvertToAbsolutePath(engineInfo.m_path.Native());
+                                engineRootAbsPath.has_value())
+                            {
+                                engineInfo.m_path = AZStd::move(*engineRootAbsPath);
+                            }
                         }
-                    }
-                }
+                        searchedEngineInfo.emplace_back(engineInfo);
+                        return AZ::SettingsRegistryInterface::VisitResponse::Continue;
+                    },
+                    enginesKey);
+
+                EngineInfo mostCompatibleEngineInfo;
+                AZ::IO::FixedMaxPath projectUserPath;
+                settingsRegistry.Get(projectUserPath.Native(), FilePathKey_ProjectUserPath);
+        
+                AZ::SettingsRegistryImpl scratchSettingsRegistry;
+
+                // Look through the manifest engines for the most compatible engine
+                for (auto& engineInfo : searchedEngineInfo)
+                {
+                    AZ::IO::FixedMaxPath projectJsonPath{projectPath / ProjectJsonFilename};
+                    AZ::IO::FixedMaxPath projectUserJsonPath{projectUserPath / ProjectJsonFilename};
+                    AZ::IO::FixedMaxPath engineJsonPath{engineInfo.m_path  / EngineJsonFilename};
 
-                AZStd::vector<EngineInfo> m_enginePaths{};
-            };
+                    auto isCompatible = Internal::EngineIsCompatible(scratchSettingsRegistry, engineJsonPath, projectJsonPath, projectUserJsonPath);
 
-            EnginePathsVisitor pathVisitor;
-            if (manifestLoaded)
-            {
-                auto enginePathsKey = FixedValueString::format("%s/engines_path", O3deManifestSettingsRootKey);
-                settingsRegistry.Visit(pathVisitor, enginePathsKey);
-            }
-
-            const auto engineMonikerKey = FixedValueString::format("%s/engine_name", EngineSettingsRootKey);
+                    // get the engine name and version
+                    scratchSettingsRegistry.Get(engineInfo.m_name, engineNameKey);
 
-            AZStd::set<AZ::IO::FixedMaxPath> projectPathsNotFound;
+                    FixedValueString engineVersion;
+                    if (scratchSettingsRegistry.Get(engineVersion, engineVersionKey); !engineVersion.empty())
+                    {
+                        if (auto parseOutcome = AZ::SemanticVersion::ParseFromString(engineVersion.c_str());
+                            parseOutcome)
+                        {
+                            engineInfo.m_version = parseOutcome.TakeValue();
+                        }
+                    }
 
-            for (EngineInfo& engineInfo : pathVisitor.m_enginePaths)
-            {
-                if (auto engineSettingsPath = AZ::IO::FixedMaxPath{engineInfo.m_path} / EngineJsonFilename;
-                    AZ::IO::SystemFile::Exists(engineSettingsPath.c_str()))
-                {
-                    if (settingsRegistry.MergeSettingsFile(
-                            engineSettingsPath.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, EngineSettingsRootKey))
+                    if (isCompatible)
                     {
-                        FixedValueString engineName;
-                        settingsRegistry.Get(engineName, engineMonikerKey);
-                        AZ_Warning("SettingsRegistryMergeUtils", engineInfo.m_moniker == engineName,
-                            R"(The engine name key "%s" mapped to engine path "%s" within the global manifest of "%s")"
-                            R"( does not match the "engine_name" field "%s" in the engine.json)" "\n"
-                            "This engine should be re-registered.",
-                            engineInfo.m_moniker.c_str(), engineInfo.m_path.c_str(), o3deManifestPath.c_str(),
-                            engineName.c_str());
-                        engineInfo.m_moniker = engineName;
+                        if (mostCompatibleEngineInfo.m_path.empty())
+                        {
+                            mostCompatibleEngineInfo = engineInfo;
+                        }
+                        else if (!engineInfo.m_version.IsZero())
+                        {
+                            AZ_Warning("SettingsRegistryMergeUtils", engineInfo.m_version != mostCompatibleEngineInfo.m_version,
+                                "Not using the engine at '%s' because another engine with the same name '%s' and version '%s' was already found at '%s'",
+                                engineInfo.m_path.c_str(), engineInfo.m_name.c_str(), engineInfo.m_version.ToString().c_str(),
+                                mostCompatibleEngineInfo.m_path.c_str());
+
+                            // is it more compatible?
+                            if (engineInfo.m_version > mostCompatibleEngineInfo.m_version)
+                            {
+                                mostCompatibleEngineInfo = engineInfo;
+                            }
+                        }
                     }
-                }
 
-                if (auto projectJsonPath = (engineInfo.m_path / projectPath / ProjectJsonFilename).LexicallyNormal();
-                    AZ::IO::SystemFile::Exists(projectJsonPath.c_str()))
-                {
-                    if (auto engineMoniker = Internal::GetEngineMonikerForProject(settingsRegistry, projectJsonPath);
-                        !engineMoniker.empty() && engineMoniker == engineInfo.m_moniker)
+                    // Remove engine settings which will be different for each engine but keep the
+                    // project settings (ProjectSettingsRootKey) in case the project path is the
+                    // same to avoid re-loading them.
+                    scratchSettingsRegistry.Remove(EngineSettingsRootKey);
+
+                    if (!AZ::IO::SystemFile::Exists(projectJsonPath.c_str()))
                     {
-                        engineRoot = engineInfo.m_path;
-                        break;
+                        // add the project path where we looked for 'project.json'
+                        missingProjectJsonPaths.insert((projectPath).LexicallyNormal());
                     }
                 }
-                else
+
+                if (!mostCompatibleEngineInfo.m_path.empty())
                 {
-                    projectPathsNotFound.insert(projectJsonPath);
+                    engineRoot = mostCompatibleEngineInfo.m_path;
+
+                    // merge in the engine and project settings with overrides
+                    Internal::MergeEngineAndProjectSettings(settingsRegistry,
+                        (engineRoot  / EngineJsonFilename),
+                        (projectPath / ProjectJsonFilename),
+                        (engineRoot / projectUserPath / ProjectJsonFilename)
+                        );
                 }
-
-                // Continue looking for candidates, remove the previous engine and project settings that were merged above.
-                settingsRegistry.Remove(ProjectSettingsRootKey);
-                settingsRegistry.Remove(EngineSettingsRootKey);
             }
 
             if (engineRoot.empty())
             {
                 AZStd::string errorStr;
-                if (!projectPathsNotFound.empty())
+                if (!missingProjectJsonPaths.empty())
                 {
                     // This case is usually encountered when a project path is given as a relative path,
                     // which is assumed to be relative to an engine root.
                     // When no project.json files are found this way, dump this error message about
                     // which project paths were checked.
                     AZStd::string projectPathsTested;
-                    for (const auto& path : projectPathsNotFound)
+                    for (const auto& path : missingProjectJsonPaths)
                     {
                         projectPathsTested.append(AZStd::string::format("  %s\n", path.c_str()));
                     }
-                    errorStr = AZStd::string::format("No valid project was found at these locations:\n%s"
+                    errorStr = AZStd::string::format(
+                        "No valid project was found (missing 'project.json') at these locations:\n%s"
                         "Please supply a valid --project-path to the application.",
                         projectPathsTested.c_str());
                 }
                 else
                 {
+                    FixedValueString projectEngineMoniker;
+                    const auto projectEngineKey = FixedValueString::format("%s/engine", ProjectSettingsRootKey);
+                    if (!settingsRegistry.Get(projectEngineMoniker, projectEngineKey) || projectEngineMoniker.empty())
+                    {
+                        projectEngineMoniker = "missing";
+                    }
+
                     // The other case is that a project.json was found, but after checking all the registered engines
                     // none of them matched the engine moniker.
                     AZStd::string enginePathsChecked;
-                    for (const auto& engineInfo : pathVisitor.m_enginePaths)
+                    for (const auto& engineInfo : searchedEngineInfo)
                     {
-                        enginePathsChecked.append(AZStd::string::format("  %s (%s)\n", engineInfo.m_path.c_str(), engineInfo.m_moniker.c_str()));
+                        enginePathsChecked.append(AZStd::string::format("  %s (%s %s)\n", engineInfo.m_path.c_str(),
+                            engineInfo.m_name.c_str(), engineInfo.m_version.ToString().c_str()));
                     }
                     errorStr = AZStd::string::format(
-                        "No engine was found in o3de_manifest.json with a name that matches the one set in the project.json.\n"
-                        "Engines that were checked:\n%s"
-                        "Please check that your engine and project have both been registered with scripts/o3de.py.", enginePathsChecked.c_str()
+                        "No engine was found in o3de_manifest.json that is compatible with the one set in the project.json '%s'\n"
+                        "If that's not the engine qualifier you expected, please check if the engine field is being overridden in 'user/project.json'.\n\n"
+                        "Engines that were checked:\n%s\n"
+                        "Please check that your engine and project have both been registered with scripts/o3de.py.",
+                        projectEngineMoniker.c_str(), enginePathsChecked.c_str()
                     );
                 }
 
@@ -227,56 +511,6 @@ namespace AZ::Internal
 
         return engineRoot;
     }
-
-    AZ::IO::FixedMaxPath ScanUpRootLocator(AZStd::string_view rootFileToLocate)
-    {
-        AZ::IO::FixedMaxPath rootCandidate{ AZ::Utils::GetExecutableDirectory() };
-
-        bool rootPathVisited = false;
-        do
-        {
-            if (AZ::IO::SystemFile::Exists((rootCandidate / rootFileToLocate).c_str()))
-            {
-                return rootCandidate;
-            }
-
-            // Note for posix filesystems the parent directory of '/' is '/' and for windows
-            // the parent directory of 'C:\\' is 'C:\\'
-
-            // Validate that the parent directory isn't itself, that would imply
-            // that it is the filesystem root path
-            AZ::IO::PathView parentPath = rootCandidate.ParentPath();
-            rootPathVisited = (rootCandidate == parentPath);
-            // Recurse upwards one directory
-            rootCandidate = AZStd::move(parentPath);
-
-        } while (!rootPathVisited);
-
-        return {};
-    }
-
-    enum class InjectLocation : bool
-    {
-        Front,
-        Back
-    };
-
-    void InjectSettingToCommandLine(AZ::SettingsRegistryInterface& settingsRegistry,
-        AZStd::string_view path, AZStd::string_view value,
-        InjectLocation injectLocation = InjectLocation::Front)
-    {
-        AZ::CommandLine commandLine;
-        AZ::SettingsRegistryMergeUtils::GetCommandLineFromRegistry(settingsRegistry, commandLine);
-        AZ::CommandLine::ParamContainer paramContainer;
-        commandLine.Dump(paramContainer);
-
-        auto projectPathOverride = AZStd::string::format(R"(--regset="%.*s=%.*s")",
-            aznumeric_cast<int>(path.size()), path.data(), aznumeric_cast<int>(value.size()), value.data());
-        auto emplaceIter = injectLocation == InjectLocation::Front ? paramContainer.begin() : paramContainer.end();
-        paramContainer.emplace(emplaceIter, AZStd::move(projectPathOverride));
-        commandLine.Parse(paramContainer);
-        AZ::SettingsRegistryMergeUtils::StoreCommandLineToRegistry(settingsRegistry, commandLine);
-    }
 } // namespace AZ::Internal
 
 namespace AZ::SettingsRegistryMergeUtils
@@ -313,139 +547,83 @@ namespace AZ::SettingsRegistryMergeUtils
 
     AZ::IO::FixedMaxPath FindEngineRoot(SettingsRegistryInterface& settingsRegistry)
     {
-        static constexpr AZStd::string_view InternalScanUpEngineRootKey{ "/O3DE/Runtime/Internal/engine_root_scan_up_path" };
-        using FixedValueString = SettingsRegistryInterface::FixedValueString;
-        using Type = SettingsRegistryInterface::Type;
-
-        AZ::IO::FixedMaxPath engineRoot;
-        // This is the 'external' engine root key, as in passed from command-line or .setreg files.
-        constexpr auto engineRootKey = FixedValueString(BootstrapSettingsRootKey) + "/engine_path";
-
         // Step 1 Run the scan upwards logic once to find the location of the engine.json if it exist
         // Once this step is run the {InternalScanUpEngineRootKey} is set in the Settings Registry
-        // to have this scan logic only run once InternalScanUpEngineRootKey the supplied registry
-        if (settingsRegistry.GetType(InternalScanUpEngineRootKey) == Type::NoType)
+        // and this logic will not run again for this Settings Registry instance
+        Internal::SetScanUpRootKey(settingsRegistry, Internal::ScanUpEngineRootKey, Internal::EngineJsonFilename);
+
+        // Check for the engine path to use in priority of
+        // 1. command line
+        // 2. <project-root>/user/project.json "engine_path"
+        // 3. first compatible engine based on project.json "engine"
+        // 4. First engine.json found by scanning upwards from the current executable directory
+        // 5. Bootstrap engine_path from .setreg file
+        AZ::IO::FixedMaxPath engineRoot = Internal::GetCommandLineOption(settingsRegistry, Internal::CommandLineEngineOptionName);
+
+        // Note: the projectRoot should be absolute because of FindProjectRoot
+        AZ::IO::FixedMaxPath projectRoot;
+        settingsRegistry.Get(projectRoot.Native(), FilePathKey_ProjectPath);
+
+        if (engineRoot.empty() && !projectRoot.empty())
         {
-            // We can scan up from exe directory to find engine.json, use that for engine root if it exists.
-            engineRoot = Internal::ScanUpRootLocator(Internal::EngineJsonFilename);
-            // The Internal ScanUp Engine Root Key will be set as an absolute path
-            if (!engineRoot.empty())
+            // Step 2 Check for alternate 'engine_path' setting in '<project-root>/user/project.json'
+            if (auto outcome = Internal::ReconcileEngineRootFromProjectUserPath(settingsRegistry, projectRoot); !outcome)
             {
-                if (engineRoot.IsRelative())
-                {
-                    if (auto engineRootAbsPath = AZ::Utils::ConvertToAbsolutePath(engineRoot.Native());
-                        engineRootAbsPath.has_value())
-                    {
-                        engineRoot = AZStd::move(*engineRootAbsPath);
-                    }
-                }
+                // An error occurred that needs to be shown the the user, possibly an invalid engine name or path
+                settingsRegistry.Set(FilePathKey_ErrorText, outcome.GetError().c_str());
+                return {};
             }
-
-            // Set the {InternalScanUpEngineRootKey} to make sure this code path isn't called again for this settings registry
-            settingsRegistry.Set(InternalScanUpEngineRootKey, engineRoot.Native());
-            if (!engineRoot.empty())
+            else
             {
-                settingsRegistry.Set(engineRootKey, engineRoot.Native());
-                // Inject the engine root to the front of the command line settings
-                Internal::InjectSettingToCommandLine(settingsRegistry, engineRootKey, engineRoot.Native());
-                return engineRoot;
+                engineRoot = outcome.TakeValue();
             }
         }
 
-        // Step 2 check if the engine_path key has been supplied
-        if (settingsRegistry.Get(engineRoot.Native(), engineRootKey); !engineRoot.empty())
+        if (engineRoot.empty() && !projectRoot.empty())
         {
-            if (engineRoot.IsRelative())
-            {
-                if (auto engineRootAbsPath = AZ::Utils::ConvertToAbsolutePath(engineRoot.Native());
-                    engineRootAbsPath.has_value())
-                {
-                    engineRoot = AZStd::move(*engineRootAbsPath);
-                }
-            }
-            return engineRoot;
+            // 3. Locate the project root and attempt to find the most compatible engine
+            // using the engine name and optional version in project.json
+            engineRoot = Internal::ReconcileEngineRootFromProjectPath(settingsRegistry, projectRoot);
         }
 
-        // Step 3 locate the project root and attempt to find the engine root using the registered engine
-        // for the project in the project.json file
-        AZ::IO::FixedMaxPath projectRoot;
-        settingsRegistry.Get(projectRoot.Native(), FilePathKey_ProjectPath);
-        if (projectRoot.empty())
+        if (engineRoot.empty())
         {
-            return {};
+            // 3. Use the engine scan up result
+            settingsRegistry.Get(engineRoot.Native(), Internal::ScanUpEngineRootKey);
         }
 
-        // Use the project.json and engine manifest to locate the engine root.
-        if (engineRoot = Internal::ReconcileEngineRootFromProjectPath(settingsRegistry, projectRoot); !engineRoot.empty())
+        if (engineRoot.empty())
         {
-            settingsRegistry.Set(engineRootKey, engineRoot.c_str());
-            return engineRoot;
+            // 4. Use the bootstrap setting
+            settingsRegistry.Get(engineRoot.Native(), Internal::SetregFileEngineRootKey);
         }
 
-        // Fall back to using the project root as the engine root if the engine path could not be reconciled
-        // by checking the project.json "engine" string within o3de_manifest.json "engine_paths" object
-        return projectRoot;
+        // Make the engine root an absolute path if it is not empty
+        if (!engineRoot.empty() && engineRoot.IsRelative())
+         {
+            if (auto engineRootAbsPath = AZ::Utils::ConvertToAbsolutePath(engineRoot.Native());
+                engineRootAbsPath.has_value())
+            {
+                engineRoot = AZStd::move(*engineRootAbsPath);
+            }
+        }
+
+        return engineRoot;
     }
 
     AZ::IO::FixedMaxPath FindProjectRoot(SettingsRegistryInterface& settingsRegistry)
     {
-        using FixedValueString = SettingsRegistryInterface::FixedValueString;
-        using Type = SettingsRegistryInterface::Type;
-
         // Run the scan upwards logic one time only for the supplied Settings Registry instance
         // to find the location of the closest ancestor project.json
-        // Once this step is run the {SetregFileProjectRootKey} is set in the Settings Registry
+        // Once this step is run the {ScanUpProjectRootKey} is set in the Settings Registry
         // and this logic will not run again for this Settings Registry instance
+        Internal::SetScanUpRootKey(settingsRegistry, Internal::ScanUpProjectRootKey, Internal::ProjectJsonFilename);
 
-        // SettingsRegistryInterface::GetType is used to check if a key is set
-        if (settingsRegistry.GetType(Internal::SetregFileProjectRootKey) == Type::NoType)
-        {
-            AZ::IO::FixedMaxPath scanUpProjectRoot = Internal::ScanUpRootLocator(Internal::ProjectJsonFilename);
-            // Convert the path to an absolute path before adding it as a setting to the
-            // InternalScanUpProjectRootKey
-            if (!scanUpProjectRoot.empty())
-            {
-                if (scanUpProjectRoot.IsRelative())
-                {
-                    if (auto projectAbsPath = AZ::Utils::ConvertToAbsolutePath(scanUpProjectRoot.Native());
-                        projectAbsPath.has_value())
-                    {
-                        scanUpProjectRoot = AZStd::move(*projectAbsPath);
-                    }
-                }
-            }
-
-            // Set the {SetregFileProjectRootKey} to make sure this code path isn't called again for this instance
-            settingsRegistry.Set(Internal::ScanUpProjectRootKey, scanUpProjectRoot.Native());
-        }
-
-        // Check for the project path to used in priority of
+        // Check for the project path to use in priority of
         // 1. command-line
         // 2. First project.json found by scanning upwards from the current executable directory
         // 3. "/Amazon/AzCore/Bootstrap/project_path" key set in .setreg file
-
-        AZ::IO::FixedMaxPath projectRoot;
-
-        // 1. Parse Command Line
-        auto VisitCommandLineOptions = [&projectRoot](const AZ::SettingsRegistryInterface::VisitArgs& visitArgs)
-        {
-            constexpr AZStd::string_view ProjectPathOptionName = "project-path";
-            // Lookup the "/O3DE/Runtime/CommandLine/%u/Option" for each command line parameter to see if
-            // the project-path key is available
-            // Check if the option value --project-path has been found key has been found
-            auto cmdProjectPathKey = FixedValueString::format("%.*s/Option", AZ_STRING_ARG(visitArgs.m_jsonKeyPath));
-            if (FixedValueString optionName;
-                visitArgs.m_registry.Get(optionName, cmdProjectPathKey) && optionName == ProjectPathOptionName)
-            {
-                // Updated the existing cmdProjectPathKey to read the value from the command line
-                cmdProjectPathKey = FixedValueString::format("%.*s/Value", AZ_STRING_ARG(visitArgs.m_jsonKeyPath));
-                visitArgs.m_registry.Get(projectRoot.Native(), cmdProjectPathKey);
-            }
-
-            // Continue to visit command line parmaetes, in case there is a second --project-path option
-            return AZ::SettingsRegistryInterface::VisitResponse::Skip;
-        };
-        SettingsRegistryVisitorUtils::VisitArray(settingsRegistry, VisitCommandLineOptions, Internal::CommandLineProjectRootKey);
+        AZ::IO::FixedMaxPath projectRoot = Internal::GetCommandLineOption(settingsRegistry, Internal::CommandLineProjectOptionName);
 
         if (projectRoot.empty())
         {
@@ -828,22 +1006,35 @@ namespace AZ::SettingsRegistryMergeUtils
         }
         else
         {
-            AZ_TracePrintf("SettingsRegistryMergeUtils",
-                R"(Project path isn't set in the Settings Registry at "%.*s".)"
-                " Project-related filepaths will be set relative to the executable directory\n",
-                AZ_STRING_ARG(projectPathKey));
             projectPath = exePath;
             registry.Set(FilePathKey_ProjectPath, exePath.Native());
         }
 
+        // User folder
+        AZ::IO::FixedMaxPath projectUserPath = FindProjectUserPath(registry, projectPath);
+        if (!projectUserPath.empty())
+        {
+            projectUserPath = projectUserPath.LexicallyNormal();
+            registry.Set(FilePathKey_ProjectUserPath, projectUserPath.Native());
+        }
+
         // Engine root folder - corresponds to the @engroot@ alias
         AZ::IO::FixedMaxPath engineRoot = FindEngineRoot(registry);
+        if (engineRoot.empty())
+        {
+            // Use the project path if the engine root wasn't found, this can happen
+            // when running the game launcher with bundled assets which are not loaded
+            // until after the SettingsRegistry has determined file paths
+            engineRoot = projectPath;
+        }
+
         if (!engineRoot.empty())
         {
             engineRoot = engineRoot.LexicallyNormal();
             registry.Set(FilePathKey_EngineRootFolder, engineRoot.Native());
         }
 
+
         // Cache folder
         AZ::IO::FixedMaxPath projectCachePath = FindProjectCachePath(registry, projectPath).LexicallyNormal();
         if (!projectCachePath.empty())
@@ -874,14 +1065,6 @@ namespace AZ::SettingsRegistryMergeUtils
             }
         }
 
-        // User folder
-        AZ::IO::FixedMaxPath projectUserPath = FindProjectUserPath(registry, projectPath);
-        if (!projectUserPath.empty())
-        {
-            projectUserPath = projectUserPath.LexicallyNormal();
-            registry.Set(FilePathKey_ProjectUserPath, projectUserPath.Native());
-        }
-
         // Log folder
         if (AZ::IO::FixedMaxPath projectLogPath = FindProjectLogPath(registry, projectUserPath); !projectLogPath.empty())
         {

+ 1 - 1
Code/Framework/AzCore/AzCore/UnitTest/Mocks/MockSettingsRegistry.h

@@ -50,7 +50,7 @@ namespace AZ
 
         MOCK_METHOD3(MergeCommandLineArgument, bool(AZStd::string_view, AZStd::string_view, const CommandLineArgumentSettings&));
         MOCK_METHOD3(MergeSettings, bool(AZStd::string_view, Format, AZStd::string_view));
-        MOCK_METHOD4(MergeSettingsFile, bool(AZStd::string_view, Format, AZStd::string_view, AZStd::vector<char>*));
+        MOCK_METHOD4(MergeSettingsFile, AZ::Outcome<void,AZStd::string>(AZStd::string_view, Format, AZStd::string_view, AZStd::vector<char>*));
         MOCK_METHOD5(
             MergeSettingsFolder,
             bool(AZStd::string_view, const Specializations&, AZStd::string_view, AZStd::string_view, AZStd::vector<char>*));

+ 1 - 1
Code/Framework/AzCore/AzCore/Utils/Utils.cpp

@@ -94,7 +94,7 @@ namespace AZ::Utils
             }
         }
 
-        // If the O3DEManifest key isn't set in teh settings registry
+        // If the O3DEManifest key isn't set in the settings registry
         // fallback to use the user's home directory with the .o3de folder appended to it
         AZ::IO::FixedMaxPath path = GetHomeDirectory(settingsRegistry);
         path /= ".o3de";

+ 3 - 0
Code/Framework/AzCore/AzCore/azcore_files.cmake

@@ -98,6 +98,9 @@ set(FILES
     Debug/TraceMessageBus.h
     Debug/TraceReflection.cpp
     Debug/TraceReflection.h
+    Dependency/Dependency.h
+    Dependency/Dependency.inl
+    Dependency/Version.h
     Docs.h
     DOM/DomBackend.cpp
     DOM/DomBackend.h

+ 319 - 0
Code/Framework/AzCore/Tests/Settings/SettingsRegistryMergeUtilsTests.cpp

@@ -19,6 +19,7 @@
 #include <AzCore/std/containers/variant.h>
 #include <AzCore/UnitTest/TestTypes.h>
 #include <AzCore/Utils/Utils.h>
+#include <AzCore/Serialization/Json/JsonUtils.h>
 
 namespace SettingsRegistryMergeUtilsTests
 {
@@ -751,4 +752,322 @@ tags=tools,renderer,metal)"
         EXPECT_TRUE(AZ::SettingsRegistryMergeUtils::IsPathAncestorDescendantOrEqual("/Amazon/AzCore/Bootstrap", "/Amazon/AzCore/Bootstrap/project_path"));
         EXPECT_FALSE(AZ::SettingsRegistryMergeUtils::IsPathAncestorDescendantOrEqual("/Amazon/AzCore/Bootstrap", "/Amazon/Project/Settings/project_name"));
     }
+
+    
+    struct SettingsRegistryFindEngineRootParams
+    {
+        AZStd::fixed_vector<const char*, 2> m_engineManifestsJson;
+        const char* m_projectManifestJson{ "" };
+        const char* m_userProjectManifestJson{ "" };
+        const int m_expectedEnginePathIndex; // negative means not found
+        const char* m_scanUpEngineRoot{ "" };
+    };
+
+    static auto MakeFindEngineRootTestingValues()
+    {
+        return AZStd::array{
+            // Selects correct engine based on name 
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de1", "version": "1.0.0"})",
+                   R"({ "engine_name": "o3de2", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de1" })",
+
+                // project/user/project.json
+                R"({})",
+
+                0, // expect o3de1
+                ""
+            },
+
+            // Selects engine with highest version when multiple of same name found 
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de", "version": "1.2.3"})",
+                   R"({ "engine_name": "o3de", "version": "2.3.4"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de" })",
+
+                // project/user/project.json
+                R"({})",
+
+                1, // expect second engine with higher version number
+                ""
+            },
+
+            // Fails to find engine with name that isn't registered
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de1", "version": "1.0.0"})",
+                   R"({ "engine_name": "o3de2", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de-not-found"  })",
+
+                // project/user/project.json
+                R"({})",
+
+                -1, // not found
+                ""
+            },
+
+            // Fails to find engine with version that isn't registered 
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de1", "version": "1.0.0"})",
+                   R"({ "engine_name": "o3de2", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de==1.1.1"  })",
+
+                // project/user/project.json
+                R"({})",
+
+                -1, // not found
+                ""
+            },
+
+            // Selects engine that has a legacy version field
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de", "O3DEVersion": "0.1.0.0"})",
+                   R"({ "engine_name": "o3de-new", "O3DEVersion": "0.1.0.0", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de"  })",
+
+                // project/user/project.json
+                R"({})",
+
+                0, // o3de 
+                ""
+            },
+
+            // Selects first engine when multiple engines found with ambiguous project engine
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de", "O3DEVersion": "0.1.0.0"})",
+                   R"({ "engine_name": "o3de", "O3DEVersion": "0.1.0.0"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de"  })",
+
+                // project/user/project.json
+                R"({})",
+
+                0, // first engine
+                ""
+            },
+
+            // Selects correct engine when a version specifier is used
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de1", "version": "1.2.3"})",
+                   R"({ "engine_name": "o3de2", "version": "2.3.4"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de2==2.3.4"  })",
+
+                // project/user/project.json
+                R"({})",
+
+                1, // o3de2
+                ""
+            },
+
+            // Selects the engine specified by name in project/user/project.json
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de1", "version": "1.2.3"})",
+                   R"({ "engine_name": "o3de2", "version": "2.3.4"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de2==2.3.4"  })",
+
+                // project/user/project.json
+                R"({ "engine":"o3de1==1.2.3" })",
+
+                0, // o3de1
+                ""
+            },
+
+            // Selects the engine specified by path in project/user/project.json
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de", "version": "1.2.3"})",
+                   R"({ "engine_name": "o3de", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de"  })",
+
+                // project/user/project.json
+                R"({ "engine_path":"<engine_path1>" })",
+
+                1, // 2nd engine, even though both have same name & version
+                ""
+            },
+
+            // Fails if invalid engine specified in project/user/project.json
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de", "version": "1.2.3"})",
+                   R"({ "engine_name": "o3de-other", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de"  })",
+
+                // project/user/project.json
+                R"({ "engine_path":"c:/path/not/found" })",
+
+                -1, // not found 
+                ""
+            },
+
+            // Uses scan up engine if all other methods fail 
+            SettingsRegistryFindEngineRootParams{
+                // engine.json files
+                {
+                   R"({ "engine_name": "o3de", "version": "1.2.3"})",
+                   R"({ "engine_name": "o3de-other", "version": "1.2.3"})",
+                },
+
+                // project/project.json
+                R"({ "project_name": "TestProject", "engine":"o3de-blah"  })",
+
+                // project/user/project.json
+                R"({})",
+
+                -1,  
+                "engine"
+            },
+
+        };
+    }
+
+    class SettingsRegistryMergeUtilsFindEngineRootFixture
+        : public UnitTest::LeakDetectionFixture
+        , public ::testing::WithParamInterface<SettingsRegistryFindEngineRootParams>
+    {
+    public:
+        void SetUp() override
+        {
+            constexpr AZStd::string_view InternalScanUpEngineRootKey{ "/O3DE/Runtime/Internal/engine_root_scan_up_path" };
+            m_registry = AZStd::make_unique<AZ::SettingsRegistryImpl>();
+
+            // Create the manifest json files
+            const auto& params = GetParam();
+            auto tempRootFolder = AZ::IO::FixedMaxPath(m_testFolder.GetDirectory());
+            AZ::IO::FixedMaxPath o3deManifestFilePath = tempRootFolder / "o3de" / "o3de_manifest.json";
+            AZ::IO::FixedMaxPath projectPath = tempRootFolder / "project" ;
+            AZ::IO::FixedMaxPath projectManifestPath = projectPath / "project.json";
+            AZ::IO::FixedMaxPath projectUserPath = tempRootFolder / "project" /  "user";
+            AZ::IO::FixedMaxPath userProjectManifestPath = projectUserPath / "project.json";
+
+            for (size_t i = 0; i < params.m_engineManifestsJson.size(); ++i)
+            {
+                const AZ::IO::FixedMaxPath enginePath = tempRootFolder / AZStd::string::format("engine%zu", i);
+                ASSERT_TRUE(CreateTestFile(enginePath / "engine.json", params.m_engineManifestsJson[i]));
+
+                m_enginePaths.emplace_back(AZStd::move(enginePath));
+            }
+
+            m_registry->Set(AZ::SettingsRegistryMergeUtils::FilePathKey_O3deManifestRootFolder,
+                (tempRootFolder / "o3de").Native());
+            m_registry->Set(AZ::SettingsRegistryMergeUtils::FilePathKey_ProjectPath,
+                projectPath.Native());
+            m_registry->Set(AZ::SettingsRegistryMergeUtils::FilePathKey_ProjectUserPath,
+                projectUserPath.Native());
+
+            if(!AZStd::string_view(params.m_scanUpEngineRoot).empty())
+            {
+                // use an absolute path because that is what should be returned from FindEngineRoot
+                AZ::IO::FixedMaxPath scanUpEngineRootPath = tempRootFolder / params.m_scanUpEngineRoot;
+                m_registry->Set(InternalScanUpEngineRootKey, scanUpEngineRootPath.Native());
+            }
+            else
+            {
+                // set to an empty value to simulate no scan up engine root found
+                m_registry->Set(InternalScanUpEngineRootKey, "");
+            }
+
+            const char* o3deManifest = R"({ "o3de_manifest_name": "testmanifest", "engines":["<engine_path0>","<engine_path1>"] })";
+            ASSERT_TRUE(CreateTestFileWithSubstitutions(o3deManifestFilePath, o3deManifest));
+            ASSERT_TRUE(CreateTestFileWithSubstitutions(projectManifestPath, params.m_projectManifestJson));
+            ASSERT_TRUE(CreateTestFileWithSubstitutions(userProjectManifestPath, params.m_userProjectManifestJson));
+        }
+
+        bool CreateTestFileWithSubstitutions(const AZ::IO::FixedMaxPath& testPath, AZStd::string_view content)
+        {
+            // replace instances of <engine0>, <engine1> etc. with actual engine paths
+            AZStd::string contentString{ content };
+            for (size_t i = 0; i < m_enginePaths.size(); ++i)
+            {
+                AZStd::string enginePath{ m_enginePaths[i].Native().c_str() };
+                AZ::StringFunc::Json::ToEscapedString(enginePath);
+                AZ::StringFunc::Replace(contentString, AZStd::string::format("<engine_path%zu>", i).c_str(), enginePath.c_str());
+            }
+            return CreateTestFile(testPath, contentString.c_str());
+        }
+
+        void TearDown() override
+        {
+            m_registry.reset();
+        }
+
+    protected:
+        AZStd::unique_ptr<AZ::SettingsRegistryImpl> m_registry;
+        AZ::Test::ScopedAutoTempDirectory m_testFolder;
+        AZStd::vector<AZ::IO::FixedMaxPath> m_enginePaths;
+    };
+
+
+    TEST_P(SettingsRegistryMergeUtilsFindEngineRootFixture, SettingsRegistryMergeUtils_FindEngineRoot_DetectsCorrectPath)
+    {
+        const auto& params = GetParam();
+        const AZ::IO::FixedMaxPath engineRoot = AZ::SettingsRegistryMergeUtils::FindEngineRoot(*m_registry).Native();
+
+        if (!AZStd::string_view(params.m_scanUpEngineRoot).empty())
+        {
+            auto tempRootFolder = AZ::IO::FixedMaxPath(m_testFolder.GetDirectory());
+            AZ::IO::FixedMaxPath scanUpEngineRootPath = tempRootFolder / params.m_scanUpEngineRoot;
+            EXPECT_EQ(engineRoot, scanUpEngineRootPath);
+        }
+        else if (params.m_expectedEnginePathIndex < 0 || params.m_expectedEnginePathIndex >= m_enginePaths.size())
+        {
+            EXPECT_TRUE(engineRoot.empty());
+        }
+        else
+        {
+            EXPECT_EQ(engineRoot, m_enginePaths[params.m_expectedEnginePathIndex]);
+        }
+    }
+
+    INSTANTIATE_TEST_CASE_P(
+        FindEngineRoot,
+        SettingsRegistryMergeUtilsFindEngineRootFixture,
+        ::testing::ValuesIn(MakeFindEngineRootTestingValues()));
 }
+

+ 26 - 40
Code/Framework/AzCore/Tests/Settings/SettingsRegistryTests.cpp

@@ -1325,11 +1325,11 @@ namespace SettingsRegistryTests
             EXPECT_EQ(1, value);
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
-        ASSERT_TRUE(result);
+        auto outcome = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
+        ASSERT_TRUE(outcome);
 
         AZStd::string history;
-        result = m_registry->Get(history, AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0");
+        bool result = m_registry->Get(history, AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0");
         ASSERT_TRUE(result);
         EXPECT_STREQ(path.c_str(), history.c_str());
     }
@@ -1347,11 +1347,11 @@ namespace SettingsRegistryTests
             EXPECT_EQ(1, value);
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, "/Path", nullptr);
-        ASSERT_TRUE(result);
+        auto outcome = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, "/Path", nullptr);
+        ASSERT_TRUE(outcome);
 
         AZStd::string history;
-        result = m_registry->Get(history, AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0");
+        bool result = m_registry->Get(history, AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0");
         ASSERT_TRUE(result);
         EXPECT_STREQ(path.c_str(), history.c_str());
     }
@@ -1363,16 +1363,14 @@ namespace SettingsRegistryTests
         AZStd::vector<char> buffer;
         buffer.push_back(32);
         buffer.push_back(64);
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, &buffer);
+        auto result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, &buffer);
         EXPECT_TRUE(result);
         EXPECT_TRUE(buffer.empty());
     }
 
     TEST_F(SettingsRegistryTest, MergeSettingsFile_EmptyPath_ReturnsFalse)
     {
-        AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFile("", AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
+        auto result = m_registry->MergeSettingsFile("", AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
         EXPECT_FALSE(result);
     }
 
@@ -1383,7 +1381,7 @@ namespace SettingsRegistryTests
         CreateTestFile("test.setreg", R"({ "Test": 1 })");
 
         AZStd::string_view subPath(path.c_str(), path.Native().size() - 4);
-        bool result = m_registry->MergeSettingsFile(subPath, AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
+        auto result = m_registry->MergeSettingsFile(subPath, AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
         EXPECT_TRUE(result);
     }
 
@@ -1392,9 +1390,7 @@ namespace SettingsRegistryTests
         constexpr AZStd::fixed_string<AZ::IO::MaxPathLength + 1> path(AZ::IO::MaxPathLength + 1, '1');
         const AZStd::string_view subPath(path);
 
-        AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFile(subPath, AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
+        auto result = m_registry->MergeSettingsFile(subPath, AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
         EXPECT_FALSE(result);
 
         EXPECT_EQ(AZ::SettingsRegistryInterface::Type::Object, m_registry->GetType(AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0"));
@@ -1404,9 +1400,7 @@ namespace SettingsRegistryTests
 
     TEST_F(SettingsRegistryTest, MergeSettingsFile_InvalidPath_ReturnsFalse)
     {
-        AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFile("InvalidPath", AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
+        auto result = m_registry->MergeSettingsFile("InvalidPath", AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
         EXPECT_FALSE(result);
 
         EXPECT_EQ(AZ::SettingsRegistryInterface::Type::Object, m_registry->GetType(AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0"));
@@ -1418,9 +1412,7 @@ namespace SettingsRegistryTests
     {
         auto path = CreateTestFile("test.setreg", R"({ "Test": 1 })");
 
-        AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, "$", nullptr);
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
+        auto result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, "$", nullptr);
         EXPECT_FALSE(result);
 
         EXPECT_EQ(AZ::SettingsRegistryInterface::Type::Object, m_registry->GetType(AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0"));
@@ -1432,9 +1424,7 @@ namespace SettingsRegistryTests
     {
         auto path = CreateTestFile("test.setreg", "{ Test: 1 }");
 
-        AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
+        auto result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {}, nullptr);
         EXPECT_FALSE(result);
 
         EXPECT_EQ(AZ::SettingsRegistryInterface::Type::Object, m_registry->GetType(AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0"));
@@ -1446,9 +1436,7 @@ namespace SettingsRegistryTests
     {
         auto path = CreateTestFile("test.setreg", R"("BooleanValue": false)");
 
-        AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {});
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
+        auto result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {});
         EXPECT_FALSE(result);
 
         EXPECT_EQ(AZ::SettingsRegistryInterface::Type::Object, m_registry->GetType(AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0"));
@@ -1462,9 +1450,7 @@ namespace SettingsRegistryTests
         // it is safe to merge the .setreg file with a boolean element at the root
         auto path = CreateTestFile("test.setreg", R"("BooleanValue": false)");
 
-        AZ_TEST_START_TRACE_SUPPRESSION;
         EXPECT_FALSE(m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, "/Test"));
-        AZ_TEST_STOP_TRACE_SUPPRESSION(1);
         // There should be an error message about attempting to serialize a root json value that is not a Json Object
         // to the settings registry
 
@@ -1484,11 +1470,11 @@ namespace SettingsRegistryTests
 
         // Merging a file with a empty JSON Object should be effectively a no-op.
         // There are some changes in the settings registry to record the merge history for introspection purposes.
-        bool result = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {});
-        EXPECT_TRUE(result);
+        auto outcome = m_registry->MergeSettingsFile(path.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch, {});
+        EXPECT_TRUE(outcome);
 
         AZStd::string history;
-        result = m_registry->Get(history, AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0");
+        bool result = m_registry->Get(history, AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0");
         EXPECT_TRUE(result);
         EXPECT_STREQ(path.c_str(), history.c_str());
 
@@ -1524,7 +1510,7 @@ namespace SettingsRegistryTests
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
 
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), {"editor", "test"}, {});
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), {"editor", "test"}, {});
         EXPECT_TRUE(result);
         EXPECT_EQ(4, counter);
 
@@ -1564,7 +1550,7 @@ namespace SettingsRegistryTests
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
 
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, "Special");
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, "Special");
         EXPECT_TRUE(result);
         EXPECT_EQ(6, counter);
 
@@ -1601,7 +1587,7 @@ namespace SettingsRegistryTests
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
         
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {});
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {});
         EXPECT_TRUE(result);
         EXPECT_EQ(4, counter);
 
@@ -1640,7 +1626,7 @@ namespace SettingsRegistryTests
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
 
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {});
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {});
         EXPECT_TRUE(result);
         EXPECT_EQ(4, counter);
 
@@ -1671,7 +1657,7 @@ namespace SettingsRegistryTests
         };
         auto testNotifier1 = m_registry->RegisterNotifier(callback);
 
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, "Special");
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, "Special");
         EXPECT_TRUE(result);
         EXPECT_EQ(1, counter);
 
@@ -1705,7 +1691,7 @@ namespace SettingsRegistryTests
 
         AZStd::vector<char> buffer;
 
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {}, "", &buffer);
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {}, "", &buffer);
         EXPECT_TRUE(result);
         EXPECT_EQ(4, counter);
 
@@ -1719,7 +1705,7 @@ namespace SettingsRegistryTests
 
     TEST_F(SettingsRegistryTest, MergeSettingsFolder_EmptyFolder_ReportsSuccessButNothingAdded)
     {
-        bool result = m_registry->MergeSettingsFolder(m_tempDirectory.GetDirectoryAsFixedMaxPath().Native(), { "editor", "test" }, {});
+        auto result = m_registry->MergeSettingsFolder(m_tempDirectory.GetDirectoryAsFixedMaxPath().Native(), { "editor", "test" }, {});
         EXPECT_TRUE(result);
 
         EXPECT_EQ(AZ::SettingsRegistryInterface::Type::Object, m_registry->GetType(AZ_SETTINGS_REGISTRY_HISTORY_KEY "/0")); // Folder and specialization settings.
@@ -1731,7 +1717,7 @@ namespace SettingsRegistryTests
         constexpr AZStd::fixed_string<AZ::IO::MaxPathLength + 1> path(AZ::IO::MaxPathLength + 1, 'a');
         
         AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFolder(path, { "editor", "test" }, {});
+        auto result = m_registry->MergeSettingsFolder(path, { "editor", "test" }, {});
         AZ_TEST_STOP_TRACE_SUPPRESSION(1);
         EXPECT_FALSE(result);
 
@@ -1746,7 +1732,7 @@ namespace SettingsRegistryTests
         CreateTestFile("Memory.editor.test.setreg", "{}");
 
         AZ_TEST_START_TRACE_SUPPRESSION;
-        bool result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {});
+        auto result = m_registry->MergeSettingsFolder((m_tempDirectory.GetDirectoryAsFixedMaxPath() / AZ::SettingsRegistryInterface::RegistryFolder).Native(), { "editor", "test" }, {});
         EXPECT_GT(::UnitTest::TestRunner::Instance().StopAssertTests(), 0);
         EXPECT_FALSE(result);
 

+ 23 - 7
Code/Framework/AzCore/Tests/StreamerTests.cpp

@@ -330,7 +330,7 @@ namespace AZ::IO
             }
         }
 
-        void PeriodicallyCheckedRead(AZ::IO::PathView filePath, void* buffer, u64 fileSize, u64 offset, AZStd::chrono::seconds timeOut)
+        void PeriodicallyCheckedRead(AZ::IO::PathView filePath, void* buffer, u64 fileSize, u64 offset, AZStd::chrono::seconds timeOut, bool& result)
         {
             AZStd::binary_semaphore sync;
 
@@ -348,6 +348,7 @@ namespace AZ::IO
             this->m_streamer->QueueRequest(AZStd::move(request));
 
             bool hasTimedOut = !sync.try_acquire_for(timeOut);
+            result = readSuccessful && !hasTimedOut;
             ASSERT_FALSE(hasTimedOut);
             ASSERT_TRUE(readSuccessful);
         }
@@ -442,8 +443,13 @@ namespace AZ::IO
         auto testFile = this->CreateTestFile(fileSize, PadArchive::No);
 
         char buffer[fileSize];
-        this->PeriodicallyCheckedRead(testFile->GetFileName(), buffer, fileSize, 0, AZStd::chrono::seconds(5));
-        this->VerifyTestFile(buffer, fileSize);
+        bool readResult{ false };
+        this->PeriodicallyCheckedRead(testFile->GetFileName(), buffer, fileSize, 0, AZStd::chrono::seconds(5), readResult);
+        EXPECT_TRUE(readResult);
+        if(readResult)
+        {
+            this->VerifyTestFile(buffer, fileSize);
+        }
     }
 
     // Read a large file that will need to be broken into chunks.
@@ -453,8 +459,13 @@ namespace AZ::IO
         auto testFile = this->CreateTestFile(fileSize, PadArchive::No);
 
         char* buffer = new char[fileSize];
-        this->PeriodicallyCheckedRead(testFile->GetFileName(), buffer, fileSize, 0, AZStd::chrono::seconds(5));
-        this->VerifyTestFile(buffer, fileSize);
+        bool readResult{ false };
+        this->PeriodicallyCheckedRead(testFile->GetFileName(), buffer, fileSize, 0, AZStd::chrono::seconds(5), readResult);
+        EXPECT_TRUE(readResult);
+        if(readResult)
+        {
+            this->VerifyTestFile(buffer, fileSize);
+        }
 
         delete[] buffer;
     }
@@ -479,8 +490,13 @@ namespace AZ::IO
         for (block = 0; block < fileSize; block += readBlock)
         {
             size_t blockSize = AZStd::min(readBlock, fileRemainder);
-            this->PeriodicallyCheckedRead(testFile->GetFileName(), buffer, blockSize, block, AZStd::chrono::seconds(5));
-            this->AssertTestFile(buffer, blockSize, block);
+            bool readResult{ false };
+            this->PeriodicallyCheckedRead(testFile->GetFileName(), buffer, blockSize, block, AZStd::chrono::seconds(5), readResult);
+            EXPECT_TRUE(readResult);
+            if (readResult)
+            {
+                this->AssertTestFile(buffer, blockSize, block);
+            }
 
             fileRemainder -= blockSize;
         }

+ 27 - 8
Code/Framework/AzFramework/AzFramework/ProjectManager/ProjectManager.cpp

@@ -20,10 +20,9 @@
 
 namespace AzFramework::ProjectManager
 {
-    AZStd::tuple<AZ::IO::FixedMaxPath, AZ::IO::FixedMaxPath> FindProjectAndEngineRootPaths(const int argc, char* argv[])
+    AZ::IO::FixedMaxPath FindProjectPath(const int argc, char* argv[])
     {
         AZ::IO::FixedMaxPath projectRootPath;
-        AZ::IO::FixedMaxPath engineRootPath;
         {
             // AZ::CommandLine and SettingsRegistryImpl is in block scope to make sure
             // that the allocated memory is cleaned up before destroying the SystemAllocator
@@ -40,16 +39,17 @@ namespace AzFramework::ProjectManager
             // in MergeSettingstoRegistry_ConfigFile
             AZ::SettingsRegistryMergeUtils::GetCommandLineFromRegistry(settingsRegistry, commandLine);
             AZ::SettingsRegistryMergeUtils::MergeSettingsToRegistry_CommandLine(settingsRegistry, commandLine, false);
-            engineRootPath = AZ::SettingsRegistryMergeUtils::FindEngineRoot(settingsRegistry);
+            // Look for the engine first in case the project path is relative
+            AZ::SettingsRegistryMergeUtils::FindEngineRoot(settingsRegistry);
             projectRootPath = AZ::SettingsRegistryMergeUtils::FindProjectRoot(settingsRegistry);
         }
-        return AZStd::make_tuple(projectRootPath, engineRootPath);
+        return projectRootPath;
     }
 
     // Check for a project name, if not found, attempt to launch project manager and shut down
     ProjectPathCheckResult CheckProjectPathProvided(const int argc, char* argv[])
     {
-        auto [projectRootPath, engineRootPath] = FindProjectAndEngineRootPaths(argc, argv);
+        const auto projectRootPath = FindProjectPath(argc, argv);
         // If we were able to locate a path to a project, we're done
         if (!projectRootPath.empty())
         {
@@ -58,9 +58,21 @@ namespace AzFramework::ProjectManager
             {
                 return ProjectPathCheckResult::ProjectPathFound;
             }
-            AZ_TracePrintf(
-                "ProjectManager", "Did not find a project file at location '%s', launching the Project Manager...",
+
+            constexpr size_t MaxMessageSize = 2048;
+            AZStd::array<char, MaxMessageSize> msg;
+            azsnprintf(msg.data(), msg.size(), "No project was found at '%s'.\n"
+                "The Project Manager will be opened so you can choose a project.",
                 projectJsonPath.c_str());
+
+            AZ_TracePrintf("ProjectManager", msg.data());
+            AZ::Utils::NativeErrorMessageBox("Project not found", msg.data());
+        }
+        else
+        {
+            AZ::Utils::NativeErrorMessageBox("Project not found",
+                "No project path was provided or detected.\nPlease provide a --project-path argument.\n"
+                "The Project Manager is being launched now so you can choose a project.");
         }
 
         if (LaunchProjectManager())
@@ -83,7 +95,14 @@ namespace AzFramework::ProjectManager
 
             if (!AZ::IO::SystemFile::Exists(executablePath.c_str()))
             {
-                AZ_Error("ProjectManager", false, "%s not found", executablePath.c_str());
+                constexpr size_t MaxMessageSize = 2048;
+                AZStd::array<char, MaxMessageSize> msg;
+                azsnprintf(msg.data(), msg.size(), 
+                    "The Project Manager was not found at '%s'.\nPlease verify O3DE is installed correctly and/or built if compiled from source. ",
+                    executablePath.c_str());
+
+                AZ_Error("ProjectManager", false, msg.data());
+                AZ::Utils::NativeErrorMessageBox("Project Manager not found", msg.data());
                 return false;
             }
 

+ 0 - 3
Code/Framework/AzFramework/AzFramework/azframework_files.cmake

@@ -475,7 +475,4 @@ set(FILES
     Visibility/EntityVisibilityBoundsUnionSystem.cpp
     Visibility/EntityVisibilityQuery.h
     Visibility/EntityVisibilityQuery.cpp
-    Dependency/Dependency.h
-    Dependency/Dependency.inl
-    Dependency/Version.h
 )

+ 10 - 0
Code/Framework/AzToolsFramework/AzToolsFramework/Application/ToolsApplication.cpp

@@ -13,6 +13,7 @@
 #include <AzCore/Serialization/SerializeContext.h>
 #include <AzCore/IO/FileIO.h>
 #include <AzCore/Debug/Profiler.h>
+#include <AzCore/Settings/SettingsRegistryMergeUtils.h>
 
 #include <AzFramework/StringFunc/StringFunc.h>
 
@@ -314,6 +315,15 @@ namespace AzToolsFramework
 
     void ToolsApplication::Start(const Descriptor& descriptor, const StartupParameters& startupParameters/* = StartupParameters()*/)
     {
+        // GameApplications can run without an engine, but ToolsApplications need an engine
+        // NOTE: we do not check 'FilePathKey_EngineRootFolder' because that might have been
+        // set to the project's path which is not enough for ToolsApplications
+        if (AZ::SettingsRegistryMergeUtils::FindEngineRoot(*m_settingsRegistry).empty())
+        {
+            ReportBadEngineRoot();
+            return;
+        }
+
         Application::Start(descriptor, startupParameters);
         if (!m_isStarted)
         {

+ 1 - 14
Code/Framework/AzToolsFramework/AzToolsFramework/UI/DocumentPropertyEditor/SettingsRegistrar.cpp

@@ -73,20 +73,7 @@ namespace AzToolsFramework
 
         AZ::IO::FixedMaxPath fullSettingsPath = AZ::Utils::GetProjectPath();
         fullSettingsPath /= relativeFilepath;
-
-        if (!AZ::IO::SystemFile::Exists(fullSettingsPath.c_str()))
-        {
-            return AZ::Failure(AZStd::string::format("Settings file does not exist: '%s'", fullSettingsPath.c_str()));
-        }
-
-        if (registry->MergeSettingsFile(fullSettingsPath.Native(), format, anchorKey))
-        {
-            return AZ::Success();
-        }
-        else
-        {
-            return AZ::Failure(AZStd::string::format("Failed to merge settings file '%s': check log for errors", fullSettingsPath.c_str()));
-        }
+        return registry->MergeSettingsFile(fullSettingsPath.Native(), format, anchorKey);
     }
 
     bool SettingsRegistrar::RemoveSettingFromRegistry(AZStd::string_view registryPath, AZ::SettingsRegistryInterface* registry) const

+ 1 - 1
Code/Tools/AssetProcessor/native/utilities/PlatformConfiguration.cpp

@@ -1347,7 +1347,7 @@ namespace AssetProcessor
         // file to the settings registry
         if (configFile.Extension() == ".setreg")
         {
-            return settingsRegistry.MergeSettingsFile(configFile.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch);
+            return settingsRegistry.MergeSettingsFile(configFile.Native(), AZ::SettingsRegistryInterface::Format::JsonMergePatch).IsSuccess();
         }
 
         AZ::SettingsRegistryMergeUtils::ConfigParserSettings configParserSettings;

+ 14 - 6
Code/Tools/ProjectManager/Source/ProjectsScreen.cpp

@@ -276,13 +276,16 @@ namespace O3DE::ProjectManager
             // Put currently building project in front, then queued projects, then sorts alphabetically
             AZStd::sort(projects.begin(), projects.end(), [buildProjectPath, this](const ProjectInfo& arg1, const ProjectInfo& arg2)
             {
-                if (AZ::IO::Path(arg1.m_path.toUtf8().constData()) == buildProjectPath)
+                if (!buildProjectPath.empty())
                 {
-                    return true;
-                }
-                else if (AZ::IO::Path(arg2.m_path.toUtf8().constData()) == buildProjectPath)
-                {
-                    return false;
+                    if (AZ::IO::Path(arg1.m_path.toUtf8().constData()) == buildProjectPath)
+                    {
+                        return true;
+                    }
+                    else if (AZ::IO::Path(arg2.m_path.toUtf8().constData()) == buildProjectPath)
+                    {
+                        return false;
+                    }
                 }
 
                 bool arg1InBuildQueue = BuildQueueContainsProject(arg1.m_path);
@@ -295,6 +298,11 @@ namespace O3DE::ProjectManager
                 {
                     return false;
                 }
+                else if (arg1.m_displayName.compare(arg2.m_displayName, Qt::CaseInsensitive) == 0)
+                {
+                    // handle case where names are the same
+                    return arg1.m_path.toLower() < arg2.m_path.toLower();
+                }
                 else
                 {
                     return arg1.m_displayName.toLower() < arg2.m_displayName.toLower();

+ 1 - 1
Code/Tools/ProjectManager/Source/PythonBindings.cpp

@@ -58,7 +58,7 @@ namespace Platform
 #define Py_To_Int(obj) obj.cast<int>()
 #define Py_To_Int_Optional(dict, key, default_int) dict.contains(key) ? Py_To_Int(dict[key]) : default_int
 #define QString_To_Py_String(value) pybind11::str(value.toStdString())
-#define QString_To_Py_Path(value) m_pathlib.attr("Path")(value.toStdString())
+#define QString_To_Py_Path(value) value.isEmpty() ? pybind11::none() : m_pathlib.attr("Path")(value.toStdString())
 
 pybind11::list QStringList_To_Py_List(const QStringList& values)
 {

+ 2 - 2
Code/Tools/PythonBindingsExample/tests/ApplicationTests.cpp

@@ -48,9 +48,9 @@ namespace PythonBindingsExample
 
     AZStd::unique_ptr<PythonBindingsExample::Application> PythonBindingsExampleTest::s_application;
 
-    TEST_F(PythonBindingsExampleTest, Application_Run_Succeeds)
+    TEST_F(PythonBindingsExampleTest, Application_Run_WithoutParameters_Returns_False)
     {
-        EXPECT_TRUE(s_application->Run());
+        EXPECT_FALSE(s_application->Run());
     }
 
     TEST_F(PythonBindingsExampleTest, Application_RunWithParameters_Works)

+ 3 - 4
Gems/AssetValidation/Code/Tests/AssetValidationTestShared.h

@@ -158,10 +158,9 @@ namespace UnitTest
                 m_registry.Set(projectPathKey, (AZ::IO::FixedMaxPath(m_tempDir.GetDirectory()) / "AutomatedTesting").Native());
                 AZ::SettingsRegistryMergeUtils::MergeSettingsToRegistry_AddRuntimeFilePaths(m_registry);
 
-                // Set the engine root to the temporary directory and re-update the runtime file paths
-                auto enginePathKey = AZ::SettingsRegistryInterface::FixedValueString(AZ::SettingsRegistryMergeUtils::BootstrapSettingsRootKey)
-                    + "/engine_path";
-                m_registry.Set(enginePathKey, m_tempDir.GetDirectory());
+                // Set the engine root scan up path to the temporary directory  
+                constexpr AZStd::string_view InternalScanUpEngineRootKey{ "/O3DE/Runtime/Internal/engine_root_scan_up_path" };
+                m_registry.Set(InternalScanUpEngineRootKey, m_tempDir.GetDirectory());
                 AZ::SettingsRegistryMergeUtils::MergeSettingsToRegistry_AddRuntimeFilePaths(m_registry);
             }
         }

+ 6 - 7
Gems/AtomLyIntegration/TechnicalArt/DccScriptingInterface/__init__.py

@@ -405,17 +405,16 @@ if not O3DE_DEV:
         # I  assume that would mainly happen only if manually edited?
 
         # if  this returns None,  section 'key'  doesn't exist
-        ENGINES_PATH = get_key_value(O3DE_MANIFEST_DATA, 'engines_path')
+        ENGINES = get_key_value(O3DE_MANIFEST_DATA, 'engines')
 
-        if ENGINES_PATH:
+        if ENGINES:
 
-            if len(ENGINES_PATH) < 1:
+            if len(ENGINES) < 1:
                 _LOGGER(f'no engines in o3de manifest')
 
-            # what if there are multiple "engines_path"s? We don't know which to use
-            elif len(ENGINES_PATH) == 1: # there can only be one
-                O3DE_ENGINENAME = list(ENGINES_PATH.items())[0][0]
-                O3DE_DEV = Path(list(ENGINES_PATH.items())[0][1])
+            # what if there are multiple engines? We don't know which to use
+            elif len(ENGINES) == 1: # there can only be one
+                O3DE_DEV = Path(ENGINES[0])
 
             else:
                 _LOGGER.warning(f'Manifest defines more then one engine: {O3DE_DEV.as_posix()}')

+ 10 - 10
Gems/LmbrCentral/Code/Source/Builders/CopyDependencyBuilder/XmlBuilderWorker/XmlBuilderWorker.cpp

@@ -12,7 +12,7 @@
 #include <AzCore/Component/ComponentApplication.h>
 #include <AzCore/IO/SystemFile.h>
 #include <AzCore/std/string/wildcard.h>
-#include <AzFramework/Dependency/Dependency.h>
+#include <AzCore/Dependency/Dependency.h>
 #include <AzFramework/FileFunc/FileFunc.h>
 #include <AzFramework/IO/LocalFileIO.h>
 #include <AzFramework/StringFunc/StringFunc.h>
@@ -282,14 +282,14 @@ namespace CopyDependencyBuilder
             return result;
         }
 
-        bool MatchesVersionConstraints(const AzFramework::Version<MaxVersionPartsCount>& version, const AZStd::vector<AZStd::string>& versionConstraints)
+        bool MatchesVersionConstraints(const AZ::Version<MaxVersionPartsCount>& version, const AZStd::vector<AZStd::string>& versionConstraints)
         {
             if (versionConstraints.size() == 0)
             {
                 return true;
             }
 
-            AzFramework::Dependency<MaxVersionPartsCount> dependency;
+            AZ::Dependency<MaxVersionPartsCount> dependency;
             AZ::Outcome<void, AZStd::string> parseOutcome = dependency.ParseVersions(versionConstraints);
             if (!parseOutcome.IsSuccess())
             {
@@ -297,12 +297,12 @@ namespace CopyDependencyBuilder
                 return false;
             }
 
-            return dependency.IsFullfilledBy(AzFramework::Specifier<MaxVersionPartsCount>(AZ::Uuid::CreateNull(), version));
+            return dependency.IsFullfilledBy(AZ::Specifier<MaxVersionPartsCount>(AZ::Uuid::CreateNull(), version));
         }
 
-        AZ::Outcome<AzFramework::Version<MaxVersionPartsCount>, AZStd::string> ParseFromString(const AZStd::string& versionStr)
+        AZ::Outcome<AZ::Version<MaxVersionPartsCount>, AZStd::string> ParseFromString(const AZStd::string& versionStr)
         {
-            AzFramework::Version<MaxVersionPartsCount> result;
+            AZ::Version<MaxVersionPartsCount> result;
             AZStd::vector<AZStd::string> versionParts;
             AzFramework::StringFunc::Tokenize(versionStr.c_str(), versionParts, VERSION_SEPARATOR_CHAR);
 
@@ -571,14 +571,14 @@ namespace CopyDependencyBuilder
         AZ::rapidxml::xml_node<char>* xmlFileRootNode = rootNodeOutcome.GetValue();
 
         AZStd::string sourceFileVersionStr = Internal::GetSourceFileVersion(xmlFileRootNode, schemaAsset.GetVersionSearchRule().GetRootNodeAttributeName());
-        AZ::Outcome <AzFramework::Version<MaxVersionPartsCount>, AZStd::string> SourceFileVersionOutcome = Internal::ParseFromString(sourceFileVersionStr);
+        AZ::Outcome <AZ::Version<MaxVersionPartsCount>, AZStd::string> SourceFileVersionOutcome = Internal::ParseFromString(sourceFileVersionStr);
         if (!SourceFileVersionOutcome.IsSuccess())
         {
             AZ_Warning("XmlBuilderWorker", false, SourceFileVersionOutcome.TakeError().c_str());
             // This isn't a blocking error, the error was on this schema, so try checking the next schema for a match.
             return SchemaMatchResult::NoMatchFound;
         }
-        AzFramework::Version<MaxVersionPartsCount> version = SourceFileVersionOutcome.GetValue();
+        AZ::Version<MaxVersionPartsCount> version = SourceFileVersionOutcome.GetValue();
 
         AZ::Outcome <void, bool> matchingRuleOutcome = SearchForMatchingRule(sourceFilePath, schemaFilePath, version, schemaAsset.GetMatchingRules(), watchFolderPath);
         if (!matchingRuleOutcome.IsSuccess())
@@ -622,7 +622,7 @@ namespace CopyDependencyBuilder
     AZ::Outcome <void, bool> XmlBuilderWorker::SearchForMatchingRule(
         const AZStd::string& sourceFilePath, 
         [[maybe_unused]] const AZStd::string& schemaFilePath,
-        const AzFramework::Version<MaxVersionPartsCount>& version,
+        const AZ::Version<MaxVersionPartsCount>& version,
         const AZStd::vector<AzFramework::MatchingRule>& matchingRules,
         [[maybe_unused]] const AZStd::string& watchFolderPath) const
     {
@@ -662,7 +662,7 @@ namespace CopyDependencyBuilder
     bool XmlBuilderWorker::SearchForDependencySearchRule(
         AZ::rapidxml::xml_node<char>* xmlFileRootNode,
         [[maybe_unused]] const AZStd::string& schemaFilePath,
-        const AzFramework::Version<MaxVersionPartsCount>& version,
+        const AZ::Version<MaxVersionPartsCount>& version,
         const AZStd::vector<AzFramework::DependencySearchRule>& dependencySearchRules,
         AZStd::vector<AssetBuilderSDK::ProductDependency>& productDependencies,
         AssetBuilderSDK::ProductPathDependencySet& pathDependencies,

+ 4 - 4
Gems/LmbrCentral/Code/Source/Builders/CopyDependencyBuilder/XmlBuilderWorker/XmlBuilderWorker.h

@@ -12,14 +12,14 @@
 
 #include <AzCore/XML/rapidxml.h>
 #include <AzFramework/Asset/XmlSchemaAsset.h>
-#include <AzFramework/Dependency/Version.h>
+#include <AzCore/Dependency/Version.h>
 
 #include <Builders/CopyDependencyBuilder/CopyDependencyBuilderWorker.h>
 
 namespace CopyDependencyBuilder
 {
     const char SchemaNamePattern[] = "*.xmlschema";
-    const char VersionContraintRegexStr[] = "(?:(~>|[>=<]{1,2}) *([0-9]+(?:\\.[0-9]+)*))";
+    const char VersionContraintRegexStr[] = "(?:(~>|~=|[>=<]{1,2}) *([0-9]+(?:\\.[0-9]+)*))";
     const char VersionRegexStr[] = "([0-9]+)(?:\\.(.*)){0,1}";
     const size_t MaxVersionPartsCount = 4;
 
@@ -81,14 +81,14 @@ namespace CopyDependencyBuilder
         AZ::Outcome <void, bool> SearchForMatchingRule(
             const AZStd::string& sourceFilePath, 
             const AZStd::string& schemaFilePath,
-            const AzFramework::Version<MaxVersionPartsCount>& version,
+            const AZ::Version<MaxVersionPartsCount>& version,
             const AZStd::vector<AzFramework::MatchingRule>& matchingRules,
             const AZStd::string& watchFolderPath) const;
 
         bool SearchForDependencySearchRule(
             AZ::rapidxml::xml_node<char>* xmlFileRootNode, 
             const AZStd::string& schemaFilePath,
-            const AzFramework::Version<MaxVersionPartsCount>& version,
+            const AZ::Version<MaxVersionPartsCount>& version,
             const AZStd::vector<AzFramework::DependencySearchRule>& matchingRules,
             AZStd::vector<AssetBuilderSDK::ProductDependency>& productDependencies,
             AssetBuilderSDK::ProductPathDependencySet& pathDependencies,

+ 27 - 1
Templates/DefaultProject/Template/CMakeLists.txt

@@ -10,7 +10,33 @@
 
 if(NOT PROJECT_NAME)
     cmake_minimum_required(VERSION 3.22)
-    include(cmake/EngineFinder.cmake OPTIONAL)
+
+    # Utility function to look for an optional 'engine_finder_cmake_path'setting 
+    function(get_engine_finder_cmake_path project_json_file_path path_value)
+        if(NOT ${path_value} AND EXISTS "${project_json_file_path}")
+            file(READ "${project_json_file_path}" project_json_data)
+            string(JSON engine_finder_cmake_value ERROR_VARIABLE json_error GET ${project_json_data} "engine_finder_cmake_path")
+            cmake_path(APPEND CMAKE_CURRENT_SOURCE_DIR "${engine_finder_cmake_value}" engine_finder_cmake_value)
+            if(NOT json_error AND EXISTS "${engine_finder_cmake_value}")
+                set(${path_value} "${engine_finder_cmake_value}" PARENT_SCOPE)
+            elseif(json_error AND ${engine_finder_cmake_value} STREQUAL "NOTFOUND")
+                # When the error value is just NOTFOUND that means there is a JSON
+                # parsing error, and not simply a missing key 
+                message(WARNING "Unable to read 'engine_finder_cmake_path'.\nError: ${json_error} ${engine_finder_cmake_value}")
+            endif()
+        endif()
+    endfunction()
+    
+    # Check for optional 'engine_finder_cmake_path' in order of preference
+    # We support per-project customization to make it easier to upgrade 
+    # or revert to a custom EngineFinder.cmake 
+    get_engine_finder_cmake_path("${CMAKE_CURRENT_SOURCE_DIR}/user/project.json" engine_finder_cmake_path)
+    get_engine_finder_cmake_path("${CMAKE_CURRENT_SOURCE_DIR}/project.json" engine_finder_cmake_path)
+    if(NOT engine_finder_cmake_path)
+        set(engine_finder_cmake_path cmake/EngineFinder.cmake)
+    endif()
+
+    include(${engine_finder_cmake_path} OPTIONAL)
     find_package(o3de REQUIRED)
     project(${Name}
         LANGUAGES C CXX

+ 117 - 44
Templates/DefaultProject/Template/cmake/EngineFinder.cmake

@@ -7,35 +7,62 @@
 #
 #
 # {END_LICENSE}
-# This file is copied during engine registration. Edits to this file will be lost next
-# time a registration happens.
+# Edits to this file may be lost in upgrades. Instead of changing this file, use 
+# the 'engine_finder_cmake_path' key in your project.json or user/project.json to specify 
+# an alternate .cmake file to use instead of this one.
 
 include_guard()
 
-# Read the engine name from the project_json file
-file(READ ${CMAKE_CURRENT_SOURCE_DIR}/project.json project_json)
 set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/project.json)
 
-string(JSON LY_ENGINE_NAME_TO_USE ERROR_VARIABLE json_error GET ${project_json} engine)
-if(json_error)
-    message(FATAL_ERROR "Unable to read key 'engine' from 'project.json'\nError: ${json_error}")
-endif()
-
+# Option 1: Use engine manually set in CMAKE_MODULE_PATH
+# CMAKE_MODULE_PATH must contain a path to an engine's cmake folder 
 if(CMAKE_MODULE_PATH)
     foreach(module_path ${CMAKE_MODULE_PATH})
-        if(EXISTS ${module_path}/Findo3de.cmake)
-            file(READ ${module_path}/../engine.json engine_json)
-            string(JSON engine_name ERROR_VARIABLE json_error GET ${engine_json} engine_name)
-            if(json_error)
-                message(FATAL_ERROR "Unable to read key 'engine_name' from 'engine.json'\nError: ${json_error}")
-            endif()
-            if(LY_ENGINE_NAME_TO_USE STREQUAL engine_name)
-                return() # Engine being forced through CMAKE_MODULE_PATH
+        cmake_path(SET module_engine_version_cmake_path "${module_path}/o3deConfigVersion.cmake")
+        if(EXISTS "${module_engine_version_cmake_path}")
+            include("${module_engine_version_cmake_path}")
+            if(PACKAGE_VERSION_COMPATIBLE)
+                message(STATUS "Selecting engine from CMAKE_MODULE_PATH '${module_path}'")
+                return()
+            else()
+                message(WARNING "Not using engine from CMAKE_MODULE_PATH '${module_path}' because it is not compatible with this project.")
             endif()
         endif()
     endforeach()
+    message(VERBOSE "No compatible engine found from CMAKE_MODULE_PATH '${CMAKE_MODULE_PATH}'.")
 endif()
 
+# Option 2: Use the engine from the 'engine_path' field in <project>/user/project.json
+cmake_path(SET O3DE_USER_PROJECT_JSON_PATH ${CMAKE_CURRENT_SOURCE_DIR}/user/project.json)
+if(EXISTS "${O3DE_USER_PROJECT_JSON_PATH}")
+    file(READ "${O3DE_USER_PROJECT_JSON_PATH}" user_project_json)
+    if(user_project_json)
+        string(JSON user_project_engine_path ERROR_VARIABLE json_error GET ${user_project_json} engine_path)
+        if(user_project_engine_path AND NOT json_error)
+            cmake_path(SET user_engine_version_cmake_path "${user_project_engine_path}/cmake/o3deConfigVersion.cmake")
+            if(EXISTS "${user_engine_version_cmake_path}")
+                include("${user_engine_version_cmake_path}")
+                if(PACKAGE_VERSION_COMPATIBLE)
+                    message(STATUS "Selecting engine '${user_project_engine_path}' from 'engine_path' in '<project>/user/project.json'.")
+                    list(APPEND CMAKE_MODULE_PATH "${user_project_engine_path}/cmake")
+                    return()
+                else()
+                    message(FATAL_ERROR "The engine at '${user_project_engine_path}' from 'engine_path' in '${O3DE_USER_PROJECT_JSON_PATH}' is not compatible with this project. Please register this project with a compatible engine, or remove the local override by running:\nscripts\\o3de edit-project-properties -pp ${CMAKE_CURRENT_SOURCE_DIR} --user --engine-path \"\"")
+                endif()
+            else()
+                message(FATAL_ERROR "This project cannot use the engine at '${user_project_engine_path}' because the version cmake file '${user_engine_version_cmake_path}' needed to check compatibility is missing.  \nPlease register this project with a compatible engine, or remove the local override by running:\nscripts\\o3de edit-project-properties -pp ${CMAKE_CURRENT_SOURCE_DIR} --user --engine-path \"\"\nIf you want this project to use an older engine(not recommended), provide a custom EngineFinder.cmake using the o3de CLI's --engine-finder-cmake-path option. ")
+            endif()
+        elseif(json_error AND ${user_project_engine_path} STREQUAL "NOTFOUND")
+            # When the value is just NOTFOUND that means there is a JSON
+            # parsing error, and not simply a missing key 
+            message(FATAL_ERROR "Unable to read 'engine_path' from '${user_project_engine_path}'\nError: ${json-error}")
+        endif()
+    endif()
+endif()
+
+
+# Option 3: Find a compatible engine registered in ~/.o3de/o3de_manifest.json 
 if(DEFINED ENV{USERPROFILE} AND EXISTS $ENV{USERPROFILE})
     set(manifest_path $ENV{USERPROFILE}/.o3de/o3de_manifest.json) # Windows
 else()
@@ -43,50 +70,96 @@ else()
 endif()
 
 set(registration_error [=[
+To enable more verbose logging, run the cmake command again with '--log-level VERBOSE'
+
 Engine registration is required before configuring a project.
 Run 'scripts/o3de register --this-engine' from the engine root.
 ]=])
 
-# Read the ~/.o3de/o3de_manifest.json file and look through the 'engines_path' object.
-# Find a key that matches LY_ENGINE_NAME_TO_USE and use that as the engine path.
+# Create a list of all engines
 if(EXISTS ${manifest_path})
     file(READ ${manifest_path} manifest_json)
     set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${manifest_path})
 
-    string(JSON engines_path_count ERROR_VARIABLE json_error LENGTH ${manifest_json} engines_path)
+    string(JSON engines_count ERROR_VARIABLE json_error LENGTH ${manifest_json} engines)
     if(json_error)
-        message(FATAL_ERROR "Unable to read key 'engines_path' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
+        message(FATAL_ERROR "Unable to read key 'engines' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
     endif()
 
-    string(JSON engines_path_type ERROR_VARIABLE json_error TYPE ${manifest_json} engines_path)
-    if(json_error OR NOT ${engines_path_type} STREQUAL "OBJECT")
-        message(FATAL_ERROR "Type of 'engines_path' in '${manifest_path}' is not a JSON Object\nError: ${json_error}")
+    string(JSON engines_type ERROR_VARIABLE json_error TYPE ${manifest_json} engines)
+    if(json_error OR NOT ${engines_type} STREQUAL "ARRAY")
+        message(FATAL_ERROR "Type of 'engines' in '${manifest_path}' is not a JSON ARRAY\nError: ${json_error}\n${registration_error}")
     endif()
 
-    math(EXPR engines_path_count "${engines_path_count}-1")
-    foreach(engine_path_index RANGE ${engines_path_count})
-        string(JSON engine_name ERROR_VARIABLE json_error MEMBER ${manifest_json} engines_path ${engine_path_index})
+    math(EXPR engines_count "${engines_count}-1")
+    foreach(array_index RANGE ${engines_count})
+        string(JSON manifest_engine_path ERROR_VARIABLE json_error GET ${manifest_json} engines "${array_index}")
         if(json_error)
-            message(FATAL_ERROR "Unable to read 'engines_path/${engine_path_index}' from '${manifest_path}'\nError: ${json_error}")
+            message(FATAL_ERROR "Unable to read 'engines/${array_index}' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
         endif()
+        list(APPEND O3DE_ENGINE_PATHS ${manifest_engine_path})
+    endforeach()
+elseif(NOT CMAKE_MODULE_PATH)
+    message(FATAL_ERROR "O3DE Manifest file not found at '${manifest_path}'.\n${registration_error}")
+endif()
 
-        if(LY_ENGINE_NAME_TO_USE STREQUAL engine_name)
-            string(JSON engine_path ERROR_VARIABLE json_error GET ${manifest_json} engines_path ${engine_name})
-            if(json_error)
-                message(FATAL_ERROR "Unable to read value from 'engines_path/${engine_name}'\nError: ${json_error}")
-            endif()
+# We cannot just run find_package() on the list of engine paths because
+# CMAKE_FIND_PACKAGE_SORT_ORDER sorts based on file name and chooses
+# the first package that returns PACKAGE_VERSION_COMPATIBLE 
+set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "")
+set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION "")
+foreach(manifest_engine_path IN LISTS O3DE_ENGINE_PATHS) 
+    # Does this engine have a config version cmake file?
+    cmake_path(SET version_cmake_path "${manifest_engine_path}/cmake/o3deConfigVersion.cmake")
+    if(NOT EXISTS "${version_cmake_path}") 
+        message(VERBOSE "Ignoring '${manifest_engine_path}' because no config version cmake file was found at '${version_cmake_path}'")
+        continue()
+    endif()
 
-            if(engine_path)
-                list(APPEND CMAKE_MODULE_PATH "${engine_path}/cmake")
-                return()
-            endif()
+    unset(PACKAGE_VERSION)
+    unset(PACKAGE_VERSION_COMPATIBLE)
+    include("${version_cmake_path}")
+
+    # Follow the version checking convention from find_package(CONFIG)
+    if(PACKAGE_VERSION_COMPATIBLE)
+        if(NOT O3DE_MOST_COMPATIBLE_ENGINE_PATH) 
+            set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "${manifest_engine_path}") 
+            set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION ${PACKAGE_VERSION}) 
+            message(VERBOSE "Found compatible engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}'")
+        elseif(${PACKAGE_VERSION} VERSION_GREATER ${O3DE_MOST_COMPATIBLE_ENGINE_VERSION})
+            set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "${manifest_engine_path}")
+            set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION ${PACKAGE_VERSION})
+            message(VERBOSE "Found more compatible engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}' because it has a greater version number.")
+        else()
+            message(VERBOSE "Not using engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}' because it doesn't have a greater version number or has a different engine name.")
         endif()
-    endforeach()
-    
-    message(FATAL_ERROR "The project.json uses engine name '${LY_ENGINE_NAME_TO_USE}' but no engine with that name has been registered.\n${registration_error}")
-else()
-    # If the user is passing CMAKE_MODULE_PATH we assume thats where we will find the engine
-    if(NOT CMAKE_MODULE_PATH)
-        message(FATAL_ERROR "O3DE Manifest file not found.\n${registration_error}")
+    else()
+        message(VERBOSE "Ignoring '${manifest_engine_path}' because it is not a compatible engine.")
     endif()
+endforeach()
+
+if(O3DE_MOST_COMPATIBLE_ENGINE_PATH)
+    message(STATUS "Selecting engine '${O3DE_MOST_COMPATIBLE_ENGINE_PATH}'")
+    list(APPEND CMAKE_MODULE_PATH "${O3DE_MOST_COMPATIBLE_ENGINE_PATH}/cmake")
+    return()
+endif()
+
+# No compatible engine was found.
+# Read the 'engine' field in project.json or user/project.json for more helpful messages 
+if(user_project_json)
+    string(JSON user_project_engine ERROR_VARIABLE json_error GET ${user_project_json} engine)
+endif()
+
+if(NOT user_project_engine)
+    file(READ ${CMAKE_CURRENT_SOURCE_DIR}/project.json o3de_project_json)
+    string(JSON project_engine ERROR_VARIABLE json_error GET ${o3de_project_json} engine)
+    if(json_error)
+        message(FATAL_ERROR "Unable to read key 'engine' from 'project.json'\nError: ${json_error}")
+    endif()
+endif()
+
+if(user_project_engine)
+    message(FATAL_ERROR "The local '${O3DE_USER_PROJECT_JSON_PATH}' engine is '${user_project_engine}' but no compatible engine with that name and version was found.  Please register the compatible engine, or remove the local engine override.\n${registration_error}")
+else()
+    message(FATAL_ERROR "The project.json engine is '${project_engine}' but no engine with that name and version was found.\n${registration_error}")
 endif()

+ 27 - 1
Templates/MinimalProject/Template/CMakeLists.txt

@@ -10,7 +10,33 @@
 
 if(NOT PROJECT_NAME)
     cmake_minimum_required(VERSION 3.22)
-    include(cmake/EngineFinder.cmake OPTIONAL)
+
+    # Utility function to look for an optional 'engine_finder_cmake_path'setting 
+    function(get_engine_finder_cmake_path project_json_file_path path_value)
+        if(NOT ${path_value} AND EXISTS "${project_json_file_path}")
+            file(READ "${project_json_file_path}" project_json_data)
+            string(JSON engine_finder_cmake_value ERROR_VARIABLE json_error GET ${project_json_data} "engine_finder_cmake_path")
+            cmake_path(APPEND CMAKE_CURRENT_SOURCE_DIR "${engine_finder_cmake_value}" engine_finder_cmake_value)
+            if(NOT json_error AND EXISTS "${engine_finder_cmake_value}")
+                set(${path_value} "${engine_finder_cmake_value}" PARENT_SCOPE)
+            elseif(json_error AND ${engine_finder_cmake_value} STREQUAL "NOTFOUND")
+                # When the error value is just NOTFOUND that means there is a JSON
+                # parsing error, and not simply a missing key 
+                message(WARNING "Unable to read 'engine_finder_cmake_path'.\nError: ${json_error} ${engine_finder_cmake_value}")
+            endif()
+        endif()
+    endfunction()
+    
+    # Check for optional 'engine_finder_cmake_path' in order of preference
+    # We support per-project customization to make it easier to upgrade 
+    # or revert to a custom EngineFinder.cmake 
+    get_engine_finder_cmake_path("${CMAKE_CURRENT_SOURCE_DIR}/user/project.json" engine_finder_cmake_path)
+    get_engine_finder_cmake_path("${CMAKE_CURRENT_SOURCE_DIR}/project.json" engine_finder_cmake_path)
+    if(NOT engine_finder_cmake_path)
+        set(engine_finder_cmake_path cmake/EngineFinder.cmake)
+    endif()
+
+    include(${engine_finder_cmake_path} OPTIONAL)
     find_package(o3de REQUIRED)
     project(${Name}
         LANGUAGES C CXX

+ 117 - 44
Templates/MinimalProject/Template/cmake/EngineFinder.cmake

@@ -7,35 +7,62 @@
 #
 #
 # {END_LICENSE}
-# This file is copied during engine registration. Edits to this file will be lost next
-# time a registration happens.
+# Edits to this file may be lost in upgrades. Instead of changing this file, use 
+# the 'engine_finder_cmake_path' key in your project.json or user/project.json to specify 
+# an alternate .cmake file to use instead of this one.
 
 include_guard()
 
-# Read the engine name from the project_json file
-file(READ ${CMAKE_CURRENT_SOURCE_DIR}/project.json project_json)
 set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/project.json)
 
-string(JSON LY_ENGINE_NAME_TO_USE ERROR_VARIABLE json_error GET ${project_json} engine)
-if(json_error)
-    message(FATAL_ERROR "Unable to read key 'engine' from 'project.json'\nError: ${json_error}")
-endif()
-
+# Option 1: Use engine manually set in CMAKE_MODULE_PATH
+# CMAKE_MODULE_PATH must contain a path to an engine's cmake folder 
 if(CMAKE_MODULE_PATH)
     foreach(module_path ${CMAKE_MODULE_PATH})
-        if(EXISTS ${module_path}/Findo3de.cmake)
-            file(READ ${module_path}/../engine.json engine_json)
-            string(JSON engine_name ERROR_VARIABLE json_error GET ${engine_json} engine_name)
-            if(json_error)
-                message(FATAL_ERROR "Unable to read key 'engine_name' from 'engine.json'\nError: ${json_error}")
-            endif()
-            if(LY_ENGINE_NAME_TO_USE STREQUAL engine_name)
-                return() # Engine being forced through CMAKE_MODULE_PATH
+        cmake_path(SET module_engine_version_cmake_path "${module_path}/o3deConfigVersion.cmake")
+        if(EXISTS "${module_engine_version_cmake_path}")
+            include("${module_engine_version_cmake_path}")
+            if(PACKAGE_VERSION_COMPATIBLE)
+                message(STATUS "Selecting engine from CMAKE_MODULE_PATH '${module_path}'")
+                return()
+            else()
+                message(WARNING "Not using engine from CMAKE_MODULE_PATH '${module_path}' because it is not compatible with this project.")
             endif()
         endif()
     endforeach()
+    message(VERBOSE "No compatible engine found from CMAKE_MODULE_PATH '${CMAKE_MODULE_PATH}'.")
 endif()
 
+# Option 2: Use the engine from the 'engine_path' field in <project>/user/project.json
+cmake_path(SET O3DE_USER_PROJECT_JSON_PATH ${CMAKE_CURRENT_SOURCE_DIR}/user/project.json)
+if(EXISTS "${O3DE_USER_PROJECT_JSON_PATH}")
+    file(READ "${O3DE_USER_PROJECT_JSON_PATH}" user_project_json)
+    if(user_project_json)
+        string(JSON user_project_engine_path ERROR_VARIABLE json_error GET ${user_project_json} engine_path)
+        if(user_project_engine_path AND NOT json_error)
+            cmake_path(SET user_engine_version_cmake_path "${user_project_engine_path}/cmake/o3deConfigVersion.cmake")
+            if(EXISTS "${user_engine_version_cmake_path}")
+                include("${user_engine_version_cmake_path}")
+                if(PACKAGE_VERSION_COMPATIBLE)
+                    message(STATUS "Selecting engine '${user_project_engine_path}' from 'engine_path' in '<project>/user/project.json'.")
+                    list(APPEND CMAKE_MODULE_PATH "${user_project_engine_path}/cmake")
+                    return()
+                else()
+                    message(FATAL_ERROR "The engine at '${user_project_engine_path}' from 'engine_path' in '${O3DE_USER_PROJECT_JSON_PATH}' is not compatible with this project. Please register this project with a compatible engine, or remove the local override by running:\nscripts\\o3de edit-project-properties -pp ${CMAKE_CURRENT_SOURCE_DIR} --user --engine-path \"\"")
+                endif()
+            else()
+                message(FATAL_ERROR "This project cannot use the engine at '${user_project_engine_path}' because the version cmake file '${user_engine_version_cmake_path}' needed to check compatibility is missing.  \nPlease register this project with a compatible engine, or remove the local override by running:\nscripts\\o3de edit-project-properties -pp ${CMAKE_CURRENT_SOURCE_DIR} --user --engine-path \"\"\nIf you want this project to use an older engine(not recommended), provide a custom EngineFinder.cmake using the o3de CLI's --engine-finder-cmake-path option. ")
+            endif()
+        elseif(json_error AND ${user_project_engine_path} STREQUAL "NOTFOUND")
+            # When the value is just NOTFOUND that means there is a JSON
+            # parsing error, and not simply a missing key 
+            message(FATAL_ERROR "Unable to read 'engine_path' from '${user_project_engine_path}'\nError: ${json-error}")
+        endif()
+    endif()
+endif()
+
+
+# Option 3: Find a compatible engine registered in ~/.o3de/o3de_manifest.json 
 if(DEFINED ENV{USERPROFILE} AND EXISTS $ENV{USERPROFILE})
     set(manifest_path $ENV{USERPROFILE}/.o3de/o3de_manifest.json) # Windows
 else()
@@ -43,50 +70,96 @@ else()
 endif()
 
 set(registration_error [=[
+To enable more verbose logging, run the cmake command again with '--log-level VERBOSE'
+
 Engine registration is required before configuring a project.
 Run 'scripts/o3de register --this-engine' from the engine root.
 ]=])
 
-# Read the ~/.o3de/o3de_manifest.json file and look through the 'engines_path' object.
-# Find a key that matches LY_ENGINE_NAME_TO_USE and use that as the engine path.
+# Create a list of all engines
 if(EXISTS ${manifest_path})
     file(READ ${manifest_path} manifest_json)
     set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${manifest_path})
 
-    string(JSON engines_path_count ERROR_VARIABLE json_error LENGTH ${manifest_json} engines_path)
+    string(JSON engines_count ERROR_VARIABLE json_error LENGTH ${manifest_json} engines)
     if(json_error)
-        message(FATAL_ERROR "Unable to read key 'engines_path' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
+        message(FATAL_ERROR "Unable to read key 'engines' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
     endif()
 
-    string(JSON engines_path_type ERROR_VARIABLE json_error TYPE ${manifest_json} engines_path)
-    if(json_error OR NOT ${engines_path_type} STREQUAL "OBJECT")
-        message(FATAL_ERROR "Type of 'engines_path' in '${manifest_path}' is not a JSON Object\nError: ${json_error}")
+    string(JSON engines_type ERROR_VARIABLE json_error TYPE ${manifest_json} engines)
+    if(json_error OR NOT ${engines_type} STREQUAL "ARRAY")
+        message(FATAL_ERROR "Type of 'engines' in '${manifest_path}' is not a JSON ARRAY\nError: ${json_error}\n${registration_error}")
     endif()
 
-    math(EXPR engines_path_count "${engines_path_count}-1")
-    foreach(engine_path_index RANGE ${engines_path_count})
-        string(JSON engine_name ERROR_VARIABLE json_error MEMBER ${manifest_json} engines_path ${engine_path_index})
+    math(EXPR engines_count "${engines_count}-1")
+    foreach(array_index RANGE ${engines_count})
+        string(JSON manifest_engine_path ERROR_VARIABLE json_error GET ${manifest_json} engines "${array_index}")
         if(json_error)
-            message(FATAL_ERROR "Unable to read 'engines_path/${engine_path_index}' from '${manifest_path}'\nError: ${json_error}")
+            message(FATAL_ERROR "Unable to read 'engines/${array_index}' from '${manifest_path}'\nError: ${json_error}\n${registration_error}")
         endif()
+        list(APPEND O3DE_ENGINE_PATHS ${manifest_engine_path})
+    endforeach()
+elseif(NOT CMAKE_MODULE_PATH)
+    message(FATAL_ERROR "O3DE Manifest file not found at '${manifest_path}'.\n${registration_error}")
+endif()
 
-        if(LY_ENGINE_NAME_TO_USE STREQUAL engine_name)
-            string(JSON engine_path ERROR_VARIABLE json_error GET ${manifest_json} engines_path ${engine_name})
-            if(json_error)
-                message(FATAL_ERROR "Unable to read value from 'engines_path/${engine_name}'\nError: ${json_error}")
-            endif()
+# We cannot just run find_package() on the list of engine paths because
+# CMAKE_FIND_PACKAGE_SORT_ORDER sorts based on file name and chooses
+# the first package that returns PACKAGE_VERSION_COMPATIBLE 
+set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "")
+set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION "")
+foreach(manifest_engine_path IN LISTS O3DE_ENGINE_PATHS) 
+    # Does this engine have a config version cmake file?
+    cmake_path(SET version_cmake_path "${manifest_engine_path}/cmake/o3deConfigVersion.cmake")
+    if(NOT EXISTS "${version_cmake_path}") 
+        message(VERBOSE "Ignoring '${manifest_engine_path}' because no config version cmake file was found at '${version_cmake_path}'")
+        continue()
+    endif()
 
-            if(engine_path)
-                list(APPEND CMAKE_MODULE_PATH "${engine_path}/cmake")
-                return()
-            endif()
+    unset(PACKAGE_VERSION)
+    unset(PACKAGE_VERSION_COMPATIBLE)
+    include("${version_cmake_path}")
+
+    # Follow the version checking convention from find_package(CONFIG)
+    if(PACKAGE_VERSION_COMPATIBLE)
+        if(NOT O3DE_MOST_COMPATIBLE_ENGINE_PATH) 
+            set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "${manifest_engine_path}") 
+            set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION ${PACKAGE_VERSION}) 
+            message(VERBOSE "Found compatible engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}'")
+        elseif(${PACKAGE_VERSION} VERSION_GREATER ${O3DE_MOST_COMPATIBLE_ENGINE_VERSION})
+            set(O3DE_MOST_COMPATIBLE_ENGINE_PATH "${manifest_engine_path}")
+            set(O3DE_MOST_COMPATIBLE_ENGINE_VERSION ${PACKAGE_VERSION})
+            message(VERBOSE "Found more compatible engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}' because it has a greater version number.")
+        else()
+            message(VERBOSE "Not using engine '${manifest_engine_path}' with version '${PACKAGE_VERSION}' because it doesn't have a greater version number or has a different engine name.")
         endif()
-    endforeach()
-    
-    message(FATAL_ERROR "The project.json uses engine name '${LY_ENGINE_NAME_TO_USE}' but no engine with that name has been registered.\n${registration_error}")
-else()
-    # If the user is passing CMAKE_MODULE_PATH we assume thats where we will find the engine
-    if(NOT CMAKE_MODULE_PATH)
-        message(FATAL_ERROR "O3DE Manifest file not found.\n${registration_error}")
+    else()
+        message(VERBOSE "Ignoring '${manifest_engine_path}' because it is not a compatible engine.")
     endif()
+endforeach()
+
+if(O3DE_MOST_COMPATIBLE_ENGINE_PATH)
+    message(STATUS "Selecting engine '${O3DE_MOST_COMPATIBLE_ENGINE_PATH}'")
+    list(APPEND CMAKE_MODULE_PATH "${O3DE_MOST_COMPATIBLE_ENGINE_PATH}/cmake")
+    return()
+endif()
+
+# No compatible engine was found.
+# Read the 'engine' field in project.json or user/project.json for more helpful messages 
+if(user_project_json)
+    string(JSON user_project_engine ERROR_VARIABLE json_error GET ${user_project_json} engine)
+endif()
+
+if(NOT user_project_engine)
+    file(READ ${CMAKE_CURRENT_SOURCE_DIR}/project.json o3de_project_json)
+    string(JSON project_engine ERROR_VARIABLE json_error GET ${o3de_project_json} engine)
+    if(json_error)
+        message(FATAL_ERROR "Unable to read key 'engine' from 'project.json'\nError: ${json_error}")
+    endif()
+endif()
+
+if(user_project_engine)
+    message(FATAL_ERROR "The local '${O3DE_USER_PROJECT_JSON_PATH}' engine is '${user_project_engine}' but no compatible engine with that name and version was found.  Please register the compatible engine, or remove the local engine override.\n${registration_error}")
+else()
+    message(FATAL_ERROR "The project.json engine is '${project_engine}' but no engine with that name and version was found.\n${registration_error}")
 endif()

+ 27 - 14
cmake/Findo3de.cmake

@@ -11,6 +11,8 @@
 
 include(FindPackageHandleStandardArgs)
 
+
+# Use CMAKE_CURRENT_FUNCTION_LIST_DIR in case an older projects used add_directory()
 function(o3de_current_file_path path)
     set(${path} ${CMAKE_CURRENT_FUNCTION_LIST_DIR} PARENT_SCOPE)
 endfunction()
@@ -21,24 +23,35 @@ o3de_current_file_path(current_path)
 # because later it's read again using a path like ${LY_ROOT_FOLDER}/engine.json, which
 # is also normalized.  They should match to avoid errors on some build systems.
 cmake_path(SET engine_json_path NORMALIZE ${current_path}/../engine.json)
-file(READ ${engine_json_path} engine_json)
 set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${engine_json_path})
 
-string(JSON this_engine_name ERROR_VARIABLE json_error GET ${engine_json} engine_name)
-if(json_error)
-    message(FATAL_ERROR "Unable to read key 'engine_name' from '${engine_json_path}', error: ${json_error}")
-endif()
+if(NOT LY_ENGINE_NAME_TO_USE)
+    # PACKAGE_VERSION_COMPATIBLE should be set TRUE by o3deConfigVersion.cmake
+    find_package_handle_standard_args(o3de
+        "This engine was not found to be compatible with your project, or no compatibility checks were done. For more information, run the command again with '--log-level VERBOSE'."
+        PACKAGE_VERSION_COMPATIBLE
+    )
+else()
+    # LY_ENGINE_NAME_TO_USE compatibility check for older projects 
+    set(found_matching_engine FALSE)
 
-# Make sure we are matching LY_ENGINE_NAME_TO_USE with the current engine
-set(found_matching_engine FALSE)
-if(this_engine_name STREQUAL LY_ENGINE_NAME_TO_USE)
-    set(found_matching_engine TRUE)
-endif()
+    file(READ ${engine_json_path} engine_json)
+    string(JSON this_engine_name ERROR_VARIABLE json_error GET ${engine_json} engine_name)
+    if(json_error)
+        message(FATAL_ERROR "Unable to read key 'engine_name' from '${engine_json_path}', error: ${json_error}")
+    endif()
 
-find_package_handle_standard_args(o3de
-    "Could not find an engine with matching ${LY_ENGINE_NAME_TO_USE}"
-    found_matching_engine
-)
+    if(this_engine_name STREQUAL LY_ENGINE_NAME_TO_USE)
+        set(found_matching_engine TRUE)
+    else()
+        message(VERBOSE "Project engine name '${LY_ENGINE_NAME_TO_USE}' does not match this engine name '${this_engine_name}' in '${engine_json_path}'")
+    endif()
+
+    find_package_handle_standard_args(o3de
+        "The engine name for this engine '${this_engine_name}' does not match the projects engine name '${LY_ENGINE_NAME_TO_USE}'"
+        found_matching_engine
+    )
+endif()
 
 cmake_path(SET engine_root_folder NORMALIZE ${current_path}/..)
 set_property(GLOBAL PROPERTY O3DE_ENGINE_ROOT_FOLDER "${engine_root_folder}")

+ 10 - 0
cmake/o3deConfig.cmake

@@ -0,0 +1,10 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+# This cmake package config file currently only exists so find_package(o3de CONFIG) 
+# will consider this engine and look for the associated o3deConfigVersion.cmake
+# file which will check if a project is compatible with this engine.

+ 158 - 0
cmake/o3deConfigVersion.cmake

@@ -0,0 +1,158 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+# This file is included by find_package(o3de CONFIG) and will set PACKAGE_VERSION_COMPATIBLE
+# to TRUE or FALSE based on whether the project is compatible with this engine.
+# This file also sets PACKAGE_VERSION and PACKAGE_VERISON_EXACT if it can determine
+# that information from the engine.json, project.json and <project>/user/project.json.
+
+set(PACKAGE_VERSION_COMPATIBLE FALSE)
+set(PACKAGE_VERSION_EXACT FALSE)
+
+# Store the project.json with any overrides from <project>/user/project.json 
+# in a global property to avoid the performance hit of loading multiple times
+# The project's CMake will likely have already set this for us, but just in case
+get_property(o3de_project_json GLOBAL PROPERTY O3DE_PROJECT_JSON)
+cmake_path(SET O3DE_PROJECT_JSON_PATH ${CMAKE_CURRENT_SOURCE_DIR}/project.json)
+if(NOT o3de_project_json)
+    if(EXISTS ${O3DE_PROJECT_JSON_PATH})
+        file(READ "${O3DE_PROJECT_JSON_PATH}" o3de_project_json)
+
+        # Allow the user to override the 'engine' value in <project>/user/project.json
+        cmake_path(SET O3DE_USER_PROJECT_JSON_PATH ${CMAKE_CURRENT_SOURCE_DIR}/user/project.json)
+        if(EXISTS ${O3DE_USER_PROJECT_JSON_PATH})
+            file(READ "${O3DE_USER_PROJECT_JSON_PATH}" o3de_user_project_json)
+            string(JSON user_project_engine ERROR_VARIABLE json_error GET ${o3de_user_project_json} engine)
+            if(user_project_engine AND NOT json_error)
+                string(JSON o3de_project_json SET "${o3de_project_json}" engine "\"${user_project_engine}\"" )
+            elseif(json_error AND ${user_project_engine} STREQUAL "NOTFOUND")
+                # When the value is just NOTFOUND that means there is a JSON
+                # parsing error, and not simply a missing key 
+                message(WARNING "Unable to read 'engine' from '${O3DE_USER_PROJECT_JSON_PATH}'\nError: ${json-error}")
+            endif()
+        endif()
+
+        set_property(GLOBAL PROPERTY O3DE_PROJECT_JSON ${o3de_project_json})
+    else()
+        message(WARNING "Unable to read '${O3DE_PROJECT_JSON_PATH}' file necessary to determine whether the project is compatible with this engine.")
+        return()
+    endif()
+endif()
+
+
+# Get the engine's 'engine_name' and 'version' fields
+cmake_path(GET CMAKE_CURRENT_LIST_DIR PARENT_PATH this_engine_path)
+file(READ ${this_engine_path}/engine.json engine_json)
+string(JSON engine_name ERROR_VARIABLE json_error GET ${engine_json} engine_name)
+if(json_error OR NOT engine_name)
+    message(WARNING "Unable to read key 'engine_name' from '${this_engine_path}/engine.json'\nError: ${json_error}")
+    return()
+endif()
+string(JSON engine_version ERROR_VARIABLE json_error GET ${engine_json} version)
+if(json_error)
+    message(WARNING "Unable to verify engine compatibility because we could not read key 'version' from '${this_engine_path}/engine.json'\nError: ${json_error}")
+    return()
+endif()
+
+set(PACKAGE_VERSION ${engine_version})
+
+# Get the project.json 'engine' field
+string(JSON project_engine ERROR_VARIABLE json_error GET ${o3de_project_json} engine)
+if(json_error OR NOT project_engine)
+    message(WARNING "Unable to read 'engine' value from '${O3DE_PROJECT_JSON_PATH}'. Please verify this project is registered with an engine. \nError: ${json_error}")
+    return()
+endif()
+
+# Split the engine field into engine name and version specifier 
+unset(project_engine_name)
+unset(project_engine_op)
+unset(project_engine_version)
+if("${project_engine}" MATCHES "^(.*)(~=|==|!=|<=|>=|<|>|===)(.*)$")
+    if(${CMAKE_MATCH_COUNT} GREATER_EQUAL 1)
+        set(project_engine_name ${CMAKE_MATCH_1})
+    endif()
+    if(${CMAKE_MATCH_COUNT} GREATER_EQUAL 2)
+        set(project_engine_op ${CMAKE_MATCH_2})
+    endif()
+    if(${CMAKE_MATCH_COUNT} GREATER_EQUAL 3)
+        set(project_engine_version ${CMAKE_MATCH_3})
+    endif()
+else()
+    # format unknown, assume it's just the dependency name
+    set(project_engine_name ${project_engine})
+endif()
+
+# Does the engine name match?
+if(NOT engine_name STREQUAL project_engine_name)
+    message(VERBOSE " Engine name '${engine_name}' found in '${this_engine_path}/engine.json' does not match expected engine name '${project_engine_name}'")
+    return()
+endif()
+
+# Is the version compatible?
+if(project_engine_op AND project_engine_version)
+    set(contains_version FALSE)
+    set(op ${project_engine_op})
+    set(specifier_version ${project_engine_version})
+    set(version ${engine_version})
+
+    if(op STREQUAL "==" AND version VERSION_EQUAL specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL "!=" AND NOT version VERSION_EQUAL specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL "<=" AND version VERSION_LESS_EQUAL specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL ">=" AND version VERSION_GREATER_EQUAL specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL "<" AND version VERSION_LESS specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL ">" AND version VERSION_GREATER specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL "===" AND version STREQUAL specifier_version)
+        set(contains_version TRUE)
+    elseif(op STREQUAL "~=")
+        # compatible versions have an equivalent combination of >= and == 
+        # e.g. ~=2.2 is equivalent to >=2.2,==2.*
+        if(version VERSION_GREATER_EQUAL specifier_version)
+            string(REPLACE "." ";" specifer_version_part_list ${specifier_version})
+            list(LENGTH specifer_version_part_list list_length)
+            if(list_length LESS 2)
+                # truncating would leave nothing to compare 
+                set(contains_version TRUE)
+            else()
+                # trim the last version part because CMake doesn't support '*'
+                math(EXPR truncated_length "${list_length} - 1")
+                list(SUBLIST specifer_version_part_list 0 ${truncated_length} specifier_version)
+                string(REPLACE ";" "." specifier_version "${specifier_version}")
+                string(REPLACE "." ";" version_part_list ${version})
+                list(SUBLIST version_part_list 0 ${truncated_length} version)
+                string(REPLACE ";" "." version "${version}")
+
+                # compare the truncated versions
+                if(version VERSION_EQUAL specifier_version)
+                    set(contains_version TRUE)
+                endif()
+            endif()
+        endif()
+    endif()
+
+    if(NOT contains_version)
+        message(VERBOSE "The engine ${engine_name} version ${engine_version} at ${this_engine_path} is not compatible with the project's version specifier '${project_engine_op}${project_engine_version}'")
+        return()
+    endif()
+
+    message(VERBOSE "The engine '${engine_name}' version '${engine_version}' at '${this_engine_path}' is compatible with the project's version specifier '${project_engine_op}${project_engine_version}'")
+else()
+    message(VERBOSE "The engine '${engine_name}' version '${engine_version}' at '${this_engine_path}' is compatible because the project has no engine version specifier.'")
+endif()
+
+set(PACKAGE_VERSION_COMPATIBLE TRUE)
+
+if(project_engine_version)
+    if(PACKAGE_VERSION STREQUAL ${project_engine_version})
+        set(PACKAGE_VERSION_EXACT TRUE)
+    endif()
+endif()

+ 70 - 1
scripts/o3de/o3de/compatibility.py

@@ -12,11 +12,80 @@ from packaging.version import Version, InvalidVersion
 from packaging.specifiers import SpecifierSet
 import pathlib
 import logging
-from o3de import manifest, utils, cmake
+from collections import OrderedDict
+from o3de import manifest, utils, cmake, validation
 
 logger = logging.getLogger('o3de.compatibility')
 logging.basicConfig(format=utils.LOG_FORMAT)
 
+def get_most_compatible_project_engine_path(project_path:pathlib.Path, 
+                                            project_json_data:dict = None, 
+                                            user_project_json_data:dict = None, 
+                                            engines_json_data:dict = None) -> pathlib.Path or None:
+    """
+    Returns the most compatible engine path for a project based on the project's 
+    'engine' field and taking into account <project_path>/user/project.json overrides
+    :param project_path: Path to the project
+    :param project_json_data: Json data to use to avoid reloading project.json  
+    :param user_project_json_data: Json data to use to avoid reloading <project_path>/user/project.json  
+    :param engines_json_data: Json data to use for engines instead of opening each file, useful for speed 
+    """
+    if not project_json_data:
+        project_json_data = manifest.get_project_json_data(project_path=project_path)
+    if not project_json_data:
+        logger.error(f'Failed to load project.json data from {project_path}. '
+            'Please verify the path is correct, the file exists and is formatted correctly.')
+        return None
+    
+    # take into account any user project.json overrides 
+    if not isinstance(user_project_json_data, dict):
+        user_project_json_path = pathlib.Path(project_path) / 'user' / 'project.json'
+        if user_project_json_path.is_file():
+            user_project_json_data = manifest.get_json_data_file(user_project_json_path, 'project', validation.always_valid)
+            if user_project_json_data:
+                project_json_data.update(user_project_json_data)
+                user_engine_path = project_json_data.get('engine_path', '')
+                if user_engine_path:
+                    return pathlib.Path(user_engine_path)
+
+    project_engine = project_json_data.get('engine')
+    if not project_engine:
+        # The project has not been registered to an engine yet
+        return None
+
+    if not engines_json_data:
+        engines_json_data = OrderedDict()
+        engines = manifest.get_manifest_engines()
+        for engine in engines:
+            if isinstance(engine, dict):
+                engine_path = pathlib.Path(engine['path']).resolve()
+            else:
+                engine_path = pathlib.Path(engine).resolve()
+            engine_json_data = manifest.get_engine_json_data(engine_path=engine_path)
+            if not engine_json_data:
+                continue
+            engines_json_data[engine_path] = engine_json_data
+
+    most_compatible_engine_path = None
+    most_compatible_engine_version = None
+    for engine_path, engine_json_data in engines_json_data.items():
+        engine_name = engine_json_data.get('engine_name')
+        engine_version = engine_json_data.get('version')
+        if not engine_version:
+            # use a default version number in case version is missing or empty
+            engine_version = '0.0.0'
+
+        if has_compatible_version([project_engine], engine_name, engine_version):
+            if not most_compatible_engine_path:
+                most_compatible_engine_path = pathlib.Path(engine_path)
+                most_compatible_engine_version = Version(engine_version)
+            elif Version(engine_version) > most_compatible_engine_version:
+                most_compatible_engine_path = pathlib.Path(engine_path)
+                most_compatible_engine_version = Version(engine_version)
+    
+    return most_compatible_engine_path
+
+
 def get_project_engine_incompatible_objects(project_path:pathlib.Path, engine_path:pathlib.Path = None) -> set:
     """
     Returns any incompatible objects for this project and engine.

+ 45 - 16
scripts/o3de/o3de/manifest.py

@@ -14,7 +14,7 @@ import logging
 import os
 import pathlib
 
-from o3de import validation, utils, repo
+from o3de import validation, utils, repo, compatibility
 
 logging.basicConfig(format=utils.LOG_FORMAT)
 logger = logging.getLogger('o3de.manifest')
@@ -307,16 +307,25 @@ def get_project_external_subdirectories(project_path: pathlib.Path) -> list:
                         project_object['external_subdirectories'])) if 'external_subdirectories' in project_object else []
     return []
 
-def get_project_engine_path(project_path: pathlib.Path) -> pathlib.Path or None:
-    # first check if the project has an engine field in project.json that
-    # refers to a registered engine
-    project_object = get_project_json_data(project_path=project_path)
-    if project_object:
-        engine_name = project_object.get('engine', '')
-        if engine_name:
-            engine_path = get_registered(engine_name=engine_name)
-            if engine_path:
-                return engine_path
+def get_project_engine_path(project_path: pathlib.Path, 
+                            project_json_data: dict = None, 
+                            user_project_json_data: dict = None, 
+                            engines_json_data: dict = None) -> pathlib.Path or None:
+    """
+    Returns the most compatible engine path for a project based on the project's 
+    'engine' field and taking into account <project_path>/user/project.json overrides
+    or the engine the project is registered with.
+    :param project_path: Path to the project
+    :param project_json_data: Optional json data to use to avoid reloading project.json  
+    :param user_project_json_data: Optional json data to use to avoid reloading <project_path>/user/project.json  
+    :param engines_json_data: Optional engines json data to use for engines to avoid reloading all engine.json files
+    """
+    engine_path = compatibility.get_most_compatible_project_engine_path(project_path, 
+                                                                        project_json_data, 
+                                                                        user_project_json_data, 
+                                                                        engines_json_data)
+    if engine_path:
+        return engine_path
 
     # check if the project is registered in an engine.json
     # in a parent folder
@@ -628,15 +637,30 @@ def get_engine_json_data(engine_name: str = None,
 
 
 def get_project_json_data(project_name: str = None,
-                          project_path: str or pathlib.Path = None) -> dict or None:
+                          project_path: str or pathlib.Path = None,
+                          user: bool = False) -> dict or None:
     if not project_name and not project_path:
         logger.error('Must specify either a Project name or Project Path.')
         return None
 
     if project_name and not project_path:
         project_path = get_registered(project_name=project_name)
+
     if pathlib.Path(project_path).is_file():
         return get_json_data_file(project_path, 'project', validation.valid_o3de_project_json)
+    elif user:
+        # create the project user folder if it doesn't exist
+        user_project_folder = pathlib.Path(project_path) / 'user'
+        user_project_folder.mkdir(parents=True, exist_ok=True)
+
+        user_project_json_path = user_project_folder / 'project.json'
+
+        # return an empty json object if no file exists
+        if not user_project_json_path.exists():
+            return {}
+        else:
+            # skip validation because a user project.json is only for overrides and can be empty
+            return get_json_data('project', user_project_folder, validation.always_valid) or {}
     else:
         return get_json_data('project', project_path, validation.valid_o3de_project_json)
 
@@ -738,6 +762,7 @@ def get_registered(engine_name: str = None,
     # check global first then this engine
     if isinstance(engine_name, str):
         engines = get_manifest_engines()
+        matching_engine_paths = []
         for engine in engines:
             if isinstance(engine, dict):
                 engine_path = pathlib.Path(engine['path']).resolve()
@@ -756,10 +781,14 @@ def get_registered(engine_name: str = None,
                     else:
                         this_engines_name = engine_json_data.get('engine_name','')
                         if this_engines_name == engine_name:
-                            return engine_path
-        engines_path = json_data.get('engines_path', {})
-        if engine_name in engines_path:
-            return pathlib.Path(engines_path[engine_name]).resolve()
+                            matching_engine_paths.append(engine_path)
+        if matching_engine_paths:
+            engine_path = matching_engine_paths[0]
+            if len(matching_engine_paths) > 1:
+                engines = "\n".join(map(str,matching_engine_paths))
+                logger.warning(f"Multiple engines were found that match: '{engine_name}'\n{engines}\nSelecting first engine: '{engine_path}'")
+            return engine_path
+        
 
     elif isinstance(project_name, str):
         projects = get_all_projects()

+ 68 - 31
scripts/o3de/o3de/project_properties.py

@@ -17,9 +17,9 @@ logger = logging.getLogger('o3de.project_properties')
 logging.basicConfig(format=utils.LOG_FORMAT)
 
 
-def get_project_props(name: str = None, path: pathlib.Path = None) -> dict:
-    proj_json = manifest.get_project_json_data(project_name=name, project_path=path)
-    if not proj_json:
+def get_project_props(name: str = None, path: pathlib.Path = None, user: bool = False) -> dict:
+    proj_json = manifest.get_project_json_data(project_name=name, project_path=path, user=user)
+    if not isinstance(proj_json, dict):
         param = name if name else path
         logger.error(f'Could not retrieve project.json file for {param}')
         return None
@@ -86,7 +86,10 @@ def edit_project_props(proj_path: pathlib.Path = None,
                        is_optional_gem: bool = False,
                        new_engine_api_dependencies: list or str = None,
                        delete_engine_api_dependencies: list or str = None,
-                       replace_engine_api_dependencies: list or str = None
+                       replace_engine_api_dependencies: list or str = None,
+                       user: bool = False,
+                       new_engine_path: pathlib.Path = None,
+                       new_engine_finder_cmake_path: pathlib.Path = None,
                        ) -> int:
     """
     Edits and modifies the project properties for the project located at 'proj_path' or with the name 'proj_name'.
@@ -114,12 +117,12 @@ def edit_project_props(proj_path: pathlib.Path = None,
     :param remove_engine_api_dependencies: Version specifiers to remove from 'engine_api_dependencies'
     :param replace_engine_api_dependencies: Version specifiers to replace 'engine_api_dependencies' with
     """
-    proj_json = get_project_props(proj_name, proj_path)
+    proj_json = get_project_props(proj_name, proj_path, user)
 
-    if not proj_json:
+    if not proj_json and not user:
         return 1
     if isinstance(new_name, str):
-        if not utils.validate_identifier(new_name):
+        if (not user or (user and new_name)) and not utils.validate_identifier(new_name):
             logger.error(f'Project name must be fewer than 64 characters, contain only alphanumeric, "_" or "-" characters, and start with a letter.  {new_name}')
             return 1
         proj_json['project_name'] = new_name
@@ -137,6 +140,23 @@ def edit_project_props(proj_path: pathlib.Path = None,
         proj_json['icon_path'] = new_icon
     if isinstance(new_version, str):
         proj_json['version'] = new_version
+    if isinstance(new_engine_path, pathlib.Path):
+        if not user:
+            logger.error('Setting the engine_path in the shared project.json is not allowed to prevent adding local paths.  Run the command again with the --user argument to set the engine_path locally only.')
+            return 1
+        
+        if new_engine_path and new_engine_path.name:
+            # engine_path is absolute or relative to the project folder to simulate overriding the shared project.json 
+            engine_path_absolute = proj_path / new_engine_path
+            engine_manifest_data = manifest.get_engine_json_data(engine_path=new_engine_path.resolve())
+            if not engine_manifest_data:
+                logger.error(f'Cannot load engine.json data at path {new_engine_path} ({engine_path_absolute.resolve()}), please verify an engine exists at the supplied location with a valid engine.json file.')
+                return 1
+
+        # if the path is empty use an empty string because as_posix() will return "."
+        proj_json['engine_path'] = new_engine_path.as_posix() if new_engine_path.name else ""
+    if isinstance(new_engine_finder_cmake_path, pathlib.Path):
+        proj_json['engine_finder_cmake_path'] = new_engine_finder_cmake_path.as_posix() if new_engine_finder_cmake_path.name else ""
 
     if new_tags or delete_tags or replace_tags != None:
         proj_json['user_tags'] = utils.update_values_in_key_list(proj_json.get('user_tags', []), new_tags,
@@ -163,33 +183,44 @@ def edit_project_props(proj_path: pathlib.Path = None,
         proj_json['engine_api_dependencies'] = utils.update_values_in_key_list(proj_json.get('engine_api_dependencies', []), 
                                                 new_engine_api_dependencies, delete_engine_api_dependencies, replace_engine_api_dependencies)
 
-    return 0 if manifest.save_o3de_manifest(proj_json, pathlib.Path(proj_path) / 'project.json') else 1
+    if user:
+        # remove all empty overrides
+        keys = proj_json.copy().keys()
+        for key in keys:
+            if proj_json.get(key,'') == '':
+                del proj_json[key]
+         
+    proj_json_path = pathlib.Path(proj_path) if not user else pathlib.Path(proj_path) / 'user'
+    return 0 if manifest.save_o3de_manifest(proj_json, proj_json_path / 'project.json') else 1
 
 
 def _edit_project_props(args: argparse) -> int:
-    return edit_project_props(args.project_path,
-                              args.project_name,
-                              args.project_new_name,
-                              args.project_id,
-                              args.project_origin,
-                              args.project_display,
-                              args.project_summary,
-                              args.project_icon,
-                              args.add_tags,
-                              args.delete_tags,
-                              args.replace_tags,
-                              args.add_gem_names,
-                              args.delete_gem_names,
-                              args.replace_gem_names,
-                              args.engine_name,
-                              args.add_compatible_engines,
-                              args.delete_compatible_engines,
-                              args.replace_compatible_engines,
-                              args.project_version,
-                              False, # is_optional_gem
-                              args.add_engine_api_dependencies,
-                              args.delete_engine_api_dependencies,
-                              args.replace_engine_api_dependencies
+    return edit_project_props(proj_path=args.project_path,
+                              proj_name=args.project_name,
+                              new_name=args.project_new_name,
+                              new_id=args.project_id,
+                              new_origin=args.project_origin,
+                              new_display=args.project_display,
+                              new_summary=args.project_summary,
+                              new_icon=args.project_icon,
+                              new_tags=args.add_tags,
+                              delete_tags=args.delete_tags,
+                              replace_tags=args.replace_tags,
+                              new_gem_names=args.add_gem_names,
+                              delete_gem_names=args.delete_gem_names,
+                              replace_gem_names=args.replace_gem_names,
+                              new_engine_name=args.engine_name,
+                              new_compatible_engines=args.add_compatible_engines,
+                              delete_compatible_engines=args.delete_compatible_engines,
+                              replace_compatible_engines=args.replace_compatible_engines,
+                              new_version=args.project_version,
+                              is_optional_gem=False,
+                              new_engine_api_dependencies=args.add_engine_api_dependencies,
+                              delete_engine_api_dependencies=args.delete_engine_api_dependencies,
+                              replace_engine_api_dependencies=args.replace_engine_api_dependencies,
+                              user=args.user,
+                              new_engine_path=args.engine_path,
+                              new_engine_finder_cmake_path=args.engine_finder_cmake_path
                               )
 
 
@@ -208,6 +239,10 @@ def add_parser_args(parser):
                        help='Sets the ID for the project.')
     group.add_argument('-en', '--engine-name', type=str, required=False,
                        help='Sets the engine name for the project.')
+    group.add_argument('-efcp', '--engine-finder-cmake-path', type=pathlib.Path, required=False,
+                       help='Sets the path to the engine finder cmake file for this project.')
+    group.add_argument('-ep', '--engine-path', type=pathlib.Path, required=False,
+                       help='Sets the engine path for the project. This setting is only allowed with the --user argument to avoid adding local paths to the shared project.json')
     group.add_argument('-po', '--project-origin', type=str, required=False,
                        help='Sets description or url for project origin (such as project host, repository, owner...etc).')
     group.add_argument('-pd', '--project-display', type=str, required=False,
@@ -216,6 +251,8 @@ def add_parser_args(parser):
                        help='Sets the summary description of the project.')
     group.add_argument('-pi', '--project-icon', type=str, required=False,
                        help='Sets the path to the projects icon resource.')
+    group.add_argument('--user', action='store_true', required=False, default=False,
+                       help='Make changes to the <project>/user/project.json only. This is useful to locally override settings in <project>/project.json which are shared.')
     group = parser.add_mutually_exclusive_group(required=False)
     group.add_argument('-at', '--add-tags', type=str, nargs='*', required=False,
                        help='Adds tag(s) to user_tags property. Space delimited list (ex. -at A B C)')

+ 1 - 56
scripts/o3de/o3de/register.py

@@ -225,54 +225,6 @@ def register_all_repos_in_folder(repos_path: pathlib.Path,
     return register_all_o3de_objects_of_type_in_folder(repos_path, 'repo', remove, force, None, engine_path=engine_path)
 
 
-def remove_engine_name_to_path(json_data: dict,
-                               engine_path: pathlib.Path) -> int:
-    """
-    Remove the engine at the specified path if it exist in the o3de manifest
-    :param json_data in-memory json view of the o3de_manifest.json data
-    :param engine_path path to engine to remove from the manifest data
-
-    returns 0 to indicate no issues has occurred with removal
-    """
-    if engine_path.is_dir() and validation.valid_o3de_engine_json(pathlib.Path(engine_path).resolve() / 'engine.json'):
-        engine_json_data = manifest.get_engine_json_data(engine_path=engine_path)
-        if 'engine_name' in engine_json_data and 'engines_path' in json_data:
-            engine_name = engine_json_data['engine_name']
-            try:
-                del json_data['engines_path'][engine_name]
-            except KeyError:
-                # Attempting to remove a non-existent engine_name is fine
-                pass
-    else:
-        logger.warning(f'Unable to find engine.json file or file is invalid at path {engine_path.as_posix()}')
-
-    return 0
-
-
-def add_engine_name_to_path(json_data: dict, engine_path: pathlib.Path, force: bool):
-    # Add an engine path JSON object which maps the "engine_name" -> "engine_path"
-    engine_json_data = manifest.get_engine_json_data(engine_path=engine_path)
-    if not engine_json_data:
-        logger.error(f'Unable to retrieve json data from engine.json at path {engine_path.as_posix()}')
-        return 1
-    engines_path_json = json_data.setdefault('engines_path', {})
-    if 'engine_name' not in engine_json_data:
-        logger.error(f'engine.json at path {engine_path.as_posix()} is missing "engine_name" key')
-        return 1
-
-    engine_name = engine_json_data['engine_name']
-    if not force and engine_name in engines_path_json and \
-            pathlib.PurePath(engines_path_json[engine_name]) != engine_path:
-        logger.error(
-            f'Attempting to register existing engine "{engine_name}" with a new path of {engine_path.as_posix()}.'
-            f' The current path is {pathlib.Path(engines_path_json[engine_name]).as_posix()}.'
-            f' To force registration of a new engine path, specify the -f/--force option.'
-            f'\nAlternatively the engine can be registered with a different name by changing the "engine_name" field in the engine.json.')
-        return 1
-    engines_path_json[engine_name] = engine_path.as_posix()
-    return 0
-
-
 def register_o3de_object_path(json_data: dict,
                               o3de_object_path: str or pathlib.Path,
                               o3de_object_key: str,
@@ -373,15 +325,8 @@ def register_engine_path(json_data: dict,
     def transform_engine_dict_to_string(engine): return engine.get('path', '') if isinstance(engine, dict) else engine
     json_data['engines'] = list(map(transform_engine_dict_to_string, engine_list))
 
-    result = register_o3de_object_path(json_data, engine_path, 'engines', 'engine.json',
+    return register_o3de_object_path(json_data, engine_path, 'engines', 'engine.json',
                                        validation.valid_o3de_engine_json, remove)
-    if result != 0:
-        return result
-
-    if remove:
-        return remove_engine_name_to_path(json_data, engine_path)
-    else:
-        return add_engine_name_to_path(json_data, engine_path, force)
 
 
 def register_external_subdirectory(json_data: dict,

+ 4 - 0
scripts/o3de/o3de/validation.py

@@ -14,6 +14,10 @@ import uuid
 from o3de import utils
 
 
+def always_valid(json_data: dict) -> bool:
+    return True
+
+
 def valid_o3de_json_dict(json_data: dict, key: str) -> bool:
     return key in json_data
 

+ 9 - 6
scripts/o3de/tests/test_disable_gem.py

@@ -100,10 +100,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "repos": [],
     "engines": [
         "D:/o3de/o3de"
-    ],
-    "engines_path": {
-        "o3de": "D:/o3de/o3de"
-    }
+    ]
 }
 '''
 
@@ -151,7 +148,9 @@ class TestDisableGemCommand:
                 return json.loads(TEST_O3DE_MANIFEST_JSON_PAYLOAD)
             return None
 
-        def get_project_json_data(project_name: str = None, project_path: pathlib.Path = None):
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             return self.disable_gem.project_data
 
         def get_gem_json_data(gem_name: str = None, gem_path: str or pathlib.Path = None,
@@ -178,9 +177,13 @@ class TestDisableGemCommand:
         def get_enabled_gems(enable_gem_cmake_file: pathlib.Path) -> list:
             return project_gem_dependencies
 
+        def is_file(path : pathlib.Path) -> bool:
+            if path.match("*/user/project.json"):
+                return False
+            return True
 
         with patch('pathlib.Path.is_dir', return_value=True) as pathlib_is_dir_patch,\
-                patch('pathlib.Path.is_file', return_value=True) as pathlib_is_file_patch, \
+                patch('pathlib.Path.is_file', new=is_file) as pathlib_is_file_patch, \
                 patch('o3de.manifest.load_o3de_manifest', side_effect=load_o3de_manifest) as load_o3de_manifest_patch, \
                 patch('o3de.manifest.save_o3de_manifest', side_effect=save_o3de_manifest) as save_o3de_manifest_patch,\
                 patch('o3de.manifest.get_engine_json_data', side_effect=get_engine_json_data) as get_engine_json_data_patch,\

+ 5 - 5
scripts/o3de/tests/test_download.py

@@ -29,8 +29,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "templates": [],
     "restricted": [],
     "repos": ["http://o3de.org"],
-    "engines": [],
-    "engines_path": {}
+    "engines": []
 }
 '''
 
@@ -49,8 +48,7 @@ TEST_O3DE_MANIFEST_EXISTING_GEM_JSON_PAYLOAD = '''
     "templates": [],
     "restricted": [],
     "repos": ["http://o3de.org"],
-    "engines": [],
-    "engines_path": {}
+    "engines": []
 }
 '''
 
@@ -300,7 +298,9 @@ class TestObjectDownload:
         def download_callback(downloaded, total_size):
             download_callback_called = True
 
-        def get_project_json_data(project_name: str = None, project_path: pathlib.Path = None):
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             return json.loads(TEST_O3DE_REPO_PROJECT_JSON_PAYLOAD)
 
         def get_gem_json_data(gem_path: pathlib.Path, project_path: pathlib.Path):

+ 19 - 8
scripts/o3de/tests/test_enable_gem.py

@@ -100,10 +100,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "repos": [],
     "engines": [
         "D:/o3de/o3de"
-    ],
-    "engines_path": {
-        "o3de": "D:/o3de/o3de"
-    }
+    ]
 }
 '''
 
@@ -158,7 +155,9 @@ class TestEnableGemCommand:
                                        ) -> dict:
             return {}
 
-        def get_project_json_data(project_name: str = None, project_path: pathlib.Path = None):
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             return self.enable_gem.project_data
 
         def get_gem_json_data(gem_name: str = None, gem_path: str or pathlib.Path = None,
@@ -180,8 +179,13 @@ class TestEnableGemCommand:
         def add_gem_dependency(enable_gem_cmake_file: pathlib.Path, gem_name: str):
             return 0
 
+        def is_file(path : pathlib.Path) -> bool:
+            if path.match("*/user/project.json"):
+                return False
+            return True
+
         with patch('pathlib.Path.is_dir', return_value=True) as pathlib_is_dir_patch,\
-                patch('pathlib.Path.is_file', return_value=True) as pathlib_is_file_patch, \
+                patch('pathlib.Path.is_file', new=is_file) as pathlib_is_file_patch, \
                 patch('o3de.manifest.load_o3de_manifest', side_effect=load_o3de_manifest) as load_o3de_manifest_patch, \
                 patch('o3de.manifest.save_o3de_manifest', side_effect=save_o3de_manifest) as save_o3de_manifest_patch,\
                 patch('o3de.manifest.get_gems_json_data_by_name', side_effect=get_gems_json_data_by_name) as get_gems_json_data_by_name_patch,\
@@ -322,7 +326,9 @@ class TestEnableGemCommand:
                 engine_data['api_versions'] = test_engine_api_versions
             return engine_data
 
-        def get_project_json_data(project_name: str = None, project_path: pathlib.Path = None):
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             project_data = self.enable_gem.project_data
             project_data['engine'] = test_engine_name
             if test_engine_version != None:
@@ -361,8 +367,13 @@ class TestEnableGemCommand:
         def add_gem_dependency(enable_gem_cmake_file: pathlib.Path, gem_name: str):
             return 0
 
+        def is_file(path : pathlib.Path) -> bool:
+            if path.match("*/user/project.json"):
+                return False
+            return True
+
         with patch('pathlib.Path.is_dir', return_value=True) as pathlib_is_dir_patch,\
-                patch('pathlib.Path.is_file', return_value=True) as pathlib_is_file_patch, \
+                patch('pathlib.Path.is_file', new=is_file) as pathlib_is_file_patch, \
                 patch('o3de.manifest.load_o3de_manifest', side_effect=load_o3de_manifest) as load_o3de_manifest_patch, \
                 patch('o3de.manifest.save_o3de_manifest', side_effect=save_o3de_manifest) as save_o3de_manifest_patch,\
                 patch('o3de.manifest.get_registered', side_effect=get_registered_path) as get_registered_patch,\

+ 67 - 24
scripts/o3de/tests/test_manifest.py

@@ -106,10 +106,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "repos": [],
     "engines": [
         "D:/o3de/o3de"
-    ],
-    "engines_path": {
-        "o3de": "D:/o3de/o3de"
-    }
+    ]
 }
 '''
 
@@ -407,7 +404,8 @@ class TestManifestGetRegistered:
                 return gem_payload
 
             def get_project_json_data(project_name: str = None,
-                                    project_path: str or pathlib.Path = None) -> dict or None:
+                                    project_path: str or pathlib.Path = None,
+                                    user: bool = False) -> dict or None:
                 project_payload = json.loads(TEST_PROJECT_JSON_PAYLOAD)
                 return project_payload
 
@@ -438,42 +436,81 @@ class TestManifestProjects:
     def samefile(self, otherFile ):
         return self.as_posix() == otherFile.as_posix()
 
-    @pytest.mark.parametrize("project_path, project_engine_name, engines, expected_engine_path", [
-            pytest.param(pathlib.Path('C:/project1'),
-                'engine1',
-                {'engine1': pathlib.Path('C:/engine1'), 'engine2': pathlib.Path('D:/engine2')},
+    @pytest.mark.parametrize("project_path, project_engine, user_project_json, engines, engines_json_data, \
+                expected_engine_path", [
+            pytest.param(pathlib.Path('C:/project1'), 'engine1', None,
+                [pathlib.Path('C:/engine1'),pathlib.Path('D:/engine2')],
+                [{'engine_name':'engine1'},{'engine_name':'engine2'}],
                 pathlib.Path('C:/engine1')),
-            pytest.param(pathlib.Path('C:/project1'),
-                'engine2',
-                {'engine1': pathlib.Path('C:/engine1'), 'engine2': pathlib.Path('D:/engine2')},
+            pytest.param(pathlib.Path('C:/project1'), 'engine2', {},
+                [pathlib.Path('C:/engine1'),pathlib.Path('D:/engine2')],
+                [{'engine_name':'engine1'},{'engine_name':'engine2'}],
                 pathlib.Path('D:/engine2')),
-            pytest.param(pathlib.Path('C:/engine1/project1'),
-                '',
-                {'engine1': pathlib.Path('C:/engine1'), 'engine2': pathlib.Path('D:/engine2')},
+            pytest.param(pathlib.Path('C:/engine1/project1'), '', {},
+                [pathlib.Path('C:/engine1'),pathlib.Path('D:/engine2')],
+                [{'engine_name':'engine1'},{'engine_name':'engine2'}],
                 pathlib.Path('C:/engine1')),
-            pytest.param(pathlib.Path('C:/project1'),
-                '',
-                {'engine1': pathlib.Path('C:/engine1'), 'engine2': pathlib.Path('D:/engine2')},
+            pytest.param(pathlib.Path('C:/project1'), '', {},
+                [pathlib.Path('C:/engine1'),pathlib.Path('D:/engine2')],
+                [{'engine_name':'engine1'},{'engine_name':'engine2'}],
+                None),
+            # When multiple engines with the same name and version exist, the first is
+            # chosen
+            pytest.param(pathlib.Path('C:/project1'), 'o3de', {},
+                [pathlib.Path('C:/o3de1'),pathlib.Path('D:/o3de2')],
+                [{'engine_name':'o3de','version':'1.0.0'},{'engine_name':'o3de','version':'1.0.0'}],
+                pathlib.Path('C:/o3de1')),
+            # When multiple engines with the same name and version exist, and the user
+            # sets a local engine_path, the engine is chosen based on engine_path
+            pytest.param(pathlib.Path('C:/project1'), 'o3de', {'engine_path':pathlib.Path('D:/o3de2')},
+                [pathlib.Path('C:/o3de1'),pathlib.Path('D:/o3de2')],
+                [{'engine_name':'o3de','version':'1.0.0'},{'engine_name':'o3de','version':'1.0.0'}],
+                pathlib.Path('D:/o3de2')),
+            # When multiple engines with the same name and different versions exist, 
+            # the engine with the highest compatible version is chosen
+            pytest.param(pathlib.Path('C:/project1'), 'o3de', {},
+                [pathlib.Path('C:/o3de1'),pathlib.Path('D:/o3de2')],
+                [{'engine_name':'o3de','version':'1.0.0'},{'engine_name':'o3de','version':'2.0.0'}],
+                pathlib.Path('D:/o3de2')),
+            # When no engines with the same name and compatible versions exists, 
+            # no engine path is returned
+            pytest.param(pathlib.Path('C:/project1'), 'o3de==1.5.0', {},
+                [pathlib.Path('C:/o3de1'),pathlib.Path('D:/o3de2')],
+                [{'engine_name':'o3de','version':'1.0.0'},{'engine_name':'o3de','version':'2.0.0'}],
                 None),
         ]
     )
-    def test_get_project_engines(self, project_path, project_engine_name,
-                                    engines, expected_engine_path):
+    def test_get_project_engines(self, project_path, project_engine, user_project_json,
+                                    engines, engines_json_data, expected_engine_path):
+        def get_engine_json_data(engine_name: str = None,
+                                engine_path: str or pathlib.Path = None) -> dict or None:
+            engine_payload = json.loads(TEST_ENGINE_JSON_PAYLOAD)
+            for i in range(len(engines)):
+                if engines[i] == engine_path:
+                    engine_payload.update(engines_json_data[i])
+            return engine_payload
+
         def get_project_json_data(project_name: str = None,
-            project_path: str or pathlib.Path = None) -> dict or None:
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             project_json = json.loads(TEST_PROJECT_JSON_PAYLOAD)
-            project_json['engine'] = project_engine_name
+            project_json['engine'] = project_engine
             return project_json
 
+        def get_json_data_file(object_json: pathlib.Path,
+                            object_typename: str,
+                            object_validator: callable) -> dict or None:
+            return user_project_json 
+
         def get_registered(engine_name: str):
             return engines[engine_name]
 
         def get_manifest_engines():
-            return list(engines.values())
+            return engines
 
         def find_ancestor_dir_containing_file(target_file_name: pathlib.PurePath, start_path: pathlib.Path,
                                             max_scan_up_range: int=0) -> pathlib.Path or None:
-            for engine_path in engines.values():
+            for engine_path in engines:
                 if engine_path in start_path.parents:
                     return engine_path
             return None
@@ -481,11 +518,17 @@ class TestManifestProjects:
         def get_engine_projects(engine_path:pathlib.Path = None) -> list:
             return [project_path] if engine_path in project_path.parents else []
 
+        def is_file(path : pathlib.Path) -> bool:
+            return True if user_project_json else False
+
         with patch('o3de.manifest.get_project_json_data', side_effect=get_project_json_data) as _1, \
+            patch('o3de.manifest.get_engine_json_data', side_effect=get_engine_json_data) as get_engine_json_data_patch, \
+            patch('o3de.manifest.get_json_data_file', side_effect=get_json_data_file) as get_json_data_file_patch, \
             patch('o3de.manifest.get_registered', side_effect=get_registered) as _2, \
             patch('o3de.manifest.get_manifest_engines', side_effect=get_manifest_engines) as _3, \
             patch('o3de.manifest.get_engine_projects', side_effect=get_engine_projects) as _4, \
             patch('o3de.utils.find_ancestor_dir_containing_file', side_effect=find_ancestor_dir_containing_file) as _5, \
+            patch('pathlib.Path.is_file', new=is_file) as pathlib_is_file_mock,\
             patch('pathlib.Path.resolve', self.resolve) as _6, \
             patch('pathlib.Path.samefile', self.samefile) as _7:
 

+ 4 - 5
scripts/o3de/tests/test_print_registration.py

@@ -136,10 +136,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "repos": [],
     "engines": [
         "D:/o3de/o3de"
-    ],
-    "engines_path": {
-        "o3de": "D:/o3de/o3de"
-    }
+    ]
 }
 '''
 
@@ -153,7 +150,9 @@ class TestPrintRegistration:
         return json.loads(TEST_ENGINE_JSON_PAYLOAD)
 
     @staticmethod
-    def get_project_json_data(project_path: pathlib.Path = None):
+    def get_project_json_data(project_name: str = None,
+                            project_path: str or pathlib.Path = None,
+                            user: bool = False) -> dict or None:
         return json.loads(TEST_PROJECT_JSON_PAYLOAD)
 
     @staticmethod

+ 67 - 20
scripts/o3de/tests/test_project_properties.py

@@ -14,6 +14,17 @@ from unittest.mock import patch
 from o3de import project_properties
 
 
+TEST_ENGINE_JSON_PAYLOAD = '''
+{
+    "engine_name": "o3de",
+    "version": "0.0.0",
+    "external_subdirectories": [
+    ],
+    "projects": [],
+    "templates": []
+}
+'''
+
 TEST_PROJECT_JSON_PAYLOAD = '''
 {
     "project_name": "TestProject",
@@ -50,10 +61,16 @@ def init_project_json_data(request):
     class ProjectJsonData:
         def __init__(self):
             self.data = json.loads(TEST_PROJECT_JSON_PAYLOAD)
+            self.path = None
     request.cls.project_json = ProjectJsonData()
 
 @pytest.mark.usefixtures('init_project_json_data')
 class TestEditProjectProperties:
+
+    @staticmethod
+    def resolve(self):
+        return self
+
     @pytest.mark.parametrize("project_path, project_name, project_new_name, project_id, project_origin,\
                               project_display, project_summary, project_icon, project_version, \
                               add_tags, delete_tags, replace_tags, expected_tags, \
@@ -63,9 +80,9 @@ class TestEditProjectProperties:
                               expected_compatible_engines, \
                               add_engine_api_dependencies, remove_engine_api_dependencies, replace_engine_api_dependencies,\
                               expected_engine_api_dependencies,\
-                              is_optional_gem,\
+                              is_optional_gem, user, engine_path, engine_finder_cmake_path,\
                               expected_result",  [
-        pytest.param(pathlib.PurePath('E:/TestProject'),
+        pytest.param(pathlib.Path('E:/TestProject'),
                     'ProjNameA1', 'ProjNameB', 'ProjID', 'Origin', 
                     'Display', 'Summary', 'Icon', '1.0.0.0', 
                     'A B C', 'B', 'D E F', ['D','E','F'],
@@ -73,9 +90,9 @@ class TestEditProjectProperties:
                     'NewEngineName',
                     'o3de>=1.0', 'o3de-sdk==2205.01', None, ['o3de>=1.0'],
                     ['editor==2.3.4'], None, None, ['framework==1.2.3','editor==2.3.4'],
-                    False,
+                    False, True, pathlib.Path('D:/TestEngine'), pathlib.Path('cmake/CustomEngineFinder.cmake'),
                     0),
-        pytest.param(pathlib.PurePath('D:/TestProject'),
+        pytest.param(pathlib.Path('D:/TestProject'),
                     'ProjNameA2', 'ProjNameB', 'ProjID', 'Origin', 
                     'Display', 'Summary', 'Icon', '1.0.0.0', 
                     'A B C', 'B', 'D E F', ['D','E','F'],
@@ -83,9 +100,9 @@ class TestEditProjectProperties:
                     'o3de-sdk',
                     'c==4.3.2.1', None, 'a>=0.1 b==1.0,==2.0', ['a>=0.1', 'b==1.0,==2.0'],
                     ['launcher==3.4.5'], ['framework==1.2.3'], None, ['launcher==3.4.5'],
-                    False,
+                    False, False, None, None,
                     0),
-        pytest.param(pathlib.PurePath('D:/TestProject'),
+        pytest.param(pathlib.Path('D:/TestProject'),
                     'ProjNameA3', 'ProjNameB', 'ProjID', 'Origin', 
                     'Display', 'Summary', 'Icon', '1.0.0.0', 
                     'A B C', 'B', 'D E F', ['D','E','F'],
@@ -93,19 +110,19 @@ class TestEditProjectProperties:
                     'o3de-install',
                     None, 'o3de-sdk==2205.01', None, [],
                     None, None, ['framework==9.8.7'], ['framework==9.8.7'],
-                    False,
+                    False, False, None, None,
                     0),
-        pytest.param(pathlib.PurePath('D:/TestProject'),
+        pytest.param(pathlib.Path('D:/TestProject'),
                     'ProjNameA4', 'ProjNameB', 'ProjID', 'Origin', 
                     'Display', 'Summary', 'Icon', '1.0.0.0', 
                     'A B C', 'B', 'D E F', ['D','E','F'],
                     None, None, '', [],
-                    'o3de-install',
+                    'o3de-custom==1.0.0',
                     None, None, [], [],
                     None, None, [], [],
-                    False,
+                    False, False, None, None,
                     0),
-        pytest.param(pathlib.PurePath('F:/TestProject'),
+        pytest.param(pathlib.Path('F:/TestProject'),
                     'ProjNameA5', 'ProjNameB', 'ProjID', 'Origin', 
                     'Display', 'Summary', 'Icon', '1.0.0.0', 
                     'A B C', 'B', 'D E F', ['D','E','F'],
@@ -113,7 +130,7 @@ class TestEditProjectProperties:
                     None,
                     None, None, 'invalid', ['b==1.0,==2.0'], # invalid version
                     None, None, None, ['framework==1.2.3'],
-                    False,
+                    False, False, None, None,
                     1),
         pytest.param('', # invalid path
                     'ProjNameA6', 'ProjNameB', 'IDB', 'OriginB', 
@@ -123,19 +140,30 @@ class TestEditProjectProperties:
                     None,
                     None, None, 'o3de-sdk==2205.1', ['o3de-sdk==2205.1'],
                     None, None, None, ['framework==1.2.3'],
-                    False,
+                    False, False, None, None,
                     1),
         # test with an optional gem
-        pytest.param(pathlib.PurePath('D:/TestProject'),
+        pytest.param(pathlib.Path('D:/TestProject'),
                     'ProjNameA4', 'ProjNameB', 'ProjID', 'Origin', 
                     'Display', 'Summary', 'Icon', '1.0.0.0', 
                     'A', None, None, ['A', 'TestProject'],
                     ['GemA'], None, '', [{'name':'GemA','optional':True}],
-                    'o3de-install',
+                    'o3de~=1.2',
                     None, None, [], [],
                     None, None, [], [],
-                    True,
+                    True, True, pathlib.Path(''), None,
                     0),
+        # fails when trying to set engine_path in shared project.json
+        pytest.param(pathlib.Path('D:/TestProject'),
+                    'ProjNameA4', 'ProjNameB', 'ProjID', 'Origin', 
+                    'Display', 'Summary', 'Icon', '1.0.0.0', 
+                    'A', None, None, ['A', 'TestProject'],
+                    ['GemA'], None, '', [{'name':'GemA','optional':True}],
+                    'o3de-install',
+                    None, None, [], [],
+                    None, None, [], [],
+                    False, False, pathlib.Path('D:/TestEngine'), None,
+                    1),
         ]
     )
     def test_edit_project_properties(self, project_path, project_name, project_new_name, project_id, project_origin,
@@ -147,21 +175,30 @@ class TestEditProjectProperties:
                                      expected_compatible_engines,
                                      add_engine_api_dependencies, remove_engine_api_dependencies, 
                                      replace_engine_api_dependencies, expected_engine_api_dependencies,
-                                     is_optional_gem,
+                                     is_optional_gem, user, engine_path, engine_finder_cmake_path,
                                      expected_result):
 
-        def get_project_json_data(project_name: str, project_path) -> dict:
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             if not project_path:
                 self.project_json.data = None
                 return None
             return self.project_json.data
 
+        def get_engine_json_data(engine_name: str = None,
+                                engine_path: str or pathlib.Path = None) -> dict or None:
+            return TEST_ENGINE_JSON_PAYLOAD
+
         def save_o3de_manifest(new_proj_data: dict, project_path) -> bool:
             self.project_json.data = new_proj_data
+            self.project_json.path = project_path
             return True
 
         with patch('o3de.manifest.get_project_json_data', side_effect=get_project_json_data) as get_project_json_data_patch, \
-                patch('o3de.manifest.save_o3de_manifest', side_effect=save_o3de_manifest) as save_o3de_manifest_patch:
+                patch('o3de.manifest.save_o3de_manifest', side_effect=save_o3de_manifest) as save_o3de_manifest_patch, \
+                patch('pathlib.Path.resolve', new=self.resolve) as pathlib_is_resolve_mock,\
+                patch('o3de.manifest.get_engine_json_data', side_effect=get_engine_json_data) as get_engine_json_data_patch:
             result = project_properties.edit_project_props(project_path, project_name, project_new_name, project_id,
                                                            project_origin, project_display, project_summary, project_icon,
                                                            add_tags, delete_tags, replace_tags,
@@ -169,7 +206,8 @@ class TestEditProjectProperties:
                                                            engine_name,
                                                            add_compatible_engines, delete_compatible_engines, replace_compatible_engines,
                                                            project_version, is_optional_gem,
-                                                           add_engine_api_dependencies, remove_engine_api_dependencies, replace_engine_api_dependencies)
+                                                           add_engine_api_dependencies, remove_engine_api_dependencies, replace_engine_api_dependencies,
+                                                           user, engine_path, engine_finder_cmake_path)
             assert result == expected_result
             if result == 0:
                 assert self.project_json.data
@@ -181,6 +219,15 @@ class TestEditProjectProperties:
                 assert self.project_json.data.get('icon_path', '') == project_icon
                 assert self.project_json.data.get('version', '') == project_version
 
+                expected_project_json_path = project_path if not user else project_path / 'user'
+                expected_project_json_path /= 'project.json'
+                assert self.project_json.path.as_posix() == expected_project_json_path.as_posix()
+
+                if engine_path:
+                    engine_path = engine_path.as_posix() if engine_path.name else None
+                assert self.project_json.data.get('engine_path') == (engine_path if user else None)
+                assert self.project_json.data.get('engine_finder_cmake_path') == (engine_finder_cmake_path.as_posix() if engine_finder_cmake_path else None)
+                
                 if engine_name:
                     assert self.project_json.data.get('engine', '') == engine_name
 

+ 16 - 8
scripts/o3de/tests/test_register.py

@@ -22,8 +22,8 @@ string_manifest_data = '{}'
         pytest.param(pathlib.PurePath('D:/o3de/o3de'), "o3de", False, 0),
         # Same engine_name and path should result in valid registration
         pytest.param(pathlib.PurePath('D:/o3de/o3de'), "o3de", False, 0),
-        # Same engine_name and but different path should fail
-        pytest.param(pathlib.PurePath('D:/o3de/engine-path'), "o3de", False, 1),
+        # Same engine_name but different path succeeds 
+        pytest.param(pathlib.PurePath('D:/o3de/engine-path'), "o3de", False, 0),
         # New engine_name should result in valid registration
         pytest.param(pathlib.PurePath('D:/o3de/engine-path'), "o3de-other", False, 0),
         # Same engine_name and but different path with --force should result in valid registration
@@ -76,9 +76,14 @@ def init_manifest_data(request):
 class TestRegisterThisEngine:
     @pytest.mark.parametrize(
         "engine_path, engine_name, force, expected_result", [
+            # registering a new engine succeeds
             pytest.param(pathlib.PurePath('D:/o3de/o3de'), "o3de", False, 0),
-            pytest.param(pathlib.PurePath('F:/Open3DEngine'), "o3de", False, 1),
-            pytest.param(pathlib.PurePath('F:/Open3DEngine'), "o3de", True, 0)
+            # registering an engine with the same name at a new location succeeds
+            pytest.param(pathlib.PurePath('F:/Open3DEngine'), "o3de", False, 0),
+            # re-registering an engine with the same name at the same location succeeds
+            pytest.param(pathlib.PurePath('F:/Open3DEngine'), "o3de", False, 0),
+            # forcing re-registering an engine with the same name at the same location succeeds
+            pytest.param(pathlib.PurePath('F:/Open3DEngine'), "o3de", True, 0),
         ]
     )
     def test_register_this_engine(self, engine_path, engine_name, force, expected_result):
@@ -167,8 +172,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "templates": [],
     "restricted": [],
     "repos": [],
-    "engines": [],
-    "engines_path": {}
+    "engines": []
 }
 '''
 @pytest.fixture(scope='function')
@@ -217,7 +221,9 @@ class TestRegisterGem:
         def get_engine_json_data(engine_name:str = None, engine_path: pathlib.Path = None):
             return json.loads(TEST_ENGINE_JSON_PAYLOAD)
 
-        def get_project_json_data(project_path: pathlib.Path = None):
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             return json.loads(TEST_PROJECT_JSON_PAYLOAD)
 
         def find_ancestor_dir(target_file_name: pathlib.PurePath, start_path: pathlib.Path):
@@ -373,7 +379,9 @@ class TestRegisterProject:
 
             return engine_json_data
 
-        def get_project_json_data(project_path: pathlib.Path = None):
+        def get_project_json_data(project_name: str = None,
+                                project_path: str or pathlib.Path = None,
+                                user: bool = False) -> dict or None:
             project_json_data = json.loads(TEST_PROJECT_JSON_PAYLOAD)
 
             # we want to allow for testing the case where these fields 

+ 1 - 2
scripts/o3de/tests/test_repo.py

@@ -30,8 +30,7 @@ TEST_O3DE_MANIFEST_JSON_PAYLOAD = '''
     "templates": [],
     "restricted": [],
     "repos": [],
-    "engines": [],
-    "engines_path": {}
+    "engines": []
 }
 '''