Swift bindings to the Playdate C API

Wraps all ten subsystems of the Playdate SDK 3.1.1 C API (system, display,
graphics, sprites, sound, file, JSON, Lua, scoreboards, network) in
idiomatic Swift: namespaced APIs, wrapper types with ownership semantics,
closures for callbacks, and typed throws. Written within the Embedded Swift
subset so the same code can target the device.

The SDK headers are resolved through a "playdate" pkg-config module
(Scripts/install-pkgconfig.sh), so the package works as a normal SwiftPM
dependency without unsafe build flags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:33:45 +02:00
co-authored by Claude Fable 5
commit 09760f99b0
29 changed files with 6533 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
//
// JSON.swift
// Wraps `playdate->json` (pd_api_json.h).
//
// The C decoder is callback-based; this wrapper drives it to build a
// complete `Value` tree. The encoder is exposed both as a streaming
// `Encoder` and as a one-shot `encode(_:)` of a `Value`.
//
internal import CPlaydate
private var jsonAPI: playdate_json { Playdate.api.json.pointee }
extension Playdate {
/// The JSON API: decoding to and encoding from a `Value` tree.
public enum JSON {}
}
extension Playdate.JSON {
/// A decoded JSON value.
public indirect enum Value {
case null
case bool(Bool)
case int(Int)
case float(Float)
case string(String)
case array([Value])
case table([String: Value])
}
// MARK: - Decoding
private final class ValueBox {
var value: Value
init(_ value: Value) { self.value = value }
}
private final class DecodeContext {
/// Containers under construction, innermost last.
var stack: [Value] = []
var errorMessage: String?
var errorLine: Int32 = 0
func append(_ value: Value, key: String?) {
guard !stack.isEmpty else { return }
switch stack[stack.count - 1] {
case .array(var items):
items.append(value)
stack[stack.count - 1] = .array(items)
case .table(var entries):
if let key { entries[key] = value }
stack[stack.count - 1] = .table(entries)
default:
break
}
}
}
/// Converts a C `json_value`, consuming any container box it references.
private static func convert(_ value: json_value) -> Value {
switch UInt32(bitPattern: Int32(value.type)) {
case kJSONTrue.rawValue: return .bool(true)
case kJSONFalse.rawValue: return .bool(false)
case kJSONInteger.rawValue: return .int(Int(value.data.intval))
case kJSONFloat.rawValue: return .float(value.data.floatval)
case kJSONString.rawValue: return .string(String(playdateCString: value.data.stringval) ?? "")
case kJSONArray.rawValue, kJSONTable.rawValue:
guard let pointer = value.data.arrayval else { return .null }
return Unmanaged<ValueBox>.fromOpaque(pointer).takeRetainedValue().value
default: return .null
}
}
private static func makeDecoder(context: Unmanaged<DecodeContext>) -> json_decoder {
var decoder = json_decoder()
decoder.userdata = context.toOpaque()
decoder.decodeError = { decoder, error, linenum in
guard let userdata = decoder?.pointee.userdata else { return }
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
context.errorMessage = String(playdateCString: error)
context.errorLine = linenum
}
decoder.willDecodeSublist = { decoder, _, type in
guard let userdata = decoder?.pointee.userdata else { return }
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
context.stack.append(type == kJSONArray ? .array([]) : .table([:]))
}
decoder.didDecodeTableValue = { decoder, key, value in
guard let userdata = decoder?.pointee.userdata else { return }
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
context.append(Playdate.JSON.convert(value), key: String(playdateCString: key))
}
decoder.didDecodeArrayValue = { decoder, _, value in
guard let userdata = decoder?.pointee.userdata else { return }
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
context.append(Playdate.JSON.convert(value), key: nil)
}
decoder.didDecodeSublist = { decoder, _, _ in
guard let userdata = decoder?.pointee.userdata else { return nil }
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
guard let finished = context.stack.popLast() else { return nil }
// Handed to the parent container (or the decode outval) as the
// sublist's value; consumed by `convert`.
return Unmanaged.passRetained(ValueBox(finished)).toOpaque()
}
return decoder
}
/// Decodes a JSON string into a `Value` tree.
public static func decode(_ jsonString: String) throws(Playdate.Error) -> Value {
let context = DecodeContext()
let unmanaged = Unmanaged.passUnretained(context)
var decoder = makeDecoder(context: unmanaged)
var outval = json_value()
let ok = jsonString.withPlaydateCString { cString in
withExtendedLifetime(context) {
jsonAPI.decodeString.unsafelyUnwrapped(&decoder, cString, &outval) != 0
}
}
guard ok else {
throw decodeError(context)
}
return convert(outval)
}
/// Decodes JSON read from an open file into a `Value` tree.
public static func decode(file: Playdate.File.Handle) throws(Playdate.Error) -> Value {
let context = DecodeContext()
var decoder = makeDecoder(context: Unmanaged.passUnretained(context))
var reader = json_reader()
reader.userdata = Unmanaged.passUnretained(file).toOpaque()
reader.read = { userdata, buffer, size in
guard let userdata, let buffer else { return -1 }
let file = Unmanaged<Playdate.File.Handle>.fromOpaque(userdata).takeUnretainedValue()
let destination = UnsafeMutableRawBufferPointer(start: buffer, count: Int(size))
do {
let count = try file.read(into: destination)
return count > 0 ? Int32(count) : -1
} catch {
return -1
}
}
var outval = json_value()
let ok = withExtendedLifetime(context) {
withExtendedLifetime(file) {
jsonAPI.decode.unsafelyUnwrapped(&decoder, reader, &outval) != 0
}
}
guard ok else {
throw decodeError(context)
}
return convert(outval)
}
/// Opens and decodes the JSON file at `path`.
public static func decodeFile(at path: String) throws(Playdate.Error) -> Value {
let file = try Playdate.File.Handle(path: path, mode: [.read, .readData])
return try decode(file: file)
}
private static func decodeError(_ context: DecodeContext) -> Playdate.Error {
Playdate.Error(message: "\(context.errorMessage ?? "JSON decode failed") (line \(context.errorLine))")
}
// MARK: - Encoding
/// A streaming JSON encoder writing into a string. Wraps `json_encoder`.
public final class Encoder {
private final class Output {
var text = ""
}
private var encoder = json_encoder()
private let output = Output()
public init(pretty: Bool = false) {
jsonAPI.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in
guard let userdata, let string else { return }
let output = Unmanaged<Output>.fromOpaque(userdata).takeUnretainedValue()
let bytes = UnsafeRawBufferPointer(start: string, count: Int(length))
output.text += String(decoding: bytes, as: UTF8.self)
}, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0)
}
/// The JSON produced so far.
public var json: String { output.text }
public func startArray() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.startArray.unsafelyUnwrapped($0) }
}
/// Call before writing each array element.
public func addArrayMember() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.addArrayMember.unsafelyUnwrapped($0) }
}
public func endArray() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.endArray.unsafelyUnwrapped($0) }
}
public func startTable() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.startTable.unsafelyUnwrapped($0) }
}
/// Call before writing each table value.
public func addTableMember(name: String) {
name.withPlaydateCString { cName in
withUnsafeMutablePointer(to: &encoder) {
$0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count))
}
}
}
public func endTable() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.endTable.unsafelyUnwrapped($0) }
}
public func writeNull() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeNull.unsafelyUnwrapped($0) }
}
public func writeBool(_ value: Bool) {
withUnsafeMutablePointer(to: &encoder) {
(value ? $0.pointee.writeTrue : $0.pointee.writeFalse).unsafelyUnwrapped($0)
}
}
public func writeInt(_ value: Int) {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeInt.unsafelyUnwrapped($0, Int32(value)) }
}
public func writeDouble(_ value: Double) {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeDouble.unsafelyUnwrapped($0, value) }
}
public func writeString(_ value: String) {
value.withPlaydateCString { cString in
withUnsafeMutablePointer(to: &encoder) {
$0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count))
}
}
}
/// Writes a complete `Value` tree.
public func write(_ value: Value) {
switch value {
case .null:
writeNull()
case .bool(let bool):
writeBool(bool)
case .int(let int):
writeInt(int)
case .float(let float):
writeDouble(Double(float))
case .string(let string):
writeString(string)
case .array(let items):
startArray()
for item in items {
addArrayMember()
write(item)
}
endArray()
case .table(let entries):
startTable()
for (key, entry) in entries {
addTableMember(name: key)
write(entry)
}
endTable()
}
}
}
/// Encodes a `Value` tree as a JSON string.
public static func encode(_ value: Value, pretty: Bool = false) -> String {
let encoder = Encoder(pretty: pretty)
encoder.write(value)
return encoder.json
}
}