commit 623e4aeda70e7bec861cae1286dba54271b71443 Author: Ross Andrews Date: Sat Oct 31 18:12:15 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5dbc09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Build output +/target/ + +# The game reads its data from the original ROM at runtime; never commit it. +*.gb + +# Headless test output +/out/ +*.wav + +# Editors / OS +.DS_Store +.idea/ +.vscode/ +*.swp +*~ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1735734 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2724 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.5", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "web-sys", + "windows", + "windows-core", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.5", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.5", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opus" +version = "0.1.0" +dependencies = [ + "cpal", + "pixels", + "winit", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pixels" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27af78f16789b973b8b340fe3f68cc2ec9f5e6f4aea64e4d794d5833ac9ca52e" +dependencies = [ + "bytemuck", + "pollster", + "raw-window-handle", + "thiserror 2.0.21", + "ultraviolet", + "wgpu", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "ultraviolet" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea519dad475ee0446b8172793c3c327e4fc81dafdaf05aaac510e630000b6296" +dependencies = [ + "wide", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.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-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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 = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a4166c1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "opus" +version = "0.1.0" +edition = "2021" +description = "Daedalian Opus, ported from its Game Boy disassembly to Rust" + +[dependencies] +winit = "0.30" +pixels = "0.17" +cpal = "0.18" + +[profile.dev] +opt-level = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..14e7307 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Daedalian Opus, ported to Rust + +A rewrite of the Game Boy game as ordinary Rust, translated by hand from the +disassembly in `../disassembly/`. Unlike `../rust/` (a static recompilation that +still runs the original instructions against emulated registers and memory), +this is the game itself: the screens, the puzzle rules, the map, the story and +the music sequencer are Rust functions over named state, so they can be read +and changed. + +``` +cargo run --release # looks for "Daedalian Opus (USA).gb" here or in .. +cargo run --release -- --rom PATH +``` + +Controls: arrows = D-pad, Z = A, X = B, Enter = Start, Right Shift / Backspace = Select, Esc = quit. + +## What comes from where + +The program contains no game content. At startup `rom.rs` parses the ROM +into typed data: levels (`Level`: starting pieces, board layout, cursor +start, music, pieces needed, which piece drops on the map), piece shapes, +screen layouts (tilemap draw scripts), story text, passwords, tile graphics +and the music byte code. Small numbers that are really code (coordinates, +tile numbers for digits, timings) live in the Rust source as named constants. + +## Layout + +| file | what | +|---|---| +| `src/game/mod.rs` | `Game` state, the top-level flow (title → story → map → puzzle → ...), the per-frame tasks, the clear-time clock | +| `src/game/puzzle.rs` | the puzzle: picking up, moving, rotating, flipping and placing pieces; the win check; reset; the level-clear screen | +| `src/game/map.rs` | the map between levels: walking, castle doors, the bridge, the password bird, the piece that drops from the sky | +| `src/game/menus.rs` | title, how to play, password entry, puzzle list | +| `src/game/story.rs` | story screens and the ending | +| `src/sound.rs` | the game's sound driver: an 8-channel byte-code sequencer (format documented at the top) | +| `src/apu.rs` | the Game Boy sound chip, as the instrument the driver plays | +| `src/video.rs` | tile graphics, two background maps, 40 sprites, palettes, and the renderer | +| `src/rom.rs` | ROM loading and parsing | +| `src/input.rs` | buttons, with the game's d-pad auto-repeat | +| `src/main.rs` | window (`winit` + `pixels`), audio (`cpal`), frame pacing | +| `src/headless.rs` | scripted runs for testing | + +The game code is written the way the original is structured: straight-line +code that calls `wait_frame()` whenever it needs the next frame (to animate, +or to wait for a button). It runs on its own thread; `wait_frame` sends the +finished picture and sound to the host and blocks until the host sends the +next frame's buttons. + +The display model (tiles, maps, sprites) is kept because it's what the art is +made of, so the game still draws by placing tiles and sprites. Map addresses +like `0x9800` remain the way cells are named, since the screen layout scripts +in the ROM use them. + +## Faithfulness + +It's checked against the recompiled build in `../rust/` with the headless +runner. Same inputs give the same screens (title, every menu, story pages, +map walks, levels 1-3 and 36 solved, the ending), and the sound driver's chip +register writes match frame for frame. Known differences: + +- Loading a screen takes no time here, where the original spent a few frames + copying graphics with the display off, so the port runs a few frames ahead. +- The original queued tile updates to the next vblank; here they happen + immediately, so some changes appear one frame sooner. +- The original's unused "resume music" sound (id $96) points at garbage; it + just resumes. + +Original quirks are kept on purpose and marked `(sic)` in the source. For +example, undoing a Select flip that doesn't fit uses Start's rule. + +## Headless testing + +``` +cargo run --release -- --headless OUT_DIR 120 start:2 90 shot:title probe goto:44:54 +``` + +Tokens: `N` run N frames; `BUTTON:N` hold a button for N frames (up down left +right a b start select); `shot:NAME` save a PNG; `probe` print the level and +cursor; `goto:YY:XX` steer the puzzle cursor (hex sprite coordinates). Audio +goes to `OUT_DIR/audio.wav`. diff --git a/src/apu.rs b/src/apu.rs new file mode 100644 index 0000000..5630774 --- /dev/null +++ b/src/apu.rs @@ -0,0 +1,388 @@ +//! The Game Boy sound chip: two pulse channels (one with sweep), the wave +//! channel and the noise channel, mixed to stereo f32 samples. +//! +//! This is the instrument the game's music was written for; the game's own +//! sound driver (`sound.rs`) plays it by setting its registers (NR10-NR52 and +//! wave RAM, addressed $FF10-$FF3F as on the hardware) once per frame. + +/// Clock the chip's timing is expressed in (the Game Boy CPU clock). +pub const CLOCK_HZ: u64 = 4_194_304; +const CPU_HZ: u64 = CLOCK_HZ; + +pub const NR10: u16 = 0xFF10; +pub const NR11: u16 = 0xFF11; +pub const NR12: u16 = 0xFF12; +pub const NR13: u16 = 0xFF13; +pub const NR14: u16 = 0xFF14; +pub const NR21: u16 = 0xFF16; +pub const NR22: u16 = 0xFF17; +pub const NR23: u16 = 0xFF18; +pub const NR24: u16 = 0xFF19; +pub const NR30: u16 = 0xFF1A; +pub const NR32: u16 = 0xFF1C; +pub const NR33: u16 = 0xFF1D; +pub const NR34: u16 = 0xFF1E; +pub const NR42: u16 = 0xFF21; +pub const NR43: u16 = 0xFF22; +pub const NR44: u16 = 0xFF23; +pub const NR50: u16 = 0xFF24; +pub const NR51: u16 = 0xFF25; +pub const NR52: u16 = 0xFF26; +pub const WAVE_RAM: u16 = 0xFF30; + +const DUTY: [[u8; 8]; 4] = [ + [0, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 0], +]; + +#[derive(Default)] +struct Envelope { + volume: u8, + increase: bool, + period: u8, + timer: u8, +} + +impl Envelope { + fn load(&mut self, reg: u8) { + self.volume = reg >> 4; + self.increase = reg & 0x08 != 0; + self.period = reg & 0x07; + self.timer = self.period; + } + fn clock(&mut self) { + if self.period == 0 { + return; + } + if self.timer > 1 { + self.timer -= 1; + return; + } + self.timer = self.period; + if self.increase && self.volume < 15 { + self.volume += 1; + } else if !self.increase && self.volume > 0 { + self.volume -= 1; + } + } +} + +#[derive(Default)] +struct Pulse { + on: bool, + length: u16, + length_enable: bool, + env: Envelope, + freq: u16, + phase: f32, + // sweep (channel 1 only) + sweep_timer: u8, + sweep_enabled: bool, + shadow_freq: u16, +} + +#[derive(Default)] +struct Wave { + on: bool, + length: u16, + length_enable: bool, + freq: u16, + phase: f32, +} + +#[derive(Default)] +struct Noise { + on: bool, + length: u16, + length_enable: bool, + env: Envelope, + lfsr: u16, + acc: f32, +} + +pub struct Apu { + regs: [u8; 0x30], // $FF10-$FF3F + sq1: Pulse, + sq2: Pulse, + wave: Wave, + noise: Noise, + sample_rate: u32, + /// clock cycles synthesized so far + now: u64, + samples_made: u64, + last_cycle: u64, + seq_cycles: u64, + seq_step: u8, + hp_l: (f32, f32), + hp_r: (f32, f32), + /// interleaved stereo samples waiting to be played + pub out: Vec, +} + +impl Apu { + pub fn new(sample_rate: u32) -> Self { + let mut a = Apu { + regs: [0; 0x30], + sq1: Pulse::default(), + sq2: Pulse::default(), + wave: Wave::default(), + noise: Noise::default(), + sample_rate, + now: 0, + samples_made: 0, + last_cycle: 0, + seq_cycles: 0, + seq_step: 0, + hp_l: (0.0, 0.0), + hp_r: (0.0, 0.0), + out: Vec::new(), + }; + a.regs[0x16] = 0xF1; // NR52: on + a.regs[0x14] = 0x77; // NR50 + a.regs[0x15] = 0xF3; // NR51 + a + } + + fn reg(&self, addr: u16) -> u8 { + self.regs[(addr - 0xFF10) as usize] + } + + pub fn read(&self, addr: u16) -> u8 { + if addr == 0xFF26 { + return (self.reg(0xFF26) & 0x80) + | 0x70 + | self.sq1.on as u8 + | (self.sq2.on as u8) << 1 + | (self.wave.on as u8) << 2 + | (self.noise.on as u8) << 3; + } + self.reg(addr) + } + + pub fn write(&mut self, addr: u16, v: u8) { + let powered = self.reg(0xFF26) & 0x80 != 0; + if !powered && addr != 0xFF26 && addr < 0xFF30 { + return; + } + self.regs[(addr - 0xFF10) as usize] = v; + match addr { + 0xFF11 => self.sq1.length = 64 - (v & 0x3F) as u16, + 0xFF12 => if v & 0xF8 == 0 { self.sq1.on = false }, + 0xFF13 => self.sq1.freq = (self.sq1.freq & 0x700) | v as u16, + 0xFF14 => { + self.sq1.freq = (self.sq1.freq & 0xFF) | ((v as u16 & 7) << 8); + self.sq1.length_enable = v & 0x40 != 0; + if v & 0x80 != 0 { + let (env, nr10) = (self.reg(0xFF12), self.reg(0xFF10)); + Self::trigger_pulse(&mut self.sq1, env); + self.sq1.shadow_freq = self.sq1.freq; + let period = (nr10 >> 4) & 7; + self.sq1.sweep_timer = if period == 0 { 8 } else { period }; + self.sq1.sweep_enabled = period != 0 || nr10 & 7 != 0; + } + } + 0xFF16 => self.sq2.length = 64 - (v & 0x3F) as u16, + 0xFF17 => if v & 0xF8 == 0 { self.sq2.on = false }, + 0xFF18 => self.sq2.freq = (self.sq2.freq & 0x700) | v as u16, + 0xFF19 => { + self.sq2.freq = (self.sq2.freq & 0xFF) | ((v as u16 & 7) << 8); + self.sq2.length_enable = v & 0x40 != 0; + if v & 0x80 != 0 { + let env = self.reg(0xFF17); + Self::trigger_pulse(&mut self.sq2, env); + } + } + 0xFF1A => if v & 0x80 == 0 { self.wave.on = false }, + 0xFF1B => self.wave.length = 256 - v as u16, + 0xFF1D => self.wave.freq = (self.wave.freq & 0x700) | v as u16, + 0xFF1E => { + self.wave.freq = (self.wave.freq & 0xFF) | ((v as u16 & 7) << 8); + self.wave.length_enable = v & 0x40 != 0; + if v & 0x80 != 0 { + self.wave.on = self.reg(0xFF1A) & 0x80 != 0; + if self.wave.length == 0 { + self.wave.length = 256; + } + self.wave.phase = 0.0; + } + } + 0xFF20 => self.noise.length = 64 - (v & 0x3F) as u16, + 0xFF21 => if v & 0xF8 == 0 { self.noise.on = false }, + 0xFF23 => { + self.noise.length_enable = v & 0x40 != 0; + if v & 0x80 != 0 { + let env = self.reg(0xFF21); + self.noise.on = env & 0xF8 != 0; + if self.noise.length == 0 { + self.noise.length = 64; + } + self.noise.env.load(env); + self.noise.lfsr = 0x7FFF; + } + } + 0xFF26 => { + if v & 0x80 == 0 { + for r in &mut self.regs[..0x16] { + *r = 0; + } + self.sq1.on = false; + self.sq2.on = false; + self.wave.on = false; + self.noise.on = false; + } + } + _ => {} + } + } + + fn trigger_pulse(p: &mut Pulse, env: u8) { + p.on = env & 0xF8 != 0; + if p.length == 0 { + p.length = 64; + } + p.env.load(env); + } + + fn sweep_calc(&mut self) -> u16 { + let nr10 = self.reg(0xFF10); + let delta = self.sq1.shadow_freq >> (nr10 & 7); + let f = if nr10 & 0x08 != 0 { + self.sq1.shadow_freq.wrapping_sub(delta) + } else { + self.sq1.shadow_freq + delta + }; + if f > 2047 { + self.sq1.on = false; + } + f + } + + /// 512 Hz frame sequencer: lengths, sweep, envelopes. + fn sequencer_step(&mut self) { + let step = self.seq_step; + self.seq_step = (self.seq_step + 1) & 7; + if step % 2 == 0 { + for (on, len, en) in [ + (&mut self.sq1.on, &mut self.sq1.length, self.sq1.length_enable), + (&mut self.sq2.on, &mut self.sq2.length, self.sq2.length_enable), + (&mut self.wave.on, &mut self.wave.length, self.wave.length_enable), + (&mut self.noise.on, &mut self.noise.length, self.noise.length_enable), + ] { + if en && *len > 0 { + *len -= 1; + if *len == 0 { + *on = false; + } + } + } + } + if step == 2 || step == 6 { + let nr10 = self.reg(0xFF10); + let period = (nr10 >> 4) & 7; + if self.sq1.sweep_timer > 0 { + self.sq1.sweep_timer -= 1; + } + if self.sq1.sweep_timer == 0 { + self.sq1.sweep_timer = if period == 0 { 8 } else { period }; + if self.sq1.sweep_enabled && period != 0 { + let f = self.sweep_calc(); + if f <= 2047 && nr10 & 7 != 0 { + self.sq1.shadow_freq = f; + self.sq1.freq = f; + self.sweep_calc(); + } + } + } + } + if step == 7 { + self.sq1.env.clock(); + self.sq2.env.clock(); + self.noise.env.clock(); + } + } + + /// Synthesize the next `cycles` clock cycles of sound into `out`. + pub fn run(&mut self, cycles: u64) { + self.now += cycles; + let rate = self.sample_rate as f32; + let target = self.now * self.sample_rate as u64 / CPU_HZ; + while self.samples_made < target { + self.samples_made += 1; + let cyc = self.samples_made * CPU_HZ / self.sample_rate as u64; + // frame sequencer runs every 8192 CPU cycles + self.seq_cycles += cyc - self.last_cycle; + self.last_cycle = cyc; + while self.seq_cycles >= 8192 { + self.seq_cycles -= 8192; + self.sequencer_step(); + } + + let mut ch = [0.0f32; 4]; + let duty1 = (self.reg(0xFF11) >> 6) as usize; + let duty2 = (self.reg(0xFF16) >> 6) as usize; + for (i, (p, duty)) in [(&mut self.sq1, duty1), (&mut self.sq2, duty2)].into_iter().enumerate() { + let hz = 131072.0 / (2048 - p.freq) as f32; + p.phase = (p.phase + hz * 8.0 / rate) % 8.0; + if p.on { + let high = DUTY[duty][p.phase as usize] != 0; + ch[i] = p.env.volume as f32 / 15.0 * if high { 1.0 } else { -1.0 }; + } + } + { + let hz = 65536.0 / (2048 - self.wave.freq) as f32; + self.wave.phase = (self.wave.phase + hz * 32.0 / rate) % 32.0; + let shift = match (self.reg(0xFF1C) >> 5) & 3 { + 0 => 8, + n => n - 1, + }; + if self.wave.on && shift < 8 { + let idx = self.wave.phase as usize; + let byte = self.regs[0x20 + idx / 2]; + let nib = if idx % 2 == 0 { byte >> 4 } else { byte & 0x0F }; + ch[2] = (nib >> shift) as f32 / 7.5 - 1.0; + } + } + { + let nr43 = self.reg(0xFF22); + let div = match nr43 & 7 { 0 => 0.5, d => d as f32 }; + let hz = 524288.0 / div / (1u32 << ((nr43 >> 4) + 1)) as f32; + self.noise.acc += hz / rate; + while self.noise.acc >= 1.0 { + self.noise.acc -= 1.0; + let x = (self.noise.lfsr ^ (self.noise.lfsr >> 1)) & 1; + self.noise.lfsr = (self.noise.lfsr >> 1) | (x << 14); + if nr43 & 0x08 != 0 { + self.noise.lfsr = (self.noise.lfsr & !0x40) | (x << 6); + } + } + if self.noise.on { + let high = self.noise.lfsr & 1 == 0; + ch[3] = self.noise.env.volume as f32 / 15.0 * if high { 1.0 } else { -1.0 }; + } + } + + let nr50 = self.reg(0xFF24); + let nr51 = self.reg(0xFF25); + let (mut l, mut r) = (0.0, 0.0); + for (i, v) in ch.iter().enumerate() { + if nr51 & (0x10 << i) != 0 { l += v; } + if nr51 & (0x01 << i) != 0 { r += v; } + } + l *= ((nr50 >> 4) & 7) as f32 + 1.0; + r *= (nr50 & 7) as f32 + 1.0; + let scale = 0.25 / 8.0; + let l = Self::highpass(&mut self.hp_l, l * scale); + let r = Self::highpass(&mut self.hp_r, r * scale); + self.out.push(l); + self.out.push(r); + } + } + + fn highpass(state: &mut (f32, f32), x: f32) -> f32 { + let y = x - state.0 + 0.996 * state.1; + *state = (x, y); + y + } +} diff --git a/src/game/map.rs b/src/game/map.rs new file mode 100644 index 0000000..756be6e --- /dev/null +++ b/src/game/map.rs @@ -0,0 +1,370 @@ +//! The map screen between levels: the hero walks from the last castle to the +//! next one. On the way a bridge may build itself, a bird flies over writing +//! the level's password, and on some levels a new piece drops from the sky. + +use super::*; +use crate::input::*; +use crate::rom::addr::*; +use crate::sound::*; +use crate::video::map_cell; + +const LAST_LEVEL: u8 = 35; + +// hero shapes +const FACE_UP: u8 = 0x41; +const STAND_RIGHT: u8 = 0x42; +const STAND_LEFT: u8 = 0x43; +const WALK_RIGHT: u8 = 0x44; +const WALK_LEFT: u8 = 0x45; +const LEAVE_CASTLE: u8 = 0x48; +const ENTER_CASTLE: u8 = 0x4C; + +const HERO_Y: u8 = 0x6C; +const STEP: u8 = 4; +const STEP_FRAMES: u8 = 11; +/// where the bridge starts building, and where a new piece drops +const BRIDGE_TRIGGER_X: u8 = 0x28; +const PIECE_DROP_X: u8 = 0x50; + +const BRIDGE_AT: u16 = map_cell(MAP0, 6, 13); +const BRIDGE_TILES: [u8; 8] = [0x30, 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, 0x32]; + +/// The bird flies in a 16-bit fixed-point x position; it writes one letter +/// at each of these x positions. +const MESSENGER_LETTER_X: [u8; 4] = [0x44, 0x4C, 0x54, 0x5C]; +const MESSENGER_DONE_X: u8 = 0xC0; +const PASSWORD_AT: u16 = map_cell(MAP0, 8, 3); +/// tile = ASCII + this +const PASSWORD_TILE_OFFSET: u8 = 0x9D; + +#[derive(Clone, Copy, Default, PartialEq)] +pub(super) enum BridgeState { + #[default] + Idle, + Triggered, + Building, +} + +#[derive(Clone, Copy, Default)] +pub(super) struct Bridge { + pub state: BridgeState, + built: u8, + delay: u8, +} + +#[derive(Clone, Copy, Default, PartialEq)] +pub(super) enum MessengerState { + #[default] + Idle, + Launch, + Flying, +} + +#[derive(Clone, Copy, Default)] +pub(super) struct Messenger { + pub state: MessengerState, + x: u16, +} + +impl Game { + pub(super) fn map_screen(&mut self) { + let data = self.data.clone(); + let level = &data.levels[self.level as usize]; + self.clear_actors(); + if level.new_piece != 0 { + self.s.actors[MARKER] = Actor::at(0x68, 0x56, 0x56); + } + self.video.load(0x8000, data.gfx(GFX_SPRITES, 0x800)); + self.video.load(0x8800, data.gfx(GFX_FONT, 0x800)); + self.video.load(0x9000, data.gfx(GFX_MAP_TERRAIN, 0x800)); + self.video.fill_maps(0x33); + let scripts = &data.scripts; + self.video.draw(&scripts.map); + let scene = match self.level { + 0 => 0, + LAST_LEVEL => 2, + _ => 1, + }; + self.video.draw(&scripts.map_scenes[scene]); + self.video.draw(&scripts.map_castles[level.map_castle as usize]); + self.video.draw(&scripts.map_next_castles[level.map_next_castle as usize]); + if self.level != 0 && self.level != LAST_LEVEL { + // castle 12 is special and has no number on it + if self.level != 12 { + self.draw_castle_number(1, self.level); + } + if self.level + 1 != 12 { + self.draw_castle_number(17, self.level + 1); + } + } + self.set_palettes(PALETTE); + self.play(MUSIC_MAP); + self.show(MAP0); + } + + /// Two-digit number, two tiles tall, at row 7. + fn draw_castle_number(&mut self, col: u16, n: u8) { + let (tens, ones) = (n / 10, n % 10); + for (c, tile) in [(col, 0x6C + 2 * tens), (col + 1, 0x58 + 2 * ones)] { + self.video.poke(map_cell(MAP0, c, 7), tile); + self.video.poke(map_cell(MAP0, c, 8), tile + 1); + } + } + + /// Walk around until the hero goes into a castle. + pub(super) fn map_walk(&mut self) { + match self.level { + 0 => { + self.s.actors[HERO] = Actor::at(HERO_Y, 0x38, STAND_RIGHT); + (self.s.walk_left, self.s.walk_right) = (0x38, 0x78); + } + LAST_LEVEL => { + (self.s.walk_left, self.s.walk_right) = (0x08, 0x64); + self.leave_castle(Actor::at(HERO_Y, 0x08, LEAVE_CASTLE)); + self.s.messenger.state = MessengerState::Launch; + } + _ => { + (self.s.walk_left, self.s.walk_right) = (0x10, 0x90); + self.leave_castle(Actor::at(HERO_Y, 0x10, LEAVE_CASTLE)); + self.s.messenger.state = MessengerState::Launch; + } + } + loop { + self.wait_frame(); + if self.try_enter_castle() { + return; + } + self.walk(); + } + } + + /// Up at a castle door: forward to the next level, or back to the + /// previous one (when facing left at the left castle). + fn try_enter_castle(&mut self) -> bool { + if self.joy.pressed & UP == 0 { + return false; + } + let hero = self.s.actors[HERO]; + if hero.x == self.s.walk_left { + if self.level == 0 || hero.shape == STAND_RIGHT { + return false; + } + self.enter_castle(); + self.level -= 1; + return true; + } + if hero.x == self.s.walk_right { + if self.level == LAST_LEVEL { + self.climb_into_tower(); + } else { + self.enter_castle(); + } + return true; + } + false + } + + /// Walks while the d-pad is held (this blocks, a step at a time). + fn walk(&mut self) { + if self.joy.held & RIGHT != 0 { + self.s.actors[HERO].shape = WALK_RIGHT; + loop { + self.step(self.s.walk_right, STEP); + if self.s.actors[HERO].x == BRIDGE_TRIGGER_X { + self.s.bridge.state = BridgeState::Triggered; + } + if self.joy.held & RIGHT == 0 { + break; + } + } + self.s.actors[HERO].shape = STAND_RIGHT; + } else if self.joy.held & LEFT != 0 { + self.s.actors[HERO].shape = WALK_LEFT; + loop { + self.step(self.s.walk_left, STEP.wrapping_neg()); + if self.joy.held & LEFT == 0 { + break; + } + } + self.s.actors[HERO].shape = STAND_LEFT; + } + } + + fn step(&mut self, limit: u8, dx: u8) { + self.play(SFX_STEP); + self.wait_frames(STEP_FRAMES); + let hero = &mut self.s.actors[HERO]; + hero.shape ^= 2; // swing legs + if hero.x == limit { + return; + } + hero.x = hero.x.wrapping_add(dx); + self.check_piece_drop(); + } + + /// Two frames of animation, four times each, then the next pair. + fn animate_pair(&mut self, times: u8) { + for _ in 0..times { + self.play(SFX_STEP); + self.wait_frames(12); + self.s.actors[HERO].shape ^= 1; + } + } + + fn leave_castle(&mut self, hero: Actor) { + self.wait_frames(30); + self.s.actors[HERO] = hero; + self.animate_pair(4); + self.s.actors[HERO].shape += 2; + self.animate_pair(4); + self.s.actors[HERO].shape = STAND_RIGHT; + } + + fn enter_castle(&mut self) { + self.s.actors[HERO].shape = ENTER_CASTLE; + self.animate_pair(4); + self.s.actors[HERO].shape += 2; + self.animate_pair(4); + self.clear_actors(); + self.s.messenger.state = MessengerState::Idle; + self.wait_frames(30); + } + + /// The last level's door is up a wall. + fn climb_into_tower(&mut self) { + self.s.actors[HERO].shape = ENTER_CASTLE; + self.climb(); + self.s.actors[HERO].shape += 2; + self.s.actors[HERO].y += 2; + self.climb(); + self.s.actors[HERO].visible = false; + self.wait_frames(30); + } + + fn climb(&mut self) { + for _ in 0..6 { + self.play(SFX_STEP); + self.wait_frames(12); + let hero = &mut self.s.actors[HERO]; + hero.shape ^= 1; + hero.y -= 3; + } + } + + /// Halfway along, some levels drop a new piece from the sky. + fn check_piece_drop(&mut self) { + if self.s.piece_drop_done || self.s.actors[HERO].x != PIECE_DROP_X { + return; + } + let piece = self.data.levels[self.level as usize].new_piece; + if piece != 0 { + self.drop_piece(piece); + } + self.s.piece_drop_done = true; + } + + fn drop_piece(&mut self, piece: u8) { + let facing = self.s.actors[HERO].shape; + self.s.actors[HERO].shape = FACE_UP; + self.s.actors[CURSOR] = Actor::at(0xE0, PIECE_DROP_X, 0); + self.s.held = piece; + self.play(SFX_CONFIRM); + self.wait_frames(30); + self.s.actors[MARKER].visible = false; + loop { + // tumble down + self.play(SFX_MOVE_PIECE); + self.wait_frames(4); + self.s.held = (self.s.held & 0xFC) | (self.s.held.wrapping_add(1) & 3); + let p = &mut self.s.actors[CURSOR]; + p.y = p.y.wrapping_add(4); + if p.y == 0x50 { + break; + } + } + self.play(SFX_PIECE_LANDS); + for _ in 0..2 { + self.set_palettes(0xE4); // flash + self.wait_frames(10); + self.set_palettes(PALETTE); + self.wait_frames(10); + } + self.wait_frames(30); + self.s.actors[CURSOR].visible = false; + self.play(JINGLE_LEVEL_CLEAR); + for _ in 0..2 { + self.s.actors[HERO].shape = STAND_RIGHT; + self.wait_frames(20); + self.s.actors[HERO].shape = FACE_UP; + self.wait_frames(20); + } + self.s.actors[HERO].shape = facing; + } + + // ---- per-frame map animations ---- + + pub(super) fn update_bridge(&mut self) { + let b = &mut self.s.bridge; + match b.state { + BridgeState::Idle => {} + BridgeState::Triggered => { + // the first and last maps have no river, and it only builds once + if self.level == 0 || self.level == LAST_LEVEL || b.built != 0 { + b.state = BridgeState::Idle; + } else { + b.state = BridgeState::Building; + b.delay = 1; + self.sound.play(SFX_BRIDGE); + } + } + BridgeState::Building => { + b.delay -= 1; + if b.delay != 0 { + return; + } + b.delay = 5; + let i = b.built; + self.video.poke(BRIDGE_AT + i as u16, BRIDGE_TILES[i as usize]); + if i + 1 == BRIDGE_TILES.len() as u8 { + b.state = BridgeState::Idle; + } else { + b.built += 1; + } + } + } + } + + pub(super) fn update_marker_blink(&mut self) { + self.s.marker_blink = self.s.marker_blink.wrapping_add(1); + if self.s.marker_blink & 0x1F == 0 { + self.s.actors[MARKER].shape ^= 1; + } + } + + pub(super) fn update_messenger(&mut self) { + let m = &mut self.s.messenger; + match m.state { + MessengerState::Idle => {} + MessengerState::Launch => { + m.state = MessengerState::Flying; + m.x = 0xF000; + self.s.actors[MESSENGER] = Actor::at(0x24, 0xE0, 0x58); + } + MessengerState::Flying => { + m.x = m.x.wrapping_add(0x80); + let x = (m.x >> 8) as u8; + let bird = &mut self.s.actors[MESSENGER]; + bird.x = x; + if let Some(i) = MESSENGER_LETTER_X.iter().position(|&lx| lx == x) { + let letter = self.data.passwords[self.level as usize - 1][i]; + self.video.poke(PASSWORD_AT + i as u16, letter.wrapping_add(PASSWORD_TILE_OFFSET)); + self.sound.play(SFX_PASSWORD_LETTER); + } else if x == MESSENGER_DONE_X { + m.state = MessengerState::Idle; + } else { + bird.shape ^= (x >> 4) & 1; // flap + } + } + } + } +} diff --git a/src/game/menus.rs b/src/game/menus.rs new file mode 100644 index 0000000..bfe9ffe --- /dev/null +++ b/src/game/menus.rs @@ -0,0 +1,287 @@ +//! Title screen, how-to-play, password entry and the puzzle list. + +use super::*; +use crate::input::*; +use crate::rom::addr::*; +use crate::sound::*; +use crate::video::{MAP0, MAP1}; + +pub enum TitleChoice { + Start, + Password, + HowToPlay, +} + +/// Title menu arrow rows. +const TITLE_START_Y: u8 = 0x60; +const TITLE_PASSWORD_Y: u8 = 0x70; + +/// Password screen: a 7x4 grid of letters A-Z with a cursor. +const LETTER_TOP: u8 = 0x40; +const LETTER_BOTTOM: u8 = 0x70; +const LETTER_LEFT: u8 = 0x30; +const LETTER_RIGHT: u8 = 0x90; +const PASSWORD_ENTRY: u16 = 0x9868; // where the typed letters appear +const BLANK_LETTER: u8 = 0x7D; +const FIRST_LETTER_TILE: u8 = 0x5E; // 'A' + +/// Puzzle list: two pages (maps 0 and 1) of 6 rows x 3 columns. +const LIST_TOP: u8 = 0x30; +const LIST_BOTTOM: u8 = 0x80; +const LIST_LEFT: u8 = 0x10; +const LIST_RIGHT: u8 = 0x70; +const LIST_PAGE: u8 = 18; +const PUZZLE_LIST_LEVEL: u8 = 36; + +impl Game { + pub(super) fn title_screen(&mut self) -> TitleChoice { + let data = self.data.clone(); + self.video.load(0x8000, data.gfx(GFX_SPRITES, 0x1800)); + self.clear_actors(); + self.s.actors[MENU_CURSOR] = Actor::at(TITLE_START_Y, 0x38, 0x55); + // map 0 is the title; map 1 is the same without "PUSH START" + self.video.fill_maps(0xEB); + self.video.draw(&data.scripts.title); + self.video.copy_map(MAP0, MAP1); + self.video.draw(&data.scripts.title_alt); + self.set_palettes(PALETTE); + self.play(MUSIC_TITLE); + self.show(MAP0); + self.joy.held = 0xFF; // ignore buttons already down + + let mut blink: u8 = 0x60; + loop { + self.wait_frame(); + let p = self.joy.pressed; + let arrow = &mut self.s.actors[MENU_CURSOR].y; + if p & A != 0 { + return TitleChoice::HowToPlay; + } else if p & SELECT != 0 { + *arrow = if *arrow == TITLE_START_Y { TITLE_PASSWORD_Y } else { TITLE_START_Y }; + } else if p & START != 0 { + self.play(SFX_CONFIRM); + return if self.s.actors[MENU_CURSOR].y == TITLE_PASSWORD_Y { + TitleChoice::Password + } else { + TitleChoice::Start + }; + } else if p & UP != 0 { + *arrow = TITLE_START_Y; + } else if p & DOWN != 0 { + *arrow = TITLE_PASSWORD_Y; + } else { + // flash "PUSH START": 32 frames on, 32 off + blink = blink.wrapping_add(1); + if blink == 0 { + blink = 0x60; + self.show(MAP0); + } else if blink == 0x80 { + blink = 0xE0; + self.show(MAP1); + } + continue; + } + self.play(SFX_MENU_MOVE); + } + } + + pub(super) fn how_to_play(&mut self) { + let data = self.data.clone(); + self.video.load(0x8800, data.gfx(GFX_HOW_TO_PLAY, 0x800)); + self.video.load(0x9000, data.gfx(GFX_FONT, 0x800)); + self.video.fill_maps(0x7F); + self.video.draw(&data.scripts.how_to_play); + self.clear_actors(); + self.set_palettes(PALETTE); + self.show(MAP0); + self.play(MUSIC_MAP); + loop { + self.wait_frame(); + if self.joy.pressed & (A | START) != 0 { + return; + } + } + } + + /// Sets `self.level` from a valid password. + pub(super) fn password_screen(&mut self) { + let data = self.data.clone(); + self.load_menu_tiles(); + self.video.fill_maps(0x7F); + self.clear_actors(); + self.video.draw(&data.scripts.password); + self.set_palettes(PALETTE); + self.play(MUSIC_TITLE); + self.show(MAP0); + self.wait_frame(); + loop { + let mut entry = [BLANK_LETTER; 4]; + let mut len = 0; + self.s.actors[HERO] = Actor::at(LETTER_TOP, LETTER_LEFT, 0x53); + loop { + for (i, &t) in entry.iter().enumerate() { + self.video.poke(PASSWORD_ENTRY + i as u16, t); + } + self.wait_frame(); + if len == 4 { + self.wait_frames(30); + if let Some(level) = self.check_password(&entry) { + self.level = level; + return; + } + self.play(SFX_ERROR); + break; + } + self.move_letter_cursor(); + let cursor = self.s.actors[HERO]; + if self.joy.pressed & A != 0 { + entry[len] = letter_at(cursor.y, cursor.x).wrapping_add(FIRST_LETTER_TILE); + len += 1; + } else if self.joy.pressed & B != 0 && len > 0 { + len -= 1; + entry[len] = BLANK_LETTER; + } + } + } + } + + fn load_menu_tiles(&mut self) { + let font = self.data.clone(); + self.video.load(0x8000, font.gfx(GFX_FONT, 0x800)); + self.video.load(0x9000, font.gfx(GFX_FONT, 0x800)); + } + + fn move_letter_cursor(&mut self) { + let r = self.joy.repeat; + let Actor { mut y, mut x, .. } = self.s.actors[HERO]; + let mut moved = 0; + if r & RIGHT != 0 { + if x != LETTER_RIGHT { + x += 0x10; + moved += 1; + if letter_at(y, x) >= 26 { + y = 0x60; // off the end of the last row: go up one + } + } + } else if r & LEFT != 0 && x != LETTER_LEFT { + x -= 0x10; + moved += 1; + } + if r & UP != 0 { + if y != LETTER_TOP { + y -= 0x10; + moved += 1; + } + } else if r & DOWN != 0 && y != LETTER_BOTTOM { + y += 0x10; + moved += 1; + if letter_at(y, x) >= 26 { + x = 0x70; // 'Z' + } + } + for _ in 0..moved { + self.play(SFX_MENU_MOVE); // once per axis moved + } + let c = &mut self.s.actors[HERO]; + (c.y, c.x) = (y, x); + } + + fn check_password(&self, entry: &[u8; 4]) -> Option { + let typed = entry.map(|t| t.wrapping_sub(FIRST_LETTER_TILE).wrapping_add(b'A')); + self.data.passwords.iter().position(|p| *p == typed).map(|i| i as u8 + 1) + } + + /// Level select, reached with the password after the last level. + pub(super) fn puzzle_list(&mut self) { + if self.level != PUZZLE_LIST_LEVEL { + return; + } + let data = self.data.clone(); + self.load_menu_tiles(); + self.video.fill_maps(0x7F); + self.clear_actors(); + self.video.draw(&data.scripts.puzzle_list); + self.set_palettes(PALETTE); + self.play(MUSIC_TITLE); + self.show(MAP0); + self.s.actors[MENU_CURSOR] = Actor::at(LIST_TOP, LIST_LEFT, 0x54); + let mut page = 0; + loop { + self.wait_frame(); + if let Some(p) = self.move_list_cursor(page) { + page = p; + continue; + } + let p = self.joy.pressed; + if p & A != 0 || (p & SELECT == 0 && p & START != 0) { + let Actor { y, x, .. } = self.s.actors[MENU_CURSOR]; + let col = match x { + LIST_LEFT => 0, + 0x40 => 1, + _ => 2, + }; + self.level = page + ((y - LIST_TOP) >> 4) * 3 + col; + self.play(SFX_CONFIRM); + return; + } + if p & SELECT != 0 { + self.play(SFX_MENU_MOVE); + if page != 0 { + self.show(MAP0); + page = 0; + } else { + self.show(MAP1); + page = LIST_PAGE; + } + } + } + } + + /// Returns the new page if the cursor moved off the top/bottom of one. + fn move_list_cursor(&mut self, page: u8) -> Option { + let r = self.joy.repeat; + let c = &mut self.s.actors[MENU_CURSOR]; + let mut moved = 0; + if r & DOWN != 0 { + if c.y != LIST_BOTTOM { + c.y += 0x10; + moved += 1; + } else if page == 0 { + c.y = LIST_TOP; + self.video.display = Display::On(MAP1); + self.sound.play(SFX_MENU_MOVE); + return Some(LIST_PAGE); + } + } else if r & UP != 0 { + if c.y != LIST_TOP { + c.y -= 0x10; + moved += 1; + } else if page != 0 { + c.y = LIST_BOTTOM; + self.video.display = Display::On(MAP0); + self.sound.play(SFX_MENU_MOVE); + return Some(0); + } + } + if r & LEFT != 0 { + if c.x != LIST_LEFT { + c.x -= 0x30; + moved += 1; + } + } else if r & RIGHT != 0 && c.x != LIST_RIGHT { + c.x += 0x30; + moved += 1; + } + for _ in 0..moved { + self.sound.play(SFX_MENU_MOVE); + } + None + } +} + +/// Letter number (0 = A) under the password cursor. +fn letter_at(y: u8, x: u8) -> u8 { + let row = y.wrapping_sub(LETTER_TOP) >> 4; + let col = x.wrapping_sub(LETTER_LEFT) >> 4; + row.wrapping_mul(7).wrapping_add(col) +} diff --git a/src/game/mod.rs b/src/game/mod.rs new file mode 100644 index 0000000..0edfae7 --- /dev/null +++ b/src/game/mod.rs @@ -0,0 +1,422 @@ +//! The game itself. +//! +//! The game is written the way the original is: as straight-line code that +//! calls [`Game::wait_frame`] whenever it wants the next frame (to animate, to +//! wait for a button, ...). It runs on its own thread; `wait_frame` hands the +//! finished picture and sound to the host and blocks until the host sends the +//! next frame's buttons. +//! +//! Modules: `menus` (title, how to play, password, puzzle list), `story` +//! (story screens and the ending), `map` (the overworld walk between levels) +//! and `puzzle` (the puzzle itself and the level-clear screen). + +mod map; +mod menus; +mod puzzle; +mod story; + +use std::sync::mpsc::{Receiver, SyncSender}; +use std::sync::Arc; + +use crate::apu::{Apu, CLOCK_HZ}; +use crate::input::Joypad; +use crate::rom::{GameData, Shape}; +use crate::sound::SoundDriver; +use crate::video::{Display, Sprite, Video, MAP0, SCREEN_H, SCREEN_W}; + +/// Clock cycles per frame (the Game Boy runs at ~59.73 frames per second). +pub const FRAME_CYCLES: u64 = 70_224; +pub const FRAME_HZ: f64 = CLOCK_HZ as f64 / FRAME_CYCLES as f64; + +/// The normal palette: shade n for color n, reversed (the game's art is +/// stored with 0 = black). +const PALETTE: u8 = 0x1B; + +// ---- actor slots (multi-tile sprites, drawn every frame) ---- +/// puzzle cursor / held piece; also the piece that drops on the map +const CURSOR: usize = 0; +/// menu arrow on the title screen and puzzle list +const MENU_CURSOR: usize = 1; +/// the hero on the map, the storyteller, the password cursor +const HERO: usize = 2; +/// "something here" marker on the map; the second figure in story scenes +const MARKER: usize = 3; +/// the bird that brings the password on the map +const MESSENGER: usize = 4; +const ACTOR_SLOTS: usize = 12; + +/// A sprite made of one shape's cells, positioned at (y, x) in sprite +/// coordinates (screen + 16, screen + 8). +#[derive(Clone, Copy, Default)] +pub struct Actor { + pub visible: bool, + pub y: u8, + pub x: u8, + pub shape: u8, +} + +impl Actor { + const fn at(y: u8, x: u8, shape: u8) -> Actor { + Actor { visible: true, y, x, shape } + } +} + +/// Where a placed piece is and which board tiles it covers. +#[derive(Clone, Copy, Default)] +struct PieceSlot { + y: u8, + x: u8, + /// the board tiles under the piece's cells + under: [u8; 6], +} + +/// The puzzle board: which piece id covers each background map cell. +type Board = Box<[u8; 0x400]>; + +/// Scratch state that the original keeps in $C000-$CFFF and wipes at the +/// start of each puzzle (and at the title screen). +struct Scratch { + actors: [Actor; ACTOR_SLOTS], + /// id of the piece in hand (type << 4 | orientation), 0 = none + held: u8, + /// one slot per piece type + slots: [PieceSlot; 16], + slots_backup: [PieceSlot; 16], + board: Board, + board_backup: Board, + bridge: map::Bridge, + marker_blink: u8, + story: u8, + piece_drop_done: bool, + timer: Timer, + messenger: map::Messenger, + /// how far the hero can walk on the map + walk_left: u8, + walk_right: u8, +} + +impl Default for Scratch { + fn default() -> Self { + Scratch { + actors: Default::default(), + held: 0, + slots: Default::default(), + slots_backup: Default::default(), + board: Box::new([0; 0x400]), + board_backup: Box::new([0; 0x400]), + bridge: Default::default(), + marker_blink: 0, + story: 0, + piece_drop_done: false, + timer: Timer::default(), + messenger: Default::default(), + walk_left: 0, + walk_right: 0, + } + } +} + +#[derive(Clone, Copy, Default, PartialEq)] +enum TimerMode { + #[default] + Hidden, + Running, + Stopped, +} + +/// The clear-time clock in the top right corner. +#[derive(Clone, Copy, Default)] +struct Timer { + mode: TimerMode, + frames: u8, + /// seconds, tens of seconds, minutes, tens of minutes, hours + digits: [u8; 5], +} + +#[derive(Clone, Copy, Default)] +struct Palettes { + bg: u8, + obj0: u8, + obj1: u8, + /// palette 1 inverts every 16 frames (makes the cursor blink) + blink: u8, +} + +/// What the game hands the host each frame. +pub struct Frame { + /// one shade (0 = white .. 3 = black) per pixel + pub pixels: Vec, + /// interleaved stereo samples + pub audio: Vec, + /// a peek at the game state, for testing + pub probe: Probe, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Probe { + pub level: u8, + pub cursor_y: u8, + pub cursor_x: u8, + pub held: u8, +} + +/// The game thread's end of the connection to the host. +pub struct Link { + pub frames: SyncSender, + pub buttons: Receiver, +} + +/// Unwinds the game thread when the host goes away. +struct Quit; + +pub struct Game { + data: Arc, + link: Link, + video: Video, + apu: Apu, + sound: SoundDriver, + joy: Joypad, + level: u8, + pieces_needed: u8, + palettes: Palettes, + /// sprites built this frame; the display picks them up at the next vblank + oam: [Sprite; 40], + s: Scratch, +} + +/// Run the game on a new thread. +pub fn spawn(data: Arc, sample_rate: u32, link: Link) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut game = Game::new(data, sample_rate, link); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| game.run())); + if let Err(e) = result { + if !e.is::() { + std::panic::resume_unwind(e); + } + } + }) +} + +impl Game { + fn new(data: Arc, sample_rate: u32, link: Link) -> Game { + Game { + sound: SoundDriver::new(data.clone()), + data, + link, + video: Video::new(), + apu: Apu::new(sample_rate), + joy: Joypad::default(), + level: 0, + pieces_needed: 0, + palettes: Palettes::default(), + oam: [Sprite::default(); 40], + s: Scratch::default(), + } + } + + fn run(&mut self) -> ! { + loop { + self.reset(); + match self.title_screen() { + menus::TitleChoice::HowToPlay => { + self.how_to_play(); + continue; + } + menus::TitleChoice::Password => { + self.password_screen(); + self.puzzle_list(); + } + menus::TitleChoice::Start => {} + } + self.play_levels(); + self.ending(); + } + } + + /// Story, map, puzzle, repeat; returns after the last level. + fn play_levels(&mut self) { + loop { + self.s.timer.mode = TimerMode::Hidden; + self.story_screen(); + self.story_text(); + self.map_screen(); + self.map_walk(); + self.puzzle_screen(); + self.s.timer.mode = TimerMode::Running; + loop { + self.wait_frame(); + self.check_reset_puzzle(); + self.move_cursor(); + self.piece_action(); + if self.pieces_in_place() == self.pieces_needed { + break; + } + } + self.s.timer.mode = TimerMode::Stopped; + self.level_clear(); + if self.level as usize + 1 >= crate::rom::LEVEL_COUNT { + self.s.timer.mode = TimerMode::Hidden; + return; + } + self.level += 1; + } + } + + /// Back to power-on state (the title screen clears all of RAM). + fn reset(&mut self) { + self.video.display = Display::Off; + self.s = Scratch::default(); + self.level = 0; + self.pieces_needed = 0; + self.palettes = Palettes::default(); + self.joy = Joypad::default(); + self.sound = SoundDriver::new(self.data.clone()); + } + + // ---------------------------------------------------------------- frames + + /// Finish this frame and wait for the next one. + fn wait_frame(&mut self) { + let mut pixels = vec![0; SCREEN_W * SCREEN_H]; + self.video.render(&mut pixels); + let probe = Probe { + level: self.level, + cursor_y: self.s.actors[CURSOR].y, + cursor_x: self.s.actors[CURSOR].x, + held: self.s.held, + }; + let frame = Frame { pixels, audio: std::mem::take(&mut self.apu.out), probe }; + if self.link.frames.send(frame).is_err() { + std::panic::resume_unwind(Box::new(Quit)); + } + let buttons = match self.link.buttons.recv() { + Ok(b) => b, + Err(_) => std::panic::resume_unwind(Box::new(Quit)), + }; + + // things the original does every vblank + self.video.sprites = self.oam; + self.update_palettes(); + self.update_cursor_shape(); + self.build_sprites(); + self.joy.update(buttons); + self.update_bridge(); + self.update_timer(); + self.draw_timer(); + self.update_marker_blink(); + self.update_messenger(); + self.sound.update(&mut self.apu); + self.apu.run(FRAME_CYCLES); + } + + fn wait_frames(&mut self, n: u8) { + for _ in 0..n { + self.wait_frame(); + } + } + + fn update_palettes(&mut self) { + let p = &mut self.palettes; + self.video.bg_palette = p.bg; + self.video.obj_palettes = [p.obj0, p.obj1]; + let (blink, wrapped) = p.blink.overflowing_add(0x10); + p.blink = blink; + if wrapped { + p.obj1 ^= 0xFF; + } + } + + fn set_palettes(&mut self, v: u8) { + self.palettes.bg = v; + self.palettes.obj0 = v; + self.palettes.obj1 = v; + } + + /// Turn the screen on showing the given background map. + fn show(&mut self, map: u16) { + self.video.display = Display::On(map); + } + + fn play(&mut self, sound: u8) { + self.sound.play(sound); + } + + fn shape(&self, n: u8) -> &Shape { + &self.data.shapes[n as usize] + } + + fn clear_actors(&mut self) { + self.s.actors = Default::default(); + self.s.held = 0; + } + + /// The cursor actor shows the held piece, or the cursor itself (which + /// changes shape over the piece tray on the right). + fn update_cursor_shape(&mut self) { + let a = &mut self.s.actors[CURSOR]; + a.shape = if self.s.held != 0 { + self.data.piece_shape(self.s.held) + } else if a.x >= 0xA4 { + 0x5D + } else { + 0 + }; + } + + fn build_sprites(&mut self) { + let mut sprites = [Sprite::default(); 40]; + let mut n = 0; + for a in self.s.actors.iter().filter(|a| a.visible) { + let shape = &self.data.shapes[a.shape as usize]; + for cell in &shape.cells { + let (dy, dx) = cell.sprite_offset(); + if let Some(s) = sprites.get_mut(n) { + *s = Sprite { y: a.y.wrapping_add(dy), x: a.x.wrapping_add(dx), tile: cell.tile, attr: shape.attr }; + } + n += 1; + } + } + self.oam = sprites; + } + + // ----------------------------------------------------------------- timer + + fn update_timer(&mut self) { + let t = &mut self.s.timer; + if t.mode != TimerMode::Running { + return; + } + t.frames += 1; + if t.frames < 60 { + return; + } + t.frames = 0; + for (digit, limit) in t.digits.iter_mut().zip([10, 6, 10, 6, 10]) { + *digit += 1; + if *digit < limit { + return; + } + *digit = 0; + } + } + + /// Two-tile-tall digits in the last 10 sprite slots: h mm ss. + fn draw_timer(&mut self) { + let t = self.s.timer; + if t.mode == TimerMode::Hidden { + return; + } + for (i, x) in [0x88, 0x80, 0x70, 0x68, 0x58].into_iter().enumerate() { + let tile = t.digits[i] * 2 + 0x64; + self.oam[30 + 2 * i] = Sprite { y: 0x10, x, tile, attr: 0 }; + self.oam[31 + 2 * i] = Sprite { y: 0x18, x, tile: tile + 1, attr: 0 }; + } + } +} + +/// Map cell under a point in sprite coordinates. +fn cell_at(y: u8, x: u8) -> u16 { + let row = (y.wrapping_sub(16) >> 3) as u16; + let col = (x.wrapping_sub(8) >> 3) as u16; + MAP0 + row * 32 + col +} diff --git a/src/game/puzzle.rs b/src/game/puzzle.rs new file mode 100644 index 0000000..a8603d6 --- /dev/null +++ b/src/game/puzzle.rs @@ -0,0 +1,341 @@ +//! The puzzle: pick up pieces, turn and flip them, and fit them all into the +//! board. Then the level-clear screen. +//! +//! The board is drawn into background map 0. Board floor tiles are $80 and +//! up, and the area to fill is the floor tiles at $F0 and up; everything below +//! $80 is a wall. `Scratch::board` records which piece covers each map cell, +//! and each piece type's slot remembers where it is and the floor tiles it +//! covers, so they can be put back when it's picked up. + +use super::*; +use crate::input::*; +use crate::rom::addr::*; +use crate::sound::*; +use crate::video::{map_cell, MAP1}; + +const FLOOR: u8 = 0x80; +const TARGET_FLOOR: u8 = 0xF0; + +/// Pieces must stay within this area (sprite coordinates). +const AREA_TOP: u8 = 0x20; +const AREA_BOTTOM: u8 = 0x99; +const AREA_LEFT: u8 = 0x08; +const AREA_RIGHT: u8 = 0xA1; + +/// The empty cursor's range. +const CURSOR_MAX_Y: u8 = 0x9C; +const CURSOR_MIN_Y: u8 = 0x25; +const CURSOR_MIN_X: u8 = 0x0D; +const CURSOR_MAX_X: u8 = 0xA4; + +/// Slot for a piece id (one per piece type). +fn slot(piece_id: u8) -> usize { + (piece_id.wrapping_sub(0x10) >> 4) as usize +} + +/// A board cell covered by a shape: the map address and the tile. +struct Cell { + addr: u16, + tile: u8, + y: u8, + x: u8, +} + +impl Game { + pub(super) fn puzzle_screen(&mut self) { + let data = self.data.clone(); + let level = &data.levels[self.level as usize]; + self.wait_frame(); + self.video.load(0x8000, data.gfx(GFX_SPRITES, 0x1800)); + self.s = Scratch::default(); + self.video.fill_maps(0x84); + self.video.draw(&data.scripts.puzzle_hud); + self.draw_puzzle_number(); + self.video.draw(&level.board); + for p in &level.pieces { + self.s.held = p.id; + self.s.actors[CURSOR].y = p.y.wrapping_add(16); + self.s.actors[CURSOR].x = p.x.wrapping_add(8); + self.update_cursor_shape(); + let cells = self.held_cells(); + self.cover(&cells); + self.put_down(&cells); + } + self.init_cursor(); + // keep the starting position for "reset" + self.s.slots_backup = self.s.slots; + self.s.board_backup = self.s.board.clone(); + self.video.copy_map(MAP0, MAP1); + self.pieces_needed = level.pieces_needed; + self.set_palettes(PALETTE); + self.play(level.music); + self.show(MAP0); + } + + /// Puzzle number in the HUD, two tiles tall, left aligned. + fn draw_puzzle_number(&mut self) { + let n = self.level + 1; + let (tens, ones) = (n / 10, n % 10); + let digits: &[u8] = if tens != 0 { &[tens, ones] } else { &[ones] }; + let mut cols = [(0x21, 0x29); 2]; // blank + for (i, d) in digits.iter().enumerate() { + cols[i] = (0x60 + 2 * d, 0x61 + 2 * d); + } + for (i, (top, bottom)) in cols.into_iter().enumerate() { + self.video.poke(map_cell(MAP0, 5 + i as u16, 0), top); + self.video.poke(map_cell(MAP0, 5 + i as u16, 1), bottom); + } + } + + fn init_cursor(&mut self) { + let (y, x) = self.data.levels[self.level as usize].cursor; + self.s.actors[CURSOR] = Actor::at((y & 0xF8) | 4, (x & 0xF8) | 4, 0); + self.s.held = 0; + } + + /// The held piece's cells, at its position snapped to the 8px grid. + fn held_cells(&self) -> Vec { + let a = self.s.actors[CURSOR]; + let shape = a.shape & 0x7F; + if shape == 0 { + return Vec::new(); + } + let (y, x) = (a.y.wrapping_add(4) & 0xF8, a.x.wrapping_add(4) & 0xF8); + self.shape(shape) + .cells + .iter() + .map(|c| { + let (dy, dx) = c.board_offset(); + let (cy, cx) = (y.wrapping_add(dy), x.wrapping_add(dx)); + Cell { addr: cell_at(cy, cx), tile: c.tile, y: cy, x: cx } + }) + .collect() + } + + /// Record the floor tiles under the held piece in its slot. Fails (true) + /// on the first cell that isn't floor. + fn cover(&mut self, cells: &[Cell]) -> bool { + let slot = slot(self.s.held); + for (i, c) in cells.iter().enumerate() { + let under = self.video.peek(c.addr); + if under < FLOOR { + return true; + } + if let Some(u) = self.s.slots[slot].under.get_mut(i) { + *u = under; + } + } + false + } + + /// Stamp the held piece onto the board. + fn put_down(&mut self, cells: &[Cell]) { + if cells.is_empty() { + return; + } + let a = self.s.actors[CURSOR]; + let id = self.data.shape_piece_id(a.shape & 0x7F); + for c in cells { + self.s.board[(c.addr - MAP0) as usize] = id; + self.video.poke(c.addr, c.tile); + } + let slot = &mut self.s.slots[slot(id)]; + (slot.y, slot.x) = (a.y.wrapping_add(4) & 0xF8, a.x.wrapping_add(4) & 0xF8); + self.play(SFX_PLACE); + } + + /// Would `shape` at (y, x) stick out of the play area? + fn out_of_bounds(&self, shape: u8, y: u8, x: u8) -> bool { + self.shape(shape).cells.iter().any(|c| { + let (dy, dx) = c.board_offset(); + let (cy, cx) = (y.wrapping_add(dy), x.wrapping_add(dx)); + !(AREA_TOP..AREA_BOTTOM).contains(&cy) || !(AREA_LEFT..AREA_RIGHT).contains(&cx) + }) + } + + /// A pressed while B is held: put every piece back where it started. + pub(super) fn check_reset_puzzle(&mut self) { + if self.joy.pressed & A == 0 || self.joy.held & B == 0 { + return; + } + self.play(SFX_ROTATE); + self.s.actors[CURSOR].visible = false; + self.wipe_rows(MAP1, MAP0); + self.s.slots = self.s.slots_backup; + self.s.board = self.s.board_backup.clone(); + self.init_cursor(); + } + + /// Copy the play area from one map to another, a row every two frames. + fn wipe_rows(&mut self, from: u16, to: u16) { + for row in 0..18 { + let row_tiles: Vec = (0..20).map(|c| self.video.peek(from + row * 32 + c)).collect(); + self.wait_frame(); + for (c, &t) in row_tiles.iter().enumerate() { + self.video.poke(to + row * 32 + c as u16, t); + } + self.wait_frame(); + } + } + + pub(super) fn move_cursor(&mut self) { + let r = self.joy.repeat; + if self.s.held == 0 { + let c = &mut self.s.actors[CURSOR]; + if r & DOWN != 0 && c.y < CURSOR_MAX_Y { + c.y += 8; + } + if r & UP != 0 && c.y >= CURSOR_MIN_Y { + c.y -= 8; + } + if r & LEFT != 0 && c.x >= CURSOR_MIN_X { + c.x -= 8; + } + if r & RIGHT != 0 && c.x < CURSOR_MAX_X { + c.x += 8; + } + return; + } + // carrying a piece: it can't leave the play area + let shape = self.s.actors[CURSOR].shape; + let Actor { mut y, mut x, .. } = self.s.actors[CURSOR]; + for (button, dy, dx) in [(DOWN, 8, 0), (UP, 0xF8, 0), (LEFT, 0, 0xF8), (RIGHT, 0, 8)] { + if r & button != 0 { + self.play(SFX_MOVE_PIECE); + let (ny, nx) = (y.wrapping_add(dy), x.wrapping_add(dx)); + if !self.out_of_bounds(shape, ny, nx) { + (y, x) = (ny, nx); + } + } + } + let c = &mut self.s.actors[CURSOR]; + (c.y, c.x) = (y, x); + } + + /// A: pick up / put down. B: rotate. Select / Start: flip. + pub(super) fn piece_action(&mut self) { + let p = self.joy.pressed; + if p & A != 0 { + if self.s.held == 0 { + self.pick_up(); + } else { + self.drop_held(); + } + } else if p & B != 0 { + if self.s.held != 0 { + self.rotate(); + } + } else if p & SELECT != 0 { + if self.s.held != 0 { + self.flip(true); + } + } else if p & START != 0 && self.s.held != 0 { + self.flip(false); + } + } + + fn pick_up(&mut self) { + let a = self.s.actors[CURSOR]; + let id = self.s.board[(cell_at(a.y, a.x) - MAP0) as usize]; + if id == 0 { + return; + } + self.s.held = id; + let slot = self.s.slots[slot(id)]; + (self.s.actors[CURSOR].y, self.s.actors[CURSOR].x) = (slot.y, slot.x); + self.update_cursor_shape(); + for (c, under) in self.held_cells().iter().zip(slot.under) { + self.s.board[(c.addr - MAP0) as usize] = 0; + self.video.poke(c.addr, under); + } + self.play(SFX_PICK_UP); + } + + fn drop_held(&mut self) { + let cells = self.held_cells(); + self.wait_frame(); + if self.cover(&cells) { + self.play(SFX_ERROR); + return; + } + self.put_down(&cells); + // the cursor moves to the piece's last cell + if let Some(last) = cells.last() { + (self.s.actors[CURSOR].y, self.s.actors[CURSOR].x) = (last.y + 4, last.x + 4); + } + self.s.held = 0; + self.update_cursor_shape(); + } + + fn rotate(&mut self) { + let held = self.s.held; + self.s.held = (held & 0xFC) | (held.wrapping_add(1) & 3); + self.update_cursor_shape(); + let a = self.s.actors[CURSOR]; + if self.out_of_bounds(a.shape, a.y, a.x) { + self.s.held = held; + } else { + self.play(SFX_ROTATE); + } + } + + /// Bit 2 of the id mirrors the piece; bit 1 is adjusted so the mirror + /// is about the expected axis. Select and Start mirror about different + /// axes. + fn flip(&mut self, select: bool) { + let mut id = self.s.held ^ 4; + if (id & 1 == 0) == select { + id ^= 2; + } + self.s.held = id; + self.update_cursor_shape(); + let a = self.s.actors[CURSOR]; + if !self.out_of_bounds(a.shape, a.y, a.x) { + self.play(SFX_FLIP); + return; + } + // (sic) undoing always uses Start's rule, so undoing a Select flip + // can leave the piece turned + id ^= 4; + if id & 1 != 0 { + id ^= 2; + } + self.s.held = id; + } + + /// Number of piece types whose cells all sit on the target area. + pub(super) fn pieces_in_place(&self) -> u8 { + self.s.slots[..13].iter().filter(|s| s.under[..5].iter().all(|&t| t >= TARGET_FLOOR)).count() as u8 + } + + pub(super) fn level_clear(&mut self) { + self.play(JINGLE_LEVEL_CLEAR); + self.clear_actors(); + self.wait_frame(); + // map 1 keeps the finished board; the banner goes over map 0 + self.wipe_rows(MAP0, MAP1); + let data = self.data.clone(); + for script in &data.scripts.level_clear_banner { + self.video.draw(script); + self.wait_frame(); + } + let mut showing_board = false; + let mut t: u8 = 0; + loop { + self.wait_frame(); + t = t.wrapping_add(1); + if t & 0x1F == 0 { + // sparkle the banner + let frame = if t & 0x20 != 0 { 0x20 } else { 0 }; + self.video.load(0x8900, data.gfx(GFX_BANNER_SPARKLE + frame, 0x20)); + } + if self.joy.pressed & START != 0 { + return; + } + if self.joy.pressed & A != 0 { + self.show(if showing_board { MAP0 } else { MAP1 }); + showing_board = !showing_board; + } + } + } +} diff --git a/src/game/story.rs b/src/game/story.rs new file mode 100644 index 0000000..ee4a374 --- /dev/null +++ b/src/game/story.rs @@ -0,0 +1,185 @@ +//! The story screens shown before some levels, and the ending. + +use super::*; +use crate::input::*; +use crate::rom::addr::*; +use crate::rom::Text; +use crate::sound::*; +use crate::video::map_cell; + +const TEXT_START: u16 = map_cell(MAP0, 2, 3); +const ENDING_TEXT_START: u16 = map_cell(MAP0, 2, 1); +const TEXT_ROWS: u16 = 8; +const TEXT_WIDTH: u16 = 16; +const BLANK: u8 = 0xFE; +/// frames per letter +const TYPE_DELAY: u8 = 8; + +/// Next text position: one cell right (wrapping within the 256-byte page, +/// as the original does). +fn advance(at: u16) -> u16 { + (at & 0xFF00) | (at as u8).wrapping_add(1) as u16 +} + +/// Start of the line after next (text is double spaced). +fn newline(at: u16) -> u16 { + (at & 0xFFC0) + 0x62 +} + +impl Game { + /// Story backdrop for the current level, if it has one. + pub(super) fn story_screen(&mut self) { + self.s.story = self.data.story_for(self.level); + if self.s.story == 0 { + return; + } + let data = self.data.clone(); + self.video.load(0x8800, data.gfx(GFX_STORY, 0x800)); + self.video.load(0x9000, data.gfx(GFX_FONT, 0x800)); + self.video.fill_maps(BLANK); + self.video.draw(&data.scripts.intermission); + self.clear_actors(); + self.s.actors[HERO] = Actor::at(0x00, 0x50, 0x52); // the storyteller, shown while talking + self.s.actors[MARKER] = Actor::at(0x80, 0x28, 0x50); + self.set_palettes(PALETTE); + self.play(MUSIC_MAP); + self.show(MAP0); + self.wait_frame(); + } + + /// Type out the story a page at a time. A skips the rest of a page. + pub(super) fn story_text(&mut self) { + let story = self.s.story; + if story == 0 || story > 4 { + return; + } + let data = self.data.clone(); + let mut at = TEXT_START; + for &t in &data.stories[story as usize - 1] { + at = advance(at); + match t { + Text::End => { + self.s.actors[HERO].y = 0; + while self.joy.pressed & (A | START) == 0 { + self.wait_frame(); + } + return; + } + Text::PageBreak => { + self.s.actors[HERO].y = 0; + while self.joy.pressed & A == 0 { + self.wait_frame(); + } + for row in 0..TEXT_ROWS { + self.video.fill_row(TEXT_START + row * 32, BLANK, TEXT_WIDTH); + self.wait_frame(); + } + at = TEXT_START; + } + Text::Newline => at = newline(at), + Text::Char(c) => { + // while A is down (it stays "pressed" until the next + // frame) letters are skipped without being drawn + if self.joy.pressed & A != 0 { + continue; + } + self.s.actors[HERO].y ^= 0x80; // mouth moves + self.video.poke(at, c); + for _ in 0..TYPE_DELAY { + self.wait_frame(); + if self.joy.pressed & A != 0 { + break; + } + } + } + } + } + } + + pub(super) fn ending(&mut self) { + let data = self.data.clone(); + let gfx = |a, len| data.gfx(a, len); + self.video.load(0x8000, gfx(GFX_STORY, 0x800)); + self.video.load(0x8800, gfx(GFX_STORY, 0x800)); + self.video.load(0x9000, gfx(GFX_FONT, 0x800)); + self.video.load(0x9600, gfx(GFX_ENDING_C, 0x140)); + self.video.load(0x8B00, gfx(GFX_ENDING_D, 0x100)); + self.video.load(0x8A40, gfx(GFX_ENDING_B, 0xC0)); + self.video.load(0x8C00, gfx(GFX_ENDING_A, 0x80)); + self.video.fill_maps(BLANK); + self.video.draw(&data.scripts.ending); + self.clear_actors(); + self.s.actors[HERO] = Actor::at(0xF0, 0x78, 0x5C); + self.s.actors[MARKER] = Actor::at(0x60, 0x50, 0x5A); + self.set_palettes(PALETTE); + self.show(MAP0); + self.play(MUSIC_TITLE); + + let art = &data.scripts.ending_art; + self.type_ending(&data.ending_text[0]); + self.wait_for_button(); + self.draw_and_wait(&art[0]); + self.draw_and_wait(&art[1]); + self.open_door(); + self.wait_for_button(); + self.clear_ending_text(); + self.draw_and_wait(&art[2]); + self.draw_and_wait(&art[3]); + self.type_ending(&data.ending_text[1]); + self.wait_for_button(); + self.clear_ending_text(); + self.type_ending(&data.ending_text[2]); + self.draw_and_wait(&art[0]); + self.draw_and_wait(&art[4]); + self.wait_for_button(); + } + + fn type_ending(&mut self, text: &[Text]) { + let mut at = ENDING_TEXT_START; + for &t in text { + at = advance(at); + let c = match t { + Text::End => break, + Text::Newline => { + at = newline(at); + continue; + } + Text::PageBreak => 0x81, // not a code in the ending's printer + Text::Char(c) => c, + }; + self.video.poke(at, c); + self.s.actors[HERO].y ^= 0x80; + self.wait_frames(TYPE_DELAY); + } + self.s.actors[HERO].y = 0xF0; + } + + fn wait_for_button(&mut self) { + loop { + self.wait_frame(); + if self.joy.pressed & (A | START) != 0 { + return; + } + } + } + + fn draw_and_wait(&mut self, script: &crate::rom::DrawScript) { + self.video.draw(script); + self.wait_frame(); + } + + fn open_door(&mut self) { + self.wait_frames(12); + for b in (1..=4).rev() { + self.video.poke(map_cell(MAP0, 12, 9) - b, 0x68 - b as u8); + self.wait_frames(12); + } + } + + fn clear_ending_text(&mut self) { + for row in 0..TEXT_ROWS { + self.video.fill_row(ENDING_TEXT_START + row * 32, BLANK, TEXT_WIDTH); + self.wait_frame(); + } + } +} diff --git a/src/headless.rs b/src/headless.rs new file mode 100644 index 0000000..34d6237 --- /dev/null +++ b/src/headless.rs @@ -0,0 +1,136 @@ +//! Headless runner for testing: `opus --headless OUT_DIR SCRIPT...` +//! +//! Script tokens: `N` run N frames; `BUTTON:N` hold a button for N frames +//! (up down left right a b start select); `shot:NAME` save OUT_DIR/NAME.png; +//! `probe` print the level and cursor; `goto:YY:XX` steer the puzzle cursor +//! (hex sprite coordinates). All audio is written to OUT_DIR/audio.wav. + +use crate::game::Frame; +use crate::input; +use crate::rom::GameData; +use crate::video::{SCREEN_H, SCREEN_W}; +use std::sync::Arc; +use std::fs; +use std::path::Path; + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &b in data { + crc ^= b as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 }; + } + } + !crc +} + +fn write_png(path: &Path, w: usize, h: usize, gray: &[u8]) { + let mut raw = Vec::new(); + for y in 0..h { + raw.push(0); + raw.extend_from_slice(&gray[y * w..(y + 1) * w]); + } + // zlib stream with stored (uncompressed) deflate blocks + let mut z = vec![0x78, 0x01]; + for (i, chunk) in raw.chunks(65535).enumerate() { + let last = (i + 1) * 65535 >= raw.len(); + z.push(last as u8); + z.extend_from_slice(&(chunk.len() as u16).to_le_bytes()); + z.extend_from_slice(&(!(chunk.len() as u16)).to_le_bytes()); + z.extend_from_slice(chunk); + } + let (mut a, mut b) = (1u32, 0u32); + for &x in &raw { + a = (a + x as u32) % 65521; + b = (b + a) % 65521; + } + z.extend_from_slice(&(b << 16 | a).to_be_bytes()); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |t: &[u8], d: &[u8]| { + png.extend_from_slice(&(d.len() as u32).to_be_bytes()); + let mut td = t.to_vec(); + td.extend_from_slice(d); + png.extend_from_slice(&td); + png.extend_from_slice(&crc32(&td).to_be_bytes()); + }; + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&(w as u32).to_be_bytes()); + ihdr.extend_from_slice(&(h as u32).to_be_bytes()); + ihdr.extend_from_slice(&[8, 0, 0, 0, 0]); + chunk(b"IHDR", &ihdr); + chunk(b"IDAT", &z); + chunk(b"IEND", &[]); + fs::write(path, png).unwrap(); +} + +fn write_wav(path: &Path, rate: u32, samples: &[f32]) { + let mut d = Vec::new(); + let data_len = samples.len() as u32 * 2; + d.extend_from_slice(b"RIFF"); + d.extend_from_slice(&(36 + data_len).to_le_bytes()); + d.extend_from_slice(b"WAVEfmt "); + d.extend_from_slice(&16u32.to_le_bytes()); + d.extend_from_slice(&1u16.to_le_bytes()); + d.extend_from_slice(&2u16.to_le_bytes()); + d.extend_from_slice(&rate.to_le_bytes()); + d.extend_from_slice(&(rate * 4).to_le_bytes()); + d.extend_from_slice(&4u16.to_le_bytes()); + d.extend_from_slice(&16u16.to_le_bytes()); + d.extend_from_slice(b"data"); + d.extend_from_slice(&data_len.to_le_bytes()); + for s in samples { + d.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()); + } + fs::write(path, d).unwrap(); +} + +pub fn run(data: Arc, args: &[String]) { + let out = Path::new(&args[0]); + fs::create_dir_all(out).unwrap(); + let rate = 44_100; + let host = crate::start_game(data, rate); + let mut audio = Vec::new(); + let mut frames = 0u64; + let mut frame: Frame = host.frames.recv().expect("game thread"); + let mut step = |buttons: u8, n: u64, frame: &mut Frame| { + for _ in 0..n { + host.buttons.send(buttons).expect("game thread"); + *frame = host.frames.recv().expect("game thread"); + audio.extend_from_slice(&frame.audio); + frames += 1; + } + }; + for tok in &args[1..] { + if let Ok(n) = tok.parse::() { + step(0, n, &mut frame); + } else if let Some(name) = tok.strip_prefix("shot:") { + let gray: Vec = frame.pixels.iter().map(|&s| [0xFF, 0xAA, 0x55, 0x00][s as usize]).collect(); + write_png(&out.join(format!("{name}.png")), SCREEN_W, SCREEN_H, &gray); + println!("{name}.png: {:?}", frame.probe); + } else if tok == "probe" { + println!("{:?}", frame.probe); + } else if let Some(spec) = tok.strip_prefix("goto:") { + let (ty, tx) = spec.split_once(':').unwrap(); + let (ty, tx) = (u8::from_str_radix(ty, 16).unwrap(), u8::from_str_radix(tx, 16).unwrap()); + for _ in 0..100 { + let p = frame.probe; + let bit = if p.cursor_y < ty { input::DOWN } else if p.cursor_y > ty { input::UP } + else if p.cursor_x < tx { input::RIGHT } else if p.cursor_x > tx { input::LEFT } else { break }; + step(bit, 1, &mut frame); + step(0, 5, &mut frame); + } + } else if let Some((btn, n)) = tok.split_once(':') { + let bit = match btn { + "up" => input::UP, "down" => input::DOWN, "left" => input::LEFT, "right" => input::RIGHT, + "a" => input::A, "b" => input::B, "start" => input::START, "select" => input::SELECT, + _ => panic!("unknown token {tok}"), + }; + step(bit, n.parse().unwrap(), &mut frame); + } else { + panic!("unknown token {tok}"); + } + } + drop(step); + write_wav(&out.join("audio.wav"), rate, &audio); + println!("{frames} frames, {:.1}s of audio", audio.len() as f32 / 2.0 / rate as f32); +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..2a68d4d --- /dev/null +++ b/src/input.rs @@ -0,0 +1,44 @@ +//! Buttons, as the game reads them once per frame. + +pub const A: u8 = 0x01; +pub const B: u8 = 0x02; +pub const SELECT: u8 = 0x04; +pub const START: u8 = 0x08; +pub const RIGHT: u8 = 0x10; +pub const LEFT: u8 = 0x20; +pub const UP: u8 = 0x40; +pub const DOWN: u8 = 0x80; +pub const DPAD: u8 = 0xF0; + +#[derive(Clone, Copy, Default)] +pub struct Joypad { + /// buttons down this frame + pub held: u8, + /// buttons that went down this frame + pub pressed: u8, + /// d-pad with auto-repeat: fires on press, then after 10 frames, then + /// every 4 frames while held + pub repeat: u8, + repeat_timer: u8, +} + +impl Joypad { + pub fn update(&mut self, now: u8) { + self.pressed = (self.held ^ now) & now; + self.held = now; + self.repeat = 0; + if now == 0 { + return; + } + if self.pressed & DPAD != 0 { + self.repeat = self.pressed & DPAD; + self.repeat_timer = 10; + return; + } + self.repeat_timer = self.repeat_timer.wrapping_sub(1); + if self.repeat_timer == 0 { + self.repeat_timer = 4; + self.repeat = now & DPAD; + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5db62d3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,268 @@ +//! Daedalian Opus, ported to Rust. +//! +//! Usage: `opus [--rom PATH] [--headless OUT_DIR SCRIPT...]` +//! +//! The game's data is read from the original ROM ("Daedalian Opus (USA).gb"), +//! looked for in the current directory and its parent unless given. +//! +//! Controls: arrows = D-pad, Z = A, X = B, Enter = Start, +//! Right Shift / Backspace = Select, Esc = quit. + +mod apu; +mod game; +mod headless; +mod input; +mod rom; +mod sound; +mod video; + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use pixels::{Pixels, SurfaceTexture}; +use winit::application::ApplicationHandler; +use winit::dpi::LogicalSize; +use winit::event::{ElementState, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{Window, WindowId}; + +use game::{Frame, FRAME_HZ}; +use video::{SCREEN_H, SCREEN_W}; + +const ROM_NAME: &str = "Daedalian Opus (USA).gb"; +const SHADES: [[u8; 4]; 4] = [ + [0xFF, 0xFF, 0xFF, 0xFF], + [0xAA, 0xAA, 0xAA, 0xFF], + [0x55, 0x55, 0x55, 0xFF], + [0x00, 0x00, 0x00, 0xFF], +]; + +type AudioQueue = Arc>>; + +/// The host's end of the connection to the game thread. +pub struct Host { + pub frames: Receiver, + pub buttons: SyncSender, +} + +pub fn start_game(data: Arc, sample_rate: u32) -> Host { + let (frame_tx, frame_rx) = sync_channel(1); + let (button_tx, button_rx) = sync_channel(1); + game::spawn(data, sample_rate, game::Link { frames: frame_tx, buttons: button_rx }); + Host { frames: frame_rx, buttons: button_tx } +} + +struct App { + window: Option>, + pixels: Option>, + host: Host, + buttons: u8, + screen: Vec, + next_frame: Instant, + frame_time: Duration, + audio: AudioQueue, + audio_rate: u32, + _stream: Option, +} + +fn start_audio(queue: AudioQueue) -> Option<(cpal::Stream, u32)> { + let device = cpal::default_host().default_output_device()?; + let config = device.default_output_config().ok()?; + if config.sample_format() != cpal::SampleFormat::F32 { + eprintln!("audio: unsupported sample format {:?}, running silent", config.sample_format()); + return None; + } + let rate = config.sample_rate(); + let channels = config.channels() as usize; + let stream = device + .build_output_stream( + config.config(), + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut q = queue.lock().unwrap(); + for frame in data.chunks_mut(channels) { + let l = q.pop_front().unwrap_or(0.0); + let r = q.pop_front().unwrap_or(l); + for (i, s) in frame.iter_mut().enumerate() { + *s = if i % 2 == 0 { l } else { r }; + } + } + }, + |e| eprintln!("audio error: {e}"), + None, + ) + .ok()?; + stream.play().ok()?; + Some((stream, rate)) +} + +impl App { + fn key(&mut self, code: KeyCode, pressed: bool) { + let bit = match code { + KeyCode::ArrowRight => input::RIGHT, + KeyCode::ArrowLeft => input::LEFT, + KeyCode::ArrowUp => input::UP, + KeyCode::ArrowDown => input::DOWN, + KeyCode::KeyZ => input::A, + KeyCode::KeyX => input::B, + KeyCode::ShiftRight | KeyCode::Backspace => input::SELECT, + KeyCode::Enter => input::START, + _ => return, + }; + if pressed { + self.buttons |= bit; + } else { + self.buttons &= !bit; + } + } + + /// Advance the game one frame. False if the game thread has stopped. + fn step(&mut self) -> bool { + let Ok(frame) = self.host.frames.recv() else { return false }; + self.screen = frame.pixels; + let mut q = self.audio.lock().unwrap(); + q.extend(frame.audio); + let max = self.audio_rate as usize / 5 * 2; // keep at most ~200 ms queued + if q.len() > max { + let excess = q.len() - max; + q.drain(..excess); + } + drop(q); + self.host.buttons.send(self.buttons).is_ok() + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attrs = Window::default_attributes() + .with_title("Daedalian Opus") + .with_inner_size(LogicalSize::new(SCREEN_W as f64 * 4.0, SCREEN_H as f64 * 4.0)); + let window = Arc::new(event_loop.create_window(attrs).expect("create window")); + let size = window.inner_size(); + let surface = SurfaceTexture::new(size.width, size.height, window.clone()); + let pixels = Pixels::new(SCREEN_W as u32, SCREEN_H as u32, surface).expect("pixels"); + self.window = Some(window); + self.pixels = Some(pixels); + self.next_frame = Instant::now(); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::KeyboardInput { event, .. } => { + if let PhysicalKey::Code(code) = event.physical_key { + if code == KeyCode::Escape { + event_loop.exit(); + } + self.key(code, event.state == ElementState::Pressed); + } + } + WindowEvent::Resized(size) => { + if let Some(p) = &mut self.pixels { + let _ = p.resize_surface(size.width.max(1), size.height.max(1)); + } + } + WindowEvent::RedrawRequested => { + if let Some(p) = &mut self.pixels { + for (dst, &shade) in p.frame_mut().chunks_exact_mut(4).zip(&self.screen) { + dst.copy_from_slice(&SHADES[shade as usize]); + } + if let Err(e) = p.render() { + eprintln!("render error: {e}"); + event_loop.exit(); + } + } + } + _ => {} + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_none() { + return; + } + let now = Instant::now(); + let mut ran = 0; + while self.next_frame <= now && ran < 4 { + if !self.step() { + eprintln!("the game stopped"); + event_loop.exit(); + return; + } + self.next_frame += self.frame_time; + ran += 1; + } + if self.next_frame < now { + self.next_frame = now; // fell behind; don't try to catch up forever + } + if let Some(w) = &self.window { + w.request_redraw(); + } + event_loop.set_control_flow(ControlFlow::WaitUntil(self.next_frame)); + } +} + +fn find_rom(explicit: Option) -> Option { + if let Some(p) = explicit { + return Some(p.into()); + } + [PathBuf::from(ROM_NAME), PathBuf::from("..").join(ROM_NAME)].into_iter().find(|p| p.exists()) +} + +fn main() { + let mut args: Vec = std::env::args().skip(1).collect(); + let mut rom_path = None; + if let Some(i) = args.iter().position(|a| a == "--rom") { + if i + 1 < args.len() { + rom_path = Some(args.remove(i + 1)); + } + args.remove(i); + } + let Some(rom_path) = find_rom(rom_path) else { + eprintln!("can't find \"{ROM_NAME}\"; pass its path with --rom"); + std::process::exit(1); + }; + let rom = match rom::Rom::load(&rom_path) { + Ok(r) => r, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + let data = Arc::new(rom::GameData::parse(rom)); + + if args.first().map(|s| s.as_str()) == Some("--headless") { + headless::run(data, &args[1..]); + return; + } + + let audio: AudioQueue = Arc::new(Mutex::new(VecDeque::new())); + let (stream, rate) = match start_audio(audio.clone()) { + Some((s, r)) => (Some(s), r), + None => { + eprintln!("audio: no output device, running silent"); + (None, 48_000) + } + }; + let mut app = App { + window: None, + pixels: None, + host: start_game(data, rate), + buttons: 0, + screen: vec![0; SCREEN_W * SCREEN_H], + next_frame: Instant::now(), + frame_time: Duration::from_secs_f64(1.0 / FRAME_HZ), + audio, + audio_rate: rate, + _stream: stream, + }; + let event_loop = EventLoop::new().expect("event loop"); + event_loop.run_app(&mut app).expect("run"); +} diff --git a/src/rom.rs b/src/rom.rs new file mode 100644 index 0000000..01ae714 --- /dev/null +++ b/src/rom.rs @@ -0,0 +1,367 @@ +//! The game's data, parsed out of the original ROM at startup. +//! +//! Nothing copyrighted is compiled into this program: levels, piece shapes, +//! screen layouts, story text, graphics and music all come from the .gb file +//! the player supplies. This module turns the raw bytes into typed structures; +//! the addresses below are where each table lives in the ROM. + +/// Where things live in the ROM. +pub mod addr { + // ---- levels ---- + pub const LEVEL_PIECES: u16 = 0x0210; // 36 pointers to [id, y, x]* 0-terminated lists + pub const PIECE_SHAPE_INDEX: u16 = 0x07FA; // (type-1)*8 + orientation -> shape number + pub const LEVEL_STORY: u16 = 0x0EA5; // story number shown before each level (0 = none) + pub const LEVEL_BOARDS: u16 = 0x19E4; // 36 pointers to board draw scripts + pub const LEVEL_NEW_PIECE: u16 = 0x255B; // piece that drops from the sky on the map + pub const SHAPE_PIECE_ID: u16 = 0x264B; // shape number -> piece id (inverse of the above) + pub const LEVEL_PIECES_NEEDED: u16 = 0x2771; + pub const LEVEL_MUSIC: u16 = 0x27A0; + pub const LEVEL_CURSOR_Y: u16 = 0x27F3; + pub const LEVEL_CURSOR_X: u16 = 0x2817; + pub const PASSWORDS: u16 = 0x3433; // 36 x 4 ASCII letters + pub const SHAPES: u16 = 0x350B; // pointers to [attr|count, (yx, tile)*count] + pub const SHAPE_COUNT: usize = 0x5E; + + // ---- screen layouts (tilemap draw scripts) ---- + pub const ENDING_SCREEN: u16 = 0x0ABB; + pub const ENDING_ART: [u16; 5] = [0x0B7C, 0x0B8E, 0x0BAB, 0x0BBD, 0x0BDA]; + pub const LEVEL_CLEAR_BANNER: u16 = 0x0D46; // 5-byte scripts, drawn one per frame + pub const INTERMISSION_SCREEN: u16 = 0x0ED6; + pub const HOW_TO_PLAY_SCREEN: u16 = 0x0FE2; + pub const MAP_SCREEN: u16 = 0x12D4; + pub const MAP_SCENES: u16 = 0x1303; // 3 pointers: first level, middle, last level + pub const MAP_CASTLE_INDEX: u16 = 0x15C7; + pub const MAP_CASTLES: u16 = 0x15EB; // 5 pointers + pub const MAP_NEXT_CASTLE_INDEX: u16 = 0x1692; + pub const MAP_NEXT_CASTLES: u16 = 0x16B6; // 4 pointers + pub const PUZZLE_LIST_SCREEN: u16 = 0x18BE; + pub const PUZZLE_HUD: u16 = 0x27C4; + pub const TITLE_SCREEN: u16 = 0x2F2D; + pub const TITLE_SCREEN_ALT: u16 = 0x2FFE; // drawn over a copy of the title in map 1 + pub const PASSWORD_SCREEN: u16 = 0x33DF; + + // ---- text ---- + pub const STORIES: u16 = 0x2A5A; // 4 pointers + pub const ENDING_TEXT: [u16; 3] = [0x0BEF, 0x0C22, 0x0C51]; + + // ---- graphics (uncompressed 2bpp tiles) ---- + pub const GFX_MAP_TERRAIN: u16 = 0x5000; + pub const GFX_ENDING_A: u16 = 0x5010; + pub const GFX_ENDING_B: u16 = 0x5340; + pub const GFX_STORY: u16 = 0x5800; + pub const GFX_SPRITES: u16 = 0x6000; // sprites, then puzzle tiles from $6800 + pub const GFX_ENDING_C: u16 = 0x6200; + pub const GFX_BANNER_SPARKLE: u16 = 0x6900; // two 2-tile frames + pub const GFX_HOW_TO_PLAY: u16 = 0x7000; + pub const GFX_ENDING_D: u16 = 0x7300; + pub const GFX_FONT: u16 = 0x7800; + + // ---- sound ---- + pub const SOUND_HEADERS: u16 = 0x39FA; // indexed by (sound id - $80) * 2 + pub const SILENCE_HEADER: u16 = 0x3BF0; + pub const VIBRATO_TABLES: u16 = 0x40E5; // indexed by vibrato number * 2 (number >= 1) + pub const WAVEFORMS: u16 = 0x4157; + pub const NOTE_PERIODS: u16 = 0x419F; +} + +/// The raw ROM image. +pub struct Rom { + bytes: Vec, +} + +impl Rom { + pub fn load(path: &std::path::Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() != 0x8000 || &bytes[0x134..0x142] != b"DAEDALIAN OPUS" { + return Err(format!("{} is not a Daedalian Opus (USA) ROM", path.display())); + } + Ok(Rom { bytes }) + } + + pub fn byte(&self, addr: u16) -> u8 { + self.bytes[addr as usize] + } + + pub fn word(&self, addr: u16) -> u16 { + u16::from_le_bytes([self.byte(addr), self.byte(addr.wrapping_add(1))]) + } + + pub fn bytes(&self, addr: u16, len: usize) -> &[u8] { + &self.bytes[addr as usize..addr as usize + len] + } + + fn table(&self, addr: u16, len: usize) -> Vec { + self.bytes(addr, len).to_vec() + } + + fn pointers(&self, addr: u16, count: usize) -> Vec { + (0..count).map(|i| self.word(addr + 2 * i as u16)).collect() + } +} + +/// A tilemap draw script: the game's format for screen layouts. Each command +/// writes a run of bytes into VRAM, either along a row or down a column. +#[derive(Clone, Default)] +pub struct DrawScript(pub Vec); + +#[derive(Clone)] +pub struct DrawCmd { + pub addr: u16, + pub vertical: bool, + pub tiles: Tiles, +} + +#[derive(Clone)] +pub enum Tiles { + /// the same tile repeated + Run(u8, usize), + /// one tile per position + List(Vec), +} + +impl DrawScript { + /// Command byte: bit 6 = vertical, bit 5 = repeat one tile, the rest + /// ($9F mask) is the address high byte. Then address low, count, data. + /// A zero command byte ends the script. + pub fn parse(rom: &Rom, mut a: u16) -> DrawScript { + let mut cmds = Vec::new(); + loop { + let c = rom.byte(a); + if c == 0 { + return DrawScript(cmds); + } + let addr = u16::from(c & 0x9F) << 8 | u16::from(rom.byte(a + 1)); + let count = match rom.byte(a + 2) { + 0 => 256, + n => n as usize, + }; + a += 3; + let tiles = if c & 0x20 != 0 { + a += 1; + Tiles::Run(rom.byte(a - 1), count) + } else { + a += count as u16; + Tiles::List(rom.bytes(a - count as u16, count).to_vec()) + }; + cmds.push(DrawCmd { addr, vertical: c & 0x40 != 0, tiles }); + } + } +} + +/// One cell of a shape: packed y/x offsets in 8-pixel units, and a tile. +#[derive(Clone, Copy)] +pub struct ShapeCell { + pub yx: u8, + pub tile: u8, +} + +impl ShapeCell { + /// Offset used when stamping a piece onto the board (unsigned nibbles). + pub fn board_offset(self) -> (u8, u8) { + ((self.yx >> 4) * 8, (self.yx & 0x0F) * 8) + } + + /// Offset used when drawing the shape as sprites (signed nibbles). + pub fn sprite_offset(self) -> (u8, u8) { + let dy = ((self.yx & 0xF0) as i8) >> 1; + let dx = (((self.yx << 4) & 0xF0) as i8) >> 1; + (dy as u8, dx as u8) + } +} + +/// A shape: the cells of a puzzle piece in one orientation, or a multi-tile +/// sprite (cursor, hero, messenger...). Pieces and sprites share the format. +#[derive(Clone)] +pub struct Shape { + /// OAM attribute bits used when drawn as sprites (palette, flips) + pub attr: u8, + pub cells: Vec, +} + +/// A piece as it sits on the board when a level starts. +#[derive(Clone, Copy)] +pub struct StartPiece { + pub id: u8, + pub y: u8, + pub x: u8, +} + +#[derive(Clone)] +pub struct Level { + pub pieces: Vec, + pub board: DrawScript, + pub pieces_needed: u8, + pub music: u8, + /// cursor start position, sprite coordinates + pub cursor: (u8, u8), + /// piece id that falls from the sky on the map screen (0 = none) + pub new_piece: u8, + /// story shown before the level (0 = none, 1-4) + pub story: u8, + pub map_castle: u8, + pub map_next_castle: u8, +} + +/// A piece of story text, as tile numbers plus layout codes. +#[derive(Clone, Copy, PartialEq)] +pub enum Text { + Char(u8), + Newline, + PageBreak, + End, +} + +fn parse_text(rom: &Rom, mut a: u16) -> Vec { + let mut out = Vec::new(); + loop { + let t = match rom.byte(a) { + 0x80 => Text::Newline, + 0x81 => Text::PageBreak, + 0x82 => Text::End, + c => Text::Char(c), + }; + out.push(t); + a += 1; + if t == Text::End { + return out; + } + } +} + +pub struct Scripts { + pub title: DrawScript, + pub title_alt: DrawScript, + pub password: DrawScript, + pub puzzle_list: DrawScript, + pub how_to_play: DrawScript, + pub intermission: DrawScript, + pub ending: DrawScript, + pub ending_art: Vec, + pub puzzle_hud: DrawScript, + pub level_clear_banner: Vec, + pub map: DrawScript, + pub map_scenes: Vec, + pub map_castles: Vec, + pub map_next_castles: Vec, +} + +/// Everything the game needs, parsed from the ROM. +pub struct GameData { + pub rom: Rom, + pub levels: Vec, + pub shapes: Vec, + piece_shape_index: Vec, + shape_piece_id: Vec, + /// 36 four-letter ASCII passwords; entry n starts level n+1 (0-based), + /// and the last one opens the puzzle list. + pub passwords: Vec<[u8; 4]>, + pub stories: Vec>, + pub ending_text: Vec>, + pub scripts: Scripts, +} + +pub const LEVEL_COUNT: usize = 36; + +impl GameData { + pub fn parse(rom: Rom) -> GameData { + let script = |a| DrawScript::parse(&rom, a); + let piece_lists = rom.pointers(addr::LEVEL_PIECES, LEVEL_COUNT); + let boards = rom.pointers(addr::LEVEL_BOARDS, LEVEL_COUNT); + let levels = (0..LEVEL_COUNT) + .map(|n| { + let mut pieces = Vec::new(); + let mut a = piece_lists[n]; + while rom.byte(a) != 0 { + pieces.push(StartPiece { id: rom.byte(a), y: rom.byte(a + 1), x: rom.byte(a + 2) }); + a += 3; + } + let n16 = n as u16; + Level { + pieces, + board: script(boards[n]), + pieces_needed: rom.byte(addr::LEVEL_PIECES_NEEDED + n16), + music: rom.byte(addr::LEVEL_MUSIC + n16), + cursor: (rom.byte(addr::LEVEL_CURSOR_Y + n16), rom.byte(addr::LEVEL_CURSOR_X + n16)), + new_piece: rom.byte(addr::LEVEL_NEW_PIECE + n16), + story: rom.byte(addr::LEVEL_STORY + n16), + map_castle: rom.byte(addr::MAP_CASTLE_INDEX + n16), + map_next_castle: rom.byte(addr::MAP_NEXT_CASTLE_INDEX + n16), + } + }) + .collect(); + + let shapes = rom + .pointers(addr::SHAPES, addr::SHAPE_COUNT) + .into_iter() + .map(|a| { + let head = rom.byte(a); + let cells = (0..(head & 0x0F) as u16) + .map(|i| ShapeCell { yx: rom.byte(a + 1 + 2 * i), tile: rom.byte(a + 2 + 2 * i) }) + .collect(); + Shape { attr: head & 0xF0, cells } + }) + .collect(); + + let passwords = (0..LEVEL_COUNT as u16) + .map(|i| rom.bytes(addr::PASSWORDS + 4 * i, 4).try_into().unwrap()) + .collect(); + + let scripts = Scripts { + title: script(addr::TITLE_SCREEN), + title_alt: script(addr::TITLE_SCREEN_ALT), + password: script(addr::PASSWORD_SCREEN), + puzzle_list: script(addr::PUZZLE_LIST_SCREEN), + how_to_play: script(addr::HOW_TO_PLAY_SCREEN), + intermission: script(addr::INTERMISSION_SCREEN), + ending: script(addr::ENDING_SCREEN), + ending_art: addr::ENDING_ART.iter().map(|&a| script(a)).collect(), + puzzle_hud: script(addr::PUZZLE_HUD), + level_clear_banner: { + let mut v = Vec::new(); + let mut a = addr::LEVEL_CLEAR_BANNER; + while rom.byte(a) != 0 { + v.push(script(a)); + a += 5; + } + v + }, + map: script(addr::MAP_SCREEN), + map_scenes: rom.pointers(addr::MAP_SCENES, 3).into_iter().map(script).collect(), + map_castles: rom.pointers(addr::MAP_CASTLES, 5).into_iter().map(script).collect(), + map_next_castles: rom.pointers(addr::MAP_NEXT_CASTLES, 4).into_iter().map(script).collect(), + }; + + GameData { + levels, + shapes, + piece_shape_index: rom.table(addr::PIECE_SHAPE_INDEX, 0x80), + shape_piece_id: rom.table(addr::SHAPE_PIECE_ID, 0x41), + passwords, + stories: rom.pointers(addr::STORIES, 4).into_iter().map(|a| parse_text(&rom, a)).collect(), + ending_text: addr::ENDING_TEXT.iter().map(|&a| parse_text(&rom, a)).collect(), + scripts, + rom, + } + } + + /// Piece id is `type << 4 | orientation`: bits 0-1 rotate, bit 2 flips. + pub fn piece_shape(&self, piece_id: u8) -> u8 { + let d = piece_id.wrapping_sub(0x10); + self.piece_shape_index[((d & 0xF0) >> 1 | (d & 0x07)) as usize] + } + + /// The piece id a placed shape is recorded under on the board. + pub fn shape_piece_id(&self, shape: u8) -> u8 { + self.shape_piece_id.get(shape as usize).copied().unwrap_or(0) + } + + /// Story number for a level index (index 36 exists in the table too). + pub fn story_for(&self, level: u8) -> u8 { + self.rom.byte(addr::LEVEL_STORY + level as u16) + } + + /// Raw tile graphics. + pub fn gfx(&self, addr: u16, len: usize) -> &[u8] { + self.rom.bytes(addr, len) + } +} diff --git a/src/sound.rs b/src/sound.rs new file mode 100644 index 0000000..5edeeb7 --- /dev/null +++ b/src/sound.rs @@ -0,0 +1,579 @@ +//! The game's sound driver: a byte-code music sequencer with 8 channels +//! (4 for music, 4 for sound effects that temporarily take over the music's +//! hardware voices). Runs once per frame and plays the sound chip in `apu.rs`. +//! +//! Songs and effects are byte code in the ROM. A sound's header is a channel +//! count followed by 8 bytes per channel: flags, voice (0-1 pulse, 2 wave, +//! 3 noise), stereo mask, start address, transpose, vibrato, envelope. +//! Channel byte code: +//! +//! | byte | meaning | +//! |-----------|------------------------------------------------------------| +//! | $00-$7F | set note length (frames) and replay the current note | +//! | $80 | rest | +//! | $81-$DF | note (+ transpose); then an optional slide step if sliding, | +//! | | then an optional length byte | +//! | $E0/$E1 a | call subroutine at a | +//! | $E2 | return | +//! | $E3 | end of song | +//! | $E4 i n a | loop: counter i starts at n; jump to a until it runs out | +//! | $E5 a | jump | +//! | $E6 v | envelope | $E7 t | transpose += t | +//! | $E8 d | duty cycle | $E9 m | stereo mask | +//! | $EA w | load waveform w | $EB | end of sound effect | +//! | $EC / $ED | pitch slide on/off | $EE | tie (don't retrigger) | +//! | $EF v | master volume | $F0 v | vibrato table v | +//! | $F1 p | set period low byte directly | + +use std::collections::VecDeque; +use std::sync::Arc; + +use crate::apu::*; +use crate::rom::{addr, GameData}; + +// ---- sound ids ---- +pub const MUSIC_TITLE: u8 = 0x81; +pub const MUSIC_MAP: u8 = 0x83; // also the story and how-to-play screens +pub const JINGLE_LEVEL_CLEAR: u8 = 0x86; +pub const SFX_CONFIRM: u8 = 0x87; +pub const SFX_MENU_MOVE: u8 = 0x88; +pub const SFX_ROTATE: u8 = 0x89; // also: puzzle reset +pub const SFX_PASSWORD_LETTER: u8 = 0x8A; +pub const SFX_FLIP: u8 = 0x8B; +pub const SFX_PLACE: u8 = 0x8C; +pub const SFX_BRIDGE: u8 = 0x8D; +pub const SFX_PICK_UP: u8 = 0x8E; +pub const SFX_ERROR: u8 = 0x8F; +pub const SFX_STEP: u8 = 0x90; +pub const SFX_MOVE_PIECE: u8 = 0x91; +pub const SFX_PIECE_LANDS: u8 = 0x92; +pub const PAUSE_MUSIC: u8 = 0x95; +pub const RESUME_MUSIC: u8 = 0x96; + +/// "no sound waiting to start" +const IDLE: u8 = 0x80; + +// channel flag bits +const ACTIVE: u8 = 0x80; +const SLIDING: u8 = 0x20; +const PAUSED: u8 = 0x10; +/// a sound effect is using this music channel's voice +const OVERRIDDEN: u8 = 0x04; +/// the current note should restart its envelope +const RETRIGGER: u8 = 0x01; + +const PULSE1: u8 = 0; +const PULSE2: u8 = 1; +const WAVE: u8 = 2; +const NOISE: u8 = 3; + +#[derive(Clone, Default)] +struct Channel { + flags: u8, + voice: u8, + stereo: u8, + pc: u16, + transpose: u8, + vibrato: u8, + envelope: u8, + return_stack: Vec, + length: u8, + timer: u8, + vibrato_pos: u8, + /// period of the current note (0 = rest) + base: u16, + /// slid period + slide: u16, + /// period actually played this frame + period: u16, + slide_step: u8, + duty: u8, + loop_counters: [u8; 11], +} + +/// Last values written to the chip, so unchanged values aren't rewritten +/// (rewriting a period would restart the note). +#[derive(Clone, Copy, Default)] +struct Written { + env: [u8; 4], + lo: [u8; 4], + hi: [u8; 4], +} + +pub struct SoundDriver { + data: Arc, + queue: VecDeque, + pending: u8, + music_priority: u8, + sfx_priority: u8, + music_wave: u8, + sfx_wave: u8, + loaded_wave: u8, + master_volume: u8, + /// 0-3 music, 4-7 sound effects + channels: [Channel; 8], + written: Written, +} + +impl SoundDriver { + pub fn new(data: Arc) -> SoundDriver { + SoundDriver { + data, + queue: VecDeque::new(), + pending: 0, + music_priority: 0, + sfx_priority: 0, + music_wave: 0, + sfx_wave: 0, + loaded_wave: 0, + master_volume: 0, + channels: Default::default(), + written: Written::default(), + } + } + + /// Queue a song or sound effect; one queued sound starts per frame. + pub fn play(&mut self, id: u8) { + self.queue.push_back(id); + } + + fn rom(&self, a: u16) -> u8 { + self.data.rom.byte(a) + } + + fn rom_word(&self, a: u16) -> u16 { + self.data.rom.word(a) + } + + /// Once per frame. + pub fn update(&mut self, apu: &mut Apu) { + if let Some(id) = self.queue.pop_front() { + self.pending = id; + } + self.start_pending(apu); + self.mark_overridden_voices(); + for ch in 0..8 { + if self.channels[ch].flags & ACTIVE != 0 { + self.update_channel(ch, apu); + } + } + } + + fn start_pending(&mut self, apu: &mut Apu) { + let id = self.pending; + if id & 0x80 == 0 || id == IDLE { + return; + } + if id >= 0x97 { + // out-of-range ids silence the music, whatever is playing + self.music_priority = 0; + self.reset_chip(apu); + self.load_channels(addr::SILENCE_HEADER, 0); + return; + } + let header = self.rom_word(addr::SOUND_HEADERS + 2 * (id - 0x80) as u16); + match id { + 0x81..=0x85 => self.start_music(header, 1, apu), + 0x86..=0x94 => self.start_sfx(header, 1), + PAUSE_MUSIC => { + self.sfx_priority += 1; + for ch in &mut self.channels[..4] { + ch.flags |= PAUSED; + } + self.start_sfx(header, 2); + } + RESUME_MUSIC => { + // The original then starts a "sound effect" whose header + // pointer is garbage (it points into code); just resume. + self.written = Written::default(); + self.sfx_priority += 1; + for ch in &mut self.channels[..4] { + ch.flags &= !PAUSED; + } + self.pending = IDLE; + } + _ => self.pending = IDLE, + } + } + + fn start_music(&mut self, header: u16, priority: u8, apu: &mut Apu) { + if self.music_priority > priority { + self.pending = IDLE; + return; + } + self.music_priority = priority; + self.reset_chip(apu); + self.load_channels(header, 0); + } + + fn start_sfx(&mut self, header: u16, priority: u8) { + if self.sfx_priority > priority { + self.pending = IDLE; + return; + } + self.sfx_priority = priority; + self.written = Written::default(); + for ch in &mut self.channels[4..] { + ch.flags = 0; + } + self.load_channels(header, 4); + } + + fn load_channels(&mut self, header: u16, first: usize) { + let count = self.rom(header) as usize; + for i in 0..count.min(8 - first) { + let a = header + 1 + 8 * i as u16; + self.channels[first + i] = Channel { + flags: self.rom(a), + voice: self.rom(a + 1), + stereo: self.rom(a + 2), + pc: self.rom_word(a + 3), + transpose: self.rom(a + 5), + vibrato: self.rom(a + 6), + envelope: self.rom(a + 7), + length: 1, + ..Default::default() + }; + } + self.pending = IDLE; + } + + /// Stop everything and put the chip in a known state (done before each song). + fn reset_chip(&mut self, apu: &mut Apu) { + for ch in &mut self.channels { + ch.flags = 0; + } + self.load_waveform(1, apu); + apu.write(NR52, 0x8F); + self.master_volume = 0x77; + self.music_wave = 0; + self.sfx_wave = 0; + self.loaded_wave = 0; + self.init_voices(apu); + } + + fn init_voices(&mut self, apu: &mut Apu) { + apu.write(NR32, 0); + apu.write(NR51, 0); + apu.write(NR10, 0x08); + apu.write(NR12, 0xF0); + apu.write(NR22, 0xF0); + apu.write(NR42, 0xF0); + apu.write(NR30, 0x80); + for r in [NR13, NR23, NR33, NR43] { + apu.write(r, 0xFF); + } + apu.write(NR50, self.master_volume); + for r in [NR14, NR24, NR34, NR44] { + apu.write(r, 0x87); + } + self.written = Written::default(); + } + + fn load_waveform(&mut self, n: u8, apu: &mut Apu) { + let src = self.rom_word(addr::WAVEFORMS + 2 * n as u16); + let power = apu.read(NR52); + apu.write(NR52, 0); + for i in 0..16 { + apu.write(WAVE_RAM + i, self.rom(src + i)); + } + apu.write(NR52, power); + self.init_voices(apu); + } + + /// A sound effect channel takes over the music channel with the same voice. + fn mark_overridden_voices(&mut self) { + for ch in &mut self.channels[..4] { + ch.flags &= !OVERRIDDEN; + } + for i in 4..8 { + if self.channels[i].flags & ACTIVE != 0 { + let voice = self.channels[i].voice as usize; + if let Some(m) = self.channels.get_mut(voice) { + m.flags |= OVERRIDDEN; + } + } + } + } + + fn update_channel(&mut self, n: usize, apu: &mut Apu) { + if self.channels[n].flags & PAUSED != 0 { + return; + } + let ch = &mut self.channels[n]; + ch.timer = ch.timer.wrapping_add(1); + if ch.timer == ch.length { + if !self.next_event(n, apu) { + return; // channel ended + } + let ch = &mut self.channels[n]; + if ch.flags & RETRIGGER != 0 { + ch.vibrato_pos = 0; + } + } + if self.channels[n].flags & OVERRIDDEN != 0 { + return; + } + self.update_period(n); + match self.channels[n].voice { + PULSE1 => self.write_pulse(n, 0, apu), + PULSE2 => self.write_pulse(n, 1, apu), + WAVE => self.write_wave(n, apu), + NOISE => self.write_noise(n, apu), + _ => {} + } + } + + /// Apply pitch slide and vibrato to the note's period. + fn update_period(&mut self, n: usize) { + let ch = &mut self.channels[n]; + ch.period = ch.base; + if ch.base == 0 { + return; + } + if ch.flags & SLIDING != 0 { + ch.slide = add_signed(ch.slide, ch.slide_step); + ch.period = ch.slide; + } + if ch.vibrato == 0 { + return; + } + let table = self.rom_word(addr::VIBRATO_TABLES + 2 * self.channels[n].vibrato as u16); + loop { + let ch = &mut self.channels[n]; + let pos = ch.vibrato_pos; + ch.vibrato_pos = pos.wrapping_add(1); + match self.data.rom.byte(table + pos as u16) { + 0x80 => ch.vibrato_pos = 0, // restart the table + 0x81 => ch.vibrato_pos = ch.vibrato_pos.wrapping_sub(2), // repeat previous entry + 0x82 => { + ch.vibrato_pos = ch.vibrato_pos.wrapping_sub(1); // hold + return; + } + 0x83 => ch.vibrato_pos = self.data.rom.byte(table + pos as u16 + 1), // jump + delta => { + ch.period = add_signed(ch.period, delta); + return; + } + } + } + } + + fn silence(voice: u8, written: &mut Written, apu: &mut Apu) { + match voice { + PULSE1 | PULSE2 => { + let (lo, hi) = if voice == PULSE1 { (NR13, NR14) } else { (NR23, NR24) }; + apu.write(lo, 0xFF); + apu.write(hi, 0x87); + written.lo[voice as usize] = 0xFF; + written.hi[voice as usize] = 0x87; + } + WAVE => { + apu.write(NR33, 0xFF); + apu.write(NR34, 0x07); + written.lo[2] = 0xFF; + written.hi[2] = 0x07; + } + NOISE => { + apu.write(NR43, 0xFF); + apu.write(NR44, 0x80); + written.lo[3] = 0xFF; + } + _ => {} + } + } + + fn write_pulse(&mut self, n: usize, voice: usize, apu: &mut Apu) { + let [nr1, nr2, nr3, nr4] = if voice == 0 { [NR11, NR12, NR13, NR14] } else { [NR21, NR22, NR23, NR24] }; + let w = &mut self.written; + let ch = &mut self.channels[n]; + apu.write(nr1, ch.duty & 0xC0); + if w.env[voice] != ch.envelope { + w.env[voice] = ch.envelope; + apu.write(nr2, ch.envelope); + } + if ch.period == 0 { + Self::silence(voice as u8, w, apu); + return; + } + let pan = if voice == 0 { 0x11 } else { 0x22 }; + apu.write(NR51, (apu.read(NR51) | pan) & ch.stereo); + let [lo, hi] = ch.period.to_le_bytes(); + let retrigger = ch.flags & RETRIGGER != 0; + if !retrigger && w.lo[voice] == lo && w.hi[voice] == hi { + return; + } + apu.write(nr3, lo); + w.lo[voice] = lo; + w.hi[voice] = hi; + ch.flags &= !RETRIGGER; + apu.write(nr4, (hi & 7) | if retrigger { 0x80 } else { 0 }); + } + + fn write_wave(&mut self, n: usize, apu: &mut Apu) { + let w = &mut self.written; + let ch = &mut self.channels[n]; + if ch.period == 0 { + Self::silence(WAVE, w, apu); + return; + } + if w.env[2] != ch.envelope { + w.env[2] = ch.envelope; + apu.write(NR32, ch.envelope & 0x60); + } + apu.write(NR51, (apu.read(NR51) | 0x44) & ch.stereo); + let [lo, hi] = ch.period.to_le_bytes(); + if w.lo[2] == lo && w.hi[2] == hi { + return; + } + apu.write(NR33, lo); + w.lo[2] = lo; + w.hi[2] = hi; + apu.write(NR34, hi & 7); // the wave voice is never restarted + } + + fn write_noise(&mut self, n: usize, apu: &mut Apu) { + let w = &mut self.written; + let ch = &mut self.channels[n]; + if w.env[3] != ch.envelope { + w.env[3] = ch.envelope; + apu.write(NR42, ch.envelope); + } + let poly = ch.period as u8; + if poly == 0 { + Self::silence(NOISE, w, apu); + return; + } + apu.write(NR51, (apu.read(NR51) | 0x88) & ch.stereo); + if w.lo[3] == poly { + return; + } + apu.write(NR43, poly); + w.lo[3] = poly; + // (sic) noise restarts only when the note is *not* flagged to retrigger + let restart = if ch.flags & RETRIGGER != 0 { 0 } else { 0x80 }; + ch.flags &= !RETRIGGER; + apu.write(NR44, restart); + } + + /// Run byte code up to the next note. Returns false if the channel ended. + fn next_event(&mut self, n: usize, apu: &mut Apu) -> bool { + self.channels[n].flags |= RETRIGGER; + let mut pc = self.channels[n].pc; + loop { + let op = self.rom(pc); + pc += 1; + if op >= 0xE0 { + match self.command(n, op, &mut pc, apu) { + Some(()) => continue, + None => return false, + } + } + let ch = &mut self.channels[n]; + if op < 0x80 { + ch.length = op; + } else { + let note = if op == 0x80 { op } else { op.wrapping_add(ch.transpose) }; + ch.base = self.data.rom.word(addr::NOTE_PERIODS + (note as u16 * 2 & 0xFF)); + ch.slide = ch.base; + if ch.flags & SLIDING != 0 { + ch.slide_step = self.data.rom.byte(pc); + pc += 1; + } + let next = self.data.rom.byte(pc); + if next & 0x80 == 0 { + ch.length = next; + pc += 1; + } + } + ch.pc = pc; + ch.timer = 0; + return true; + } + } + + /// Execute one $E0-$F1 command. None = the channel stopped. + fn command(&mut self, n: usize, op: u8, pc: &mut u16, apu: &mut Apu) -> Option<()> { + let arg = self.rom(*pc); + let target = self.rom_word(*pc); + let ch = &mut self.channels[n]; + match op { + 0xE0 | 0xE1 => { + ch.return_stack.push(*pc + 2); + *pc = target; + } + 0xE2 => *pc = ch.return_stack.pop().unwrap_or(*pc), + 0xE3 => { + self.music_priority = 0; + self.stop_channel(n, apu); + return None; + } + 0xE4 => { + let counter = &mut ch.loop_counters[arg as usize]; + if *counter == 0 { + *counter = self.data.rom.byte(*pc + 1); + } + *counter -= 1; + *pc = if *counter == 0 { *pc + 4 } else { self.data.rom.word(*pc + 2) }; + } + 0xE5 => *pc = target, + 0xE6 => (ch.envelope, *pc) = (arg, *pc + 1), + 0xE7 => (ch.transpose, *pc) = (ch.transpose.wrapping_add(arg), *pc + 1), + 0xE8 => (ch.duty, *pc) = (arg, *pc + 1), + 0xE9 => (ch.stereo, *pc) = (arg, *pc + 1), + 0xEA => { + *pc += 1; + self.select_waveform(n, arg, apu); + } + 0xEB => { + self.sfx_priority = 0; + if self.music_wave != self.loaded_wave { + self.loaded_wave = self.music_wave; + self.load_waveform(self.music_wave, apu); + } + self.stop_channel(n, apu); + return None; + } + 0xEC => ch.flags |= SLIDING, + 0xED => ch.flags &= !SLIDING, + 0xEE => ch.flags &= !RETRIGGER, + 0xEF => { + *pc += 1; + self.master_volume = arg; + apu.write(NR50, arg); + } + 0xF0 => (ch.vibrato, *pc) = (arg, *pc + 1), + 0xF1 => (ch.base, *pc) = ((ch.base & 0xFF00) | arg as u16, *pc + 1), + _ => {} // unused opcodes + } + Some(()) + } + + fn select_waveform(&mut self, n: usize, w: u8, apu: &mut Apu) { + if self.channels[n].flags & OVERRIDDEN != 0 { + self.music_wave = w; + return; + } + if w == self.loaded_wave { + return; + } + self.loaded_wave = w; + // (sic) the original counts only channels 0-2 as music here + if n < 3 { + self.music_wave = w; + } else { + self.sfx_wave = w; + } + self.load_waveform(w, apu); + } + + fn stop_channel(&mut self, n: usize, apu: &mut Apu) { + self.channels[n].flags = 0; + Self::silence(self.channels[n].voice, &mut self.written, apu); + } +} + +/// period + signed 8-bit delta +fn add_signed(v: u16, delta: u8) -> u16 { + v.wrapping_add(delta as i8 as u16) +} diff --git a/src/video.rs b/src/video.rs new file mode 100644 index 0000000..bed5674 --- /dev/null +++ b/src/video.rs @@ -0,0 +1,167 @@ +//! The display: tile graphics, two 32x32 background maps, 40 hardware +//! sprites and three palettes, rendered to a 160x144 image of 4 shades. +//! +//! This is the model the game's art is built for, so the game draws by +//! writing tiles into maps and placing sprites, exactly as the original did. +//! The game never scrolls and never uses the window layer, so neither exists +//! here. + +use crate::rom::{DrawScript, Tiles}; + +pub const SCREEN_W: usize = 160; +pub const SCREEN_H: usize = 144; + +/// Background map base addresses (the game addresses tiles by VRAM address, +/// and so do its draw scripts). +pub const MAP0: u16 = 0x9800; +pub const MAP1: u16 = 0x9C00; +pub const MAP_SIZE: usize = 0x400; + +/// Address of the map cell at (column, row). +pub const fn map_cell(map: u16, col: u16, row: u16) -> u16 { + map + row * 32 + col +} + +#[derive(Clone, Copy, Default)] +pub struct Sprite { + /// screen y + 16 (0 hides the sprite) + pub y: u8, + /// screen x + 8 + pub x: u8, + pub tile: u8, + /// bit 4 = palette 1, bit 5 = x flip, bit 6 = y flip, bit 7 = behind background + pub attr: u8, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Display { + Off, + /// background (from the given map) and sprites + On(u16), +} + +pub struct Video { + vram: Box<[u8; 0x2000]>, + pub sprites: [Sprite; 40], + pub bg_palette: u8, + pub obj_palettes: [u8; 2], + pub display: Display, +} + +impl Video { + pub fn new() -> Video { + Video { + vram: Box::new([0; 0x2000]), + sprites: [Sprite::default(); 40], + bg_palette: 0, + obj_palettes: [0; 2], + display: Display::Off, + } + } + + pub fn peek(&self, addr: u16) -> u8 { + self.vram[(addr & 0x1FFF) as usize] + } + + pub fn poke(&mut self, addr: u16, v: u8) { + if (0x8000..0xA000).contains(&addr) { + self.vram[(addr - 0x8000) as usize] = v; + } + } + + /// Copy tile graphics (or anything else) into VRAM. + pub fn load(&mut self, addr: u16, bytes: &[u8]) { + for (i, &b) in bytes.iter().enumerate() { + self.poke(addr.wrapping_add(i as u16), b); + } + } + + /// Fill both background maps with one tile. + pub fn fill_maps(&mut self, tile: u8) { + self.vram[0x1800..0x2000].fill(tile); + } + + pub fn copy_map(&mut self, from: u16, to: u16) { + let (f, t) = ((from - 0x8000) as usize, (to - 0x8000) as usize); + self.vram.copy_within(f..f + MAP_SIZE, t); + } + + /// Write `n` copies of `tile` along a row. + pub fn fill_row(&mut self, addr: u16, tile: u8, n: u16) { + for i in 0..n { + self.poke(addr + i, tile); + } + } + + pub fn draw(&mut self, script: &DrawScript) { + for cmd in &script.0 { + let step = if cmd.vertical { 32 } else { 1 }; + let mut a = cmd.addr; + let mut put = |t| { + self.poke(a, t); + a = a.wrapping_add(step); + }; + match &cmd.tiles { + Tiles::Run(t, n) => (0..*n).for_each(|_| put(*t)), + Tiles::List(ts) => ts.iter().for_each(|&t| put(t)), + } + } + } + + fn tile_pixel(&self, tile_addr: usize, x: usize, y: usize) -> u8 { + let lo = self.vram[tile_addr + y * 2]; + let hi = self.vram[tile_addr + y * 2 + 1]; + ((lo >> (7 - x)) & 1) | (((hi >> (7 - x)) & 1) << 1) + } + + /// Background tiles 0-127 come from $9000, 128-255 from $8800. + fn bg_tile_addr(tile: u8) -> usize { + (0x1000 + (tile as i8 as isize) * 16) as usize + } + + /// Render the frame as shades 0 (white) to 3 (black). + pub fn render(&self, out: &mut [u8]) { + let map = match self.display { + Display::Off => { + out.fill(0); + return; + } + Display::On(map) => (map - 0x8000) as usize, + }; + let shade = |pal: u8, c: u8| (pal >> (c * 2)) & 3; + let mut bg_color = [0u8; SCREEN_W * SCREEN_H]; + for y in 0..SCREEN_H { + for x in 0..SCREEN_W { + let tile = self.vram[map + (y / 8) * 32 + x / 8]; + let c = self.tile_pixel(Self::bg_tile_addr(tile), x % 8, y % 8); + bg_color[y * SCREEN_W + x] = c; + out[y * SCREEN_W + x] = shade(self.bg_palette, c); + } + } + // lower sprite numbers win, so draw from the end + for s in self.sprites.iter().rev() { + let (sy, sx) = (s.y as i32 - 16, s.x as i32 - 8); + let pal = self.obj_palettes[(s.attr >> 4 & 1) as usize]; + for ty in 0..8 { + let py = sy + ty as i32; + if !(0..SCREEN_H as i32).contains(&py) { + continue; + } + let row = if s.attr & 0x40 != 0 { 7 - ty } else { ty }; + for tx in 0..8 { + let px = sx + tx as i32; + if !(0..SCREEN_W as i32).contains(&px) { + continue; + } + let col = if s.attr & 0x20 != 0 { 7 - tx } else { tx }; + let c = self.tile_pixel(s.tile as usize * 16, col, row); + let i = py as usize * SCREEN_W + px as usize; + if c == 0 || (s.attr & 0x80 != 0 && bg_color[i] != 0) { + continue; + } + out[i] = shade(pal, c); + } + } + } + } +}