Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a561b91f | ||
|
|
d84113f7a8 | ||
|
|
d233cbaddb | ||
|
|
0d0b397ce1 | ||
|
|
41558399e3 | ||
|
|
2b046005d1 | ||
|
|
8b002d9be8 |
Binary file not shown.
@@ -32,7 +32,7 @@ final class Game {
|
||||
private let boxSize = 24
|
||||
|
||||
func start() {
|
||||
Display.setRefreshRate(50)
|
||||
Display.refreshRate = 50
|
||||
|
||||
System.addMenuItem(title: "reset") { _ in
|
||||
Game.shared.reset()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Javier Cicchelli
|
||||
Copyright (c) 2026 Röck+Cöde VoF
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -103,7 +103,7 @@ final class Game {
|
||||
var player = Sprite()
|
||||
|
||||
func start() {
|
||||
Display.setRefreshRate(50)
|
||||
Display.refreshRate = 50
|
||||
|
||||
System.setUpdateCallback {
|
||||
self.update()
|
||||
@@ -253,7 +253,7 @@ try save.write(JSON.encode(.table([
|
||||
])))
|
||||
try save.close()
|
||||
|
||||
let loaded = try JSON.decodeFile(at: "save.json")
|
||||
let loaded = try JSON.decodeFile(path: "save.json")
|
||||
if case .table(let entries) = loaded, case .int(let level)? = entries["level"] {
|
||||
Game.shared.level = level
|
||||
}
|
||||
@@ -300,6 +300,8 @@ 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
|
||||
|
||||
@@ -13,15 +13,13 @@ extension Display {
|
||||
/// The display height in pixels, taking the current scale into account.
|
||||
public static var height: Int { Int(api.pointee.getHeight.unsafelyUnwrapped()) }
|
||||
|
||||
/// Sets the nominal refresh rate in frames per second. Pass 0 to update
|
||||
/// The nominal refresh rate in frames per second. Set to 0 to update
|
||||
/// as fast as possible (the update callback drives the pace).
|
||||
public static func setRefreshRate(_ rate: Float) {
|
||||
api.pointee.setRefreshRate.unsafelyUnwrapped(rate)
|
||||
public static var refreshRate: Float {
|
||||
get { api.pointee.getRefreshRate.unsafelyUnwrapped() }
|
||||
set { api.pointee.setRefreshRate.unsafelyUnwrapped(newValue) }
|
||||
}
|
||||
|
||||
/// The current nominal refresh rate.
|
||||
public static var refreshRate: Float { api.pointee.getRefreshRate.unsafelyUnwrapped() }
|
||||
|
||||
/// The measured average frames per second.
|
||||
public static var fps: Float { api.pointee.getFPS.unsafelyUnwrapped() }
|
||||
|
||||
|
||||
@@ -56,3 +56,17 @@ extension Graphics {
|
||||
public var count: Int { info.count }
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
public subscript(position: Int) -> Graphics.Bitmap {
|
||||
guard let bitmap = bitmap(at: position) else {
|
||||
preconditionFailure("bitmap table index out of range")
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,21 @@ extension Graphics {
|
||||
gfx.pointee.setClipRect.unsafelyUnwrapped(Int32(x), Int32(y), Int32(width), Int32(height))
|
||||
}
|
||||
|
||||
/// Sets the clip rect in world coordinates (affected by the draw offset).
|
||||
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).
|
||||
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).
|
||||
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()
|
||||
@@ -103,6 +113,11 @@ extension Graphics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the outline of a rectangle, 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 {
|
||||
@@ -110,6 +125,11 @@ extension Graphics {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
public static func drawRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int,
|
||||
@@ -120,6 +140,13 @@ extension Graphics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the outline of a rectangle with rounded corners, stroked with
|
||||
/// `lineWidth`.
|
||||
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.
|
||||
public static func fillRoundRect(x: Int, y: Int, width: Int, height: Int, radius: Int, color: Color) {
|
||||
color.withLCDColor {
|
||||
@@ -128,6 +155,12 @@ extension Graphics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills a rectangle with rounded corners.
|
||||
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).
|
||||
public static func drawEllipse(x: Int, y: Int, width: Int, height: Int, lineWidth: Int,
|
||||
@@ -148,6 +181,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).
|
||||
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).
|
||||
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.
|
||||
public static func fillPolygon(points: [(x: Int, y: Int)], color: Color,
|
||||
@@ -205,19 +254,22 @@ extension Graphics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `text` wrapped and aligned inside the given rectangle.
|
||||
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.
|
||||
public static func setFont(_ font: Font) {
|
||||
gfx.pointee.setFont.unsafelyUnwrapped(font.pointer)
|
||||
}
|
||||
|
||||
/// Extra space added between letters, in pixels.
|
||||
public static func setTextTracking(_ tracking: Int) {
|
||||
gfx.pointee.setTextTracking.unsafelyUnwrapped(Int32(tracking))
|
||||
}
|
||||
|
||||
/// The extra space currently added between letters, in pixels.
|
||||
public static var textTracking: Int {
|
||||
Int(gfx.pointee.getTextTracking.unsafelyUnwrapped())
|
||||
get { Int(gfx.pointee.getTextTracking.unsafelyUnwrapped()) }
|
||||
set { gfx.pointee.setTextTracking.unsafelyUnwrapped(Int32(newValue)) }
|
||||
}
|
||||
|
||||
/// Adjusts the line height used when drawing multi-line text.
|
||||
|
||||
@@ -33,6 +33,12 @@ 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)
|
||||
|
||||
@@ -4,7 +4,7 @@ extension JSON {
|
||||
/// A streaming JSON encoder writing into a string. Wraps `json_encoder`.
|
||||
public final class Encoder {
|
||||
private final class Output {
|
||||
var text = ""
|
||||
var bytes: [UInt8] = []
|
||||
}
|
||||
|
||||
private var encoder = json_encoder()
|
||||
@@ -14,13 +14,12 @@ extension JSON {
|
||||
jsonAPI.pointee.initEncoder.unsafelyUnwrapped(&encoder, { userdata, string, length in
|
||||
guard let userdata, let string else { return }
|
||||
let output = Unmanaged<Output>.fromOpaque(userdata).takeUnretainedValue()
|
||||
let bytes = UnsafeRawBufferPointer(start: string, count: Int(length))
|
||||
output.text += String(decoding: bytes, as: UTF8.self)
|
||||
output.bytes.append(contentsOf: UnsafeRawBufferPointer(start: string, count: Int(length)))
|
||||
}, Unmanaged.passUnretained(output).toOpaque(), pretty ? 1 : 0)
|
||||
}
|
||||
|
||||
/// The JSON produced so far.
|
||||
public var json: String { output.text }
|
||||
public var json: String { String(decoding: output.bytes, as: UTF8.self) }
|
||||
|
||||
/// Starts a JSON array.
|
||||
public func startArray() {
|
||||
@@ -44,9 +43,10 @@ extension JSON {
|
||||
|
||||
/// Call before writing each table value.
|
||||
public func addTableMember(name: String) {
|
||||
name.withPlaydateCString { cName in
|
||||
name.withPlaydateUTF8 { bytes, count in
|
||||
withUnsafeMutablePointer(to: &encoder) {
|
||||
$0.pointee.addTableMember.unsafelyUnwrapped($0, cName, Int32(name.utf8.count))
|
||||
$0.pointee.addTableMember.unsafelyUnwrapped(
|
||||
$0, bytes.assumingMemoryBound(to: CChar.self), Int32(count))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,10 @@ extension JSON {
|
||||
|
||||
/// Writes a string value.
|
||||
public func writeString(_ value: String) {
|
||||
value.withPlaydateCString { cString in
|
||||
value.withPlaydateUTF8 { bytes, count in
|
||||
withUnsafeMutablePointer(to: &encoder) {
|
||||
$0.pointee.writeString.unsafelyUnwrapped($0, cString, Int32(value.utf8.count))
|
||||
$0.pointee.writeString.unsafelyUnwrapped(
|
||||
$0, bytes.assumingMemoryBound(to: CChar.self), Int32(count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ extension JSON {
|
||||
}
|
||||
|
||||
/// Opens and decodes the JSON file at `path`.
|
||||
public static func decodeFile(at path: String) throws(PlaydateError) -> Value {
|
||||
public static func decodeFile(path: String) throws(PlaydateError) -> Value {
|
||||
let file = try File.Handle(path: path, mode: [.read, .readData])
|
||||
return try decode(file: file)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// A public import: `addFunction(_:name:)` and `pushFunction(_:)` expose the
|
||||
// `CFunction` alias of `lua_CFunction` in their public signatures.
|
||||
public import CPlaydate
|
||||
internal import CPlaydate
|
||||
|
||||
/// The cached `playdate->lua` C API table.
|
||||
var luaAPI: UnsafePointer<playdate_lua> { Playdate.luaAPI.unsafelyUnwrapped }
|
||||
|
||||
@@ -31,7 +31,7 @@ final class Game {
|
||||
nonisolated(unsafe) static let shared = Game()
|
||||
|
||||
func start() {
|
||||
Display.setRefreshRate(50)
|
||||
Display.refreshRate = 50
|
||||
|
||||
System.setUpdateCallback {
|
||||
self.update()
|
||||
|
||||
@@ -119,13 +119,12 @@ extension Sound {
|
||||
}
|
||||
|
||||
/// Modulates the channel's volume.
|
||||
public func setVolumeModulator(_ modulator: SignalValue?) {
|
||||
retain(modulator)
|
||||
Channel.api.pointee.setVolumeModulator.unsafelyUnwrapped(pointer, modulator?.pointer)
|
||||
}
|
||||
|
||||
public var volumeModulator: SignalValue? {
|
||||
SignalValue.wrap(Channel.api.pointee.getVolumeModulator.unsafelyUnwrapped(pointer))
|
||||
get { SignalValue.wrap(Channel.api.pointee.getVolumeModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
Channel.api.pointee.setVolumeModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel's stereo pan: -1 (left) to 1 (right).
|
||||
@@ -135,13 +134,12 @@ extension Sound {
|
||||
|
||||
/// Modulates the channel's pan. The signal's range 0...1 maps to
|
||||
/// left...right.
|
||||
public func setPanModulator(_ modulator: SignalValue?) {
|
||||
retain(modulator)
|
||||
Channel.api.pointee.setPanModulator.unsafelyUnwrapped(pointer, modulator?.pointer)
|
||||
}
|
||||
|
||||
public var panModulator: SignalValue? {
|
||||
SignalValue.wrap(Channel.api.pointee.getPanModulator.unsafelyUnwrapped(pointer))
|
||||
get { SignalValue.wrap(Channel.api.pointee.getPanModulator.unsafelyUnwrapped(pointer)) }
|
||||
set {
|
||||
retain(newValue)
|
||||
Channel.api.pointee.setPanModulator.unsafelyUnwrapped(pointer, newValue?.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
/// A signal following the channel's dry (unprocessed) level.
|
||||
|
||||
@@ -38,10 +38,16 @@ extension Sound {
|
||||
}
|
||||
|
||||
deinit {
|
||||
if isOwned {
|
||||
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
|
||||
// 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.
|
||||
if let processorBox {
|
||||
if isOwned {
|
||||
effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer)
|
||||
}
|
||||
processorBox.release()
|
||||
}
|
||||
processorBox?.release()
|
||||
}
|
||||
|
||||
/// The wet/dry mix: 1 is fully processed, 0 fully dry.
|
||||
|
||||
@@ -16,21 +16,19 @@ extension Sound {
|
||||
self.isOwned = isOwned
|
||||
}
|
||||
|
||||
/// Sets the playback volume for the left and right channels, 0...1.
|
||||
public func setVolume(left: Float, right: Float) {
|
||||
Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right)
|
||||
/// The playback volume of the left and right channels, 0...1.
|
||||
public var volume: (left: Float, right: Float) {
|
||||
get {
|
||||
var left: Float = 0, right: Float = 0
|
||||
Source.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right)
|
||||
return (left, right)
|
||||
}
|
||||
set { Source.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
|
||||
}
|
||||
|
||||
/// Sets the playback volume of both channels.
|
||||
public func setVolume(_ volume: Float) {
|
||||
setVolume(left: volume, right: volume)
|
||||
}
|
||||
|
||||
/// The playback volume of the left and right channels.
|
||||
public var volume: (left: Float, right: Float) {
|
||||
var left: Float = 0, right: Float = 0
|
||||
Source.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right)
|
||||
return (left, right)
|
||||
self.volume = (volume, volume)
|
||||
}
|
||||
|
||||
public var isPlaying: Bool {
|
||||
|
||||
@@ -93,16 +93,14 @@ extension Sound {
|
||||
Instrument.api.pointee.allNotesOff.unsafelyUnwrapped(pointer, when)
|
||||
}
|
||||
|
||||
/// Sets the volume of the left and right channels, 0...1.
|
||||
public func setVolume(left: Float, right: Float) {
|
||||
Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, left, right)
|
||||
}
|
||||
|
||||
/// The volume of the left and right channels.
|
||||
/// The volume of the left and right channels, 0...1.
|
||||
public var volume: (left: Float, right: Float) {
|
||||
var left: Float = 0, right: Float = 0
|
||||
Instrument.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right)
|
||||
return (left, right)
|
||||
get {
|
||||
var left: Float = 0, right: Float = 0
|
||||
Instrument.api.pointee.getVolume.unsafelyUnwrapped(pointer, &left, &right)
|
||||
return (left, right)
|
||||
}
|
||||
set { Instrument.api.pointee.setVolume.unsafelyUnwrapped(pointer, newValue.left, newValue.right) }
|
||||
}
|
||||
|
||||
/// The number of voices currently playing.
|
||||
|
||||
@@ -15,9 +15,9 @@ extension Sound {
|
||||
}
|
||||
|
||||
/// Creates a sequence and loads the MIDI file at `path`.
|
||||
public convenience init(midiFilePath: String) throws(PlaydateError) {
|
||||
public convenience init(path: String) throws(PlaydateError) {
|
||||
self.init()
|
||||
try loadMIDIFile(path: midiFilePath)
|
||||
try loadMIDIFile(path: path)
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -20,6 +20,10 @@ enum Mock {
|
||||
nonisolated(unsafe) static let spriteAPI = UnsafeMutablePointer<playdate_sprite>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let soundAPI = UnsafeMutablePointer<playdate_sound>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let channelAPI = UnsafeMutablePointer<playdate_sound_channel>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let synthAPI = UnsafeMutablePointer<playdate_sound_synth>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let soundEffectAPI = UnsafeMutablePointer<playdate_sound_effect>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let lfoAPI = UnsafeMutablePointer<playdate_sound_lfo>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let delayLineAPI = UnsafeMutablePointer<playdate_sound_effect_delayline>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer<playdate_file>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer<playdate_json>.allocate(capacity: 1)
|
||||
nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer<PlaydateAPI>.allocate(capacity: 1)
|
||||
@@ -39,6 +43,19 @@ enum Mock {
|
||||
nonisolated(unsafe) static var spriteUpdateCallback: (@convention(c) (OpaquePointer?) -> Void)?
|
||||
nonisolated(unsafe) static var audioCallback: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int16>?, UnsafeMutablePointer<Int16>?, Int32) -> Int32)?
|
||||
nonisolated(unsafe) static var audioContext: UnsafeMutableRawPointer?
|
||||
/// Generator registrations per synth. The OS deallocs a generator's
|
||||
/// userdata when the generator is replaced or its synth is freed; the
|
||||
/// mock mirrors that contract.
|
||||
struct SynthGenerator {
|
||||
var render: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int32>?, UnsafeMutablePointer<Int32>?, Int32, UInt32, Int32) -> Int32)?
|
||||
var dealloc: (@convention(c) (UnsafeMutableRawPointer?) -> Void)?
|
||||
var userdata: UnsafeMutableRawPointer?
|
||||
}
|
||||
nonisolated(unsafe) static var synthGenerators: [OpaquePointer: SynthGenerator] = [:]
|
||||
/// The last custom-effect proc handed to `newEffect`, plus each
|
||||
/// effect's userdata, for re-invocation by tests.
|
||||
nonisolated(unsafe) static var effectProc: (@convention(c) (OpaquePointer?, UnsafeMutablePointer<Int32>?, UnsafeMutablePointer<Int32>?, Int32, Int32) -> Int32)?
|
||||
nonisolated(unsafe) static var effectUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:]
|
||||
|
||||
static func record(_ event: String) {
|
||||
events.append(event)
|
||||
@@ -72,6 +89,9 @@ enum Mock {
|
||||
spriteUpdateCallback = nil
|
||||
audioCallback = nil
|
||||
audioContext = nil
|
||||
synthGenerators = [:]
|
||||
effectProc = nil
|
||||
effectUserdata = [:]
|
||||
}
|
||||
|
||||
private static func install() {
|
||||
@@ -186,6 +206,12 @@ enum Mock {
|
||||
Mock.record("getTableBitmap(\(index))")
|
||||
return index == 0 ? Mock.fakePointer() : nil
|
||||
}
|
||||
gfxAPI.pointee.getBitmapTableInfo = { _, count, cellsWide in
|
||||
Mock.record("getBitmapTableInfo")
|
||||
// One bitmap, matching getTableBitmap vending index 0 only.
|
||||
count?.pointee = 1
|
||||
cellsWide?.pointee = 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sprite
|
||||
@@ -276,6 +302,86 @@ enum Mock {
|
||||
Mock.record("channelRemoveSource")
|
||||
return 1
|
||||
}
|
||||
channelAPI.pointee.addSource = { _, _ in
|
||||
Mock.record("channelAddSource")
|
||||
return 1
|
||||
}
|
||||
|
||||
soundAPI.pointee.synth = UnsafePointer(synthAPI)
|
||||
synthAPI.initialize(to: playdate_sound_synth())
|
||||
synthAPI.pointee.newSynth = {
|
||||
Mock.record("newSynth")
|
||||
return Mock.fakePointer()
|
||||
}
|
||||
synthAPI.pointee.freeSynth = { synth in
|
||||
Mock.record("freeSynth")
|
||||
// Freeing a synth deallocs its generator's userdata.
|
||||
if let synth, let generator = Mock.synthGenerators.removeValue(forKey: synth) {
|
||||
generator.dealloc?(generator.userdata)
|
||||
}
|
||||
}
|
||||
synthAPI.pointee.setGenerator = { synth, _, render, _, _, _, dealloc, _, userdata in
|
||||
Mock.record("setGenerator")
|
||||
guard let synth else { return }
|
||||
// Replacing a generator deallocs the previous one first.
|
||||
if let previous = Mock.synthGenerators[synth] {
|
||||
previous.dealloc?(previous.userdata)
|
||||
}
|
||||
Mock.synthGenerators[synth] = SynthGenerator(render: render, dealloc: dealloc,
|
||||
userdata: userdata)
|
||||
}
|
||||
synthAPI.pointee.setFrequencyModulator = { _, _ in
|
||||
Mock.record("setFrequencyModulator")
|
||||
}
|
||||
|
||||
soundAPI.pointee.lfo = UnsafePointer(lfoAPI)
|
||||
lfoAPI.initialize(to: playdate_sound_lfo())
|
||||
lfoAPI.pointee.newLFO = { _ in
|
||||
Mock.record("newLFO")
|
||||
return Mock.fakePointer()
|
||||
}
|
||||
lfoAPI.pointee.freeLFO = { _ in
|
||||
Mock.record("freeLFO")
|
||||
}
|
||||
|
||||
soundAPI.pointee.effect = UnsafePointer(soundEffectAPI)
|
||||
soundEffectAPI.initialize(to: playdate_sound_effect())
|
||||
soundEffectAPI.pointee.newEffect = { proc, userdata in
|
||||
Mock.record("newEffect")
|
||||
let pointer = Mock.fakePointer()
|
||||
Mock.effectProc = proc
|
||||
if let userdata {
|
||||
Mock.effectUserdata[pointer] = userdata
|
||||
}
|
||||
return pointer
|
||||
}
|
||||
soundEffectAPI.pointee.freeEffect = { effect in
|
||||
Mock.record("freeEffect")
|
||||
if let effect {
|
||||
Mock.effectUserdata[effect] = nil
|
||||
}
|
||||
}
|
||||
soundEffectAPI.pointee.getUserdata = { effect in
|
||||
guard let effect else { return nil }
|
||||
return Mock.effectUserdata[effect]
|
||||
}
|
||||
|
||||
soundEffectAPI.pointee.delayline = UnsafePointer(delayLineAPI)
|
||||
delayLineAPI.initialize(to: playdate_sound_effect_delayline())
|
||||
delayLineAPI.pointee.newDelayLine = { _, _ in
|
||||
Mock.record("newDelayLine")
|
||||
return Mock.fakePointer()
|
||||
}
|
||||
delayLineAPI.pointee.freeDelayLine = { _ in
|
||||
Mock.record("freeDelayLine")
|
||||
}
|
||||
delayLineAPI.pointee.addTap = { _, _ in
|
||||
Mock.record("addTap")
|
||||
return Mock.fakePointer()
|
||||
}
|
||||
delayLineAPI.pointee.freeTap = { _ in
|
||||
Mock.record("freeTap")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File
|
||||
@@ -342,5 +448,35 @@ enum Mock {
|
||||
data: .init(tableval: root))
|
||||
return 1
|
||||
}
|
||||
|
||||
// A simplified encoder: each call emits its JSON fragment verbatim,
|
||||
// without the real OS's separator and pretty-printing logic.
|
||||
jsonAPI.pointee.initEncoder = { encoder, write, userdata, _ in
|
||||
Mock.record("initEncoder")
|
||||
guard let encoder else { return }
|
||||
encoder.pointee.writeStringFunc = write
|
||||
encoder.pointee.userdata = userdata
|
||||
encoder.pointee.startTable = { Mock.emitJSON($0, "{") }
|
||||
encoder.pointee.endTable = { Mock.emitJSON($0, "}") }
|
||||
encoder.pointee.startArray = { Mock.emitJSON($0, "[") }
|
||||
encoder.pointee.endArray = { Mock.emitJSON($0, "]") }
|
||||
encoder.pointee.addTableMember = { encoder, name, length in
|
||||
guard let name else { return }
|
||||
let bytes = UnsafeRawBufferPointer(start: name, count: Int(length))
|
||||
Mock.emitJSON(encoder, "\"\(String(decoding: bytes, as: UTF8.self))\":")
|
||||
}
|
||||
encoder.pointee.addArrayMember = { Mock.emitJSON($0, ",") }
|
||||
encoder.pointee.writeInt = { Mock.emitJSON($0, "\($1)") }
|
||||
encoder.pointee.writeString = { encoder, string, length in
|
||||
guard let string else { return }
|
||||
let bytes = UnsafeRawBufferPointer(start: string, count: Int(length))
|
||||
Mock.emitJSON(encoder, "\"\(String(decoding: bytes, as: UTF8.self))\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func emitJSON(_ encoder: UnsafeMutablePointer<json_encoder>?, _ text: String) {
|
||||
guard let encoder, let write = encoder.pointee.writeStringFunc else { return }
|
||||
text.withCString { write(encoder.pointee.userdata, $0, Int32(text.utf8.count)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,16 @@ struct WrapperTests {
|
||||
#expect(Mock.eventCount("freeBitmapTable") == 1)
|
||||
}
|
||||
|
||||
@Test func bitmapTableIsACollection() {
|
||||
let table = Graphics.BitmapTable(count: 4, width: 8, height: 8)
|
||||
#expect(table.count == 1) // the mock reports a single bitmap
|
||||
#expect(table.first != nil)
|
||||
|
||||
var visited = 0
|
||||
for _ in table { visited += 1 }
|
||||
#expect(visited == 1)
|
||||
}
|
||||
|
||||
// MARK: Sprite
|
||||
|
||||
@Test func spriteUserdataRecoversWrapperInCallbacks() {
|
||||
@@ -172,6 +182,148 @@ struct WrapperTests {
|
||||
#expect(Sound.CallbackSource.live.count == baseline)
|
||||
}
|
||||
|
||||
// MARK: Sound ownership graph
|
||||
|
||||
@Test func channelRetainsAddedSourceUntilRemoved() {
|
||||
let channel = Sound.Channel()
|
||||
weak var weakSynth: Sound.Synth?
|
||||
do {
|
||||
let synth = Sound.Synth()
|
||||
weakSynth = synth
|
||||
channel.addSource(synth)
|
||||
}
|
||||
#expect(weakSynth != nil) // the channel keeps the source alive
|
||||
#expect(Mock.eventCount("freeSynth") == 0)
|
||||
|
||||
if let synth = weakSynth {
|
||||
channel.removeSource(synth)
|
||||
}
|
||||
#expect(weakSynth == nil)
|
||||
#expect(Mock.eventCount("freeSynth") == 1)
|
||||
}
|
||||
|
||||
@Test func synthRetainsItsModulatorWhileAlive() {
|
||||
weak var weakLFO: Sound.LFO?
|
||||
do {
|
||||
let synth = Sound.Synth()
|
||||
do {
|
||||
let lfo = Sound.LFO()
|
||||
weakLFO = lfo
|
||||
synth.frequencyModulator = lfo
|
||||
}
|
||||
#expect(weakLFO != nil) // the synth retains the modulator
|
||||
#expect(Mock.eventCount("freeLFO") == 0)
|
||||
}
|
||||
#expect(weakLFO == nil)
|
||||
#expect(Mock.eventCount("freeLFO") == 1)
|
||||
}
|
||||
|
||||
@Test func synthGeneratorDispatchesAndIsReleasedWithTheSynth() {
|
||||
final class Token {}
|
||||
weak var weakToken: Token?
|
||||
var rendered = 0
|
||||
|
||||
do {
|
||||
let synth = Sound.Synth()
|
||||
let token = Token()
|
||||
weakToken = token
|
||||
synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in
|
||||
_ = token
|
||||
rendered += 1
|
||||
return left.count
|
||||
}))
|
||||
|
||||
// Simulate the audio engine rendering through the trampoline.
|
||||
let generator = try! #require(Mock.synthGenerators[synth.pointer])
|
||||
var samples = [Int32](repeating: 0, count: 8)
|
||||
let frames = samples.withUnsafeMutableBufferPointer { buffer in
|
||||
generator.render?(generator.userdata, buffer.baseAddress, nil,
|
||||
Int32(buffer.count), 0, 0)
|
||||
}
|
||||
#expect(frames == 8)
|
||||
#expect(rendered == 1)
|
||||
#expect(weakToken != nil)
|
||||
}
|
||||
// freeSynth deallocs the generator userdata, releasing the box and
|
||||
// the closure's captures with it.
|
||||
#expect(Mock.eventCount("freeSynth") == 1)
|
||||
#expect(weakToken == nil)
|
||||
}
|
||||
|
||||
@Test func replacingASynthGeneratorReleasesThePreviousOne() {
|
||||
final class Token {}
|
||||
weak var firstToken: Token?
|
||||
|
||||
let synth = Sound.Synth()
|
||||
do {
|
||||
let token = Token()
|
||||
firstToken = token
|
||||
synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in
|
||||
_ = token
|
||||
return left.count
|
||||
}))
|
||||
}
|
||||
#expect(firstToken != nil)
|
||||
|
||||
synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in left.count }))
|
||||
#expect(firstToken == nil) // the OS deallocs the replaced generator
|
||||
}
|
||||
|
||||
@Test func effectProcessorDispatchesAndIsReleasedOnDeinit() {
|
||||
final class Token {}
|
||||
weak var weakToken: Token?
|
||||
var processed = 0
|
||||
|
||||
do {
|
||||
let token = Token()
|
||||
weakToken = token
|
||||
let effect = Sound.Effect(processor: { left, right, _ in
|
||||
_ = token
|
||||
processed += left.count
|
||||
#expect(right == nil)
|
||||
return true
|
||||
})
|
||||
|
||||
// Drive the effect the way the OS would: through the registered
|
||||
// proc, which recovers the box from the effect's userdata.
|
||||
var samples = [Int32](repeating: 0, count: 4)
|
||||
let active = samples.withUnsafeMutableBufferPointer { buffer in
|
||||
Mock.effectProc?(effect.pointer, buffer.baseAddress, nil,
|
||||
Int32(buffer.count), 1)
|
||||
}
|
||||
#expect(active == 1)
|
||||
#expect(processed == 4)
|
||||
}
|
||||
#expect(Mock.eventCount("freeEffect") == 1)
|
||||
#expect(weakToken == nil)
|
||||
}
|
||||
|
||||
@Test func effectSubclassIsFreedExactlyOnceWithItsOwnFree() {
|
||||
do {
|
||||
let line = Sound.DelayLine(length: 256)
|
||||
_ = line
|
||||
}
|
||||
#expect(Mock.eventCount("freeDelayLine") == 1)
|
||||
// The base Effect deinit must not also free the subclass's object.
|
||||
#expect(Mock.eventCount("freeEffect") == 0)
|
||||
}
|
||||
|
||||
@Test func delayLineTapKeepsItsDelayLineAlive() {
|
||||
weak var weakLine: Sound.DelayLine?
|
||||
var tap: Sound.DelayLineTap?
|
||||
do {
|
||||
let line = Sound.DelayLine(length: 256)
|
||||
weakLine = line
|
||||
tap = line.addTap(delay: 128)
|
||||
}
|
||||
#expect(weakLine != nil) // the tap retains its delay line
|
||||
#expect(Mock.eventCount("freeDelayLine") == 0)
|
||||
|
||||
tap = nil
|
||||
#expect(Mock.eventCount("freeTap") == 1)
|
||||
#expect(Mock.eventCount("freeDelayLine") == 1)
|
||||
}
|
||||
|
||||
// MARK: File
|
||||
|
||||
@Test func fileHandleReadsWritesAndClosesExactlyOnce() throws {
|
||||
@@ -214,4 +366,18 @@ struct WrapperTests {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test func jsonEncoderAccumulatesOutput() {
|
||||
let encoder = JSON.Encoder()
|
||||
encoder.startTable()
|
||||
encoder.addTableMember(name: "level")
|
||||
encoder.writeInt(3)
|
||||
encoder.addTableMember(name: "name")
|
||||
encoder.writeString("Röck")
|
||||
encoder.endTable()
|
||||
|
||||
// The mock emits fragments verbatim, with no separators between
|
||||
// members; "Röck" exercises multi-byte UTF-8 through the byte buffer.
|
||||
#expect(encoder.json == "{\"level\":3\"name\":\"Röck\"}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user