diff --git a/Examples/HelloPlaydate/Package.swift b/Examples/HelloPlaydate/Package.swift index 6ebb3ad..36bbbdf 100644 --- a/Examples/HelloPlaydate/Package.swift +++ b/Examples/HelloPlaydate/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.3 +// swift-tools-version: 6.4 import PackageDescription diff --git a/Examples/HelloPlaydate/Sources/HelloPlaydate/Game.swift b/Examples/HelloPlaydate/Sources/HelloPlaydate/Game.swift index 4ea054a..e1bb792 100644 --- a/Examples/HelloPlaydate/Sources/HelloPlaydate/Game.swift +++ b/Examples/HelloPlaydate/Sources/HelloPlaydate/Game.swift @@ -8,7 +8,7 @@ import CPlaydate import PlaydateKit -@_cdecl("eventHandler") +@c(eventHandler) public func eventHandler( pointer: UnsafeMutableRawPointer, event: PDSystemEvent, diff --git a/Makefile b/Makefile index 02e0b5b..62f4590 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/Package.swift b/Package.swift index 0f8a696..4121bf5 100644 --- a/Package.swift +++ b/Package.swift @@ -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, ), ] ) diff --git a/README.md b/README.md index 5bad8ff..0af4df3 100644 --- a/README.md +++ b/README.md @@ -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 `. - -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 `. 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=`. +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=` 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. diff --git a/Scripts/build-embedded.sh b/Scripts/build-embedded.sh index 4ae8911..f1f6b39 100755 --- a/Scripts/build-embedded.sh +++ b/Scripts/build-embedded.sh @@ -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" diff --git a/Sources/PlaydateKit/Display/Display.swift b/Sources/PlaydateKit/Display/Display.swift index 5eb85ad..24ae76a 100644 --- a/Sources/PlaydateKit/Display/Display.swift +++ b/Sources/PlaydateKit/Display/Display.swift @@ -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.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)) } diff --git a/Sources/PlaydateKit/File/Classes/Handle.swift b/Sources/PlaydateKit/File/Classes/Handle.swift index 692dfb5..66eb3f4 100644 --- a/Sources/PlaydateKit/File/Classes/Handle.swift +++ b/Sources/PlaydateKit/File/Classes/Handle.swift @@ -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) 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) 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() diff --git a/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift b/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift index 0b3fe94..27ecd79 100644 --- a/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift +++ b/Sources/PlaydateKit/File/Enumerations/SeekOrigin.swift @@ -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 } } diff --git a/Sources/PlaydateKit/File/File.swift b/Sources/PlaydateKit/File/File.swift index b6a6380..6dc0477 100644 --- a/Sources/PlaydateKit/File/File.swift +++ b/Sources/PlaydateKit/File/File.swift @@ -1,34 +1,32 @@ internal import CPlaydate -/// The cached `playdate->file` C API table. +/// Cached `playdate->file` table. var fileAPI: UnsafePointer { 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) } } diff --git a/Sources/PlaydateKit/File/Structures/Options.swift b/Sources/PlaydateKit/File/Structures/Options.swift index d23de7b..e0c2487 100644 --- a/Sources/PlaydateKit/File/Structures/Options.swift +++ b/Sources/PlaydateKit/File/Structures/Options.swift @@ -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)) } diff --git a/Sources/PlaydateKit/File/Structures/Stat.swift b/Sources/PlaydateKit/File/Structures/Stat.swift index 58ad59a..61cd2c3 100644 --- a/Sources/PlaydateKit/File/Structures/Stat.swift +++ b/Sources/PlaydateKit/File/Structures/Stat.swift @@ -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 } } diff --git a/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift b/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift index e40a8a8..f5b6603 100644 --- a/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift +++ b/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift @@ -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? - 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( + _ body: (inout MutableSpan) 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( + _ body: (inout MutableSpan) 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?, data: UnsafeMutablePointer?) { var width: Int32 = 0, height: Int32 = 0, rowBytes: Int32 = 0 var mask: UnsafeMutablePointer? var data: UnsafeMutablePointer? 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? - 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) diff --git a/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift b/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift index 9cbebe0..2594085 100644 --- a/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift +++ b/Sources/PlaydateKit/Graphics/Classes/BitmapTable.swift @@ -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? - 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? - 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") diff --git a/Sources/PlaydateKit/Graphics/Classes/Font.swift b/Sources/PlaydateKit/Graphics/Classes/Font.swift index c156789..4939672 100644 --- a/Sources/PlaydateKit/Graphics/Classes/Font.swift +++ b/Sources/PlaydateKit/Graphics/Classes/Font.swift @@ -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? - 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, 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 diff --git a/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift b/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift index 46bf11e..b3d45e2 100644 --- a/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift +++ b/Sources/PlaydateKit/Graphics/Classes/StreamPlayer.swift @@ -1,15 +1,16 @@ internal import CPlaydate -/// The cached `playdate->graphics->videostream` C API table. +/// `playdate->graphics->videostream`. private var streamAPI: UnsafePointer { 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) } diff --git a/Sources/PlaydateKit/Graphics/Classes/TileMap.swift b/Sources/PlaydateKit/Graphics/Classes/TileMap.swift index daa1003..10846d7 100644 --- a/Sources/PlaydateKit/Graphics/Classes/TileMap.swift +++ b/Sources/PlaydateKit/Graphics/Classes/TileMap.swift @@ -1,13 +1,13 @@ internal import CPlaydate -/// The cached `playdate->graphics->tilemap` C API table. +/// `playdate->graphics->tilemap`. private var tilemapAPI: UnsafePointer { 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, 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) } diff --git a/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift b/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift index 919c6ca..ac714d6 100644 --- a/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift +++ b/Sources/PlaydateKit/Graphics/Classes/VideoPlayer.swift @@ -1,14 +1,15 @@ internal import CPlaydate -/// The cached `playdate->graphics->video` C API table. +/// `playdate->graphics->video`. private var videoAPI: UnsafePointer { 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 diff --git a/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift b/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift index f1dbd33..66a3660 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/BitmapFlip.swift @@ -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 diff --git a/Sources/PlaydateKit/Graphics/Enumerations/Color.swift b/Sources/PlaydateKit/Graphics/Enumerations/Color.swift index 798ddd6..8e289ea 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/Color.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/Color.swift @@ -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(_ body: (LCDColor) -> Result) -> Result { switch self { case .black: return body(LCDColor(kColorBlack.rawValue)) diff --git a/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift b/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift index 6f951ef..3fe3f47 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/DrawMode.swift @@ -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 } diff --git a/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift b/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift index c520a67..0ac92c9 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/LineCapStyle.swift @@ -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 diff --git a/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift b/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift index bd57d33..fae350a 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/PolygonFillRule.swift @@ -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)) } diff --git a/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift b/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift index afe17b5..b3cab46 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/SolidColor.swift @@ -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 } diff --git a/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift b/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift index 2eb9433..6dfb161 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/StringEncoding.swift @@ -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)) } diff --git a/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift b/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift index 01299fc..076e414 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/TextAlignment.swift @@ -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 diff --git a/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift b/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift index 1a9c02f..e64a756 100644 --- a/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift +++ b/Sources/PlaydateKit/Graphics/Enumerations/TextWrappingMode.swift @@ -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 diff --git a/Sources/PlaydateKit/Graphics/Graphics.swift b/Sources/PlaydateKit/Graphics/Graphics.swift index 2ccd4ad..c0ec4c1 100644 --- a/Sources/PlaydateKit/Graphics/Graphics.swift +++ b/Sources/PlaydateKit/Graphics/Graphics.swift @@ -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.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? { - 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( + _ body: (inout MutableSpan) 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? { - 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( + _ body: (Span) 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() } diff --git a/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift b/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift index 7efc480..06493c9 100644 --- a/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift +++ b/Sources/PlaydateKit/Graphics/Structures/Bitmap.Data.swift @@ -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? - /// The bitmap's pixel data. One bit per pixel; rows are `rowBytes` - /// wide. - public let data: UnsafeMutablePointer? + public let hasMask: Bool } } diff --git a/Sources/PlaydateKit/Graphics/Structures/FontPage.swift b/Sources/PlaydateKit/Graphics/Structures/FontPage.swift index 657d1cb..bb8adde 100644 --- a/Sources/PlaydateKit/Graphics/Structures/FontPage.swift +++ b/Sources/PlaydateKit/Graphics/Structures/FontPage.swift @@ -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 diff --git a/Sources/PlaydateKit/Graphics/Structures/Glyph.swift b/Sources/PlaydateKit/Graphics/Structures/Glyph.swift index e169011..71f33b4 100644 --- a/Sources/PlaydateKit/Graphics/Structures/Glyph.swift +++ b/Sources/PlaydateKit/Graphics/Structures/Glyph.swift @@ -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)) } diff --git a/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift b/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift index 7bb5e21..7532afb 100644 --- a/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift +++ b/Sources/PlaydateKit/Graphics/Structures/Graphics.Rect.swift @@ -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) } diff --git a/Sources/PlaydateKit/Graphics/Structures/Pattern.swift b/Sources/PlaydateKit/Graphics/Structures/Pattern.swift index 32b6815..f18d86e 100644 --- a/Sources/PlaydateKit/Graphics/Structures/Pattern.swift +++ b/Sources/PlaydateKit/Graphics/Structures/Pattern.swift @@ -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) +} diff --git a/Sources/PlaydateKit/JSON/Classes/Encoder.swift b/Sources/PlaydateKit/JSON/Classes/Encoder.swift index e0e32b2..aa383ac 100644 --- a/Sources/PlaydateKit/JSON/Classes/Encoder.swift +++ b/Sources/PlaydateKit/JSON/Classes/Encoder.swift @@ -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: diff --git a/Sources/PlaydateKit/JSON/Enumerations/Value.swift b/Sources/PlaydateKit/JSON/Enumerations/Value.swift index 10bed5e..b35cf02 100644 --- a/Sources/PlaydateKit/JSON/Enumerations/Value.swift +++ b/Sources/PlaydateKit/JSON/Enumerations/Value.swift @@ -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]) } } diff --git a/Sources/PlaydateKit/JSON/JSON.swift b/Sources/PlaydateKit/JSON/JSON.swift index 88ccea0..dad6fa4 100644 --- a/Sources/PlaydateKit/JSON/JSON.swift +++ b/Sources/PlaydateKit/JSON/JSON.swift @@ -1,26 +1,22 @@ internal import CPlaydate -/// The cached `playdate->json` C API table. +/// Cached `playdate->json` table. var jsonAPI: UnsafePointer { 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.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.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) diff --git a/Sources/PlaydateKit/Lua/Aliases/CFunction.swift b/Sources/PlaydateKit/Lua/Aliases/CFunction.swift index aa4a6c2..dc818d7 100644 --- a/Sources/PlaydateKit/Lua/Aliases/CFunction.swift +++ b/Sources/PlaydateKit/Lua/Aliases/CFunction.swift @@ -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 } diff --git a/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift b/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift index 5c97985..e5dd37e 100644 --- a/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift +++ b/Sources/PlaydateKit/Lua/Enumerations/ClassValue.swift @@ -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) } } diff --git a/Sources/PlaydateKit/Lua/Enumerations/Kind.swift b/Sources/PlaydateKit/Lua/Enumerations/Kind.swift index 5e9940c..54f97e1 100644 --- a/Sources/PlaydateKit/Lua/Enumerations/Kind.swift +++ b/Sources/PlaydateKit/Lua/Enumerations/Kind.swift @@ -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 } diff --git a/Sources/PlaydateKit/Lua/Lua.swift b/Sources/PlaydateKit/Lua/Lua.swift index c38dd23..c57bb17 100644 --- a/Sources/PlaydateKit/Lua/Lua.swift +++ b/Sources/PlaydateKit/Lua/Lua.swift @@ -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.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 { @@ -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? - 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? - 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? 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? - let ok = name.withPlaydateCString { + let ok = name.withCString { luaAPI.pointee.callFunction.unsafelyUnwrapped($0, Int32(argumentCount), &error) != 0 } if !ok { throw PlaydateError(cString: error) } diff --git a/Sources/PlaydateKit/Lua/Structures/UDObject.swift b/Sources/PlaydateKit/Lua/Structures/UDObject.swift index 0a3ec0b..6895f2a 100644 --- a/Sources/PlaydateKit/Lua/Structures/UDObject.swift +++ b/Sources/PlaydateKit/Lua/Structures/UDObject.swift @@ -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) diff --git a/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift b/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift index 1281d7c..c8fe2af 100644 --- a/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift +++ b/Sources/PlaydateKit/Network/Classes/HTTPConnection.swift @@ -4,11 +4,9 @@ internal import CPlaydate private var httpAPI: UnsafePointer { 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) 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 { diff --git a/Sources/PlaydateKit/Network/Classes/TCPConnection.swift b/Sources/PlaydateKit/Network/Classes/TCPConnection.swift index 21e122b..68c0008 100644 --- a/Sources/PlaydateKit/Network/Classes/TCPConnection.swift +++ b/Sources/PlaydateKit/Network/Classes/TCPConnection.swift @@ -4,19 +4,17 @@ internal import CPlaydate private var tcpAPI: UnsafePointer { 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.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) 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) 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) + } + } } } diff --git a/Sources/PlaydateKit/Network/Enumerations/NetError.swift b/Sources/PlaydateKit/Network/Enumerations/NetError.swift index f80137e..c3cb0b9 100644 --- a/Sources/PlaydateKit/Network/Enumerations/NetError.swift +++ b/Sources/PlaydateKit/Network/Enumerations/NetError.swift @@ -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 } diff --git a/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift b/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift index f3217c8..6536a02 100644 --- a/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift +++ b/Sources/PlaydateKit/Network/Enumerations/WifiStatus.swift @@ -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 } } diff --git a/Sources/PlaydateKit/Network/Network.swift b/Sources/PlaydateKit/Network/Network.swift index 7f67483..9efb242 100644 --- a/Sources/PlaydateKit/Network/Network.swift +++ b/Sources/PlaydateKit/Network/Network.swift @@ -3,45 +3,50 @@ internal import CPlaydate /// The cached `playdate->network` C API table. private var networkAPI: UnsafePointer { 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?, Int32, Bool, UnsafePointer?, (@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?, @@ -57,9 +62,9 @@ extension Network { guard let userdata else { return } Unmanaged.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 diff --git a/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift b/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift index 57b2778..4d41abe 100644 --- a/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift +++ b/Sources/PlaydateKit/Playdate/Enumerations/AccessReply.swift @@ -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 } diff --git a/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift b/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift index 71b689c..7746226 100644 --- a/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift +++ b/Sources/PlaydateKit/Playdate/Enumerations/SystemEvent.swift @@ -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 diff --git a/Sources/PlaydateKit/Playdate/Playdate.swift b/Sources/PlaydateKit/Playdate/Playdate.swift index 9d528a7..4de58e8 100644 --- a/Sources/PlaydateKit/Playdate/Playdate.swift +++ b/Sources/PlaydateKit/Playdate/Playdate.swift @@ -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! - // 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! nonisolated(unsafe) static var displayAPI: UnsafePointer! nonisolated(unsafe) static var graphicsAPI: UnsafePointer! @@ -31,10 +23,8 @@ public enum Playdate { nonisolated(unsafe) static var scoreboardsAPI: UnsafePointer! nonisolated(unsafe) static var networkAPI: UnsafePointer! - // 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! nonisolated(unsafe) static var videoAPI: UnsafePointer! nonisolated(unsafe) static var videoStreamAPI: UnsafePointer! @@ -61,10 +51,8 @@ public enum Playdate { nonisolated(unsafe) static var httpAPI: UnsafePointer! nonisolated(unsafe) static var tcpAPI: UnsafePointer! - /// 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 diff --git a/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift b/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift index 6692e22..2ae4ac5 100644 --- a/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift +++ b/Sources/PlaydateKit/Playdate/Structures/PlaydateError.swift @@ -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?) { self.init(message: String(playdateCString: cString) ?? "unknown error") } diff --git a/Sources/PlaydateKit/PlaydateKit.docc/GettingStarted.md b/Sources/PlaydateKit/PlaydateKit.docc/GettingStarted.md index fa13c5f..858b192 100644 --- a/Sources/PlaydateKit/PlaydateKit.docc/GettingStarted.md +++ b/Sources/PlaydateKit/PlaydateKit.docc/GettingStarted.md @@ -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. diff --git a/Sources/PlaydateKit/PlaydateKit.docc/PlaydateKit.md b/Sources/PlaydateKit/PlaydateKit.docc/PlaydateKit.md index 96b1e0c..43d09e2 100644 --- a/Sources/PlaydateKit/PlaydateKit.docc/PlaydateKit.md +++ b/Sources/PlaydateKit/PlaydateKit.docc/PlaydateKit.md @@ -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 . +anything else; see . -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 diff --git a/Sources/PlaydateKit/Scoreboards/Scoreboards.swift b/Sources/PlaydateKit/Scoreboards/Scoreboards.swift index 8026588..05070ff 100644 --- a/Sources/PlaydateKit/Scoreboards/Scoreboards.swift +++ b/Sources/PlaydateKit/Scoreboards/Scoreboards.swift @@ -3,11 +3,9 @@ internal import CPlaydate /// The cached `playdate->scoreboards` C API table. var scoreboardsAPI: UnsafePointer { 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) -> Void)? nonisolated(unsafe) private static var scoresCompletion: ((Result) -> 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) -> 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) -> 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) -> 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) -> 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 diff --git a/Sources/PlaydateKit/Scoreboards/Structures/Board.swift b/Sources/PlaydateKit/Scoreboards/Structures/Board.swift index e602429..0c97c05 100644 --- a/Sources/PlaydateKit/Scoreboards/Structures/Board.swift +++ b/Sources/PlaydateKit/Scoreboards/Structures/Board.swift @@ -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) { diff --git a/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift b/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift index 46f7027..1db5fcf 100644 --- a/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift +++ b/Sources/PlaydateKit/Scoreboards/Structures/BoardsList.swift @@ -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) { diff --git a/Sources/PlaydateKit/Scoreboards/Structures/Score.swift b/Sources/PlaydateKit/Scoreboards/Structures/Score.swift index cd2533d..983721e 100644 --- a/Sources/PlaydateKit/Scoreboards/Structures/Score.swift +++ b/Sources/PlaydateKit/Scoreboards/Structures/Score.swift @@ -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) { diff --git a/Sources/PlaydateKit/Scoreboards/Structures/ScoresList.swift b/Sources/PlaydateKit/Scoreboards/Structures/ScoresList.swift index c957e84..e5c4e5a 100644 --- a/Sources/PlaydateKit/Scoreboards/Structures/ScoresList.swift +++ b/Sources/PlaydateKit/Scoreboards/Structures/ScoresList.swift @@ -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) { diff --git a/Sources/PlaydateKit/Sound/Aliases/MIDINote.swift b/Sources/PlaydateKit/Sound/Aliases/MIDINote.swift index 0084d24..5e4530e 100644 --- a/Sources/PlaydateKit/Sound/Aliases/MIDINote.swift +++ b/Sources/PlaydateKit/Sound/Aliases/MIDINote.swift @@ -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 } diff --git a/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift b/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift index 3d7cc8a..a4691a0 100644 --- a/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift +++ b/Sources/PlaydateKit/Sound/Effect/Aliases/Effect.Processor.swift @@ -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, - _ right: UnsafeMutableBufferPointer?, + /// 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, + _ right: inout MutableSpan, _ bufferActive: Bool) -> Bool } diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift b/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift index c3f29a1..46c0a0a 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/BitCrusher.swift @@ -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 { diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift index 420cdbc..a30c7fa 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLine.swift @@ -5,7 +5,7 @@ extension Sound { public final class DelayLine: Effect { private static var api: UnsafePointer { 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 diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift index 290ffc3..a3d2189 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/DelayLineTap.swift @@ -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.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) } diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift b/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift index 7d1ec25..c24f4bd 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift @@ -1,11 +1,9 @@ internal import CPlaydate -/// The cached `playdate->sound->effect` C API table. private var effectAPI: UnsafePointer { 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.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 { diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift b/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift index 2228e7a..b8f6d80 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/OnePoleFilter.swift @@ -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 { diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift b/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift index 3997c48..29f5b3e 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/Overdrive.swift @@ -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 { diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift b/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift index 39bf760..35e582c 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/RingModulator.swift @@ -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 { diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift b/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift index 0f7dfca..d7c0a93 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/TwoPoleFilter.swift @@ -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 { diff --git a/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift b/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift index 166b57a..bdee103 100644 --- a/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift +++ b/Sources/PlaydateKit/Sound/Effect/Enumerations/TwoPoleFilter.Kind.swift @@ -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 diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift b/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift index 8d7c077..05ae025 100644 --- a/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift +++ b/Sources/PlaydateKit/Sound/Signal/Classes/ControlSignal.swift @@ -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.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)) } diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift b/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift index 53d0bf3..904bc0a 100644 --- a/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift +++ b/Sources/PlaydateKit/Sound/Signal/Classes/Envelope.swift @@ -5,8 +5,7 @@ extension Sound { public final class Envelope: SignalValue { private static var api: UnsafePointer { 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) } diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift b/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift index 61f170e..6ae7a4f 100644 --- a/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift +++ b/Sources/PlaydateKit/Sound/Signal/Classes/LFO.swift @@ -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) } diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift b/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift index 6d5a371..a920920 100644 --- a/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift +++ b/Sources/PlaydateKit/Sound/Signal/Classes/Signal.swift @@ -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.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) } diff --git a/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift b/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift index 323b1ca..8a1502c 100644 --- a/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift +++ b/Sources/PlaydateKit/Sound/Signal/Classes/SignalValue.swift @@ -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) diff --git a/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift b/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift index bb57635..ba96f01 100644 --- a/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift +++ b/Sources/PlaydateKit/Sound/Signal/Enumerations/LFO.Shape.swift @@ -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)) } diff --git a/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift b/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift index 416a2a8..a0df24d 100644 --- a/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift +++ b/Sources/PlaydateKit/Sound/Signal/Structures/SignalCallbacks.swift @@ -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?, _ interpolationValue: UnsafeMutablePointer?) -> 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?, diff --git a/Sources/PlaydateKit/Sound/Sound.swift b/Sources/PlaydateKit/Sound/Sound.swift index 658af3d..ae5e618 100644 --- a/Sources/PlaydateKit/Sound/Sound.swift +++ b/Sources/PlaydateKit/Sound/Sound.swift @@ -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) -> Bool)?) -> Bool { + _ callback: ((Span) -> 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) -> Bool)? + nonisolated(unsafe) private static var micCallback: ((Span) -> 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) diff --git a/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift b/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift index a899ebb..2c25980 100644 --- a/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift +++ b/Sources/PlaydateKit/Sound/Source/Aliases/SoundCallbackSource.swift @@ -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, - _ right: UnsafeMutableBufferPointer?) -> 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, + _ right: inout MutableSpan) -> Bool } diff --git a/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift b/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift index e39d583..011ca69 100644 --- a/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift +++ b/Sources/PlaydateKit/Sound/Source/Classes/AudioSample.swift @@ -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, 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?, format: Format, sampleRate: UInt32, byteLength: UInt32) { var data: UnsafeMutablePointer? @@ -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 diff --git a/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift b/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift index 0d16202..eae5aac 100644 --- a/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift +++ b/Sources/PlaydateKit/Sound/Source/Classes/CallbackSource.swift @@ -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?, Int32) -> Int32 = { context, left, right, length in guard let context, let left else { return 0 } let source = Unmanaged.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. diff --git a/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift b/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift index b41dd42..fc57a19 100644 --- a/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift +++ b/Sources/PlaydateKit/Sound/Source/Classes/FilePlayer.swift @@ -7,7 +7,7 @@ extension Sound { var loopCallback: ((FilePlayer) -> Void)? var fadeCallback: ((FilePlayer) -> Void)? - var mp3DataSource: ((UnsafeMutableBufferPointer) -> Int)? + var mp3DataSource: ((inout MutableSpan) -> 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` (0–1) 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) -> Int) { + _ dataSource: @escaping (inout MutableSpan) -> Int) { mp3DataSource = dataSource FilePlayer.api.pointee.setMP3StreamSource.unsafelyUnwrapped(pointer, { data, bytes, userdata in guard let userdata, let data else { return 0 } let player = Unmanaged.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 { diff --git a/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift b/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift index 305a3e4..b089929 100644 --- a/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift +++ b/Sources/PlaydateKit/Sound/Source/Classes/SamplePlayer.swift @@ -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 { diff --git a/Sources/PlaydateKit/Sound/Source/Classes/Source.swift b/Sources/PlaydateKit/Sound/Source/Classes/Source.swift index 0cfd723..3b53526 100644 --- a/Sources/PlaydateKit/Sound/Source/Classes/Source.swift +++ b/Sources/PlaydateKit/Sound/Source/Classes/Source.swift @@ -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.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, 0–1. 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 { diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift b/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift index fb95c2a..c000f28 100644 --- a/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift +++ b/Sources/PlaydateKit/Sound/Synth/Classes/Instrument.swift @@ -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.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)) } diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift b/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift index cc72c78..44ef23c 100644 --- a/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift +++ b/Sources/PlaydateKit/Sound/Synth/Classes/Sequence.swift @@ -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.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.. 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) } diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift b/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift index b08ff88..0baefd1 100644 --- a/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift +++ b/Sources/PlaydateKit/Sound/Synth/Classes/SequenceTrack.swift @@ -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.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) } diff --git a/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift b/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift index 5b3b6d8..9b2bd4c 100644 --- a/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift +++ b/Sources/PlaydateKit/Sound/Synth/Classes/Synth.swift @@ -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.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...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) } diff --git a/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift b/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift index 7de492a..4cf9d80 100644 --- a/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift +++ b/Sources/PlaydateKit/Sound/Synth/Enumerations/Synth.Waveform.swift @@ -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)) } diff --git a/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift b/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift index 492579d..a8ebcea 100644 --- a/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift +++ b/Sources/PlaydateKit/Sound/Synth/Structures/Synth.Generator.swift @@ -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, - _ right: UnsafeMutableBufferPointer?, + /// 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, + _ right: inout MutableSpan, _ 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, - _ right: UnsafeMutableBufferPointer?, + public init(render: @escaping (_ left: inout MutableSpan, + _ right: inout MutableSpan, _ rate: UInt32, _ drate: Int32) -> Int, noteOn: ((_ note: Sound.MIDINote, _ velocity: Float, _ length: Float) -> Void)? = nil, release: ((_ stop: Bool) -> Void)? = nil, diff --git a/Sources/PlaydateKit/Sprite/Classes/Sprite.swift b/Sources/PlaydateKit/Sprite/Classes/Sprite.swift index 2089237..d49f323 100644 --- a/Sources/PlaydateKit/Sprite/Classes/Sprite.swift +++ b/Sources/PlaydateKit/Sprite/Classes/Sprite.swift @@ -1,25 +1,20 @@ internal import CPlaydate -/// The cached `playdate->sprite` C API table. private var spriteAPI: UnsafePointer { 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.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, 0–255, 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?, 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?, 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) diff --git a/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift b/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift index bb2d95d..9ce11b1 100644 --- a/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift +++ b/Sources/PlaydateKit/Sprite/Enumerations/CollisionResponse.swift @@ -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 } diff --git a/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift b/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift index 7e72780..5e76924 100644 --- a/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift +++ b/Sources/PlaydateKit/Sprite/Structures/CollisionInfo.swift @@ -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) { diff --git a/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift b/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift index 1ada11d..c220651 100644 --- a/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift +++ b/Sources/PlaydateKit/Sprite/Structures/QueryInfo.swift @@ -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) diff --git a/Sources/PlaydateKit/Sprite/Structures/Rect.swift b/Sources/PlaydateKit/Sprite/Structures/Rect.swift index deaa8b0..3e58425 100644 --- a/Sources/PlaydateKit/Sprite/Structures/Rect.swift +++ b/Sources/PlaydateKit/Sprite/Structures/Rect.swift @@ -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 diff --git a/Sources/PlaydateKit/Support.swift b/Sources/PlaydateKit/Support.swift index 7098972..b104773 100644 --- a/Sources/PlaydateKit/Support.swift +++ b/Sources/PlaydateKit/Support.swift @@ -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) { - 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?) { 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(_ body: (UnsafePointer) -> 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(_ 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 { - let count = utf8.count - let buffer = UnsafeMutablePointer.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.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, _ alignment: Int, diff --git a/Sources/PlaydateKit/System/Classes/MenuItem.swift b/Sources/PlaydateKit/System/Classes/MenuItem.swift index 48dcbe0..6a7c2af 100644 --- a/Sources/PlaydateKit/System/Classes/MenuItem.swift +++ b/Sources/PlaydateKit/System/Classes/MenuItem.swift @@ -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] = [] - /// 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] = [], 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 } diff --git a/Sources/PlaydateKit/System/Enumerations/Language.swift b/Sources/PlaydateKit/System/Enumerations/Language.swift index d9b86c1..298c443 100644 --- a/Sources/PlaydateKit/System/Enumerations/Language.swift +++ b/Sources/PlaydateKit/System/Enumerations/Language.swift @@ -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 } diff --git a/Sources/PlaydateKit/System/Structures/Buttons.swift b/Sources/PlaydateKit/System/Structures/Buttons.swift index eb07f3e..f417937 100644 --- a/Sources/PlaydateKit/System/Structures/Buttons.swift +++ b/Sources/PlaydateKit/System/Structures/Buttons.swift @@ -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 } diff --git a/Sources/PlaydateKit/System/Structures/DateTime.swift b/Sources/PlaydateKit/System/Structures/DateTime.swift index 7394aa4..8c5a0cd 100644 --- a/Sources/PlaydateKit/System/Structures/DateTime.swift +++ b/Sources/PlaydateKit/System/Structures/DateTime.swift @@ -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, diff --git a/Sources/PlaydateKit/System/Structures/Info.swift b/Sources/PlaydateKit/System/Structures/Info.swift index a5ea6fc..886a0ed 100644 --- a/Sources/PlaydateKit/System/Structures/Info.swift +++ b/Sources/PlaydateKit/System/Structures/Info.swift @@ -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 } } diff --git a/Sources/PlaydateKit/System/Structures/Peripherals.swift b/Sources/PlaydateKit/System/Structures/Peripherals.swift index e0752dc..91f0693 100644 --- a/Sources/PlaydateKit/System/Structures/Peripherals.swift +++ b/Sources/PlaydateKit/System/Structures/Peripherals.swift @@ -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)) } diff --git a/Sources/PlaydateKit/System/Structures/PowerStatus.swift b/Sources/PlaydateKit/System/Structures/PowerStatus.swift index 5abf7e9..9878b75 100644 --- a/Sources/PlaydateKit/System/Structures/PowerStatus.swift +++ b/Sources/PlaydateKit/System/Structures/PowerStatus.swift @@ -1,16 +1,14 @@ internal import CPlaydate extension System { - /// Battery and power supply state. + /// Battery and power supply state. Wraps `PDPowerStatus`. public struct PowerStatus: OptionSet, Sendable { public let rawValue: UInt32 public init(rawValue: UInt32) { self.rawValue = rawValue } - /// The battery is charging. public static let charging = PowerStatus(rawValue: UInt32(kPDPowerStatusCharging.rawValue)) - /// Power is supplied over USB. public static let usb = PowerStatus(rawValue: UInt32(kPDPowerStatusUsb.rawValue)) - /// Power is supplied through the accessory screw terminals. + /// Powered through the corner screw pads. public static let screws = PowerStatus(rawValue: UInt32(kPDPowerStatusScrews.rawValue)) } } diff --git a/Sources/PlaydateKit/System/System.swift b/Sources/PlaydateKit/System/System.swift index 3b687ce..8e28363 100644 --- a/Sources/PlaydateKit/System/System.swift +++ b/Sources/PlaydateKit/System/System.swift @@ -4,46 +4,44 @@ internal import CPlaydate public enum System {} extension System { - /// The cached `playdate->system` C API table. + /// Cached `playdate->system` table. private static var api: UnsafePointer { Playdate.systemAPI.unsafelyUnwrapped } // MARK: - Memory - /// The system allocator. Pass `nil` to allocate, `size` 0 to free. + /// System allocator (`realloc` semantics): `nil` allocates; `size` 0 frees, returns `nil`. @discardableResult public static func realloc(_ pointer: UnsafeMutableRawPointer?, size: Int) -> UnsafeMutableRawPointer? { api.pointee.realloc.unsafelyUnwrapped(pointer, size) } - /// Frees memory that the Playdate OS handed to the caller (e.g. strings - /// returned by `localizedText(forKey:)`). + /// Frees OS-allocated memory, e.g. `localizedText(forKey:language:)` strings. static func systemFree(_ pointer: UnsafeMutableRawPointer?) { _ = api.pointee.realloc.unsafelyUnwrapped(pointer, 0) } // MARK: - Logging - /// Logs a message to the console (device serial or simulator console). + /// Logs to the device serial or simulator console. public static func log(_ message: String) { - message.withPlaydateCString { cplaydate_log(Playdate.apiPointer, $0) } + message.withCString { cplaydate_log(Playdate.apiPointer, $0) } } - /// Stops execution and displays the message as a fatal error. + /// Logs `message` as an error, then pauses execution. public static func error(_ message: String) { - message.withPlaydateCString { cplaydate_error(Playdate.apiPointer, $0) } + message.withCString { cplaydate_error(Playdate.apiPointer, $0) } } - // MARK: - Time + // MARK: - Language and time - /// The system language setting. public static var language: Language { Language(api.pointee.getLanguage.unsafelyUnwrapped()) } - /// Milliseconds since the game launched. Wraps around after about 49 days. + /// Milliseconds since an arbitrary point; pauses while asleep; wraps after ~49 days. public static var currentTimeMilliseconds: UInt32 { UInt32(api.pointee.getCurrentTimeMilliseconds.unsafelyUnwrapped()) } - /// Seconds (and sub-second milliseconds) since midnight 2000-01-01 UTC. + /// Seconds, plus millisecond remainder, since 2000-01-01 00:00 UTC. public static var secondsSinceEpoch: (seconds: UInt32, milliseconds: UInt32) { var milliseconds: UInt32 = 0 let seconds = withUnsafeMutablePointer(to: &milliseconds) { @@ -52,41 +50,39 @@ extension System { return (UInt32(seconds), milliseconds) } - /// High-resolution timer value, in seconds. + /// Seconds since `resetElapsedTime()`, with microsecond accuracy. public static var elapsedTime: Float { api.pointee.getElapsedTime.unsafelyUnwrapped() } - /// Resets the high-resolution timer to zero. public static func resetElapsedTime() { api.pointee.resetElapsedTime.unsafelyUnwrapped() } - /// Offset from UTC of the user-set timezone, in seconds. + /// Offset from UTC, in seconds. public static var timezoneOffset: Int32 { api.pointee.getTimezoneOffset.unsafelyUnwrapped() } - /// Whether the user prefers 24-hour time display. + /// The user's 24-hour time setting. public static var shouldDisplay24HourTime: Bool { api.pointee.shouldDisplay24HourTime.unsafelyUnwrapped() != 0 } - /// Converts seconds since the 2000-01-01 epoch to a calendar date. + /// `epoch` is seconds since 2000-01-01. public static func convertEpochToDateTime(_ epoch: UInt32) -> DateTime { var dateTime = PDDateTime() api.pointee.convertEpochToDateTime.unsafelyUnwrapped(epoch, &dateTime) return DateTime(dateTime) } - /// Converts a calendar date to seconds since the 2000-01-01 epoch. + /// Returns seconds since 2000-01-01. public static func convertDateTimeToEpoch(_ dateTime: DateTime) -> UInt32 { var cValue = dateTime.cValue return api.pointee.convertDateTimeToEpoch.unsafelyUnwrapped(&cValue) } - /// Blocks execution for the given number of milliseconds. + /// Blocks execution. public static func delay(milliseconds: UInt32) { api.pointee.delay.unsafelyUnwrapped(milliseconds) } - /// Requests the server time. The completion receives the time string or - /// an error string. Only one request is tracked at a time; a second call - /// before the first completes replaces the stored completion. + /// Asynchronously fetches the server time: `time` is seconds since 2000-01-01 UTC, + /// as a string. One completion at a time; calling again replaces a pending one. public static func getServerTime(_ completion: @escaping (_ time: String?, _ error: String?) -> Void) { serverTimeCompletion = completion api.pointee.getServerTime.unsafelyUnwrapped { time, error in @@ -100,7 +96,7 @@ extension System { // MARK: - Update loop - /// Sets the per-frame update callback. Return `true` to redraw the display. + /// Sets the per-frame callback, replacing any previous one; return `true` to redraw. public static func setUpdateCallback(_ callback: @escaping () -> Bool) { updateCallback = callback api.pointee.setUpdateCallback.unsafelyUnwrapped({ _ in @@ -110,23 +106,23 @@ extension System { nonisolated(unsafe) private static var updateCallback: (() -> Bool)? - /// Draws the current frames-per-second value at the given point. + /// Draws the current FPS at (`x`, `y`). public static func drawFPS(x: Int = 0, y: Int = 0) { api.pointee.drawFPS.unsafelyUnwrapped(Int32(x), Int32(y)) } // MARK: - Input - /// The current button state: held, pressed this frame, released this frame. + /// Buttons held now, and those pushed or released during the previous update. public static var buttonState: (current: Buttons, pushed: Buttons, released: Buttons) { var current = PDButtons(0), pushed = PDButtons(0), released = PDButtons(0) api.pointee.getButtonState.unsafelyUnwrapped(¤t, &pushed, &released) return (Buttons(current), Buttons(pushed), Buttons(released)) } - /// Installs a callback invoked for every button press/release. `queueSize` - /// sets how many events are buffered between frames. The return value of - /// the callback is reserved by the OS; return 0. + /// Calls `callback` per button down/up in the previous update, replacing any previous + /// one; `nil` removes it. `queueSize`: events buffered per update (5 suffices at 30 FPS). + /// `callback` returns 0, or non-zero to signal an error. public static func setButtonCallback(queueSize: Int = 5, _ callback: ((_ button: Buttons, _ isDown: Bool, _ when: UInt32) -> Int32)?) { buttonCallback = callback @@ -141,45 +137,42 @@ extension System { nonisolated(unsafe) private static var buttonCallback: ((Buttons, Bool, UInt32) -> Int32)? - /// Enables the given peripherals (e.g. the accelerometer), disabling - /// the rest. + /// Enables `peripherals`, disabling the rest; accelerometer data arrives next update. public static func setPeripheralsEnabled(_ peripherals: Peripherals) { api.pointee.setPeripheralsEnabled.unsafelyUnwrapped(PDPeripherals(PDPeripherals.RawValue(peripherals.rawValue))) } - /// The most recent accelerometer reading, in g. Enable the accelerometer - /// with `setPeripheralsEnabled(.accelerometer)` first. + /// Last reading, in g; requires `setPeripheralsEnabled(.accelerometer)`. public static var accelerometer: (x: Float, y: Float, z: Float) { var x: Float = 0, y: Float = 0, z: Float = 0 api.pointee.getAccelerometer.unsafelyUnwrapped(&x, &y, &z) return (x, y, z) } - /// Degrees the crank moved since the last frame. + /// Degrees moved since last read; negative is counterclockwise. public static var crankChange: Float { api.pointee.getCrankChange.unsafelyUnwrapped() } - /// The crank position in degrees; 0 points along the +Y axis. + /// Degrees, 0...360; 0 points up, increasing clockwise viewed from the right side. public static var crankAngle: Float { api.pointee.getCrankAngle.unsafelyUnwrapped() } - /// Whether the crank is folded into the device. public static var isCrankDocked: Bool { api.pointee.isCrankDocked.unsafelyUnwrapped() != 0 } - /// Disables or enables the crank dock/undock sounds. Returns the previous setting. + /// Toggles crank dock/undock sounds; returns the previous `disabled` value. @discardableResult public static func setCrankSoundsDisabled(_ disabled: Bool) -> Bool { api.pointee.setCrankSoundsDisabled.unsafelyUnwrapped(disabled ? 1 : 0) != 0 } - /// Whether the user has the "flipped" system setting enabled. + /// The user's "flipped" system setting. public static var isFlipped: Bool { api.pointee.getFlipped.unsafelyUnwrapped() != 0 } - /// Disables or re-enables the automatic screen lock. + /// Toggles the 3-minute auto lock; either call resets its timer. public static func setAutoLockDisabled(_ disabled: Bool) { api.pointee.setAutoLockDisabled.unsafelyUnwrapped(disabled ? 1 : 0) } - /// Installs a callback invoked when a message is received on the serial port - /// via `msg `. + /// Calls `callback` for serial `msg ` messages; `nil` removes it. One closure + /// at a time. public static func setSerialMessageCallback(_ callback: ((String) -> Void)?) { serialMessageCallback = callback if callback != nil { @@ -196,6 +189,7 @@ extension System { // MARK: - System menu + // Retains items until removed; the OS holds only unretained userdata pointers. nonisolated(unsafe) private static var liveMenuItems: [MenuItem] = [] private static let menuItemTrampoline: @convention(c) (UnsafeMutableRawPointer?) -> Void = { userdata in @@ -204,23 +198,24 @@ extension System { item.onSelect(item) } - /// Adds a plain menu item to the system menu. + /// Adds an action item; `onSelect` runs when picked. `nil` if the OS can't add it. @discardableResult public static func addMenuItem(title: String, onSelect: @escaping (MenuItem) -> Void) -> MenuItem? { var item: MenuItem? - title.withPlaydateCString { cTitle in + title.withCString { cTitle in let pointer = api.pointee.addMenuItem.unsafelyUnwrapped(cTitle, menuItemTrampoline, nil) item = MenuItem(pointer: pointer, onSelect: onSelect) } return registered(item) } - /// Adds a menu item with a checkbox. + /// Adds a checkmark item; `onSelect` runs when the menu closes after a toggle. + /// `nil` if the OS can't add it. @discardableResult public static func addCheckmarkMenuItem(title: String, isChecked: Bool = false, onSelect: @escaping (MenuItem) -> Void) -> MenuItem? { var item: MenuItem? - title.withPlaydateCString { cTitle in + title.withCString { cTitle in let pointer = api.pointee.addCheckmarkMenuItem.unsafelyUnwrapped( cTitle, isChecked ? 1 : 0, menuItemTrampoline, nil) item = MenuItem(pointer: pointer, onSelect: onSelect) @@ -228,16 +223,16 @@ extension System { return registered(item) } - /// Adds a menu item that cycles through the given options. + /// Adds an item cycling through `options`; `onSelect` runs when the menu closes + /// after a change. `nil` if the OS can't add it. @discardableResult public static func addOptionsMenuItem(title: String, options: [String], onSelect: @escaping (MenuItem) -> Void) -> MenuItem? { - // The OS keeps the option title pointers, so copy and retain them for - // the lifetime of the menu item. + // The OS keeps the title pointers; the copies live until the item is removed. let copies = options.map { $0.copiedPlaydateCString() } var cOptions: [UnsafePointer?] = copies.map { UnsafePointer($0) } var item: MenuItem? - title.withPlaydateCString { cTitle in + title.withCString { cTitle in cOptions.withUnsafeMutableBufferPointer { buffer in let pointer = api.pointee.addOptionsMenuItem.unsafelyUnwrapped( cTitle, buffer.baseAddress, Int32(options.count), menuItemTrampoline, nil) @@ -247,7 +242,7 @@ extension System { return registered(item) } - /// Registers the wrapper as the item's userdata and keeps it alive. + /// Sets `item` as its own userdata and retains it until removed. private static func registered(_ item: MenuItem?) -> MenuItem? { guard let item else { return nil } api.pointee.setMenuItemUserdata.unsafelyUnwrapped( @@ -256,62 +251,68 @@ extension System { return item } + /// Removes `item`; the OS frees it, so don't use `item` afterwards. public static func removeMenuItem(_ item: MenuItem) { api.pointee.removeMenuItem.unsafelyUnwrapped(item.pointer) item.deallocateRetainedTitles() liveMenuItems.removeAll { $0 === item } } + /// Removes all custom items; existing `MenuItem`s must not be used afterwards. public static func removeAllMenuItems() { api.pointee.removeAllMenuItems.unsafelyUnwrapped() for item in liveMenuItems { item.deallocateRetainedTitles() } liveMenuItems = [] } - /// Sets a custom image for the pause menu, optionally shifted left by - /// `xOffset` (0...200). + /// Sets the 400x240 menu image; only its left 200 px stay visible. `xOffset` + /// (0...200 px) shifts it left as the menu animates in. public static func setMenuImage(_ bitmap: Graphics.Bitmap?, xOffset: Int = 0) { api.pointee.setMenuImage.unsafelyUnwrapped(bitmap?.pointer, Int32(xOffset)) } // MARK: - Device state - /// Whether the user has enabled the "reduce flashing" accessibility setting. + /// The user's "reduce flashing" accessibility setting. public static var reduceFlashing: Bool { api.pointee.getReduceFlashing.unsafelyUnwrapped() != 0 } - /// Battery charge, 0...100. + /// 0 (empty)...100 (full). public static var batteryPercentage: Float { api.pointee.getBatteryPercentage.unsafelyUnwrapped() } - /// The battery voltage, in volts. + /// In volts. public static var batteryVoltage: Float { api.pointee.getBatteryVoltage.unsafelyUnwrapped() } - /// Flushes the CPU instruction cache after loading code at runtime. + /// Flushes the CPU instruction cache; needed only after modifying code at runtime. public static func clearICache() { api.pointee.clearICache.unsafelyUnwrapped() } - /// Quits the current game and restarts it with the given launch arguments. + /// Reinitializes the runtime and restarts the game with `launchArguments`. public static func restartGame(launchArguments: String? = nil) { if let launchArguments { - launchArguments.withPlaydateCString { api.pointee.restartGame.unsafelyUnwrapped($0) } + launchArguments.withCString { api.pointee.restartGame.unsafelyUnwrapped($0) } } else { api.pointee.restartGame.unsafelyUnwrapped(nil) } } - /// The arguments the game was launched with, and the path of the pdx. + /// Launch arguments (simulator command line, device `run`, or `restartGame`) and + /// the loaded game's path. public static var launchArguments: (arguments: String?, path: String?) { var path: UnsafePointer? let arguments = api.pointee.getLaunchArgs.unsafelyUnwrapped(&path) return (String(playdateCString: arguments), String(playdateCString: path)) } - /// Sends data over the mirror connection. Returns `false` if mirroring is - /// not active or the send fails. + /// Sends `data` with `command` over Mirror; `false` if not mirroring or the send fails. @discardableResult - public static func sendMirrorData(command: UInt8, data: UnsafeMutableRawBufferPointer) -> Bool { - api.pointee.sendMirrorData.unsafelyUnwrapped(command, data.baseAddress, Int32(data.count)) + public static func sendMirrorData(command: UInt8, data: Span) -> Bool { + data.withUnsafeBufferPointer { buffer in + // The C API takes a non-const pointer but only reads the data. + api.pointee.sendMirrorData.unsafelyUnwrapped( + command, UnsafeMutableRawPointer(mutating: buffer.baseAddress), Int32(buffer.count)) + } } - /// OS, language, and pdx version information. + /// OS version, system language, and the SDK version the game was built with. public static var info: Info { let info = api.pointee.getSystemInfo.unsafelyUnwrapped().unsafelyUnwrapped.pointee return Info(osVersion: info.osversion, @@ -319,9 +320,10 @@ extension System { pdxVersion: info.pdxversion) } - /// Looks up a localized string by key from the game's strings files. + /// Looks up `key` in `language`'s `.strings` file; `nil` if the key or file is missing. + /// `.system` falls back to the other language's file if the system one can't load. public static func localizedText(forKey key: String, language: Language = .system) -> String? { - key.withPlaydateCString { cKey in + key.withCString { cKey in guard let cString = api.pointee.getLocalizedText.unsafelyUnwrapped(cKey, language.cValue) else { return nil } @@ -331,14 +333,13 @@ extension System { } } - /// The system volume, 0...1. + /// Menu volume, 0...1. public static var volume: Float { api.pointee.getVolume.unsafelyUnwrapped() } - /// The battery and power supply state. public static var powerStatus: PowerStatus { PowerStatus(rawValue: UInt32(api.pointee.getPowerStatus.unsafelyUnwrapped().rawValue)) } - /// Quits the game and returns to the launcher. + /// Sends the game `kEventTerminate`, then quits to the launcher. public static func exitToLauncher() { api.pointee.exitToLauncher.unsafelyUnwrapped() } } diff --git a/Tests/PlaydateKit/MockPlaydate.swift b/Tests/PlaydateKit/MockPlaydate.swift index 8d21f1a..64e0edc 100644 --- a/Tests/PlaydateKit/MockPlaydate.swift +++ b/Tests/PlaydateKit/MockPlaydate.swift @@ -24,8 +24,10 @@ enum Mock { nonisolated(unsafe) static let soundEffectAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let lfoAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let delayLineAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let tilemapAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let networkAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer.allocate(capacity: 1) // MARK: - Recordings @@ -35,6 +37,18 @@ enum Mock { nonisolated(unsafe) static var buttonState: (current: UInt32, pushed: UInt32, released: UInt32) = (0, 0, 0) /// The 16 bytes behind the last pattern `LCDColor` seen by a stub. nonisolated(unsafe) static var patternBytes: [UInt8] = [] + /// The 8 rows last handed to `setStencilPattern`. + nonisolated(unsafe) static var stencilRows: [UInt8] = [] + /// Caps the bytes a file read returns (0 = end of file, negative = + /// error); `nil` fills the whole request. + nonisolated(unsafe) static var fileReadLimit: Int32? + /// Bytes left to read before 0 (end of file); `nil` never ends. + nonisolated(unsafe) static var fileBytesRemaining: Int32? + /// The last `network->setEnabled` callback. + nonisolated(unsafe) static var networkEnabledCallback: (@convention(c) (PDNetErr) -> Void)? + /// The index buffer and count last handed to `setTiles`. + nonisolated(unsafe) static var tilesPointer: UnsafeMutablePointer? + nonisolated(unsafe) static var tilesCount: Int32 = 0 /// Userdata stored per sprite / menu item, as the OS would keep it. nonisolated(unsafe) static var spriteUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:] nonisolated(unsafe) static var menuUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:] @@ -83,6 +97,12 @@ enum Mock { events = [] buttonState = (0, 0, 0) patternBytes = [] + stencilRows = [] + fileReadLimit = nil + fileBytesRemaining = nil + networkEnabledCallback = nil + tilesPointer = nil + tilesCount = 0 spriteUserdata = [:] menuUserdata = [:] menuCallback = nil @@ -102,6 +122,7 @@ enum Mock { installSound() installFile() installJSON() + installNetwork() apiStruct.initialize(to: PlaydateAPI( system: UnsafePointer(sysAPI), file: UnsafePointer(fileAPI), @@ -112,7 +133,7 @@ enum Mock { lua: nil, json: UnsafePointer(jsonAPI), scoreboards: nil, - network: nil)) + network: UnsafePointer(networkAPI))) Playdate.initialize(with: UnsafeMutableRawPointer(apiStruct)) } @@ -170,6 +191,19 @@ enum Mock { private static func installGraphics() { gfxAPI.initialize(to: playdate_graphics()) + tilemapAPI.initialize(to: playdate_tilemap()) + gfxAPI.pointee.tilemap = UnsafePointer(tilemapAPI) + + tilemapAPI.pointee.newTilemap = { + Mock.record("newTilemap") + return Mock.fakePointer() + } + tilemapAPI.pointee.freeTilemap = { _ in Mock.record("freeTilemap") } + tilemapAPI.pointee.setTiles = { _, indexes, count, rowWidth in + Mock.record("setTiles(\(count),\(rowWidth))") + Mock.tilesPointer = indexes + Mock.tilesCount = count + } gfxAPI.pointee.fillRect = { x, y, width, height, color in if color > 3, let pattern = UnsafeRawPointer(bitPattern: color) { @@ -194,6 +228,10 @@ enum Mock { gfxAPI.pointee.freeBitmap = { _ in Mock.record("freeBitmap") } + gfxAPI.pointee.getBitmapMask = { _ in + Mock.record("getBitmapMask") + return Mock.fakePointer() + } gfxAPI.pointee.newBitmapTable = { count, width, height in Mock.record("newBitmapTable(\(count))") @@ -223,6 +261,9 @@ enum Mock { Mock.record("newSprite") return Mock.fakePointer() } + spriteAPI.pointee.setStencilPattern = { _, pattern in + Mock.stencilRows = Array(UnsafeBufferPointer(start: pattern, count: 8)) + } spriteAPI.pointee.freeSprite = { _ in Mock.record("freeSprite") } @@ -400,9 +441,14 @@ enum Mock { return 0 } fileAPI.pointee.read = { _, buffer, length in - memset(buffer, 0xAB, Int(length)) Mock.record("read(\(length))") - return Int32(length) + var count = min(Int32(length), Mock.fileReadLimit ?? Int32(length)) + if let remaining = Mock.fileBytesRemaining { + count = min(count, remaining) + Mock.fileBytesRemaining = remaining - max(count, 0) + } + if count > 0 { memset(buffer, 0xAB, Int(count)) } + return count } fileAPI.pointee.write = { _, _, length in Mock.record("write(\(length))") @@ -410,6 +456,16 @@ enum Mock { } } + // MARK: - Network + + private static func installNetwork() { + networkAPI.initialize(to: playdate_network()) + networkAPI.pointee.setEnabled = { flag, callback in + Mock.record("setEnabled(\(flag),\(callback == nil ? "nil" : "callback"))") + Mock.networkEnabledCallback = callback + } + } + // MARK: - JSON /// Simulates the OS parser's callback sequence for the document @@ -417,6 +473,23 @@ enum Mock { private static func installJSON() { jsonAPI.initialize(to: playdate_json()) + // Reads until the reader returns 0 or less, then decodes `null`. + jsonAPI.pointee.decode = { _, reader, outval in + var buffer = [UInt8](repeating: 0, count: 16) + var total: Int32 = 0, last: Int32 = 0 + for _ in 0..<64 { + last = buffer.withUnsafeMutableBufferPointer { buffer in + reader.read?(reader.userdata, buffer.baseAddress, Int32(buffer.count)) ?? -1 + } + guard last > 0 else { break } + total += last + } + Mock.record("decode(total:\(total),end:\(last))") + outval?.pointee = json_value() + outval?.pointee.type = CChar(kJSONNull.rawValue) + return 1 + } + jsonAPI.pointee.decodeString = { decoder, _, outval in guard let decoder else { return 0 } diff --git a/Tests/PlaydateKit/PlaydateKitTests.swift b/Tests/PlaydateKit/PlaydateKitTests.swift index 8b06c17..07bd131 100644 --- a/Tests/PlaydateKit/PlaydateKitTests.swift +++ b/Tests/PlaydateKit/PlaydateKitTests.swift @@ -69,8 +69,7 @@ import Testing defer { copy.deallocate() } #expect(String(playdateCString: copy) == original) - let viaClosure = original.withPlaydateCString { String(playdateCString: $0) } - #expect(viaClosure == original) + #expect(String(playdateCString: nil) == nil) } @Test func dateTimeMirrorsCStruct() { diff --git a/Tests/PlaydateKit/WrapperTests.swift b/Tests/PlaydateKit/WrapperTests.swift index 793772a..2596691 100644 --- a/Tests/PlaydateKit/WrapperTests.swift +++ b/Tests/PlaydateKit/WrapperTests.swift @@ -64,6 +64,24 @@ struct WrapperTests { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) } + @Test func inlineArrayPatternMatchesTheTupleOne() throws { + guard #available(macOS 26, *) else { return } + // An array literal picks the InlineArray overload; a tuple literal + // still picks the tuple one. + let inline = Graphics.Pattern(rows: [1, 2, 3, 4, 5, 6, 7, 8]) + let tuple = Graphics.Pattern(rows: (1, 2, 3, 4, 5, 6, 7, 8)) + #expect(withUnsafeBytes(of: inline.bytes, Array.init) + == withUnsafeBytes(of: tuple.bytes, Array.init)) + + Graphics.fillRect(x: 0, y: 0, width: 8, height: 8, color: .pattern(inline)) + #expect(Mock.patternBytes == [1, 2, 3, 4, 5, 6, 7, 8, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) + + var pattern = inline + pattern.inlineBytes[8] = 0x0f + #expect(pattern.bytes.8 == 0x0f) + } + @Test func drawTextSendsUTF8BytesAndLength() { let width = Graphics.drawText("Hëllo", x: 4, y: 6) #expect(Mock.events == ["drawText(Hëllo,enc:\(kUTF8Encoding.rawValue),4,6)"]) @@ -99,6 +117,33 @@ struct WrapperTests { #expect(visited == 1) } + @Test func tileMapSetTilesPassesTheCallersStorageWithoutCopying() { + let tileMap = Graphics.TileMap() + let indexes: [UInt16] = [1, 2, 3, 4, 5, 6] + tileMap.setTiles(indexes, rowWidth: 3) + + #expect(Mock.events.contains("setTiles(6,3)")) + let storage = indexes.withUnsafeBufferPointer { $0.baseAddress } + #expect(UnsafePointer(Mock.tilesPointer) == storage) + } + + @Test func bitmapMaskIsFreedAndKeepsItsBitmapAlive() { + weak var weakBitmap: Graphics.Bitmap? + var mask: Graphics.Bitmap? + do { + let bitmap = Graphics.Bitmap(width: 8, height: 8) + weakBitmap = bitmap + mask = bitmap.mask + } + #expect(mask != nil) + #expect(weakBitmap != nil) // the mask keeps it alive + #expect(Mock.eventCount("freeBitmap") == 0) + + mask = nil + #expect(weakBitmap == nil) + #expect(Mock.eventCount("freeBitmap") == 2) // the mask, then the bitmap + } + // MARK: Sprite @Test func spriteUserdataRecoversWrapperInCallbacks() { @@ -111,6 +156,17 @@ struct WrapperTests { #expect(updated == [ObjectIdentifier(sprite)]) } + @Test func spriteStencilPatternOverloadsSendTheSameRows() { + let sprite = Sprite() + sprite.setStencilPattern((1, 2, 3, 4, 5, 6, 7, 8)) + #expect(Mock.stencilRows == [1, 2, 3, 4, 5, 6, 7, 8]) + + guard #available(macOS 26, *) else { return } + Mock.stencilRows = [] + sprite.setStencilPattern([1, 2, 3, 4, 5, 6, 7, 8]) + #expect(Mock.stencilRows == [1, 2, 3, 4, 5, 6, 7, 8]) + } + @Test func spriteIsFreedOnDeinitAndNotWhileReferenced() { var sprite: Sprite? = Sprite() _ = sprite @@ -146,7 +202,8 @@ struct WrapperTests { var produced = 0 let source = Sound.addSource(stereo: false) { left, right in produced += left.count - #expect(right == nil) + let isMono = right.isEmpty // #expect cannot capture a span + #expect(isMono) return true } #expect(Sound.CallbackSource.live.count == baseline + 1) @@ -280,7 +337,8 @@ struct WrapperTests { let effect = Sound.Effect(processor: { left, right, _ in _ = token processed += left.count - #expect(right == nil) + let isMono = right.isEmpty // #expect cannot capture a span + #expect(isMono) return true }) @@ -343,6 +401,54 @@ struct WrapperTests { #expect(Mock.eventCount("close") == 1) } + @Test func jsonDecodeFileReadsThroughTheHandleAndClosesIt() throws { + Mock.fileBytesRemaining = 20 + let value = try JSON.decodeFile(path: "save.json") + guard case .null = value else { + Issue.record("expected .null, got \(value)") + return + } + // End of file reaches the decoder as 0, not -1. + #expect(Mock.events.contains("decode(total:20,end:0)")) + #expect(Mock.eventCount("close") == 1) + } + + @Test func fileHandleClosesWhenItGoesOutOfScope() throws { + do { + let handle = try File.Handle(path: "save.dat", mode: .write) + _ = try handle.write([1]) + #expect(Mock.eventCount("close") == 0) + } + #expect(Mock.eventCount("close") == 1) + } + + @Test func fileHandleReadLengthReturnsOnlyTheBytesRead() throws { + let handle = try File.Handle(path: "save.dat", mode: [.read, .readData]) + + Mock.fileReadLimit = 3 + #expect(try handle.read(length: 8) == [0xAB, 0xAB, 0xAB]) + + Mock.fileReadLimit = 0 + #expect(try handle.read(length: 8).isEmpty) + + Mock.fileReadLimit = -1 + #expect(throws: PlaydateError.self) { try handle.read(length: 8) } + } + + // MARK: Network + + @Test func networkDisableRegistersNoCallbackSoEnableGetsItsOwnResult() { + var results: [String] = [] + Network.disable() + #expect(Mock.events.last == "setEnabled(false,nil)") + + Network.enable { error in results.append(error == nil ? "ok" : "failed") } + #expect(Mock.events.last == "setEnabled(true,callback)") + + Mock.networkEnabledCallback?(NET_OK) + #expect(results == ["ok"]) + } + // MARK: JSON @Test func jsonDecodeBuildsTheValueTree() throws {