diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 739a06b7..8f2afd88 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -6,11 +6,53 @@ name: iOS # is tag- (and manual-) gated because macOS runner minutes bill ~10x Linux — the # same gating posture as android.yml's heavier jobs and release.yml. Accuracy is # never gated on a device toolchain. +# +# Beyond the TestFlight upload, every run also COMPILES THE SWIFT APP for the iOS +# Simulator (the "Build the app" step). Nothing else in CI builds the Swift +# target, so until v2.9.7 the app could stop compiling with every check green -- +# and it had. The first Xcode build on a Mac after v2.9.7 failed with 24 errors. +# That step runs only when this workflow does, on purpose: macOS minutes are the +# cost this file's gating exists to contain. This workflow runs for a release +# (the `workflow_call` from release-auto.yml), by hand, and on the +# every-other-month TestFlight refresh cron, which is skipped unless the +# `IOS_SIGNING_READY` repo variable is "true" (unset as of 2026-10-02, so the +# cron is dormant). It never runs on a pull request. For an answer before a +# release, run it by hand (`workflow_dispatch`) on the release branch. on: push: tags: - 'v*' workflow_dispatch: + # Invoked by `release-auto.yml` for every release it cuts. This is the trigger + # that actually fires: the auto-release tag is pushed with the built-in + # GITHUB_TOKEN, which does NOT trigger `on: push: tags` (GitHub's recursion + # guard, the same reason release-auto calls release.yml directly). The tag + # trigger above has therefore not run since v2.3.8 (2026-08-20). Every + # release from v2.3.9 to v2.9.7 was auto-tagged, and none of them built the + # iOS host at all. + workflow_call: + inputs: + tag: + description: "Release tag to build (e.g. v2.9.8)." + required: true + type: string + # Exactly the secrets the TestFlight steps read, passed by name from + # release-auto.yml rather than with `secrets: inherit`, which would hand this + # workflow every repository secret. All optional: absent, the "Detect iOS + # signing secrets" step skips the upload, as it does on a tag push. + secrets: + ASC_KEY_ID: + required: false + ASC_ISSUER_ID: + required: false + ASC_KEY_CONTENT: + required: false + MATCH_GIT_URL: + required: false + MATCH_PASSWORD: + required: false + MATCH_GIT_BASIC_AUTHORIZATION: + required: false # v1.9.1 "Patch" — TestFlight build-refresh cadence. A TestFlight build expires # 90 days after upload; re-build + re-upload at 06:00 UTC on the 1st of every # other month (~60-day cadence) so external testers never lose access between @@ -43,8 +85,11 @@ jobs: # so an un-provisioned repo must not paint every release tag push red. if: ${{ github.event_name != 'schedule' || vars.IOS_SIGNING_READY == 'true' }} steps: + # The release tag when called by release-auto (it exists by then, as + # release-auto creates it first); otherwise the triggering ref. - uses: actions/checkout@v7 with: + ref: ${{ inputs.tag || github.ref }} persist-credentials: false # v2.0.7 "Trim" — App Store submission floor. From 2026-04-28 every App Store @@ -111,6 +156,41 @@ jobs: - name: Build xcframework + generate project run: ./scripts/build-ios-xcframework.sh + # The script must leave the tracked tree untouched. Through v2.9.7 it + # rewrote the hand-written ios/RustyNES/Info.plist on every run (an + # `info:` block in project.yml made XcodeGen treat the plist as output), + # and on a throwaway CI checkout nobody saw it. + - name: Build left the tracked tree unchanged + run: git diff --exit-code + + # Compile and link the Swift app against the xcframework and the freshly + # generated UniFFI bindings. Unsigned, generic simulator: no device and + # no certificate needed, so it runs whether or not the signing secrets + # exist. This catches a Swift / UniFFI mismatch, for example a Rust type + # renamed or added in rustynes-mobile, at release time instead of on the + # first developer's Mac. It proves the app builds, not that it runs; that + # is the device run sheet's job. + - name: Build the app (iOS Simulator, unsigned) + working-directory: ios + run: | + set -o pipefail + xcodebuild build \ + -project RustyNES.xcodeproj \ + -scheme RustyNES \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + | tee xcodebuild.log + + - name: Upload the xcodebuild log + if: ${{ failure() }} + uses: actions/upload-artifact@v7 + with: + name: ios-app-xcodebuild-log + path: ios/xcodebuild.log + if-no-files-found: ignore + # Detect whether the maintainer-provisioned signing secrets exist. They are a # documented manual carryover (docs/ios.md "Maintainer-manual carryovers"): # the App Store Connect API key (ASC_*) + the fastlane match repo (MATCH_*). diff --git a/.github/workflows/release-auto.yml b/.github/workflows/release-auto.yml index 7ba85b53..4cb623d4 100644 --- a/.github/workflows/release-auto.yml +++ b/.github/workflows/release-auto.yml @@ -247,3 +247,31 @@ jobs: uses: ./.github/workflows/release.yml with: tag: ${{ needs.prepare.outputs.tag }} + + # The iOS host for the same release: the xcframework, the Swift app compiled + # for the simulator, and, once signing is provisioned, the TestFlight upload. + # Called directly for the same reason as `build` above: the tag this run + # pushed with GITHUB_TOKEN never fires ios.yml's own `push: tags` trigger, + # which is why no release from v2.3.9 to v2.9.7 built the iOS app at all. + # It runs alongside `build`, not after it, and a failure here marks the + # release run red without touching the binaries `build` attaches. + ios: + name: iOS host (xcframework + Swift build) + needs: prepare + if: needs.prepare.outputs.should_release == 'true' + permissions: + contents: read + uses: ./.github/workflows/ios.yml + with: + tag: ${{ needs.prepare.outputs.tag }} + # Only the App Store Connect + fastlane match secrets ios.yml reads for the + # TestFlight upload, by name. `secrets: inherit` would pass every repository + # secret to the called workflow (least privilege, CodeRabbit on #578). Its + # "Detect iOS signing secrets" step skips the upload when they are absent. + secrets: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6160c314..1d409ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,39 @@ cycle-accurate core later replaced. ## [Unreleased] +### Fixed + +- **The iOS app compiles again.** The first Xcode build on a Mac (Xcode 27) + failed with 24 errors in v2.9.7's Swift. The app's own `NesButton` collided + with the type UniFFI generates from `rustynes-mobile`, now renamed + `NesButtonBit`. The FDS BIOS prompt also caught `MobileError.missingFdsBios`, + but the generated case is `MissingFdsBios`. +- **iOS games no longer open to a black screen.** The frame loop started only + after the renderer was built, and the only retry of a build deferred at + first layout ran off that same loop, so no frame ever ran until the app was + backgrounded and foregrounded. The loop now starts first. +- **No iOS launch crash without the iCloud capability.** Save-state sync is + opt-in, but the app checked the iCloud account at launch anyway, and + `CKContainer` traps uncatchably in a build without the container + entitlement. CloudKit is now untouched while sync is off, and a container is + created only when the entitlement is present. +- **`scripts/build-ios-xcframework.sh` no longer overwrites + `ios/RustyNES/Info.plist`** (an `info:` block in `ios/project.yml` made + XcodeGen regenerate it, dropping the ROM document types, fonts and + orientations). It also builds the Rust and C code for the app's iOS 17.0 + floor instead of the installed SDK's version. +- **A macOS clone no longer starts dirty.** `tests/roms/accuracycoin/README.md` + and `tests/roms/AccuracyCoin/README.md` were one file on a case-insensitive + filesystem; the lowercase one is now `RUNTIME.md`. + +### Changed + +- **CI builds the iOS app at release time.** `ios.yml` now compiles the Swift + app for the iOS Simulator, and fails if the build script modified a tracked + file. `release-auto.yml` calls `ios.yml` for every release: its tag trigger + never fires for auto-pushed tags, so no release from v2.3.9 to v2.9.7 had + built the iOS host. macOS jobs still never run on pull requests. + ## [2.9.7] - 2026-09-30 - "Tandem" (the desktop's features on the web and on phones, full release binaries, and an A12 fix found by real games) The eighth release of the v2.9.x line and the fourth of the line to v3.0.0: diff --git a/crates/rustynes-test-harness/tests/accuracycoin.rs b/crates/rustynes-test-harness/tests/accuracycoin.rs index dda4df48..98310aba 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin.rs @@ -61,7 +61,7 @@ const MIN_PASS_RATE: f64 = 0.60; /// The exact number of `AccuracyCoin` tests the shipped headless build /// passes, re-blessed at the 2026-09 upstream re-sync (upstream `69c8860`; /// the ROM has since moved to `46199ae4`, which changes no verdict — see -/// `tests/roms/accuracycoin/README.md`). +/// `tests/roms/accuracycoin/RUNTIME.md`). /// /// 143 of 144 assigned. The catalog grew 141 -> 144 assigned tests and /// **nothing that passed before stopped passing**: upstream removed no diff --git a/docs/ios.md b/docs/ios.md index 321aadbf..33c6c734 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -252,10 +252,20 @@ device `.a` (`cargo run -p rustynes-mobile --bin uniffi-bindgen -- generate --library … --language swift`; rename the modulemap to `module.modulemap`) -> assemble the headers dir (`rustynes_mobileFFI.h` + `rustynes_ios.h` + `module.modulemap`) -> `xcodebuild -create-xcframework` -> `xcodegen generate`. -CI (`.github/workflows/ios.yml`) runs this on `macos-latest`, **gated to tag -pushes (`v*`) + manual dispatch** because macOS minutes bill ~10x — the host -`ci.yml` remains the accuracy / determinism authority and is never gated on a -device toolchain. `fastlane` (`match` read-only signing + `gym` + `pilot`) uploads +CI (`.github/workflows/ios.yml`) runs this on `macos-latest`, **never on a pull +request**, because macOS minutes bill ~10x — the host `ci.yml` remains the +accuracy / determinism authority and is never gated on a device toolchain. It +runs for a release, by hand, and on the TestFlight refresh cron described below +(dormant until the `IOS_SIGNING_READY` repo variable is set). "For a release" +means `release-auto.yml`, which calls `ios.yml` (`workflow_call`) for every +release it cuts. Its `push: tags` trigger never fires for those, because the +auto-release tag is pushed with `GITHUB_TOKEN`. So no release from v2.3.9 to +v2.9.7 built the iOS host. The call is unreleased as of this writing: it was +added after v2.9.7 and first runs on the next release. Manual dispatch remains +for an on-demand run, for example on a release branch before it merges. Each run then **compiles the Swift app** for the generic iOS Simulator, +unsigned. It is the only place CI builds the Swift target, and v2.9.7 shipped +with 24 Swift compile errors because nothing did. The run also fails if the +script modified any tracked file. `fastlane` (`match` read-only signing + `gym` + `pilot`) uploads to TestFlight via an App Store Connect API key. The upload is **gated on the signing secrets being present** (a "Detect iOS signing secrets" step): until they are provisioned, the xcframework build still runs (proving the iOS host compiles) diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index a70e2144..ffd631d3 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -364,7 +364,7 @@ final class AppModel: ObservableObject { netplay.attach(core: core) // Reconcile this game's cloud save-states (pull any newer-remote slots). cloudSaveStates.setCurrentGame(sha: entry.sha) - } catch MobileError.missingFdsBios { + } catch MobileError.MissingFdsBios { // v2.9.7: ask for disksys.rom once, then open this disk again. pendingFdsEntry = entry needsFdsBios = true diff --git a/ios/RustyNES/CloudSaveStateSync.swift b/ios/RustyNES/CloudSaveStateSync.swift index f52a2e7e..7af93d94 100644 --- a/ios/RustyNES/CloudSaveStateSync.swift +++ b/ios/RustyNES/CloudSaveStateSync.swift @@ -45,6 +45,7 @@ import CloudKit import Foundation +import MachO @MainActor final class CloudSaveStateSync: ObservableObject { @@ -91,8 +92,10 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Lifecycle wiring (driven by AppModel) - /// Kick off an account check at launch (no game yet). Safe to call when disabled. + /// Kick off an account check at launch (no game yet). Safe to call when disabled: + /// sync is opt-in, so a disabled sync never touches CloudKit at all. func start() { + guard enabled else { return } Task { await refreshAccount() } } @@ -127,8 +130,18 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Account availability + /// Re-read whether the user's iCloud account can be used, into `accountAvailable`. + /// + /// Touches CloudKit only when sync is enabled and the binary carries the + /// container entitlement (`Self.container` is nil otherwise). Every other case, + /// a disabled sync, a missing entitlement or a failed status call, leaves the + /// account unavailable, which every caller treats as "stay local". private func refreshAccount() async { - let status = try? await CKContainer.default().accountStatus() + guard enabled else { + accountAvailable = false + return + } + let status = try? await Self.container?.accountStatus() accountAvailable = (status == .available) } @@ -176,9 +189,9 @@ final class CloudSaveStateSync: ObservableObject { // Re-check the account live rather than trusting a possibly-stale cached flag: // the initial async account check may not have finished when a save fires, which // would wrongly skip the upload and leave the slot `localOnly`. - let status = try? await CKContainer.default().accountStatus() + let status = try? await Self.container?.accountStatus() accountAvailable = (status == .available) - guard accountAvailable else { return nil } + guard accountAvailable, let database = Self.database else { return nil } let urls = saveStates.fileURLs(sha: sha, slot: slot) let meta = saveStates.slot(sha: sha, index: slot) guard !meta.isEmpty else { return nil } @@ -194,7 +207,7 @@ final class CloudSaveStateSync: ObservableObject { // fails this save instead of being lost. (A missing record, or a fetch // that fails offline, starts a new record; saving a new record over an // existing one also fails under that policy.) - let existing = try? await Self.database.record(for: id) + let existing = try? await database.record(for: id) if let existing, let remoteSaved = existing["savedAt"] as? Date, remoteSaved > savedAt { return nil } @@ -214,7 +227,7 @@ final class CloudSaveStateSync: ObservableObject { } do { - let (saved, _) = try await Self.database.modifyRecords( + let (saved, _) = try await database.modifyRecords( saving: [record], deleting: [], savePolicy: .ifServerRecordUnchanged, atomically: true ) // The call succeeds as a whole even when this record's save failed @@ -240,14 +253,29 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Delete (on slot clear) /// Remove a slot's cloud record (best-effort) when the user deletes it locally. + /// + /// Best-effort means the local delete never waits on or fails with the cloud + /// one, not that a failure goes unrecorded: it is logged like an upload + /// failure. `modifyRecords` reports a per-record failure in its result rather + /// than by throwing, so both are checked. `.unknownItem` is not logged: it + /// only means the slot was never uploaded. func delete(sha: String, slot: Int) { states[slot] = nil - guard enabled else { return } + guard enabled, let database = Self.database else { return } + let id = recordID(sha: sha, slot: slot) Task { - _ = try? await Self.database.modifyRecords( - saving: [], deleting: [recordID(sha: sha, slot: slot)], - savePolicy: .allKeys, atomically: true - ) + do { + let (_, deleted) = try await database.modifyRecords( + saving: [], deleting: [id], + savePolicy: .allKeys, atomically: true + ) + if case .failure(let error) = deleted[id], + (error as? CKError)?.code != .unknownItem { + NSLog("RustyNES: iCloud slot \(slot) not deleted: \(error)") + } + } catch { + NSLog("RustyNES: iCloud slot \(slot) delete failed: \(error)") + } } } @@ -258,7 +286,7 @@ final class CloudSaveStateSync: ObservableObject { func reconcile(sha: String) async { guard enabled else { return } await refreshAccount() - guard accountAvailable else { + guard accountAvailable, let database = Self.database else { markAllUnavailable() return } @@ -266,9 +294,11 @@ final class CloudSaveStateSync: ObservableObject { let ids = (0..] do { - results = try await Self.database.records(for: ids) + results = try await database.records(for: ids) } catch { - // Offline / transient: keep the local-derived states, don't churn the UI. + // Offline / transient: keep the local-derived states, don't churn the + // UI. Logged, not shown: a fetch failure changes nothing on screen. + NSLog("RustyNES: iCloud reconcile fetch failed: \(error)") return } @@ -347,7 +377,21 @@ final class CloudSaveStateSync: ObservableObject { // MARK: - Record identity - private static var database: CKDatabase { CKContainer.default().privateCloudDatabase } + /// The CloudKit container, or `nil` when this binary is not entitled to one. + /// + /// CloudKit raises an uncatchable trap -- not a `CKError` -- when the app + /// creates a container without the `com.apple.developer.icloud-container- + /// identifiers` entitlement: `CKContainer.default()` throws a `CKException` + /// ("containerIdentifier can not be nil") and `CKContainer(identifier:)` stops + /// in a `brk` inside its initialiser (both measured on the iOS 27 simulator). + /// An unsigned simulator build, or a sideload whose profile lacks the iCloud + /// capability, crashed at launch that way. So no container is created unless + /// the entitlement is present, and every CloudKit call site below treats a + /// `nil` container as "unavailable", exactly like a signed-out account. + private static let container: CKContainer? = + CloudKitEntitlement.isPresent ? CKContainer.default() : nil + + private static var database: CKDatabase? { container?.privateCloudDatabase } private func recordID(sha: String, slot: Int) -> CKRecord.ID { CKRecord.ID(recordName: "state-\(sha)-\(slot)") @@ -359,3 +403,68 @@ final class CloudSaveStateSync: ObservableObject { return Int(id.recordName[id.recordName.index(after: dash)...]) } } + +// MARK: - Entitlement probe + +/// Whether this binary carries the CloudKit container entitlement, read with public +/// APIs only (iOS exposes no call that returns an app's own entitlements). +/// +/// * Simulator: Xcode links the entitlements into the main executable's +/// `__TEXT,__entitlements` section ("Simulated.xcent"); an unsigned build +/// (`CODE_SIGNING_ALLOWED=NO`) has no such section. +/// * Device: a development or ad-hoc build embeds its provisioning profile, whose +/// `Entitlements` dictionary is what the signature was allowed to claim. App Store +/// and TestFlight builds carry no `embedded.mobileprovision`, and are always +/// signed with the capability, so a missing profile counts as entitled. +enum CloudKitEntitlement { + private static let key = "com.apple.developer.icloud-container-identifiers" + + static let isPresent: Bool = { + #if targetEnvironment(simulator) + guard let entitlements = simulatorEntitlements() else { return false } + #else + guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") else { + return true + } + guard let entitlements = profileEntitlements(at: url) else { return false } + #endif + return !((entitlements[key] as? [Any])?.isEmpty ?? true) + }() + + #if targetEnvironment(simulator) + /// The entitlements Xcode linked into the main executable's + /// `__TEXT,__entitlements` section, or nil if there is none (an unsigned + /// build). + /// + /// Image 0 is dyld's main executable, which is deliberate: in a Debug build the + /// app's code lives in `RustyNES.debug.dylib`, and only the stub executable + /// carries the section, so resolving the image from one of our own symbols + /// (`dladdr`) would look in the wrong file. + private static func simulatorEntitlements() -> [String: Any]? { + guard let header = _dyld_get_image_header(0) else { return nil } + var size: UInt = 0 + let raw = header.withMemoryRebound(to: mach_header_64.self, capacity: 1) { + getsectiondata($0, "__TEXT", "__entitlements", &size) + } + guard let raw, size > 0 else { return nil } + return plist(Data(bytes: raw, count: Int(size))) + } + #else + /// The profile is a CMS envelope around an XML plist; the plist is read out of + /// it by its delimiters rather than by verifying the signature, which is the + /// kernel's job, not this probe's. + private static func profileEntitlements(at url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url), + let start = data.range(of: Data("".utf8), in: start.lowerBound.. [String: Any]? { + (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any] + } +} diff --git a/ios/RustyNES/ControlPadLayout.swift b/ios/RustyNES/ControlPadLayout.swift index 0ebc7a1b..6fb062b8 100644 --- a/ios/RustyNES/ControlPadLayout.swift +++ b/ios/RustyNES/ControlPadLayout.swift @@ -17,7 +17,7 @@ // derive from these same constants, so a resize rescales and remaps them in lockstep // -- they can never desync. // -// The mask bit order is `NesButton` (A=0x01 ... Right=0x80) -- the exact order the +// The mask bit order is `NesButtonBit` (A=0x01 ... Right=0x80) -- the exact order the // core's `Buttons` bitflag uses (see NesButtons.swift). Every input path lands on // the same late-latched bitmask, so determinism is untouched. // @@ -30,7 +30,7 @@ import UIKit /// now-superseded single-touch overlay; the live multi-touch hit test uses the shared /// `ControlPadLayout.hitTest(_:in:)` Android-geometry port directly). struct PadButton: Identifiable { - let button: NesButton + let button: NesButtonBit let frame: CGRect /// The visible glyph ("A"/"B"/"SEL"/"STA"); empty for the D-pad arms. let label: String diff --git a/ios/RustyNES/GameControllerManager.swift b/ios/RustyNES/GameControllerManager.swift index c7eb6075..67262851 100644 --- a/ios/RustyNES/GameControllerManager.swift +++ b/ios/RustyNES/GameControllerManager.swift @@ -84,7 +84,7 @@ enum ControllerInput: String, Codable, CaseIterable, Identifiable { } /// The plain NES button this drives, or nil for turbo/unmapped (handled apart). - var nesButton: NesButton? { + var nesButton: NesButtonBit? { switch self { case .a: return .a case .b: return .b diff --git a/ios/RustyNES/MetalGameView.swift b/ios/RustyNES/MetalGameView.swift index c03c02df..cc913570 100644 --- a/ios/RustyNES/MetalGameView.swift +++ b/ios/RustyNES/MetalGameView.swift @@ -109,11 +109,23 @@ struct MetalGameView: UIViewRepresentable { } /// Build the renderer for the current drawable and start the loop. + /// + /// The display link starts FIRST, unconditionally. `makeUIView` calls this + /// before SwiftUI has laid the view out, so the drawable is normally 0 x 0 + /// here and the renderer build is deferred -- and the code that retries it + /// is `step`'s resize branch, which runs off this same link. Starting the + /// link only after a successful build (as through v2.9.7) meant a deferred + /// build was never retried: no renderer, no frames, a black game view under + /// working controls. (`mtkView(_:drawableSizeWillChange:)` cannot do the + /// retry: `view.drawableSize` still reads 0 x 0 inside it.) `tick()` is a + /// no-op until the renderer exists, so the early link costs nothing. func attachAndStart() { guard let view, !attached else { return } + startDisplayLink() let size = view.drawableSize guard size.width > 0, size.height > 0 else { - // The drawable is not sized yet; defer to the first delegate call. + // The drawable is not sized yet; `step` retries on the first tick + // that sees a real size. return } let ptr = Unmanaged.passUnretained(view).toOpaque() @@ -121,7 +133,6 @@ struct MetalGameView: UIViewRepresentable { lastDrawableSize = size attached = true emulator.start() - startDisplayLink() } private func startDisplayLink() { @@ -134,6 +145,14 @@ struct MetalGameView: UIViewRepresentable { displayLink = link } + /// One display-link tick: complete a deferred renderer build or apply a + /// drawable resize, then run the console frames this tick owes. + /// + /// The link starts before the renderer exists (see `attachAndStart`), so + /// the first ticks may only build it; `EmulatorCore.tick()` is a no-op + /// until it does. On a ~60 Hz link exactly one console frame runs per + /// vsync; on any other refresh the wall-clock accumulator below paces the + /// core at the console's 60.0988 Hz. @objc private func step(_ link: CADisplayLink) { // If the drawable resized (rotation / Stage Manager), reconfigure first. if let view, view.drawableSize != lastDrawableSize { @@ -233,7 +252,10 @@ struct MetalGameView: UIViewRepresentable { @objc private func appWillEnterForeground() { guard attached else { // The renderer was never built (drawable was 0 at makeUIView); try now. + // The link already exists (attachAndStart starts it first) and was + // paused on background, so resume it or no tick ever retries. attachAndStart() + displayLink?.isPaused = false return } if let view { diff --git a/ios/RustyNES/MultiTouchControlPad.swift b/ios/RustyNES/MultiTouchControlPad.swift index 95bfad1f..7664ef59 100644 --- a/ios/RustyNES/MultiTouchControlPad.swift +++ b/ios/RustyNES/MultiTouchControlPad.swift @@ -166,7 +166,7 @@ private enum NesControllerArt { let w = size.width let h = size.height - func has(_ b: NesButton) -> Bool { mask & b.rawValue != 0 } + func has(_ b: NesButtonBit) -> Bool { mask & b.rawValue != 0 } // --- Body + edge, then the near-black central face. The white-plastic borders // are asymmetric like the real shell: thick top, thin bottom, thin sides. @@ -276,7 +276,7 @@ private enum NesControllerArt { let sqW = 0.112 * w let sqH = 0.271 * h let br = 0.046 * w - for (bx, bit) in [(ControlPadLayout.AB_BX, NesButton.b), (ControlPadLayout.AB_AX, NesButton.a)] { + for (bx, bit) in [(ControlPadLayout.AB_BX, NesButtonBit.b), (ControlPadLayout.AB_AX, NesButtonBit.a)] { let cx = bx * w fillRR(&ctx, NesPad.housingW, cx - sqW / 2, abY - sqH / 2, sqW, sqH, 0.035 * h) strokeRR(&ctx, NesPad.housingE, cx - sqW / 2, abY - sqH / 2, sqW, sqH, 0.035 * h, 0.005 * h) diff --git a/ios/RustyNES/NesButtons.swift b/ios/RustyNES/NesButtons.swift index 79407662..d6a23efe 100644 --- a/ios/RustyNES/NesButtons.swift +++ b/ios/RustyNES/NesButtons.swift @@ -25,7 +25,7 @@ import Foundation /// One standard NES controller button as its single-bit mask value. -enum NesButton: UInt8, CaseIterable { +enum NesButtonBit: UInt8, CaseIterable { case a = 0x01 case b = 0x02 case select = 0x04 @@ -36,14 +36,14 @@ enum NesButton: UInt8, CaseIterable { case right = 0x80 } -/// A live 8-bit controller mask (a set of pressed `NesButton`s) for one port. +/// A live 8-bit controller mask (a set of pressed `NesButtonBit`s) for one port. struct NesButtonMask { private(set) var bits: UInt8 = 0 init(bits: UInt8 = 0) { self.bits = bits } /// Press or release a single button, preserving the others. - mutating func set(_ button: NesButton, pressed: Bool) { + mutating func set(_ button: NesButtonBit, pressed: Bool) { if pressed { bits |= button.rawValue } else { @@ -52,7 +52,7 @@ struct NesButtonMask { } /// Whether a button is currently held. - func contains(_ button: NesButton) -> Bool { + func contains(_ button: NesButtonBit) -> Bool { bits & button.rawValue != 0 } @@ -78,8 +78,8 @@ struct NesButtonMask { /// untouched. UNCOMPILED at v2.9.7 as well; see the run sheet. mutating func cancelOpposingDirections(enabled: Bool = true) { guard enabled else { return } - let vertical = NesButton.up.rawValue | NesButton.down.rawValue - let horizontal = NesButton.left.rawValue | NesButton.right.rawValue + let vertical = NesButtonBit.up.rawValue | NesButtonBit.down.rawValue + let horizontal = NesButtonBit.left.rawValue | NesButtonBit.right.rawValue if bits & vertical == vertical { bits &= ~vertical } if bits & horizontal == horizontal { bits &= ~horizontal } } diff --git a/ios/RustyNES/TouchControlsOverlay.swift b/ios/RustyNES/TouchControlsOverlay.swift index 2d524702..87c0d1ea 100644 --- a/ios/RustyNES/TouchControlsOverlay.swift +++ b/ios/RustyNES/TouchControlsOverlay.swift @@ -9,7 +9,7 @@ // this in `GameView` with `MultiTouchControlPad` (a UIView-backed true multi-touch // responder). Both share `ControlPadLayout`, so the regions stay identical. // -// The mask bit order is NesButton (A=0x01 ... Right=0x80) — the exact order the +// The mask bit order is NesButtonBit (A=0x01 ... Right=0x80) — the exact order the // core's `Buttons` bitflag uses (see NesButtons.swift). Touch input flows through // the same late-latched mask path, so determinism is untouched. // diff --git a/ios/project.yml b/ios/project.yml index 936c6af3..d9b25288 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -100,8 +100,11 @@ targets: # GameKit — opt-in Game Center sign-in + access point (GameCenterModel.swift). - sdk: ReplayKit.framework - sdk: GameKit.framework - info: - path: RustyNES/Info.plist + # No `info:` block: XcodeGen GENERATES the file an `info.path` names, and + # with no `info.properties` it overwrote the hand-written Info.plist with a + # bare template on every run (dropping the ROM document types, the UTI + # declarations, the fonts and the orientations). `INFOPLIST_FILE` above + # already points the target at the tracked plist. schemes: RustyNES: diff --git a/scripts/build-ios-xcframework.sh b/scripts/build-ios-xcframework.sh index 341d8864..50cf93ae 100755 --- a/scripts/build-ios-xcframework.sh +++ b/scripts/build-ios-xcframework.sh @@ -36,6 +36,14 @@ TARGET_DEVICE="aarch64-apple-ios" TARGET_SIM_ARM="aarch64-apple-ios-sim" TARGET_SIM_X86="x86_64-apple-ios" +# The minimum iOS the archive is compiled for, matching `deploymentTarget` in +# ios/project.yml. rustc and the `cc` crate (which compiles the bundled C: Lua, +# rcheevos, ring) both read it. Unset, the C objects default to the installed +# SDK's version, so the app (17.0) linked objects "built for newer iOS-simulator +# version (27.0)" -- a warning per object, and on an older device a crash on any +# newer symbol those objects reference. +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-17.0}" + echo "==> Installing iOS Rust targets" rustup target add "${TARGET_DEVICE}" "${TARGET_SIM_ARM}" "${TARGET_SIM_X86}" diff --git a/tests/roms/accuracycoin/README.md b/tests/roms/accuracycoin/RUNTIME.md similarity index 94% rename from tests/roms/accuracycoin/README.md rename to tests/roms/accuracycoin/RUNTIME.md index a87b7830..ae4d5fde 100644 --- a/tests/roms/accuracycoin/README.md +++ b/tests/roms/accuracycoin/RUNTIME.md @@ -6,6 +6,10 @@ the uppercase [`../AccuracyCoin/`](../AccuracyCoin/) directory holds the upstream test catalog (TSV) that the diagnostic decoder needs, plus the custom sub-test ROMs. It holds no copy of the battery ROM itself. +This file is `RUNTIME.md`, not `README.md`, because the two directories +differ only in case: on a case-insensitive filesystem (macOS, Windows) they +are one directory, and two `README.md` paths collide on clone. + ## Files | File | Author | License |