Swift 6.4 migration and exhancements (#1)

This PR contains the work done to update the library and the attached example project to use the Swift 6.4 computer as a minimum supported version and also, to use the latest features introduced in it.

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

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