AGS, Steam, Mac, and Dynamic Libraries

15th October 2024 | Games

This article is a follow up to the previous post, and will be far more technical, but it is not required reading for configuring an Adventure Game Studio (AGS) game to work with Steam. This will detail the processes I used to figure out how to get third party libraries to work together with an AGS game, Steam, and the Mac.

When I was first trying to figure out how to get Steam achievements to work in a Mac game, it required a lot of trial and error, with many failures along the way. There seemed to be no concrete details on the proper way to set up the game. Windows versions of AGS games just dump all of the extra files in the same folder as the executable, but a Mac app bundle is structured in a different manner. I tried a lot of experiments in where to place the dynamic libraries and the steam_appid.txt file, but to no avail. Looking at other games, I've seen files like libsteam_api.dylib located in various locations, or several variants of the agsteam library (libagsteam.dylib, libagsteam-unified.dylib, libagsteam-disjoint.dylib).

In my more recent attempts to get Steam achievements to work, I was developing and testing on a 2021 MacBook Pro with an M1 Pro processor. Despite following what seemed to be functional steps to configure the game properly, I wasn't seeing any achievements trigger. It wasn't until I tested on an older Intel-based Mac did I finally see some achievements.

Interesting...

This eventually led me to determine why some people had been able to get things to work, but I was not also seeing similar positive results. It required the game to be code signed on an older Intel-based Mac to be at least somewhat functional. This showed some promise, but it wasn't good enough. As of 2024, macOS still supports older programs built for Intel processors, but this will certainly not last forever. I've already been through the PPC to Intel migration, so it will likely not be much longer before Apple officially drops Intel support and removes Rosetta 2. Keeping this in mind, it was my goal to make sure that AGS games and associated libraries were ready for the next generation of Apple-supported processors. This required having Universal Binary versions of the AGS shell and popular third party libraries. The first task has already been accomplished, but ensuring that the libraries had been updated was going to be trickier.

I found inconsistencies in other AGS games in what files were needed and where they needed to be placed within the app bundle. In the end, I only needed three files to get Steam achievements to work, all which are placed in the bundle's Contents/Resources folder:

libsteam_api.dylib

The libsteam_api.dylib file is the dynamic library provided by Steam. This file can be found in other Mac games, regardless of the game engine, so the game can communicate with the Steam client. Download the Steamworks SDK from the Steamworks API Overview page. Uncompress the zip file, and libsteam_api.dylib is found in the Steamworks/sdk/redistributable_bin/osx folder. I used version 1.59 from February 2024.

steam_appid.txt

This is a very small text file (just a couple of bytes) which contains only the application ID of the game. According to the Heathen KB site, steam_appid.txt is not required for production versions of the game, but it is useful for cases such as dev testing. Still, I prefer to include this file "just in case".

libagsteam-unified.dylib

For AGS games to communicate with the Steam client, it requires integrating with the agsteam and ags2client plug-ins. Looking through other games, I've seen variants of the agsteam library, such as libagsteam.dylib, libagsteam-disjoint.dylib, and libagsteam-unified.dylib. According to agsteam's documentation, the unified build is the default and should work better if one wants to use AGS2Client to work with either Steam or GoG achievements. All of the games I found which used this library had an older Intel-only version. To get this to work properly on modern Macs, I would need to rebuild the library as a Universal Binary.

Building a Universal Binary libagsteam-unified.dylib

This dynamic library is the lynchpin to get Steam achievements to work with AGS games on the Mac. However, the last set of agsteam releases was back in 2018, several years before Apple Silicon was even announced, so the Mac library would have only been built for Intel processors.

The source project has a Makefile, so I decided to try and rebuild the library using make. Except...it didn't work. I tried to fix and modify the Makefile, and even purchased Managing Projects with GNU Make to improve my Make-fu, but to no avail. After banging my head against the wall for a little too long, I decided to write a bash script which followed the direction of the Makefile. The following version of the script creates a Universal Binary build of libagsteam-unified.dylib, which links against the Steamworks SDK (version 1.59).

#!/bin/bash

# File: build_agsteam.sh
# Description: Build script to build the libagsteam-unified.dylib as a Universal Binary.  Replacement for this
# project's Makefile.  
# Author: Chad Armstrong
# Date: 14-17 September 2024

# Define some paths...
PATH_SRC=.
PATH_AGS2CLIENT=$PATH_SRC/ags2client
# Path to the Steamworks SDK folder.  This build pointed to Steamworks build 159
PATH_STEAMWORKS=$PATH_SRC/../steamworks/sdk
PATH_STEAMWORKS_INC=$PATH_STEAMWORKS/public
PATH_STEAMWORKS_LIB=$PATH_STEAMWORKS/redistributable_bin
PATH_BUILD=$PATH_SRC/Solutions/build
SRCS="ags2client/IAGS2Client.cpp ags2client/IClientAchievements.cpp ags2client/IClientLeaderboards.cpp  ags2client/IClientStats.cpp ags2client/main.cpp AGS2Client.cpp AGSteamPlugin.cpp SteamAchievements.cpp SteamLeaderboards.cpp SteamStats.cpp"

# .o object files for ags2client end up in a separate directory
# https://linuxsimply.com/bash-scripting-tutorial/string/manipulation/string-replace/
OBJS="${SRCS//.cpp/.o}"
CXXFLAGS="-g -Wall -std=c++11 -O2 -fPIC -I$PATH_STEAMWORKS_INC"
CXX=g++ # g++ is needed to compile this project, clang throws errors

PATH_OSX_BUILD="$PATH_BUILD/osx" # platform_build_path
PATH_OSX_OBJ="$PATH_OSX_BUILD/obj" # platform_obj_path

# Get object file path names (e.g., ./Solutions/build/osx/obj/ags2client/IAGS2Client.o) for all object files
OSX_OBJ_FILE_PATHS="" # ${OBJS//PATH_OSX_OBJ/ /}" # obj_file_paths

# This may not be the most elegant way to do this, but it works to construct the list where each 
# of the object files is stored
for obj in ${OBJS}; do
	OSX_OBJ_FILE_PATHS+="$PATH_OSX_OBJ/$obj "
done

# OS X
OSX_CXX_FLAGS="-DMAC_VERSION"
OSX_STEAMWORKS_DIR=osx
OSX_LIB_FLAGS="-dynamiclib -o $PATH_OSX_BUILD/libagsteam-unified.dylib"

# Create a directory at ./Solutions/build/osx/obj/ags2client
mkdir -p "$PATH_OSX_OBJ/ags2client"

# Create an array of the source files, based from the SRCS string
SRCS_ARRAY=($SRCS)

# Generate the object (.o) files
# This works in bash, but not zsh
for filename in $SRCS; do
	# Swap the .cpp for a .o file extension
	OBJ_FILENAME="${filename//.cpp/.o}"
	# Example: g++ -g -Wall -std=c++11 -O2 -fPIC -I./../steamworks/sdk/public -DAGS2CLIENT_UNIFIED_CLIENT_NAME -DMAC_VERSION -c SteamStats.cpp -o ./Solutions/build/osx/obj/SteamStats.o
	$CXX -arch x86_64 -arch arm64 $CXXFLAGS -DAGS2CLIENT_UNIFIED_CLIENT_NAME $OSX_CXX_FLAGS -c $filename -o "$PATH_OSX_OBJ/$OBJ_FILENAME"
done

# Create the unified build, link up the object files created in the previous step
# Example: # g++  -v -arch x86_64 -arch arm64 -L./../steamworks/sdk/redistributable_bin/osx -lsteam_api -dynamiclib -o ./Solutions/build/osx/libagsteam.dylib ./Solutions/build/osx/obj/ags2client/IAGS2Client.o ./Solutions/build/osx/obj/ags2client/IClientAchievements.o ./Solutions/build/osx/obj/ags2client/IClientLeaderboards.o ./Solutions/build/osx/obj/ags2client/IClientStats.o ./Solutions/build/osx/obj/ags2client/main.o ./Solutions/build/osx/obj/AGS2Client.o ./Solutions/build/osx/obj/AGSteamPlugin.o ./Solutions/build/osx/obj/SteamAchievements.o ./Solutions/build/osx/obj/SteamLeaderboards.o ./Solutions/build/osx/obj/SteamStats.o 
$CXX -v -arch x86_64 -arch arm64 -L$PATH_STEAMWORKS_LIB/$OSX_STEAMWORKS_DIR -lsteam_api $OSX_LIB_FLAGS $OSX_OBJ_FILE_PATHS

Troubleshooting

Initially trying to implement Steam achievements for the Mac was a classic case of black box testing. I'd try something, but nothing would happen. I'd try something else. Still nothing. After enough random experimentation, I would make some tentative progress. Slowly I'd take additional steps to reach the ultimate goal, but not without needing additional tools and methods to inspect what did and did not work along the way.

A classic method of debugging is via print statements, or in this case, inspecting what is being output to either the console or log files. It can be useful to launch the game via the Terminal (./MyGame.app/Contents/MacOS/AGS) and look at the console log to see if there are any hints why Steam achievements may not be working, or if there is a crash. In my testing, I saw cases where a third party library (.dylib) would not load properly because it was reliant upon another library, was built for another architecture (x86 versus arm64), or was built for too new of a system and didn't work on an older OS.

In my early attempts, I would see a vague line like this:

# Plugin 'agsteam-unified' could not be loaded (expected 'libagsteam-unified.dylib'), trying built-in plugins...

After rebuilding AGS a few times and adding more debugging lines, I started to figure out more of what was happening. Several other developers I conferred with were both using Intel-based Macs to build their apps, and for whatever reason, those don't have issues with getting the Steam achievements to work. I started tracking down in files like agsplugin.cpp and library_posix.h why the libagsteam-unified.dylib wasn't being properly loaded. For this particular case, it looked like since that dylib was Intel/x86-only, that was causing the dlopen command to freak out and fail on the Apple Silicon Mac.

Command line tools like file or lipo can be used to check which architectures an executable or library supports. Examples of using the two utilities:

% file libagsteam-old.dylib
libagsteam-old.dylib: Mach-O 64-bit dynamically linked shared library x86_64

% lipo -archs libagsteam-old.dylib
x86_64

% file libagsteam-unified.dylib
libagsteam-unified.dylib: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit dynamically linked shared library x86_64] [arm64:Mach-O 64-bit dynamically linked shared library arm64]
libagsteam-unified.dylib (for architecture x86_64):	Mach-O 64-bit dynamically linked shared library x86_64
libagsteam-unified.dylib (for architecture arm64):	Mach-O 64-bit dynamically linked shared library arm64

% lipo -archs libagsteam-unified.dylib
x86_64 arm64

In trying to figure out how AGS communicated with the various libraries, I used otool to inspect the executable and libraries to see how things were connected. Some apps did link the game's executable (Contents/MacOS/AGS) to libsteam_api.dylib, but in most cases it did not, which made things more confusing why there was an inconsistency in how these games were being constructed.

In this example, otool is used to inspect the libagsteam-unified.dylib library, which reveals that the Intel and Apple Silicon architectures are linked to Steam's libsteam_api.dylib library. Other tools like MacDependency and Apparency are also useful in inspecting dependencies, code signing, and entitlements of an applications.

% otool -L libagsteam-unified.dylib

libagsteam-unified.dylib (architecture x86_64):
	./Solutions/build/osx/libagsteam.dylib (compatibility version 0.0.0, current version 0.0.0)
	@loader_path/libsteam_api.dylib (compatibility version 1.0.0, current version 1.0.0)
	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1600.157.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.61.1)
libagsteam-unified.dylib (architecture arm64):
	./Solutions/build/osx/libagsteam.dylib (compatibility version 0.0.0, current version 0.0.0)
	@loader_path/libsteam_api.dylib (compatibility version 1.0.0, current version 1.0.0)
	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1600.157.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.61.1)

Issues, Errors, and Crashes

I encountered three primary issues when trying to get Steam achievements to work on the Mac with various testing attempts. A quick summary of the issues, followed by more in-depth explanations.

  1. The Old: The original third-party library libagsteam-unified.dylib was relatively old, dating back to ~2018, two years before Apple Silicon processors were available. I was able to get the achievements to run on Intel-based Macs, but they did not work on Apple Silicon Macs. There was a warning in the console about how there was an incompatible architecture (have 'x86_64', need 'arm64')
  2. Old and New: The first version of libagsteam.dylib I created was only built for Apple Silicon, so I combined it with the original Intel version using lipo to create a makeshift Universal Binary. This caused a crash on Intel because the old Intel libagsteam.dylib was linked to an older version of libsteam_api.dylib, and so it did not work properly with the updated version of Steam's library.
  3. The New: I then created a Universal binary build (built under macOS Ventura 13.6.7), but it had an issue when running on Intel and complained that the library was too new. I then had to rebuild under macOS Big Sur 11.7.

The Old

After I saw achievements work on Intel Macs, I needed to investigate why things were not working properly on Apple Silicon Macs. I launched the app from the Terminal (./MyGame.app/Contents/MacOS/AGS) so I could inspect any errors or warnings coming from AGS. When using the original libagsteam.dylib in a game, I received this error when trying to launch the game on Apple Silicon:

dlopen error: dlopen(/path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib, 0x0001): tried: '/path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib' (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64')), '/System/Volumes/Preboot/Cryptexes/OS/path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib' (no such file), '/path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib' (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64'))

Fortunately, this error was not overly cryptic and made sense for what needed to be done to get the library to work. Since the rest of the application was built as a Universal Binary (including the newer libsteam_api.dylib), I suspect that Rosetta 2 would not try and translate the single library, instead it threw an error, and silently failed. This spurred the necessity to rebuild the libagsteam-unified.dylib.

Old and New

When I first rebuilt the libagsteam library, I did not include the -arch x86_64 option during compilation, so the binary was built only for Apple Silicon. I then decided to use lipo to combine this new version with the original library, so it would create a fat binary with both architecture types.

To create a fat binary with lipo follow this pattern:

lipo -create -output universal_app x86_app arm_app

My example to combine the new and old libagsteam libraries:

lipo -create -output libagsteam-unified.dylib libagsteam-x86.dylib libagsteam-arm64.dylib

libagsteam-x86.dylib is the original libagsteam-unified.dylib I've found in other AGS games, which I simply renamed for this process to avoid confusion. libagsteam-arm64.dylib is the Apple Silicon version of the library I compiled. libagsteam-unified.dylib is the resultant file after combining the two libraries.

Note: If one really wants to do so, they can include other architecture types, such as PowerPC (PPC) builds, but there are very few Mac apps out there which support PowerPC, Intel, and Apple Silicon all in a single app.

With this build, I placed the libsteam_api.dylib, libagsteam-unified.dylib, and steam_appid.txt files into the game's Contents/Resources folder, launched the Steam client, and tested some achievements on an Apple Silicon Mac. With great joy, I finally saw achievements getting triggered! But to do a full regression, I also needed to test on an Intel Mac. The Intel version of libagsteam-unified.dylib worked before, so everything should work, right?

Right?!

One heartbreaking crash later when testing on an Intel iMac...

The error I encountered when launching the game from the Terminal:


# libname 'agsteam-unified' | libfile 'libagsteam-unified.dylib'
# Try library path: libagsteam-unified.dylib
# Plugin 'agsteam-unified' loaded from 'libagsteam-unified.dylib', resolving imports...
# dyld: lazy symbol binding failed: Symbol not found: _SteamAPI_Init
#   Referenced from: libagsteam-unified.dylib
#   Expected in: libsteam_api.dylib
# 
# dyld: Symbol not found: _SteamAPI_Init
#   Referenced from: libagsteam-unified.dylib
#   Expected in: libsteam_api.dylib
# 
# zsh: abort      ./MyGame.app/Contents/MacOS/AGS

And the crash log when trying to run the app after it was code signed on an M1 MBP:

# 
# Process:               AGS [1126]
# Path:                  /Users/USER/*/MyGame.app/Contents/MacOS/AGS
# Identifier:            com.companyname.mygame
# Version:               1.0 (1.0)
# Code Type:             X86-64 (Native)
# Parent Process:        ??? [1]
# Responsible:           AGS [1126]
# User ID:               501
# 
# Date/Time:             2024-09-14 18:50:56.150 -0600
# OS Version:            macOS 11.7.10 (20G1427)
# Report Version:        12
# Anonymous UUID:        E51BB38A-D649-7541-7AC8-154D8B33BA72
# 
# 
# Time Awake Since Boot: 650 seconds
# 
# System Integrity Protection: enabled
# 
# Crashed Thread:        0  Dispatch queue: com.apple.main-thread
# 
# Exception Type:        EXC_CRASH (SIGABRT)
# Exception Codes:       0x0000000000000000, 0x0000000000000000
# Exception Note:        EXC_CORPSE_NOTIFY
# 
# Termination Reason:    DYLD, [0x4] Symbol missing
# 
# Dyld Error Message:
#   Symbol not found: _SteamAPI_Init
#   Referenced from: libagsteam-unified.dylib
#   Expected in: libsteam_api.dylib
# 
# Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
# 0   dyld                          	0x000000010afa9e0a __abort_with_payload + 10
# 1   dyld                          	0x000000010afd2bb1 abort_with_payload_wrapper_internal + 80
# 2   dyld                          	0x000000010afd2be3 abort_with_payload + 9
# 3   dyld                          	0x000000010af52412 dyld::halt(char const*) + 672
# 4   dyld                          	0x000000010af5259c dyld::fastBindLazySymbol(ImageLoader**, unsigned long) + 167
# 5   libdyld.dylib                 	0x00007fff20745ce8 _dyld_fast_stub_entry(void*, long) + 65
# 6   libdyld.dylib                 	0x00007fff20745c26 dyld_stub_binder + 282
# 7   ???                           	0x00000001084950f0 0 + 4433989872
# 8   libagsteam-unified.dylib      	0x0000000108490a0c AGS_EngineStartup + 44
# 9   com.companyname.mygame            0x0000000101e5171f AGS::Engine::InitGameState(AGS::Common::LoadedGameEntities const&, GameDataVersion) + 15279
# 10  com.companyname.mygame            0x0000000101e84d04 load_game_file() + 3748
# 11  com.companyname.mygame            0x0000000101d9e942 initialize_engine(std::__1::map, std::__1::allocator > >, std::__1::less, std::__1::allocator, std::__1::allocator > > > > > const&) + 14690
# 12  com.companyname.mygame            0x0000000101da7cd5 ags_entry_point(int, char**) + 8949
# 13  libdyld.dylib                 	0x00007fff20746f3d start + 1
# 

I was hoping to avoid a little extra work by not having to create the entire Universal Binary version of libagsteam-unified.dylib, but that obviously wouldn't work. That would have been too easy, of course. I figured I would have to create a UB build, which was relatively simple by just adding the -arch x86_64 -arch arm64 options when compiling the library.

When trying to determine how these various libraries were linked together, I used a variety of tools like MacDependency or otool to check their dependencies. Using otool on my universal build of libagsteam-unified.dylib revealed details for both the x86 and arm64 architectures which were linked to the libsteam_api.dylib.

Mulling over this issue further, I surmised that the crash might be happening because I noticed that the libsteam_api.dylib I originally had in the game was just the x86 version, not the Universal Binary version. But I updated that library to the newer version, but when I created the libagsteam-unified.dylib, I integrated the old Intel version of the libagsteam-unified.dylib, and it may not be able to find something appropriately because it was not built and linked against the newer (version 1.59) Steam library which was available in the game, so something was not compatible.

This was making progress, but it obviously wasn't the complete working solution, so I would need to create a Universal Binary build that linked against the new Steam library.

The New

After creating a Universal Binary version of libagsteam-unified.dylib on my newer Mac, the achievements still worked on the M1 MacBook Pro, and the game launched on my Intel iMac, but achievements were still not launching on Intel. A positive step forward that it was no longer causing a crash, but the lack of achievements was annoying. The errors I saw when I launched the game again from the Terminal:

# 
# libname 'agsteam-unified' | libfile 'libagsteam-unified.dylib'
# Try library path: libagsteam-unified.dylib
# dlopen error: dlopen(libagsteam-unified.dylib, 1): Symbol not found: __ZNKSt3__115basic_stringbufIcNS_11char_traitsIcEENS_9allocatorIcEEE3strEv
#   Referenced from: libagsteam-unified.dylib (which was built for Mac OS X 13.0)
#   Expected in: /usr/lib/libc++.1.dylib
# 
# Try library path: ./libagsteam-unified.dylib
# dlopen error: dlopen(./libagsteam-unified.dylib, 1): Symbol not found: __ZNKSt3__115basic_stringbufIcNS_11char_traitsIcEENS_9allocatorIcEEE3strEv
#   Referenced from: ./libagsteam-unified.dylib (which was built for Mac OS X 13.0)
#   Expected in: /usr/lib/libc++.1.dylib
# 
# Try library path: /path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib
# dlopen error: dlopen(/path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib, 1): Symbol not found: __ZNKSt3__115basic_stringbufIcNS_11char_traitsIcEENS_9allocatorIcEEE3strEv
#   Referenced from: /path/to/MyGame.app/Contents/Resources/libagsteam-unified.dylib (which was built for Mac OS X 13.0)
#   Expected in: /usr/lib/libc++.1.dylib
# 
# Plugin 'agsteam-unified' could not be loaded (expected 'libagsteam-unified.dylib'), trying built-in plugins...

The line which was built for Mac OS X 13.0 was the key to solving this issue. I initially built the library on macOS Ventura 13.6.7, but apparently that was too new for running on an older system (in this case, macOS Big Sur 11.7). The solution to this was to just rebuild the library under macOS Big Sur 11.7 on my Intel iMac. I tested this on as far back as macOS 10.14 Mojave, in addition to newer versions of macOS on Apple Silicon, and it seemed to work just fine then. Amazingly enough, I didn't have to alter my build script to get it to work on the slightly older Intel Mac.

This is the solution which finally worked for both Apple Silicon and Intel Macs. You can download the libraries here or download it from my branch of the agsteam project.

Tools

I used a number of tools to get everything working, especially with inspecting the dynamic libraries and determining how all of the disparate parts worked together.

Resources

All of the research for this post resulted in a lot references. Happy reading!