Tightened the source code and README documentations in the library.

This commit is contained in:
2026-09-18 12:45:39 +02:00
parent c9f887bacb
commit 9ae10590cc
97 changed files with 854 additions and 1142 deletions
+64 -84
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.4 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,7 +65,7 @@ targets: [
## Getting started ## Getting started
A Playdate application or game has a single C entry point, `eventHandler`. Export it with `@c`, 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
@@ -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),
@@ -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,95 +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`.
- **Properties vs. methods.** State the OS can report back is a property: read-write where the C API has a get/set pair (`Display.refreshRate`, `Source.volume`), get-only where it only has a getter (`Display.fps`). A `set…` method means the C API is write-only there (`Display.setScale`, `Synth.setAttackTime`) or setting takes extra arguments — a property getter never invents a value the OS can't return. Callbacks are installed with `set…Callback`/`set…Function` methods. - **Properties vs. methods.** Readable state is a property; `set…` methods are write-only or take extra arguments. Callbacks: `set…Callback` / `set…Function`.
- **Paths.** APIs that load a single file label the argument `path:` (`Bitmap(path:)`, `JSON.decodeFile(path:)`); directory operations use `at:` (`File.listFiles(at:)`). The POSIX-named `File.stat`, `File.mkdir`, and `File.unlink` take their path unlabeled, like their C namesakes. - **Paths.** `path:` for files, `at:` for directories; `File.stat`/`mkdir`/`unlink` are unlabeled like C.
- **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. - **Errors.** Typed throws: `PlaydateError`, or `Network.NetError` for network I/O.
- **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. - **Buffers.** `Span`/`MutableSpan`, valid only during the call. Mono audio gets an empty `right`.
- **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 - **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()`.
completions, `getServerTime`); those track one Swift closure at a time, as noted in their documentation. - **Callbacks.** C callbacks without a userdata slot (serial, headphones, scoreboards, `getServerTime`) keep one closure at a time.
- **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. - **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.
+9 -13
View File
@@ -1,50 +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()) }
/// The nominal refresh rate in frames per second. Set to 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 var refreshRate: Float {
get { api.pointee.getRefreshRate.unsafelyUnwrapped() } get { api.pointee.getRefreshRate.unsafelyUnwrapped() }
set { api.pointee.setRefreshRate.unsafelyUnwrapped(newValue) } set { api.pointee.setRefreshRate.unsafelyUnwrapped(newValue) }
} }
/// The measured average frames per second. /// Measured frames per second; can fall below `refreshRate` on slow frames.
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))
} }
+12 -17
View File
@@ -1,15 +1,12 @@
internal import CPlaydate internal import CPlaydate
extension File { extension File {
/// An open file. Wraps `SDFile`. The file is closed when the handle goes /// An open file. Wraps `SDFile`. Non-copyable: closes when the handle goes out of
/// out of scope, unless it was closed explicitly with `close()`. /// scope, or earlier via consuming `close()`. At most 64 files may be open.
///
/// The handle is non-copyable: it has a single owner, so it cannot be
/// used after `close()` and no heap allocation backs it.
public struct Handle: ~Copyable { public struct Handle: ~Copyable {
let pointer: UnsafeMutableRawPointer let pointer: UnsafeMutableRawPointer
/// 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.withCString { let pointer = path.withCString {
fileAPI.pointee.open.unsafelyUnwrapped($0, mode.cValue) fileAPI.pointee.open.unsafelyUnwrapped($0, mode.cValue)
@@ -23,8 +20,7 @@ extension File {
} }
/// Closes the file, consuming the handle. /// Closes the file, consuming the handle.
// `@export(interface)` lets `discard` compile in Embedded Swift with // `@export(interface)` lets `discard` compile in Embedded Swift on the 6.4 toolchain.
// the 6.4 release toolchain; later toolchains accept it without.
@export(interface) @export(interface)
public consuming func close() throws(PlaydateError) { public consuming func close() throws(PlaydateError) {
let pointer = self.pointer let pointer = self.pointer
@@ -32,8 +28,7 @@ extension File {
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: inout MutableSpan<UInt8>) throws(PlaydateError) -> Int {
let result = buffer.withUnsafeMutableBufferPointer { buffer in let result = buffer.withUnsafeMutableBufferPointer { buffer in
fileAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) fileAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
@@ -42,7 +37,7 @@ extension File {
return Int(result) return Int(result)
} }
/// Reads up to `length` bytes and returns them. /// Reads up to `length` bytes; shorter near end of file, empty at it.
public func read(length: Int) throws(PlaydateError) -> [UInt8] { public func read(length: Int) throws(PlaydateError) -> [UInt8] {
try [UInt8](capacity: length) { output throws(PlaydateError) in try [UInt8](capacity: length) { output throws(PlaydateError) in
let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in
@@ -55,7 +50,7 @@ extension File {
} }
} }
/// Writes the bytes to the file. Returns the number of bytes written. /// Writes `bytes`; returns the count written.
@discardableResult @discardableResult
public func write(_ bytes: Span<UInt8>) throws(PlaydateError) -> Int { public func write(_ bytes: Span<UInt8>) throws(PlaydateError) -> Int {
let result = bytes.withUnsafeBufferPointer { buffer in let result = bytes.withUnsafeBufferPointer { buffer in
@@ -65,7 +60,7 @@ extension File {
return Int(result) return Int(result)
} }
/// Writes the bytes to the file. Returns the number of bytes written. /// Writes `bytes`; returns the count written.
@discardableResult @discardableResult
public func write(_ bytes: [UInt8]) throws(PlaydateError) -> Int { public func write(_ bytes: [UInt8]) throws(PlaydateError) -> Int {
try bytes.withUnsafeBufferPointer { buffer throws(PlaydateError) in try bytes.withUnsafeBufferPointer { buffer throws(PlaydateError) in
@@ -73,7 +68,7 @@ extension File {
} }
} }
/// Writes the string's UTF-8 to the file. Returns the bytes written. /// 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.withCString { cString in let result = string.withCString { cString in
@@ -83,7 +78,7 @@ extension File {
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)
@@ -91,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
} }
} }
+11 -14
View File
@@ -1,24 +1,22 @@
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
@@ -36,7 +34,7 @@ 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.withCString { fileAPI.pointee.stat.unsafelyUnwrapped($0, &stat) } let result = path.withCString { fileAPI.pointee.stat.unsafelyUnwrapped($0, &stat) }
@@ -49,14 +47,13 @@ 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.withCString { 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.withCString { let result = path.withCString {
fileAPI.pointee.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0) fileAPI.pointee.unlink.unsafelyUnwrapped($0, recursive ? 1 : 0)
@@ -64,8 +61,8 @@ extension File {
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.withCString { cFrom in let result = from.withCString { cFrom in
to.withCString { cTo in to.withCString { cTo in
@@ -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,13 +1,11 @@
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, masks, 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
init(pointer: OpaquePointer, isOwned: Bool) { init(pointer: OpaquePointer, isOwned: Bool) {
@@ -15,7 +13,6 @@ extension Graphics {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// 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,7 +20,7 @@ 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.withCString { gfx.pointee.loadBitmap.unsafelyUnwrapped($0, &error) } let pointer = path.withCString { gfx.pointee.loadBitmap.unsafelyUnwrapped($0, &error) }
@@ -37,17 +34,16 @@ extension Graphics {
} }
} }
// MARK: Properties // MARK: Size and pixel data
/// The bitmap's dimensions and row stride.
public var data: Data { public var data: Data {
let raw = rawData() let raw = rawData()
return Data(width: raw.width, height: raw.height, rowBytes: raw.rowBytes, return Data(width: raw.width, height: raw.height, rowBytes: raw.rowBytes,
hasMask: raw.mask != nil) hasMask: raw.mask != nil)
} }
/// Calls `body` with the bitmap's pixel data: one bit per pixel, /// 1 bit per pixel, MSB first, `height` rows of `rowBytes` bytes. The span is valid
/// `height` rows of `rowBytes` bytes each. /// only inside `body`, and empty if the bitmap has no data.
public func withPixelData<Result, Failure: Error>( public func withPixelData<Result, Failure: Error>(
_ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result _ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result { ) throws(Failure) -> Result {
@@ -57,8 +53,8 @@ extension Graphics {
return try body(&span) return try body(&span)
} }
/// Calls `body` with the bitmap's mask data, laid out like the pixel /// Laid out like the pixel data; valid only inside `body`. Returns `nil` without
/// data. Returns `nil` if the bitmap has no mask. /// calling `body` if the bitmap has no mask.
public func withMaskData<Result, Failure: Error>( public func withMaskData<Result, Failure: Error>(
_ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result _ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result? { ) throws(Failure) -> Result? {
@@ -77,9 +73,7 @@ extension Graphics {
return (Int(width), Int(height), Int(rowBytes), mask, data) return (Int(width), Int(height), Int(rowBytes), mask, 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) {
@@ -90,19 +84,19 @@ extension Graphics {
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.withCString { gfx.pointee.loadIntoBitmap.unsafelyUnwrapped($0, pointer, &error) } path.withCString { gfx.pointee.loadIntoBitmap.unsafelyUnwrapped($0, pointer, &error) }
@@ -110,17 +104,15 @@ extension Graphics {
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(
@@ -128,21 +120,20 @@ 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: drawing into it edits the mask.
/// owned by this bitmap.
public var mask: Bitmap? { public var mask: Bitmap? {
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: false)
} }
/// 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,
@@ -155,19 +146,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) {
@@ -175,7 +165,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,13 +10,12 @@ 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.withCString { gfx.pointee.loadBitmapTable.unsafelyUnwrapped($0, &error) } let pointer = path.withCString { gfx.pointee.loadBitmapTable.unsafelyUnwrapped($0, &error) }
@@ -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.withCString { 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,13 @@ 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 }
} }
} }
@@ -61,8 +56,7 @@ extension Graphics.BitmapTable: RandomAccessCollection {
public var startIndex: Int { 0 } public var startIndex: Int { 0 }
public var endIndex: Int { count } public var endIndex: Int { count }
/// The bitmap at `position`. The bitmap references storage owned by the /// Traps if out of range.
/// table; keep the table alive while using it.
public subscript(position: Int) -> Graphics.Bitmap { public subscript(position: Int) -> Graphics.Bitmap {
guard let bitmap = bitmap(at: position) else { guard let bitmap = bitmap(at: position) else {
preconditionFailure("bitmap table index out of range") preconditionFailure("bitmap table index out of range")
+11 -14
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,7 +13,6 @@ 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.withCString { gfx.pointee.loadFont.unsafelyUnwrapped($0, &error) } let pointer = path.withCString { gfx.pointee.loadFont.unsafelyUnwrapped($0, &error) }
@@ -21,8 +20,8 @@ extension Graphics {
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: Span<UInt8>, 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)
data.withUnsafeBytes { bytes in data.withUnsafeBytes { bytes in
@@ -38,17 +37,17 @@ 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.withCString { cString in text.withCString { cString in
Int(gfx.pointee.getTextWidth.unsafelyUnwrapped(pointer, cString, text.utf8.count, Int(gfx.pointee.getTextWidth.unsafelyUnwrapped(pointer, cString, text.utf8.count,
@@ -56,7 +55,7 @@ extension Graphics {
} }
} }
/// 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.withCString { cString in text.withCString { cString in
@@ -66,15 +65,13 @@ extension Graphics {
} }
} }
/// 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,14 @@
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. A file /// The C player reads the source; non-copyable `File.Handle` needs its own slot.
/// handle is owned outright, as it cannot be shared.
private var retainedSource: AnyObject? private var retainedSource: AnyObject?
private var retainedFile: File.Handle? private var retainedFile: File.Handle?
@@ -21,36 +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. The stream takes ownership of the /// Takes ownership; the handle closes when replaced or on deinit.
/// handle and closes it when the source changes or the stream is freed.
public func setFile(_ file: consuming File.Handle) { public func setFile(_ file: consuming File.Handle) {
streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer) streamAPI.pointee.setFile.unsafelyUnwrapped(pointer, file.pointer)
retainedFile = consume file retainedFile = consume file
retainedSource = nil retainedSource = nil
} }
/// Streams from an HTTP connection.
public func setHTTPConnection(_ connection: Network.HTTPConnection) { public func setHTTPConnection(_ connection: Network.HTTPConnection) {
streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer) streamAPI.pointee.setHTTPConnection.unsafelyUnwrapped(pointer, connection.pointer)
retainedSource = connection retainedSource = connection
retainedFile = nil retainedFile = nil
} }
/// Streams from a TCP connection.
public func setTCPConnection(_ connection: Network.TCPConnection) { public func setTCPConnection(_ connection: Network.TCPConnection) {
streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer) streamAPI.pointee.setTCPConnection.unsafelyUnwrapped(pointer, connection.pointer)
retainedSource = connection retainedSource = connection
retainedFile = nil retainedFile = nil
} }
/// The player used for the stream's audio track. Owned by the stream. /// Borrowed. The same wrapper is returned while the underlying player is unchanged,
/// The same wrapper is returned on every access, so callbacks /// so callbacks registered on it persist.
/// registered on it stay valid for the stream's lifetime.
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 {
@@ -63,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,55 +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: Span<UInt16>, rowWidth: Int) { public func setTiles(_ indexes: Span<UInt16>, rowWidth: Int) {
indexes.withUnsafeBufferPointer { buffer in indexes.withUnsafeBufferPointer { buffer in
// The C API takes a non-const pointer but only reads the // Non-const in C, but only read (and copied).
// indexes, copying them into the tilemap.
tilemapAPI.pointee.setTiles.unsafelyUnwrapped( tilemapAPI.pointee.setTiles.unsafelyUnwrapped(
pointer, UnsafeMutablePointer(mutating: buffer.baseAddress), pointer, UnsafeMutablePointer(mutating: buffer.baseAddress),
Int32(buffer.count), Int32(rowWidth)) Int32(buffer.count), Int32(rowWidth))
} }
} }
/// 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: [UInt16], rowWidth: Int) {
indexes.withUnsafeBufferPointer { setTiles($0.span, rowWidth: rowWidth) } indexes.withUnsafeBufferPointer { setTiles($0.span, rowWidth: rowWidth) }
} }
/// Sets the tile index at position (x, y). /// `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,7 +17,6 @@ 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.withCString { videoAPI.pointee.loadVideo.unsafelyUnwrapped($0) } let pointer = path.withCString { videoAPI.pointee.loadVideo.unsafelyUnwrapped($0) }
guard let pointer else { guard let pointer else {
@@ -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
+47 -65
View File
@@ -3,102 +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 world coordinates (affected by the draw offset). /// In world coordinates (translated by the draw offset). Cleared each update.
public static func setClipRect(_ rect: Rect) { public static func setClipRect(_ rect: Rect) {
setClipRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height) setClipRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height)
} }
/// Sets the clip rect in screen coordinates (unaffected by the draw offset). /// 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))
} }
/// Sets the clip rect in screen coordinates (unaffected by the draw offset). /// In screen coordinates (ignoring the draw offset).
public static func setScreenClipRect(_ rect: Rect) { public static func setScreenClipRect(_ rect: Rect) {
setScreenClipRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height) setScreenClipRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height)
} }
/// Clears the current clip rect.
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),
@@ -106,32 +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)
} }
} }
/// Draws the outline of a rectangle, stroked inside its frame. /// Stroked inside its frame.
public static func drawRect(_ rect: Rect, color: Color) { public static func drawRect(_ rect: Rect, color: Color) {
drawRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, color: color) drawRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, color: color)
} }
/// Fills the rectangle with `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)
} }
} }
/// Fills the rectangle with `color`.
public static func fillRect(_ rect: Rect, color: Color) { public static func fillRect(_ rect: Rect, color: Color) {
fillRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, color: color) fillRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, color: color)
} }
/// Draws the outline of a rectangle with rounded corners, stroked with /// Stroked inside the rect. `radius` and `lineWidth` are in pixels.
/// `lineWidth`.
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 {
@@ -140,14 +130,13 @@ extension Graphics {
} }
} }
/// Draws the outline of a rectangle with rounded corners, stroked with /// Stroked inside the rect. `radius` and `lineWidth` are in pixels.
/// `lineWidth`.
public static func drawRoundRect(_ rect: Rect, radius: Int, lineWidth: Int, color: Color) { 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, drawRoundRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
radius: radius, lineWidth: lineWidth, color: color) radius: radius, lineWidth: lineWidth, color: color)
} }
/// Fills a rectangle with rounded corners. /// `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),
@@ -155,14 +144,14 @@ extension Graphics {
} }
} }
/// Fills a rectangle with rounded corners. /// `radius` is in pixels.
public static func fillRoundRect(_ rect: Rect, radius: Int, color: Color) { public static func fillRoundRect(_ rect: Rect, radius: Int, color: Color) {
fillRoundRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height, fillRoundRect(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
radius: radius, color: color) radius: radius, color: color)
} }
/// Draws an ellipse stroked inside the rect. If the angles differ, draws /// Stroked inside the rect. Differing angles draw only that arc (degrees clockwise
/// an arc from `startAngle` to `endAngle` (clockwise degrees, 0 at top). /// 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 {
@@ -171,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 {
@@ -181,24 +169,22 @@ extension Graphics {
} }
} }
/// Draws an ellipse stroked inside the rect. If the angles differ, draws /// Stroked inside the rect. Differing angles draw only that arc (degrees clockwise
/// an arc from `startAngle` to `endAngle` (clockwise degrees, 0 at top). /// from the top).
public static func drawEllipse(in rect: Rect, lineWidth: Int, public static func drawEllipse(in rect: Rect, lineWidth: Int,
startAngle: Float = 0, endAngle: Float = 0, color: Color) { startAngle: Float = 0, endAngle: Float = 0, color: Color) {
drawEllipse(x: rect.left, y: rect.top, width: rect.width, height: rect.height, drawEllipse(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
lineWidth: lineWidth, startAngle: startAngle, endAngle: endAngle, color: color) lineWidth: lineWidth, startAngle: startAngle, endAngle: endAngle, color: color)
} }
/// 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(in rect: Rect, public static func fillEllipse(in rect: Rect,
startAngle: Float = 0, endAngle: Float = 0, color: Color) { startAngle: Float = 0, endAngle: Float = 0, color: Color) {
fillEllipse(x: rect.left, y: rect.top, width: rect.width, height: rect.height, fillEllipse(x: rect.left, y: rect.top, width: rect.width, height: rect.height,
startAngle: startAngle, endAngle: endAngle, color: color) startAngle: startAngle, endAngle: endAngle, color: color)
} }
/// Fills the polygon described by the points, connecting the last point /// The last point connects back to the first.
/// 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
@@ -215,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))
@@ -235,7 +221,7 @@ 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.withCString { cString in text.withCString { cString in
@@ -244,7 +230,7 @@ extension Graphics {
} }
} }
/// 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.withCString { cString in text.withCString { cString in
@@ -254,34 +240,34 @@ extension Graphics {
} }
} }
/// 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, in rect: Rect, public static func drawText(_ text: String, in rect: Rect,
wrap: TextWrappingMode = .word, align: TextAlignment = .left) { wrap: TextWrappingMode = .word, align: TextAlignment = .left) {
drawText(text, x: rect.left, y: rect.top, width: rect.width, height: rect.height, drawText(text, x: rect.left, y: rect.top, width: rect.width, height: rect.height,
wrap: wrap, align: align) wrap: wrap, align: align)
} }
/// Sets the font used by subsequent text drawing. /// 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 var textTracking: Int { public static var textTracking: Int {
get { Int(gfx.pointee.getTextTracking.unsafelyUnwrapped()) } get { Int(gfx.pointee.getTextTracking.unsafelyUnwrapped()) }
set { gfx.pointee.setTextTracking.unsafelyUnwrapped(Int32(newValue)) } 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
/// Calls `body` with the current working framebuffer: `rows` rows of /// The working framebuffer: `rows` rows of `rowSize` bytes, 1 bit per pixel, MSB first,
/// `rowSize` bytes each. Returns `nil` if there is no framebuffer. /// last 2 bytes of each row unused. The span is valid only inside `body`; `nil` if
/// Call `markUpdatedRows(from:to:)` after writing directly. /// there is no framebuffer. Call `markUpdatedRows(from:to:)` after writing.
public static func withFrame<Result, Failure: Error>( public static func withFrame<Result, Failure: Error>(
_ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result _ body: (inout MutableSpan<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result? { ) throws(Failure) -> Result? {
@@ -290,9 +276,8 @@ extension Graphics {
return try body(&span) return try body(&span)
} }
/// Calls `body` with the framebuffer currently shown on the display: /// The last frame shown, laid out like `withFrame(_:)`. The span is valid only inside
/// `rows` rows of `rowSize` bytes each. Returns `nil` if there is no /// `body`; `nil` if there is no framebuffer.
/// framebuffer.
public static func withDisplayFrame<Result, Failure: Error>( public static func withDisplayFrame<Result, Failure: Error>(
_ body: (Span<UInt8>) throws(Failure) -> Result _ body: (Span<UInt8>) throws(Failure) -> Result
) throws(Failure) -> Result? { ) throws(Failure) -> Result? {
@@ -300,33 +285,30 @@ extension Graphics {
return try body(UnsafeBufferPointer(start: frame, count: rows * rowSize).span) 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,14 +1,12 @@
extension Graphics.Bitmap { extension Graphics.Bitmap {
/// The bitmap's dimensions and row stride. Access the pixels themselves /// A bitmap's layout. Read pixels with `withPixelData(_:)` and `withMaskData(_:)`.
/// with `withPixelData(_:)` and `withMaskData(_:)`.
public struct Data: Sendable { public struct Data: Sendable {
/// The bitmap's width, in pixels. /// 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 (and mask) data, in bytes. /// Row stride of the pixel and mask data, in bytes.
public let rowBytes: Int public let rowBytes: Int
/// Whether the bitmap has a mask.
public let hasMask: Bool public let hasMask: Bool
} }
} }
@@ -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,13 +32,10 @@ extension Graphics {
top: Int32(top), bottom: Int32(bottom)) top: Int32(top), bottom: Int32(bottom))
} }
/// The rect's width.
public var width: Int { right - left } public var width: Int { right - left }
/// The rect's height.
public var height: Int { bottom - top } public var height: Int { bottom - top }
/// Returns the rect offset by (dx, dy).
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,18 +1,16 @@
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)
@@ -20,24 +18,20 @@ extension Graphics {
} }
} }
// InlineArray needs macOS 26 on the host, so these conveniences are gated // InlineArray needs macOS 26 on the host; device and Linux are unrestricted.
// there while the tuple API keeps working on older systems. The device and // Conversions reinterpret the same 16 bytes.
// Linux have no such restriction. Both representations are 16 contiguous
// bytes, so converting between them is a reinterpretation, not a copy.
@available(macOS 26, *) @available(macOS 26, *)
extension Graphics.Pattern { extension Graphics.Pattern {
/// Creates a pattern from 8 rows of image data and 8 rows of mask.
public init(bytes: [16 of UInt8]) { public init(bytes: [16 of UInt8]) {
self.init(bytes: unsafeBitCast(bytes, to: Bytes.self)) self.init(bytes: unsafeBitCast(bytes, to: Bytes.self))
} }
/// Creates an opaque pattern from 8 rows of image data. /// An opaque pattern (mask rows all `0xff`).
public init(rows: [8 of UInt8]) { public init(rows: [8 of UInt8]) {
self.init(bytes: [16 of UInt8] { $0 < 8 ? rows[$0] : 0xff }) self.init(bytes: [16 of UInt8] { $0 < 8 ? rows[$0] : 0xff })
} }
/// The pattern's bytes as an inline array: 8 rows of image data /// `bytes` as an inline array.
/// followed by 8 rows of mask.
public var inlineBytes: [16 of UInt8] { public var inlineBytes: [16 of UInt8] {
get { unsafeBitCast(bytes, to: [16 of UInt8].self) } get { unsafeBitCast(bytes, to: [16 of UInt8].self) }
set { bytes = unsafeBitCast(newValue, to: Bytes.self) } set { bytes = unsafeBitCast(newValue, to: Bytes.self) }
+7 -11
View File
@@ -1,8 +1,10 @@
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 bytes: [UInt8] = [] var bytes: [UInt8] = []
} }
@@ -10,6 +12,7 @@ extension JSON {
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 }
@@ -21,7 +24,6 @@ extension JSON {
/// The JSON produced so far. /// The JSON produced so far.
public var json: String { String(decoding: output.bytes, as: UTF8.self) } 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) }
} }
@@ -31,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) }
} }
@@ -41,7 +42,7 @@ 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.withCString { cString in name.withCString { cString in
withUnsafeMutablePointer(to: &encoder) { withUnsafeMutablePointer(to: &encoder) {
@@ -51,34 +52,29 @@ extension JSON {
} }
} }
/// 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.withCString { cString in value.withCString { cString in
withUnsafeMutablePointer(to: &encoder) { withUnsafeMutablePointer(to: &encoder) {
@@ -88,7 +84,7 @@ extension JSON {
} }
} }
/// 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])
} }
} }
+16 -24
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,14 +86,13 @@ 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)
@@ -109,21 +104,20 @@ extension JSON {
} }
} }
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
/// error message on failure.
public static func decode(file: borrowing File.Handle) throws(PlaydateError) -> Value { 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()
// The handle is borrowed for the whole call, so its `SDFile` stays // Borrowing keeps the `SDFile` open for the whole decode.
// open while the decoder reads through it.
reader.userdata = file.pointer reader.userdata = file.pointer
reader.read = { userdata, buffer, size in reader.read = { userdata, buffer, size in
guard let userdata, let buffer else { return -1 } guard let userdata, let buffer else { return -1 }
@@ -135,29 +129,27 @@ extension JSON {
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(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 }
+27 -48
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 internal 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,8 +21,8 @@ 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.withCString { let ok = name.withCString {
@@ -36,15 +31,13 @@ extension Lua {
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)
} }
@@ -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,13 +138,11 @@ 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.withCString { cType in
luaAPI.pointee.getArgObject.unsafelyUnwrapped( luaAPI.pointee.getArgObject.unsafelyUnwrapped(
Int32(position), UnsafeMutablePointer(mutating: cType), &userdataObject) Int32(position), UnsafeMutablePointer(mutating: cType), &userdataObject)
@@ -160,14 +150,12 @@ extension Lua {
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.withCString { 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,23 +191,20 @@ 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.withCString { cType in
luaAPI.pointee.pushObject.unsafelyUnwrapped( luaAPI.pointee.pushObject.unsafelyUnwrapped(
object, UnsafeMutablePointer(mutating: cType), Int32(valueCount)) object, UnsafeMutablePointer(mutating: cType), Int32(valueCount))
@@ -236,8 +215,8 @@ 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.withCString { let ok = name.withCString {
@@ -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,8 +28,7 @@ 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.withCString { let pointer = server.withCString {
httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
@@ -54,35 +51,35 @@ 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.withCString { cPath in let error = path.withCString { cPath in
headers.withCString { cHeaders in headers.withCString { cHeaders in
@@ -92,7 +89,7 @@ extension Network {
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.withCString { cPath in let error = path.withCString { cPath in
headers.withCString { cHeaders in headers.withCString { cHeaders in
@@ -107,7 +104,7 @@ 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.withCString { cMethod in let error = method.withCString { cMethod in
@@ -127,31 +124,30 @@ 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: inout MutableSpan<UInt8>) throws(NetError) -> Int { public func read(into buffer: inout MutableSpan<UInt8>) throws(NetError) -> Int {
let result = buffer.withUnsafeMutableBufferPointer { buffer in let result = buffer.withUnsafeMutableBufferPointer { buffer in
httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count)) httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
@@ -162,7 +158,7 @@ extension Network {
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] {
try [UInt8](capacity: length) { output throws(NetError) in try [UInt8](capacity: length) { output throws(NetError) in
let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in
@@ -177,14 +173,14 @@ extension Network {
} }
} }
/// 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 {
@@ -199,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 {
@@ -212,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 {
@@ -225,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 {
@@ -238,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,8 +25,7 @@ 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.withCString { let pointer = server.withCString {
tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL) tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
@@ -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,28 +86,27 @@ 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: inout MutableSpan<UInt8>) throws(NetError) -> Int {
let result = buffer.withUnsafeMutableBufferPointer { buffer in let result = buffer.withUnsafeMutableBufferPointer { buffer in
tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count) tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
@@ -122,7 +117,7 @@ extension Network {
return Int(result) return Int(result)
} }
/// Reads up to `length` bytes, waiting up to the read timeout. /// Like `read(into:)`, returning the bytes read.
public func read(length: Int) throws(NetError) -> [UInt8] { public func read(length: Int) throws(NetError) -> [UInt8] {
try [UInt8](capacity: length) { output throws(NetError) in try [UInt8](capacity: length) { output throws(NetError) in
let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in let result = output.withUnsafeMutableBufferPointer { buffer, initializedCount in
@@ -136,8 +131,7 @@ extension Network {
} }
} }
/// Writes the bytes to the connection. Returns the number of bytes /// Queues `bytes`; returns the count handed to the network stack.
/// accepted.
@discardableResult @discardableResult
public func write(_ bytes: Span<UInt8>) throws(NetError) -> Int { public func write(_ bytes: Span<UInt8>) throws(NetError) -> Int {
let result = bytes.withUnsafeBufferPointer { buffer in let result = bytes.withUnsafeBufferPointer { buffer in
@@ -149,8 +143,7 @@ extension Network {
return Int(result) return Int(result)
} }
/// Writes the bytes to the connection. Returns the number of bytes /// Same as the `Span` overload.
/// accepted.
@discardableResult @discardableResult
public func write(_ bytes: [UInt8]) throws(NetError) -> Int { public func write(_ bytes: [UInt8]) throws(NetError) -> Int {
try bytes.withUnsafeBufferPointer { buffer throws(NetError) in try bytes.withUnsafeBufferPointer { buffer throws(NetError) in
@@ -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
} }
} }
+8 -8
View File
@@ -3,29 +3,28 @@ 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 /// `true` connects to the configured access point; `false` turns wifi off before
/// success. Completions of overlapping calls are delivered in call order. /// the 30 s idle timeout. `completion` (documented for `true` only) gets `nil`
/// on success; completions fire in call order.
public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) { public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) {
if let completion { if let completion {
setEnabledCompletions.append(completion) setEnabledCompletions.append(completion)
@@ -41,7 +40,8 @@ extension Network {
nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = [] nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = []
/// Requests permission to connect to `server`. Shared by HTTP and TCP. /// 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)?,
@@ -67,7 +67,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,10 +4,10 @@ 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
`@c`, 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
@@ -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,8 +14,7 @@ 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 {
@@ -31,7 +28,7 @@ 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 {
@@ -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,7 +59,7 @@ 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 {
@@ -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
} }
@@ -1,8 +1,7 @@
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. `right` is empty when the channel is mono. /// `right` is empty on mono channels; `bufferActive` is `false` if nothing was
/// `bufferActive` is `false` when the input buffer is silent. Returns /// written. Returns `true` if it changed the samples.
/// `true` if the effect produced output.
public typealias Processor = (_ left: inout MutableSpan<Int32>, public typealias Processor = (_ left: inout MutableSpan<Int32>,
_ right: inout MutableSpan<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
@@ -38,10 +36,7 @@ extension Sound {
} }
deinit { deinit {
// Subclasses free the C object in their own deinit with the // Subclasses free their C object themselves; freeing here would double-free.
// subsystem's type-specific free (freeDelayLine, freeOverdrive,
// ...); freeing here as well would double-free. The base class
// owns only the custom-processor effects it creates itself.
if let processorBox { if let processorBox {
if isOwned { if isOwned {
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer) effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
@@ -50,12 +45,11 @@ extension Sound {
} }
} }
/// 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>?,
+12 -19
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,9 +49,8 @@ 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 span contains mono 16-bit samples.
@discardableResult @discardableResult
public static func setMicCallback(source: MicSource = .autodetect, public static func setMicCallback(source: MicSource = .autodetect,
_ callback: ((Span<Int16>) -> Bool)?) -> Bool { _ callback: ((Span<Int16>) -> Bool)?) -> Bool {
@@ -68,10 +67,8 @@ extension Sound {
nonisolated(unsafe) private static var micCallback: ((Span<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 {
@@ -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 empty for mono sources. /// Returns `false` if the source was silent this cycle.
public typealias Callback = (_ left: inout MutableSpan<Int16>, public typealias Callback = (_ left: inout MutableSpan<Int16>,
_ right: inout MutableSpan<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)
@@ -28,10 +28,8 @@ extension Sound {
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,7 +45,6 @@ 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.withCString { let loaded = path.withCString {
AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0 AudioSample.api.pointee.loadIntoSample.unsafelyUnwrapped(pointer, $0) != 0
@@ -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 }
} }
@@ -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,7 +30,6 @@ 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.withCString { let loaded = path.withCString {
FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0 FilePlayer.api.pointee.loadIntoPlayer.unsafelyUnwrapped(pointer, $0) != 0
@@ -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,9 +122,8 @@ 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 (inout MutableSpan<UInt8>) -> Int) { _ dataSource: @escaping (inout MutableSpan<UInt8>) -> Int) {
mp3DataSource = dataSource mp3DataSource = dataSource
@@ -141,7 +135,7 @@ extension Sound {
}, 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,7 +16,7 @@ extension Sound {
self.isOwned = isOwned self.isOwned = isOwned
} }
/// The playback volume of the left and right channels, 0...1. /// Per-channel volume, 01.
public var volume: (left: Float, right: Float) { public var volume: (left: Float, right: Float) {
get { get {
var left: Float = 0, right: Float = 0 var left: Float = 0, right: Float = 0
@@ -26,7 +26,6 @@ extension Sound {
set { Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) } set { Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
} }
/// Sets the playback volume of both channels.
public func setVolume(_ volume: Float) { public func setVolume(_ volume: Float) {
self.volume = (volume, volume) self.volume = (volume, volume)
} }
@@ -35,7 +34,7 @@ extension Sound {
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,33 +66,32 @@ 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)
} }
/// The volume of the left and right channels, 0...1. /// Left and right volume, 0...1.
public var volume: (left: Float, right: Float) { public var volume: (left: Float, right: Float) {
get { get {
var left: Float = 0, right: Float = 0 var left: Float = 0, right: Float = 0
@@ -103,7 +101,6 @@ extension Sound {
set { Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.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,7 +14,6 @@ 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(path: String) throws(PlaydateError) {
self.init() self.init()
try loadMIDIFile(path: path) try loadMIDIFile(path: path)
@@ -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(
@@ -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,20 +1,17 @@
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; it is empty for mono ones). `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
/// number of frames rendered.
public var render: (_ left: inout MutableSpan<Int32>, public var render: (_ left: inout MutableSpan<Int32>,
_ right: inout MutableSpan<Int32>, _ right: inout MutableSpan<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: inout MutableSpan<Int32>, public init(render: @escaping (_ left: inout MutableSpan<Int32>,
+69 -105
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,11 +223,10 @@ public final class Sprite {
} }
} }
/// Sets an 8×8 stencil pattern (8 rows of image data). /// `InlineArray` overload of the tuple variant.
@available(macOS 26, *) @available(macOS 26, *)
public func setStencilPattern(_ rows: [8 of UInt8]) { public func setStencilPattern(_ rows: [8 of UInt8]) {
// The C side copies the pattern, so passing the array's storage // C copies the pattern, so passing the inline array's storage is safe.
// directly is safe.
rows.span.withUnsafeBufferPointer { buffer in rows.span.withUnsafeBufferPointer { buffer in
spriteAPI.pointee.setStencilPattern.unsafelyUnwrapped( spriteAPI.pointee.setStencilPattern.unsafelyUnwrapped(
pointer, UnsafeMutablePointer(mutating: buffer.baseAddress)) pointer, UnsafeMutablePointer(mutating: buffer.baseAddress))
@@ -260,7 +238,7 @@ public final class Sprite {
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)
} }
@@ -269,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 {
@@ -343,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 {
@@ -361,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) }
@@ -376,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 {
@@ -392,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 }
@@ -411,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
@@ -421,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
@@ -432,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]) {
@@ -443,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) {
@@ -455,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 }
@@ -476,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
@@ -507,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
@@ -523,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
@@ -538,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
+6 -13
View File
@@ -1,18 +1,14 @@
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.
///
/// Passing strings to C goes through the standard `withCString`, which hands
/// out a pointer to the string's own null-terminated storage without copying.
extension String { extension String {
/// Creates a string from a nullable C string, or `nil` if the pointer is null. /// `nil` if `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(cString: pointer) self.init(cString: pointer)
} }
/// Copies the string into a newly allocated null-terminated C string. /// New null-terminated copy; the caller frees it with `deallocate()`.
/// The caller owns the memory and must free it with `deallocate()`.
func copiedPlaydateCString() -> UnsafeMutablePointer<CChar> { func copiedPlaydateCString() -> UnsafeMutablePointer<CChar> {
withCString { cString in withCString { cString in
let count = utf8.count + 1 let count = utf8.count + 1
@@ -24,12 +20,9 @@ extension String {
} }
#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
/// to adjust alignment; the firmware allocator's natural alignment has to
/// satisfy the request, which the precondition asserts.
@c(posix_memalign) @c(posix_memalign)
public func posix_memalign( public func posix_memalign(
_ memptr: UnsafeMutablePointer<UnsafeMutableRawPointer?>, _ memptr: UnsafeMutablePointer<UnsafeMutableRawPointer?>,
@@ -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,7 +22,7 @@ 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)) ?? ""
@@ -34,14 +34,13 @@ extension System {
} }
} }
/// 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 }
@@ -1,18 +1,21 @@
internal import CPlaydate internal import CPlaydate
extension System { extension System {
/// A calendar date and time, mirroring `PDDateTime`. /// A calendar date and time. Mirrors `PDDateTime`.
public struct DateTime: Sendable { public struct DateTime: Sendable {
/// Full year, e.g. 2026.
public var year: UInt16 public var year: UInt16
/// 1...12 /// 1...12.
public var month: UInt8 public var month: UInt8
/// 1...31 /// 1...31.
public var day: UInt8 public var day: UInt8
/// 1 = Monday ... 7 = Sunday /// 1 (Monday)...7 (Sunday); 0 when unset.
public var weekday: UInt8 public var weekday: UInt8
/// 0...23 /// 0...23.
public var hour: UInt8 public var hour: UInt8
/// 0...59.
public var minute: UInt8 public var minute: UInt8
/// 0...59.
public var second: UInt8 public var second: UInt8
public init(year: UInt16, month: UInt8, day: UInt8, weekday: UInt8 = 0, public init(year: UInt16, month: UInt8, day: UInt8, weekday: UInt8 = 0,
@@ -1,11 +1,10 @@
extension System { extension System {
/// OS, language, and pdx version information, mirroring `PDInfo`. /// OS, language, and SDK version information. Mirrors `PDInfo`.
public struct Info: Sendable { public struct Info: Sendable {
/// The Playdate OS version. /// E.g. 20705 for 2.7.5.
public let osVersion: UInt32 public let osVersion: UInt32
/// The system language.
public let language: Language public let language: Language
/// The version of the game's pdx. /// The pdxinfo `pdxversion`: the SDK version the game was built with.
public let pdxVersion: UInt32 public let pdxVersion: UInt32
} }
} }
@@ -1,12 +1,13 @@
internal import CPlaydate internal import CPlaydate
extension System { extension System {
/// Peripherals that can be enabled with `setPeripheralsEnabled(_:)`. /// Peripherals for `setPeripheralsEnabled(_:)`. Wraps `PDPeripherals`.
public struct Peripherals: OptionSet, Sendable { public struct Peripherals: OptionSet, Sendable {
public let rawValue: UInt32 public let rawValue: UInt32
public init(rawValue: UInt32) { self.rawValue = rawValue } public init(rawValue: UInt32) { self.rawValue = rawValue }
public static let none = Peripherals([]) public static let none = Peripherals([])
/// Disabled by default.
public static let accelerometer = Peripherals(rawValue: UInt32(kAccelerometer.rawValue)) public static let accelerometer = Peripherals(rawValue: UInt32(kAccelerometer.rawValue))
public static let all = Peripherals(rawValue: UInt32(kAllPeripherals.rawValue)) public static let all = Peripherals(rawValue: UInt32(kAllPeripherals.rawValue))
} }
@@ -1,16 +1,14 @@
internal import CPlaydate internal import CPlaydate
extension System { extension System {
/// Battery and power supply state. /// Battery and power supply state. Wraps `PDPowerStatus`.
public struct PowerStatus: OptionSet, Sendable { public struct PowerStatus: OptionSet, Sendable {
public let rawValue: UInt32 public let rawValue: UInt32
public init(rawValue: UInt32) { self.rawValue = rawValue } public init(rawValue: UInt32) { self.rawValue = rawValue }
/// The battery is charging.
public static let charging = PowerStatus(rawValue: UInt32(kPDPowerStatusCharging.rawValue)) public static let charging = PowerStatus(rawValue: UInt32(kPDPowerStatusCharging.rawValue))
/// Power is supplied over USB.
public static let usb = PowerStatus(rawValue: UInt32(kPDPowerStatusUsb.rawValue)) public static let usb = PowerStatus(rawValue: UInt32(kPDPowerStatusUsb.rawValue))
/// Power is supplied through the accessory screw terminals. /// Powered through the corner screw pads.
public static let screws = PowerStatus(rawValue: UInt32(kPDPowerStatusScrews.rawValue)) public static let screws = PowerStatus(rawValue: UInt32(kPDPowerStatusScrews.rawValue))
} }
} }
+56 -59
View File
@@ -4,46 +4,44 @@ internal import CPlaydate
public enum System {} public enum System {}
extension System { extension System {
/// The cached `playdate->system` C API table. /// Cached `playdate->system` table.
private static var api: UnsafePointer<playdate_sys> { Playdate.systemAPI.unsafelyUnwrapped } private static var api: UnsafePointer<playdate_sys> { Playdate.systemAPI.unsafelyUnwrapped }
// MARK: - Memory // MARK: - Memory
/// The system allocator. Pass `nil` to allocate, `size` 0 to free. /// System allocator (`realloc` semantics): `nil` allocates; `size` 0 frees, returns `nil`.
@discardableResult @discardableResult
public static func realloc(_ pointer: UnsafeMutableRawPointer?, size: Int) -> UnsafeMutableRawPointer? { public static func realloc(_ pointer: UnsafeMutableRawPointer?, size: Int) -> UnsafeMutableRawPointer? {
api.pointee.realloc.unsafelyUnwrapped(pointer, size) api.pointee.realloc.unsafelyUnwrapped(pointer, size)
} }
/// Frees memory that the Playdate OS handed to the caller (e.g. strings /// Frees OS-allocated memory, e.g. `localizedText(forKey:language:)` strings.
/// returned by `localizedText(forKey:)`).
static func systemFree(_ pointer: UnsafeMutableRawPointer?) { static func systemFree(_ pointer: UnsafeMutableRawPointer?) {
_ = api.pointee.realloc.unsafelyUnwrapped(pointer, 0) _ = api.pointee.realloc.unsafelyUnwrapped(pointer, 0)
} }
// MARK: - Logging // MARK: - Logging
/// Logs a message to the console (device serial or simulator console). /// Logs to the device serial or simulator console.
public static func log(_ message: String) { public static func log(_ message: String) {
message.withCString { cplaydate_log(Playdate.apiPointer, $0) } message.withCString { cplaydate_log(Playdate.apiPointer, $0) }
} }
/// Stops execution and displays the message as a fatal error. /// Logs `message` as an error, then pauses execution.
public static func error(_ message: String) { public static func error(_ message: String) {
message.withCString { cplaydate_error(Playdate.apiPointer, $0) } message.withCString { cplaydate_error(Playdate.apiPointer, $0) }
} }
// MARK: - Time // MARK: - Language and time
/// The system language setting.
public static var language: Language { Language(api.pointee.getLanguage.unsafelyUnwrapped()) } public static var language: Language { Language(api.pointee.getLanguage.unsafelyUnwrapped()) }
/// Milliseconds since the game launched. Wraps around after about 49 days. /// Milliseconds since an arbitrary point; pauses while asleep; wraps after ~49 days.
public static var currentTimeMilliseconds: UInt32 { public static var currentTimeMilliseconds: UInt32 {
UInt32(api.pointee.getCurrentTimeMilliseconds.unsafelyUnwrapped()) UInt32(api.pointee.getCurrentTimeMilliseconds.unsafelyUnwrapped())
} }
/// Seconds (and sub-second milliseconds) since midnight 2000-01-01 UTC. /// Seconds, plus millisecond remainder, since 2000-01-01 00:00 UTC.
public static var secondsSinceEpoch: (seconds: UInt32, milliseconds: UInt32) { public static var secondsSinceEpoch: (seconds: UInt32, milliseconds: UInt32) {
var milliseconds: UInt32 = 0 var milliseconds: UInt32 = 0
let seconds = withUnsafeMutablePointer(to: &milliseconds) { let seconds = withUnsafeMutablePointer(to: &milliseconds) {
@@ -52,41 +50,39 @@ extension System {
return (UInt32(seconds), milliseconds) return (UInt32(seconds), milliseconds)
} }
/// High-resolution timer value, in seconds. /// Seconds since `resetElapsedTime()`, with microsecond accuracy.
public static var elapsedTime: Float { api.pointee.getElapsedTime.unsafelyUnwrapped() } public static var elapsedTime: Float { api.pointee.getElapsedTime.unsafelyUnwrapped() }
/// Resets the high-resolution timer to zero.
public static func resetElapsedTime() { api.pointee.resetElapsedTime.unsafelyUnwrapped() } public static func resetElapsedTime() { api.pointee.resetElapsedTime.unsafelyUnwrapped() }
/// Offset from UTC of the user-set timezone, in seconds. /// Offset from UTC, in seconds.
public static var timezoneOffset: Int32 { api.pointee.getTimezoneOffset.unsafelyUnwrapped() } public static var timezoneOffset: Int32 { api.pointee.getTimezoneOffset.unsafelyUnwrapped() }
/// Whether the user prefers 24-hour time display. /// The user's 24-hour time setting.
public static var shouldDisplay24HourTime: Bool { public static var shouldDisplay24HourTime: Bool {
api.pointee.shouldDisplay24HourTime.unsafelyUnwrapped() != 0 api.pointee.shouldDisplay24HourTime.unsafelyUnwrapped() != 0
} }
/// Converts seconds since the 2000-01-01 epoch to a calendar date. /// `epoch` is seconds since 2000-01-01.
public static func convertEpochToDateTime(_ epoch: UInt32) -> DateTime { public static func convertEpochToDateTime(_ epoch: UInt32) -> DateTime {
var dateTime = PDDateTime() var dateTime = PDDateTime()
api.pointee.convertEpochToDateTime.unsafelyUnwrapped(epoch, &dateTime) api.pointee.convertEpochToDateTime.unsafelyUnwrapped(epoch, &dateTime)
return DateTime(dateTime) return DateTime(dateTime)
} }
/// Converts a calendar date to seconds since the 2000-01-01 epoch. /// Returns seconds since 2000-01-01.
public static func convertDateTimeToEpoch(_ dateTime: DateTime) -> UInt32 { public static func convertDateTimeToEpoch(_ dateTime: DateTime) -> UInt32 {
var cValue = dateTime.cValue var cValue = dateTime.cValue
return api.pointee.convertDateTimeToEpoch.unsafelyUnwrapped(&cValue) return api.pointee.convertDateTimeToEpoch.unsafelyUnwrapped(&cValue)
} }
/// Blocks execution for the given number of milliseconds. /// Blocks execution.
public static func delay(milliseconds: UInt32) { public static func delay(milliseconds: UInt32) {
api.pointee.delay.unsafelyUnwrapped(milliseconds) api.pointee.delay.unsafelyUnwrapped(milliseconds)
} }
/// Requests the server time. The completion receives the time string or /// Asynchronously fetches the server time: `time` is seconds since 2000-01-01 UTC,
/// an error string. Only one request is tracked at a time; a second call /// as a string. One completion at a time; calling again replaces a pending one.
/// before the first completes replaces the stored completion.
public static func getServerTime(_ completion: @escaping (_ time: String?, _ error: String?) -> Void) { public static func getServerTime(_ completion: @escaping (_ time: String?, _ error: String?) -> Void) {
serverTimeCompletion = completion serverTimeCompletion = completion
api.pointee.getServerTime.unsafelyUnwrapped { time, error in api.pointee.getServerTime.unsafelyUnwrapped { time, error in
@@ -100,7 +96,7 @@ extension System {
// MARK: - Update loop // MARK: - Update loop
/// Sets the per-frame update callback. Return `true` to redraw the display. /// Sets the per-frame callback, replacing any previous one; return `true` to redraw.
public static func setUpdateCallback(_ callback: @escaping () -> Bool) { public static func setUpdateCallback(_ callback: @escaping () -> Bool) {
updateCallback = callback updateCallback = callback
api.pointee.setUpdateCallback.unsafelyUnwrapped({ _ in api.pointee.setUpdateCallback.unsafelyUnwrapped({ _ in
@@ -110,23 +106,23 @@ extension System {
nonisolated(unsafe) private static var updateCallback: (() -> Bool)? nonisolated(unsafe) private static var updateCallback: (() -> Bool)?
/// Draws the current frames-per-second value at the given point. /// Draws the current FPS at (`x`, `y`).
public static func drawFPS(x: Int = 0, y: Int = 0) { public static func drawFPS(x: Int = 0, y: Int = 0) {
api.pointee.drawFPS.unsafelyUnwrapped(Int32(x), Int32(y)) api.pointee.drawFPS.unsafelyUnwrapped(Int32(x), Int32(y))
} }
// MARK: - Input // MARK: - Input
/// The current button state: held, pressed this frame, released this frame. /// Buttons held now, and those pushed or released during the previous update.
public static var buttonState: (current: Buttons, pushed: Buttons, released: Buttons) { public static var buttonState: (current: Buttons, pushed: Buttons, released: Buttons) {
var current = PDButtons(0), pushed = PDButtons(0), released = PDButtons(0) var current = PDButtons(0), pushed = PDButtons(0), released = PDButtons(0)
api.pointee.getButtonState.unsafelyUnwrapped(&current, &pushed, &released) api.pointee.getButtonState.unsafelyUnwrapped(&current, &pushed, &released)
return (Buttons(current), Buttons(pushed), Buttons(released)) return (Buttons(current), Buttons(pushed), Buttons(released))
} }
/// Installs a callback invoked for every button press/release. `queueSize` /// Calls `callback` per button down/up in the previous update, replacing any previous
/// sets how many events are buffered between frames. The return value of /// one; `nil` removes it. `queueSize`: events buffered per update (5 suffices at 30 FPS).
/// the callback is reserved by the OS; return 0. /// `callback` returns 0, or non-zero to signal an error.
public static func setButtonCallback(queueSize: Int = 5, public static func setButtonCallback(queueSize: Int = 5,
_ callback: ((_ button: Buttons, _ isDown: Bool, _ when: UInt32) -> Int32)?) { _ callback: ((_ button: Buttons, _ isDown: Bool, _ when: UInt32) -> Int32)?) {
buttonCallback = callback buttonCallback = callback
@@ -141,45 +137,42 @@ extension System {
nonisolated(unsafe) private static var buttonCallback: ((Buttons, Bool, UInt32) -> Int32)? nonisolated(unsafe) private static var buttonCallback: ((Buttons, Bool, UInt32) -> Int32)?
/// Enables the given peripherals (e.g. the accelerometer), disabling /// Enables `peripherals`, disabling the rest; accelerometer data arrives next update.
/// the rest.
public static func setPeripheralsEnabled(_ peripherals: Peripherals) { public static func setPeripheralsEnabled(_ peripherals: Peripherals) {
api.pointee.setPeripheralsEnabled.unsafelyUnwrapped(PDPeripherals(PDPeripherals.RawValue(peripherals.rawValue))) api.pointee.setPeripheralsEnabled.unsafelyUnwrapped(PDPeripherals(PDPeripherals.RawValue(peripherals.rawValue)))
} }
/// The most recent accelerometer reading, in g. Enable the accelerometer /// Last reading, in g; requires `setPeripheralsEnabled(.accelerometer)`.
/// with `setPeripheralsEnabled(.accelerometer)` first.
public static var accelerometer: (x: Float, y: Float, z: Float) { public static var accelerometer: (x: Float, y: Float, z: Float) {
var x: Float = 0, y: Float = 0, z: Float = 0 var x: Float = 0, y: Float = 0, z: Float = 0
api.pointee.getAccelerometer.unsafelyUnwrapped(&x, &y, &z) api.pointee.getAccelerometer.unsafelyUnwrapped(&x, &y, &z)
return (x, y, z) return (x, y, z)
} }
/// Degrees the crank moved since the last frame. /// Degrees moved since last read; negative is counterclockwise.
public static var crankChange: Float { api.pointee.getCrankChange.unsafelyUnwrapped() } public static var crankChange: Float { api.pointee.getCrankChange.unsafelyUnwrapped() }
/// The crank position in degrees; 0 points along the +Y axis. /// Degrees, 0...360; 0 points up, increasing clockwise viewed from the right side.
public static var crankAngle: Float { api.pointee.getCrankAngle.unsafelyUnwrapped() } public static var crankAngle: Float { api.pointee.getCrankAngle.unsafelyUnwrapped() }
/// Whether the crank is folded into the device.
public static var isCrankDocked: Bool { api.pointee.isCrankDocked.unsafelyUnwrapped() != 0 } public static var isCrankDocked: Bool { api.pointee.isCrankDocked.unsafelyUnwrapped() != 0 }
/// Disables or enables the crank dock/undock sounds. Returns the previous setting. /// Toggles crank dock/undock sounds; returns the previous `disabled` value.
@discardableResult @discardableResult
public static func setCrankSoundsDisabled(_ disabled: Bool) -> Bool { public static func setCrankSoundsDisabled(_ disabled: Bool) -> Bool {
api.pointee.setCrankSoundsDisabled.unsafelyUnwrapped(disabled ? 1 : 0) != 0 api.pointee.setCrankSoundsDisabled.unsafelyUnwrapped(disabled ? 1 : 0) != 0
} }
/// Whether the user has the "flipped" system setting enabled. /// The user's "flipped" system setting.
public static var isFlipped: Bool { api.pointee.getFlipped.unsafelyUnwrapped() != 0 } public static var isFlipped: Bool { api.pointee.getFlipped.unsafelyUnwrapped() != 0 }
/// Disables or re-enables the automatic screen lock. /// Toggles the 3-minute auto lock; either call resets its timer.
public static func setAutoLockDisabled(_ disabled: Bool) { public static func setAutoLockDisabled(_ disabled: Bool) {
api.pointee.setAutoLockDisabled.unsafelyUnwrapped(disabled ? 1 : 0) api.pointee.setAutoLockDisabled.unsafelyUnwrapped(disabled ? 1 : 0)
} }
/// Installs a callback invoked when a message is received on the serial port /// Calls `callback` for serial `msg <text>` messages; `nil` removes it. One closure
/// via `msg <text>`. /// at a time.
public static func setSerialMessageCallback(_ callback: ((String) -> Void)?) { public static func setSerialMessageCallback(_ callback: ((String) -> Void)?) {
serialMessageCallback = callback serialMessageCallback = callback
if callback != nil { if callback != nil {
@@ -196,6 +189,7 @@ extension System {
// MARK: - System menu // MARK: - System menu
// Retains items until removed; the OS holds only unretained userdata pointers.
nonisolated(unsafe) private static var liveMenuItems: [MenuItem] = [] nonisolated(unsafe) private static var liveMenuItems: [MenuItem] = []
private static let menuItemTrampoline: @convention(c) (UnsafeMutableRawPointer?) -> Void = { userdata in private static let menuItemTrampoline: @convention(c) (UnsafeMutableRawPointer?) -> Void = { userdata in
@@ -204,7 +198,7 @@ extension System {
item.onSelect(item) item.onSelect(item)
} }
/// Adds a plain menu item to the system menu. /// Adds an action item; `onSelect` runs when picked. `nil` if the OS can't add it.
@discardableResult @discardableResult
public static func addMenuItem(title: String, onSelect: @escaping (MenuItem) -> Void) -> MenuItem? { public static func addMenuItem(title: String, onSelect: @escaping (MenuItem) -> Void) -> MenuItem? {
var item: MenuItem? var item: MenuItem?
@@ -215,7 +209,8 @@ extension System {
return registered(item) return registered(item)
} }
/// Adds a menu item with a checkbox. /// Adds a checkmark item; `onSelect` runs when the menu closes after a toggle.
/// `nil` if the OS can't add it.
@discardableResult @discardableResult
public static func addCheckmarkMenuItem(title: String, isChecked: Bool = false, public static func addCheckmarkMenuItem(title: String, isChecked: Bool = false,
onSelect: @escaping (MenuItem) -> Void) -> MenuItem? { onSelect: @escaping (MenuItem) -> Void) -> MenuItem? {
@@ -228,12 +223,12 @@ extension System {
return registered(item) return registered(item)
} }
/// Adds a menu item that cycles through the given options. /// Adds an item cycling through `options`; `onSelect` runs when the menu closes
/// after a change. `nil` if the OS can't add it.
@discardableResult @discardableResult
public static func addOptionsMenuItem(title: String, options: [String], public static func addOptionsMenuItem(title: String, options: [String],
onSelect: @escaping (MenuItem) -> Void) -> MenuItem? { onSelect: @escaping (MenuItem) -> Void) -> MenuItem? {
// The OS keeps the option title pointers, so copy and retain them for // The OS keeps the title pointers; the copies live until the item is removed.
// the lifetime of the menu item.
let copies = options.map { $0.copiedPlaydateCString() } let copies = options.map { $0.copiedPlaydateCString() }
var cOptions: [UnsafePointer<CChar>?] = copies.map { UnsafePointer($0) } var cOptions: [UnsafePointer<CChar>?] = copies.map { UnsafePointer($0) }
var item: MenuItem? var item: MenuItem?
@@ -247,7 +242,7 @@ extension System {
return registered(item) return registered(item)
} }
/// Registers the wrapper as the item's userdata and keeps it alive. /// Sets `item` as its own userdata and retains it until removed.
private static func registered(_ item: MenuItem?) -> MenuItem? { private static func registered(_ item: MenuItem?) -> MenuItem? {
guard let item else { return nil } guard let item else { return nil }
api.pointee.setMenuItemUserdata.unsafelyUnwrapped( api.pointee.setMenuItemUserdata.unsafelyUnwrapped(
@@ -256,39 +251,41 @@ extension System {
return item return item
} }
/// Removes `item`; the OS frees it, so don't use `item` afterwards.
public static func removeMenuItem(_ item: MenuItem) { public static func removeMenuItem(_ item: MenuItem) {
api.pointee.removeMenuItem.unsafelyUnwrapped(item.pointer) api.pointee.removeMenuItem.unsafelyUnwrapped(item.pointer)
item.deallocateRetainedTitles() item.deallocateRetainedTitles()
liveMenuItems.removeAll { $0 === item } liveMenuItems.removeAll { $0 === item }
} }
/// Removes all custom items; existing `MenuItem`s must not be used afterwards.
public static func removeAllMenuItems() { public static func removeAllMenuItems() {
api.pointee.removeAllMenuItems.unsafelyUnwrapped() api.pointee.removeAllMenuItems.unsafelyUnwrapped()
for item in liveMenuItems { item.deallocateRetainedTitles() } for item in liveMenuItems { item.deallocateRetainedTitles() }
liveMenuItems = [] liveMenuItems = []
} }
/// Sets a custom image for the pause menu, optionally shifted left by /// Sets the 400x240 menu image; only its left 200 px stay visible. `xOffset`
/// `xOffset` (0...200). /// (0...200 px) shifts it left as the menu animates in.
public static func setMenuImage(_ bitmap: Graphics.Bitmap?, xOffset: Int = 0) { public static func setMenuImage(_ bitmap: Graphics.Bitmap?, xOffset: Int = 0) {
api.pointee.setMenuImage.unsafelyUnwrapped(bitmap?.pointer, Int32(xOffset)) api.pointee.setMenuImage.unsafelyUnwrapped(bitmap?.pointer, Int32(xOffset))
} }
// MARK: - Device state // MARK: - Device state
/// Whether the user has enabled the "reduce flashing" accessibility setting. /// The user's "reduce flashing" accessibility setting.
public static var reduceFlashing: Bool { api.pointee.getReduceFlashing.unsafelyUnwrapped() != 0 } public static var reduceFlashing: Bool { api.pointee.getReduceFlashing.unsafelyUnwrapped() != 0 }
/// Battery charge, 0...100. /// 0 (empty)...100 (full).
public static var batteryPercentage: Float { api.pointee.getBatteryPercentage.unsafelyUnwrapped() } public static var batteryPercentage: Float { api.pointee.getBatteryPercentage.unsafelyUnwrapped() }
/// The battery voltage, in volts. /// In volts.
public static var batteryVoltage: Float { api.pointee.getBatteryVoltage.unsafelyUnwrapped() } public static var batteryVoltage: Float { api.pointee.getBatteryVoltage.unsafelyUnwrapped() }
/// Flushes the CPU instruction cache after loading code at runtime. /// Flushes the CPU instruction cache; needed only after modifying code at runtime.
public static func clearICache() { api.pointee.clearICache.unsafelyUnwrapped() } public static func clearICache() { api.pointee.clearICache.unsafelyUnwrapped() }
/// Quits the current game and restarts it with the given launch arguments. /// Reinitializes the runtime and restarts the game with `launchArguments`.
public static func restartGame(launchArguments: String? = nil) { public static func restartGame(launchArguments: String? = nil) {
if let launchArguments { if let launchArguments {
launchArguments.withCString { api.pointee.restartGame.unsafelyUnwrapped($0) } launchArguments.withCString { api.pointee.restartGame.unsafelyUnwrapped($0) }
@@ -297,15 +294,15 @@ extension System {
} }
} }
/// The arguments the game was launched with, and the path of the pdx. /// Launch arguments (simulator command line, device `run`, or `restartGame`) and
/// the loaded game's path.
public static var launchArguments: (arguments: String?, path: String?) { public static var launchArguments: (arguments: String?, path: String?) {
var path: UnsafePointer<CChar>? var path: UnsafePointer<CChar>?
let arguments = api.pointee.getLaunchArgs.unsafelyUnwrapped(&path) let arguments = api.pointee.getLaunchArgs.unsafelyUnwrapped(&path)
return (String(playdateCString: arguments), String(playdateCString: path)) return (String(playdateCString: arguments), String(playdateCString: path))
} }
/// Sends data over the mirror connection. Returns `false` if mirroring is /// Sends `data` with `command` over Mirror; `false` if not mirroring or the send fails.
/// not active or the send fails.
@discardableResult @discardableResult
public static func sendMirrorData(command: UInt8, data: Span<UInt8>) -> Bool { public static func sendMirrorData(command: UInt8, data: Span<UInt8>) -> Bool {
data.withUnsafeBufferPointer { buffer in data.withUnsafeBufferPointer { buffer in
@@ -315,7 +312,7 @@ extension System {
} }
} }
/// OS, language, and pdx version information. /// OS version, system language, and the SDK version the game was built with.
public static var info: Info { public static var info: Info {
let info = api.pointee.getSystemInfo.unsafelyUnwrapped().unsafelyUnwrapped.pointee let info = api.pointee.getSystemInfo.unsafelyUnwrapped().unsafelyUnwrapped.pointee
return Info(osVersion: info.osversion, return Info(osVersion: info.osversion,
@@ -323,7 +320,8 @@ extension System {
pdxVersion: info.pdxversion) pdxVersion: info.pdxversion)
} }
/// Looks up a localized string by key from the game's strings files. /// Looks up `key` in `language`'s `.strings` file; `nil` if the key or file is missing.
/// `.system` falls back to the other language's file if the system one can't load.
public static func localizedText(forKey key: String, language: Language = .system) -> String? { public static func localizedText(forKey key: String, language: Language = .system) -> String? {
key.withCString { cKey in key.withCString { cKey in
guard let cString = api.pointee.getLocalizedText.unsafelyUnwrapped(cKey, language.cValue) else { guard let cString = api.pointee.getLocalizedText.unsafelyUnwrapped(cKey, language.cValue) else {
@@ -335,14 +333,13 @@ extension System {
} }
} }
/// The system volume, 0...1. /// Menu volume, 0...1.
public static var volume: Float { api.pointee.getVolume.unsafelyUnwrapped() } public static var volume: Float { api.pointee.getVolume.unsafelyUnwrapped() }
/// The battery and power supply state.
public static var powerStatus: PowerStatus { public static var powerStatus: PowerStatus {
PowerStatus(rawValue: UInt32(api.pointee.getPowerStatus.unsafelyUnwrapped().rawValue)) PowerStatus(rawValue: UInt32(api.pointee.getPowerStatus.unsafelyUnwrapped().rawValue))
} }
/// Quits the game and returns to the launcher. /// Sends the game `kEventTerminate`, then quits to the launcher.
public static func exitToLauncher() { api.pointee.exitToLauncher.unsafelyUnwrapped() } public static func exitToLauncher() { api.pointee.exitToLauncher.unsafelyUnwrapped() }
} }