Developers should start here first for breif instructions for building and working with the source code
Provenance uses two complementary JIT classification types:
| Type | Module | Purpose |
|---|---|---|
PVPrimitives.PVJITRequirement |
PVPrimitives |
Rich 4-case Swift enum; override in each PVEmulatorCore subclass |
PVCoreBridge.PVJITPlistRequirement |
PVCoreBridge |
Simple 3-case plist-parsed type; populated at runtime by CoreLoader |
Note: The two types have different names on purpose — both
PVEmulatorCoreandPVCoreBridgeare@_exported import-ed by downstream modules, so using the same name in both would cause ambiguous-type compiler errors.
| Case | Meaning | Safe without JIT? |
|---|---|---|
.notSupported |
Core has no JIT code path | ✅ Yes |
.optional(fallback:) |
JIT improves perf/accuracy; interpreter fallback available | ✅ Yes |
.automaticWithFallback |
Core self-detects JIT and selects execution path automatically | ✅ Yes |
.requiredOrCrash |
Core crashes or produces garbage without JIT | ❌ No |
| Core | PVJITRequirement (Swift) |
Notes |
|---|---|---|
| Dolphin (Wii/GC) | .automaticWithFallback |
Selects JIT or Cached Interpreter at startup; never crashes without JIT |
| melonDS (DS) | .optional(fallback: "Interpreter") |
JIT recompiler boosts DS performance |
| DeSmuME 2015 (DS) | .optional(fallback: "Interpreter") |
Older DS core, optional JIT |
| PCSX Rearmed (PSX) | .optional(fallback: "Interpreter") |
ARM dynarec; interpreter always available |
| Mupen64Plus (N64) | .optional(fallback: "Interpreter") |
JIT recompiler; interpreter fallback |
| PPSSPP (PSP) | .optional(fallback: "Interpreter") |
JIT for full-speed PSP; interpreter available |
| Flycast (Dreamcast) | .optional(fallback: "Interpreter") |
JIT recompiler available; interpreter fallback |
| Play! (PS2) | .requiredOrCrash |
Crashes or produces garbage output without JIT |
| Azahar / Citra (3DS) | .automaticWithFallback |
Auto-detects JIT; falls back to interpreter. enableJIT defaults to true for best performance. |
| emuThree (3DS) | .automaticWithFallback |
Same Citra codebase; same automatic fallback behaviour. |
Query a core's JIT requirement before launching a game:
let core: PVEmulatorCore = ...
switch core.jitRequirement {
case .notSupported:
break // no JIT needed — launch immediately
case .optional(let fallback):
// Try to acquire JIT; warn user if unavailable (will run via fallback)
acquireJITIfAvailable()
case .automaticWithFallback:
// Attempt JIT acquisition, but launch regardless of outcome
acquireJITIfAvailable()
case .requiredOrCrash:
// Must acquire JIT or refuse to launch
guard acquireJIT() else {
showJITRequiredError()
return
}
}Override jitRequirement in the core's PVEmulatorCore subclass:
open override var jitRequirement: PVPrimitives.PVJITRequirement {
.optional(fallback: "Interpreter")
}The default implementation returns .notSupported, so only JIT-capable cores need to override.
Also add a PVJITRequirement key to the core's Core.plist:
<key>PVJITRequirement</key>
<string>optional</string> <!-- "required" | "optional" | "notRequired" -->Some cores are shipped with PVDisabled = true because they require JIT to run at all
(e.g., Azahar/3DS). Mark them with PVJITDisabledWithoutJIT = true so the runtime can
auto-enable them once JIT is successfully acquired:
<key>PVDisabled</key>
<true/>
<key>PVJITDisabledWithoutJIT</key>
<true/>
<key>PVJITRequirement</key>
<string>required</string>The app layer (#2794) will query EmulatorCoreInfoPlist.jitDisabledWithoutJIT to find
these cores and toggle them on when a JIT entitlement is obtained.
- Copy
CodeSigning.xcconfig.sampletoCodeSigning.xcconfigand edit your relevent developer account details - Accept any XCode / Swift Packagage Manager plugins (this will be presented to you by XCode at first build)
- Select scheme to build
- I suggest building
Litefirst and working your way up toXLas you resolve any issues you may encouter in less time with theLiteapp target. - Most users will want wither
Provenance-ReleaseorProvenacne-XL (Release). The XL build includes moreRetroArchand native local cores. See the build target and./CoresRetro/RetroArch/Scripts/build file lists for the most accurate list of cores for each target.
- I suggest building
- If initial build fails, try again, as some source code files are generated lazily at compile time and sometimes XCode doesn't get the build order corrct
When working with Realm and Swift Concurrency, it's important to remember that Realm objects are thread-confined, meaning they can only be accessed on the thread where they were created. Here's the recommended approach:
-
Use Object IDs or Primary Keys: Instead of passing the managed object directly, pass the object's ID or primary key to the other thread. This is safe because IDs and primary keys are simple value types.
let objectId = managedObject.id // Assuming your object has an id property Task { await someAsyncFunction(objectId) }
-
Fetch the Object on the New Thread: In the async function, use the ID to fetch a new instance of the object from the Realm on that thread.
func someAsyncFunction(_ objectId: ObjectId) async { let realm = try! await Realm() if let object = realm.object(ofType: YourObject.self, forPrimaryKey: objectId) { // Use the object here } }
-
Use Unmanaged Objects: If you need to pass actual data between threads, you can create an unmanaged copy of the object. This is useful when you don't need to update the object in the database.
let unmanagedCopy = YourObject(value: managedObject) Task { await someAsyncFunction(unmanagedCopy) }
-
Use Realm's Built-in Threading Support: Realm provides some built-in support for working across threads. You can use
Realm.asyncOpen()to open a Realm asynchronously:Task { do { let realm = try await Realm.asyncOpen() // Use realm here } catch { print("Failed to open realm: \(error.localizedDescription)") } }
-
Freeze Objects: Realm allows you to create a frozen copy of an object, which can be safely passed between threads:
let frozenObject = managedObject.freeze() Task { await someAsyncFunction(frozenObject) } func someAsyncFunction(_ frozenObject: YourObject) async { // Use frozenObject here. It's immutable but can be safely accessed across threads. }
-
Use ThreadSafeReference: For more complex scenarios, you can use
ThreadSafeReference:let reference = ThreadSafeReference(to: managedObject) Task { let realm = try! await Realm() guard let resolvedObject = realm.resolve(reference) else { return // The object has been deleted } // Use resolvedObject here }
Remember, when using Swift Concurrency with Realm:
- Always access Realm and its objects on the same thread they were created on.
- Use
@MainActorfor UI updates involving Realm objects. - Be cautious with long-running transactions in async contexts to avoid blocking the thread.
By following these guidelines, you can safely work with Realm objects across different threads when using Swift Concurrency.
- https://provenance-emu.com/test_roms/240pee.nes
- https://provenance-emu.com/test_roms/240pee_mb.gba
- https://provenance-emu.com/test_roms/gb240p.gb
-
Apple
- Apple II
- Macintosh
-
Atari
- Atari 2600
- Atari 5200
- Atari 7800
- Atari 8bit Computer
- Atari Jaguar
- Atari Jaguar CD
- Atari Lynx
- Atari ST
-
Bandai
- WonderSwan
-
CBS
- CBS ColecoVision
-
Enterprise
- Enterprise 128
-
IBM
- IBM PC DOS
-
Libretro
- RetroArch
-
Magnavox
- Magnavox Odyssey2
-
MAME
- MAME
-
Mattel
- Mattel Intellivision
-
NEC
- PC98
- PCFX
- SuperGrafx
- TurboGrafx-16
- TurboGrafx-CD
-
Nintendo
- DS
- Famicom Disk System
- Game Boy
- Game Boy Advance
- Game Boy Color
- Nintendo
- Nintendo 64
- Nintendo GameCube
- Nintendo Wii
- Pokémon mini
- Super Nintendo
- Virtual Boy
-
Panasonic
- 3DO
-
Sega
- 32X
- Dreamcast
- Game Gear
- Genesis
- Master System
- Saturn
- Sega CD
- SG-1000
-
Smith Engineering
- Smith Engineering Vectrex
-
SNK
- Neo Geo
- Neo Geo Pocket
- Neo Geo Pocket Color
-
Sony
- PlayStation
- PlayStation 2
- PlayStation Portable
-
Various
- Game Music
-
Watara
- Supervision
-
ZX
- ZX Spectrum
Notes: Crackles sometimes, sounds slowed down
Logs: 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 ℹ️ PVMetalViewController.swift:268 - updatePreferredFPS(): updatePreferredFPS (59) 🔍 GameAudioEngine2.swift:203 - updateSourceNode(): Entering updateSourceNode 🔍 GameAudioEngine2.swift:207 - updateSourceNode(): Detached existing source node 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:219 - updateSourceNode(): Using format: <AVAudioFormat 0x301b47840: 2 ch, 44100 Hz, Int16, interleaved> 🔍 GameAudioEngine2.swift:238 - updateSourceNode(): Attached new source node 🔍 GameAudioEngine2.swift:240 - updateSourceNode(): Exiting updateSourceNode 🔍 GameAudioEngine2.swift:266 - connectNodes(): Entering connectNodes 🔍 GameAudioEngine2.swift:273 - connectNodes(): Output format: <AVAudioFormat 0x30185d590: 2 ch, 48000 Hz, Float32, deinterleaved>
Notes: Sounds slowed down, drunk. Sometimes pops / cracks
Logs: 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 1, Bits: 16 🔍 GameAudioEngine2.swift:219 - updateSourceNode(): Using format: <AVAudioFormat 0x301dcfa20: 1 ch, 44100 Hz, Int16> 🔍 GameAudioEngine2.swift:238 - updateSourceNode(): Attached new source node 🔍 GameAudioEngine2.swift:240 - updateSourceNode(): Exiting updateSourceNode 🔍 GameAudioEngine2.swift:266 - connectNodes(): Entering connectNodes 🔍 GameAudioEngine2.swift:273 - connectNodes(): Output format: <AVAudioFormat 0x301dced50: 2 ch, 48000 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:297 - connectNodes(): Connected with format conversion: <AVAudioFormat 0x301dcfa20: 1 ch, 44100 Hz, Int16> -> <AVAudioFormat 0x301dcee90: 1 ch, 44100 Hz, Float32> 🔍 GameAudioEngine2.swift:304 - connectNodes(): Set main mixer node output volume to 1.0 🔍 GameAudioEngine2.swift:305 - connectNodes(): Exiting connectNodes
I think it sounds fine
Logs: 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:219 - updateSourceNode(): Using format: <AVAudioFormat 0x301afb4d0: 2 ch, 44100 Hz, Int16, interleaved> 🔍 GameAudioEngine2.swift:238 - updateSourceNode(): Attached new source node 🔍 GameAudioEngine2.swift:240 - updateSourceNode(): Exiting updateSourceNode 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:313 - updateSampleRateConversion(): Source rate: 44100.0, Target rate: 48000.0 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:314 - updateSampleRateConversion(): Source format: <AVAudioFormat 0x301b60320: 2 ch, 44100 Hz, Int16, interleaved>, Output format: <AVAudioFormat 0x301af9680: 2 ch, 48000 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:324 - updateSampleRateConversion(): Setting sample rate conversion ratio: 1.0884354 🔍 GameAudioEngine2.swift:342 - updateSampleRateConversion(): Connecting with converter format: <AVAudioFormat 0x301b60370: 2 ch, 44100 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:365 - updateSampleRateConversion(): Successfully connected through converter chain
Notes: Sounds fine
Logs: 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:219 - updateSourceNode(): Using format: <AVAudioFormat 0x305bba170: 2 ch, 44100 Hz, Int16, interleaved> 🔍 GameAudioEngine2.swift:238 - updateSourceNode(): Attached new source node 🔍 GameAudioEngine2.swift:240 - updateSourceNode(): Exiting updateSourceNode 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:313 - updateSampleRateConversion(): Source rate: 44100.0, Target rate: 48000.0 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:314 - updateSampleRateConversion(): Source format: <AVAudioFormat 0x305bbb480: 2 ch, 44100 Hz, Int16, interleaved>, Output format: <AVAudioFormat 0x305bba260: 2 ch, 48000 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:324 - updateSampleRateConversion(): Setting sample rate conversion ratio: 1.0884354 🔍 GameAudioEngine2.swift:342 - updateSampleRateConversion(): Connecting with converter format: <AVAudioFormat 0x305bba580: 2 ch, 44100 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:365 - updateSampleRateConversion(): Successfully connected through converter chain 🔍 PVMediaCache.swift:292 - image(forKey:completion:): Image found in memory cache: true 🔍 PVMediaCache.swift:292 - image(forKey:completion:): Image found in memory cache: true 🔍 GameAudioEngine2.swift:203 - updateSourceNode(): Entering updateSourceNode 🔍 GameAudioEngine2.swift:207 - updateSourceNode(): Detached existing source node 🔍 GameAudioEngine2.swift:175 - streamDescription: Creating stream description - Rate: 44100.0, Channels: 2, Bits: 16 🔍 GameAudioEngine2.swift:219 - updateSourceNode(): Using format: <AVAudioFormat 0x305b09450: 2 ch, 44100 Hz, Int16, interleaved> 🔍 GameAudioEngine2.swift:238 - updateSourceNode(): Attached new source node 🔍 GameAudioEngine2.swift:240 - updateSourceNode(): Exiting updateSourceNode 🔍 GameAudioEngine2.swift:266 - connectNodes(): Entering connectNodes 🔍 GameAudioEngine2.swift:273 - connectNodes(): Output format: <AVAudioFormat 0x305b094a0: 2 ch, 48000 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:297 - connectNodes(): Connected with format conversion: <AVAudioFormat 0x305b09450: 2 ch, 44100 Hz, Int16, interleaved> -> <AVAudioFormat 0x305b09c20: 2 ch, 44100 Hz, Float32, deinterleaved> 🔍 GameAudioEngine2.swift:304 - connectNodes(): Set main mixer node output volume to 1.0 🔍 GameAudioEngine2.swift:305 - connectNodes(): Exiting connectNodes
Provenance supports RetroArch's native per-game core options system using .opt files. This allows individual games to have their own core option overrides.
RetroArch stores core options in the following locations:
<Documents>/RetroArch/config/<core_name>/<core_name>.opt # Per-core global options
<Documents>/RetroArch/config/<core_name>/<game_name>.opt # Per-game override
core_name: The libretro core's library name (e.g., "Nestopia", "Snes9x", "Beetle PSX")game_name: The ROM filename without extension (e.g., "Super Mario World" for "Super Mario World.smc")
The PVRetroArchCoreBridge class provides methods to access these paths:
+perGameOptionsPathForGame:- Returns the path to a per-game.optfile+perCoreOptionsPath- Returns the path to the per-core global.optfile+coreLibraryName- Returns the core's library name as reported by libretro
For Swift UI integration, use RetroArchCoreOptionsFileHelper which provides:
readOptFile(atPath:)- Reads an.optfile and returns key-value pairswriteOptFile(options:toPath:)- Writes options to an.optfilemergeOptions(intoOptFileAtPath:)- Merges new options into an existing filedeleteOptFile(atPath:)- Deletes an.optfile
RetroArch .opt files are simple text files with the following format:
# RetroArch Core Options
# Generated by Provenance
nestopia_blargg_ntsc_filter = "disabled"
nestopia_aspect_ratio = "auto"
nestopia_fds_auto_insert = "enabled"
Each line represents a core option in key = "value" format. Lines starting with # are comments.
-
ROM Filename Matching: The game name used for the
.optfilename is derived from the ROM path's basename (filename without directory or extension). If Provenance renames or relocates ROMs, the.optfile must be renamed accordingly. -
Core Library Name: The core name used in the path comes from the libretro core's
library_namefield (e.g., "Nestopia" not "PVNestopiaCore"). This is obtained at runtime from the loaded core. -
Config Directory: The base config directory (
<Documents>/RetroArch/config) is determined by RetroArch's path system viaAPPLICATION_SPECIAL_DIRECTORY_CONFIG. -
File Creation: RetroArch creates
.optfiles automatically when core options are changed andgame_specific_optionsis enabled in the configuration (which is the default in Provenance).
The artwork pipeline lives in PVLookup (Tier 5). PVLookup.shared is the single entry point; it fans out to multiple database back-ends, merges their results, sorts by type priority, and caches the final list.
PVLookup.shared (actor)
├── OpenVGDB (offline SQLite — ArtworkLookupOfflineService)
├── libretrodb (offline SQLite + remote thumbnails — ArtworkLookupOfflineService)
└── TheGamesDB (offline SQLite index + remote CDN URLs — ArtworkLookupService)
All three back-ends are queried in parallel via withTaskGroup. Results are concatenated in source order (OpenVGDB → LibretroDB → TheGamesDB), then sorted by ArtworkType priority.
ArtworkType is a UInt-backed OptionSet defined in PVLookup/Sources/PVLookupTypes/ArtworkType.swift:
| Case | Raw bit | Display name |
|---|---|---|
.boxFront |
1 << 0 |
Box Front |
.boxBack |
1 << 1 |
Box Back |
.manual |
1 << 2 |
Manual |
.screenshot |
1 << 3 |
Screenshot |
.titleScreen |
1 << 4 |
Title Screen |
.fanArt |
1 << 5 |
Fan Art |
.banner |
1 << 6 |
Banner |
.clearLogo |
1 << 7 |
Clear Logo |
.other |
1 << 8 |
Other |
Composite constants:
.defaults—[.boxFront, .titleScreen, .boxBack]— used when no type filter is supplied.retroDBSupported—[.boxFront, .titleScreen, .screenshot]— the subset LibretroDB thumbnails can provide
| Source | boxFront | boxBack | screenshot | titleScreen | fanArt | banner | clearLogo | manual | other |
|---|---|---|---|---|---|---|---|---|---|
| OpenVGDB | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ |
| LibretroDB | ✅ | — | ✅ | ✅ | — | — | — | — | — |
| TheGamesDB | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ |
OpenVGDB infers type from URL path keywords ("front" → .boxFront, "back" → .boxBack, "screenshot" → .screenshot); other artwork URLs (for example, cart/disc covers) fall back to .other.
LibretroDB maps its three thumbnail directories: named_boxarts/ → .boxFront, named_titles/ → .titleScreen, named_snaps/ → .screenshot.
TheGamesDB maps its type/side columns via ArtworkType(fromTheGamesDB:side:); several logical artwork types (including manuals) are currently collapsed to .other rather than emitting .manual.
ArtworkSearchCache is a Swift actor singleton (ArtworkSearchCache.shared) backed by a [ArtworkSearchKey: [ArtworkMetadata]] dictionary, defined in PVLookup/Sources/PVLookupTypes/ArtworkSearchCache.swift.
- Capacity: 100 entries (
maxCacheSize = 100). - Eviction: Least-recently-used. Access order is tracked in an
accessOrder: [ArtworkSearchKey]array. On every cache hit the key is moved to the end; on overflow the first (oldest) key is removed. - Cache key (
ArtworkSearchKey): composite ofgameName(case-insensitive, compared usinglowercased()),systemID: SystemIdentifier?, andartworkTypes: ArtworkType. Two keys are equal when all three fields match (name comparison is case-insensitive). - Writes:
PVLookup.searchArtwork(byGameName:systemID:artworkTypes:)writes to the cache after a successful search. A result is only cached when it is non-empty. - Invalidation: Call
ArtworkSearchCache.shared.clear()to flush the entire cache (e.g., after a library rescan).
After the parallel task group completes, results are combined in this fixed order:
openVGDBArtwork + libretroDBArtwork + theGamesDBartwork
The combined array is then sorted by type priority (highest first):
boxFront → boxBack → screenshot → titleScreen → clearLogo → banner → fanArt → manual → other
This sort is performed by PVLookup.sortArtworkByType(_:), which only compares type priority. Relative ordering of artwork items with the same type is not guaranteed by this sort; if you need a deterministic secondary ordering (for example, preferring OpenVGDB over LibretroDB over TheGamesDB), apply an additional stable sort or explicitly sort by a composite key such as (typePriority, sourceRank) at the call site.
ArtworkMetadata is Hashable by (url, type, source). Individual back-ends or helper types may perform their own internal deduplication using Set<ArtworkMetadata> (for example, the LibretroArtwork.searchArtwork(byGameName:systemID:artworkTypes:) helper can do this when used directly). The LibretroDB code path that PVLookup currently uses (libretrodb.searchArtwork(...) in libretrodb.swift) does not perform Set-based deduplication. The merged result from PVLookup is not additionally deduplicated at the aggregation layer, so distinct sources can return the same artwork URL as long as source differs.
Note: Converting the result array to a
Set<ArtworkMetadata>only removes exact(url, type, source)duplicates. If you need global deduplication across sources (e.g. by URL or by(url, type)), build a dictionary keyed by your desired key and then take the values, for example:let byURL = Dictionary(artworks.map { ($0.url, $0) }, uniquingKeysWith: { first, _ in first }) let globallyDedupedByURL = Array(byURL.values)or, if you need
(url, type)as the key:let byURLAndType = Dictionary(artworks.map { (($0.url, $0.type), $0) }, uniquingKeysWith: { first, _ in first }) let globallyDedupedByURLAndType = Array(byURLAndType.values)
Create a new type that conforms to ArtworkLookupService. If your source also needs to expose a mapping from logical artwork types to its own identifiers (e.g. size or variant codes), additionally conform to ArtworkLookupOnlineService and implement getArtworkMappings(). The choice of protocol is about capabilities, not whether the source is online or offline:
// PVLookup/Sources/MyNewDB/MyNewDB.swift
import PVLookupTypes
import PVSystems
public struct MyNewDB: ArtworkLookupService {
public func searchArtwork(
byGameName name: String,
systemID: SystemIdentifier?,
artworkTypes: ArtworkType?
) async throws -> [ArtworkMetadata]? {
// Query your data source and return ArtworkMetadata values.
// Use `source: "MyNewDB"` so duplicates from other sources are kept distinct.
return nil
}
public func getArtwork(
forGameID gameID: String,
artworkTypes: ArtworkType?
) async throws -> [ArtworkMetadata]? {
return nil
}
public func getArtworkURLs(forRom rom: ROMMetadata) async throws -> [URL]? {
return nil
}
}For sources that also provide ROM-to-artwork mapping tables, additionally conform to ArtworkLookupOfflineService (for fully offline sources) or ArtworkLookupOnlineService (for sources that require an online connection). As of now, both protocols simply add getArtworkMappings() async throws -> ArtworkMapping and are distinguished by their connectivity semantics.
Open PVLookup/Sources/PVLookup/PVLookup.swift and make four edits:
a. Add a case to LocalDatabases:
public enum LocalDatabases: CaseIterable {
// ... existing cases ...
case myNewDB
}b. Add a stored property to PVLookup:
private var myNewDB: MyNewDB?c. Initialize it in initializeDatabases():
do {
let db = try await MyNewDB()
self.myNewDB = db
} catch {
ELOG("Failed to initialize MyNewDB: \(error)")
}d. Query it in searchArtwork(byGameName:systemID:artworkTypes:) and getArtwork(forGameID:artworkTypes:):
let shouldSearchMyNewDB = databases.contains(.myNewDB)
var myNewDBArtwork: [ArtworkMetadata] = []
// Inside the existing withTaskGroup block:
group.addTask {
guard shouldSearchMyNewDB else { return (3, []) }
if let results = try? await self.myNewDB?.searchArtwork(
byGameName: name,
systemID: systemID,
artworkTypes: artworkTypes
) {
return (3, results)
}
return (3, [])
}
// In the for-await loop:
case 3: myNewDBArtwork = result
// In the combine step:
let results = openVGDBArtwork + libretroDBArtwork + theGamesDBartwork + myNewDBArtworkAlso add it to the default databases array and to getArtworkURLs(forRom:) / getArtworkMappings() if applicable.
If your source is experimental, add a case to PVFeature in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift:
/// Enables artwork lookup from MyNewDB.
/// Disabled by default until the data set is validated.
case myNewDBArtwork = "myNewDBArtwork"Then guard initialization and the databases.contains(.myNewDB) check with the flag:
// In initializeDatabases():
guard await PVFeatureFlags.shared.isEnabled(.myNewDBArtwork) else { return }
// In the databases array initializer:
private var databases: [LocalDatabases] = {
var dbs: [LocalDatabases] = [.libretro, .openVGDB, .theGamesDB]
// myNewDB added at runtime after flag check
return dbs
}()Alternatively, check the flag inside the group.addTask closure so the query is skipped at search time rather than at initialization time.
If your source lives in a new Swift package target, add it to PVLookup/Package.swift:
.target(
name: "MyNewDB",
dependencies: ["PVLookupTypes", "PVSQLiteDatabase", "PVSystems", "PVLogging"],
path: "Sources/MyNewDB"
),And add it as a dependency of the PVLookup target:
.target(
name: "PVLookup",
dependencies: [
// ... existing dependencies ...
.target(name: "MyNewDB"),
],
...
),Use #if canImport(MyNewDB) guards in PVLookup.swift to keep the module optional (matching the pattern used for OpenVGDB, libretrodb, TheGamesDB, and ShiraGame).