8 Commits
108 changed files with 1801 additions and 1433 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3 // swift-tools-version: 6.4
import PackageDescription import PackageDescription
Binary file not shown.
@@ -8,7 +8,7 @@
import CPlaydate import CPlaydate
import PlaydateKit import PlaydateKit
@_cdecl("eventHandler") @c(eventHandler)
public func eventHandler( public func eventHandler(
pointer: UnsafeMutableRawPointer, pointer: UnsafeMutableRawPointer,
event: PDSystemEvent, event: PDSystemEvent,
@@ -32,7 +32,7 @@ final class Game {
private let boxSize = 24 private let boxSize = 24
func start() { func start() {
Display.setRefreshRate(50) Display.refreshRate = 50
System.addMenuItem(title: "reset") { _ in System.addMenuItem(title: "reset") { _ in
Game.shared.reset() Game.shared.reset()
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Javier Cicchelli Copyright (c) 2026 Röck+Cöde VoF
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+7 -2
View File
@@ -16,6 +16,11 @@ EXAMPLE_DIR := Examples/HelloPlaydate
SWIFT_LATEST := $(HOME)/Library/Developer/Toolchains/swift-latest.xctoolchain/usr/bin/swift SWIFT_LATEST := $(HOME)/Library/Developer/Toolchains/swift-latest.xctoolchain/usr/bin/swift
SWIFT_BIN ?= $(if $(wildcard $(SWIFT_LATEST)),$(SWIFT_LATEST),swift) SWIFT_BIN ?= $(if $(wildcard $(SWIFT_LATEST)),$(SWIFT_LATEST),swift)
# Use Xcode's docc: swift.org toolchains ship an x86_64-only one that needs
# Rosetta. Override with `make docs DOCC_EXEC=/path/to/docc`.
DOCC_EXEC ?= $(shell TOOLCHAINS= xcrun --find docc 2>/dev/null)
DOCC_ENV := $(if $(DOCC_EXEC),DOCC_EXEC="$(DOCC_EXEC)")
.PHONY: help setup build test outdated upgrade embedded consumer-test check docs docs-preview example example-run clean .PHONY: help setup build test outdated upgrade embedded consumer-test check docs docs-preview example example-run clean
help: ## List the available targets help: ## List the available targets
@@ -45,10 +50,10 @@ consumer-test: ## Build and run a scratch package depending on playdate-kit
check: build test embedded consumer-test ## Everything CI runs: build, test, embedded, consumer-test check: build test embedded consumer-test ## Everything CI runs: build, test, embedded, consumer-test
docs: ## Generate the DocC documentation archive docs: ## Generate the DocC documentation archive
swift package generate-documentation --target PlaydateKit $(DOCC_ENV) swift package generate-documentation --target PlaydateKit
docs-preview: ## Preview the DocC documentation in a local web server docs-preview: ## Preview the DocC documentation in a local web server
swift package --disable-sandbox preview-documentation --target PlaydateKit $(DOCC_ENV) swift package --disable-sandbox preview-documentation --target PlaydateKit
example: ## Build the HelloPlaydate example (device + simulator pdx) example: ## Build the HelloPlaydate example (device + simulator pdx)
$(MAKE) -C $(EXAMPLE_DIR) $(MAKE) -C $(EXAMPLE_DIR)
+10 -7
View File
@@ -1,7 +1,14 @@
// swift-tools-version: 6.3 // swift-tools-version: 6.4
import PackageDescription import PackageDescription
// Upcoming language features adopted ahead of the next language mode.
let swiftSettings: [SwiftSetting] = [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
.enableUpcomingFeature("MemberImportVisibility"),
]
let package = Package( let package = Package(
name: "playdate-kit", name: "playdate-kit",
products: [ products: [
@@ -29,17 +36,13 @@ let package = Package(
name: "PlaydateKit", name: "PlaydateKit",
dependencies: ["CPlaydate"], dependencies: ["CPlaydate"],
path: "Sources/PlaydateKit", path: "Sources/PlaydateKit",
swiftSettings: [ swiftSettings: swiftSettings,
.enableUpcomingFeature("ApproachableConcurrency"),
],
), ),
.testTarget( .testTarget(
name: "PlaydateKitTests", name: "PlaydateKitTests",
dependencies: ["PlaydateKit"], dependencies: ["PlaydateKit"],
path: "Tests/PlaydateKit", path: "Tests/PlaydateKit",
swiftSettings: [ swiftSettings: swiftSettings,
.enableUpcomingFeature("ApproachableConcurrency"),
],
), ),
] ]
) )
+67 -85
View File
@@ -4,9 +4,7 @@
Swift bindings to the [Playdate](https://play.date) C API. Swift bindings to the [Playdate](https://play.date) C API.
The Playdate C API is delivered as a `PlaydateAPI*` struct of function pointers that the firmware hands to your game at launch. This package wraps that surface in idiomatic Swift: namespaced APIs, wrapper types with ownership semantics, closures instead of function-pointer/userdata pairs, `OptionSet` and `enum` types instead of raw constants, and typed `throws` for fallible calls. Namespaces per subsystem, wrapper types that own their C objects, closures instead of function-pointer/userdata pairs, `OptionSet`s and `enum`s instead of raw constants, and typed `throws`. All ten C subsystems are covered:
All ten C subsystems are covered:
| Namespace | Wraps | Highlights | | Namespace | Wraps | Highlights |
|---|---|---| |---|---|---|
@@ -23,29 +21,24 @@ All ten C subsystems are covered:
## Requirements ## Requirements
- The [Playdate SDK](https://play.date/dev/) (3.1.1 or later). The SDK is not vendored into this repository. - [Playdate SDK](https://play.date/dev/) 3.1.1+ (not bundled).
- Swift 6.3 tools or later. - Swift 6.4 tools.
- Any macOS. `InlineArray` overloads are `@available(macOS 26, *)` in Simulator builds; unrestricted on device and Linux.
Device builds additionally need: Device builds also need:
- A [swift.org development snapshot toolchain](https://www.swift.org/install/macos/) — Xcode's toolchain does not ship the Embedded Swift stdlib for the device target (`armv7em-none-none-eabi`). The easiest way to get one is to install [Swiftly](https://www.swift.org/swiftly/), Swift's toolchain manager, and install the `main-snapshot` toolchain with it: - A [swift.org toolchain](https://www.swift.org/install/macos/) (6.4 release or a snapshot); Xcode's lacks the Embedded Swift stdlib. With [Swiftly](https://www.swift.org/swiftly/): `swiftly install main-snapshot`.
- The [Arm GNU toolchain](https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads) (`arm-none-eabi-gcc`) on your `PATH`.
```sh
swiftly install main-snapshot
```
- The [Arm GNU toolchain](https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads) (`arm-none-eabi-gcc`) on your `PATH`, which the Playdate SDK's build support uses to compile and link the device binary.
### One-time SDK setup ### One-time SDK setup
The `CPlaydate` target resolves `pd_api.h` through a `playdate` pkg-config module, so the package works as a normal SwiftPM dependency without unsafe build flags. Generate the pkg-config file once per machine: `CPlaydate` finds `pd_api.h` through a `playdate` pkg-config module. Create it once per machine:
```sh ```sh
make setup make setup
``` ```
The target locates the SDK via `PLAYDATE_SDK_PATH` (default `~/Developer/PlaydateSDK`) and writes `playdate.pc` into a directory SwiftPM searches by default (`/usr/local/lib/pkgconfig`). If you prefer another location on your `PKG_CONFIG_PATH`, run the underlying script directly with the destination as an argument: `Scripts/install-pkgconfig.sh <directory>`. It reads `PLAYDATE_SDK_PATH` (default `~/Developer/PlaydateSDK`) and writes `/usr/local/lib/pkgconfig/playdate.pc`; pass another directory with `Scripts/install-pkgconfig.sh <directory>`. If Xcode had the package open, run File ▸ Packages ▸ Reset Package Caches.
If Xcode had the package open before you ran the setup, make it re-read the manifest (File ▸ Packages ▸ Reset Package Caches) — Xcode caches package resolution and won't notice the new `.pc` file on its own.
## Adding the dependency ## Adding the dependency
@@ -72,13 +65,13 @@ targets: [
## Getting started ## Getting started
A Playdate application or game has a single C entry point, `eventHandler`. Export it with `@_cdecl`, initialize the binding on the first event, and install an update callback: A Playdate game has one C entry point, `eventHandler`. Export it with `@c`, call `Playdate.initialize(with:)` on the first event, and install an update callback:
```swift ```swift
import CPlaydate import CPlaydate
import PlaydateKit import PlaydateKit
@_cdecl("eventHandler") @c(eventHandler)
func eventHandler( func eventHandler(
pointer: UnsafeMutableRawPointer, pointer: UnsafeMutableRawPointer,
event: PDSystemEvent, event: PDSystemEvent,
@@ -103,7 +96,7 @@ final class Game {
var player = Sprite() var player = Sprite()
func start() { func start() {
Display.setRefreshRate(50) Display.refreshRate = 50
System.setUpdateCallback { System.setUpdateCallback {
self.update() self.update()
@@ -111,6 +104,10 @@ final class Game {
} }
} }
func pause() {
System.log("paused")
}
func update() { func update() {
let (_, pushed, _) = System.buttonState let (_, pushed, _) = System.buttonState
if pushed.contains(.a) { if pushed.contains(.a) {
@@ -123,14 +120,14 @@ final class Game {
} }
``` ```
`Playdate.initialize(with:)` stores the API pointer once; every wrapper in the module uses it from then on. Calling any wrapper before `initialize` is a programmer error and will crash. Calling any wrapper before `Playdate.initialize(with:)` crashes.
## Tour of the API ## Tour of the API
### System: input, time, menu ### System: input, time, menu
```swift ```swift
// Buttons are an OptionSet: current (held), pushed and released this frame. // Buttons: held now, pushed this frame, released this frame.
let (current, pushed, released) = System.buttonState let (current, pushed, released) = System.buttonState
if current.contains([.b, .down]) { /* charge shot */ } if current.contains([.b, .down]) { /* charge shot */ }
@@ -140,11 +137,11 @@ if !System.isCrankDocked {
spin(by: System.crankChange) spin(by: System.crankChange)
} }
// Accelerometer is a peripheral you enable first. // Enable the accelerometer before reading it.
System.setPeripheralsEnabled(.accelerometer) System.setPeripheralsEnabled(.accelerometer)
let (x, y, z) = System.accelerometer let (x, y, z) = System.accelerometer
// System menu items take closures; the binding keeps them alive until removed. // Menu items stay alive until removed.
System.addCheckmarkMenuItem(title: "music", isChecked: true) { item in System.addCheckmarkMenuItem(title: "music", isChecked: true) { item in
Audio.musicEnabled = item.isChecked Audio.musicEnabled = item.isChecked
} }
@@ -152,15 +149,14 @@ System.addOptionsMenuItem(title: "mode", options: ["easy", "hard"]) { item in
Game.shared.difficulty = item.value Game.shared.difficulty = item.value
} }
// Logging goes to the simulator console or device serial. // Logs go to the Simulator console or the device's serial port.
System.log("spawned \(count) enemies") System.log("spawned \(count) enemies")
System.error("unrecoverable") // stops execution System.error("unrecoverable") // stops the game
``` ```
### Graphics: drawing, bitmaps, fonts ### Graphics: drawing, bitmaps, fonts
Fallible loads (`Bitmap(path:)`, `Font(path:)`, …) throw `PlaydateError`, Loads (`Bitmap(path:)`, `Font(path:)`, …) throw `PlaydateError` with the OS's message:
which carries the message produced by the OS:
```swift ```swift
let font = try Graphics.Font(path: "fonts/Asheville-Sans-14-Bold.pft") let font = try Graphics.Font(path: "fonts/Asheville-Sans-14-Bold.pft")
@@ -170,13 +166,14 @@ Graphics.clear(color: .white)
Graphics.fillRect(x: 0, y: 0, width: 400, height: 32, color: .black) Graphics.fillRect(x: 0, y: 0, width: 400, height: 32, color: .black)
Graphics.drawText("Hëllo, Playdate", x: 8, y: 8) Graphics.drawText("Hëllo, Playdate", x: 8, y: 8)
// Colors are solid or 8×8 patterns. // Colors are solid or 8×8 patterns. On macOS 26+, the device, and Linux,
// `rows:` also takes an array literal.
let checker = Graphics.Pattern(rows: (0xAA, 0x55, 0xAA, 0x55, let checker = Graphics.Pattern(rows: (0xAA, 0x55, 0xAA, 0x55,
0xAA, 0x55, 0xAA, 0x55)) 0xAA, 0x55, 0xAA, 0x55))
Graphics.fillEllipse(x: 100, y: 100, width: 64, height: 64, Graphics.fillEllipse(x: 100, y: 100, width: 64, height: 64,
color: .pattern(checker)) color: .pattern(checker))
// Bitmaps draw themselves; draw into one by pushing it as the context. // Draw into a bitmap by pushing it as the drawing context.
let logo = try Graphics.Bitmap(path: "images/logo") let logo = try Graphics.Bitmap(path: "images/logo")
logo.draw(x: 168, y: 88) logo.draw(x: 168, y: 88)
@@ -194,7 +191,7 @@ ball.setImage(try Graphics.Bitmap(path: "images/ball"))
ball.moveTo(x: 200, y: 120) ball.moveTo(x: 200, y: 120)
ball.collideRect = Rect(x: 0, y: 0, width: 16, height: 16) ball.collideRect = Rect(x: 0, y: 0, width: 16, height: 16)
ball.setCollisionResponseFunction { _, _ in .bounce } ball.setCollisionResponseFunction { _, _ in .bounce }
ball.add() // adds to the display list; the binding keeps it alive while added ball.add() // the display list keeps the sprite alive until it is removed
// In the update callback: // In the update callback:
let (actual, collisions) = ball.moveWithCollisions(goalX: goalX, goalY: goalY) let (actual, collisions) = ball.moveWithCollisions(goalX: goalX, goalY: goalY)
@@ -202,23 +199,22 @@ for collision in collisions where collision.other.tag == Tags.brick {
collision.other.remove() collision.other.remove()
} }
// Every collision/query API also has a visitor form that iterates the // Collision and query APIs also have a visitor form that allocates no array:
// results in place instead of building an array — useful in hot loops:
ball.moveWithCollisions(goalX: goalX, goalY: goalY) { collision in ball.moveWithCollisions(goalX: goalX, goalY: goalY) { collision in
if collision.other.tag == Tags.brick { collision.other.remove() } if collision.other.tag == Tags.brick { collision.other.remove() }
} }
``` ```
Sprite callbacks (`setUpdateFunction`, `setDrawFunction`, `setCollisionResponseFunction`) receive the Swift wrapper back. The C-level sprite userdata slot is reserved by the binding for that recovery — use the `userdata` property on `Sprite` for your own per-sprite storage instead. The binding owns the C userdata slot; store your own per-sprite data in `Sprite.userdata`.
### Sound ### Sound
```swift ```swift
// Stream music from disk. // Stream from disk.
let music = try Sound.FilePlayer(path: "audio/theme") let music = try Sound.FilePlayer(path: "audio/theme")
music.play(repeat: 0) // 0 = loop forever music.play(repeat: 0) // 0 = loop forever
// Play short effects from memory. // Play from memory.
let blip = try Sound.SamplePlayer(path: "audio/blip") let blip = try Sound.SamplePlayer(path: "audio/blip")
blip.play() blip.play()
@@ -236,7 +232,7 @@ let filter = Sound.TwoPoleFilter(kind: .lowPass)
filter.setFrequency(800) filter.setFrequency(800)
channel.addEffect(filter) channel.addEffect(filter)
// Anything that takes a modulator accepts any SignalValue (LFO, Envelope, …). // Modulator properties accept any SignalValue (LFO, Envelope, ).
let wobble = Sound.LFO(shape: .sine) let wobble = Sound.LFO(shape: .sine)
wobble.setRate(2) wobble.setRate(2)
synth.frequencyModulator = wobble synth.frequencyModulator = wobble
@@ -245,7 +241,7 @@ synth.frequencyModulator = wobble
### Files and JSON ### Files and JSON
```swift ```swift
// Paths resolve against the game's Data directory and pdx per the open mode. // The open mode decides whether paths resolve in the Data directory or the pdx.
let save = try File.Handle(path: "save.json", mode: .write) let save = try File.Handle(path: "save.json", mode: .write)
try save.write(JSON.encode(.table([ try save.write(JSON.encode(.table([
"level": .int(3), "level": .int(3),
@@ -253,7 +249,7 @@ try save.write(JSON.encode(.table([
]))) ])))
try save.close() try save.close()
let loaded = try JSON.decodeFile(at: "save.json") let loaded = try JSON.decodeFile(path: "save.json")
if case .table(let entries) = loaded, case .int(let level)? = entries["level"] { if case .table(let entries) = loaded, case .int(let level)? = entries["level"] {
Game.shared.level = level Game.shared.level = level
} }
@@ -265,7 +261,7 @@ try File.listFiles(at: "replays") { name in
### Network ### Network
Network access requires user permission per server: Each server needs the user's permission:
```swift ```swift
let reply = Network.HTTPConnection.requestAccess( let reply = Network.HTTPConnection.requestAccess(
@@ -278,7 +274,7 @@ func fetch() {
guard let connection = Network.HTTPConnection(server: "example.com") else { return } guard let connection = Network.HTTPConnection(server: "example.com") else { return }
connection.setRequestCompleteCallback { connection in connection.setRequestCompleteCallback { connection in
let body = try? connection.read(length: connection.bytesAvailable) let body = try? connection.read(length: connection.bytesAvailable)
// … keep `connection` referenced somewhere until this fires … // Keep `connection` referenced until this callback fires.
} }
try? connection.get(path: "/daily.json") try? connection.get(path: "/daily.json")
} }
@@ -286,8 +282,7 @@ func fetch() {
### Lua interop ### Lua interop
Lua callbacks are C function pointers with no context, so they must be Lua callbacks are C function pointers with no context, so they cannot capture:
`@convention(c)` functions rather than capturing closures:
```swift ```swift
let double: Lua.CFunction = { _ in let double: Lua.CFunction = { _ in
@@ -299,93 +294,80 @@ try Lua.addFunction(double, name: "mylib.double")
## Conventions ## Conventions
- **Namespaces.** The subsystem namespaces (`System`, `Graphics`, `Sound`, …) live at the top level of the module; only the raw C API bootstrap stays under `Playdate` (`Playdate.initialize(with:)`, `Playdate.api`). On a name collision with another module, qualify with the module name: `PlaydateKit.System`. - **Namespaces.** Subsystems are top-level; only the bootstrap lives in `Playdate`. Qualify on a clash: `PlaydateKit.System`.
- **Errors.** Fallible operations use typed throws — `throws(PlaydateError)` generally, `throws(Network.NetError)` for network I/O — so `catch` gives you a concrete type, and no `any Error` existentials are needed. - **Properties vs. methods.** Readable state is a property; `set…` methods are write-only or take extra arguments. Callbacks: `set…Callback` / `set…Function`.
- **Ownership.** A wrapper that *creates* a C object frees it on `deinit`; keep the wrapper referenced for as long as you use it. Wrappers vending OS-owned objects (a `Bitmap` from a `BitmapTable`, a track from a `Sequence`, …) don't free them — keep the owner alive instead, as documented on each API. Resources a C object keeps referencing (a sprite's image, a synth's sample, modulators, menu-item option titles) are retained by the wrapper automatically. - **Paths.** `path:` for files, `at:` for directories; `File.stat`/`mkdir`/`unlink` are unlabeled like C.
- **Callbacks.** Where the C API provides a userdata slot, closures are supported everywhere and delivered back with the right wrapper. A few C callbacks have no userdata (serial messages, headphone changes, scoreboard - **Errors.** Typed throws: `PlaydateError`, or `Network.NetError` for network I/O.
completions, `getServerTime`); those track one Swift closure at a time, as noted in their documentation. - **Buffers.** `Span`/`MutableSpan`, valid only during the call. Mono audio gets an empty `right`.
- **Threading.** The Playdate runtime is single-threaded (audio callbacks excepted); statics in the binding are `nonisolated(unsafe)` on that basis. Don't call the API from other threads. - **Ownership.** Wrappers free what they create on `deinit` and retain what their C object references. Objects vended by the OS (a table's `Bitmap`, a `Sequence` track) need their owner alive. `File.Handle` is non-copyable and closes at end of scope or on `close()`.
- **Callbacks.** C callbacks without a userdata slot (serial, headphones, scoreboards, `getServerTime`) keep one closure at a time.
- **Threading.** Single-threaded except audio callbacks; don't call the API from other threads.
## Building for the simulator and device ## Building for the simulator and device
This package builds as a plain Swift library, which is how you develop and unit-test game logic on the host (`swift test` works out of the box). Game logic builds and tests on the host with `swift build` / `swift test`. For a `.pdx`:
Shipping a `.pdx` needs the Playdate toolchain on top: - **Simulator:** a host dylib inside the pdx; see `Examples/HelloPlaydate/build.sh`.
- **Device:** Embedded Swift for `armv7em-none-none-eabi` (`-fshort-enums`), linked by the SDK's make rules.
- **Simulator** builds compile your game as a host dylib placed in the pdx (`Examples/HelloPlaydate/build.sh` shows the SwiftPM-based flow). [`Examples/swift.mk`](Examples/swift.mk) runs the device pipeline; a game adds a short Makefile like [`Examples/HelloPlaydate/Makefile`](Examples/HelloPlaydate/Makefile), and `make` builds a `.pdx` with both binaries. Swift compiles `-Osize` (~15% smaller than `-O`); override with `make SWIFT_OPT=-O`. Adapted from Apple's [swift-playdate-examples](https://github.com/apple/swift-playdate-examples).
- **Device** builds cross-compile with Embedded Swift for ARM Cortex-M7 (`-enable-experimental-feature Embedded`, triple `armv7em-none-none-eabi`, `-fshort-enums` to match the firmware ABI) and link through the SDK's own make infrastructure.
[`Examples/swift.mk`](Examples/swift.mk) packages the device pipeline: it locates the SDK and a Swift snapshot toolchain, compiles the `PlaydateKit` wrapper as a module-aliased Embedded Swift module, and hooks the game objects into the SDK's `common.mk`. A game needs only a short Makefile on top — [`Examples/HelloPlaydate/Makefile`](Examples/HelloPlaydate/Makefile) is the template, and `make` in that directory produces a `.pdx` containing both the device binary and the simulator dylib. Swift code is compiled `-Osize` by default (measured ~15% smaller than `-O` on the example); override with `make SWIFT_OPT=-O`. The setup follows Apple's [swift-playdate-examples](https://github.com/apple/swift-playdate-examples), adapted to this package. The library stays within Embedded Swift (no Foundation, reflection, or untyped throws). `make embedded` checks this by compiling the module for the device; it uses `~/Library/Developer/Toolchains/swift-latest.xctoolchain` unless `SWIFT_BIN=<path to swift>` is set.
The wrappers are written within the Embedded Swift subset for exactly this reason: no Foundation, no reflection, no untyped throws. That claim is enforced, not aspirational: the check cross-compiles the whole module for `armv7em-none-none-eabi` with Embedded Swift enabled and the device's `-fshort-enums` ABI. Running it needs the same snapshot toolchain and Arm GNU toolchain headers:
```sh
make embedded
```
The target picks up a snapshot toolchain installed at `~/Library/Developer/Toolchains` automatically; point it at another one with `make embedded SWIFT_BIN=<path to swift>`.
## Make targets ## Make targets
A root Makefile fronts the development lifecycle; a bare `make` (or `make help`) lists the targets. `build`, `test`, `docs`, and `consumer-test` need only Xcode's toolchain (after the one-time `make setup`); `embedded` and the example targets additionally need the device toolchains above. `embedded` and `example*` need the device toolchains; the rest need only Xcode and `make setup`.
| Target | Effect | | Target | Effect |
|---|---| |---|---|
| `make setup` | One-time: point the `playdate` pkg-config module at the SDK | | `make setup` | Point the `playdate` pkg-config module at the SDK (once per machine) |
| `make build` / `make test` | Build the bindings for the host / run the unit tests | | `make build` / `make test` | Build the bindings for the host / run the unit tests |
| `make outdated` / `make upgrade` | Show / apply updates to the SwiftPM dependencies | | `make outdated` / `make upgrade` | Show / apply updates to the SwiftPM dependencies |
| `make embedded` | Compile-only device check (Embedded Swift, `armv7em-none-none-eabi`) | | `make embedded` | Compile-only device check (Embedded Swift, `armv7em-none-none-eabi`) |
| `make consumer-test` | Build and run a scratch package depending on playdate-kit | | `make consumer-test` | Build and run a scratch package depending on playdate-kit |
| `make check` | The full verification suite: build, test, embedded, consumer-test | | `make check` | `build`, `test`, `embedded`, and `consumer-test` |
| `make docs` / `make docs-preview` | Generate / preview the DocC documentation | | `make docs` / `make docs-preview` | Generate / preview the DocC documentation |
| `make example` / `make example-run` | Build the HelloPlaydate example / open it in the Playdate Simulator | | `make example` / `make example-run` | Build the HelloPlaydate example / open it in the Playdate Simulator |
| `make clean` | Remove build products of the package and the example | | `make clean` | Remove build products of the package and the example |
## Example ## Example
[`Examples/HelloPlaydate`](Examples/HelloPlaydate) is a complete minimal game — bouncing box, crank needle, button handling, a system menu item — that builds into a runnable `.pdx`: [`Examples/HelloPlaydate`](Examples/HelloPlaydate): a bouncing box, crank needle, buttons, and a menu item.
```sh ```sh
cd Examples/HelloPlaydate cd Examples/HelloPlaydate
# Simulator only (plain SwiftPM, no extra toolchains): # Simulator only (SwiftPM, no extra toolchains):
./build.sh ./build.sh
open -a "$HOME/Developer/PlaydateSDK/bin/Playdate Simulator.app" HelloPlaydate.pdx open -a "$HOME/Developer/PlaydateSDK/bin/Playdate Simulator.app" HelloPlaydate.pdx
# Device + simulator (snapshot toolchain and arm-none-eabi-gcc required): # Device and Simulator (swift.org toolchain and arm-none-eabi-gcc required):
make make
``` ```
Sideload the device build from the Playdate Simulator (Device ▸ Upload Game to Device) or with the SDK's `pdutil`. Install on a device with Device ▸ Upload Game to Device in the Simulator, or `pdutil`.
## Documentation ## Documentation
The API reference is a DocC catalog. Generate it locally with: DocC: `make docs`, `make docs-preview`, or Xcode's Product ▸ Build Documentation.
```sh
make docs
```
serve it in a local web server with `make docs-preview`, or browse it with Xcode's documentation viewer (Product ▸ Build Documentation).
## Layout ## Layout
``` ```
Examples/ Examples/
swift.mk Shared make rules for device builds: toolchain/SDK discovery and Embedded Swift compile flags swift.mk Device build rules
HelloPlaydate/ Minimal game buildable into a .pdx for the simulator (build.sh) or simulator + device (make) HelloPlaydate/ Example game
Scripts/ Scripts/
install-pkgconfig.sh One-time setup: points the "playdate" pkg-config module at your SDK installation install-pkgconfig.sh Writes the playdate pkg-config module
build-embedded.sh Compile-only device check: Embedded Swift for armv7em-none-none-eabi with the device ABI build-embedded.sh Device compile check
consumer-test.sh Builds a scratch package depending on playdate-kit to prove settings propagate to consumers consumer-test.sh Builds a package that depends on playdate-kit
Sources/ Sources/
CPlaydate/ System library target: module map + umbrella header importing pd_api.h from the SDK, plus inline shims for the variadic log/error functions CPlaydate/ pd_api.h module and log/error shims
PlaydateKit/ The Swift bindings, one folder per subsystem (System, Graphics, Sound, ...) holding one type per file, grouped by kind (Classes, Structures, Enumerations, Aliases); PlaydateKit/ Bindings: a folder per subsystem, a type per file, plus the DocC catalog
Sound is further split into the Source, Signal, Effect, and Synth subdomains. Also holds the shared Support.swift helpers and the PlaydateKit.docc documentation catalog
Tests/ Tests/
PlaydateKit/ Host-runnable tests for the pure value types PlaydateKit/ Tests against a mock PlaydateAPI
``` ```
## License ## License
MIT — see [LICENSE](LICENSE). The Playdate SDK itself is licensed separately by Panic, Inc. and is not distributed with this package. MIT ([LICENSE](LICENSE)). The Playdate SDK is licensed separately by Panic, Inc.
+45 -14
View File
@@ -12,7 +12,14 @@
# libc headers that bare-metal builds resolve against newlib. Override the # libc headers that bare-metal builds resolve against newlib. Override the
# directory with ARM_NONE_EABI_INCLUDE, otherwise common install locations # directory with ARM_NONE_EABI_INCLUDE, otherwise common install locations
# are searched. # are searched.
# - The "playdate" pkg-config module (Scripts/install-pkgconfig.sh). # - The Playdate SDK, located via $PLAYDATE_SDK_PATH (default
# ~/Developer/PlaydateSDK), for pd_api.h.
#
# The module is compiled with swiftc directly rather than `swift build`:
# SwiftPM's default build system links the target's objects with Darwin
# linker flags even for bare-metal ELF targets, which neither ld64 nor
# ld.lld accept, and the native build system that stopped after compiling
# is deprecated.
set -eu set -eu
@@ -38,21 +45,45 @@ if [ -z "$include_dir" ] || [ ! -f "$include_dir/stdlib.h" ]; then
exit 1 exit 1
fi fi
echo "Using swift: $swift_bin ($($swift_bin --version 2>/dev/null | head -1))" repo_root="$(cd "$(dirname "$0")/.." && pwd)"
echo "Using arm-none-eabi headers: $include_dir" swiftc_bin="$(dirname "$(command -v "$swift_bin")")/swiftc"
sdk_path="${PLAYDATE_SDK_PATH:-$HOME/Developer/PlaydateSDK}"
output_dir="$repo_root/.build/embedded"
# --build-system native stops after compilation; the default build system if [ ! -f "$sdk_path/C_API/pd_api.h" ]; then
# also tries to merge objects with the host linker, which cannot process echo "error: pd_api.h not found under '$sdk_path/C_API'." >&2
# bare-metal ELF objects. echo "Install the Playdate SDK or set PLAYDATE_SDK_PATH." >&2
exec "$swift_bin" build \ exit 1
--build-system native \ fi
--target PlaydateKit \
--triple armv7em-none-none-eabi \ echo "Using swiftc: $swiftc_bin ($("$swiftc_bin" --version 2>/dev/null | head -1))"
-Xswiftc -enable-experimental-feature -Xswiftc Embedded \ echo "Using arm-none-eabi headers: $include_dir"
-Xswiftc -wmo \ echo "Using Playdate SDK: $sdk_path"
-Xswiftc -Osize \
mkdir -p "$output_dir"
# The upcoming features mirror the target's swiftSettings in Package.swift.
find "$repo_root/Sources/PlaydateKit" -name '*.swift' -exec "$swiftc_bin" \
-module-name PlaydateKit \
-parse-as-library \
-swift-version 6 \
-enable-upcoming-feature ExistentialAny \
-enable-upcoming-feature InternalImportsByDefault \
-enable-upcoming-feature MemberImportVisibility \
-target armv7em-none-none-eabi \
-enable-experimental-feature Embedded \
-wmo \
-Osize \
-I "$repo_root/Sources/CPlaydate" \
-Xcc -I"$sdk_path/C_API" \
-Xcc -I"$include_dir" \ -Xcc -I"$include_dir" \
-Xcc -mcpu=cortex-m7 \ -Xcc -mcpu=cortex-m7 \
-Xcc -mfloat-abi=hard \ -Xcc -mfloat-abi=hard \
-Xcc -mfpu=fpv5-sp-d16 \ -Xcc -mfpu=fpv5-sp-d16 \
-Xcc -fshort-enums -Xcc -fshort-enums \
-module-cache-path "$output_dir/module-cache" \
-emit-module -emit-module-path "$output_dir/PlaydateKit.swiftmodule" \
-c -o "$output_dir/PlaydateKit.o" \
{} +
echo "Compiled $output_dir/PlaydateKit.o"
+12 -18
View File
@@ -1,52 +1,46 @@
internal import CPlaydate internal import CPlaydate
/// The display API: resolution, refresh rate, scaling, and effects. /// Display size, refresh rate, scale, and effects. Wraps `playdate_display`.
public enum Display {} public enum Display {}
extension Display { extension Display {
/// The cached `playdate->display` C API table.
private static var api: UnsafePointer<playdate_display> { Playdate.displayAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_display> { Playdate.displayAPI.unsafelyUnwrapped }
/// The display width in pixels, taking the current scale into account. /// Pixels at the current scale (200 at scale 2).
public static var width: Int { Int(api.pointee.getWidth.unsafelyUnwrapped()) } public static var width: Int { Int(api.pointee.getWidth.unsafelyUnwrapped()) }
/// The display height in pixels, taking the current scale into account. /// Pixels at the current scale (120 at scale 2).
public static var height: Int { Int(api.pointee.getHeight.unsafelyUnwrapped()) } public static var height: Int { Int(api.pointee.getHeight.unsafelyUnwrapped()) }
/// Sets the nominal refresh rate in frames per second. Pass 0 to update /// Target frames per second; default 30, max 50. 0 updates as fast as possible.
/// as fast as possible (the update callback drives the pace). public static var refreshRate: Float {
public static func setRefreshRate(_ rate: Float) { get { api.pointee.getRefreshRate.unsafelyUnwrapped() }
api.pointee.setRefreshRate.unsafelyUnwrapped(rate) set { api.pointee.setRefreshRate.unsafelyUnwrapped(newValue) }
} }
/// The current nominal refresh rate. /// Measured frames per second; can fall below `refreshRate` on slow frames.
public static var refreshRate: Float { api.pointee.getRefreshRate.unsafelyUnwrapped() }
/// The measured average frames per second.
public static var fps: Float { api.pointee.getFPS.unsafelyUnwrapped() } public static var fps: Float { api.pointee.getFPS.unsafelyUnwrapped() }
/// Draws the frame white-on-black when `true`. /// `true` swaps black and white.
public static func setInverted(_ inverted: Bool) { public static func setInverted(_ inverted: Bool) {
api.pointee.setInverted.unsafelyUnwrapped(inverted ? 1 : 0) api.pointee.setInverted.unsafelyUnwrapped(inverted ? 1 : 0)
} }
/// Sets the display scale factor: 1, 2, 4, or 8. /// Valid values: 1, 2, 4, 8.
public static func setScale(_ scale: UInt32) { public static func setScale(_ scale: UInt32) {
api.pointee.setScale.unsafelyUnwrapped(scale) api.pointee.setScale.unsafelyUnwrapped(scale)
} }
/// Adds a mosaic effect. Valid values for each axis are 0...3. /// Mosaic effect; `x` and `y` in 0...3.
public static func setMosaic(x: UInt32, y: UInt32) { public static func setMosaic(x: UInt32, y: UInt32) {
api.pointee.setMosaic.unsafelyUnwrapped(x, y) api.pointee.setMosaic.unsafelyUnwrapped(x, y)
} }
/// Flips the display on the given axes.
public static func setFlipped(x: Bool, y: Bool) { public static func setFlipped(x: Bool, y: Bool) {
api.pointee.setFlipped.unsafelyUnwrapped(x ? 1 : 0, y ? 1 : 0) api.pointee.setFlipped.unsafelyUnwrapped(x ? 1 : 0, y ? 1 : 0)
} }
/// Offsets the display by the given amount. Areas outside the frame /// Offset in pixels; uncovered areas show the current background color.
/// buffer draw black.
public static func setOffset(x: Int, y: Int) { public static func setOffset(x: Int, y: Int) {
api.pointee.setOffset.unsafelyUnwrapped(Int32(x), Int32(y)) api.pointee.setOffset.unsafelyUnwrapped(Int32(x), Int32(y))
} }
+44 -44
View File
@@ -1,15 +1,14 @@
internal import CPlaydate internal import CPlaydate
extension File { extension File {
/// An open file. Wraps `SDFile`. The file is closed on deinit if it has /// An open file. Wraps `SDFile`. Non-copyable: closes when the handle goes out of
/// not been closed explicitly. /// scope, or earlier via consuming `close()`. At most 64 files may be open.
public final class Handle { public struct Handle: ~Copyable {
let pointer: UnsafeMutableRawPointer let pointer: UnsafeMutableRawPointer
private var isClosed = false
/// Opens the file at `path`. /// Opens the file at `path` in `mode`.
public init(path: String, mode: Options) throws(PlaydateError) { public init(path: String, mode: Options) throws(PlaydateError) {
let pointer = path.withPlaydateCString { let pointer = path.withCString {
fileAPI.pointee.open.unsafelyUnwrapped($0, mode.cValue) fileAPI.pointee.open.unsafelyUnwrapped($0, mode.cValue)
} }
guard let pointer else { throw lastFileError() } guard let pointer else { throw lastFileError() }
@@ -17,68 +16,69 @@ extension File {
} }
deinit { deinit {
if !isClosed {
_ = fileAPI.pointee.close.unsafelyUnwrapped(pointer) _ = fileAPI.pointee.close.unsafelyUnwrapped(pointer)
} }
}
/// Closes the file. Further operations are invalid. /// Closes the file, consuming the handle.
public func close() throws(PlaydateError) { // `@export(interface)` lets `discard` compile in Embedded Swift on the 6.4 toolchain.
guard !isClosed else { return } @export(interface)
isClosed = true public consuming func close() throws(PlaydateError) {
let pointer = self.pointer
discard self
if fileAPI.pointee.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() } if fileAPI.pointee.close.unsafelyUnwrapped(pointer) != 0 { throw lastFileError() }
} }
/// Reads up to `buffer.count` bytes into `buffer`. Returns the number /// Reads up to `buffer.count` bytes; returns the count read, 0 at end of file.
/// of bytes read; 0 indicates end of file. public func read(into buffer: inout MutableSpan<UInt8>) throws(PlaydateError) -> Int {
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(PlaydateError) -> Int { let result = buffer.withUnsafeMutableBufferPointer { buffer in
let result = fileAPI.pointee.read.unsafelyUnwrapped(
pointer, buffer.baseAddress, UInt32(buffer.count))
if result < 0 { throw lastFileError() }
return Int(result)
}
/// Reads up to `length` bytes and returns them.
public func read(length: Int) throws(PlaydateError) -> [UInt8] {
var bytes = [UInt8](repeating: 0, count: length)
let result = bytes.withUnsafeMutableBytes { buffer in
fileAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) fileAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
} }
if result < 0 { throw lastFileError() } if result < 0 { throw lastFileError() }
bytes.removeLast(length - Int(result))
return bytes
}
/// Writes the buffer to the file. Returns the number of bytes written.
@discardableResult
public func write(_ buffer: UnsafeRawBufferPointer) throws(PlaydateError) -> Int {
let result = fileAPI.pointee.write.unsafelyUnwrapped(
pointer, buffer.baseAddress, UInt32(buffer.count))
if result < 0 { throw lastFileError() }
return Int(result) return Int(result)
} }
/// Writes the bytes to the file. Returns the number of bytes written. /// Reads up to `length` bytes; shorter near end of file, empty at it.
public func read(length: Int) throws(PlaydateError) -> [UInt8] {
try [UInt8](capacity: length) { output throws(PlaydateError) in
let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in
let result = fileAPI.pointee.read.unsafelyUnwrapped(
pointer, buffer.baseAddress, UInt32(buffer.count))
initializedCount = max(Int(result), 0)
return result
}
if result < 0 { throw lastFileError() }
}
}
/// Writes `bytes`; returns the count written.
@discardableResult @discardableResult
public func write(_ bytes: [UInt8]) throws(PlaydateError) -> Int { public func write(_ bytes: Span<UInt8>) throws(PlaydateError) -> Int {
let result = bytes.withUnsafeBytes { buffer in let result = bytes.withUnsafeBufferPointer { buffer in
fileAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) fileAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
} }
if result < 0 { throw lastFileError() } if result < 0 { throw lastFileError() }
return Int(result) return Int(result)
} }
/// Writes the string's UTF-8 to the file. Returns the bytes written. /// Writes `bytes`; returns the count written.
@discardableResult
public func write(_ bytes: [UInt8]) throws(PlaydateError) -> Int {
try bytes.withUnsafeBufferPointer { buffer throws(PlaydateError) in
try write(buffer.span)
}
}
/// Writes `string` as UTF-8, without a NUL terminator; returns the count written.
@discardableResult @discardableResult
public func write(_ string: String) throws(PlaydateError) -> Int { public func write(_ string: String) throws(PlaydateError) -> Int {
let result = string.withPlaydateUTF8 { bytes, count in let result = string.withCString { cString in
fileAPI.pointee.write.unsafelyUnwrapped(pointer, bytes, UInt32(count)) fileAPI.pointee.write.unsafelyUnwrapped(pointer, cString, UInt32(string.utf8.count))
} }
if result < 0 { throw lastFileError() } if result < 0 { throw lastFileError() }
return Int(result) return Int(result)
} }
/// Flushes buffered writes to disk. Returns the bytes written. /// Flushes buffered writes; returns the count written.
@discardableResult @discardableResult
public func flush() throws(PlaydateError) -> Int { public func flush() throws(PlaydateError) -> Int {
let result = fileAPI.pointee.flush.unsafelyUnwrapped(pointer) let result = fileAPI.pointee.flush.unsafelyUnwrapped(pointer)
@@ -86,14 +86,14 @@ extension File {
return Int(result) return Int(result)
} }
/// The current read/write offset. /// The current read/write offset, in bytes.
public func tell() throws(PlaydateError) -> Int { public func tell() throws(PlaydateError) -> Int {
let result = fileAPI.pointee.tell.unsafelyUnwrapped(pointer) let result = fileAPI.pointee.tell.unsafelyUnwrapped(pointer)
if result < 0 { throw lastFileError() } if result < 0 { throw lastFileError() }
return Int(result) return Int(result)
} }
/// Moves the read/write offset to `offset` relative to `origin`. /// Moves the read/write offset to `offset` bytes from `origin`.
public func seek(to offset: Int, from origin: SeekOrigin = .start) throws(PlaydateError) { public func seek(to offset: Int, from origin: SeekOrigin = .start) throws(PlaydateError) {
if fileAPI.pointee.seek.unsafelyUnwrapped(pointer, Int32(offset), origin.rawValue) != 0 { if fileAPI.pointee.seek.unsafelyUnwrapped(pointer, Int32(offset), origin.rawValue) != 0 {
throw lastFileError() throw lastFileError()
@@ -1,11 +1,8 @@
extension File { extension File {
/// The origin used by `Handle.seek(to:from:)`. /// Origin for `Handle.seek(to:from:)`: `SEEK_SET`, `SEEK_CUR`, `SEEK_END`.
public enum SeekOrigin: Int32, Sendable { public enum SeekOrigin: Int32, Sendable {
/// Relative to the beginning of the file.
case start = 0 case start = 0
/// Relative to the current offset.
case current = 1 case current = 1
/// Relative to the end of the file.
case end = 2 case end = 2
} }
} }
+18 -21
View File
@@ -1,34 +1,32 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->file` C API table. /// Cached `playdate->file` table.
var fileAPI: UnsafePointer<playdate_file> { Playdate.fileAPI.unsafelyUnwrapped } var fileAPI: UnsafePointer<playdate_file> { Playdate.fileAPI.unsafelyUnwrapped }
/// The most recent file system error as a thrown error. /// The most recent file error, with the OS's description.
func lastFileError() -> PlaydateError { func lastFileError() -> PlaydateError {
PlaydateError(cString: fileAPI.pointee.geterr.unsafelyUnwrapped()) PlaydateError(cString: fileAPI.pointee.geterr.unsafelyUnwrapped())
} }
/// The file API: access to the game's Data directory and pdx contents. /// The file API. Paths are relative to the Data directory (writable) or the pdx (read-only).
/// /// Every throwing API throws `PlaydateError` with the OS's description on failure.
/// Paths are relative to the game's Data directory (read/write) or the
/// game's pdx (read-only), depending on the mode used to open them.
public enum File {} public enum File {}
extension File { extension File {
// MARK: - Directory operations // MARK: - Directory operations
/// Calls `each` with the name of every file in `path`. Subdirectory names /// Calls `each` with each entry name in `path`, non-recursively; directories end in `/`.
/// end in a slash. Throws if the directory does not exist. /// Skips `.`-prefixed names unless `showHidden`. Throws if `path` can't be opened.
public static func listFiles(at path: String, showHidden: Bool = false, public static func listFiles(at path: String, showHidden: Bool = false,
_ each: (String) -> Void) throws(PlaydateError) { _ each: (String) -> Void) throws(PlaydateError) {
let result = withoutActuallyEscaping(each) { each in let result = withoutActuallyEscaping(each) { each in
var callback = each var callback = each
return path.withPlaydateCString { cPath in return path.withCString { cPath in
withUnsafeMutablePointer(to: &callback) { callbackPointer in withUnsafeMutablePointer(to: &callback) { callbackPointer in
fileAPI.pointee.listfiles.unsafelyUnwrapped(cPath, { cName, userdata in fileAPI.pointee.listfiles.unsafelyUnwrapped(cPath, { cName, userdata in
guard let cName, let userdata else { return } guard let cName, let userdata else { return }
let each = userdata.assumingMemoryBound(to: ((String) -> Void).self).pointee let each = userdata.assumingMemoryBound(to: ((String) -> Void).self).pointee
each(String(playdateCString: cName)) each(String(cString: cName))
}, callbackPointer, showHidden ? 1 : 0) }, callbackPointer, showHidden ? 1 : 0)
} }
} }
@@ -36,10 +34,10 @@ extension File {
if result != 0 { throw lastFileError() } if result != 0 { throw lastFileError() }
} }
/// Information about the file or directory at `path`. /// Information about the file or directory at `path`; throws if it is missing.
public static func stat(_ path: String) throws(PlaydateError) -> Stat { public static func stat(_ path: String) throws(PlaydateError) -> Stat {
var stat = FileStat() var stat = FileStat()
let result = path.withPlaydateCString { fileAPI.pointee.stat.unsafelyUnwrapped($0, &stat) } let result = path.withCString { fileAPI.pointee.stat.unsafelyUnwrapped($0, &stat) }
if result != 0 { throw lastFileError() } if result != 0 { throw lastFileError() }
return Stat( return Stat(
isDirectory: stat.isdir != 0, isDirectory: stat.isdir != 0,
@@ -49,26 +47,25 @@ extension File {
hour: UInt8(stat.m_hour), minute: UInt8(stat.m_minute), second: UInt8(stat.m_second))) hour: UInt8(stat.m_hour), minute: UInt8(stat.m_minute), second: UInt8(stat.m_second)))
} }
/// Creates a directory (and intermediate directories) in the Data directory. /// Creates directory `path` in the Data directory; does not create intermediate ones.
public static func mkdir(_ path: String) throws(PlaydateError) { public static func mkdir(_ path: String) throws(PlaydateError) {
let result = path.withPlaydateCString { fileAPI.pointee.mkdir.unsafelyUnwrapped($0) } let result = path.withCString { fileAPI.pointee.mkdir.unsafelyUnwrapped($0) }
if result != 0 { throw lastFileError() } if result != 0 { throw lastFileError() }
} }
/// Deletes the file or directory at `path`. Directories require /// Deletes the file at `path`; with `recursive`, a directory and its contents.
/// `recursive` to be deleted with their contents.
public static func unlink(_ path: String, recursive: Bool = false) throws(PlaydateError) { public static func unlink(_ path: String, recursive: Bool = false) throws(PlaydateError) {
let result = path.withPlaydateCString { let result = path.withCString {
fileAPI.pointee.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0) fileAPI.pointee.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0)
} }
if result != 0 { throw lastFileError() } if result != 0 { throw lastFileError() }
} }
/// Renames (moves) a file in the Data directory, overwriting any existing /// Moves `from` to `to` in the Data directory, overwriting `to`; does not create
/// file at the destination. /// intermediate directories.
public static func rename(from: String, to: String) throws(PlaydateError) { public static func rename(from: String, to: String) throws(PlaydateError) {
let result = from.withPlaydateCString { cFrom in let result = from.withCString { cFrom in
to.withPlaydateCString { cTo in to.withCString { cTo in
fileAPI.pointee.rename.unsafelyUnwrapped(cFrom, cTo) fileAPI.pointee.rename.unsafelyUnwrapped(cFrom, cTo)
} }
} }
@@ -1,18 +1,18 @@
internal import CPlaydate internal import CPlaydate
extension File { extension File {
/// How to open a file. /// How to open a file. Wraps `FileOptions`.
public struct Options: OptionSet, Sendable { public struct Options: OptionSet, Sendable {
public let rawValue: UInt32 public let rawValue: UInt32
public init(rawValue: UInt32) { self.rawValue = rawValue } public init(rawValue: UInt32) { self.rawValue = rawValue }
/// Read from the game pdx, then the Data directory. /// Read from the pdx only; add `.readData` to search the Data directory first.
public static let read = Options(rawValue: UInt32(kFileRead.rawValue)) public static let read = Options(rawValue: UInt32(kFileRead.rawValue))
/// Read from the Data directory only. /// Read from the Data directory.
public static let readData = Options(rawValue: UInt32(kFileReadData.rawValue)) public static let readData = Options(rawValue: UInt32(kFileReadData.rawValue))
/// Write to the Data directory, truncating an existing file. /// Write to the Data directory, truncating.
public static let write = Options(rawValue: UInt32(kFileWrite.rawValue)) public static let write = Options(rawValue: UInt32(kFileWrite.rawValue))
/// Write to the Data directory, appending to an existing file. /// Write to the Data directory, appending.
public static let append = Options(rawValue: UInt32(kFileAppend.rawValue)) public static let append = Options(rawValue: UInt32(kFileAppend.rawValue))
var cValue: FileOptions { FileOptions(FileOptions.RawValue(rawValue)) } var cValue: FileOptions { FileOptions(FileOptions.RawValue(rawValue)) }
@@ -1,11 +1,10 @@
extension File { extension File {
/// Information about a file or directory, mirroring `FileStat`. /// File or directory information. Mirrors `FileStat`.
public struct Stat: Sendable { public struct Stat: Sendable {
/// Whether the path is a directory.
public let isDirectory: Bool public let isDirectory: Bool
/// The file's size, in bytes. /// Size in bytes.
public let size: UInt32 public let size: UInt32
/// The time the file was last modified. /// Last modification time; `weekday` is 0 (unset).
public let modified: System.DateTime public let modified: System.DateTime
} }
} }
@@ -1,21 +1,21 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// An image that can be drawn to the screen or used as a drawing target. /// A drawable image and drawing target. Wraps `LCDBitmap`. Bitmaps borrowed from
/// Wraps `LCDBitmap`. /// tables, fonts, video players, or the system live only as long as their owner.
public final class Bitmap { public final class Bitmap {
let pointer: OpaquePointer let pointer: OpaquePointer
/// Whether this wrapper owns the underlying `LCDBitmap` and frees it /// Whether deinit frees the `LCDBitmap`.
/// on deinit. Bitmaps vended by tables or the system are not owned;
/// keep their owner alive while using them.
let isOwned: Bool let isOwned: Bool
/// Kept alive because this bitmap shares its pixels.
private let owner: Bitmap?
init(pointer: OpaquePointer, isOwned: Bool) { init(pointer: OpaquePointer, isOwned: Bool, owner: Bitmap? = nil) {
self.pointer = pointer self.pointer = pointer
self.isOwned = isOwned self.isOwned = isOwned
self.owner = owner
} }
/// Allocates a new bitmap filled with `backgroundColor`.
public convenience init(width: Int, height: Int, backgroundColor: Color = .clear) { public convenience init(width: Int, height: Int, backgroundColor: Color = .clear) {
let pointer = backgroundColor.withLCDColor { let pointer = backgroundColor.withLCDColor {
gfx.pointee.newBitmap.unsafelyUnwrapped(Int32(width), Int32(height), $0) gfx.pointee.newBitmap.unsafelyUnwrapped(Int32(width), Int32(height), $0)
@@ -23,10 +23,10 @@ extension Graphics {
self.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) self.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
} }
/// Loads a bitmap from a file in the game's pdx or Data directory. /// `path` is in the game's pdx or Data directory.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
let pointer = path.withPlaydateCString { gfx.pointee.loadBitmap.unsafelyUnwrapped($0, &error) } let pointer = path.withCString { gfx.pointee.loadBitmap.unsafelyUnwrapped($0, &error) }
guard let pointer else { throw PlaydateError(cString: error) } guard let pointer else { throw PlaydateError(cString: error) }
self.init(pointer: pointer, isOwned: true) self.init(pointer: pointer, isOwned: true)
} }
@@ -37,62 +37,85 @@ extension Graphics {
} }
} }
// MARK: Properties // MARK: Size and pixel data
/// The bitmap's dimensions, row stride, and raw storage.
public var data: Data { public var data: Data {
let raw = rawData()
return Data(width: raw.width, height: raw.height, rowBytes: raw.rowBytes,
hasMask: raw.mask != nil)
}
/// 1 bit per pixel, MSB first, `height` rows of `rowBytes` bytes. The span is valid
/// only inside `body`, and empty if the bitmap has no data.
public func withPixelData<Result, Failure: Error>(
_ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result {
let raw = rawData()
var span = UnsafeMutableBufferPointer(
start: raw.data, count: raw.data == nil ? 0 : raw.height * raw.rowBytes).mutableSpan
return try body(&span)
}
/// Laid out like the pixel data; valid only inside `body`. Returns `nil` without
/// calling `body` if the bitmap has no mask.
public func withMaskData<Result, Failure: Error>(
_ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result? {
let raw = rawData()
guard let mask = raw.mask else { return nil }
var span = UnsafeMutableBufferPointer(start: mask, count: raw.height * raw.rowBytes).mutableSpan
return try body(&span)
}
private func rawData() -> (width: Int, height: Int, rowBytes: Int,
mask: UnsafeMutablePointer<UInt8>?, data: UnsafeMutablePointer<UInt8>?) {
var width: Int32 = 0, height: Int32 = 0, rowBytes: Int32 = 0 var width: Int32 = 0, height: Int32 = 0, rowBytes: Int32 = 0
var mask: UnsafeMutablePointer<UInt8>? var mask: UnsafeMutablePointer<UInt8>?
var data: UnsafeMutablePointer<UInt8>? var data: UnsafeMutablePointer<UInt8>?
gfx.pointee.getBitmapData.unsafelyUnwrapped(pointer, &width, &height, &rowBytes, &mask, &data) gfx.pointee.getBitmapData.unsafelyUnwrapped(pointer, &width, &height, &rowBytes, &mask, &data)
return Data(width: Int(width), height: Int(height), rowBytes: Int(rowBytes), return (Int(width), Int(height), Int(rowBytes), mask, data)
mask: mask, data: data)
} }
/// Cached dimensions, so `width`/`height` don't pay a full /// Saves a `getBitmapData` call per access. Reset by `load(path:)`, the only resizer.
/// `getBitmapData` round-trip per access. Only `load(path:)` can
/// change a bitmap's size, which resets the cache.
private var cachedSize: (width: Int, height: Int)? private var cachedSize: (width: Int, height: Int)?
private var size: (width: Int, height: Int) { private var size: (width: Int, height: Int) {
if let cachedSize { return cachedSize } if let cachedSize { return cachedSize }
let data = self.data let raw = rawData()
let size = (data.width, data.height) let size = (raw.width, raw.height)
cachedSize = size cachedSize = size
return size return size
} }
/// The bitmap's width, in pixels. /// Width, in pixels.
public var width: Int { size.width } public var width: Int { size.width }
/// The bitmap's height, in pixels. /// Height, in pixels.
public var height: Int { size.height } public var height: Int { size.height }
/// The color of the pixel at (x, y). /// `.black` or `.white`, or `.clear` if out of bounds or masked out.
public func pixel(x: Int, y: Int) -> SolidColor { public func pixel(x: Int, y: Int) -> SolidColor {
SolidColor(gfx.pointee.getBitmapPixel.unsafelyUnwrapped(pointer, Int32(x), Int32(y))) SolidColor(gfx.pointee.getBitmapPixel.unsafelyUnwrapped(pointer, Int32(x), Int32(y)))
} }
// MARK: Operations // MARK: Operations
/// Replaces the bitmap's contents with the image at `path`. /// Replaces the contents, and possibly the size, with the image at `path`.
public func load(path: String) throws(PlaydateError) { public func load(path: String) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
path.withPlaydateCString { gfx.pointee.loadIntoBitmap.unsafelyUnwrapped($0, pointer, &error) } path.withCString { gfx.pointee.loadIntoBitmap.unsafelyUnwrapped($0, pointer, &error) }
cachedSize = nil cachedSize = nil
if let error { throw PlaydateError(cString: error) } if let error { throw PlaydateError(cString: error) }
} }
/// Fills the bitmap with `color`.
public func clear(color: Color) { public func clear(color: Color) {
color.withLCDColor { gfx.pointee.clearBitmap.unsafelyUnwrapped(pointer, $0) } color.withLCDColor { gfx.pointee.clearBitmap.unsafelyUnwrapped(pointer, $0) }
} }
/// Returns a new copy of the bitmap.
public func copy() -> Bitmap { public func copy() -> Bitmap {
Bitmap(pointer: gfx.pointee.copyBitmap.unsafelyUnwrapped(pointer).unsafelyUnwrapped, isOwned: true) Bitmap(pointer: gfx.pointee.copyBitmap.unsafelyUnwrapped(pointer).unsafelyUnwrapped, isOwned: true)
} }
/// Returns a new bitmap rotated by `degrees` (clockwise) and scaled. /// `degrees` is clockwise. Returns `nil` on failure.
public func rotated(by degrees: Float, xScale: Float = 1, yScale: Float = 1) -> Bitmap? { public func rotated(by degrees: Float, xScale: Float = 1, yScale: Float = 1) -> Bitmap? {
var allocatedSize: Int32 = 0 var allocatedSize: Int32 = 0
guard let rotated = gfx.pointee.rotatedBitmap.unsafelyUnwrapped( guard let rotated = gfx.pointee.rotatedBitmap.unsafelyUnwrapped(
@@ -100,21 +123,21 @@ extension Graphics {
return Bitmap(pointer: rotated, isOwned: true) return Bitmap(pointer: rotated, isOwned: true)
} }
/// Sets a mask image. The mask must match the bitmap's dimensions. /// Returns `false` if `mask` is `nil` or a different size.
@discardableResult @discardableResult
public func setMask(_ mask: Bitmap?) -> Bool { public func setMask(_ mask: Bitmap?) -> Bool {
gfx.pointee.setBitmapMask.unsafelyUnwrapped(pointer, mask?.pointer) != 0 gfx.pointee.setBitmapMask.unsafelyUnwrapped(pointer, mask?.pointer) != 0
} }
/// The bitmap's mask, if any. The returned bitmap references storage /// Shares this bitmap's mask data, and keeps this bitmap alive.
/// owned by this bitmap.
public var mask: Bitmap? { public var mask: Bitmap? {
// Owned by the caller; pixels are shared with `self`.
guard let mask = gfx.pointee.getBitmapMask.unsafelyUnwrapped(pointer) else { return nil } guard let mask = gfx.pointee.getBitmapMask.unsafelyUnwrapped(pointer) else { return nil }
return Bitmap(pointer: mask, isOwned: false) return Bitmap(pointer: mask, isOwned: true, owner: self)
} }
/// Tests whether the opaque pixels of two bitmaps overlap within /// Whether opaque pixels of both bitmaps overlap within the non-empty `rect`.
/// `rect`, given each bitmap's position and flip. /// `false` if either bitmap lies entirely outside `rect`.
public func checkMaskCollision(x: Int, y: Int, flip: BitmapFlip = .unflipped, public func checkMaskCollision(x: Int, y: Int, flip: BitmapFlip = .unflipped,
other: Bitmap, otherX: Int, otherY: Int, other: Bitmap, otherX: Int, otherY: Int,
otherFlip: BitmapFlip = .unflipped, otherFlip: BitmapFlip = .unflipped,
@@ -127,19 +150,18 @@ extension Graphics {
// MARK: Drawing // MARK: Drawing
/// Draws the bitmap with its upper-left corner at (x, y). /// (x, y) is the upper-left corner.
public func draw(x: Int, y: Int, flip: BitmapFlip = .unflipped) { public func draw(x: Int, y: Int, flip: BitmapFlip = .unflipped) {
gfx.pointee.drawBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), flip.cValue) gfx.pointee.drawBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), flip.cValue)
} }
/// Draws the bitmap scaled by (xScale, yScale) with its upper-left /// (x, y) is the upper-left corner. Negative scales flip the bitmap.
/// corner at (x, y).
public func drawScaled(x: Int, y: Int, xScale: Float, yScale: Float) { public func drawScaled(x: Int, y: Int, xScale: Float, yScale: Float) {
gfx.pointee.drawScaledBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), xScale, yScale) gfx.pointee.drawScaledBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), xScale, yScale)
} }
/// Draws the bitmap rotated by `degrees` around its anchor point, /// Scales, then rotates, placing the anchor (`centerX`, `centerY`) at (x, y). Anchors
/// where (0.5, 0.5) is the center. /// are proportional: (0.5, 0.5) is the center, (0, 0) the unrotated upper-left.
public func drawRotated(x: Int, y: Int, degrees: Float, public func drawRotated(x: Int, y: Int, degrees: Float,
centerX: Float = 0.5, centerY: Float = 0.5, centerX: Float = 0.5, centerY: Float = 0.5,
xScale: Float = 1, yScale: Float = 1) { xScale: Float = 1, yScale: Float = 1) {
@@ -147,7 +169,7 @@ extension Graphics {
centerX, centerY, xScale, yScale) centerX, centerY, xScale, yScale)
} }
/// Tiles the bitmap over the given area. /// Tiles the `width` × `height` rect whose upper-left corner is (x, y).
public func tile(x: Int, y: Int, width: Int, height: Int, flip: BitmapFlip = .unflipped) { public func tile(x: Int, y: Int, width: Int, height: Int, flip: BitmapFlip = .unflipped) {
gfx.pointee.tileBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y), gfx.pointee.tileBitmap.unsafelyUnwrapped(pointer, Int32(x), Int32(y),
Int32(width), Int32(height), flip.cValue) Int32(width), Int32(height), flip.cValue)
@@ -1,7 +1,8 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// A collection of bitmaps loaded from an image table. Wraps `LCDBitmapTable`. /// An image table. Wraps `LCDBitmapTable`. Its bitmaps are borrowed and invalid once
/// the table is freed.
public final class BitmapTable { public final class BitmapTable {
let pointer: OpaquePointer let pointer: OpaquePointer
@@ -9,16 +10,15 @@ extension Graphics {
self.pointer = pointer self.pointer = pointer
} }
/// Allocates a table with room for `count` bitmaps of the given size. /// Room for `count` bitmaps of `width` × `height` pixels.
public convenience init(count: Int, width: Int, height: Int) { public convenience init(count: Int, width: Int, height: Int) {
let pointer = gfx.pointee.newBitmapTable.unsafelyUnwrapped(Int32(count), Int32(width), Int32(height)) let pointer = gfx.pointee.newBitmapTable.unsafelyUnwrapped(Int32(count), Int32(width), Int32(height))
self.init(pointer: pointer.unsafelyUnwrapped) self.init(pointer: pointer.unsafelyUnwrapped)
} }
/// Loads an image table from a file.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
let pointer = path.withPlaydateCString { gfx.pointee.loadBitmapTable.unsafelyUnwrapped($0, &error) } let pointer = path.withCString { gfx.pointee.loadBitmapTable.unsafelyUnwrapped($0, &error) }
guard let pointer else { throw PlaydateError(cString: error) } guard let pointer else { throw PlaydateError(cString: error) }
self.init(pointer: pointer) self.init(pointer: pointer)
} }
@@ -27,16 +27,13 @@ extension Graphics {
gfx.pointee.freeBitmapTable.unsafelyUnwrapped(pointer) gfx.pointee.freeBitmapTable.unsafelyUnwrapped(pointer)
} }
/// Replaces the table's contents with the image table at `path`.
public func load(path: String) throws(PlaydateError) { public func load(path: String) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
path.withPlaydateCString { gfx.pointee.loadIntoBitmapTable.unsafelyUnwrapped($0, pointer, &error) } path.withCString { gfx.pointee.loadIntoBitmapTable.unsafelyUnwrapped($0, pointer, &error) }
if let error { throw PlaydateError(cString: error) } if let error { throw PlaydateError(cString: error) }
} }
/// The bitmap at `index`, or `nil` if out of range. The bitmap /// `nil` if out of range.
/// references storage owned by the table; keep the table alive while
/// using it.
public func bitmap(at index: Int) -> Bitmap? { public func bitmap(at index: Int) -> Bitmap? {
guard let bitmap = gfx.pointee.getTableBitmap.unsafelyUnwrapped(pointer, Int32(index)) else { guard let bitmap = gfx.pointee.getTableBitmap.unsafelyUnwrapped(pointer, Int32(index)) else {
return nil return nil
@@ -44,15 +41,26 @@ extension Graphics {
return Bitmap(pointer: bitmap, isOwned: false) return Bitmap(pointer: bitmap, isOwned: false)
} }
/// The number of bitmaps in the table and the number of cells per row /// Bitmap count and cells across the source image.
/// of the source image.
public var info: (count: Int, cellsWide: Int) { public var info: (count: Int, cellsWide: Int) {
var count: Int32 = 0, width: Int32 = 0 var count: Int32 = 0, width: Int32 = 0
gfx.pointee.getBitmapTableInfo.unsafelyUnwrapped(pointer, &count, &width) gfx.pointee.getBitmapTableInfo.unsafelyUnwrapped(pointer, &count, &width)
return (Int(count), Int(width)) return (Int(count), Int(width))
} }
/// The number of bitmaps in the table.
public var count: Int { info.count } public var count: Int { info.count }
} }
} }
extension Graphics.BitmapTable: RandomAccessCollection {
public var startIndex: Int { 0 }
public var endIndex: Int { count }
/// Traps if out of range.
public subscript(position: Int) -> Graphics.Bitmap {
guard let bitmap = bitmap(at: position) else {
preconditionFailure("bitmap table index out of range")
}
return bitmap
}
}
+20 -21
View File
@@ -1,11 +1,11 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// A font loaded from a .pft file. Wraps `LCDFont`. /// A .pft bitmap font. Wraps `LCDFont`. Glyph bitmaps are borrowed and don't retain
/// the font; keep it alive while using them.
public final class Font { public final class Font {
let pointer: OpaquePointer let pointer: OpaquePointer
/// Fonts created from in-memory data reference that data; it is kept /// `makeFontFromData` doesn't copy its buffer, so it lives as long as the font.
/// alive here.
private let retainedData: UnsafeRawPointer? private let retainedData: UnsafeRawPointer?
init(pointer: OpaquePointer, retainedData: UnsafeRawPointer? = nil) { init(pointer: OpaquePointer, retainedData: UnsafeRawPointer? = nil) {
@@ -13,19 +13,20 @@ extension Graphics {
self.retainedData = retainedData self.retainedData = retainedData
} }
/// Loads a font from a file.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
let pointer = path.withPlaydateCString { gfx.pointee.loadFont.unsafelyUnwrapped($0, &error) } let pointer = path.withCString { gfx.pointee.loadFont.unsafelyUnwrapped($0, &error) }
guard let pointer else { throw PlaydateError(cString: error) } guard let pointer else { throw PlaydateError(cString: error) }
self.init(pointer: pointer) self.init(pointer: pointer)
} }
/// Creates a font from the contents of a .pft file already in memory. /// `data`: an uncompressed .pft file minus its 16-byte header; copied for the font's
/// The bytes are copied and retained for the font's lifetime. /// lifetime. `wide` must match the header flag for glyphs above U+1FFFF.
public convenience init?(data: UnsafeRawBufferPointer, wide: Bool = false) { public convenience init?(data: Span<UInt8>, wide: Bool = false) {
let copy = UnsafeMutableRawPointer.allocate(byteCount: data.count, alignment: 4) let copy = UnsafeMutableRawPointer.allocate(byteCount: data.count, alignment: 4)
copy.copyMemory(from: data.baseAddress.unsafelyUnwrapped, byteCount: data.count) data.withUnsafeBytes { bytes in
copy.copyMemory(from: bytes.baseAddress.unsafelyUnwrapped, byteCount: bytes.count)
}
let fontData = OpaquePointer(copy) let fontData = OpaquePointer(copy)
guard let pointer = gfx.pointee.makeFontFromData.unsafelyUnwrapped( guard let pointer = gfx.pointee.makeFontFromData.unsafelyUnwrapped(
fontData, wide ? 1 : 0, Int32(data.count)) else { fontData, wide ? 1 : 0, Int32(data.count)) else {
@@ -36,43 +37,41 @@ extension Graphics {
} }
deinit { deinit {
// Per the C API docs, fonts are freed with the system allocator. // There is no freeFont; fonts are released with `realloc(font, 0)`.
System.systemFree(UnsafeMutableRawPointer(pointer)) System.systemFree(UnsafeMutableRawPointer(pointer))
retainedData?.deallocate() retainedData?.deallocate()
} }
/// The font's glyph height in pixels. /// Height, in pixels.
public var height: Int { public var height: Int {
Int(gfx.pointee.getFontHeight.unsafelyUnwrapped(pointer)) Int(gfx.pointee.getFontHeight.unsafelyUnwrapped(pointer))
} }
/// The width of `text` when drawn with this font. /// Width in pixels; `tracking` is pixels between characters.
public func textWidth(_ text: String, tracking: Int = 0) -> Int { public func textWidth(_ text: String, tracking: Int = 0) -> Int {
text.withPlaydateUTF8 { bytes, count in text.withCString { cString in
Int(gfx.pointee.getTextWidth.unsafelyUnwrapped(pointer, bytes, count, Int(gfx.pointee.getTextWidth.unsafelyUnwrapped(pointer, cString, text.utf8.count,
kUTF8Encoding, Int32(tracking))) kUTF8Encoding, Int32(tracking)))
} }
} }
/// The height of `text` when wrapped to `maxWidth` with this font. /// Height in pixels of `text` wrapped to `maxWidth` pixels.
public func textHeight(_ text: String, maxWidth: Int, wrap: TextWrappingMode = .word, public func textHeight(_ text: String, maxWidth: Int, wrap: TextWrappingMode = .word,
tracking: Int = 0, extraLeading: Int = 0) -> Int { tracking: Int = 0, extraLeading: Int = 0) -> Int {
text.withPlaydateUTF8 { bytes, count in text.withCString { cString in
Int(gfx.pointee.getTextHeightForMaxWidth.unsafelyUnwrapped( Int(gfx.pointee.getTextHeightForMaxWidth.unsafelyUnwrapped(
pointer, bytes, count, Int32(maxWidth), kUTF8Encoding, pointer, cString, text.utf8.count, Int32(maxWidth), kUTF8Encoding,
wrap.cValue, Int32(tracking), Int32(extraLeading))) wrap.cValue, Int32(tracking), Int32(extraLeading)))
} }
} }
/// The page containing glyph data for the character `codepoint` /// `nil` if none. Codepoints differing only in their low 8 bits share a page.
/// belongs to. The page references data owned by the font.
public func page(for codepoint: UInt32) -> FontPage? { public func page(for codepoint: UInt32) -> FontPage? {
guard let page = gfx.pointee.getFontPage.unsafelyUnwrapped(pointer, codepoint) else { return nil } guard let page = gfx.pointee.getFontPage.unsafelyUnwrapped(pointer, codepoint) else { return nil }
return FontPage(pointer: page, font: self) return FontPage(pointer: page, font: self)
} }
/// The glyph for `codepoint`, with its bitmap and advance. /// `nil` if the font has no glyph for `codepoint`.
/// The bitmap references data owned by the font.
public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? { public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? {
var bitmap: OpaquePointer? var bitmap: OpaquePointer?
var advance: Int32 = 0 var advance: Int32 = 0
@@ -1,15 +1,16 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->graphics->videostream` C API table. /// `playdate->graphics->videostream`.
private var streamAPI: UnsafePointer<playdate_videostream> { Playdate.videoStreamAPI.unsafelyUnwrapped } private var streamAPI: UnsafePointer<playdate_videostream> { Playdate.videoStreamAPI.unsafelyUnwrapped }
extension Graphics { extension Graphics {
/// Streams video (and audio) from a file or network connection. /// Streams video and audio from a file or connection. Wraps `LCDStreamPlayer`.
/// Wraps `LCDStreamPlayer`. /// Retains its source until replaced.
public final class StreamPlayer { public final class StreamPlayer {
let pointer: OpaquePointer let pointer: OpaquePointer
/// Retains the active source so it outlives the stream. /// The C player reads the source; non-copyable `File.Handle` needs its own slot.
private var retainedSource: AnyObject? private var retainedSource: AnyObject?
private var retainedFile: File.Handle?
public init() { public init() {
pointer = streamAPI.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped pointer = streamAPI.pointee.newPlayer.unsafelyUnwrapped().unsafelyUnwrapped
@@ -19,32 +20,32 @@ extension Graphics {
streamAPI.pointee.freePlayer.unsafelyUnwrapped(pointer) streamAPI.pointee.freePlayer.unsafelyUnwrapped(pointer)
} }
/// Sets the sizes of the stream's video and audio buffers, in bytes. /// Buffer sizes, in bytes.
public func setBufferSize(video: Int, audio: Int) { public func setBufferSize(video: Int, audio: Int) {
streamAPI.pointee.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio)) streamAPI.pointee.setBufferSize.unsafelyUnwrapped(pointer, Int32(video), Int32(audio))
} }
/// Streams from an open file. /// Takes ownership; the handle closes when replaced or on deinit.
public func setFile(_ file: File.Handle) { public func setFile(_ file: consuming File.Handle) {
retainedSource = file
streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer) streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer)
retainedFile = consume file
retainedSource = nil
} }
/// Streams from an HTTP connection.
public func setHTTPConnection(_ connection: Network.HTTPConnection) { public func setHTTPConnection(_ connection: Network.HTTPConnection) {
retainedSource = connection
streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer) streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer)
}
/// Streams from a TCP connection.
public func setTCPConnection(_ connection: Network.TCPConnection) {
retainedSource = connection retainedSource = connection
streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer) retainedFile = nil
} }
/// The player used for the stream's audio track. Owned by the stream. public func setTCPConnection(_ connection: Network.TCPConnection) {
/// The same wrapper is returned on every access, so callbacks streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer)
/// registered on it stay valid for the stream's lifetime. retainedSource = connection
retainedFile = nil
}
/// Borrowed. The same wrapper is returned while the underlying player is unchanged,
/// so callbacks registered on it persist.
public var filePlayer: Sound.FilePlayer? { public var filePlayer: Sound.FilePlayer? {
guard let player = streamAPI.pointee.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil } guard let player = streamAPI.pointee.getFilePlayer.unsafelyUnwrapped(pointer) else { return nil }
if let cached = cachedFilePlayer, cached.pointer == player { if let cached = cachedFilePlayer, cached.pointer == player {
@@ -57,24 +58,23 @@ extension Graphics {
private var cachedFilePlayer: Sound.FilePlayer? private var cachedFilePlayer: Sound.FilePlayer?
/// The player used for the stream's video track. Owned by the stream. /// Borrowed; keep this player alive while using it.
public var videoPlayer: VideoPlayer? { public var videoPlayer: VideoPlayer? {
guard let player = streamAPI.pointee.getVideoPlayer.unsafelyUnwrapped(pointer) else { return nil } guard let player = streamAPI.pointee.getVideoPlayer.unsafelyUnwrapped(pointer) else { return nil }
return VideoPlayer(pointer: player, isOwned: false) return VideoPlayer(pointer: player, isOwned: false)
} }
/// Advances the stream. Returns `true` if a frame was drawn. /// Returns `true` if a frame was drawn.
@discardableResult @discardableResult
public func update() -> Bool { public func update() -> Bool {
streamAPI.pointee.update.unsafelyUnwrapped(pointer) streamAPI.pointee.update.unsafelyUnwrapped(pointer)
} }
/// The number of video frames currently buffered.
public var bufferedFrameCount: Int { public var bufferedFrameCount: Int {
Int(streamAPI.pointee.getBufferedFrameCount.unsafelyUnwrapped(pointer)) Int(streamAPI.pointee.getBufferedFrameCount.unsafelyUnwrapped(pointer))
} }
/// The total number of bytes read from the source. /// Bytes read from the source so far.
public var bytesRead: UInt32 { public var bytesRead: UInt32 {
streamAPI.pointee.getBytesRead.unsafelyUnwrapped(pointer) streamAPI.pointee.getBytesRead.unsafelyUnwrapped(pointer)
} }
@@ -1,13 +1,13 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->graphics->tilemap` C API table. /// `playdate->graphics->tilemap`.
private var tilemapAPI: UnsafePointer<playdate_tilemap> { Playdate.tilemapAPI.unsafelyUnwrapped } private var tilemapAPI: UnsafePointer<playdate_tilemap> { Playdate.tilemapAPI.unsafelyUnwrapped }
extension Graphics { extension Graphics {
/// A grid of tiles drawn from a bitmap table. Wraps `LCDTileMap`. /// A grid of tiles drawn from a bitmap table. Wraps `LCDTileMap`.
public final class TileMap { public final class TileMap {
let pointer: OpaquePointer let pointer: OpaquePointer
/// The image table is retained so the tilemap's tiles stay valid. /// The C tilemap holds only a raw pointer to its table.
private var retainedImageTable: BitmapTable? private var retainedImageTable: BitmapTable?
public init() { public init() {
@@ -18,7 +18,7 @@ extension Graphics {
tilemapAPI.pointee.freeTilemap.unsafelyUnwrapped(pointer) tilemapAPI.pointee.freeTilemap.unsafelyUnwrapped(pointer)
} }
/// The bitmap table the tile indexes refer to. /// Retained while set.
public var imageTable: BitmapTable? { public var imageTable: BitmapTable? {
get { retainedImageTable } get { retainedImageTable }
set { set {
@@ -27,47 +27,52 @@ extension Graphics {
} }
} }
/// Sets the tilemap's size in tiles.
public func setSize(tilesWide: Int, tilesHigh: Int) { public func setSize(tilesWide: Int, tilesHigh: Int) {
tilemapAPI.pointee.setSize.unsafelyUnwrapped(pointer, Int32(tilesWide), Int32(tilesHigh)) tilemapAPI.pointee.setSize.unsafelyUnwrapped(pointer, Int32(tilesWide), Int32(tilesHigh))
} }
/// The tilemap's size in tiles.
public var size: (tilesWide: Int, tilesHigh: Int) { public var size: (tilesWide: Int, tilesHigh: Int) {
var wide: Int32 = 0, high: Int32 = 0 var wide: Int32 = 0, high: Int32 = 0
tilemapAPI.pointee.getSize.unsafelyUnwrapped(pointer, &wide, &high) tilemapAPI.pointee.getSize.unsafelyUnwrapped(pointer, &wide, &high)
return (Int(wide), Int(high)) return (Int(wide), Int(high))
} }
/// The tilemap's total size in pixels. /// Tile image size times tile counts.
public var pixelSize: (width: Int, height: Int) { public var pixelSize: (width: Int, height: Int) {
var width: UInt32 = 0, height: UInt32 = 0 var width: UInt32 = 0, height: UInt32 = 0
tilemapAPI.pointee.getPixelSize.unsafelyUnwrapped(pointer, &width, &height) tilemapAPI.pointee.getPixelSize.unsafelyUnwrapped(pointer, &width, &height)
return (Int(width), Int(height)) return (Int(width), Int(height))
} }
/// Fills the tilemap with `indexes`, `rowWidth` tiles per row. The /// Sets all tiles row by row, resizing to `rowWidth` × `indexes.count / rowWidth`.
/// tilemap is resized to fit. /// `indexes.count` must be a multiple of `rowWidth`.
public func setTiles(_ indexes: [UInt16], rowWidth: Int) { public func setTiles(_ indexes: Span<UInt16>, rowWidth: Int) {
var indexes = indexes indexes.withUnsafeBufferPointer { buffer in
indexes.withUnsafeMutableBufferPointer { buffer in // Non-const in C, but only read (and copied).
tilemapAPI.pointee.setTiles.unsafelyUnwrapped(pointer, buffer.baseAddress, tilemapAPI.pointee.setTiles.unsafelyUnwrapped(
pointer, UnsafeMutablePointer(mutating: buffer.baseAddress),
Int32(buffer.count), Int32(rowWidth)) Int32(buffer.count), Int32(rowWidth))
} }
} }
/// Sets the tile index at position (x, y). /// Sets all tiles row by row, resizing to `rowWidth` × `indexes.count / rowWidth`.
/// `indexes.count` must be a multiple of `rowWidth`.
public func setTiles(_ indexes: [UInt16], rowWidth: Int) {
indexes.withUnsafeBufferPointer { setTiles($0.span, rowWidth: rowWidth) }
}
/// `x` is the column, `y` the row, `index` an image table index.
public func setTile(x: Int, y: Int, index: UInt16) { public func setTile(x: Int, y: Int, index: UInt16) {
tilemapAPI.pointee.setTileAtPosition.unsafelyUnwrapped(pointer, Int32(x), Int32(y), index) tilemapAPI.pointee.setTileAtPosition.unsafelyUnwrapped(pointer, Int32(x), Int32(y), index)
} }
/// The tile index at position (x, y), or `nil` if out of bounds. /// The image table index at column `x`, row `y`; `nil` if out of bounds.
public func tile(x: Int, y: Int) -> Int? { public func tile(x: Int, y: Int) -> Int? {
let index = tilemapAPI.pointee.getTileAtPosition.unsafelyUnwrapped(pointer, Int32(x), Int32(y)) let index = tilemapAPI.pointee.getTileAtPosition.unsafelyUnwrapped(pointer, Int32(x), Int32(y))
return index < 0 ? nil : Int(index) return index < 0 ? nil : Int(index)
} }
/// Draws the tilemap with its upper-left corner at (x, y). /// (x, y) is the upper-left corner, in pixels.
public func draw(x: Float, y: Float) { public func draw(x: Float, y: Float) {
tilemapAPI.pointee.drawAtPoint.unsafelyUnwrapped(pointer, x, y) tilemapAPI.pointee.drawAtPoint.unsafelyUnwrapped(pointer, x, y)
} }
@@ -1,14 +1,15 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->graphics->video` C API table. /// `playdate->graphics->video`.
private var videoAPI: UnsafePointer<playdate_video> { Playdate.videoAPI.unsafelyUnwrapped } private var videoAPI: UnsafePointer<playdate_video> { Playdate.videoAPI.unsafelyUnwrapped }
extension Graphics { extension Graphics {
/// Plays .pdv video files. Wraps `LCDVideoPlayer`. /// Plays .pdv video files. Wraps `LCDVideoPlayer`.
public final class VideoPlayer { public final class VideoPlayer {
let pointer: OpaquePointer let pointer: OpaquePointer
/// `false` for players vended by a `StreamPlayer`.
let isOwned: Bool let isOwned: Bool
/// Retains the render context bitmap while the player uses it. /// The C player holds only a raw pointer to its context.
private var retainedContext: Bitmap? private var retainedContext: Bitmap?
init(pointer: OpaquePointer, isOwned: Bool) { init(pointer: OpaquePointer, isOwned: Bool) {
@@ -16,9 +17,8 @@ extension Graphics {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// Opens the .pdv file at `path`.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
let pointer = path.withPlaydateCString { videoAPI.pointee.loadVideo.unsafelyUnwrapped($0) } let pointer = path.withCString { videoAPI.pointee.loadVideo.unsafelyUnwrapped($0) }
guard let pointer else { guard let pointer else {
throw PlaydateError(message: "unable to load video: \(path)") throw PlaydateError(message: "unable to load video: \(path)")
} }
@@ -31,7 +31,7 @@ extension Graphics {
} }
} }
/// Sets the bitmap the video renders into. /// Retains `context`; throws with `error`. Its mask isn't drawn; use an opaque one.
public func setContext(_ context: Bitmap) throws(PlaydateError) { public func setContext(_ context: Bitmap) throws(PlaydateError) {
guard videoAPI.pointee.setContext.unsafelyUnwrapped(pointer, context.pointer) != 0 else { guard videoAPI.pointee.setContext.unsafelyUnwrapped(pointer, context.pointer) != 0 else {
throw PlaydateError(message: error ?? "unable to set video context") throw PlaydateError(message: error ?? "unable to set video context")
@@ -39,34 +39,32 @@ extension Graphics {
retainedContext = context retainedContext = context
} }
/// The bitmap the video renders into. /// Borrowed. If none was set, the player allocates one the size of the video.
public var context: Bitmap? { public var context: Bitmap? {
guard let context = videoAPI.pointee.getContext.unsafelyUnwrapped(pointer) else { return nil } guard let context = videoAPI.pointee.getContext.unsafelyUnwrapped(pointer) else { return nil }
return Bitmap(pointer: context, isOwned: false) return Bitmap(pointer: context, isOwned: false)
} }
/// Renders directly into the display framebuffer. /// Releases any retained context.
public func useScreenContext() { public func useScreenContext() {
retainedContext = nil retainedContext = nil
videoAPI.pointee.useScreenContext.unsafelyUnwrapped(pointer) videoAPI.pointee.useScreenContext.unsafelyUnwrapped(pointer)
} }
/// Renders frame `frame` into the current context. /// Renders into the current context; throws with `error`.
public func renderFrame(_ frame: Int) throws(PlaydateError) { public func renderFrame(_ frame: Int) throws(PlaydateError) {
guard videoAPI.pointee.renderFrame.unsafelyUnwrapped(pointer, Int32(frame)) != 0 else { guard videoAPI.pointee.renderFrame.unsafelyUnwrapped(pointer, Int32(frame)) != 0 else {
// Static message: the caller knows the frame it passed, and // Static: interpolating `frame` would link integer formatting.
// interpolating it would pull integer formatting machinery
// into the device binary.
throw PlaydateError(message: error ?? "unable to render frame") throw PlaydateError(message: error ?? "unable to render frame")
} }
} }
/// The most recent error message, if any. /// The most recent error message.
public var error: String? { public var error: String? {
String(playdateCString: videoAPI.pointee.getError.unsafelyUnwrapped(pointer)) String(playdateCString: videoAPI.pointee.getError.unsafelyUnwrapped(pointer))
} }
/// The video's dimensions, frame rate, frame count, and current frame. /// Size in pixels, frame rate in frames per second, frame count, current frame.
public var info: (width: Int, height: Int, frameRate: Float, frameCount: Int, currentFrame: Int) { public var info: (width: Int, height: Int, frameRate: Float, frameCount: Int, currentFrame: Int) {
var width: Int32 = 0, height: Int32 = 0, frameCount: Int32 = 0, currentFrame: Int32 = 0 var width: Int32 = 0, height: Int32 = 0, frameCount: Int32 = 0, currentFrame: Int32 = 0
var frameRate: Float = 0 var frameRate: Float = 0
@@ -1,7 +1,7 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// Mirroring applied when drawing a bitmap. /// Mirroring applied when drawing a bitmap. Wraps `LCDBitmapFlip`.
public enum BitmapFlip: UInt32, Sendable { public enum BitmapFlip: UInt32, Sendable {
case unflipped = 0 case unflipped = 0
case flippedX = 1 case flippedX = 1
@@ -1,22 +1,17 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// A drawing color: solid or an 8×8 pattern. /// Wraps `LCDColor`: a solid color or an 8×8 pattern.
public enum Color: Sendable { public enum Color: Sendable {
/// Solid black.
case black case black
/// Solid white.
case white case white
/// Transparent; leaves the destination unchanged. /// Leaves the destination unchanged.
case clear case clear
/// Inverts the destination pixels. /// Inverts the destination.
case xor case xor
/// An 8×8 two-color pattern.
case pattern(Pattern) case pattern(Pattern)
/// Materializes the `LCDColor` for the duration of `body`. Pattern /// For `.pattern`, the `LCDColor` points to a copy valid only during `body`.
/// colors pass a pointer to a temporary, so the value must not be
/// stored beyond the call.
func withLCDColor<Result>(_ body: (LCDColor) -> Result) -> Result { func withLCDColor<Result>(_ body: (LCDColor) -> Result) -> Result {
switch self { switch self {
case .black: return body(LCDColor(kColorBlack.rawValue)) case .black: return body(LCDColor(kColorBlack.rawValue))
@@ -1,23 +1,19 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// How source pixels combine with the destination when drawing. /// Wraps `LCDBitmapDrawMode`: how bitmap and text pixels combine with the destination.
public enum DrawMode: UInt32, Sendable { public enum DrawMode: UInt32, Sendable {
/// Source pixels replace the destination.
case copy = 0 case copy = 0
/// White source pixels are treated as transparent. /// White source pixels are transparent.
case whiteTransparent = 1 case whiteTransparent = 1
/// Black source pixels are treated as transparent. /// Black source pixels are transparent.
case blackTransparent = 2 case blackTransparent = 2
/// Opaque source pixels draw white. /// Opaque source pixels draw white.
case fillWhite = 3 case fillWhite = 3
/// Opaque source pixels draw black. /// Opaque source pixels draw black.
case fillBlack = 4 case fillBlack = 4
/// Source pixels are XORed with the destination.
case xor = 5 case xor = 5
/// The inverse of `xor`.
case nxor = 6 case nxor = 6
/// Source pixels draw inverted.
case inverted = 7 case inverted = 7
init(_ mode: LCDBitmapDrawMode) { self = DrawMode(rawValue: UInt32(mode.rawValue)) ?? .copy } init(_ mode: LCDBitmapDrawMode) { self = DrawMode(rawValue: UInt32(mode.rawValue)) ?? .copy }
@@ -1,9 +1,11 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// The end cap style used when drawing lines. /// Line end caps. Wraps `LCDLineCapStyle`.
public enum LineCapStyle: UInt32, Sendable { public enum LineCapStyle: UInt32, Sendable {
/// Flat, ending at the endpoint.
case butt = 0 case butt = 0
/// Square, extending past the endpoint.
case square = 1 case square = 1
case round = 2 case round = 2
@@ -1,9 +1,11 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// The winding rule used by `fillPolygon`. /// Winding rule for `fillPolygon(points:color:fillRule:)`. Wraps `LCDPolygonFillRule`.
public enum PolygonFillRule: UInt32, Sendable { public enum PolygonFillRule: UInt32, Sendable {
/// Fills points with a nonzero winding number.
case nonZero = 0 case nonZero = 0
/// Fills points crossed by an odd number of edges.
case evenOdd = 1 case evenOdd = 1
var cValue: LCDPolygonFillRule { LCDPolygonFillRule(LCDPolygonFillRule.RawValue(rawValue)) } var cValue: LCDPolygonFillRule { LCDPolygonFillRule(LCDPolygonFillRule.RawValue(rawValue)) }
@@ -1,11 +1,13 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// A solid color, for APIs that cannot take a pattern. /// A color for APIs that cannot take a pattern. Wraps `LCDSolidColor`.
public enum SolidColor: UInt32, Sendable { public enum SolidColor: UInt32, Sendable {
case black = 0 case black = 0
case white = 1 case white = 1
/// Transparent.
case clear = 2 case clear = 2
/// Inverts the destination.
case xor = 3 case xor = 3
init(_ color: LCDSolidColor) { self = SolidColor(rawValue: UInt32(color.rawValue)) ?? .clear } init(_ color: LCDSolidColor) { self = SolidColor(rawValue: UInt32(color.rawValue)) ?? .clear }
@@ -1,10 +1,12 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// The encoding of text passed to the text functions. /// Text encoding for the C text functions. Wraps `PDStringEncoding`.
/// The Swift text wrappers always pass UTF-8.
public enum StringEncoding: UInt32, Sendable { public enum StringEncoding: UInt32, Sendable {
case ascii = 0 case ascii = 0
case utf8 = 1 case utf8 = 1
/// UTF-16, little-endian.
case utf16LittleEndian = 2 case utf16LittleEndian = 2
var cValue: PDStringEncoding { PDStringEncoding(PDStringEncoding.RawValue(rawValue)) } var cValue: PDStringEncoding { PDStringEncoding(PDStringEncoding.RawValue(rawValue)) }
@@ -1,7 +1,7 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// Horizontal alignment for `drawText(in:)`. /// Alignment for the rect-bounded `drawText` overloads. Wraps `PDTextAlignment`.
public enum TextAlignment: UInt32, Sendable { public enum TextAlignment: UInt32, Sendable {
case left = 0 case left = 0
case center = 1 case center = 1
@@ -1,8 +1,10 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// How text wraps in `drawText(in:)`. /// Wrapping for the rect-bounded `drawText` overloads and `Font.textHeight`.
/// Wraps `PDTextWrappingMode`.
public enum TextWrappingMode: UInt32, Sendable { public enum TextWrappingMode: UInt32, Sendable {
/// No wrapping; text past the edge is clipped.
case clip = 0 case clip = 0
case character = 1 case character = 1
case word = 2 case word = 2
+108 -64
View File
@@ -3,92 +3,95 @@ internal import CPlaydate
/// The graphics API: drawing, bitmaps, fonts, tilemaps, and video. /// The graphics API: drawing, bitmaps, fonts, tilemaps, and video.
public enum Graphics {} public enum Graphics {}
/// The cached `playdate->graphics` C API table. /// `playdate->graphics`.
var gfx: UnsafePointer<playdate_graphics> { Playdate.graphicsAPI.unsafelyUnwrapped } var gfx: UnsafePointer<playdate_graphics> { Playdate.graphicsAPI.unsafelyUnwrapped }
extension Graphics { extension Graphics {
// MARK: - Screen constants // MARK: - Screen constants
/// The width of the screen in pixels (`LCD_COLUMNS`). /// Screen width, in pixels (`LCD_COLUMNS`).
public static let columns = 400 public static let columns = 400
/// The height of the screen in pixels (`LCD_ROWS`). /// Screen height, in pixels (`LCD_ROWS`).
public static let rows = 240 public static let rows = 240
/// The stride of a framebuffer row in bytes (`LCD_ROWSIZE`). /// Framebuffer row stride, in bytes (`LCD_ROWSIZE`).
public static let rowSize = 52 public static let rowSize = 52
// MARK: - Drawing state // MARK: - Drawing state
/// Clears the entire display, filling it with `color`.
public static func clear(color: Color = .white) { public static func clear(color: Color = .white) {
color.withLCDColor { gfx.pointee.clear.unsafelyUnwrapped($0) } color.withLCDColor { gfx.pointee.clear.unsafelyUnwrapped($0) }
} }
/// Sets the background color shown when the display is offset or for /// Shown where the display is offset; clears dirty areas in the sprite system.
/// clear pixels in drawn images.
public static func setBackgroundColor(_ color: SolidColor) { public static func setBackgroundColor(_ color: SolidColor) {
gfx.pointee.setBackgroundColor.unsafelyUnwrapped(color.cValue) gfx.pointee.setBackgroundColor.unsafelyUnwrapped(color.cValue)
} }
/// Sets the mode that determines how source pixels combine with the /// Applies to bitmaps, and so text. Returns the previous mode.
/// destination. Returns the previous mode.
@discardableResult @discardableResult
public static func setDrawMode(_ mode: DrawMode) -> DrawMode { public static func setDrawMode(_ mode: DrawMode) -> DrawMode {
DrawMode(gfx.pointee.setDrawMode.unsafelyUnwrapped(mode.cValue)) DrawMode(gfx.pointee.setDrawMode.unsafelyUnwrapped(mode.cValue))
} }
/// Offsets all subsequent drawing by (dx, dy). /// Offsets subsequent drawing by (`dx`, `dy`) pixels; may be negative.
public static func setDrawOffset(dx: Int, dy: Int) { public static func setDrawOffset(dx: Int, dy: Int) {
gfx.pointee.setDrawOffset.unsafelyUnwrapped(Int32(dx), Int32(dy)) gfx.pointee.setDrawOffset.unsafelyUnwrapped(Int32(dx), Int32(dy))
} }
/// Sets the clip rect in world coordinates (affected by the draw offset). /// In world coordinates (translated by the draw offset). Cleared each update.
public static func setClipRect(x: Int, y: Int, width: Int, height: Int) { public static func setClipRect(x: Int, y: Int, width: Int, height: Int) {
gfx.pointee.setClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height)) gfx.pointee.setClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height))
} }
/// Sets the clip rect in screen coordinates (unaffected by the draw offset). /// In world coordinates (translated by the draw offset). Cleared each update.
public static func setClipRect(_ rect: Rect) {
setClipRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height)
}
/// In screen coordinates (ignoring the draw offset).
public static func setScreenClipRect(x: Int, y: Int, width: Int, height: Int) { public static func setScreenClipRect(x: Int, y: Int, width: Int, height: Int) {
gfx.pointee.setScreenClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height)) gfx.pointee.setScreenClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height))
} }
/// Clears the current clip rect. /// In screen coordinates (ignoring the draw offset).
public static func setScreenClipRect(_ rect: Rect) {
setScreenClipRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height)
}
public static func clearClipRect() { public static func clearClipRect() {
gfx.pointee.clearClipRect.unsafelyUnwrapped() gfx.pointee.clearClipRect.unsafelyUnwrapped()
} }
/// Sets the end cap style used by subsequent line drawing.
public static func setLineCapStyle(_ style: LineCapStyle) { public static func setLineCapStyle(_ style: LineCapStyle) {
gfx.pointee.setLineCapStyle.unsafelyUnwrapped(style.cValue) gfx.pointee.setLineCapStyle.unsafelyUnwrapped(style.cValue)
} }
/// Sets the stencil applied to subsequent drawing. If `tile` is `true` /// Pixels draw only where the stencil is white; `nil` clears it. A tiled stencil's
/// the stencil image is tiled, and its width must be a multiple of 32. /// width must be a multiple of 32. Not retained; keep it alive while set.
/// Pass `nil` to clear the stencil.
public static func setStencil(_ image: Bitmap?, tile: Bool = false) { public static func setStencil(_ image: Bitmap?, tile: Bool = false) {
gfx.pointee.setStencilImage.unsafelyUnwrapped(image?.pointer, tile ? 1 : 0) gfx.pointee.setStencilImage.unsafelyUnwrapped(image?.pointer, tile ? 1 : 0)
} }
/// Pushes a new drawing context targeting `target`, or the display if /// `nil` targets the display framebuffer. Not retained; keep `target` alive until
/// `target` is `nil`. /// the matching `popContext()`.
public static func pushContext(_ target: Bitmap? = nil) { public static func pushContext(_ target: Bitmap? = nil) {
gfx.pointee.pushContext.unsafelyUnwrapped(target?.pointer) gfx.pointee.pushContext.unsafelyUnwrapped(target?.pointer)
} }
/// Pops the top drawing context off the stack. /// Restores the previous context's drawing settings. No-op if none.
public static func popContext() { public static func popContext() {
gfx.pointee.popContext.unsafelyUnwrapped() gfx.pointee.popContext.unsafelyUnwrapped()
} }
// MARK: - Shapes // MARK: - Shapes
/// Draws a line from (x1, y1) to (x2, y2) with the given stroke width. /// `width` is in pixels.
public static func drawLine(x1: Int, y1: Int, x2: Int, y2: Int, width: Int, color: Color) { public static func drawLine(x1: Int, y1: Int, x2: Int, y2: Int, width: Int, color: Color) {
color.withLCDColor { color.withLCDColor {
gfx.pointee.drawLine.unsafelyUnwrapped(Int32(x1), Int32(y1), Int32(x2), Int32(y2), Int32(width), $0) gfx.pointee.drawLine.unsafelyUnwrapped(Int32(x1), Int32(y1), Int32(x2), Int32(y2), Int32(width), $0)
} }
} }
/// Fills the triangle with vertices (x1, y1), (x2, y2), and (x3, y3).
public static func fillTriangle(x1: Int, y1: Int, x2: Int, y2: Int, x3: Int, y3: Int, color: Color) { public static func fillTriangle(x1: Int, y1: Int, x2: Int, y2: Int, x3: Int, y3: Int, color: Color) {
color.withLCDColor { color.withLCDColor {
gfx.pointee.fillTriangle.unsafelyUnwrapped(Int32(x1), Int32(y1), Int32(x2), Int32(y2), gfx.pointee.fillTriangle.unsafelyUnwrapped(Int32(x1), Int32(y1), Int32(x2), Int32(y2),
@@ -96,22 +99,29 @@ extension Graphics {
} }
} }
/// Draws the outline of a rectangle, stroked inside its frame. /// Stroked inside its frame.
public static func drawRect(x: Int, y: Int, width: Int, height: Int, color: Color) { public static func drawRect(x: Int, y: Int, width: Int, height: Int, color: Color) {
color.withLCDColor { color.withLCDColor {
gfx.pointee.drawRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), $0) gfx.pointee.drawRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), $0)
} }
} }
/// Fills the rectangle with `color`. /// Stroked inside its frame.
public static func drawRect(_ rect: Rect, color: Color) {
drawRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, color: color)
}
public static func fillRect(x: Int, y: Int, width: Int, height: Int, color: Color) { public static func fillRect(x: Int, y: Int, width: Int, height: Int, color: Color) {
color.withLCDColor { color.withLCDColor {
gfx.pointee.fillRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), $0) gfx.pointee.fillRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), $0)
} }
} }
/// Draws the outline of a rectangle with rounded corners, stroked with public static func fillRect(_ rect: Rect, color: Color) {
/// `lineWidth`. fillRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, color: color)
}
/// Stroked inside the rect. `radius` and `lineWidth` are in pixels.
public static func drawRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int, public static func drawRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int,
lineWidth: Int, color: Color) { lineWidth: Int, color: Color) {
color.withLCDColor { color.withLCDColor {
@@ -120,7 +130,13 @@ extension Graphics {
} }
} }
/// Fills a rectangle with rounded corners. /// Stroked inside the rect. `radius` and `lineWidth` are in pixels.
public static func drawRoundRect(_ rect: Rect, radius: Int, lineWidth: Int, color: Color) {
drawRoundRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
radius: radius, lineWidth: lineWidth, color: color)
}
/// `radius` is in pixels.
public static func fillRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int, color: Color) { public static func fillRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int, color: Color) {
color.withLCDColor { color.withLCDColor {
gfx.pointee.fillRoundRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height), gfx.pointee.fillRoundRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height),
@@ -128,8 +144,14 @@ extension Graphics {
} }
} }
/// Draws an ellipse stroked inside the rect. If the angles differ, draws /// `radius` is in pixels.
/// an arc from `startAngle` to `endAngle` (clockwise degrees, 0 at top). public static func fillRoundRect(_ rect: Rect, radius: Int, color: Color) {
fillRoundRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
radius: radius, color: color)
}
/// Stroked inside the rect. Differing angles draw only that arc (degrees clockwise
/// from the top).
public static func drawEllipse(x: Int, y: Int, width: Int, height: Int, lineWidth: Int, public static func drawEllipse(x: Int, y: Int, width: Int, height: Int, lineWidth: Int,
startAngle: Float = 0, endAngle: Float = 0, color: Color) { startAngle: Float = 0, endAngle: Float = 0, color: Color) {
color.withLCDColor { color.withLCDColor {
@@ -138,8 +160,7 @@ extension Graphics {
} }
} }
/// Fills an ellipse inside the rect. If the angles differ, fills the /// Differing angles fill only that wedge (degrees clockwise from the top).
/// wedge from `startAngle` to `endAngle` (clockwise degrees, 0 at top).
public static func fillEllipse(x: Int, y: Int, width: Int, height: Int, public static func fillEllipse(x: Int, y: Int, width: Int, height: Int,
startAngle: Float = 0, endAngle: Float = 0, color: Color) { startAngle: Float = 0, endAngle: Float = 0, color: Color) {
color.withLCDColor { color.withLCDColor {
@@ -148,8 +169,22 @@ extension Graphics {
} }
} }
/// Fills the polygon described by the points, connecting the last point /// Stroked inside the rect. Differing angles draw only that arc (degrees clockwise
/// back to the first. /// from the top).
public static func drawEllipse(in rect: Rect, lineWidth: Int,
startAngle: Float = 0, endAngle: Float = 0, color: Color) {
drawEllipse(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
lineWidth: lineWidth, startAngle: startAngle, endAngle: endAngle, color: color)
}
/// Differing angles fill only that wedge (degrees clockwise from the top).
public static func fillEllipse(in rect: Rect,
startAngle: Float = 0, endAngle: Float = 0, color: Color) {
fillEllipse(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
startAngle: startAngle, endAngle: endAngle, color: color)
}
/// The last point connects back to the first.
public static func fillPolygon(points: [(x: Int, y: Int)], color: Color, public static func fillPolygon(points: [(x: Int, y: Int)], color: Color,
fillRule: PolygonFillRule = .nonZero) { fillRule: PolygonFillRule = .nonZero) {
withUnsafeTemporaryAllocation(of: Int32.self, capacity: points.count * 2) { coordinates in withUnsafeTemporaryAllocation(of: Int32.self, capacity: points.count * 2) { coordinates in
@@ -166,12 +201,12 @@ extension Graphics {
} }
} }
/// Sets the pixel at (x, y) in the current drawing context. /// Slow in bulk; prefer bitmaps or framebuffer writes for many pixels.
public static func setPixel(x: Int, y: Int, color: Color) { public static func setPixel(x: Int, y: Int, color: Color) {
color.withLCDColor { gfx.pointee.setPixel.unsafelyUnwrapped(Int32(x), Int32(y), $0) } color.withLCDColor { gfx.pointee.setPixel.unsafelyUnwrapped(Int32(x), Int32(y), $0) }
} }
/// Reads an 8×8 pattern from the bitmap starting at (x, y). /// The 8×8 pattern whose upper-left corner is (x, y) in `bitmap`.
public static func colorToPattern(from bitmap: Bitmap, x: Int, y: Int) -> Pattern { public static func colorToPattern(from bitmap: Bitmap, x: Int, y: Int) -> Pattern {
var color: LCDColor = 0 var color: LCDColor = 0
gfx.pointee.setColorToPattern.unsafelyUnwrapped(&color, bitmap.pointer, Int32(x), Int32(y)) gfx.pointee.setColorToPattern.unsafelyUnwrapped(&color, bitmap.pointer, Int32(x), Int32(y))
@@ -186,85 +221,94 @@ extension Graphics {
// MARK: - Text // MARK: - Text
/// Draws `text` at (x, y) using the current font. Returns the drawn width. /// Uses the current font, or the system font if none is set. Returns the drawn width.
@discardableResult @discardableResult
public static func drawText(_ text: String, x: Int, y: Int) -> Int { public static func drawText(_ text: String, x: Int, y: Int) -> Int {
text.withPlaydateUTF8 { bytes, count in text.withCString { cString in
Int(gfx.pointee.drawText.unsafelyUnwrapped(bytes, count, Int(gfx.pointee.drawText.unsafelyUnwrapped(cString, text.utf8.count,
kUTF8Encoding, Int32(x), Int32(y))) kUTF8Encoding, Int32(x), Int32(y)))
} }
} }
/// Draws `text` wrapped and aligned inside the given rectangle. /// Wrapped and aligned inside the rect, with the current font.
public static func drawText(_ text: String, x: Int, y: Int, width: Int, height: Int, public static func drawText(_ text: String, x: Int, y: Int, width: Int, height: Int,
wrap: TextWrappingMode = .word, align: TextAlignment = .left) { wrap: TextWrappingMode = .word, align: TextAlignment = .left) {
text.withPlaydateUTF8 { bytes, count in text.withCString { cString in
gfx.pointee.drawTextInRect.unsafelyUnwrapped(bytes, count, kUTF8Encoding, gfx.pointee.drawTextInRect.unsafelyUnwrapped(cString, text.utf8.count, kUTF8Encoding,
Int32(x), Int32(y), Int32(width), Int32(height), Int32(x), Int32(y), Int32(width), Int32(height),
wrap.cValue, align.cValue) wrap.cValue, align.cValue)
} }
} }
/// Sets the font used by subsequent text drawing. /// Wrapped and aligned inside the rect, with the current font.
public static func drawText(_ text: String, in rect: Rect,
wrap: TextWrappingMode = .word, align: TextAlignment = .left) {
drawText(text, x: rect.left, y: rect.top, width: rect.width, height: rect.height,
wrap: wrap, align: align)
}
/// Not retained; keep `font` alive while set.
public static func setFont(_ font: Font) { public static func setFont(_ font: Font) {
gfx.pointee.setFont.unsafelyUnwrapped(font.pointer) gfx.pointee.setFont.unsafelyUnwrapped(font.pointer)
} }
/// Extra space added between letters, in pixels. /// Extra space between letters, in pixels.
public static func setTextTracking(_ tracking: Int) {
gfx.pointee.setTextTracking.unsafelyUnwrapped(Int32(tracking))
}
/// The extra space currently added between letters, in pixels.
public static var textTracking: Int { public static var textTracking: Int {
Int(gfx.pointee.getTextTracking.unsafelyUnwrapped()) get { Int(gfx.pointee.getTextTracking.unsafelyUnwrapped()) }
set { gfx.pointee.setTextTracking.unsafelyUnwrapped(Int32(newValue)) }
} }
/// Adjusts the line height used when drawing multi-line text. /// Pixels added to the font's own leading for multi-line text.
public static func setTextLeading(_ lineHeightAdjustment: Int) { public static func setTextLeading(_ lineHeightAdjustment: Int) {
gfx.pointee.setTextLeading.unsafelyUnwrapped(Int32(lineHeightAdjustment)) gfx.pointee.setTextLeading.unsafelyUnwrapped(Int32(lineHeightAdjustment))
} }
// MARK: - Framebuffer // MARK: - Framebuffer
/// The current working framebuffer. Rows are `rowSize` bytes. /// The working framebuffer: `rows` rows of `rowSize` bytes, 1 bit per pixel, MSB first,
/// Call `markUpdatedRows(from:to:)` after writing directly. /// last 2 bytes of each row unused. The span is valid only inside `body`; `nil` if
public static var frame: UnsafeMutablePointer<UInt8>? { /// there is no framebuffer. Call `markUpdatedRows(from:to:)` after writing.
gfx.pointee.getFrame.unsafelyUnwrapped() public static func withFrame<Result, Failure: Error>(
_ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result? {
guard let frame = gfx.pointee.getFrame.unsafelyUnwrapped() else { return nil }
var span = UnsafeMutableBufferPointer(start: frame, count: rows * rowSize).mutableSpan
return try body(&span)
} }
/// The framebuffer currently shown on the display. Rows are `rowSize` bytes. /// The last frame shown, laid out like `withFrame(_:)`. The span is valid only inside
public static var displayFrame: UnsafeMutablePointer<UInt8>? { /// `body`; `nil` if there is no framebuffer.
gfx.pointee.getDisplayFrame.unsafelyUnwrapped() public static func withDisplayFrame<Result, Failure: Error>(
_ body: (Span<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result? {
guard let frame = gfx.pointee.getDisplayFrame.unsafelyUnwrapped() else { return nil }
return try body(UnsafeBufferPointer(start: frame, count: rows * rowSize).span)
} }
/// A bitmap view of the display framebuffer. Simulator only; `nil` on device. /// Simulator only: white pixels overlay the display in translucent red. `nil` on device.
public static var debugBitmap: Bitmap? { public static var debugBitmap: Bitmap? {
guard let getDebugBitmap = gfx.pointee.getDebugBitmap, guard let getDebugBitmap = gfx.pointee.getDebugBitmap,
let pointer = getDebugBitmap() else { return nil } let pointer = getDebugBitmap() else { return nil }
return Bitmap(pointer: pointer, isOwned: false) return Bitmap(pointer: pointer, isOwned: false)
} }
/// A bitmap referencing the display framebuffer (not a copy). /// Not a copy; owned by the system.
public static var displayBufferBitmap: Bitmap? { public static var displayBufferBitmap: Bitmap? {
guard let pointer = gfx.pointee.getDisplayBufferBitmap.unsafelyUnwrapped() else { return nil } guard let pointer = gfx.pointee.getDisplayBufferBitmap.unsafelyUnwrapped() else { return nil }
return Bitmap(pointer: pointer, isOwned: false) return Bitmap(pointer: pointer, isOwned: false)
} }
/// A copy of the working framebuffer as a new bitmap.
public static func copyFrameBufferBitmap() -> Bitmap? { public static func copyFrameBufferBitmap() -> Bitmap? {
guard let pointer = gfx.pointee.copyFrameBufferBitmap.unsafelyUnwrapped() else { return nil } guard let pointer = gfx.pointee.copyFrameBufferBitmap.unsafelyUnwrapped() else { return nil }
return Bitmap(pointer: pointer, isOwned: true) return Bitmap(pointer: pointer, isOwned: true)
} }
/// Tells the system which rows (inclusive) were changed by direct /// Marks rows `start`...`end` (inclusive) as changed by direct framebuffer writes.
/// framebuffer writes and need redisplay.
public static func markUpdatedRows(from start: Int, to end: Int) { public static func markUpdatedRows(from start: Int, to end: Int) {
gfx.pointee.markUpdatedRows.unsafelyUnwrapped(Int32(start), Int32(end)) gfx.pointee.markUpdatedRows.unsafelyUnwrapped(Int32(start), Int32(end))
} }
/// Manually flushes the framebuffer to the display. Only needed when /// Flushes the framebuffer. The system does this after each update.
/// drawing outside the normal update cycle.
public static func display() { public static func display() {
gfx.pointee.display.unsafelyUnwrapped() gfx.pointee.display.unsafelyUnwrapped()
} }
@@ -1,18 +1,12 @@
extension Graphics.Bitmap { extension Graphics.Bitmap {
/// The bitmap's dimensions, row stride, and raw pixel/mask storage. /// A bitmap's layout. Read pixels with `withPixelData(_:)` and `withMaskData(_:)`.
/// The pointers are owned by the bitmap. public struct Data: Sendable {
public struct Data { /// Width, in pixels.
/// The bitmap's width, in pixels.
public let width: Int public let width: Int
/// The bitmap's height, in pixels. /// Height, in pixels.
public let height: Int public let height: Int
/// The stride of one row of pixel data, in bytes. /// Row stride of the pixel and mask data, in bytes.
public let rowBytes: Int public let rowBytes: Int
/// The bitmap's mask data, or `nil` if it has no mask. One bit per public let hasMask: Bool
/// pixel; rows are `rowBytes` wide.
public let mask: UnsafeMutablePointer<UInt8>?
/// The bitmap's pixel data. One bit per pixel; rows are `rowBytes`
/// wide.
public let data: UnsafeMutablePointer<UInt8>?
} }
} }
@@ -1,13 +1,12 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// A page of glyphs within a font. Wraps `LCDFontPage`. /// A page of 256 glyphs in a font. Wraps `LCDFontPage`. Retains its font.
/// Keep the font alive while using its pages.
public struct FontPage { public struct FontPage {
let pointer: OpaquePointer let pointer: OpaquePointer
let font: Font let font: Font
/// The glyph for `codepoint` within this page, with its bitmap and advance. /// `nil` if `codepoint` isn't on this page. The bitmap doesn't retain the font.
public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? { public func glyph(for codepoint: UInt32) -> (glyph: Glyph, bitmap: Bitmap?, advance: Int)? {
var bitmap: OpaquePointer? var bitmap: OpaquePointer?
var advance: Int32 = 0 var advance: Int32 = 0
@@ -1,13 +1,12 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// A single glyph within a font. Wraps `LCDFontGlyph`. /// A glyph in a font. Wraps `LCDFontGlyph`. Retains its font.
/// Keep the font alive while using its glyphs.
public struct Glyph { public struct Glyph {
let pointer: OpaquePointer let pointer: OpaquePointer
let font: Font let font: Font
/// The kerning adjustment between this glyph and the next character. /// Kerning adjustment between `glyphCode` and `nextCode`.
public func kerning(glyphCode: UInt32, nextCode: UInt32) -> Int { public func kerning(glyphCode: UInt32, nextCode: UInt32) -> Int {
Int(gfx.pointee.getGlyphKerning.unsafelyUnwrapped(pointer, glyphCode, nextCode)) Int(gfx.pointee.getGlyphKerning.unsafelyUnwrapped(pointer, glyphCode, nextCode))
} }
@@ -1,16 +1,15 @@
internal import CPlaydate internal import CPlaydate
extension Graphics { extension Graphics {
/// An integer rectangle mirroring `LCDRect`. `right` and `bottom` are /// A rectangle, in pixels. Mirrors `LCDRect`: `right` and `bottom` are exclusive.
/// not inclusive.
public struct Rect: Sendable { public struct Rect: Sendable {
public var left: Int public var left: Int
/// Exclusive.
public var right: Int public var right: Int
public var top: Int public var top: Int
/// Exclusive.
public var bottom: Int public var bottom: Int
/// Creates a rect from its edges. `right` and `bottom` are not
/// inclusive.
public init(left: Int, right: Int, top: Int, bottom: Int) { public init(left: Int, right: Int, top: Int, bottom: Int) {
self.left = left self.left = left
self.right = right self.right = right
@@ -18,7 +17,7 @@ extension Graphics {
self.bottom = bottom self.bottom = bottom
} }
/// Creates a rect from an origin and size. /// `(x, y)` is the upper-left corner.
public init(x: Int, y: Int, width: Int, height: Int) { public init(x: Int, y: Int, width: Int, height: Int) {
self.init(left: x, right: x + width, top: y, bottom: y + height) self.init(left: x, right: x + width, top: y, bottom: y + height)
} }
@@ -33,7 +32,10 @@ extension Graphics {
top: Int32(top), bottom: Int32(bottom)) top: Int32(top), bottom: Int32(bottom))
} }
/// Returns the rect offset by (dx, dy). public var width: Int { right - left }
public var height: Int { bottom - top }
public func translated(dx: Int, dy: Int) -> Rect { public func translated(dx: Int, dy: Int) -> Rect {
Rect(left: left + dx, right: right + dx, top: top + dy, bottom: bottom + dy) Rect(left: left + dx, right: right + dx, top: top + dy, bottom: bottom + dy)
} }
@@ -1,21 +1,44 @@
extension Graphics { extension Graphics {
/// An 8×8 two-color pattern: 8 rows of image data followed by 8 rows of mask. /// An 8×8 pattern. Mirrors `LCDPattern`: 8 image rows then 8 mask rows,
/// one byte per row, one bit per pixel.
public struct Pattern: Sendable { public struct Pattern: Sendable {
/// The pattern's 8 rows of image data followed by 8 rows of mask,
/// one byte per row.
public var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, public var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)
/// Creates a pattern from 8 rows of image data and 8 rows of mask.
public init(bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, public init(bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) {
self.bytes = bytes self.bytes = bytes
} }
/// Creates an opaque pattern from 8 rows of image data. /// An opaque pattern (mask rows all `0xff`).
public init(rows r: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { public init(rows r: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) {
bytes = (r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, bytes = (r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff) 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)
} }
} }
} }
// InlineArray needs macOS 26 on the host; device and Linux are unrestricted.
// Conversions reinterpret the same 16 bytes.
@available(macOS 26, *)
extension Graphics.Pattern {
public init(bytes: [16 of UInt8]) {
self.init(bytes: unsafeBitCast(bytes, to: Bytes.self))
}
/// An opaque pattern (mask rows all `0xff`).
public init(rows: [8 of UInt8]) {
self.init(bytes: [16 of UInt8] { $0 < 8 ? rows[$0] : 0xff })
}
/// `bytes` as an inline array.
public var inlineBytes: [16 of UInt8] {
get { unsafeBitCast(bytes, to: [16 of UInt8].self) }
set { bytes = unsafeBitCast(newValue, to: Bytes.self) }
}
}
extension Graphics.Pattern {
typealias Bytes = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)
}
+16 -19
View File
@@ -1,28 +1,29 @@
internal import CPlaydate internal import CPlaydate
extension JSON { extension JSON {
/// A streaming JSON encoder writing into a string. Wraps `json_encoder`. /// A streaming JSON encoder into a string. Wraps `json_encoder`.
/// Does not validate: the caller must emit well-formed JSON.
public final class Encoder { public final class Encoder {
/// A class so the write callback's userdata pointer stays stable.
private final class Output { private final class Output {
var text = "" var bytes: [UInt8] = []
} }
private var encoder = json_encoder() private var encoder = json_encoder()
private let output = Output() private let output = Output()
/// `pretty` adds human-readable formatting.
public init(pretty: Bool = false) { public init(pretty: Bool = false) {
jsonAPI.pointee.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in jsonAPI.pointee.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in
guard let userdata, let string else { return } guard let userdata, let string else { return }
let output = Unmanaged<Output>.fromOpaque(userdata).takeUnretainedValue() let output = Unmanaged<Output>.fromOpaque(userdata).takeUnretainedValue()
let bytes = UnsafeRawBufferPointer(start: string, count: Int(length)) output.bytes.append(contentsOf: UnsafeRawBufferPointer(start: string, count: Int(length)))
output.text += String(decoding: bytes, as: UTF8.self)
}, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0) }, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0)
} }
/// The JSON produced so far. /// The JSON produced so far.
public var json: String { output.text } public var json: String { String(decoding: output.bytes, as: UTF8.self) }
/// Starts a JSON array.
public func startArray() { public func startArray() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.startArray.unsafelyUnwrapped($0) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.startArray.unsafelyUnwrapped($0) }
} }
@@ -32,7 +33,6 @@ extension JSON {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.addArrayMember.unsafelyUnwrapped($0) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.addArrayMember.unsafelyUnwrapped($0) }
} }
/// Ends the current array.
public func endArray() { public func endArray() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.endArray.unsafelyUnwrapped($0) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.endArray.unsafelyUnwrapped($0) }
} }
@@ -42,52 +42,49 @@ extension JSON {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.startTable.unsafelyUnwrapped($0) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.startTable.unsafelyUnwrapped($0) }
} }
/// Call before writing each table value. /// Call before writing member `name`'s value.
public func addTableMember(name: String) { public func addTableMember(name: String) {
name.withPlaydateCString { cName in name.withCString { cString in
withUnsafeMutablePointer(to: &encoder) { withUnsafeMutablePointer(to: &encoder) {
$0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count)) $0.pointee.addTableMember.unsafelyUnwrapped(
$0, cString, Int32(name.utf8.count))
} }
} }
} }
/// Ends the current object.
public func endTable() { public func endTable() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.endTable.unsafelyUnwrapped($0) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.endTable.unsafelyUnwrapped($0) }
} }
/// Writes a `null` value.
public func writeNull() { public func writeNull() {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeNull.unsafelyUnwrapped($0) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeNull.unsafelyUnwrapped($0) }
} }
/// Writes a boolean value.
public func writeBool(_ value: Bool) { public func writeBool(_ value: Bool) {
withUnsafeMutablePointer(to: &encoder) { withUnsafeMutablePointer(to: &encoder) {
(value ? $0.pointee.writeTrue : $0.pointee.writeFalse).unsafelyUnwrapped($0) (value ? $0.pointee.writeTrue : $0.pointee.writeFalse).unsafelyUnwrapped($0)
} }
} }
/// Writes an integer value. /// `value` must fit in `Int32`.
public func writeInt(_ value: Int) { public func writeInt(_ value: Int) {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeInt.unsafelyUnwrapped($0, Int32(value)) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeInt.unsafelyUnwrapped($0, Int32(value)) }
} }
/// Writes a floating-point value.
public func writeDouble(_ value: Double) { public func writeDouble(_ value: Double) {
withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeDouble.unsafelyUnwrapped($0, value) } withUnsafeMutablePointer(to: &encoder) { $0.pointee.writeDouble.unsafelyUnwrapped($0, value) }
} }
/// Writes a string value.
public func writeString(_ value: String) { public func writeString(_ value: String) {
value.withPlaydateCString { cString in value.withCString { cString in
withUnsafeMutablePointer(to: &encoder) { withUnsafeMutablePointer(to: &encoder) {
$0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count)) $0.pointee.writeString.unsafelyUnwrapped(
$0, cString, Int32(value.utf8.count))
} }
} }
} }
/// Writes a complete `Value` tree. /// Writes a whole `Value` tree; `.float` as `Double`, keys in `Dictionary` order.
public func write(_ value: Value) { public func write(_ value: Value) {
switch value { switch value {
case .null: case .null:
@@ -1,19 +1,15 @@
extension JSON { extension JSON {
/// A decoded JSON value. /// A JSON value tree, produced by `JSON.decode` and consumed by `JSON.encode(_:pretty:)`.
public indirect enum Value { public indirect enum Value {
/// A JSON `null`.
case null case null
/// A JSON `true` or `false`.
case bool(Bool) case bool(Bool)
/// A JSON number without a fractional part. /// Encoded as 32-bit; must fit in `Int32`.
case int(Int) case int(Int)
/// A JSON number with a fractional part. /// A number with a fractional part.
case float(Float) case float(Float)
/// A JSON string.
case string(String) case string(String)
/// A JSON array.
case array([Value]) case array([Value])
/// A JSON object. /// A JSON object; key order is not preserved.
case table([String: Value]) case table([String: Value])
} }
} }
+23 -37
View File
@@ -1,26 +1,22 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->json` C API table. /// Cached `playdate->json` table.
var jsonAPI: UnsafePointer<playdate_json> { Playdate.jsonAPI.unsafelyUnwrapped } var jsonAPI: UnsafePointer<playdate_json> { Playdate.jsonAPI.unsafelyUnwrapped }
/// The JSON API: decoding to and encoding from a `Value` tree. /// The JSON API: decodes to a complete `Value` tree; encodes by streaming (`Encoder`)
/// /// or in one shot (`encode(_:pretty:)`).
/// 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`.
public enum JSON {} public enum JSON {}
extension JSON { extension JSON {
// MARK: - Decoding // MARK: - Decoding
/// Boxes a finished container to pass through the C decoder as a `void*`.
private final class ValueBox { private final class ValueBox {
var value: Value var value: Value
init(_ value: Value) { self.value = value } init(_ value: Value) { self.value = value }
} }
/// A container under construction. A class, so appends mutate uniquely /// A container being built; a class so appends don't copy out of an enum payload.
/// referenced storage in place instead of copying the collection out of
/// and back into an enum payload on every element.
private final class Container { private final class Container {
let isArray: Bool let isArray: Bool
var items: [Value] = [] var items: [Value] = []
@@ -32,7 +28,7 @@ extension JSON {
} }
private final class DecodeContext { private final class DecodeContext {
/// Containers under construction, innermost last. /// Open containers, innermost last.
var stack: [Container] = [] var stack: [Container] = []
var errorMessage: String? var errorMessage: String?
var errorLine: Int32 = 0 var errorLine: Int32 = 0
@@ -90,80 +86,70 @@ extension JSON {
guard let userdata = decoder?.pointee.userdata else { return nil } guard let userdata = decoder?.pointee.userdata else { return nil }
let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue() let context = Unmanaged<DecodeContext>.fromOpaque(userdata).takeUnretainedValue()
guard let finished = context.stack.popLast() else { return nil } guard let finished = context.stack.popLast() else { return nil }
// Handed to the parent container (or the decode outval) as the // Goes to the parent's callback (or `outval` for the root); `convert` releases it.
// sublist's value; consumed by `convert`.
return Unmanaged.passRetained(ValueBox(finished.value)).toOpaque() return Unmanaged.passRetained(ValueBox(finished.value)).toOpaque()
} }
return decoder return decoder
} }
/// Decodes a JSON string into a `Value` tree. /// Decodes `jsonString`; throws the decoder's error message on failure.
public static func decode(_ jsonString: String) throws(PlaydateError) -> Value { public static func decode(_ jsonString: String) throws(PlaydateError) -> Value {
let context = DecodeContext() let context = DecodeContext()
let unmanaged = Unmanaged.passUnretained(context) let unmanaged = Unmanaged.passUnretained(context)
var decoder = makeDecoder(context: unmanaged) var decoder = makeDecoder(context: unmanaged)
var outval = json_value() var outval = json_value()
let ok = jsonString.withPlaydateCString { cString in let ok = jsonString.withCString { cString in
withExtendedLifetime(context) { withExtendedLifetime(context) {
jsonAPI.pointee.decodeString.unsafelyUnwrapped(&decoder, cString, &outval) != 0 jsonAPI.pointee.decodeString.unsafelyUnwrapped(&decoder, cString, &outval) != 0
} }
} }
guard ok else { guard ok else {
// A completed root container may already have been written to // Consume any root box already written to outval so it isn't leaked.
// outval before the failure; consume it so its box is not leaked.
_ = convert(outval) _ = convert(outval)
throw decodeError(context) throw decodeError(context)
} }
return convert(outval) return convert(outval)
} }
/// Decodes JSON read from an open file into a `Value` tree. /// Decodes JSON from `file`'s current offset, leaving it open; throws the decoder's
public static func decode(file: File.Handle) throws(PlaydateError) -> Value { /// error message on failure.
public static func decode(file: borrowing File.Handle) throws(PlaydateError) -> Value {
let context = DecodeContext() let context = DecodeContext()
var decoder = makeDecoder(context: Unmanaged.passUnretained(context)) var decoder = makeDecoder(context: Unmanaged.passUnretained(context))
var reader = json_reader() var reader = json_reader()
reader.userdata = Unmanaged.passUnretained(file).toOpaque() // Borrowing keeps the `SDFile` open for the whole decode.
reader.userdata = file.pointer
reader.read = { userdata, buffer, size in reader.read = { userdata, buffer, size in
guard let userdata, let buffer else { return -1 } // `file->read` returns 0 at end of data, as the decoder expects.
let file = Unmanaged<File.Handle>.fromOpaque(userdata).takeUnretainedValue() guard let userdata, let buffer else { return 0 }
let destination = UnsafeMutableRawBufferPointer(start: buffer, count: Int(size)) return fileAPI.pointee.read.unsafelyUnwrapped(userdata, buffer, UInt32(size))
do {
let count = try file.read(into: destination)
return count > 0 ? Int32(count) : -1
} catch {
return -1
}
} }
var outval = json_value() var outval = json_value()
let ok = withExtendedLifetime(context) { let ok = withExtendedLifetime(context) {
withExtendedLifetime(file) {
jsonAPI.pointee.decode.unsafelyUnwrapped(&decoder, reader, &outval) != 0 jsonAPI.pointee.decode.unsafelyUnwrapped(&decoder, reader, &outval) != 0
} }
}
guard ok else { guard ok else {
// A completed root container may already have been written to // Consume any root box already written to outval so it isn't leaked.
// outval before the failure; consume it so its box is not leaked.
_ = convert(outval) _ = convert(outval)
throw decodeError(context) throw decodeError(context)
} }
return convert(outval) return convert(outval)
} }
/// Opens and decodes the JSON file at `path`. /// Decodes the file at `path` (Data directory first, then pdx), closing it on return.
public static func decodeFile(at path: String) throws(PlaydateError) -> Value { public static func decodeFile(path: String) throws(PlaydateError) -> Value {
let file = try File.Handle(path: path, mode: [.read, .readData]) let file = try File.Handle(path: path, mode: [.read, .readData])
return try decode(file: file) return try decode(file: file)
} }
// Static message: interpolating the line number would pull integer // Static message: interpolating the line number pulls integer formatting into binaries.
// formatting machinery into every device binary that decodes JSON.
private static func decodeError(_ context: DecodeContext) -> PlaydateError { private static func decodeError(_ context: DecodeContext) -> PlaydateError {
PlaydateError(message: context.errorMessage ?? "JSON decode failed") PlaydateError(message: context.errorMessage ?? "JSON decode failed")
} }
// MARK: - Encoding // MARK: - Encoding
/// Encodes a `Value` tree as a JSON string. /// Encodes `value`; `pretty` adds formatting. Table keys follow `Dictionary` order.
public static func encode(_ value: Value, pretty: Bool = false) -> String { public static func encode(_ value: Value, pretty: Bool = false) -> String {
let encoder = Encoder(pretty: pretty) let encoder = Encoder(pretty: pretty)
encoder.write(value) encoder.write(value)
@@ -1,7 +1,6 @@
public import CPlaydate public import CPlaydate
extension Lua { extension Lua {
/// A function callable from Lua. Returns the number of values it pushed /// Wraps `lua_CFunction`; returns the number of values it pushed as results.
/// onto the stack.
public typealias CFunction = lua_CFunction public typealias CFunction = lua_CFunction
} }
@@ -1,11 +1,8 @@
extension Lua { extension Lua {
/// A constant published on a registered class. /// A class constant for `Lua.registerClass`. Wraps `lua_val`.
public enum ClassValue { public enum ClassValue {
/// An integer constant.
case int(name: String, value: UInt32) case int(name: String, value: UInt32)
/// A floating-point constant.
case float(name: String, value: Float) case float(name: String, value: Float)
/// A string constant.
case string(name: String, value: String) case string(name: String, value: String)
} }
} }
@@ -1,8 +1,9 @@
internal import CPlaydate internal import CPlaydate
extension Lua { extension Lua {
/// The type of a value on the Lua stack. /// The type of a value on the Lua stack. Wraps `LuaType`.
public enum Kind: UInt32, Sendable { public enum Kind: UInt32, Sendable {
/// Also used for unrecognized type codes.
case `nil` = 0 case `nil` = 0
case bool = 1 case bool = 1
case int = 2 case int = 2
@@ -10,7 +11,9 @@ extension Lua {
case string = 4 case string = 4
case table = 5 case table = 5
case function = 6 case function = 6
/// A coroutine.
case thread = 7 case thread = 7
/// Userdata.
case object = 8 case object = 8
init(_ type: LuaType) { self = Kind(rawValue: UInt32(type.rawValue)) ?? .nil } init(_ type: LuaType) { self = Kind(rawValue: UInt32(type.rawValue)) ?? .nil }
+34 -55
View File
@@ -1,21 +1,16 @@
// A public import: `addFunction(_:name:)` and `pushFunction(_:)` expose the // Internal suffices: `CFunction.swift` publicly imports `lua_CFunction`.
// `CFunction` alias of `lua_CFunction` in their public signatures. internal import CPlaydate
public import CPlaydate
/// The cached `playdate->lua` C API table. /// The cached `playdate->lua` C API table.
var luaAPI: UnsafePointer<playdate_lua> { Playdate.luaAPI.unsafelyUnwrapped } var luaAPI: UnsafePointer<playdate_lua> { Playdate.luaAPI.unsafelyUnwrapped }
/// The Lua bridge: registering C functions and classes, and exchanging /// Lua bridge: registers C functions and classes; exchanges values via the Lua stack.
/// values with Lua code. /// Registered functions must be `CFunction`s (`@convention(c)`), not capturing
/// /// closures. Argument positions are 1-based.
/// Lua callbacks are C function pointers without userdata, so functions
/// registered here must be `@convention(c)` (the `CFunction` typealias),
/// not capturing closures.
public enum Lua {} public enum Lua {}
extension Lua { extension Lua {
/// Buffers passed to `registerClass`/`addFunction`; the OS may keep /// Strings and tables passed to `registerClass`; never freed (the OS may keep them).
/// referencing them, so they are retained for the life of the game.
nonisolated(unsafe) private static var retainedBuffers: [UnsafeMutableRawPointer] = [] nonisolated(unsafe) private static var retainedBuffers: [UnsafeMutableRawPointer] = []
private static func retainedCString(_ string: String) -> UnsafePointer<CChar> { private static func retainedCString(_ string: String) -> UnsafePointer<CChar> {
@@ -26,25 +21,23 @@ extension Lua {
// MARK: - Registration // MARK: - Registration
/// Makes `function` callable from Lua as `name` (which may contain dots /// Makes `function` callable from Lua as `name`, which may be a dotted path
/// for namespacing, e.g. "mylib.myfunc"). /// ("mylib.myfunc"). Throws `PlaydateError`.
public static func addFunction(_ function: CFunction, name: String) throws(PlaydateError) { public static func addFunction(_ function: CFunction, name: String) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
let ok = name.withPlaydateCString { let ok = name.withCString {
luaAPI.pointee.addFunction.unsafelyUnwrapped(function, $0, &error) != 0 luaAPI.pointee.addFunction.unsafelyUnwrapped(function, $0, &error) != 0
} }
if !ok { throw PlaydateError(cString: error) } if !ok { throw PlaydateError(cString: error) }
} }
/// Registers a Lua class named `name` with the given methods and /// Registers class `name` (a metatable; a plain table if `isStatic`) with
/// constants. When `isStatic` is `true` a plain table of functions is /// `functions` and constant `values`. Throws `PlaydateError`.
/// created instead of a class.
public static func registerClass(name: String, public static func registerClass(name: String,
functions: [(name: String, function: CFunction)], functions: [(name: String, function: CFunction)],
values: [ClassValue] = [], values: [ClassValue] = [],
isStatic: Bool = false) throws(PlaydateError) { isStatic: Bool = false) throws(PlaydateError) {
// The registration tables are kept alive permanently: the OS // Leaked on purpose: the C API is not documented to copy them.
// documents no copying guarantees for them.
var registrations: [lua_reg] = functions.map { entry in var registrations: [lua_reg] = functions.map { entry in
lua_reg(name: retainedCString(entry.name), func: entry.function) lua_reg(name: retainedCString(entry.name), func: entry.function)
} }
@@ -72,7 +65,7 @@ extension Lua {
retainedBuffers.append(UnsafeMutableRawPointer(constantsBuffer)) retainedBuffers.append(UnsafeMutableRawPointer(constantsBuffer))
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
let ok = name.withPlaydateCString { let ok = name.withCString {
luaAPI.pointee.registerClass.unsafelyUnwrapped($0, registrationsBuffer, luaAPI.pointee.registerClass.unsafelyUnwrapped($0, registrationsBuffer,
values.isEmpty ? nil : constantsBuffer, values.isEmpty ? nil : constantsBuffer,
isStatic ? 1 : 0, &error) != 0 isStatic ? 1 : 0, &error) != 0
@@ -80,36 +73,34 @@ extension Lua {
if !ok { throw PlaydateError(cString: error) } if !ok { throw PlaydateError(cString: error) }
} }
/// Pushes a function onto the stack, e.g. for `setUserValue`.
public static func pushFunction(_ function: CFunction) { public static func pushFunction(_ function: CFunction) {
luaAPI.pointee.pushFunction.unsafelyUnwrapped(function) luaAPI.pointee.pushFunction.unsafelyUnwrapped(function)
} }
/// From a class's `__index` callback: looks up the key in the instance /// Looks up the indexed key in the class metatable; call first in `__index`.
/// metatable first. Returns 1 if a value was found. /// If `true`, the value is on the stack and `__index` should return 1.
public static func indexMetatable() -> Bool { public static func indexMetatable() -> Bool {
luaAPI.pointee.indexMetatable.unsafelyUnwrapped() != 0 luaAPI.pointee.indexMetatable.unsafelyUnwrapped() != 0
} }
/// Pauses the Lua runtime. /// Stops the Lua run loop.
public static func stop() { public static func stop() {
luaAPI.pointee.stop.unsafelyUnwrapped() luaAPI.pointee.stop.unsafelyUnwrapped()
} }
/// Resumes the Lua runtime. /// Restarts the Lua run loop after `stop()`.
public static func start() { public static func start() {
luaAPI.pointee.start.unsafelyUnwrapped() luaAPI.pointee.start.unsafelyUnwrapped()
} }
// MARK: - Arguments // MARK: - Arguments
/// The number of arguments the Lua caller passed. Positions are 1-based. /// The number of arguments to the current Lua call.
public static var argumentCount: Int { public static var argumentCount: Int {
Int(luaAPI.pointee.getArgCount.unsafelyUnwrapped()) Int(luaAPI.pointee.getArgCount.unsafelyUnwrapped())
} }
/// The type of the argument at 1-based `position`; for objects, also the /// The argument's type, plus its metatable name if `.object` (else `nil`).
/// class name.
public static func argumentType(at position: Int) -> (kind: Kind, className: String?) { public static func argumentType(at position: Int) -> (kind: Kind, className: String?) {
var className: UnsafePointer<CChar>? var className: UnsafePointer<CChar>?
let type = luaAPI.pointee.getArgType.unsafelyUnwrapped(Int32(position), &className) let type = luaAPI.pointee.getArgType.unsafelyUnwrapped(Int32(position), &className)
@@ -132,11 +123,12 @@ extension Lua {
luaAPI.pointee.getArgFloat.unsafelyUnwrapped(Int32(position)) luaAPI.pointee.getArgFloat.unsafelyUnwrapped(Int32(position))
} }
/// `nil` if the C API returns `NULL`.
public static func stringArgument(at position: Int) -> String? { public static func stringArgument(at position: Int) -> String? {
String(playdateCString: luaAPI.pointee.getArgString.unsafelyUnwrapped(Int32(position))) String(playdateCString: luaAPI.pointee.getArgString.unsafelyUnwrapped(Int32(position)))
} }
/// The argument as raw bytes (which may contain embedded zeros). /// Raw bytes (may contain zeros), or `nil` if the C API returns `NULL`.
public static func bytesArgument(at position: Int) -> [UInt8]? { public static func bytesArgument(at position: Int) -> [UInt8]? {
var length = 0 var length = 0
guard let bytes = luaAPI.pointee.getArgBytes.unsafelyUnwrapped(Int32(position), &length) else { guard let bytes = luaAPI.pointee.getArgBytes.unsafelyUnwrapped(Int32(position), &length) else {
@@ -146,28 +138,24 @@ extension Lua {
return [UInt8](buffer) return [UInt8](buffer)
} }
/// The argument as an object instance of class `type`, with the /// Instance of class `type` and its handle; `object` is `nil` on type mismatch.
/// `UDObject` handle for retaining it.
public static func objectArgument(at position: Int, type: String) public static func objectArgument(at position: Int, type: String)
-> (object: UnsafeMutableRawPointer?, userdataObject: UDObject?) { -> (object: UnsafeMutableRawPointer?, userdataObject: UDObject?) {
var userdataObject: OpaquePointer? var userdataObject: OpaquePointer?
// The C API takes a non-const class name but only reads it, so the // The C API declares the class name non-const but only reads it.
// stack copy can be passed with a mutating cast. let object = type.withCString { cType in
let object = type.withPlaydateCString { cType in
luaAPI.pointee.getArgObject.unsafelyUnwrapped( luaAPI.pointee.getArgObject.unsafelyUnwrapped(
Int32(position), UnsafeMutablePointer(mutating: cType), &userdataObject) Int32(position), UnsafeMutablePointer(mutating: cType), &userdataObject)
} }
return (object, userdataObject.map { UDObject(pointer: $0) }) return (object, userdataObject.map { UDObject(pointer: $0) })
} }
/// The argument as a bitmap. References an object owned by Lua; retain /// Lua owns the bitmap; keep the Lua value alive while using it.
/// the Lua value while using it.
public static func bitmapArgument(at position: Int) -> Graphics.Bitmap? { public static func bitmapArgument(at position: Int) -> Graphics.Bitmap? {
guard let bitmap = luaAPI.pointee.getBitmap.unsafelyUnwrapped(Int32(position)) else { return nil } guard let bitmap = luaAPI.pointee.getBitmap.unsafelyUnwrapped(Int32(position)) else { return nil }
return Graphics.Bitmap(pointer: bitmap, isOwned: false) return Graphics.Bitmap(pointer: bitmap, isOwned: false)
} }
/// The argument as a sprite.
public static func spriteArgument(at position: Int) -> Sprite? { public static func spriteArgument(at position: Int) -> Sprite? {
guard let sprite = luaAPI.pointee.getSprite.unsafelyUnwrapped(Int32(position)) else { return nil } guard let sprite = luaAPI.pointee.getSprite.unsafelyUnwrapped(Int32(position)) else { return nil }
return Sprite.wrapper(for: sprite) return Sprite.wrapper(for: sprite)
@@ -175,33 +163,27 @@ extension Lua {
// MARK: - Return values // MARK: - Return values
/// Pushes nil onto the stack.
public static func pushNil() { public static func pushNil() {
luaAPI.pointee.pushNil.unsafelyUnwrapped() luaAPI.pointee.pushNil.unsafelyUnwrapped()
} }
/// Pushes a boolean onto the stack.
public static func push(_ value: Bool) { public static func push(_ value: Bool) {
luaAPI.pointee.pushBool.unsafelyUnwrapped(value ? 1 : 0) luaAPI.pointee.pushBool.unsafelyUnwrapped(value ? 1 : 0)
} }
/// Pushes an integer onto the stack.
public static func push(_ value: Int) { public static func push(_ value: Int) {
luaAPI.pointee.pushInt.unsafelyUnwrapped(Int32(value)) luaAPI.pointee.pushInt.unsafelyUnwrapped(Int32(value))
} }
/// Pushes a float onto the stack.
public static func push(_ value: Float) { public static func push(_ value: Float) {
luaAPI.pointee.pushFloat.unsafelyUnwrapped(value) luaAPI.pointee.pushFloat.unsafelyUnwrapped(value)
} }
/// Pushes a string onto the stack.
public static func push(_ value: String) { public static func push(_ value: String) {
value.withPlaydateCString { luaAPI.pointee.pushString.unsafelyUnwrapped($0) } value.withCString { luaAPI.pointee.pushString.unsafelyUnwrapped($0) }
} }
/// Pushes raw bytes (which may contain embedded zeros) onto the stack /// Pushes `bytes` as a Lua string; zeros are kept.
/// as a Lua string.
public static func push(bytes: [UInt8]) { public static func push(bytes: [UInt8]) {
bytes.withUnsafeBytes { buffer in bytes.withUnsafeBytes { buffer in
luaAPI.pointee.pushBytes.unsafelyUnwrapped( luaAPI.pointee.pushBytes.unsafelyUnwrapped(
@@ -209,24 +191,21 @@ extension Lua {
} }
} }
/// Pushes a bitmap onto the stack.
public static func push(_ bitmap: Graphics.Bitmap) { public static func push(_ bitmap: Graphics.Bitmap) {
luaAPI.pointee.pushBitmap.unsafelyUnwrapped(bitmap.pointer) luaAPI.pointee.pushBitmap.unsafelyUnwrapped(bitmap.pointer)
} }
/// Pushes a sprite onto the stack.
public static func push(_ sprite: Sprite) { public static func push(_ sprite: Sprite) {
luaAPI.pointee.pushSprite.unsafelyUnwrapped(sprite.pointer) luaAPI.pointee.pushSprite.unsafelyUnwrapped(sprite.pointer)
} }
/// Wraps `object` in a Lua instance of class `type` and pushes it, with /// Pushes `object` as an instance of class `type` with `valueCount` user-value
/// `valueCount` extra user-value slots. /// slots. Returns its handle, or `nil` on failure.
@discardableResult @discardableResult
public static func pushObject(_ object: UnsafeMutableRawPointer, type: String, public static func pushObject(_ object: UnsafeMutableRawPointer, type: String,
valueCount: Int = 0) -> UDObject? { valueCount: Int = 0) -> UDObject? {
// The C API takes a non-const class name but only reads it, so the // The C API declares the class name non-const but only reads it.
// stack copy can be passed with a mutating cast. let pointer = type.withCString { cType in
let pointer = type.withPlaydateCString { cType in
luaAPI.pointee.pushObject.unsafelyUnwrapped( luaAPI.pointee.pushObject.unsafelyUnwrapped(
object, UnsafeMutablePointer(mutating: cType), Int32(valueCount)) object, UnsafeMutablePointer(mutating: cType), Int32(valueCount))
} }
@@ -236,11 +215,11 @@ extension Lua {
// MARK: - Calling Lua // MARK: - Calling Lua
/// Calls the Lua function `name`. Push the arguments onto the stack /// Calls Lua function `name` (dotted path allowed) with the `argumentCount`
/// first. Calling Lua from Swift has overhead; use sparingly. /// arguments already pushed. Slow; use sparingly. Throws `PlaydateError`.
public static func callFunction(_ name: String, argumentCount: Int = 0) throws(PlaydateError) { public static func callFunction(_ name: String, argumentCount: Int = 0) throws(PlaydateError) {
var error: UnsafePointer<CChar>? var error: UnsafePointer<CChar>?
let ok = name.withPlaydateCString { let ok = name.withCString {
luaAPI.pointee.callFunction.unsafelyUnwrapped($0, Int32(argumentCount), &error) != 0 luaAPI.pointee.callFunction.unsafelyUnwrapped($0, Int32(argumentCount), &error) != 0
} }
if !ok { throw PlaydateError(cString: error) } if !ok { throw PlaydateError(cString: error) }
@@ -5,26 +5,23 @@ extension Lua {
public struct UDObject { public struct UDObject {
let pointer: OpaquePointer let pointer: OpaquePointer
/// Prevents the object from being garbage-collected until `release()`. /// Prevents garbage collection until a balancing `release()`. Returns `self`.
@discardableResult @discardableResult
public func retain() -> UDObject { public func retain() -> UDObject {
UDObject(pointer: luaAPI.pointee.retainObject.unsafelyUnwrapped(pointer).unsafelyUnwrapped) UDObject(pointer: luaAPI.pointee.retainObject.unsafelyUnwrapped(pointer).unsafelyUnwrapped)
} }
/// Balances a `retain()`, allowing the object to be /// Balances one `retain()`.
/// garbage-collected again.
public func release() { public func release() {
luaAPI.pointee.releaseObject.unsafelyUnwrapped(pointer) luaAPI.pointee.releaseObject.unsafelyUnwrapped(pointer)
} }
/// Pops the value on top of the stack and stores it in the object's /// Sets user-value `slot` (1-based) to the top stack value.
/// user-value `slot` (1-based).
public func setUserValue(slot: UInt32) { public func setUserValue(slot: UInt32) {
luaAPI.pointee.setUserValue.unsafelyUnwrapped(pointer, slot) luaAPI.pointee.setUserValue.unsafelyUnwrapped(pointer, slot)
} }
/// Pushes the value in user-value `slot` onto the stack and returns /// Pushes user-value `slot` (1-based); returns its stack position, or `nil` if 0.
/// its stack position, or `nil` if there is none.
@discardableResult @discardableResult
public func getUserValue(slot: UInt32) -> Int? { public func getUserValue(slot: UInt32) -> Int? {
let position = luaAPI.pointee.getUserValue.unsafelyUnwrapped(pointer, slot) let position = luaAPI.pointee.getUserValue.unsafelyUnwrapped(pointer, slot)
@@ -4,11 +4,9 @@ internal import CPlaydate
private var httpAPI: UnsafePointer<playdate_http> { Playdate.httpAPI.unsafelyUnwrapped } private var httpAPI: UnsafePointer<playdate_http> { Playdate.httpAPI.unsafelyUnwrapped }
extension Network { extension Network {
/// An HTTP connection to a server. Wraps `HTTPConnection`. /// An HTTP connection. Wraps `HTTPConnection`; methods throw `Network.NetError`.
/// /// Callbacks don't retain it: keep it referenced until they fire, as `deinit`
/// The binding stores a back-reference to each wrapper in the /// drops pending callbacks and releases the C connection.
/// underlying object's userdata slot so callbacks can recover the
/// wrapper; the C userdata slot is therefore reserved by the binding.
public final class HTTPConnection { public final class HTTPConnection {
let pointer: OpaquePointer let pointer: OpaquePointer
@@ -18,8 +16,8 @@ extension Network {
var requestCompleteCallback: ((HTTPConnection) -> Void)? var requestCompleteCallback: ((HTTPConnection) -> Void)?
var connectionClosedCallback: ((HTTPConnection) -> Void)? var connectionClosedCallback: ((HTTPConnection) -> Void)?
/// Requests permission to connect to `server`. If the reply is /// Asks to connect to `server` and its subdomains; call before `init`.
/// `.ask`, the completion is called later with the user's answer. /// `purpose` appears in the dialog; `completion` runs only if the reply is `.ask`.
@discardableResult @discardableResult
public static func requestAccess(server: String, port: Int = 443, useSSL: Bool = true, public static func requestAccess(server: String, port: Int = 443, useSSL: Bool = true,
purpose: String? = nil, purpose: String? = nil,
@@ -30,10 +28,9 @@ extension Network {
completion: completion) completion: completion)
} }
/// Opens a connection to `server`. Fails if access has not been /// Sends nothing until a request. `nil` if access is denied or not yet granted.
/// granted.
public init?(server: String, port: Int = 443, useSSL: Bool = true) { public init?(server: String, port: Int = 443, useSSL: Bool = true) {
let pointer = server.withPlaydateCString { let pointer = server.withCString {
httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
} }
guard let pointer else { return nil } guard let pointer else { return nil }
@@ -54,48 +51,48 @@ extension Network {
// MARK: Configuration // MARK: Configuration
/// The time to wait for the connection to open, in milliseconds. /// Connect timeout, in ms.
public func setConnectTimeout(milliseconds: Int) { public func setConnectTimeout(milliseconds: Int) {
httpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) httpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
} }
/// Whether to keep the connection open after a request completes. /// Whether requests send `Connection: keep-alive`.
public func setKeepAlive(_ keepAlive: Bool) { public func setKeepAlive(_ keepAlive: Bool) {
httpAPI.pointee.setKeepAlive.unsafelyUnwrapped(pointer, keepAlive) httpAPI.pointee.setKeepAlive.unsafelyUnwrapped(pointer, keepAlive)
} }
/// Adds a `Range: bytes=start-end` header to future requests. /// Adds a `Range: bytes=start-end` header.
public func setByteRange(start: Int, end: Int) { public func setByteRange(start: Int, end: Int) {
httpAPI.pointee.setByteRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end)) httpAPI.pointee.setByteRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end))
} }
/// The time to wait for incoming data, in milliseconds. /// How long `read` waits for data, in ms (default 1000).
public func setReadTimeout(milliseconds: Int) { public func setReadTimeout(milliseconds: Int) {
httpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) httpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
} }
/// The size of the connection's read buffer, in bytes. /// Read buffer size, in bytes (default 64 KB).
public func setReadBufferSize(bytes: Int) { public func setReadBufferSize(bytes: Int) {
httpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes)) httpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes))
} }
// MARK: Requests // MARK: Requests
/// Sends a GET request for `path`. `headers` are raw header lines /// GETs `path`, opening the connection if needed. `headers` are extra raw
/// (e.g. "Accept: text/html\r\n"). /// header lines (e.g. "Accept: text/html\r\n").
public func get(path: String, headers: String = "") throws(NetError) { public func get(path: String, headers: String = "") throws(NetError) {
let error = path.withPlaydateCString { cPath in let error = path.withCString { cPath in
headers.withPlaydateCString { cHeaders in headers.withCString { cHeaders in
httpAPI.pointee.get.unsafelyUnwrapped(pointer, cPath, cHeaders, headers.utf8.count) httpAPI.pointee.get.unsafelyUnwrapped(pointer, cPath, cHeaders, headers.utf8.count)
} }
} }
try Network.check(error) try Network.check(error)
} }
/// Sends a POST request for `path` with the given body. /// POSTs `body` to `path`; otherwise like `get`.
public func post(path: String, headers: String = "", body: [UInt8]) throws(NetError) { public func post(path: String, headers: String = "", body: [UInt8]) throws(NetError) {
let error = path.withPlaydateCString { cPath in let error = path.withCString { cPath in
headers.withPlaydateCString { cHeaders in headers.withCString { cHeaders in
body.withUnsafeBytes { bodyBuffer in body.withUnsafeBytes { bodyBuffer in
httpAPI.pointee.post.unsafelyUnwrapped( httpAPI.pointee.post.unsafelyUnwrapped(
pointer, cPath, cHeaders, headers.utf8.count, pointer, cPath, cHeaders, headers.utf8.count,
@@ -107,12 +104,12 @@ extension Network {
try Network.check(error) try Network.check(error)
} }
/// Sends a request with an arbitrary HTTP method. /// Sends a `method` request; otherwise like `post`.
public func query(method: String, path: String, headers: String = "", public func query(method: String, path: String, headers: String = "",
body: [UInt8] = []) throws(NetError) { body: [UInt8] = []) throws(NetError) {
let error = method.withPlaydateCString { cMethod in let error = method.withCString { cMethod in
path.withPlaydateCString { cPath in path.withCString { cPath in
headers.withPlaydateCString { cHeaders in headers.withCString { cHeaders in
body.withUnsafeBytes { bodyBuffer in body.withUnsafeBytes { bodyBuffer in
httpAPI.pointee.query.unsafelyUnwrapped( httpAPI.pointee.query.unsafelyUnwrapped(
pointer, cMethod, cPath, cHeaders, headers.utf8.count, pointer, cMethod, cPath, cHeaders, headers.utf8.count,
@@ -127,61 +124,63 @@ extension Network {
// MARK: Response // MARK: Response
/// The last error on the connection, if any. /// The connection's last error, if any.
public var error: NetError? { public var error: NetError? {
Network.optionalError(httpAPI.pointee.getError.unsafelyUnwrapped(pointer)) Network.optionalError(httpAPI.pointee.getError.unsafelyUnwrapped(pointer))
} }
/// The number of bytes read of the current response, and the total /// Response bytes read so far, and the total expected if known.
/// expected (0 if the response has no Content-Length).
public var progress: (read: Int, total: Int) { public var progress: (read: Int, total: Int) {
var read: Int32 = 0, total: Int32 = 0 var read: Int32 = 0, total: Int32 = 0
httpAPI.pointee.getProgress.unsafelyUnwrapped(pointer, &read, &total) httpAPI.pointee.getProgress.unsafelyUnwrapped(pointer, &read, &total)
return (Int(read), Int(total)) return (Int(read), Int(total))
} }
/// The HTTP status code of the response. /// HTTP status code, valid once headers are parsed.
public var responseStatus: Int { public var responseStatus: Int {
Int(httpAPI.pointee.getResponseStatus.unsafelyUnwrapped(pointer)) Int(httpAPI.pointee.getResponseStatus.unsafelyUnwrapped(pointer))
} }
/// The number of response bytes available to read. /// Response bytes available to read.
public var bytesAvailable: Int { public var bytesAvailable: Int {
Int(httpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer)) Int(httpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer))
} }
/// Reads up to `buffer.count` response bytes. Returns the number of /// Reads up to `buffer.count` bytes (capped by the read buffer size), waiting
/// bytes read. /// up to the read timeout. Returns the count read.
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int { public func read(into buffer: inout MutableSpan<UInt8>) throws(NetError) -> Int {
let result = httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, let result = buffer.withUnsafeMutableBufferPointer { buffer in
UInt32(buffer.count)) httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
}
if result < 0 { if result < 0 {
throw NetError(rawValue: result) ?? .unknown throw NetError(rawValue: result) ?? .unknown
} }
return Int(result) return Int(result)
} }
/// Reads up to `length` available response bytes. /// Like `read(into:)`, returning the bytes read.
public func read(length: Int) throws(NetError) -> [UInt8] { public func read(length: Int) throws(NetError) -> [UInt8] {
var bytes = [UInt8](repeating: 0, count: length) try [UInt8](capacity: length) { output throws(NetError) in
let result = bytes.withUnsafeMutableBytes { buffer in let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in
httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) let result = httpAPI.pointee.read.unsafelyUnwrapped(
pointer, buffer.baseAddress, UInt32(buffer.count))
initializedCount = max(Int(result), 0)
return result
} }
if result < 0 { if result < 0 {
throw NetError(rawValue: result) ?? .unknown throw NetError(rawValue: result) ?? .unknown
} }
bytes.removeLast(length - Int(result)) }
return bytes
} }
/// Closes the connection. /// Closes the connection; it can be reused for another request.
public func close() { public func close() {
httpAPI.pointee.close.unsafelyUnwrapped(pointer) httpAPI.pointee.close.unsafelyUnwrapped(pointer)
} }
// MARK: Callbacks // MARK: Callbacks
/// Called for each header line as it arrives. /// Called per response header line. `nil` removes it.
public func setHeaderReceivedCallback(_ callback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?) { public func setHeaderReceivedCallback(_ callback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?) {
headerReceivedCallback = callback headerReceivedCallback = callback
if callback != nil { if callback != nil {
@@ -196,7 +195,8 @@ extension Network {
} }
} }
/// Called when all headers have been read. /// Called once headers are parsed, making `responseStatus` and `progress` valid.
/// `nil` removes it.
public func setHeadersReadCallback(_ callback: ((HTTPConnection) -> Void)?) { public func setHeadersReadCallback(_ callback: ((HTTPConnection) -> Void)?) {
headersReadCallback = callback headersReadCallback = callback
if callback != nil { if callback != nil {
@@ -209,7 +209,7 @@ extension Network {
} }
} }
/// Called when response data is available to read. /// Called when response data is available to read. `nil` removes it.
public func setResponseCallback(_ callback: ((HTTPConnection) -> Void)?) { public func setResponseCallback(_ callback: ((HTTPConnection) -> Void)?) {
responseCallback = callback responseCallback = callback
if callback != nil { if callback != nil {
@@ -222,7 +222,7 @@ extension Network {
} }
} }
/// Called when the request finishes. /// Called when all data arrives (size known) or the request times out. `nil` removes it.
public func setRequestCompleteCallback(_ callback: ((HTTPConnection) -> Void)?) { public func setRequestCompleteCallback(_ callback: ((HTTPConnection) -> Void)?) {
requestCompleteCallback = callback requestCompleteCallback = callback
if callback != nil { if callback != nil {
@@ -235,7 +235,7 @@ extension Network {
} }
} }
/// Called when the connection closes. /// Called when the server closes the connection. `nil` removes it.
public func setConnectionClosedCallback(_ callback: ((HTTPConnection) -> Void)?) { public func setConnectionClosedCallback(_ callback: ((HTTPConnection) -> Void)?) {
connectionClosedCallback = callback connectionClosedCallback = callback
if callback != nil { if callback != nil {
@@ -4,19 +4,17 @@ internal import CPlaydate
private var tcpAPI: UnsafePointer<playdate_tcp> { Playdate.tcpAPI.unsafelyUnwrapped } private var tcpAPI: UnsafePointer<playdate_tcp> { Playdate.tcpAPI.unsafelyUnwrapped }
extension Network { extension Network {
/// A TCP connection to a server. Wraps `TCPConnection`. /// A TCP connection. Wraps `TCPConnection`; methods throw `Network.NetError`.
/// /// Callbacks don't retain it: keep it referenced until they fire, as `deinit`
/// The binding stores a back-reference to each wrapper in the /// drops pending callbacks and releases the C connection.
/// underlying object's userdata slot so callbacks can recover the
/// wrapper; the C userdata slot is therefore reserved by the binding.
public final class TCPConnection { public final class TCPConnection {
let pointer: OpaquePointer let pointer: OpaquePointer
var openCompletion: ((TCPConnection, NetError?) -> Void)? var openCompletion: ((TCPConnection, NetError?) -> Void)?
var connectionClosedCallback: ((TCPConnection, NetError?) -> Void)? var connectionClosedCallback: ((TCPConnection, NetError?) -> Void)?
/// Requests permission to connect to `server`. If the reply is /// Asks to connect to `server`; call before `init`. `purpose` appears in
/// `.ask`, the completion is called later with the user's answer. /// the dialog; `completion` runs only if the reply is `.ask`.
@discardableResult @discardableResult
public static func requestAccess(server: String, port: Int, useSSL: Bool = true, public static func requestAccess(server: String, port: Int, useSSL: Bool = true,
purpose: String? = nil, purpose: String? = nil,
@@ -27,10 +25,9 @@ extension Network {
completion: completion) completion: completion)
} }
/// Creates a connection to `server`. Fails if access has not been /// Does nothing until `open(_:)`. `nil` if access is denied or not yet granted.
/// granted. Call `open(_:)` to connect.
public init?(server: String, port: Int, useSSL: Bool = true) { public init?(server: String, port: Int, useSSL: Bool = true) {
let pointer = server.withPlaydateCString { let pointer = server.withCString {
tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
} }
guard let pointer else { return nil } guard let pointer else { return nil }
@@ -49,17 +46,17 @@ extension Network {
return Unmanaged<TCPConnection>.fromOpaque(userdata).takeUnretainedValue() return Unmanaged<TCPConnection>.fromOpaque(userdata).takeUnretainedValue()
} }
/// The last error on the connection, if any. /// The connection's last error, if any.
public var error: NetError? { public var error: NetError? {
Network.optionalError(tcpAPI.pointee.getError.unsafelyUnwrapped(pointer)) Network.optionalError(tcpAPI.pointee.getError.unsafelyUnwrapped(pointer))
} }
/// The time to wait for the connection to open, in milliseconds. /// Connect timeout, in ms.
public func setConnectTimeout(milliseconds: Int) { public func setConnectTimeout(milliseconds: Int) {
tcpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) tcpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
} }
/// Opens the connection. The completion receives `nil` on success. /// Errors are thrown immediately or passed to `completion` (`nil` on success).
public func open(_ completion: @escaping (TCPConnection, NetError?) -> Void) throws(NetError) { public func open(_ completion: @escaping (TCPConnection, NetError?) -> Void) throws(NetError) {
openCompletion = completion openCompletion = completion
let error = tcpAPI.pointee.open.unsafelyUnwrapped(pointer, { connection, error, _ in let error = tcpAPI.pointee.open.unsafelyUnwrapped(pointer, { connection, error, _ in
@@ -71,13 +68,12 @@ extension Network {
try Network.check(error) try Network.check(error)
} }
/// Closes the connection. /// Closes the connection; it can be reused.
public func close() throws(NetError) { public func close() throws(NetError) {
try Network.check(tcpAPI.pointee.close.unsafelyUnwrapped(pointer)) try Network.check(tcpAPI.pointee.close.unsafelyUnwrapped(pointer))
} }
/// Called when the connection closes, with the reason if it closed /// Called on close with the error, if any; `nil` removes it.
/// due to an error.
public func setConnectionClosedCallback(_ callback: ((TCPConnection, NetError?) -> Void)?) { public func setConnectionClosedCallback(_ callback: ((TCPConnection, NetError?) -> Void)?) {
connectionClosedCallback = callback connectionClosedCallback = callback
if callback != nil { if callback != nil {
@@ -90,65 +86,55 @@ extension Network {
} }
} }
/// The time to wait for incoming data, in milliseconds. /// How long `read` waits for data, in ms (default 1000).
public func setReadTimeout(milliseconds: Int) { public func setReadTimeout(milliseconds: Int) {
tcpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds)) tcpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
} }
/// The size of the connection's read buffer, in bytes. /// Read buffer size, in bytes (default 64 KB).
public func setReadBufferSize(bytes: Int) { public func setReadBufferSize(bytes: Int) {
tcpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes)) tcpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes))
} }
/// The number of bytes available to read. /// Bytes available to read.
public var bytesAvailable: Int { public var bytesAvailable: Int {
Int(tcpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer)) Int(tcpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer))
} }
/// The number of written bytes not yet sent on the wire. /// Written bytes not yet sent.
public var sentBytesPending: Int { public var sentBytesPending: Int {
Int(tcpAPI.pointee.getSentBytesPending.unsafelyUnwrapped(pointer)) Int(tcpAPI.pointee.getSentBytesPending.unsafelyUnwrapped(pointer))
} }
/// Reads up to `buffer.count` bytes, waiting up to the read timeout. /// Reads up to `buffer.count` bytes within the read timeout; returns the count.
/// Returns the number of bytes read. public func read(into buffer: inout MutableSpan<UInt8>) throws(NetError) -> Int {
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int { let result = buffer.withUnsafeMutableBufferPointer { buffer in
let result = tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
if result < 0 {
throw NetError(rawValue: result) ?? .unknown
}
return Int(result)
}
/// Reads up to `length` bytes, waiting up to the read timeout.
public func read(length: Int) throws(NetError) -> [UInt8] {
var bytes = [UInt8](repeating: 0, count: length)
let result = bytes.withUnsafeMutableBytes { buffer in
tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
} }
if result < 0 { if result < 0 {
throw NetError(rawValue: result) ?? .unknown throw NetError(rawValue: result) ?? .unknown
} }
bytes.removeLast(length - Int(result))
return bytes
}
/// Writes the buffer to the connection. Returns the number of bytes
/// accepted.
@discardableResult
public func write(_ buffer: UnsafeRawBufferPointer) throws(NetError) -> Int {
let result = tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
if result < 0 {
throw NetError(rawValue: result) ?? .unknown
}
return Int(result) return Int(result)
} }
/// Writes the bytes to the connection. Returns the number of bytes /// Like `read(into:)`, returning the bytes read.
/// accepted. public func read(length: Int) throws(NetError) -> [UInt8] {
try [UInt8](capacity: length) { output throws(NetError) in
let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in
let result = tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
initializedCount = max(Int(result), 0)
return result
}
if result < 0 {
throw NetError(rawValue: result) ?? .unknown
}
}
}
/// Queues `bytes`; returns the count handed to the network stack.
@discardableResult @discardableResult
public func write(_ bytes: [UInt8]) throws(NetError) -> Int { public func write(_ bytes: Span<UInt8>) throws(NetError) -> Int {
let result = bytes.withUnsafeBytes { buffer in let result = bytes.withUnsafeBufferPointer { buffer in
tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
} }
if result < 0 { if result < 0 {
@@ -156,5 +142,13 @@ extension Network {
} }
return Int(result) return Int(result)
} }
/// Same as the `Span` overload.
@discardableResult
public func write(_ bytes: [UInt8]) throws(NetError) -> Int {
try bytes.withUnsafeBufferPointer { buffer throws(NetError) in
try write(buffer.span)
}
}
} }
} }
@@ -1,31 +1,47 @@
internal import CPlaydate internal import CPlaydate
extension Network { extension Network {
/// A network error code (`PDNetErr`). /// A network error. Wraps the negative `PDNetErr` codes.
public enum NetError: Int32, Swift.Error, Sendable { public enum NetError: Int32, Swift.Error, Sendable {
/// `NET_NO_DEVICE`.
case noDevice = -1 case noDevice = -1
/// `NET_BUSY`.
case busy = -2 case busy = -2
/// `NET_WRITE_ERROR`.
case writeError = -3 case writeError = -3
/// `NET_WRITE_BUSY`.
case writeBusy = -4 case writeBusy = -4
/// `NET_WRITE_TIMEOUT`.
case writeTimeout = -5 case writeTimeout = -5
/// `NET_READ_ERROR`.
case readError = -6 case readError = -6
/// `NET_READ_BUSY`.
case readBusy = -7 case readBusy = -7
/// `NET_READ_TIMEOUT`.
case readTimeout = -8 case readTimeout = -8
/// `NET_READ_OVERFLOW`.
case readOverflow = -9 case readOverflow = -9
/// `NET_FRAME_ERROR`.
case frameError = -10 case frameError = -10
/// `NET_BAD_RESPONSE`.
case badResponse = -11 case badResponse = -11
/// `NET_ERROR_RESPONSE`.
case errorResponse = -12 case errorResponse = -12
/// `NET_RESET_TIMEOUT`.
case resetTimeout = -13 case resetTimeout = -13
/// `NET_BUFFER_TOO_SMALL`.
case bufferTooSmall = -14 case bufferTooSmall = -14
/// `NET_UNEXPECTED_RESPONSE`.
case unexpectedResponse = -15 case unexpectedResponse = -15
/// `NET_NOT_CONNECTED_TO_AP`.
case notConnectedToAP = -16 case notConnectedToAP = -16
/// `NET_NOT_IMPLEMENTED`.
case notImplemented = -17 case notImplemented = -17
/// `NET_CONNECTION_CLOSED`.
case connectionClosed = -18 case connectionClosed = -18
/// An error code not covered by `PDNetErr`. /// A code not in `PDNetErr`.
case unknown = 1 case unknown = 1
/// Creates an error from the C code, or `.unknown` for
/// unrecognized codes.
init(_ error: PDNetErr) { init(_ error: PDNetErr) {
self = NetError(rawValue: Int32(error.rawValue)) ?? .unknown self = NetError(rawValue: Int32(error.rawValue)) ?? .unknown
} }
@@ -1,10 +1,9 @@
extension Network { extension Network {
/// The device's wifi status. /// The device's wifi status. Wraps `WifiStatus`.
public enum WifiStatus: UInt32, Sendable { public enum WifiStatus: UInt32, Sendable {
case notConnected = 0 case notConnected = 0
case connected = 1 case connected = 1
/// A connection was attempted but no configured access point was /// A connection was attempted but no configured access point was available.
/// available.
case notAvailable = 2 case notAvailable = 2
} }
} }
+22 -17
View File
@@ -3,45 +3,50 @@ internal import CPlaydate
/// The cached `playdate->network` C API table. /// The cached `playdate->network` C API table.
private var networkAPI: UnsafePointer<playdate_network> { Playdate.networkAPI.unsafelyUnwrapped } private var networkAPI: UnsafePointer<playdate_network> { Playdate.networkAPI.unsafelyUnwrapped }
/// The network API: wifi status, HTTP, and TCP. /// Wifi control, HTTP, and TCP. Throwing APIs here throw `Network.NetError`.
public enum Network {} public enum Network {}
extension Network { extension Network {
/// Throws unless `error` is `NET_OK`.
static func check(_ error: PDNetErr) throws(NetError) { static func check(_ error: PDNetErr) throws(NetError) {
if error != NET_OK { if error != NET_OK {
throw NetError(error) throw NetError(error)
} }
} }
/// Converts an error code to `nil` (OK) or a `NetError`.
static func optionalError(_ error: PDNetErr) -> NetError? { static func optionalError(_ error: PDNetErr) -> NetError? {
error == NET_OK ? nil : NetError(error) error == NET_OK ? nil : NetError(error)
} }
/// The device's current wifi status. /// The current wifi status; `.notConnected` for unrecognized C values.
public static var status: WifiStatus { public static var status: WifiStatus {
WifiStatus(rawValue: UInt32(networkAPI.pointee.getStatus.unsafelyUnwrapped().rawValue)) ?? .notConnected WifiStatus(rawValue: UInt32(networkAPI.pointee.getStatus.unsafelyUnwrapped().rawValue)) ?? .notConnected
} }
/// Turns the wifi radio on or off. The completion receives `nil` on /// Connects to the access point now. `completion` gets `nil` on success, in call order.
/// success. Completions of overlapping calls are delivered in call order. public static func enable(completion: ((NetError?) -> Void)? = nil) {
public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) {
if let completion { if let completion {
setEnabledCompletions.append(completion) enableCompletions.append(completion)
networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, { error in networkAPI.pointee.setEnabled.unsafelyUnwrapped(true, { error in
guard !Network.setEnabledCompletions.isEmpty else { return } guard !Network.enableCompletions.isEmpty else { return }
let completion = Network.setEnabledCompletions.removeFirst() let completion = Network.enableCompletions.removeFirst()
completion(Network.optionalError(error)) completion(Network.optionalError(error))
}) })
} else { } else {
networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, nil) networkAPI.pointee.setEnabled.unsafelyUnwrapped(true, nil)
} }
} }
nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = [] /// Turns wifi off now, not after the 30 s idle timeout.
public static func disable() {
// No callback: C documents it for enabling only, and a queued one would take
// the next `enable` result.
networkAPI.pointee.setEnabled.unsafelyUnwrapped(false, nil)
}
/// Requests permission to connect to `server`. Shared by HTTP and TCP. nonisolated(unsafe) private static var enableCompletions: [(NetError?) -> Void] = []
/// Shared by HTTP and TCP. Retains `completion` until the C callback, which
/// fires only for `.ask`.
static func requestAccess( static func requestAccess(
rawRequest: (UnsafePointer<CChar>?, Int32, Bool, UnsafePointer<CChar>?, rawRequest: (UnsafePointer<CChar>?, Int32, Bool, UnsafePointer<CChar>?,
(@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?, (@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?,
@@ -57,9 +62,9 @@ extension Network {
guard let userdata else { return } guard let userdata else { return }
Unmanaged<Box>.fromOpaque(userdata).takeRetainedValue().body(allowed) Unmanaged<Box>.fromOpaque(userdata).takeRetainedValue().body(allowed)
} }
let reply = server.withPlaydateCString { cServer in let reply = server.withCString { cServer in
if let purpose { if let purpose {
return purpose.withPlaydateCString { cPurpose in return purpose.withCString { cPurpose in
rawRequest(cServer, Int32(port), useSSL, cPurpose, trampoline, box.toOpaque()) rawRequest(cServer, Int32(port), useSSL, cPurpose, trampoline, box.toOpaque())
} }
} else { } else {
@@ -67,7 +72,7 @@ extension Network {
} }
} }
if reply != kAccessAsk { if reply != kAccessAsk {
// The callback will not be invoked; balance the retain. // Only `kAccessAsk` invokes the callback; balance the retain now.
box.release() box.release()
} }
return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask
@@ -1,10 +1,9 @@
/// The user's answer to a permission request (microphone, network). /// Immediate result of a permission request (microphone, network). Wraps `enum accessReply`.
public enum AccessReply: UInt32, Sendable { public enum AccessReply: UInt32, Sendable {
/// The user has not answered yet; the request's completion delivers /// Not answered yet; the completion receives the answer.
/// the answer later.
case ask = 0 case ask = 0
/// The user has already denied access; the completion is not called. /// Already denied; the completion is not called.
case deny = 1 case deny = 1
/// The user has already granted access; the completion is not called. /// Already granted; the completion is not called.
case allow = 2 case allow = 2
} }
@@ -1,36 +1,31 @@
public import CPlaydate public import CPlaydate
/// A Swift view of `PDSystemEvent` with the key code folded into the /// An event sent to the game's `eventHandler`. Wraps `PDSystemEvent`; key events carry
/// key events. /// the event argument.
public enum SystemEvent { public enum SystemEvent {
/// Sent once at startup, before the first update. /// Once after the game loads, before the first update.
case initialize case initialize
/// Sent when the Lua runtime is ready, for registering custom /// After `initialize` if no update callback is set, once Lua exists and before
/// functions and classes. /// `main.lua` runs; register Lua functions and classes here.
case initializeLua case initializeLua
/// The device was locked.
case lock case lock
/// The device was unlocked.
case unlock case unlock
/// The game was paused (e.g. the system menu opened). /// E.g. the system menu opened.
case pause case pause
/// The game resumed after a pause.
case resume case resume
/// The game is about to be terminated.
case terminate case terminate
/// A simulator key was pressed. /// Simulator only.
case keyPressed(keyCode: UInt32) case keyPressed(keyCode: UInt32)
/// A simulator key was released. /// Simulator only.
case keyReleased(keyCode: UInt32) case keyReleased(keyCode: UInt32)
/// The device is about to power down because the battery is low. /// About to enter low-power sleep because the battery is low.
case lowPower case lowPower
/// A Mirror session started. /// Mirror connected.
case mirrorStarted case mirrorStarted
/// A Mirror session ended. /// Mirror disconnected.
case mirrorEnded case mirrorEnded
/// Creates an event from the C event and its argument, or `nil` for /// From the `eventHandler` arguments; `nil` for events this binding doesn't know.
/// events unknown to this binding.
public init?(event: PDSystemEvent, argument: UInt32) { public init?(event: PDSystemEvent, argument: UInt32) {
switch event { switch event {
case kEventInit: self = .initialize case kEventInit: self = .initialize
+11 -23
View File
@@ -1,25 +1,17 @@
public import CPlaydate public import CPlaydate
/// The raw C API bootstrap. /// Raw C API bootstrap. The firmware passes the `PlaydateAPI` table to the game's
/// /// `eventHandler`; call `initialize(with:)` there before any other API in this module.
/// The C API is delivered as a `PlaydateAPI` struct of function pointers /// The wrappers (`System`, `Graphics`, `Sprite`, `Sound`, ...) are top-level.
/// that the firmware hands to the game's `eventHandler` entry point. Call
/// `initialize(with:)` from that entry point before using any other API in
/// this module. Everything else (System, Graphics, Sprite, Sound, ...)
/// lives at the top level of the `PlaydateKit` module.
public enum Playdate { public enum Playdate {
/// The raw C API. Populated by `initialize(with:)`. /// Copy of the C API table; `nil` until `initialize(with:)`. Unsynchronized: the
/// /// runtime is single-threaded and this is written once at startup.
/// Access is unsynchronized: the Playdate runtime is single-threaded and
/// the API pointer is written exactly once at startup.
public internal(set) nonisolated(unsafe) static var api: PlaydateAPI! public internal(set) nonisolated(unsafe) static var api: PlaydateAPI!
/// The raw C API pointer handed to `initialize(with:)`, for calls that /// The pointer passed to `initialize(with:)`, for C calls that take it; `nil` until then.
/// need to pass the `PlaydateAPI*` back to C.
public internal(set) nonisolated(unsafe) static var apiPointer: UnsafeMutablePointer<PlaydateAPI>! public internal(set) nonisolated(unsafe) static var apiPointer: UnsafeMutablePointer<PlaydateAPI>!
// Sub-API pointers cached once at initialization, so wrapper calls are a // Cached so each wrapper call is one field load instead of re-walking `api`.
// single field load off a pointer instead of re-walking `api` per call.
nonisolated(unsafe) static var systemAPI: UnsafePointer<playdate_sys>! nonisolated(unsafe) static var systemAPI: UnsafePointer<playdate_sys>!
nonisolated(unsafe) static var displayAPI: UnsafePointer<playdate_display>! nonisolated(unsafe) static var displayAPI: UnsafePointer<playdate_display>!
nonisolated(unsafe) static var graphicsAPI: UnsafePointer<playdate_graphics>! nonisolated(unsafe) static var graphicsAPI: UnsafePointer<playdate_graphics>!
@@ -31,10 +23,8 @@ public enum Playdate {
nonisolated(unsafe) static var scoreboardsAPI: UnsafePointer<playdate_scoreboards>! nonisolated(unsafe) static var scoreboardsAPI: UnsafePointer<playdate_scoreboards>!
nonisolated(unsafe) static var networkAPI: UnsafePointer<playdate_network>! nonisolated(unsafe) static var networkAPI: UnsafePointer<playdate_network>!
// Second-level tables, cached for the same reason. Assigned with // Optional chaining tolerates partial tables (e.g. test mocks) with a null parent;
// optional chaining because partial API tables (e.g. test mocks) may // using a missing table traps at the call site.
// leave some of them null; using an absent table traps at the call
// site, as before.
nonisolated(unsafe) static var tilemapAPI: UnsafePointer<playdate_tilemap>! nonisolated(unsafe) static var tilemapAPI: UnsafePointer<playdate_tilemap>!
nonisolated(unsafe) static var videoAPI: UnsafePointer<playdate_video>! nonisolated(unsafe) static var videoAPI: UnsafePointer<playdate_video>!
nonisolated(unsafe) static var videoStreamAPI: UnsafePointer<playdate_videostream>! nonisolated(unsafe) static var videoStreamAPI: UnsafePointer<playdate_videostream>!
@@ -61,10 +51,8 @@ public enum Playdate {
nonisolated(unsafe) static var httpAPI: UnsafePointer<playdate_http>! nonisolated(unsafe) static var httpAPI: UnsafePointer<playdate_http>!
nonisolated(unsafe) static var tcpAPI: UnsafePointer<playdate_tcp>! nonisolated(unsafe) static var tcpAPI: UnsafePointer<playdate_tcp>!
/// Stores the API pointer handed to the game's `eventHandler`. /// Stores the `eventHandler`'s `PlaydateAPI*` and caches its sub-tables. Call on the
/// /// `.initialize` event, before any other API in this module.
/// Call this first, on the `.initialize` event, before using any other
/// wrapper in this module.
public static func initialize(with pointer: UnsafeMutableRawPointer) { public static func initialize(with pointer: UnsafeMutableRawPointer) {
apiPointer = pointer.assumingMemoryBound(to: PlaydateAPI.self) apiPointer = pointer.assumingMemoryBound(to: PlaydateAPI.self)
api = apiPointer.pointee api = apiPointer.pointee
@@ -1,15 +1,13 @@
/// An error reported by the Playdate OS. /// An error reported by the Playdate OS.
public struct PlaydateError: Swift.Error, Sendable { public struct PlaydateError: Swift.Error, Sendable {
/// The message reported by the OS, or a description of the failure. /// The OS message, or a description of the failure.
public let message: String public let message: String
/// Creates an error with the given message.
init(message: String) { init(message: String) {
self.message = message self.message = message
} }
/// Creates an error by copying an OS-provided C string; a nil pointer /// Copies an OS C string; null yields "unknown error".
/// produces "unknown error".
init(cString: UnsafePointer<CChar>?) { init(cString: UnsafePointer<CChar>?) {
self.init(message: String(playdateCString: cString) ?? "unknown error") self.init(message: String(playdateCString: cString) ?? "unknown error")
} }
@@ -4,16 +4,16 @@ Bootstrap the bindings from your game's entry point and drive a frame loop.
## Overview ## Overview
A Playdate game has a single C entry point, `eventHandler`, which the The firmware calls a game's single C entry point, `eventHandler`, with a
firmware calls with a `PlaydateAPI*` and an event code. Export it with `PlaydateAPI*` and an event code. Export it with `@c`, call
`@_cdecl`, call ``Playdate/initialize(with:)`` on the first event, and ``Playdate/initialize(with:)`` on the first event, and install an update
install an update callback: callback:
```swift ```swift
import CPlaydate import CPlaydate
import PlaydateKit import PlaydateKit
@_cdecl("eventHandler") @c(eventHandler)
func eventHandler( func eventHandler(
pointer: UnsafeMutableRawPointer, pointer: UnsafeMutableRawPointer,
event: PDSystemEvent, event: PDSystemEvent,
@@ -31,7 +31,7 @@ final class Game {
nonisolated(unsafe) static let shared = Game() nonisolated(unsafe) static let shared = Game()
func start() { func start() {
Display.setRefreshRate(50) Display.refreshRate = 50
System.setUpdateCallback { System.setUpdateCallback {
self.update() self.update()
@@ -54,13 +54,14 @@ final class Game {
## Conventions to know ## Conventions to know
- **Initialization.** Calling any wrapper before - **Initialization.** Calling a wrapper before
``Playdate/initialize(with:)`` is a programmer error and will crash. ``Playdate/initialize(with:)`` crashes.
- **Errors.** Fallible operations use typed throws ``PlaydateError`` - **Errors.** Typed throws: ``PlaydateError`` in general,
generally, ``Network/NetError`` for network I/O. ``Network/NetError`` for network I/O.
- **Ownership.** A wrapper that creates a C object frees it on `deinit`; - **Ownership.** A wrapper that creates a C object frees it on `deinit`;
keep the wrapper referenced for as long as you use it. Wrappers vending keep the wrapper referenced while you use it. Objects vended by the OS
OS-owned objects don't free them — keep the owner alive instead, as are not freed by their wrapper; keep the owner alive instead.
documented on each API. - **Buffers.** Audio callbacks, I/O, the framebuffer, and bitmap pixels use
- **Threading.** The Playdate runtime is single-threaded; don't call the `Span`/`MutableSpan`, valid only for the duration of the call.
API from other threads. - **Threading.** The Playdate runtime is single-threaded, except audio
callbacks. Do not call the API from other threads.
@@ -4,19 +4,16 @@ Swift bindings to the Playdate C API.
## Overview ## Overview
The Playdate C API is delivered as a `PlaydateAPI*` struct of function The firmware hands your game a `PlaydateAPI*`: a struct of function
pointers that the firmware hands to your game at launch. This module wraps pointers. This module wraps it with per-subsystem namespaces, wrapper types
that surface in idiomatic Swift: top-level namespaces per subsystem, wrapper that own their C objects, closures instead of function-pointer/userdata
types with ownership semantics, closures instead of function-pointer/userdata pairs, `OptionSet`s and `enum`s instead of raw constants, and typed `throws`.
pairs, `OptionSet`s and `enum`s instead of raw constants, and typed `throws`
for fallible calls.
Call ``Playdate/initialize(with:)`` from your game's `eventHandler` before Call ``Playdate/initialize(with:)`` from your game's `eventHandler` before
using anything else see <doc:GettingStarted>. anything else; see <doc:GettingStarted>.
The bindings are written within the Embedded Swift subset, so the same code The module uses only the Embedded Swift subset, so the same code compiles
compiles for the Playdate Simulator and for the device for the Playdate Simulator and the device (`armv7em-none-none-eabi`).
(`armv7em-none-none-eabi`).
## Topics ## Topics
@@ -3,11 +3,9 @@ internal import CPlaydate
/// The cached `playdate->scoreboards` C API table. /// The cached `playdate->scoreboards` C API table.
var scoreboardsAPI: UnsafePointer<playdate_scoreboards> { Playdate.scoreboardsAPI.unsafelyUnwrapped } var scoreboardsAPI: UnsafePointer<playdate_scoreboards> { Playdate.scoreboardsAPI.unsafelyUnwrapped }
/// The scoreboards API for games with online leaderboards. /// Online leaderboards. Requests return `false` if they could not start; completions
/// /// fail with `PlaydateError` (the C error message). One pending completion per
/// The C callbacks carry no userdata, so one completion per operation kind /// operation: a repeat request replaces it. C results are copied and freed.
/// is tracked at a time; starting a second request of the same kind before
/// the first completes replaces the stored completion.
public enum Scoreboards {} public enum Scoreboards {}
extension Scoreboards { extension Scoreboards {
@@ -16,13 +14,12 @@ extension Scoreboards {
nonisolated(unsafe) private static var boardsCompletion: ((Result<BoardsList, PlaydateError>) -> Void)? nonisolated(unsafe) private static var boardsCompletion: ((Result<BoardsList, PlaydateError>) -> Void)?
nonisolated(unsafe) private static var scoresCompletion: ((Result<ScoresList, PlaydateError>) -> Void)? nonisolated(unsafe) private static var scoresCompletion: ((Result<ScoresList, PlaydateError>) -> Void)?
/// Submits a score to the board. Returns `false` if the request could /// Submits `value` to `boardID`; `completion` gets the resulting score.
/// not be started.
@discardableResult @discardableResult
public static func addScore(boardID: String, value: UInt32, public static func addScore(boardID: String, value: UInt32,
completion: @escaping (Result<Score, PlaydateError>) -> Void) -> Bool { completion: @escaping (Result<Score, PlaydateError>) -> Void) -> Bool {
addScoreCompletion = completion addScoreCompletion = completion
return boardID.withPlaydateCString { cBoardID in return boardID.withCString { cBoardID in
scoreboardsAPI.pointee.addScore.unsafelyUnwrapped(cBoardID, value, { score, errorMessage in scoreboardsAPI.pointee.addScore.unsafelyUnwrapped(cBoardID, value, { score, errorMessage in
let completion = Scoreboards.addScoreCompletion let completion = Scoreboards.addScoreCompletion
Scoreboards.addScoreCompletion = nil Scoreboards.addScoreCompletion = nil
@@ -31,12 +28,12 @@ extension Scoreboards {
} }
} }
/// Fetches the current player's best score on the board. /// Fetches the current player's best score on `boardID`.
@discardableResult @discardableResult
public static func getPersonalBest(boardID: String, public static func getPersonalBest(boardID: String,
completion: @escaping (Result<Score, PlaydateError>) -> Void) -> Bool { completion: @escaping (Result<Score, PlaydateError>) -> Void) -> Bool {
personalBestCompletion = completion personalBestCompletion = completion
return boardID.withPlaydateCString { cBoardID in return boardID.withCString { cBoardID in
scoreboardsAPI.pointee.getPersonalBest.unsafelyUnwrapped(cBoardID, { score, errorMessage in scoreboardsAPI.pointee.getPersonalBest.unsafelyUnwrapped(cBoardID, { score, errorMessage in
let completion = Scoreboards.personalBestCompletion let completion = Scoreboards.personalBestCompletion
Scoreboards.personalBestCompletion = nil Scoreboards.personalBestCompletion = nil
@@ -45,7 +42,7 @@ extension Scoreboards {
} }
} }
/// Fetches the list of the game's boards. /// Fetches the game's boards.
@discardableResult @discardableResult
public static func getScoreboards(completion: @escaping (Result<BoardsList, PlaydateError>) -> Void) -> Bool { public static func getScoreboards(completion: @escaping (Result<BoardsList, PlaydateError>) -> Void) -> Bool {
boardsCompletion = completion boardsCompletion = completion
@@ -62,12 +59,12 @@ extension Scoreboards {
}) != 0 }) != 0
} }
/// Fetches the scores on the board. /// Fetches the scores on `boardID`.
@discardableResult @discardableResult
public static func getScores(boardID: String, public static func getScores(boardID: String,
completion: @escaping (Result<ScoresList, PlaydateError>) -> Void) -> Bool { completion: @escaping (Result<ScoresList, PlaydateError>) -> Void) -> Bool {
scoresCompletion = completion scoresCompletion = completion
return boardID.withPlaydateCString { cBoardID in return boardID.withCString { cBoardID in
scoreboardsAPI.pointee.getScores.unsafelyUnwrapped(cBoardID, { scores, errorMessage in scoreboardsAPI.pointee.getScores.unsafelyUnwrapped(cBoardID, { scores, errorMessage in
let completion = Scoreboards.scoresCompletion let completion = Scoreboards.scoresCompletion
Scoreboards.scoresCompletion = nil Scoreboards.scoresCompletion = nil
@@ -1,11 +1,11 @@
internal import CPlaydate internal import CPlaydate
extension Scoreboards { extension Scoreboards {
/// A board belonging to the game. /// One of the game's boards. Copied from `PDBoard`.
public struct Board { public struct Board {
/// The board's identifier, used in the other scoreboard calls. /// Passed as `boardID` to the other calls.
public let boardID: String public let boardID: String
/// The board's display name. /// Display name.
public let name: String public let name: String
init(_ board: PDBoard) { init(_ board: PDBoard) {
@@ -1,11 +1,10 @@
internal import CPlaydate internal import CPlaydate
extension Scoreboards { extension Scoreboards {
/// The game's boards. /// The game's boards. Copied from `PDBoardsList`.
public struct BoardsList { public struct BoardsList {
/// When the list was last updated, in seconds since the epoch. /// Last update, in seconds since the epoch.
public let lastUpdated: UInt32 public let lastUpdated: UInt32
/// The game's boards.
public let boards: [Board] public let boards: [Board]
init(_ list: PDBoardsList) { init(_ list: PDBoardsList) {
@@ -1,15 +1,14 @@
internal import CPlaydate internal import CPlaydate
extension Scoreboards { extension Scoreboards {
/// A score on a board. /// A score on a board. Copied from `PDScore` or `PDListScore`.
public struct Score { public struct Score {
/// The score's position on the board, starting at 1. /// Position on the board, from 1.
public let rank: UInt32 public let rank: UInt32
/// The score's value.
public let value: UInt32 public let value: UInt32
/// The name of the player who posted the score. /// Name of the player who posted it.
public let player: String public let player: String
/// The board the score belongs to, when known. /// `nil` if the C API gave none.
public let boardID: String? public let boardID: String?
init(_ score: PDScore) { init(_ score: PDScore) {
@@ -1,17 +1,16 @@
internal import CPlaydate internal import CPlaydate
extension Scoreboards { extension Scoreboards {
/// The scores on a board. /// The scores on a board. Copied from `PDScoresList`.
public struct ScoresList { public struct ScoresList {
/// The board the scores belong to.
public let boardID: String public let boardID: String
/// When the list was last updated, in seconds since the epoch. /// Last update, in seconds since the epoch.
public let lastUpdated: UInt32 public let lastUpdated: UInt32
/// Whether the current player's score is included in the list. /// Whether the current player's score is in the list.
public let playerIncluded: Bool public let playerIncluded: Bool
/// The maximum number of scores the list can hold. /// Maximum number of scores the list can hold.
public let limit: UInt32 public let limit: UInt32
/// The scores, ordered by rank. /// Ordered by rank.
public let scores: [Score] public let scores: [Score]
init(_ list: PDScoresList) { init(_ list: PDScoresList) {
@@ -1,5 +1,4 @@
extension Sound { extension Sound {
/// A note as a MIDI note number, where 60 is middle C. Fractional values /// A MIDI note number (60 is middle C); fractional values are valid.
/// are valid.
public typealias MIDINote = Float public typealias MIDINote = Float
} }
+10 -12
View File
@@ -119,13 +119,12 @@ extension Sound {
} }
/// Modulates the channel's volume. /// Modulates the channel's volume.
public func setVolumeModulator(_ modulator: SignalValue?) {
retain(modulator)
Channel.api.pointee.setVolumeModulator.unsafelyUnwrapped(pointer, modulator?.pointer)
}
public var volumeModulator: SignalValue? { public var volumeModulator: SignalValue? {
SignalValue.wrap(Channel.api.pointee.getVolumeModulator.unsafelyUnwrapped(pointer)) get { SignalValue.wrap(Channel.api.pointee.getVolumeModulator.unsafelyUnwrapped(pointer)) }
set {
retain(newValue)
Channel.api.pointee.setVolumeModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
}
} }
/// The channel's stereo pan: -1 (left) to 1 (right). /// The channel's stereo pan: -1 (left) to 1 (right).
@@ -135,13 +134,12 @@ extension Sound {
/// Modulates the channel's pan. The signal's range 0...1 maps to /// Modulates the channel's pan. The signal's range 0...1 maps to
/// left...right. /// left...right.
public func setPanModulator(_ modulator: SignalValue?) {
retain(modulator)
Channel.api.pointee.setPanModulator.unsafelyUnwrapped(pointer, modulator?.pointer)
}
public var panModulator: SignalValue? { public var panModulator: SignalValue? {
SignalValue.wrap(Channel.api.pointee.getPanModulator.unsafelyUnwrapped(pointer)) get { SignalValue.wrap(Channel.api.pointee.getPanModulator.unsafelyUnwrapped(pointer)) }
set {
retain(newValue)
Channel.api.pointee.setPanModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
}
} }
/// A signal following the channel's dry (unprocessed) level. /// A signal following the channel's dry (unprocessed) level.
@@ -1,8 +1,8 @@
extension Sound.Effect { extension Sound.Effect {
/// Processes up to `AUDIO_FRAMES_PER_CYCLE` sample frames in signed /// Processes up to 512 (`AUDIO_FRAMES_PER_CYCLE`) signed Q8.24 frames in place.
/// Q8.24 format. `bufferActive` is `false` when the input buffer is /// `right` is empty on mono channels; `bufferActive` is `false` if nothing was
/// silent. Returns `true` if the effect produced output. /// written. Returns `true` if it changed the samples.
public typealias Processor = (_ left: UnsafeMutableBufferPointer<Int32>, public typealias Processor = (_ left: inout MutableSpan<Int32>,
_ right: UnsafeMutableBufferPointer<Int32>?, _ right: inout MutableSpan<Int32>,
_ bufferActive: Bool) -> Bool _ bufferActive: Bool) -> Bool
} }
@@ -18,17 +18,17 @@ extension Sound {
} }
} }
/// When `true`, `setDepth` values map exponentially to bit depth. /// If `true`, quantizing scales with amplitude so quiet sounds survive; if `false`,
/// it clears a fixed number of low-order bits.
public func setExponential(_ flag: Bool) { public func setExponential(_ flag: Bool) {
BitCrusher.api.pointee.setExponential.unsafelyUnwrapped(pointer, flag) BitCrusher.api.pointee.setExponential.unsafelyUnwrapped(pointer, flag)
} }
/// The amount of crushing, 0 (none) to 1 (quantized to 1 bit). /// Quantizing, 0 (none) to 1 (1-bit output).
public func setDepth(_ depth: Float) { public func setDepth(_ depth: Float) {
BitCrusher.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth) BitCrusher.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth)
} }
/// Modulates the crush depth.
public var depthModulator: SignalValue? { public var depthModulator: SignalValue? {
get { SignalValue.wrap(BitCrusher.api.pointee.getDepthModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(BitCrusher.api.pointee.getDepthModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -37,12 +37,11 @@ extension Sound {
} }
} }
/// The amount of downsampling, 0 (none) to 1 (every sample repeated). /// Sample-rate reduction, 0 (none) to 1 (so much that audio stops).
public func setDownsampling(_ downsampling: Float) { public func setDownsampling(_ downsampling: Float) {
BitCrusher.api.pointee.setDownsampling.unsafelyUnwrapped(pointer, downsampling) BitCrusher.api.pointee.setDownsampling.unsafelyUnwrapped(pointer, downsampling)
} }
/// Modulates the downsampling amount.
public var downsamplingModulator: SignalValue? { public var downsamplingModulator: SignalValue? {
get { SignalValue.wrap(BitCrusher.api.pointee.getDownsamplingModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(BitCrusher.api.pointee.getDownsamplingModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -5,7 +5,7 @@ extension Sound {
public final class DelayLine: Effect { public final class DelayLine: Effect {
private static var api: UnsafePointer<playdate_sound_effect_delayline> { Playdate.delayLineAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_effect_delayline> { Playdate.delayLineAPI.unsafelyUnwrapped }
/// Creates a delay line holding `length` frames. /// `length` is in frames.
public init(length: Int, stereo: Bool = false) { public init(length: Int, stereo: Bool = false) {
super.init(pointer: DelayLine.api.pointee.newDelayLine.unsafelyUnwrapped( super.init(pointer: DelayLine.api.pointee.newDelayLine.unsafelyUnwrapped(
Int32(length), stereo ? 1 : 0).unsafelyUnwrapped, isOwned: true) Int32(length), stereo ? 1 : 0).unsafelyUnwrapped, isOwned: true)
@@ -17,19 +17,18 @@ extension Sound {
} }
} }
/// Changes the delay length. Cannot be larger than the line's /// Clears the buffer and reallocates, so not safe while the line is in use.
/// original length.
public func setLength(frames: Int) { public func setLength(frames: Int) {
DelayLine.api.pointee.setLength.unsafelyUnwrapped(pointer, Int32(frames)) DelayLine.api.pointee.setLength.unsafelyUnwrapped(pointer, Int32(frames))
} }
/// The feedback level, 0...1. /// 0...1.
public func setFeedback(_ feedback: Float) { public func setFeedback(_ feedback: Float) {
DelayLine.api.pointee.setFeedback.unsafelyUnwrapped(pointer, feedback) DelayLine.api.pointee.setFeedback.unsafelyUnwrapped(pointer, feedback)
} }
/// Adds a tap `delay` frames behind the write head. The tap can be /// `delay` is in frames behind the write head, at most the line's length.
/// added to a channel as a sound source. /// The tap keeps the line alive.
public func addTap(delay: Int) -> DelayLineTap? { public func addTap(delay: Int) -> DelayLineTap? {
guard let tap = DelayLine.api.pointee.addTap.unsafelyUnwrapped(pointer, Int32(delay)) else { guard let tap = DelayLine.api.pointee.addTap.unsafelyUnwrapped(pointer, Int32(delay)) else {
return nil return nil
@@ -1,12 +1,11 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A tap into a delay line; produces audio and can be added to a channel /// A read point on a delay line, playable as a channel source. Wraps `DelayLineTap`.
/// as a source. Wraps `DelayLineTap`.
public final class DelayLineTap: Source { public final class DelayLineTap: Source {
private static var api: UnsafePointer<playdate_sound_effect_delayline> { Playdate.delayLineAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_effect_delayline> { Playdate.delayLineAPI.unsafelyUnwrapped }
/// The delay line is retained so the tap stays valid. /// Kept alive: the tap reads from its buffer.
private let delayLine: DelayLine private let delayLine: DelayLine
private var retainedDelayModulator: SignalValue? private var retainedDelayModulator: SignalValue?
@@ -19,12 +18,12 @@ extension Sound {
DelayLineTap.api.pointee.freeTap.unsafelyUnwrapped(pointer) DelayLineTap.api.pointee.freeTap.unsafelyUnwrapped(pointer)
} }
/// The tap's position in the delay line, in frames. /// In frames, up to the delay line's length.
public func setDelay(frames: Int) { public func setDelay(frames: Int) {
DelayLineTap.api.pointee.setTapDelay.unsafelyUnwrapped(pointer, Int32(frames)) DelayLineTap.api.pointee.setTapDelay.unsafelyUnwrapped(pointer, Int32(frames))
} }
/// Modulates the tap's delay. /// A continuous signal speeds up or slows down playback.
public var delayModulator: SignalValue? { public var delayModulator: SignalValue? {
get { SignalValue.wrap(DelayLineTap.api.pointee.getTapDelayModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(DelayLineTap.api.pointee.getTapDelayModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -33,7 +32,7 @@ extension Sound {
} }
} }
/// For stereo delay lines: swaps the left and right channels. /// Stereo delay lines only.
public func setChannelsFlipped(_ flipped: Bool) { public func setChannelsFlipped(_ flipped: Bool) {
DelayLineTap.api.pointee.setTapChannelsFlipped.unsafelyUnwrapped(pointer, flipped ? 1 : 0) DelayLineTap.api.pointee.setTapChannelsFlipped.unsafelyUnwrapped(pointer, flipped ? 1 : 0)
} }
@@ -1,11 +1,9 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->sound->effect` C API table.
private var effectAPI: UnsafePointer<playdate_sound_effect> { Playdate.effectAPI.unsafelyUnwrapped } private var effectAPI: UnsafePointer<playdate_sound_effect> { Playdate.effectAPI.unsafelyUnwrapped }
extension Sound { extension Sound {
/// An effect that processes a channel's audio: the base class of the /// Processes a channel's audio; base of the built-in effects. Wraps `SoundEffect`.
/// built-in effects. Wraps `SoundEffect`.
public class Effect { public class Effect {
let pointer: OpaquePointer let pointer: OpaquePointer
let isOwned: Bool let isOwned: Bool
@@ -22,7 +20,7 @@ extension Sound {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// Creates an effect that processes audio with a Swift callback. /// Runs `processor` each audio cycle; keeps it alive until deinit.
public init(processor: @escaping Processor) { public init(processor: @escaping Processor) {
let box = Unmanaged.passRetained(ProcessorBox(processor)) let box = Unmanaged.passRetained(ProcessorBox(processor))
processorBox = box processorBox = box
@@ -30,26 +28,28 @@ extension Sound {
guard let effect, let left, guard let effect, let left,
let userdata = effectAPI.pointee.getUserdata.unsafelyUnwrapped(effect) else { return 0 } let userdata = effectAPI.pointee.getUserdata.unsafelyUnwrapped(effect) else { return 0 }
let box = Unmanaged<ProcessorBox>.fromOpaque(userdata).takeUnretainedValue() let box = Unmanaged<ProcessorBox>.fromOpaque(userdata).takeUnretainedValue()
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(nsamples)) var leftSpan = UnsafeMutableBufferPointer(start: left, count: Int(nsamples)).mutableSpan
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(nsamples)) } var rightSpan = UnsafeMutableBufferPointer(start: right, count: right == nil ? 0 : Int(nsamples)).mutableSpan
return box.processor(leftBuffer, rightBuffer, bufactive != 0) ? 1 : 0 return box.processor(&leftSpan, &rightSpan, bufactive != 0) ? 1 : 0
}, box.toOpaque()).unsafelyUnwrapped }, box.toOpaque()).unsafelyUnwrapped
isOwned = true isOwned = true
} }
deinit { deinit {
// Subclasses free their C object themselves; freeing here would double-free.
if let processorBox {
if isOwned { if isOwned {
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer) effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
} }
processorBox?.release() processorBox.release()
}
} }
/// The wet/dry mix: 1 is fully processed, 0 fully dry. /// Wet/dry mix: 0 leaves the effect out, 1 replaces the input with its output.
public func setMix(_ level: Float) { public func setMix(_ level: Float) {
effectAPI.pointee.setMix.unsafelyUnwrapped(pointer, level) effectAPI.pointee.setMix.unsafelyUnwrapped(pointer, level)
} }
/// Modulates the wet/dry mix.
public var mixModulator: SignalValue? { public var mixModulator: SignalValue? {
get { SignalValue.wrap(effectAPI.pointee.getMixModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(effectAPI.pointee.getMixModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -18,13 +18,11 @@ extension Sound {
} }
} }
/// The filter's cutoff: -1 to 1, where values above 0 are low-pass /// The cutoff, -1 to 1: above 0 is high-pass, below 0 low-pass.
/// and values below 0 high-pass.
public func setParameter(_ parameter: Float) { public func setParameter(_ parameter: Float) {
OnePoleFilter.api.pointee.setParameter.unsafelyUnwrapped(pointer, parameter) OnePoleFilter.api.pointee.setParameter.unsafelyUnwrapped(pointer, parameter)
} }
/// Modulates the filter's cutoff parameter.
public var parameterModulator: SignalValue? { public var parameterModulator: SignalValue? {
get { SignalValue.wrap(OnePoleFilter.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(OnePoleFilter.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -18,7 +18,7 @@ extension Sound {
} }
} }
/// The input gain applied before clipping. /// Input gain, applied before clipping.
public func setGain(_ gain: Float) { public func setGain(_ gain: Float) {
Overdrive.api.pointee.setGain.unsafelyUnwrapped(pointer, gain) Overdrive.api.pointee.setGain.unsafelyUnwrapped(pointer, gain)
} }
@@ -28,7 +28,6 @@ extension Sound {
Overdrive.api.pointee.setLimit.unsafelyUnwrapped(pointer, limit) Overdrive.api.pointee.setLimit.unsafelyUnwrapped(pointer, limit)
} }
/// Modulates the clipping limit.
public var limitModulator: SignalValue? { public var limitModulator: SignalValue? {
get { SignalValue.wrap(Overdrive.api.pointee.getLimitModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(Overdrive.api.pointee.getLimitModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -37,12 +36,11 @@ extension Sound {
} }
} }
/// A DC offset applied to the input, making the clipping asymmetric. /// Added to the upper and lower limits, making clipping asymmetric.
public func setOffset(_ offset: Float) { public func setOffset(_ offset: Float) {
Overdrive.api.pointee.setOffset.unsafelyUnwrapped(pointer, offset) Overdrive.api.pointee.setOffset.unsafelyUnwrapped(pointer, offset)
} }
/// Modulates the DC offset.
public var offsetModulator: SignalValue? { public var offsetModulator: SignalValue? {
get { SignalValue.wrap(Overdrive.api.pointee.getOffsetModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(Overdrive.api.pointee.getOffsetModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -18,12 +18,11 @@ extension Sound {
} }
} }
/// The modulation frequency, in Hz. /// In Hz.
public func setFrequency(_ frequency: Float) { public func setFrequency(_ frequency: Float) {
RingModulator.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency) RingModulator.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency)
} }
/// Modulates the modulation frequency.
public var frequencyModulator: SignalValue? { public var frequencyModulator: SignalValue? {
get { SignalValue.wrap(RingModulator.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(RingModulator.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -24,12 +24,12 @@ extension Sound {
TwoPoleFilter.api.pointee.setType.unsafelyUnwrapped(pointer, kind.cValue) TwoPoleFilter.api.pointee.setType.unsafelyUnwrapped(pointer, kind.cValue)
} }
/// The center/corner frequency, in Hz. /// Center or corner frequency, in Hz.
public func setFrequency(_ frequency: Float) { public func setFrequency(_ frequency: Float) {
TwoPoleFilter.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency) TwoPoleFilter.api.pointee.setFrequency.unsafelyUnwrapped(pointer, frequency)
} }
/// Modulates the filter's frequency. /// 1 is half the sample rate.
public var frequencyModulator: SignalValue? { public var frequencyModulator: SignalValue? {
get { SignalValue.wrap(TwoPoleFilter.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(TwoPoleFilter.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -38,7 +38,7 @@ extension Sound {
} }
} }
/// The gain, used by PEQ and shelf filters. /// Used by `.peq` and shelf filters.
public func setGain(_ gain: Float) { public func setGain(_ gain: Float) {
TwoPoleFilter.api.pointee.setGain.unsafelyUnwrapped(pointer, gain) TwoPoleFilter.api.pointee.setGain.unsafelyUnwrapped(pointer, gain)
} }
@@ -47,7 +47,6 @@ extension Sound {
TwoPoleFilter.api.pointee.setResonance.unsafelyUnwrapped(pointer, resonance) TwoPoleFilter.api.pointee.setResonance.unsafelyUnwrapped(pointer, resonance)
} }
/// Modulates the filter's resonance.
public var resonanceModulator: SignalValue? { public var resonanceModulator: SignalValue? {
get { SignalValue.wrap(TwoPoleFilter.api.pointee.getResonanceModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(TwoPoleFilter.api.pointee.getResonanceModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -1,13 +1,13 @@
internal import CPlaydate internal import CPlaydate
extension Sound.TwoPoleFilter { extension Sound.TwoPoleFilter {
/// The filter's response type.
public enum Kind: UInt32, Sendable { public enum Kind: UInt32, Sendable {
case lowPass = 0 case lowPass = 0
case highPass = 1 case highPass = 1
case bandPass = 2 case bandPass = 2
/// Band-reject.
case notch = 3 case notch = 3
/// A parametric EQ filter. /// Parametric EQ.
case peq = 4 case peq = 4
case lowShelf = 5 case lowShelf = 5
case highShelf = 6 case highShelf = 6
@@ -1,8 +1,7 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A signal whose values are set on a sequence timeline. Wraps /// Values set at sequence steps, for automating parameters. Wraps `ControlSignal`.
/// `ControlSignal`.
public final class ControlSignal: SignalValue { public final class ControlSignal: SignalValue {
private static var api: UnsafePointer<playdate_control_signal> { Playdate.controlSignalAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_control_signal> { Playdate.controlSignalAPI.unsafelyUnwrapped }
@@ -21,24 +20,21 @@ extension Sound {
} }
} }
/// Removes all events from the signal's timeline.
public func clearEvents() { public func clearEvents() {
ControlSignal.api.pointee.clearEvents.unsafelyUnwrapped(pointer) ControlSignal.api.pointee.clearEvents.unsafelyUnwrapped(pointer)
} }
/// Adds a value at `step` in the signal's timeline. If `interpolate` /// If `interpolate`, ramps to `value` from the previous event.
/// is `true`, the value ramps from the previous event.
public func addEvent(step: Int, value: Float, interpolate: Bool = false) { public func addEvent(step: Int, value: Float, interpolate: Bool = false) {
ControlSignal.api.pointee.addEvent.unsafelyUnwrapped(pointer, Int32(step), value, ControlSignal.api.pointee.addEvent.unsafelyUnwrapped(pointer, Int32(step), value,
interpolate ? 1 : 0) interpolate ? 1 : 0)
} }
/// Removes the event at `step`, if any.
public func removeEvent(step: Int) { public func removeEvent(step: Int) {
ControlSignal.api.pointee.removeEvent.unsafelyUnwrapped(pointer, Int32(step)) ControlSignal.api.pointee.removeEvent.unsafelyUnwrapped(pointer, Int32(step))
} }
/// The MIDI controller number for signals loaded from a MIDI file. /// For signals created by `Sequence.loadMIDIFile(path:)`.
public var midiControllerNumber: Int { public var midiControllerNumber: Int {
Int(ControlSignal.api.pointee.getMIDIControllerNumber.unsafelyUnwrapped(pointer)) Int(ControlSignal.api.pointee.getMIDIControllerNumber.unsafelyUnwrapped(pointer))
} }
@@ -5,8 +5,7 @@ extension Sound {
public final class Envelope: SignalValue { public final class Envelope: SignalValue {
private static var api: UnsafePointer<playdate_sound_envelope> { Playdate.envelopeAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_envelope> { Playdate.envelopeAPI.unsafelyUnwrapped }
/// Creates an envelope with the given attack and decay times /// `attack`, `decay`, and `release` are in seconds; `sustain` is 0...1.
/// (seconds), sustain level (0...1), and release time (seconds).
public init(attack: Float = 0, decay: Float = 0, sustain: Float = 1, release: Float = 0) { public init(attack: Float = 0, decay: Float = 0, sustain: Float = 1, release: Float = 0) {
let pointer = Envelope.api.pointee.newEnvelope.unsafelyUnwrapped(attack, decay, sustain, release) let pointer = Envelope.api.pointee.newEnvelope.unsafelyUnwrapped(attack, decay, sustain, release)
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
@@ -22,55 +21,51 @@ extension Sound {
} }
} }
/// The attack time, in seconds. /// In seconds.
public func setAttack(_ attack: Float) { public func setAttack(_ attack: Float) {
Envelope.api.pointee.setAttack.unsafelyUnwrapped(pointer, attack) Envelope.api.pointee.setAttack.unsafelyUnwrapped(pointer, attack)
} }
/// The decay time, in seconds. /// In seconds.
public func setDecay(_ decay: Float) { public func setDecay(_ decay: Float) {
Envelope.api.pointee.setDecay.unsafelyUnwrapped(pointer, decay) Envelope.api.pointee.setDecay.unsafelyUnwrapped(pointer, decay)
} }
/// The sustain level, 0...1. /// 0...1.
public func setSustain(_ sustain: Float) { public func setSustain(_ sustain: Float) {
Envelope.api.pointee.setSustain.unsafelyUnwrapped(pointer, sustain) Envelope.api.pointee.setSustain.unsafelyUnwrapped(pointer, sustain)
} }
/// The release time, in seconds. /// In seconds.
public func setRelease(_ release: Float) { public func setRelease(_ release: Float) {
Envelope.api.pointee.setRelease.unsafelyUnwrapped(pointer, release) Envelope.api.pointee.setRelease.unsafelyUnwrapped(pointer, release)
} }
/// When `true`, a new note while a note is playing does not restart /// If `true`, retriggering before release stays in sustain instead of re-attacking.
/// the envelope.
public func setLegato(_ flag: Bool) { public func setLegato(_ flag: Bool) {
Envelope.api.pointee.setLegato.unsafelyUnwrapped(pointer, flag ? 1 : 0) Envelope.api.pointee.setLegato.unsafelyUnwrapped(pointer, flag ? 1 : 0)
} }
/// When `true`, a new note restarts the envelope from zero instead of /// If `true`, each note starts from 0 instead of the current value.
/// its current value.
public func setRetrigger(_ flag: Bool) { public func setRetrigger(_ flag: Bool) {
Envelope.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0) Envelope.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0)
} }
/// Bends the envelope's segments: 0 is linear, 1 is maximum curvature. /// Segment shape, 0 (linear) to 1 (exponential).
public func setCurvature(_ amount: Float) { public func setCurvature(_ amount: Float) {
Envelope.api.pointee.setCurvature.unsafelyUnwrapped(pointer, amount) Envelope.api.pointee.setCurvature.unsafelyUnwrapped(pointer, amount)
} }
/// How much note velocity scales the envelope's output. /// 1 (default) scales output by velocity; 0 ignores it.
public func setVelocitySensitivity(_ sensitivity: Float) { public func setVelocitySensitivity(_ sensitivity: Float) {
Envelope.api.pointee.setVelocitySensitivity.unsafelyUnwrapped(pointer, sensitivity) Envelope.api.pointee.setVelocitySensitivity.unsafelyUnwrapped(pointer, sensitivity)
} }
/// Scales the envelope's rate by note: notes above `start` play the /// Rate scale by note: 1 below `start`, `scaling` above `end`, interpolated between.
/// envelope faster (up to `scaling` at `end` and beyond).
public func setRateScaling(_ scaling: Float, start: MIDINote, end: MIDINote) { public func setRateScaling(_ scaling: Float, start: MIDINote, end: MIDINote) {
Envelope.api.pointee.setRateScaling.unsafelyUnwrapped(pointer, scaling, start, end) Envelope.api.pointee.setRateScaling.unsafelyUnwrapped(pointer, scaling, start, end)
} }
/// The envelope's current value.
public var value: Float { public var value: Float {
Envelope.api.pointee.getValue.unsafelyUnwrapped(pointer) Envelope.api.pointee.getValue.unsafelyUnwrapped(pointer)
} }
@@ -22,33 +22,32 @@ extension Sound {
LFO.api.pointee.setType.unsafelyUnwrapped(pointer, shape.cValue) LFO.api.pointee.setType.unsafelyUnwrapped(pointer, shape.cValue)
} }
/// The LFO rate, in cycles per second. /// In cycles per second.
public func setRate(_ rate: Float) { public func setRate(_ rate: Float) {
LFO.api.pointee.setRate.unsafelyUnwrapped(pointer, rate) LFO.api.pointee.setRate.unsafelyUnwrapped(pointer, rate)
} }
/// The current phase, 0...1. /// 0...1.
public func setPhase(_ phase: Float) { public func setPhase(_ phase: Float) {
LFO.api.pointee.setPhase.unsafelyUnwrapped(pointer, phase) LFO.api.pointee.setPhase.unsafelyUnwrapped(pointer, phase)
} }
/// The phase the LFO starts at when a note starts, 0...1. /// 0...1; used when the LFO is retriggered.
public func setStartPhase(_ phase: Float) { public func setStartPhase(_ phase: Float) {
LFO.api.pointee.setStartPhase.unsafelyUnwrapped(pointer, phase) LFO.api.pointee.setStartPhase.unsafelyUnwrapped(pointer, phase)
} }
/// The center value of the LFO output.
public func setCenter(_ center: Float) { public func setCenter(_ center: Float) {
LFO.api.pointee.setCenter.unsafelyUnwrapped(pointer, center) LFO.api.pointee.setCenter.unsafelyUnwrapped(pointer, center)
} }
/// The amplitude of the LFO around its center. /// The output's amplitude around its center.
public func setDepth(_ depth: Float) { public func setDepth(_ depth: Float) {
LFO.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth) LFO.api.pointee.setDepth.unsafelyUnwrapped(pointer, depth)
} }
/// For `.arpeggiator` LFOs: the sequence of values (in half-steps) /// Switches to `.arpeggiator` over `steps`, in half-steps from the center note
/// to step through. /// (e.g. `[0, 4, 7, 12]` for a major chord).
public func setArpeggiation(_ steps: [Float]) { public func setArpeggiation(_ steps: [Float]) {
var steps = steps var steps = steps
steps.withUnsafeMutableBufferPointer { buffer in steps.withUnsafeMutableBufferPointer { buffer in
@@ -57,8 +56,7 @@ extension Sound {
} }
} }
/// For `.function` LFOs: the Swift function providing the value. If /// For `.function` LFOs; `interpolate` smooths between calls. Keeps `function` alive.
/// `interpolate` is `true`, values are interpolated between calls.
public func setFunction(interpolate: Bool = false, _ function: @escaping (LFO) -> Float) { public func setFunction(interpolate: Bool = false, _ function: @escaping (LFO) -> Float) {
self.function = function self.function = function
LFO.api.pointee.setFunction.unsafelyUnwrapped(pointer, { _, userdata in LFO.api.pointee.setFunction.unsafelyUnwrapped(pointer, { _, userdata in
@@ -68,28 +66,27 @@ extension Sound {
}, Unmanaged.passUnretained(self).toOpaque(), interpolate ? 1 : 0) }, Unmanaged.passUnretained(self).toOpaque(), interpolate ? 1 : 0)
} }
/// Waits `holdoff` seconds after a note starts, then ramps the LFO /// Holds at center `holdoff` seconds after a note starts, then ramps linearly to
/// depth up over `rampTime` seconds. /// full depth over `rampTime` seconds.
public func setDelay(holdoff: Float, rampTime: Float) { public func setDelay(holdoff: Float, rampTime: Float) {
LFO.api.pointee.setDelay.unsafelyUnwrapped(pointer, holdoff, rampTime) LFO.api.pointee.setDelay.unsafelyUnwrapped(pointer, holdoff, rampTime)
} }
/// Whether the LFO phase restarts on every new note. /// If `true`, notes on a synth using the LFO reset its phase to the start phase.
public func setRetrigger(_ flag: Bool) { public func setRetrigger(_ flag: Bool) {
LFO.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0) LFO.api.pointee.setRetrigger.unsafelyUnwrapped(pointer, flag ? 1 : 0)
} }
/// When `true`, the LFO runs globally instead of per-note. /// If `true`, updates continuously, even when not in use.
public func setGlobal(_ global: Bool) { public func setGlobal(_ global: Bool) {
LFO.api.pointee.setGlobal.unsafelyUnwrapped(pointer, global ? 1 : 0) LFO.api.pointee.setGlobal.unsafelyUnwrapped(pointer, global ? 1 : 0)
} }
/// Seeds the random number generator used by `.sampleAndHold` LFOs. /// Seeds the random generator, for reproducible `.sampleAndHold` output.
public func setRandomSeed(_ seed: UInt16) { public func setRandomSeed(_ seed: UInt16) {
LFO.api.pointee.setRandomSeed.unsafelyUnwrapped(pointer, seed) LFO.api.pointee.setRandomSeed.unsafelyUnwrapped(pointer, seed)
} }
/// The LFO's current value.
public var value: Float { public var value: Float {
LFO.api.pointee.getValue.unsafelyUnwrapped(pointer) LFO.api.pointee.getValue.unsafelyUnwrapped(pointer)
} }
@@ -1,8 +1,8 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A signal object; also provides custom signals driven by Swift /// A scaled, offset signal: custom (Swift callbacks) or tracking another value.
/// callbacks. Wraps `PDSynthSignal`. /// Wraps `PDSynthSignal`.
public final class Signal: SignalValue { public final class Signal: SignalValue {
private static var api: UnsafePointer<playdate_sound_signal> { Playdate.signalAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_signal> { Playdate.signalAPI.unsafelyUnwrapped }
@@ -11,7 +11,7 @@ extension Sound {
init(_ callbacks: Callbacks) { self.callbacks = callbacks } init(_ callbacks: Callbacks) { self.callbacks = callbacks }
} }
/// Creates a signal driven by the given callbacks. /// `callbacks` stay alive until the C signal is freed.
public init(callbacks: Callbacks) { public init(callbacks: Callbacks) {
let box = Unmanaged.passRetained(Box(callbacks)) let box = Unmanaged.passRetained(Box(callbacks))
let pointer = Signal.api.pointee.newSignal.unsafelyUnwrapped( let pointer = Signal.api.pointee.newSignal.unsafelyUnwrapped(
@@ -38,8 +38,7 @@ extension Sound {
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
} }
/// Creates a plain signal object wrapping an existing signal value, /// Tracks `value` so it can be scaled and offset; does not keep `value` alive.
/// so it can be scaled and offset.
public init(value: SignalValue) { public init(value: SignalValue) {
let pointer = Signal.api.pointee.newSignalForValue.unsafelyUnwrapped(value.pointer) let pointer = Signal.api.pointee.newSignalForValue.unsafelyUnwrapped(value.pointer)
super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true) super.init(pointer: pointer.unsafelyUnwrapped, isOwned: true)
@@ -55,17 +54,15 @@ extension Sound {
} }
} }
/// The signal's current value.
public var value: Float { public var value: Float {
Signal.api.pointee.getValue.unsafelyUnwrapped(pointer) Signal.api.pointee.getValue.unsafelyUnwrapped(pointer)
} }
/// Scales the signal's output. /// Applied before the offset.
public func setValueScale(_ scale: Float) { public func setValueScale(_ scale: Float) {
Signal.api.pointee.setValueScale.unsafelyUnwrapped(pointer, scale) Signal.api.pointee.setValueScale.unsafelyUnwrapped(pointer, scale)
} }
/// Offsets the signal's output.
public func setValueOffset(_ offset: Float) { public func setValueOffset(_ offset: Float) {
Signal.api.pointee.setValueOffset.unsafelyUnwrapped(pointer, offset) Signal.api.pointee.setValueOffset.unsafelyUnwrapped(pointer, offset)
} }
@@ -1,6 +1,7 @@
extension Sound { extension Sound {
/// A value that can modulate a parameter. The base class of `Signal`, /// A value that can modulate a parameter. Wraps `PDSynthSignalValue`; base of
/// `LFO`, `Envelope`, and `ControlSignal`. Wraps `PDSynthSignalValue`. /// `Signal`, `LFO`, `Envelope`, and `ControlSignal`. What it modulates keeps it
/// alive; assigning `nil` to a modulator property clears it.
public class SignalValue { public class SignalValue {
let pointer: OpaquePointer let pointer: OpaquePointer
let isOwned: Bool let isOwned: Bool
@@ -10,7 +11,7 @@ extension Sound {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// Wraps a signal value pointer returned by the OS (not owned). /// Wraps a C API pointer without taking ownership.
static func wrap(_ pointer: OpaquePointer?) -> SignalValue? { static func wrap(_ pointer: OpaquePointer?) -> SignalValue? {
guard let pointer else { return nil } guard let pointer else { return nil }
return SignalValue(pointer: pointer, isOwned: false) return SignalValue(pointer: pointer, isOwned: false)
@@ -1,15 +1,17 @@
internal import CPlaydate internal import CPlaydate
extension Sound.LFO { extension Sound.LFO {
/// The oscillator's waveform.
public enum Shape: UInt32, Sendable { public enum Shape: UInt32, Sendable {
case square = 0 case square = 0
case triangle = 1 case triangle = 1
case sine = 2 case sine = 2
/// Random values, held for each cycle.
case sampleAndHold = 3 case sampleAndHold = 3
case sawtoothUp = 4 case sawtoothUp = 4
case sawtoothDown = 5 case sawtoothDown = 5
/// Steps through the values set by `setArpeggiation(_:)`.
case arpeggiator = 6 case arpeggiator = 6
/// Values come from the function set by `setFunction(interpolate:_:)`.
case function = 7 case function = 7
var cValue: LFOType { LFOType(LFOType.RawValue(rawValue)) } var cValue: LFOType { LFOType(LFOType.RawValue(rawValue)) }
@@ -1,16 +1,14 @@
extension Sound.Signal { extension Sound.Signal {
/// Custom signal callbacks. /// Custom signal callbacks, run on the audio render thread; return quickly.
public struct Callbacks { public struct Callbacks {
/// Returns the signal's value at the end of the current cycle. /// Returns the value at the end of the cycle; `ioFrames` holds its frames left. For
/// `ioFrames` is the number of frames until the cycle ends and /// a mid-cycle value, write it to `interpolationValue`, set `ioFrames` to its offset.
/// may be lowered to interpolate toward `interpolationValue`.
public var step: (_ ioFrames: UnsafeMutablePointer<Int32>?, public var step: (_ ioFrames: UnsafeMutablePointer<Int32>?,
_ interpolationValue: UnsafeMutablePointer<Float>?) -> Float _ interpolationValue: UnsafeMutablePointer<Float>?) -> Float
/// Called on note-on events. `length` is -1 for indefinite notes. /// `length` is in seconds, or -1 if indefinite.
public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)?
/// Called on note-off events. `stopped` is `false` when the note /// `stopped` is `false` on release, `true` on stop; `offset` is the frame offset
/// is released and `true` when it actually stops playing; /// into the cycle.
/// `offset` is the frame offset within the current cycle.
public var noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)? public var noteOff: ((_ stopped: Bool, _ offset: Int) -> Void)?
public init(step: @escaping (_ ioFrames: UnsafeMutablePointer<Int32>?, public init(step: @escaping (_ ioFrames: UnsafeMutablePointer<Int32>?,
+16 -23
View File
@@ -10,8 +10,7 @@ extension Sound {
/// Middle C (`NOTE_C4`). /// Middle C (`NOTE_C4`).
public static let noteC4: MIDINote = 60 public static let noteC4: MIDINote = 60
/// The number of audio frames rendered per system audio cycle /// Audio frames rendered per audio cycle (`AUDIO_FRAMES_PER_CYCLE`).
/// (`AUDIO_FRAMES_PER_CYCLE`).
public static let audioFramesPerCycle = 512 public static let audioFramesPerCycle = 512
/// Converts a MIDI note to a frequency in Hz. /// Converts a MIDI note to a frequency in Hz.
@@ -24,7 +23,7 @@ extension Sound {
pd_frequencyToNote(frequency) pd_frequencyToNote(frequency)
} }
/// The most recent sound error as a thrown error. /// The last sound error, as a `PlaydateError`.
static func lastError() -> PlaydateError { static func lastError() -> PlaydateError {
PlaydateError(cString: snd.pointee.getError.unsafelyUnwrapped()) PlaydateError(cString: snd.pointee.getError.unsafelyUnwrapped())
} }
@@ -41,7 +40,8 @@ extension Sound {
String(playdateCString: snd.pointee.getError.unsafelyUnwrapped()) String(playdateCString: snd.pointee.getError.unsafelyUnwrapped())
} }
/// Removes a source from its channel. /// Removes `source` from its channel; `false` if it wasn't in one. Also releases a
/// `CallbackSource`'s callback.
@discardableResult @discardableResult
public static func removeSource(_ source: Source) -> Bool { public static func removeSource(_ source: Source) -> Bool {
let removed = snd.pointee.removeSource.unsafelyUnwrapped(source.pointer) != 0 let removed = snd.pointee.removeSource.unsafelyUnwrapped(source.pointer) != 0
@@ -49,16 +49,15 @@ extension Sound {
return removed return removed
} }
/// Sets a callback that records microphone input. Return `false` from the /// `callback` gets mono 16-bit mic samples each audio cycle and returns `false` to stop;
/// callback to stop recording. Pass `nil` to stop recording immediately. /// `nil` stops now. Returns `false` on error, e.g. access denied (`requestMicAccess`).
/// The buffer contains mono 16-bit samples.
@discardableResult @discardableResult
public static func setMicCallback(source: MicSource = .autodetect, public static func setMicCallback(source: MicSource = .autodetect,
_ callback: ((UnsafeMutableBufferPointer<Int16>) -> Bool)?) -> Bool { _ callback: ((Span<Int16>) -> Bool)?) -> Bool {
micCallback = callback micCallback = callback
if callback != nil { if callback != nil {
return snd.pointee.setMicCallback.unsafelyUnwrapped({ _, buffer, length in return snd.pointee.setMicCallback.unsafelyUnwrapped({ _, buffer, length in
let samples = UnsafeMutableBufferPointer(start: buffer, count: Int(length)) let samples = UnsafeBufferPointer(start: buffer, count: Int(length)).span
return Sound.micCallback?(samples) == true ? 1 : 0 return Sound.micCallback?(samples) == true ? 1 : 0
}, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0 }, nil, CPlaydate.MicSource(CPlaydate.MicSource.RawValue(source.rawValue))) != 0
} else { } else {
@@ -66,12 +65,10 @@ extension Sound {
} }
} }
nonisolated(unsafe) private static var micCallback: ((UnsafeMutableBufferPointer<Int16>) -> Bool)? nonisolated(unsafe) private static var micCallback: ((Span<Int16>) -> Bool)?
/// Asks the user for permission to record from the microphone. `purpose` /// Asks for mic permission before `setMicCallback`; `purpose` is shown in the prompt.
/// is shown in the permission prompt. The completion receives whether /// `completion` gets the answer only when this returns `.ask` (else already known).
/// access was granted; it is not called if the reply was already
/// determined (the returned value is `.deny` or `.allow`).
@discardableResult @discardableResult
public static func requestMicAccess(purpose: String? = nil, public static func requestMicAccess(purpose: String? = nil,
_ completion: @escaping (Bool) -> Void) -> AccessReply { _ completion: @escaping (Bool) -> Void) -> AccessReply {
@@ -84,7 +81,7 @@ extension Sound {
} }
let reply: accessReply let reply: accessReply
if let purpose { if let purpose {
reply = purpose.withPlaydateCString { reply = purpose.withCString {
snd.pointee.requestMicAccess.unsafelyUnwrapped($0, trampoline, box.toOpaque()) snd.pointee.requestMicAccess.unsafelyUnwrapped($0, trampoline, box.toOpaque())
} }
} else { } else {
@@ -104,8 +101,8 @@ extension Sound {
return (headphone != 0, headsetMic != 0) return (headphone != 0, headsetMic != 0)
} }
/// Installs a callback invoked when the headphone or headset-mic state /// Called when headphone or headset-mic state changes; `nil` removes it. While set,
/// changes. /// output doesn't auto-switch speaker/headphones; call `setOutputsActive` from it.
public static func setHeadphoneChangeCallback(_ callback: ((_ headphone: Bool, _ headsetMic: Bool) -> Void)?) { public static func setHeadphoneChangeCallback(_ callback: ((_ headphone: Bool, _ headsetMic: Bool) -> Void)?) {
headphoneChangeCallback = callback headphoneChangeCallback = callback
if callback != nil { if callback != nil {
@@ -119,16 +116,12 @@ extension Sound {
nonisolated(unsafe) private static var headphoneChangeCallback: ((Bool, Bool) -> Void)? nonisolated(unsafe) private static var headphoneChangeCallback: ((Bool, Bool) -> Void)?
/// Forces audio output to the headphone and/or speaker. When the /// Forces audio output to the given outputs, regardless of headphone state.
/// headphone jack drives output and `speaker` is also set, the speaker
/// plays too.
public static func setOutputsActive(headphone: Bool, speaker: Bool) { public static func setOutputsActive(headphone: Bool, speaker: Bool) {
snd.pointee.setOutputsActive.unsafelyUnwrapped(headphone ? 1 : 0, speaker ? 1 : 0) snd.pointee.setOutputsActive.unsafelyUnwrapped(headphone ? 1 : 0, speaker ? 1 : 0)
} }
/// Adds a callback-based source to the default channel. The callback /// Adds a `CallbackSource` to the default channel.
/// fills the sample buffers and returns `true` if it produced output.
/// Buffers hold 16-bit samples; `right` is non-nil only when `stereo`.
public static func addSource(stereo: Bool, public static func addSource(stereo: Bool,
_ callback: @escaping CallbackSource.Callback) -> CallbackSource { _ callback: @escaping CallbackSource.Callback) -> CallbackSource {
let source = CallbackSource(callback: callback) let source = CallbackSource(callback: callback)
@@ -1,6 +1,6 @@
extension Sound.CallbackSource { extension Sound.CallbackSource {
/// Fills the sample buffers and returns `true` if output was /// Fills `left` and, if stereo, `right` (else empty) with 16-bit samples.
/// produced. `right` is non-nil only for stereo sources. /// Returns `false` if the source was silent this cycle.
public typealias Callback = (_ left: UnsafeMutableBufferPointer<Int16>, public typealias Callback = (_ left: inout MutableSpan<Int16>,
_ right: UnsafeMutableBufferPointer<Int16>?) -> Bool _ right: inout MutableSpan<Int16>) -> Bool
} }
@@ -13,7 +13,7 @@ extension Sound {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// Allocates a sample buffer with room for `byteCount` bytes. /// An empty buffer sized for a `byteCount`-byte file; fill it with `load(path:)`.
public convenience init(byteCount: Int) { public convenience init(byteCount: Int) {
self.init(pointer: AudioSample.api.pointee.newSampleBuffer.unsafelyUnwrapped( self.init(pointer: AudioSample.api.pointee.newSampleBuffer.unsafelyUnwrapped(
Int32(byteCount)).unsafelyUnwrapped, isOwned: true) Int32(byteCount)).unsafelyUnwrapped, isOwned: true)
@@ -21,17 +21,15 @@ extension Sound {
/// Loads the wav or aiff file at `path`. /// Loads the wav or aiff file at `path`.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
let pointer = path.withPlaydateCString { AudioSample.api.pointee.load.unsafelyUnwrapped($0) } let pointer = path.withCString { AudioSample.api.pointee.load.unsafelyUnwrapped($0) }
guard let pointer else { guard let pointer else {
throw PlaydateError(message: "unable to load sample: \(path)") throw PlaydateError(message: "unable to load sample: \(path)")
} }
self.init(pointer: pointer, isOwned: true) self.init(pointer: pointer, isOwned: true)
} }
/// Creates a sample referencing existing sample data. If /// References `data` without copying; it must outlive the sample, which frees it
/// `freeWhenDone` is `true`, the OS frees `data` when the sample is /// if `freeWhenDone`. Returns `nil` on failure.
/// freed; otherwise the caller must keep `data` valid for the
/// sample's lifetime.
public convenience init?(data: UnsafeMutablePointer<UInt8>, format: Format, public convenience init?(data: UnsafeMutablePointer<UInt8>, format: Format,
sampleRate: UInt32, byteCount: Int, freeWhenDone: Bool) { sampleRate: UInt32, byteCount: Int, freeWhenDone: Bool) {
guard let pointer = AudioSample.api.pointee.newSampleFromData.unsafelyUnwrapped( guard let pointer = AudioSample.api.pointee.newSampleFromData.unsafelyUnwrapped(
@@ -47,9 +45,8 @@ extension Sound {
} }
} }
/// Loads the file at `path` into this sample's buffer.
public func load(path: String) throws(PlaydateError) { public func load(path: String) throws(PlaydateError) {
let loaded = path.withPlaydateCString { let loaded = path.withCString {
AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0 AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0
} }
if !loaded { if !loaded {
@@ -57,7 +54,7 @@ extension Sound {
} }
} }
/// The sample's raw data, format, and rate. /// Data pointer (owned by the sample), format, rate in Hz, and length in bytes.
public var data: (data: UnsafeMutablePointer<UInt8>?, format: Format, public var data: (data: UnsafeMutablePointer<UInt8>?, format: Format,
sampleRate: UInt32, byteLength: UInt32) { sampleRate: UInt32, byteLength: UInt32) {
var data: UnsafeMutablePointer<UInt8>? var data: UnsafeMutablePointer<UInt8>?
@@ -67,13 +64,13 @@ extension Sound {
return (data, Format(format), sampleRate, byteLength) return (data, Format(format), sampleRate, byteLength)
} }
/// The sample's length in seconds. /// Length in seconds.
public var length: Float { public var length: Float {
AudioSample.api.pointee.getLength.unsafelyUnwrapped(pointer) AudioSample.api.pointee.getLength.unsafelyUnwrapped(pointer)
} }
/// Decompresses an ADPCM sample to 16-bit PCM so it can be used in a /// Decompresses ADPCM to 16-bit PCM (4x memory), needed for synths and reverse
/// synth. Returns `false` if there is not enough memory. /// play. Returns `false` if out of memory.
@discardableResult @discardableResult
public func decompress() -> Bool { public func decompress() -> Bool {
AudioSample.api.pointee.decompress.unsafelyUnwrapped(pointer) != 0 AudioSample.api.pointee.decompress.unsafelyUnwrapped(pointer) != 0
@@ -1,15 +1,14 @@
extension Sound { extension Sound {
/// A source that produces audio by calling back into Swift. /// A source rendered by a Swift callback every audio cycle. Create with
/// `Sound.addSource(stereo:_:)`; it stays alive until removed with `removeSource`.
public final class CallbackSource: Source { public final class CallbackSource: Source {
let callback: Callback let callback: Callback
/// Every callback source is kept alive here while the C side may /// Keeps sources alive for the C trampoline until removed (`Sound`/`Channel`
/// still invoke its trampoline: from creation until it is removed /// `.removeSource`) or their channel is freed.
/// with `Sound.removeSource`/`Channel.removeSource`, or until its
/// owning channel is freed.
nonisolated(unsafe) static var live: [CallbackSource] = [] nonisolated(unsafe) static var live: [CallbackSource] = []
/// Releases the registration added by `adopt(pointer:)`. /// Drops the reference added by `adopt(pointer:)`.
static func release(_ source: Source) { static func release(_ source: Source) {
live.removeAll { $0 === source } live.removeAll { $0 === source }
} }
@@ -27,9 +26,9 @@ extension Sound {
UnsafeMutablePointer<Int16>?, Int32) -> Int32 = { context, left, right, length in UnsafeMutablePointer<Int16>?, Int32) -> Int32 = { context, left, right, length in
guard let context, let left else { return 0 } guard let context, let left else { return 0 }
let source = Unmanaged<CallbackSource>.fromOpaque(context).takeUnretainedValue() let source = Unmanaged<CallbackSource>.fromOpaque(context).takeUnretainedValue()
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(length)) var leftSpan = UnsafeMutableBufferPointer(start: left, count: Int(length)).mutableSpan
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(length)) } var rightSpan = UnsafeMutableBufferPointer(start: right, count: right == nil ? 0 : Int(length)).mutableSpan
return source.callback(leftBuffer, rightBuffer) ? 1 : 0 return source.callback(&leftSpan, &rightSpan) ? 1 : 0
} }
/// Attaches the C object created for this source. /// Attaches the C object created for this source.
@@ -7,7 +7,7 @@ extension Sound {
var loopCallback: ((FilePlayer) -> Void)? var loopCallback: ((FilePlayer) -> Void)?
var fadeCallback: ((FilePlayer) -> Void)? var fadeCallback: ((FilePlayer) -> Void)?
var mp3DataSource: ((UnsafeMutableBufferPointer<UInt8>) -> Int)? var mp3DataSource: ((inout MutableSpan<UInt8>) -> Int)?
private var retainedRateModulator: SignalValue? private var retainedRateModulator: SignalValue?
override init(pointer: OpaquePointer?, isOwned: Bool) { override init(pointer: OpaquePointer?, isOwned: Bool) {
@@ -19,7 +19,6 @@ extension Sound {
isOwned: true) isOwned: true)
} }
/// Creates a player and loads the audio file at `path`.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
self.init() self.init()
try load(path: path) try load(path: path)
@@ -31,9 +30,8 @@ extension Sound {
} }
} }
/// Prepares the player to stream the file at `path`.
public func load(path: String) throws(PlaydateError) { public func load(path: String) throws(PlaydateError) {
let loaded = path.withPlaydateCString { let loaded = path.withCString {
FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0 FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0
} }
if !loaded { if !loaded {
@@ -41,63 +39,60 @@ extension Sound {
} }
} }
/// Sets the length of the stream buffer, in seconds. Default 0.25. /// Stream buffer length, in seconds; default 0.25.
public func setBufferLength(_ seconds: Float) { public func setBufferLength(_ seconds: Float) {
FilePlayer.api.pointee.setBufferLength.unsafelyUnwrapped(pointer, seconds) FilePlayer.api.pointee.setBufferLength.unsafelyUnwrapped(pointer, seconds)
} }
/// Starts playback, looping `repeat` times; 0 loops endlessly. /// Plays `repeat` times (0 loops forever); `false` if buffer allocation failed.
@discardableResult @discardableResult
public func play(repeat repeatCount: Int = 1) -> Bool { public func play(repeat repeatCount: Int = 1) -> Bool {
FilePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount)) != 0 FilePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount)) != 0
} }
/// Pauses playback.
public func pause() { public func pause() {
FilePlayer.api.pointee.pause.unsafelyUnwrapped(pointer) FilePlayer.api.pointee.pause.unsafelyUnwrapped(pointer)
} }
/// Stops playback.
public func stop() { public func stop() {
FilePlayer.api.pointee.stop.unsafelyUnwrapped(pointer) FilePlayer.api.pointee.stop.unsafelyUnwrapped(pointer)
} }
/// The file's length in seconds. /// Length in seconds.
public var length: Float { public var length: Float {
FilePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer) FilePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer)
} }
/// The playback position in seconds. /// Playback position, in seconds.
public var offset: Float { public var offset: Float {
get { FilePlayer.api.pointee.getOffset.unsafelyUnwrapped(pointer) } get { FilePlayer.api.pointee.getOffset.unsafelyUnwrapped(pointer) }
set { FilePlayer.api.pointee.setOffset.unsafelyUnwrapped(pointer, newValue) } set { FilePlayer.api.pointee.setOffset.unsafelyUnwrapped(pointer, newValue) }
} }
/// The playback rate; 1 is normal speed, negative values are not /// Playback rate; 1 is normal. Negative (reverse) is unsupported.
/// supported.
public var rate: Float { public var rate: Float {
get { FilePlayer.api.pointee.getRate.unsafelyUnwrapped(pointer) } get { FilePlayer.api.pointee.getRate.unsafelyUnwrapped(pointer) }
set { FilePlayer.api.pointee.setRate.unsafelyUnwrapped(pointer, newValue) } set { FilePlayer.api.pointee.setRate.unsafelyUnwrapped(pointer, newValue) }
} }
/// Loops playback between `start` and `end` (seconds) while playing /// Loop region, in seconds; `end` 0 means end of file. Loops only if played
/// with `repeat` 0. An `end` of 0 means the end of the file. /// with `repeat` 0 or 2.
public func setLoopRange(start: Float, end: Float) { public func setLoopRange(start: Float, end: Float) {
FilePlayer.api.pointee.setLoopRange.unsafelyUnwrapped(pointer, start, end) FilePlayer.api.pointee.setLoopRange.unsafelyUnwrapped(pointer, start, end)
} }
/// Whether playback underran because the file could not be read fast /// Whether playback underran because the file couldn't be read fast enough.
/// enough.
public var didUnderrun: Bool { public var didUnderrun: Bool {
FilePlayer.api.pointee.didUnderrun.unsafelyUnwrapped(pointer) != 0 FilePlayer.api.pointee.didUnderrun.unsafelyUnwrapped(pointer) != 0
} }
/// Stops playback (instead of looping the buffer) on underrun. /// If `true`, an underrun stops playback and calls the finish callback; by
/// default playback resumes after a stutter once data arrives.
public func setStopOnUnderrun(_ flag: Bool) { public func setStopOnUnderrun(_ flag: Bool) {
FilePlayer.api.pointee.setStopOnUnderrun.unsafelyUnwrapped(pointer, flag ? 1 : 0) FilePlayer.api.pointee.setStopOnUnderrun.unsafelyUnwrapped(pointer, flag ? 1 : 0)
} }
/// Sets a function called every time playback loops. /// Called each time playback loops; `nil` removes it.
public func setLoopCallback(_ callback: ((FilePlayer) -> Void)?) { public func setLoopCallback(_ callback: ((FilePlayer) -> Void)?) {
loopCallback = callback loopCallback = callback
if callback != nil { if callback != nil {
@@ -111,8 +106,8 @@ extension Sound {
} }
} }
/// Fades the volume to the given levels over `length` sample frames, /// Fades to `left`/`right` (01) over `length` sample frames, then calls
/// then calls `completion`. /// `completion`.
public func fadeVolume(left: Float, right: Float, length: Int32, public func fadeVolume(left: Float, right: Float, length: Int32,
completion: ((FilePlayer) -> Void)? = nil) { completion: ((FilePlayer) -> Void)? = nil) {
fadeCallback = completion fadeCallback = completion
@@ -127,21 +122,20 @@ extension Sound {
} }
} }
/// Streams MP3 data from a callback instead of a file. The callback /// Streams MP3 from `dataSource`, buffering `bufferLength` seconds. `dataSource`
/// fills the buffer and returns the number of bytes written; return 0 /// fills the span and returns bytes written; 0 ends the stream.
/// to signal the end of the stream.
public func setMP3StreamSource(bufferLength: Float, public func setMP3StreamSource(bufferLength: Float,
_ dataSource: @escaping (UnsafeMutableBufferPointer<UInt8>) -> Int) { _ dataSource: @escaping (inout MutableSpan<UInt8>) -> Int) {
mp3DataSource = dataSource mp3DataSource = dataSource
FilePlayer.api.pointee.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in FilePlayer.api.pointee.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in
guard let userdata, let data else { return 0 } guard let userdata, let data else { return 0 }
let player = Unmanaged<FilePlayer>.fromOpaque(userdata).takeUnretainedValue() let player = Unmanaged<FilePlayer>.fromOpaque(userdata).takeUnretainedValue()
let buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes)) var buffer = UnsafeMutableBufferPointer(start: data, count: Int(bytes)).mutableSpan
return Int32(player.mp3DataSource?(buffer) ?? 0) return Int32(player.mp3DataSource?(&buffer) ?? 0)
}, Unmanaged.passUnretained(self).toOpaque(), bufferLength) }, Unmanaged.passUnretained(self).toOpaque(), bufferLength)
} }
/// Modulates the playback rate. /// A signal added to `rate`; `nil` clears it. The player retains it.
public var rateModulator: SignalValue? { public var rateModulator: SignalValue? {
get { SignalValue.wrap(FilePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(FilePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -18,7 +18,6 @@ extension Sound {
isOwned: true) isOwned: true)
} }
/// Creates a player for the sample at `path`.
public convenience init(path: String) throws(PlaydateError) { public convenience init(path: String) throws(PlaydateError) {
self.init() self.init()
sample = try AudioSample(path: path) sample = try AudioSample(path: path)
@@ -30,7 +29,7 @@ extension Sound {
} }
} }
/// The sample to play. /// Retained by the player.
public var sample: AudioSample? { public var sample: AudioSample? {
get { retainedSample } get { retainedSample }
set { set {
@@ -39,46 +38,43 @@ extension Sound {
} }
} }
/// Starts playback at `rate`, looping `repeat` times; 0 loops /// Plays `repeat` times at `rate` (1 is normal); 0 loops forever, -1 ping-pongs.
/// endlessly, -1 loops ping-pong.
@discardableResult @discardableResult
public func play(repeat repeatCount: Int = 1, rate: Float = 1) -> Bool { public func play(repeat repeatCount: Int = 1, rate: Float = 1) -> Bool {
SamplePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount), rate) != 0 SamplePlayer.api.pointee.play.unsafelyUnwrapped(pointer, Int32(repeatCount), rate) != 0
} }
/// Stops playback.
public func stop() { public func stop() {
SamplePlayer.api.pointee.stop.unsafelyUnwrapped(pointer) SamplePlayer.api.pointee.stop.unsafelyUnwrapped(pointer)
} }
/// Pauses or resumes playback.
public func setPaused(_ paused: Bool) { public func setPaused(_ paused: Bool) {
SamplePlayer.api.pointee.setPaused.unsafelyUnwrapped(pointer, paused ? 1 : 0) SamplePlayer.api.pointee.setPaused.unsafelyUnwrapped(pointer, paused ? 1 : 0)
} }
/// The sample's length in seconds. /// Length in seconds.
public var length: Float { public var length: Float {
SamplePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer) SamplePlayer.api.pointee.getLength.unsafelyUnwrapped(pointer)
} }
/// The playback position in seconds. /// Playback position, in seconds.
public var offset: Float { public var offset: Float {
get { SamplePlayer.api.pointee.getOffset.unsafelyUnwrapped(pointer) } get { SamplePlayer.api.pointee.getOffset.unsafelyUnwrapped(pointer) }
set { SamplePlayer.api.pointee.setOffset.unsafelyUnwrapped(pointer, newValue) } set { SamplePlayer.api.pointee.setOffset.unsafelyUnwrapped(pointer, newValue) }
} }
/// The playback rate; 1 is normal speed, negative plays backward. /// Playback rate; 1 is normal. Negative plays backward (PCM only, not ADPCM).
public var rate: Float { public var rate: Float {
get { SamplePlayer.api.pointee.getRate.unsafelyUnwrapped(pointer) } get { SamplePlayer.api.pointee.getRate.unsafelyUnwrapped(pointer) }
set { SamplePlayer.api.pointee.setRate.unsafelyUnwrapped(pointer, newValue) } set { SamplePlayer.api.pointee.setRate.unsafelyUnwrapped(pointer, newValue) }
} }
/// Restricts playback to the given range of sample frames. /// Restricts playback to `start``end`, in sample frames.
public func setPlayRange(start: Int, end: Int) { public func setPlayRange(start: Int, end: Int) {
SamplePlayer.api.pointee.setPlayRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end)) SamplePlayer.api.pointee.setPlayRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end))
} }
/// Sets a function called every time playback loops. /// Called each time playback loops; `nil` removes it.
public func setLoopCallback(_ callback: ((SamplePlayer) -> Void)?) { public func setLoopCallback(_ callback: ((SamplePlayer) -> Void)?) {
loopCallback = callback loopCallback = callback
if callback != nil { if callback != nil {
@@ -92,7 +88,7 @@ extension Sound {
} }
} }
/// Modulates the playback rate. /// A signal added to `rate`; `nil` clears it. The player retains it.
public var rateModulator: SignalValue? { public var rateModulator: SignalValue? {
get { SignalValue.wrap(SamplePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(SamplePlayer.api.pointee.getRateModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -1,12 +1,12 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A source of audio: the base class of `FilePlayer`, `SamplePlayer`, /// Base class of `FilePlayer`, `SamplePlayer`, `Synth`, `DelayLineTap`, and
/// `Synth`, `DelayLineTap`, and `CallbackSource`. Wraps `SoundSource`. /// `CallbackSource`. Wraps `SoundSource`.
public class Source { public class Source {
private static var api: UnsafePointer<playdate_sound_source> { Playdate.sourceAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_source> { Playdate.sourceAPI.unsafelyUnwrapped }
/// The underlying C object. Set once, immediately after creation. /// Set once, right after creation.
var pointer: OpaquePointer! var pointer: OpaquePointer!
let isOwned: Bool let isOwned: Bool
var finishCallback: ((Source) -> Void)? var finishCallback: ((Source) -> Void)?
@@ -16,28 +16,25 @@ extension Sound {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// Sets the playback volume for the left and right channels, 0...1. /// Per-channel volume, 01.
public func setVolume(left: Float, right: Float) {
Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right)
}
/// Sets the playback volume of both channels.
public func setVolume(_ volume: Float) {
setVolume(left: volume, right: volume)
}
/// The playback volume of the left and right channels.
public var volume: (left: Float, right: Float) { public var volume: (left: Float, right: Float) {
get {
var left: Float = 0, right: Float = 0 var left: Float = 0, right: Float = 0
Source.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right) Source.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right)
return (left, right) return (left, right)
} }
set { Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
}
public func setVolume(_ volume: Float) {
self.volume = (volume, volume)
}
public var isPlaying: Bool { public var isPlaying: Bool {
Source.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0 Source.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0
} }
/// Sets a function called when the source finishes playing. /// Called when the source finishes playing; `nil` removes it.
public func setFinishCallback(_ callback: ((Source) -> Void)?) { public func setFinishCallback(_ callback: ((Source) -> Void)?) {
finishCallback = callback finishCallback = callback
if callback != nil { if callback != nil {
@@ -1,8 +1,8 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A bank of synth voices for playing a sequence track. Wraps /// A pool of synth voices for polyphonic playback. Wraps `PDSynthInstrument`.
/// `PDSynthInstrument`. /// Keeps added voices alive.
public final class Instrument { public final class Instrument {
private static var api: UnsafePointer<playdate_sound_instrument> { Playdate.instrumentAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_instrument> { Playdate.instrumentAPI.unsafelyUnwrapped }
@@ -26,9 +26,8 @@ extension Sound {
} }
} }
/// Adds a voice to the instrument, handling notes in /// Voices notes `rangeStart...rangeEnd`, transposed `transpose` half-steps on top of
/// `rangeStart...rangeEnd` (0...127 handles all notes), transposed by /// the instrument. Returns `false` if `synth` has another instrument or channel.
/// `transpose` half-steps.
@discardableResult @discardableResult
public func addVoice(_ synth: Synth, rangeStart: MIDINote = 0, rangeEnd: MIDINote = 127, public func addVoice(_ synth: Synth, rangeStart: MIDINote = 0, rangeEnd: MIDINote = 127,
transpose: Float = 0) -> Bool { transpose: Float = 0) -> Bool {
@@ -40,8 +39,8 @@ extension Sound {
return added return added
} }
/// Plays a note at `frequency` Hz on an available voice. Returns the /// Uses the next free voice, else the one released or playing longest. Arguments
/// synth used, if any. /// as in `Synth.playNote`. Returns the voice used, if any.
@discardableResult @discardableResult
public func playNote(frequency: Float, velocity: Float = 1, public func playNote(frequency: Float, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) -> Synth? { length: Float? = nil, when: UInt32 = 0) -> Synth? {
@@ -50,7 +49,7 @@ extension Sound {
return voice(for: synth) return voice(for: synth)
} }
/// Plays a MIDI note on an available voice. Returns the synth used. /// Like `playNote(frequency:velocity:length:when:)`; returns the voice used, if any.
@discardableResult @discardableResult
public func playMIDINote(_ note: MIDINote, velocity: Float = 1, public func playMIDINote(_ note: MIDINote, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) -> Synth? { length: Float? = nil, when: UInt32 = 0) -> Synth? {
@@ -67,45 +66,41 @@ extension Sound {
return Synth(pointer: pointer, isOwned: false) return Synth(pointer: pointer, isOwned: false)
} }
/// Bends played notes by `bend` × the pitch bend range. /// A fraction of the pitch bend range.
public func setPitchBend(_ bend: Float) { public func setPitchBend(_ bend: Float) {
Instrument.api.pointee.setPitchBend.unsafelyUnwrapped(pointer, bend) Instrument.api.pointee.setPitchBend.unsafelyUnwrapped(pointer, bend)
} }
/// The range of `setPitchBend(_:)`, in half-steps. /// The range of `setPitchBend(_:)`; default 12.
public func setPitchBendRange(halfSteps: Float) { public func setPitchBendRange(halfSteps: Float) {
Instrument.api.pointee.setPitchBendRange.unsafelyUnwrapped(pointer, halfSteps) Instrument.api.pointee.setPitchBendRange.unsafelyUnwrapped(pointer, halfSteps)
} }
/// Transposes played notes by `halfSteps` (fractional values /// Transposes all voices; fractional values allowed.
/// allowed).
public func setTranspose(halfSteps: Float) { public func setTranspose(halfSteps: Float) {
Instrument.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps) Instrument.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps)
} }
/// Releases the voice playing `note` at time `when` (0 = now). /// Releases the voice playing `note` at audio-clock time `when`, or now if 0.
public func noteOff(_ note: MIDINote, when: UInt32 = 0) { public func noteOff(_ note: MIDINote, when: UInt32 = 0) {
Instrument.api.pointee.noteOff.unsafelyUnwrapped(pointer, note, when) Instrument.api.pointee.noteOff.unsafelyUnwrapped(pointer, note, when)
} }
/// Releases every playing voice at time `when` (0 = now). /// Releases every voice at audio-clock time `when`, or now if 0.
public func allNotesOff(when: UInt32 = 0) { public func allNotesOff(when: UInt32 = 0) {
Instrument.api.pointee.allNotesOff.unsafelyUnwrapped(pointer, when) Instrument.api.pointee.allNotesOff.unsafelyUnwrapped(pointer, when)
} }
/// Sets the volume of the left and right channels, 0...1. /// Left and right volume, 0...1.
public func setVolume(left: Float, right: Float) {
Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right)
}
/// The volume of the left and right channels.
public var volume: (left: Float, right: Float) { public var volume: (left: Float, right: Float) {
get {
var left: Float = 0, right: Float = 0 var left: Float = 0, right: Float = 0
Instrument.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right) Instrument.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right)
return (left, right) return (left, right)
} }
set { Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
}
/// The number of voices currently playing.
public var activeVoiceCount: Int { public var activeVoiceCount: Int {
Int(Instrument.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer)) Int(Instrument.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer))
} }
@@ -1,8 +1,8 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A collection of tracks with tempo and loop control, playable from a /// Tracks played at a shared tempo. Wraps `SoundSequence`.
/// MIDI file. Wraps `SoundSequence`. /// Owns, or keeps alive, every track it returns or is given.
public final class Sequence { public final class Sequence {
private static var api: UnsafePointer<playdate_sound_sequence> { Playdate.sequenceAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_sequence> { Playdate.sequenceAPI.unsafelyUnwrapped }
@@ -14,10 +14,9 @@ extension Sound {
pointer = Sequence.api.pointee.newSequence.unsafelyUnwrapped().unsafelyUnwrapped pointer = Sequence.api.pointee.newSequence.unsafelyUnwrapped().unsafelyUnwrapped
} }
/// Creates a sequence and loads the MIDI file at `path`. public convenience init(path: String) throws(PlaydateError) {
public convenience init(midiFilePath: String) throws(PlaydateError) {
self.init() self.init()
try loadMIDIFile(path: midiFilePath) try loadMIDIFile(path: path)
} }
deinit { deinit {
@@ -25,7 +24,7 @@ extension Sound {
} }
public func loadMIDIFile(path: String) throws(PlaydateError) { public func loadMIDIFile(path: String) throws(PlaydateError) {
let loaded = path.withPlaydateCString { let loaded = path.withCString {
Sequence.api.pointee.loadMIDIFile.unsafelyUnwrapped(pointer, $0) != 0 Sequence.api.pointee.loadMIDIFile.unsafelyUnwrapped(pointer, $0) != 0
} }
if !loaded { if !loaded {
@@ -33,7 +32,7 @@ extension Sound {
} }
} }
/// Starts playback. `completion` is called when the sequence finishes. /// `completion` is called when the sequence finishes.
public func play(completion: ((Sequence) -> Void)? = nil) { public func play(completion: ((Sequence) -> Void)? = nil) {
finishCallback = completion finishCallback = completion
if completion != nil { if completion != nil {
@@ -47,48 +46,45 @@ extension Sound {
} }
} }
/// Stops playback.
public func stop() { public func stop() {
Sequence.api.pointee.stop.unsafelyUnwrapped(pointer) Sequence.api.pointee.stop.unsafelyUnwrapped(pointer)
} }
/// Whether the sequence is playing.
public var isPlaying: Bool { public var isPlaying: Bool {
Sequence.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0 Sequence.api.pointee.isPlaying.unsafelyUnwrapped(pointer) != 0
} }
/// The playback position, in samples. /// The playback position, in samples (not steps).
public var time: UInt32 { public var time: UInt32 {
get { Sequence.api.pointee.getTime.unsafelyUnwrapped(pointer) } get { Sequence.api.pointee.getTime.unsafelyUnwrapped(pointer) }
set { Sequence.api.pointee.setTime.unsafelyUnwrapped(pointer, newValue) } set { Sequence.api.pointee.setTime.unsafelyUnwrapped(pointer, newValue) }
} }
/// The tempo, in steps per second. /// In steps per second.
public var tempo: Float { public var tempo: Float {
get { Sequence.api.pointee.getTempo.unsafelyUnwrapped(pointer) } get { Sequence.api.pointee.getTempo.unsafelyUnwrapped(pointer) }
set { Sequence.api.pointee.setTempo.unsafelyUnwrapped(pointer, newValue) } set { Sequence.api.pointee.setTempo.unsafelyUnwrapped(pointer, newValue) }
} }
/// The sequence's length in steps, including the tail of the last note. /// The length of the longest track, in steps.
public var length: UInt32 { public var length: UInt32 {
Sequence.api.pointee.getLength.unsafelyUnwrapped(pointer) Sequence.api.pointee.getLength.unsafelyUnwrapped(pointer)
} }
/// Loops the range `loopStart..<loopEnd` (steps) `loops` times while /// Loops steps `start` to `end` `count` times; 0 loops forever.
/// playing; 0 loops endlessly.
public func setLoops(start: Int, end: Int, count: Int = 0) { public func setLoops(start: Int, end: Int, count: Int = 0) {
Sequence.api.pointee.setLoops.unsafelyUnwrapped(pointer, Int32(start), Int32(end), Int32(count)) Sequence.api.pointee.setLoops.unsafelyUnwrapped(pointer, Int32(start), Int32(end), Int32(count))
} }
/// The current step, and the time offset (in samples) into that step. /// `timeOffset` is in samples.
public var currentStep: (step: Int, timeOffset: Int) { public var currentStep: (step: Int, timeOffset: Int) {
var timeOffset: Int32 = 0 var timeOffset: Int32 = 0
let step = Sequence.api.pointee.getCurrentStep.unsafelyUnwrapped(pointer, &timeOffset) let step = Sequence.api.pointee.getCurrentStep.unsafelyUnwrapped(pointer, &timeOffset)
return (Int(step), Int(timeOffset)) return (Int(step), Int(timeOffset))
} }
/// Moves playback to the given step. If `playNotes` is `true`, notes /// `timeOffset` is in samples. If `playNotes`, plays the notes at `step`
/// at the position (that started before it) are played. /// (ignoring `timeOffset`).
public func setCurrentStep(_ step: Int, timeOffset: Int = 0, playNotes: Bool = false) { public func setCurrentStep(_ step: Int, timeOffset: Int = 0, playNotes: Bool = false) {
Sequence.api.pointee.setCurrentStep.unsafelyUnwrapped(pointer, Int32(step), Sequence.api.pointee.setCurrentStep.unsafelyUnwrapped(pointer, Int32(step),
Int32(timeOffset), playNotes ? 1 : 0) Int32(timeOffset), playNotes ? 1 : 0)
@@ -100,8 +96,6 @@ extension Sound {
Int(Sequence.api.pointee.getTrackCount.unsafelyUnwrapped(pointer)) Int(Sequence.api.pointee.getTrackCount.unsafelyUnwrapped(pointer))
} }
/// Adds a new track to the sequence. The track is owned by the
/// sequence.
@discardableResult @discardableResult
public func addTrack() -> SequenceTrack { public func addTrack() -> SequenceTrack {
let track = SequenceTrack( let track = SequenceTrack(
@@ -111,14 +105,12 @@ extension Sound {
return track return track
} }
/// The track at `index`. Owned by the sequence.
public func track(at index: Int) -> SequenceTrack? { public func track(at index: Int) -> SequenceTrack? {
guard let track = Sequence.api.pointee.getTrackAtIndex.unsafelyUnwrapped( guard let track = Sequence.api.pointee.getTrackAtIndex.unsafelyUnwrapped(
pointer, UInt32(index)) else { return nil } pointer, UInt32(index)) else { return nil }
return SequenceTrack(pointer: track, isOwned: false) return SequenceTrack(pointer: track, isOwned: false)
} }
/// Installs `track` at `index`.
public func setTrack(_ track: SequenceTrack, at index: Int) { public func setTrack(_ track: SequenceTrack, at index: Int) {
if !retainedTracks.contains(where: { $0 === track }) { if !retainedTracks.contains(where: { $0 === track }) {
retainedTracks.append(track) retainedTracks.append(track)
@@ -126,7 +118,6 @@ extension Sound {
Sequence.api.pointee.setTrackAtIndex.unsafelyUnwrapped(pointer, track.pointer, UInt32(index)) Sequence.api.pointee.setTrackAtIndex.unsafelyUnwrapped(pointer, track.pointer, UInt32(index))
} }
/// Releases every playing note in the sequence.
public func allNotesOff() { public func allNotesOff() {
Sequence.api.pointee.allNotesOff.unsafelyUnwrapped(pointer) Sequence.api.pointee.allNotesOff.unsafelyUnwrapped(pointer)
} }
@@ -1,7 +1,8 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A track of notes played by an instrument. Wraps `SequenceTrack`. /// Notes and control signals played on one instrument. Wraps `SequenceTrack`.
/// Owns the control signals it returns; keeps an instrument set on it alive.
public final class SequenceTrack { public final class SequenceTrack {
private static var api: UnsafePointer<playdate_sound_track> { Playdate.trackAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_track> { Playdate.trackAPI.unsafelyUnwrapped }
@@ -25,7 +26,6 @@ extension Sound {
} }
} }
/// The instrument that plays this track's notes.
public var instrument: Instrument? { public var instrument: Instrument? {
get { get {
if let retainedInstrument { return retainedInstrument } if let retainedInstrument { return retainedInstrument }
@@ -40,32 +40,29 @@ extension Sound {
} }
} }
/// Adds a note starting at `step`, lasting `length` steps. /// `length` is in steps.
public func addNote(step: UInt32, length: UInt32, note: MIDINote, velocity: Float = 1) { public func addNote(step: UInt32, length: UInt32, note: MIDINote, velocity: Float = 1) {
SequenceTrack.api.pointee.addNoteEvent.unsafelyUnwrapped(pointer, step, length, note, velocity) SequenceTrack.api.pointee.addNoteEvent.unsafelyUnwrapped(pointer, step, length, note, velocity)
} }
/// Removes the note at `step`, if any.
public func removeNote(step: UInt32, note: MIDINote) { public func removeNote(step: UInt32, note: MIDINote) {
SequenceTrack.api.pointee.removeNoteEvent.unsafelyUnwrapped(pointer, step, note) SequenceTrack.api.pointee.removeNoteEvent.unsafelyUnwrapped(pointer, step, note)
} }
/// Removes all notes from the track.
public func clearNotes() { public func clearNotes() {
SequenceTrack.api.pointee.clearNotes.unsafelyUnwrapped(pointer) SequenceTrack.api.pointee.clearNotes.unsafelyUnwrapped(pointer)
} }
/// The track's length in steps, including the tail of the last note. /// In steps: where the last note ends.
public var length: UInt32 { public var length: UInt32 {
SequenceTrack.api.pointee.getLength.unsafelyUnwrapped(pointer) SequenceTrack.api.pointee.getLength.unsafelyUnwrapped(pointer)
} }
/// The index of the first note at or after `step`. /// The internal index of the first note at `step`.
public func indexForStep(_ step: UInt32) -> Int { public func indexForStep(_ step: UInt32) -> Int {
Int(SequenceTrack.api.pointee.getIndexForStep.unsafelyUnwrapped(pointer, step)) Int(SequenceTrack.api.pointee.getIndexForStep.unsafelyUnwrapped(pointer, step))
} }
/// The note at `index`, or `nil` if the index is out of range.
public func note(at index: Int) -> (step: UInt32, length: UInt32, public func note(at index: Int) -> (step: UInt32, length: UInt32,
note: MIDINote, velocity: Float)? { note: MIDINote, velocity: Float)? {
var step: UInt32 = 0, length: UInt32 = 0 var step: UInt32 = 0, length: UInt32 = 0
@@ -76,42 +73,37 @@ extension Sound {
return (step, length, note, velocity) return (step, length, note, velocity)
} }
/// The number of control signals on the track.
public var controlSignalCount: Int { public var controlSignalCount: Int {
Int(SequenceTrack.api.pointee.getControlSignalCount.unsafelyUnwrapped(pointer)) Int(SequenceTrack.api.pointee.getControlSignalCount.unsafelyUnwrapped(pointer))
} }
/// The control signal at `index`. Owned by the track.
public func controlSignal(at index: Int) -> ControlSignal? { public func controlSignal(at index: Int) -> ControlSignal? {
guard let signal = SequenceTrack.api.pointee.getControlSignal.unsafelyUnwrapped( guard let signal = SequenceTrack.api.pointee.getControlSignal.unsafelyUnwrapped(
pointer, Int32(index)) else { return nil } pointer, Int32(index)) else { return nil }
return ControlSignal(pointer: signal, isOwned: false) return ControlSignal(pointer: signal, isOwned: false)
} }
/// The control signal for MIDI controller `controller`, optionally /// If `create`, makes the signal for `controller` when it is missing.
/// creating it. Owned by the track.
public func signalForController(_ controller: Int, create: Bool = false) -> ControlSignal? { public func signalForController(_ controller: Int, create: Bool = false) -> ControlSignal? {
guard let signal = SequenceTrack.api.pointee.getSignalForController.unsafelyUnwrapped( guard let signal = SequenceTrack.api.pointee.getSignalForController.unsafelyUnwrapped(
pointer, Int32(controller), create ? 1 : 0) else { return nil } pointer, Int32(controller), create ? 1 : 0) else { return nil }
return ControlSignal(pointer: signal, isOwned: false) return ControlSignal(pointer: signal, isOwned: false)
} }
/// Removes all control signal events from the track.
public func clearControlEvents() { public func clearControlEvents() {
SequenceTrack.api.pointee.clearControlEvents.unsafelyUnwrapped(pointer) SequenceTrack.api.pointee.clearControlEvents.unsafelyUnwrapped(pointer)
} }
/// The maximum number of simultaneous notes in the track. /// Max simultaneous notes; set only for tracks loaded from a MIDI file.
public var polyphony: Int { public var polyphony: Int {
Int(SequenceTrack.api.pointee.getPolyphony.unsafelyUnwrapped(pointer)) Int(SequenceTrack.api.pointee.getPolyphony.unsafelyUnwrapped(pointer))
} }
/// The number of notes currently playing. /// Voices playing in the track's instrument.
public var activeVoiceCount: Int { public var activeVoiceCount: Int {
Int(SequenceTrack.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer)) Int(SequenceTrack.api.pointee.activeVoiceCount.unsafelyUnwrapped(pointer))
} }
/// Mutes or unmutes the track.
public func setMuted(_ muted: Bool) { public func setMuted(_ muted: Bool) {
SequenceTrack.api.pointee.setMuted.unsafelyUnwrapped(pointer, muted ? 1 : 0) SequenceTrack.api.pointee.setMuted.unsafelyUnwrapped(pointer, muted ? 1 : 0)
} }
@@ -1,7 +1,7 @@
internal import CPlaydate internal import CPlaydate
extension Sound { extension Sound {
/// A synthesizer voice. Wraps `PDSynth`. /// A synthesizer voice. Wraps `PDSynth`. Keeps samples and generators set on it alive.
public final class Synth: Source { public final class Synth: Source {
private static var api: UnsafePointer<playdate_sound_synth> { Playdate.synthAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sound_synth> { Playdate.synthAPI.unsafelyUnwrapped }
@@ -37,7 +37,7 @@ extension Sound {
} }
} }
/// Copies the synth (and its generator, if any). /// An independently owned copy, including any generator.
public func copy() -> Synth { public func copy() -> Synth {
Synth(pointer: Synth.api.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped, Synth(pointer: Synth.api.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped,
isOwned: true) isOwned: true)
@@ -49,15 +49,15 @@ extension Sound {
Synth.api.pointee.setWaveform.unsafelyUnwrapped(pointer, waveform.cValue) Synth.api.pointee.setWaveform.unsafelyUnwrapped(pointer, waveform.cValue)
} }
/// Plays a sample instead of a waveform. A nonzero sustain range /// Plays `sample` (uncompressed PCM, not ADPCM). Frames `sustainStart..<sustainEnd`
/// loops that part of the sample while the note is held. /// loop while held; `sustainEnd` 0 with nonzero `sustainStart` means the sample's end.
public func setSample(_ sample: AudioSample, sustainStart: UInt32 = 0, sustainEnd: UInt32 = 0) { public func setSample(_ sample: AudioSample, sustainStart: UInt32 = 0, sustainEnd: UInt32 = 0) {
retainedSample = sample retainedSample = sample
Synth.api.pointee.setSample.unsafelyUnwrapped(pointer, sample.pointer, sustainStart, sustainEnd) Synth.api.pointee.setSample.unsafelyUnwrapped(pointer, sample.pointer, sustainStart, sustainEnd)
} }
/// Uses a wavetable for the synth. `log2size` is the base-2 log of /// Plays `sample` (16-bit mono, uncompressed) as `columns` × `rows` cells of
/// each waveform's size (e.g. 8 for 256 samples). /// 2^`log2size` samples; parameters 14 select the position.
public func setWavetable(_ sample: AudioSample, log2size: Int, public func setWavetable(_ sample: AudioSample, log2size: Int,
columns: Int, rows: Int) throws(PlaydateError) { columns: Int, rows: Int) throws(PlaydateError) {
retainedSample = sample retainedSample = sample
@@ -67,7 +67,7 @@ extension Sound {
} }
} }
/// Provides audio via custom Swift callbacks. /// `copy()` shares `generator`.
public func setGenerator(stereo: Bool, _ generator: Generator) { public func setGenerator(stereo: Bool, _ generator: Generator) {
let box = Unmanaged.passRetained(GeneratorBox(generator, stereo: stereo)) let box = Unmanaged.passRetained(GeneratorBox(generator, stereo: stereo))
Synth.api.pointee.setGenerator.unsafelyUnwrapped( Synth.api.pointee.setGenerator.unsafelyUnwrapped(
@@ -75,9 +75,9 @@ extension Sound {
{ userdata, left, right, nsamples, rate, drate in { userdata, left, right, nsamples, rate, drate in
guard let userdata, let left else { return 0 } guard let userdata, let left else { return 0 }
let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue() let box = Unmanaged<GeneratorBox>.fromOpaque(userdata).takeUnretainedValue()
let leftBuffer = UnsafeMutableBufferPointer(start: left, count: Int(nsamples)) var leftSpan = UnsafeMutableBufferPointer(start: left, count: Int(nsamples)).mutableSpan
let rightBuffer = right.map { UnsafeMutableBufferPointer(start: $0, count: Int(nsamples)) } var rightSpan = UnsafeMutableBufferPointer(start: right, count: right == nil ? 0 : Int(nsamples)).mutableSpan
return Int32(box.generator.render(leftBuffer, rightBuffer, rate, drate)) return Int32(box.generator.render(&leftSpan, &rightSpan, rate, drate))
}, },
{ userdata, note, velocity, length in { userdata, note, velocity, length in
guard let userdata else { return } guard let userdata else { return }
@@ -108,45 +108,44 @@ extension Sound {
// MARK: Envelope // MARK: Envelope
/// The envelope's attack time, in seconds. /// In seconds.
public func setAttackTime(_ attack: Float) { public func setAttackTime(_ attack: Float) {
Synth.api.pointee.setAttackTime.unsafelyUnwrapped(pointer, attack) Synth.api.pointee.setAttackTime.unsafelyUnwrapped(pointer, attack)
} }
/// The envelope's decay time, in seconds. /// In seconds.
public func setDecayTime(_ decay: Float) { public func setDecayTime(_ decay: Float) {
Synth.api.pointee.setDecayTime.unsafelyUnwrapped(pointer, decay) Synth.api.pointee.setDecayTime.unsafelyUnwrapped(pointer, decay)
} }
/// The envelope's sustain level, 0...1. /// 0...1.
public func setSustainLevel(_ sustain: Float) { public func setSustainLevel(_ sustain: Float) {
Synth.api.pointee.setSustainLevel.unsafelyUnwrapped(pointer, sustain) Synth.api.pointee.setSustainLevel.unsafelyUnwrapped(pointer, sustain)
} }
/// The envelope's release time, in seconds. /// In seconds.
public func setReleaseTime(_ release: Float) { public func setReleaseTime(_ release: Float) {
Synth.api.pointee.setReleaseTime.unsafelyUnwrapped(pointer, release) Synth.api.pointee.setReleaseTime.unsafelyUnwrapped(pointer, release)
} }
/// The synth's amplitude envelope. Owned by the synth. /// The amplitude envelope; owned by the synth, valid only while it is alive.
public var envelope: Envelope? { public var envelope: Envelope? {
guard let envelope = Synth.api.pointee.getEnvelope.unsafelyUnwrapped(pointer) else { return nil } guard let envelope = Synth.api.pointee.getEnvelope.unsafelyUnwrapped(pointer) else { return nil }
return Envelope(pointer: envelope, isOwned: false) return Envelope(pointer: envelope, isOwned: false)
} }
/// Clears the synth's envelope so it plays at constant volume.
public func clearEnvelope() { public func clearEnvelope() {
Synth.api.pointee.clearEnvelope.unsafelyUnwrapped(pointer) Synth.api.pointee.clearEnvelope.unsafelyUnwrapped(pointer)
} }
// MARK: Modulation // MARK: Pitch, modulation, and parameters
/// Transposes played notes by `halfSteps` (fractional values allowed). /// Fractional half-steps allowed.
public func setTranspose(_ halfSteps: Float) { public func setTranspose(_ halfSteps: Float) {
Synth.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps) Synth.api.pointee.setTranspose.unsafelyUnwrapped(pointer, halfSteps)
} }
/// Modulates the synth's frequency. /// 1 is an octave up, -1 an octave down.
public var frequencyModulator: SignalValue? { public var frequencyModulator: SignalValue? {
get { SignalValue.wrap(Synth.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(Synth.api.pointee.getFrequencyModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -155,7 +154,6 @@ extension Sound {
} }
} }
/// Modulates the synth's amplitude.
public var amplitudeModulator: SignalValue? { public var amplitudeModulator: SignalValue? {
get { SignalValue.wrap(Synth.api.pointee.getAmplitudeModulator.unsafelyUnwrapped(pointer)) } get { SignalValue.wrap(Synth.api.pointee.getAmplitudeModulator.unsafelyUnwrapped(pointer)) }
set { set {
@@ -164,26 +162,25 @@ extension Sound {
} }
} }
/// The number of parameters the synth's generator supports. /// The number of parameters the generator supports.
public var parameterCount: Int { public var parameterCount: Int {
Int(Synth.api.pointee.getParameterCount.unsafelyUnwrapped(pointer)) Int(Synth.api.pointee.getParameterCount.unsafelyUnwrapped(pointer))
} }
/// Sets a generator parameter. Returns `false` if the parameter is /// `parameter` is 1-based. Returns `false` if it is invalid.
/// invalid.
@discardableResult @discardableResult
public func setParameter(_ parameter: Int, value: Float) -> Bool { public func setParameter(_ parameter: Int, value: Float) -> Bool {
Synth.api.pointee.setParameter.unsafelyUnwrapped(pointer, Int32(parameter), value) != 0 Synth.api.pointee.setParameter.unsafelyUnwrapped(pointer, Int32(parameter), value) != 0
} }
/// Modulates a generator parameter. /// `parameter` is 1-based.
public func setParameterModulator(_ parameter: Int, _ modulator: SignalValue?) { public func setParameterModulator(_ parameter: Int, _ modulator: SignalValue?) {
retain(modulator) retain(modulator)
Synth.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter), Synth.api.pointee.setParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter),
modulator?.pointer) modulator?.pointer)
} }
/// The modulator installed on a generator parameter, if any. /// `parameter` is 1-based.
public func parameterModulator(_ parameter: Int) -> SignalValue? { public func parameterModulator(_ parameter: Int) -> SignalValue? {
SignalValue.wrap(Synth.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter))) SignalValue.wrap(Synth.api.pointee.getParameterModulator.unsafelyUnwrapped(pointer, Int32(parameter)))
} }
@@ -196,26 +193,25 @@ extension Sound {
// MARK: Playing // MARK: Playing
/// Plays a note at `frequency` Hz. `length` is in seconds; `nil` /// `frequency` in Hz; `length` in seconds, `nil` until `noteOff(when:)`;
/// plays until `noteOff()`. `when` is the audio-clock time to start, /// `when` is an audio-clock time, 0 for now.
/// or 0 for immediately.
public func playNote(frequency: Float, velocity: Float = 1, public func playNote(frequency: Float, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) { length: Float? = nil, when: UInt32 = 0) {
Synth.api.pointee.playNote.unsafelyUnwrapped(pointer, frequency, velocity, length ?? -1, when) Synth.api.pointee.playNote.unsafelyUnwrapped(pointer, frequency, velocity, length ?? -1, when)
} }
/// Plays a MIDI note, where 60 is middle C. /// 60 is C4; fractional notes allowed. Other arguments as in `playNote`.
public func playMIDINote(_ note: MIDINote, velocity: Float = 1, public func playMIDINote(_ note: MIDINote, velocity: Float = 1,
length: Float? = nil, when: UInt32 = 0) { length: Float? = nil, when: UInt32 = 0) {
Synth.api.pointee.playMIDINote.unsafelyUnwrapped(pointer, note, velocity, length ?? -1, when) Synth.api.pointee.playMIDINote.unsafelyUnwrapped(pointer, note, velocity, length ?? -1, when)
} }
/// Releases the playing note at time `when`, or immediately if 0. /// Releases the note at audio-clock time `when`, or now if 0.
public func noteOff(when: UInt32 = 0) { public func noteOff(when: UInt32 = 0) {
Synth.api.pointee.noteOff.unsafelyUnwrapped(pointer, when) Synth.api.pointee.noteOff.unsafelyUnwrapped(pointer, when)
} }
/// Stops the synth immediately, without playing the release phase. /// Stops immediately, skipping the release phase.
public func stop() { public func stop() {
Synth.api.pointee.stop.unsafelyUnwrapped(pointer) Synth.api.pointee.stop.unsafelyUnwrapped(pointer)
} }
@@ -1,18 +1,19 @@
internal import CPlaydate internal import CPlaydate
extension Sound.Synth { extension Sound.Synth {
/// The synth's waveform.
public enum Waveform: UInt32, Sendable { public enum Waveform: UInt32, Sendable {
/// Parameter 1 sets the pulse width.
case square = 0 case square = 0
case triangle = 1 case triangle = 1
case sine = 2 case sine = 2
/// White noise.
case noise = 3 case noise = 3
case sawtooth = 4 case sawtooth = 4
/// A Pocket Operator-style phase-distortion waveform. /// Pocket Operator-style phase distortion.
case poPhase = 5 case poPhase = 5
/// A Pocket Operator-style digital waveform. /// Pocket Operator-style digital.
case poDigital = 6 case poDigital = 6
/// A Pocket Operator-style VOSIM (voice simulation) waveform. /// Pocket Operator-style VOSIM (voice simulation).
case poVosim = 7 case poVosim = 7
var cValue: SoundWaveform { SoundWaveform(SoundWaveform.RawValue(rawValue)) } var cValue: SoundWaveform { SoundWaveform(SoundWaveform.RawValue(rawValue)) }
@@ -1,24 +1,21 @@
extension Sound.Synth { extension Sound.Synth {
/// Custom generator callbacks. Samples are in signed Q8.24 format. /// Custom generator callbacks, run on the audio render thread; return quickly.
/// Samples are signed Q8.24.
public struct Generator { public struct Generator {
/// Renders up to 256 sample frames into `left` (and `right` for /// Renders `left.count` frames into `left` and `right` (empty if mono). `rate` is the
/// stereo generators). `rate` is the per-frame phase step in /// per-frame Q0.32 phase step, `drate` its per-frame change. Returns frames rendered.
/// Q0.32 format and `drate` its per-frame change. Returns the public var render: (_ left: inout MutableSpan<Int32>,
/// number of frames rendered. _ right: inout MutableSpan<Int32>,
public var render: (_ left: UnsafeMutableBufferPointer<Int32>,
_ right: UnsafeMutableBufferPointer<Int32>?,
_ rate: UInt32, _ drate: Int32) -> Int _ rate: UInt32, _ drate: Int32) -> Int
/// Called when a note starts. `length` is -1 for indefinite notes. /// `length` is in seconds, or -1 if indefinite.
public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? public var noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)?
/// Called when a note is released (`stop == false`) or stopped /// `stop` is `false` on release, `true` on stop.
/// (`stop == true`).
public var release: ((_ stop: Bool) -> Void)? public var release: ((_ stop: Bool) -> Void)?
/// Sets a generator parameter. Returns `true` if the parameter is /// Called by `Synth.setParameter(_:value:)` or a modulator. Returns `true` if valid.
/// valid.
public var setParameter: ((_ parameter: Int, _ value: Float) -> Bool)? public var setParameter: ((_ parameter: Int, _ value: Float) -> Bool)?
public init(render: @escaping (_ left: UnsafeMutableBufferPointer<Int32>, public init(render: @escaping (_ left: inout MutableSpan<Int32>,
_ right: UnsafeMutableBufferPointer<Int32>?, _ right: inout MutableSpan<Int32>,
_ rate: UInt32, _ drate: Int32) -> Int, _ rate: UInt32, _ drate: Int32) -> Int,
noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil, noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil,
release: ((_ stop: Bool) -> Void)? = nil, release: ((_ stop: Bool) -> Void)? = nil,
+77 -102
View File
@@ -1,25 +1,20 @@
internal import CPlaydate internal import CPlaydate
/// The cached `playdate->sprite` C API table.
private var spriteAPI: UnsafePointer<playdate_sprite> { Playdate.spriteAPI.unsafelyUnwrapped } private var spriteAPI: UnsafePointer<playdate_sprite> { Playdate.spriteAPI.unsafelyUnwrapped }
/// A sprite: a drawable object with position, z-order, and collision /// A drawable object with position, z-order, and collisions. Wraps `LCDSprite`; static
/// support. Wraps `LCDSprite`. Static members wrap the global sprite /// members wrap the global sprite functions. Retains its image, stencil, tilemap, closures.
/// system functions. /// The C userdata slot holds the wrapper back-reference; don't set it from C (use `userdata`).
/// /// `add()` retains the sprite until `remove()`/`removeAll()`. Owned sprites free their
/// The binding stores a back-reference to each `Sprite` wrapper in the /// `LCDSprite` on deinit; sprites created elsewhere get transient, non-owning wrappers.
/// underlying `LCDSprite`'s userdata slot, so callbacks and queries can
/// recover the wrapper. Do not mix these wrappers with C code that sets
/// its own sprite userdata; use `userdata` for per-sprite storage instead.
public final class Sprite { public final class Sprite {
let pointer: OpaquePointer let pointer: OpaquePointer
let isOwned: Bool let isOwned: Bool
/// Position in the static `displayList`, or -1 when not in it; makes /// Index in `displayList`, or -1 when absent; makes `add()`/`remove()` O(1).
/// `add()`/`remove()` O(1) instead of scanning the list.
private var displayListIndex = -1 private var displayListIndex = -1
/// Per-sprite callbacks and retained resources. /// Called by the C trampolines; resources retained so C never points at freed ones.
var updateFunction: ((Sprite) -> Void)? var updateFunction: ((Sprite) -> Void)?
var drawFunction: ((Sprite, _ bounds: Rect, _ drawRect: Rect) -> Void)? var drawFunction: ((Sprite, _ bounds: Rect, _ drawRect: Rect) -> Void)?
var collisionResponseFunction: ((Sprite, _ other: Sprite) -> CollisionResponse)? var collisionResponseFunction: ((Sprite, _ other: Sprite) -> CollisionResponse)?
@@ -27,22 +22,19 @@ public final class Sprite {
private var retainedStencil: Graphics.Bitmap? private var retainedStencil: Graphics.Bitmap?
private var retainedTilemap: Graphics.TileMap? private var retainedTilemap: Graphics.TileMap?
/// Free-form storage for game use (the C userdata slot is reserved /// Free-form game storage. Not copied by `copy()`.
/// by the binding).
public var userdata: AnyObject? public var userdata: AnyObject?
init(pointer: OpaquePointer, isOwned: Bool) { init(pointer: OpaquePointer, isOwned: Bool) {
self.pointer = pointer self.pointer = pointer
self.isOwned = isOwned self.isOwned = isOwned
// Transient wrappers for sprites created outside the binding must not // Only owned wrappers clear the back-reference in deinit; others would dangle.
// store a back-reference: it would dangle once the wrapper is
// deallocated, and only owned wrappers clear it in `deinit`.
if isOwned { if isOwned {
spriteAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque()) spriteAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
} }
} }
/// Allocates a new sprite. /// Allocates a sprite, not yet in the display list.
public convenience init() { public convenience init() {
self.init(pointer: spriteAPI.pointee.newSprite.unsafelyUnwrapped().unsafelyUnwrapped, isOwned: true) self.init(pointer: spriteAPI.pointee.newSprite.unsafelyUnwrapped().unsafelyUnwrapped, isOwned: true)
} }
@@ -54,8 +46,7 @@ public final class Sprite {
} }
} }
/// Returns the Swift wrapper stored in the sprite's userdata, or a /// The stored wrapper, or a transient non-owning one for sprites created elsewhere.
/// transient unowned wrapper for sprites created outside the binding.
static func wrapper(for pointer: OpaquePointer) -> Sprite { static func wrapper(for pointer: OpaquePointer) -> Sprite {
if let userdata = spriteAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) { if let userdata = spriteAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) {
return Unmanaged<Sprite>.fromOpaque(userdata).takeUnretainedValue() return Unmanaged<Sprite>.fromOpaque(userdata).takeUnretainedValue()
@@ -63,8 +54,7 @@ public final class Sprite {
return Sprite(pointer: pointer, isOwned: false) return Sprite(pointer: pointer, isOwned: false)
} }
/// Copies the sprite. Callbacks and retained resources are carried /// Also copies callbacks and the retained image, stencil, and tilemap; not `userdata`.
/// over to the copy.
public func copy() -> Sprite { public func copy() -> Sprite {
let copy = Sprite(pointer: spriteAPI.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped, let copy = Sprite(pointer: spriteAPI.pointee.copy.unsafelyUnwrapped(pointer).unsafelyUnwrapped,
isOwned: true) isOwned: true)
@@ -77,38 +67,36 @@ public final class Sprite {
return copy return copy
} }
// MARK: - Display list // MARK: - Display list and drawing
/// Sprites currently added to the display list, kept alive here. /// Keeps added sprites alive while the C display list references them.
nonisolated(unsafe) private static var displayList: [Sprite] = [] nonisolated(unsafe) private static var displayList: [Sprite] = []
/// When `true`, all sprites redraw every frame instead of only when /// `true` redraws all sprites every frame; can be faster with many moving sprites.
/// marked dirty.
public static func setAlwaysRedraw(_ flag: Bool) { public static func setAlwaysRedraw(_ flag: Bool) {
spriteAPI.pointee.setAlwaysRedraw.unsafelyUnwrapped(flag ? 1 : 0) spriteAPI.pointee.setAlwaysRedraw.unsafelyUnwrapped(flag ? 1 : 0)
} }
/// Marks the given screen region as needing a redraw. /// Marks `rect` (screen coordinates) dirty. Graphics drawing calls do this already.
public static func addDirtyRect(_ rect: Graphics.Rect) { public static func addDirtyRect(_ rect: Graphics.Rect) {
spriteAPI.pointee.addDirtyRect.unsafelyUnwrapped(rect.cValue) spriteAPI.pointee.addDirtyRect.unsafelyUnwrapped(rect.cValue)
} }
/// Draws every sprite in the display list.
public static func drawAll() { public static func drawAll() {
spriteAPI.pointee.drawSprites.unsafelyUnwrapped() spriteAPI.pointee.drawSprites.unsafelyUnwrapped()
} }
/// Updates and then draws every sprite in the display list. /// Calls each sprite's update function, then draws all sprites.
public static func updateAndDrawAll() { public static func updateAndDrawAll() {
spriteAPI.pointee.updateAndDrawSprites.unsafelyUnwrapped() spriteAPI.pointee.updateAndDrawSprites.unsafelyUnwrapped()
} }
/// The number of sprites in the display list. /// Number of sprites in the display list.
public static var count: Int { public static var count: Int {
Int(spriteAPI.pointee.getSpriteCount.unsafelyUnwrapped()) Int(spriteAPI.pointee.getSpriteCount.unsafelyUnwrapped())
} }
/// Adds the sprite to the display list. /// Adds to the display list; repeated adds retain only once.
public func add() { public func add() {
spriteAPI.pointee.addSprite.unsafelyUnwrapped(pointer) spriteAPI.pointee.addSprite.unsafelyUnwrapped(pointer)
if displayListIndex < 0 { if displayListIndex < 0 {
@@ -117,12 +105,10 @@ public final class Sprite {
} }
} }
/// Removes the sprite from the display list.
public func remove() { public func remove() {
spriteAPI.pointee.removeSprite.unsafelyUnwrapped(pointer) spriteAPI.pointee.removeSprite.unsafelyUnwrapped(pointer)
guard displayListIndex >= 0 else { return } guard displayListIndex >= 0 else { return }
// Swap-remove: the keep-alive list is unordered (the OS keeps the // Swap-remove: the keep-alive list is unordered (the OS keeps draw order).
// draw order), so the last sprite can take the vacated slot.
let index = displayListIndex let index = displayListIndex
let last = Sprite.displayList.removeLast() let last = Sprite.displayList.removeLast()
if last !== self { if last !== self {
@@ -132,12 +118,10 @@ public final class Sprite {
displayListIndex = -1 displayListIndex = -1
} }
/// Removes the given sprites from the display list.
public static func remove(_ sprites: [Sprite]) { public static func remove(_ sprites: [Sprite]) {
for sprite in sprites { sprite.remove() } for sprite in sprites { sprite.remove() }
} }
/// Removes every sprite from the display list.
public static func removeAll() { public static func removeAll() {
spriteAPI.pointee.removeAllSprites.unsafelyUnwrapped() spriteAPI.pointee.removeAllSprites.unsafelyUnwrapped()
for sprite in displayList { sprite.displayListIndex = -1 } for sprite in displayList { sprite.displayListIndex = -1 }
@@ -146,36 +130,34 @@ public final class Sprite {
// MARK: - Geometry // MARK: - Geometry
/// The sprite's bounds. Setting this positions and sizes the sprite.
public var bounds: Rect { public var bounds: Rect {
get { Rect(spriteAPI.pointee.getBounds.unsafelyUnwrapped(pointer)) } get { Rect(spriteAPI.pointee.getBounds.unsafelyUnwrapped(pointer)) }
set { spriteAPI.pointee.setBounds.unsafelyUnwrapped(pointer, newValue.cValue) } set { spriteAPI.pointee.setBounds.unsafelyUnwrapped(pointer, newValue.cValue) }
} }
/// Moves the sprite so its anchor point is at (x, y). /// Moves so `center` is at (`x`, `y`), recomputing bounds from size and `center`.
public func moveTo(x: Float, y: Float) { public func moveTo(x: Float, y: Float) {
spriteAPI.pointee.moveTo.unsafelyUnwrapped(pointer, x, y) spriteAPI.pointee.moveTo.unsafelyUnwrapped(pointer, x, y)
} }
/// Moves the sprite by (dx, dy).
public func moveBy(dx: Float, dy: Float) { public func moveBy(dx: Float, dy: Float) {
spriteAPI.pointee.moveBy.unsafelyUnwrapped(pointer, dx, dy) spriteAPI.pointee.moveBy.unsafelyUnwrapped(pointer, dx, dy)
} }
/// The sprite's anchor position. /// Where the sprite's `center` point is.
public var position: (x: Float, y: Float) { public var position: (x: Float, y: Float) {
var x: Float = 0, y: Float = 0 var x: Float = 0, y: Float = 0
spriteAPI.pointee.getPosition.unsafelyUnwrapped(pointer, &x, &y) spriteAPI.pointee.getPosition.unsafelyUnwrapped(pointer, &x, &y)
return (x, y) return (x, y)
} }
/// Sets the sprite's size without changing its image. /// Size `moveTo(x:y:)` uses to compute bounds.
public func setSize(width: Float, height: Float) { public func setSize(width: Float, height: Float) {
spriteAPI.pointee.setSize.unsafelyUnwrapped(pointer, width, height) spriteAPI.pointee.setSize.unsafelyUnwrapped(pointer, width, height)
} }
/// The anchor point used for positioning, where (0, 0) is the top /// Drawing center as a 0...1 fraction of size; (0, 0) is top left, (1, 1) bottom right.
/// left and (1, 1) the bottom right. Defaults to (0.5, 0.5). /// Default (0.5, 0.5).
public var center: (x: Float, y: Float) { public var center: (x: Float, y: Float) {
get { get {
var x: Float = 0, y: Float = 0 var x: Float = 0, y: Float = 0
@@ -185,7 +167,7 @@ public final class Sprite {
set { spriteAPI.pointee.setCenter.unsafelyUnwrapped(pointer, newValue.x, newValue.y) } set { spriteAPI.pointee.setCenter.unsafelyUnwrapped(pointer, newValue.x, newValue.y) }
} }
/// Draw order: higher values draw on top. /// Higher values draw on top.
public var zIndex: Int16 { public var zIndex: Int16 {
get { spriteAPI.pointee.getZIndex.unsafelyUnwrapped(pointer) } get { spriteAPI.pointee.getZIndex.unsafelyUnwrapped(pointer) }
set { spriteAPI.pointee.setZIndex.unsafelyUnwrapped(pointer, newValue) } set { spriteAPI.pointee.setZIndex.unsafelyUnwrapped(pointer, newValue) }
@@ -193,20 +175,20 @@ public final class Sprite {
// MARK: - Appearance // MARK: - Appearance
/// Sets the sprite's image, resizing its bounds to match. /// Sets the image, drawn with `flip`, and resizes bounds to match; `nil` removes it.
public func setImage(_ image: Graphics.Bitmap?, flip: Graphics.BitmapFlip = .unflipped) { public func setImage(_ image: Graphics.Bitmap?, flip: Graphics.BitmapFlip = .unflipped) {
retainedImage = image retainedImage = image
spriteAPI.pointee.setImage.unsafelyUnwrapped(pointer, image?.pointer, flip.cValue) spriteAPI.pointee.setImage.unsafelyUnwrapped(pointer, image?.pointer, flip.cValue)
} }
/// The sprite's image. /// The image from `setImage(_:flip:)`, else a non-owning wrapper of the C one, or `nil`.
public var image: Graphics.Bitmap? { public var image: Graphics.Bitmap? {
if let retainedImage { return retainedImage } if let retainedImage { return retainedImage }
guard let image = spriteAPI.pointee.getImage.unsafelyUnwrapped(pointer) else { return nil } guard let image = spriteAPI.pointee.getImage.unsafelyUnwrapped(pointer) else { return nil }
return Graphics.Bitmap(pointer: image, isOwned: false) return Graphics.Bitmap(pointer: image, isOwned: false)
} }
/// Sets the sprite's tilemap, resizing its bounds to match. /// The tilemap set here. Setting resizes bounds to match; `nil` removes it.
public var tilemap: Graphics.TileMap? { public var tilemap: Graphics.TileMap? {
get { retainedTilemap } get { retainedTilemap }
set { set {
@@ -215,28 +197,25 @@ public final class Sprite {
} }
} }
/// The mode used to draw the sprite's image.
public func setDrawMode(_ mode: Graphics.DrawMode) { public func setDrawMode(_ mode: Graphics.DrawMode) {
spriteAPI.pointee.setDrawMode.unsafelyUnwrapped(pointer, mode.cValue) spriteAPI.pointee.setDrawMode.unsafelyUnwrapped(pointer, mode.cValue)
} }
/// How the sprite's image is mirrored when drawn.
public var imageFlip: Graphics.BitmapFlip { public var imageFlip: Graphics.BitmapFlip {
get { Graphics.BitmapFlip(spriteAPI.pointee.getImageFlip.unsafelyUnwrapped(pointer)) } get { Graphics.BitmapFlip(spriteAPI.pointee.getImageFlip.unsafelyUnwrapped(pointer)) }
set { spriteAPI.pointee.setImageFlip.unsafelyUnwrapped(pointer, newValue.cValue) } set { spriteAPI.pointee.setImageFlip.unsafelyUnwrapped(pointer, newValue.cValue) }
} }
/// Sets the stencil applied when drawing the sprite. If `tile` is /// Pixels draw only where `stencil` is white. Screen space: it doesn't move with the
/// `true` the image width must be a multiple of 32. /// sprite. `nil` clears it. With `tile`, it repeats; width must be a multiple of 32.
public func setStencil(_ stencil: Graphics.Bitmap?, tile: Bool = false) { public func setStencil(_ stencil: Graphics.Bitmap?, tile: Bool = false) {
retainedStencil = stencil retainedStencil = stencil
spriteAPI.pointee.setStencilImage.unsafelyUnwrapped(pointer, stencil?.pointer, tile ? 1 : 0) spriteAPI.pointee.setStencilImage.unsafelyUnwrapped(pointer, stencil?.pointer, tile ? 1 : 0)
} }
/// Sets an 8×8 stencil pattern (8 rows of image data). /// Sets an 8×8 stencil pattern, one byte per row.
public func setStencilPattern(_ rows: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { public func setStencilPattern(_ rows: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) {
// The tuple is already 8 contiguous bytes; the C side copies the // The tuple is 8 contiguous bytes and C copies them, so stack storage is safe.
// pattern, so passing the stack storage directly is safe.
withUnsafeBytes(of: rows) { buffer in withUnsafeBytes(of: rows) { buffer in
let pattern = UnsafeMutablePointer( let pattern = UnsafeMutablePointer(
mutating: buffer.baseAddress.unsafelyUnwrapped.assumingMemoryBound(to: UInt8.self)) mutating: buffer.baseAddress.unsafelyUnwrapped.assumingMemoryBound(to: UInt8.self))
@@ -244,12 +223,22 @@ public final class Sprite {
} }
} }
/// `InlineArray` overload of the tuple variant.
@available(macOS 26, *)
public func setStencilPattern(_ rows: [8 of UInt8]) {
// C copies the pattern, so passing the inline array's storage is safe.
rows.span.withUnsafeBufferPointer { buffer in
spriteAPI.pointee.setStencilPattern.unsafelyUnwrapped(
pointer, UnsafeMutablePointer(mutating: buffer.baseAddress))
}
}
public func clearStencil() { public func clearStencil() {
retainedStencil = nil retainedStencil = nil
spriteAPI.pointee.clearStencil.unsafelyUnwrapped(pointer) spriteAPI.pointee.clearStencil.unsafelyUnwrapped(pointer)
} }
/// Clips the sprite's drawing to `rect` (screen coordinates). /// `rect` is in screen coordinates.
public func setClipRect(_ rect: Graphics.Rect) { public func setClipRect(_ rect: Graphics.Rect) {
spriteAPI.pointee.setClipRect.unsafelyUnwrapped(pointer, rect.cValue) spriteAPI.pointee.setClipRect.unsafelyUnwrapped(pointer, rect.cValue)
} }
@@ -258,67 +247,63 @@ public final class Sprite {
spriteAPI.pointee.clearClipRect.unsafelyUnwrapped(pointer) spriteAPI.pointee.clearClipRect.unsafelyUnwrapped(pointer)
} }
/// Clips all sprites with z-index in `startZ...endZ` to `rect`. /// Clips sprites with a z-index in `startZ...endZ` (inclusive) to `rect`.
public static func setClipRectsInRange(_ rect: Graphics.Rect, startZ: Int, endZ: Int) { public static func setClipRectsInRange(_ rect: Graphics.Rect, startZ: Int, endZ: Int) {
spriteAPI.pointee.setClipRectsInRange.unsafelyUnwrapped(rect.cValue, Int32(startZ), Int32(endZ)) spriteAPI.pointee.setClipRectsInRange.unsafelyUnwrapped(rect.cValue, Int32(startZ), Int32(endZ))
} }
/// Clears clip rects of sprites with a z-index in `startZ...endZ` (inclusive).
public static func clearClipRectsInRange(startZ: Int, endZ: Int) { public static func clearClipRectsInRange(startZ: Int, endZ: Int) {
spriteAPI.pointee.clearClipRectsInRange.unsafelyUnwrapped(Int32(startZ), Int32(endZ)) spriteAPI.pointee.clearClipRectsInRange.unsafelyUnwrapped(Int32(startZ), Int32(endZ))
} }
// MARK: - Behavior flags // MARK: - Flags, redraw, and tag
/// Whether the sprite's update function is called by `updateAndDrawAll()`. /// Whether `updateAndDrawAll()` calls the update function.
public var updatesEnabled: Bool { public var updatesEnabled: Bool {
get { spriteAPI.pointee.updatesEnabled.unsafelyUnwrapped(pointer) != 0 } get { spriteAPI.pointee.updatesEnabled.unsafelyUnwrapped(pointer) != 0 }
set { spriteAPI.pointee.setUpdatesEnabled.unsafelyUnwrapped(pointer, newValue ? 1 : 0) } set { spriteAPI.pointee.setUpdatesEnabled.unsafelyUnwrapped(pointer, newValue ? 1 : 0) }
} }
/// Whether the sprite participates in collisions. /// Also requires a `collideRect`. Default `true`.
public var collisionsEnabled: Bool { public var collisionsEnabled: Bool {
get { spriteAPI.pointee.collisionsEnabled.unsafelyUnwrapped(pointer) != 0 } get { spriteAPI.pointee.collisionsEnabled.unsafelyUnwrapped(pointer) != 0 }
set { spriteAPI.pointee.setCollisionsEnabled.unsafelyUnwrapped(pointer, newValue ? 1 : 0) } set { spriteAPI.pointee.setCollisionsEnabled.unsafelyUnwrapped(pointer, newValue ? 1 : 0) }
} }
/// Whether the sprite is drawn.
public var isVisible: Bool { public var isVisible: Bool {
get { spriteAPI.pointee.isVisible.unsafelyUnwrapped(pointer) != 0 } get { spriteAPI.pointee.isVisible.unsafelyUnwrapped(pointer) != 0 }
set { spriteAPI.pointee.setVisible.unsafelyUnwrapped(pointer, newValue ? 1 : 0) } set { spriteAPI.pointee.setVisible.unsafelyUnwrapped(pointer, newValue ? 1 : 0) }
} }
/// Marking a sprite opaque tells the system it does not need to redraw /// Opaque sprites hide what's behind them. Set automatically for images without a mask.
/// anything behind it.
public func setOpaque(_ flag: Bool) { public func setOpaque(_ flag: Bool) {
spriteAPI.pointee.setOpaque.unsafelyUnwrapped(pointer, flag ? 1 : 0) spriteAPI.pointee.setOpaque.unsafelyUnwrapped(pointer, flag ? 1 : 0)
} }
/// Forces the sprite to redraw this frame.
public func markDirty() { public func markDirty() {
spriteAPI.pointee.markDirty.unsafelyUnwrapped(pointer) spriteAPI.pointee.markDirty.unsafelyUnwrapped(pointer)
} }
/// Marks part of the sprite (in sprite-local coordinates) as needing /// `rect` is relative to the sprite's top-left corner.
/// a redraw.
public func markDirty(rect: Rect) { public func markDirty(rect: Rect) {
spriteAPI.pointee.markDirtyRect.unsafelyUnwrapped(pointer, rect.cValue) spriteAPI.pointee.markDirtyRect.unsafelyUnwrapped(pointer, rect.cValue)
} }
/// An integer tag for identifying sprites (e.g. in collisions). /// Game-defined tag, 0255, e.g. for collision handling.
public var tag: UInt8 { public var tag: UInt8 {
get { spriteAPI.pointee.getTag.unsafelyUnwrapped(pointer) } get { spriteAPI.pointee.getTag.unsafelyUnwrapped(pointer) }
set { spriteAPI.pointee.setTag.unsafelyUnwrapped(pointer, newValue) } set { spriteAPI.pointee.setTag.unsafelyUnwrapped(pointer, newValue) }
} }
/// When `true`, the sprite draws in screen coordinates, ignoring the /// `true` draws in screen coordinates; collisions stay in world space.
/// global draw offset.
public func setIgnoresDrawOffset(_ flag: Bool) { public func setIgnoresDrawOffset(_ flag: Bool) {
spriteAPI.pointee.setIgnoresDrawOffset.unsafelyUnwrapped(pointer, flag ? 1 : 0) spriteAPI.pointee.setIgnoresDrawOffset.unsafelyUnwrapped(pointer, flag ? 1 : 0)
} }
// MARK: - Callbacks // MARK: - Callbacks
/// Sets the function called by `updateAndDrawAll()` for this sprite. /// Called by `updateAndDrawAll()`; `nil` removes it.
public func setUpdateFunction(_ update: ((Sprite) -> Void)?) { public func setUpdateFunction(_ update: ((Sprite) -> Void)?) {
updateFunction = update updateFunction = update
if update != nil { if update != nil {
@@ -332,9 +317,8 @@ public final class Sprite {
} }
} }
/// Sets a custom draw function, called when the sprite needs to draw. /// Receives `bounds` and the dirty `drawRect`; `nil` removes it. Runs only while on
/// `bounds` is the sprite's bounds; `drawRect` is the region that /// screen with a size (from `setSize(width:height:)` or `bounds`).
/// needs redrawing.
public func setDrawFunction(_ draw: ((Sprite, _ bounds: Rect, _ drawRect: Rect) -> Void)?) { public func setDrawFunction(_ draw: ((Sprite, _ bounds: Rect, _ drawRect: Rect) -> Void)?) {
drawFunction = draw drawFunction = draw
if draw != nil { if draw != nil {
@@ -350,12 +334,12 @@ public final class Sprite {
// MARK: - Collisions // MARK: - Collisions
/// Clears the collision world. Call when changing scenes. /// Frees and reallocates the collision data, resetting it. Call when changing scenes.
public static func resetCollisionWorld() { public static func resetCollisionWorld() {
spriteAPI.pointee.resetCollisionWorld.unsafelyUnwrapped() spriteAPI.pointee.resetCollisionWorld.unsafelyUnwrapped()
} }
/// The rect (in sprite-local coordinates) used for collisions. /// Relative to the sprite's bounds.
public var collideRect: Rect { public var collideRect: Rect {
get { Rect(spriteAPI.pointee.getCollideRect.unsafelyUnwrapped(pointer)) } get { Rect(spriteAPI.pointee.getCollideRect.unsafelyUnwrapped(pointer)) }
set { spriteAPI.pointee.setCollideRect.unsafelyUnwrapped(pointer, newValue.cValue) } set { spriteAPI.pointee.setCollideRect.unsafelyUnwrapped(pointer, newValue.cValue) }
@@ -365,8 +349,7 @@ public final class Sprite {
spriteAPI.pointee.clearCollideRect.unsafelyUnwrapped(pointer) spriteAPI.pointee.clearCollideRect.unsafelyUnwrapped(pointer)
} }
/// Sets the function deciding how this sprite responds when it /// Chooses this sprite's response when colliding with `other`; `nil` removes it.
/// collides with `other`.
public func setCollisionResponseFunction(_ filter: ((Sprite, _ other: Sprite) -> CollisionResponse)?) { public func setCollisionResponseFunction(_ filter: ((Sprite, _ other: Sprite) -> CollisionResponse)?) {
collisionResponseFunction = filter collisionResponseFunction = filter
if filter != nil { if filter != nil {
@@ -381,7 +364,7 @@ public final class Sprite {
} }
} }
/// Visits and frees a C collision info array. /// Visits each entry of a C collision array, then frees it (C transfers ownership).
private static func visitCollisions(_ pointer: UnsafeMutablePointer<SpriteCollisionInfo>?, private static func visitCollisions(_ pointer: UnsafeMutablePointer<SpriteCollisionInfo>?,
count: Int32, _ visit: (CollisionInfo) -> Void) { count: Int32, _ visit: (CollisionInfo) -> Void) {
guard let pointer else { return } guard let pointer else { return }
@@ -400,8 +383,7 @@ public final class Sprite {
return infos return infos
} }
/// Returns the collisions that would occur if the sprite moved toward /// Where a move toward the goal would end and what it would hit, without moving.
/// (goalX, goalY), without moving it.
public func checkCollisions(goalX: Float, goalY: Float) public func checkCollisions(goalX: Float, goalY: Float)
-> (actual: (x: Float, y: Float), collisions: [CollisionInfo]) { -> (actual: (x: Float, y: Float), collisions: [CollisionInfo]) {
var actualX: Float = 0, actualY: Float = 0, count: Int32 = 0 var actualX: Float = 0, actualY: Float = 0, count: Int32 = 0
@@ -410,8 +392,7 @@ public final class Sprite {
return ((actualX, actualY), Sprite.collisionInfos(result, count: count)) return ((actualX, actualY), Sprite.collisionInfos(result, count: count))
} }
/// Like `checkCollisions(goalX:goalY:)`, but visits each collision /// Like `checkCollisions(goalX:goalY:)`, but visits each collision without allocating.
/// instead of building an array, avoiding per-call allocations.
public func checkCollisions(goalX: Float, goalY: Float, public func checkCollisions(goalX: Float, goalY: Float,
_ visit: (CollisionInfo) -> Void) -> (x: Float, y: Float) { _ visit: (CollisionInfo) -> Void) -> (x: Float, y: Float) {
var actualX: Float = 0, actualY: Float = 0, count: Int32 = 0 var actualX: Float = 0, actualY: Float = 0, count: Int32 = 0
@@ -421,8 +402,8 @@ public final class Sprite {
return (actualX, actualY) return (actualX, actualY)
} }
/// Moves the sprite toward (goalX, goalY), resolving collisions, and /// Moves toward the goal, resolving collisions. Returns the final position (the goal if
/// returns where it ended up and what it hit. /// nothing was hit) and the collisions.
@discardableResult @discardableResult
public func moveWithCollisions(goalX: Float, goalY: Float) public func moveWithCollisions(goalX: Float, goalY: Float)
-> (actual: (x: Float, y: Float), collisions: [CollisionInfo]) { -> (actual: (x: Float, y: Float), collisions: [CollisionInfo]) {
@@ -432,8 +413,7 @@ public final class Sprite {
return ((actualX, actualY), Sprite.collisionInfos(result, count: count)) return ((actualX, actualY), Sprite.collisionInfos(result, count: count))
} }
/// Like `moveWithCollisions(goalX:goalY:)`, but visits each collision /// Like `moveWithCollisions(goalX:goalY:)`, but visits collisions without allocating.
/// instead of building an array, avoiding per-call allocations.
@discardableResult @discardableResult
public func moveWithCollisions(goalX: Float, goalY: Float, public func moveWithCollisions(goalX: Float, goalY: Float,
_ visit: (CollisionInfo) -> Void) -> (x: Float, y: Float) { _ visit: (CollisionInfo) -> Void) -> (x: Float, y: Float) {
@@ -444,7 +424,7 @@ public final class Sprite {
return (actualX, actualY) return (actualX, actualY)
} }
/// Visits and frees a C sprite pointer array. /// Visits non-null entries of a C sprite array, then frees it (C transfers ownership).
private static func visitSprites(_ pointer: UnsafeMutablePointer<OpaquePointer?>?, private static func visitSprites(_ pointer: UnsafeMutablePointer<OpaquePointer?>?,
count: Int32, _ visit: (Sprite) -> Void) { count: Int32, _ visit: (Sprite) -> Void) {
guard let pointer else { return } guard let pointer else { return }
@@ -465,30 +445,28 @@ public final class Sprite {
return sprites return sprites
} }
/// Sprites with collision rects containing the point. /// Sprites whose collide rects contain (`x`, `y`).
public static func query(atPoint x: Float, _ y: Float) -> [Sprite] { public static func query(atPoint x: Float, _ y: Float) -> [Sprite] {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.querySpritesAtPoint.unsafelyUnwrapped(x, y, &count) let result = spriteAPI.pointee.querySpritesAtPoint.unsafelyUnwrapped(x, y, &count)
return sprites(result, count: count) return sprites(result, count: count)
} }
/// Like `query(atPoint:_:)`, visiting each sprite without building an /// Like `query(atPoint:_:)`, but visits each sprite without allocating.
/// array.
public static func query(atPoint x: Float, _ y: Float, _ visit: (Sprite) -> Void) { public static func query(atPoint x: Float, _ y: Float, _ visit: (Sprite) -> Void) {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.querySpritesAtPoint.unsafelyUnwrapped(x, y, &count) let result = spriteAPI.pointee.querySpritesAtPoint.unsafelyUnwrapped(x, y, &count)
visitSprites(result, count: count, visit) visitSprites(result, count: count, visit)
} }
/// Sprites with collision rects intersecting the rect. /// Sprites whose collide rects intersect the `width` × `height` rect at (`x`, `y`).
public static func query(inRect x: Float, _ y: Float, width: Float, height: Float) -> [Sprite] { public static func query(inRect x: Float, _ y: Float, width: Float, height: Float) -> [Sprite] {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.querySpritesInRect.unsafelyUnwrapped(x, y, width, height, &count) let result = spriteAPI.pointee.querySpritesInRect.unsafelyUnwrapped(x, y, width, height, &count)
return sprites(result, count: count) return sprites(result, count: count)
} }
/// Like `query(inRect:_:width:height:)`, visiting each sprite without /// Like `query(inRect:_:width:height:)`, but visits each sprite without allocating.
/// building an array.
public static func query(inRect x: Float, _ y: Float, width: Float, height: Float, public static func query(inRect x: Float, _ y: Float, width: Float, height: Float,
_ visit: (Sprite) -> Void) { _ visit: (Sprite) -> Void) {
var count: Int32 = 0 var count: Int32 = 0
@@ -496,15 +474,14 @@ public final class Sprite {
visitSprites(result, count: count, visit) visitSprites(result, count: count, visit)
} }
/// Sprites with collision rects intersecting the line segment. /// Sprites whose collide rects intersect the segment (`x1`, `y1`)(`x2`, `y2`).
public static func query(alongLine x1: Float, _ y1: Float, _ x2: Float, _ y2: Float) -> [Sprite] { public static func query(alongLine x1: Float, _ y1: Float, _ x2: Float, _ y2: Float) -> [Sprite] {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.querySpritesAlongLine.unsafelyUnwrapped(x1, y1, x2, y2, &count) let result = spriteAPI.pointee.querySpritesAlongLine.unsafelyUnwrapped(x1, y1, x2, y2, &count)
return sprites(result, count: count) return sprites(result, count: count)
} }
/// Like `query(alongLine:_:_:_:)`, visiting each sprite without building /// Like `query(alongLine:_:_:_:)`, but visits each sprite without allocating.
/// an array.
public static func query(alongLine x1: Float, _ y1: Float, _ x2: Float, _ y2: Float, public static func query(alongLine x1: Float, _ y1: Float, _ x2: Float, _ y2: Float,
_ visit: (Sprite) -> Void) { _ visit: (Sprite) -> Void) {
var count: Int32 = 0 var count: Int32 = 0
@@ -512,7 +489,7 @@ public final class Sprite {
visitSprites(result, count: count, visit) visitSprites(result, count: count, visit)
} }
/// Like `query(alongLine:)`, with entry/exit information for each sprite. /// Like `query(alongLine:_:_:_:)`, plus entry/exit details. Slower; use only if needed.
public static func queryInfo(alongLine x1: Float, _ y1: Float, public static func queryInfo(alongLine x1: Float, _ y1: Float,
_ x2: Float, _ y2: Float) -> [QueryInfo] { _ x2: Float, _ y2: Float) -> [QueryInfo] {
var count: Int32 = 0 var count: Int32 = 0
@@ -527,30 +504,28 @@ public final class Sprite {
return infos return infos
} }
/// Sprites whose collision rects overlap this sprite's. /// Sprites whose collide rects overlap this sprite's.
public var overlappingSprites: [Sprite] { public var overlappingSprites: [Sprite] {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.overlappingSprites.unsafelyUnwrapped(pointer, &count) let result = spriteAPI.pointee.overlappingSprites.unsafelyUnwrapped(pointer, &count)
return Sprite.sprites(result, count: count) return Sprite.sprites(result, count: count)
} }
/// Like `overlappingSprites`, visiting each sprite without building an /// Like `overlappingSprites`, but visits each sprite without allocating.
/// array.
public func overlappingSprites(_ visit: (Sprite) -> Void) { public func overlappingSprites(_ visit: (Sprite) -> Void) {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.overlappingSprites.unsafelyUnwrapped(pointer, &count) let result = spriteAPI.pointee.overlappingSprites.unsafelyUnwrapped(pointer, &count)
Sprite.visitSprites(result, count: count, visit) Sprite.visitSprites(result, count: count, visit)
} }
/// All sprites in the display list that overlap another sprite. /// All overlapping sprites as consecutive pairs: [0] and [1] overlap, [2] and [3], etc.
public static var allOverlappingSprites: [Sprite] { public static var allOverlappingSprites: [Sprite] {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.allOverlappingSprites.unsafelyUnwrapped(&count) let result = spriteAPI.pointee.allOverlappingSprites.unsafelyUnwrapped(&count)
return sprites(result, count: count) return sprites(result, count: count)
} }
/// Like `allOverlappingSprites`, visiting each sprite without building /// Like `allOverlappingSprites`, but visits sprites (same pair order) without allocating.
/// an array.
public static func allOverlappingSprites(_ visit: (Sprite) -> Void) { public static func allOverlappingSprites(_ visit: (Sprite) -> Void) {
var count: Int32 = 0 var count: Int32 = 0
let result = spriteAPI.pointee.allOverlappingSprites.unsafelyUnwrapped(&count) let result = spriteAPI.pointee.allOverlappingSprites.unsafelyUnwrapped(&count)
@@ -1,17 +1,17 @@
internal import CPlaydate internal import CPlaydate
extension Sprite { extension Sprite {
/// How a sprite reacts when a collision occurs. /// How a moving sprite reacts to a collision. Wraps `SpriteCollisionResponseType`.
public enum CollisionResponse: UInt32, Sendable { public enum CollisionResponse: UInt32, Sendable {
/// The sprite slides along the edge of the other sprite. /// Slides along the other sprite.
case slide = 0 case slide = 0
/// The sprite stops at the point of collision. /// Stops at the point of collision.
case freeze = 1 case freeze = 1
/// The sprite passes through, still reporting the collision. /// Passes through; the collision is still reported.
case overlap = 2 case overlap = 2
/// The sprite bounces off the other sprite.
case bounce = 3 case bounce = 3
/// Unknown C values map to `.freeze`.
init(_ response: SpriteCollisionResponseType) { init(_ response: SpriteCollisionResponseType) {
self = CollisionResponse(rawValue: UInt32(response.rawValue)) ?? .freeze self = CollisionResponse(rawValue: UInt32(response.rawValue)) ?? .freeze
} }
@@ -1,28 +1,25 @@
internal import CPlaydate internal import CPlaydate
extension Sprite { extension Sprite {
/// Information about a single collision, mirroring `SpriteCollisionInfo`. /// A single collision. Wraps `SpriteCollisionInfo`.
public struct CollisionInfo { public struct CollisionInfo {
/// The sprite being moved. /// The sprite being moved.
public let sprite: Sprite public let sprite: Sprite
/// The sprite it collided with.
public let other: Sprite public let other: Sprite
/// The collision response used.
public let response: CollisionResponse public let response: CollisionResponse
/// `true` if the sprites were overlapping when the collision /// `true` if already overlapping `other` at the start; `false` if it tunneled through.
/// started; `false` if the sprite tunneled through.
public let overlaps: Bool public let overlaps: Bool
/// How far along the movement (0...1) the collision occurred. /// Fraction of the move to the goal done at the collision, 0...1.
public let ti: Float public let ti: Float
/// The difference between the requested and actual positions. /// Difference between the original and actual positions at the collision.
public let move: (x: Float, y: Float) public let move: (x: Float, y: Float)
/// The collision normal (each component -1, 0, or 1). /// Components usually -1, 0, or 1.
public let normal: (x: Int, y: Int) public let normal: (x: Int, y: Int)
/// Where the sprite started touching `other`. /// Where `sprite` started touching `other`.
public let touch: (x: Float, y: Float) public let touch: (x: Float, y: Float)
/// The sprite's rect at the moment of the touch. /// `sprite`'s rect at the touch.
public let spriteRect: Rect public let spriteRect: Rect
/// `other`'s rect at the moment of the touch. /// `other`'s rect at the touch.
public let otherRect: Rect public let otherRect: Rect
init(_ info: SpriteCollisionInfo) { init(_ info: SpriteCollisionInfo) {
@@ -1,13 +1,12 @@
internal import CPlaydate internal import CPlaydate
extension Sprite { extension Sprite {
/// Information about a sprite intersected by a line segment, /// A sprite intersected by a line segment. Wraps `SpriteQueryInfo`.
/// mirroring `SpriteQueryInfo`.
public struct QueryInfo { public struct QueryInfo {
public let sprite: Sprite public let sprite: Sprite
/// How far along the segment (0...1) the segment enters the sprite. /// Entry point's position along the segment, 0...1.
public let ti1: Float public let ti1: Float
/// How far along the segment (0...1) the segment exits the sprite. /// Exit point's position along the segment, 0...1.
public let ti2: Float public let ti2: Float
public let entryPoint: (x: Float, y: Float) public let entryPoint: (x: Float, y: Float)
public let exitPoint: (x: Float, y: Float) public let exitPoint: (x: Float, y: Float)
@@ -1,13 +1,14 @@
internal import CPlaydate internal import CPlaydate
/// A floating-point rectangle mirroring `PDRect`. /// A floating-point rectangle, in pixels. Wraps `PDRect`.
public struct Rect: Sendable { public struct Rect: Sendable {
/// Left edge.
public var x: Float public var x: Float
/// Top edge.
public var y: Float public var y: Float
public var width: Float public var width: Float
public var height: Float public var height: Float
/// Creates a rect from an origin and size.
public init(x: Float, y: Float, width: Float, height: Float) { public init(x: Float, y: Float, width: Float, height: Float) {
self.x = x self.x = x
self.y = y self.y = y
+13 -63
View File
@@ -1,79 +1,29 @@
internal import CPlaydate internal import CPlaydate
/// Internal C-string helpers shared by the wrappers. /// C-string helpers. Strings passed to C use `withCString`, which doesn't copy.
///
/// The conversions are implemented manually (rather than with
/// `String(cString:)` / `withCString`) so the module stays within the
/// Embedded Swift subset used for device builds.
extension String { extension String {
/// Creates a string by copying a null-terminated UTF-8 C string. /// `nil` if `pointer` is null.
init(playdateCString pointer: UnsafePointer<CChar>) {
var count = 0
while pointer[count] != 0 { count += 1 }
let bytes = UnsafeRawBufferPointer(start: pointer, count: count)
self = String(decoding: bytes, as: UTF8.self)
}
/// Creates a string from a nullable C string, or `nil` if the pointer is null.
init?(playdateCString pointer: UnsafePointer<CChar>?) { init?(playdateCString pointer: UnsafePointer<CChar>?) {
guard let pointer else { return nil } guard let pointer else { return nil }
self.init(playdateCString: pointer) self.init(cString: pointer)
} }
/// Calls `body` with a temporary null-terminated UTF-8 copy of the /// New null-terminated copy; the caller frees it with `deallocate()`.
/// string. The copy lives on the stack for short strings, so calling
/// this in the update loop does not churn the heap.
func withPlaydateCString<Result>(_ body: (UnsafePointer<CChar>) -> Result) -> Result {
let count = utf8.count
return withUnsafeTemporaryAllocation(of: CChar.self, capacity: count + 1) { buffer in
var index = 0
for byte in utf8 {
buffer[index] = CChar(bitPattern: byte)
index += 1
}
buffer[count] = 0
return body(buffer.baseAddress.unsafelyUnwrapped)
}
}
/// Calls `body` with a temporary buffer of the string's UTF-8 bytes (not
/// null-terminated) and its length, for the `(const void*, size_t)` text
/// APIs. Stack-allocated for short strings.
func withPlaydateUTF8<Result>(_ body: (UnsafeRawPointer, Int) -> Result) -> Result {
let count = utf8.count
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: count + 1) { buffer in
var index = 0
for byte in utf8 {
buffer[index] = byte
index += 1
}
return body(UnsafeRawPointer(buffer.baseAddress.unsafelyUnwrapped), count)
}
}
/// Copies the string into a newly allocated null-terminated C string.
/// The caller owns the memory and must free it with `deallocate()`.
func copiedPlaydateCString() -> UnsafeMutablePointer<CChar> { func copiedPlaydateCString() -> UnsafeMutablePointer<CChar> {
let count = utf8.count withCString { cString in
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: count + 1) let count = utf8.count + 1
var index = 0 let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: count)
for byte in utf8 { buffer.initialize(from: cString, count: count)
buffer[index] = CChar(bitPattern: byte)
index += 1
}
buffer[count] = 0
return buffer return buffer
} }
}
} }
#if hasFeature(Embedded) && !os(macOS) #if hasFeature(Embedded) && !os(macOS)
/// The Embedded Swift runtime allocates through `posix_memalign(3)`, which /// `posix_memalign(3)` for the Embedded Swift runtime; the device C library lacks it.
/// the Playdate device C library does not provide. Memory comes from /// Uses `malloc` (the firmware allocator). Freed with plain `free`, so no alignment offset:
/// `malloc`, which the SDK's setup code routes to the firmware allocator. /// the precondition checks `malloc`'s alignment suffices. Traps on failure; else returns 0.
/// The pointer is later released with plain `free`, so it cannot be offset @c(posix_memalign)
/// to adjust alignment; the firmware allocator's natural alignment has to
/// satisfy the request, which the precondition asserts.
@_cdecl("posix_memalign")
public func posix_memalign( public func posix_memalign(
_ memptr: UnsafeMutablePointer<UnsafeMutableRawPointer?>, _ memptr: UnsafeMutablePointer<UnsafeMutableRawPointer?>,
_ alignment: Int, _ alignment: Int,
@@ -1,15 +1,15 @@
internal import CPlaydate internal import CPlaydate
extension System { extension System {
/// An item added to the system menu. Keep no more than three items at once. /// A custom system menu item (at most three). Wraps `PDMenuItem`.
/// `System` keeps it alive until it is removed.
public final class MenuItem { public final class MenuItem {
let pointer: OpaquePointer let pointer: OpaquePointer
var onSelect: (MenuItem) -> Void var onSelect: (MenuItem) -> Void
/// Retains C strings passed to the OS for option titles. /// Option title C strings the OS points into; freed on removal.
private var retainedOptionTitles: [UnsafeMutablePointer<CChar>] = [] private var retainedOptionTitles: [UnsafeMutablePointer<CChar>] = []
/// Wraps the C menu item; fails (and frees the retained titles) if /// Fails, freeing `retainedOptionTitles`, if `pointer` is `nil`.
/// `pointer` is nil.
init?(pointer: OpaquePointer?, init?(pointer: OpaquePointer?,
retainedOptionTitles: [UnsafeMutablePointer<CChar>] = [], retainedOptionTitles: [UnsafeMutablePointer<CChar>] = [],
onSelect: @escaping (MenuItem) -> Void) { onSelect: @escaping (MenuItem) -> Void) {
@@ -22,26 +22,25 @@ extension System {
self.onSelect = onSelect self.onSelect = onSelect
} }
/// The menu item's title. /// The displayed title; empty if the OS returns none.
public var title: String { public var title: String {
get { get {
String(playdateCString: Playdate.systemAPI.pointee.getMenuItemTitle.unsafelyUnwrapped(pointer)) ?? "" String(playdateCString: Playdate.systemAPI.pointee.getMenuItemTitle.unsafelyUnwrapped(pointer)) ?? ""
} }
set { set {
newValue.withPlaydateCString { newValue.withCString {
Playdate.systemAPI.pointee.setMenuItemTitle.unsafelyUnwrapped(pointer, $0) Playdate.systemAPI.pointee.setMenuItemTitle.unsafelyUnwrapped(pointer, $0)
} }
} }
} }
/// For checkmark items this is 0 or 1; for option items it is the /// Checkmark items: 0 or 1 (checked). Options items: the selected index.
/// index of the selected option.
public var value: Int { public var value: Int {
get { Int(Playdate.systemAPI.pointee.getMenuItemValue.unsafelyUnwrapped(pointer)) } get { Int(Playdate.systemAPI.pointee.getMenuItemValue.unsafelyUnwrapped(pointer)) }
set { Playdate.systemAPI.pointee.setMenuItemValue.unsafelyUnwrapped(pointer, Int32(newValue)) } set { Playdate.systemAPI.pointee.setMenuItemValue.unsafelyUnwrapped(pointer, Int32(newValue)) }
} }
/// Convenience view of `value` for checkmark items. /// `value` as a `Bool`, for checkmark items.
public var isChecked: Bool { public var isChecked: Bool {
get { value != 0 } get { value != 0 }
set { value = newValue ? 1 : 0 } set { value = newValue ? 1 : 0 }
@@ -1,13 +1,14 @@
internal import CPlaydate internal import CPlaydate
extension System { extension System {
/// The system language. /// A system language. Wraps `PDLanguage`.
public enum Language: UInt32, Sendable { public enum Language: UInt32, Sendable {
case english = 0 case english = 0
case japanese = 1 case japanese = 1
/// Only meaningful as an argument to `localizedText(forKey:language:)`. /// The current system language; only meaningful for `localizedText(forKey:language:)`.
case system = 2 case system = 2
// Unknown C values fall back to English.
init(_ language: PDLanguage) { init(_ language: PDLanguage) {
self = Language(rawValue: UInt32(language.rawValue)) ?? .english self = Language(rawValue: UInt32(language.rawValue)) ?? .english
} }
@@ -1,7 +1,7 @@
internal import CPlaydate internal import CPlaydate
extension System { extension System {
/// The state of the d-pad and face buttons, as an option set. /// A set of d-pad and A/B buttons. Wraps `PDButtons`.
public struct Buttons: OptionSet, Sendable { public struct Buttons: OptionSet, Sendable {
public let rawValue: UInt32 public let rawValue: UInt32
public init(rawValue: UInt32) { self.rawValue = rawValue } public init(rawValue: UInt32) { self.rawValue = rawValue }

Some files were not shown because too many files have changed in this diff Show More