commit bdf23de8db74aabdd84546708ab6f89f4cc5c0cd Author: se.cherkasov Date: Fri Mar 13 11:48:45 2026 +0300 Initial commit: NES emulator with GTK4 desktop frontend Full NES emulation: CPU, PPU, APU, 47 mappers, iNES/NES 2.0 parsing. GTK4 desktop client with HeaderBar, pixel-perfect Cairo rendering, drag-and-drop ROM loading, and keyboard shortcuts. 187 tests covering core emulation, mappers, and runtime. diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..c68566b --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +clippy-strict = "clippy --all-targets --all-features -- -D warnings" +clippy-relaxed = "clippy --all-targets --all-features -- -W clippy::pedantic -W clippy::wildcard_imports -W clippy::match_same_arms" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9663350 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Build + run: cargo build --locked + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy (strict) + run: cargo clippy-strict + + - name: Test + run: cargo test --all-targets --all-features + + - name: Public API Contract Tests + run: cargo test --test public_api --all-features + + - name: Minimal Client Contract Tests + run: cargo test -p nesemu-client-minimal --test cli_contract diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e3a7700 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,784 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cairo-rs" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ac2a4d0e69036cf0062976f6efcba1aaee3e448594e6514bb2ddf87acce562" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", + "thiserror", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3bb3119664efbd78b5e6c93957447944f16bdbced84c17a9f41c7829b81e64" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624eaba126021103c7339b2e179ae4ee8cdab842daab419040710f38ed9f8699" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4efa05a4f83c8cc50eb4d883787b919b85e5f1d8dd10b5a1df53bf5689782379" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db265c9dd42d6a371e09e52deab3a84808427198b86ac792d75fd35c07990a07" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9418fb4e8a67074919fe7604429c45aa74eb9df82e7ca529767c6d4e9dc66dd" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c49f117d373ffcc98a35d114db5478bc223341cff53e39a5d6feced9e2ddffe" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", + "thiserror", +] + +[[package]] +name = "gio-sys" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd743ba4714d671ad6b6234e8ab2a13b42304d0e13ab7eba1dcdd78a7d6d4ef" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.19.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib-macros" +version = "0.19.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4429b0277a14ae9751350ad9b658b1be0abb5b54faa5bcdf6e74a3372582fad7" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2dc18d3a82b0006d470b13304fbbb3e0a9bd4884cf985a60a7ed733ac2c4a5" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e697e252d6e0416fd1d9e169bda51c0f1c926026c39ca21fbe8b1bb5c3b8b9e" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5fb86031d24d9ec0a2a15978fc7a65d545a2549642cf1eb7c3dda358da42bcf" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f530e0944bccba4b55065e9c69f4975ad691609191ebac16e13ab8e1f27af05" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7563884bf6939f4468e5d94654945bdd9afcaf8c3ba4c5dd17b5342b747221be" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23024bf2636c38bbd1f822f58acc9d1c25b28da896ff0f291a1a232d4272b3dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04e11319b08af11358ab543105a9e49b0c491faca35e2b8e7e36bfba8b671ab" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec655a7ef88d8ce9592899deb8b2d0fa50bab1e6dd69182deb764e643c522408" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c8aa86b7f85ea71d66ea88c1d4bae1cfacf51ca4856274565133838d77e57b5" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nesemu" +version = "0.1.0" +dependencies = [ + "nesemu-adapter-api", + "nesemu-adapter-headless", +] + +[[package]] +name = "nesemu-adapter-api" +version = "0.1.0" + +[[package]] +name = "nesemu-adapter-headless" +version = "0.1.0" +dependencies = [ + "nesemu-adapter-api", +] + +[[package]] +name = "nesemu-desktop" +version = "0.1.0" +dependencies = [ + "cairo-rs", + "gtk4", + "nesemu", +] + +[[package]] +name = "pango" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0d328648058085cfd6897c9ae4272884098a926f3a833cd50c8c73e6eccecd" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff03da4fa086c0b244d4a4587d3e20622a3ecdb21daea9edf66597224c634ba0" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.4+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[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", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[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 = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a137e2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "nesemu" +version = "0.1.0" +edition = "2024" +description = "Core NES/Famicom emulation library in Rust" +readme = "README.md" +license = "MIT OR Apache-2.0" +documentation = "https://docs.rs/nesemu" +keywords = ["nes", "emulator", "famicom", "gamedev"] +categories = ["emulators", "games"] +rust-version = "1.85" + +[workspace] +members = [ + ".", + "crates/nesemu-adapter-api", + "crates/nesemu-adapter-headless", + "crates/nesemu-desktop", +] +default-members = ["."] +resolver = "2" + +[dependencies] +nesemu-adapter-api = { path = "crates/nesemu-adapter-api", optional = true } +nesemu-adapter-headless = { path = "crates/nesemu-adapter-headless", optional = true } + +[features] +default = [] +adapter-api = ["dep:nesemu-adapter-api"] +adapter-headless = ["adapter-api", "dep:nesemu-adapter-headless"] + +[lints.rust] +unsafe_code = "forbid" +unreachable_pub = "warn" + +[lints.clippy] +pedantic = { level = "allow", priority = -1 } +wildcard_imports = "allow" +match_same_arms = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +needless_pass_by_value = "allow" diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa1b458 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# nesemu + +NES/Famicom emulation workspace in Rust. + +The workspace is built around a reusable core library. It also contains optional adapter crates and a GTK4 desktop frontend for manual testing. + +## What Is Here + +- `nesemu`: core emulation library +- `nesemu-adapter-api`: backend-agnostic adapter traits +- `nesemu-adapter-headless`: null/headless adapter implementations +- `nesemu-desktop`: GTK4 desktop frontend + +## What The Core Library Provides + +- CPU, PPU, APU, bus, and cartridge mapper emulation +- iNES ROM parsing +- Save/load state support +- Host-facing runtime wrappers for frame execution and pacing +- Public API and behavior tests + +## Quick Start + +Add the main crate as a dependency: + +```toml +[dependencies] +nesemu = { path = "../nesemu" } +``` + +Enable optional adapter support if needed: + +```toml +[dependencies] +nesemu = { path = "../nesemu", features = ["adapter-api", "adapter-headless"] } +``` + +Recommended import style: + +```rust +use nesemu::prelude::*; +``` + +Minimal setup: + +```rust +use nesemu::{Cpu6502, NativeBus, create_mapper, parse_rom}; + +let rom_bytes = std::fs::read("game.nes")?; +let rom = parse_rom(&rom_bytes)?; +let mapper = create_mapper(rom)?; +let mut bus = NativeBus::new(mapper); +let mut cpu = Cpu6502::default(); +cpu.reset(&mut bus); +``` + +Higher-level runtime setup: + +```rust +use nesemu::{FRAME_RGBA_BYTES, NesRuntime}; + +let rom_bytes = std::fs::read("game.nes")?; +let mut runtime = NesRuntime::from_rom_bytes(&rom_bytes)?; +runtime.run_until_frame_complete()?; + +let mut rgba = vec![0; FRAME_RGBA_BYTES]; +runtime.render_frame_rgba(&mut rgba)?; +``` + +## Desktop Frontend + +Run the GTK4 desktop frontend: + +```bash +cargo run -p nesemu-desktop -- path/to/game.nes +``` + +Linux build requirements: GTK4 development packages and `pkg-config` (for example on Debian/Ubuntu: `libgtk-4-dev pkg-config`). + +Controls: +- `Esc`: quit +- `P`: pause/resume +- `Open ROM`: load `.nes` file +- Arrow keys: D-pad +- `X`: A +- `Z`: B +- `Enter`: Start +- `Left Shift` / `Right Shift`: Select + +## Development + +```bash +cargo fmt --all +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +``` + +## Documentation Map + +- [API Contract](docs/api_contract.md): supported external surface and stability expectations +- [Integration Guide](docs/integration.md): how to embed the library into a host or frontend +- [Architecture](docs/architecture.md): internal module layout and layering diff --git a/crates/nesemu-adapter-api/Cargo.toml b/crates/nesemu-adapter-api/Cargo.toml new file mode 100644 index 0000000..c8edb60 --- /dev/null +++ b/crates/nesemu-adapter-api/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "nesemu-adapter-api" +version = "0.1.0" +edition = "2024" +description = "Backend-agnostic adapter traits for nesemu clients" +license = "MIT OR Apache-2.0" +rust-version = "1.85" + +[dependencies] diff --git a/crates/nesemu-adapter-api/src/lib.rs b/crates/nesemu-adapter-api/src/lib.rs new file mode 100644 index 0000000..c3e5c17 --- /dev/null +++ b/crates/nesemu-adapter-api/src/lib.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; + +pub const BUTTONS_COUNT: usize = 8; +pub type ButtonState = [bool; BUTTONS_COUNT]; + +pub trait InputSource { + fn poll_buttons(&mut self) -> ButtonState; +} + +pub trait VideoSink { + fn present_rgba(&mut self, frame: &[u8], width: u32, height: u32); +} + +pub trait AudioSink { + fn push_samples(&mut self, samples: &[f32]); +} + +pub trait TimeSource { + fn wait_next_frame(&mut self); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum StorageError { + NotFound(String), + Io(String), +} + +pub trait FileStore { + fn read(&self, key: &str) -> Result, StorageError>; + fn write(&mut self, key: &str, bytes: &[u8]) -> Result<(), StorageError>; +} + +#[derive(Default)] +pub struct MemoryStore { + items: HashMap>, +} + +impl FileStore for MemoryStore { + fn read(&self, key: &str) -> Result, StorageError> { + self.items + .get(key) + .cloned() + .ok_or_else(|| StorageError::NotFound(key.to_string())) + } + + fn write(&mut self, key: &str, bytes: &[u8]) -> Result<(), StorageError> { + self.items.insert(key.to_string(), bytes.to_vec()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{FileStore, MemoryStore, StorageError}; + + #[test] + fn memory_store_roundtrip() { + let mut store = MemoryStore::default(); + store.write("slot1", &[1, 2, 3]).expect("write"); + assert_eq!(store.read("slot1").expect("read"), vec![1, 2, 3]); + } + + #[test] + fn memory_store_not_found() { + let store = MemoryStore::default(); + let err = store.read("missing").expect_err("must fail"); + assert!(matches!(err, StorageError::NotFound(_))); + } +} diff --git a/crates/nesemu-adapter-headless/Cargo.toml b/crates/nesemu-adapter-headless/Cargo.toml new file mode 100644 index 0000000..b3347a1 --- /dev/null +++ b/crates/nesemu-adapter-headless/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "nesemu-adapter-headless" +version = "0.1.0" +edition = "2024" +description = "Headless/null adapter implementations for nesemu adapter API" +license = "MIT OR Apache-2.0" +rust-version = "1.85" + +[dependencies] +nesemu-adapter-api = { path = "../nesemu-adapter-api" } diff --git a/crates/nesemu-adapter-headless/src/lib.rs b/crates/nesemu-adapter-headless/src/lib.rs new file mode 100644 index 0000000..ab25d3b --- /dev/null +++ b/crates/nesemu-adapter-headless/src/lib.rs @@ -0,0 +1,52 @@ +use nesemu_adapter_api::{ + AudioSink, BUTTONS_COUNT, ButtonState, InputSource, TimeSource, VideoSink, +}; + +#[derive(Default)] +pub struct NullInput; + +impl InputSource for NullInput { + fn poll_buttons(&mut self) -> ButtonState { + [false; BUTTONS_COUNT] + } +} + +#[derive(Default)] +pub struct NullVideo; + +impl VideoSink for NullVideo { + fn present_rgba(&mut self, _frame: &[u8], _width: u32, _height: u32) {} +} + +#[derive(Default)] +pub struct NullAudio; + +impl AudioSink for NullAudio { + fn push_samples(&mut self, _samples: &[f32]) {} +} + +#[derive(Default)] +pub struct NoopTime; + +impl TimeSource for NoopTime { + fn wait_next_frame(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::{NoopTime, NullAudio, NullInput, NullVideo}; + use nesemu_adapter_api::{AudioSink, BUTTONS_COUNT, InputSource, TimeSource, VideoSink}; + + #[test] + fn null_adapters_are_noop() { + let mut input = NullInput; + let mut video = NullVideo; + let mut audio = NullAudio; + let mut time = NoopTime; + + assert_eq!(input.poll_buttons(), [false; BUTTONS_COUNT]); + video.present_rgba(&[], 256, 240); + audio.push_samples(&[]); + time.wait_next_frame(); + } +} diff --git a/crates/nesemu-desktop/Cargo.toml b/crates/nesemu-desktop/Cargo.toml new file mode 100644 index 0000000..725f36f --- /dev/null +++ b/crates/nesemu-desktop/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "nesemu-desktop" +version = "0.1.0" +edition = "2024" + +[dependencies] +nesemu = { path = "../.." } +gtk4 = "0.8" +cairo-rs = "0.19" diff --git a/crates/nesemu-desktop/src/main.rs b/crates/nesemu-desktop/src/main.rs new file mode 100644 index 0000000..5aa1a1e --- /dev/null +++ b/crates/nesemu-desktop/src/main.rs @@ -0,0 +1,505 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Duration; + +use gtk::gio; +use gtk::gdk; +use gtk::glib; +use gtk::prelude::*; +use gtk4 as gtk; +use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop}; +use nesemu::{ + FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton, + JoypadButtons, NesRuntime, set_button_pressed, +}; + +const APP_ID: &str = "org.nesemu.desktop"; +const TITLE: &str = "NES Emulator"; +const SCALE: i32 = 3; +const SAMPLE_RATE: u32 = 48_000; + +fn main() { + if std::env::var_os("GSK_RENDERER").is_none() { + unsafe { + std::env::set_var("GSK_RENDERER", "cairo"); + } + } + + let app = gtk::Application::builder() + .application_id(APP_ID) + .build(); + + let initial_rom: Rc>> = + Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from))); + + let initial_rom_for_activate = Rc::clone(&initial_rom); + app.connect_activate(move |app| { + let rom = initial_rom_for_activate.borrow_mut().take(); + build_ui(app, rom); + }); + + app.run_with_args::<&str>(&[]); +} + +fn build_ui(app: >k::Application, initial_rom: Option) { + let window = gtk::ApplicationWindow::builder() + .application(app) + .title(TITLE) + .default_width((FRAME_WIDTH as i32) * SCALE) + .default_height((FRAME_HEIGHT as i32) * SCALE) + .build(); + + // --- Header bar --- + let header = gtk::HeaderBar::new(); + + let open_button = gtk::Button::builder() + .icon_name("document-open-symbolic") + .tooltip_text("Open ROM (Ctrl+O)") + .focusable(false) + .build(); + + let pause_button = gtk::Button::builder() + .icon_name("media-playback-pause-symbolic") + .tooltip_text("Pause / Resume (P)") + .focusable(false) + .sensitive(false) + .build(); + + let reset_button = gtk::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text("Reset (Ctrl+R)") + .focusable(false) + .sensitive(false) + .build(); + + header.pack_start(&open_button); + header.pack_start(&pause_button); + header.pack_start(&reset_button); + + window.set_titlebar(Some(&header)); + + // --- Drawing area --- + let drawing_area = gtk::DrawingArea::new(); + drawing_area.set_hexpand(true); + drawing_area.set_vexpand(true); + + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&drawing_area)); + + let drop_label = gtk::Label::builder() + .label("Drop a .nes ROM here\nor press Ctrl+O to open") + .justify(gtk::Justification::Center) + .css_classes(["dim-label"]) + .build(); + drop_label.set_halign(gtk::Align::Center); + drop_label.set_valign(gtk::Align::Center); + overlay.add_overlay(&drop_label); + + window.set_child(Some(&overlay)); + + // --- State --- + let desktop = Rc::new(RefCell::new(DesktopApp::new())); + let frame_for_draw: Rc>> = + Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES])); + + // --- Draw function (pixel-perfect nearest-neighbor) --- + { + let frame_for_draw = Rc::clone(&frame_for_draw); + drawing_area.set_draw_func(move |_da, cr, width, height| { + let frame = frame_for_draw.borrow(); + let stride = + cairo::Format::ARgb32.stride_for_width(FRAME_WIDTH as u32).unwrap(); + let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT]; + for y in 0..FRAME_HEIGHT { + for x in 0..FRAME_WIDTH { + let src = (y * FRAME_WIDTH + x) * 4; + let dst = y * stride as usize + x * 4; + let r = frame[src]; + let g = frame[src + 1]; + let b = frame[src + 2]; + let a = frame[src + 3]; + argb[dst] = b; + argb[dst + 1] = g; + argb[dst + 2] = r; + argb[dst + 3] = a; + } + } + let surface = cairo::ImageSurface::create_for_data( + argb, + cairo::Format::ARgb32, + FRAME_WIDTH as i32, + FRAME_HEIGHT as i32, + stride, + ) + .expect("Failed to create Cairo surface"); + + // Fill background black + let _ = cr.set_source_rgb(0.0, 0.0, 0.0); + let _ = cr.paint(); + + let sx = width as f64 / FRAME_WIDTH as f64; + let sy = height as f64 / FRAME_HEIGHT as f64; + let scale = sx.min(sy); + let offset_x = (width as f64 - FRAME_WIDTH as f64 * scale) / 2.0; + let offset_y = (height as f64 - FRAME_HEIGHT as f64 * scale) / 2.0; + + let _ = cr.translate(offset_x, offset_y); + let _ = cr.scale(scale, scale); + let _ = cr.set_source_surface(&surface, 0.0, 0.0); + cr.source().set_filter(cairo::Filter::Nearest); + let _ = cr.paint(); + }); + } + + // --- Helper to sync UI with emulation state --- + let sync_ui = { + let pause_button = pause_button.clone(); + let reset_button = reset_button.clone(); + let drop_label = drop_label.clone(); + let window = window.clone(); + move |app_state: &DesktopApp, rom_name: Option<&str>| { + let loaded = app_state.is_loaded(); + pause_button.set_sensitive(loaded); + reset_button.set_sensitive(loaded); + drop_label.set_visible(!loaded); + + if app_state.state() == EmulationState::Running { + pause_button.set_icon_name("media-playback-pause-symbolic"); + pause_button.set_tooltip_text(Some("Pause (P)")); + } else { + pause_button.set_icon_name("media-playback-start-symbolic"); + pause_button.set_tooltip_text(Some("Resume (P)")); + } + + if let Some(name) = rom_name { + window.set_title(Some(&format!("{TITLE} — {name}"))); + } + } + }; + let sync_ui = Rc::new(sync_ui); + + // --- Load initial ROM --- + { + let mut app_state = desktop.borrow_mut(); + if let Some(path) = initial_rom { + if let Err(err) = app_state.load_rom_from_path(&path) { + eprintln!("Failed to load ROM '{}': {err}", path.display()); + sync_ui(&app_state, None); + } else { + let name = rom_filename(&path); + sync_ui(&app_state, Some(&name)); + } + } else { + sync_ui(&app_state, None); + } + } + + // --- Open ROM handler --- + let do_open_rom = { + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + let window = window.clone(); + Rc::new(move || { + let chooser = gtk::FileChooserNative::new( + Some("Open NES ROM"), + Some(&window), + gtk::FileChooserAction::Open, + Some("Open"), + Some("Cancel"), + ); + + let nes_filter = gtk::FileFilter::new(); + nes_filter.set_name(Some("NES ROMs")); + nes_filter.add_pattern("*.nes"); + chooser.add_filter(&nes_filter); + let all_filter = gtk::FileFilter::new(); + all_filter.set_name(Some("All files")); + all_filter.add_pattern("*"); + chooser.add_filter(&all_filter); + + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + chooser.connect_response(move |dialog, response| { + if response == gtk::ResponseType::Accept { + if let Some(path) = dialog.file().and_then(|f| f.path()) { + let mut app_state = desktop.borrow_mut(); + if let Err(err) = app_state.load_rom_from_path(&path) { + eprintln!("Failed to load ROM '{}': {err}", path.display()); + } else { + let name = rom_filename(&path); + sync_ui(&app_state, Some(&name)); + } + } + } + }); + + chooser.show(); + }) + }; + + // --- Button handlers --- + { + let do_open_rom = Rc::clone(&do_open_rom); + open_button.connect_clicked(move |_| { + do_open_rom(); + }); + } + + { + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + pause_button.connect_clicked(move |_| { + let mut app_state = desktop.borrow_mut(); + app_state.toggle_pause(); + sync_ui(&app_state, None); + }); + } + + { + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + reset_button.connect_clicked(move |_| { + let mut app_state = desktop.borrow_mut(); + app_state.reset(); + sync_ui(&app_state, None); + }); + } + + // --- Keyboard shortcuts via actions --- + let action_open = gio::SimpleAction::new("open", None); + { + let do_open_rom = Rc::clone(&do_open_rom); + action_open.connect_activate(move |_, _| { + do_open_rom(); + }); + } + window.add_action(&action_open); + app.set_accels_for_action("win.open", &["o"]); + + let action_pause = gio::SimpleAction::new("toggle-pause", None); + { + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + action_pause.connect_activate(move |_, _| { + let mut app_state = desktop.borrow_mut(); + if app_state.is_loaded() { + app_state.toggle_pause(); + sync_ui(&app_state, None); + } + }); + } + window.add_action(&action_pause); + app.set_accels_for_action("win.toggle-pause", &["p"]); + + let action_reset = gio::SimpleAction::new("reset", None); + { + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + action_reset.connect_activate(move |_, _| { + let mut app_state = desktop.borrow_mut(); + if app_state.is_loaded() { + app_state.reset(); + sync_ui(&app_state, None); + } + }); + } + window.add_action(&action_reset); + app.set_accels_for_action("win.reset", &["r"]); + + // --- Keyboard controller for joypad input --- + { + let desktop = Rc::clone(&desktop); + let key_controller = gtk::EventControllerKey::new(); + + let desktop_for_press = Rc::clone(&desktop); + key_controller.connect_key_pressed(move |_, key, _, _| { + let mut app_state = desktop_for_press.borrow_mut(); + app_state.input_mut().set_key_state(key, true); + gtk::glib::Propagation::Proceed + }); + + key_controller.connect_key_released(move |_, key, _, _| { + desktop.borrow_mut().input_mut().set_key_state(key, false); + }); + + window.add_controller(key_controller); + } + + // --- Drag-and-drop --- + { + let desktop = Rc::clone(&desktop); + let sync_ui = Rc::clone(&sync_ui); + let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY); + drop_target.connect_drop(move |_, value, _, _| { + if let Ok(file) = value.get::() { + if let Some(path) = file.path() { + let mut app_state = desktop.borrow_mut(); + if let Err(err) = app_state.load_rom_from_path(&path) { + eprintln!("Failed to load ROM '{}': {err}", path.display()); + return false; + } + let name = rom_filename(&path); + sync_ui(&app_state, Some(&name)); + return true; + } + } + false + }); + drawing_area.add_controller(drop_target); + } + + // --- Game loop --- + { + let desktop = Rc::clone(&desktop); + let drawing_area = drawing_area.clone(); + let frame_for_draw = Rc::clone(&frame_for_draw); + glib::timeout_add_local(Duration::from_millis(16), move || { + let mut app_state = desktop.borrow_mut(); + app_state.tick(); + + frame_for_draw + .borrow_mut() + .copy_from_slice(app_state.frame_rgba()); + drawing_area.queue_draw(); + + glib::ControlFlow::Continue + }); + } + + window.present(); +} + +fn rom_filename(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Unknown".into()) +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct InputState { + buttons: JoypadButtons, +} + +impl InputState { + fn set_key_state(&mut self, key: gdk::Key, pressed: bool) { + let button = match key { + gdk::Key::Up => JoypadButton::Up, + gdk::Key::Down => JoypadButton::Down, + gdk::Key::Left => JoypadButton::Left, + gdk::Key::Right => JoypadButton::Right, + gdk::Key::x | gdk::Key::X => JoypadButton::A, + gdk::Key::z | gdk::Key::Z => JoypadButton::B, + gdk::Key::Return => JoypadButton::Start, + gdk::Key::Shift_L | gdk::Key::Shift_R => JoypadButton::Select, + _ => return, + }; + set_button_pressed(&mut self.buttons, button, pressed); + } +} + +impl InputProvider for InputState { + fn poll_buttons(&mut self) -> JoypadButtons { + self.buttons + } +} + +// --------------------------------------------------------------------------- +// Audio (stub) +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct AudioSink; + +impl nesemu::AudioOutput for AudioSink { + fn push_samples(&mut self, _samples: &[f32]) {} +} + +// --------------------------------------------------------------------------- +// Application state +// --------------------------------------------------------------------------- + +struct DesktopApp { + host: Option>>, + input: InputState, + audio: AudioSink, + frame_rgba: Vec, + state: EmulationState, +} + +impl DesktopApp { + fn new() -> Self { + Self { + host: None, + input: InputState::default(), + audio: AudioSink, + frame_rgba: vec![0; FRAME_RGBA_BYTES], + state: EmulationState::Paused, + } + } + + fn load_rom_from_path(&mut self, path: &Path) -> Result<(), Box> { + let data = std::fs::read(path)?; + let runtime = NesRuntime::from_rom_bytes(&data)?; + let config = HostConfig::new(SAMPLE_RATE, false); + self.host = Some(RuntimeHostLoop::with_config(runtime, config)); + self.state = EmulationState::Running; + Ok(()) + } + + fn reset(&mut self) { + if let Some(host) = self.host.as_mut() { + host.runtime_mut().reset(); + self.state = EmulationState::Running; + } + } + + fn is_loaded(&self) -> bool { + self.host.is_some() + } + + fn state(&self) -> EmulationState { + self.state + } + + fn toggle_pause(&mut self) { + self.state = match self.state { + EmulationState::Running => EmulationState::Paused, + EmulationState::Paused => EmulationState::Running, + _ => EmulationState::Paused, + }; + } + + fn tick(&mut self) { + if self.state != EmulationState::Running { + return; + } + + let Some(host) = self.host.as_mut() else { + return; + }; + + let mut null_video = nesemu::NullVideo; + if let Err(err) = host.run_frame_unpaced(&mut self.input, &mut null_video, &mut self.audio) + { + eprintln!("Frame execution error: {err}"); + self.state = EmulationState::Paused; + return; + } + + self.frame_rgba + .copy_from_slice(&host.runtime().frame_rgba()); + } + + fn frame_rgba(&self) -> &[u8] { + &self.frame_rgba + } + + fn input_mut(&mut self) -> &mut InputState { + &mut self.input + } +} diff --git a/docs/api_contract.md b/docs/api_contract.md new file mode 100644 index 0000000..8b157ff --- /dev/null +++ b/docs/api_contract.md @@ -0,0 +1,116 @@ +# API Contract + +This document defines the supported external contract for `nesemu` `0.x`. + +Use this file as the boundary of what external clients should rely on. For practical embedding examples, see `integration.md`. For internal structure, see `architecture.md`. + +## Supported Surface + +External users should prefer these entry points: + +- Root crate re-exports from `src/lib.rs` +- `nesemu::runtime::*` +- `nesemu::prelude::*` + +Optional adapter-facing API is available behind features: + +- `adapter-api` +- `adapter-headless` + +## Recommended Public Entry Points + +The main public API is organized around these groups: + +- ROM loading: + - `parse_header` + - `parse_rom` + - `InesHeader` + - `InesRom` + - `Mirroring` +- Cartridge mapping: + - `create_mapper` + - `Mapper` +- Low-level execution: + - `Cpu6502` + - `CpuBus` + - `CpuError` + - `NativeBus` +- High-level runtime: + - `NesRuntime` +- Host execution and lifecycle: + - `RuntimeHostLoop` + - `ClientRuntime` + - `HostConfig` + - `EmulationState` +- Host IO traits: + - `InputProvider` + - `VideoOutput` + - `AudioOutput` +- Timing and pacing: + - `FrameClock` + - `FramePacer` + - `PacingClock` + - `NoopClock` + - `VideoMode` +- Input helpers: + - `JoypadButton` + - `JoypadButtons` + - `JOYPAD_BUTTON_ORDER` + - `JOYPAD_BUTTONS_COUNT` + - `set_button_pressed` + - `button_pressed` + +## Supported Client Flow + +The expected integration flow is: + +1. Load ROM bytes and parse them, or construct `NesRuntime` directly from ROM bytes. +2. Choose your integration level: + - use `Cpu6502` + `NativeBus` for low-level control + - use `NesRuntime` for a higher-level core wrapper + - use `RuntimeHostLoop` or `ClientRuntime` for host-facing frame execution +3. Provide input, video, and audio implementations through the public host traits. +4. Use save/load state through the runtime or bus APIs when snapshot behavior is needed. + +## Stability Rules + +The following are considered the primary supported surface for `0.x`: + +- root re-exports +- `runtime` +- `prelude` + +The following are available but less stable: + +- `native_core::*` for advanced or experimental integrations + +Lower-level modules may evolve faster than the root re-export surface. + +## Compatibility Notes + +- Types marked `#[non_exhaustive]` may gain fields or variants without a major version bump. +- Save-state compatibility is only guaranteed within the same crate version unless explicitly documented otherwise. +- Optional features may expose additional adapter-facing API, but they do not change the baseline contract of the main library. + +## Extension Points + +The intended extension points for hosts and frontends are: + +- `InputProvider` +- `VideoOutput` +- `AudioOutput` +- `FrameClock` +- optional adapter bridge types when `adapter-api` is enabled: + - `InputAdapter` + - `VideoAdapter` + - `AudioAdapter` + - `ClockAdapter` + +## Out Of Scope + +This contract does not promise stability for: + +- GTK frontend behavior in `nesemu-desktop` +- internal module layout under `native_core` and `runtime` +- concrete implementation details of mapper modules +- cross-version save-state compatibility unless explicitly documented diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a9d1244 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,107 @@ +# Architecture + +This document describes how the workspace is organized internally. + +Use `../README.md` for project overview, `integration.md` for host integration, and `api_contract.md` for supported public surface. + +## Workspace Layout + +- `nesemu`: reusable emulation library and host-facing runtime wrappers +- `crates/nesemu-adapter-api`: backend-agnostic adapter traits +- `crates/nesemu-adapter-headless`: headless/null adapter implementations +- `crates/nesemu-desktop`: GTK4 desktop frontend that consumes the root crate + +## High-Level Layers + +The workspace is split into four layers: + +1. `native_core` + Owns emulation correctness and hardware-facing behavior. +2. `runtime` + Wraps the core with host-oriented execution, pacing, lifecycle control, and save-state helpers. +3. adapter crates + Define integration edges without coupling the core to a concrete backend. +4. desktop frontend + Serves as a consumer and manual test harness, not as part of the library contract. + +## Core Module Boundaries + +- `src/native_core/cpu`: 6502 execution, addressing helpers, opcode dispatch +- `src/native_core/ppu`: rendering pipeline, VRAM/OAM/register behavior +- `src/native_core/apu`: timing, channel state, and audio-facing hardware state +- `src/native_core/bus`: component wiring and device-visible read/write semantics +- `src/native_core/mapper`: cartridge mapper abstraction and concrete implementations +- `src/native_core/ines`: iNES parsing and ROM metadata +- `src/native_core/state_io`: shared state decoding helpers + +## Runtime Module Boundaries + +- `src/runtime/core.rs`: `NesRuntime` orchestration around CPU + bus +- `src/runtime/state.rs`: runtime save/load state format +- `src/runtime/audio.rs`: interim PCM synthesis from core state +- `src/runtime/timing.rs`: frame pacing types and video timing +- `src/runtime/types.rs`: public joypad-related types and helpers +- `src/runtime/host/io.rs`: host IO traits and null implementations +- `src/runtime/host/executor.rs`: per-frame execution unit +- `src/runtime/host/clock.rs`: clock abstraction and pacing implementations +- `src/runtime/host/loop_runner.rs`: host loop wrapper for frame-based execution +- `src/runtime/host/session.rs`: app lifecycle wrapper for running, pausing, and stepping + +## Data Flow + +At a high level, the runtime stack looks like this: + +1. ROM bytes are parsed into cartridge metadata and ROM contents. +2. A mapper is created from the ROM description. +3. `NativeBus` wires CPU, PPU, APU, mapper, and input-visible state together. +4. `Cpu6502` executes against the bus. +5. `NesRuntime` wraps the core to provide frame-level execution, rendering, and save-state helpers. +6. `RuntimeHostLoop` and `ClientRuntime` adapt the runtime to host application control flow. + +## Public Surface Strategy + +The root crate re-exports the integration-critical API so external users do not need to depend on the internal module layout. + +The design intent is: + +- external clients use root re-exports and `runtime` +- advanced clients may use `native_core` +- internal module paths are free to evolve faster than the root surface + +## Host Responsibilities + +The library intentionally leaves these concerns to the host application: + +- windowing and presentation backend +- audio device/output backend +- platform input mapping +- ROM file I/O +- persistent state storage + +## Input, Video, and Audio Contracts + +- `JoypadButtons` are exposed in the public order `[Up, Down, Left, Right, A, B, Start, Select]` +- `InputProvider` polls the current button state from the host +- `VideoOutput` receives RGBA frames +- `AudioOutput` receives mixed mono samples +- port 2 is currently treated as disconnected in the exposed core API + +## Save-State Design + +- `NativeBus::save_state` and `NativeBus::load_state` persist low-level emulator state +- `NesRuntime` extends that state with runtime metadata such as frame number and active buttons +- save-state payloads are versioned for crate-internal use, not for long-term external compatibility + +## Testing Layout + +- CPU tests are grouped by behavior, interrupts, and invariants +- mapper tests are grouped by mapper family and property-style checks +- runtime tests cover frame execution, pacing, state roundtrips, and lifecycle control +- `tests/public_api.rs` exercises the supported public flow as a black-box consumer + +## Constraints And Tradeoffs + +- no platform backend is bundled beyond the GTK desktop example +- audio mixing in `runtime/audio.rs` is intentionally interim +- optional adapter crates are thin integration layers, not mandatory parts of the core runtime +- compatibility promises are defined in `docs/api_contract.md`, not by internal module visibility diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..4df6ee4 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,177 @@ +# Integration Guide + +This guide shows how to embed `nesemu` into a host application or frontend. + +For the stable API boundary, see `api_contract.md`. For internal structure, see `architecture.md`. + +## Choose An Integration Level + +Use the lowest level that matches your needs: + +- `Cpu6502` + `NativeBus` + Use this if you need fine-grained stepping or low-level control. +- `NesRuntime` + Use this if you want frame-oriented execution, rendering helpers, and runtime state handling. +- `RuntimeHostLoop` + Use this if your host runs the emulator frame-by-frame and wants explicit input, video, audio, and pacing control. +- `ClientRuntime` + Use this if your app has running/paused/step states and needs lifecycle-oriented ticking. + +## Minimal ROM Load + +```rust +use nesemu::{create_mapper, parse_rom, Cpu6502, NativeBus}; + +let rom_bytes = std::fs::read("game.nes")?; +let rom = parse_rom(&rom_bytes)?; +let mapper = create_mapper(rom)?; +let mut bus = NativeBus::new(mapper); +let mut cpu = Cpu6502::default(); +cpu.reset(&mut bus); +``` + +## Using `NesRuntime` + +```rust +use nesemu::{FRAME_RGBA_BYTES, NesRuntime}; + +let rom_bytes = std::fs::read("game.nes")?; +let mut runtime = NesRuntime::from_rom_bytes(&rom_bytes)?; +runtime.run_until_frame_complete()?; + +let mut frame = vec![0; FRAME_RGBA_BYTES]; +runtime.render_frame_rgba(&mut frame)?; +``` + +Use `NesRuntime` when you want: + +- frame-based stepping instead of raw CPU control +- framebuffer extraction +- runtime-level save/load state helpers + +## Using `RuntimeHostLoop` + +`RuntimeHostLoop` is the main integration point for hosts that want explicit control over frame execution. + +```rust +use nesemu::{ + AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NesRuntime, RuntimeHostLoop, + VideoOutput, +}; + +struct Input; +impl InputProvider for Input { + fn poll_buttons(&mut self) -> [bool; JOYPAD_BUTTONS_COUNT] { + [false; JOYPAD_BUTTONS_COUNT] + } +} + +struct Video; +impl VideoOutput for Video { + fn present_rgba(&mut self, _frame: &[u8], _width: usize, _height: usize) {} +} + +struct Audio; +impl AudioOutput for Audio { + fn push_samples(&mut self, _samples: &[f32]) {} +} + +let rom_bytes = std::fs::read("game.nes")?; +let runtime = NesRuntime::from_rom_bytes(&rom_bytes)?; +let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false)); + +let mut input = Input; +let mut video = Video; +let mut audio = Audio; + +let stats = host.run_frame_unpaced(&mut input, &mut video, &mut audio)?; +let _ = stats; +``` + +Use `run_frame` for paced execution and `run_frame_unpaced` when the host controls timing externally. + +## Using `ClientRuntime` + +`ClientRuntime` wraps the runtime with a simple running/paused/step lifecycle. + +```rust +use nesemu::{ + AudioOutput, ClientRuntime, EmulationState, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, + NesRuntime, VideoOutput, +}; + +struct Input; +impl InputProvider for Input { + fn poll_buttons(&mut self) -> [bool; JOYPAD_BUTTONS_COUNT] { + [false; JOYPAD_BUTTONS_COUNT] + } +} + +struct Video; +impl VideoOutput for Video { + fn present_rgba(&mut self, _frame: &[u8], _width: usize, _height: usize) {} +} + +struct Audio; +impl AudioOutput for Audio { + fn push_samples(&mut self, _samples: &[f32]) {} +} + +let rom_bytes = std::fs::read("game.nes")?; +let runtime = NesRuntime::from_rom_bytes(&rom_bytes)?; +let mut client = ClientRuntime::with_config(runtime, HostConfig::new(48_000, true)); + +client.set_state(EmulationState::Running); + +let mut input = Input; +let mut video = Video; +let mut audio = Audio; +let _ = client.tick(&mut input, &mut video, &mut audio)?; + +client.pause(); +client.step_frame(&mut input, &mut video, &mut audio)?; +``` + +Use this wrapper when your UI loop naturally switches between running, paused, and manual stepping. + +## Input Mapping + +Public helpers are available to avoid hard-coded button indices: + +- `JoypadButton` +- `JOYPAD_BUTTON_ORDER` +- `set_button_pressed` +- `button_pressed` + +Public button order is: + +`[Up, Down, Left, Right, A, B, Start, Select]` + +## Framebuffer And Audio + +- Video frames are exposed as RGBA8 +- Frame size is `256x240` +- Audio output is a stream of mixed mono `f32` samples +- The runtime mixer is usable for host integration, but it is intentionally interim + +## Save-State Use + +Use runtime-level state when you need host-visible frame metadata and input state preserved alongside low-level emulation state. + +Use bus-level state if you are integrating at the low-level core boundary. + +## Optional Adapter Crates + +If you want backend-agnostic adapter traits and headless implementations: + +```toml +[dependencies] +nesemu = { path = "../nesemu", features = ["adapter-api", "adapter-headless"] } +``` + +Then: + +```rust +#[cfg(feature = "adapter-api")] +use nesemu::adapter_api::{AudioSink, InputSource, TimeSource, VideoSink}; +``` diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c5e39ce --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,43 @@ +//! `nesemu` is a core NES/Famicom emulation library. +//! +//! It exposes CPU/PPU/APU/bus primitives and ROM/mapper helpers so a host app +//! can provide platform-specific frontend (windowing, audio device, input). +//! +//! # API Stability +//! - `runtime` and root re-exports are the supported public `v0` API for external clients. +//! - `native_core` modules are available for advanced integrations, but treated as lower-level +//! surface that may evolve faster between minor releases. + +pub mod native_core; +pub mod runtime; + +#[cfg(feature = "adapter-api")] +pub use nesemu_adapter_api as adapter_api; +#[cfg(feature = "adapter-headless")] +pub use nesemu_adapter_headless as adapter_headless; + +pub use native_core::apu::{Apu, ApuStateTail}; +pub use native_core::bus::NativeBus; +pub use native_core::cpu::{Cpu6502, CpuBus, CpuError}; +pub use native_core::ines::{InesHeader, InesRom, Mirroring, parse_header, parse_rom}; +pub use native_core::mapper::{Mapper, create_mapper}; +pub use native_core::ppu::Ppu; +#[cfg(feature = "adapter-api")] +pub use runtime::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter}; +pub use runtime::{ + AudioMixer, AudioOutput, ClientRuntime, EmulationState, FRAME_HEIGHT, FRAME_RGBA_BYTES, + FRAME_WIDTH, FrameClock, FramePacer, HostConfig, InputProvider, JOYPAD_BUTTON_ORDER, + JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput, + NullVideo, PacingClock, RuntimeError, RuntimeHostLoop, SAVE_STATE_VERSION, VideoMode, + VideoOutput, button_pressed, set_button_pressed, +}; + +pub mod prelude { + #[cfg(feature = "adapter-api")] + pub use crate::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter}; + pub use crate::{ + AudioOutput, ClientRuntime, EmulationState, HostConfig, InputProvider, JoypadButton, + JoypadButtons, NesRuntime, RuntimeError, RuntimeHostLoop, VideoOutput, button_pressed, + create_mapper, parse_rom, set_button_pressed, + }; +} diff --git a/src/native_core/apu/api.rs b/src/native_core/apu/api.rs new file mode 100644 index 0000000..1d79a4f --- /dev/null +++ b/src/native_core/apu/api.rs @@ -0,0 +1,264 @@ +use super::types::{Apu, ApuStateTail}; + +impl Apu { + pub fn new() -> Self { + Self { + io: [0; 0x20], + frame_cycle: 0, + frame_mode_5step: false, + frame_irq_inhibit: false, + frame_irq_pending: false, + channel_enable_mask: 0, + length_counters: [0; 4], + dmc_bytes_remaining: 0, + dmc_irq_enabled: false, + dmc_irq_pending: false, + dmc_cycle_counter: 0, + dmc_current_addr: 0xC000, + dmc_sample_buffer: 0, + dmc_sample_buffer_valid: false, + dmc_shift_reg: 0, + dmc_bits_remaining: 8, + dmc_silence: true, + dmc_output_level: 0, + dmc_dma_request: false, + envelope_divider: [0; 3], + envelope_decay: [0; 3], + envelope_start_flags: 0, + triangle_linear_counter: 0, + triangle_linear_reload_flag: false, + sweep_divider: [0; 2], + sweep_reload_flags: 0, + cpu_cycle_parity: false, + frame_reset_pending: false, + frame_reset_delay: 0, + pending_frame_mode_5step: false, + pending_frame_irq_inhibit: false, + } + } + + pub fn registers(&self) -> &[u8; 0x20] { + &self.io + } + + pub fn set_registers(&mut self, regs: [u8; 0x20]) { + self.io = regs; + } + + pub fn read(&mut self, addr: u16) -> u8 { + match addr { + 0x4000..=0x4013 => self.io[(addr as usize) - 0x4000], + 0x4015 => self.read_status(), + _ => 0, + } + } + + pub fn write(&mut self, addr: u16, value: u8) { + match addr { + 0x4000..=0x4013 => { + self.io[(addr as usize) - 0x4000] = value; + match addr { + 0x4003 => { + self.reload_length_counter(0, value >> 3); + self.envelope_start_flags |= 1 << 0; + } + 0x4001 => { + self.sweep_reload_flags |= 1 << 0; + } + 0x4007 => { + self.reload_length_counter(1, value >> 3); + self.envelope_start_flags |= 1 << 1; + } + 0x4005 => { + self.sweep_reload_flags |= 1 << 1; + } + 0x400B => { + self.reload_length_counter(2, value >> 3); + self.triangle_linear_reload_flag = true; + } + 0x400F => { + self.reload_length_counter(3, value >> 3); + self.envelope_start_flags |= 1 << 2; + } + 0x4010 => { + self.dmc_irq_enabled = (value & 0x80) != 0; + if !self.dmc_irq_enabled { + self.dmc_irq_pending = false; + } + } + 0x4011 => { + self.dmc_output_level = value & 0x7F; + } + 0x4012 => { + self.dmc_current_addr = self.dmc_sample_start_addr(); + } + _ => {} + } + } + 0x4014 => { + self.io[0x14] = value; + } + 0x4015 => { + self.io[(addr as usize) - 0x4000] = value; + self.channel_enable_mask = value & 0x1F; + self.dmc_irq_pending = false; + for chan in 0..4usize { + if (self.channel_enable_mask & (1 << chan)) == 0 { + self.length_counters[chan] = 0; + } + } + if (self.channel_enable_mask & 0x10) == 0 { + self.dmc_bytes_remaining = 0; + self.dmc_cycle_counter = 0; + self.dmc_dma_request = false; + } else if self.dmc_bytes_remaining == 0 { + self.dmc_bytes_remaining = self.dmc_sample_length_bytes(); + self.dmc_cycle_counter = self.dmc_byte_period(); + self.dmc_current_addr = self.dmc_sample_start_addr(); + if !self.dmc_sample_buffer_valid { + self.dmc_dma_request = true; + } + } + } + 0x4017 => { + self.io[(addr as usize) - 0x4000] = value; + self.pending_frame_mode_5step = (value & 0x80) != 0; + self.pending_frame_irq_inhibit = (value & 0x40) != 0; + self.frame_reset_delay = if self.cpu_cycle_parity { 4 } else { 3 }; + self.frame_reset_pending = true; + if self.pending_frame_irq_inhibit { + self.frame_irq_pending = false; + } + } + _ => {} + } + } + + pub fn clock_cpu_cycle(&mut self) { + let mut skip_frame_counter_clock = false; + if self.frame_reset_pending { + self.frame_reset_delay = self.frame_reset_delay.saturating_sub(1); + if self.frame_reset_delay == 0 { + self.frame_reset_pending = false; + self.frame_mode_5step = self.pending_frame_mode_5step; + self.frame_irq_inhibit = self.pending_frame_irq_inhibit; + self.frame_cycle = 0; + if self.frame_mode_5step { + self.clock_quarter_frame(); + self.clock_half_frame(); + } + skip_frame_counter_clock = true; + } + } + if !skip_frame_counter_clock { + self.clock_frame_counter(); + } + self.clock_dmc(); + self.cpu_cycle_parity = !self.cpu_cycle_parity; + } + + pub fn poll_irq(&self) -> bool { + self.dmc_irq_pending || (self.frame_irq_pending && !self.frame_irq_inhibit) + } + + pub fn take_dmc_dma_request(&mut self) -> Option { + if self.dmc_dma_request { + Some(self.dmc_current_addr) + } else { + None + } + } + + pub fn provide_dmc_dma_byte(&mut self, byte: u8) { + if !self.dmc_dma_request { + return; + } + self.dmc_dma_request = false; + self.dmc_sample_buffer = byte; + self.dmc_sample_buffer_valid = true; + self.dmc_current_addr = if self.dmc_current_addr == 0xFFFF { + 0x8000 + } else { + self.dmc_current_addr.wrapping_add(1) + }; + + if self.dmc_bytes_remaining > 0 { + self.dmc_bytes_remaining -= 1; + } + if self.dmc_bytes_remaining == 0 { + if (self.io[0x10] & 0x40) != 0 { + self.dmc_bytes_remaining = self.dmc_sample_length_bytes(); + self.dmc_current_addr = self.dmc_sample_start_addr(); + self.dmc_dma_request = true; + } else if self.dmc_irq_enabled { + self.dmc_irq_pending = true; + } + } + } + + pub fn save_state_tail(&self, out: &mut Vec) { + out.extend_from_slice(&self.frame_cycle.to_le_bytes()); + out.push(u8::from(self.frame_mode_5step)); + out.push(u8::from(self.frame_irq_inhibit)); + out.push(u8::from(self.frame_irq_pending)); + out.push(self.channel_enable_mask); + out.extend_from_slice(&self.length_counters); + out.extend_from_slice(&self.dmc_bytes_remaining.to_le_bytes()); + out.push(u8::from(self.dmc_irq_enabled)); + out.push(u8::from(self.dmc_irq_pending)); + out.extend_from_slice(&self.dmc_cycle_counter.to_le_bytes()); + out.extend_from_slice(&self.dmc_current_addr.to_le_bytes()); + out.push(self.dmc_sample_buffer); + out.push(u8::from(self.dmc_sample_buffer_valid)); + out.push(self.dmc_shift_reg); + out.push(self.dmc_bits_remaining); + out.push(u8::from(self.dmc_silence)); + out.push(self.dmc_output_level); + out.push(u8::from(self.dmc_dma_request)); + out.extend_from_slice(&self.envelope_divider); + out.extend_from_slice(&self.envelope_decay); + out.push(self.envelope_start_flags); + out.push(self.triangle_linear_counter); + out.push(u8::from(self.triangle_linear_reload_flag)); + out.extend_from_slice(&self.sweep_divider); + out.push(self.sweep_reload_flags); + out.push(u8::from(self.cpu_cycle_parity)); + out.push(u8::from(self.frame_reset_pending)); + out.push(self.frame_reset_delay); + out.push(u8::from(self.pending_frame_mode_5step)); + out.push(u8::from(self.pending_frame_irq_inhibit)); + } + + pub fn load_state_tail(&mut self, state: ApuStateTail) { + self.frame_cycle = state.frame_cycle; + self.frame_mode_5step = state.frame_mode_5step; + self.frame_irq_inhibit = state.frame_irq_inhibit; + self.frame_irq_pending = state.frame_irq_pending; + self.channel_enable_mask = state.channel_enable_mask; + self.length_counters = state.length_counters; + self.dmc_bytes_remaining = state.dmc_bytes_remaining; + self.dmc_irq_enabled = state.dmc_irq_enabled; + self.dmc_irq_pending = state.dmc_irq_pending; + self.dmc_cycle_counter = state.dmc_cycle_counter; + self.dmc_current_addr = state.dmc_current_addr; + self.dmc_sample_buffer = state.dmc_sample_buffer; + self.dmc_sample_buffer_valid = state.dmc_sample_buffer_valid; + self.dmc_shift_reg = state.dmc_shift_reg; + self.dmc_bits_remaining = state.dmc_bits_remaining.max(1); + self.dmc_silence = state.dmc_silence; + self.dmc_output_level = state.dmc_output_level & 0x7F; + self.dmc_dma_request = state.dmc_dma_request; + self.envelope_divider = state.envelope_divider; + self.envelope_decay = state.envelope_decay; + self.envelope_start_flags = state.envelope_start_flags & 0x07; + self.triangle_linear_counter = state.triangle_linear_counter & 0x7F; + self.triangle_linear_reload_flag = state.triangle_linear_reload_flag; + self.sweep_divider = state.sweep_divider; + self.sweep_reload_flags = state.sweep_reload_flags & 0x03; + self.cpu_cycle_parity = state.cpu_cycle_parity; + self.frame_reset_pending = state.frame_reset_pending; + self.frame_reset_delay = state.frame_reset_delay; + self.pending_frame_mode_5step = state.pending_frame_mode_5step; + self.pending_frame_irq_inhibit = state.pending_frame_irq_inhibit; + } +} diff --git a/src/native_core/apu/mod.rs b/src/native_core/apu/mod.rs new file mode 100644 index 0000000..075f5ba --- /dev/null +++ b/src/native_core/apu/mod.rs @@ -0,0 +1,5 @@ +mod api; +mod timing; +mod types; + +pub use types::{Apu, ApuStateTail}; diff --git a/src/native_core/apu/timing.rs b/src/native_core/apu/timing.rs new file mode 100644 index 0000000..5401373 --- /dev/null +++ b/src/native_core/apu/timing.rs @@ -0,0 +1,227 @@ +use super::types::*; + +impl Apu { + pub(crate) fn read_status(&mut self) -> u8 { + let mut status = 0u8; + status |= u8::from(self.length_counters[0] > 0); + status |= u8::from(self.length_counters[1] > 0) << 1; + status |= u8::from(self.length_counters[2] > 0) << 2; + status |= u8::from(self.length_counters[3] > 0) << 3; + status |= u8::from(self.dmc_bytes_remaining > 0) << 4; + status |= u8::from(self.frame_irq_pending) << 6; + status |= u8::from(self.dmc_irq_pending) << 7; + self.frame_irq_pending = false; + status + } + pub(crate) fn clock_frame_counter(&mut self) { + let seq_len = if self.frame_mode_5step { + APU_FRAME_SEQ_5_STEP_CYCLES + } else { + APU_FRAME_SEQ_4_STEP_CYCLES + }; + if seq_len == 0 { + return; + } + self.frame_cycle = self.frame_cycle.wrapping_add(1); + if self.frame_cycle == APU_HALF_FRAME_1 + || (!self.frame_mode_5step && self.frame_cycle == APU_HALF_FRAME_2_4STEP) + || (self.frame_mode_5step && self.frame_cycle == APU_HALF_FRAME_2_5STEP) + { + self.clock_half_frame(); + } + let quarter_frame_tick = self.frame_cycle == APU_QUARTER_FRAME_1 + || self.frame_cycle == APU_QUARTER_FRAME_2 + || self.frame_cycle == APU_QUARTER_FRAME_3 + || (!self.frame_mode_5step && self.frame_cycle == APU_QUARTER_FRAME_4) + || (self.frame_mode_5step && self.frame_cycle == APU_QUARTER_FRAME_5STEP_4); + if quarter_frame_tick { + self.clock_quarter_frame(); + } + if !self.frame_mode_5step + && self.frame_cycle == APU_FRAME_SEQ_4_STEP_CYCLES - 1 + && !self.frame_irq_inhibit + { + self.frame_irq_pending = true; + } + if self.frame_cycle >= seq_len { + self.frame_cycle = 0; + } + } + pub(crate) fn clock_half_frame(&mut self) { + self.clock_sweep(0, 0x01); + self.clock_sweep(1, 0x05); + + for chan in 0..4usize { + if self.length_counters[chan] == 0 { + continue; + } + if self.length_halt(chan) { + continue; + } + self.length_counters[chan] -= 1; + } + } + pub(crate) fn clock_quarter_frame(&mut self) { + self.clock_envelope(0, 0x00); + self.clock_envelope(1, 0x04); + self.clock_envelope(2, 0x0C); + + if self.triangle_linear_reload_flag { + self.triangle_linear_counter = self.io[0x08] & 0x7F; + } else if self.triangle_linear_counter > 0 { + self.triangle_linear_counter -= 1; + } + if (self.io[0x08] & 0x80) == 0 { + self.triangle_linear_reload_flag = false; + } + } + pub(crate) fn clock_envelope(&mut self, env_idx: usize, reg_idx: usize) { + let start_mask = 1u8 << env_idx; + let period = self.io[reg_idx] & 0x0F; + let loop_flag = (self.io[reg_idx] & 0x20) != 0; + if (self.envelope_start_flags & start_mask) != 0 { + self.envelope_start_flags &= !start_mask; + self.envelope_decay[env_idx] = 15; + self.envelope_divider[env_idx] = period; + return; + } + if self.envelope_divider[env_idx] == 0 { + self.envelope_divider[env_idx] = period; + if self.envelope_decay[env_idx] == 0 { + if loop_flag { + self.envelope_decay[env_idx] = 15; + } + } else { + self.envelope_decay[env_idx] -= 1; + } + } else { + self.envelope_divider[env_idx] -= 1; + } + } + pub(crate) fn clock_dmc(&mut self) { + if (self.channel_enable_mask & 0x10) == 0 { + return; + } + if self.dmc_cycle_counter == 0 { + self.dmc_cycle_counter = self.dmc_byte_period(); + } + self.dmc_cycle_counter = self.dmc_cycle_counter.saturating_sub(1); + if self.dmc_cycle_counter != 0 { + return; + } + self.dmc_cycle_counter = self.dmc_byte_period(); + + if !self.dmc_silence { + if (self.dmc_shift_reg & 0x01) != 0 { + self.dmc_output_level = self.dmc_output_level.saturating_add(2).min(127); + } else { + self.dmc_output_level = self.dmc_output_level.saturating_sub(2); + } + } + self.dmc_shift_reg >>= 1; + self.dmc_bits_remaining = self.dmc_bits_remaining.saturating_sub(1); + + if self.dmc_bits_remaining == 0 { + self.dmc_bits_remaining = 8; + if self.dmc_sample_buffer_valid { + self.dmc_shift_reg = self.dmc_sample_buffer; + self.dmc_sample_buffer_valid = false; + self.dmc_silence = false; + } else { + self.dmc_silence = true; + } + if self.dmc_bytes_remaining > 0 + && !self.dmc_sample_buffer_valid + && !self.dmc_dma_request + { + self.dmc_dma_request = true; + } + } + } + pub(crate) fn length_halt(&self, channel: usize) -> bool { + match channel { + 0 => (self.io[0x00] & 0x20) != 0, + 1 => (self.io[0x04] & 0x20) != 0, + 2 => (self.io[0x08] & 0x80) != 0, + 3 => (self.io[0x0C] & 0x20) != 0, + _ => true, + } + } + pub(crate) fn reload_length_counter(&mut self, channel: usize, length_index: u8) { + if channel >= self.length_counters.len() { + return; + } + if (self.channel_enable_mask & (1 << channel)) == 0 { + return; + } + self.length_counters[channel] = APU_LENGTH_TABLE[(length_index & 0x1F) as usize]; + } + pub(crate) fn dmc_sample_length_bytes(&self) -> u16 { + (self.io[0x13] as u16).saturating_mul(16).saturating_add(1) + } + pub(crate) fn dmc_sample_start_addr(&self) -> u16 { + 0xC000u16.saturating_add((self.io[0x12] as u16) << 6) + } + pub(crate) fn dmc_byte_period(&self) -> u16 { + let rate_idx = (self.io[0x10] & 0x0F) as usize; + APU_DMC_PERIOD_TABLE[rate_idx] + } + pub(crate) fn clock_sweep(&mut self, channel: usize, reg_idx: usize) { + let reload_mask = 1u8 << channel; + let sweep_reg = self.io[reg_idx]; + let enabled = (sweep_reg & 0x80) != 0; + let period = ((sweep_reg >> 4) & 0x07).saturating_add(1); + let shift = sweep_reg & 0x07; + let divider = self.sweep_divider[channel]; + let reload = (self.sweep_reload_flags & reload_mask) != 0; + let mute = self.sweep_mutes_channel(channel, reg_idx + 1); + + if divider == 0 && enabled && shift != 0 && !mute { + let target = self.sweep_target_period(channel, reg_idx + 1); + if target <= 0x07FF { + self.set_pulse_timer_period(channel, target as u16); + } + } + if divider == 0 || reload { + self.sweep_divider[channel] = period; + } else { + self.sweep_divider[channel] = divider.saturating_sub(1); + } + self.sweep_reload_flags &= !reload_mask; + } + pub(crate) fn sweep_target_period(&self, channel: usize, timer_lo_idx: usize) -> i32 { + let period = self.pulse_timer_period(timer_lo_idx) as i32; + let shift = (self.io[timer_lo_idx - 1] & 0x07) as i32; + if shift == 0 { + return period; + } + let change = period >> shift; + if (self.io[timer_lo_idx - 1] & 0x08) != 0 { + if channel == 0 { + period - change - 1 + } else { + period - change + } + } else { + period + change + } + } + pub(crate) fn sweep_mutes_channel(&self, channel: usize, timer_lo_idx: usize) -> bool { + let period = self.pulse_timer_period(timer_lo_idx); + period < 8 || self.sweep_target_period(channel, timer_lo_idx) > 0x07FF + } + pub(crate) fn pulse_timer_period(&self, timer_lo_idx: usize) -> u16 { + let lo = self.io[timer_lo_idx] as u16; + let hi = (self.io[timer_lo_idx + 1] as u16 & 0x07) << 8; + hi | lo + } + pub(crate) fn set_pulse_timer_period(&mut self, channel: usize, period: u16) { + let (timer_lo_idx, timer_hi_idx) = if channel == 0 { + (0x02, 0x03) + } else { + (0x06, 0x07) + }; + self.io[timer_lo_idx] = (period & 0x00FF) as u8; + self.io[timer_hi_idx] = (self.io[timer_hi_idx] & !0x07) | ((period >> 8) as u8 & 0x07); + } +} diff --git a/src/native_core/apu/types.rs b/src/native_core/apu/types.rs new file mode 100644 index 0000000..b3d9bf3 --- /dev/null +++ b/src/native_core/apu/types.rs @@ -0,0 +1,90 @@ +pub(super) const APU_FRAME_SEQ_4_STEP_CYCLES: u32 = 14_915; +pub(super) const APU_FRAME_SEQ_5_STEP_CYCLES: u32 = 18_641; +pub(super) const APU_QUARTER_FRAME_1: u32 = 3_729; +pub(super) const APU_QUARTER_FRAME_2: u32 = 7_457; +pub(super) const APU_QUARTER_FRAME_3: u32 = 11_186; +pub(super) const APU_QUARTER_FRAME_4: u32 = 14_915; +pub(super) const APU_QUARTER_FRAME_5STEP_4: u32 = 18_641; +pub(super) const APU_HALF_FRAME_1: u32 = 7_457; +pub(super) const APU_HALF_FRAME_2_4STEP: u32 = 14_915; +pub(super) const APU_HALF_FRAME_2_5STEP: u32 = 18_641; +pub(super) const APU_DMC_PERIOD_TABLE: [u16; 16] = [ + 428, 380, 340, 320, 286, 254, 226, 214, 190, 160, 142, 128, 106, 85, 72, 54, +]; +pub(super) const APU_LENGTH_TABLE: [u8; 32] = [ + 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, + 192, 24, 72, 26, 16, 28, 32, 30, +]; + +pub struct Apu { + pub(super) io: [u8; 0x20], + pub(crate) frame_cycle: u32, + pub(crate) frame_mode_5step: bool, + pub(crate) frame_irq_inhibit: bool, + pub(crate) frame_irq_pending: bool, + pub(crate) channel_enable_mask: u8, + pub(crate) length_counters: [u8; 4], + pub(crate) dmc_bytes_remaining: u16, + pub(crate) dmc_irq_enabled: bool, + pub(crate) dmc_irq_pending: bool, + pub(crate) dmc_cycle_counter: u16, + pub(crate) dmc_current_addr: u16, + pub(crate) dmc_sample_buffer: u8, + pub(crate) dmc_sample_buffer_valid: bool, + pub(crate) dmc_shift_reg: u8, + pub(crate) dmc_bits_remaining: u8, + pub(crate) dmc_silence: bool, + pub(crate) dmc_output_level: u8, + pub(crate) dmc_dma_request: bool, + pub(crate) envelope_divider: [u8; 3], + pub(crate) envelope_decay: [u8; 3], + pub(crate) envelope_start_flags: u8, + pub(crate) triangle_linear_counter: u8, + pub(crate) triangle_linear_reload_flag: bool, + pub(crate) sweep_divider: [u8; 2], + pub(crate) sweep_reload_flags: u8, + pub(crate) cpu_cycle_parity: bool, + pub(crate) frame_reset_pending: bool, + pub(crate) frame_reset_delay: u8, + pub(crate) pending_frame_mode_5step: bool, + pub(crate) pending_frame_irq_inhibit: bool, +} + +pub struct ApuStateTail { + pub frame_cycle: u32, + pub frame_mode_5step: bool, + pub frame_irq_inhibit: bool, + pub frame_irq_pending: bool, + pub channel_enable_mask: u8, + pub length_counters: [u8; 4], + pub dmc_bytes_remaining: u16, + pub dmc_irq_enabled: bool, + pub dmc_irq_pending: bool, + pub dmc_cycle_counter: u16, + pub dmc_current_addr: u16, + pub dmc_sample_buffer: u8, + pub dmc_sample_buffer_valid: bool, + pub dmc_shift_reg: u8, + pub dmc_bits_remaining: u8, + pub dmc_silence: bool, + pub dmc_output_level: u8, + pub dmc_dma_request: bool, + pub envelope_divider: [u8; 3], + pub envelope_decay: [u8; 3], + pub envelope_start_flags: u8, + pub triangle_linear_counter: u8, + pub triangle_linear_reload_flag: bool, + pub sweep_divider: [u8; 2], + pub sweep_reload_flags: u8, + pub cpu_cycle_parity: bool, + pub frame_reset_pending: bool, + pub frame_reset_delay: u8, + pub pending_frame_mode_5step: bool, + pub pending_frame_irq_inhibit: bool, +} + +impl Default for Apu { + fn default() -> Self { + Self::new() + } +} diff --git a/src/native_core/bus.rs b/src/native_core/bus.rs new file mode 100644 index 0000000..911d1a5 --- /dev/null +++ b/src/native_core/bus.rs @@ -0,0 +1,93 @@ +use crate::native_core::{apu::Apu, cpu::CpuBus, mapper::Mapper, ppu::Ppu}; + +const CPU_RAM_SIZE: usize = 0x0800; +const PPU_DOTS_PER_SCANLINE: u32 = 341; +const PPU_SCANLINES_PER_FRAME: u32 = 262; +const PPU_DOTS_PER_FRAME: u32 = PPU_DOTS_PER_SCANLINE * PPU_SCANLINES_PER_FRAME; +const PPU_VBLANK_START_SCANLINE: u32 = 241; +const PPU_PRERENDER_SCANLINE: u32 = 261; + +pub struct NativeBus { + cpu_ram: [u8; CPU_RAM_SIZE], + ppu: Ppu, + apu: Apu, + cpu_open_bus: u8, + joypad_state: u8, + joypad_shift: u8, + joypad2_state: u8, + joypad2_shift: u8, + joypad_strobe: bool, + nmi_pending: bool, + suppress_vblank_this_frame: bool, + ppu_dot: u32, + odd_frame: bool, + in_vblank: bool, + frame_complete: bool, + mmc3_a12_prev_high: bool, + mmc3_a12_low_dots: u16, + mmc3_last_irq_scanline: u32, + mapper: Box, +} + +impl NativeBus { + pub fn new(mapper: Box) -> Self { + Self { + cpu_ram: [0; CPU_RAM_SIZE], + ppu: Ppu::new(), + apu: Apu::new(), + cpu_open_bus: 0, + joypad_state: 0, + joypad_shift: 0, + joypad2_state: 0, + joypad2_shift: 0, + joypad_strobe: false, + nmi_pending: false, + suppress_vblank_this_frame: false, + ppu_dot: 0, + odd_frame: false, + in_vblank: false, + frame_complete: false, + mmc3_a12_prev_high: false, + mmc3_a12_low_dots: 8, + mmc3_last_irq_scanline: u32::MAX, + mapper, + } + } + + pub fn apu_registers(&self) -> &[u8; 0x20] { + self.apu.registers() + } + + pub fn render_frame(&self, out_rgba: &mut [u8], frame_number: u32, buttons: [bool; 8]) { + let _ = (frame_number, buttons); + let src = self.ppu.frame_buffer(); + if out_rgba.len() >= src.len() { + out_rgba[..src.len()].copy_from_slice(src); + } + } + + pub fn begin_frame(&mut self) { + self.frame_complete = false; + self.ppu.begin_frame(); + } + + pub fn take_frame_complete(&mut self) -> bool { + let out = self.frame_complete; + self.frame_complete = false; + out + } + + pub fn clock_cpu(&mut self, cycles: u8) { + self.clock_cpu_cycles(cycles as u32); + } +} + +// CpuBus trait implementation (memory map + side effects). +mod cpu_bus_impl; +mod joypad; +// Save-state serialization helpers. +mod state; +mod timing; + +#[cfg(test)] +mod tests; diff --git a/src/native_core/bus/cpu_bus_impl.rs b/src/native_core/bus/cpu_bus_impl.rs new file mode 100644 index 0000000..d9eec72 --- /dev/null +++ b/src/native_core/bus/cpu_bus_impl.rs @@ -0,0 +1,101 @@ +use super::*; +impl CpuBus for NativeBus { + fn read(&mut self, addr: u16) -> u8 { + let value = match addr { + 0x0000..=0x1FFF => self.cpu_ram[(addr as usize) & 0x07FF], + 0x2000..=0x3FFF => { + let reg = (addr as u8) & 7; + let scanline = self.ppu_dot / PPU_DOTS_PER_SCANLINE; + let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE; + let value = if reg == 4 { + let rendering_read_phase = self.ppu.rendering_enabled() + && (scanline < 240 || scanline == PPU_PRERENDER_SCANLINE) + && ((1..=256).contains(&dot) || (321..=340).contains(&dot)); + self.ppu.cpu_read_oamdata(rendering_read_phase) + } else { + let mapper: &(dyn Mapper + Send) = &*self.mapper; + self.ppu.cpu_read(reg, mapper) + }; + if reg == 2 { + if scanline == PPU_VBLANK_START_SCANLINE && dot == 1 { + self.suppress_vblank_this_frame = true; + } + // Reading PPUSTATUS clears VBlank; do not keep a stale NMI latched. + self.nmi_pending = false; + } + value + } + 0x4000..=0x4013 => self.cpu_open_bus, + 0x4015 => self.apu.read(addr), + 0x4016 => self.joypad_read(), + 0x4017 => self.joypad2_read(), + 0x6000..=0x7FFF => self.mapper.cpu_read_low(addr).unwrap_or(self.cpu_open_bus), + 0x8000..=0xFFFF => self.mapper.cpu_read(addr), + _ => self.cpu_open_bus, + }; + self.cpu_open_bus = value; + value + } + + fn write(&mut self, addr: u16, value: u8) { + self.cpu_open_bus = value; + match addr { + 0x0000..=0x1FFF => self.cpu_ram[(addr as usize) & 0x07FF] = value, + 0x2000..=0x3FFF => { + let reg = (addr as u8) & 7; + let nmi_was_enabled = self.ppu.nmi_enabled(); + { + let (ppu, mapper) = (&mut self.ppu, &mut self.mapper); + ppu.cpu_write(reg, value, &mut **mapper); + } + if reg == 0 + && !nmi_was_enabled + && self.ppu.nmi_enabled() + && self.ppu.vblank_flag_set() + { + self.nmi_pending = true; + } + if reg == 0 || reg == 5 { + self.note_scroll_write_now(); + } + } + 0x4000..=0x4013 => self.apu.write(addr, value), + 0x4015 => self.apu.write(addr, value), + 0x4017 => self.apu.write(addr, value), + 0x4014 => { + self.apu.write(0x4014, value); + let base = (value as u16) << 8; + let mut dma = [0u8; 256]; + for i in 0..=u8::MAX { + dma[i as usize] = self.dma_read(base.wrapping_add(i as u16)); + } + for byte in dma { + self.ppu.dma_write_oam(byte); + } + // OAM DMA stalls CPU for 513/514 cycles while PPU and mapper continue. + let cpu_phase = (self.ppu_dot / 3) & 1; + self.clock_cpu_cycles(513 + cpu_phase); + } + 0x4016 => self.joypad_write(value), + 0x6000..=0x7FFF => { + self.mapper.cpu_write_low(addr, value); + } + 0x8000..=0xFFFF => self.mapper.cpu_write(addr, value), + _ => {} + } + } + + fn poll_nmi(&mut self) -> bool { + let out = self.nmi_pending; + self.nmi_pending = false; + out + } + + fn poll_irq(&mut self) -> bool { + if self.apu.poll_irq() { + true + } else { + self.mapper.poll_irq() + } + } +} diff --git a/src/native_core/bus/joypad.rs b/src/native_core/bus/joypad.rs new file mode 100644 index 0000000..cdd1dc7 --- /dev/null +++ b/src/native_core/bus/joypad.rs @@ -0,0 +1,74 @@ +use super::NativeBus; + +impl NativeBus { + pub fn set_joypad_buttons(&mut self, buttons: [bool; 8]) { + let mut state = 0u8; + if buttons[4] { + state |= 1 << 0; // A + } + if buttons[5] { + state |= 1 << 1; // B + } + if buttons[7] { + state |= 1 << 2; // Select + } + if buttons[6] { + state |= 1 << 3; // Start + } + if buttons[0] { + state |= 1 << 4; // Up + } + if buttons[1] { + state |= 1 << 5; // Down + } + if buttons[2] { + state |= 1 << 6; // Left + } + if buttons[3] { + state |= 1 << 7; // Right + } + self.joypad_state = state; + self.joypad2_state = 0; + if self.joypad_strobe { + self.joypad_shift = self.joypad_state; + self.joypad2_shift = self.joypad2_state; + } + } + + pub(super) fn joypad_read(&mut self) -> u8 { + let bit = if self.joypad_strobe { + self.joypad_state & 1 + } else { + let bit = self.joypad_shift & 1; + self.joypad_shift = (self.joypad_shift >> 1) | 0x80; + bit + }; + self.format_controller_read(bit) + } + + pub(super) fn joypad2_read(&mut self) -> u8 { + let bit = if self.joypad_strobe { + self.joypad2_state & 1 + } else { + let bit = self.joypad2_shift & 1; + self.joypad2_shift = (self.joypad2_shift >> 1) | 0x80; + bit + }; + self.format_controller_read(bit) + } + + pub(super) fn joypad_write(&mut self, value: u8) { + let strobe = (value & 1) != 0; + self.joypad_strobe = strobe; + if strobe { + self.joypad_shift = self.joypad_state; + self.joypad2_shift = self.joypad2_state; + } + } + + fn format_controller_read(&self, bit: u8) -> u8 { + // Controller reads expose serial data in bit0, keep bit6 high, and + // preserve open-bus upper bits. + (self.cpu_open_bus & 0xE0) | 0x40 | (bit & 1) + } +} diff --git a/src/native_core/bus/state.rs b/src/native_core/bus/state.rs new file mode 100644 index 0000000..0848644 --- /dev/null +++ b/src/native_core/bus/state.rs @@ -0,0 +1,158 @@ +use super::*; +use crate::native_core::apu::ApuStateTail; +use crate::native_core::state_io as sio; + +const BUS_STATE_CTX: &str = "bus state"; + +impl NativeBus { + pub fn save_state(&self, out: &mut Vec) { + out.extend_from_slice(&self.cpu_ram); + self.ppu.save_state(out); + out.extend_from_slice(self.apu.registers()); + out.push(self.cpu_open_bus); + out.push(self.joypad_state); + out.push(self.joypad_shift); + out.push(self.joypad2_state); + out.push(self.joypad2_shift); + out.push(u8::from(self.joypad_strobe)); + out.push(u8::from(self.nmi_pending)); + out.extend_from_slice(&self.ppu_dot.to_le_bytes()); + out.push(u8::from(self.odd_frame)); + out.push(u8::from(self.in_vblank)); + out.push(u8::from(self.mmc3_a12_prev_high)); + out.extend_from_slice(&self.mmc3_a12_low_dots.to_le_bytes()); + out.extend_from_slice(&self.mmc3_last_irq_scanline.to_le_bytes()); + self.apu.save_state_tail(out); + + let mut mapper_state = Vec::new(); + self.mapper.save_state(&mut mapper_state); + out.extend_from_slice(&(mapper_state.len() as u32).to_le_bytes()); + out.extend_from_slice(&mapper_state); + } + + pub fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + let mut cursor = 0usize; + + self.cpu_ram.copy_from_slice(sio::take_exact( + data, + &mut cursor, + CPU_RAM_SIZE, + BUS_STATE_CTX, + )?); + let ppu_consumed = self.ppu.load_state(&data[cursor..])?; + cursor = cursor.saturating_add(ppu_consumed); + let mut apu_regs = [0u8; 0x20]; + apu_regs.copy_from_slice(sio::take_exact(data, &mut cursor, 0x20, BUS_STATE_CTX)?); + self.apu.set_registers(apu_regs); + self.frame_complete = false; + + self.cpu_open_bus = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + self.joypad_state = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + self.joypad_shift = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + self.joypad2_state = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + self.joypad2_shift = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + self.joypad_strobe = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + self.nmi_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + self.suppress_vblank_this_frame = false; + self.ppu_dot = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)?; + self.ppu_dot %= PPU_DOTS_PER_FRAME; + self.odd_frame = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + self.in_vblank = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + self.mmc3_a12_prev_high = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + self.mmc3_a12_low_dots = u16::from_le_bytes([ + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + ]); + self.mmc3_last_irq_scanline = u32::from_le_bytes([ + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + ]); + let frame_cycle = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)?; + let frame_mode_5step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let frame_irq_inhibit = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let frame_irq_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let channel_enable_mask = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let mut length_counters = [0u8; 4]; + length_counters.copy_from_slice(sio::take_exact(data, &mut cursor, 4, BUS_STATE_CTX)?); + let dmc_bytes_remaining = u16::from_le_bytes([ + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + ]); + let dmc_irq_enabled = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let dmc_irq_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let dmc_cycle_counter = u16::from_le_bytes([ + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + ]); + let dmc_current_addr = u16::from_le_bytes([ + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?, + ]); + let dmc_sample_buffer = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let dmc_sample_buffer_valid = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let dmc_shift_reg = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let dmc_bits_remaining = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let dmc_silence = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let dmc_output_level = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let dmc_dma_request = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let mut envelope_divider = [0u8; 3]; + envelope_divider.copy_from_slice(sio::take_exact(data, &mut cursor, 3, BUS_STATE_CTX)?); + let mut envelope_decay = [0u8; 3]; + envelope_decay.copy_from_slice(sio::take_exact(data, &mut cursor, 3, BUS_STATE_CTX)?); + let envelope_start_flags = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let triangle_linear_counter = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let triangle_linear_reload_flag = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let mut sweep_divider = [0u8; 2]; + sweep_divider.copy_from_slice(sio::take_exact(data, &mut cursor, 2, BUS_STATE_CTX)?); + let sweep_reload_flags = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let cpu_cycle_parity = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let frame_reset_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let frame_reset_delay = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; + let pending_frame_mode_5step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + let pending_frame_irq_inhibit = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0; + self.apu.load_state_tail(ApuStateTail { + frame_cycle, + frame_mode_5step, + frame_irq_inhibit, + frame_irq_pending, + channel_enable_mask, + length_counters, + dmc_bytes_remaining, + dmc_irq_enabled, + dmc_irq_pending, + dmc_cycle_counter, + dmc_current_addr, + dmc_sample_buffer, + dmc_sample_buffer_valid, + dmc_shift_reg, + dmc_bits_remaining, + dmc_silence, + dmc_output_level, + dmc_dma_request, + envelope_divider, + envelope_decay, + envelope_start_flags, + triangle_linear_counter, + triangle_linear_reload_flag, + sweep_divider, + sweep_reload_flags, + cpu_cycle_parity, + frame_reset_pending, + frame_reset_delay, + pending_frame_mode_5step, + pending_frame_irq_inhibit, + }); + let mapper_len = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)? as usize; + let mapper_state = sio::take_exact(data, &mut cursor, mapper_len, BUS_STATE_CTX)?; + + self.ppu.set_vblank(self.in_vblank); + self.mapper.load_state(mapper_state)?; + + if cursor != data.len() { + return Err("bus state: trailing bytes in payload".to_string()); + } + Ok(()) + } +} diff --git a/src/native_core/bus/tests.rs b/src/native_core/bus/tests.rs new file mode 100644 index 0000000..5a57272 --- /dev/null +++ b/src/native_core/bus/tests.rs @@ -0,0 +1,159 @@ +use super::{ + CpuBus, NativeBus, PPU_DOTS_PER_SCANLINE, PPU_PRERENDER_SCANLINE, PPU_VBLANK_START_SCANLINE, +}; +use crate::native_core::{ines::Mirroring, mapper::Mapper}; + +struct StubMapper; + +impl Mapper for StubMapper { + fn cpu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn ppu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn ppu_write(&mut self, _addr: u16, _value: u8) {} + + fn mirroring(&self) -> Mirroring { + Mirroring::Horizontal + } + + fn save_state(&self, _out: &mut Vec) {} + + fn load_state(&mut self, _data: &[u8]) -> Result<(), String> { + Ok(()) + } +} + +struct ScanlineIrqMapper { + irq_pending: bool, +} + +impl Mapper for ScanlineIrqMapper { + fn cpu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn ppu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn ppu_write(&mut self, _addr: u16, _value: u8) {} + + fn mirroring(&self) -> Mirroring { + Mirroring::Horizontal + } + + fn clock_scanline(&mut self) { + self.irq_pending = true; + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, _out: &mut Vec) {} + + fn load_state(&mut self, _data: &[u8]) -> Result<(), String> { + Ok(()) + } +} + +struct A12GatedMapper { + irq_pending: bool, +} + +impl Mapper for A12GatedMapper { + fn cpu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn ppu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn ppu_write(&mut self, _addr: u16, _value: u8) {} + + fn mirroring(&self) -> Mirroring { + Mirroring::Horizontal + } + + fn clock_scanline(&mut self) { + self.irq_pending = true; + } + + fn needs_ppu_a12_clock(&self) -> bool { + true + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, _out: &mut Vec) {} + + fn load_state(&mut self, _data: &[u8]) -> Result<(), String> { + Ok(()) + } +} + +struct A12CountMapper { + clocks: u8, +} + +impl Mapper for A12CountMapper { + fn cpu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn ppu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn ppu_write(&mut self, _addr: u16, _value: u8) {} + + fn mirroring(&self) -> Mirroring { + Mirroring::Horizontal + } + + fn clock_scanline(&mut self) { + self.clocks = self.clocks.saturating_add(1); + } + + fn needs_ppu_a12_clock(&self) -> bool { + true + } + + fn poll_irq(&mut self) -> bool { + if self.clocks == 0 { + false + } else { + self.clocks -= 1; + true + } + } + + fn save_state(&self, _out: &mut Vec) {} + + fn load_state(&mut self, _data: &[u8]) -> Result<(), String> { + Ok(()) + } +} + +mod apu; +mod mapper_timing; +mod ppu_open_bus; diff --git a/src/native_core/bus/tests/apu.rs b/src/native_core/bus/tests/apu.rs new file mode 100644 index 0000000..e635af2 --- /dev/null +++ b/src/native_core/bus/tests/apu.rs @@ -0,0 +1,314 @@ +use super::*; + +#[test] +fn apu_frame_irq_asserts_in_4_step_mode() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4017, 0x00); // 4-step, IRQ enabled + + for _ in 0..14_918u32 { + bus.clock_cpu(1); + } + + assert!(bus.poll_irq(), "APU frame IRQ should assert in 4-step mode"); +} + +#[test] +fn reading_4015_clears_apu_frame_irq_flag() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4017, 0x00); // 4-step, IRQ enabled + + for _ in 0..14_918u32 { + bus.clock_cpu(1); + } + + let status = bus.read(0x4015); + assert_ne!(status & 0x40, 0, "frame IRQ bit should be set in status"); + assert!(!bus.poll_irq(), "reading 4015 should clear frame IRQ"); +} + +#[test] +fn apu_frame_irq_inhibit_bit_disables_irq_and_clears_pending() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4017, 0x00); // 4-step, IRQ enabled + for _ in 0..14_918u32 { + bus.clock_cpu(1); + } + assert!(bus.poll_irq()); + + bus.write(0x4017, 0x40); // 4-step, IRQ inhibit + assert!( + !bus.poll_irq(), + "inhibit write should clear pending frame IRQ" + ); +} + +#[test] +fn writing_4015_does_not_acknowledge_apu_frame_irq() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4017, 0x00); // 4-step, IRQ enabled + for _ in 0..14_918u32 { + bus.clock_cpu(1); + } + assert!(bus.poll_irq(), "frame IRQ must be pending"); + + // Recreate pending frame IRQ and ensure $4015 write does not clear it. + for _ in 0..14_918u32 { + bus.clock_cpu(1); + } + bus.write(0x4015, 0x00); + assert!(bus.poll_irq(), "writing $4015 must not clear frame IRQ"); + + // Reading $4015 still acknowledges frame IRQ as expected. + let _ = bus.read(0x4015); + assert!(!bus.poll_irq(), "reading $4015 should clear frame IRQ"); +} + +#[test] +fn apu_5step_mode_does_not_generate_frame_irq() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4017, 0x80); // 5-step mode + + for _ in 0..20_000u32 { + bus.clock_cpu(1); + } + assert!(!bus.poll_irq(), "5-step mode must not assert frame IRQ"); +} + +#[test] +fn apu_write_only_register_reads_return_cpu_open_bus() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4000, 0x12); + bus.write(0x0000, 0xAB); + assert_eq!(bus.read(0x0000), 0xAB); + + assert_eq!(bus.read(0x4000), 0xAB); + assert_eq!(bus.read(0x400E), 0xAB); +} + +#[test] +fn writing_4017_in_5step_mode_clocks_half_frame_after_delay() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4015, 0x01); // enable pulse1 + bus.write(0x4000, 0x00); // length halt disabled + bus.write(0x4003, 0x18); // length index 3 => 2 + assert_eq!(bus.apu.length_counters[0], 2); + + bus.write(0x4017, 0x80); // switch to 5-step mode + assert_eq!(bus.apu.length_counters[0], 2); + for _ in 0..2u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.length_counters[0], 2); + bus.clock_cpu(1); // reset delay complete (3 CPU cycles on even phase) + assert_eq!(bus.apu.length_counters[0], 1); +} + +#[test] +fn state_roundtrip_preserves_apu_frame_counter_fields() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.apu.frame_cycle = 777; + bus.apu.frame_mode_5step = true; + bus.apu.frame_irq_inhibit = true; + bus.apu.frame_irq_pending = true; + bus.apu.channel_enable_mask = 0x1F; + bus.apu.length_counters = [1, 2, 3, 4]; + bus.apu.dmc_bytes_remaining = 99; + bus.apu.dmc_irq_enabled = true; + bus.apu.dmc_irq_pending = true; + bus.apu.dmc_cycle_counter = 1234; + bus.apu.envelope_divider = [9, 8, 7]; + bus.apu.envelope_decay = [6, 5, 4]; + bus.apu.envelope_start_flags = 0x05; + bus.apu.triangle_linear_counter = 3; + bus.apu.triangle_linear_reload_flag = true; + bus.apu.sweep_divider = [11, 12]; + bus.apu.sweep_reload_flags = 0x03; + bus.apu.cpu_cycle_parity = true; + bus.apu.frame_reset_pending = true; + bus.apu.frame_reset_delay = 2; + bus.apu.pending_frame_mode_5step = true; + bus.apu.pending_frame_irq_inhibit = false; + + let mut raw = Vec::new(); + bus.save_state(&mut raw); + + let mut restored = NativeBus::new(Box::new(StubMapper)); + restored.load_state(&raw).expect("state should load"); + assert_eq!(restored.apu.frame_cycle, 777); + assert!(restored.apu.frame_mode_5step); + assert!(restored.apu.frame_irq_inhibit); + assert!(restored.apu.frame_irq_pending); + assert_eq!(restored.apu.channel_enable_mask, 0x1F); + assert_eq!(restored.apu.length_counters, [1, 2, 3, 4]); + assert_eq!(restored.apu.dmc_bytes_remaining, 99); + assert!(restored.apu.dmc_irq_enabled); + assert!(restored.apu.dmc_irq_pending); + assert_eq!(restored.apu.dmc_cycle_counter, 1234); + assert_eq!(restored.apu.envelope_divider, [9, 8, 7]); + assert_eq!(restored.apu.envelope_decay, [6, 5, 4]); + assert_eq!(restored.apu.envelope_start_flags, 0x05); + assert_eq!(restored.apu.triangle_linear_counter, 3); + assert!(restored.apu.triangle_linear_reload_flag); + assert_eq!(restored.apu.sweep_divider, [11, 12]); + assert_eq!(restored.apu.sweep_reload_flags, 0x03); + assert!(restored.apu.cpu_cycle_parity); + assert!(restored.apu.frame_reset_pending); + assert_eq!(restored.apu.frame_reset_delay, 2); + assert!(restored.apu.pending_frame_mode_5step); + assert!(!restored.apu.pending_frame_irq_inhibit); +} + +#[test] +fn apu_status_reflects_length_counters_and_disable_clears_them() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4015, 0x0F); // enable pulse1/pulse2/triangle/noise + bus.write(0x4003, 0xF8); // load pulse1 length index 31 + bus.write(0x4007, 0xF8); // load pulse2 + bus.write(0x400B, 0xF8); // load triangle + bus.write(0x400F, 0xF8); // load noise + + let status = bus.read(0x4015); + assert_eq!(status & 0x0F, 0x0F); + + bus.write(0x4015, 0x00); + let status2 = bus.read(0x4015); + assert_eq!(status2 & 0x0F, 0x00); +} + +#[test] +fn apu_length_counter_decrements_on_half_frame_when_not_halted() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4015, 0x01); // enable pulse1 + bus.write(0x4000, 0x00); // halt=0 + bus.write(0x4003, 0x18); // length index 3 => value 2 + + assert_eq!(bus.apu.length_counters[0], 2); + for _ in 0..7_457u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.length_counters[0], 1); + for _ in 0..7_458u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.length_counters[0], 0); +} + +#[test] +fn dmc_irq_raises_and_is_reported_in_4015_status() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4010, 0x8F); // IRQ enable, no loop, fastest rate + bus.write(0x4013, 0x00); // sample length = 1 byte + bus.write(0x4015, 0x10); // enable DMC + + for _ in 0..54u32 { + bus.clock_cpu(1); + } + assert!(bus.poll_irq()); + + let status = bus.read(0x4015); + assert_ne!(status & 0x80, 0, "DMC IRQ should be visible in status"); + assert!(bus.poll_irq(), "status read must not clear DMC IRQ"); + bus.write(0x4015, 0x10); + assert!(!bus.poll_irq(), "writing 4015 acknowledges DMC IRQ"); +} + +#[test] +fn quarter_frame_clocks_triangle_linear_counter() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4008, 0x05); // control=0, reload value=5 + bus.write(0x400B, 0x00); // set reload flag + + for _ in 0..3_729u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.triangle_linear_counter, 5); + assert!(!bus.apu.triangle_linear_reload_flag); + + for _ in 0..3_728u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.triangle_linear_counter, 4); +} + +#[test] +fn quarter_frame_envelope_start_reloads_decay() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4015, 0x01); // enable pulse1 + bus.write(0x4000, 0x03); // envelope period=3 + bus.write(0x4003, 0x00); // start envelope + assert_ne!(bus.apu.envelope_start_flags & 0x01, 0); + + for _ in 0..3_729u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.envelope_decay[0], 15); + assert_eq!(bus.apu.envelope_divider[0], 3); + assert_eq!(bus.apu.envelope_start_flags & 0x01, 0); +} + +#[test] +fn sweep_half_frame_updates_pulse_timer_period() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4002, 0x00); // timer low + bus.write(0x4003, 0x02); // timer high => period 0x200 + bus.write(0x4001, 0x82); // enable, period=1, negate=0, shift=2 + + for _ in 0..7_457u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.read(0x4002), 0x80); + assert_eq!(bus.apu.read(0x4003) & 0x07, 0x02); +} + +#[test] +fn sweep_negative_pulse1_uses_ones_complement() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4002, 0x00); // period 0x200 + bus.write(0x4003, 0x02); + bus.write(0x4001, 0x8A); // enable, period=1, negate=1, shift=2 + + for _ in 0..7_457u32 { + bus.clock_cpu(1); + } + assert_eq!(bus.apu.read(0x4002), 0x7F); + assert_eq!(bus.apu.read(0x4003) & 0x07, 0x01); +} + +#[test] +fn dmc_dma_fetches_sample_bytes_and_steals_cpu_cycles() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4010, 0x0F); // no IRQ, no loop, fastest period + bus.write(0x4012, 0x00); // sample start $C000 + bus.write(0x4013, 0x00); // sample length = 1 byte + bus.write(0x4015, 0x10); // enable DMC (issues initial DMA request) + + assert_eq!(bus.ppu_dot, 0); + bus.clock_cpu(1); + + // 1 CPU cycle + 4-cycle DMA steal = 5 total CPU cycles => 15 PPU dots. + assert_eq!(bus.ppu_dot, 15); + assert_eq!(bus.apu.dmc_bytes_remaining, 0); + assert_eq!(bus.apu.dmc_current_addr, 0xC001); + assert!(bus.apu.dmc_sample_buffer_valid); +} + +#[test] +fn dmc_playback_updates_output_level_from_sample_bits() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4011, 0x20); // initial DMC DAC level + bus.write(0x4010, 0x0F); // fastest DMC rate + bus.write(0x4012, 0x00); // sample start $C000 + bus.write(0x4013, 0x00); // 1-byte sample + bus.write(0x4015, 0x10); // enable DMC + + // Service initial DMA request. + bus.clock_cpu(1); + let initial = bus.apu.dmc_output_level; + + // Stub mapper returns 0x00 sample byte, so each played bit drives output down by 2. + for _ in 0..600u32 { + bus.clock_cpu(1); + } + + assert!(bus.apu.dmc_output_level < initial); +} diff --git a/src/native_core/bus/tests/mapper_timing.rs b/src/native_core/bus/tests/mapper_timing.rs new file mode 100644 index 0000000..21bb9d7 --- /dev/null +++ b/src/native_core/bus/tests/mapper_timing.rs @@ -0,0 +1,72 @@ +use super::*; + +#[test] +fn prerender_scanline_still_clocks_mapper_scanline_irq() { + let mut bus = NativeBus::new(Box::new(ScanlineIrqMapper { irq_pending: false })); + bus.write(0x2001, 0x18); // enable rendering + + bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE + 259; + bus.clock_ppu_dot(); // now at dot 260 + assert!(bus.poll_irq()); +} + +#[test] +fn mmc3_class_scanline_clock_is_suppressed_when_a12_has_no_activity() { + let mut bus = NativeBus::new(Box::new(A12GatedMapper { irq_pending: false })); + bus.write(0x2001, 0x18); // BG+sprites on + bus.write(0x2000, 0x00); // BG table $0000, sprite table $0000, 8x8 sprites + + bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE; + for _ in 0..120 { + bus.clock_ppu_dot(); + } + assert!(!bus.poll_irq()); +} + +#[test] +fn mmc3_class_scanline_clock_runs_when_pattern_table_uses_a12() { + let mut bus = NativeBus::new(Box::new(A12GatedMapper { irq_pending: false })); + bus.write(0x2001, 0x18); // BG+sprites on + bus.write(0x2000, 0x10); // BG pattern table $1000 + + bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE; + for _ in 0..120 { + bus.clock_ppu_dot(); + } + assert!(bus.poll_irq()); +} + +#[test] +fn mmc3_class_a12_filter_clocks_once_per_scanline() { + let mut bus = NativeBus::new(Box::new(A12CountMapper { clocks: 0 })); + bus.write(0x2001, 0x08); // BG on + bus.write(0x2000, 0x10); // BG pattern table $1000 + + bus.ppu_dot = 30 * PPU_DOTS_PER_SCANLINE; + for _ in 0..PPU_DOTS_PER_SCANLINE { + bus.clock_ppu_dot(); + } + + let mut count = 0u8; + while bus.poll_irq() { + count = count.saturating_add(1); + } + assert_eq!(count, 1); +} + +#[test] +fn state_roundtrip_preserves_mmc3_a12_timing_fields() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.mmc3_a12_prev_high = true; + bus.mmc3_a12_low_dots = 3; + bus.mmc3_last_irq_scanline = 123; + + let mut raw = Vec::new(); + bus.save_state(&mut raw); + + let mut restored = NativeBus::new(Box::new(StubMapper)); + restored.load_state(&raw).expect("state should load"); + assert!(restored.mmc3_a12_prev_high); + assert_eq!(restored.mmc3_a12_low_dots, 3); + assert_eq!(restored.mmc3_last_irq_scanline, 123); +} diff --git a/src/native_core/bus/tests/ppu_open_bus.rs b/src/native_core/bus/tests/ppu_open_bus.rs new file mode 100644 index 0000000..b45611a --- /dev/null +++ b/src/native_core/bus/tests/ppu_open_bus.rs @@ -0,0 +1,191 @@ +use super::*; + +#[test] +fn reading_ppustatus_clears_latched_nmi_request() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2000, 0x80); // enable NMI on VBlank + + for _ in 0..100_000usize { + bus.clock_cpu(1); + if bus.nmi_pending { + break; + } + } + assert!(bus.nmi_pending, "vblank NMI should have latched"); + + let _ = bus.read(0x2002); + assert!(!bus.nmi_pending, "status read should clear pending NMI"); + assert!( + !bus.poll_nmi(), + "CPU should not observe stale NMI after status read" + ); +} + +#[test] +fn sprite_overflow_flag_is_set_during_rendering_and_cleared_prerender() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2001, 0x18); // enable BG + sprites rendering + + // Initialize OAM offscreen, then place 9 sprites on scanline 20. + bus.write(0x2003, 0x00); + for _ in 0..256usize { + bus.write(0x2004, 0xFF); + } + bus.write(0x2003, 0x00); + for _ in 0..9usize { + bus.write(0x2004, 19); // Y (sprite appears at Y+1 = 20) + bus.write(0x2004, 0); // tile + bus.write(0x2004, 0); // attr + bus.write(0x2004, 0); // X + } + + bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 256; + bus.clock_ppu_dot(); // dot 257 -> overflow evaluation + assert!(bus.ppu.sprite_overflow_set()); + + bus.in_vblank = true; + bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE; + bus.clock_ppu_dot(); // prerender dot 1 -> clear status flags + assert!(!bus.ppu.sprite_overflow_set()); +} + +#[test] +fn sprite_overflow_latches_until_prerender_clear() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2001, 0x18); // rendering enabled + + // Populate first 9 sprites on scanline 20, others offscreen. + bus.write(0x2003, 0x00); + for _ in 0..256usize { + bus.write(0x2004, 0xFF); + } + bus.write(0x2003, 0x00); + for _ in 0..9usize { + bus.write(0x2004, 19); // Y => visible on scanline 20 + bus.write(0x2004, 0); + bus.write(0x2004, 0); + bus.write(0x2004, 0); + } + + bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 256; + bus.clock_ppu_dot(); // scanline 20 dot 257 + assert!(bus.ppu.sprite_overflow_set()); + + // Move to a scanline with no overflow and ensure bit stays latched. + bus.ppu_dot = 100 * PPU_DOTS_PER_SCANLINE + 256; + bus.clock_ppu_dot(); + assert!(bus.ppu.sprite_overflow_set()); +} + +#[test] +fn sprite_overflow_not_evaluated_when_sprites_disabled() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2001, 0x08); // BG enabled, sprites disabled + + bus.write(0x2003, 0x00); + for _ in 0..256usize { + bus.write(0x2004, 0xFF); + } + bus.write(0x2003, 0x00); + for _ in 0..9usize { + bus.write(0x2004, 19); + bus.write(0x2004, 0); + bus.write(0x2004, 0); + bus.write(0x2004, 0); + } + + bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 256; + bus.clock_ppu_dot(); + assert!(!bus.ppu.sprite_overflow_set()); +} + +#[test] +fn odd_frame_skips_one_ppu_dot_when_rendering_enabled() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2001, 0x18); // rendering enabled + + let mut dots = 0u32; + while !bus.frame_complete { + bus.clock_ppu_dot(); + dots += 1; + } + assert_eq!(dots, 341 * 262); // even frame + bus.frame_complete = false; + + let mut odd_frame_dots = 0u32; + while !bus.frame_complete { + bus.clock_ppu_dot(); + odd_frame_dots += 1; + } + assert_eq!(odd_frame_dots, 341 * 262 - 1); // odd frame skip +} + +#[test] +fn unmapped_cpu_reads_return_open_bus_value() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + + bus.write(0x0000, 0xAB); // write puts value on CPU bus + assert_eq!(bus.read(0x4018), 0xAB); // unmapped APU/test range + + bus.write(0x0001, 0xCD); + assert_eq!(bus.read(0x4FFF), 0xCD); // unmapped expansion range +} + +#[test] +fn joypad_read_preserves_open_bus_upper_bits_and_sets_bit6() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.set_joypad_buttons([false, false, false, false, true, false, false, false]); // A pressed + bus.write(0x4016, 0xA1); // strobe on + seed open bus high bits + let v = bus.read(0x4016); + assert_eq!(v & 0x01, 1); + assert_eq!(v & 0x40, 0x40); + assert_eq!(v & 0xE0, 0xE0); +} + +#[test] +fn ppustatus_read_at_vblank_edge_suppresses_vblank_for_that_frame() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2000, 0x80); // enable NMI + + // Position exactly at vblank set point (scanline 241 dot 1). + bus.ppu_dot = PPU_VBLANK_START_SCANLINE * PPU_DOTS_PER_SCANLINE + 1; + let _ = bus.read(0x2002); // status read at dot 1 suppresses vblank for this frame + bus.clock_ppu_dot(); // dot 2 on scanline 241 + + assert!(!bus.in_vblank); + assert!(!bus.nmi_pending); + let status = bus.read(0x2002); + assert_eq!(status & 0x80, 0); +} + +#[test] +fn prerender_clears_status_flags_even_if_not_in_vblank() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.in_vblank = false; + bus.ppu.set_vblank(true); + bus.ppu.set_sprite0_hit(true); + bus.ppu.set_sprite_overflow(true); + + bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE; + bus.clock_ppu_dot(); // prerender dot 1 + + let status = bus.read(0x2002); + assert_eq!(status & 0xE0, 0); +} + +#[test] +fn oamdata_read_returns_ff_during_active_rendering() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x2001, 0x18); // rendering enabled + bus.write(0x2003, 0x10); + bus.write(0x2004, 0x22); + bus.write(0x2003, 0x10); + + bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 100; + let v = bus.read(0x2004); + assert_eq!(v, 0xFF); + + bus.ppu_dot = PPU_VBLANK_START_SCANLINE * PPU_DOTS_PER_SCANLINE + 10; + let v2 = bus.read(0x2004); + assert_eq!(v2, 0x22); +} diff --git a/src/native_core/bus/timing.rs b/src/native_core/bus/timing.rs new file mode 100644 index 0000000..94d4aa7 --- /dev/null +++ b/src/native_core/bus/timing.rs @@ -0,0 +1,116 @@ +use super::{ + NativeBus, PPU_DOTS_PER_FRAME, PPU_DOTS_PER_SCANLINE, PPU_PRERENDER_SCANLINE, + PPU_VBLANK_START_SCANLINE, +}; +use crate::native_core::cpu::CpuBus; +use crate::native_core::mapper::Mapper; + +impl NativeBus { + fn clock_one_cpu_cycle(&mut self) { + for _ in 0..3 { + self.clock_ppu_dot(); + } + self.mapper.clock_cpu(1); + self.apu.clock_cpu_cycle(); + } + + fn service_dmc_dma(&mut self, addr: u16) { + let byte = self.dma_read(addr); + self.apu.provide_dmc_dma_byte(byte); + // DMC DMA steals CPU bus cycles while APU/PPU/mapper keep ticking. + for _ in 0..4 { + self.clock_one_cpu_cycle(); + } + } + + pub(super) fn dma_read(&mut self, addr: u16) -> u8 { + ::read(self, addr) + } + + pub(super) fn clock_ppu_dot(&mut self) { + self.ppu_dot += 1; + if self.ppu_dot >= PPU_DOTS_PER_FRAME { + self.ppu_dot = 0; + self.frame_complete = true; + self.odd_frame = !self.odd_frame; + } + + let scanline = self.ppu_dot / PPU_DOTS_PER_SCANLINE; + let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE; + let rendering_enabled = self.ppu.rendering_enabled(); + + { + let mapper: &(dyn Mapper + Send) = &*self.mapper; + self.ppu.render_dot(mapper, scanline, dot); + } + + if rendering_enabled && (scanline < 240 || scanline == PPU_PRERENDER_SCANLINE) { + if self.mapper.needs_ppu_a12_clock() { + let a12_high = self.ppu.mmc3_a12_high_at(scanline, dot); + if a12_high { + if !self.mmc3_a12_prev_high + && self.mmc3_a12_low_dots >= 8 + && self.mmc3_last_irq_scanline != scanline + { + self.mapper.clock_scanline(); + self.mmc3_last_irq_scanline = scanline; + } + self.mmc3_a12_prev_high = true; + self.mmc3_a12_low_dots = 0; + } else { + self.mmc3_a12_prev_high = false; + self.mmc3_a12_low_dots = self.mmc3_a12_low_dots.saturating_add(1); + } + } else if dot == 260 { + self.mapper.clock_scanline(); + } + } else { + self.mmc3_a12_prev_high = false; + self.mmc3_a12_low_dots = self.mmc3_a12_low_dots.saturating_add(1); + } + if rendering_enabled && scanline == PPU_PRERENDER_SCANLINE && dot == 339 && self.odd_frame { + // NTSC odd frame timing: skip pre-render dot 340 when rendering is enabled. + self.ppu_dot = self.ppu_dot.saturating_add(1); + } + + if !self.in_vblank && scanline == PPU_VBLANK_START_SCANLINE && dot == 1 { + if self.suppress_vblank_this_frame { + self.suppress_vblank_this_frame = false; + self.in_vblank = false; + self.ppu.set_vblank(false); + } else { + self.in_vblank = true; + self.ppu.set_vblank(true); + if self.ppu.nmi_enabled() { + self.nmi_pending = true; + } + } + } else if scanline == PPU_PRERENDER_SCANLINE && dot == 1 { + self.in_vblank = false; + self.ppu.set_vblank(false); + self.ppu.set_sprite0_hit(false); + self.ppu.set_sprite_overflow(false); + self.suppress_vblank_this_frame = false; + } + } + + pub(super) fn clock_cpu_cycles(&mut self, cycles: u32) { + if cycles == 0 { + return; + } + let mut remaining = cycles; + while remaining != 0 { + self.clock_one_cpu_cycle(); + while let Some(addr) = self.apu.take_dmc_dma_request() { + self.service_dmc_dma(addr); + } + remaining -= 1; + } + } + + pub(super) fn note_scroll_write_now(&mut self) { + let scanline = (self.ppu_dot / PPU_DOTS_PER_SCANLINE) as usize; + let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE; + self.ppu.note_scroll_register_write(scanline, dot); + } +} diff --git a/src/native_core/cpu.rs b/src/native_core/cpu.rs new file mode 100644 index 0000000..9886e60 --- /dev/null +++ b/src/native_core/cpu.rs @@ -0,0 +1,112 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CpuError { + UnsupportedOpcode { opcode: u8, pc: u16 }, +} + +pub trait CpuBus { + fn read(&mut self, addr: u16) -> u8; + fn write(&mut self, addr: u16, value: u8); + fn poll_nmi(&mut self) -> bool { + false + } + fn poll_irq(&mut self) -> bool { + false + } +} + +const FLAG_CARRY: u8 = 0b0000_0001; +const FLAG_ZERO: u8 = 0b0000_0010; +const FLAG_IRQ_DISABLE: u8 = 0b0000_0100; +const FLAG_DECIMAL: u8 = 0b0000_1000; +const FLAG_BREAK: u8 = 0b0001_0000; +const FLAG_UNUSED: u8 = 0b0010_0000; +const FLAG_OVERFLOW: u8 = 0b0100_0000; +const FLAG_NEGATIVE: u8 = 0b1000_0000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cpu6502 { + pub a: u8, + pub x: u8, + pub y: u8, + pub sp: u8, + pub pc: u16, + pub p: u8, + pub halted: bool, + pub irq_delay: bool, + pub pending_nmi: bool, + pub pending_irq: bool, +} + +impl Default for Cpu6502 { + fn default() -> Self { + Self { + a: 0, + x: 0, + y: 0, + sp: 0xFD, + pc: 0, + p: FLAG_IRQ_DISABLE | FLAG_UNUSED, + halted: false, + irq_delay: false, + pending_nmi: false, + pending_irq: false, + } + } +} + +impl Cpu6502 { + pub fn reset(&mut self, bus: &mut B) { + let lo = bus.read(0xFFFC) as u16; + let hi = bus.read(0xFFFD) as u16; + self.pc = (hi << 8) | lo; + self.sp = self.sp.wrapping_sub(3); + self.p = (self.p | FLAG_IRQ_DISABLE | FLAG_UNUSED) & !FLAG_BREAK; + self.halted = false; + self.irq_delay = false; + self.pending_nmi = false; + self.pending_irq = false; + } + + pub fn step(&mut self, bus: &mut B) -> Result { + if self.halted { + return Ok(2); + } + if self.pending_nmi { + self.pending_nmi = false; + self.pending_irq = false; + self.service_interrupt(bus, 0xFFFA, false); + self.p |= FLAG_UNUSED; + return Ok(7); + } + let irq_delayed = self.irq_delay; + self.irq_delay = false; + if self.pending_irq && !irq_delayed && (self.p & FLAG_IRQ_DISABLE) == 0 { + self.pending_irq = false; + self.service_interrupt(bus, 0xFFFE, false); + self.p |= FLAG_UNUSED; + return Ok(7); + } + + let pc_before = self.pc; + let opcode = self.fetch(bus); + let cycles = self.execute_opcode(bus, opcode, pc_before)?; + + // Real 6502 polls IRQ/NMI near the end of the current instruction and + // services it before the next opcode fetch. + if bus.poll_nmi() { + self.pending_nmi = true; + self.pending_irq = false; + } else if !irq_delayed && (self.p & FLAG_IRQ_DISABLE) == 0 && bus.poll_irq() { + self.pending_irq = true; + } + Ok(cycles) + } +} + +// Addressing modes, ALU helpers, stack and interrupt internals. +mod helpers; +// Opcode decoder/executor table split out from cpu.rs for readability. +mod opcodes; + +#[cfg(test)] +mod tests; diff --git a/src/native_core/cpu/helpers.rs b/src/native_core/cpu/helpers.rs new file mode 100644 index 0000000..dbeb096 --- /dev/null +++ b/src/native_core/cpu/helpers.rs @@ -0,0 +1,245 @@ +use super::*; + +impl Cpu6502 { + pub(super) fn fetch(&mut self, bus: &mut B) -> u8 { + let value = bus.read(self.pc); + self.pc = self.pc.wrapping_add(1); + value + } + + pub(super) fn fetch_u16(&mut self, bus: &mut B) -> u16 { + let lo = self.fetch(bus) as u16; + let hi = self.fetch(bus) as u16; + (hi << 8) | lo + } + + pub(super) fn read_u16(&mut self, bus: &mut B, addr: u16) -> u16 { + let lo = bus.read(addr) as u16; + let hi = bus.read(addr.wrapping_add(1)) as u16; + (hi << 8) | lo + } + + pub(super) fn read_u16_bug(&mut self, bus: &mut B, addr: u16) -> u16 { + let lo = bus.read(addr) as u16; + let hi_addr = (addr & 0xFF00) | (addr.wrapping_add(1) & 0x00FF); + let hi = bus.read(hi_addr) as u16; + (hi << 8) | lo + } + + pub(super) fn addr_zp(&mut self, bus: &mut B) -> u16 { + self.fetch(bus) as u16 + } + + pub(super) fn addr_zpx(&mut self, bus: &mut B) -> u16 { + self.fetch(bus).wrapping_add(self.x) as u16 + } + + pub(super) fn addr_zpy(&mut self, bus: &mut B) -> u16 { + self.fetch(bus).wrapping_add(self.y) as u16 + } + + pub(super) fn addr_abs(&mut self, bus: &mut B) -> u16 { + self.fetch_u16(bus) + } + + pub(super) fn addr_abx(&mut self, bus: &mut B) -> u16 { + self.addr_abx_cross(bus).0 + } + + pub(super) fn addr_aby(&mut self, bus: &mut B) -> u16 { + self.addr_aby_cross(bus).0 + } + + pub(super) fn addr_indx(&mut self, bus: &mut B) -> u16 { + let ptr = self.fetch(bus).wrapping_add(self.x); + let lo = bus.read(ptr as u16) as u16; + let hi = bus.read(ptr.wrapping_add(1) as u16) as u16; + (hi << 8) | lo + } + + pub(super) fn addr_indy(&mut self, bus: &mut B) -> u16 { + self.addr_indy_cross(bus).0 + } + + pub(super) fn addr_abx_cross(&mut self, bus: &mut B) -> (u16, bool) { + let base = self.fetch_u16(bus); + let addr = base.wrapping_add(self.x as u16); + (addr, (base & 0xFF00) != (addr & 0xFF00)) + } + + pub(super) fn addr_aby_cross(&mut self, bus: &mut B) -> (u16, bool) { + let base = self.fetch_u16(bus); + let addr = base.wrapping_add(self.y as u16); + (addr, (base & 0xFF00) != (addr & 0xFF00)) + } + + pub(super) fn addr_indy_cross(&mut self, bus: &mut B) -> (u16, bool) { + let ptr = self.fetch(bus); + let lo = bus.read(ptr as u16) as u16; + let hi = bus.read(ptr.wrapping_add(1) as u16) as u16; + let base = (hi << 8) | lo; + let addr = base.wrapping_add(self.y as u16); + (addr, (base & 0xFF00) != (addr & 0xFF00)) + } + + pub(super) fn read_zp(&mut self, bus: &mut B) -> u8 { + let addr = self.addr_zp(bus); + bus.read(addr) + } + + pub(super) fn read_zpx(&mut self, bus: &mut B) -> u8 { + let addr = self.addr_zpx(bus); + bus.read(addr) + } + + pub(super) fn read_zpy(&mut self, bus: &mut B) -> u8 { + let addr = self.addr_zpy(bus); + bus.read(addr) + } + + pub(super) fn read_abs(&mut self, bus: &mut B) -> u8 { + let addr = self.addr_abs(bus); + bus.read(addr) + } + + pub(super) fn read_abx_cross(&mut self, bus: &mut B) -> (u8, bool) { + let (addr, crossed) = self.addr_abx_cross(bus); + (bus.read(addr), crossed) + } + + pub(super) fn read_aby_cross(&mut self, bus: &mut B) -> (u8, bool) { + let (addr, crossed) = self.addr_aby_cross(bus); + (bus.read(addr), crossed) + } + + pub(super) fn read_indx(&mut self, bus: &mut B) -> u8 { + let addr = self.addr_indx(bus); + bus.read(addr) + } + + pub(super) fn read_indy_cross(&mut self, bus: &mut B) -> (u8, bool) { + let (addr, crossed) = self.addr_indy_cross(bus); + (bus.read(addr), crossed) + } + + pub(super) fn push(&mut self, bus: &mut B, value: u8) { + let addr = 0x0100 | self.sp as u16; + bus.write(addr, value); + self.sp = self.sp.wrapping_sub(1); + } + + pub(super) fn pop(&mut self, bus: &mut B) -> u8 { + self.sp = self.sp.wrapping_add(1); + let addr = 0x0100 | self.sp as u16; + bus.read(addr) + } + + pub(super) fn set_zn(&mut self, value: u8) { + if value == 0 { + self.p |= FLAG_ZERO; + } else { + self.p &= !FLAG_ZERO; + } + if (value & 0x80) != 0 { + self.p |= FLAG_NEGATIVE; + } else { + self.p &= !FLAG_NEGATIVE; + } + } + + pub(super) fn set_flag(&mut self, mask: u8, enabled: bool) { + if enabled { + self.p |= mask; + } else { + self.p &= !mask; + } + } + + pub(super) fn compare(&mut self, lhs: u8, rhs: u8) { + let result = lhs.wrapping_sub(rhs); + self.set_flag(FLAG_CARRY, lhs >= rhs); + self.set_zn(result); + } + + pub(super) fn bit(&mut self, value: u8) { + self.set_flag(FLAG_ZERO, (self.a & value) == 0); + self.set_flag(FLAG_OVERFLOW, (value & 0x40) != 0); + self.set_flag(FLAG_NEGATIVE, (value & 0x80) != 0); + } + + pub(super) fn adc(&mut self, value: u8) { + let carry = u16::from((self.p & FLAG_CARRY) != 0); + let a = self.a as u16; + let b = value as u16; + let sum = a + b + carry; + let result = sum as u8; + + self.set_flag(FLAG_CARRY, sum > 0xFF); + let overflow = ((self.a ^ result) & (value ^ result) & 0x80) != 0; + self.set_flag(FLAG_OVERFLOW, overflow); + + self.a = result; + self.set_zn(self.a); + } + + pub(super) fn asl(&mut self, value: u8) -> u8 { + self.set_flag(FLAG_CARRY, (value & 0x80) != 0); + let out = value << 1; + self.set_zn(out); + out + } + + pub(super) fn lsr(&mut self, value: u8) -> u8 { + self.set_flag(FLAG_CARRY, (value & 0x01) != 0); + let out = value >> 1; + self.set_zn(out); + out + } + + pub(super) fn rol(&mut self, value: u8) -> u8 { + let carry_in = u8::from((self.p & FLAG_CARRY) != 0); + self.set_flag(FLAG_CARRY, (value & 0x80) != 0); + let out = (value << 1) | carry_in; + self.set_zn(out); + out + } + + pub(super) fn ror(&mut self, value: u8) -> u8 { + let carry_in = if (self.p & FLAG_CARRY) != 0 { 0x80 } else { 0 }; + self.set_flag(FLAG_CARRY, (value & 0x01) != 0); + let out = (value >> 1) | carry_in; + self.set_zn(out); + out + } + + pub(super) fn branch(&mut self, bus: &mut B, condition: bool) -> u8 { + let offset = self.fetch(bus) as i8; + if !condition { + return 2; + } + let old_pc = self.pc; + self.pc = self.pc.wrapping_add_signed(offset as i16); + if (old_pc & 0xFF00) != (self.pc & 0xFF00) { + 4 + } else { + 3 + } + } + + pub(super) fn service_interrupt( + &mut self, + bus: &mut B, + vector_addr: u16, + break_flag: bool, + ) { + self.push(bus, (self.pc >> 8) as u8); + self.push(bus, self.pc as u8); + let mut status = (self.p | FLAG_UNUSED) & !FLAG_BREAK; + if break_flag { + status |= FLAG_BREAK; + } + self.push(bus, status); + self.p = (self.p | FLAG_IRQ_DISABLE | FLAG_UNUSED) & !FLAG_BREAK; + self.pc = self.read_u16(bus, vector_addr); + } +} diff --git a/src/native_core/cpu/opcodes/mod.rs b/src/native_core/cpu/opcodes/mod.rs new file mode 100644 index 0000000..7ed5d70 --- /dev/null +++ b/src/native_core/cpu/opcodes/mod.rs @@ -0,0 +1,29 @@ +use super::*; + +mod official; +mod ops; +mod undocumented; + +impl Cpu6502 { + pub(super) fn execute_opcode( + &mut self, + bus: &mut B, + opcode: u8, + pc_before: u16, + ) -> Result { + if let Some(cycles) = self.execute_undocumented(bus, opcode, pc_before) { + self.p |= FLAG_UNUSED; + return Ok(cycles); + } + + if let Some(cycles) = self.execute_official(bus, opcode) { + self.p |= FLAG_UNUSED; + return Ok(cycles); + } + + Err(CpuError::UnsupportedOpcode { + opcode, + pc: pc_before, + }) + } +} diff --git a/src/native_core/cpu/opcodes/official/alu.rs b/src/native_core/cpu/opcodes/official/alu.rs new file mode 100644 index 0000000..d74ed88 --- /dev/null +++ b/src/native_core/cpu/opcodes/official/alu.rs @@ -0,0 +1,375 @@ +use super::ops::{OperandReadMode, OperandWriteMode}; +use super::*; + +impl Cpu6502 { + pub(super) fn execute_official_alu( + &mut self, + bus: &mut B, + opcode: u8, + ) -> Option { + let cycles = match opcode { + // ADC + 0x69 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.adc(value); + cycles + } + 0x65 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.adc(value); + cycles + } + 0x75 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.adc(value); + cycles + } + 0x6D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.adc(value); + cycles + } + 0x7D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.adc(value); + cycles + } + 0x79 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.adc(value); + cycles + } + 0x61 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.adc(value); + cycles + } + 0x71 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.adc(value); + cycles + } + + // SBC + 0xE9 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.adc(!value); + cycles + } + 0xE5 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.adc(!value); + cycles + } + 0xF5 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.adc(!value); + cycles + } + 0xED => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.adc(!value); + cycles + } + 0xFD => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.adc(!value); + cycles + } + 0xF9 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.adc(!value); + cycles + } + 0xE1 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.adc(!value); + cycles + } + 0xF1 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.adc(!value); + cycles + } + + // AND + 0x29 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x25 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x35 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x2D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x3D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x39 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x21 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.a &= value; + self.set_zn(self.a); + cycles + } + 0x31 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.a &= value; + self.set_zn(self.a); + cycles + } + + // ORA + 0x09 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x05 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x15 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x0D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x19 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x1D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x01 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.a |= value; + self.set_zn(self.a); + cycles + } + 0x11 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.a |= value; + self.set_zn(self.a); + cycles + } + + // EOR + 0x49 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x45 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x55 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x4D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x5D => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x59 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x41 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.a ^= value; + self.set_zn(self.a); + cycles + } + 0x51 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.a ^= value; + self.set_zn(self.a); + cycles + } + + // BIT + 0x24 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.bit(value); + cycles + } + 0x2C => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.bit(value); + cycles + } + + // CMP + 0xC9 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.compare(self.a, value); + cycles + } + 0xC5 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.compare(self.a, value); + cycles + } + 0xD5 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.compare(self.a, value); + cycles + } + 0xCD => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.compare(self.a, value); + cycles + } + 0xDD => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.compare(self.a, value); + cycles + } + 0xD9 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.compare(self.a, value); + cycles + } + 0xC1 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.compare(self.a, value); + cycles + } + 0xD1 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.compare(self.a, value); + cycles + } + + // CPX / CPY + 0xE0 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.compare(self.x, value); + cycles + } + 0xE4 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.compare(self.x, value); + cycles + } + 0xEC => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.compare(self.x, value); + cycles + } + 0xC0 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.compare(self.y, value); + cycles + } + 0xC4 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.compare(self.y, value); + cycles + } + 0xCC => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.compare(self.y, value); + cycles + } + + // ASL + 0x0A => { + self.a = self.asl(self.a); + 2 + } + 0x06 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| cpu.asl(value)), + 0x16 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| cpu.asl(value)), + 0x0E => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| cpu.asl(value)), + 0x1E => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| cpu.asl(value)), + + // LSR + 0x4A => { + self.a = self.lsr(self.a); + 2 + } + 0x46 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| cpu.lsr(value)), + 0x56 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| cpu.lsr(value)), + 0x4E => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| cpu.lsr(value)), + 0x5E => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| cpu.lsr(value)), + + // ROL + 0x2A => { + self.a = self.rol(self.a); + 2 + } + 0x26 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| cpu.rol(value)), + 0x36 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| cpu.rol(value)), + 0x2E => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| cpu.rol(value)), + 0x3E => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| cpu.rol(value)), + + // ROR + 0x6A => { + self.a = self.ror(self.a); + 2 + } + 0x66 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| cpu.ror(value)), + 0x76 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| cpu.ror(value)), + 0x6E => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| cpu.ror(value)), + 0x7E => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| cpu.ror(value)), + + _ => return None, + }; + Some(cycles) + } +} diff --git a/src/native_core/cpu/opcodes/official/control.rs b/src/native_core/cpu/opcodes/official/control.rs new file mode 100644 index 0000000..50c81ca --- /dev/null +++ b/src/native_core/cpu/opcodes/official/control.rs @@ -0,0 +1,133 @@ +use super::*; + +impl Cpu6502 { + pub(super) fn execute_official_control( + &mut self, + bus: &mut B, + opcode: u8, + ) -> Option { + let cycles = match opcode { + 0xEA => 2, + + // Jumps / calls + 0x4C => { + self.pc = self.addr_abs(bus); + 3 + } + 0x6C => { + let ptr = self.addr_abs(bus); + self.pc = self.read_u16_bug(bus, ptr); + 5 + } + 0x20 => { + let addr = self.addr_abs(bus); + let ret = self.pc.wrapping_sub(1); + self.push(bus, (ret >> 8) as u8); + self.push(bus, ret as u8); + self.pc = addr; + 6 + } + 0x60 => { + let lo = self.pop(bus) as u16; + let hi = self.pop(bus) as u16; + self.pc = ((hi << 8) | lo).wrapping_add(1); + 6 + } + + // Stack + 0x48 => { + self.push(bus, self.a); + 3 + } + 0x68 => { + self.a = self.pop(bus); + self.set_zn(self.a); + 4 + } + 0x08 => { + self.push(bus, self.p | FLAG_BREAK | FLAG_UNUSED); + 3 + } + 0x28 => { + let old_i = (self.p & FLAG_IRQ_DISABLE) != 0; + self.p = (self.pop(bus) | FLAG_UNUSED) & !FLAG_BREAK; + let new_i = (self.p & FLAG_IRQ_DISABLE) != 0; + if old_i && !new_i { + self.irq_delay = true; + } + 4 + } + + // Flags + 0x18 => { + self.p &= !FLAG_CARRY; + 2 + } + 0x38 => { + self.p |= FLAG_CARRY; + 2 + } + 0x58 => { + self.p &= !FLAG_IRQ_DISABLE; + self.irq_delay = true; + 2 + } + 0x78 => { + self.p |= FLAG_IRQ_DISABLE; + 2 + } + 0xD8 => { + self.p &= !FLAG_DECIMAL; + 2 + } + 0xF8 => { + self.p |= FLAG_DECIMAL; + 2 + } + 0xB8 => { + self.p &= !FLAG_OVERFLOW; + 2 + } + + // BRK / RTI + 0x00 => { + self.pc = self.pc.wrapping_add(1); + self.push(bus, (self.pc >> 8) as u8); + self.push(bus, self.pc as u8); + self.push(bus, self.p | FLAG_BREAK | FLAG_UNUSED); + self.p |= FLAG_IRQ_DISABLE; + // NMI can hijack BRK and force vector $FFFA while preserving B=1 on stack. + let vector = if bus.poll_nmi() { 0xFFFA } else { 0xFFFE }; + self.pending_nmi = false; + self.pending_irq = false; + self.pc = self.read_u16(bus, vector); + 7 + } + 0x40 => { + let old_i = (self.p & FLAG_IRQ_DISABLE) != 0; + self.p = (self.pop(bus) | FLAG_UNUSED) & !FLAG_BREAK; + let new_i = (self.p & FLAG_IRQ_DISABLE) != 0; + if old_i && !new_i { + self.irq_delay = true; + } + let lo = self.pop(bus) as u16; + let hi = self.pop(bus) as u16; + self.pc = (hi << 8) | lo; + 6 + } + + // Branches + 0xD0 => self.branch(bus, (self.p & FLAG_ZERO) == 0), + 0xF0 => self.branch(bus, (self.p & FLAG_ZERO) != 0), + 0x10 => self.branch(bus, (self.p & FLAG_NEGATIVE) == 0), + 0x30 => self.branch(bus, (self.p & FLAG_NEGATIVE) != 0), + 0x90 => self.branch(bus, (self.p & FLAG_CARRY) == 0), + 0xB0 => self.branch(bus, (self.p & FLAG_CARRY) != 0), + 0x50 => self.branch(bus, (self.p & FLAG_OVERFLOW) == 0), + 0x70 => self.branch(bus, (self.p & FLAG_OVERFLOW) != 0), + + _ => return None, + }; + Some(cycles) + } +} diff --git a/src/native_core/cpu/opcodes/official/load_store.rs b/src/native_core/cpu/opcodes/official/load_store.rs new file mode 100644 index 0000000..74d27f0 --- /dev/null +++ b/src/native_core/cpu/opcodes/official/load_store.rs @@ -0,0 +1,239 @@ +use super::ops::{OperandReadMode, OperandWriteMode}; +use super::*; + +impl Cpu6502 { + pub(super) fn execute_official_load_store( + &mut self, + bus: &mut B, + opcode: u8, + ) -> Option { + let cycles = match opcode { + // LDA + 0xA9 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xA5 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xB5 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xAD => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xBD => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xB9 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xA1 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.a = value; + self.set_zn(self.a); + cycles + } + 0xB1 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.a = value; + self.set_zn(self.a); + cycles + } + + // LDX + 0xA2 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.x = value; + self.set_zn(self.x); + cycles + } + 0xA6 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.x = value; + self.set_zn(self.x); + cycles + } + 0xB6 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpy, 4); + self.x = value; + self.set_zn(self.x); + cycles + } + 0xAE => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.x = value; + self.set_zn(self.x); + cycles + } + 0xBE => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.x = value; + self.set_zn(self.x); + cycles + } + + // LDY + 0xA0 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.y = value; + self.set_zn(self.y); + cycles + } + 0xA4 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.y = value; + self.set_zn(self.y); + cycles + } + 0xB4 => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Zpx, 4); + self.y = value; + self.set_zn(self.y); + cycles + } + 0xAC => { + let (value, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.y = value; + self.set_zn(self.y); + cycles + } + 0xBC => { + let (value, cycles) = self.op_read(bus, OperandReadMode::AbxCross, 4); + self.y = value; + self.set_zn(self.y); + cycles + } + + // STA/STX/STY + 0x85 => self.op_store(bus, OperandWriteMode::Zp, self.a, 3), + 0x95 => self.op_store(bus, OperandWriteMode::Zpx, self.a, 4), + 0x8D => self.op_store(bus, OperandWriteMode::Abs, self.a, 4), + 0x9D => self.op_store(bus, OperandWriteMode::Abx, self.a, 5), + 0x99 => self.op_store(bus, OperandWriteMode::Aby, self.a, 5), + 0x81 => self.op_store(bus, OperandWriteMode::Indx, self.a, 6), + 0x91 => self.op_store(bus, OperandWriteMode::Indy, self.a, 6), + 0x86 => self.op_store(bus, OperandWriteMode::Zp, self.x, 3), + 0x96 => self.op_store(bus, OperandWriteMode::Zpy, self.x, 4), + 0x8E => self.op_store(bus, OperandWriteMode::Abs, self.x, 4), + 0x84 => self.op_store(bus, OperandWriteMode::Zp, self.y, 3), + 0x94 => self.op_store(bus, OperandWriteMode::Zpx, self.y, 4), + 0x8C => self.op_store(bus, OperandWriteMode::Abs, self.y, 4), + + // Transfers + 0xAA => { + self.x = self.a; + self.set_zn(self.x); + 2 + } + 0x8A => { + self.a = self.x; + self.set_zn(self.a); + 2 + } + 0xA8 => { + self.y = self.a; + self.set_zn(self.y); + 2 + } + 0x98 => { + self.a = self.y; + self.set_zn(self.a); + 2 + } + 0xBA => { + self.x = self.sp; + self.set_zn(self.x); + 2 + } + 0x9A => { + self.sp = self.x; + 2 + } + + // Increments / decrements regs + 0xE8 => { + self.x = self.x.wrapping_add(1); + self.set_zn(self.x); + 2 + } + 0xC8 => { + self.y = self.y.wrapping_add(1); + self.set_zn(self.y); + 2 + } + 0xCA => { + self.x = self.x.wrapping_sub(1); + self.set_zn(self.x); + 2 + } + 0x88 => { + self.y = self.y.wrapping_sub(1); + self.set_zn(self.y); + 2 + } + + // INC/DEC memory + 0xE6 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let value = value.wrapping_add(1); + cpu.set_zn(value); + value + }), + 0xF6 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let value = value.wrapping_add(1); + cpu.set_zn(value); + value + }), + 0xEE => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let value = value.wrapping_add(1); + cpu.set_zn(value); + value + }), + 0xFE => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let value = value.wrapping_add(1); + cpu.set_zn(value); + value + }), + 0xC6 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let value = value.wrapping_sub(1); + cpu.set_zn(value); + value + }), + 0xD6 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let value = value.wrapping_sub(1); + cpu.set_zn(value); + value + }), + 0xCE => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let value = value.wrapping_sub(1); + cpu.set_zn(value); + value + }), + 0xDE => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let value = value.wrapping_sub(1); + cpu.set_zn(value); + value + }), + + _ => return None, + }; + Some(cycles) + } +} diff --git a/src/native_core/cpu/opcodes/official/mod.rs b/src/native_core/cpu/opcodes/official/mod.rs new file mode 100644 index 0000000..208ca80 --- /dev/null +++ b/src/native_core/cpu/opcodes/official/mod.rs @@ -0,0 +1,13 @@ +use super::*; + +mod alu; +mod control; +mod load_store; + +impl Cpu6502 { + pub(super) fn execute_official(&mut self, bus: &mut B, opcode: u8) -> Option { + self.execute_official_load_store(bus, opcode) + .or_else(|| self.execute_official_alu(bus, opcode)) + .or_else(|| self.execute_official_control(bus, opcode)) + } +} diff --git a/src/native_core/cpu/opcodes/ops.rs b/src/native_core/cpu/opcodes/ops.rs new file mode 100644 index 0000000..63e4de9 --- /dev/null +++ b/src/native_core/cpu/opcodes/ops.rs @@ -0,0 +1,100 @@ +use super::*; + +#[derive(Clone, Copy)] +pub(super) enum OperandReadMode { + Imm, + Zp, + Zpx, + Zpy, + Abs, + AbxCross, + AbyCross, + Indx, + IndyCross, +} + +#[derive(Clone, Copy)] +pub(super) enum OperandWriteMode { + Zp, + Zpx, + Zpy, + Abs, + Abx, + Aby, + Indx, + Indy, +} + +impl Cpu6502 { + pub(super) fn op_read( + &mut self, + bus: &mut B, + mode: OperandReadMode, + base_cycles: u8, + ) -> (u8, u8) { + match mode { + OperandReadMode::Imm => (self.fetch(bus), base_cycles), + OperandReadMode::Zp => (self.read_zp(bus), base_cycles), + OperandReadMode::Zpx => (self.read_zpx(bus), base_cycles), + OperandReadMode::Zpy => (self.read_zpy(bus), base_cycles), + OperandReadMode::Abs => (self.read_abs(bus), base_cycles), + OperandReadMode::AbxCross => { + let (value, crossed) = self.read_abx_cross(bus); + (value, base_cycles + u8::from(crossed)) + } + OperandReadMode::AbyCross => { + let (value, crossed) = self.read_aby_cross(bus); + (value, base_cycles + u8::from(crossed)) + } + OperandReadMode::Indx => (self.read_indx(bus), base_cycles), + OperandReadMode::IndyCross => { + let (value, crossed) = self.read_indy_cross(bus); + (value, base_cycles + u8::from(crossed)) + } + } + } + + pub(super) fn op_store( + &mut self, + bus: &mut B, + mode: OperandWriteMode, + value: u8, + cycles: u8, + ) -> u8 { + let addr = self.addr_for_write(bus, mode); + bus.write(addr, value); + cycles + } + + pub(super) fn op_rmw( + &mut self, + bus: &mut B, + mode: OperandWriteMode, + cycles: u8, + op: F, + ) -> u8 + where + F: FnOnce(&mut Self, u8) -> u8, + { + let addr = self.addr_for_write(bus, mode); + let old = bus.read(addr); + // 6502 RMW does a dummy write of the unmodified value before the final value. + bus.write(addr, old); + let value = op(self, old); + bus.write(addr, value); + cycles + } + + fn addr_for_write(&mut self, bus: &mut B, mode: OperandWriteMode) -> u16 { + match mode { + OperandWriteMode::Zp => self.addr_zp(bus), + OperandWriteMode::Zpx => self.addr_zpx(bus), + OperandWriteMode::Zpy => self.addr_zpy(bus), + OperandWriteMode::Abs => self.addr_abs(bus), + OperandWriteMode::Abx => self.addr_abx(bus), + OperandWriteMode::Aby => self.addr_aby(bus), + OperandWriteMode::Indx => self.addr_indx(bus), + OperandWriteMode::Indy => self.addr_indy(bus), + } + } +} diff --git a/src/native_core/cpu/opcodes/undocumented/combos.rs b/src/native_core/cpu/opcodes/undocumented/combos.rs new file mode 100644 index 0000000..8a2d59f --- /dev/null +++ b/src/native_core/cpu/opcodes/undocumented/combos.rs @@ -0,0 +1,94 @@ +use super::*; + +impl Cpu6502 { + pub(super) fn execute_undocumented_combos( + &mut self, + bus: &mut B, + opcode: u8, + ) -> Option { + let cycles = match opcode { + // Undocumented immediate combos + 0x0B | 0x2B => { + self.a &= self.fetch(bus); + self.set_zn(self.a); + self.set_flag(FLAG_CARRY, (self.a & 0x80) != 0); + 2 + } + 0x4B => { + self.a &= self.fetch(bus); + self.a = self.lsr(self.a); + 2 + } + 0x6B => { + let imm = self.fetch(bus); + self.a &= imm; + self.a = self.ror(self.a); + let bit5 = (self.a & 0x20) != 0; + let bit6 = (self.a & 0x40) != 0; + self.set_flag(FLAG_CARRY, bit6); + self.set_flag(FLAG_OVERFLOW, bit5 ^ bit6); + 2 + } + 0xCB => { + let imm = self.fetch(bus); + let ax = self.a & self.x; + let v = ax.wrapping_sub(imm); + self.x = v; + self.set_flag(FLAG_CARRY, ax >= imm); + self.set_zn(self.x); + 2 + } + 0xEB => { + let value = self.fetch(bus); + self.adc(!value); + 2 + } + 0xBB => { + let (addr, crossed) = self.addr_aby_cross(bus); + let v = bus.read(addr) & self.sp; + self.a = v; + self.x = v; + self.sp = v; + self.set_zn(v); + 4 + u8::from(crossed) + } + 0x93 => { + let zp = self.fetch(bus); + let lo = bus.read(zp as u16) as u16; + let hi = bus.read(zp.wrapping_add(1) as u16) as u16; + let addr = ((hi << 8) | lo).wrapping_add(self.y as u16); + let hi_mask = ((addr >> 8) as u8).wrapping_add(1); + bus.write(addr, self.a & self.x & hi_mask); + 6 + } + 0x9F => { + let addr = self.addr_aby(bus); + let hi_mask = ((addr >> 8) as u8).wrapping_add(1); + bus.write(addr, self.a & self.x & hi_mask); + 5 + } + 0x9B => { + let addr = self.addr_aby(bus); + self.sp = self.a & self.x; + let hi_mask = ((addr >> 8) as u8).wrapping_add(1); + bus.write(addr, self.sp & hi_mask); + 5 + } + 0x9C => { + let addr = self.addr_abx(bus); + let hi_mask = ((addr >> 8) as u8).wrapping_add(1); + bus.write(addr, self.y & hi_mask); + 5 + } + 0x9E => { + let addr = self.addr_aby(bus); + let hi_mask = ((addr >> 8) as u8).wrapping_add(1); + bus.write(addr, self.x & hi_mask); + 5 + } + + _ => return None, + }; + Some(cycles) + } +} diff --git a/src/native_core/cpu/opcodes/undocumented/mod.rs b/src/native_core/cpu/opcodes/undocumented/mod.rs new file mode 100644 index 0000000..252595d --- /dev/null +++ b/src/native_core/cpu/opcodes/undocumented/mod.rs @@ -0,0 +1,18 @@ +use super::*; + +mod combos; +mod rmw; +mod system; + +impl Cpu6502 { + pub(super) fn execute_undocumented( + &mut self, + bus: &mut B, + opcode: u8, + pc_before: u16, + ) -> Option { + self.execute_undocumented_system(bus, opcode, pc_before) + .or_else(|| self.execute_undocumented_rmw(bus, opcode)) + .or_else(|| self.execute_undocumented_combos(bus, opcode)) + } +} diff --git a/src/native_core/cpu/opcodes/undocumented/rmw.rs b/src/native_core/cpu/opcodes/undocumented/rmw.rs new file mode 100644 index 0000000..fce5436 --- /dev/null +++ b/src/native_core/cpu/opcodes/undocumented/rmw.rs @@ -0,0 +1,258 @@ +use super::ops::OperandWriteMode; +use super::*; + +impl Cpu6502 { + pub(super) fn execute_undocumented_rmw( + &mut self, + bus: &mut B, + opcode: u8, + ) -> Option { + let cycles = match opcode { + // Undocumented SLO: ASL M then ORA M + 0x03 => self.op_rmw(bus, OperandWriteMode::Indx, 8, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + 0x07 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + 0x0F => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + 0x13 => self.op_rmw(bus, OperandWriteMode::Indy, 8, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + 0x17 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + 0x1B => self.op_rmw(bus, OperandWriteMode::Aby, 7, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + 0x1F => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let v = cpu.asl(value); + cpu.a |= v; + cpu.set_zn(cpu.a); + v + }), + + // Undocumented RLA: ROL M then AND M + 0x23 => self.op_rmw(bus, OperandWriteMode::Indx, 8, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + 0x27 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + 0x2F => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + 0x33 => self.op_rmw(bus, OperandWriteMode::Indy, 8, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + 0x37 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + 0x3B => self.op_rmw(bus, OperandWriteMode::Aby, 7, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + 0x3F => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let v = cpu.rol(value); + cpu.a &= v; + cpu.set_zn(cpu.a); + v + }), + + // Undocumented SRE: LSR M then EOR M + 0x43 => self.op_rmw(bus, OperandWriteMode::Indx, 8, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + 0x47 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + 0x4F => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + 0x53 => self.op_rmw(bus, OperandWriteMode::Indy, 8, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + 0x57 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + 0x5B => self.op_rmw(bus, OperandWriteMode::Aby, 7, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + 0x5F => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let v = cpu.lsr(value); + cpu.a ^= v; + cpu.set_zn(cpu.a); + v + }), + + // Undocumented RRA: ROR M then ADC M + 0x63 => self.op_rmw(bus, OperandWriteMode::Indx, 8, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + 0x67 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + 0x6F => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + 0x73 => self.op_rmw(bus, OperandWriteMode::Indy, 8, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + 0x77 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + 0x7B => self.op_rmw(bus, OperandWriteMode::Aby, 7, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + 0x7F => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let v = cpu.ror(value); + cpu.adc(v); + v + }), + + // Undocumented DCP: DEC M then CMP M + 0xC3 => self.op_rmw(bus, OperandWriteMode::Indx, 8, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + 0xC7 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + 0xCF => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + 0xD3 => self.op_rmw(bus, OperandWriteMode::Indy, 8, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + 0xD7 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + 0xDB => self.op_rmw(bus, OperandWriteMode::Aby, 7, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + 0xDF => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let v = value.wrapping_sub(1); + cpu.compare(cpu.a, v); + v + }), + + // Undocumented ISC/ISB: INC M then SBC M + 0xE3 => self.op_rmw(bus, OperandWriteMode::Indx, 8, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + 0xE7 => self.op_rmw(bus, OperandWriteMode::Zp, 5, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + 0xEF => self.op_rmw(bus, OperandWriteMode::Abs, 6, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + 0xF3 => self.op_rmw(bus, OperandWriteMode::Indy, 8, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + 0xF7 => self.op_rmw(bus, OperandWriteMode::Zpx, 6, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + 0xFB => self.op_rmw(bus, OperandWriteMode::Aby, 7, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + 0xFF => self.op_rmw(bus, OperandWriteMode::Abx, 7, |cpu, value| { + let v = value.wrapping_add(1); + cpu.adc(!v); + v + }), + + _ => return None, + }; + Some(cycles) + } +} diff --git a/src/native_core/cpu/opcodes/undocumented/system.rs b/src/native_core/cpu/opcodes/undocumented/system.rs new file mode 100644 index 0000000..aca683a --- /dev/null +++ b/src/native_core/cpu/opcodes/undocumented/system.rs @@ -0,0 +1,97 @@ +use super::ops::{OperandReadMode, OperandWriteMode}; +use super::*; + +impl Cpu6502 { + pub(super) fn execute_undocumented_system( + &mut self, + bus: &mut B, + opcode: u8, + pc_before: u16, + ) -> Option { + let cycles = match opcode { + // Undocumented NOPs used by commercial ROMs. + 0x1A | 0x3A | 0x5A | 0x7A | 0xDA | 0xFA => 2, + 0x80 | 0x82 | 0x89 | 0xC2 | 0xE2 => self.op_read(bus, OperandReadMode::Imm, 2).1, + 0x04 | 0x44 | 0x64 => self.op_read(bus, OperandReadMode::Zp, 3).1, + 0x14 | 0x34 | 0x54 | 0x74 | 0xD4 | 0xF4 => self.op_read(bus, OperandReadMode::Zpx, 4).1, + 0x0C => self.op_read(bus, OperandReadMode::Abs, 4).1, + 0x1C | 0x3C | 0x5C | 0x7C | 0xDC | 0xFC => { + self.op_read(bus, OperandReadMode::AbxCross, 4).1 + } + + // JAM/KIL opcodes lock the real CPU until reset. + 0x02 | 0x12 | 0x22 | 0x32 | 0x42 | 0x52 | 0x62 | 0x72 | 0x92 | 0xB2 | 0xD2 | 0xF2 => { + self.halted = true; + self.pc = pc_before; + 2 + } + + // Undocumented LAX: A <- M, X <- A + 0xAB => { + let (v, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + 0xA3 => { + let (v, cycles) = self.op_read(bus, OperandReadMode::Indx, 6); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + 0xA7 => { + let (v, cycles) = self.op_read(bus, OperandReadMode::Zp, 3); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + 0xAF => { + let (v, cycles) = self.op_read(bus, OperandReadMode::Abs, 4); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + 0xB3 => { + let (v, cycles) = self.op_read(bus, OperandReadMode::IndyCross, 5); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + 0xB7 => { + let (v, cycles) = self.op_read(bus, OperandReadMode::Zpy, 4); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + 0xBF => { + let (v, cycles) = self.op_read(bus, OperandReadMode::AbyCross, 4); + self.a = v; + self.x = v; + self.set_zn(v); + cycles + } + + // Undocumented SAX: M <- A & X + 0x83 => self.op_store(bus, OperandWriteMode::Indx, self.a & self.x, 6), + 0x87 => self.op_store(bus, OperandWriteMode::Zp, self.a & self.x, 3), + 0x8F => self.op_store(bus, OperandWriteMode::Abs, self.a & self.x, 4), + 0x97 => self.op_store(bus, OperandWriteMode::Zpy, self.a & self.x, 4), + 0x8B => { + // XAA/ANE (unstable on hardware), practical emulation form. + let (v, cycles) = self.op_read(bus, OperandReadMode::Imm, 2); + self.a = self.x & v; + self.set_zn(self.a); + cycles + } + + _ => return None, + }; + Some(cycles) + } +} diff --git a/src/native_core/cpu/tests.rs b/src/native_core/cpu/tests.rs new file mode 100644 index 0000000..1f8cfb1 --- /dev/null +++ b/src/native_core/cpu/tests.rs @@ -0,0 +1,10 @@ +use super::{Cpu6502, CpuBus, FLAG_CARRY, FLAG_NEGATIVE, FLAG_ZERO}; +use crate::native_core::test_support::{TestRamBus, cpu_setup_with_reset}; + +fn setup_cpu_with_reset(prog: &[u8]) -> (Cpu6502, TestRamBus) { + cpu_setup_with_reset(prog) +} + +mod core; +mod interrupts; +mod property; diff --git a/src/native_core/cpu/tests/core.rs b/src/native_core/cpu/tests/core.rs new file mode 100644 index 0000000..8b72f98 --- /dev/null +++ b/src/native_core/cpu/tests/core.rs @@ -0,0 +1,272 @@ +use super::*; + +#[test] +fn lda_sta_and_branch_work() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA9, 0x42, // LDA #$42 + 0x8D, 0x34, 0x12, // STA $1234 + 0xC9, 0x42, // CMP #$42 + 0xF0, 0x02, // BEQ +2 + 0xA9, 0x00, // skipped + 0xA9, 0x99, // LDA #$99 + ]); + + for _ in 0..5 { + cpu.step(&mut bus).expect("opcode must be supported"); + } + + assert_eq!(bus.0[0x1234], 0x42); + assert_eq!(cpu.a, 0x99); + assert_eq!(cpu.p & FLAG_ZERO, 0); +} + +#[test] +fn branch_not_taken_uses_two_cycles() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0x38, // SEC + 0x90, 0x7F, // BCC +$7F (not taken) + ]); + assert_eq!(cpu.step(&mut bus).expect("SEC"), 2); + let cycles = cpu.step(&mut bus).expect("BCC"); + assert_eq!(cycles, 2); + assert_eq!(cpu.pc, 0x8003); +} + +#[test] +fn branch_taken_same_page_uses_three_cycles() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0x18, // CLC + 0x90, 0x02, // BCC +2 + 0xEA, // skipped + 0xEA, // target + ]); + assert_eq!(cpu.step(&mut bus).expect("CLC"), 2); + let cycles = cpu.step(&mut bus).expect("BCC"); + assert_eq!(cycles, 3); + assert_eq!(cpu.pc, 0x8005); +} + +#[test] +fn branch_taken_cross_page_uses_four_cycles() { + let (_cpu, mut bus) = setup_cpu_with_reset(&[]); + bus.0[0x80FD] = 0x90; // BCC + bus.0[0x80FE] = 0x02; // target $8101 (page-crossing from $80FF) + + let mut cpu = Cpu6502 { + pc: 0x80FD, + ..Cpu6502::default() + }; + cpu.p &= !FLAG_CARRY; + let cycles = cpu.step(&mut bus).expect("BCC cross-page"); + assert_eq!(cycles, 4); + assert_eq!(cpu.pc, 0x8101); +} + +#[test] +fn jsr_rts_roundtrip() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0x20, 0x06, 0x80, // JSR $8006 + 0xA9, 0x11, // LDA #$11 + 0xEA, // NOP + 0xA9, 0x77, // LDA #$77 + 0x60, // RTS + ]); + + for _ in 0..4 { + cpu.step(&mut bus).expect("opcode must be supported"); + } + + assert_eq!(cpu.a, 0x11); +} + +#[test] +fn adc_and_sbc_update_flags() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA9, 0x01, // LDA #$01 + 0x18, // CLC + 0x69, 0x01, // ADC #$01 + 0x38, // SEC + 0xE9, 0x02, // SBC #$02 + ]); + + for _ in 0..5 { + cpu.step(&mut bus).expect("opcode must be supported"); + } + + assert_eq!(cpu.a, 0x00); + assert_ne!(cpu.p & FLAG_CARRY, 0); + assert_ne!(cpu.p & FLAG_ZERO, 0); + assert_eq!(cpu.p & FLAG_NEGATIVE, 0); +} + +#[test] +fn indirect_x_and_indirect_y_addressing() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA2, 0x04, // LDX #$04 + 0xA1, 0x10, // LDA ($10,X) -> ptr at $14/$15 + 0xA0, 0x01, // LDY #$01 + 0xB1, 0x20, // LDA ($20),Y + ]); + bus.0[0x0014] = 0x00; + bus.0[0x0015] = 0x90; + bus.0[0x9000] = 0x55; + bus.0[0x0020] = 0x10; + bus.0[0x0021] = 0x90; + bus.0[0x9011] = 0xAA; + + cpu.step(&mut bus).expect("ldx"); + cpu.step(&mut bus).expect("lda (ind,x)"); + assert_eq!(cpu.a, 0x55); + cpu.step(&mut bus).expect("ldy"); + cpu.step(&mut bus).expect("lda (ind),y"); + assert_eq!(cpu.a, 0xAA); +} + +#[test] +fn bit_and_shift_ops_work() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA9, 0x40, // LDA #$40 + 0x24, 0x10, // BIT $10 + 0x0A, // ASL A (0x80) + 0x4A, // LSR A (0x40) + 0x38, // SEC + 0x6A, // ROR A (0xA0) + ]); + bus.0[0x0010] = 0xC0; + + for _ in 0..6 { + cpu.step(&mut bus).expect("opcode must be supported"); + } + + assert_eq!(cpu.a, 0xA0); + assert_ne!(cpu.p & FLAG_NEGATIVE, 0); + assert_eq!(cpu.p & FLAG_ZERO, 0); +} + +#[test] +fn rmw_instructions_perform_dummy_write_before_final_write() { + struct TraceBus { + ram: [u8; 0x10000], + writes: Vec<(u16, u8)>, + } + + impl CpuBus for TraceBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + self.writes.push((addr, value)); + } + } + + let mut bus = TraceBus { + ram: [0; 0x10000], + writes: Vec::new(), + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0x8000] = 0xEE; // INC $2000 + bus.ram[0x8001] = 0x00; + bus.ram[0x8002] = 0x20; + bus.ram[0x2000] = 0x7F; + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + cpu.step(&mut bus).expect("INC should execute"); + + let writes_to_target: Vec<_> = bus + .writes + .iter() + .copied() + .filter(|(addr, _)| *addr == 0x2000) + .collect(); + assert_eq!(writes_to_target, vec![(0x2000, 0x7F), (0x2000, 0x80)]); +} + +#[test] +fn nmi_interrupt_jumps_to_vector() { + struct NmiBus { + ram: [u8; 0x10000], + nmi_pending: bool, + } + + impl CpuBus for NmiBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + } + fn poll_nmi(&mut self) -> bool { + let out = self.nmi_pending; + self.nmi_pending = false; + out + } + } + + let mut bus = NmiBus { + ram: [0; 0x10000], + nmi_pending: true, + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0xFFFA] = 0x34; + bus.ram[0xFFFB] = 0x12; + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + let cycles = cpu.step(&mut bus).expect("nmi should be serviced"); + assert_eq!(cycles, 7); + assert_eq!(cpu.pc, 0x1234); +} + +#[test] +fn undocumented_nop_da_is_accepted() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xDA, // undocumented NOP + 0xA9, 0x42, // LDA #$42 + ]); + cpu.step(&mut bus).expect("0xDA should be supported"); + cpu.step(&mut bus).expect("LDA should execute after 0xDA"); + assert_eq!(cpu.a, 0x42); +} + +#[test] +fn undocumented_slo_03_is_accepted() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA2, 0x00, // LDX #$00 + 0x03, 0x10, // SLO ($10,X) + ]); + bus.0[0x0010] = 0x00; + bus.0[0x0011] = 0x90; + bus.0[0x9000] = 0x40; + + cpu.step(&mut bus).expect("LDX"); + cpu.step(&mut bus).expect("0x03 should be supported"); + + assert_eq!(bus.0[0x9000], 0x80); + assert_eq!(cpu.a, 0x80); +} + +#[test] +fn jam_opcode_halts_cpu_until_reset() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0x02, // JAM/KIL + 0xA9, 0x77, // LDA #$77 (must not execute while halted) + ]); + + let cycles = cpu.step(&mut bus).expect("jam opcode should decode"); + assert_eq!(cycles, 2); + assert!(cpu.halted); + assert_eq!(cpu.pc, 0x8000); + + let cycles2 = cpu.step(&mut bus).expect("halted cpu step should be valid"); + assert_eq!(cycles2, 2); + assert_eq!(cpu.pc, 0x8000); + assert_eq!(cpu.a, 0x00); + + cpu.reset(&mut bus); + assert!(!cpu.halted); + assert_eq!(cpu.pc, 0x8000); +} diff --git a/src/native_core/cpu/tests/interrupts.rs b/src/native_core/cpu/tests/interrupts.rs new file mode 100644 index 0000000..3c2bd97 --- /dev/null +++ b/src/native_core/cpu/tests/interrupts.rs @@ -0,0 +1,242 @@ +use super::*; + +#[test] +fn cli_delays_irq_acceptance_by_one_instruction() { + struct IrqBus { + ram: [u8; 0x10000], + irq_level: bool, + } + + impl CpuBus for IrqBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + } + fn poll_irq(&mut self) -> bool { + self.irq_level + } + } + + let mut bus = IrqBus { + ram: [0; 0x10000], + irq_level: true, + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0xFFFE] = 0x00; + bus.ram[0xFFFF] = 0x90; // IRQ vector -> $9000 + bus.ram[0x8000] = 0x58; // CLI + bus.ram[0x8001] = 0xEA; // NOP + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + + let c1 = cpu.step(&mut bus).expect("CLI"); + assert_eq!(c1, 2); + assert_eq!(cpu.pc, 0x8001); + + let c2 = cpu.step(&mut bus).expect("NOP executes before IRQ"); + assert_eq!(c2, 2); + assert_eq!(cpu.pc, 0x8002); + + let c3 = cpu.step(&mut bus).expect("IRQ taken now"); + assert_eq!(c3, 7); + assert_eq!(cpu.pc, 0x9000); +} + +#[test] +fn plp_delays_irq_acceptance_by_one_instruction_when_clearing_i() { + struct IrqBus { + ram: [u8; 0x10000], + irq_level: bool, + } + + impl CpuBus for IrqBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + } + fn poll_irq(&mut self) -> bool { + self.irq_level + } + } + + let mut bus = IrqBus { + ram: [0; 0x10000], + irq_level: true, + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0xFFFE] = 0x00; + bus.ram[0xFFFF] = 0x90; + bus.ram[0x8000] = 0xA9; // LDA #$20 + bus.ram[0x8001] = 0x20; + bus.ram[0x8002] = 0x48; // PHA + bus.ram[0x8003] = 0x28; // PLP -> I clears + bus.ram[0x8004] = 0xEA; // NOP + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + + cpu.step(&mut bus).expect("LDA"); + cpu.step(&mut bus).expect("PHA"); + let c3 = cpu.step(&mut bus).expect("PLP"); + assert_eq!(c3, 4); + assert_eq!(cpu.pc, 0x8004); + + let c4 = cpu.step(&mut bus).expect("NOP executes before IRQ"); + assert_eq!(c4, 2); + assert_eq!(cpu.pc, 0x8005); + + let c5 = cpu.step(&mut bus).expect("IRQ taken"); + assert_eq!(c5, 7); + assert_eq!(cpu.pc, 0x9000); +} + +#[test] +fn jam_halt_ignores_nmi_and_irq() { + struct InterruptBus { + ram: [u8; 0x10000], + nmi: bool, + irq: bool, + } + + impl CpuBus for InterruptBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + } + fn poll_nmi(&mut self) -> bool { + let out = self.nmi; + self.nmi = false; + out + } + fn poll_irq(&mut self) -> bool { + self.irq + } + } + + let mut bus = InterruptBus { + ram: [0; 0x10000], + nmi: false, + irq: false, + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0xFFFA] = 0x00; + bus.ram[0xFFFB] = 0x90; + bus.ram[0xFFFE] = 0x00; + bus.ram[0xFFFF] = 0xA0; + bus.ram[0x8000] = 0x02; // JAM + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + cpu.step(&mut bus).expect("jam"); + assert!(cpu.halted); + + bus.nmi = true; + bus.irq = true; + let c = cpu.step(&mut bus).expect("halted step"); + assert_eq!(c, 2); + assert_eq!(cpu.pc, 0x8000); +} + +#[test] +fn rti_delays_irq_acceptance_by_one_instruction_when_clearing_i() { + struct IrqBus { + ram: [u8; 0x10000], + irq_level: bool, + } + + impl CpuBus for IrqBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + } + fn poll_irq(&mut self) -> bool { + self.irq_level + } + } + + let mut bus = IrqBus { + ram: [0; 0x10000], + irq_level: true, + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0xFFFE] = 0x00; + bus.ram[0xFFFF] = 0x90; // IRQ vector + + bus.ram[0x8000] = 0x40; // RTI + bus.ram[0x8001] = 0xEA; // NOP + + // After reset SP is $FA, so RTI pops from $01FB/$01FC/$01FD. + bus.ram[0x01FB] = 0x20; // P (I clear) + bus.ram[0x01FC] = 0x01; // PC low + bus.ram[0x01FD] = 0x80; // PC high + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + + let c1 = cpu.step(&mut bus).expect("RTI"); + assert_eq!(c1, 6); + assert_eq!(cpu.pc, 0x8001); + + let c2 = cpu.step(&mut bus).expect("NOP before IRQ"); + assert_eq!(c2, 2); + assert_eq!(cpu.pc, 0x8002); + + let c3 = cpu.step(&mut bus).expect("IRQ now taken"); + assert_eq!(c3, 7); + assert_eq!(cpu.pc, 0x9000); +} + +#[test] +fn brk_is_hijacked_by_nmi_vector_when_nmi_arrives_during_brk() { + struct NmiBus { + ram: [u8; 0x10000], + nmi_now: bool, + } + + impl CpuBus for NmiBus { + fn read(&mut self, addr: u16) -> u8 { + self.ram[addr as usize] + } + fn write(&mut self, addr: u16, value: u8) { + self.ram[addr as usize] = value; + } + fn poll_nmi(&mut self) -> bool { + let out = self.nmi_now; + self.nmi_now = false; + out + } + } + + let mut bus = NmiBus { + ram: [0; 0x10000], + nmi_now: false, + }; + bus.ram[0xFFFC] = 0x00; + bus.ram[0xFFFD] = 0x80; + bus.ram[0xFFFA] = 0x34; // NMI vector + bus.ram[0xFFFB] = 0x12; + bus.ram[0xFFFE] = 0x78; // IRQ/BRK vector + bus.ram[0xFFFF] = 0x56; + bus.ram[0x8000] = 0x00; // BRK + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + + bus.nmi_now = true; + let cycles = cpu.step(&mut bus).expect("BRK"); + assert_eq!(cycles, 7); + assert_eq!(cpu.pc, 0x1234, "NMI vector should hijack BRK"); +} diff --git a/src/native_core/cpu/tests/property.rs b/src/native_core/cpu/tests/property.rs new file mode 100644 index 0000000..f33bb9c --- /dev/null +++ b/src/native_core/cpu/tests/property.rs @@ -0,0 +1,56 @@ +use super::*; + +#[test] +fn property_all_256_opcodes_decode_without_error() { + for opcode in 0u16..=255 { + let mut bus = TestRamBus::new(); + bus.0[0xFFFC] = 0x00; + bus.0[0xFFFD] = 0x80; + bus.0[0xFFFE] = 0x00; + bus.0[0xFFFF] = 0x80; + bus.0[0xFFFA] = 0x00; + bus.0[0xFFFB] = 0x80; + bus.0[0x8000] = opcode as u8; + bus.0[0x8001] = 0x00; + bus.0[0x8002] = 0x00; + + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + let res = cpu.step(&mut bus); + assert!(res.is_ok(), "opcode {:02X} must be supported", opcode); + } +} + +#[test] +fn property_lda_abs_x_adds_cycle_on_page_cross() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA2, 0x01, // LDX #$01 + 0xBD, 0xFF, 0x12, // LDA $12FF,X -> crosses to $1300 + ]); + bus.0[0x1300] = 0x77; + + let c1 = cpu.step(&mut bus).expect("LDX"); + let c2 = cpu.step(&mut bus).expect("LDA abs,X"); + + assert_eq!(c1, 2); + assert_eq!(c2, 5); + assert_eq!(cpu.a, 0x77); +} + +#[test] +fn property_lda_ind_y_adds_cycle_on_page_cross() { + let (mut cpu, mut bus) = setup_cpu_with_reset(&[ + 0xA0, 0x01, // LDY #$01 + 0xB1, 0x10, // LDA ($10),Y + ]); + bus.0[0x0010] = 0xFF; + bus.0[0x0011] = 0x12; + bus.0[0x1300] = 0x55; + + let c1 = cpu.step(&mut bus).expect("LDY"); + let c2 = cpu.step(&mut bus).expect("LDA ind,Y"); + + assert_eq!(c1, 2); + assert_eq!(c2, 6); + assert_eq!(cpu.a, 0x55); +} diff --git a/src/native_core/ines.rs b/src/native_core/ines.rs new file mode 100644 index 0000000..e98a798 --- /dev/null +++ b/src/native_core/ines.rs @@ -0,0 +1,225 @@ +const INES_HEADER_LEN: usize = 16; +const TRAINER_LEN: usize = 512; +const PRG_BANK_16K: usize = 16 * 1024; +const CHR_BANK_8K: usize = 8 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mirroring { + Horizontal, + Vertical, + FourScreen, + OneScreenLow, + OneScreenHigh, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InesHeader { + pub mapper: u16, + pub submapper: u8, + pub is_nes2: bool, + pub prg_rom_banks_16k: u16, + pub chr_rom_banks_8k: u16, + pub has_trainer: bool, + pub has_battery: bool, + pub mirroring: Mirroring, + pub prg_ram_shift: u8, + pub prg_nvram_shift: u8, + pub chr_ram_shift: u8, + pub chr_nvram_shift: u8, + pub cpu_ppu_timing_mode: u8, + pub vs_hardware_type: u8, + pub vs_ppu_type: u8, + pub misc_rom_count: u8, + pub default_expansion_device: u8, +} + +pub fn parse_header(bytes: &[u8]) -> Result { + if bytes.len() < INES_HEADER_LEN { + return Err("invalid file: too small for iNES header".to_string()); + } + if &bytes[0..4] != b"NES\x1A" { + return Err("invalid ROM: missing NES header".to_string()); + } + + let flags6 = bytes[6]; + let flags7 = bytes[7]; + let flags8 = bytes[8]; + let flags9 = bytes[9]; + let flags10 = bytes[10]; + let flags11 = bytes[11]; + let flags12 = bytes[12]; + let flags13 = bytes[13]; + let flags14 = bytes[14]; + let flags15 = bytes[15]; + + let is_nes2 = (flags7 & 0b0000_1100) == 0b0000_1000; + + let mapper = ((flags6 >> 4) as u16) + | ((flags7 & 0xF0) as u16) + | if is_nes2 { + ((flags8 & 0x0F) as u16) << 8 + } else { + 0 + }; + + let submapper = if is_nes2 { flags8 >> 4 } else { 0 }; + + let prg_rom_banks_16k = if is_nes2 { + let upper = (flags9 & 0x0F) as u16; + if upper == 0x0F { + return Err("NES 2.0 exponent/multiplier PRG sizes are not supported".to_string()); + } + (bytes[4] as u16) | (upper << 8) + } else { + bytes[4] as u16 + }; + + let chr_rom_banks_8k = if is_nes2 { + let upper = ((flags9 >> 4) & 0x0F) as u16; + if upper == 0x0F { + return Err("NES 2.0 exponent/multiplier CHR sizes are not supported".to_string()); + } + (bytes[5] as u16) | (upper << 8) + } else { + bytes[5] as u16 + }; + + let mirroring = if (flags6 & 0b0000_1000) != 0 { + Mirroring::FourScreen + } else if (flags6 & 0b0000_0001) != 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + + Ok(InesHeader { + mapper, + submapper, + is_nes2, + prg_rom_banks_16k, + chr_rom_banks_8k, + has_trainer: (flags6 & 0b0000_0100) != 0, + has_battery: (flags6 & 0b0000_0010) != 0, + mirroring, + prg_ram_shift: if is_nes2 { flags10 & 0x0F } else { 0 }, + prg_nvram_shift: if is_nes2 { (flags10 >> 4) & 0x0F } else { 0 }, + chr_ram_shift: if is_nes2 { flags11 & 0x0F } else { 0 }, + chr_nvram_shift: if is_nes2 { (flags11 >> 4) & 0x0F } else { 0 }, + cpu_ppu_timing_mode: if is_nes2 { flags12 & 0x03 } else { 0 }, + vs_hardware_type: if is_nes2 { (flags13 >> 4) & 0x0F } else { 0 }, + vs_ppu_type: if is_nes2 { flags13 & 0x0F } else { 0 }, + misc_rom_count: if is_nes2 { flags14 & 0x03 } else { 0 }, + default_expansion_device: if is_nes2 { flags15 & 0x3F } else { 0 }, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InesRom { + pub header: InesHeader, + pub prg_rom: Vec, + pub chr_data: Vec, + pub chr_is_ram: bool, +} + +pub fn parse_rom(bytes: &[u8]) -> Result { + let header = parse_header(bytes)?; + + let prg_len = header.prg_rom_banks_16k as usize * PRG_BANK_16K; + let chr_len = header.chr_rom_banks_8k as usize * CHR_BANK_8K; + + let mut offset = INES_HEADER_LEN; + if header.has_trainer { + offset = offset + .checked_add(TRAINER_LEN) + .ok_or_else(|| "ROM size overflow".to_string())?; + } + + let prg_end = offset + .checked_add(prg_len) + .ok_or_else(|| "ROM size overflow".to_string())?; + if bytes.len() < prg_end { + return Err("invalid ROM: truncated PRG segment".to_string()); + } + let prg_rom = bytes[offset..prg_end].to_vec(); + + let chr_end = prg_end + .checked_add(chr_len) + .ok_or_else(|| "ROM size overflow".to_string())?; + if bytes.len() < chr_end { + return Err("invalid ROM: truncated CHR segment".to_string()); + } + let chr_data = if chr_len == 0 { + vec![0; CHR_BANK_8K] + } else { + bytes[prg_end..chr_end].to_vec() + }; + + Ok(InesRom { + header, + prg_rom, + chr_data, + chr_is_ram: chr_len == 0, + }) +} + +#[cfg(test)] +mod tests { + + use super::{Mirroring, parse_header, parse_rom}; + + #[test] + fn parse_ines1_header_basics() { + let mut rom = vec![0u8; 16 + (2 * 16 * 1024) + (8 * 1024)]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 2; + rom[5] = 1; + rom[6] = 0b0001_0001; + rom[7] = 0b0010_0000; + + let header = parse_header(&rom).expect("header should parse"); + assert_eq!(header.mapper, 0x21); + assert_eq!(header.prg_rom_banks_16k, 2); + assert_eq!(header.chr_rom_banks_8k, 1); + assert_eq!(header.mirroring, Mirroring::Vertical); + } + + #[test] + fn parse_chr_ram_rom() { + let mut rom = vec![0u8; 16 + (16 * 1024)]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 1; + rom[5] = 0; + + let parsed = parse_rom(&rom).expect("ROM should parse"); + assert_eq!(parsed.prg_rom.len(), 16 * 1024); + assert_eq!(parsed.chr_data.len(), 8 * 1024); + assert!(parsed.chr_is_ram); + } + + #[test] + fn parse_nes2_extended_fields() { + let mut rom = vec![0u8; 16 + (16 * 1024)]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 1; + rom[5] = 0; + rom[7] = 0x08; // NES2 marker + rom[10] = 0x54; + rom[11] = 0x32; + rom[12] = 0x01; + rom[13] = 0xBA; + rom[14] = 0x02; + rom[15] = 0x1C; + + let header = parse_header(&rom).expect("NES2 header should parse"); + assert!(header.is_nes2); + assert_eq!(header.prg_ram_shift, 4); + assert_eq!(header.prg_nvram_shift, 5); + assert_eq!(header.chr_ram_shift, 2); + assert_eq!(header.chr_nvram_shift, 3); + assert_eq!(header.cpu_ppu_timing_mode, 1); + assert_eq!(header.vs_hardware_type, 0xB); + assert_eq!(header.vs_ppu_type, 0xA); + assert_eq!(header.misc_rom_count, 2); + assert_eq!(header.default_expansion_device, 0x1C); + } +} diff --git a/src/native_core/mapper/core.rs b/src/native_core/mapper/core.rs new file mode 100644 index 0000000..69bca09 --- /dev/null +++ b/src/native_core/mapper/core.rs @@ -0,0 +1,253 @@ +use crate::native_core::ines::Mirroring; + +const MAPPER_STATE_SECTION_MAGIC: [u8; 4] = *b"MSS1"; +const MAPPER_STATE_SECTION_VERSION: u8 = 1; + +pub trait Mapper { + fn cpu_read(&self, addr: u16) -> u8; + fn cpu_write(&mut self, addr: u16, value: u8); + fn cpu_read_low(&self, _addr: u16) -> Option { + None + } + fn cpu_write_low(&mut self, _addr: u16, _value: u8) -> bool { + false + } + fn ppu_read(&self, addr: u16) -> u8; + fn ppu_write(&mut self, addr: u16, value: u8); + fn mirroring(&self) -> Mirroring; + fn map_nametable_addr(&self, _addr: u16) -> Option { + None + } + fn clock_cpu(&mut self, _cycles: u8) {} + fn clock_scanline(&mut self) {} + fn needs_ppu_a12_clock(&self) -> bool { + false + } + fn poll_irq(&mut self) -> bool { + false + } + fn save_state(&self, out: &mut Vec); + fn load_state(&mut self, data: &[u8]) -> Result<(), String>; +} + +pub(super) struct MapperStateSectionWriter<'a> { + out: &'a mut Vec, +} + +impl<'a> MapperStateSectionWriter<'a> { + pub(super) fn new(out: &'a mut Vec) -> Self { + Self { out } + } + + pub(super) fn write_bytes(&mut self, bytes: &[u8]) { + // Unified section envelope: + // magic (4), version (1), payload_len (4), payload (N). + self.out.extend_from_slice(&MAPPER_STATE_SECTION_MAGIC); + self.out.push(MAPPER_STATE_SECTION_VERSION); + self.out + .extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + self.out.extend_from_slice(bytes); + } +} + +pub(super) struct MapperStateSectionReader<'a> { + data: &'a [u8], + cursor: usize, +} + +impl<'a> MapperStateSectionReader<'a> { + pub(super) fn new(data: &'a [u8]) -> Self { + Self { data, cursor: 0 } + } + + pub(super) fn read_bytes(&mut self) -> Result<&'a [u8], String> { + let rem = self.data.len().saturating_sub(self.cursor); + let (len, header_len) = + if rem >= 9 && self.data[self.cursor..self.cursor + 4] == MAPPER_STATE_SECTION_MAGIC { + let version = self.data[self.cursor + 4]; + if version != MAPPER_STATE_SECTION_VERSION { + return Err(format!( + "unsupported mapper state section version {}", + version + )); + } + let len = u32::from_le_bytes([ + self.data[self.cursor + 5], + self.data[self.cursor + 6], + self.data[self.cursor + 7], + self.data[self.cursor + 8], + ]) as usize; + (len, 9usize) + } else if rem >= 4 { + // Backward-compatible legacy section envelope: + // payload_len (4), payload (N). + let len = u32::from_le_bytes([ + self.data[self.cursor], + self.data[self.cursor + 1], + self.data[self.cursor + 2], + self.data[self.cursor + 3], + ]) as usize; + (len, 4usize) + } else { + return Err("mapper state is truncated".to_string()); + }; + self.cursor += header_len; + let end = self + .cursor + .checked_add(len) + .ok_or_else(|| "mapper state cursor overflow".to_string())?; + if end > self.data.len() { + return Err("mapper state payload has invalid length".to_string()); + } + let out = &self.data[self.cursor..end]; + self.cursor = end; + Ok(out) + } + + pub(super) fn at_end(&self) -> bool { + self.cursor == self.data.len() + } +} + +pub(super) fn safe_mod(value: usize, modulo: usize) -> usize { + if modulo == 0 { 0 } else { value % modulo } +} + +pub(super) fn read_bank(data: &[u8], bank_size: usize, bank: usize, offset: usize) -> u8 { + if data.is_empty() || bank_size == 0 { + return 0; + } + let total_banks = data.len() / bank_size; + let bank_idx = safe_mod(bank, total_banks.max(1)); + let idx = bank_idx * bank_size + safe_mod(offset, bank_size); + data.get(idx).copied().unwrap_or(0) +} + +pub(super) fn write_state_bytes(out: &mut Vec, bytes: &[u8]) { + MapperStateSectionWriter::new(out).write_bytes(bytes); +} + +pub(super) fn read_state_bytes<'a>(data: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], String> { + let mut rd = MapperStateSectionReader { + data, + cursor: *cursor, + }; + let payload = rd.read_bytes()?; + *cursor = rd.cursor; + Ok(payload) +} + +pub(super) fn write_chr_state(out: &mut Vec, chr_data: &[u8]) { + write_state_bytes(out, chr_data); +} + +pub(super) fn load_chr_state(chr_data: &mut [u8], data: &[u8]) -> Result<(), String> { + let mut rd = MapperStateSectionReader::new(data); + let payload = rd.read_bytes()?; + if payload.len() != chr_data.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + chr_data.copy_from_slice(payload); + if !rd.at_end() { + return Err("mapper state has trailing bytes".to_string()); + } + Ok(()) +} + +pub(super) fn encode_mirroring(mirroring: Mirroring) -> u8 { + match mirroring { + Mirroring::Horizontal => 0, + Mirroring::Vertical => 1, + Mirroring::FourScreen => 2, + Mirroring::OneScreenLow => 3, + Mirroring::OneScreenHigh => 4, + } +} + +pub(super) fn decode_mirroring(value: u8) -> Mirroring { + match value { + 1 => Mirroring::Vertical, + 2 => Mirroring::FourScreen, + 3 => Mirroring::OneScreenLow, + 4 => Mirroring::OneScreenHigh, + _ => Mirroring::Horizontal, + } +} + +pub(super) struct VrcIrqRegisters<'a> { + pub latch: &'a mut u8, + pub counter: &'a mut u8, + pub enabled: &'a mut bool, + pub enabled_after_ack: &'a mut bool, + pub mode_cpu: &'a mut bool, + pub pending: &'a mut bool, + pub prescaler: &'a mut i16, +} + +pub(super) fn vrc_irq_tick(counter: &mut u8, latch: u8, pending: &mut bool) { + if *counter == 0xFF { + *counter = latch; + *pending = true; + } else { + *counter = counter.wrapping_add(1); + } +} + +pub(super) fn vrc_irq_write_control(value: u8, irq: VrcIrqRegisters<'_>) { + let VrcIrqRegisters { + latch, + counter, + enabled, + enabled_after_ack, + mode_cpu, + pending, + prescaler, + } = irq; + *mode_cpu = (value & 0x04) != 0; + *enabled = (value & 0x02) != 0; + *enabled_after_ack = (value & 0x01) != 0; + *pending = false; + *prescaler = 341; + if *enabled { + *counter = *latch; + } +} + +pub(super) fn vrc_irq_ack(irq: VrcIrqRegisters<'_>) { + let VrcIrqRegisters { + enabled, + enabled_after_ack, + pending, + prescaler, + .. + } = irq; + *pending = false; + *enabled = *enabled_after_ack; + *prescaler = 341; +} + +pub(super) fn vrc_irq_clock(cycles: u8, irq: VrcIrqRegisters<'_>) { + let VrcIrqRegisters { + latch, + counter, + enabled, + mode_cpu, + pending, + prescaler, + .. + } = irq; + if !*enabled { + return; + } + for _ in 0..cycles { + if *mode_cpu { + vrc_irq_tick(counter, *latch, pending); + } else { + *prescaler -= 3; + if *prescaler <= 0 { + *prescaler += 341; + vrc_irq_tick(counter, *latch, pending); + } + } + } +} diff --git a/src/native_core/mapper/mappers/axrom.rs b/src/native_core/mapper/mappers/axrom.rs new file mode 100644 index 0000000..0336283 --- /dev/null +++ b/src/native_core/mapper/mappers/axrom.rs @@ -0,0 +1,91 @@ +use super::*; + +pub(crate) struct Axrom { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + bank_select: u8, + one_screen_hi: bool, + bus_conflicts_and: bool, +} + +impl Axrom { + pub(crate) fn new(rom: InesRom) -> Self { + let bus_conflicts_and = rom.header.mapper == 7 && rom.header.submapper == 2; + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + bank_select: 0, + one_screen_hi: false, + bus_conflicts_and, + } + } +} + +impl Mapper for Axrom { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + self.bank_select as usize, + (addr as usize) - 0x8000, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + let latched = if self.bus_conflicts_and { + value & self.cpu_read(addr) + } else { + value + }; + self.bank_select = latched & 0x07; + self.one_screen_hi = (latched & 0x10) != 0; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + if self.one_screen_hi { + Mirroring::OneScreenHigh + } else { + Mirroring::OneScreenLow + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.bank_select); + out.push(u8::from(self.one_screen_hi)); + out.push(u8::from(self.bus_conflicts_and)); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 3 { + return Err("mapper state is truncated".to_string()); + } + self.bank_select = data[0]; + self.one_screen_hi = data[1] != 0; + self.bus_conflicts_and = data[2] != 0; + load_chr_state(&mut self.chr_data, &data[3..]) + } +} diff --git a/src/native_core/mapper/mappers/bandai70_152.rs b/src/native_core/mapper/mappers/bandai70_152.rs new file mode 100644 index 0000000..70f4cb4 --- /dev/null +++ b/src/native_core/mapper/mappers/bandai70_152.rs @@ -0,0 +1,119 @@ +use super::*; + +pub(crate) struct Bandai70_152 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring_default: Mirroring, + prg_bank: u8, + chr_bank: u8, + one_screen_hi: Option, +} + +impl Bandai70_152 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring_default: rom.header.mirroring, + prg_bank: 0, + chr_bank: 0, + one_screen_hi: None, + } + } + + fn prg_bank_count_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } +} + +impl Mapper for Bandai70_152 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank_count_16k().saturating_sub(1), + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr < 0x8000 { + return; + } + self.prg_bank = value & 0x0F; + self.chr_bank = (value >> 4) & 0x0F; + self.one_screen_hi = Some((value & 0x80) != 0); + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let bank_idx = safe_mod(self.chr_bank as usize, total_banks); + let idx = bank_idx * 0x2000 + ((addr as usize) & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match self.one_screen_hi { + Some(true) => Mirroring::OneScreenHigh, + Some(false) => Mirroring::OneScreenLow, + None => self.mirroring_default, + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + out.push(match self.one_screen_hi { + Some(true) => 2, + Some(false) => 1, + None => 0, + }); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 3 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + self.one_screen_hi = match data[2] { + 0 => None, + 1 => Some(false), + 2 => Some(true), + _ => None, + }; + load_chr_state(&mut self.chr_data, &data[3..]) + } +} diff --git a/src/native_core/mapper/mappers/bnrom34.rs b/src/native_core/mapper/mappers/bnrom34.rs new file mode 100644 index 0000000..38334b9 --- /dev/null +++ b/src/native_core/mapper/mappers/bnrom34.rs @@ -0,0 +1,112 @@ +use super::*; + +pub(crate) struct Bnrom34 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_bank: u8, + chr_bank_0_4k: u8, + chr_bank_1_4k: u8, +} + +impl Bnrom34 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_bank: 0, + chr_bank_0_4k: 0, + chr_bank_1_4k: 1, + } + } +} + +impl Mapper for Bnrom34 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.prg_bank = value & 0x0F; + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + match addr { + 0x7FFD => { + self.prg_bank = value & 0x0F; + true + } + 0x7FFE => { + self.chr_bank_0_4k = value & 0x0F; + true + } + 0x7FFF => { + self.chr_bank_1_4k = value & 0x0F; + true + } + _ => false, + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let bank = if addr < 0x1000 { + self.chr_bank_0_4k as usize + } else { + self.chr_bank_1_4k as usize + }; + read_bank(&self.chr_data, 0x1000, bank, (addr as usize) & 0x0FFF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let bank = if addr < 0x1000 { + self.chr_bank_0_4k as usize + } else { + self.chr_bank_1_4k as usize + }; + let total = (self.chr_data.len() / 0x1000).max(1); + let idx = safe_mod(bank, total) * 0x1000 + ((addr as usize) & 0x0FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank_0_4k); + out.push(self.chr_bank_1_4k); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 3 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank_0_4k = data[1]; + self.chr_bank_1_4k = data[2]; + load_chr_state(&mut self.chr_data, &data[3..]) + } +} diff --git a/src/native_core/mapper/mappers/camerica71.rs b/src/native_core/mapper/mappers/camerica71.rs new file mode 100644 index 0000000..30c8290 --- /dev/null +++ b/src/native_core/mapper/mappers/camerica71.rs @@ -0,0 +1,114 @@ +use super::*; + +pub(crate) struct Camerica71 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + submapper: u8, + mirroring_default: Mirroring, + prg_bank: u8, + one_screen_hi: Option, +} + +impl Camerica71 { + pub(crate) fn new(rom: InesRom) -> Self { + let submapper = rom.header.submapper; + let one_screen_hi = if submapper == 1 { Some(false) } else { None }; + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + submapper, + mirroring_default: rom.header.mirroring, + prg_bank: 0, + one_screen_hi, + } + } + + fn prg_bank_count_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } +} + +impl Mapper for Camerica71 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank_count_16k() - 1, + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if self.submapper == 1 && (0x9000..=0x9FFF).contains(&addr) { + self.one_screen_hi = Some((value & 0x10) != 0); + return; + } + if addr >= 0x8000 { + self.prg_bank = value & 0x0F; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match self.one_screen_hi { + Some(true) => Mirroring::OneScreenHigh, + Some(false) => Mirroring::OneScreenLow, + None => self.mirroring_default, + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.submapper); + out.push(self.prg_bank); + out.push(match self.one_screen_hi { + Some(true) => 2, + Some(false) => 1, + None => 0, + }); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 3 { + return Err("mapper state is truncated".to_string()); + } + self.submapper = data[0]; + self.prg_bank = data[1]; + self.one_screen_hi = match data[2] { + 0 => None, + 1 => Some(false), + 2 => Some(true), + _ => None, + }; + load_chr_state(&mut self.chr_data, &data[3..]) + } +} diff --git a/src/native_core/mapper/mappers/cnrom.rs b/src/native_core/mapper/mappers/cnrom.rs new file mode 100644 index 0000000..e69177a --- /dev/null +++ b/src/native_core/mapper/mappers/cnrom.rs @@ -0,0 +1,100 @@ +use super::*; + +pub(crate) struct Cnrom { + submapper: u8, + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + chr_bank: u8, + bus_conflicts_and: bool, + mirroring: Mirroring, +} + +impl Cnrom { + pub(crate) fn new(rom: InesRom) -> Self { + let bus_conflicts_and = rom.header.mapper == 3 && rom.header.submapper == 2; + Self { + submapper: rom.header.submapper, + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + chr_bank: 0, + bus_conflicts_and, + mirroring: rom.header.mirroring, + } + } + + fn chr_banks(&self) -> usize { + (self.chr_data.len() / 0x2000).max(1) + } +} + +impl Mapper for Cnrom { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x4000, + ((addr - 0x8000) as usize) / 0x4000, + addr as usize, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + let value = if self.bus_conflicts_and { + value & self.cpu_read(addr) + } else { + value + }; + let max = self.chr_banks() as u8; + self.chr_bank = if max == 0 { 0 } else { value % max }; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let bank = safe_mod(self.chr_bank as usize, self.chr_banks()); + let idx = bank * 0x2000 + (addr as usize & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.chr_bank); + out.push(self.submapper); + out.push(u8::from(self.bus_conflicts_and)); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 3 { + return Err("mapper state is truncated".to_string()); + } + self.chr_bank = data[0]; + self.submapper = data[1]; + self.bus_conflicts_and = data[2] != 0; + load_chr_state(&mut self.chr_data, &data[3..]) + } +} diff --git a/src/native_core/mapper/mappers/color_dreams11.rs b/src/native_core/mapper/mappers/color_dreams11.rs new file mode 100644 index 0000000..c8c2192 --- /dev/null +++ b/src/native_core/mapper/mappers/color_dreams11.rs @@ -0,0 +1,88 @@ +use super::*; + +pub(crate) struct ColorDreams11 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_bank: u8, + chr_bank: u8, +} + +impl ColorDreams11 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_bank: 0, + chr_bank: 0, + } + } +} + +impl Mapper for ColorDreams11 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + let latched = value & self.cpu_read(addr); + self.prg_bank = latched & 0x03; + self.chr_bank = (latched >> 4) & 0x0F; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let bank_idx = safe_mod(self.chr_bank as usize, total_banks); + let idx = bank_idx * 0x2000 + (addr as usize & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 2 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + load_chr_state(&mut self.chr_data, &data[2..]) + } +} diff --git a/src/native_core/mapper/mappers/cprom13.rs b/src/native_core/mapper/mappers/cprom13.rs new file mode 100644 index 0000000..56001b2 --- /dev/null +++ b/src/native_core/mapper/mappers/cprom13.rs @@ -0,0 +1,88 @@ +use super::*; + +pub(crate) struct Cprom13 { + prg_rom: Vec, + chr_ram: Vec, + mirroring: Mirroring, + chr_bank_hi_4k: u8, +} + +impl Cprom13 { + pub(crate) fn new(rom: InesRom) -> Self { + let mut chr_ram = rom.chr_data; + if chr_ram.len() < 0x4000 { + chr_ram.resize(0x4000, 0); + } + Self { + prg_rom: rom.prg_rom, + chr_ram, + mirroring: rom.header.mirroring, + chr_bank_hi_4k: 0, + } + } +} + +impl Mapper for Cprom13 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x4000, + ((addr - 0x8000) as usize) / 0x4000, + addr as usize, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.chr_bank_hi_4k = value & 0x03; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let bank = if addr < 0x1000 { + 0usize + } else { + self.chr_bank_hi_4k as usize + }; + read_bank(&self.chr_ram, 0x1000, bank, (addr as usize) & 0x0FFF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if addr > 0x1FFF { + return; + } + let bank = if addr < 0x1000 { + 0usize + } else { + self.chr_bank_hi_4k as usize + }; + let total = (self.chr_ram.len() / 0x1000).max(1); + let idx = safe_mod(bank, total) * 0x1000 + ((addr as usize) & 0x0FFF); + if let Some(cell) = self.chr_ram.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.chr_bank_hi_4k); + write_chr_state(out, &self.chr_ram); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.is_empty() { + return Err("mapper state is truncated".to_string()); + } + self.chr_bank_hi_4k = data[0]; + load_chr_state(&mut self.chr_ram, &data[1..]) + } +} diff --git a/src/native_core/mapper/mappers/crazy_climber180.rs b/src/native_core/mapper/mappers/crazy_climber180.rs new file mode 100644 index 0000000..9ff6e70 --- /dev/null +++ b/src/native_core/mapper/mappers/crazy_climber180.rs @@ -0,0 +1,78 @@ +use super::*; + +pub(crate) struct CrazyClimber180 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + bank_select: u8, +} + +impl CrazyClimber180 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + bank_select: 0, + } + } +} + +impl Mapper for CrazyClimber180 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank(&self.prg_rom, 0x4000, 0, (addr as usize) - 0x8000) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.bank_select as usize, + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.bank_select = value & 0x07; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.bank_select); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.is_empty() { + return Err("mapper state is truncated".to_string()); + } + self.bank_select = data[0]; + load_chr_state(&mut self.chr_data, &data[1..]) + } +} diff --git a/src/native_core/mapper/mappers/fme7.rs b/src/native_core/mapper/mappers/fme7.rs new file mode 100644 index 0000000..d5595c5 --- /dev/null +++ b/src/native_core/mapper/mappers/fme7.rs @@ -0,0 +1,19 @@ +use super::*; + +pub(crate) struct Fme7 { + pub(super) prg_rom: Vec, + pub(super) chr_data: Vec, + pub(super) chr_is_ram: bool, + pub(super) mirroring: Mirroring, + pub(super) command: u8, + pub(super) chr_banks: [u8; 8], + pub(super) prg_banks: [u8; 3], + pub(super) low_bank: u8, + pub(super) low_is_ram: bool, + pub(super) low_ram_enabled: bool, + pub(super) low_ram: Vec, + pub(super) irq_counter: u16, + pub(super) irq_enabled: bool, + pub(super) irq_counter_enabled: bool, + pub(super) irq_pending: bool, +} diff --git a/src/native_core/mapper/mappers/gxrom.rs b/src/native_core/mapper/mappers/gxrom.rs new file mode 100644 index 0000000..76e60fc --- /dev/null +++ b/src/native_core/mapper/mappers/gxrom.rs @@ -0,0 +1,85 @@ +use super::*; + +pub(crate) struct Gxrom { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + prg_bank: u8, + chr_bank: u8, + mirroring: Mirroring, +} + +impl Gxrom { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + prg_bank: 0, + chr_bank: 0, + mirroring: rom.header.mirroring, + } + } +} + +impl Mapper for Gxrom { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.prg_bank = (value >> 4) & 0x03; + self.chr_bank = value & 0x03; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total = (self.chr_data.len() / 0x2000).max(1); + let idx = safe_mod(self.chr_bank as usize, total) * 0x2000 + (addr as usize & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() != 2 { + return Err("mapper state payload has invalid length".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/mapper105.rs b/src/native_core/mapper/mappers/mapper105.rs new file mode 100644 index 0000000..2fe98e2 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper105.rs @@ -0,0 +1,295 @@ +use super::*; + +pub(crate) struct InesMapper105 { + prg_rom: Vec, + chr_data: Vec, + prg_ram: Vec, + shift_reg: u8, + shift_count: u8, + control: u8, + reg_a: u8, + reg_b: u8, + wram_disabled: bool, + prg_unlocked: bool, + saw_i_low: bool, + irq_counter: u32, // 30-bit counter + irq_pending: bool, +} + +impl InesMapper105 { + pub(crate) const IRQ_THRESHOLD: u32 = 0x2800_0000; // Official Nintendo World Championships DIP setting. + + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: if rom.chr_data.is_empty() { + vec![0; 0x2000] + } else { + rom.chr_data + }, + prg_ram: vec![0; 0x2000], + shift_reg: 0, + shift_count: 0, + control: 0x0C, + reg_a: 0x10, // I bit high after reset keeps timer halted. + reg_b: 0, + wram_disabled: false, + prg_unlocked: false, + saw_i_low: false, + irq_counter: 0, + irq_pending: false, + } + } + + fn prg_mode(&self) -> u8 { + (self.control >> 2) & 0x03 + } + + fn timer_halted(&self) -> bool { + (self.reg_a & 0x10) != 0 + } + + fn outer_chip_selected(&self) -> bool { + (self.reg_a & 0x08) != 0 + } + + fn outer_32k_bank(&self) -> usize { + ((self.reg_a >> 1) & 0x03) as usize + } + + fn prg_bank_value(&self) -> usize { + (self.reg_b & 0x0F) as usize + } + + fn prg_bank_count_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } + + fn first_chip_banks_16k(&self) -> usize { + (self.prg_bank_count_16k() / 2).max(1) + } + + fn second_chip_base_16k(&self) -> usize { + self.first_chip_banks_16k() + } + + fn second_chip_banks_16k(&self) -> usize { + self.prg_bank_count_16k() + .saturating_sub(self.second_chip_base_16k()) + .max(1) + } + + fn on_reg_a_write(&mut self, value: u8) { + let previous_i = (self.reg_a & 0x10) != 0; + let next_i = (value & 0x10) != 0; + self.reg_a = value & 0x1F; + + if !next_i { + self.saw_i_low = true; + } + if previous_i && !next_i { + self.irq_pending = false; + } + if !previous_i && next_i && self.saw_i_low { + self.prg_unlocked = true; + self.irq_pending = false; + } + if next_i { + self.irq_counter = 0; + self.irq_pending = false; + } + } + + fn write_serial(&mut self, addr: u16, value: u8) { + if (value & 0x80) != 0 { + self.shift_reg = 0; + self.shift_count = 0; + self.control |= 0x0C; + return; + } + + self.shift_reg |= (value & 1) << self.shift_count; + self.shift_count = self.shift_count.wrapping_add(1); + if self.shift_count < 5 { + return; + } + + let latched = self.shift_reg & 0x1F; + match (addr >> 13) & 0x03 { + 0 => self.control = latched, + 1 => self.on_reg_a_write(latched), + 2 => {} + _ => { + self.reg_b = latched; + self.wram_disabled = (latched & 0x10) != 0; + } + } + + self.shift_reg = 0; + self.shift_count = 0; + } +} + +impl Mapper for InesMapper105 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + + if !self.prg_unlocked { + // Reset/power-on maps a fixed 32KiB window from the first PRG chip. + return read_bank( + &self.prg_rom, + 0x8000, + 0, + (addr as usize).saturating_sub(0x8000), + ); + } + + if !self.outer_chip_selected() { + let bank32 = self.outer_32k_bank(); + return read_bank( + &self.prg_rom, + 0x8000, + bank32, + (addr as usize).saturating_sub(0x8000), + ); + } + + let second_base = self.second_chip_base_16k(); + let second_count = self.second_chip_banks_16k(); + let prg_bank = self.prg_bank_value(); + + let bank16 = match self.prg_mode() { + 0 | 1 => { + let bank32 = prg_bank >> 1; + if addr < 0xC000 { + second_base + safe_mod(bank32 * 2, second_count) + } else { + second_base + safe_mod(bank32 * 2 + 1, second_count) + } + } + 2 => { + if addr < 0xC000 { + second_base + } else { + second_base + safe_mod(prg_bank, second_count) + } + } + _ => { + if addr < 0xC000 { + second_base + safe_mod(prg_bank, second_count) + } else { + second_base + second_count.saturating_sub(1) + } + } + }; + read_bank(&self.prg_rom, 0x4000, bank16, (addr as usize) & 0x3FFF) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.write_serial(addr, value); + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) && !self.wram_disabled { + let idx = (addr as usize) & 0x1FFF; + return self.prg_ram.get(idx).copied(); + } + None + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) && !self.wram_disabled { + let idx = (addr as usize) & 0x1FFF; + if let Some(cell) = self.prg_ram.get_mut(idx) { + *cell = value; + } + return true; + } + false + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match self.control & 0x03 { + 0 => Mirroring::OneScreenLow, + 1 => Mirroring::OneScreenHigh, + 2 => Mirroring::Vertical, + _ => Mirroring::Horizontal, + } + } + + fn clock_cpu(&mut self, cycles: u8) { + if self.timer_halted() || self.irq_pending { + return; + } + let previous = self.irq_counter; + self.irq_counter = (self.irq_counter.wrapping_add(cycles as u32)) & 0x3FFF_FFFF; + if previous < Self::IRQ_THRESHOLD && self.irq_counter >= Self::IRQ_THRESHOLD { + self.irq_pending = true; + } + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.shift_reg); + out.push(self.shift_count); + out.push(self.control); + out.push(self.reg_a); + out.push(self.reg_b); + out.push(u8::from(self.wram_disabled)); + out.push(u8::from(self.prg_unlocked)); + out.push(u8::from(self.saw_i_low)); + out.extend_from_slice(&self.irq_counter.to_le_bytes()); + out.push(u8::from(self.irq_pending)); + write_state_bytes(out, &self.prg_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 13 { + return Err("mapper state is truncated".to_string()); + } + self.shift_reg = data[0]; + self.shift_count = data[1]; + self.control = data[2]; + self.reg_a = data[3]; + self.reg_b = data[4]; + self.wram_disabled = data[5] != 0; + self.prg_unlocked = data[6] != 0; + self.saw_i_low = data[7] != 0; + self.irq_counter = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) & 0x3FFF_FFFF; + self.irq_pending = data[12] != 0; + + let mut cursor = 13usize; + let prg_ram = read_state_bytes(data, &mut cursor)?; + if prg_ram.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram); + load_chr_state(&mut self.chr_data, &data[cursor..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper118.rs b/src/native_core/mapper/mappers/mapper118.rs new file mode 100644 index 0000000..36e9f3c --- /dev/null +++ b/src/native_core/mapper/mappers/mapper118.rs @@ -0,0 +1,77 @@ +use super::*; + +pub(crate) struct InesMapper118 { + mmc3: Mmc3, +} + +impl InesMapper118 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + mmc3: Mmc3::new(rom), + } + } +} + +impl Mapper for InesMapper118 { + fn cpu_read(&self, addr: u16) -> u8 { + self.mmc3.cpu_read(addr) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + self.mmc3.cpu_write(addr, value); + } + + fn cpu_read_low(&self, addr: u16) -> Option { + self.mmc3.cpu_read_low(addr) + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + self.mmc3.cpu_write_low(addr, value) + } + + fn ppu_read(&self, addr: u16) -> u8 { + self.mmc3.ppu_read(addr) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + self.mmc3.ppu_write(addr, value); + } + + fn mirroring(&self) -> Mirroring { + self.mmc3.mirroring() + } + + fn map_nametable_addr(&self, addr: u16) -> Option { + if !(0x2000..=0x3EFF).contains(&addr) { + return None; + } + // TxSROM-class boards route CHR bank bit 7 (A17) to CIRAM A10 for NT fetches. + // The board responds to $2000-$2FFF the same as MMC3's $0000-$0FFF CHR decode. + let rel = (addr - 0x2000) & 0x0FFF; + let page = (rel / 0x0400) as usize; // NT0..NT3 -> 1KB pages 0..3 + let offset = (rel & 0x03FF) as usize; + let bank = self.mmc3.chr_bank_for_1k_page(page) as u8; + let ciram_page = ((bank >> 7) & 1) as usize; + Some(ciram_page * 0x0400 + offset) + } + + fn clock_scanline(&mut self) { + self.mmc3.clock_scanline(); + } + + fn needs_ppu_a12_clock(&self) -> bool { + self.mmc3.needs_ppu_a12_clock() + } + + fn poll_irq(&mut self) -> bool { + self.mmc3.poll_irq() + } + + fn save_state(&self, out: &mut Vec) { + self.mmc3.save_state(out); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + self.mmc3.load_state(data) + } +} diff --git a/src/native_core/mapper/mappers/mapper140.rs b/src/native_core/mapper/mappers/mapper140.rs new file mode 100644 index 0000000..c041e48 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper140.rs @@ -0,0 +1,90 @@ +use super::*; + +pub(crate) struct InesMapper140 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_bank: u8, + chr_bank: u8, +} + +impl InesMapper140 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_bank: 0, + chr_bank: 0, + } + } +} + +impl Mapper for InesMapper140 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if !(0x6000..=0x7FFF).contains(&addr) { + return false; + } + self.chr_bank = value & 0x0F; + self.prg_bank = (value >> 4) & 0x03; + true + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total = (self.chr_data.len() / 0x2000).max(1); + let idx = safe_mod(self.chr_bank as usize, total) * 0x2000 + ((addr as usize) & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 2 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + load_chr_state(&mut self.chr_data, &data[2..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper155.rs b/src/native_core/mapper/mappers/mapper155.rs new file mode 100644 index 0000000..ddc13e7 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper155.rs @@ -0,0 +1,253 @@ +use super::*; + +pub(crate) struct InesMapper155 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + prg_ram: Vec, + shift_reg: u8, + shift_count: u8, + control: u8, + chr_bank0: u8, + chr_bank1: u8, + prg_bank: u8, + mirroring_default: Mirroring, +} + +impl InesMapper155 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + prg_ram: vec![0; 0x2000], + shift_reg: 0, + shift_count: 0, + control: 0x0C, + chr_bank0: 0, + chr_bank1: 0, + prg_bank: 0, + mirroring_default: rom.header.mirroring, + } + } + + fn prg_mode(&self) -> u8 { + (self.control >> 2) & 0x03 + } + + fn chr_mode(&self) -> u8 { + (self.control >> 4) & 1 + } + + fn prg_bank_count_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } + + fn outer_a17_override_enabled(&self) -> bool { + (self.prg_bank & 0x10) != 0 + } + + fn outer_bank_half(&self) -> usize { + ((self.prg_bank >> 3) & 1) as usize + } + + fn map_bank_16k_with_outer(&self, inner_bank: usize) -> usize { + let total = self.prg_bank_count_16k(); + if !self.outer_a17_override_enabled() || total <= 8 { + return safe_mod(inner_bank, total); + } + let half = (total / 2).max(1); + let base = safe_mod(self.outer_bank_half(), 2) * half; + base + safe_mod(inner_bank, half) + } + + fn write_serial(&mut self, addr: u16, value: u8) { + if (value & 0x80) != 0 { + self.shift_reg = 0; + self.shift_count = 0; + self.control |= 0x0C; + return; + } + + self.shift_reg |= (value & 1) << self.shift_count; + self.shift_count = self.shift_count.wrapping_add(1); + if self.shift_count < 5 { + return; + } + + let reg = (addr >> 13) & 0x03; + match reg { + 0 => self.control = self.shift_reg & 0x1F, + 1 => self.chr_bank0 = self.shift_reg & 0x1F, + 2 => self.chr_bank1 = self.shift_reg & 0x1F, + _ => self.prg_bank = self.shift_reg & 0x1F, // MMC1A uses bit 4 for A17 behavior + } + self.shift_reg = 0; + self.shift_count = 0; + } +} + +impl Mapper for InesMapper155 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + + let prg_mode = self.prg_mode(); + if prg_mode <= 1 { + let bank32 = (self.prg_bank as usize) >> 1; + let bank16 = bank32 * 2 + usize::from(addr >= 0xC000); + read_bank( + &self.prg_rom, + 0x4000, + self.map_bank_16k_with_outer(bank16), + (addr as usize) & 0x3FFF, + ) + } else if prg_mode == 2 { + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.map_bank_16k_with_outer(0), + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.map_bank_16k_with_outer((self.prg_bank & 0x0F) as usize), + (addr as usize) - 0xC000, + ) + } + } else if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.map_bank_16k_with_outer((self.prg_bank & 0x0F) as usize), + (addr as usize) - 0x8000, + ) + } else { + let fixed_last = if self.outer_a17_override_enabled() && self.prg_bank_count_16k() > 8 { + self.prg_bank_count_16k() / 2 - 1 + } else { + self.prg_bank_count_16k().saturating_sub(1) + }; + read_bank( + &self.prg_rom, + 0x4000, + self.map_bank_16k_with_outer(fixed_last), + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.write_serial(addr, value); + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) { + return self.prg_ram.get((addr as usize) & 0x1FFF).copied(); + } + None + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) { + if let Some(cell) = self.prg_ram.get_mut((addr as usize) & 0x1FFF) { + *cell = value; + } + return true; + } + false + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + if self.chr_mode() == 0 { + let bank = (self.chr_bank0 as usize) >> 1; + read_bank(&self.chr_data, 0x2000, bank, addr as usize) + } else if addr < 0x1000 { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank0 as usize, + addr as usize, + ) + } else { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank1 as usize, + (addr as usize) - 0x1000, + ) + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let idx = if self.chr_mode() == 0 { + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let bank = safe_mod((self.chr_bank0 as usize) >> 1, total_banks); + bank * 0x2000 + ((addr as usize) & 0x1FFF) + } else { + let total_banks = (self.chr_data.len() / 0x1000).max(1); + let bank = if addr < 0x1000 { + self.chr_bank0 as usize + } else { + self.chr_bank1 as usize + }; + safe_mod(bank, total_banks) * 0x1000 + ((addr as usize) & 0x0FFF) + }; + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match self.control & 0x03 { + 0 => Mirroring::OneScreenLow, + 1 => Mirroring::OneScreenHigh, + 2 => Mirroring::Vertical, + 3 => Mirroring::Horizontal, + _ => self.mirroring_default, + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.shift_reg); + out.push(self.shift_count); + out.push(self.control); + out.push(self.chr_bank0); + out.push(self.chr_bank1); + out.push(self.prg_bank); + write_state_bytes(out, &self.prg_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 6 { + return Err("mapper state is truncated".to_string()); + } + self.shift_reg = data[0]; + self.shift_count = data[1]; + self.control = data[2]; + self.chr_bank0 = data[3]; + self.chr_bank1 = data[4]; + self.prg_bank = data[5]; + + let mut cursor = 6usize; + let prg_ram = read_state_bytes(data, &mut cursor)?; + if prg_ram.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram); + load_chr_state(&mut self.chr_data, &data[cursor..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper158.rs b/src/native_core/mapper/mappers/mapper158.rs new file mode 100644 index 0000000..a5cb6d6 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper158.rs @@ -0,0 +1,75 @@ +use super::*; + +pub(crate) struct InesMapper158 { + base: InesMapper64, +} + +impl InesMapper158 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + base: InesMapper64::new(rom), + } + } +} + +impl Mapper for InesMapper158 { + fn cpu_read(&self, addr: u16) -> u8 { + self.base.cpu_read(addr) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + // Mapper 158 uses CHR-bank controlled nametable routing instead of $A000 mirroring writes. + if (0xA000..=0xBFFF).contains(&addr) && (addr & 1) == 0 { + return; + } + self.base.cpu_write(addr, value); + } + + fn ppu_read(&self, addr: u16) -> u8 { + self.base.ppu_read(addr) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + self.base.ppu_write(addr, value); + } + + fn mirroring(&self) -> Mirroring { + Mirroring::Horizontal + } + + fn map_nametable_addr(&self, addr: u16) -> Option { + if !(0x2000..=0x3EFF).contains(&addr) { + return None; + } + let rel = (addr - 0x2000) & 0x0FFF; + let page = (rel / 0x0400) as usize; // NT0..NT3 + let offset = (rel & 0x03FF) as usize; + let chr_bank = self.base.chr_bank_for_page(page) as u8; + let ciram_page = ((chr_bank >> 7) & 1) as usize; + Some(ciram_page * 0x0400 + offset) + } + + fn clock_cpu(&mut self, cycles: u8) { + self.base.clock_cpu(cycles); + } + + fn clock_scanline(&mut self) { + self.base.clock_scanline(); + } + + fn needs_ppu_a12_clock(&self) -> bool { + self.base.needs_ppu_a12_clock() + } + + fn poll_irq(&mut self) -> bool { + self.base.poll_irq() + } + + fn save_state(&self, out: &mut Vec) { + self.base.save_state(out); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + self.base.load_state(data) + } +} diff --git a/src/native_core/mapper/mappers/mapper184.rs b/src/native_core/mapper/mappers/mapper184.rs new file mode 100644 index 0000000..3e5d81d --- /dev/null +++ b/src/native_core/mapper/mappers/mapper184.rs @@ -0,0 +1,95 @@ +use super::*; + +pub(crate) struct InesMapper184 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + chr_bank_lo_4k: u8, + chr_bank_hi_4k: u8, +} + +impl InesMapper184 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + chr_bank_lo_4k: 0, + chr_bank_hi_4k: 4, + } + } +} + +impl Mapper for InesMapper184 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x4000, + ((addr - 0x8000) as usize) / 0x4000, + addr as usize, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if !(0x6000..=0x7FFF).contains(&addr) { + return false; + } + self.chr_bank_lo_4k = value & 0x07; + self.chr_bank_hi_4k = 0x04 | ((value >> 4) & 0x07); + true + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let bank = if addr < 0x1000 { + self.chr_bank_lo_4k as usize + } else { + self.chr_bank_hi_4k as usize + }; + read_bank(&self.chr_data, 0x1000, bank, (addr as usize) & 0x0FFF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let bank = if addr < 0x1000 { + self.chr_bank_lo_4k as usize + } else { + self.chr_bank_hi_4k as usize + }; + let total = (self.chr_data.len() / 0x1000).max(1); + let idx = safe_mod(bank, total) * 0x1000 + ((addr as usize) & 0x0FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.chr_bank_lo_4k); + out.push(self.chr_bank_hi_4k); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 2 { + return Err("mapper state is truncated".to_string()); + } + self.chr_bank_lo_4k = data[0]; + self.chr_bank_hi_4k = data[1]; + load_chr_state(&mut self.chr_data, &data[2..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper185.rs b/src/native_core/mapper/mappers/mapper185.rs new file mode 100644 index 0000000..325da41 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper185.rs @@ -0,0 +1,92 @@ +use super::*; + +pub(crate) struct InesMapper185 { + submapper: u8, + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + latch: u8, +} + +impl InesMapper185 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + submapper: rom.header.submapper, + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + latch: 0, + } + } + + fn chr_enabled(&self) -> bool { + match self.submapper { + 4..=7 => (self.latch & 0x03) == (self.submapper - 4), + _ => true, + } + } +} + +impl Mapper for InesMapper185 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x4000, + ((addr - 0x8000) as usize) / 0x4000, + addr as usize, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + // Mapper 185 always has AND bus conflicts. + self.latch = value & self.cpu_read(addr); + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + if !self.chr_enabled() { + return 0xFF; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if !self.chr_enabled() { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.submapper); + out.push(self.latch); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 2 { + return Err("mapper state is truncated".to_string()); + } + self.submapper = data[0]; + self.latch = data[1]; + load_chr_state(&mut self.chr_data, &data[2..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper206.rs b/src/native_core/mapper/mappers/mapper206.rs new file mode 100644 index 0000000..ab82971 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper206.rs @@ -0,0 +1,127 @@ +use super::*; + +pub(crate) struct InesMapper206 { + pub(super) prg_rom: Vec, + pub(super) chr_data: Vec, + pub(super) chr_is_ram: bool, + pub(super) mirroring: Mirroring, + pub(super) bank_regs: [u8; 8], + pub(super) bank_select: u8, +} + +impl InesMapper206 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + bank_regs: [0; 8], + bank_select: 0, + } + } + + pub(super) fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + pub(super) fn prg_bank_for_slot(&self, slot: usize) -> usize { + let last = self.prg_bank_count_8k() - 1; + let second_last = last.saturating_sub(1); + match slot { + 0 => self.bank_regs[6] as usize, + 1 => self.bank_regs[7] as usize, + 2 => second_last, + 3 => last, + _ => 0, + } + } + + pub(super) fn chr_bank_for_1k_page(&self, page: usize) -> u8 { + let regs = &self.bank_regs; + match page { + 0 => regs[0] & !1, + 1 => (regs[0] & !1).wrapping_add(1), + 2 => regs[1] & !1, + 3 => (regs[1] & !1).wrapping_add(1), + 4 => regs[2], + 5 => regs[3], + 6 => regs[4], + _ => regs[5], + } + } +} + +impl Mapper for InesMapper206 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = self.prg_bank_for_slot(slot); + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0x9FFF if (addr & 1) == 0 => self.bank_select = value & 0x07, + 0x8000..=0x9FFF => { + let reg = (self.bank_select & 0x07) as usize; + self.bank_regs[reg] = value; + } + _ => {} + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_1k_page(page); + read_bank( + &self.chr_data, + 0x0400, + bank as usize, + (addr as usize) & 0x03FF, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_1k_page(page) as usize; + let total_banks = (self.chr_data.len() / 0x0400).max(1); + let bank_idx = safe_mod(bank, total_banks); + let idx = bank_idx * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.extend_from_slice(&self.bank_regs); + out.push(self.bank_select); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 9 { + return Err("mapper state is truncated".to_string()); + } + self.bank_regs.copy_from_slice(&data[0..8]); + self.bank_select = data[8]; + load_chr_state(&mut self.chr_data, &data[9..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper206_submapper1.rs b/src/native_core/mapper/mappers/mapper206_submapper1.rs new file mode 100644 index 0000000..7c648d0 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper206_submapper1.rs @@ -0,0 +1,63 @@ +use super::*; + +pub(crate) struct InesMapper206Submapper1 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, +} + +impl InesMapper206Submapper1 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + } + } +} + +impl Mapper for InesMapper206Submapper1 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + 0, + ((addr as usize) - 0x8000) & 0x7FFF, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + load_chr_state(&mut self.chr_data, data) + } +} diff --git a/src/native_core/mapper/mappers/mapper253.rs b/src/native_core/mapper/mappers/mapper253.rs new file mode 100644 index 0000000..a3725e4 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper253.rs @@ -0,0 +1,313 @@ +use super::*; + +pub(crate) struct InesMapper253 { + base: Vrc2_23, + chr_ram_2k: [u8; 0x800], // PPU pages 4/5 ($1000-$17FF): on-cart CHR-RAM overlay +} + +impl InesMapper253 { + pub(crate) fn new(rom: InesRom) -> Self { + let mut base = Vrc2_23::new_with_submapper(rom, 2); // VRC4e-style decode + base.mapper_id = 23; + Self { + base, + chr_ram_2k: [0; 0x800], + } + } +} + +impl Mapper for InesMapper253 { + fn cpu_read(&self, addr: u16) -> u8 { + self.base.cpu_read(addr) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + self.base.cpu_write(addr, value); + } + + fn cpu_read_low(&self, addr: u16) -> Option { + self.base.cpu_read_low(addr) + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + self.base.cpu_write_low(addr, value) + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + if (0x1000..0x1800).contains(&addr) { + return self.chr_ram_2k[(addr as usize) - 0x1000]; + } + self.base.ppu_read(addr) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if addr > 0x1FFF { + return; + } + if (0x1000..0x1800).contains(&addr) { + self.chr_ram_2k[(addr as usize) - 0x1000] = value; + return; + } + self.base.ppu_write(addr, value); + } + + fn mirroring(&self) -> Mirroring { + self.base.mirroring() + } + + fn clock_cpu(&mut self, cycles: u8) { + self.base.clock_cpu(cycles); + } + + fn poll_irq(&mut self) -> bool { + self.base.poll_irq() + } + + fn save_state(&self, out: &mut Vec) { + let mut base_state = Vec::new(); + self.base.save_state(&mut base_state); + write_state_bytes(out, &base_state); + out.extend_from_slice(&self.chr_ram_2k); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + let mut cursor = 0usize; + let base_state = read_state_bytes(data, &mut cursor)?; + if data.len().saturating_sub(cursor) != self.chr_ram_2k.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.base.load_state(base_state)?; + self.chr_ram_2k.copy_from_slice(&data[cursor..]); + Ok(()) + } +} + +impl Fme7 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + command: 0, + chr_banks: [0; 8], + prg_banks: [0, 1, 0xFE], + low_bank: 0, + low_is_ram: false, + low_ram_enabled: false, + low_ram: vec![0; 0x8000], + irq_counter: 0, + irq_enabled: false, + irq_counter_enabled: false, + irq_pending: false, + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + fn low_ram_index(&self, addr: u16) -> usize { + let bank = (self.low_bank & 0x03) as usize; + bank * 0x2000 + ((addr as usize) & 0x1FFF) + } +} + +impl Mapper for Fme7 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let bank = match ((addr - 0x8000) / 0x2000) as usize { + 0 => self.prg_banks[0] as usize, + 1 => self.prg_banks[1] as usize, + 2 => self.prg_banks[2] as usize, + _ => self.prg_bank_count_8k().saturating_sub(1), + }; + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if (0x8000..=0x9FFF).contains(&addr) { + self.command = value & 0x0F; + return; + } + if !(0xA000..=0xBFFF).contains(&addr) { + return; + } + + match self.command { + 0x0..=0x7 => self.chr_banks[self.command as usize] = value, + 0x8 => { + self.low_bank = value & 0x3F; + self.low_is_ram = (value & 0x40) != 0; + self.low_ram_enabled = (value & 0x80) != 0; + } + 0x9 => self.prg_banks[0] = value & 0x3F, + 0xA => self.prg_banks[1] = value & 0x3F, + 0xB => self.prg_banks[2] = value & 0x3F, + 0xC => { + self.mirroring = match value & 0x03 { + 0 => Mirroring::Vertical, + 1 => Mirroring::Horizontal, + 2 => Mirroring::OneScreenLow, + _ => Mirroring::OneScreenHigh, + }; + } + 0xD => { + self.irq_enabled = (value & 0x01) != 0; + self.irq_counter_enabled = (value & 0x80) != 0; + if !self.irq_enabled { + self.irq_pending = false; + } + } + 0xE => { + self.irq_counter = (self.irq_counter & 0xFF00) | value as u16; + self.irq_pending = false; + } + 0xF => { + self.irq_counter = (self.irq_counter & 0x00FF) | ((value as u16) << 8); + self.irq_pending = false; + } + _ => {} + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if !(0x6000..=0x7FFF).contains(&addr) { + return None; + } + if self.low_is_ram && self.low_ram_enabled { + return Some(self.low_ram[self.low_ram_index(addr)]); + } + if self.low_is_ram { + return Some(0); + } + Some(read_bank( + &self.prg_rom, + 0x2000, + self.low_bank as usize, + (addr as usize) & 0x1FFF, + )) + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if !(0x6000..=0x7FFF).contains(&addr) { + return false; + } + if self.low_is_ram && self.low_ram_enabled { + let idx = self.low_ram_index(addr); + self.low_ram[idx] = value; + } + true + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_banks[page] as usize; + read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_banks[page] as usize; + let total_banks = (self.chr_data.len() / 0x0400).max(1); + let bank_idx = safe_mod(bank, total_banks); + let idx = bank_idx * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn clock_cpu(&mut self, cycles: u8) { + if !self.irq_counter_enabled { + return; + } + for _ in 0..cycles { + if self.irq_counter == 0 { + self.irq_counter = 0xFFFF; + if self.irq_enabled { + self.irq_pending = true; + } + } else { + self.irq_counter = self.irq_counter.wrapping_sub(1); + } + } + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.command); + out.extend_from_slice(&self.chr_banks); + out.extend_from_slice(&self.prg_banks); + out.push(self.low_bank); + out.push(u8::from(self.low_is_ram)); + out.push(u8::from(self.low_ram_enabled)); + out.extend_from_slice(&self.irq_counter.to_le_bytes()); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_counter_enabled)); + out.push(u8::from(self.irq_pending)); + out.push(encode_mirroring(self.mirroring)); + write_state_bytes(out, &self.low_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 21 { + return Err("mapper state is truncated".to_string()); + } + let mut cursor = 0usize; + self.command = data[cursor]; + cursor += 1; + self.chr_banks.copy_from_slice(&data[cursor..cursor + 8]); + cursor += 8; + self.prg_banks.copy_from_slice(&data[cursor..cursor + 3]); + cursor += 3; + self.low_bank = data[cursor]; + cursor += 1; + self.low_is_ram = data[cursor] != 0; + cursor += 1; + self.low_ram_enabled = data[cursor] != 0; + cursor += 1; + self.irq_counter = u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + self.irq_enabled = data[cursor] != 0; + cursor += 1; + self.irq_counter_enabled = data[cursor] != 0; + cursor += 1; + self.irq_pending = data[cursor] != 0; + cursor += 1; + self.mirroring = decode_mirroring(data[cursor]); + cursor += 1; + let low_ram_payload = read_state_bytes(data, &mut cursor)?; + if low_ram_payload.len() != self.low_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.low_ram.copy_from_slice(low_ram_payload); + load_chr_state(&mut self.chr_data, &data[cursor..])?; + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/mapper64.rs b/src/native_core/mapper/mappers/mapper64.rs new file mode 100644 index 0000000..f952211 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper64.rs @@ -0,0 +1,268 @@ +use super::*; + +pub(crate) struct InesMapper64 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + bank_select: u8, + bank_regs: [u8; 16], // R0..RF + irq_latch: u8, + irq_counter: u8, + irq_reload: bool, // set by $C001; applied on next clock + irq_enabled: bool, + irq_pending: bool, + irq_delay: u8, // pending IRQ assert delay in CPU cycles + cycle_prescaler: u8, // CPU-cycle mode clocks every 4 cycles +} + +impl InesMapper64 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + bank_select: 0, + bank_regs: [0; 16], + irq_latch: 0, + irq_counter: 0, + irq_reload: false, + irq_enabled: false, + irq_pending: false, + irq_delay: 0, + cycle_prescaler: 0, + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + fn chr_bank_count_1k(&self) -> usize { + (self.chr_data.len() / 0x0400).max(1) + } + + fn prg_mode(&self) -> bool { + (self.bank_select & 0x40) != 0 + } + + fn chr_invert(&self) -> bool { + (self.bank_select & 0x80) != 0 + } + + fn chr_full_1k_mode(&self) -> bool { + (self.bank_select & 0x20) != 0 + } + + fn irq_cycle_mode(&self) -> bool { + (self.bank_select & 0x01) != 0 + } + + fn prg_bank_for_slot(&self, slot: usize) -> usize { + let last = self.prg_bank_count_8k().saturating_sub(1); + let r6 = self.bank_regs[0x6] as usize; + let r7 = self.bank_regs[0x7] as usize; + let rf = self.bank_regs[0xF] as usize; + match (self.prg_mode(), slot) { + (false, 0) => r6, + (false, 1) => r7, + (false, 2) => rf, + (false, 3) => last, + (true, 0) => rf, + (true, 1) => r7, + (true, 2) => r6, + (true, 3) => last, + _ => 0, + } + } + + pub(super) fn chr_bank_for_page(&self, page: usize) -> usize { + let r = &self.bank_regs; + let k = self.chr_full_1k_mode(); + let r0_lo = (r[0x0] & !1) as usize; + let r1_lo = (r[0x1] & !1) as usize; + + let mut layout = [0usize; 8]; + layout[0] = r0_lo; + layout[1] = if k { r[0x8] as usize } else { r0_lo + 1 }; + layout[2] = r1_lo; + layout[3] = if k { r[0x9] as usize } else { r1_lo + 1 }; + layout[4] = r[0x2] as usize; + layout[5] = r[0x3] as usize; + layout[6] = r[0x4] as usize; + layout[7] = r[0x5] as usize; + + if self.chr_invert() { + layout.rotate_left(4); + } + layout[page] + } + + fn clock_counter(&mut self) { + if self.irq_reload { + self.irq_counter = self.irq_latch; + if self.irq_counter != 0 { + self.irq_counter |= 1; + } + self.irq_reload = false; + } else if self.irq_counter == 0 { + self.irq_counter = self.irq_latch; + } else { + self.irq_counter = self.irq_counter.wrapping_sub(1); + } + + if self.irq_enabled && self.irq_counter == 0 { + self.irq_delay = 4; + } + } + + fn tick_irq_delay(&mut self, cycles: u8) { + if self.irq_delay == 0 { + return; + } + if cycles >= self.irq_delay { + self.irq_delay = 0; + self.irq_pending = true; + } else { + self.irq_delay -= cycles; + } + } +} + +impl Mapper for InesMapper64 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = self.prg_bank_for_slot(slot); + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0x9FFF if (addr & 1) == 0 => self.bank_select = value, + 0x8000..=0x9FFF => { + let reg = (self.bank_select & 0x0F) as usize; + match reg { + 0x0..=0x9 | 0xF => self.bank_regs[reg] = value, + _ => {} + } + } + 0xA000..=0xBFFF if (addr & 1) == 0 => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } + 0xC000..=0xDFFF if (addr & 1) == 0 => self.irq_latch = value, + 0xC000..=0xDFFF => { + self.irq_counter = 0; + self.irq_reload = true; + self.cycle_prescaler = 0; + } + 0xE000..=0xFFFF if (addr & 1) == 0 => { + self.irq_enabled = false; + self.irq_pending = false; + self.irq_delay = 0; + } + 0xE000..=0xFFFF => self.irq_enabled = true, + _ => {} + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_page(page); + read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_page(page); + let total = self.chr_bank_count_1k(); + let idx = safe_mod(bank, total) * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn clock_cpu(&mut self, cycles: u8) { + self.tick_irq_delay(cycles); + if !self.irq_cycle_mode() { + return; + } + for _ in 0..cycles { + self.cycle_prescaler = self.cycle_prescaler.wrapping_add(1); + if self.cycle_prescaler >= 4 { + self.cycle_prescaler = 0; + self.clock_counter(); + } + self.tick_irq_delay(1); + } + } + + fn clock_scanline(&mut self) { + if !self.irq_cycle_mode() { + self.clock_counter(); + } + } + + fn needs_ppu_a12_clock(&self) -> bool { + !self.irq_cycle_mode() + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.bank_select); + out.extend_from_slice(&self.bank_regs); + out.push(encode_mirroring(self.mirroring)); + out.push(self.irq_latch); + out.push(self.irq_counter); + out.push(u8::from(self.irq_reload)); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_pending)); + out.push(self.irq_delay); + out.push(self.cycle_prescaler); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 25 { + return Err("mapper state is truncated".to_string()); + } + self.bank_select = data[0]; + self.bank_regs.copy_from_slice(&data[1..17]); + self.mirroring = decode_mirroring(data[17]); + self.irq_latch = data[18]; + self.irq_counter = data[19]; + self.irq_reload = data[20] != 0; + self.irq_enabled = data[21] != 0; + self.irq_pending = data[22] != 0; + self.irq_delay = data[23]; + self.cycle_prescaler = data[24]; + load_chr_state(&mut self.chr_data, &data[25..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper78.rs b/src/native_core/mapper/mappers/mapper78.rs new file mode 100644 index 0000000..f331872 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper78.rs @@ -0,0 +1,123 @@ +use super::*; + +pub(crate) struct InesMapper78 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + submapper: u8, + hv_mirroring_mode: bool, + prg_bank: u8, + chr_bank: u8, + mirror_select: bool, +} + +impl InesMapper78 { + pub(crate) fn new(rom: InesRom) -> Self { + let hv_mirroring_mode = match rom.header.submapper { + 1 => false, // Uchuusen / Cosmo Carrier: 1scA/1scB + 3 => true, // Holy Diver: H/V + _ => matches!(rom.header.mirroring, Mirroring::Vertical), + }; + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + submapper: rom.header.submapper, + hv_mirroring_mode, + prg_bank: 0, + chr_bank: 0, + mirror_select: false, + } + } +} + +impl Mapper for InesMapper78 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + (self.prg_rom.len() / 0x4000).saturating_sub(1), + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr < 0x8000 { + return; + } + let latched = value & self.cpu_read(addr); // Mapper 78 has bus conflicts. + self.prg_bank = latched & 0x07; + self.mirror_select = (latched & 0x08) != 0; + self.chr_bank = (latched >> 4) & 0x0F; + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total = (self.chr_data.len() / 0x2000).max(1); + let idx = safe_mod(self.chr_bank as usize, total) * 0x2000 + ((addr as usize) & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + if self.hv_mirroring_mode { + if self.mirror_select { + Mirroring::Vertical + } else { + Mirroring::Horizontal + } + } else if self.mirror_select { + Mirroring::OneScreenHigh + } else { + Mirroring::OneScreenLow + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + out.push(self.mirror_select as u8); + out.push(self.submapper); + out.push(self.hv_mirroring_mode as u8); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 5 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + self.mirror_select = (data[2] & 1) != 0; + self.submapper = data[3]; + self.hv_mirroring_mode = (data[4] & 1) != 0; + load_chr_state(&mut self.chr_data, &data[5..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper87.rs b/src/native_core/mapper/mappers/mapper87.rs new file mode 100644 index 0000000..5d46812 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper87.rs @@ -0,0 +1,87 @@ +use super::*; + +pub(crate) struct InesMapper87 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + chr_bank: u8, +} + +impl InesMapper87 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + chr_bank: 0, + } + } +} + +impl Mapper for InesMapper87 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x4000, + ((addr - 0x8000) as usize) / 0x4000, + addr as usize, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) { + let low = value & 0x01; + let high = (value >> 1) & 0x01; + self.chr_bank = (low << 1) | high; + return true; + } + false + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let idx = safe_mod(self.chr_bank as usize, total_banks) * 0x2000 + (addr as usize & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.chr_bank); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.is_empty() { + return Err("mapper state is truncated".to_string()); + } + self.chr_bank = data[0]; + load_chr_state(&mut self.chr_data, &data[1..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper88.rs b/src/native_core/mapper/mappers/mapper88.rs new file mode 100644 index 0000000..ee19bf7 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper88.rs @@ -0,0 +1,64 @@ +use super::*; + +pub(crate) struct InesMapper88 { + base: InesMapper206, +} + +impl InesMapper88 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + base: InesMapper206::new(rom), + } + } +} + +impl Mapper for InesMapper88 { + fn cpu_read(&self, addr: u16) -> u8 { + self.base.cpu_read(addr) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + self.base.cpu_write(addr, value); + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let mut bank = self.base.chr_bank_for_1k_page(page) as usize; + if (addr & 0x1000) != 0 { + bank |= 0x40; + } + read_bank(&self.base.chr_data, 0x0400, bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.base.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let mut bank = self.base.chr_bank_for_1k_page(page) as usize; + if (addr & 0x1000) != 0 { + bank |= 0x40; + } + let total_banks = (self.base.chr_data.len() / 0x0400).max(1); + let bank_idx = safe_mod(bank, total_banks); + let idx = bank_idx * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.base.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.base.mirroring + } + + fn save_state(&self, out: &mut Vec) { + self.base.save_state(out); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + self.base.load_state(data) + } +} diff --git a/src/native_core/mapper/mappers/mapper93.rs b/src/native_core/mapper/mappers/mapper93.rs new file mode 100644 index 0000000..5bddf63 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper93.rs @@ -0,0 +1,87 @@ +use super::*; + +pub(crate) struct InesMapper93 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_bank: u8, +} + +impl InesMapper93 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_bank: 0, + } + } + + fn prg_banks_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } +} + +impl Mapper for InesMapper93 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_banks_16k().saturating_sub(1), + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.prg_bank = (value >> 4) & 0x07; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.is_empty() { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + load_chr_state(&mut self.chr_data, &data[1..]) + } +} diff --git a/src/native_core/mapper/mappers/mapper95.rs b/src/native_core/mapper/mappers/mapper95.rs new file mode 100644 index 0000000..d2d38e2 --- /dev/null +++ b/src/native_core/mapper/mappers/mapper95.rs @@ -0,0 +1,55 @@ +use super::*; + +pub(crate) struct InesMapper95 { + base: InesMapper206, +} + +impl InesMapper95 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + base: InesMapper206::new(rom), + } + } +} + +impl Mapper for InesMapper95 { + fn cpu_read(&self, addr: u16) -> u8 { + self.base.cpu_read(addr) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + self.base.cpu_write(addr, value); + } + + fn ppu_read(&self, addr: u16) -> u8 { + self.base.ppu_read(addr) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + self.base.ppu_write(addr, value); + } + + fn mirroring(&self) -> Mirroring { + Mirroring::Horizontal + } + + fn map_nametable_addr(&self, addr: u16) -> Option { + if !(0x2000..=0x3EFF).contains(&addr) { + return None; + } + let rel = (addr - 0x2000) & 0x0FFF; + let page = (rel / 0x0400) as usize; + let offset = (rel & 0x03FF) as usize; + let bank = self.base.chr_bank_for_1k_page(page); + let ciram_page = ((bank >> 5) & 1) as usize; + Some(ciram_page * 0x0400 + offset) + } + + fn save_state(&self, out: &mut Vec) { + self.base.save_state(out); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + self.base.load_state(data) + } +} diff --git a/src/native_core/mapper/mappers/mmc1.rs b/src/native_core/mapper/mappers/mmc1.rs new file mode 100644 index 0000000..04b6b04 --- /dev/null +++ b/src/native_core/mapper/mappers/mmc1.rs @@ -0,0 +1,192 @@ +use super::*; + +pub(crate) struct Mmc1 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + shift_reg: u8, + shift_count: u8, + control: u8, + chr_bank0: u8, + chr_bank1: u8, + prg_bank: u8, + mirroring_default: Mirroring, +} + +impl Mmc1 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + shift_reg: 0, + shift_count: 0, + control: 0x0C, + chr_bank0: 0, + chr_bank1: 0, + prg_bank: 0, + mirroring_default: rom.header.mirroring, + } + } + + fn prg_mode(&self) -> u8 { + (self.control >> 2) & 0x03 + } + + fn chr_mode(&self) -> u8 { + (self.control >> 4) & 1 + } + + fn prg_bank_count(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } + + fn write_serial(&mut self, addr: u16, value: u8) { + if (value & 0x80) != 0 { + self.shift_reg = 0; + self.shift_count = 0; + self.control |= 0x0C; + return; + } + + self.shift_reg |= (value & 1) << self.shift_count; + self.shift_count = self.shift_count.wrapping_add(1); + if self.shift_count < 5 { + return; + } + + let reg = (addr >> 13) & 0x03; + match reg { + 0 => self.control = self.shift_reg & 0x1F, + 1 => self.chr_bank0 = self.shift_reg & 0x1F, + 2 => self.chr_bank1 = self.shift_reg & 0x1F, + _ => self.prg_bank = self.shift_reg & 0x0F, + } + self.shift_reg = 0; + self.shift_count = 0; + } +} + +impl Mapper for Mmc1 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + + let prg_mode = self.prg_mode(); + if prg_mode <= 1 { + let bank32 = (self.prg_bank as usize) >> 1; + read_bank(&self.prg_rom, 0x8000, bank32, (addr as usize) - 0x8000) + } else if prg_mode == 2 { + if addr < 0xC000 { + read_bank(&self.prg_rom, 0x4000, 0, (addr as usize) - 0x8000) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0xC000, + ) + } + } else if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank_count() - 1, + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.write_serial(addr, value); + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + if self.chr_mode() == 0 { + let bank = (self.chr_bank0 as usize) >> 1; + read_bank(&self.chr_data, 0x2000, bank, addr as usize) + } else if addr < 0x1000 { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank0 as usize, + addr as usize, + ) + } else { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank1 as usize, + (addr as usize) - 0x1000, + ) + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let idx = if self.chr_mode() == 0 { + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let bank = safe_mod((self.chr_bank0 as usize) >> 1, total_banks); + bank * 0x2000 + ((addr as usize) & 0x1FFF) + } else { + let total_banks = (self.chr_data.len() / 0x1000).max(1); + let bank = if addr < 0x1000 { + self.chr_bank0 as usize + } else { + self.chr_bank1 as usize + }; + safe_mod(bank, total_banks) * 0x1000 + ((addr as usize) & 0x0FFF) + }; + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match self.control & 0x03 { + 0 => Mirroring::OneScreenLow, + 1 => Mirroring::OneScreenHigh, + 2 => Mirroring::Vertical, + 3 => Mirroring::Horizontal, + _ => self.mirroring_default, + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.shift_reg); + out.push(self.shift_count); + out.push(self.control); + out.push(self.chr_bank0); + out.push(self.chr_bank1); + out.push(self.prg_bank); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 6 { + return Err("mapper state is truncated".to_string()); + } + self.shift_reg = data[0]; + self.shift_count = data[1]; + self.control = data[2]; + self.chr_bank0 = data[3]; + self.chr_bank1 = data[4]; + self.prg_bank = data[5]; + load_chr_state(&mut self.chr_data, &data[6..]) + } +} diff --git a/src/native_core/mapper/mappers/mmc2.rs b/src/native_core/mapper/mappers/mmc2.rs new file mode 100644 index 0000000..636973b --- /dev/null +++ b/src/native_core/mapper/mappers/mmc2.rs @@ -0,0 +1,178 @@ +use super::*; + +#[derive(Clone, Copy)] +enum Mmc2Latch { + Fd, + Fe, +} + +pub(crate) struct Mmc2 { + prg_rom: Vec, + chr_data: Vec, + mirroring: Mirroring, + prg_bank_8k: u8, + chr_fd_0000: u8, + chr_fe_0000: u8, + chr_fd_1000: u8, + chr_fe_1000: u8, + latch_0000: Cell, + latch_1000: Cell, +} + +impl Mmc2 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + mirroring: rom.header.mirroring, + prg_bank_8k: 0, + chr_fd_0000: 0, + chr_fe_0000: 0, + chr_fd_1000: 0, + chr_fe_1000: 0, + latch_0000: Cell::new(Mmc2Latch::Fd), + latch_1000: Cell::new(Mmc2Latch::Fd), + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + fn chr_bank_0000(&self) -> usize { + match self.latch_0000.get() { + Mmc2Latch::Fd => self.chr_fd_0000 as usize, + Mmc2Latch::Fe => self.chr_fe_0000 as usize, + } + } + + fn chr_bank_1000(&self) -> usize { + match self.latch_1000.get() { + Mmc2Latch::Fd => self.chr_fd_1000 as usize, + Mmc2Latch::Fe => self.chr_fe_1000 as usize, + } + } + + fn update_latches_for_ppu_addr(&self, addr: u16) { + if (0x0FD8..=0x0FDF).contains(&addr) { + self.latch_0000.set(Mmc2Latch::Fd); + } else if (0x0FE8..=0x0FEF).contains(&addr) { + self.latch_0000.set(Mmc2Latch::Fe); + } else if (0x1FD8..=0x1FDF).contains(&addr) { + self.latch_1000.set(Mmc2Latch::Fd); + } else if (0x1FE8..=0x1FEF).contains(&addr) { + self.latch_1000.set(Mmc2Latch::Fe); + } + } +} + +impl Mapper for Mmc2 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = match slot { + 0 => self.prg_bank_8k as usize, + 1 => self.prg_bank_count_8k().saturating_sub(3), + 2 => self.prg_bank_count_8k().saturating_sub(2), + _ => self.prg_bank_count_8k().saturating_sub(1), + }; + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0xA000..=0xAFFF => self.prg_bank_8k = value & 0x0F, + 0xB000..=0xBFFF => self.chr_fd_0000 = value & 0x1F, + 0xC000..=0xCFFF => self.chr_fe_0000 = value & 0x1F, + 0xD000..=0xDFFF => self.chr_fd_1000 = value & 0x1F, + 0xE000..=0xEFFF => self.chr_fe_1000 = value & 0x1F, + 0xF000..=0xFFFF => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } + _ => {} + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let value = if addr < 0x1000 { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank_0000(), + addr as usize & 0x0FFF, + ) + } else { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank_1000(), + (addr as usize) & 0x0FFF, + ) + }; + // MMC2 latches update after the triggering tile fetch. + self.update_latches_for_ppu_addr(addr); + value + } + + fn ppu_write(&mut self, addr: u16, _value: u8) { + self.update_latches_for_ppu_addr(addr); + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank_8k); + out.push(self.chr_fd_0000); + out.push(self.chr_fe_0000); + out.push(self.chr_fd_1000); + out.push(self.chr_fe_1000); + out.push(match self.latch_0000.get() { + Mmc2Latch::Fd => 0, + Mmc2Latch::Fe => 1, + }); + out.push(match self.latch_1000.get() { + Mmc2Latch::Fd => 0, + Mmc2Latch::Fe => 1, + }); + out.push(encode_mirroring(self.mirroring)); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() != 8 { + return Err("mapper state payload has invalid length".to_string()); + } + self.prg_bank_8k = data[0]; + self.chr_fd_0000 = data[1]; + self.chr_fe_0000 = data[2]; + self.chr_fd_1000 = data[3]; + self.chr_fe_1000 = data[4]; + self.latch_0000.set(if data[5] == 0 { + Mmc2Latch::Fd + } else { + Mmc2Latch::Fe + }); + self.latch_1000.set(if data[6] == 0 { + Mmc2Latch::Fd + } else { + Mmc2Latch::Fe + }); + self.mirroring = decode_mirroring(data[7]); + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/mmc3.rs b/src/native_core/mapper/mappers/mmc3.rs new file mode 100644 index 0000000..1b3815b --- /dev/null +++ b/src/native_core/mapper/mappers/mmc3.rs @@ -0,0 +1,250 @@ +use super::*; + +pub(crate) struct Mmc3 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + prg_ram: Vec, + prg_ram_enabled: bool, + prg_ram_write_protect: bool, + mirroring: Mirroring, + bank_regs: [u8; 8], + bank_select: u8, + irq_latch: u8, + irq_counter: u8, + irq_reload: bool, + irq_enabled: bool, + irq_pending: bool, +} + +impl Mmc3 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + prg_ram: vec![0; 0x2000], + prg_ram_enabled: true, + prg_ram_write_protect: false, + mirroring: rom.header.mirroring, + bank_regs: [0; 8], + bank_select: 0, + irq_latch: 0, + irq_counter: 0, + irq_reload: false, + irq_enabled: false, + irq_pending: false, + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + fn prg_mode(&self) -> bool { + (self.bank_select & 0x40) != 0 + } + + fn chr_invert(&self) -> bool { + (self.bank_select & 0x80) != 0 + } + + fn prg_bank_for_slot(&self, slot: usize) -> usize { + let last = self.prg_bank_count_8k() - 1; + let second_last = last.saturating_sub(1); + match (self.prg_mode(), slot) { + (false, 0) => self.bank_regs[6] as usize, + (false, 1) => self.bank_regs[7] as usize, + (false, 2) => second_last, + (false, 3) => last, + (true, 0) => second_last, + (true, 1) => self.bank_regs[7] as usize, + (true, 2) => self.bank_regs[6] as usize, + (true, 3) => last, + _ => 0, + } + } + + pub(super) fn chr_bank_for_1k_page(&self, page: usize) -> usize { + let regs = &self.bank_regs; + let mut layout = [0usize; 8]; + + layout[0] = (regs[0] as usize) & !1; + layout[1] = layout[0] + 1; + layout[2] = (regs[1] as usize) & !1; + layout[3] = layout[2] + 1; + layout[4] = regs[2] as usize; + layout[5] = regs[3] as usize; + layout[6] = regs[4] as usize; + layout[7] = regs[5] as usize; + + if self.chr_invert() { + layout.rotate_left(4); + } + layout[page] + } + + fn clock_irq_scanline(&mut self) { + if self.irq_reload || self.irq_counter == 0 { + self.irq_counter = self.irq_latch; + self.irq_reload = false; + } else { + self.irq_counter = self.irq_counter.wrapping_sub(1); + } + if self.irq_enabled && self.irq_counter == 0 { + self.irq_pending = true; + } + } +} + +impl Mapper for Mmc3 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = self.prg_bank_for_slot(slot); + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0x9FFF if (addr & 1) == 0 => self.bank_select = value, + 0x8000..=0x9FFF => { + let reg = (self.bank_select & 0x07) as usize; + self.bank_regs[reg] = value; + } + 0xA000..=0xBFFF if (addr & 1) == 0 => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } + 0xA000..=0xBFFF => { + self.prg_ram_enabled = (value & 0x80) != 0; + self.prg_ram_write_protect = (value & 0x40) != 0; + } + 0xC000..=0xDFFF if (addr & 1) == 0 => self.irq_latch = value, + 0xC000..=0xDFFF => { + self.irq_counter = 0; + self.irq_reload = true; + } + 0xE000..=0xFFFF if (addr & 1) == 0 => { + self.irq_enabled = false; + self.irq_pending = false; + } + 0xE000..=0xFFFF => self.irq_enabled = true, + _ => {} + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) { + if self.prg_ram_enabled { + Some(self.prg_ram[(addr as usize) - 0x6000]) + } else { + Some(0) + } + } else { + None + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) { + if self.prg_ram_enabled && !self.prg_ram_write_protect { + self.prg_ram[(addr as usize) - 0x6000] = value; + } + true + } else { + false + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_1k_page(page); + read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_1k_page(page); + let total_banks = (self.chr_data.len() / 0x0400).max(1); + let bank_idx = safe_mod(bank, total_banks); + let idx = bank_idx * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn clock_scanline(&mut self) { + self.clock_irq_scanline(); + } + + fn needs_ppu_a12_clock(&self) -> bool { + true + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.extend_from_slice(&self.bank_regs); + out.push(self.bank_select); + out.push(encode_mirroring(self.mirroring)); + out.push(self.irq_latch); + out.push(self.irq_counter); + out.push(u8::from(self.irq_reload)); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_pending)); + out.push(u8::from(self.prg_ram_enabled)); + out.push(u8::from(self.prg_ram_write_protect)); + write_state_bytes(out, &self.prg_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 17 { + return Err("mapper state is truncated".to_string()); + } + self.bank_regs.copy_from_slice(&data[0..8]); + self.bank_select = data[8]; + self.mirroring = decode_mirroring(data[9]); + self.irq_latch = data[10]; + self.irq_counter = data[11]; + self.irq_reload = data[12] != 0; + self.irq_enabled = data[13] != 0; + self.irq_pending = data[14] != 0; + let mut cursor = 15usize; + self.prg_ram_enabled = data[cursor] != 0; + cursor += 1; + self.prg_ram_write_protect = data[cursor] != 0; + cursor += 1; + let prg_ram = read_state_bytes(data, &mut cursor)?; + if prg_ram.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram); + load_chr_state(&mut self.chr_data, &data[cursor..]) + } +} diff --git a/src/native_core/mapper/mappers/mmc4.rs b/src/native_core/mapper/mappers/mmc4.rs new file mode 100644 index 0000000..494be24 --- /dev/null +++ b/src/native_core/mapper/mappers/mmc4.rs @@ -0,0 +1,180 @@ +use super::*; + +#[derive(Clone, Copy)] +enum Mmc2Latch { + Fd, + Fe, +} + +pub(crate) struct Mmc4 { + prg_rom: Vec, + chr_data: Vec, + mirroring: Mirroring, + prg_bank_16k: u8, + chr_fd_0000: u8, + chr_fe_0000: u8, + chr_fd_1000: u8, + chr_fe_1000: u8, + latch_0000: Cell, + latch_1000: Cell, +} + +impl Mmc4 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + mirroring: rom.header.mirroring, + prg_bank_16k: 0, + chr_fd_0000: 0, + chr_fe_0000: 0, + chr_fd_1000: 0, + chr_fe_1000: 0, + latch_0000: Cell::new(Mmc2Latch::Fd), + latch_1000: Cell::new(Mmc2Latch::Fd), + } + } + + fn prg_bank_count_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } + + fn chr_bank_0000(&self) -> usize { + match self.latch_0000.get() { + Mmc2Latch::Fd => self.chr_fd_0000 as usize, + Mmc2Latch::Fe => self.chr_fe_0000 as usize, + } + } + + fn chr_bank_1000(&self) -> usize { + match self.latch_1000.get() { + Mmc2Latch::Fd => self.chr_fd_1000 as usize, + Mmc2Latch::Fe => self.chr_fe_1000 as usize, + } + } + + fn update_latches_for_ppu_addr(&self, addr: u16) { + if (0x0FD8..=0x0FDF).contains(&addr) { + self.latch_0000.set(Mmc2Latch::Fd); + } else if (0x0FE8..=0x0FEF).contains(&addr) { + self.latch_0000.set(Mmc2Latch::Fe); + } else if (0x1FD8..=0x1FDF).contains(&addr) { + self.latch_1000.set(Mmc2Latch::Fd); + } else if (0x1FE8..=0x1FEF).contains(&addr) { + self.latch_1000.set(Mmc2Latch::Fe); + } + } +} + +impl Mapper for Mmc4 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank_16k as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank_count_16k().saturating_sub(1), + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0xA000..=0xAFFF => self.prg_bank_16k = value & 0x0F, + 0xB000..=0xBFFF => self.chr_fd_0000 = value & 0x1F, + 0xC000..=0xCFFF => self.chr_fe_0000 = value & 0x1F, + 0xD000..=0xDFFF => self.chr_fd_1000 = value & 0x1F, + 0xE000..=0xEFFF => self.chr_fe_1000 = value & 0x1F, + 0xF000..=0xFFFF => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } + _ => {} + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let value = if addr < 0x1000 { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank_0000(), + addr as usize & 0x0FFF, + ) + } else { + read_bank( + &self.chr_data, + 0x1000, + self.chr_bank_1000(), + (addr as usize) & 0x0FFF, + ) + }; + // MMC4 latches update after the triggering tile fetch. + self.update_latches_for_ppu_addr(addr); + value + } + + fn ppu_write(&mut self, addr: u16, _value: u8) { + self.update_latches_for_ppu_addr(addr); + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank_16k); + out.push(self.chr_fd_0000); + out.push(self.chr_fe_0000); + out.push(self.chr_fd_1000); + out.push(self.chr_fe_1000); + out.push(match self.latch_0000.get() { + Mmc2Latch::Fd => 0, + Mmc2Latch::Fe => 1, + }); + out.push(match self.latch_1000.get() { + Mmc2Latch::Fd => 0, + Mmc2Latch::Fe => 1, + }); + out.push(encode_mirroring(self.mirroring)); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() != 8 { + return Err("mapper state payload has invalid length".to_string()); + } + self.prg_bank_16k = data[0]; + self.chr_fd_0000 = data[1]; + self.chr_fe_0000 = data[2]; + self.chr_fd_1000 = data[3]; + self.chr_fe_1000 = data[4]; + self.latch_0000.set(if data[5] == 0 { + Mmc2Latch::Fd + } else { + Mmc2Latch::Fe + }); + self.latch_1000.set(if data[6] == 0 { + Mmc2Latch::Fd + } else { + Mmc2Latch::Fe + }); + self.mirroring = decode_mirroring(data[7]); + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/mmc5.rs b/src/native_core/mapper/mappers/mmc5.rs new file mode 100644 index 0000000..cc4f828 --- /dev/null +++ b/src/native_core/mapper/mappers/mmc5.rs @@ -0,0 +1,359 @@ +use super::*; + +pub(crate) struct Mmc5 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring_default: Mirroring, + prg_mode: u8, + chr_mode: u8, + prg_regs_8k: [u8; 4], + prg_ram_bank: u8, + prg_ram: Vec, + chr_banks_1k: [u16; 8], + nt_mapping: u8, + ram_protect_1: u8, + ram_protect_2: u8, + chr_upper_bits: u8, + multiplier_a: u8, + multiplier_b: u8, + irq_scanline: u8, + irq_enable: bool, + irq_pending: bool, + irq_cycles: u32, +} + +impl Mmc5 { + pub(crate) fn new(rom: InesRom) -> Self { + let prg_banks_8k = (rom.prg_rom.len() / 0x2000).max(1); + let last = prg_banks_8k.saturating_sub(1) as u8; + let second_last = prg_banks_8k.saturating_sub(2) as u8; + let mut chr_banks_1k = [0u16; 8]; + for (i, bank) in chr_banks_1k.iter_mut().enumerate() { + *bank = i as u16; + } + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring_default: rom.header.mirroring, + prg_mode: 3, + chr_mode: 3, + prg_regs_8k: [0, 1, second_last, last], + prg_ram_bank: 0, + prg_ram: vec![0; 0x10000], + chr_banks_1k, + nt_mapping: 0x50, + ram_protect_1: 0, + ram_protect_2: 0, + chr_upper_bits: 0, + multiplier_a: 0, + multiplier_b: 0, + irq_scanline: 0, + irq_enable: false, + irq_pending: false, + irq_cycles: 0, + } + } + + fn chr_bank_count_1k(&self) -> usize { + (self.chr_data.len() / 0x0400).max(1) + } + + fn prg_bank_8k_for_slot(&self, slot: usize) -> usize { + let regs = &self.prg_regs_8k; + match self.prg_mode & 0x03 { + 0 => { + let bank32 = (regs[3] as usize) >> 2; + bank32 * 4 + slot + } + 1 => { + let bank16_lo = (regs[1] as usize) >> 1; + let bank16_hi = (regs[3] as usize) >> 1; + if slot < 2 { + bank16_lo * 2 + slot + } else { + bank16_hi * 2 + (slot - 2) + } + } + 2 => match slot { + 0 | 1 => { + let bank16 = (regs[1] as usize) >> 1; + bank16 * 2 + slot + } + 2 => regs[2] as usize, + _ => regs[3] as usize, + }, + _ => regs[slot] as usize, + } + } + + fn chr_bank_1k_for_page(&self, page: usize) -> usize { + let mut banks = [0usize; 8]; + match self.chr_mode & 0x03 { + 0 => { + let base = (self.chr_banks_1k[7] as usize) & !7; + for (i, bank) in banks.iter_mut().enumerate() { + *bank = base + i; + } + } + 1 => { + let b0 = (self.chr_banks_1k[3] as usize) & !3; + let b1 = (self.chr_banks_1k[7] as usize) & !3; + for i in 0..4usize { + banks[i] = b0 + i; + banks[i + 4] = b1 + i; + } + } + 2 => { + let b0 = (self.chr_banks_1k[1] as usize) & !1; + let b1 = (self.chr_banks_1k[3] as usize) & !1; + let b2 = (self.chr_banks_1k[5] as usize) & !1; + let b3 = (self.chr_banks_1k[7] as usize) & !1; + banks[0] = b0; + banks[1] = b0 + 1; + banks[2] = b1; + banks[3] = b1 + 1; + banks[4] = b2; + banks[5] = b2 + 1; + banks[6] = b3; + banks[7] = b3 + 1; + } + _ => { + for (i, bank) in banks.iter_mut().enumerate() { + *bank = self.chr_banks_1k[i] as usize; + } + } + } + banks[page] + } + + fn ram_writable(&self) -> bool { + self.ram_protect_1 == 0x02 && self.ram_protect_2 == 0x01 + } + + fn decode_mirroring(&self) -> Mirroring { + let nt0 = self.nt_mapping & 0x03; + let nt1 = (self.nt_mapping >> 2) & 0x03; + let nt2 = (self.nt_mapping >> 4) & 0x03; + let nt3 = (self.nt_mapping >> 6) & 0x03; + if nt0 == 0 && nt1 == 1 && nt2 == 0 && nt3 == 1 { + return Mirroring::Vertical; + } + if nt0 == 0 && nt1 == 0 && nt2 == 1 && nt3 == 1 { + return Mirroring::Horizontal; + } + if nt0 == 0 && nt1 == 0 && nt2 == 0 && nt3 == 0 { + return Mirroring::OneScreenLow; + } + if nt0 == 1 && nt1 == 1 && nt2 == 1 && nt3 == 1 { + return Mirroring::OneScreenHigh; + } + self.mirroring_default + } +} + +impl Mapper for Mmc5 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = self.prg_bank_8k_for_slot(slot); + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn cpu_read_low(&self, addr: u16) -> Option { + match addr { + 0x5204 => { + let mut status = 0u8; + if self.irq_pending { + status |= 0x80; + } + if self.irq_enable { + status |= 0x40; + } + Some(status) + } + 0x5205 => Some(((self.multiplier_a as u16 * self.multiplier_b as u16) & 0x00FF) as u8), + 0x5206 => Some(((self.multiplier_a as u16 * self.multiplier_b as u16) >> 8) as u8), + 0x6000..=0x7FFF => { + let bank = (self.prg_ram_bank & 0x07) as usize; + let idx = bank * 0x2000 + ((addr as usize) & 0x1FFF); + Some(self.prg_ram.get(idx).copied().unwrap_or(0)) + } + _ => None, + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + match addr { + 0x5100 => self.prg_mode = value & 0x03, + 0x5101 => self.chr_mode = value & 0x03, + 0x5102 => self.ram_protect_1 = value & 0x03, + 0x5103 => self.ram_protect_2 = value & 0x03, + 0x5105 => self.nt_mapping = value, + 0x5113 => self.prg_ram_bank = value & 0x07, + 0x5114..=0x5117 => { + let reg = (addr - 0x5114) as usize; + self.prg_regs_8k[reg] = value & 0x7F; + } + 0x5120..=0x5127 => { + let reg = (addr - 0x5120) as usize; + let bank = (((self.chr_upper_bits & 0x03) as u16) << 8) | value as u16; + self.chr_banks_1k[reg] = bank; + } + 0x5128..=0x512B => { + let reg = (addr - 0x5128) as usize; + let bank = (((self.chr_upper_bits & 0x03) as u16) << 8) | value as u16; + let base = reg * 2; + self.chr_banks_1k[base] = bank & !1; + self.chr_banks_1k[base + 1] = (bank & !1).wrapping_add(1); + } + 0x5130 => self.chr_upper_bits = value & 0x03, + 0x5203 => self.irq_scanline = value, + 0x5204 => { + self.irq_enable = (value & 0x80) != 0; + if !self.irq_enable { + self.irq_pending = false; + } + } + 0x5205 => self.multiplier_a = value, + 0x5206 => self.multiplier_b = value, + 0x6000..=0x7FFF => { + if self.ram_writable() { + let bank = (self.prg_ram_bank & 0x07) as usize; + let idx = bank * 0x2000 + ((addr as usize) & 0x1FFF); + if let Some(cell) = self.prg_ram.get_mut(idx) { + *cell = value; + } + } + } + _ => return false, + } + true + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = safe_mod(self.chr_bank_1k_for_page(page), self.chr_bank_count_1k()); + read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = safe_mod(self.chr_bank_1k_for_page(page), self.chr_bank_count_1k()); + let idx = bank * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.decode_mirroring() + } + + fn clock_cpu(&mut self, cycles: u8) { + if !self.irq_enable { + return; + } + self.irq_cycles = self.irq_cycles.saturating_add(cycles as u32); + let threshold = (self.irq_scanline as u32 + 1).saturating_mul(113); + if threshold != 0 && self.irq_cycles >= threshold { + self.irq_cycles %= threshold; + self.irq_pending = true; + } + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_mode); + out.push(self.chr_mode); + out.extend_from_slice(&self.prg_regs_8k); + out.push(self.prg_ram_bank); + out.push(self.nt_mapping); + out.push(self.ram_protect_1); + out.push(self.ram_protect_2); + out.push(self.chr_upper_bits); + out.push(self.multiplier_a); + out.push(self.multiplier_b); + out.push(self.irq_scanline); + out.push(u8::from(self.irq_enable)); + out.push(u8::from(self.irq_pending)); + out.extend_from_slice(&self.irq_cycles.to_le_bytes()); + for bank in self.chr_banks_1k { + out.extend_from_slice(&bank.to_le_bytes()); + } + write_state_bytes(out, &self.prg_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 23 + 16 { + return Err("mapper state is truncated".to_string()); + } + let mut cursor = 0usize; + self.prg_mode = data[cursor]; + cursor += 1; + self.chr_mode = data[cursor]; + cursor += 1; + self.prg_regs_8k.copy_from_slice(&data[cursor..cursor + 4]); + cursor += 4; + self.prg_ram_bank = data[cursor]; + cursor += 1; + self.nt_mapping = data[cursor]; + cursor += 1; + self.ram_protect_1 = data[cursor]; + cursor += 1; + self.ram_protect_2 = data[cursor]; + cursor += 1; + self.chr_upper_bits = data[cursor]; + cursor += 1; + self.multiplier_a = data[cursor]; + cursor += 1; + self.multiplier_b = data[cursor]; + cursor += 1; + self.irq_scanline = data[cursor]; + cursor += 1; + self.irq_enable = data[cursor] != 0; + cursor += 1; + self.irq_pending = data[cursor] != 0; + cursor += 1; + self.irq_cycles = u32::from_le_bytes([ + data[cursor], + data[cursor + 1], + data[cursor + 2], + data[cursor + 3], + ]); + cursor += 4; + for i in 0..8usize { + self.chr_banks_1k[i] = u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + } + let prg_ram_payload = read_state_bytes(data, &mut cursor)?; + if prg_ram_payload.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram_payload); + load_chr_state(&mut self.chr_data, &data[cursor..])?; + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/mod.rs b/src/native_core/mapper/mappers/mod.rs new file mode 100644 index 0000000..4530690 --- /dev/null +++ b/src/native_core/mapper/mappers/mod.rs @@ -0,0 +1,84 @@ +use super::core::*; +use super::types::*; +use crate::native_core::ines::{InesRom, Mirroring}; +use std::cell::Cell; + +mod axrom; +mod bandai70_152; +mod bnrom34; +mod camerica71; +mod cnrom; +mod color_dreams11; +mod cprom13; +mod crazy_climber180; +mod fme7; +mod gxrom; +mod mapper105; +mod mapper118; +mod mapper140; +mod mapper155; +mod mapper158; +mod mapper184; +mod mapper185; +mod mapper206; +mod mapper206_submapper1; +mod mapper253; +mod mapper64; +mod mapper78; +mod mapper87; +mod mapper88; +mod mapper93; +mod mapper95; +mod mmc1; +mod mmc2; +mod mmc3; +mod mmc4; +mod mmc5; +mod namco163_19; +mod nina79; +mod nrom; +mod tqrom119; +mod un1rom94; +mod unrom512_30; +mod uxrom; +mod vrc; + +pub(super) use axrom::Axrom; +pub(super) use bandai70_152::Bandai70_152; +pub(super) use bnrom34::Bnrom34; +pub(super) use camerica71::Camerica71; +pub(super) use cnrom::Cnrom; +pub(super) use color_dreams11::ColorDreams11; +pub(super) use cprom13::Cprom13; +pub(super) use crazy_climber180::CrazyClimber180; +pub(super) use fme7::Fme7; +pub(super) use gxrom::Gxrom; +pub(super) use mapper64::InesMapper64; +pub(super) use mapper78::InesMapper78; +pub(super) use mapper87::InesMapper87; +pub(super) use mapper88::InesMapper88; +pub(super) use mapper93::InesMapper93; +pub(super) use mapper95::InesMapper95; +pub(super) use mapper105::InesMapper105; +pub(super) use mapper118::InesMapper118; +pub(super) use mapper140::InesMapper140; +pub(super) use mapper155::InesMapper155; +pub(super) use mapper158::InesMapper158; +pub(super) use mapper184::InesMapper184; +pub(super) use mapper185::InesMapper185; +pub(super) use mapper206::InesMapper206; +pub(super) use mapper206_submapper1::InesMapper206Submapper1; +pub(super) use mapper253::InesMapper253; +pub(super) use mmc1::Mmc1; +pub(super) use mmc2::Mmc2; +pub(super) use mmc3::Mmc3; +pub(super) use mmc4::Mmc4; +pub(super) use mmc5::Mmc5; +pub(super) use namco163_19::Namco163_19; +pub(super) use nina79::Nina79; +pub(super) use nrom::Nrom; +pub(super) use tqrom119::Tqrom119; +pub(super) use un1rom94::Un1rom94; +pub(super) use unrom512_30::Unrom512_30; +pub(super) use uxrom::Uxrom; +pub(super) use vrc::{Vrc1_75, Vrc2_23, Vrc6_24, Vrc7_85}; diff --git a/src/native_core/mapper/mappers/namco163_19.rs b/src/native_core/mapper/mappers/namco163_19.rs new file mode 100644 index 0000000..271b779 --- /dev/null +++ b/src/native_core/mapper/mappers/namco163_19.rs @@ -0,0 +1,202 @@ +use super::*; + +pub(crate) struct Namco163_19 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_ram: Vec, + prg_banks_8k: [u8; 3], + chr_banks_1k: [u8; 8], + audio_ram: [u8; 0x80], + irq_counter: u16, + irq_enabled: bool, + irq_pending: bool, +} + +impl Namco163_19 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_ram: vec![0; 0x2000], + prg_banks_8k: [0, 1, 2], + chr_banks_1k: [0, 1, 2, 3, 4, 5, 6, 7], + audio_ram: [0; 0x80], + irq_counter: 0, + irq_enabled: false, + irq_pending: false, + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + fn chr_bank_count_1k(&self) -> usize { + (self.chr_data.len() / 0x0400).max(1) + } +} + +impl Mapper for Namco163_19 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = match slot { + 0 => self.prg_banks_8k[0] as usize, + 1 => self.prg_banks_8k[1] as usize, + 2 => self.prg_banks_8k[2] as usize, + _ => self.prg_bank_count_8k().saturating_sub(1), + }; + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0xDFFF => { + let reg = ((addr - 0x8000) / 0x0800) as usize; + if reg < 8 { + self.chr_banks_1k[reg] = value; + } + } + 0xE000..=0xE7FF => self.prg_banks_8k[0] = value & 0x3F, + 0xE800..=0xEFFF => self.prg_banks_8k[1] = value & 0x3F, + 0xF000..=0xF7FF => self.prg_banks_8k[2] = value & 0x3F, + 0xF800..=0xFFFF => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } + _ => {} + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + match addr { + 0x4800..=0x487F => Some(self.audio_ram[(addr as usize) & 0x7F]), + 0x5000 => Some((self.irq_counter & 0x00FF) as u8), + 0x5800 => { + let mut hi = ((self.irq_counter >> 8) as u8) & 0x7F; + if self.irq_enabled { + hi |= 0x80; + } + Some(hi) + } + 0x6000..=0x7FFF => Some(self.prg_ram[(addr as usize) - 0x6000]), + _ => None, + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + match addr { + 0x4800..=0x487F => self.audio_ram[(addr as usize) & 0x7F] = value, + 0x5000 => { + self.irq_counter = (self.irq_counter & 0xFF00) | value as u16; + self.irq_pending = false; + } + 0x5800 => { + self.irq_counter = (self.irq_counter & 0x00FF) | (((value & 0x7F) as u16) << 8); + self.irq_enabled = (value & 0x80) != 0; + if !self.irq_enabled { + self.irq_pending = false; + } + } + 0x6000..=0x7FFF => self.prg_ram[(addr as usize) - 0x6000] = value, + _ => return false, + } + true + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = safe_mod(self.chr_banks_1k[page] as usize, self.chr_bank_count_1k()); + read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = safe_mod(self.chr_banks_1k[page] as usize, self.chr_bank_count_1k()); + let idx = bank * 0x0400 + ((addr as usize) & 0x03FF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn clock_cpu(&mut self, cycles: u8) { + if !self.irq_enabled { + return; + } + let sum = self.irq_counter as u32 + cycles as u32; + if sum > 0x7FFF { + self.irq_pending = true; + } + self.irq_counter = (sum as u16) & 0x7FFF; + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(encode_mirroring(self.mirroring)); + out.extend_from_slice(&self.prg_banks_8k); + out.extend_from_slice(&self.chr_banks_1k); + out.extend_from_slice(&self.audio_ram); + out.extend_from_slice(&self.irq_counter.to_le_bytes()); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_pending)); + write_state_bytes(out, &self.prg_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 1 + 3 + 8 + 0x80 + 4 { + return Err("mapper state is truncated".to_string()); + } + let mut cursor = 0usize; + self.mirroring = decode_mirroring(data[cursor]); + cursor += 1; + self.prg_banks_8k.copy_from_slice(&data[cursor..cursor + 3]); + cursor += 3; + self.chr_banks_1k.copy_from_slice(&data[cursor..cursor + 8]); + cursor += 8; + self.audio_ram.copy_from_slice(&data[cursor..cursor + 0x80]); + cursor += 0x80; + self.irq_counter = u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + self.irq_enabled = data[cursor] != 0; + cursor += 1; + self.irq_pending = data[cursor] != 0; + cursor += 1; + let prg_ram_payload = read_state_bytes(data, &mut cursor)?; + if prg_ram_payload.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram_payload); + load_chr_state(&mut self.chr_data, &data[cursor..])?; + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/nina79.rs b/src/native_core/mapper/mappers/nina79.rs new file mode 100644 index 0000000..ca87be6 --- /dev/null +++ b/src/native_core/mapper/mappers/nina79.rs @@ -0,0 +1,91 @@ +use super::*; + +pub(crate) struct Nina79 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_bank: u8, + chr_bank: u8, +} + +impl Nina79 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_bank: 0, + chr_bank: 0, + } + } +} + +impl Mapper for Nina79 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x8000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + // NINA-003/006 latch: 010x xxx1 xxxx xxxx ($4100/$4300/.../$5F00). + if (addr & 0xE100) == 0x4100 { + self.prg_bank = (value >> 3) & 0x01; + self.chr_bank = value & 0x07; + return true; + } + false + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let idx = safe_mod(self.chr_bank as usize, total_banks) * 0x2000 + (addr as usize & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 2 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + load_chr_state(&mut self.chr_data, &data[2..]) + } +} diff --git a/src/native_core/mapper/mappers/nrom.rs b/src/native_core/mapper/mappers/nrom.rs new file mode 100644 index 0000000..f9492c8 --- /dev/null +++ b/src/native_core/mapper/mappers/nrom.rs @@ -0,0 +1,73 @@ +use super::*; + +pub(crate) struct Nrom { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, +} + +impl Nrom { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + } + } +} + +impl Mapper for Nrom { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + read_bank( + &self.prg_rom, + 0x4000, + ((addr - 0x8000) as usize) / 0x4000, + addr as usize, + ) + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn cpu_read_low(&self, addr: u16) -> Option { + let _ = addr; + None + } + + fn cpu_write_low(&mut self, addr: u16, _value: u8) -> bool { + let _ = addr; + false + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + load_chr_state(&mut self.chr_data, data) + } +} diff --git a/src/native_core/mapper/mappers/tqrom119.rs b/src/native_core/mapper/mappers/tqrom119.rs new file mode 100644 index 0000000..5f08c27 --- /dev/null +++ b/src/native_core/mapper/mappers/tqrom119.rs @@ -0,0 +1,269 @@ +use super::*; + +pub(crate) struct Tqrom119 { + prg_rom: Vec, + chr_rom: Vec, + chr_ram: Vec, + prg_ram: Vec, + prg_ram_enabled: bool, + prg_ram_write_protect: bool, + mirroring: Mirroring, + bank_regs: [u8; 8], + bank_select: u8, + irq_latch: u8, + irq_counter: u8, + irq_reload: bool, + irq_enabled: bool, + irq_pending: bool, +} + +impl Tqrom119 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_rom: rom.chr_data, + chr_ram: vec![0; 0x2000], + prg_ram: vec![0; 0x2000], + prg_ram_enabled: true, + prg_ram_write_protect: false, + mirroring: rom.header.mirroring, + bank_regs: [0; 8], + bank_select: 0, + irq_latch: 0, + irq_counter: 0, + irq_reload: false, + irq_enabled: false, + irq_pending: false, + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / 0x2000).max(1) + } + + fn prg_mode(&self) -> bool { + (self.bank_select & 0x40) != 0 + } + + fn chr_invert(&self) -> bool { + (self.bank_select & 0x80) != 0 + } + + fn prg_bank_for_slot(&self, slot: usize) -> usize { + let last = self.prg_bank_count_8k() - 1; + let second_last = last.saturating_sub(1); + match (self.prg_mode(), slot) { + (false, 0) => self.bank_regs[6] as usize, + (false, 1) => self.bank_regs[7] as usize, + (false, 2) => second_last, + (false, 3) => last, + (true, 0) => second_last, + (true, 1) => self.bank_regs[7] as usize, + (true, 2) => self.bank_regs[6] as usize, + (true, 3) => last, + _ => 0, + } + } + + fn chr_bank_for_1k_page(&self, page: usize) -> u8 { + let regs = &self.bank_regs; + let mut layout = [0u8; 8]; + + layout[0] = regs[0] & !1; + layout[1] = layout[0].wrapping_add(1); + layout[2] = regs[1] & !1; + layout[3] = layout[2].wrapping_add(1); + layout[4] = regs[2]; + layout[5] = regs[3]; + layout[6] = regs[4]; + layout[7] = regs[5]; + + if self.chr_invert() { + layout.rotate_left(4); + } + layout[page] + } + + fn clock_irq_scanline(&mut self) { + if self.irq_reload || self.irq_counter == 0 { + self.irq_counter = self.irq_latch; + self.irq_reload = false; + } else { + self.irq_counter = self.irq_counter.wrapping_sub(1); + } + if self.irq_enabled && self.irq_counter == 0 { + self.irq_pending = true; + } + } + + fn chr_read_page(&self, bank: u8, offset: usize) -> u8 { + if (bank & 0x40) != 0 { + read_bank(&self.chr_ram, 0x0400, (bank & 0x07) as usize, offset) + } else { + read_bank(&self.chr_rom, 0x0400, (bank & 0x3F) as usize, offset) + } + } + + fn chr_write_page(&mut self, bank: u8, offset: usize, value: u8) { + if (bank & 0x40) == 0 { + return; + } + let page = (bank & 0x07) as usize; + let idx = page * 0x0400 + (offset & 0x03FF); + if let Some(cell) = self.chr_ram.get_mut(idx) { + *cell = value; + } + } +} + +impl Mapper for Tqrom119 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let bank = self.prg_bank_for_slot(slot); + read_bank( + &self.prg_rom, + 0x2000, + bank, + ((addr as usize) - 0x8000) & 0x1FFF, + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0x9FFF if (addr & 1) == 0 => self.bank_select = value, + 0x8000..=0x9FFF => { + let reg = (self.bank_select & 0x07) as usize; + self.bank_regs[reg] = value; + } + 0xA000..=0xBFFF if (addr & 1) == 0 => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } + 0xA000..=0xBFFF => { + self.prg_ram_enabled = (value & 0x80) != 0; + self.prg_ram_write_protect = (value & 0x40) != 0; + } + 0xC000..=0xDFFF if (addr & 1) == 0 => self.irq_latch = value, + 0xC000..=0xDFFF => { + self.irq_counter = 0; + self.irq_reload = true; + } + 0xE000..=0xFFFF if (addr & 1) == 0 => { + self.irq_enabled = false; + self.irq_pending = false; + } + 0xE000..=0xFFFF => self.irq_enabled = true, + _ => {} + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) { + if self.prg_ram_enabled { + Some(self.prg_ram[(addr as usize) - 0x6000]) + } else { + Some(0) + } + } else { + None + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) { + if self.prg_ram_enabled && !self.prg_ram_write_protect { + self.prg_ram[(addr as usize) - 0x6000] = value; + } + true + } else { + false + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_1k_page(page); + self.chr_read_page(bank, (addr as usize) & 0x03FF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if addr > 0x1FFF { + return; + } + let page = (addr / 0x0400) as usize; + let bank = self.chr_bank_for_1k_page(page); + self.chr_write_page(bank, (addr as usize) & 0x03FF, value); + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn clock_scanline(&mut self) { + self.clock_irq_scanline(); + } + + fn needs_ppu_a12_clock(&self) -> bool { + true + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.extend_from_slice(&self.bank_regs); + out.push(self.bank_select); + out.push(encode_mirroring(self.mirroring)); + out.push(self.irq_latch); + out.push(self.irq_counter); + out.push(u8::from(self.irq_reload)); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_pending)); + out.push(u8::from(self.prg_ram_enabled)); + out.push(u8::from(self.prg_ram_write_protect)); + write_state_bytes(out, &self.prg_ram); + write_state_bytes(out, &self.chr_ram); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 17 { + return Err("mapper state is truncated".to_string()); + } + self.bank_regs.copy_from_slice(&data[0..8]); + self.bank_select = data[8]; + self.mirroring = decode_mirroring(data[9]); + self.irq_latch = data[10]; + self.irq_counter = data[11]; + self.irq_reload = data[12] != 0; + self.irq_enabled = data[13] != 0; + self.irq_pending = data[14] != 0; + let mut cursor = 15usize; + self.prg_ram_enabled = data[cursor] != 0; + cursor += 1; + self.prg_ram_write_protect = data[cursor] != 0; + cursor += 1; + let prg_ram = read_state_bytes(data, &mut cursor)?; + if prg_ram.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram); + let chr_ram = read_state_bytes(data, &mut cursor)?; + if chr_ram.len() != self.chr_ram.len() || cursor != data.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.chr_ram.copy_from_slice(chr_ram); + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/un1rom94.rs b/src/native_core/mapper/mappers/un1rom94.rs new file mode 100644 index 0000000..ac3c8a9 --- /dev/null +++ b/src/native_core/mapper/mappers/un1rom94.rs @@ -0,0 +1,87 @@ +use super::*; + +pub(crate) struct Un1rom94 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + bank_select: u8, +} + +impl Un1rom94 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + bank_select: 0, + } + } + + fn prg_banks(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } +} + +impl Mapper for Un1rom94 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.bank_select as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_banks() - 1, + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.bank_select = (value >> 2) & 0x07; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.bank_select); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.is_empty() { + return Err("mapper state is truncated".to_string()); + } + self.bank_select = data[0]; + load_chr_state(&mut self.chr_data, &data[1..]) + } +} diff --git a/src/native_core/mapper/mappers/unrom512_30.rs b/src/native_core/mapper/mappers/unrom512_30.rs new file mode 100644 index 0000000..8ab4264 --- /dev/null +++ b/src/native_core/mapper/mappers/unrom512_30.rs @@ -0,0 +1,123 @@ +use super::*; + +pub(crate) struct Unrom512_30 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring_default: Mirroring, + prg_bank: u8, + chr_bank: u8, + one_screen_hi: Option, +} + +impl Unrom512_30 { + pub(crate) fn new(rom: InesRom) -> Self { + let mut chr_data = rom.chr_data; + if rom.chr_is_ram && chr_data.len() < 0x8000 { + chr_data.resize(0x8000, 0); + } + Self { + prg_rom: rom.prg_rom, + chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring_default: rom.header.mirroring, + prg_bank: 0, + chr_bank: 0, + one_screen_hi: None, + } + } + + fn prg_bank_count_16k(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } +} + +impl Mapper for Unrom512_30 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_bank_count_16k().saturating_sub(1), + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr < 0x8000 { + return; + } + self.prg_bank = value & 0x1F; + self.chr_bank = (value >> 5) & 0x03; + self.one_screen_hi = Some((value & 0x80) != 0); + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + read_bank( + &self.chr_data, + 0x2000, + self.chr_bank as usize, + addr as usize, + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let total_banks = (self.chr_data.len() / 0x2000).max(1); + let bank_idx = safe_mod(self.chr_bank as usize, total_banks); + let idx = bank_idx * 0x2000 + ((addr as usize) & 0x1FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match self.one_screen_hi { + Some(true) => Mirroring::OneScreenHigh, + Some(false) => Mirroring::OneScreenLow, + None => self.mirroring_default, + } + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank); + out.push(self.chr_bank); + out.push(match self.one_screen_hi { + Some(true) => 2, + Some(false) => 1, + None => 0, + }); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 3 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank = data[0]; + self.chr_bank = data[1]; + self.one_screen_hi = match data[2] { + 0 => None, + 1 => Some(false), + 2 => Some(true), + _ => None, + }; + load_chr_state(&mut self.chr_data, &data[3..]) + } +} diff --git a/src/native_core/mapper/mappers/uxrom.rs b/src/native_core/mapper/mappers/uxrom.rs new file mode 100644 index 0000000..4864851 --- /dev/null +++ b/src/native_core/mapper/mappers/uxrom.rs @@ -0,0 +1,87 @@ +use super::*; + +pub(crate) struct Uxrom { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + bank_select: u8, +} + +impl Uxrom { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + bank_select: 0, + } + } + + fn prg_banks(&self) -> usize { + (self.prg_rom.len() / 0x4000).max(1) + } +} + +impl Mapper for Uxrom { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + 0x4000, + self.bank_select as usize, + (addr as usize) - 0x8000, + ) + } else { + read_bank( + &self.prg_rom, + 0x4000, + self.prg_banks() - 1, + (addr as usize) - 0xC000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.bank_select = value & 0x0F; + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + self.chr_data.get(addr as usize).copied().unwrap_or(0) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + if let Some(cell) = self.chr_data.get_mut(addr as usize) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.bank_select); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.is_empty() { + return Err("mapper state is truncated".to_string()); + } + self.bank_select = data[0]; + load_chr_state(&mut self.chr_data, &data[1..]) + } +} diff --git a/src/native_core/mapper/mappers/vrc/mod.rs b/src/native_core/mapper/mappers/vrc/mod.rs new file mode 100644 index 0000000..c5a0b58 --- /dev/null +++ b/src/native_core/mapper/mappers/vrc/mod.rs @@ -0,0 +1,11 @@ +use super::*; + +mod vrc1_75; +mod vrc2_23; +mod vrc6_24; +mod vrc7_85; + +pub(crate) use vrc1_75::Vrc1_75; +pub(crate) use vrc2_23::Vrc2_23; +pub(crate) use vrc6_24::Vrc6_24; +pub(crate) use vrc7_85::Vrc7_85; diff --git a/src/native_core/mapper/mappers/vrc/vrc1_75.rs b/src/native_core/mapper/mappers/vrc/vrc1_75.rs new file mode 100644 index 0000000..96be860 --- /dev/null +++ b/src/native_core/mapper/mappers/vrc/vrc1_75.rs @@ -0,0 +1,122 @@ +use super::*; + +pub(crate) struct Vrc1_75 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring: Mirroring, + prg_bank_8k_8000: u8, + prg_bank_8k_a000: u8, + prg_bank_8k_c000: u8, + chr_bank_0: u8, + chr_bank_1: u8, +} + +impl Vrc1_75 { + pub(crate) fn new(rom: InesRom) -> Self { + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring: rom.header.mirroring, + prg_bank_8k_8000: 0, + prg_bank_8k_a000: 1, + prg_bank_8k_c000: 2, + chr_bank_0: 0, + chr_bank_1: 1, + } + } +} + +impl Mapper for Vrc1_75 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let bank = if addr < 0xA000 { + self.prg_bank_8k_8000 as usize + } else if addr < 0xC000 { + self.prg_bank_8k_a000 as usize + } else if addr < 0xE000 { + self.prg_bank_8k_c000 as usize + } else { + (self.prg_rom.len() / 0x2000).max(1).saturating_sub(1) + }; + read_bank(&self.prg_rom, 0x2000, bank, (addr as usize) & 0x1FFF) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0x8FFF => self.prg_bank_8k_8000 = value & 0x0F, + 0x9000..=0x9FFF => { + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + self.chr_bank_0 = (self.chr_bank_0 & 0x0F) | ((value & 0x02) << 3); + self.chr_bank_1 = (self.chr_bank_1 & 0x0F) | ((value & 0x04) << 2); + } + 0xA000..=0xAFFF => self.prg_bank_8k_a000 = value & 0x0F, + 0xC000..=0xCFFF => self.prg_bank_8k_c000 = value & 0x0F, + 0xE000..=0xEFFF => self.chr_bank_0 = (self.chr_bank_0 & 0x10) | (value & 0x0F), + 0xF000..=0xFFFF => self.chr_bank_1 = (self.chr_bank_1 & 0x10) | (value & 0x0F), + _ => {} + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let bank = if addr < 0x1000 { + self.chr_bank_0 as usize + } else { + self.chr_bank_1 as usize + }; + read_bank(&self.chr_data, 0x1000, bank, (addr as usize) & 0x0FFF) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let bank = if addr < 0x1000 { + self.chr_bank_0 as usize + } else { + self.chr_bank_1 as usize + }; + let total = (self.chr_data.len() / 0x1000).max(1); + let idx = safe_mod(bank, total) * 0x1000 + ((addr as usize) & 0x0FFF); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank_8k_8000); + out.push(self.prg_bank_8k_a000); + out.push(self.prg_bank_8k_c000); + out.push(self.chr_bank_0); + out.push(self.chr_bank_1); + out.push(encode_mirroring(self.mirroring)); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 6 { + return Err("mapper state is truncated".to_string()); + } + self.prg_bank_8k_8000 = data[0]; + self.prg_bank_8k_a000 = data[1]; + self.prg_bank_8k_c000 = data[2]; + self.chr_bank_0 = data[3]; + self.chr_bank_1 = data[4]; + self.mirroring = decode_mirroring(data[5]); + load_chr_state(&mut self.chr_data, &data[6..]) + } +} diff --git a/src/native_core/mapper/mappers/vrc/vrc2_23.rs b/src/native_core/mapper/mappers/vrc/vrc2_23.rs new file mode 100644 index 0000000..0c501b1 --- /dev/null +++ b/src/native_core/mapper/mappers/vrc/vrc2_23.rs @@ -0,0 +1,399 @@ +use super::*; + +pub(crate) struct Vrc2_23 { + pub(crate) mapper_id: u16, + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + prg_ram: Vec, + mirroring: Mirroring, + has_irq: bool, + submapper: u8, + prg_swap: bool, + prg_bank_8000: u8, + prg_bank_a000: u8, + chr_banks_1k: [u16; 8], + irq_latch: u8, + irq_counter: u8, + irq_enabled: bool, + irq_enabled_after_ack: bool, + irq_mode_cpu: bool, + irq_pending: bool, + irq_prescaler: i16, +} + +impl Vrc2_23 { + pub(crate) fn new(rom: InesRom) -> Self { + let mapper_id = rom.header.mapper; + let submapper = rom.header.submapper; + let has_irq = mapper_id != 22 && !matches!(submapper, 3 | 4); + let mut chr_banks_1k = [0u16; 8]; + for (i, bank) in chr_banks_1k.iter_mut().enumerate() { + *bank = i as u16; + } + let prg_ram_size = if rom.header.prg_ram_shift > 0 { + 64usize << rom.header.prg_ram_shift + } else { + 8192 // Default 8KB WRAM for VRC2/VRC4 boards + }; + Self { + mapper_id, + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + prg_ram: vec![0; prg_ram_size], + mirroring: rom.header.mirroring, + has_irq, + submapper, + prg_swap: false, + prg_bank_8000: 0, + prg_bank_a000: 1, + chr_banks_1k, + irq_latch: 0, + irq_counter: 0, + irq_enabled: false, + irq_enabled_after_ack: false, + irq_mode_cpu: false, + irq_pending: false, + irq_prescaler: 341, + } + } + + pub(crate) fn new_with_submapper(rom: InesRom, forced_submapper: u8) -> Self { + let mut mapper = Self::new(rom); + mapper.submapper = forced_submapper; + mapper + } + + fn chr_bank_count_1k(&self) -> usize { + (self.chr_data.len() / CHR_BANK_1K).max(1) + } + + fn set_chr_nibble(&mut self, bank: usize, high: bool, value: u8) { + if bank >= 8 { + return; + } + let cur = self.chr_banks_1k[bank]; + self.chr_banks_1k[bank] = if high { + (cur & 0x000F) | (((value & 0x0F) as u16) << 4) + } else { + (cur & 0x01F0) | ((value & 0x0F) as u16) + }; + } + + fn decode_subindex(addr: u16, a0_bit: u8, a1_bit: u8) -> usize { + let a0 = ((addr >> a0_bit) & 1) as usize; + let a1 = ((addr >> a1_bit) & 1) as usize; + a0 | (a1 << 1) + } + + fn lower_wiring_bits(&self) -> (u8, u8) { + match self.mapper_id { + 21 => (1, 2), // VRC4a-style + 22 => (1, 0), // VRC2a (swapped A0/A1) + 23 => (0, 1), // VRC2b/VRC4f + 25 => (1, 0), // VRC2c/VRC4d (swapped) + _ => (0, 1), + } + } + + fn higher_wiring_bits(&self) -> (u8, u8) { + match self.mapper_id { + 21 => (6, 7), // VRC4c-style + 22 => (1, 0), // VRC2a has a single decode variant + 23 => (2, 3), // VRC4e + 25 => (3, 2), // VRC4d (swapped) + _ => (0, 1), + } + } + + fn decode_subindices(&self, addr: u16) -> ([usize; 2], usize) { + if self.mapper_id == 22 { + let (a0, a1) = self.lower_wiring_bits(); + return ([Self::decode_subindex(addr, a0, a1), 0], 1); + } + + let (lower_a0, lower_a1) = self.lower_wiring_bits(); + let lower = Self::decode_subindex(addr, lower_a0, lower_a1); + match self.submapper { + 1 | 3 => ([lower, 0], 1), // lower-variant wiring only + 2 | 4 => { + let (higher_a0, higher_a1) = self.higher_wiring_bits(); + ([Self::decode_subindex(addr, higher_a0, higher_a1), 0], 1) + } + _ => { + let (higher_a0, higher_a1) = self.higher_wiring_bits(); + // iNES and NES2 submapper 0: support both known address families, + // but choose one decode per write to avoid conflicting double-updates. + let higher_selected = + ((addr >> higher_a0) & 1) != 0 || ((addr >> higher_a1) & 1) != 0; + if higher_selected { + ([Self::decode_subindex(addr, higher_a0, higher_a1), 0], 1) + } else { + ([lower, 0], 1) + } + } + } + } + + fn effective_chr_bank(&self, raw_bank: u16) -> usize { + if self.mapper_id == 22 { + // VRC2a (mapper 22): CHR bank value is shifted right by 1. + (raw_bank >> 1) as usize + } else { + raw_bank as usize + } + } + + fn irq_state(&mut self) -> VrcIrqRegisters<'_> { + VrcIrqRegisters { + latch: &mut self.irq_latch, + counter: &mut self.irq_counter, + enabled: &mut self.irq_enabled, + enabled_after_ack: &mut self.irq_enabled_after_ack, + mode_cpu: &mut self.irq_mode_cpu, + pending: &mut self.irq_pending, + prescaler: &mut self.irq_prescaler, + } + } +} + +impl Mapper for Vrc2_23 { + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) && !self.prg_ram.is_empty() { + Some(self.prg_ram[(addr as usize - 0x6000) % self.prg_ram.len()]) + } else { + None + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) && !self.prg_ram.is_empty() { + let idx = (addr as usize - 0x6000) % self.prg_ram.len(); + self.prg_ram[idx] = value; + true + } else { + false + } + } + + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let slot = ((addr - 0x8000) / 0x2000) as usize; + let last = (self.prg_rom.len() / PRG_BANK_8K).max(1).saturating_sub(1); + let fixed_second_last = last.saturating_sub(1); + let bank0 = if self.has_irq && self.prg_swap { + fixed_second_last + } else { + self.prg_bank_8000 as usize + }; + let bank2 = if self.has_irq && self.prg_swap { + self.prg_bank_8000 as usize + } else { + fixed_second_last + }; + let bank = match slot { + 0 => bank0, + 1 => self.prg_bank_a000 as usize, + 2 => bank2, + _ => last, + }; + read_bank( + &self.prg_rom, + PRG_BANK_8K, + bank, + ((addr as usize) - 0x8000) & (PRG_BANK_8K - 1), + ) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x8000..=0x8FFF => self.prg_bank_8000 = value & 0x1F, + 0x9000..=0x9FFF => { + let (subs, count) = self.decode_subindices(addr); + for i in 0..count { + let sub = subs[i]; + if i > 0 && sub == subs[0] { + continue; + } + if sub == 0 { + self.mirroring = if self.has_irq { + match value & 0x03 { + 0 => Mirroring::Vertical, + 1 => Mirroring::Horizontal, + 2 => Mirroring::OneScreenLow, + _ => Mirroring::OneScreenHigh, + } + } else if (value & 0x01) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + } else if sub == 2 && self.has_irq { + self.prg_swap = (value & 0x02) != 0; + } + } + } + 0xA000..=0xAFFF => self.prg_bank_a000 = value & 0x1F, + 0xB000..=0xEFFF => { + let group = ((addr - 0xB000) / 0x1000) as usize; + let (subs, count) = self.decode_subindices(addr); + for i in 0..count { + let sub = subs[i]; + if i > 0 && sub == subs[0] { + continue; + } + let bank = group * 2 + ((sub >> 1) & 1); + let high = (sub & 1) != 0; + self.set_chr_nibble(bank, high, value); + } + } + 0xF000..=0xFFFF if self.has_irq => { + let (subs, count) = self.decode_subindices(addr); + for i in 0..count { + let sub = subs[i]; + if i > 0 && sub == subs[0] { + continue; + } + match sub { + 0 => self.irq_latch = (self.irq_latch & 0xF0) | (value & 0x0F), + 1 => self.irq_latch = (self.irq_latch & 0x0F) | ((value & 0x0F) << 4), + 2 => vrc_irq_write_control(value, self.irq_state()), + _ => vrc_irq_ack(self.irq_state()), + } + } + } + _ => {} + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / CHR_BANK_1K as u16) as usize; + let bank = safe_mod( + self.effective_chr_bank(self.chr_banks_1k[page]), + self.chr_bank_count_1k(), + ); + read_bank( + &self.chr_data, + CHR_BANK_1K, + bank, + (addr as usize) & (CHR_BANK_1K - 1), + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / CHR_BANK_1K as u16) as usize; + let bank = safe_mod( + self.effective_chr_bank(self.chr_banks_1k[page]), + self.chr_bank_count_1k(), + ); + let idx = bank * CHR_BANK_1K + (addr as usize & (CHR_BANK_1K - 1)); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn clock_cpu(&mut self, cycles: u8) { + if !self.has_irq { + return; + } + vrc_irq_clock(cycles, self.irq_state()); + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(self.prg_bank_8000); + out.push(self.prg_bank_a000); + out.push(encode_mirroring(self.mirroring)); + out.push(u8::from(self.has_irq)); + out.push(u8::from(self.chr_is_ram)); + out.push(self.submapper); + out.push(u8::from(self.prg_swap)); + out.push(self.irq_latch); + out.push(self.irq_counter); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_enabled_after_ack)); + out.push(u8::from(self.irq_mode_cpu)); + out.push(u8::from(self.irq_pending)); + out.extend_from_slice(&self.irq_prescaler.to_le_bytes()); + for bank in self.chr_banks_1k { + out.extend_from_slice(&bank.to_le_bytes()); + } + write_chr_state(out, &self.chr_data); + write_state_bytes(out, &self.prg_ram); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 35 { + return Err("mapper state is truncated".to_string()); + } + let mut cursor = 0usize; + self.prg_bank_8000 = data[cursor]; + cursor += 1; + self.prg_bank_a000 = data[cursor]; + cursor += 1; + self.mirroring = decode_mirroring(data[cursor]); + cursor += 1; + + self.has_irq = data[cursor] != 0; + cursor += 1; + self.chr_is_ram = data[cursor] != 0; + cursor += 1; + self.submapper = data[cursor]; + cursor += 1; + self.prg_swap = data[cursor] != 0; + cursor += 1; + self.irq_latch = data[cursor]; + cursor += 1; + self.irq_counter = data[cursor]; + cursor += 1; + self.irq_enabled = data[cursor] != 0; + cursor += 1; + self.irq_enabled_after_ack = data[cursor] != 0; + cursor += 1; + self.irq_mode_cpu = data[cursor] != 0; + cursor += 1; + self.irq_pending = data[cursor] != 0; + cursor += 1; + self.irq_prescaler = i16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + if data.len().saturating_sub(cursor) < 16 + 4 { + return Err("mapper state is truncated".to_string()); + } + for i in 0..8usize { + let off = cursor + i * 2; + self.chr_banks_1k[i] = u16::from_le_bytes([data[off], data[off + 1]]); + } + cursor += 16; + let chr_payload = read_state_bytes(data, &mut cursor)?; + if chr_payload.len() != self.chr_data.len() { + return Err("mapper CHR state does not match loaded ROM".to_string()); + } + self.chr_data.copy_from_slice(chr_payload); + // Try to load PRG-RAM if present (backwards compatible) + if cursor + 4 <= data.len() { + let ram_payload = read_state_bytes(data, &mut cursor)?; + if ram_payload.len() == self.prg_ram.len() { + self.prg_ram.copy_from_slice(ram_payload); + } + } + Ok(()) + } +} diff --git a/src/native_core/mapper/mappers/vrc/vrc6_24.rs b/src/native_core/mapper/mappers/vrc/vrc6_24.rs new file mode 100644 index 0000000..24fcd5c --- /dev/null +++ b/src/native_core/mapper/mappers/vrc/vrc6_24.rs @@ -0,0 +1,259 @@ +use super::*; + +pub(crate) struct Vrc6_24 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + mirroring_default: Mirroring, + mapper26_wiring: bool, + prg_bank_16k: u8, + prg_bank_8k: u8, + chr_banks_1k: [u8; 8], + control: u8, + prg_ram: Vec, + irq_latch: u8, + irq_counter: u8, + irq_enabled: bool, + irq_enabled_after_ack: bool, + irq_mode_cpu: bool, + irq_pending: bool, + irq_prescaler: i16, +} + +impl Vrc6_24 { + pub(crate) fn new(rom: InesRom, mapper26_wiring: bool) -> Self { + let mut chr_banks_1k = [0u8; 8]; + for (i, bank) in chr_banks_1k.iter_mut().enumerate() { + *bank = i as u8; + } + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + mirroring_default: rom.header.mirroring, + mapper26_wiring, + prg_bank_16k: 0, + prg_bank_8k: 0, + chr_banks_1k, + control: 0, + prg_ram: vec![0; PRG_RAM_8K], + irq_latch: 0, + irq_counter: 0, + irq_enabled: false, + irq_enabled_after_ack: false, + irq_mode_cpu: false, + irq_pending: false, + irq_prescaler: 341, + } + } + + fn decode_register(&self, addr: u16) -> u16 { + let hi = addr & 0xF000; + let mut lo = addr & 0x0003; + if self.mapper26_wiring { + lo = match lo { + 1 => 2, + 2 => 1, + _ => lo, + }; + } + hi | lo + } + + fn chr_page_bank(&self, page: usize) -> usize { + let mode = self.control & 0x03; + let idx = match mode { + 0 => page, + 1 => [0, 1, 1, 3, 2, 5, 3, 7][page], + _ => [0, 0, 1, 1, 2, 2, 3, 3][page], + }; + self.chr_banks_1k[idx] as usize + } + + fn irq_state(&mut self) -> VrcIrqRegisters<'_> { + VrcIrqRegisters { + latch: &mut self.irq_latch, + counter: &mut self.irq_counter, + enabled: &mut self.irq_enabled, + enabled_after_ack: &mut self.irq_enabled_after_ack, + mode_cpu: &mut self.irq_mode_cpu, + pending: &mut self.irq_pending, + prescaler: &mut self.irq_prescaler, + } + } +} + +impl Mapper for Vrc6_24 { + fn cpu_read(&self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + if addr < 0xC000 { + read_bank( + &self.prg_rom, + PRG_BANK_16K, + self.prg_bank_16k as usize, + (addr as usize) - 0x8000, + ) + } else if addr < 0xE000 { + read_bank( + &self.prg_rom, + PRG_BANK_8K, + self.prg_bank_8k as usize, + (addr as usize) - 0xC000, + ) + } else { + read_bank( + &self.prg_rom, + PRG_BANK_8K, + (self.prg_rom.len() / PRG_BANK_8K).max(1).saturating_sub(1), + (addr as usize) - 0xE000, + ) + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr < 0x8000 { + return; + } + match self.decode_register(addr) { + 0x8000..=0x8003 => self.prg_bank_16k = value & 0x0F, + 0x9003 => self.control = value, + 0xC000..=0xC003 => self.prg_bank_8k = value & 0x1F, + 0xD000 => self.chr_banks_1k[0] = value, + 0xD001 => self.chr_banks_1k[1] = value, + 0xD002 => self.chr_banks_1k[2] = value, + 0xD003 => self.chr_banks_1k[3] = value, + 0xE000 => self.chr_banks_1k[4] = value, + 0xE001 => self.chr_banks_1k[5] = value, + 0xE002 => self.chr_banks_1k[6] = value, + 0xE003 => self.chr_banks_1k[7] = value, + 0xF000 => self.irq_latch = value, + 0xF001 => vrc_irq_write_control(value, self.irq_state()), + 0xF002 => vrc_irq_ack(self.irq_state()), + _ => {} + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) && (self.control & 0x80) != 0 { + Some(self.prg_ram[(addr as usize) - 0x6000]) + } else { + None + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) && (self.control & 0x80) != 0 { + self.prg_ram[(addr as usize) - 0x6000] = value; + true + } else { + false + } + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / CHR_BANK_1K as u16) as usize; + let bank = self.chr_page_bank(page); + read_bank( + &self.chr_data, + CHR_BANK_1K, + bank, + (addr as usize) & (CHR_BANK_1K - 1), + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / CHR_BANK_1K as u16) as usize; + let bank = safe_mod( + self.chr_page_bank(page), + (self.chr_data.len() / CHR_BANK_1K).max(1), + ); + let idx = bank * CHR_BANK_1K + ((addr as usize) & (CHR_BANK_1K - 1)); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + match (self.control >> 2) & 0x03 { + 0 => Mirroring::Vertical, + 1 => Mirroring::Horizontal, + 2 => Mirroring::OneScreenLow, + 3 => Mirroring::OneScreenHigh, + _ => self.mirroring_default, + } + } + + fn clock_cpu(&mut self, cycles: u8) { + vrc_irq_clock(cycles, self.irq_state()); + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.push(u8::from(self.mapper26_wiring)); + out.push(self.prg_bank_16k); + out.push(self.prg_bank_8k); + out.extend_from_slice(&self.chr_banks_1k); + out.push(self.control); + out.push(self.irq_latch); + out.push(self.irq_counter); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_enabled_after_ack)); + out.push(u8::from(self.irq_mode_cpu)); + out.push(u8::from(self.irq_pending)); + out.extend_from_slice(&self.irq_prescaler.to_le_bytes()); + write_state_bytes(out, &self.prg_ram); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + if data.len() < 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 2 { + return Err("mapper state is truncated".to_string()); + } + let mut cursor = 0usize; + self.mapper26_wiring = data[cursor] != 0; + cursor += 1; + self.prg_bank_16k = data[cursor]; + cursor += 1; + self.prg_bank_8k = data[cursor]; + cursor += 1; + self.chr_banks_1k.copy_from_slice(&data[cursor..cursor + 8]); + cursor += 8; + self.control = data[cursor]; + cursor += 1; + self.irq_latch = data[cursor]; + cursor += 1; + self.irq_counter = data[cursor]; + cursor += 1; + self.irq_enabled = data[cursor] != 0; + cursor += 1; + self.irq_enabled_after_ack = data[cursor] != 0; + cursor += 1; + self.irq_mode_cpu = data[cursor] != 0; + cursor += 1; + self.irq_pending = data[cursor] != 0; + cursor += 1; + self.irq_prescaler = i16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + + let prg_ram = read_state_bytes(data, &mut cursor)?; + if prg_ram.len() != self.prg_ram.len() { + return Err("mapper state does not match loaded ROM".to_string()); + } + self.prg_ram.copy_from_slice(prg_ram); + + load_chr_state(&mut self.chr_data, &data[cursor..]) + } +} diff --git a/src/native_core/mapper/mappers/vrc/vrc7_85.rs b/src/native_core/mapper/mappers/vrc/vrc7_85.rs new file mode 100644 index 0000000..5d7ab49 --- /dev/null +++ b/src/native_core/mapper/mappers/vrc/vrc7_85.rs @@ -0,0 +1,309 @@ +use super::*; + +pub(crate) struct Vrc7_85 { + prg_rom: Vec, + chr_data: Vec, + chr_is_ram: bool, + submapper: u8, + mirroring_default: Mirroring, + prg_ram: Vec, + prg_ram_enabled: bool, + prg_banks_8k: [u8; 3], + chr_banks_1k: [u8; 8], + irq_latch: u8, + irq_counter: u8, + irq_enabled: bool, + irq_enabled_after_ack: bool, + irq_mode_cpu: bool, + irq_pending: bool, + irq_prescaler: i16, +} + +impl Vrc7_85 { + pub(crate) fn new(rom: InesRom) -> Self { + let mut chr_banks_1k = [0u8; 8]; + for (i, bank) in chr_banks_1k.iter_mut().enumerate() { + *bank = i as u8; + } + Self { + prg_rom: rom.prg_rom, + chr_data: rom.chr_data, + chr_is_ram: rom.chr_is_ram, + submapper: rom.header.submapper, + mirroring_default: rom.header.mirroring, + prg_ram: vec![0; PRG_RAM_8K], + prg_ram_enabled: false, + prg_banks_8k: [0, 1, 2], + chr_banks_1k, + irq_latch: 0, + irq_counter: 0, + irq_enabled: false, + irq_enabled_after_ack: false, + irq_mode_cpu: false, + irq_pending: false, + irq_prescaler: 341, + } + } + + fn prg_bank_count_8k(&self) -> usize { + (self.prg_rom.len() / PRG_BANK_8K).max(1) + } + + fn chr_bank_count_1k(&self) -> usize { + (self.chr_data.len() / CHR_BANK_1K).max(1) + } + + fn is_secondary_addr(&self, addr: u16) -> bool { + let a3 = (addr & 0x0008) != 0; + let a4 = (addr & 0x0010) != 0; + match self.submapper { + 1 => a3, + 2 => a4, + _ => a3 || a4, + } + } + + fn decode_chr_reg(&self, addr: u16) -> Option { + let group = ((addr >> 12) & 0x0F) as usize; + if !(0x0A..=0x0D).contains(&group) { + return None; + } + let base = (group - 0x0A) * 2; + let odd = usize::from(self.is_secondary_addr(addr)); + Some(base + odd) + } + + fn load_native_state(&mut self, data: &[u8]) -> Result<(), String> { + let min_len = PRG_RAM_8K + + 1 // submapper + + 1 // prg_ram_enabled + + 3 // prg banks + + 8 // chr banks + + 1 // mirroring + + 1 // irq_latch + + 1 // irq_counter + + 1 // irq_enabled + + 1 // irq_enabled_after_ack + + 1 // irq_mode_cpu + + 1 // irq_pending + + 2 // irq_prescaler + + 4; // chr blob length + if data.len() < min_len { + return Err("mapper state is truncated".to_string()); + } + + let mut cursor = 0usize; + let mut prg_ram = vec![0u8; PRG_RAM_8K]; + prg_ram.copy_from_slice(&data[cursor..cursor + PRG_RAM_8K]); + cursor += PRG_RAM_8K; + + let submapper = data[cursor]; + cursor += 1; + let prg_ram_enabled = data[cursor] != 0; + cursor += 1; + + let mut prg_banks_8k = [0u8; 3]; + prg_banks_8k.copy_from_slice(&data[cursor..cursor + 3]); + cursor += 3; + + let mut chr_banks_1k = [0u8; 8]; + chr_banks_1k.copy_from_slice(&data[cursor..cursor + 8]); + cursor += 8; + + let mirroring_default = decode_mirroring(data[cursor]); + cursor += 1; + let irq_latch = data[cursor]; + cursor += 1; + let irq_counter = data[cursor]; + cursor += 1; + let irq_enabled = data[cursor] != 0; + cursor += 1; + let irq_enabled_after_ack = data[cursor] != 0; + cursor += 1; + let irq_mode_cpu = data[cursor] != 0; + cursor += 1; + let irq_pending = data[cursor] != 0; + cursor += 1; + let irq_prescaler = i16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + + let mut chr_data = self.chr_data.clone(); + load_chr_state(&mut chr_data, &data[cursor..])?; + + self.prg_ram = prg_ram; + self.submapper = submapper; + self.prg_ram_enabled = prg_ram_enabled; + self.prg_banks_8k = prg_banks_8k; + self.chr_banks_1k = chr_banks_1k; + self.mirroring_default = mirroring_default; + self.irq_latch = irq_latch; + self.irq_counter = irq_counter; + self.irq_enabled = irq_enabled; + self.irq_enabled_after_ack = irq_enabled_after_ack; + self.irq_mode_cpu = irq_mode_cpu; + self.irq_pending = irq_pending; + self.irq_prescaler = irq_prescaler; + self.chr_data = chr_data; + Ok(()) + } + + fn irq_state(&mut self) -> VrcIrqRegisters<'_> { + VrcIrqRegisters { + latch: &mut self.irq_latch, + counter: &mut self.irq_counter, + enabled: &mut self.irq_enabled, + enabled_after_ack: &mut self.irq_enabled_after_ack, + mode_cpu: &mut self.irq_mode_cpu, + pending: &mut self.irq_pending, + prescaler: &mut self.irq_prescaler, + } + } +} + +impl Mapper for Vrc7_85 { + fn cpu_read(&self, addr: u16) -> u8 { + match addr { + 0x6000..=0x7FFF => { + if self.prg_ram_enabled { + self.prg_ram[(addr as usize) - 0x6000] + } else { + 0 + } + } + 0x8000..=0xFFFF => { + let slot = ((addr - 0x8000) / 0x2000) as usize; + let last = self.prg_bank_count_8k().saturating_sub(1); + let bank = match slot { + 0 => self.prg_banks_8k[0] as usize, + 1 => self.prg_banks_8k[1] as usize, + 2 => self.prg_banks_8k[2] as usize, + _ => last, + }; + read_bank( + &self.prg_rom, + PRG_BANK_8K, + bank, + ((addr as usize) - 0x8000) & (PRG_BANK_8K - 1), + ) + } + _ => 0, + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x6000..=0x7FFF => { + if self.prg_ram_enabled { + self.prg_ram[(addr as usize) - 0x6000] = value; + } + } + 0x8000..=0xFFFF => { + let masked = addr & 0xF018; + match masked { + 0x8000 => self.prg_banks_8k[0] = value & 0x3F, + 0x9000 => self.prg_banks_8k[2] = value & 0x3F, + 0x8008 | 0x8010 | 0x8018 => self.prg_banks_8k[1] = value & 0x3F, + 0xA000 | 0xA008 | 0xA010 | 0xA018 | 0xB000 | 0xB008 | 0xB010 | 0xB018 + | 0xC000 | 0xC008 | 0xC010 | 0xC018 | 0xD000 | 0xD008 | 0xD010 | 0xD018 => { + if let Some(reg) = self.decode_chr_reg(addr) { + self.chr_banks_1k[reg] = value; + } + } + 0xE000 => { + self.prg_ram_enabled = (value & 0x80) != 0; + self.mirroring_default = match value & 0x03 { + 0 => Mirroring::Vertical, + 1 => Mirroring::Horizontal, + 2 => Mirroring::OneScreenLow, + _ => Mirroring::OneScreenHigh, + }; + } + 0xE008 | 0xE010 | 0xE018 => self.irq_latch = value, + 0xF000 => vrc_irq_write_control(value, self.irq_state()), + 0xF008 | 0xF010 | 0xF018 => vrc_irq_ack(self.irq_state()), + _ => {} + } + } + _ => {} + } + } + + fn cpu_read_low(&self, addr: u16) -> Option { + if (0x6000..=0x7FFF).contains(&addr) && self.prg_ram_enabled { + Some(self.prg_ram[(addr as usize) - 0x6000]) + } else { + None + } + } + + fn cpu_write_low(&mut self, addr: u16, value: u8) -> bool { + if (0x6000..=0x7FFF).contains(&addr) { + if self.prg_ram_enabled { + self.prg_ram[(addr as usize) - 0x6000] = value; + } + return true; + } + false + } + + fn ppu_read(&self, addr: u16) -> u8 { + if addr > 0x1FFF { + return 0; + } + let page = (addr / CHR_BANK_1K as u16) as usize; + let bank = safe_mod(self.chr_banks_1k[page] as usize, self.chr_bank_count_1k()); + read_bank( + &self.chr_data, + CHR_BANK_1K, + bank, + (addr as usize) & (CHR_BANK_1K - 1), + ) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + if !self.chr_is_ram || addr > 0x1FFF { + return; + } + let page = (addr / CHR_BANK_1K as u16) as usize; + let bank = safe_mod(self.chr_banks_1k[page] as usize, self.chr_bank_count_1k()); + let idx = bank * CHR_BANK_1K + (addr as usize & (CHR_BANK_1K - 1)); + if let Some(cell) = self.chr_data.get_mut(idx) { + *cell = value; + } + } + + fn mirroring(&self) -> Mirroring { + self.mirroring_default + } + + fn clock_cpu(&mut self, cycles: u8) { + vrc_irq_clock(cycles, self.irq_state()); + } + + fn poll_irq(&mut self) -> bool { + let out = self.irq_pending; + self.irq_pending = false; + out + } + + fn save_state(&self, out: &mut Vec) { + out.extend_from_slice(&self.prg_ram); + out.push(self.submapper); + out.push(u8::from(self.prg_ram_enabled)); + out.extend_from_slice(&self.prg_banks_8k); + out.extend_from_slice(&self.chr_banks_1k); + out.push(encode_mirroring(self.mirroring_default)); + out.push(self.irq_latch); + out.push(self.irq_counter); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_enabled_after_ack)); + out.push(u8::from(self.irq_mode_cpu)); + out.push(u8::from(self.irq_pending)); + out.extend_from_slice(&self.irq_prescaler.to_le_bytes()); + write_chr_state(out, &self.chr_data); + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), String> { + self.load_native_state(data) + } +} diff --git a/src/native_core/mapper/mod.rs b/src/native_core/mapper/mod.rs new file mode 100644 index 0000000..df7caa1 --- /dev/null +++ b/src/native_core/mapper/mod.rs @@ -0,0 +1,70 @@ +mod core; +mod mappers; +mod types; + +pub use core::Mapper; + +use crate::native_core::ines::InesRom; +use mappers::*; + +pub fn create_mapper(rom: InesRom) -> Result, String> { + match rom.header.mapper { + 0 => Ok(Box::new(Nrom::new(rom))), + 1 => Ok(Box::new(Mmc1::new(rom))), + 2 => Ok(Box::new(Uxrom::new(rom))), + 3 => Ok(Box::new(Cnrom::new(rom))), + 4 => Ok(Box::new(Mmc3::new(rom))), + 5 => Ok(Box::new(Mmc5::new(rom))), + 7 => Ok(Box::new(Axrom::new(rom))), + 9 => Ok(Box::new(Mmc2::new(rom))), + 10 => Ok(Box::new(Mmc4::new(rom))), + 11 => Ok(Box::new(ColorDreams11::new(rom))), + 13 => Ok(Box::new(Cprom13::new(rom))), + 19 => Ok(Box::new(Namco163_19::new(rom))), + 21 => Ok(Box::new(Vrc2_23::new(rom))), + 22 => Ok(Box::new(Vrc2_23::new_with_submapper(rom, 2))), + 23 => Ok(Box::new(Vrc2_23::new(rom))), + 24 => Ok(Box::new(Vrc6_24::new(rom, false))), + 25 => Ok(Box::new(Vrc2_23::new(rom))), + 26 => Ok(Box::new(Vrc6_24::new(rom, true))), + 30 => Ok(Box::new(Unrom512_30::new(rom))), + 66 => Ok(Box::new(Gxrom::new(rom))), + 69 => Ok(Box::new(Fme7::new(rom))), + 70 => Ok(Box::new(Bandai70_152::new(rom))), + 71 => Ok(Box::new(Camerica71::new(rom))), + 78 => Ok(Box::new(InesMapper78::new(rom))), + 79 => Ok(Box::new(Nina79::new(rom))), + 85 => Ok(Box::new(Vrc7_85::new(rom))), + 87 => Ok(Box::new(InesMapper87::new(rom))), + 88 => Ok(Box::new(InesMapper88::new(rom))), + 89 => Ok(Box::new(Bandai70_152::new(rom))), + 93 => Ok(Box::new(InesMapper93::new(rom))), + 94 => Ok(Box::new(Un1rom94::new(rom))), + 95 => Ok(Box::new(InesMapper95::new(rom))), + 105 => Ok(Box::new(InesMapper105::new(rom))), + 140 => Ok(Box::new(InesMapper140::new(rom))), + 155 => Ok(Box::new(InesMapper155::new(rom))), + 75 => Ok(Box::new(Vrc1_75::new(rom))), + 118 => Ok(Box::new(InesMapper118::new(rom))), // TxSROM / TLSROM / TKSROM + 119 => Ok(Box::new(Tqrom119::new(rom))), + 152 => Ok(Box::new(Bandai70_152::new(rom))), + 34 => Ok(Box::new(Bnrom34::new(rom))), + 158 => Ok(Box::new(InesMapper158::new(rom))), + 64 => Ok(Box::new(InesMapper64::new(rom))), + 180 => Ok(Box::new(CrazyClimber180::new(rom))), + 184 => Ok(Box::new(InesMapper184::new(rom))), + 185 => Ok(Box::new(InesMapper185::new(rom))), + 206 => { + if rom.header.submapper == 1 { + Ok(Box::new(InesMapper206Submapper1::new(rom))) + } else { + Ok(Box::new(InesMapper206::new(rom))) + } + } + 253 => Ok(Box::new(InesMapper253::new(rom))), + mapper => Err(format!("unsupported mapper: {mapper}")), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/native_core/mapper/tests.rs b/src/native_core/mapper/tests.rs new file mode 100644 index 0000000..7d4e617 --- /dev/null +++ b/src/native_core/mapper/tests.rs @@ -0,0 +1,32 @@ +use super::{InesMapper105, Mapper, create_mapper}; +use crate::native_core::ines::{InesRom, Mirroring}; +use crate::native_core::test_support::MapperRomBuilder; + +fn test_rom(mapper: u16, prg_banks_16k: u16, chr_banks_8k: u16) -> InesRom { + MapperRomBuilder::new(mapper) + .prg_banks_16k(prg_banks_16k) + .chr_banks_8k(chr_banks_8k) + .build() +} + +fn test_rom_with_submapper( + mapper: u16, + submapper: u8, + prg_banks_16k: u16, + chr_banks_8k: u16, +) -> InesRom { + MapperRomBuilder::new(mapper) + .submapper(submapper) + .prg_banks_16k(prg_banks_16k) + .chr_banks_8k(chr_banks_8k) + .mirroring(Mirroring::Horizontal) + .build() +} + +mod basic_bank_switch; +mod chr_ram_and_conflicts; +mod mmc1_105_155_5_19; +mod mmc3_vrc_core; +mod property_invariants; +mod rambo_dxrom_others; +mod vrc_variants_mmc2_4; diff --git a/src/native_core/mapper/tests/basic_bank_switch/discrete.rs b/src/native_core/mapper/tests/basic_bank_switch/discrete.rs new file mode 100644 index 0000000..266e20b --- /dev/null +++ b/src/native_core/mapper/tests/basic_bank_switch/discrete.rs @@ -0,0 +1,139 @@ +use super::*; +#[test] +fn mapper11_switches_prg_and_chr_banks() { + let mut rom = test_rom(11, 8, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // PRG bank 0 + rom.prg_rom[0x0001] = 0x32; // bus-conflict source for write at $8001 + rom.prg_rom[0x10000] = 0x20; // PRG bank 2 (2 * 32K) + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x6000] = 0x07; // CHR bank 3 (3 * 8K) + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0x8001, 0x32); // PRG=2, CHR=3 with bus-conflict-safe source byte + assert_eq!(mapper.cpu_read(0x8000), 0x20); + assert_eq!(mapper.ppu_read(0x0000), 0x07); +} + +#[test] +fn mapper11_applies_bus_conflicts_when_latching_bank_bits() { + let mut rom = test_rom(11, 8, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x11; // $8000 currently reads 0x11 + rom.prg_rom[0x8000] = 0x20; // PRG bank 1 + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x2000] = 0x02; // CHR bank 1 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x33); // 0x33 & 0x11 => 0x11 + assert_eq!(mapper.cpu_read(0x8000), 0x20); + assert_eq!(mapper.ppu_read(0x0000), 0x02); +} + +#[test] +fn mapper71_switches_lower_prg_bank() { + let mut rom = test_rom(71, 8, 0); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x11; // bank 0 + rom.prg_rom[0x8000] = 0x22; // bank 2 + rom.prg_rom[0x1C000] = 0xFF; // last bank fixed + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x11); + assert_eq!(mapper.cpu_read(0xC000), 0xFF); + mapper.cpu_write(0xC000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x22); + assert_eq!(mapper.cpu_read(0xC000), 0xFF); +} + +#[test] +fn mapper71_submapper0_ignores_9000_mirroring_control() { + let mut rom = test_rom_with_submapper(71, 0, 8, 1); + rom.header.mirroring = Mirroring::Vertical; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.mirroring(), Mirroring::Vertical); + mapper.cpu_write(0x9000, 0x10); + assert_eq!(mapper.mirroring(), Mirroring::Vertical); +} + +#[test] +fn mapper71_submapper1_uses_single_screen_mirroring_control() { + let rom = test_rom_with_submapper(71, 1, 8, 1); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.mirroring(), Mirroring::OneScreenLow); + mapper.cpu_write(0x9000, 0x10); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenHigh); + mapper.cpu_write(0x9000, 0x00); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenLow); +} + +#[test] +fn mapper94_uses_shifted_bank_value() { + let mut rom = test_rom(94, 8, 0); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x10; // bank 0 + rom.prg_rom[0xC000] = 0x40; // bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + mapper.cpu_write(0x8000, 0x0C); // 0x0C >> 2 = 3 + assert_eq!(mapper.cpu_read(0x8000), 0x40); +} + +#[test] +fn mapper180_switches_upper_prg_bank() { + let mut rom = test_rom(180, 8, 0); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x11; // fixed first bank + rom.prg_rom[0x8000] = 0x33; // bank 2 at upper window + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x11); + mapper.cpu_write(0x8000, 0x02); + assert_eq!(mapper.cpu_read(0xC000), 0x33); +} + +#[test] +fn mapper34_switches_32k_prg_bank() { + let mut rom = test_rom(34, 8, 0); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x11; // 32K bank 0 + rom.prg_rom[0x10000] = 0x44; // 32K bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x11); + mapper.cpu_write(0x8000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x44); +} + +#[test] +fn mapper34_supports_nina001_low_registers() { + let mut rom = test_rom(34, 8, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // PRG bank 0 (32K) + rom.prg_rom[0x8000] = 0x20; // PRG bank 1 (32K) + rom.chr_data[0x0000] = 0x01; // CHR 4K bank 0 + rom.chr_data[0x1000] = 0x02; // CHR 4K bank 1 + rom.chr_data[0x3000] = 0x04; // CHR 4K bank 3 + rom.chr_data[0x4000] = 0x05; // CHR 4K bank 4 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + assert_eq!(mapper.ppu_read(0x1000), 0x02); + + assert!(mapper.cpu_write_low(0x7FFD, 0x01)); // PRG bank + assert!(mapper.cpu_write_low(0x7FFE, 0x03)); // CHR low 4K + assert!(mapper.cpu_write_low(0x7FFF, 0x04)); // CHR high 4K + + assert_eq!(mapper.cpu_read(0x8000), 0x20); + assert_eq!(mapper.ppu_read(0x0000), 0x04); + assert_eq!(mapper.ppu_read(0x1000), 0x05); +} diff --git a/src/native_core/mapper/tests/basic_bank_switch/mapper78.rs b/src/native_core/mapper/tests/basic_bank_switch/mapper78.rs new file mode 100644 index 0000000..b993383 --- /dev/null +++ b/src/native_core/mapper/tests/basic_bank_switch/mapper78.rs @@ -0,0 +1,46 @@ +use super::*; +#[test] +fn mapper78_switches_prg_chr_and_mirroring() { + let mut rom = test_rom(78, 8, 8); + rom.prg_rom.fill(0xFF); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // PRG bank 0 lower window + rom.prg_rom[0x8000] = 0x20; // PRG bank 2 lower window + rom.prg_rom[0x1C000] = 0xFE; // fixed last bank + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x6000] = 0x07; // CHR bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xC000), 0xFE); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0x8001, 0x3A); // avoid bus conflict masking at $8000 byte + assert_eq!(mapper.cpu_read(0x8000), 0x20); + assert_eq!(mapper.ppu_read(0x0000), 0x07); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenHigh); +} + +#[test] +fn mapper78_submapper3_uses_horizontal_vertical_mirroring() { + let mut rom = test_rom_with_submapper(78, 3, 8, 2); + rom.prg_rom.fill(0xFF); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); + mapper.cpu_write(0x8000, 0x08); + assert_eq!(mapper.mirroring(), Mirroring::Vertical); +} + +#[test] +fn mapper78_applies_bus_conflicts() { + let mut rom = test_rom_with_submapper(78, 1, 8, 2); + rom.prg_rom.fill(0xFF); + // At $8000 lower PRG bank maps to byte 0x07, so write $08 becomes $00. + rom.prg_rom[0x0000] = 0x07; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x08); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenLow); + assert_eq!(mapper.cpu_read(0x8000), 0x07); +} diff --git a/src/native_core/mapper/tests/basic_bank_switch/mod.rs b/src/native_core/mapper/tests/basic_bank_switch/mod.rs new file mode 100644 index 0000000..0c029d4 --- /dev/null +++ b/src/native_core/mapper/tests/basic_bank_switch/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +mod discrete; +mod mapper78; +mod smoke_and_fme7; diff --git a/src/native_core/mapper/tests/basic_bank_switch/smoke_and_fme7.rs b/src/native_core/mapper/tests/basic_bank_switch/smoke_and_fme7.rs new file mode 100644 index 0000000..05c846c --- /dev/null +++ b/src/native_core/mapper/tests/basic_bank_switch/smoke_and_fme7.rs @@ -0,0 +1,138 @@ +use super::*; +#[test] +fn supports_known_mapper_ids() { + for mapper in [ + 0u16, 1, 2, 3, 4, 5, 7, 9, 10, 11, 13, 19, 21, 22, 23, 24, 25, 26, 30, 34, 64, 66, 69, 70, + 71, 75, 78, 79, 85, 87, 88, 89, 93, 94, 95, 105, 118, 119, 140, 152, 155, 158, 180, 184, + 185, 206, 253, + ] { + let rom = test_rom(mapper, 8, 4); + let m = create_mapper(rom).expect("mapper should be created"); + let _ = m.cpu_read(0x8000); + let _ = m.ppu_read(0x0000); + } +} + +#[test] +fn nrom_state_roundtrip() { + let rom = test_rom(0, 1, 0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.ppu_write(0x0010, 0xAB); + + let mut saved = Vec::new(); + mapper.save_state(&mut saved); + + let rom2 = test_rom(0, 1, 0); + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&saved).expect("state must load"); + assert_eq!(restored.ppu_read(0x0010), 0xAB); +} + +#[test] +fn nrom_16k_prg_mirrors_into_upper_half() { + let mut rom = test_rom(0, 1, 1); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0xA1; + rom.prg_rom[0x3FFF] = 0xB2; + let mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0xA1); + assert_eq!(mapper.cpu_read(0xBFFF), 0xB2); + assert_eq!(mapper.cpu_read(0xC000), 0xA1); + assert_eq!(mapper.cpu_read(0xFFFF), 0xB2); +} + +#[test] +fn nrom_low_window_is_not_backend_prg_ram() { + let rom = test_rom(0, 2, 1); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read_low(0x6000), None); + assert!(!mapper.cpu_write_low(0x6000, 0xAB)); + assert_eq!(mapper.cpu_read_low(0x6000), None); +} + +#[test] +fn uxrom_switches_bank() { + let mut rom = test_rom(2, 4, 1); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x10; + rom.prg_rom[0x4000] = 0x20; + rom.prg_rom[0x8000] = 0x30; + rom.prg_rom[0xC000] = 0x40; + + let mut mapper = create_mapper(rom).expect("must create mapper"); + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xC000), 0x40); + + mapper.cpu_write(0x8000, 2); + assert_eq!(mapper.cpu_read(0x8000), 0x30); +} + +#[test] +fn rejects_unknown_mapper() { + let rom = test_rom(255, 1, 1); + assert!(create_mapper(rom).is_err()); +} + +#[test] +fn fme7_accepts_command_and_data_across_port_ranges() { + let mut rom = test_rom(69, 8, 1); + for bank in 0..8usize { + rom.chr_data[bank * 0x400] = bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x9FFF, 0x00); + mapper.cpu_write(0xB123, 0x03); + + assert_eq!(mapper.ppu_read(0x0000), 3); +} + +#[test] +fn fme7_chr_ram_write_uses_selected_bank() { + let rom = test_rom(69, 8, 0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0xA000, 0x01); + mapper.ppu_write(0x0012, 0xAA); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0xA000, 0x00); + assert_eq!(mapper.ppu_read(0x0012), 0x00); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0xA000, 0x01); + assert_eq!(mapper.ppu_read(0x0012), 0xAA); +} + +#[test] +fn fme7_low_window_defaults_to_prg_rom_when_ram_disabled() { + let mut rom = test_rom(69, 8, 1); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x11; // 8K bank 0 @ $6000 when RAM disabled + rom.prg_rom[0x2000] = 0x22; // 8K bank 1 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x11)); + mapper.cpu_write(0x8000, 0x08); + mapper.cpu_write(0xA000, 0x01); // RAM disabled, select ROM bank 1 + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x22)); +} + +#[test] +fn fme7_low_window_uses_ram_only_when_enabled() { + let mut rom = test_rom(69, 8, 1); + rom.prg_rom.fill(0x33); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x08); + mapper.cpu_write(0xA000, 0xC0); // Select RAM + enable RAM in $6000-$7FFF + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x00)); + assert!(mapper.cpu_write_low(0x6123, 0xA5)); + assert_eq!(mapper.cpu_read_low(0x6123), Some(0xA5)); + + mapper.cpu_write(0xA000, 0x00); // Disable RAM -> back to ROM view + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x33)); +} diff --git a/src/native_core/mapper/tests/chr_ram_and_conflicts/mapper3_7.rs b/src/native_core/mapper/tests/chr_ram_and_conflicts/mapper3_7.rs new file mode 100644 index 0000000..9230791 --- /dev/null +++ b/src/native_core/mapper/tests/chr_ram_and_conflicts/mapper3_7.rs @@ -0,0 +1,84 @@ +use super::*; + +#[test] +fn mapper3_allows_chr_ram_write_when_chr_is_ram() { + let mut rom = test_rom(3, 2, 0); + rom.chr_data = vec![0; 0x4000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x01); + mapper.ppu_write(0x0012, 0xA5); + assert_eq!(mapper.ppu_read(0x0012), 0xA5); + mapper.cpu_write(0x8000, 0x00); + assert_eq!(mapper.ppu_read(0x0012), 0x00); +} + +#[test] +fn mapper3_submapper2_applies_and_bus_conflicts() { + let mut rom = test_rom_with_submapper(3, 2, 2, 4); + rom.prg_rom.fill(0xFF); + rom.prg_rom[0x0000] = 0x01; // $8000 bus value used for conflict mask + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x10; // CHR bank 0 marker + rom.chr_data[0x2000] = 0x11; // CHR bank 1 marker + rom.chr_data[0x4000] = 0x12; // CHR bank 2 marker + rom.chr_data[0x6000] = 0x13; // CHR bank 3 marker + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x03); // bus conflict -> 0x03 & 0x01 = 0x01 + assert_eq!(mapper.ppu_read(0x0000), 0x11); +} + +#[test] +fn mapper7_submapper2_applies_and_bus_conflicts() { + let mut rom = test_rom_with_submapper(7, 2, 8, 1); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x02; // CPU sees this byte at $8000 during write + rom.prg_rom[0x10000] = 0xA2; // 32K bank 2 marker at $8000 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x01); // with conflicts latched=0x00 -> bank 0 + assert_eq!(mapper.cpu_read(0x8000), 0x02); + + mapper.cpu_write(0x8000, 0x13); // with conflicts latched=0x02 -> bank 2, one-screen low + assert_eq!(mapper.cpu_read(0x8000), 0xA2); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenLow); +} + +#[test] +fn mapper7_submapper1_has_no_bus_conflicts() { + let mut rom = test_rom_with_submapper(7, 1, 8, 1); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x02; // if conflicts existed, bit0 would be masked off + rom.prg_rom[0x8000] = 0xA1; // 32K bank 1 marker at $8000 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x01); // no conflicts -> bank 1 selected + assert_eq!(mapper.cpu_read(0x8000), 0xA1); +} + +#[test] +fn mapper3_chr_ram_state_roundtrip_uses_strict_payload() { + let mut rom = test_rom(3, 2, 0); + rom.chr_data = vec![0; 0x4000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x01); + mapper.ppu_write(0x0020, 0xA6); + + let mut state = Vec::new(); + mapper.save_state(&mut state); + assert!( + state.len() > 8, + "mapper 3 state must include CHR payload blob" + ); + + let mut rom2 = test_rom(3, 2, 0); + rom2.chr_data = vec![0; 0x4000]; + rom2.chr_is_ram = true; + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state load"); + assert_eq!(restored.ppu_read(0x0020), 0xA6); +} diff --git a/src/native_core/mapper/tests/chr_ram_and_conflicts/mapper79_and_friends.rs b/src/native_core/mapper/tests/chr_ram_and_conflicts/mapper79_and_friends.rs new file mode 100644 index 0000000..aaef860 --- /dev/null +++ b/src/native_core/mapper/tests/chr_ram_and_conflicts/mapper79_and_friends.rs @@ -0,0 +1,125 @@ +use super::*; + +#[test] +fn mapper75_switches_prg_chr_and_mirroring() { + let mut rom = test_rom(75, 16, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // PRG bank 0 + rom.prg_rom[0x2000] = 0x11; // PRG bank 1 + rom.prg_rom[0x6000] = 0x13; // PRG bank 3 + rom.prg_rom[0x3E000] = 0x1F; // fixed last bank + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x2000] = 0x03; // CHR bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + mapper.cpu_write(0x8000, 0x01); + assert_eq!(mapper.cpu_read(0x8000), 0x11); + mapper.cpu_write(0xC000, 0x03); + assert_eq!(mapper.cpu_read(0xC000), 0x13); + assert_eq!(mapper.cpu_read(0xE000), 0x1F); + + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0xE000, 0x02); + assert_eq!(mapper.ppu_read(0x0000), 0x03); + + mapper.cpu_write(0x9000, 0x01); + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); +} + +#[test] +fn mapper140_selects_prg_chr_from_low_window_write() { + let mut rom = test_rom(140, 8, 16); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // 32K bank 0 + rom.prg_rom[0x8000] = 0x20; // 32K bank 1 + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x6000] = 0x04; // CHR bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + assert!(mapper.cpu_write_low(0x6000, 0x13)); // PRG=1 CHR=3 + assert_eq!(mapper.cpu_read(0x8000), 0x20); + assert_eq!(mapper.ppu_read(0x0000), 0x04); +} + +#[test] +fn mapper184_selects_two_4k_chr_banks_from_low_window_write() { + let mut rom = test_rom(184, 2, 16); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x11; // bank 0 + rom.chr_data[0x5000] = 0x55; // bank 5 + rom.chr_data[0x6000] = 0x66; // bank 6 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.cpu_write_low(0x6000, 0x65)); // low=5, high=(6|4)=10 + assert_eq!(mapper.ppu_read(0x0000), 0x55); + assert_eq!(mapper.ppu_read(0x1000), 0x66); +} + +#[test] +fn mapper79_switches_prg_and_chr_from_low_window_write() { + let mut rom = test_rom(79, 4, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x11; // PRG 32K bank 0 + rom.prg_rom[0x8000] = 0x22; // PRG 32K bank 1 + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x6000] = 0x07; // CHR bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x11); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + assert!(mapper.cpu_write_low(0x4100, 0x0B)); // PRG=1 CHR=3 + assert_eq!(mapper.cpu_read(0x8000), 0x22); + assert_eq!(mapper.ppu_read(0x0000), 0x07); +} + +#[test] +fn mapper79_only_accepts_masked_low_register_addresses() { + let mut rom = test_rom(79, 4, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x11; // PRG 32K bank 0 + rom.prg_rom[0x8000] = 0x22; // PRG 32K bank 1 + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x6000] = 0x07; // CHR bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(!mapper.cpu_write_low(0x4200, 0x0B)); // A8=0 -> not a register + assert_eq!(mapper.cpu_read(0x8000), 0x11); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + + assert!(!mapper.cpu_write_low(0x6100, 0x0B)); // outside $4xxx/$5xxx + assert_eq!(mapper.cpu_read(0x8000), 0x11); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + + assert!(mapper.cpu_write_low(0x5F00, 0x0B)); // valid masked register + assert_eq!(mapper.cpu_read(0x8000), 0x22); + assert_eq!(mapper.ppu_read(0x0000), 0x07); +} + +#[test] +fn mapper79_chr_ram_write_and_state_roundtrip() { + let mut rom = test_rom(79, 4, 0); + rom.chr_data = vec![0; 0x8000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.cpu_write_low(0x4100, 0x09)); // PRG=1 CHR=1 + mapper.ppu_write(0x0010, 0xA5); + assert_eq!(mapper.ppu_read(0x0010), 0xA5); + + let mut state = Vec::new(); + mapper.save_state(&mut state); + let mut rom2 = test_rom(79, 4, 0); + rom2.chr_data = vec![0; 0x8000]; + rom2.chr_is_ram = true; + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state load"); + assert!(restored.cpu_write_low(0x4100, 0x09)); + assert_eq!(restored.ppu_read(0x0010), 0xA5); +} diff --git a/src/native_core/mapper/tests/chr_ram_and_conflicts/misc_chr_switch.rs b/src/native_core/mapper/tests/chr_ram_and_conflicts/misc_chr_switch.rs new file mode 100644 index 0000000..f24b8f8 --- /dev/null +++ b/src/native_core/mapper/tests/chr_ram_and_conflicts/misc_chr_switch.rs @@ -0,0 +1,142 @@ +use super::*; +#[test] +fn mapper78_chr_ram_state_roundtrip() { + let mut rom = test_rom_with_submapper(78, 1, 4, 0); + rom.prg_rom.fill(0xFF); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x10); // CHR bank 1 + mapper.ppu_write(0x0123, 0x5A); + assert_eq!(mapper.ppu_read(0x0123), 0x5A); + + let mut state = Vec::new(); + mapper.save_state(&mut state); + + let mut rom2 = test_rom_with_submapper(78, 1, 4, 0); + rom2.prg_rom.fill(0xFF); + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state must load"); + assert_eq!(restored.ppu_read(0x0123), 0x5A); + assert_eq!(restored.mirroring(), Mirroring::OneScreenLow); +} + +#[test] +fn mapper87_switches_chr_bank() { + let mut rom = test_rom(87, 2, 4); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x2000] = 0x02; // CHR bank 1 + rom.chr_data[0x4000] = 0x03; // CHR bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.ppu_read(0x0000), 0x01); + assert!(mapper.cpu_write_low(0x6000, 0x01)); // LH -> bank 2 + assert_eq!(mapper.ppu_read(0x0000), 0x03); + assert!(mapper.cpu_write_low(0x6000, 0x02)); // LH -> bank 1 + assert_eq!(mapper.ppu_read(0x0000), 0x02); +} + +#[test] +fn mapper87_chr_ram_write_and_state_roundtrip() { + let mut rom = test_rom(87, 2, 0); + rom.chr_data = vec![0; 0x4000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.cpu_write_low(0x6000, 0x02)); + mapper.ppu_write(0x0010, 0x7C); + assert_eq!(mapper.ppu_read(0x0010), 0x7C); + + let mut state = Vec::new(); + mapper.save_state(&mut state); + let mut rom2 = test_rom(87, 2, 0); + rom2.chr_data = vec![0; 0x4000]; + rom2.chr_is_ram = true; + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state load"); + assert!(restored.cpu_write_low(0x6000, 0x02)); + assert_eq!(restored.ppu_read(0x0010), 0x7C); +} + +#[test] +fn mapper87_ignores_cpu_write_high_window() { + let mut rom = test_rom(87, 2, 4); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x2000] = 0x02; // CHR bank 1 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x01); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + assert!(mapper.cpu_write_low(0x7000, 0x02)); + assert_eq!(mapper.ppu_read(0x0000), 0x02); +} + +#[test] +fn mapper93_switches_lower_prg_bank_using_high_nibble() { + let mut rom = test_rom(93, 8, 0); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x10; // bank 0 + rom.prg_rom[0x8000] = 0x30; // bank 2 + rom.prg_rom[0x1C000] = 0xFF; // fixed last bank + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xC000), 0xFF); + mapper.cpu_write(0x8000, 0x20); // 0x2 in high nibble + assert_eq!(mapper.cpu_read(0x8000), 0x30); + assert_eq!(mapper.cpu_read(0xC000), 0xFF); +} + +#[test] +fn mapper30_switches_prg_and_chr_ram_bank() { + let mut rom = test_rom(30, 8, 0); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x11; // PRG bank 0 + rom.prg_rom[0x8000] = 0x33; // PRG bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x11); + mapper.cpu_write(0x8000, 0x22); // PRG=2, CHR=1 + assert_eq!(mapper.cpu_read(0x8000), 0x33); + mapper.ppu_write(0x0010, 0xA5); + assert_eq!(mapper.ppu_read(0x0010), 0xA5); + mapper.cpu_write(0x8000, 0x02); // CHR bank 0 + assert_eq!(mapper.ppu_read(0x0010), 0x00); +} + +#[test] +fn mapper70_switches_prg_and_chr() { + let mut rom = test_rom(70, 8, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // PRG bank 0 + rom.prg_rom[0x8000] = 0x30; // PRG bank 2 + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x4000] = 0x03; // CHR bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0x8000, 0x22); // CHR=2 PRG=2 + assert_eq!(mapper.cpu_read(0x8000), 0x30); + assert_eq!(mapper.ppu_read(0x0000), 0x03); +} + +#[test] +fn mapper13_switches_upper_4k_chr_ram_bank() { + let mut rom = test_rom(13, 2, 0); + rom.chr_data = vec![0; 0x4000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.ppu_write(0x1000, 0xA1); // bank 0 + assert_eq!(mapper.ppu_read(0x1000), 0xA1); + mapper.cpu_write(0x8000, 0x01); + assert_eq!(mapper.ppu_read(0x1000), 0x00); + mapper.ppu_write(0x1000, 0xB2); // bank 1 + assert_eq!(mapper.ppu_read(0x1000), 0xB2); + mapper.cpu_write(0x8000, 0x00); + assert_eq!(mapper.ppu_read(0x1000), 0xA1); +} diff --git a/src/native_core/mapper/tests/chr_ram_and_conflicts/mod.rs b/src/native_core/mapper/tests/chr_ram_and_conflicts/mod.rs new file mode 100644 index 0000000..5e67e6b --- /dev/null +++ b/src/native_core/mapper/tests/chr_ram_and_conflicts/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +mod mapper3_7; +mod mapper79_and_friends; +mod misc_chr_switch; diff --git a/src/native_core/mapper/tests/mmc1_105_155_5_19.rs b/src/native_core/mapper/tests/mmc1_105_155_5_19.rs new file mode 100644 index 0000000..d3eda65 --- /dev/null +++ b/src/native_core/mapper/tests/mmc1_105_155_5_19.rs @@ -0,0 +1,222 @@ +use super::*; + +#[test] +fn mmc1_control_mirroring_modes_match_hardware_layout() { + let rom = test_rom(1, 2, 1); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + serial_write5(&mut mapper, 0x8000, 0); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenLow); + + serial_write5(&mut mapper, 0x8000, 1); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenHigh); + + serial_write5(&mut mapper, 0x8000, 2); + assert_eq!(mapper.mirroring(), Mirroring::Vertical); + + serial_write5(&mut mapper, 0x8000, 3); + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); +} + +#[test] +fn mmc1_chr_ram_write_respects_selected_4k_chr_banks() { + let mut rom = test_rom(1, 2, 0); + rom.chr_data = vec![0; 0x8000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + serial_write5(&mut mapper, 0x8000, 0x10); // CHR mode 1 (two 4KB banks) + serial_write5(&mut mapper, 0xA000, 2); // $0000-$0FFF -> bank 2 + serial_write5(&mut mapper, 0xC000, 5); // $1000-$1FFF -> bank 5 + mapper.ppu_write(0x0123, 0xA1); + mapper.ppu_write(0x1456, 0xB2); + + serial_write5(&mut mapper, 0xA000, 3); + serial_write5(&mut mapper, 0xC000, 6); + assert_eq!(mapper.ppu_read(0x0123), 0x00); + assert_eq!(mapper.ppu_read(0x1456), 0x00); + + serial_write5(&mut mapper, 0xA000, 2); + serial_write5(&mut mapper, 0xC000, 5); + assert_eq!(mapper.ppu_read(0x0123), 0xA1); + assert_eq!(mapper.ppu_read(0x1456), 0xB2); +} + +#[test] +fn mapper105_requires_unlock_sequence_before_prg_switching() { + let mut rom = test_rom(105, 16, 0); + rom.prg_rom.fill(0); + for bank in 0..16usize { + rom.prg_rom[bank * 0x4000] = bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + // Writes before the 0->1 transition of I must not unlock PRG switching. + serial_write5(&mut mapper, 0xA000, 0x08); + serial_write5(&mut mapper, 0x8000, 0x0C); + serial_write5(&mut mapper, 0xE000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x00); + assert_eq!(mapper.cpu_read(0xC000), 0x01); + + // NES-EVENT unlock sequence: I low, then high once, then mapper can switch. + serial_write5(&mut mapper, 0xA000, 0x00); + serial_write5(&mut mapper, 0xA000, 0x10); + serial_write5(&mut mapper, 0xA000, 0x08); + serial_write5(&mut mapper, 0x8000, 0x0C); + serial_write5(&mut mapper, 0xE000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x0A); + assert_eq!(mapper.cpu_read(0xC000), 0x0F); +} + +#[test] +fn mapper105_wram_disable_bit_controls_6000_window() { + let rom = test_rom(105, 16, 0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + assert!(mapper.cpu_write_low(0x6000, 0x55)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x55)); + + serial_write5(&mut mapper, 0xE000, 0x10); + assert!(!mapper.cpu_write_low(0x6000, 0xAA)); + assert_eq!(mapper.cpu_read_low(0x6000), None); + + serial_write5(&mut mapper, 0xE000, 0x00); + assert!(mapper.cpu_write_low(0x6000, 0xAA)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0xAA)); +} + +#[test] +fn mapper105_irq_counter_reaches_threshold_from_restored_state() { + let rom = test_rom(105, 16, 0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + serial_write5(&mut mapper, 0xA000, 0x00); // timer running (I=0) + + let mut state = Vec::new(); + mapper.save_state(&mut state); + let near_threshold = super::InesMapper105::IRQ_THRESHOLD - 1; + state[3] = 0x00; // reg_a with I=0 + state[8..12].copy_from_slice(&near_threshold.to_le_bytes()); + state[12] = 0; + mapper.load_state(&state).expect("state must load"); + + mapper.clock_cpu(1); + assert!(mapper.poll_irq()); +} + +#[test] +fn mapper155_keeps_wram_enabled_when_prg_bit4_is_set() { + let rom = test_rom(155, 16, 0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + serial_write5(&mut mapper, 0xE000, 0x10); + assert!(mapper.cpu_write_low(0x6000, 0x66)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x66)); +} + +#[test] +fn mapper155_prg_bit4_with_bit3_controls_outer_fixed_bank_region() { + let mut rom = test_rom(155, 16, 1); + rom.prg_rom.fill(0); + for bank in 0..16usize { + rom.prg_rom[bank * 0x4000] = bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + fn serial_write5(mapper: &mut Box, addr: u16, value: u8) { + for i in 0..5 { + mapper.cpu_write(addr, (value >> i) & 1); + } + } + + serial_write5(&mut mapper, 0x8000, 0x08); // mode 2: fixed first @ $8000, switch @ $C000 + serial_write5(&mut mapper, 0xE000, 0x18); // bit4=1 (MMC1A mode), bit3=1 selects A17 upper half + assert_eq!(mapper.cpu_read(0x8000), 0x08); +} + +#[test] +fn mapper5_supports_low_window_registers_and_prg_bank_switch() { + let mut rom = test_rom(5, 16, 8); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x10; // 8K bank 0 + rom.prg_rom[0x2000] = 0x11; // 8K bank 1 + rom.prg_rom[0x4000] = 0x12; // 8K bank 2 + rom.prg_rom[0x6000] = 0x13; // 8K bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.cpu_write_low(0x5100, 0x03)); // 4x8K PRG mode + assert!(mapper.cpu_write_low(0x5114, 0x00)); + assert!(mapper.cpu_write_low(0x5115, 0x01)); + assert!(mapper.cpu_write_low(0x5116, 0x02)); + assert!(mapper.cpu_write_low(0x5117, 0x03)); + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xA000), 0x11); + assert_eq!(mapper.cpu_read(0xC000), 0x12); + assert_eq!(mapper.cpu_read(0xE000), 0x13); +} + +#[test] +fn mapper19_switches_prg_chr_and_triggers_irq() { + let mut rom = test_rom(19, 8, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // 8K bank 0 + rom.prg_rom[0x2000] = 0x11; // 8K bank 1 + rom.prg_rom[0x4000] = 0x12; // 8K bank 2 + rom.prg_rom[0x1E000] = 0x17; // fixed last 8K bank + rom.chr_data[0x0000] = 0x01; + rom.chr_data[0x1000] = 0x05; // bank 4 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xE000, 0x00); + mapper.cpu_write(0xE800, 0x01); + mapper.cpu_write(0xF000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xA000), 0x11); + assert_eq!(mapper.cpu_read(0xC000), 0x12); + assert_eq!(mapper.cpu_read(0xE000), 0x17); + + mapper.cpu_write(0x8000, 0x04); + assert_eq!(mapper.ppu_read(0x0000), 0x05); + + assert!(mapper.cpu_write_low(0x5000, 0xFE)); + assert!(mapper.cpu_write_low(0x5800, 0xFF)); // enable IRQ + mapper.clock_cpu(4); + assert!(mapper.poll_irq()); +} diff --git a/src/native_core/mapper/tests/mmc3_vrc_core.rs b/src/native_core/mapper/tests/mmc3_vrc_core.rs new file mode 100644 index 0000000..750bc25 --- /dev/null +++ b/src/native_core/mapper/tests/mmc3_vrc_core.rs @@ -0,0 +1,297 @@ +use super::*; + +#[test] +fn mapper185_submapper4_chr_gate_uses_latch_low_bits() { + let mut rom = test_rom_with_submapper(185, 4, 2, 1); + rom.prg_rom.fill(0xFF); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x3C; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + assert_eq!(mapper.ppu_read(0x0000), 0x3C); + + mapper.cpu_write(0x8000, 0x01); + assert_eq!(mapper.ppu_read(0x0000), 0xFF); +} + +#[test] +fn mapper185_always_applies_and_bus_conflicts() { + let mut rom = test_rom_with_submapper(185, 4, 2, 1); + rom.prg_rom.fill(0xFF); + rom.prg_rom[0x0000] = 0x00; // force effective latch value to 0 on $8000 writes + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x55; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x03); // with conflicts latch remains 0 -> enabled for submapper 4 + assert_eq!(mapper.ppu_read(0x0000), 0x55); +} + +#[test] +fn mapper185_submapper0_does_not_use_startup_open_bus_reads() { + let mut rom = test_rom_with_submapper(185, 0, 2, 1); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0xA5; + let mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.ppu_read(0x0000), 0xA5); + assert_eq!(mapper.ppu_read(0x0000), 0xA5); +} + +#[test] +fn mapper66_allows_chr_ram_write_when_chr_is_ram() { + let mut rom = test_rom(66, 4, 0); + rom.chr_data = vec![0; 0x8000]; + rom.chr_is_ram = true; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x01); // CHR bank 1 + mapper.ppu_write(0x0020, 0x5A); + assert_eq!(mapper.ppu_read(0x0020), 0x5A); + mapper.cpu_write(0x8000, 0x00); // CHR bank 0 + assert_eq!(mapper.ppu_read(0x0020), 0x00); +} + +#[test] +fn mapper_alias_206_behaves_like_mmc3_bank_select() { + let mut rom = test_rom(206, 8, 4); + rom.prg_rom.fill(0); + rom.prg_rom[0x0000] = 0x10; // bank 0 + rom.prg_rom[0x2000] = 0x20; // bank 1 + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.cpu_write(0x8000, 0x06); // select R6 + mapper.cpu_write(0x8001, 0x01); // put bank1 in first slot + assert_eq!(mapper.cpu_read(0x8000), 0x20); +} + +#[test] +fn mapper118_routes_nametables_from_chr_bank_bit7() { + let rom = test_rom(118, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + // R0 controls pages 0/1 (NT0/NT1); set bit7 => CIRAM page 1. + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x80); + // R1 controls pages 2/3 (NT2/NT3); keep bit7 cleared => CIRAM page 0. + mapper.cpu_write(0x8000, 0x01); + mapper.cpu_write(0x8001, 0x00); + + assert_eq!(mapper.map_nametable_addr(0x2000), Some(0x0400)); + assert_eq!(mapper.map_nametable_addr(0x2400), Some(0x0400)); + assert_eq!(mapper.map_nametable_addr(0x2800), Some(0x0000)); + assert_eq!(mapper.map_nametable_addr(0x2C00), Some(0x0000)); +} + +#[test] +fn mapper118_nametable_mapping_is_independent_from_a000_mirroring_writes() { + let rom = test_rom(118, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x80); + let before = mapper.map_nametable_addr(0x2000); + + mapper.cpu_write(0xA000, 0x00); + mapper.cpu_write(0xA000, 0x01); + let after = mapper.map_nametable_addr(0x2000); + + assert_eq!(before, Some(0x0400)); + assert_eq!(after, before); +} + +#[test] +fn mapper95_routes_nametables_from_chr_bank_bit5() { + let rom = test_rom(95, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + // R0 controls pages 0/1 (NT0/NT1); set bit5 => CIRAM page 1. + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x20); + // R1 controls pages 2/3 (NT2/NT3); keep bit5 clear => CIRAM page 0. + mapper.cpu_write(0x8000, 0x01); + mapper.cpu_write(0x8001, 0x00); + + assert_eq!(mapper.map_nametable_addr(0x2000), Some(0x0400)); + assert_eq!(mapper.map_nametable_addr(0x2400), Some(0x0400)); + assert_eq!(mapper.map_nametable_addr(0x2800), Some(0x0000)); + assert_eq!(mapper.map_nametable_addr(0x2C00), Some(0x0000)); +} + +#[test] +fn mapper95_nametable_mapping_is_independent_from_a000_writes() { + let rom = test_rom(95, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x20); + let before = mapper.map_nametable_addr(0x2000); + + mapper.cpu_write(0xA000, 0x00); + mapper.cpu_write(0xA000, 0x01); + let after = mapper.map_nametable_addr(0x2000); + + assert_eq!(before, Some(0x0400)); + assert_eq!(after, before); +} + +#[test] +fn mapper4_mmc3_prg_ram_respects_a001_enable_and_write_protect() { + let rom = test_rom(4, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.cpu_write_low(0x6000, 0x12)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x12)); + + mapper.cpu_write(0xA001, 0x00); // disable PRG-RAM + assert!(mapper.cpu_write_low(0x6000, 0x34)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x00)); + + mapper.cpu_write(0xA001, 0x80); // enable writes + assert!(mapper.cpu_write_low(0x6000, 0x34)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x34)); + + mapper.cpu_write(0xA001, 0xC0); // enable + write-protect + assert!(mapper.cpu_write_low(0x6000, 0x56)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x34)); +} + +#[test] +fn mapper119_tqrom_prg_ram_respects_a001_enable_and_write_protect() { + let rom = test_rom(119, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.cpu_write_low(0x6000, 0xAB)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0xAB)); + + mapper.cpu_write(0xA001, 0x00); // disable PRG-RAM + assert!(mapper.cpu_write_low(0x6000, 0xCD)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x00)); + + mapper.cpu_write(0xA001, 0x80); // enable writes + assert!(mapper.cpu_write_low(0x6000, 0xCD)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0xCD)); + + mapper.cpu_write(0xA001, 0xC0); // enable + write-protect + assert!(mapper.cpu_write_low(0x6000, 0xEF)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0xCD)); +} + +#[test] +fn mapper4_state_roundtrip_preserves_prg_ram_and_a001_bits() { + let rom = test_rom(4, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.cpu_write(0xA001, 0x80); // enable PRG-RAM writes + assert!(mapper.cpu_write_low(0x6001, 0x5A)); + mapper.cpu_write(0xA001, 0xC0); // write-protect on + + let mut state = Vec::new(); + mapper.save_state(&mut state); + + let rom2 = test_rom(4, 8, 8); + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state load"); + + assert_eq!(restored.cpu_read_low(0x6001), Some(0x5A)); + assert!(restored.cpu_write_low(0x6001, 0x99)); // ignored because write-protect + assert_eq!(restored.cpu_read_low(0x6001), Some(0x5A)); +} + +#[test] +fn mapper119_state_roundtrip_preserves_prg_ram_and_a001_bits() { + let rom = test_rom(119, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.cpu_write(0xA001, 0x80); // enable PRG-RAM writes + assert!(mapper.cpu_write_low(0x6002, 0xA7)); + mapper.cpu_write(0xA001, 0xC0); // write-protect on + + let mut state = Vec::new(); + mapper.save_state(&mut state); + + let rom2 = test_rom(119, 8, 8); + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state load"); + + assert_eq!(restored.cpu_read_low(0x6002), Some(0xA7)); + assert!(restored.cpu_write_low(0x6002, 0xBC)); // ignored because write-protect + assert_eq!(restored.cpu_read_low(0x6002), Some(0xA7)); +} + +#[test] +fn mapper119_tqrom_can_map_chr_ram_via_bank_bit6() { + let mut rom = test_rom(119, 8, 8); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x11; // ROM bank 0 + rom.chr_data[0x0400] = 0x22; // ROM bank 1 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + // Default CHR from ROM. + assert_eq!(mapper.ppu_read(0x0000), 0x11); + + // Select R2 (1K bank slot 4) and point it to CHR-RAM page 0x40. + mapper.cpu_write(0x8000, 0x02); + mapper.cpu_write(0x8001, 0x40); + + mapper.ppu_write(0x1000, 0xA5); // page 4 starts at $1000 + assert_eq!(mapper.ppu_read(0x1000), 0xA5); + + // Switch same slot back to ROM page 1 and verify RAM is not visible. + mapper.cpu_write(0x8000, 0x02); + mapper.cpu_write(0x8001, 0x01); + assert_eq!(mapper.ppu_read(0x1000), 0x22); +} + +#[test] +fn mapper23_switches_prg_and_chr_and_mirroring() { + let mut rom = test_rom(23, 4, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // 8K bank 0 @ $8000 + rom.prg_rom[0x2000] = 0x11; // 8K bank 1 @ $A000 + rom.prg_rom[0xA000] = 0x15; // 8K bank 5 + rom.chr_data[0x0000] = 0x01; // 1K bank 0 + rom.chr_data[0x1400] = 0x05; // 1K bank 5 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xA000), 0x11); + mapper.cpu_write(0x8000, 0x05); + assert_eq!(mapper.cpu_read(0x8000), 0x15); + + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0xB000, 0x05); // bank0 low nibble + mapper.cpu_write(0xB001, 0x00); // bank0 high nibble + assert_eq!(mapper.ppu_read(0x0000), 0x05); + + mapper.cpu_write(0x9000, 0x01); + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); +} + +#[test] +fn mapper23_vrc_irq_can_raise_cpu_irq() { + let rom = test_rom(23, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xF000, 0x0E); // latch low nibble + mapper.cpu_write(0xF001, 0x0F); // latch high nibble => 0xFE + mapper.cpu_write(0xF002, 0x06); // enable IRQ + CPU cycle mode + mapper.clock_cpu(2); // 0xFE -> 0xFF -> IRQ pending + assert!(mapper.poll_irq()); +} + +#[test] +fn mapper23_vrc4_prg_swap_moves_switchable_bank_to_c000() { + let mut rom = test_rom(23, 4, 8); + rom.prg_rom.fill(0); + rom.prg_rom[0x6000] = 0x33; // switchable bank 3 + rom.prg_rom[0xC000] = 0x66; // fixed second-last bank (6) + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x03); + assert_eq!(mapper.cpu_read(0x8000), 0x33); + assert_eq!(mapper.cpu_read(0xC000), 0x66); + + mapper.cpu_write(0x9002, 0x02); // PRG swap on (VRC4 control) + assert_eq!(mapper.cpu_read(0x8000), 0x66); + assert_eq!(mapper.cpu_read(0xC000), 0x33); +} diff --git a/src/native_core/mapper/tests/property_invariants.rs b/src/native_core/mapper/tests/property_invariants.rs new file mode 100644 index 0000000..15bf764 --- /dev/null +++ b/src/native_core/mapper/tests/property_invariants.rs @@ -0,0 +1,49 @@ +use super::*; + +#[test] +fn uxrom_prg_bank_selection_is_periodic_by_bank_count() { + let mut rom = test_rom(2, 8, 1); + for bank in 0..8usize { + rom.prg_rom[bank * 0x4000] = bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + for value in 0u16..=255 { + mapper.cpu_write(0x8000, value as u8); + let observed = mapper.cpu_read(0x8000); + assert_eq!(observed, (value as usize % 8) as u8, "value={value}"); + } +} + +#[test] +fn cnrom_chr_bank_selection_is_periodic_by_bank_count() { + let mut rom = test_rom(3, 2, 4); + for bank in 0..4usize { + rom.chr_data[bank * 0x2000] = 0xA0 + bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + for value in 0u16..=255 { + mapper.cpu_write(0x8000, value as u8); + let observed = mapper.ppu_read(0x0000); + let expected = 0xA0 + (value as usize % 4) as u8; + assert_eq!(observed, expected, "value={value}"); + } +} + +#[test] +fn mapper78_submapper3_mirroring_depends_only_on_control_bit3() { + let mut rom = test_rom_with_submapper(78, 3, 8, 2); + rom.prg_rom.fill(0xFF); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + for value in 0u16..=255 { + mapper.cpu_write(0x8000, value as u8); + let expected = if (value & 0x08) == 0 { + Mirroring::Horizontal + } else { + Mirroring::Vertical + }; + assert_eq!(mapper.mirroring(), expected, "value={value}"); + } +} diff --git a/src/native_core/mapper/tests/rambo_dxrom_others.rs b/src/native_core/mapper/tests/rambo_dxrom_others.rs new file mode 100644 index 0000000..80e7cb9 --- /dev/null +++ b/src/native_core/mapper/tests/rambo_dxrom_others.rs @@ -0,0 +1,309 @@ +use super::*; + +#[test] +fn mapper64_rambo1_switches_prg_with_r6_r7_rf_and_prg_mode() { + let mut rom = test_rom(64, 8, 8); + rom.prg_rom.fill(0); + for bank in 0..16usize { + rom.prg_rom[bank * 0x2000] = 0x10 + bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x06); + mapper.cpu_write(0x8001, 0x02); // R6 + mapper.cpu_write(0x8000, 0x07); + mapper.cpu_write(0x8001, 0x03); // R7 + mapper.cpu_write(0x8000, 0x0F); + mapper.cpu_write(0x8001, 0x04); // RF + + assert_eq!(mapper.cpu_read(0x8000), 0x12); + assert_eq!(mapper.cpu_read(0xA000), 0x13); + assert_eq!(mapper.cpu_read(0xC000), 0x14); + assert_eq!(mapper.cpu_read(0xE000), 0x1F); + + mapper.cpu_write(0x8000, 0x46); // PRG mode=1 + assert_eq!(mapper.cpu_read(0x8000), 0x14); + assert_eq!(mapper.cpu_read(0xA000), 0x13); + assert_eq!(mapper.cpu_read(0xC000), 0x12); + assert_eq!(mapper.cpu_read(0xE000), 0x1F); +} + +#[test] +fn mapper64_rambo1_chr_k_mode_and_invert_follow_register_table() { + let mut rom = test_rom(64, 4, 16); + rom.chr_data.fill(0); + for bank in 0..128usize { + let idx = bank * 0x0400; + if idx < rom.chr_data.len() { + rom.chr_data[idx] = bank as u8; + } + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x04); // R0 + mapper.cpu_write(0x8000, 0x01); + mapper.cpu_write(0x8001, 0x08); // R1 + mapper.cpu_write(0x8000, 0x02); + mapper.cpu_write(0x8001, 0x0A); // R2 + mapper.cpu_write(0x8000, 0x03); + mapper.cpu_write(0x8001, 0x0B); // R3 + mapper.cpu_write(0x8000, 0x04); + mapper.cpu_write(0x8001, 0x0C); // R4 + mapper.cpu_write(0x8000, 0x05); + mapper.cpu_write(0x8001, 0x0D); // R5 + mapper.cpu_write(0x8000, 0x08); + mapper.cpu_write(0x8001, 0x06); // R8 + mapper.cpu_write(0x8000, 0x09); + mapper.cpu_write(0x8001, 0x0E); // R9 + + // K=0, C=0: 2KB pairs in the first half. + mapper.cpu_write(0x8000, 0x00); + assert_eq!(mapper.ppu_read(0x0000), 0x04); + assert_eq!(mapper.ppu_read(0x0400), 0x05); + assert_eq!(mapper.ppu_read(0x0800), 0x08); + assert_eq!(mapper.ppu_read(0x0C00), 0x09); + assert_eq!(mapper.ppu_read(0x1000), 0x0A); + + // K=1, C=0: R8/R9 replace second 1KB bank in each lower pair. + mapper.cpu_write(0x8000, 0x20); + assert_eq!(mapper.ppu_read(0x0400), 0x06); + assert_eq!(mapper.ppu_read(0x0C00), 0x0E); + + // K=1, C=1: lower/upper 4KB halves swap. + mapper.cpu_write(0x8000, 0xA0); + assert_eq!(mapper.ppu_read(0x0000), 0x0A); + assert_eq!(mapper.ppu_read(0x1000), 0x04); + assert_eq!(mapper.ppu_read(0x1400), 0x06); +} + +#[test] +fn mapper64_rambo1_cycle_irq_mode_counts_every_four_cpu_cycles() { + let rom = test_rom(64, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert!(mapper.needs_ppu_a12_clock()); + mapper.cpu_write(0x8000, 0x01); // cycle IRQ mode + assert!(!mapper.needs_ppu_a12_clock()); + + mapper.cpu_write(0xC000, 0x02); // latch + mapper.cpu_write(0xC001, 0x00); // request reload + reset prescaler + mapper.cpu_write(0xE001, 0x00); // enable IRQ + + mapper.clock_cpu(15); + assert!(!mapper.poll_irq()); + mapper.clock_cpu(8); + assert!(mapper.poll_irq()); +} + +#[test] +fn mapper64_state_roundtrip_preserves_regs_and_irq_mode() { + let mut rom = test_rom(64, 8, 8); + rom.chr_data.fill(0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.cpu_write(0x8000, 0x26); // K=1 + select R6 + mapper.cpu_write(0x8001, 0x03); + mapper.cpu_write(0x8000, 0x09); + mapper.cpu_write(0x8001, 0x07); + mapper.cpu_write(0x8000, 0x01); // cycle mode + mapper.cpu_write(0xC000, 0x01); + mapper.cpu_write(0xC001, 0x00); + mapper.cpu_write(0xE001, 0x00); + mapper.clock_cpu(8); + mapper.ppu_write(0x0400, 0x5A); + + let mut state = Vec::new(); + mapper.save_state(&mut state); + + let mut rom2 = test_rom(64, 8, 8); + rom2.chr_data.fill(0); + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state must load"); + + assert_eq!(restored.cpu_read(0xC000), mapper.cpu_read(0xC000)); + assert_eq!(restored.ppu_read(0x0400), mapper.ppu_read(0x0400)); + assert_eq!(restored.needs_ppu_a12_clock(), mapper.needs_ppu_a12_clock()); +} + +#[test] +fn mapper158_uses_chr_bit7_for_nametable_routing() { + let mut rom = test_rom(158, 4, 32); + rom.chr_data.fill(0); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + // Program CHR banks used by $0000-$0FFF pages. + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x00); // page0 -> bank 0 (bit7=0) + mapper.cpu_write(0x8000, 0x20 | 0x08); + mapper.cpu_write(0x8001, 0x80); // page1 -> bank 0x80 (bit7=1) via R8 in K=1 mode + mapper.cpu_write(0x8000, 0x20 | 0x01); + mapper.cpu_write(0x8001, 0x00); // page2 base + mapper.cpu_write(0x8000, 0x20 | 0x09); + mapper.cpu_write(0x8001, 0x80); // page3 -> bank 0x80 (bit7=1) via R9 + + assert_eq!(mapper.map_nametable_addr(0x2000), Some(0x000)); // NT0 -> CIRAM page 0 + assert_eq!(mapper.map_nametable_addr(0x2400), Some(0x400)); // NT1 -> CIRAM page 1 + assert_eq!(mapper.map_nametable_addr(0x2800), Some(0x000)); // NT2 -> CIRAM page 0 + assert_eq!(mapper.map_nametable_addr(0x2C00), Some(0x400)); // NT3 -> CIRAM page 1 +} + +#[test] +fn mapper158_ignores_a000_mirroring_register() { + let rom = test_rom(158, 4, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); + mapper.cpu_write(0xA000, 0x00); // would set vertical on mapper 64 + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); +} + +#[test] +fn mapper206_dxrom_has_no_cpu_low_window_mapping() { + let rom = test_rom(206, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read_low(0x6000), None); + assert!(!mapper.cpu_write_low(0x6000, 0x5A)); + assert_eq!(mapper.cpu_read_low(0x7000), None); +} + +#[test] +fn mapper206_uses_fixed_upper_prg_and_ignores_mmc3_mode_bits_irq_regs() { + let mut rom = test_rom(206, 8, 8); + rom.prg_rom.fill(0); + // 8K PRG bank markers. + for bank in 0..16usize { + rom.prg_rom[bank * 0x2000] = 0x10 + bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x06); + mapper.cpu_write(0x8001, 0x02); // bank6 -> slot $8000 + mapper.cpu_write(0x8000, 0x07); + mapper.cpu_write(0x8001, 0x03); // bank7 -> slot $A000 + + assert_eq!(mapper.cpu_read(0x8000), 0x12); + assert_eq!(mapper.cpu_read(0xA000), 0x13); + assert_eq!(mapper.cpu_read(0xC000), 0x1E); // second last fixed + assert_eq!(mapper.cpu_read(0xE000), 0x1F); // last fixed + + // MMC3 PRG mode/invert bits must not affect mapper 206 slot layout. + mapper.cpu_write(0x8000, 0x46); // would flip PRG mode on MMC3 + mapper.cpu_write(0x8001, 0x04); + assert_eq!(mapper.cpu_read(0x8000), 0x14); + assert_eq!(mapper.cpu_read(0xC000), 0x1E); + + // IRQ and mirroring control registers are not implemented by mapper 206. + mapper.cpu_write(0xA000, 0x01); + mapper.cpu_write(0xC000, 0xFF); + mapper.cpu_write(0xE001, 0x00); + mapper.clock_cpu(16); + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); + assert!(!mapper.poll_irq()); +} + +#[test] +fn mapper206_submapper1_is_fixed_32k_and_ignores_cpu_mapper_writes() { + let mut rom = test_rom_with_submapper(206, 1, 4, 1); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // first 32K, lower half + rom.prg_rom[0x4000] = 0x11; // first 32K, upper half + rom.prg_rom[0x8000] = 0x20; // second 32K, must stay unreachable + rom.prg_rom[0xC000] = 0x21; + rom.chr_data[0x0000] = 0x33; + rom.chr_data[0x0400] = 0x44; + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xC000), 0x11); + assert_eq!(mapper.ppu_read(0x0000), 0x33); + assert_eq!(mapper.ppu_read(0x0400), 0x44); + + mapper.cpu_write(0x8000, 0x06); + mapper.cpu_write(0x8001, 0x0F); + mapper.cpu_write(0xA000, 0x01); + mapper.cpu_write(0xE001, 0x01); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xC000), 0x11); + assert_eq!(mapper.ppu_read(0x0000), 0x33); + assert_eq!(mapper.ppu_read(0x0400), 0x44); + assert!(!mapper.poll_irq()); +} + +#[test] +fn mapper88_routes_chr_a16_from_ppu_a12() { + let mut rom = test_rom(88, 8, 16); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x11; // bank 0 + rom.chr_data[64 * 0x0400] = 0x22; // bank 64 (A16=1 path) + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x8000, 0x00); + mapper.cpu_write(0x8001, 0x00); // reg0 + mapper.cpu_write(0x8000, 0x02); + mapper.cpu_write(0x8001, 0x00); // reg2 + + assert_eq!(mapper.ppu_read(0x0000), 0x11); + assert_eq!(mapper.ppu_read(0x1000), 0x22); +} + +#[test] +fn mapper253_uses_vrc4e_prg_and_chr_ram_overlay_on_pages_4_5() { + let mut rom = test_rom(253, 8, 128); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + // Distinguish 8K PRG banks for VRC4 layout. + rom.prg_rom[0x0000] = 0x10; // bank 0 at $8000 initially + rom.prg_rom[0x2000] = 0x20; // bank 1 at $A000 initially + rom.prg_rom[14 * 0x2000] = 0x30; // fixed second-last bank at $C000 + rom.prg_rom[15 * 0x2000] = 0x40; // fixed last bank at $E000 + rom.prg_rom[0x8000] = 0x50; // bank 4 (switch target for $8000) + rom.chr_data[0x0000] = 0x01; // page 0: CHR-ROM + rom.chr_data[64 * 0x0400] = 0x02; // page 0 via different bank + rom.chr_data[0x1800] = 0x77; // page 6 baseline + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xA000), 0x20); + assert_eq!(mapper.cpu_read(0xC000), 0x30); + assert_eq!(mapper.cpu_read(0xE000), 0x40); + mapper.cpu_write(0x8000, 0x04); // VRC4: $8000 selects $8000 bank + assert_eq!(mapper.cpu_read(0x8000), 0x50); + + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0xB000, 0x00); // CHR reg0 low nibble = 0 + mapper.cpu_write(0xB004, 0x04); // CHR reg0 high nibble = 4 -> bank 64 (VRC4e wiring) + assert_eq!(mapper.ppu_read(0x0000), 0x02); + + // Mapper 253 overlays page 4/5 with dedicated CHR-RAM. + assert_eq!(mapper.ppu_read(0x1000), 0x00); + mapper.ppu_write(0x1000, 0xAB); + mapper.ppu_write(0x17FF, 0xCD); + assert_eq!(mapper.ppu_read(0x1000), 0xAB); + assert_eq!(mapper.ppu_read(0x17FF), 0xCD); + // Neighboring page still uses CHR-ROM mapping. + assert_eq!(mapper.ppu_read(0x1800), 0x77); +} + +#[test] +fn mapper253_state_roundtrip_preserves_chr_ram_overlay() { + let rom = test_rom(253, 8, 2); + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.ppu_write(0x1001, 0x5A); + mapper.ppu_write(0x1402, 0xA5); + mapper.cpu_write(0x8000, 0x03); + mapper.cpu_write(0xA000, 0x04); + + let mut state = Vec::new(); + mapper.save_state(&mut state); + + let rom2 = test_rom(253, 8, 2); + let mut restored = create_mapper(rom2).expect("must create mapper"); + restored.load_state(&state).expect("state must load"); + assert_eq!(restored.ppu_read(0x1001), 0x5A); + assert_eq!(restored.ppu_read(0x1402), 0xA5); + assert_eq!(restored.cpu_read(0x8000), mapper.cpu_read(0x8000)); + assert_eq!(restored.cpu_read(0xA000), mapper.cpu_read(0xA000)); +} diff --git a/src/native_core/mapper/tests/vrc_variants_mmc2_4.rs b/src/native_core/mapper/tests/vrc_variants_mmc2_4.rs new file mode 100644 index 0000000..d62b26f --- /dev/null +++ b/src/native_core/mapper/tests/vrc_variants_mmc2_4.rs @@ -0,0 +1,263 @@ +use super::*; + +#[test] +fn mapper22_vrc2a_uses_hv_mirroring_and_no_irq() { + let rom = test_rom(22, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0x9000, 0x02); + assert_eq!(mapper.mirroring(), Mirroring::Vertical); + mapper.cpu_write(0x9000, 0x03); + assert_eq!(mapper.mirroring(), Mirroring::Horizontal); + + mapper.cpu_write(0xF002, 0x06); + mapper.clock_cpu(8); + assert!(!mapper.poll_irq()); +} + +#[test] +fn mapper22_vrc2a_chr_bank_value_is_shifted_right_by_one() { + let mut rom = test_rom(22, 4, 8); + rom.chr_data.fill(0); + let chr_1k_banks = rom.chr_data.len() / 0x0400; + for bank in 0..chr_1k_banks { + rom.chr_data[bank * 0x0400] = bank as u8; + } + + let mut mapper = create_mapper(rom).expect("must create mapper"); + mapper.cpu_write(0xB000, 0x05); // bank0 low nibble + mapper.cpu_write(0xB002, 0x00); // bank0 high nibble => raw value 0x05 + + // Mapper 22 uses value >> 1 for CHR bank selection. + assert_eq!(mapper.ppu_read(0x0000), 0x02); +} + +#[test] +fn mapper25_submapper1_uses_lower_address_wiring_for_chr_registers() { + let mut rom = test_rom_with_submapper(25, 1, 4, 8); + rom.chr_data.fill(0); + let chr_1k_banks = rom.chr_data.len() / 0x0400; + for bank in 0..chr_1k_banks { + rom.chr_data[bank * 0x0400] = bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.ppu_read(0x0000), 0x00); + assert_eq!(mapper.ppu_read(0x0400), 0x01); + mapper.cpu_write(0xB001, 0x05); // lower wiring => subindex 2 => bank1 low nibble + assert_eq!(mapper.ppu_read(0x0000), 0x00); + assert_eq!(mapper.ppu_read(0x0400), 0x05); +} + +#[test] +fn mapper25_submapper2_uses_higher_address_wiring_for_chr_registers() { + let mut rom = test_rom_with_submapper(25, 2, 4, 8); + rom.chr_data.fill(0); + let chr_1k_banks = rom.chr_data.len() / 0x0400; + for bank in 0..chr_1k_banks { + rom.chr_data[bank * 0x0400] = bank as u8; + } + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.ppu_read(0x0000), 0x00); + assert_eq!(mapper.ppu_read(0x0400), 0x01); + mapper.cpu_write(0xB001, 0x05); // higher wiring => subindex 0 => bank0 low nibble + assert_eq!(mapper.ppu_read(0x0000), 0x05); + assert_eq!(mapper.ppu_read(0x0400), 0x01); +} + +#[test] +fn mapper25_submapper3_disables_vrc_irq_block() { + let rom = test_rom_with_submapper(25, 3, 8, 8); + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xF000, 0x0E); // latch low nibble + mapper.cpu_write(0xF001, 0x0F); // latch high nibble + mapper.cpu_write(0xF002, 0x06); // would enable IRQ on VRC4 wiring + mapper.clock_cpu(8); + assert!(!mapper.poll_irq()); +} + +#[test] +fn mapper24_vrc6_switches_prg_chr_and_irq() { + let mut rom = test_rom(24, 16, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // 16K bank 0 + rom.prg_rom[0x4000] = 0x20; // 16K bank 1 + rom.prg_rom[0xA000] = 0x33; // 8K bank 5 + rom.chr_data[0x0000] = 0x01; // CHR 1K bank 0 + rom.chr_data[0x0400] = 0x02; // CHR 1K bank 1 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + mapper.cpu_write(0x8000, 0x01); + assert_eq!(mapper.cpu_read(0x8000), 0x20); + + mapper.cpu_write(0xC000, 0x05); + assert_eq!(mapper.cpu_read(0xC000), 0x33); + + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.cpu_write(0xD000, 0x01); + assert_eq!(mapper.ppu_read(0x0000), 0x02); + + mapper.cpu_write(0xF000, 0xFE); + mapper.cpu_write(0xF001, 0x06); // CPU mode + enabled + mapper.clock_cpu(2); + assert!(mapper.poll_irq()); +} + +#[test] +fn mapper26_swaps_a0_a1_for_vrc6_register_decode() { + let mut rom = test_rom(26, 16, 4); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x11; // CHR bank 0 + rom.chr_data[0x0400] = 0x22; // CHR bank 1 + rom.chr_data[0x0800] = 0x33; // CHR bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.ppu_read(0x0400), 0x22); + mapper.cpu_write(0xD001, 0x02); // mapper 26 swaps A0/A1 -> writes R2, not R1 + assert_eq!(mapper.ppu_read(0x0400), 0x22); + assert_eq!(mapper.ppu_read(0x0800), 0x33); +} + +#[test] +fn mapper85_vrc7_switches_prg_chr_mirroring_and_irq() { + let mut rom = test_rom(85, 4, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // 8K bank 0 + rom.prg_rom[0x2000] = 0x11; // 8K bank 1 + rom.prg_rom[0x4000] = 0x12; // 8K bank 2 + rom.prg_rom[0x6000] = 0x13; // 8K bank 3 + rom.prg_rom[0xE000] = 0x17; // last fixed bank (7) + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x0400] = 0x02; // CHR bank 1 + rom.chr_data[0x1000] = 0x05; // CHR bank 4 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + assert_eq!(mapper.cpu_read(0xA000), 0x11); + assert_eq!(mapper.cpu_read(0xC000), 0x12); + assert_eq!(mapper.cpu_read(0xE000), 0x17); + + mapper.cpu_write(0x8000, 0x03); + mapper.cpu_write(0x8008, 0x01); + mapper.cpu_write(0x9000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x13); + assert_eq!(mapper.cpu_read(0xA000), 0x11); + assert_eq!(mapper.cpu_read(0xC000), 0x12); + + assert_eq!(mapper.ppu_read(0x0400), 0x02); + mapper.cpu_write(0xA008, 0x04); // CHR bank 1 via secondary alias + assert_eq!(mapper.ppu_read(0x0400), 0x05); + + mapper.cpu_write(0xE000, 0x03); + assert_eq!(mapper.mirroring(), Mirroring::OneScreenHigh); + + mapper.cpu_write(0xE000, 0x80); + assert!(mapper.cpu_write_low(0x6000, 0x5A)); + assert_eq!(mapper.cpu_read_low(0x6000), Some(0x5A)); + + mapper.cpu_write(0xE010, 0xFE); + mapper.cpu_write(0xF000, 0x06); // CPU mode + enabled + mapper.clock_cpu(2); + assert!(mapper.poll_irq()); +} + +#[test] +fn mapper85_submapper1_uses_a3_for_secondary_chr_registers() { + let mut rom = test_rom_with_submapper(85, 1, 4, 8); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x01; // bank 0 + rom.chr_data[0x1000] = 0x05; // bank 4 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xA008, 0x04); // submapper1: A3 selects odd register (reg1) + assert_eq!(mapper.ppu_read(0x0400), 0x05); +} + +#[test] +fn mapper85_submapper2_uses_a4_for_secondary_chr_registers() { + let mut rom = test_rom_with_submapper(85, 2, 4, 8); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x01; // bank 0 + rom.chr_data[0x1000] = 0x05; // bank 4 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xA008, 0x04); // submapper2: A3 ignored for odd/even + assert_eq!(mapper.ppu_read(0x0400), 0x00); + mapper.cpu_write(0xA010, 0x04); // submapper2: A4 selects odd register (reg1) + assert_eq!(mapper.ppu_read(0x0400), 0x05); +} + +#[test] +fn mapper9_mmc2_chr_latches_switch_banks() { + let mut rom = test_rom(9, 8, 8); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x11; // bank 0 + rom.chr_data[0x2000] = 0x22; // bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xB000, 0x00); // latch FD -> bank0 + mapper.cpu_write(0xC000, 0x02); // latch FE -> bank2 + mapper.ppu_write(0x0FD8, 0); + assert_eq!(mapper.ppu_read(0x0000), 0x11); + mapper.ppu_write(0x0FE8, 0); + assert_eq!(mapper.ppu_read(0x0000), 0x22); +} + +#[test] +fn mapper9_mmc2_latches_also_switch_on_ppu_reads() { + let mut rom = test_rom(9, 8, 8); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x11; // bank 0 + rom.chr_data[0x2000] = 0x22; // bank 2 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xB000, 0x00); // latch FD -> bank0 + mapper.cpu_write(0xC000, 0x02); // latch FE -> bank2 + assert_eq!(mapper.ppu_read(0x0000), 0x11); + let _ = mapper.ppu_read(0x0FE8); // read-trigger FE latch + assert_eq!(mapper.ppu_read(0x0000), 0x22); +} + +#[test] +fn mapper10_mmc4_switches_prg_and_chr_latches() { + let mut rom = test_rom(10, 8, 8); + rom.prg_rom.fill(0); + rom.chr_data.fill(0); + rom.prg_rom[0x0000] = 0x10; // PRG bank 0 @ $8000 + rom.prg_rom[0x8000] = 0x30; // PRG bank 2 @ $8000 + rom.chr_data[0x0000] = 0x01; // CHR bank 0 + rom.chr_data[0x3000] = 0x03; // CHR bank 3 + let mut mapper = create_mapper(rom).expect("must create mapper"); + + assert_eq!(mapper.cpu_read(0x8000), 0x10); + mapper.cpu_write(0xA000, 0x02); + assert_eq!(mapper.cpu_read(0x8000), 0x30); + + mapper.cpu_write(0xB000, 0x00); + mapper.cpu_write(0xC000, 0x03); + mapper.ppu_write(0x0FD8, 0); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + mapper.ppu_write(0x0FE8, 0); + assert_eq!(mapper.ppu_read(0x0000), 0x03); +} + +#[test] +fn mapper10_mmc4_latches_also_switch_on_ppu_reads() { + let mut rom = test_rom(10, 8, 8); + rom.chr_data.fill(0); + rom.chr_data[0x0000] = 0x01; // FD bank + rom.chr_data[0x3000] = 0x03; // FE bank + let mut mapper = create_mapper(rom).expect("must create mapper"); + + mapper.cpu_write(0xB000, 0x00); + mapper.cpu_write(0xC000, 0x03); + assert_eq!(mapper.ppu_read(0x0000), 0x01); + let _ = mapper.ppu_read(0x0FE8); // read-trigger FE latch + assert_eq!(mapper.ppu_read(0x0000), 0x03); +} diff --git a/src/native_core/mapper/types.rs b/src/native_core/mapper/types.rs new file mode 100644 index 0000000..c91148c --- /dev/null +++ b/src/native_core/mapper/types.rs @@ -0,0 +1,4 @@ +pub(crate) const PRG_BANK_8K: usize = 0x2000; +pub(crate) const PRG_BANK_16K: usize = 0x4000; +pub(crate) const CHR_BANK_1K: usize = 0x0400; +pub(crate) const PRG_RAM_8K: usize = 0x2000; diff --git a/src/native_core/mod.rs b/src/native_core/mod.rs new file mode 100644 index 0000000..7faf183 --- /dev/null +++ b/src/native_core/mod.rs @@ -0,0 +1,9 @@ +pub mod apu; +pub mod bus; +pub mod cpu; +pub mod ines; +pub mod mapper; +pub mod ppu; +pub(crate) mod state_io; +#[cfg(test)] +pub(crate) mod test_support; diff --git a/src/native_core/ppu/api.rs b/src/native_core/ppu/api.rs new file mode 100644 index 0000000..6943e41 --- /dev/null +++ b/src/native_core/ppu/api.rs @@ -0,0 +1,387 @@ +use super::state::{apply_color_emphasis, nes_rgb, palette_index}; +use super::types::{OAM_SIZE, PALETTE_SIZE, Ppu, ScrollEvent, VISIBLE_SCANLINES, VRAM_SIZE}; +use crate::native_core::ines::Mirroring; +use crate::native_core::mapper::Mapper; +use crate::native_core::state_io as sio; + +const PPU_STATE_CTX: &str = "ppu state"; + +impl Ppu { + pub fn new() -> Self { + Self { + vram: [0; VRAM_SIZE], + palette_ram: [0; PALETTE_SIZE], + oam: [0; OAM_SIZE], + ctrl: 0, + mask: 0, + status: 0, + oam_addr: 0, + write_latch: false, + vram_addr: 0, + temp_addr: 0, + fine_x: 0, + scroll_x: 0, + scroll_y: 0, + read_buffer: 0, + io_latch: 0, + scroll_events: vec![ScrollEvent { + scanline: 0, + x_start: 0, + scroll_x: 0, + scroll_y: 0, + base_nt: 0, + }], + frame_rgba: vec![0; 256 * 240 * 4], + bg_shift_pattern_lo: 0, + bg_shift_pattern_hi: 0, + bg_shift_attr_lo: 0, + bg_shift_attr_hi: 0, + next_tile_id: 0, + next_tile_attr: 0, + next_tile_lsb: 0, + next_tile_msb: 0, + sprite_indices: [0; 8], + sprite_count: 0, + next_sprite_indices: [0; 8], + next_sprite_count: 0, + } + } + + pub fn begin_frame(&mut self) { + self.scroll_events.clear(); + self.scroll_events.push(self.current_scroll_event(0, 0)); + self.bg_shift_pattern_lo = 0; + self.bg_shift_pattern_hi = 0; + self.bg_shift_attr_lo = 0; + self.bg_shift_attr_hi = 0; + self.next_tile_id = 0; + self.next_tile_attr = 0; + self.next_tile_lsb = 0; + self.next_tile_msb = 0; + self.sprite_count = 0; + self.next_sprite_count = 0; + } + + pub fn frame_buffer(&self) -> &[u8] { + &self.frame_rgba + } + + pub fn render_dot(&mut self, mapper: &dyn Mapper, scanline: u32, dot: u32) { + if dot == 0 || dot > 340 { + return; + } + + let rendering_active = self.rendering_enabled() && (scanline < 240 || scanline == 261); + let bg_fetch_cycle = + rendering_active && ((1..=256).contains(&dot) || (321..=336).contains(&dot)); + + let show_bg = (self.mask & 0x08) != 0; + let show_bg_left = (self.mask & 0x02) != 0; + let show_spr = (self.mask & 0x10) != 0; + let show_spr_left = (self.mask & 0x04) != 0; + + if scanline < 240 && (1..=256).contains(&dot) { + let x = (dot - 1) as usize; + let y = scanline as usize; + let bg_layer_enabled = show_bg && (x >= 8 || show_bg_left); + let (bg_color_index, bg_opaque) = if bg_layer_enabled { + self.background_pixel_from_shifters() + } else { + (self.read_palette(0), false) + }; + + if !self.sprite0_hit_set() && self.sprite0_hit_at(mapper, y, dot) && bg_opaque { + self.set_sprite0_hit(true); + } + + let mut final_color = bg_color_index & 0x3F; + let sprite_layer_enabled = show_spr && (x >= 8 || show_spr_left); + if sprite_layer_enabled + && let Some((spr_color_index, behind_bg)) = self.sprite_pixel(mapper, x, y) + && !(behind_bg && bg_opaque) + { + final_color = spr_color_index & 0x3F; + } + + let (r, g, b) = apply_color_emphasis(nes_rgb(final_color), self.mask); + let i = (y * 256 + x) * 4; + self.frame_rgba[i] = r; + self.frame_rgba[i + 1] = g; + self.frame_rgba[i + 2] = b; + self.frame_rgba[i + 3] = 255; + } + + if bg_fetch_cycle { + self.bg_shift_pattern_lo <<= 1; + self.bg_shift_pattern_hi <<= 1; + self.bg_shift_attr_lo <<= 1; + self.bg_shift_attr_hi <<= 1; + let phase = dot & 7; + match phase { + 1 => { + let nt_addr = 0x2000 | (self.vram_addr & 0x0FFF); + self.next_tile_id = self.read_nt(nt_addr, mapper); + } + 3 => { + let attr_addr = 0x23C0 + | (self.vram_addr & 0x0C00) + | ((self.vram_addr >> 4) & 0x38) + | ((self.vram_addr >> 2) & 0x07); + let attr = self.read_nt(attr_addr, mapper); + let shift = (((self.vram_addr >> 4) & 4) | (self.vram_addr & 2)) as u8; + self.next_tile_attr = (attr >> shift) & 0x03; + } + 5 => { + let table = if (self.ctrl & 0x10) != 0 { + 0x1000 + } else { + 0x0000 + }; + let fine_y = (self.vram_addr >> 12) & 0x7; + let addr = table + ((self.next_tile_id as u16) << 4) + fine_y; + self.next_tile_lsb = mapper.ppu_read(addr); + } + 7 => { + let table = if (self.ctrl & 0x10) != 0 { + 0x1000 + } else { + 0x0000 + }; + let fine_y = (self.vram_addr >> 12) & 0x7; + let addr = table + ((self.next_tile_id as u16) << 4) + fine_y + 8; + self.next_tile_msb = mapper.ppu_read(addr); + } + 0 => { + self.reload_bg_shifters(); + self.increment_coarse_x(); + } + _ => {} + } + } + + if rendering_active { + // Transfer pre-evaluated sprite list at the start of each visible scanline, + // so dots 1-256 render with the correct sprites for *this* scanline. + if scanline < 240 && dot == 1 && self.sprites_enabled() { + self.sprite_count = self.next_sprite_count; + self.sprite_indices = self.next_sprite_indices; + } + + if dot == 256 { + self.increment_fine_y(); + } else if dot == 257 { + self.copy_horizontal_bits(); + if scanline < 240 { + if self.sprites_enabled() { + let next_scanline = (scanline as usize + 1) % 240; + let (count, indices, overflow) = + self.evaluate_sprites_for_scanline(next_scanline); + self.next_sprite_count = count; + self.next_sprite_indices = indices; + if overflow { + self.set_sprite_overflow(true); + } + } + } else if scanline == 261 { + let (count, indices, _) = self.evaluate_sprites_for_scanline(0); + self.next_sprite_count = count; + self.next_sprite_indices = indices; + } + } else if scanline == 261 && (280..=304).contains(&dot) { + self.copy_vertical_bits(); + } + } + } + + pub fn note_scroll_register_write(&mut self, scanline: usize, dot: u32) { + self.note_scroll_register_write_legacy(scanline, dot); + } + + pub(super) fn background_pixel_from_shifters(&self) -> (u8, bool) { + let bit = 0x8000u16 >> self.fine_x; + let p0 = u8::from((self.bg_shift_pattern_lo & bit) != 0); + let p1 = u8::from((self.bg_shift_pattern_hi & bit) != 0); + let pix = p0 | (p1 << 1); + if pix == 0 { + return (self.read_palette(0), false); + } + let a0 = u8::from((self.bg_shift_attr_lo & bit) != 0); + let a1 = u8::from((self.bg_shift_attr_hi & bit) != 0); + let pal = (a0 | (a1 << 1)) << 2 | pix; + (self.read_palette(pal as u16), true) + } + + pub(super) fn reload_bg_shifters(&mut self) { + self.bg_shift_pattern_lo = (self.bg_shift_pattern_lo & 0xFF00) | self.next_tile_lsb as u16; + self.bg_shift_pattern_hi = (self.bg_shift_pattern_hi & 0xFF00) | self.next_tile_msb as u16; + let attr_lo = if (self.next_tile_attr & 0x01) != 0 { + 0xFF + } else { + 0x00 + }; + let attr_hi = if (self.next_tile_attr & 0x02) != 0 { + 0xFF + } else { + 0x00 + }; + self.bg_shift_attr_lo = (self.bg_shift_attr_lo & 0xFF00) | attr_lo; + self.bg_shift_attr_hi = (self.bg_shift_attr_hi & 0xFF00) | attr_hi; + } + + pub(super) fn increment_coarse_x(&mut self) { + if (self.vram_addr & 0x001F) == 31 { + self.vram_addr &= !0x001F; + self.vram_addr ^= 0x0400; + } else { + self.vram_addr = self.vram_addr.wrapping_add(1); + } + } + + pub(super) fn increment_fine_y(&mut self) { + if (self.vram_addr & 0x7000) != 0x7000 { + self.vram_addr = self.vram_addr.wrapping_add(0x1000); + return; + } + self.vram_addr &= !0x7000; + let mut y = (self.vram_addr & 0x03E0) >> 5; + if y == 29 { + y = 0; + self.vram_addr ^= 0x0800; + } else if y == 31 { + y = 0; + } else { + y += 1; + } + self.vram_addr = (self.vram_addr & !0x03E0) | (y << 5); + } + + pub(super) fn copy_horizontal_bits(&mut self) { + self.vram_addr = (self.vram_addr & !0x041F) | (self.temp_addr & 0x041F); + } + + pub(super) fn copy_vertical_bits(&mut self) { + self.vram_addr = (self.vram_addr & !0x7BE0) | (self.temp_addr & 0x7BE0); + } + + pub(super) fn evaluate_sprites_for_scanline(&self, y: usize) -> (u8, [u8; 8], bool) { + let mut indices = [0u8; 8]; + let mut count = 0u8; + let sprite_height = if (self.ctrl & 0x20) != 0 { 16i16 } else { 8i16 }; + let y = y as i16; + for i in 0..64usize { + let oam_idx = i * 4; + let sprite_y = self.oam[oam_idx] as i16 + 1; + if y >= sprite_y && y < sprite_y + sprite_height { + if count < 8 { + indices[count as usize] = i as u8; + count += 1; + } else { + break; + } + } + } + let overflow = self.sprite_overflow_on_scanline(y as usize); + (count, indices, overflow) + } + + pub fn note_scroll_register_write_legacy(&mut self, scanline: usize, dot: u32) { + let mut target_scanline = scanline; + let mut x_start = 0u8; + if dot <= 256 { + if dot > 0 { + x_start = (dot - 1) as u8; + } + } else { + target_scanline = target_scanline.saturating_add(1); + } + + if target_scanline >= VISIBLE_SCANLINES { + return; + } + + let clamped = target_scanline as u8; + let event = self.current_scroll_event(clamped, x_start); + if let Some(last) = self.scroll_events.last_mut() + && last.scanline == clamped + && last.x_start == x_start + { + *last = event; + return; + } + self.scroll_events.push(event); + } + + pub fn set_vblank(&mut self, enabled: bool) { + if enabled { + self.status |= 0x80; + } else { + self.status &= !0x80; + } + } + + pub fn set_sprite0_hit(&mut self, enabled: bool) { + if enabled { + self.status |= 0x40; + } else { + self.status &= !0x40; + } + } + + pub fn set_sprite_overflow(&mut self, enabled: bool) { + if enabled { + self.status |= 0x20; + } else { + self.status &= !0x20; + } + } + + #[cfg(test)] + pub fn sprite_overflow_set(&self) -> bool { + (self.status & 0x20) != 0 + } + + pub fn sprite0_hit_set(&self) -> bool { + (self.status & 0x40) != 0 + } + + pub fn rendering_enabled(&self) -> bool { + (self.mask & 0x18) != 0 + } + + pub fn sprites_enabled(&self) -> bool { + (self.mask & 0x10) != 0 + } + + pub fn nmi_enabled(&self) -> bool { + (self.ctrl & 0x80) != 0 + } + + pub fn vblank_flag_set(&self) -> bool { + (self.status & 0x80) != 0 + } + + pub fn mmc3_scanline_clock_active(&self) -> bool { + let bg_enabled = (self.mask & 0x08) != 0; + let spr_enabled = (self.mask & 0x10) != 0; + if !bg_enabled && !spr_enabled { + return false; + } + + let bg_uses_1000 = bg_enabled && (self.ctrl & 0x10) != 0; + let sprite_uses_1000 = if !spr_enabled { + false + } else if (self.ctrl & 0x20) != 0 { + // 8x16 sprites select pattern table per-tile; A12 activity is possible. + true + } else { + (self.ctrl & 0x08) != 0 + }; + + bg_uses_1000 || sprite_uses_1000 + } +} + +mod memory; +mod persistence; +mod registers; +mod render; diff --git a/src/native_core/ppu/api/memory.rs b/src/native_core/ppu/api/memory.rs new file mode 100644 index 0000000..ee8508d --- /dev/null +++ b/src/native_core/ppu/api/memory.rs @@ -0,0 +1,61 @@ +use super::*; + +impl Ppu { + pub(super) fn ppu_read(&self, addr: u16, mapper: &dyn Mapper) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => mapper.ppu_read(addr), + 0x2000..=0x3EFF => self.read_nt(addr, mapper), + 0x3F00..=0x3FFF => self.read_palette(addr), + _ => 0, + } + } + + pub(super) fn ppu_write(&mut self, addr: u16, value: u8, mapper: &mut dyn Mapper) { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => mapper.ppu_write(addr, value), + 0x2000..=0x3EFF => { + let idx = mapper + .map_nametable_addr(addr) + .unwrap_or_else(|| self.nt_index(addr, mapper.mirroring())); + self.vram[idx] = value; + } + 0x3F00..=0x3FFF => { + let idx = palette_index(addr); + self.palette_ram[idx] = value & 0x3F; + } + _ => {} + } + } + + pub(super) fn read_nt(&self, addr: u16, mapper: &dyn Mapper) -> u8 { + let idx = mapper + .map_nametable_addr(addr) + .unwrap_or_else(|| self.nt_index(addr, mapper.mirroring())); + self.vram[idx] + } + + pub(super) fn nt_index(&self, addr: u16, mirroring: Mirroring) -> usize { + let rel = (addr - 0x2000) & 0x0FFF; + let table = (rel / 0x0400) as usize; + let offset = (rel & 0x03FF) as usize; + let mapped_table = match mirroring { + Mirroring::Vertical => table & 1, + Mirroring::Horizontal => (table >> 1) & 1, + Mirroring::FourScreen => table & 3, + Mirroring::OneScreenLow => 0, + Mirroring::OneScreenHigh => 1, + }; + mapped_table * 0x0400 + offset + } + + pub(super) fn read_palette(&self, addr: u16) -> u8 { + let value = self.palette_ram[palette_index(addr)]; + if (self.mask & 0x01) != 0 { + value & 0x30 + } else { + value + } + } +} diff --git a/src/native_core/ppu/api/persistence.rs b/src/native_core/ppu/api/persistence.rs new file mode 100644 index 0000000..ca6a3eb --- /dev/null +++ b/src/native_core/ppu/api/persistence.rs @@ -0,0 +1,108 @@ +use super::*; + +impl Ppu { + pub fn save_state(&self, out: &mut Vec) { + out.extend_from_slice(&self.vram); + out.extend_from_slice(&self.palette_ram); + out.extend_from_slice(&self.oam); + out.push(self.ctrl); + out.push(self.mask); + out.push(self.status); + out.push(self.oam_addr); + out.push(u8::from(self.write_latch)); + out.extend_from_slice(&self.vram_addr.to_le_bytes()); + out.extend_from_slice(&self.temp_addr.to_le_bytes()); + out.push(self.fine_x); + out.push(self.scroll_x); + out.push(self.scroll_y); + out.push(self.read_buffer); + out.push(self.io_latch); + out.extend_from_slice(&self.bg_shift_pattern_lo.to_le_bytes()); + out.extend_from_slice(&self.bg_shift_pattern_hi.to_le_bytes()); + out.extend_from_slice(&self.bg_shift_attr_lo.to_le_bytes()); + out.extend_from_slice(&self.bg_shift_attr_hi.to_le_bytes()); + out.push(self.next_tile_id); + out.push(self.next_tile_attr); + out.push(self.next_tile_lsb); + out.push(self.next_tile_msb); + out.push(self.sprite_count); + out.extend_from_slice(&self.sprite_indices); + out.push(self.next_sprite_count); + out.extend_from_slice(&self.next_sprite_indices); + } + + pub fn load_state(&mut self, data: &[u8]) -> Result { + let mut cursor = 0usize; + self.vram.copy_from_slice(sio::take_exact( + data, + &mut cursor, + VRAM_SIZE, + PPU_STATE_CTX, + )?); + self.palette_ram.copy_from_slice(sio::take_exact( + data, + &mut cursor, + PALETTE_SIZE, + PPU_STATE_CTX, + )?); + self.oam + .copy_from_slice(sio::take_exact(data, &mut cursor, OAM_SIZE, PPU_STATE_CTX)?); + self.ctrl = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.mask = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.status = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.oam_addr = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.write_latch = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)? != 0; + self.vram_addr = sio::take_u16(data, &mut cursor, PPU_STATE_CTX)?; + self.vram_addr &= 0x3FFF; + self.temp_addr = sio::take_u16(data, &mut cursor, PPU_STATE_CTX)? & 0x3FFF; + self.fine_x = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.scroll_x = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.scroll_y = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.read_buffer = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.io_latch = if cursor < data.len() { + sio::take_u8(data, &mut cursor, PPU_STATE_CTX)? + } else { + 0 + }; + if cursor + 30 <= data.len() { + self.bg_shift_pattern_lo = sio::take_u16(data, &mut cursor, PPU_STATE_CTX)?; + self.bg_shift_pattern_hi = sio::take_u16(data, &mut cursor, PPU_STATE_CTX)?; + self.bg_shift_attr_lo = sio::take_u16(data, &mut cursor, PPU_STATE_CTX)?; + self.bg_shift_attr_hi = sio::take_u16(data, &mut cursor, PPU_STATE_CTX)?; + self.next_tile_id = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.next_tile_attr = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.next_tile_lsb = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.next_tile_msb = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.sprite_count = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.sprite_indices.copy_from_slice(sio::take_exact( + data, + &mut cursor, + 8, + PPU_STATE_CTX, + )?); + self.next_sprite_count = sio::take_u8(data, &mut cursor, PPU_STATE_CTX)?; + self.next_sprite_indices.copy_from_slice(sio::take_exact( + data, + &mut cursor, + 8, + PPU_STATE_CTX, + )?); + } else { + self.bg_shift_pattern_lo = 0; + self.bg_shift_pattern_hi = 0; + self.bg_shift_attr_lo = 0; + self.bg_shift_attr_hi = 0; + self.next_tile_id = 0; + self.next_tile_attr = 0; + self.next_tile_lsb = 0; + self.next_tile_msb = 0; + self.sprite_count = 0; + self.sprite_indices = [0; 8]; + self.next_sprite_count = 0; + self.next_sprite_indices = [0; 8]; + } + self.scroll_events.clear(); + self.scroll_events.push(self.current_scroll_event(0, 0)); + Ok(cursor) + } +} diff --git a/src/native_core/ppu/api/registers.rs b/src/native_core/ppu/api/registers.rs new file mode 100644 index 0000000..5acf411 --- /dev/null +++ b/src/native_core/ppu/api/registers.rs @@ -0,0 +1,105 @@ +use super::Ppu; +use crate::native_core::mapper::Mapper; + +impl Ppu { + fn sanitize_oam_data_byte(oam_addr: u8, value: u8) -> u8 { + if (oam_addr & 0x03) == 0x02 { + // Sprite attribute byte: bits 2..4 are unimplemented and read as zero. + value & 0xE3 + } else { + value + } + } + + pub fn cpu_read_oamdata(&mut self, force_ff: bool) -> u8 { + let out = if force_ff { + 0xFF + } else { + Self::sanitize_oam_data_byte(self.oam_addr, self.oam[self.oam_addr as usize]) + }; + self.io_latch = out; + out + } + + pub fn cpu_read(&mut self, reg: u8, mapper: &dyn Mapper) -> u8 { + match reg & 7 { + 2 => { + let out = (self.status & 0xE0) | (self.io_latch & 0x1F); + self.status &= 0x7F; + self.write_latch = false; + self.io_latch = out; + out + } + 4 => self.cpu_read_oamdata(false), + 7 => { + let addr = self.vram_addr; + let inc = self.vram_increment(); + self.vram_addr = self.vram_addr.wrapping_add(inc) & 0x3FFF; + + let value = self.ppu_read(addr, mapper); + let out = if (addr & 0x3FFF) >= 0x3F00 { + self.read_buffer = self.ppu_read(addr.wrapping_sub(0x1000), mapper); + (value & 0x3F) | (self.io_latch & 0xC0) + } else { + let buffered = self.read_buffer; + self.read_buffer = value; + buffered + }; + self.io_latch = out; + out + } + _ => self.io_latch, + } + } + + pub fn cpu_write(&mut self, reg: u8, value: u8, mapper: &mut dyn Mapper) { + self.io_latch = value; + match reg & 7 { + 0 => { + self.ctrl = value; + self.temp_addr = (self.temp_addr & !0x0C00) | (((value as u16) & 0x03) << 10); + } + 1 => self.mask = value, + 3 => self.oam_addr = value, + 4 => { + self.oam[self.oam_addr as usize] = + Self::sanitize_oam_data_byte(self.oam_addr, value); + self.oam_addr = self.oam_addr.wrapping_add(1); + } + 5 => { + if !self.write_latch { + self.fine_x = value & 0x07; + self.scroll_x = value; + self.temp_addr = (self.temp_addr & !0x001F) | ((value as u16) >> 3); + } else { + self.scroll_y = value; + self.temp_addr = (self.temp_addr & !0x73E0) + | (((value as u16) & 0x07) << 12) + | (((value as u16) & 0xF8) << 2); + } + self.write_latch = !self.write_latch; + } + 6 => { + if !self.write_latch { + self.temp_addr = (self.temp_addr & 0x00FF) | (((value as u16) & 0x3F) << 8); + } else { + self.temp_addr = ((self.temp_addr & 0x3F00) | (value as u16)) & 0x3FFF; + self.vram_addr = self.temp_addr; + } + self.write_latch = !self.write_latch; + } + 7 => { + let addr = self.vram_addr; + self.ppu_write(addr, value, mapper); + let inc = self.vram_increment(); + self.vram_addr = self.vram_addr.wrapping_add(inc) & 0x3FFF; + } + _ => {} + } + } + + pub fn dma_write_oam(&mut self, value: u8) { + self.oam[self.oam_addr as usize] = Self::sanitize_oam_data_byte(self.oam_addr, value); + self.oam_addr = self.oam_addr.wrapping_add(1); + } +} diff --git a/src/native_core/ppu/api/render.rs b/src/native_core/ppu/api/render.rs new file mode 100644 index 0000000..793029c --- /dev/null +++ b/src/native_core/ppu/api/render.rs @@ -0,0 +1,427 @@ +use super::*; + +impl Ppu { + pub fn render_frame( + &self, + mapper: &dyn Mapper, + out_rgba: &mut [u8], + _frame_number: u32, + _buttons: [bool; 8], + ) { + let width = 256usize; + let height = 240usize; + if out_rgba.len() < width * height * 4 { + return; + } + let show_bg = (self.mask & 0x08) != 0; + let show_bg_left = (self.mask & 0x02) != 0; + let show_spr = (self.mask & 0x10) != 0; + let show_spr_left = (self.mask & 0x04) != 0; + let mut event_idx = 0usize; + let use_global_scroll = self.scroll_events.len() <= 1; + + for y in 0..height { + let mut event = if use_global_scroll { + ScrollEvent { + scanline: 0, + x_start: 0, + scroll_x: self.scroll_x, + scroll_y: self.scroll_y, + base_nt: self.ctrl & 0x03, + } + } else { + while event_idx + 1 < self.scroll_events.len() { + let next = self.scroll_events[event_idx + 1]; + if next.scanline as usize > y { + break; + } + if next.scanline as usize == y && next.x_start != 0 { + break; + } + event_idx += 1; + } + self.scroll_events + .get(event_idx) + .copied() + .unwrap_or(ScrollEvent { + scanline: 0, + x_start: 0, + scroll_x: self.scroll_x, + scroll_y: self.scroll_y, + base_nt: self.ctrl & 0x03, + }) + }; + for x in 0..width { + while event_idx + 1 < self.scroll_events.len() { + let next = self.scroll_events[event_idx + 1]; + if next.scanline as usize != y || next.x_start as usize > x { + break; + } + event_idx += 1; + event = self.scroll_events[event_idx]; + } + let bg_layer_enabled = show_bg && (x >= 8 || show_bg_left); + let (bg_color_index, bg_opaque) = if bg_layer_enabled { + self.background_pixel_with_scroll( + mapper, + x, + y, + event.scroll_x as usize, + event.scroll_y as usize, + event.base_nt as usize, + ) + } else { + (self.read_palette(0), false) + }; + + let mut final_color = bg_color_index & 0x3F; + let sprite_layer_enabled = show_spr && (x >= 8 || show_spr_left); + if sprite_layer_enabled + && let Some((spr_color_index, behind_bg)) = self.sprite_pixel(mapper, x, y) + && !(behind_bg && bg_opaque) + { + final_color = spr_color_index & 0x3F; + } + + let (r, g, b) = apply_color_emphasis(nes_rgb(final_color), self.mask); + + let i = (y * width + x) * 4; + out_rgba[i] = r; + out_rgba[i + 1] = g; + out_rgba[i + 2] = b; + out_rgba[i + 3] = 255; + } + } + } + + pub(super) fn vram_increment(&self) -> u16 { + if (self.ctrl & 0x04) != 0 { 32 } else { 1 } + } + + pub(super) fn background_pixel_with_scroll( + &self, + mapper: &dyn Mapper, + x: usize, + y: usize, + scroll_x: usize, + scroll_y: usize, + base_nt: usize, + ) -> (u8, bool) { + let world_x = x + scroll_x; + let world_y = y + scroll_y; + + let nt_x = (world_x / 256) & 1; + let nt_y = (world_y / 240) & 1; + let coarse_x = (world_x / 8) & 31; + let coarse_y = ((world_y % 240) / 8) & 31; + let fine_y = world_y & 7; + let fine_x = world_x & 7; + + let current_nt = ((base_nt ^ nt_x ^ (nt_y << 1)) & 0x03) as u16; + let nt_base = 0x2000 + (current_nt * 0x0400); + let nt_addr = nt_base + (coarse_y * 32 + coarse_x) as u16; + let tile_id = self.read_nt(nt_addr, mapper); + + let pattern_base = if (self.ctrl & 0x10) != 0 { + 0x1000 + } else { + 0x0000 + }; + let tile_addr = pattern_base + (tile_id as u16) * 16; + let lo = mapper.ppu_read(tile_addr + fine_y as u16); + let hi = mapper.ppu_read(tile_addr + 8 + fine_y as u16); + let bit = 7 - fine_x as u8; + let pix = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1); + if pix == 0 { + return (self.read_palette(0), false); + } + + let attr_addr = nt_base + 0x03C0 + ((coarse_y / 4) * 8 + (coarse_x / 4)) as u16; + let attr = self.read_nt(attr_addr, mapper); + let shift = (((coarse_y & 2) as u8) << 1) | ((coarse_x & 2) as u8); + let pal_hi = (attr >> shift) & 0x03; + let pal_index = (pal_hi << 2) | pix; + (self.read_palette(pal_index as u16), true) + } + + pub(super) fn current_scroll_event(&self, scanline: u8, x_start: u8) -> ScrollEvent { + ScrollEvent { + scanline, + x_start, + scroll_x: self.scroll_x, + scroll_y: self.scroll_y, + base_nt: self.ctrl & 0x03, + } + } + + pub(super) fn scroll_event_at(&self, y: usize, x: usize) -> ScrollEvent { + let mut active = ScrollEvent { + scanline: 0, + x_start: 0, + scroll_x: self.scroll_x, + scroll_y: self.scroll_y, + base_nt: self.ctrl & 0x03, + }; + for ev in &self.scroll_events { + if ev.scanline as usize > y { + break; + } + if ev.scanline as usize == y && ev.x_start as usize > x { + break; + } + active = *ev; + } + active + } + + pub(super) fn sprite_pixel( + &self, + mapper: &dyn Mapper, + x: usize, + y: usize, + ) -> Option<(u8, bool)> { + let sprite_height = if (self.ctrl & 0x20) != 0 { 16i16 } else { 8i16 }; + let use_secondary_oam = self.sprite_count != 0; + let max = if use_secondary_oam { + self.sprite_count as usize + } else { + 64 + }; + for slot in 0..max { + let i = if use_secondary_oam { + self.sprite_indices[slot] as usize + } else { + slot + }; + let oam_idx = i * 4; + let sprite_y = self.oam[oam_idx] as i16 + 1; + let mut row = y as i16 - sprite_y; + if row < 0 || row >= sprite_height { + continue; + } + + let sprite_x = self.oam[oam_idx + 3] as i16; + let mut col = x as i16 - sprite_x; + if !(0..8).contains(&col) { + continue; + } + + let tile = self.oam[oam_idx + 1]; + let attr = self.oam[oam_idx + 2]; + if (attr & 0x80) != 0 { + row = sprite_height - 1 - row; + } + if (attr & 0x40) != 0 { + col = 7 - col; + } + + let (tile_addr, bit) = if sprite_height == 16 { + let table = ((tile & 1) as u16) << 12; + let tile_row = row as u16; + let tile_num = (tile & 0xFE).wrapping_add((tile_row / 8) as u8) as u16; + let row_in_tile = tile_row & 7; + let addr = table + tile_num * 16 + row_in_tile; + (addr, 7 - (col as u8)) + } else { + let table = if (self.ctrl & 0x08) != 0 { + 0x1000 + } else { + 0x0000 + }; + let addr = table + (tile as u16) * 16 + (row as u16); + (addr, 7 - (col as u8)) + }; + + let lo = mapper.ppu_read(tile_addr); + let hi = mapper.ppu_read(tile_addr + 8); + let pix = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1); + if pix == 0 { + continue; + } + + let palette_addr = 0x10 + (((attr & 0x03) as u16) << 2) + pix as u16; + let color = self.read_palette(palette_addr); + let behind_bg = (attr & 0x20) != 0; + return Some((color, behind_bg)); + } + None + } + + pub fn sprite_overflow_on_scanline(&self, y: usize) -> bool { + // Emulate PPU sprite overflow quirk with diagonal OAM scan once 8 sprites are found. + let sprite_height = if (self.ctrl & 0x20) != 0 { 16i16 } else { 8i16 }; + let y = y as i16; + let mut found = 0u8; + let mut n = 0usize; + while n < 64 { + let base = n * 4; + let sprite_y = self.oam[base] as i16 + 1; + let in_range = y >= sprite_y && y < sprite_y + sprite_height; + if in_range { + found = found.saturating_add(1); + if found == 8 { + break; + } + } + n += 1; + } + if found < 8 { + return false; + } + // Fast path for common cases where exactly 8 sprites are present. + let mut simple_count = 0u8; + for i in 0..64usize { + let base = i * 4; + let sprite_y = self.oam[base] as i16 + 1; + if y >= sprite_y && y < sprite_y + sprite_height { + simple_count = simple_count.saturating_add(1); + if simple_count > 8 { + break; + } + } + } + if simple_count <= 8 { + return false; + } + + let mut m = 0usize; + while n < 64 { + let byte = self.oam[n * 4 + m]; + if m == 0 { + let sprite_y = byte as i16 + 1; + let in_range = y >= sprite_y && y < sprite_y + sprite_height; + if in_range { + return true; + } + n += 1; + m = 0; + } else { + let sprite_y = byte as i16 + 1; + if y >= sprite_y && y < sprite_y + sprite_height { + return true; + } + n += 1; + m = (m + 1) & 3; + } + } + false + } + + pub fn sprite0_hit_at(&self, mapper: &dyn Mapper, y: usize, dot: u32) -> bool { + // Visible dots map to X=0..255 (dot 1..=256). + if dot == 0 || dot > 256 { + return false; + } + let show_bg = (self.mask & 0x08) != 0; + let show_bg_left = (self.mask & 0x02) != 0; + let show_spr = (self.mask & 0x10) != 0; + let show_spr_left = (self.mask & 0x04) != 0; + if !(show_bg && show_spr) { + return false; + } + let x = (dot - 1) as i16; + if x == 255 { + return false; + } + if x < 8 && !(show_bg_left && show_spr_left) { + return false; + } + + let sprite_height = if (self.ctrl & 0x20) != 0 { 16i16 } else { 8i16 }; + let sprite_y = self.oam[0] as i16 + 1; + let sprite_x = self.oam[3] as i16; + let mut row = y as i16 - sprite_y; + if !(0..sprite_height).contains(&row) { + return false; + } + let mut col = x - sprite_x; + if !(0..8).contains(&col) { + return false; + } + + let tile = self.oam[1]; + let attr = self.oam[2]; + if (attr & 0x80) != 0 { + row = sprite_height - 1 - row; + } + if (attr & 0x40) != 0 { + col = 7 - col; + } + + let (tile_addr, bit) = if sprite_height == 16 { + let table = ((tile & 1) as u16) << 12; + let tile_row = row as u16; + let tile_num = (tile & 0xFE).wrapping_add((tile_row / 8) as u8) as u16; + let row_in_tile = tile_row & 7; + let addr = table + tile_num * 16 + row_in_tile; + (addr, 7 - col as u8) + } else { + let table = if (self.ctrl & 0x08) != 0 { + 0x1000 + } else { + 0x0000 + }; + let addr = table + (tile as u16) * 16 + (row as u16); + (addr, 7 - col as u8) + }; + + let lo = mapper.ppu_read(tile_addr); + let hi = mapper.ppu_read(tile_addr + 8); + let spr_pix = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1); + if spr_pix == 0 { + return false; + } + + let (_, bg_opaque) = if self.bg_shift_pattern_lo == 0 && self.bg_shift_pattern_hi == 0 { + let scroll = self.scroll_event_at(y, x as usize); + self.background_pixel_with_scroll( + mapper, + x as usize, + y, + scroll.scroll_x as usize, + scroll.scroll_y as usize, + scroll.base_nt as usize, + ) + } else { + self.background_pixel_from_shifters() + }; + bg_opaque + } + + pub fn mmc3_a12_high_at(&self, scanline: u32, dot: u32) -> bool { + if !(scanline < 240 || scanline == 261) || !self.rendering_enabled() { + return false; + } + + let bg_enabled = (self.mask & 0x08) != 0; + let spr_enabled = (self.mask & 0x10) != 0; + let is_pattern_fetch_phase = |phase_dot: u32| { + let phase = (phase_dot - 1) & 7; + phase == 5 || phase == 7 + }; + + if bg_enabled && ((1..=256).contains(&dot) || (321..=336).contains(&dot)) { + let bg_uses_1000 = (self.ctrl & 0x10) != 0; + if bg_uses_1000 && is_pattern_fetch_phase(dot) { + return true; + } + } + + if spr_enabled && (257..=320).contains(&dot) { + let sprite_uses_1000 = if (self.ctrl & 0x20) != 0 { + // 8x16 sprites select pattern table per tile; A12 highs can occur. + true + } else { + (self.ctrl & 0x08) != 0 + }; + if sprite_uses_1000 { + let sprite_phase = dot - 256; + if is_pattern_fetch_phase(sprite_phase) { + return true; + } + } + } + + false + } +} diff --git a/src/native_core/ppu/mod.rs b/src/native_core/ppu/mod.rs new file mode 100644 index 0000000..b1ffa97 --- /dev/null +++ b/src/native_core/ppu/mod.rs @@ -0,0 +1,8 @@ +mod api; +mod state; +mod types; + +pub use types::Ppu; + +#[cfg(test)] +mod tests; diff --git a/src/native_core/ppu/state.rs b/src/native_core/ppu/state.rs new file mode 100644 index 0000000..f062570 --- /dev/null +++ b/src/native_core/ppu/state.rs @@ -0,0 +1,99 @@ +pub(super) fn palette_index(addr: u16) -> usize { + let mut idx = (addr as usize) & 0x1F; + if matches!(idx, 0x10 | 0x14 | 0x18 | 0x1C) { + idx -= 0x10; + } + idx +} + +pub(super) fn nes_rgb(index: u8) -> (u8, u8, u8) { + const PAL: [(u8, u8, u8); 64] = [ + (84, 84, 84), + (0, 30, 116), + (8, 16, 144), + (48, 0, 136), + (68, 0, 100), + (92, 0, 48), + (84, 4, 0), + (60, 24, 0), + (32, 42, 0), + (8, 58, 0), + (0, 64, 0), + (0, 60, 0), + (0, 50, 60), + (0, 0, 0), + (0, 0, 0), + (0, 0, 0), + (152, 150, 152), + (8, 76, 196), + (48, 50, 236), + (92, 30, 228), + (136, 20, 176), + (160, 20, 100), + (152, 34, 32), + (120, 60, 0), + (84, 90, 0), + (40, 114, 0), + (8, 124, 0), + (0, 118, 40), + (0, 102, 120), + (0, 0, 0), + (0, 0, 0), + (0, 0, 0), + (236, 238, 236), + (76, 154, 236), + (120, 124, 236), + (176, 98, 236), + (228, 84, 236), + (236, 88, 180), + (236, 106, 100), + (212, 136, 32), + (160, 170, 0), + (116, 196, 0), + (76, 208, 32), + (56, 204, 108), + (56, 180, 204), + (60, 60, 60), + (0, 0, 0), + (0, 0, 0), + (236, 238, 236), + (168, 204, 236), + (188, 188, 236), + (212, 178, 236), + (236, 174, 236), + (236, 174, 212), + (236, 180, 176), + (228, 196, 144), + (204, 210, 120), + (180, 222, 120), + (168, 226, 144), + (152, 226, 180), + (160, 214, 228), + (160, 162, 160), + (0, 0, 0), + (0, 0, 0), + ]; + PAL[index as usize] +} + +pub(super) fn apply_color_emphasis(rgb: (u8, u8, u8), mask: u8) -> (u8, u8, u8) { + let mut mr = 1.0f32; + let mut mg = 1.0f32; + let mut mb = 1.0f32; + + if (mask & 0x20) != 0 { + mg *= 0.85; + mb *= 0.85; + } + if (mask & 0x40) != 0 { + mr *= 0.85; + mb *= 0.85; + } + if (mask & 0x80) != 0 { + mr *= 0.85; + mg *= 0.85; + } + + let scale = |v: u8, m: f32| -> u8 { ((v as f32) * m).round().clamp(0.0, 255.0) as u8 }; + (scale(rgb.0, mr), scale(rgb.1, mg), scale(rgb.2, mb)) +} diff --git a/src/native_core/ppu/tests.rs b/src/native_core/ppu/tests.rs new file mode 100644 index 0000000..ccf6414 --- /dev/null +++ b/src/native_core/ppu/tests.rs @@ -0,0 +1,70 @@ +use super::Ppu; +use super::state::nes_rgb; +use crate::native_core::{ines::Mirroring, mapper::Mapper}; + +struct StubMapper { + chr: [u8; 0x2000], + mirroring: Mirroring, + nt_page_override: Option, +} + +impl StubMapper { + fn new(mirroring: Mirroring) -> Self { + Self { + chr: [0; 0x2000], + mirroring, + nt_page_override: None, + } + } + + fn new_with_nt_override(mirroring: Mirroring, nt_page_override: usize) -> Self { + Self { + chr: [0; 0x2000], + mirroring, + nt_page_override: Some(nt_page_override & 0x03), + } + } +} + +impl Mapper for StubMapper { + fn cpu_read(&self, _addr: u16) -> u8 { + 0 + } + + fn cpu_write(&mut self, _addr: u16, _value: u8) {} + + fn ppu_read(&self, addr: u16) -> u8 { + self.chr[(addr as usize) & 0x1FFF] + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + self.chr[(addr as usize) & 0x1FFF] = value; + } + + fn mirroring(&self) -> Mirroring { + self.mirroring + } + + fn map_nametable_addr(&self, addr: u16) -> Option { + self.nt_page_override + .map(|page| page * 0x0400 + ((addr as usize) & 0x03FF)) + } + + fn save_state(&self, _out: &mut Vec) {} + + fn load_state(&mut self, _data: &[u8]) -> Result<(), String> { + Ok(()) + } +} + +fn pixel_rgb(frame: &[u8], x: usize, y: usize) -> (u8, u8, u8) { + let idx = (y * 256 + x) * 4; + (frame[idx], frame[idx + 1], frame[idx + 2]) +} + +mod loopy_timing; +mod nametable_and_addressing; +mod register_io; +mod rendering; +mod sprite_eval; +mod state_restore; diff --git a/src/native_core/ppu/tests/loopy_timing.rs b/src/native_core/ppu/tests/loopy_timing.rs new file mode 100644 index 0000000..97fed84 --- /dev/null +++ b/src/native_core/ppu/tests/loopy_timing.rs @@ -0,0 +1,41 @@ +use super::*; + +#[test] +fn dot_256_increments_coarse_x_and_fine_y() { + let mut ppu = Ppu::new(); + let mapper = StubMapper::new(Mirroring::Horizontal); + ppu.mask = 0x08; // rendering enabled + ppu.vram_addr = 0x0415; + + ppu.render_dot(&mapper, 0, 256); + + assert_eq!(ppu.vram_addr, 0x1416); +} + +#[test] +fn dot_257_copies_horizontal_bits_from_t_to_v() { + let mut ppu = Ppu::new(); + let mapper = StubMapper::new(Mirroring::Horizontal); + ppu.mask = 0x08; // rendering enabled + ppu.vram_addr = 0x7BE0; + ppu.temp_addr = 0x041F; + + ppu.render_dot(&mapper, 0, 257); + + let expected = (0x7BE0 & !0x041F) | (0x041F & 0x041F); + assert_eq!(ppu.vram_addr, expected); +} + +#[test] +fn prerender_280_copies_vertical_bits_from_t_to_v() { + let mut ppu = Ppu::new(); + let mapper = StubMapper::new(Mirroring::Horizontal); + ppu.mask = 0x08; // rendering enabled + ppu.vram_addr = 0x041F; + ppu.temp_addr = 0x7BE0; + + ppu.render_dot(&mapper, 261, 280); + + let expected = (0x041F & !0x7BE0) | (0x7BE0 & 0x7BE0); + assert_eq!(ppu.vram_addr, expected); +} diff --git a/src/native_core/ppu/tests/nametable_and_addressing.rs b/src/native_core/ppu/tests/nametable_and_addressing.rs new file mode 100644 index 0000000..9c17c85 --- /dev/null +++ b/src/native_core/ppu/tests/nametable_and_addressing.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn horizontal_mirroring_maps_nt_2000_and_2400_together() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.cpu_write(6, 0x20, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0xAB, &mut mapper); + + ppu.cpu_write(6, 0x24, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + assert_eq!(ppu.cpu_read(7, &mapper), 0); + assert_eq!(ppu.cpu_read(7, &mapper), 0xAB); +} + +#[test] +fn four_screen_keeps_tables_separate() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::FourScreen); + + ppu.cpu_write(6, 0x20, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0x11, &mut mapper); + + ppu.cpu_write(6, 0x24, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0x22, &mut mapper); + + ppu.cpu_write(6, 0x20, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + let _ = ppu.cpu_read(7, &mapper); + assert_eq!(ppu.cpu_read(7, &mapper), 0x11); + + ppu.cpu_write(6, 0x24, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + let _ = ppu.cpu_read(7, &mapper); + assert_eq!(ppu.cpu_read(7, &mapper), 0x22); +} + +#[test] +fn custom_mapper_nametable_mapping_overrides_mirroring() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new_with_nt_override(Mirroring::FourScreen, 1); + + ppu.cpu_write(6, 0x20, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0x5A, &mut mapper); + + ppu.cpu_write(6, 0x24, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + let _ = ppu.cpu_read(7, &mapper); + assert_eq!(ppu.cpu_read(7, &mapper), 0x5A); +} + +#[test] +fn ppudata_increment_wraps_at_14bit_boundary() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0xFF, &mut mapper); + ppu.cpu_write(7, 0x12, &mut mapper); // write at $3FFF, then wrap to $0000 + ppu.cpu_write(7, 0x34, &mut mapper); // write at $0000 + + assert_eq!(mapper.ppu_read(0x0000), 0x34); + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0xFF, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v & 0x3F, 0x12); +} + +#[test] +fn ppuaddr_high_byte_masks_to_14bit() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.cpu_write(6, 0xFF, &mut mapper); // high byte must be masked to 6 bits + ppu.cpu_write(6, 0x00, &mut mapper); // effective address becomes $3F00 + ppu.cpu_write(7, 0x2A, &mut mapper); + + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v & 0x3F, 0x2A); +} + +#[test] +fn ppudata_increment_32_wraps_at_14bit_boundary() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.cpu_write(0, 0x04, &mut mapper); // vram increment = 32 + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0xE0, &mut mapper); // $3FE0 + ppu.cpu_write(7, 0x21, &mut mapper); // write at $3FE0, next must wrap to $0000 + ppu.cpu_write(7, 0x43, &mut mapper); // write at $0000 + + assert_eq!(mapper.ppu_read(0x0000), 0x43); + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0xE0, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v & 0x3F, 0x21); +} diff --git a/src/native_core/ppu/tests/register_io.rs b/src/native_core/ppu/tests/register_io.rs new file mode 100644 index 0000000..98cd72c --- /dev/null +++ b/src/native_core/ppu/tests/register_io.rs @@ -0,0 +1,101 @@ +use super::*; + +#[test] +fn vblank_flag_clears_on_status_read() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + ppu.set_vblank(true); + ppu.cpu_write(0, 0x1F, &mut mapper); // seed open bus low bits + let status = ppu.cpu_read(2, &mapper); + assert_ne!(status & 0x80, 0); + assert_eq!(status & 0x1F, 0x1F); + let status2 = ppu.cpu_read(2, &mapper); + assert_eq!(status2 & 0x80, 0); +} + +#[test] +fn write_only_register_reads_return_open_bus_latch() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.cpu_write(1, 0x2A, &mut mapper); + assert_eq!(ppu.cpu_read(0, &mapper), 0x2A); +} + +#[test] +fn oam_attribute_byte_masks_unimplemented_bits_on_read_and_write() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + // CPU write path. + ppu.cpu_write(3, 0x02, &mut mapper); // attribute byte of sprite 0 + ppu.cpu_write(4, 0xFF, &mut mapper); + ppu.cpu_write(3, 0x02, &mut mapper); + assert_eq!(ppu.cpu_read(4, &mapper), 0xE3); + + // DMA write path. + ppu.cpu_write(3, 0x06, &mut mapper); // attribute byte of sprite 1 + ppu.dma_write_oam(0x7C); + ppu.cpu_write(3, 0x06, &mut mapper); + assert_eq!(ppu.cpu_read(4, &mapper), 0x60); + + // Non-attribute byte must stay unmasked. + ppu.cpu_write(3, 0x01, &mut mapper); // tile index byte + ppu.cpu_write(4, 0xFF, &mut mapper); + ppu.cpu_write(3, 0x01, &mut mapper); + assert_eq!(ppu.cpu_read(4, &mapper), 0xFF); +} + +#[test] +fn palette_ram_write_masks_to_6_bits() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0xFF, &mut mapper); + + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v & 0x3F, 0x3F); +} + +#[test] +fn palette_ppudata_read_uses_open_bus_for_upper_bits() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + // Write palette color 0x0F to $3F00. + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0x0F, &mut mapper); + + // Read back from $3F00; upper bits come from IO latch. + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(1, 0xC0, &mut mapper); // seed IO latch upper bits immediately before read + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v, 0xCF); +} + +#[test] +fn grayscale_mask_affects_palette_reads_via_ppudata() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(7, 0x2F, &mut mapper); + + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(1, 0x01, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v, 0x20); + + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0x00, &mut mapper); + ppu.cpu_write(1, 0x00, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v, 0x2F); +} diff --git a/src/native_core/ppu/tests/rendering.rs b/src/native_core/ppu/tests/rendering.rs new file mode 100644 index 0000000..41fc126 --- /dev/null +++ b/src/native_core/ppu/tests/rendering.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn sprite_renders_over_background_when_priority_front() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + let mut frame = vec![0u8; 256 * 240 * 4]; + + ppu.mask = 0x1E; // show background + sprites + left 8px for both + ppu.vram[0] = 1; // tile 1 at top-left + mapper.chr[17] = 0x80; // BG tile 1, row 1, leftmost pixel set + ppu.palette_ram[1] = 0x01; // BG palette entry + + ppu.oam[0] = 0; // appears at scanline 1 + ppu.oam[1] = 2; // tile 2 + ppu.oam[2] = 0x00; // in front of background + ppu.oam[3] = 0; // x=0 + mapper.chr[32] = 0x80; // SPR tile 2, row 0, leftmost pixel set + ppu.palette_ram[0x11] = 0x21; // sprite palette entry + + ppu.render_frame(&mapper, &mut frame, 0, [false; 8]); + assert_eq!(pixel_rgb(&frame, 0, 1), nes_rgb(0x21)); +} + +#[test] +fn sprite_priority_behind_background_keeps_bg_pixel() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + let mut frame = vec![0u8; 256 * 240 * 4]; + + ppu.mask = 0x1E; // show background + sprites + left 8px for both + ppu.vram[0] = 1; // tile 1 at top-left + mapper.chr[17] = 0x80; // BG tile 1, row 1, leftmost pixel set + ppu.palette_ram[1] = 0x01; // BG palette entry + + ppu.oam[0] = 0; // appears at scanline 1 + ppu.oam[1] = 2; // tile 2 + ppu.oam[2] = 0x20; // behind background + ppu.oam[3] = 0; // x=0 + mapper.chr[32] = 0x80; // SPR tile 2, row 0, leftmost pixel set + ppu.palette_ram[0x11] = 0x21; // sprite palette entry + + ppu.render_frame(&mapper, &mut frame, 0, [false; 8]); + assert_eq!(pixel_rgb(&frame, 0, 1), nes_rgb(0x01)); +} + +#[test] +fn ppuaddr_write_does_not_override_scroll_used_by_renderer() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + let mut frame = vec![0u8; 256 * 240 * 4]; + + ppu.mask = 0x0A; // show background + left 8px background + ppu.cpu_write(5, 8, &mut mapper); // X scroll + ppu.cpu_write(5, 0, &mut mapper); // Y scroll + + ppu.vram[0] = 0; + ppu.vram[1] = 1; + mapper.chr[16] = 0x80; // tile 1, row 0, leftmost pixel = color 1 + ppu.palette_ram[1] = 0x21; + + // PPUADDR writes are common and must not affect active scroll. + ppu.cpu_write(6, 0x23, &mut mapper); + ppu.cpu_write(6, 0xC0, &mut mapper); + + ppu.render_frame(&mapper, &mut frame, 0, [false; 8]); + assert_eq!(pixel_rgb(&frame, 0, 0), nes_rgb(0x21)); +} + +#[test] +fn split_scroll_events_affect_only_later_scanlines() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + let mut frame = vec![0u8; 256 * 240 * 4]; + + ppu.mask = 0x0A; // show background + left 8px background + ppu.begin_frame(); + + for row in 0..30usize { + ppu.vram[row * 32] = 0; + ppu.vram[row * 32 + 1] = 1; // tile that appears after x-scroll = 8 + } + for row in 0..8usize { + mapper.chr[16 + row] = 0x80; // tile 1: leftmost pixel set on all rows + } + ppu.palette_ram[1] = 0x21; + + // Apply horizontal scroll from scanline 120 onward. + ppu.cpu_write(5, 8, &mut mapper); + ppu.cpu_write(5, 0, &mut mapper); + ppu.note_scroll_register_write(120, 1); + + ppu.render_frame(&mapper, &mut frame, 0, [false; 8]); + + // Before split: still tile 0 -> backdrop color. + assert_eq!(pixel_rgb(&frame, 0, 10), nes_rgb(0x00)); + // After split: tile 1 visible due to x-scroll. + assert_eq!(pixel_rgb(&frame, 0, 130), nes_rgb(0x21)); +} + +#[test] +fn color_emphasis_affects_render_dot_framebuffer() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + ppu.vram[0] = 1; + mapper.chr[16] = 0x80; + ppu.palette_ram[1] = 0x21; + + ppu.mask = 0x0A; + ppu.render_dot(&mapper, 0, 1); + let base = pixel_rgb(ppu.frame_buffer(), 0, 0); + + ppu.mask = 0x2A; + ppu.render_dot(&mapper, 0, 1); + let emph = pixel_rgb(ppu.frame_buffer(), 0, 0); + + assert_eq!(emph.0, base.0); + assert!(emph.1 < base.1); + assert!(emph.2 < base.2); +} diff --git a/src/native_core/ppu/tests/sprite_eval.rs b/src/native_core/ppu/tests/sprite_eval.rs new file mode 100644 index 0000000..16fdc37 --- /dev/null +++ b/src/native_core/ppu/tests/sprite_eval.rs @@ -0,0 +1,70 @@ +use super::*; + +#[test] +fn sprite0_hit_does_not_trigger_at_rightmost_visible_pixel() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + + ppu.mask = 0x1E; // show background + sprites + left 8px for both + + // Background at y=1, x=255 (tile column 31, fine-x=7 => bit0). + ppu.vram[31] = 1; + mapper.chr[16 + 1] = 0x01; + + // Sprite 0 at x=255, y=1 (OAM y is y-1), row 0 col 0 => bit7. + ppu.oam[0] = 0; + ppu.oam[1] = 2; + ppu.oam[2] = 0x00; + ppu.oam[3] = 255; + mapper.chr[32] = 0x80; + + assert!(!ppu.sprite0_hit_at(&mapper, 1, 256)); +} + +#[test] +fn sprite0_hit_uses_scroll_event_at_current_x() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + ppu.begin_frame(); + ppu.mask = 0x1E; // show background + sprites + left 8px for both + + // Tile at x=1 is transparent, tile at x=2 has leftmost opaque pixel on row 1. + ppu.vram[1] = 1; + ppu.vram[2] = 2; + mapper.chr[(2 * 16) + 1] = 0x80; + + // Sprite 0 at x=8,y=1 with opaque pixel. + ppu.oam[0] = 0; + ppu.oam[1] = 3; + ppu.oam[2] = 0x00; + ppu.oam[3] = 8; + mapper.chr[3 * 16] = 0x80; + + // Mid-scanline split: starting at x=8, apply scroll_x=8. + ppu.cpu_write(5, 8, &mut mapper); + ppu.cpu_write(5, 0, &mut mapper); + ppu.note_scroll_register_write(1, 9); + + // dot=9 corresponds to x=8. + assert!(ppu.sprite0_hit_at(&mapper, 1, 9)); +} + +#[test] +fn detects_sprite_overflow_when_more_than_8_sprites_share_scanline() { + let mut ppu = Ppu::new(); + ppu.ctrl = 0x00; // 8x8 sprites + for i in 0..9usize { + ppu.oam[i * 4] = 9; // each sprite appears at y=10 + } + assert!(ppu.sprite_overflow_on_scanline(10)); +} + +#[test] +fn no_sprite_overflow_when_8_or_fewer_sprites_share_scanline() { + let mut ppu = Ppu::new(); + ppu.ctrl = 0x00; // 8x8 sprites + for i in 0..8usize { + ppu.oam[i * 4] = 19; // each sprite appears at y=20 + } + assert!(!ppu.sprite_overflow_on_scanline(20)); +} diff --git a/src/native_core/ppu/tests/state_restore.rs b/src/native_core/ppu/tests/state_restore.rs new file mode 100644 index 0000000..e6c2f23 --- /dev/null +++ b/src/native_core/ppu/tests/state_restore.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn load_state_masks_vram_and_temp_address_to_14bit() { + let mut ppu = Ppu::new(); + let mut mapper = StubMapper::new(Mirroring::Horizontal); + let mut raw = Vec::new(); + ppu.save_state(&mut raw); + + let off = 0x1000 + 0x20 + 0x100 + 5; // vram/palette/oam + ctrl/mask/status/oam_addr/write_latch + raw[off] = 0xFF; // vram_addr lo + raw[off + 1] = 0xFF; // vram_addr hi + raw[off + 2] = 0xFF; // temp_addr lo + raw[off + 3] = 0xFF; // temp_addr hi + + ppu.load_state(&raw).expect("state load"); + ppu.cpu_write(7, 0x2A, &mut mapper); // writes at masked $3FFF + ppu.cpu_write(7, 0x34, &mut mapper); // wraps to $0000 + + assert_eq!(mapper.ppu_read(0x0000), 0x34); + ppu.cpu_write(6, 0x3F, &mut mapper); + ppu.cpu_write(6, 0xFF, &mut mapper); + let v = ppu.cpu_read(7, &mapper); + assert_eq!(v & 0x3F, 0x2A); +} diff --git a/src/native_core/ppu/types.rs b/src/native_core/ppu/types.rs new file mode 100644 index 0000000..b7147d0 --- /dev/null +++ b/src/native_core/ppu/types.rs @@ -0,0 +1,51 @@ +pub(super) const VRAM_SIZE: usize = 0x1000; +pub(super) const PALETTE_SIZE: usize = 0x20; +pub(super) const OAM_SIZE: usize = 0x100; +pub(super) const VISIBLE_SCANLINES: usize = 240; + +#[derive(Clone, Copy)] +pub(super) struct ScrollEvent { + pub(super) scanline: u8, + pub(super) x_start: u8, + pub(super) scroll_x: u8, + pub(super) scroll_y: u8, + pub(super) base_nt: u8, +} + +pub struct Ppu { + pub(super) vram: [u8; VRAM_SIZE], + pub(super) palette_ram: [u8; PALETTE_SIZE], + pub(super) oam: [u8; OAM_SIZE], + pub(super) ctrl: u8, + pub(super) mask: u8, + pub(super) status: u8, + pub(super) oam_addr: u8, + pub(super) write_latch: bool, + pub(super) vram_addr: u16, + pub(super) temp_addr: u16, + pub(super) fine_x: u8, + pub(super) scroll_x: u8, + pub(super) scroll_y: u8, + pub(super) read_buffer: u8, + pub(super) io_latch: u8, + pub(super) scroll_events: Vec, + pub(super) frame_rgba: Vec, + pub(super) bg_shift_pattern_lo: u16, + pub(super) bg_shift_pattern_hi: u16, + pub(super) bg_shift_attr_lo: u16, + pub(super) bg_shift_attr_hi: u16, + pub(super) next_tile_id: u8, + pub(super) next_tile_attr: u8, + pub(super) next_tile_lsb: u8, + pub(super) next_tile_msb: u8, + pub(super) sprite_indices: [u8; 8], + pub(super) sprite_count: u8, + pub(super) next_sprite_indices: [u8; 8], + pub(super) next_sprite_count: u8, +} + +impl Default for Ppu { + fn default() -> Self { + Self::new() + } +} diff --git a/src/native_core/state_io.rs b/src/native_core/state_io.rs new file mode 100644 index 0000000..c6fb3d1 --- /dev/null +++ b/src/native_core/state_io.rs @@ -0,0 +1,30 @@ +pub(crate) fn take_exact<'a>( + data: &'a [u8], + cursor: &mut usize, + len: usize, + ctx: &str, +) -> Result<&'a [u8], String> { + let end = cursor + .checked_add(len) + .ok_or_else(|| format!("{ctx}: cursor overflow"))?; + if end > data.len() { + return Err(format!("{ctx}: payload is truncated")); + } + let out = &data[*cursor..end]; + *cursor = end; + Ok(out) +} + +pub(crate) fn take_u8(data: &[u8], cursor: &mut usize, ctx: &str) -> Result { + Ok(take_exact(data, cursor, 1, ctx)?[0]) +} + +pub(crate) fn take_u16(data: &[u8], cursor: &mut usize, ctx: &str) -> Result { + let raw = take_exact(data, cursor, 2, ctx)?; + Ok(u16::from_le_bytes([raw[0], raw[1]])) +} + +pub(crate) fn take_u32(data: &[u8], cursor: &mut usize, ctx: &str) -> Result { + let raw = take_exact(data, cursor, 4, ctx)?; + Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]])) +} diff --git a/src/native_core/test_support.rs b/src/native_core/test_support.rs new file mode 100644 index 0000000..e6215d6 --- /dev/null +++ b/src/native_core/test_support.rs @@ -0,0 +1,114 @@ +use crate::native_core::cpu::{Cpu6502, CpuBus}; +use crate::native_core::ines::{InesHeader, InesRom, Mirroring}; + +pub(crate) struct TestRamBus(pub [u8; 0x10000]); + +impl TestRamBus { + pub(crate) fn new() -> Self { + Self([0; 0x10000]) + } + + pub(crate) fn load(&mut self, start: u16, bytes: &[u8]) { + for (i, b) in bytes.iter().copied().enumerate() { + self.0[start as usize + i] = b; + } + } +} + +impl CpuBus for TestRamBus { + fn read(&mut self, addr: u16) -> u8 { + self.0[addr as usize] + } + + fn write(&mut self, addr: u16, value: u8) { + self.0[addr as usize] = value; + } +} + +pub(crate) fn cpu_setup_with_reset(prog: &[u8]) -> (Cpu6502, TestRamBus) { + let mut bus = TestRamBus::new(); + bus.load(0x8000, prog); + bus.0[0xFFFC] = 0x00; + bus.0[0xFFFD] = 0x80; + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + (cpu, bus) +} + +pub(crate) struct MapperRomBuilder { + mapper: u16, + submapper: u8, + prg_banks_16k: u16, + chr_banks_8k: u16, + mirroring: Mirroring, +} + +impl MapperRomBuilder { + pub(crate) fn new(mapper: u16) -> Self { + Self { + mapper, + submapper: 0, + prg_banks_16k: 1, + chr_banks_8k: 1, + mirroring: Mirroring::Horizontal, + } + } + + pub(crate) fn submapper(mut self, submapper: u8) -> Self { + self.submapper = submapper; + self + } + + pub(crate) fn prg_banks_16k(mut self, banks: u16) -> Self { + self.prg_banks_16k = banks; + self + } + + pub(crate) fn chr_banks_8k(mut self, banks: u16) -> Self { + self.chr_banks_8k = banks; + self + } + + pub(crate) fn mirroring(mut self, mirroring: Mirroring) -> Self { + self.mirroring = mirroring; + self + } + + pub(crate) fn build(self) -> InesRom { + let prg_rom_len = self.prg_banks_16k as usize * 16 * 1024; + let chr_len = if self.chr_banks_8k == 0 { + 8 * 1024 + } else { + self.chr_banks_8k as usize * 8 * 1024 + }; + let chr_data = if self.chr_banks_8k == 0 { + vec![0; chr_len] + } else { + (0..chr_len).map(|v| (v & 0xFF) as u8).collect() + }; + InesRom { + header: InesHeader { + mapper: self.mapper, + submapper: self.submapper, + is_nes2: false, + prg_rom_banks_16k: self.prg_banks_16k, + chr_rom_banks_8k: self.chr_banks_8k, + has_trainer: false, + has_battery: false, + mirroring: self.mirroring, + prg_ram_shift: 0, + prg_nvram_shift: 0, + chr_ram_shift: 0, + chr_nvram_shift: 0, + cpu_ppu_timing_mode: 0, + vs_hardware_type: 0, + vs_ppu_type: 0, + misc_rom_count: 0, + default_expansion_device: 0, + }, + prg_rom: (0..prg_rom_len).map(|v| (v & 0xFF) as u8).collect(), + chr_data, + chr_is_ram: self.chr_banks_8k == 0, + } + } +} diff --git a/src/runtime/adapters.rs b/src/runtime/adapters.rs new file mode 100644 index 0000000..1d3b8b6 --- /dev/null +++ b/src/runtime/adapters.rs @@ -0,0 +1,118 @@ +#[cfg(feature = "adapter-api")] +use nesemu_adapter_api::{ + AudioSink, BUTTONS_COUNT, ButtonState, InputSource, TimeSource, VideoSink, +}; + +#[cfg(feature = "adapter-api")] +use crate::runtime::{FrameClock, JoypadButtons}; + +#[cfg(feature = "adapter-api")] +fn to_joypad_buttons(buttons: ButtonState) -> JoypadButtons { + let mut out = [false; crate::runtime::JOYPAD_BUTTONS_COUNT]; + out.copy_from_slice(&buttons[..BUTTONS_COUNT]); + out +} + +#[cfg(feature = "adapter-api")] +pub struct InputAdapter { + inner: T, +} + +#[cfg(feature = "adapter-api")] +impl InputAdapter { + pub const fn new(inner: T) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +#[cfg(feature = "adapter-api")] +impl crate::runtime::InputProvider for InputAdapter +where + T: InputSource, +{ + fn poll_buttons(&mut self) -> JoypadButtons { + to_joypad_buttons(self.inner.poll_buttons()) + } +} + +#[cfg(feature = "adapter-api")] +pub struct VideoAdapter { + inner: T, +} + +#[cfg(feature = "adapter-api")] +impl VideoAdapter { + pub const fn new(inner: T) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +#[cfg(feature = "adapter-api")] +impl crate::runtime::VideoOutput for VideoAdapter +where + T: VideoSink, +{ + fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) { + self.inner.present_rgba(frame, width as u32, height as u32); + } +} + +#[cfg(feature = "adapter-api")] +pub struct AudioAdapter { + inner: T, +} + +#[cfg(feature = "adapter-api")] +impl AudioAdapter { + pub const fn new(inner: T) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +#[cfg(feature = "adapter-api")] +impl crate::runtime::AudioOutput for AudioAdapter +where + T: AudioSink, +{ + fn push_samples(&mut self, samples: &[f32]) { + self.inner.push_samples(samples); + } +} + +#[cfg(feature = "adapter-api")] +pub struct ClockAdapter { + inner: T, +} + +#[cfg(feature = "adapter-api")] +impl ClockAdapter { + pub const fn new(inner: T) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +#[cfg(feature = "adapter-api")] +impl FrameClock for ClockAdapter +where + T: TimeSource, +{ + fn wait_next_frame(&mut self) { + self.inner.wait_next_frame(); + } +} diff --git a/src/runtime/audio.rs b/src/runtime/audio.rs new file mode 100644 index 0000000..ca25cad --- /dev/null +++ b/src/runtime/audio.rs @@ -0,0 +1,39 @@ +use crate::runtime::VideoMode; + +#[derive(Debug)] +pub struct AudioMixer { + sample_rate: u32, + samples_per_cpu_cycle: f64, + sample_accumulator: f64, +} + +impl AudioMixer { + pub fn new(sample_rate: u32, mode: VideoMode) -> Self { + let cpu_hz = mode.cpu_hz(); + Self { + sample_rate, + samples_per_cpu_cycle: sample_rate as f64 / cpu_hz, + sample_accumulator: 0.0, + } + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn reset(&mut self) { + self.sample_accumulator = 0.0; + } + + pub fn push_cycles(&mut self, cpu_cycles: u8, apu_regs: &[u8; 0x20], out: &mut Vec) { + self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles); + let samples = self.sample_accumulator.floor() as usize; + self.sample_accumulator -= samples as f64; + + // Current core does not expose a final mixed PCM stream yet. + // Use DMC output level as a stable interim signal in [-1.0, 1.0]. + let dmc = apu_regs[0x11] & 0x7F; + let sample = (f32::from(dmc) / 63.5) - 1.0; + out.extend(std::iter::repeat_n(sample, samples)); + } +} diff --git a/src/runtime/constants.rs b/src/runtime/constants.rs new file mode 100644 index 0000000..6f5f059 --- /dev/null +++ b/src/runtime/constants.rs @@ -0,0 +1,6 @@ +pub const FRAME_WIDTH: usize = 256; +pub const FRAME_HEIGHT: usize = 240; +pub const FRAME_RGBA_BYTES: usize = FRAME_WIDTH * FRAME_HEIGHT * 4; +pub const SAVE_STATE_VERSION: u32 = 1; + +pub(crate) const SAVE_STATE_MAGIC: &[u8; 8] = b"NESRT001"; diff --git a/src/runtime/core.rs b/src/runtime/core.rs new file mode 100644 index 0000000..f076a93 --- /dev/null +++ b/src/runtime/core.rs @@ -0,0 +1,150 @@ +use crate::runtime::state::{load_runtime_state, save_runtime_state}; +use crate::runtime::{ + AudioMixer, FRAME_RGBA_BYTES, FramePacer, JoypadButtons, RuntimeError, VideoMode, +}; +use crate::{Cpu6502, InesRom, NativeBus, create_mapper, parse_rom}; + +pub struct NesRuntime { + cpu: Cpu6502, + bus: NativeBus, + video_mode: VideoMode, + frame_number: u64, + buttons: JoypadButtons, +} + +impl NesRuntime { + pub fn from_rom_bytes(bytes: &[u8]) -> Result { + let rom = parse_rom(bytes).map_err(RuntimeError::RomParse)?; + Self::from_rom(rom) + } + + pub fn from_rom(rom: InesRom) -> Result { + let video_mode = VideoMode::from_ines_timing_mode(rom.header.cpu_ppu_timing_mode); + let mapper = create_mapper(rom).map_err(RuntimeError::MapperInit)?; + let mut bus = NativeBus::new(mapper); + let mut cpu = Cpu6502::default(); + cpu.reset(&mut bus); + Ok(Self { + cpu, + bus, + video_mode, + frame_number: 0, + buttons: [false; crate::runtime::JOYPAD_BUTTONS_COUNT], + }) + } + + pub fn reset(&mut self) { + self.cpu.reset(&mut self.bus); + self.frame_number = 0; + } + + pub fn cpu(&self) -> &Cpu6502 { + &self.cpu + } + + pub fn cpu_mut(&mut self) -> &mut Cpu6502 { + &mut self.cpu + } + + pub fn bus(&self) -> &NativeBus { + &self.bus + } + + pub fn bus_mut(&mut self) -> &mut NativeBus { + &mut self.bus + } + + pub fn frame_number(&self) -> u64 { + self.frame_number + } + + pub fn video_mode(&self) -> VideoMode { + self.video_mode + } + + pub fn default_frame_pacer(&self) -> FramePacer { + FramePacer::new(self.video_mode) + } + + pub fn default_audio_mixer(&self, sample_rate: u32) -> AudioMixer { + AudioMixer::new(sample_rate, self.video_mode) + } + + pub fn buttons(&self) -> JoypadButtons { + self.buttons + } + + pub fn set_buttons(&mut self, buttons: JoypadButtons) { + self.buttons = buttons; + self.bus.set_joypad_buttons(buttons); + } + + pub fn step_instruction(&mut self) -> Result { + self.bus.set_joypad_buttons(self.buttons); + let cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?; + self.bus.clock_cpu(cycles); + Ok(cycles) + } + + pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> { + self.bus.begin_frame(); + while !self.bus.take_frame_complete() { + self.step_instruction()?; + } + self.frame_number = self.frame_number.saturating_add(1); + Ok(()) + } + + pub fn run_frame_paced(&mut self, pacer: &mut FramePacer) -> Result<(), RuntimeError> { + self.run_until_frame_complete()?; + pacer.wait_next_frame(); + Ok(()) + } + + pub fn run_until_frame_complete_with_audio( + &mut self, + mixer: &mut AudioMixer, + out_samples: &mut Vec, + ) -> Result<(), RuntimeError> { + self.bus.begin_frame(); + while !self.bus.take_frame_complete() { + let cycles = self.step_instruction()?; + mixer.push_cycles(cycles, self.bus.apu_registers(), out_samples); + } + self.frame_number = self.frame_number.saturating_add(1); + Ok(()) + } + + pub fn render_frame_rgba(&self, out_rgba: &mut [u8]) -> Result<(), RuntimeError> { + if out_rgba.len() < FRAME_RGBA_BYTES { + return Err(RuntimeError::BufferTooSmall { + expected: FRAME_RGBA_BYTES, + got: out_rgba.len(), + }); + } + self.bus + .render_frame(out_rgba, self.frame_number as u32, self.buttons); + Ok(()) + } + + pub fn frame_rgba(&self) -> Vec { + let mut out = vec![0; FRAME_RGBA_BYTES]; + self.bus + .render_frame(&mut out, self.frame_number as u32, self.buttons); + out + } + + pub fn save_state(&self) -> Vec { + save_runtime_state(self.frame_number, self.buttons, &self.cpu, &self.bus) + } + + pub fn load_state(&mut self, data: &[u8]) -> Result<(), RuntimeError> { + load_runtime_state( + data, + &mut self.frame_number, + &mut self.buttons, + &mut self.cpu, + &mut self.bus, + ) + } +} diff --git a/src/runtime/error.rs b/src/runtime/error.rs new file mode 100644 index 0000000..d46edcd --- /dev/null +++ b/src/runtime/error.rs @@ -0,0 +1,28 @@ +use crate::CpuError; +use core::fmt; + +#[derive(Debug)] +#[non_exhaustive] +pub enum RuntimeError { + RomParse(String), + MapperInit(String), + Cpu(CpuError), + BufferTooSmall { expected: usize, got: usize }, + InvalidState(String), +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RomParse(e) => write!(f, "ROM parse error: {e}"), + Self::MapperInit(e) => write!(f, "mapper init error: {e}"), + Self::Cpu(e) => write!(f, "CPU error: {e:?}"), + Self::BufferTooSmall { expected, got } => { + write!(f, "buffer too small: expected {expected} bytes, got {got}") + } + Self::InvalidState(e) => write!(f, "invalid runtime state: {e}"), + } + } +} + +impl std::error::Error for RuntimeError {} diff --git a/src/runtime/host/clock.rs b/src/runtime/host/clock.rs new file mode 100644 index 0000000..3eb2276 --- /dev/null +++ b/src/runtime/host/clock.rs @@ -0,0 +1,51 @@ +use crate::runtime::{FramePacer, NesRuntime}; + +pub trait FrameClock { + fn wait_next_frame(&mut self); +} + +impl FrameClock for Box +where + T: FrameClock + ?Sized, +{ + fn wait_next_frame(&mut self) { + (**self).wait_next_frame(); + } +} + +pub struct PacingClock { + pacer: FramePacer, +} + +impl PacingClock { + pub fn from_runtime(runtime: &NesRuntime) -> Self { + Self { + pacer: runtime.default_frame_pacer(), + } + } + + pub fn from_pacer(pacer: FramePacer) -> Self { + Self { pacer } + } + + pub fn pacer(&self) -> &FramePacer { + &self.pacer + } + + pub fn pacer_mut(&mut self) -> &mut FramePacer { + &mut self.pacer + } +} + +impl FrameClock for PacingClock { + fn wait_next_frame(&mut self) { + self.pacer.wait_next_frame(); + } +} + +#[derive(Default)] +pub struct NoopClock; + +impl FrameClock for NoopClock { + fn wait_next_frame(&mut self) {} +} diff --git a/src/runtime/host/config.rs b/src/runtime/host/config.rs new file mode 100644 index 0000000..7b7202a --- /dev/null +++ b/src/runtime/host/config.rs @@ -0,0 +1,24 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct HostConfig { + pub sample_rate: u32, + pub pacing: bool, +} + +impl HostConfig { + pub const fn new(sample_rate: u32, pacing: bool) -> Self { + Self { + sample_rate, + pacing, + } + } +} + +impl Default for HostConfig { + fn default() -> Self { + Self { + sample_rate: 48_000, + pacing: true, + } + } +} diff --git a/src/runtime/host/executor.rs b/src/runtime/host/executor.rs new file mode 100644 index 0000000..942afa3 --- /dev/null +++ b/src/runtime/host/executor.rs @@ -0,0 +1,63 @@ +use crate::runtime::{ + AudioMixer, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, NesRuntime, RuntimeError, +}; + +use super::io::{AudioOutput, InputProvider, VideoOutput}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct FrameExecution { + pub frame_number: u64, + pub audio_samples: usize, +} + +pub struct FrameExecutor { + mixer: AudioMixer, + frame_buffer: Vec, + audio_buffer: Vec, +} + +impl FrameExecutor { + pub fn from_runtime(runtime: &NesRuntime, sample_rate: u32) -> Self { + Self { + mixer: runtime.default_audio_mixer(sample_rate), + frame_buffer: vec![0; FRAME_RGBA_BYTES], + audio_buffer: Vec::new(), + } + } + + pub fn mixer(&self) -> &AudioMixer { + &self.mixer + } + + pub fn mixer_mut(&mut self) -> &mut AudioMixer { + &mut self.mixer + } + + pub fn execute_frame( + &mut self, + runtime: &mut NesRuntime, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + runtime.set_buttons(input.poll_buttons()); + self.audio_buffer.clear(); + + runtime.run_until_frame_complete_with_audio(&mut self.mixer, &mut self.audio_buffer)?; + runtime.render_frame_rgba(&mut self.frame_buffer)?; + + audio.push_samples(&self.audio_buffer); + video.present_rgba(&self.frame_buffer, FRAME_WIDTH, FRAME_HEIGHT); + + Ok(FrameExecution { + frame_number: runtime.frame_number(), + audio_samples: self.audio_buffer.len(), + }) + } +} diff --git a/src/runtime/host/io.rs b/src/runtime/host/io.rs new file mode 100644 index 0000000..4dcd356 --- /dev/null +++ b/src/runtime/host/io.rs @@ -0,0 +1,36 @@ +use crate::runtime::{JOYPAD_BUTTONS_COUNT, JoypadButtons}; + +pub trait InputProvider { + fn poll_buttons(&mut self) -> JoypadButtons; +} + +pub trait VideoOutput { + fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize); +} + +pub trait AudioOutput { + fn push_samples(&mut self, samples: &[f32]); +} + +#[derive(Default)] +pub struct NullInput; + +impl InputProvider for NullInput { + fn poll_buttons(&mut self) -> JoypadButtons { + [false; JOYPAD_BUTTONS_COUNT] + } +} + +#[derive(Default)] +pub struct NullVideo; + +impl VideoOutput for NullVideo { + fn present_rgba(&mut self, _frame: &[u8], _width: usize, _height: usize) {} +} + +#[derive(Default)] +pub struct NullAudio; + +impl AudioOutput for NullAudio { + fn push_samples(&mut self, _samples: &[f32]) {} +} diff --git a/src/runtime/host/loop_runner.rs b/src/runtime/host/loop_runner.rs new file mode 100644 index 0000000..cc8eb4b --- /dev/null +++ b/src/runtime/host/loop_runner.rs @@ -0,0 +1,154 @@ +use crate::runtime::{NesRuntime, RuntimeError}; + +use super::clock::{FrameClock, NoopClock, PacingClock}; +use super::config::HostConfig; +use super::executor::{FrameExecution, FrameExecutor}; +use super::io::{AudioOutput, InputProvider, VideoOutput}; + +pub struct RuntimeHostLoop { + runtime: NesRuntime, + executor: FrameExecutor, + clock: C, +} + +impl RuntimeHostLoop { + pub fn new(runtime: NesRuntime, sample_rate: u32) -> Self { + let executor = FrameExecutor::from_runtime(&runtime, sample_rate); + let clock = PacingClock::from_runtime(&runtime); + Self { + runtime, + executor, + clock, + } + } + + pub fn with_config( + runtime: NesRuntime, + config: HostConfig, + ) -> RuntimeHostLoop> { + let executor = FrameExecutor::from_runtime(&runtime, config.sample_rate); + let clock: Box = if config.pacing { + Box::new(PacingClock::from_runtime(&runtime)) + } else { + Box::new(NoopClock) + }; + RuntimeHostLoop { + runtime, + executor, + clock, + } + } +} + +impl RuntimeHostLoop +where + C: FrameClock, +{ + pub fn with_clock(runtime: NesRuntime, sample_rate: u32, clock: C) -> Self { + let executor = FrameExecutor::from_runtime(&runtime, sample_rate); + Self { + runtime, + executor, + clock, + } + } + + pub fn runtime(&self) -> &NesRuntime { + &self.runtime + } + + pub fn runtime_mut(&mut self) -> &mut NesRuntime { + &mut self.runtime + } + + pub fn into_runtime(self) -> NesRuntime { + self.runtime + } + + pub fn executor(&self) -> &FrameExecutor { + &self.executor + } + + pub fn executor_mut(&mut self) -> &mut FrameExecutor { + &mut self.executor + } + + pub fn clock(&self) -> &C { + &self.clock + } + + pub fn clock_mut(&mut self) -> &mut C { + &mut self.clock + } + + pub fn run_frame( + &mut self, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + let stats = self.run_frame_unpaced(input, video, audio)?; + self.clock.wait_next_frame(); + Ok(stats) + } + + pub fn run_frame_unpaced( + &mut self, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + self.executor + .execute_frame(&mut self.runtime, input, video, audio) + } + + pub fn run_frames( + &mut self, + frames: usize, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + let mut total_samples = 0usize; + for _ in 0..frames { + total_samples = + total_samples.saturating_add(self.run_frame(input, video, audio)?.audio_samples); + } + Ok(total_samples) + } + + pub fn run_frames_unpaced( + &mut self, + frames: usize, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + let mut total_samples = 0usize; + for _ in 0..frames { + total_samples = total_samples + .saturating_add(self.run_frame_unpaced(input, video, audio)?.audio_samples); + } + Ok(total_samples) + } +} diff --git a/src/runtime/host/mod.rs b/src/runtime/host/mod.rs new file mode 100644 index 0000000..909cfff --- /dev/null +++ b/src/runtime/host/mod.rs @@ -0,0 +1,13 @@ +mod clock; +mod config; +mod executor; +mod io; +mod loop_runner; +mod session; + +pub use clock::{FrameClock, NoopClock, PacingClock}; +pub use config::HostConfig; +pub use executor::{FrameExecution, FrameExecutor}; +pub use io::{AudioOutput, InputProvider, NullAudio, NullInput, NullVideo, VideoOutput}; +pub use loop_runner::RuntimeHostLoop; +pub use session::{ClientRuntime, EmulationState}; diff --git a/src/runtime/host/session.rs b/src/runtime/host/session.rs new file mode 100644 index 0000000..da049cb --- /dev/null +++ b/src/runtime/host/session.rs @@ -0,0 +1,110 @@ +use crate::runtime::RuntimeError; + +use super::clock::{FrameClock, PacingClock}; +use super::config::HostConfig; +use super::executor::FrameExecution; +use super::io::{AudioOutput, InputProvider, VideoOutput}; +use super::loop_runner::RuntimeHostLoop; +use crate::runtime::NesRuntime; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum EmulationState { + Running, + Paused, +} + +pub struct ClientRuntime { + host: RuntimeHostLoop, + state: EmulationState, +} + +impl ClientRuntime { + pub fn new(runtime: NesRuntime, sample_rate: u32) -> Self { + Self { + host: RuntimeHostLoop::new(runtime, sample_rate), + state: EmulationState::Running, + } + } + + pub fn with_config( + runtime: NesRuntime, + config: HostConfig, + ) -> ClientRuntime> { + ClientRuntime { + host: RuntimeHostLoop::with_config(runtime, config), + state: EmulationState::Running, + } + } +} + +impl ClientRuntime +where + C: FrameClock, +{ + pub fn with_host_loop(host: RuntimeHostLoop) -> Self { + Self { + host, + state: EmulationState::Running, + } + } + + pub fn state(&self) -> EmulationState { + self.state + } + + pub fn pause(&mut self) { + self.state = EmulationState::Paused; + } + + pub fn resume(&mut self) { + self.state = EmulationState::Running; + } + + pub fn set_state(&mut self, state: EmulationState) { + self.state = state; + } + + pub fn host(&self) -> &RuntimeHostLoop { + &self.host + } + + pub fn host_mut(&mut self) -> &mut RuntimeHostLoop { + &mut self.host + } + + pub fn into_host_loop(self) -> RuntimeHostLoop { + self.host + } + + pub fn tick( + &mut self, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result, RuntimeError> + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + if self.state == EmulationState::Paused { + return Ok(None); + } + self.host.run_frame(input, video, audio).map(Some) + } + + pub fn step_frame( + &mut self, + input: &mut I, + video: &mut V, + audio: &mut A, + ) -> Result + where + I: InputProvider, + V: VideoOutput, + A: AudioOutput, + { + self.host.run_frame_unpaced(input, video, audio) + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..929047e --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,43 @@ +#[cfg(feature = "adapter-api")] +mod adapters; +mod audio; +mod constants; +mod core; +mod error; +mod host; +mod state; +mod timing; +mod types; + +#[cfg(feature = "adapter-api")] +pub use adapters::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter}; +pub use audio::AudioMixer; +pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERSION}; +pub use core::NesRuntime; +pub use error::RuntimeError; +pub use host::{ + AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, FrameExecutor, + HostConfig, InputProvider, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, + RuntimeHostLoop, VideoOutput, +}; +pub use timing::{FramePacer, VideoMode}; +pub use types::{ + JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons, button_pressed, + set_button_pressed, +}; + +pub mod prelude { + #[cfg(feature = "adapter-api")] + pub use crate::runtime::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter}; + pub use crate::runtime::{ + AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, FrameExecutor, + HostConfig, InputProvider, JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, + JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, + RuntimeError, RuntimeHostLoop, VideoOutput, button_pressed, set_button_pressed, + }; +} + +pub(crate) use constants::SAVE_STATE_MAGIC; + +#[cfg(test)] +mod tests; diff --git a/src/runtime/state.rs b/src/runtime/state.rs new file mode 100644 index 0000000..7a32f7a --- /dev/null +++ b/src/runtime/state.rs @@ -0,0 +1,151 @@ +use crate::runtime::{JoypadButtons, RuntimeError, SAVE_STATE_MAGIC, SAVE_STATE_VERSION}; +use crate::{Cpu6502, NativeBus}; + +const CPU_STATE_BYTES: usize = 12; + +pub(crate) fn save_runtime_state( + frame_number: u64, + buttons: JoypadButtons, + cpu: &Cpu6502, + bus: &NativeBus, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(SAVE_STATE_MAGIC); + out.extend_from_slice(&SAVE_STATE_VERSION.to_le_bytes()); + out.extend_from_slice(&frame_number.to_le_bytes()); + out.push(buttons_to_bits(buttons)); + out.extend_from_slice(&cpu_to_bytes(cpu)); + + let mut bus_state = Vec::new(); + bus.save_state(&mut bus_state); + out.extend_from_slice(&(bus_state.len() as u32).to_le_bytes()); + out.extend_from_slice(&bus_state); + out +} + +pub(crate) fn load_runtime_state( + data: &[u8], + frame_number: &mut u64, + buttons: &mut JoypadButtons, + cpu: &mut Cpu6502, + bus: &mut NativeBus, +) -> Result<(), RuntimeError> { + let mut cursor = 0usize; + let magic = take_exact(data, &mut cursor, SAVE_STATE_MAGIC.len())?; + if magic != SAVE_STATE_MAGIC { + return Err(RuntimeError::InvalidState( + "unexpected save-state magic".to_string(), + )); + } + + let version = take_u32(data, &mut cursor)?; + if version != SAVE_STATE_VERSION { + return Err(RuntimeError::InvalidState(format!( + "unsupported save-state version: {version}" + ))); + } + + *frame_number = take_u64(data, &mut cursor)?; + *buttons = bits_to_buttons(take_u8(data, &mut cursor)?); + *cpu = cpu_from_bytes(take_exact(data, &mut cursor, CPU_STATE_BYTES)?)?; + + let bus_len = take_u32(data, &mut cursor)? as usize; + let bus_state = take_exact(data, &mut cursor, bus_len)?; + bus.load_state(bus_state) + .map_err(RuntimeError::InvalidState)?; + bus.set_joypad_buttons(*buttons); + + if cursor != data.len() { + return Err(RuntimeError::InvalidState( + "trailing bytes in runtime save-state payload".to_string(), + )); + } + Ok(()) +} + +fn buttons_to_bits(buttons: JoypadButtons) -> u8 { + buttons.iter().enumerate().fold( + 0u8, + |acc, (idx, pressed)| { + if *pressed { acc | (1 << idx) } else { acc } + }, + ) +} + +fn bits_to_buttons(bits: u8) -> JoypadButtons { + let mut out = [false; crate::runtime::JOYPAD_BUTTONS_COUNT]; + for (idx, item) in out.iter_mut().enumerate() { + *item = (bits & (1 << idx)) != 0; + } + out +} + +fn cpu_to_bytes(cpu: &Cpu6502) -> [u8; CPU_STATE_BYTES] { + [ + cpu.a, + cpu.x, + cpu.y, + cpu.sp, + cpu.pc as u8, + (cpu.pc >> 8) as u8, + cpu.p, + u8::from(cpu.halted), + u8::from(cpu.irq_delay), + u8::from(cpu.pending_nmi), + u8::from(cpu.pending_irq), + 0, + ] +} + +fn cpu_from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != CPU_STATE_BYTES { + return Err(RuntimeError::InvalidState(format!( + "invalid cpu state size: {}", + bytes.len() + ))); + } + Ok(Cpu6502 { + a: bytes[0], + x: bytes[1], + y: bytes[2], + sp: bytes[3], + pc: u16::from_le_bytes([bytes[4], bytes[5]]), + p: bytes[6], + halted: bytes[7] != 0, + irq_delay: bytes[8] != 0, + pending_nmi: bytes[9] != 0, + pending_irq: bytes[10] != 0, + }) +} + +fn take_exact<'a>( + data: &'a [u8], + cursor: &mut usize, + len: usize, +) -> Result<&'a [u8], RuntimeError> { + let end = cursor + .checked_add(len) + .ok_or_else(|| RuntimeError::InvalidState("payload overflow".to_string()))?; + if end > data.len() { + return Err(RuntimeError::InvalidState("payload too short".to_string())); + } + let out = &data[*cursor..end]; + *cursor = end; + Ok(out) +} + +fn take_u8(data: &[u8], cursor: &mut usize) -> Result { + Ok(take_exact(data, cursor, 1)?[0]) +} + +fn take_u32(data: &[u8], cursor: &mut usize) -> Result { + let bytes = take_exact(data, cursor, 4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +fn take_u64(data: &[u8], cursor: &mut usize) -> Result { + let bytes = take_exact(data, cursor, 8)?; + Ok(u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) +} diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs new file mode 100644 index 0000000..050274b --- /dev/null +++ b/src/runtime/tests.rs @@ -0,0 +1,262 @@ +use crate::runtime::{ + AudioOutput, ClientRuntime, EmulationState, FRAME_RGBA_BYTES, HostConfig, InputProvider, + JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, NesRuntime, NoopClock, + RuntimeHostLoop, VideoMode, VideoOutput, button_pressed, set_button_pressed, +}; +use std::cell::Cell; +use std::rc::Rc; + +fn nrom_test_rom() -> Vec { + let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 1; // 16 KiB PRG + rom[5] = 1; // 8 KiB CHR + + let prg_offset = 16; + let reset_vec = prg_offset + 0x3FFC; + rom[reset_vec] = 0x00; + rom[reset_vec + 1] = 0x80; + + // 0x8000: NOP; JMP $8000 + rom[prg_offset] = 0xEA; + rom[prg_offset + 1] = 0x4C; + rom[prg_offset + 2] = 0x00; + rom[prg_offset + 3] = 0x80; + rom +} + +#[test] +fn runtime_runs_frame_and_renders() { + let rom = nrom_test_rom(); + let mut rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + rt.run_until_frame_complete().expect("frame should run"); + let frame = rt.frame_rgba(); + assert_eq!(frame.len(), FRAME_RGBA_BYTES); + assert_eq!(rt.frame_number(), 1); +} + +#[test] +fn runtime_state_roundtrip() { + let rom = nrom_test_rom(); + let mut rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + rt.set_buttons([true, false, true, false, true, false, true, false]); + rt.step_instruction().expect("step"); + let state = rt.save_state(); + rt.run_until_frame_complete().expect("run"); + + rt.load_state(&state).expect("load state"); + assert_eq!( + rt.buttons(), + [true, false, true, false, true, false, true, false] + ); +} + +#[test] +fn timing_mode_defaults_to_ntsc_for_ines1() { + let rom = nrom_test_rom(); + let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + assert_eq!(rt.video_mode(), VideoMode::Ntsc); +} + +#[test] +fn joypad_button_helpers_match_public_order() { + let mut buttons = [false; JOYPAD_BUTTONS_COUNT]; + for &button in &JOYPAD_BUTTON_ORDER { + assert!(!button_pressed(&buttons, button)); + set_button_pressed(&mut buttons, button, true); + assert!(button_pressed(&buttons, button)); + } + + assert!(button_pressed(&buttons, JoypadButton::A)); + assert!(button_pressed(&buttons, JoypadButton::Select)); +} + +#[test] +fn audio_mixer_generates_samples() { + let rom = nrom_test_rom(); + let mut rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mut mixer = rt.default_audio_mixer(48_000); + let mut out = Vec::new(); + rt.run_until_frame_complete_with_audio(&mut mixer, &mut out) + .expect("run frame with audio"); + assert!(!out.is_empty()); + assert_eq!(mixer.sample_rate(), 48_000); +} + +struct FixedInput; + +impl InputProvider for FixedInput { + fn poll_buttons(&mut self) -> [bool; JOYPAD_BUTTONS_COUNT] { + [true, false, false, false, false, false, false, false] + } +} + +#[derive(Default)] +struct MockVideo { + frames: usize, + last_len: usize, +} + +impl VideoOutput for MockVideo { + fn present_rgba(&mut self, frame: &[u8], _width: usize, _height: usize) { + self.frames += 1; + self.last_len = frame.len(); + } +} + +#[derive(Default)] +struct MockAudio { + total_samples: usize, +} + +impl AudioOutput for MockAudio { + fn push_samples(&mut self, samples: &[f32]) { + self.total_samples += samples.len(); + } +} + +#[derive(Clone)] +struct CountingClock { + waits: Rc>, +} + +impl CountingClock { + fn new() -> Self { + Self { + waits: Rc::new(Cell::new(0)), + } + } + + fn waits(&self) -> usize { + self.waits.get() + } +} + +impl crate::runtime::FrameClock for CountingClock { + fn wait_next_frame(&mut self) { + self.waits.set(self.waits.get().saturating_add(1)); + } +} + +#[test] +fn host_loop_runs_single_frame() { + let rom = nrom_test_rom(); + let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mut host = RuntimeHostLoop::with_clock(rt, 48_000, NoopClock); + + let mut input = FixedInput; + let mut video = MockVideo::default(); + let mut audio = MockAudio::default(); + + let stats = host + .run_frame(&mut input, &mut video, &mut audio) + .expect("host frame should run"); + + assert_eq!(stats.frame_number, 1); + assert!(stats.audio_samples > 0); + assert_eq!(host.runtime().frame_number(), 1); + assert_eq!(video.frames, 1); + assert_eq!(video.last_len, FRAME_RGBA_BYTES); + assert!(audio.total_samples > 0); +} + +#[test] +fn host_loop_runs_multiple_frames() { + let rom = nrom_test_rom(); + let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let config = HostConfig::new(48_000, false); + let mut host = RuntimeHostLoop::with_config(rt, config); + + let mut input = FixedInput; + let mut video = MockVideo::default(); + let mut audio = MockAudio::default(); + + let total_samples = host + .run_frames(3, &mut input, &mut video, &mut audio) + .expect("host frames should run"); + + assert_eq!(host.runtime().frame_number(), 3); + assert_eq!(video.frames, 3); + assert!(audio.total_samples > 0); + assert_eq!(total_samples, audio.total_samples); +} + +#[test] +fn client_runtime_respects_pause_and_step() { + let rom = nrom_test_rom(); + let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let host = RuntimeHostLoop::with_clock(rt, 48_000, NoopClock); + let mut client = ClientRuntime::with_host_loop(host); + + let mut input = FixedInput; + let mut video = MockVideo::default(); + let mut audio = MockAudio::default(); + + client.pause(); + assert_eq!(client.state(), EmulationState::Paused); + + let skipped = client + .tick(&mut input, &mut video, &mut audio) + .expect("paused tick should succeed"); + assert!(skipped.is_none()); + assert_eq!(client.host().runtime().frame_number(), 0); + + let step_stats = client + .step_frame(&mut input, &mut video, &mut audio) + .expect("manual step should run"); + assert_eq!(step_stats.frame_number, 1); + assert_eq!(client.host().runtime().frame_number(), 1); + + client.resume(); + let tick_stats = client + .tick(&mut input, &mut video, &mut audio) + .expect("running tick should succeed"); + assert_eq!(tick_stats.expect("must run").frame_number, 2); +} + +#[test] +fn run_frame_unpaced_does_not_call_clock() { + let rom = nrom_test_rom(); + let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let clock = CountingClock::new(); + let clock_probe = clock.clone(); + let mut host = RuntimeHostLoop::with_clock(rt, 48_000, clock); + + let mut input = FixedInput; + let mut video = MockVideo::default(); + let mut audio = MockAudio::default(); + + host.run_frame_unpaced(&mut input, &mut video, &mut audio) + .expect("frame should run"); + assert_eq!(clock_probe.waits(), 0); + + host.run_frame(&mut input, &mut video, &mut audio) + .expect("paced frame should run"); + assert_eq!(clock_probe.waits(), 1); +} + +#[test] +fn client_step_frame_is_unpaced() { + let rom = nrom_test_rom(); + let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let clock = CountingClock::new(); + let clock_probe = clock.clone(); + let host = RuntimeHostLoop::with_clock(rt, 48_000, clock); + let mut client = ClientRuntime::with_host_loop(host); + + let mut input = FixedInput; + let mut video = MockVideo::default(); + let mut audio = MockAudio::default(); + + client.pause(); + client + .step_frame(&mut input, &mut video, &mut audio) + .expect("manual step should run"); + assert_eq!(clock_probe.waits(), 0); + + client.resume(); + client + .tick(&mut input, &mut video, &mut audio) + .expect("running tick should run"); + assert_eq!(clock_probe.waits(), 1); +} diff --git a/src/runtime/timing.rs b/src/runtime/timing.rs new file mode 100644 index 0000000..f988af7 --- /dev/null +++ b/src/runtime/timing.rs @@ -0,0 +1,83 @@ +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum VideoMode { + Ntsc, + Pal, + Dendy, +} + +impl VideoMode { + pub fn from_ines_timing_mode(mode: u8) -> Self { + match mode { + 1 => Self::Pal, + 3 => Self::Dendy, + _ => Self::Ntsc, + } + } + + pub fn cpu_hz(self) -> f64 { + match self { + Self::Ntsc => 1_789_773.0, + Self::Pal => 1_662_607.0, + Self::Dendy => 1_773_448.0, + } + } + + pub fn frame_hz(self) -> f64 { + match self { + Self::Ntsc => 60.098_8, + Self::Pal => 50.007_0, + Self::Dendy => 50.0, + } + } + + pub fn frame_duration(self) -> Duration { + Duration::from_secs_f64(1.0 / self.frame_hz()) + } +} + +#[derive(Debug)] +pub struct FramePacer { + frame_duration: Duration, + next_deadline: Option, +} + +impl FramePacer { + pub fn new(mode: VideoMode) -> Self { + Self::with_frame_duration(mode.frame_duration()) + } + + pub fn with_frame_duration(frame_duration: Duration) -> Self { + Self { + frame_duration, + next_deadline: None, + } + } + + pub fn reset(&mut self) { + self.next_deadline = None; + } + + pub fn wait_next_frame(&mut self) { + let now = Instant::now(); + match self.next_deadline { + None => { + self.next_deadline = Some(now + self.frame_duration); + } + Some(deadline) => { + if now < deadline { + std::thread::sleep(deadline - now); + self.next_deadline = Some(deadline + self.frame_duration); + } else { + let frame_ns = self.frame_duration.as_nanos(); + let late_ns = now.duration_since(deadline).as_nanos(); + let missed = (late_ns / frame_ns) + 1; + self.next_deadline = + Some(deadline + self.frame_duration.mul_f64(missed as f64 + 1.0)); + } + } + } + } +} diff --git a/src/runtime/types.rs b/src/runtime/types.rs new file mode 100644 index 0000000..ceeb429 --- /dev/null +++ b/src/runtime/types.rs @@ -0,0 +1,49 @@ +pub const JOYPAD_BUTTONS_COUNT: usize = 8; + +pub type JoypadButtons = [bool; JOYPAD_BUTTONS_COUNT]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoypadButton { + Up, + Down, + Left, + Right, + A, + B, + Start, + Select, +} + +impl JoypadButton { + pub const fn index(self) -> usize { + match self { + Self::Up => 0, + Self::Down => 1, + Self::Left => 2, + Self::Right => 3, + Self::A => 4, + Self::B => 5, + Self::Start => 6, + Self::Select => 7, + } + } +} + +pub const JOYPAD_BUTTON_ORDER: [JoypadButton; JOYPAD_BUTTONS_COUNT] = [ + JoypadButton::Up, + JoypadButton::Down, + JoypadButton::Left, + JoypadButton::Right, + JoypadButton::A, + JoypadButton::B, + JoypadButton::Start, + JoypadButton::Select, +]; + +pub fn set_button_pressed(buttons: &mut JoypadButtons, button: JoypadButton, pressed: bool) { + buttons[button.index()] = pressed; +} + +pub fn button_pressed(buttons: &JoypadButtons, button: JoypadButton) -> bool { + buttons[button.index()] +} diff --git a/tests/public_api.rs b/tests/public_api.rs new file mode 100644 index 0000000..b59b2db --- /dev/null +++ b/tests/public_api.rs @@ -0,0 +1,227 @@ +use nesemu::prelude::*; +use nesemu::{ + AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NullAudio, NullInput, NullVideo, + RuntimeError, VideoOutput, +}; + +#[derive(Clone, Copy)] +struct Fnv1a64 { + state: u64, +} + +impl Fnv1a64 { + const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + + const fn new() -> Self { + Self { + state: Self::OFFSET_BASIS, + } + } + + fn update(&mut self, bytes: &[u8]) { + for &b in bytes { + self.state ^= u64::from(b); + self.state = self.state.wrapping_mul(Self::PRIME); + } + } + + const fn finish(self) -> u64 { + self.state + } +} + +fn nrom_test_rom() -> Vec { + let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 1; + rom[5] = 1; + + let prg_offset = 16; + let reset_vec = prg_offset + 0x3FFC; + rom[reset_vec] = 0x00; + rom[reset_vec + 1] = 0x80; + + rom[prg_offset] = 0xEA; + rom[prg_offset + 1] = 0x4C; + rom[prg_offset + 2] = 0x00; + rom[prg_offset + 3] = 0x80; + rom +} + +struct FixedInput; + +impl InputProvider for FixedInput { + fn poll_buttons(&mut self) -> [bool; JOYPAD_BUTTONS_COUNT] { + [true, false, false, false, false, false, false, false] + } +} + +#[derive(Default)] +struct CountingVideo { + frames: usize, + last_hash: u64, +} + +impl VideoOutput for CountingVideo { + fn present_rgba(&mut self, frame: &[u8], _width: usize, _height: usize) { + self.frames = self.frames.saturating_add(1); + let mut hasher = Fnv1a64::new(); + hasher.update(frame); + self.last_hash = hasher.finish(); + } +} + +struct CountingAudio { + samples: usize, + hash: u64, +} + +impl Default for CountingAudio { + fn default() -> Self { + Self { + samples: 0, + hash: Fnv1a64::new().finish(), + } + } +} + +impl AudioOutput for CountingAudio { + fn push_samples(&mut self, samples: &[f32]) { + self.samples = self.samples.saturating_add(samples.len()); + let mut hasher = Fnv1a64 { state: self.hash }; + for sample in samples { + hasher.update(&sample.to_le_bytes()); + } + self.hash = hasher.finish(); + } +} + +#[test] +fn public_api_load_run_input_frame_flow() { + let rom = nrom_test_rom(); + let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false)); + + let mut input = FixedInput; + let mut video = CountingVideo::default(); + let mut audio = CountingAudio::default(); + + let first = host + .run_frame_unpaced(&mut input, &mut video, &mut audio) + .expect("frame 1"); + let second = host + .run_frame_unpaced(&mut input, &mut video, &mut audio) + .expect("frame 2"); + + assert_eq!(first.frame_number, 1); + assert_eq!(second.frame_number, 2); + assert_eq!(video.frames, 2); + assert!(audio.samples > 0); +} + +#[test] +fn public_api_invalid_state_is_reported() { + let rom = nrom_test_rom(); + let mut runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + runtime.run_until_frame_complete().expect("frame"); + + let mut state = runtime.save_state(); + state[0] ^= 0xFF; + + let err = runtime + .load_state(&state) + .expect_err("must reject bad state"); + assert!(matches!(err, RuntimeError::InvalidState(_))); +} + +#[test] +fn public_api_headless_client_loop_smoke_1000_frames() { + let rom = nrom_test_rom(); + let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false)); + + let total_samples = host + .run_frames_unpaced(1_000, &mut NullInput, &mut NullVideo, &mut NullAudio) + .expect("run frames"); + + assert_eq!(host.runtime().frame_number(), 1_000); + assert!(total_samples > 0); +} + +#[test] +fn public_api_client_pause_resume_contract() { + let rom = nrom_test_rom(); + let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mut client = ClientRuntime::with_config(runtime, HostConfig::new(48_000, false)); + + let mut input = FixedInput; + let mut video = CountingVideo::default(); + let mut audio = CountingAudio::default(); + + client.pause(); + let skipped = client + .tick(&mut input, &mut video, &mut audio) + .expect("tick"); + assert!(skipped.is_none()); + + let stepped = client + .step_frame(&mut input, &mut video, &mut audio) + .expect("step"); + assert_eq!(stepped.frame_number, 1); + + client.resume(); + let ticked = client + .tick(&mut input, &mut video, &mut audio) + .expect("tick") + .expect("running tick"); + assert_eq!(ticked.frame_number, 2); +} + +#[test] +fn public_api_audio_timing_within_expected_drift() { + let rom = nrom_test_rom(); + let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mode = runtime.video_mode(); + let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false)); + + let total_samples = host + .run_frames_unpaced(1_200, &mut NullInput, &mut NullVideo, &mut NullAudio) + .expect("run frames"); + + let expected = ((host.runtime().frame_number() as f64) * 48_000.0 / mode.frame_hz()).round(); + let drift_pct = ((total_samples as f64 - expected).abs() / expected) * 100.0; + + assert!( + drift_pct <= 2.5, + "audio drift too high: {drift_pct:.3}% (samples={total_samples}, expected={expected:.0})" + ); +} + +#[test] +fn public_api_regression_hashes_for_reference_rom() { + let rom = nrom_test_rom(); + let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false)); + + let mut input = FixedInput; + let mut video = CountingVideo::default(); + let mut audio = CountingAudio::default(); + + host.run_frames_unpaced(120, &mut input, &mut video, &mut audio) + .expect("run frames"); + + let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64; + let expected_audio_hash = 0xa075_8dd6_adea_e775_u64; + + assert_eq!( + video.last_hash, expected_frame_hash, + "update expected frame hash to 0x{:016x}", + video.last_hash + ); + assert_eq!( + audio.hash, expected_audio_hash, + "update expected audio hash to 0x{:016x}", + audio.hash + ); +}