feat(desktop): add opt-in Darwin audio output

This commit is contained in:
0xrsydn 2026-10-07 07:16:42 +07:00
commit 2ea7164d9a
21 changed files with 1311 additions and 30 deletions

618
Cargo.lock generated
View file

@ -2,6 +2,34 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.13.2",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -20,6 +48,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.5.1" version = "1.5.1"
@ -30,12 +64,28 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.5" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "combine"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "console_error_panic_hook" name = "console_error_panic_hook"
version = "0.1.7" version = "0.1.7"
@ -46,6 +96,68 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "coreaudio-rs"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
dependencies = [
"bitflags 1.3.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
dependencies = [
"alsa",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
@ -108,7 +220,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.6",
] ]
[[package]] [[package]]
@ -144,6 +256,7 @@ dependencies = [
name = "gameboy-rust-desktop" name = "gameboy-rust-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cpal",
"gba-core", "gba-core",
"gba-demos", "gba-demos",
"minifb", "minifb",
@ -165,6 +278,22 @@ dependencies = [
"gba-core", "gba-core",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.13" version = "0.1.13"
@ -183,6 +312,50 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.106" version = "0.3.106"
@ -218,6 +391,21 @@ dependencies = [
"redox_syscall", "redox_syscall",
] ]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@ -247,6 +435,149 @@ dependencies = [
"x11-dl", "x11-dl",
] ]
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.2",
"jni-sys 0.3.1",
"log",
"ndk-sys",
"num_enum",
"thiserror",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.13.2",
"libc",
"objc2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"objc2",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -282,6 +613,15 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@ -321,6 +661,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "sdl2" name = "sdl2"
version = "0.38.0" version = "0.38.0"
@ -371,7 +720,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.6",
] ]
[[package]] [[package]]
@ -399,6 +748,17 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.6" version = "3.0.6"
@ -410,6 +770,26 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.1"
@ -419,6 +799,36 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
@ -431,6 +841,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.129" version = "0.2.129"
@ -476,7 +896,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.6",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -515,12 +935,204 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "x11-dl" name = "x11-dl"
version = "2.21.0" version = "2.21.0"

View file

@ -55,7 +55,7 @@ Use `--test-suite suite.json` for [repeatable headless ROM assertions](docs/rom-
| [docs/hardware/bios.md](docs/hardware/bios.md) | The original BIOS replacement and each supported service | | [docs/hardware/bios.md](docs/hardware/bios.md) | The original BIOS replacement and each supported service |
| [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) | Timers, interrupt registers, HALT/STOP, and power control | | [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) | Timers, interrupt registers, HALT/STOP, and power control |
| [docs/hardware/dma.md](docs/hardware/dma.md) | DMA channels, triggers, and timing | | [docs/hardware/dma.md](docs/hardware/dma.md) | DMA channels, triggers, and timing |
| [docs/hardware/audio.md](docs/hardware/audio.md) | Disabled sound initialization and remaining audio work | | [docs/hardware/audio.md](docs/hardware/audio.md) | Sound devices, stereo capture, optional Darwin output, and limits |
| [docs/hardware/serial.md](docs/hardware/serial.md) | Disconnected serial initialization and transfer limits | | [docs/hardware/serial.md](docs/hardware/serial.md) | Disconnected serial initialization and transfer limits |
| [docs/hardware/display-timing.md](docs/hardware/display-timing.md) | Display clock, status, IRQs, and scanline capture | | [docs/hardware/display-timing.md](docs/hardware/display-timing.md) | Display clock, status, IRQs, and scanline capture |
| [docs/hardware/video.md](docs/hardware/video.md) | Backgrounds, sprites, mosaic, windows, color effects, and input | | [docs/hardware/video.md](docs/hardware/video.md) | Backgrounds, sprites, mosaic, windows, color effects, and input |

View file

@ -15,3 +15,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Pinned because src/desktop.rs corrects a macOS focus bug in this version. # Pinned because src/desktop.rs corrects a macOS focus bug in this version.
minifb = { version = "=0.28.0", default-features = false, features = ["x11"] } minifb = { version = "=0.28.0", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "macos")'.dependencies]
cpal = { version = "=0.16.0", default-features = false }

View file

@ -0,0 +1,30 @@
//! Opt-in desktop audio. The emulator core never owns a host device.
#[cfg(target_os = "macos")]
mod device;
#[cfg(any(target_os = "macos", test))]
mod playback;
#[cfg(target_os = "macos")]
pub use device::AudioOutput;
#[cfg(not(target_os = "macos"))]
pub struct AudioOutput;
#[cfg(not(target_os = "macos"))]
impl AudioOutput {
pub fn new() -> std::io::Result<Self> {
Err(std::io::Error::other(
"--audio currently requires macOS; omit --audio to run muted",
))
}
pub fn submit(&self, _: &[gba_core::audio::StereoLevel]) -> std::io::Result<()> {
unreachable!()
}
pub fn clear(&self) -> std::io::Result<()> {
unreachable!()
}
pub fn description(&self) -> String {
unreachable!()
}
pub fn report(&self, _: &mut impl std::io::Write) -> std::io::Result<()> {
unreachable!()
}
}

View file

@ -0,0 +1,188 @@
//! macOS CoreAudio through CPAL. No blocking or allocation in the data callback.
use super::playback::Playback;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use gba_core::audio::StereoLevel;
use std::{
io::{self, Write},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
};
struct Shared {
playback: Mutex<Playback>,
failed: AtomicBool,
callbacks: AtomicU64,
nonzero: AtomicU64,
contention: AtomicU64,
}
pub struct AudioOutput {
_stream: cpal::Stream,
shared: Arc<Shared>,
rate: u32,
channels: u16,
}
impl AudioOutput {
pub fn new() -> io::Result<Self> {
let device = cpal::default_host()
.default_output_device()
.ok_or_else(|| {
io::Error::other("no default audio output device; omit --audio to run muted")
})?;
let config = device.default_output_config().map_err(io::Error::other)?;
let rate = config.sample_rate().0;
let channels = config.channels();
if !(8000..=192000).contains(&rate) || !(1..=32).contains(&channels) {
return Err(io::Error::other(
"unsupported audio rate/channel count; omit --audio to run muted",
));
}
let shared = Arc::new(Shared {
playback: Mutex::new(Playback::new(rate, usize::from(channels))),
failed: AtomicBool::new(false),
callbacks: AtomicU64::new(0),
nonzero: AtomicU64::new(0),
contention: AtomicU64::new(0),
});
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => build::<f32>(&device, &config.into(), shared.clone()),
cpal::SampleFormat::I16 => build::<i16>(&device, &config.into(), shared.clone()),
cpal::SampleFormat::U16 => build::<u16>(&device, &config.into(), shared.clone()),
format => {
return Err(io::Error::other(format!(
"unsupported output sample format {format}; omit --audio to run muted"
)))
}
}?;
stream.play().map_err(io::Error::other)?;
Ok(Self {
_stream: stream,
shared,
rate,
channels,
})
}
fn check(&self) -> io::Result<()> {
if self.shared.failed.load(Ordering::Relaxed) {
Err(io::Error::other(
"audio output stream failed; restart without --audio to run muted",
))
} else {
Ok(())
}
}
pub fn submit(&self, samples: &[StereoLevel]) -> io::Result<()> {
self.check()?;
self.shared
.playback
.lock()
.map_err(|_| io::Error::other("audio queue poisoned"))?
.submit(samples);
Ok(())
}
pub fn clear(&self) -> io::Result<()> {
self.check()?;
self.shared
.playback
.lock()
.map_err(|_| io::Error::other("audio queue poisoned"))?
.clear();
Ok(())
}
pub fn description(&self) -> String {
format!(
"CoreAudio: {} Hz, {} channels; linear conversion, 25% gain, DC removal",
self.rate, self.channels
)
}
pub fn report(&self, writer: &mut impl Write) -> io::Result<()> {
let p = self
.shared
.playback
.lock()
.map_err(|_| io::Error::other("audio queue poisoned"))?;
writeln!(writer,"Audio callbacks: {}; nonzero output frames: {}; underruns: {}; dropped input frames: {}; callback lock misses: {}", self.shared.callbacks.load(Ordering::Relaxed),self.shared.nonzero.load(Ordering::Relaxed),p.underruns,p.dropped,self.shared.contention.load(Ordering::Relaxed))?;
writeln!(
writer,
"Audio submitted frames: {}; nonzero input frames: {}",
p.submitted, p.nonzero_input
)?;
self.check()
}
}
fn build<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
shared: Arc<Shared>,
) -> io::Result<cpal::Stream>
where
T: cpal::SizedSample + cpal::FromSample<f32>,
{
let errors = shared.clone();
device
.build_output_stream(
config,
move |data: &mut [T], _| {
data.fill(T::from_sample(0.));
shared.callbacks.fetch_add(1, Ordering::Relaxed);
match shared.playback.try_lock() {
Ok(mut p) => {
let nonzero = p.render(data, T::from_sample);
shared.nonzero.fetch_add(nonzero as u64, Ordering::Relaxed);
}
Err(std::sync::TryLockError::WouldBlock) => {
shared.contention.fetch_add(1, Ordering::Relaxed);
}
Err(std::sync::TryLockError::Poisoned(_)) => {
shared.failed.store(true, Ordering::Relaxed);
}
}
},
move |_| {
errors.failed.store(true, Ordering::Relaxed);
},
None,
)
.map_err(io::Error::other)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires a native default audio device; plays a quiet original test tone"]
fn native_audio_output_consumes_original_samples() {
let output = AudioOutput::new().unwrap();
// Exercise the actual core capture path without depending on window focus.
let mut memory = gba_core::memory::Memory::new(vec![]).unwrap();
for (address, value) in [
(0x04000084, 0x80),
(0x04000088, 0x200),
(0x04000082, 2),
(0x04000080, 0x1177),
(0x04000062, 0x3080),
(0x04000064, 0x86d6),
] {
memory.write16(address, value).unwrap();
}
memory.set_audio_capture(true);
memory.advance_cycles(4096 * 512);
let mut samples = vec![StereoLevel::default(); 4096];
assert_eq!(memory.drain_audio_samples(&mut samples), 4096);
assert_eq!(memory.audio_dropped_samples(), 0);
assert!(samples.iter().any(|s| s.left > 0));
assert!(samples.iter().any(|s| s.left < 0));
output.submit(&samples).unwrap();
let start = std::time::Instant::now();
while output.shared.nonzero.load(Ordering::Relaxed) == 0
&& start.elapsed() < std::time::Duration::from_secs(3)
{
std::thread::sleep(std::time::Duration::from_millis(10));
output.check().unwrap();
}
output.report(&mut std::io::stdout()).unwrap();
assert!(output.shared.nonzero.load(Ordering::Relaxed) > 0);
output.clear().unwrap();
}
}

View file

@ -0,0 +1,193 @@
//! Bounded host buffering and linear rate conversion. No device access here.
use gba_core::audio::{StereoLevel, AUDIO_SAMPLE_RATE};
use std::collections::VecDeque;
pub(super) const CAPACITY: usize = 8192;
const PREFILL: usize = 1024;
pub(super) struct Playback {
queue: VecDeque<StereoLevel>,
rate: u32,
channels: usize,
phase: u32,
pair: Option<[StereoLevel; 2]>,
previous: [f32; 2],
filtered: [f32; 2],
decay: f32,
pub(super) submitted: u64,
pub(super) nonzero_input: u64,
pub(super) dropped: u64,
pub(super) underruns: u64,
}
impl Playback {
pub(super) fn new(rate: u32, channels: usize) -> Self {
assert!((8000..=192000).contains(&rate) && (1..=32).contains(&channels));
Self {
queue: VecDeque::with_capacity(CAPACITY),
rate,
channels,
phase: 0,
pair: None,
previous: [0.; 2],
filtered: [0.; 2],
decay: (-std::f32::consts::TAU * 20. / rate as f32).exp(),
submitted: 0,
nonzero_input: 0,
dropped: 0,
underruns: 0,
}
}
pub(super) fn submit(&mut self, samples: &[StereoLevel]) {
self.submitted = self.submitted.saturating_add(samples.len() as u64);
self.nonzero_input = self.nonzero_input.saturating_add(
samples
.iter()
.filter(|s| s.left != 0 || s.right != 0)
.count() as u64,
);
for &sample in samples {
if self.queue.len() == CAPACITY {
self.dropped = self.dropped.saturating_add(1);
} else {
self.queue.push_back(sample);
}
}
}
pub(super) fn clear(&mut self) {
self.queue.clear();
self.reset();
}
fn reset(&mut self) {
self.pair = None;
self.phase = 0;
self.previous = [0.; 2];
self.filtered = [0.; 2];
}
fn next(&mut self) -> [f32; 2] {
if self.pair.is_none() {
if self.queue.len() < PREFILL {
return [0.; 2];
}
self.pair = Some([
self.queue.pop_front().unwrap(),
self.queue.pop_front().unwrap(),
]);
}
let [a, b] = self.pair.unwrap();
let fraction = self.phase as f32 / self.rate as f32;
let mut output = [0.; 2];
for (side, (a, b)) in [(a.left, b.left), (a.right, b.right)]
.into_iter()
.enumerate()
{
// Conservative 25% host gain and 20 Hz DC removal; not a hardware analog model.
let input = (f32::from(a) + (f32::from(b) - f32::from(a)) * fraction) / 512. * 0.25;
self.filtered[side] = input - self.previous[side] + self.decay * self.filtered[side];
self.previous[side] = input;
output[side] = self.filtered[side].clamp(-1., 1.);
}
self.phase += AUDIO_SAMPLE_RATE;
while self.phase >= self.rate {
self.phase -= self.rate;
if let Some(next) = self.queue.pop_front() {
let old = self.pair.unwrap()[1];
self.pair = Some([old, next]);
} else {
self.underruns = self.underruns.saturating_add(1);
self.reset();
break;
}
}
output
}
pub(super) fn render<T: Copy>(
&mut self,
output: &mut [T],
convert: impl Fn(f32) -> T,
) -> usize {
output.fill(convert(0.));
let mut nonzero = 0;
for frame in output.chunks_exact_mut(self.channels) {
let [left, right] = self.next();
if self.channels == 1 {
frame[0] = convert((left + right) * 0.5);
} else {
frame[0] = convert(left);
frame[1] = convert(right);
}
nonzero += usize::from(left != 0. || right != 0.);
}
nonzero
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(left: i16, right: i16) -> StereoLevel {
StereoLevel { left, right }
}
#[test]
fn prefill_silence_stereo_mapping_extra_channels_and_clear() {
let mut p = Playback::new(48000, 4);
let mut out = [9.; 16];
p.render(&mut out, |x| x);
assert_eq!(out, [0.; 16]);
p.submit(&vec![sample(256, -256); PREFILL]);
p.render(&mut out, |x| x);
assert_eq!(&out[..4], &[0.125, -0.125, 0., 0.]);
assert!(out[4] > 0. && out[4] < 0.125);
p.clear();
p.render(&mut out, |x| x);
assert_eq!(out, [0.; 16]);
}
#[test]
fn split_callbacks_keep_phase_and_rate_conversion_consumption() {
for rate in [8000, 32768, 44100, 48000, 192000] {
let mut a = Playback::new(rate, 2);
let mut b = Playback::new(rate, 2);
let data: Vec<_> = (0..4096)
.map(|i| sample((i % 1024) as i16 - 512, 0))
.collect();
a.submit(&data);
b.submit(&data);
let mut one = vec![0.; 2000];
let mut split = one.clone();
a.render(&mut one, |x| x);
for part in split.chunks_mut(14) {
b.render(part, |x| x);
}
assert_eq!(one, split);
assert_eq!(a.phase, b.phase);
assert_eq!(a.queue, b.queue);
if rate >= 32768 {
assert_eq!(
a.queue.len(),
4096 - 2 - (1000 * AUDIO_SAMPLE_RATE / rate) as usize
);
}
}
}
#[test]
fn overflow_underflow_and_recovery_are_bounded() {
let mut p = Playback::new(32768, 1);
p.submit(&vec![sample(100, -100); CAPACITY + 7]);
assert_eq!(p.dropped, 7);
let mut out = vec![1.; CAPACITY + 100];
p.render(&mut out, |x| x);
assert!(out.iter().all(|x| *x == 0.));
assert_eq!(p.underruns, 1);
p.submit(&vec![sample(256, 256); PREFILL]);
p.render(&mut out[..1], |x| x);
assert_eq!(out[0], 0.125);
}
#[test]
fn dc_offset_decays_without_out_of_range_or_nonfinite_samples() {
let mut p = Playback::new(32768, 2);
p.submit(&vec![sample(-512, 511); CAPACITY]);
let mut out = vec![0.; 12000];
p.render(&mut out, |x| x);
assert!(out.iter().all(|x| x.is_finite() && x.abs() <= 0.25));
assert!(out[11999].abs() < 0.000001);
}
}

View file

@ -21,7 +21,7 @@ use gba_core::{
const MAX_STEPS: u64 = 100_000_000; const MAX_STEPS: u64 = 100_000_000;
const MAX_FRAMES: u64 = 100_000; const MAX_FRAMES: u64 = 100_000;
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
enum Mode { enum Mode {
@ -35,6 +35,7 @@ pub struct Options {
mode: Mode, mode: Mode,
hardware: CartridgeHardware, hardware: CartridgeHardware,
save_device: SaveDevice, save_device: SaveDevice,
audio: bool,
} }
pub fn parse_args(args: &[OsString]) -> io::Result<Options> { pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
@ -43,6 +44,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
let mut steps = None; let mut steps = None;
let mut frames = None; let mut frames = None;
let mut window = false; let mut window = false;
let mut audio = false;
let mut hardware = CartridgeHardware::None; let mut hardware = CartridgeHardware::None;
let mut save_device = SaveDevice::None; let mut save_device = SaveDevice::None;
let mut args = args.iter(); let mut args = args.iter();
@ -61,6 +63,8 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
Some("flash128") => SaveDevice::Flash128, Some("flash128") => SaveDevice::Flash128,
_ => return Err(invalid()), _ => return Err(invalid()),
}; };
} else if arg == "--audio" && !audio {
audio = true;
} else if arg == "--window" && !window { } else if arg == "--window" && !window {
window = true; window = true;
} else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) { } else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) {
@ -89,6 +93,9 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
return Err(invalid()); return Err(invalid());
} }
} }
if audio && !window {
return Err(invalid());
}
let mode = match (window, steps, frames) { let mode = match (window, steps, frames) {
(false, Some(steps), None) => Mode::Terminal { steps }, (false, Some(steps), None) => Mode::Terminal { steps },
(true, None, frames) => Mode::Window { frames }, (true, None, frames) => Mode::Window { frames },
@ -99,6 +106,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
mode, mode,
hardware, hardware,
save_device, save_device,
audio,
}) })
} }
@ -284,6 +292,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
frames, frames,
options.hardware, options.hardware,
options.save_device, options.save_device,
options.audio,
writer, writer,
) )
} }

View file

@ -28,13 +28,36 @@ fn options_require_both_flags_in_either_order() {
path: "original program.gba".into(), path: "original program.gba".into(),
mode: Mode::Terminal { steps: count }, mode: Mode::Terminal { steps: count },
hardware: CartridgeHardware::None, hardware: CartridgeHardware::None,
save_device: SaveDevice::None save_device: SaveDevice::None,
audio: false,
} }
); );
} }
} }
} }
#[test]
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
assert!(
parse_args(&args(&["--audio", "--rom", "test.gba", "--window"]))
.unwrap()
.audio
);
assert!(
!parse_args(&args(&["--rom", "test.gba", "--window"]))
.unwrap()
.audio
);
for tail in [
vec!["--audio", "--steps", "10"],
vec!["--audio", "--window", "--audio"],
] {
let mut arguments = args(&["--rom", "test.gba"]);
arguments.extend(args(&tail));
assert!(parse_args(&arguments).is_err());
}
}
#[test] #[test]
fn window_options_accept_any_order_and_optional_frame_limits() { fn window_options_accept_any_order_and_optional_frame_limits() {
for arguments in [ for arguments in [

View file

@ -1,15 +1,25 @@
//! Main-thread ROM presentation and physical keyboard input. //! Main-thread ROM presentation, physical keyboard input, and optional host audio.
use std::io::Write; use std::io::Write;
use super::*; use super::*;
use crate::cartridge::window::{Session, Update}; use crate::cartridge::window::{Session, Update};
fn next_frame_start(previous: Instant, now: Instant, period: Duration, audio: bool) -> Instant {
let target = previous + period;
if audio && now.saturating_duration_since(target) <= period {
target
} else {
now
}
}
pub fn run( pub fn run(
bytes: Vec<u8>, bytes: Vec<u8>,
frame_limit: Option<u64>, frame_limit: Option<u64>,
hardware: gba_core::cartridge::CartridgeHardware, hardware: gba_core::cartridge::CartridgeHardware,
save_device: gba_core::cartridge::SaveDevice, save_device: gba_core::cartridge::SaveDevice,
audio_enabled: bool,
writer: &mut impl Write, writer: &mut impl Write,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let mut session = Session::new(bytes)?; let mut session = Session::new(bytes)?;
@ -28,7 +38,7 @@ pub fn run(
)?; )?;
writeln!( writeln!(
writer, writer,
"Input releases on focus loss. No audio or saves. Startup frames are not suppressed." "Input releases and audio mutes on focus loss. No saves. Startup frames are not suppressed."
)?; )?;
if let Some(limit) = frame_limit { if let Some(limit) = frame_limit {
writeln!( writeln!(
@ -37,10 +47,22 @@ pub fn run(
)?; )?;
} }
writer.flush()?; writer.flush()?;
let mut audio = None;
let mut samples = [gba_core::audio::StereoLevel::default(); 1024];
let result = (|| -> Result<&str, Box<dyn Error>> { let result = (|| -> Result<&str, Box<dyn Error>> {
let title = "GBA Rust | ROM | Esc: exit | No audio or saves"; let title = "GBA Rust | ROM | Esc: exit | No saves";
let mut window = create_window(title)?; let mut window = create_window(title)?;
window.set_target_fps(0); window.set_target_fps(0);
if audio_enabled {
let output = crate::audio::AudioOutput::new()?;
writeln!(writer, "Audio enabled: {}", output.description())?;
audio = Some(output);
session.memory_mut().set_audio_capture(true);
} else {
writeln!(writer, "Audio muted; use --audio for macOS output.")?;
}
writer.flush()?;
let mut was_audio_active = false;
let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ));
let mut frame_started = Instant::now(); let mut frame_started = Instant::now();
let mut was_stopped = false; let mut was_stopped = false;
@ -50,9 +72,30 @@ pub fn run(
if let Some(clock) = rtc_clock.as_mut() { if let Some(clock) = rtc_clock.as_mut() {
clock.sync(session.memory_mut())?; clock.sync(session.memory_mut())?;
} }
let buttons = read_buttons(window.is_active(), |key| window.is_key_down(key)); let reported_active = window.is_active();
let focused = is_focused(reported_active);
let buttons = read_buttons(reported_active, |key| window.is_key_down(key));
let update = session.update(buttons)?; let update = session.update(buttons)?;
let stopped = update == Update::Stopped; let stopped = update == Update::Stopped;
if let Some(output) = audio.as_ref() {
let active = focused && !stopped;
if !active && was_audio_active {
output.clear()?;
}
was_audio_active = active;
loop {
let count = session.memory_mut().drain_audio_samples(&mut samples);
output.submit(if active { &samples[..count] } else { &[] })?;
if count < samples.len() {
break;
}
}
if session.memory_mut().audio_dropped_samples() != 0 {
return Err(
io::Error::other("core audio capture overflow; reduce host load").into(),
);
}
}
if stopped != was_stopped { if stopped != was_stopped {
window.set_title(if stopped { window.set_title(if stopped {
"GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit" "GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit"
@ -71,9 +114,12 @@ pub fn run(
if frame_limit.is_some_and(|limit| session.frames() >= limit) { if frame_limit.is_some_and(|limit| session.frames() >= limit) {
return Ok("window frame limit reached"); return Ok("window frame limit reached");
} }
// Slow hosts do not skip emulated frames or accumulate catch-up work.
thread::sleep(period.saturating_sub(frame_started.elapsed())); thread::sleep(period.saturating_sub(frame_started.elapsed()));
frame_started = Instant::now(); // With audio, retain an absolute frame deadline so sleep overshoot
// does not steadily starve the output device. Catch up at most one
// frame; a slow host never accumulates unbounded work or skips emulation.
frame_started =
next_frame_start(frame_started, Instant::now(), period, audio_enabled);
} }
Update::Stopped => { Update::Stopped => {
if frame_limit.is_some() { if frame_limit.is_some() {
@ -95,5 +141,26 @@ pub fn run(
writer, writer,
result.as_ref().copied().unwrap_or("ROM window diagnostic"), result.as_ref().copied().unwrap_or("ROM window diagnostic"),
)?; )?;
if let Some(output) = audio.as_ref() {
output.report(writer)?;
}
result.map(|_| ()) result.map(|_| ())
} }
#[cfg(test)]
mod pacing_tests {
use super::*;
#[test]
fn audio_deadlines_absorb_sleep_overshoot_but_bound_catch_up() {
let start = Instant::now();
let period = Duration::from_millis(16);
assert_eq!(
next_frame_start(start, start + Duration::from_millis(17), period, true),
start + period
);
let late = start + Duration::from_millis(50);
assert_eq!(next_frame_start(start, late, period, true), late);
let now = start + Duration::from_millis(17);
assert_eq!(next_frame_start(start, now, period, false), now);
}
}

View file

@ -1,3 +1,4 @@
mod audio;
mod cartridge; mod cartridge;
mod desktop; mod desktop;
@ -53,6 +54,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
"--window", "--window",
"--frames", "--frames",
"--rtc", "--rtc",
"--audio",
"--save-type", "--save-type",
] ]
.iter() .iter()
@ -86,7 +88,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help), [arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
_ => Err(io::Error::new( _ => Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT]); or gameboy-rust --test-suite PATH.json", "usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT] [--audio]); or gameboy-rust --test-suite PATH.json",
)), )),
} }
} }
@ -132,6 +134,7 @@ fn run() -> Result<(), Box<dyn Error>> {
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\ --rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
--rom PATH --window Open a raw ROM with keyboard input\n\ --rom PATH --window Open a raw ROM with keyboard input\n\
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\ --frames COUNT Exit ROM window after 1..=100000 captured frames\n\
--audio Enable macOS output for ROM windows (default: muted)\n\
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\ --rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--save-type flash64|flash128 Select Flash identification/read subset (no saving yet)\n\ --save-type flash64|flash128 Select Flash identification/read subset (no saving yet)\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\ --test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
@ -167,7 +170,7 @@ fn run() -> Result<(), Box<dyn Error>> {
Affine backgrounds: Arrows pan; Q rotates; W zooms; Z disables wrapping; Enter resets; Escape exits.\n\ Affine backgrounds: Arrows pan; Q rotates; W zooms; Z disables wrapping; Enter resets; Escape exits.\n\
Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\ Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\
All demos use original test content. ROM mode uses our limited BIOS replacement.\n\ All demos use original test content. ROM mode uses our limited BIOS replacement.\n\
Terminal ROM mode has no window or input. ROM modes have no audio or saves.\n\ Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. No saves.\n\
ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits.\n\ ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits.\n\
Commercial games are not supported." Commercial games are not supported."
); );

View file

@ -82,10 +82,13 @@ fn wave_setup() -> Vec<u32> {
let mut code = Vec::new(); let mut code = Vec::new();
for (address, value) in [ for (address, value) in [
(0x04000084, 0x80), (0x04000084, 0x80),
(0x04000088, 0x200),
(0x04000080, 0x4477),
(0x04000082, 2),
(0x04000090, 0xf012), // CPU bank 1 before selecting playback bank 1. (0x04000090, 0xf012), // CPU bank 1 before selecting playback bank 1.
(0x04000070, 0xc0), (0x04000070, 0xc0),
(0x04000072, 0x2000), (0x04000072, 0x2000),
(0x04000074, 0x87ff), (0x04000074, 0x87e0),
] { ] {
code.extend([0xe59f0000, 0xea000000, address]); code.extend([0xe59f0000, 0xea000000, address]);
code.extend([0xe59f1000, 0xea000000, value]); code.extend([0xe59f1000, 0xea000000, value]);
@ -95,6 +98,29 @@ fn wave_setup() -> Vec<u32> {
code code
} }
#[test]
fn audio_options_fail_before_file_access_unless_a_window_is_selected() {
for tail in [
vec!["--audio", "--steps", "1"],
vec!["--audio", "--window", "--audio"],
] {
let output = command()
.args(["--rom", "missing-original.gba"])
.args(tail)
.output()
.unwrap();
assert!(!output.status.success());
assert!(stderr(&output).contains("usage:"));
assert!(!stderr(&output).contains("cannot load ROM"));
}
let output = command()
.args(["--audio", "--window", "--rom", "missing-original.gba"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(stderr(&output).contains("cannot load ROM"));
}
#[test] #[test]
fn original_wave_program_can_activate_channel3_without_host_audio() { fn original_wave_program_can_activate_channel3_without_host_audio() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@ -401,7 +427,7 @@ fn help_describes_terminal_rom_mode_and_existing_cpu_demo_still_runs() {
assert!(output.status.success()); assert!(output.status.success());
assert!(stdout(&output).contains("--rom PATH --steps COUNT")); assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
assert!(stdout(&output).contains("--rom PATH --window")); assert!(stdout(&output).contains("--rom PATH --window"));
assert!(stdout(&output).contains("ROM modes have no audio or saves")); assert!(stdout(&output).contains("ROM windows offer --audio on macOS. No saves."));
let output = command().arg("--cpu-demo").output().unwrap(); let output = command().arg("--cpu-demo").output().unwrap();
assert!(output.status.success()); assert!(output.status.success());
assert!(stdout(&output).contains("Exception demo")); assert!(stdout(&output).contains("Exception demo"));
@ -440,6 +466,56 @@ fn window_options_and_bad_files_fail_without_opening_a_window() {
assert!(output.stdout.is_empty()); assert!(output.stdout.is_empty());
} }
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires a focused native window and audio device; plays an original wave tone"]
fn native_rom_audio_window_routes_core_samples_to_output() {
let fixture = Fixture::new();
let path = fixture.rom(&wave_setup());
let mut child = command()
.arg("--rom")
.arg(&path)
.args(["--window", "--audio", "--frames", "120"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let start = std::time::Instant::now();
while child.try_wait().unwrap().is_none() {
if start.elapsed() > std::time::Duration::from_secs(30) {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!(
"audio window timed out: {} {}",
stdout(&output),
stderr(&output)
);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{} {}",
stdout(&output),
stderr(&output)
);
let text = stdout(&output);
assert!(text.contains("Audio enabled: CoreAudio:"), "{text}");
assert!(text.contains("Captured ROM frames: 120"), "{text}");
let nonzero: u64 = text
.split("nonzero output frames: ")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap()
.parse()
.unwrap();
assert!(nonzero > 0, "{text}");
assert!(text.contains("dropped input frames: 0"), "{text}");
}
#[test] #[test]
#[ignore = "requires a logged-in native desktop session"] #[ignore = "requires a logged-in native desktop session"]
fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() { fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() {

View file

@ -152,6 +152,9 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-demos/src/mosaic_demo.rs` | CPU-driven background/sprite mosaic demo runner | | `crates/gba-demos/src/mosaic_demo.rs` | CPU-driven background/sprite mosaic demo runner |
| `crates/gba-demos/src/raster_demo.rs` | Original ARM HBlank DMA raster program and color table | | `crates/gba-demos/src/raster_demo.rs` | Original ARM HBlank DMA raster program and color table |
| `crates/gba-core/src/video/effects.rs` | Region masks and RGB555 alpha/brightness arithmetic | | `crates/gba-core/src/video/effects.rs` | Region masks and RGB555 alpha/brightness arithmetic |
| `crates/desktop/src/audio.rs` | Host-audio platform boundary and explicit non-Darwin diagnostic |
| `crates/desktop/src/audio/device.rs` | CPAL/CoreAudio lifecycle, nonblocking output callback, runtime errors, and counters |
| `crates/desktop/src/audio/playback.rs` | Bounded host queue, prefill, stereo mapping, linear rate conversion, gain, and DC removal |
| `crates/desktop/src/desktop.rs` | Native window, display test, and keyboard controls | | `crates/desktop/src/desktop.rs` | Native window, display test, and keyboard controls |
| `crates/desktop/src/desktop/affine.rs` | Affine background window and smoke-test pixel checks | | `crates/desktop/src/desktop/affine.rs` | Affine background window and smoke-test pixel checks |
| `crates/desktop/src/desktop/affine_raster.rs` | Affine raster window and every-pixel smoke checks | | `crates/desktop/src/desktop/affine_raster.rs` | Affine raster window and every-pixel smoke checks |
@ -169,7 +172,7 @@ The repository is a Cargo workspace with three crates:
| `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests | | `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests |
| `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits | | `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits |
| `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics | | `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics |
| `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, presentation pacing, and frame-limited exit | | `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, optional audio draining/muting, bounded pacing, and frame-limited exit |
| `crates/desktop/examples/write_rom_demo.rs` | Non-overwriting export of the original input-test ROM | | `crates/desktop/examples/write_rom_demo.rs` | Non-overwriting export of the original input-test ROM |
| `crates/desktop/src/cartridge/tests.rs` | ROM options, bounded readers, instruction/DMA/IRQ budgets, HALT/STOP, and diagnostic reports | | `crates/desktop/src/cartridge/tests.rs` | ROM options, bounded readers, instruction/DMA/IRQ budgets, HALT/STOP, and diagnostic reports |
| `crates/desktop/tests/rom_cli.rs` | File loading and exit-status checks; opt-in native ROM-window integration test | | `crates/desktop/tests/rom_cli.rs` | File loading and exit-status checks; opt-in native ROM-window integration test |

View file

@ -22,7 +22,11 @@ Verified locally on macOS 15.7.3:
- The headless suite runner passes generated ARM/Thumb/BIOS cases, produces repeatable JSON, and rejects deliberate assertion mismatches. - The headless suite runner passes generated ARM/Thumb/BIOS cases, produces repeatable JSON, and rejects deliberate assertion mismatches.
These results cover the CPU core, memory, Mode 0–5 snapshots and row capture, input mapping, and desktop windows. These results cover the CPU core, memory, Mode 0–5 snapshots and row capture, input mapping, and desktop windows.
They do not verify a complete GBA display controller or audio. They do not verify a complete GBA display controller or hardware audio fidelity.
CPAL 0.16 is a macOS-only dependency of the desktop crate. The existing Nix Darwin shell builds its CoreAudio backend without extra shell packages.
Native tests submit an original tone and original ROM-generated samples to the default output device.
Use `--audio` with a ROM window to opt in. Ordinary tests and muted windows do not require an audio device.
Other host audio platforms are not implemented. Underruns remain possible; device callbacks do not prove listening quality.
Arrow-key movement in the original host display test was confirmed manually on this Mac. Arrow-key movement in the original host display test was confirmed manually on this Mac.
CPU demos and ROM windows have automated input-mapping tests; their physical keyboard behavior still needs manual confirmation. CPU demos and ROM windows have automated input-mapping tests; their physical keyboard behavior still needs manual confirmation.

View file

@ -1,7 +1,7 @@
# Audio device subset # Audio device subset
The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) channels 1–4. The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) channels 1–4.
Wave channel 3 supports single-bank playback only. The core does not produce desktop audio. Wave channel 3 supports single-bank playback only. Optional fixed-rate capture supplies samples to the separate Darwin output adapter.
`Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream. `Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream.
## Registers and PSG state ## Registers and PSG state
@ -262,6 +262,43 @@ Capture is disabled by default. Changing enable state clears queued frames, phas
Repeating the current enable state preserves capture state. Configure capture between machine steps. Repeating the current enable state preserves capture state. Configure capture between machine steps.
Original tests cover exact sample boundaries, timer consumption, pulse phases, batching, queue overflow, HALT/STOP, and transactional rollback. Original tests cover exact sample boundaries, timer consumption, pulse phases, batching, queue overflow, HALT/STOP, and transactional rollback.
## Darwin output adapter
Use `--audio` with `--rom ... --window` to enable CPAL 0.16/CoreAudio output on macOS.
Windows remain muted by default. Terminal runs, ROM suites, and demos do not open an audio device.
The flag is invalid in terminal mode and cannot be repeated. Other platforms currently report an explicit unsupported-platform error.
The adapter opens the default output device at its default configuration.
Supported formats are float32, signed16, and unsigned16, with 8–192 kHz rates and 1–32 channels.
Stereo maps to the first two channels. Mono averages left/right; additional channels receive silence.
Device initialization, unsupported formats, and stream errors fail the audio-enabled run rather than silently switching to muted execution.
Omit `--audio` to run without an audio device.
The core's 32,768 Hz frames enter a bounded 8,192-frame host queue.
Playback waits for 1,024 frames before starting or resuming after an underrun.
Linear interpolation converts to the device rate. A 20 Hz high-pass filter removes DC, followed by conservative 25% host gain and clipping.
These are presentation choices, not a measured GBA analog model or band-limited reconstruction.
High-frequency aliases, startup transients, and channel-model inaccuracies remain possible.
The data callback performs no allocation, blocking lock, emulation step, or log write.
It tries the queue lock once; contention produces silence and increments a counter.
An underrun produces silence and returns to the prefill state. Overflow discards new input frames and records the loss.
The final report includes submitted/nonzero-input frames, callbacks, nonzero-output frames, underruns, dropped input, and lock misses.
Zero submitted frames can indicate an unfocused window, not a failed output device.
The window treats a core capture overflow as an error; it drains samples between bounded CPU slices.
The callback cannot advance emulator clocks or request extra emulation work.
Focus loss and STOP clear queued host audio and conversion state. Muted frames are drained without being submitted.
The device may still finish its already-submitted buffer. Resume waits for fresh prefill instead of replaying stale samples.
Audio-enabled windows use absolute frame deadlines with at most one frame of catch-up.
This reduces sleep drift without skipping emulated frames or accumulating unbounded catch-up work.
Slow hosts can still underrun. Sample delivery is not a claim of uninterrupted or hardware-faithful sound.
Native checks on Darwin arm64 exercised a quiet original tone and an original wave-ROM window through CoreAudio.
A 900-frame local Emerald run reached its limit with 554,484 nonzero device-output frames, zero dropped input frames, and 13 underruns.
Those counters demonstrate output submission, not human listening, correct music, or game compatibility.
Audio quality and physical speaker/headphone output still require a manual listening check.
## Validation and remaining work ## Validation and remaining work
Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping. Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping.
@ -278,13 +315,16 @@ CPU/DMA tests include noise register writes at bus completion and failed-step ro
BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active. BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active.
These tests validate the documented nominal model, not hardware audio fidelity. These tests validate the documented nominal model, not hardware audio fidelity.
Remaining audio work: two-bank wave playback, verified live bank changes, hardware PWM sampling, filtering/resampling, and a Darwin host backend. Remaining audio work: two-bank wave playback, verified live bank changes, hardware PWM sampling, improved reconstruction, and lower-underrun pacing.
A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback. A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback.
The same scheduled-input probe passes that point and completes its extended frame budget; audible output and gameplay remain unverified. The same scheduled-input probe passes that point and completes its extended frame budget; gameplay remains unverified.
Later native output checks exercise the host audio path separately; they do not establish game-audio accuracy.
See [the local runtime result](../research/emerald-reset.md). See [the local runtime result](../research/emerald-reset.md).
## References ## References
- [CPAL 0.16.0](https://docs.rs/cpal/0.16.0/cpal/): default device/configuration, typed output callbacks, sample formats, CoreAudio backend, and stream lifecycle. CPAL is a macOS-only desktop dependency here.
- [GBATEK wave channel 3](https://problemkaputt.de/gbatek-gba-sound-channel-3-wave-output.htm): bank selection, nibble order, rotating RAM, sample rate, length, and force volume. - [GBATEK wave channel 3](https://problemkaputt.de/gbatek-gba-sound-channel-3-wave-output.htm): bank selection, nibble order, rotating RAM, sample rate, length, and force volume.
- [NanoBoyAdvance wave at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/wave_channel.cc) and [header](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/wave_channel.hh): signed gain and rate comparison; pointer-based RAM and two-bank/retrigger differences are not copied. - [NanoBoyAdvance wave at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/wave_channel.cc) and [header](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/wave_channel.hh): signed gain and rate comparison; pointer-based RAM and two-bank/retrigger differences are not copied.

View file

@ -544,6 +544,7 @@ Unsupported SWIs, unsupported exception vectors, and null/misaligned IRQ callbac
The CPU reports `CpuError::UnsupportedInstruction` with `bios::UNSUPPORTED_TRAP`, rather than silently treating a service as a no-op. The CPU reports `CpuError::UnsupportedInstruction` with `bios::UNSUPPORTED_TRAP`, rather than silently treating a service as a no-op.
Invalid arithmetic/decompression arguments use the same CPU error type with the distinct `bios::INVALID_ARGUMENT_TRAP` instruction. Invalid arithmetic/decompression arguments use the same CPU error type with the distinct `bios::INVALID_ARGUMENT_TRAP` instruction.
Prior boot/service steps remain committed on failure. Prior boot/service steps remain committed on failure.
Two-bank wave playback, host audio output, connected serial links, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. Two-bank wave playback, connected serial links, HardReset, and serial/Game Pak STOP wake-up remain unimplemented.
Darwin ROM windows can enable host audio with `--audio`; this does not extend BIOS coverage.
Disconnected normal serial transfers use the [bounded serial model](serial.md); cancel active transfers before BIOS serial reset. Disconnected normal serial transfers use the [bounded serial model](serial.md); cancel active transfers before BIOS serial reset.
This subset is not sufficient for Pokémon Emerald compatibility. This subset is not sufficient for Pokémon Emerald compatibility.

View file

@ -135,5 +135,9 @@ The CPU writes the palette: blue at rest, red while Z is held, and green while R
Right takes priority over Z. Other buttons have no visible effect in this small program. Right takes priority over Z. Other buttons have no visible effect in this small program.
No game or firmware content is included. No game or firmware content is included.
Both ROM modes lack audio, saves, and per-step tracing. Commercial-game compatibility remains unverified and unsupported. ROM windows can enable Darwin output with `--audio`; they are muted by default.
The option requires a usable default audio device. Device/configuration errors fail explicitly; omit the flag to run muted.
Focus loss and STOP clear queued audio. See [audio output and its limits](audio.md#darwin-output-adapter).
Terminal mode has no audio. Both modes lack save writing/persistence and per-step tracing.
Commercial-game compatibility remains unverified and unsupported.
Only load files that you may lawfully use. Do not commit game ROMs or firmware. Only load files that you may lawfully use. Do not commit game ROMs or firmware.

View file

@ -125,7 +125,8 @@ This result does not establish gameplay, linked transfers, or cartridge/serial G
Noise now has deterministic counter clocks and shares tested length/envelope logic with the independent pulse channels. Noise now has deterministic counter clocks and shares tested length/envelope logic with the independent pulse channels.
Sweep applies only to channel 1. Direct Sound has FIFO clocks and nominal DMA requests. Sweep applies only to channel 1. Direct Sound has FIFO clocks and nominal DMA requests.
All supported channels use the instantaneous digital-level inspection interface. All supported channels use the instantaneous digital-level inspection interface.
Wave channel 3 now supports single-bank playback. Two-bank playback, a continuous sample stream, and desktop output are still missing. Wave channel 3 now supports single-bank playback. Two-bank playback remains missing.
Fixed-rate capture and optional Darwin `--audio` output were added later; their counters do not establish correct game music.
Do not treat digital-level tests as audible-game validation or suppress remaining diagnostics without implementing their behavior. Do not treat digital-level tests as audible-game validation or suppress remaining diagnostics without implementing their behavior.
Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol. Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol.

View file

@ -83,7 +83,7 @@ A separate 600-frame Emerald native run still reaches its frame limit without in
## Remaining requirements ## Remaining requirements
- Verify visible menu text and gameplay through direct window testing or explicit independently justified assertions. - Verify visible menu text and gameplay through direct window testing or explicit independently justified assertions.
- Implement continuous sample production and a Darwin output backend before claiming audible sound. - Fixed-rate capture and opt-in Darwin `--audio` output were added later. Verify listening quality and reduce observed underruns.
- Research two-bank wave playback and live bank changes separately. Both remain diagnostic during active playback. - Research two-bank wave playback and live bank changes separately. Both remain diagnostic during active playback.
- Implement Flash programming/erase and safe persistence before claiming saving. - Implement Flash programming/erase and safe persistence before claiming saving.
@ -93,5 +93,6 @@ For a manual native check, use:
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window
``` ```
Enter maps to Start. Z maps to A. Escape exits. This command does not supply the deterministic probe schedule or fixed RTC. Enter maps to Start. Z maps to A. Escape exits. Add `--audio` for optional Darwin output.
This command does not supply the deterministic probe schedule or fixed RTC.
Hardware sources and implementation comparisons are recorded in [audio references](../hardware/audio.md#references). Hardware sources and implementation comparisons are recorded in [audio references](../hardware/audio.md#references).

View file

@ -92,6 +92,7 @@ Update this file when a feature lands or a limit is removed.
- Wave channel 3 supports single-bank nibble playback, rotating RAM, volume/force-volume, length, activity status, and digital mixing. - Wave channel 3 supports single-bank nibble playback, rotating RAM, volume/force-volume, length, activity status, and digital mixing.
- Two-bank wave playback, active bank changes, and unsupported serial operations remain diagnostic with whole-access and block-store rollback. - Two-bank wave playback, active bank changes, and unsupported serial operations remain diagnostic with whole-access and block-store rollback.
- ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP. - ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP.
- Opt-in macOS CoreAudio output, with tested original tones and wave-ROM playback; focus loss and STOP mute queued audio.
- Optional ROM-window frame limits and an original input-test ROM generator. - Optional ROM-window frame limits and an original input-test ROM generator.
- Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports. - Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports.
- Hash-pinned preparation of public `jsmolka/gba-tests` ARM, Thumb, memory, and BIOS ROMs, with verified result-register checkpoints. - Hash-pinned preparation of public `jsmolka/gba-tests` ARM, Thumb, memory, and BIOS ROMs, with verified result-register checkpoints.
@ -146,7 +147,7 @@ Internal affine origins, vertical window flags, and vertical mosaic counters tra
Horizontal window comparators retain four-cycle event history. Horizontal window comparators retain four-cycle event history.
Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits. Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits.
Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented. Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented.
There is no host audio output or save writing/persistence. ROM windows offer opt-in Darwin audio output with `--audio`. Save writing/persistence remains unavailable.
The [Flash subset](hardware/saves.md) supports explicit device selection, identification, array reads, and banking. The [Flash subset](hardware/saves.md) supports explicit device selection, identification, array reads, and banking.
Flash programming, erase, busy polling, wider accesses, unverified aliases, and other save-device protocols remain diagnostic or unimplemented. Flash programming, erase, busy polling, wider accesses, unverified aliases, and other save-device protocols remain diagnostic or unimplemented.
The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes. The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes.
@ -170,7 +171,9 @@ The presentation framebuffer is separate from emulated video RAM.
All named graphics demos connect desktop input and rendering to the emulated CPU. The default display test remains host-generated. All named graphics demos connect desktop input and rendering to the emulated CPU. The default display test remains host-generated.
ROM files can run in bounded terminal mode or a window with the original BIOS replacement. ROM files can run in bounded terminal mode or a window with the original BIOS replacement.
ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices. ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices.
Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility. Audio is muted by default; `--audio` opens the macOS output device for ROM windows only. Saves and external BIOS loading remain unavailable.
The host output has bounded buffers, linear rate conversion, DC removal, and underrun/drop counters. Slow hosts can stutter.
ROM loading and nonzero audio callbacks do not establish commercial-game compatibility or correct music.
A local Emerald run now completes its all-device reset and master sound enable. A local Emerald run now completes its all-device reset and master sound enable.
It also passes the pulse/noise triggers and normal-mode RCNT configuration. It also passes the pulse/noise triggers and normal-mode RCNT configuration.
It also completes a CpuSet call to address zero through the corrected ignored BIOS-write behavior. It also completes a CpuSet call to address zero through the corrected ignored BIOS-write behavior.

View file

@ -81,7 +81,25 @@ An original desktop ROM installs its Flash byte reader in IWRAM; terminal/native
They verify explicit activation errors and atomic byte/word/block-store/DMA failure behavior. They verify explicit activation errors and atomic byte/word/block-store/DMA failure behavior.
Wave tests cover every frequency and bank, independent nibble rotation, full rotations, bounded maximum batches, and fractional gains. Wave tests cover every frequency and bank, independent nibble rotation, full rotations, bounded maximum batches, and fractional gains.
They also check inactive triggers, gates, length, status, master/BIOS reset, HALT/STOP, live-write limits, and CPU/DMA rollback. They also check inactive triggers, gates, length, status, master/BIOS reset, HALT/STOP, live-write limits, and CPU/DMA rollback.
Original terminal/native programs activate wave playback without external assets or host audio. Original terminal/native programs activate wave playback without external assets.
Fixed-rate capture tests verify sample boundaries, timer/PSG ordering, rollback, queue limits, drop counts, and HALT/STOP.
Host conversion tests require no device and cover rates, stereo/mono mapping, prefill, overflow, underflow, DC removal, and callback splitting.
CLI tests keep output muted by default and reject `--audio` outside ROM-window mode.
The following ignored tests require a native audio device and play original test tones:
```sh
direnv exec . cargo test --locked --release -p gameboy-rust-desktop native_audio_output -- --ignored --nocapture --test-threads=1
direnv exec . cargo test --locked --release -p gameboy-rust-desktop --test rom_cli native_rom_audio_window -- --ignored --test-threads=1
```
The device test generates samples with the actual core and does not require window focus.
The window test requires focus. It checks that ROM-generated samples reach a nonzero CoreAudio callback.
An unfocused window correctly submits no samples, so that test fails rather than claiming playback success.
The focused window check passed locally; a later unattended run remained unfocused and did not validate playback.
Use the reported submitted-frame count to distinguish this condition from an output-device failure.
These checks do not prove physical speaker output, correct game music, or hardware audio fidelity.
Normal Cargo/Python tests do not open an audio device. Underruns are reported and can occur on slow hosts.
The optional [local Emerald input probe](research/emerald-wave.md) now completes its scheduled Start/A frame budget. The optional [local Emerald input probe](research/emerald-wave.md) now completes its scheduled Start/A frame budget.
Frame completion and different pixels are not assertions about readable menus, correct graphics, or gameplay. Frame completion and different pixels are not assertions about readable menus, correct graphics, or gameplay.
[Jev evidence review](jev-debugging.md) is optional and never changes deterministic test verdicts. [Jev evidence review](jev-debugging.md) is optional and never changes deterministic test verdicts.

View file

@ -48,7 +48,9 @@ A [fixed-time input probe](research/emerald-wave.md) then exposed an unsupported
Single-bank wave support now passes that point and completes 1,320 frames with scheduled Start/A input. Single-bank wave support now passes that point and completes 1,320 frames with scheduled Start/A input.
Captured output differs from a no-input control; readable menus and gameplay remain unverified. Captured output differs from a no-input control; readable menus and gameplay remain unverified.
The core has tested Direct Sound, both pulse channels, single-bank wave channel 3, and noise channel 4. The core has tested Direct Sound, both pulse channels, single-bank wave channel 3, and noise channel 4.
Two-bank wave playback, a continuous sample stream, and desktop audio output remain missing. Fixed-rate stereo capture and opt-in Darwin `--audio` output now work with original native probes.
A local Emerald run submits nonzero device frames but records underruns; listening quality and correct music remain unverified.
Two-bank wave playback, hardware PWM sampling, and improved reconstruction/pacing remain missing.
See [the runtime result](research/emerald-reset.md). Next, verify visible menu progress rather than treating frame counts as playability. See [the runtime result](research/emerald-reset.md). Next, verify visible menu progress rather than treating frame counts as playability.
The separate save requirement includes Flash programming/erase and safe host persistence. Identification/read support is not successful saving. The separate save requirement includes Flash programming/erase and safe host persistence. Identification/read support is not successful saving.
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.