7 Commits
19 changed files with 439 additions and 64 deletions
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 -1
View File
@@ -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
+4 -2
View File
@@ -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
+4 -6
View File
@@ -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
}
}
+58 -6
View File
@@ -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))
}
}
}
+1 -1
View File
@@ -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 -1
View 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()
+10 -12
View File
@@ -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 {
+136
View File
@@ -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)) }
}
}
+166
View File
@@ -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\"}")
}
}