steamworks-swift

main

Steamworks API in Swift
johnfairh/steamworks-swift

macOS Steamworks 1.59 Test MIT

steamworks-swift

A practical interface to the Steamworks SDK using the Swift C++ importer.

Caveat Integrator: The Swift C++ importer is new and shaky; this package is built on top

Current state:

  • All Steamworks interfaces complete - see API docs
  • Code gen creates Swift versions of Steam types; callbacks and call-returns work
  • Code gen creates SDK header-file oriented type index for documentation
  • Some interface quality-of-life helpers in a separate SteamworksHelpers module
  • make test builds and runs unit tests that run frame loops and access portions of the Steam API doing various sync and async tasks.
  • Experimental custom-executor for multithreaded Steamworks access in a separate SteamworksConcurrency module
  • Encrypted app ticket support in separate SteamworksEncryptedAppTicket module
  • Separate demo showing encrypted app-ticket stuff, make run_ticket
  • Requires Swift 5.10, Xcode 15.3 -- Linux C++ interop is a bit better in 5.10 but still curious
  • The Xcode project basically works.
  • Unit tests sometimes crash inside steam on exit - some kind of XCTest incompatibility?

Below:

Concept

  • Offer a pure Swift module Steamworks covering all of the current Steamworks API
  • Leave out the deprecated and WIN32-only stuff
  • Do not diverge too far from the 'real' API names to aid docs / searching / porting: I think this is a better starting point than doing a complete OO analysis to carve out function. Can go on to augment SteamworksHelpers if worthwhile. Name etc. changes:
    • Don't use Swift properties for 0-arg getters: diverges too far from Steamworks naming
    • Drop the intermittent Hungarian notation (argh the 1990s are calling)
    • Use Swift closures for callbacks as well as async-await sugar
    • Map unions onto enums with associated values
  • Provide custom API-lifetime and message dispatch classes
  • Provide strongly typed handles
  • Access interfaces via central types
  • Use code gen to deal with the ~900 APIs and their ~400 types, taking advantage of the handy JSON file. This code-gen piece is the actual main work in this project
  • Provide quality-of-life helpers module SteamworksHelpers to wrap up API patterns involving multiple calls, usually determining buffer lengths

Next

  • Finish practical Swift concurrency support in Swift 6
  • More SpaceWar porting over to Swift to check general practicality, somewhat real-world usage, general interest - see spacewar-swift.

API mapping design

Lifecycle

// Initialization
let steam = SteamAPI(appID: MyAppId) // or `SteamGameServerAPI`

// Frame loop
steam.runCallbacks() // or `steam.releaseCurrentThreadMemory()`

// Shutdown
// ...when `steam` goes out of scope

Callbacks

C++

STEAM_CALLBACK(MyClass, OnUserStatsReceived, UserStatsReceived_t, m_CallbackUserStatsReceived);

...

m_CallbackUserStatsReceived( this, &MyClass::OnUserStatsReceived )

...

void MyClass::OnUserStatsReceived( UserStatsReceived_t *pCallback ) {
  ...
}

Swift

steam.onUserStatsReceived { userStatsReceived in
  ...
}

There are async versions too, like:

for await userStatsReceived in steam.userStatsReceived {
  ...
}

Be sure to check Swift concurrency concerns.

Functions

auto handle = SteamInventory()->StartUpdateProperties();
let handle = steam.inventory.startUpdateProperties()

Call-return style

C++

CCallResult<MyClass, FriendsGetFollowerCount_t> m_GetFollowerCountCallResult;

...

auto hSteamAPICall = SteamFriends.GetFollowerCount(steamID);
m_GetFollowerCountCallResult.Set(hSteamAPICall, this, &MyClass::OnGetFollowerCount);

...

void MyClass::OnGetFollowerCount(FriendsGetFollowerCount_t *pCallback, bool bIOFailure) {
  ...
}

Swift

steam.friends.getFollowerCount(steamID: steamID) { getFollowerCount in
  guard let getFollowerCount = getFollowerCount else {
    // `bIOFailure` case
    ...
  }
  ...
}

There are async versions:

let getFollowerCount = await steam.friends.getFollowerCount(steamID: steamID)

...but do check Swift concurrency concerns: this form is not safe right now, though this should be fixable in Swift 6.

Array-length parameters

Parameters carrying the length of an input array are discarded because Swift arrays carry their length with them.

'Out' parameters

C++ 'out' parameters filled in by APIs are returned in a tuple, or, if the Steam API is void then as the sole return value.

SteamInventoryResult_t result;
bool rc = SteamInventory()->GrantPromoItems(&result);
let (rc, result) = steamAPI.inventory.grantPromoItems()

Optional 'out' parameters

Some C++ 'out' parameters are optional: they can be passed as NULL to indicate they're not required by caller. In the Swift API these generate an additional boolean parameter return<ParamName> with default true.

auto avail = SteamNetworkingUtils()->GetRelayNetworkStatusAvailability(NULL);
let (avail, _) = steamAPI.networkingUtils.getRelayNetworkStatusAvailability(returnDetails: false)

The return tuple is still populated with something but its contents is undefined; the library guarantees to pass NULL to the underlying Steamworks API.

'In-out' parameters

C++ parameters whose values are significant and also have their value updated are present in both Swift function parameters and the return tuple.

uint32 itemDefIDCount = 0;
bool rc1 = SteamInventory()->GetItemDefinitionIDs(NULL, &itemDefIDCount);
auto itemDefIDs = new SteamItemDef_t [itemDefIDCount];
bool rc2 = SteamInventory()->GetItemDefinitions(itemDefIDs, &itemDefIDCount);
let (rc1, _, itemDefIDCount) = steamAPI.inventory.
                                   getItemDefinitionIDs(returnItemDefIDs: false,
                                                        itemDefIDsArraySize: 0)
let (rc2, itemDefIDs, _) = steamAPI.inventory.
                               getItemDefinitionIDs(itemDefIDsArraySize: itemDefIDCount)

Default parameter values

Default values are provided where the API docs suggest a value, but there are still APIs where caller is required to provide a max buffer length for an output string -- these look pretty weird in Swift but no way to avoid. Some Steamworks APIs support the old "pass NULL to get the required length" two-pass style and these patterns are wrapped up in a Swifty way in the SteamworksHelpers module.

Swift Concurrency Concerns

The Steamworks architecture is thread-based. For each thread you want to call Steam APIs you must regularly call SteamAPI.runCallbacks() or SteamAPI.releaseCurrentThreadMemory(). The former synchronously calls back into your code to fulfill callbacks; they both do internal thread-specific housekeeping.

Swift concurrency and its built-in libdispatch-based executors are dead-set against users thinking about threads, with a begrudging exception for 'the main thread'.

To use async-await with Steamworks I think there are two approaches:

  1. Keep Steam interactions on the main thread. Use @MainActor and related tools to keep your code there (MainActor.assumeIsolated() can be a life-saver). If you need to call Steam from another isolation domain then you have to hop over -- just like with AppKit and friends.

    Call SteamAPI.runCallbacks() as part of your frame loop or similar.

  2. Use a Swift custom executor to manage a thread to run your code and do the required Steam polling. Assign instances of these executors to actors to host your program, tastefully choosing the number and distribution of threads.

A couple of examples of (1) in the tests, see TestApiSimple.testCallReturnAsync() and TestApiSimple.testCallbackAsync() along with their callback-based versions.

A prototype executor for (2) in SteamExecutor in the SteamworksConcurrency module, along with an example of use in TestExecutor.testExecutorSteam().

I think a practical solution is to mix these: use @MainActor-bound code for general things, using the frame loop to trigger frequent callbacks, and then use one or more executors to look after gameservers or lower-priority work.

How To Use This Project

Prereqs:

  • Needs Swift 5.10 (Xcode 15.3)
  • Needs Steam client installed (and logged-in, running for the tests or to do anything useful)
  • I'm using macOS 14; should work on Linux; might work on Windows eventually

Install the Steamworks SDK:

  • Clone steamworks-swift-sdk
  • make install (this is far from ideal but hard stuck behind various Swift issues)

Sample Package.swift:

// swift-tools-version: 5.9

import PackageDescription

let package = Package(
  name: "MySteamApp",
  platforms: [
    .macOS("14.0"),
  ],
  dependencies: [
    .package(url: "https://github.com/johnfairh/steamworks-swift", from: "0.5.2"),
  ],
  targets: [
    .executableTarget(
      name: "MySteamApp",
      dependencies: [
        .product(name: "Steamworks", package: "steamworks-swift")
      ],
      swiftSettings: [.interoperabilityMode(.Cxx)]
    )
  ]
)

Note that you must set .interoperabilityMode(.Cxx) in all targets that depend on Steamworks, and all targets that depend on them, forever and forever unto the last dependency. This virality is part of the current Swift design and unavoidable for now.

Sample skeleton program:

import Steamworks

@main
public struct MySteamApp {
  public static func main() {
    guard let steam = SteamAPI(appID: .spaceWar, fakeAppIdTxtFile: true) else {
      print("SteamInit failed")
      return
    }
    print("Hello world with Steam name \(steam.friends.getPersonaName())")
  }
}

API docs here.

Fully-fledged AppKit/Metal demo here.

Implementation notes

Swift C++ Bugs

to recheck in Swift 6, noticed 5.10 has fewer simd screw-ups at least

Tech limitations, on 5.9 Xcode 15.b6:

  • Some structures/classes aren't imported -- is the common factor a protected destructor? Verify by trying to use SteamNetworkingMessage_t.
  • Something goes wrong storing pointers to classes and they get nobbled by something. Verify by making SteamIPAddress a struct and running TestApiServer. Or change interfaces to cache the interface pointers.
  • Calls to virtual functions aren't generated properly: Swift generates a ref to a symbol instead of doing the vtable call. So the actual C++ interfaces are not usable in practice. Will use the flat API.
  • Anonymous enums are not imported at all. Affects callback etc. ID constants. Will work around.
  • sourcekit won't give me a module interface for CSteamworks to see what else the importer is doing. Probably Xcode's fault, still not passing the user's flags to sourcekit and still doing insultingly bad error-reporting. fixed in Xcode 15?!
  • Linux only: random parts of Glibc silently fail to import. SMH. Work around in C++.
  • Linux only: implicit struct constructors are not created, Swift generates a ref to a non-existent method that fails at link time. Work around with dumb C++ allocate shim. Sort of fixed in 5.9, but instead swiftc crashes on some uses -- on both macOS and Linux. Check by refs to eg. CSteamNetworkingIPAddr_Allocate().`
  • Linux only, again: SPM test auto-discovery has no clue about C++ interop. Work around by smashing in the flag everywhere...
  • Swift 5.8+ adopts a broken/paranoid model about 'projected pointers' requiring some fairly ugly code to work around. Verify with the __ unsafe stuff in ManualTypes.swift.

Non-Swift Problems

  • Some Steamworks SDK issues, nothing too serious.
  • CI really needs a private runner with a logged-in steam account, current version just runs the non-steam-requiring tests.

Weird Steam messages

Getting unexpected SteamAPICallCompleteds out of SteamAPI_ManualDispatch_GetNextCallback() -- suspect parts of steamworks trying to use callbacks internally without understanding manual dispatch mode. Or I'm missing an API somewhere to dispatch them.

  • 2101 - HTTPRequestCompleted_t.k_iCallback
  • 1296 - k_iSteamNetworkingUtilsCallbacks + 16 - undefined, not a clue

Seems triggered by using steamnetworking.

Facepunch logs & drops these too, so, erm, shrug I suppose.

Getting src/steamnetworkingsockets/clientlib/csteamnetworkingmessages.cpp (229) : Assertion Failed: [#40725897 pipe] Unlinking connection in state 1 using steamnetworkingmessages; possibly it's not expecting to send messages from a steam ID to itself.

JSON notes

Capture some notes on troubles reflecting the json into the module.

  • The 'modern' isteamnetworking stuff is incomplete somehow - Json describes SteamDatagramGameCoordinatorServerLogin, SteamDatagramHostedAddress are missing from the header files. The online API docs are hilariously broken here, scads of broken links. Have to wait for Valve to fix this.

    I found some of this in the SDR SDK, but it's not supported on macOS and uses actual grown-up C++ with std::string and friends so best leave it alone for now.

  • SteamNetworkingMessage_t doesn't import into Swift. Probably stumbling into a hole of C++ struct with function pointer fields. Trust Apple will get to this eventually, will write a zero-cost inline shim.

  • Json (and all non-C languages) struggles with unions. Thankfully rare: SteamIPAddress_t, SteamInputAction_t, SteamNetworkingConfigValue_t. SteamNetworkingConfigValue_t. Rare enough to deal with manually.

  • Loads of missing out_string_count etc. annotations and a few wrong, see patchfile.

Contributions

Welcome: open an issue / johnfairh@gmail.com / @johnfairh@mastodon.social

License

Distributed under the MIT license. Except the Steamworks SDK parts.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

Last updated: Tue Jul 09 2024 18:59:29 GMT-0900 (Hawaii-Aleutian Daylight Time)