Adding devex to bure

Sun 10 March 2024

bure

About twelve years ago, as a side project, I started working on a client-server game. It was inspired in games like Tibia and Habbo Hotel. The idea was you could log in the game with your own account (which you could create in an accompanying website), then create a character and chat with other people. It never wen't far, but I learnt a lot.

Five years later I decided to work a bit more on it, but I thought at the time that I had to reduce scope if I wanted to get something achievable, so I removed all of the networking stack and kept it as a single-player game, instead. In that iteration I refactored much of the code and introduced creatures, pathfinding and procedural maps.

I (naturally) never finished it anyway, but I was thinking now that it could be cool to write in retrospective about what I did, what I think were good or bad decisions and maybe try to add new features on top.

For a starting post in this series, let's focus on developer experience, so that future improvements are (hopefully) easier to make. We'll go over how to compile and run the project, fix a few issues along the way, and introduce a library to get nicer errors when something inevitably crashes.

The source code in this github repo. The whole engine and "game" (if it can even called one at this point) is written in C++ using SDL.

Compiling the project

The first step to try to get this running would be to compile the project, but unfortunately there are no documented dependencies anywhere. We can get those from the cmake config (CMakeLists.txt), though:

find_package(SDL2 REQUIRED)
find_package(SDL2_image REQUIRED)
find_package(SDL2_ttf REQUIRED)
find_package(LibXMLPP REQUIRED)
find_package(Glib REQUIRED)

There are also a bunch of cmake files in the cmake folder, which IIRC are used to find those libs in the system.

[crodriguez@fedora bure]$ ls -la cmake
total 80
drwxr-xr-x. 1 crodriguez crodriguez   442 mar  3 00:06 .
drwxr-xr-x. 1 crodriguez crodriguez   754 mar  3 00:38 ..
-rw-r--r--. 1 crodriguez crodriguez  5932 mar  3 00:06 CMakeParseArguments.cmake
-rw-r--r--. 1 crodriguez crodriguez  5512 mar  3 00:06 FindGlib.cmake
-rw-r--r--. 1 crodriguez crodriguez   934 mar  3 00:06 FindGlibmm.cmake
-rw-r--r--. 1 crodriguez crodriguez  1429 mar  3 00:06 FindLibXMLPP.cmake
-rw-r--r--. 1 crodriguez crodriguez 14836 mar  3 00:06 FindPackageHandleStandardArgs.cmake
-rw-r--r--. 1 crodriguez crodriguez  2029 mar  3 00:06 FindPackageMessage.cmake
-rw-r--r--. 1 crodriguez crodriguez  7705 mar  3 00:06 FindSDL2.cmake
-rw-r--r--. 1 crodriguez crodriguez  3795 mar  3 00:06 FindSDL2_image.cmake
-rw-r--r--. 1 crodriguez crodriguez  3241 mar  3 00:06 FindSDL2_net.cmake
-rw-r--r--. 1 crodriguez crodriguez  6651 mar  3 00:06 FindSDL2_ttf.cmake
-rw-r--r--. 1 crodriguez crodriguez 10713 mar  3 00:06 LibFindMacros.cmake

So let's install some of them (I'm using fedora 39 right now)

sudo dnf install SDL2-devel SDL2_image-devel SDL2_ttf-devel libxml2-devel glib-devel glibmm2.4-devel libxml++30-devel cmake

There was a really short script to build the whole thing:

[crodriguez@fedora bure]$ cat build.sh
#!/bin/bash

pushd build
cmake ..
make
popd

When doing so, we get a few warnings. Let's try to fix them:

CMake Deprecation Warning at engine/test/lib/googletest-1.8.0/googletest/CMakeLists.txt:48 (cmake_minimum_required):
  Compatibility with CMake < 3.5 will be removed from a future version of
  CMake.

  Update the VERSION argument <min> value or use a ...<max> suffix to tell
  CMake that the project does not need compatibility with older versions.

CMake Warning (dev) at engine/test/lib/googletest-1.8.0/googletest/cmake/internal_utils.cmake:209 (find_package):
  Policy CMP0148 is not set: The FindPythonInterp and FindPythonLibs modules
  are removed.  Run "cmake --help-policy CMP0148" for policy details.  Use
  the cmake_policy command to set the policy and suppress this warning.

Call Stack (most recent call first):
  engine/test/lib/googletest-1.8.0/googletest/CMakeLists.txt:60 (include)
This warning is for project developers.  Use -Wno-dev to suppress it.

They seem to be coming from googletest. I vendor'd this lib in engine/test/lib. It seems I'm using 1.8 while the lib is now in 1.14, but I'm going to give this version a try. I don't have many tests anyway, so I could look into upgrading later. These warns seem easy to fix.

There is only a couple of changes we need to do to the CMakeLists file in googletest. The first one is the min supported version of cmake listed in the file is 2.6.2, while we can change that to be at least 3.6 I'm currently using 3.27 anyway.

For the second one, you just run that command to get more info about it:

CMP0148
-------

.. versionadded:: 3.27

The ``FindPythonInterp`` and ``FindPythonLibs`` modules are removed.

These modules have been deprecated since CMake 3.12.
CMake 3.27 and above prefer to not provide the modules.
This policy provides compatibility for projects that have not been
ported away from them.

Projects using the ``FindPythonInterp`` and/or ``FindPythonLibs``
modules should be updated to use one of their replacements:

* ``FindPython3``
* ``FindPython2``
* ``FindPython``

The ``OLD`` behavior of this policy is for ``find_package(PythonInterp)``
and ``find_package(PythonLibs)`` to load the deprecated modules.  The ``NEW``
behavior is for uses of the modules to fail as if they do not exist.

This policy was introduced in CMake version 3.27.  CMake version
3.27.7 warns when the policy is not set and uses ``OLD`` behavior.
Use the ``cmake_policy()`` command to set it to ``OLD`` or ``NEW``
explicitly.

.. note::
  The ``OLD`` behavior of a policy is
  ``deprecated by definition``
  and may be removed in a future version of CMake.

I spent like 10 minutes using grep and reading cmake files just to realize the file is indeed mentioned in the error message (do read error messages completely folks).

The fix is simply changing this:

find_package(PythonInterp)

to:

find_package(Python)

Now we can run cmake and build without warnings.

We run the binary, and... it surprisingly works! That wasn't that hard after all.

bure

But... after a bit, the program crashed with this error:

[2024-03-03 00:46:50] [ERROR] max iterations reached in a_star()

Stacktraces

I was getting a fair amount of fatal errors, and I remember trying to add a library called backward to help debug those situations. backward prints stack traces whenever there's a fatal error, but I could not made it work at the time. Let's give that another try now.

First, we'll make a reproducible segmentation fault. That should be easy:

...

  // initialize drawing system
  auto ds = std::make_unique<bure::systems::drawing_system>(std::move(gr));
  this->addSystem(std::move(ds));

  // Reproduce segmentation fault
  int* dir = 0x0;
  *dir = 10;

  // add ui renderer with manager
  _uiManager = std::make_shared<bure::ui::ui_manager>();
  auto uiRenderer = std::make_unique<bure::ui::ui_renderer>(_uiManager);

...

We compile and run:

[crodriguez@fedora bure]$ ./build.sh
~/dev/bure/build ~/dev/bure
-- Found Glibmm  : /usr/lib64/libglibmm-2.4.so
-- Configuring done (0.1s)
-- Generating done (0.0s)
-- Build files have been written to: /home/crodriguez/dev/bure/build
[ 17%] Built target bure-ui
[ 19%] Building CXX object CMakeFiles/bure-engine.dir/engine/engine.cpp.o
[ 21%] Linking CXX static library libbure-engine.a
[ 61%] Built target bure-engine
[ 84%] Built target client-lib
[ 86%] Linking CXX executable client
[ 88%] Built target client
[ 92%] Built target gtest
[ 96%] Built target gtest_main
[ 98%] Linking CXX executable client-test
[100%] Built target client-test
~/dev/bure
[crodriguez@fedora bure]$ ./build/client
Segmentation fault (core dumped)

Alright, so now let's figure out how to add backward so we can get nice stack traces.

Originally I tried to simply drop backward.(cpp/h) files in the source code, add it to my cmake targets and call it a day.

Today I'm going to try a different approach. I'll vendor the lib in a vendor folder (which will be the first step towards vendoring all libs anyway) and then use the CMakeFiles from the original lib to load it.

[crodriguez@fedora bure]$ mkdir vendor
[crodriguez@fedora bure]$ cd vendor
[crodriguez@fedora vendor]$ git clone [email protected]:bombela/backward-cpp.git

Then I add it in CMakeLists in my main client link stage. I add both the Interface target so the lib is available, and the Object target so there are triggers to print the trace whenever there is a fatal error.

add_subdirectory(./vendor/backward-cpp)

...

target_link_libraries(client
  client-lib
  bure-engine
  bure-ui
  ${SDL2_LIBRARIES}
  ${SDL2_IMAGE_LIBRARIES}
  ${SDL2TTF_LIBRARY}
  ${GLIB_LIBRARY}
  ${LibXMLPP_LIBRARIES}
  Backward::Interface   # <-- These two
  Backward::Object)

We compile and run and:

Stack trace (most recent call last):
#5    Object "[0xffffffffffffffff]", at 0xffffffffffffffff, in
#4    Object "./build/client", at 0x405be4, in
#3    Object "/lib64/libc.so.6", at 0x7f996a76520a, in __libc_start_main
#2    Object "/lib64/libc.so.6", at 0x7f996a765149, in
#1    Object "./build/client", at 0x405e0b, in
#0    Object "./build/client", at 0x4312e8, in
Segmentation fault (Address not mapped to object [(nil)])
Segmentation fault (core dumped)

Progress! In the main README for backward it mentions you can install binutils-devel for the ultimate stack trace. I installed that, then ran it again, and:

Stack trace (most recent call last):
#5    Object "", at 0xffffffffffffffff, in
#4    Object "/home/crodriguez/dev/bure/build/client", at 0x4071c4, in _start
#3    Object "/lib64/libc.so.6", at 0x7fb20ba4620a, in __libc_start_main
#2    Object "/lib64/libc.so.6", at 0x7fb20ba46149, in __libc_start_call_main
#1    Source "/home/crodriguez/dev/bure/main.cpp", line 26, in main [0x4073eb]
         24:   // Init main game object
         25:   auto game = std::make_unique<example_game>();
      >  26:   bure::engine::get().init(std::move(game), resourcesPath, SCREEN_WIDTH,
         27:                            SCREEN_HEIGHT);
         28:
         29:   // Main game loop
#0    Source "/home/crodriguez/dev/bure/engine/engine.cpp", line 28, in bure::engine::init(std::unique_ptr<bure::game, std::default_delete<bure::game> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, int, int) [0x435e72]
         26:   // Reproduce segmentation fault
         27:   int* dir = 0x0;
      >  28:   *dir = 10;
         29:
         30:   // add ui renderer with manager
         31:   _uiManager = std::make_shared<bure::ui::ui_manager>();
Segmentation fault (Address not mapped to object [(nil)])
Segmentation fault (core dumped)

Nice!

Let's try now a different sort of bug. Before, I did get an issue with the A* algorithm failing saying "too many steps". I wonder if that could create a stack trace now.

I changed the code to allow for only 10 iterations in the A* algorithm (before, it was 10K). Now, when we run the binary, we instantly get:

[crodriguez@fedora bure]$ ./build/client
[2024-03-03 10:42:15] [ERROR] max iterations reached in a_star()

No stacktrace, though.

That makes sense when you look at the code:

    if (iter++ > MAX_ITERATIONS) {
      bure::logger::error("max iterations reached in a_star()");
      exit(1);
    }

There are no fatal errors here. We simply log a message in stderr and exit with status code 1. If we want to print a stack trace in these sort of situations, we would need to figure out how backward gets triggered on error.

The default backward trigger is a signal (code here), so that's not going to work for us. We want to trigger stack traces on demand. Instead, we are going to define a new fatal function in the logging lib to print the log message, print a stacktrace and exit.

We are going to be using the backward lib, so we need to add backward to the include libs:

include_directories(${SDL2_INCLUDE_DIRS}
        ${SDL2_IMAGE_INCLUDE_DIRS}
        ${SDL2_TTF_INCLUDE_DIRS}
        ${GLIB_INCLUDE_DIRS}
        ${LibXMLPP_INCLUDE_DIRS}
        ${BACKWARD_INCLUDE_DIRS}
        ${gtest_SOURCE_DIR}/include
        ${gtest_SOURCE_DIR}
        engine)

We'll transform the call to something like this:

    if (iter++ > MAX_ITERATIONS) {
      bure::logger::fatal("max iterations reached in a_star()");
    }

Then define the function:

// logger.cpp

#include "backward.hpp"

using namespace backward;

[...]

void logger::fatal(std::string format...) {
  va_list args;
  va_start(args, format);
  log(level::fatal, format, args);
  va_end(args);

  Printer p;
  StackTrace st;
  st.load_here(32);
  st.skip_n_firsts(3);
  p.print(st);
  exit(1);
}

Now we get these nice stacktraces:

[2024-03-04 22:37:58] [FATAL] max iterations reached in a_star()
Stack trace (most recent call last):
#7    Object "", at 0xffffffffffffffff, in
#6    Object "/home/crodriguez/dev/bure/build/client", at 0x4071e4, in _start
#5    Object "/lib64/libc.so.6", at 0x7faa55c2220a, in __libc_start_main
#4    Object "/lib64/libc.so.6", at 0x7faa55c22149, in __libc_start_call_main
#3    Source "/home/crodriguez/dev/bure/main.cpp", line 32, in main
         29:   // Main game loop
         30:   while (!bure::engine::get().getGame()->isFinished()) {
         31:     bure::event_manager::get().pollEvent();
      >  32:     bure::engine::get().update();
         33:     SDL_Delay(SLEEP_BETWEEN_FRAMES_MS);
         34:   }
#2    Source "/home/crodriguez/dev/bure/engine/engine.cpp", line 74, in bure::engine::update()
         72:   for (auto&& e : getEntities()) {
         73:     if (_entitiesToRemove.find(&e.get()) == _entitiesToRemove.end()) {
      >  74:       e.get().update();
         75:     }
         76:   }
#1    Source "/home/crodriguez/dev/bure/entities/enemy_entity.cpp", line 60, in enemy_entity::update()
         57:           }
         58:
         59:           return result;
      >  60:         });
         61:
         62:     if (!path.empty()) {
         63:       auto next_pos = path.front();
#0    Source "/home/crodriguez/dev/bure/engine/utils/pathfinding.cpp", line 31, in bure::pathfinding::a_star(bure::map_coords, bure::map_coords, std::function<std::unordered_set<bure::map_coords, std::hash<bure::map_coords>, std::equal_to<bure::map_coords>, std::allocator<bure::map_coords> > (bure::map_coords, bure::map_coords)>)
         29:   while (!openSet.empty()) {
         30:     if (iter++ > MAX_ITERATIONS) {
      >  31:       bure::logger::fatal("max iterations reached in a_star()");
         32:     }
         33:
         34:     auto current = lowest_fscore(fScore, openSet);

That should be helpful in the future!

links

social