From d33cf4c528515600611ec0347b58e8233cd94006 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 18 Sep 2026 12:50:47 +0200 Subject: [PATCH] Fixed bugs found for the bitmap mask, JSON reader and wifi wrappers in the library. --- .../PlaydateKit/Graphics/Classes/Bitmap.swift | 12 +++-- Sources/PlaydateKit/JSON/JSON.swift | 6 +-- Sources/PlaydateKit/Network/Network.swift | 25 ++++++----- Tests/PlaydateKit/MockPlaydate.swift | 44 ++++++++++++++++--- Tests/PlaydateKit/WrapperTests.swift | 36 ++++++++++++++- 5 files changed, 97 insertions(+), 26 deletions(-) diff --git a/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift b/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift index 9610603..f5b6603 100644 --- a/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift +++ b/Sources/PlaydateKit/Graphics/Classes/Bitmap.swift @@ -2,15 +2,18 @@ internal import CPlaydate extension Graphics { /// A drawable image and drawing target. Wraps `LCDBitmap`. Bitmaps borrowed from - /// tables, fonts, masks, video players, or the system live only as long as their owner. + /// tables, fonts, video players, or the system live only as long as their owner. public final class Bitmap { let pointer: OpaquePointer /// 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 } public convenience init(width: Int, height: Int, backgroundColor: Color = .clear) { @@ -126,10 +129,11 @@ extension Graphics { gfx.pointee.setBitmapMask.unsafelyUnwrapped(pointer, mask?.pointer) != 0 } - /// Shares this bitmap's mask data: drawing into it edits the mask. + /// 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) } /// Whether opaque pixels of both bitmaps overlap within the non-empty `rect`. diff --git a/Sources/PlaydateKit/JSON/JSON.swift b/Sources/PlaydateKit/JSON/JSON.swift index b6eb283..dad6fa4 100644 --- a/Sources/PlaydateKit/JSON/JSON.swift +++ b/Sources/PlaydateKit/JSON/JSON.swift @@ -120,9 +120,9 @@ extension JSON { // 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 count = fileAPI.pointee.read.unsafelyUnwrapped(userdata, buffer, UInt32(size)) - return count > 0 ? count : -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) { diff --git a/Sources/PlaydateKit/Network/Network.swift b/Sources/PlaydateKit/Network/Network.swift index a95c92a..9efb242 100644 --- a/Sources/PlaydateKit/Network/Network.swift +++ b/Sources/PlaydateKit/Network/Network.swift @@ -22,23 +22,28 @@ extension Network { WifiStatus(rawValue: UInt32(networkAPI.pointee.getStatus.unsafelyUnwrapped().rawValue)) ?? .notConnected } - /// `true` connects to the configured access point; `false` turns wifi off before - /// the 30 s idle timeout. `completion` (documented for `true` only) gets `nil` - /// on success; completions fire in call order. - public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) { + /// 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) + } + + nonisolated(unsafe) private static var enableCompletions: [(NetError?) -> Void] = [] /// Shared by HTTP and TCP. Retains `completion` until the C callback, which /// fires only for `.ask`. diff --git a/Tests/PlaydateKit/MockPlaydate.swift b/Tests/PlaydateKit/MockPlaydate.swift index 1c01b4a..64e0edc 100644 --- a/Tests/PlaydateKit/MockPlaydate.swift +++ b/Tests/PlaydateKit/MockPlaydate.swift @@ -27,6 +27,7 @@ enum Mock { 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 @@ -41,6 +42,10 @@ enum Mock { /// 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 @@ -94,6 +99,8 @@ enum Mock { patternBytes = [] stencilRows = [] fileReadLimit = nil + fileBytesRemaining = nil + networkEnabledCallback = nil tilesPointer = nil tilesCount = 0 spriteUserdata = [:] @@ -115,6 +122,7 @@ enum Mock { installSound() installFile() installJSON() + installNetwork() apiStruct.initialize(to: PlaydateAPI( system: UnsafePointer(sysAPI), file: UnsafePointer(fileAPI), @@ -125,7 +133,7 @@ enum Mock { lua: nil, json: UnsafePointer(jsonAPI), scoreboards: nil, - network: nil)) + network: UnsafePointer(networkAPI))) Playdate.initialize(with: UnsafeMutableRawPointer(apiStruct)) } @@ -220,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))") @@ -430,7 +442,11 @@ enum Mock { } fileAPI.pointee.read = { _, buffer, length in Mock.record("read(\(length))") - let count = min(Int32(length), Mock.fileReadLimit ?? 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 } @@ -440,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 @@ -447,14 +473,18 @@ enum Mock { private static func installJSON() { jsonAPI.initialize(to: playdate_json()) - // Pulls one chunk through the reader, as the OS would, and decodes - // it as `null`. + // Reads until the reader returns 0 or less, then decodes `null`. jsonAPI.pointee.decode = { _, reader, outval in var buffer = [UInt8](repeating: 0, count: 16) - let count = buffer.withUnsafeMutableBufferPointer { buffer in - reader.read?(reader.userdata, buffer.baseAddress, Int32(buffer.count)) ?? -1 + 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(\(count))") + Mock.record("decode(total:\(total),end:\(last))") outval?.pointee = json_value() outval?.pointee.type = CChar(kJSONNull.rawValue) return 1 diff --git a/Tests/PlaydateKit/WrapperTests.swift b/Tests/PlaydateKit/WrapperTests.swift index bcd409d..2596691 100644 --- a/Tests/PlaydateKit/WrapperTests.swift +++ b/Tests/PlaydateKit/WrapperTests.swift @@ -127,6 +127,23 @@ struct WrapperTests { #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() { @@ -385,13 +402,14 @@ struct WrapperTests { } @Test func jsonDecodeFileReadsThroughTheHandleAndClosesIt() throws { - Mock.fileReadLimit = 5 + Mock.fileBytesRemaining = 20 let value = try JSON.decodeFile(path: "save.json") guard case .null = value else { Issue.record("expected .null, got \(value)") return } - #expect(Mock.events.contains("decode(5)")) + // End of file reaches the decoder as 0, not -1. + #expect(Mock.events.contains("decode(total:20,end:0)")) #expect(Mock.eventCount("close") == 1) } @@ -417,6 +435,20 @@ struct WrapperTests { #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 {