Language ecosystems are not perfect. Sometimes resulting executables are performant but the syntax is horrible, sometimes there is a nice package manager but standard functions are scarce to a fault — it’s all about the compromise.
Python is nice to use but the performance might be not great. The latter causes such tools as NumPy to emerge. How does it work? Well, it uses C calls — essentially borrowing performance improvements from the C ecosystem. In this article, I’ll show how to use foreign C functions from dynamic libraries in Python. Calls will be done to the Steamworks SDK which games use to communicate with Steam.
Overview
When talking about calling foreign functions from Python, at least two solutions come to mind —
ctypes
and
cython
.
ctypes
is a standard package, does not require additional compilation steps or tools —
basically, Plug and Play for dynamic libraries. cython
is an external package and is a bit more involved —
having a different (but similar to Python) syntax, tools and so on. I’ll use ctypes
below but cython
can be useful in more complicated situations.
As for Steam, the Steamworks API is the point of interest.
It’s a collection of C++ calls — including interfaces, classes, methods and all of that.
Unfortunately, ctypes
doesn’t bode well with C++, preferring C signatures instead.
Fortunately, the Steamworks API provides a special API
for interop purposes – ctypes
is capable of using it without issues or side-effects.
Making the Call
Linking
The Steamworks SDK has following Steamworks API binaries.
redistributable_bin
├── linux32
│ └── libsteam_api.so
├── linux64
│ └── libsteam_api.so
├── osx
│ └── libsteam_api.dylib
├── steam_api.dll
├── steam_api.lib
└── win64
├── steam_api64.dll
└── steam_api64.lib
An appropriate file must be picked based on the OS and passed to ctypes
to receive a dynamic library object (ctypes.CDLL
).
I have a Mac and a Steam Deck so the code below is for macOS and Linux since I cannot check how Windows works but it should be similar.
import ctypes
import pathlib
import platform
class Steam:
_dll: ctypes.CDLL
def __init__(self, sdk_path: pathlib.Path) -> None:
self._dll = ctypes.CDLL(Steam._resolve_dll_path(sdk_path))
@staticmethod
def _resolve_dll_path(sdk_path: pathlib.Path) -> pathlib.Path:
dlls_path = sdk_path / "redistributable_bin"
system = platform.system()
if system == "Darwin":
return dlls_path / "osx" / "libsteam_api.dylib"
elif system == "Linux":
(system_bits, _) = platform.architecture()
if system_bits == "64bit":
return dlls_path / "linux64" / "libsteam_api.so"
elif system_bits == "32bit":
return dlls_path / "linux32" / "libsteam_api.so"
else:
raise SteamException(f"unsupported system bits: {bits}")
else:
raise SteamException(f"unsupported system: {platform.system()}")
Binding: C
To call functions from a dynamic library, ctypes
needs to know their signature.
To feed this knowledge, functions are defined on the ctypes.CDLL
object —
including their arguments and results.
From the C perspective, these calls look like this (see sdk/public/steam/steam_api.h
):
S_API bool S_CALLTYPE SteamAPI_Init();
S_API void S_CALLTYPE SteamAPI_Shutdown();
As such, the Python declaration will be (note argtypes
and restype
):
import ctypes
class Steam:
def __init__(self, sdk_path: pathlib.Path) -> None:
# S_API bool S_CALLTYPE SteamAPI_Init();
self._dll.SteamAPI_Init.argtypes = []
self._dll.SteamAPI_Init.restype = ctypes.c_bool
# S_API void S_CALLTYPE SteamAPI_Shutdown();
self._dll.SteamAPI_Shutdown.argtypes = []
self._dll.SteamAPI_Shutdown.restype = None
Python calls refer to ctypes.CDLL
and can benefit from the context management:
class SteamException(Exception):
pass
class Steam:
def __enter__(self):
if not self._dll.SteamAPI_Init():
raise SteamException("unable to init")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._dll.SteamAPI_Shutdown()
with Steam(pathlib.Path("./sdk")) as steam:
print("Success!")
If the Steam application is running in the background, the Steamworks SDK is unpacked near the Python file and the file itself is executed, it will be successfull indeed. However, the call is not that useful. What if I want to check game achievements?
Binding: C++
The Steamworks API is available as multiple C++ interfaces — each interface covers its own area.
For example, achievements and stats are available at ISteamUserStats
.
As I’ve mentioned above, ctypes
works with C calls, not C++ ones. Fortunately, a compatible — C-like — flat API is available.
C++ API:
class ISteamUserStats
{
public:
virtual uint32 GetNumAchievements() = 0;
virtual const char *GetAchievementName( uint32 iAchievement ) = 0;
virtual const char *GetAchievementDisplayAttribute( const char *pchName, const char *pchKey ) = 0;
};
C++ flat API (see sdk/public/steam/steam_api_flat.h
):
S_API ISteamUserStats *SteamAPI_SteamUserStats_v012();
S_API uint32 SteamAPI_ISteamUserStats_GetNumAchievements( ISteamUserStats* self );
S_API const char * SteamAPI_ISteamUserStats_GetAchievementName( ISteamUserStats* self, uint32 iAchievement );
S_API const char * SteamAPI_ISteamUserStats_GetAchievementDisplayAttribute( ISteamUserStats* self, const char * pchName, const char * pchKey );
These calls have arguments and pointers unlike the ones before. However, the same ctypes
approach works fine.
Pick a C signature, provide a ctypes
binding based on C arguments and results, call resulting functions.
import ctypes
import os
# Alias for "ISteamUserStats*"
# A pointer is needed and not its type so a "void*" works
class SteamUserStatsPtr(ctypes.c_void_p):
pass
class SteamUserStats:
_dll: ctypes.CDLL
_ptr: SteamUserStatsPtr
def __init__(self, dll: ctypes.CDLL, ptr: SteamUserStatsPtr) -> None:
self._dll = dll
self._ptr = ptr
# S_API uint32 SteamAPI_ISteamUserStats_GetNumAchievements( ISteamUserStats* self );
self._dll.SteamAPI_ISteamUserStats_GetNumAchievements.argtypes = [SteamUserStatsPtr]
self._dll.SteamAPI_ISteamUserStats_GetNumAchievements.restype = ctypes.c_uint32
# S_API const char * SteamAPI_ISteamUserStats_GetAchievementName( ISteamUserStats* self, uint32 iAchievement );
self._dll.SteamAPI_ISteamUserStats_GetAchievementName.argtypes = [SteamUserStatsPtr, ctypes.c_uint32]
self._dll.SteamAPI_ISteamUserStats_GetAchievementName.restype = ctypes.c_char_p
# S_API const char * SteamAPI_ISteamUserStats_GetAchievementDisplayAttribute( ISteamUserStats* self, const char * pchName, const char * pchKey );
self._dll.SteamAPI_ISteamUserStats_GetAchievementDisplayAttribute.argtypes = [SteamUserStatsPtr, ctypes.c_char_p, ctypes.c_char_p]
self._dll.SteamAPI_ISteamUserStats_GetAchievementDisplayAttribute.restype = ctypes.c_char_p
def get_achievements_count(self) -> int:
return self._dll.SteamAPI_ISteamUserStats_GetNumAchievements(self._ptr)
def get_achievement_id(self, achievement_index: int) -> str:
return self._dll.SteamAPI_ISteamUserStats_GetAchievementName(self._ptr, achievement_index).decode("utf-8")
def get_achievement_name(self, achievement_id: str) -> str:
return self._dll.SteamAPI_ISteamUserStats_GetAchievementDisplayAttribute(self._ptr, achievement_id.encode("utf-8"), "name".encode("utf-8")).decode("utf-8")
class Steam:
def __init__(self, sdk_path: pathlib.Path, application_id: int) -> None:
# S_API ISteamUserStats *SteamAPI_SteamUserStats_v012();
self._dll.SteamAPI_SteamUserStats_v012.argtypes = []
self._dll.SteamAPI_SteamUserStats_v012.restype = SteamUserStatsPtr
# This is where Steam picks the Steam Application ID
# ID can be found at Steam URLs and at SteamDB
os.environ["SteamAppId"] = f"{application_id}"
def get_user_stats(self) -> SteamUserStats:
return SteamUserStats(self._dll, self._dll.SteamAPI_SteamUserStats_v012())
Might be a bit daunting to look at but at its core it’s a simple concept. Usage:
import pathlib
# Planescape: Torment @ https://store.steampowered.com/app/466300
with Steam(pathlib.Path("./sdk"), 466300) as steam:
steam_user_stats = steam.get_user_stats()
for achievement_index in range(steam_user_stats.get_achievements_count()):
achievement_id = steam_user_stats.get_achievement_id(achievement_index)
achievement_name = steam_user_stats.get_achievement_name(achievement_id)
print(f"{achievement_id:30}{achievement_name:30}")
BD_ACH_001 Call of the Blade
BD_ACH_002 Call of the Art
BD_ACH_003 Call of the Shadows
BD_ACH_004 Master of Blades
BD_ACH_005 Master of the Art
...
Of course, there are more use cases IRL — using callbacks, passing non-standard pointers, checking incorrect states and so on. However, it’s not that different / difficult.
Thoughts
Overall, ctypes
is a good starting point when working with dynamic libraries.
It doesn’t require additional steps — a Python file and a *.dylib
/ *.so
file are enough, no tools,
no dependencies. I’ve checked the code above on a Mac and on a Steam Deck with system-level
Python interpreters and it worked great. As such, the ctypes
-based code can be used
for scripting and even for something more complicated.
However, if the task at hand is too complex for ctypes
— there is cython
.