diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c3f4595 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: build + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact: skids-linux-x86_64 + bin: skids + - os: windows-latest + artifact: skids-windows-x86_64 + bin: skids.exe + - os: macos-latest + artifact: skids-macos-arm64 + bin: skids + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Linux dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libx11-dev libxkbcommon-dev + - name: Test + run: cargo test --release + - name: Build + run: cargo build --release + - name: Check against the game data + shell: bash + run: | + ./target/release/${{ matrix.bin }} info --data . + ./target/release/${{ matrix.bin }} bench --data . --seconds 60 + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + target/release/${{ matrix.bin }} + README.md + docs/FORMATS.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8697097 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*.png +/out diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..726e390 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1515 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[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 = "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", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minifb" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a093126f2ed9012fc0b146934c97eb0273e54983680a8bf5309b6b4a365b32" +dependencies = [ + "cc", + "console_error_panic_hook", + "dlib", + "futures", + "instant", + "js-sys", + "lazy_static", + "libc", + "orbclient", + "raw-window-handle", + "serde", + "serde_derive", + "tempfile", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "web-sys", + "winapi", + "x11-dl", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", + "sdl2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.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 = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[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 = "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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[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 = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "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 = "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 = "sdl2" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "sdl2-sys", +] + +[[package]] +name = "sdl2-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" +dependencies = [ + "cfg-if", + "libc", + "version-compare", +] + +[[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 = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skids" +version = "0.1.0" +dependencies = [ + "cpal", + "minifb", + "png", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +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 = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-client" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3b068c05a039c9f755f881dc50f01732214f5685e379829759088967c46715" +dependencies = [ + "bitflags 1.3.2", + "downcast-rs", + "libc", + "nix", + "scoped-tls", + "wayland-commons", + "wayland-scanner", + "wayland-sys", +] + +[[package]] +name = "wayland-commons" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8691f134d584a33a6606d9d717b95c4fa20065605f798a3f350d78dced02a902" +dependencies = [ + "nix", + "once_cell", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-cursor" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6865c6b66f13d6257bef1cd40cbfe8ef2f150fb8ebbdb1e8e873455931377661" +dependencies = [ + "nix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b950621f9354b322ee817a23474e479b34be96c2e909c14f7bc0100e9a970bc6" +dependencies = [ + "bitflags 1.3.2", + "wayland-client", + "wayland-commons", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4303d8fa22ab852f789e75a967f0a2cdc430a607751c0499bada3e451cbd53" +dependencies = [ + "proc-macro2", + "quote", + "xml-rs", +] + +[[package]] +name = "wayland-sys" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be12ce1a3c39ec7dba25594b97b42cb3195d54953ddb9d3d95a7c3902bc6e9d4" +dependencies = [ + "dlib", + "lazy_static", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +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 = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..75936b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "skids" +version = "0.1.0" +edition = "2021" +description = "Cross-platform revival of Super Skidmarks (Acid Software, 1995) that runs on the original Amiga game data" +license = "MIT" +readme = "README.md" +repository = "https://github.com/andydixon/skids" + +[features] +default = ["audio"] +# Procedural engine/skid sound via cpal. Disable with --no-default-features +# on Linux machines without the ALSA development headers. +audio = ["dep:cpal"] + +[dependencies] +minifb = "0.28" +png = "0.18" +cpal = { version = "0.15", optional = true } + +[profile.dev] +# The renderer is a software rasteriser; unoptimised builds are too slow to play. +opt-level = 2 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e53af7 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Skids: a Super Skidmarks revival + +A cross-platform (Windows, macOS, Linux) engine written in Rust that plays +*Super Skidmarks* (Acid Software, 1995) on the original Amiga data. + +It reads the game's ADF disk images directly: it unpacks the LHA archives +on them, decrunches the CrunchMania-packed files, and decodes the planar +track graphics and AGA sprite banks. No game assets are compiled into the +program; you supply the disks. + +## What works + +- All 24 tracks across the four track files. Each has its isometric height + map and lap gates. +- All 8 vehicles (Mini, Beetle, Porsche, Formula 1, Midget, Truck, Police, + Cow), with 32 headings and 25 body-tilt frames for hills. +- 1 or 2 players (split screen) plus up to 7 computer drivers. +- Power-slide handling, slopes, car-to-car bumping, and skid marks burnt + into the track. +- Lap and position tracking, lap times, and a results table. +- Procedural engine, tyre and crash sound. + +## What's reconstructed + +Disk 1 of the six-disk set is missing from this archive. It held the +executable, palettes, sound and music. As a result: + +- **Handling, AI and race rules** are new code written to feel like the + original, not a translation of the original 68000 code. +- **Colours** come from a reconstructed palette, built around how the + tracks use their 16 colour indices. If you have the real palettes, put + them in a `palettes/` folder next to the disks as `.pal` files (16 lines + of `RRGGBB`): + - `track1-3.pal` for one track + - `track1.pal` for all six tracks in a file + - `default.pal` for everything +- **Sound** is synthesised. + +If you find disk 1, drop it in with the others. Anything it contains under +`track/` or `vehicles/` is picked up automatically. The format notes in +[docs/FORMATS.md](docs/FORMATS.md) cover everything reverse engineered so +far. + +## Running + +```sh +cargo run --release -- --data /path/to/adfs +``` + +Without `--data`, the game looks in the current directory and then next to +the executable, so running it from this repository's root just works. + +| Action | Player 1 | Player 2 | +|-------------|---------------------------------------|----------| +| Accelerate | ↑ (or W in one-player games) | W | +| Brake / reverse | ↓ (or S) | S | +| Steer | ← → (or A D) | A D | +| Pause | P | | +| Leave race | Esc | | + +Options: `--scale 1|2|4` (window size), `--mute`. + +### Tools + +```sh +skids info --data DIR # list tracks and vehicles found +skids extract --data DIR --out pngs/ # dump every track (with gates) and car sheet +skids bench --data DIR --seconds 120 # race CPU cars on every track: laps and time on road +skids trace --data DIR --track 11 --out t.png # plot CPU racing lines +skids render --data DIR --track 3 --out shot.png # headless screenshot +``` + +## Building + +You need a stable Rust toolchain. + +- **Linux:** install the ALSA and X11 development packages, e.g. + `sudo apt install libasound2-dev libx11-dev libxkbcommon-dev`. To build + without sound (no ALSA needed), use `cargo build --release --no-default-features`. +- **macOS / Windows:** `cargo build --release` works as is. + +GitHub Actions builds all three platforms on every push +(`.github/workflows/build.yml`). + +## Layout + +``` +src/formats/ ADF (OFS/FFS), LHA and CrunchMania readers +src/data/ track, vehicle and palette decoding; locating the game files +src/game/ physics, AI and race rules +src/render/ software renderer at 320×256 (PAL low-res) and a bitmap font +src/audio.rs synthesised sound (cpal) +src/app.rs menus, race screens and results +``` + +## Credits + +*Super Skidmarks* is by Acid Software. This project is an unofficial +revival for preservation. The CrunchMania bitstream layout follows Teemu +Suutari's [ancient](https://github.com/temisu/ancient) (BSD-2-Clause). diff --git a/docs/FORMATS.md b/docs/FORMATS.md new file mode 100644 index 0000000..12706ad --- /dev/null +++ b/docs/FORMATS.md @@ -0,0 +1,135 @@ +# Super Skidmarks data formats + +Notes from reverse engineering the AGA/CD32 release of *Super Skidmarks* +(Acid Software, 1995). Everything here was worked out from the data on disks +2–6. Disk 1, which holds the executable, is missing from this archive, so +anything that lives only in the executable (palettes, sound, handling) is +unknown. See [Gaps](#gaps). + +All multi-byte values are big-endian unless noted. + +## Disks + +Each disk is a standard 880 KB double-density ADF (1760 blocks of 512 bytes) +formatted as OFS (`DOS\0`). The `[HD]` in the file names refers to the +hard-drive-installable release, not high-density disks. Each disk holds a +single file: + +| Disk | File | Contents | +|------|---------------|------------------------------------| +| 1 | `smcd32a.lha` | *missing* | +| 2 | `smcd32b.lha` | `vehicles/cow.aga`, `track/TRACK1` | +| 3 | `smcd32c.lha` | `track/TRACK2`, `vehicles/TRUCK.aga` | +| 4 | `smcd32d.lha` | `track/TRACK3`, `vehicles/PORSCHE.aga` | +| 5 | `smcd32e.lha` | `track/track4`, `vehicles/vw.aga` | +| 6 | `smcd32f.lha` | `vehicles/F1.aga`, `Midget.aga`, `mini.aga`, `Police.aga` | + +Each disk's archive stands on its own (the set isn't one archive split +across disks). They use level-0 headers and the `-lh0-` method (stored), +under a top-level `super_skidmarks_cd³²\` directory. Most members are +individually packed with CrunchMania. + +## CrunchMania + +``` +0 4 id: "CrM!" (LZ + fixed codes) or "CrM2" (LZ + Huffman) + lower-case "Crm" = delta-encoded samples (running byte sum) +4 2 unused +6 4 unpacked size +10 4 packed size (excludes this 14-byte header) +14 … packed stream +``` + +The stream is read **backwards** from `14 + packed - 6`, a byte at a time, +LSB first. The last six bytes seed the bit buffer: a 32-bit word shifted +right by `16 - shift` with `16 + shift` valid bits, where `shift` is the +final 16-bit word. Output is also written from the end towards the start. +`src/formats/crm.rs` implements both variants. Every packed file on the +disks decodes exactly: the output buffer fills completely and the input is +consumed to its first byte. + +## Tracks (`track/TRACKn`) + +``` +0x00 7 × u32 end offset of sections 0..6 +0x1C … section 0: track-selector thumbnails (20622 bytes, raw) +… sections 1..6: one track each, CrunchMania-packed +``` + +So the four files hold 24 tracks. Each track section unpacks to 281240 +(`0x44A98`) bytes: + +| Offset | Size | Contents | +|----------|--------|----------| +| `0x0000` | 8192 | Height map: 64 × 64 `u16`, 8.8 fixed point, row-major. Each cell covers 16 × 8 screen pixels. | +| `0x2000` | 8192 | Unused, zero on every track. | +| `0x4000` | 8 | `u16` gate count, `u16` start x, `u16` start y, `u16` flags. | +| `0x4008` | 8 × n | Gates: `x1 y1 x2 y2`, world coordinates × 32. The list repeats after the last gate. | +| … | | Unknown data, mostly a bit pattern, up to `0x4A98`. | +| `0x4A98` | 4 × 65536 | Four non-interleaved 1024 × 512 bitplanes, giving 16 colours. | + +### Track geometry + +Gates are line segments across the road and run in race order; gate 0 is +the start/finish line. They're defined on an isometric ground plane rotated +45° to the picture. The projection that best fits the data is: + +``` +screen x = wx - wy + 512 +screen y = (wx + wy) × 31/64 - 252 +``` + +With these constants, 96% of points sampled along the gates land on road +pixels across all 24 tracks (92% minimum). A plain screen-aligned 2:1 squash +gives 40–70%. Adding a height term doesn't improve the fit, so the height +map is used only for slope physics and body tilt. + +The stored start point is on the road for 14 of the 24 tracks read as screen +coordinates, and 10 read as world coordinates. It isn't used; the grid forms +up behind gate 0. + +### Palette use + +The track pixel indices are ordered from dark to light. Indices 0–7 are +scenery and 8–15 the driving surface, so the fourth bitplane is effectively +a road mask. The game uses it as such here: off-road means less grip and +lower speed. + +## Vehicles (`vehicles/*.aga`) + +CrunchMania-packed, 409600 bytes unpacked: +25 tilt blocks × 32 headings × 512-byte frames. + +A frame is a 64 × 32 AGA hardware sprite. Each line holds 8 bytes of plane +0, then 8 bytes of plane 1, giving 3 colours plus transparent: + +| Index | Use | +|-------|---------------------| +| 0 | transparent | +| 1 | tyres, shadow, dark | +| 2 | trim / accent | +| 3 | bodywork | + +- **Heading:** frame 12 faces screen-right, and frames advance clockwise in + 11.25° steps. In world terms, frame = 12 + (angle + 45°) / 11.25°. +- **Tilt:** block = roll × 5 + pitch, each in 0..4, with 2 = level. + Block 12 is the car on flat ground. + +`Police.aga` reads as all zeros with one third-party ADF tool, but it's +intact when read directly from the disk image. + +## Gaps + +These almost certainly live on the missing disk 1: + +- The main executable, and with it the real handling model, AI, rules, + menus and fonts. This port's versions are reconstructions. +- Colour palettes. Neither the track nor the vehicle files carry any. The + engine uses a reconstructed palette, and real ones can be dropped in as + `.pal` files (see the README). +- Sound effects and music. +- Any tracks or vehicles beyond the 24 and 8 above. + +If disk 1 turns up, put it next to the others and run `skids info`. +Anything in a `track/` or `vehicles/` directory inside its archive is +picked up automatically. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..9f60a23 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,692 @@ +//! Front end: menu, race screens and results. + +use crate::audio::Audio; +use crate::data::{palette, track, vehicle, GameData}; +use crate::game::car::Controls; +use crate::game::{Driver, EntrantSetup, Phase, Race}; +use crate::render::{self, Frame, Rect, SCREEN_H, SCREEN_W}; +use minifb::Key; +use std::collections::HashMap; + +const THUMB_W: usize = 128; +const THUMB_H: usize = 64; +const CPU_NAMES: [&str; 8] = [ + "ACE", "BLITZ", "DUSTY", "NITRO", "ROCKET", "SPUD", "TURBO", "ZIPPY", +]; +const LAP_CHOICES: [usize; 5] = [1, 3, 5, 7, 10]; + +const WHITE: u32 = 0xffffff; +const YELLOW: u32 = 0xffd840; +const GREY: u32 = 0x9098a0; +const RED: u32 = 0xff5040; + +#[derive(Default)] +pub struct Input { + pub held: Vec, + pub pressed: Vec, +} + +impl Input { + pub fn down(&self, k: Key) -> bool { + self.held.contains(&k) + } + + pub fn hit(&self, k: Key) -> bool { + self.pressed.contains(&k) + } + + fn controls(&self, up: Key, down: Key, left: Key, right: Key) -> Controls { + let axis = |neg: Key, pos: Key| (self.down(pos) as i32 - self.down(neg) as i32) as f32; + Controls { + throttle: self.down(up) as i32 as f32, + brake: self.down(down) as i32 as f32, + steer: axis(left, right), + } + } +} + +pub struct Settings { + pub players: usize, + pub track: usize, + pub laps: usize, + pub cars: [usize; 2], + pub opponents: usize, +} + +#[derive(Clone, Copy, PartialEq)] +enum Item { + Start, + Players, + Track, + Laps, + Car1, + Car2, + Opponents, + Quit, +} + +enum Screen { + Menu { + cursor: usize, + }, + Race { + race: Box, + cams: [(f32, f32); 2], + paused: bool, + }, + Results { + race: Box, + }, +} + +pub struct App { + pub data: GameData, + tracks: Vec<(usize, usize)>, + pub settings: Settings, + screen: Screen, + pub frame: Frame, + audio: Audio, + demo: Option>, + demo_track: usize, + thumbs: HashMap>, + tick: u64, + pub quit: bool, + pub status: Option, +} + +impl App { + pub fn new(data: GameData, audio: Audio) -> App { + let tracks = data.track_list(); + let cars = [ + data.vehicles.iter().position(|v| v.id == "f1").unwrap_or(0), + data.vehicles + .iter() + .position(|v| v.id == "mini") + .unwrap_or(0), + ]; + App { + data, + tracks, + settings: Settings { + players: 1, + track: 0, + laps: 3, + cars, + opponents: 3, + }, + screen: Screen::Menu { cursor: 0 }, + frame: Frame::new(), + audio, + demo: None, + demo_track: 0, + thumbs: HashMap::new(), + tick: 0, + quit: false, + status: None, + } + } + + fn items(&self) -> Vec { + let mut v = vec![ + Item::Start, + Item::Players, + Item::Track, + Item::Laps, + Item::Car1, + ]; + if self.settings.players == 2 { + v.push(Item::Car2); + } + v.extend([Item::Opponents, Item::Quit]); + v + } + + pub fn build_race( + &self, + track_index: usize, + humans: usize, + cars: [usize; 2], + opponents: usize, + laps: usize, + seed: u32, + ) -> Result { + let (f, s) = self.tracks[track_index]; + let track = self.data.load_track(f, s)?; + let nv = self.data.vehicles.len(); + let mut setup = Vec::new(); + for (p, &car) in cars.iter().enumerate().take(humans) { + setup.push(EntrantSetup { + vehicle: car, + driver: Driver::Human(p), + team: p, + name: format!("PLAYER {}", p + 1), + }); + } + let mut rng = seed.wrapping_mul(2654435761) | 1; + for i in 0..opponents.min(8 - humans) { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + setup.push(EntrantSetup { + vehicle: rng as usize % nv, + driver: Driver::Cpu, + team: humans + i, + name: CPU_NAMES[(i + seed as usize) % CPU_NAMES.len()].to_string(), + }); + } + // Humans start at the back, as is tradition. + let back = humans.min(setup.len()); + setup.rotate_left(back); + Ok(Race::new(track, &self.data.vehicles, &setup, laps, seed)) + } + + fn thumb(&mut self, i: usize) -> Option<&Vec> { + if !self.thumbs.contains_key(&i) { + let (f, s) = self.tracks[i]; + let t = self.data.load_track(f, s).ok()?; + self.thumbs + .insert(i, render::downscale(&t.rgb(), THUMB_W, THUMB_H)); + } + self.thumbs.get(&i) + } + + pub fn update(&mut self, input: &Input) { + self.tick += 1; + match &mut self.screen { + Screen::Menu { .. } => self.update_menu(input), + Screen::Race { race, paused, .. } => { + if input.hit(Key::Escape) { + self.audio.silence(); + self.screen = Screen::Menu { cursor: 0 }; + return; + } + if input.hit(Key::P) { + *paused = !*paused; + } + if *paused { + self.audio.silence(); + return; + } + let two = self.settings.players == 2; + let p1 = if two { + input.controls(Key::Up, Key::Down, Key::Left, Key::Right) + } else { + let a = input.controls(Key::Up, Key::Down, Key::Left, Key::Right); + let b = input.controls(Key::W, Key::S, Key::A, Key::D); + Controls { + throttle: a.throttle.max(b.throttle), + brake: a.brake.max(b.brake), + steer: (a.steer + b.steer).clamp(-1.0, 1.0), + } + }; + let p2 = input.controls(Key::W, Key::S, Key::A, Key::D); + race.step(&[p1, p2]); + for (i, e) in race + .entrants + .iter() + .filter(|e| matches!(e.driver, Driver::Human(_))) + .enumerate() + { + let c = &e.car; + let skid = if c.on_road { + ((c.slip - 50.0) / 100.0).clamp(0.0, 1.0) + } else { + 0.0 + }; + self.audio.set_voice(i, true, c.rpm, skid, c.bump); + } + if race.phase == Phase::Done { + self.audio.silence(); + let Screen::Race { race, .. } = + std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 }) + else { + unreachable!() + }; + self.screen = Screen::Results { race }; + } + } + Screen::Results { .. } => { + if input.hit(Key::Enter) || input.hit(Key::Escape) || input.hit(Key::Space) { + self.screen = Screen::Menu { cursor: 0 }; + } + } + } + } + + fn update_menu(&mut self, input: &Input) { + self.step_demo(); + let items = self.items(); + let Screen::Menu { cursor } = &mut self.screen else { + return; + }; + let mut cur = (*cursor).min(items.len() - 1); + if input.hit(Key::Up) { + cur = (cur + items.len() - 1) % items.len(); + } + if input.hit(Key::Down) { + cur = (cur + 1) % items.len(); + } + *cursor = cur; + let delta = input.hit(Key::Right) as i32 - input.hit(Key::Left) as i32; + let item = items[cur]; + let s = &mut self.settings; + let nv = self.data.vehicles.len(); + let wrap = |v: usize, n: usize, d: i32| (v as i32 + d).rem_euclid(n as i32) as usize; + if delta != 0 { + match item { + Item::Players => s.players = if s.players == 1 { 2 } else { 1 }, + Item::Track => s.track = wrap(s.track, self.tracks.len(), delta), + Item::Laps => { + let i = LAP_CHOICES.iter().position(|&l| l == s.laps).unwrap_or(1); + s.laps = LAP_CHOICES[wrap(i, LAP_CHOICES.len(), delta)]; + } + Item::Car1 => s.cars[0] = wrap(s.cars[0], nv, delta), + Item::Car2 => s.cars[1] = wrap(s.cars[1], nv, delta), + Item::Opponents => s.opponents = wrap(s.opponents, 8 - s.players + 1, delta), + _ => {} + } + s.opponents = s.opponents.min(8 - s.players); + } + if input.hit(Key::Escape) { + self.quit = true; + } + if input.hit(Key::Enter) || input.hit(Key::Space) { + match item { + Item::Quit => self.quit = true, + Item::Start + | Item::Track + | Item::Car1 + | Item::Car2 + | Item::Laps + | Item::Players + | Item::Opponents => { + let s = &self.settings; + match self.build_race( + s.track, + s.players, + s.cars, + s.opponents, + s.laps, + self.tick as u32, + ) { + Ok(r) => { + let cams = [(0.0, 0.0); 2]; + let mut screen = Screen::Race { + race: Box::new(r), + cams, + paused: false, + }; + if let Screen::Race { race, cams, .. } = &mut screen { + for (i, cam) in cams.iter_mut().enumerate() { + if let Some(e) = + race.entrants.iter().find(|e| e.driver == Driver::Human(i)) + { + *cam = e.car.screen_pos(); + } + } + } + self.screen = screen; + } + Err(e) => self.status = Some(e), + } + } + } + } + } + + fn step_demo(&mut self) { + let finished = self + .demo + .as_ref() + .is_none_or(|d| d.phase == Phase::Done || d.race_time() > 75.0); + if finished { + self.demo_track = (self.demo_track + 7) % self.tracks.len(); + self.demo = self + .build_race(self.demo_track, 0, [0, 0], 4, 2, self.tick as u32 + 99) + .ok() + .map(Box::new); + } + if let Some(d) = &mut self.demo { + d.step(&[]); + } + } + + pub fn draw(&mut self) { + let take = std::mem::replace(&mut self.screen, Screen::Menu { cursor: 0 }); + match &take { + Screen::Menu { cursor } => self.draw_menu(*cursor), + Screen::Race { race, paused, .. } => { + let mut cams = match &take { + Screen::Race { cams, .. } => *cams, + _ => unreachable!(), + }; + draw_race( + &mut self.frame, + &self.data, + race, + &mut cams, + self.settings.players, + ); + if *paused { + self.frame + .shade(Rect::new(0, 100, SCREEN_W as i32, 40), 120); + self.frame.text_centered(160, 108, "PAUSED", YELLOW, 2); + self.frame + .text_centered(160, 126, "P: RESUME ESC: QUIT RACE", WHITE, 1); + } + self.screen = take; + if let Screen::Race { cams: c, .. } = &mut self.screen { + *c = cams; + } + return; + } + Screen::Results { race } => draw_results(&mut self.frame, &self.data, race), + } + self.screen = take; + } + + fn draw_menu(&mut self, cursor: usize) { + let items = self.items(); + let f = &mut self.frame; + if let Some(d) = &self.demo { + let leader = d.standings()[0]; + let mut cam = [d.entrants[leader].car.screen_pos(); 2]; + draw_world( + f, + &self.data, + d, + Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32), + &mut cam[0], + true, + 1.0, + ); + f.shade(Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32), 110); + } else { + f.clear(0x101820); + } + + f.text_centered(160, 10, "SKIDS", YELLOW, 4); + f.text_centered(160, 42, "A SUPER SKIDMARKS REVIVAL", WHITE, 1); + + let cursor = cursor.min(items.len() - 1); + let s = &self.settings; + for (i, item) in items.iter().enumerate() { + let label = match item { + Item::Start => "START RACE".to_string(), + Item::Players => format!("PLAYERS < {} >", s.players), + Item::Track => format!("TRACK < {} >", { + let (fi, si) = self.tracks[s.track]; + self.data.track_files[fi].track_name(si) + }), + Item::Laps => format!("LAPS < {} >", s.laps), + Item::Car1 => format!("P1 CAR < {} >", self.data.vehicles[s.cars[0]].name), + Item::Car2 => format!("P2 CAR < {} >", self.data.vehicles[s.cars[1]].name), + Item::Opponents => format!("OPPONENTS < {} >", s.opponents), + Item::Quit => "QUIT".to_string(), + }; + let y = 64 + i as i32 * 14; + let colour = if i == cursor { YELLOW } else { WHITE }; + if i == cursor { + f.text_shadow(8, y, ">", RED, 1); + } + f.text_shadow(18, y, &label, colour, 1); + } + + // Track preview. + let track = s.track; + let px = SCREEN_W as i32 - THUMB_W as i32 - 10; + if let Some(th) = self.thumb(track).cloned() { + let f = &mut self.frame; + f.frame_rect( + Rect::new(px - 2, 62, THUMB_W as i32 + 4, THUMB_H as i32 + 4), + WHITE, + ); + f.image(&th, THUMB_W, THUMB_H, px, 64); + } + + // Car preview, slowly turning. + let f = &mut self.frame; + let s = &self.settings; + let which = if items[cursor] == Item::Car2 { 1 } else { 0 }; + let v = &self.data.vehicles[s.cars[which]]; + let heading = (self.tick / 4) as usize % vehicle::HEADINGS; + let pal = palette::car_palette(which); + let (cx, cy) = (px + THUMB_W as i32 / 2 - 32, 150); + f.fill(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), 0x203018); + f.frame_rect(Rect::new(px - 2, 140, THUMB_W as i32 + 4, 52), WHITE); + let clip = Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32); + f.sprite_shadow(v.frame(vehicle::LEVEL, heading), cx + 3, cy + 3, clip); + f.sprite(v.frame(vehicle::LEVEL, heading), &pal, cx, cy, clip); + f.text_centered(px + THUMB_W as i32 / 2, 182, &v.name, WHITE, 1); + + let help = if self.settings.players == 2 { + "P1: ARROWS P2: W A S D" + } else { + "DRIVE: ARROWS OR W A S D" + }; + f.text_centered(160, 222, help, GREY, 1); + f.text_centered( + 160, + 236, + "UP/DOWN: CHOOSE LEFT/RIGHT: CHANGE ENTER: GO", + GREY, + 1, + ); + if let Some(st) = &self.status { + f.text_centered(160, 200, st, RED, 1); + } + } +} + +pub fn draw_race( + f: &mut Frame, + data: &GameData, + race: &Race, + cams: &mut [(f32, f32); 2], + players: usize, +) { + f.clear(0); + let views: Vec = if players == 2 { + vec![ + Rect::new(0, 0, SCREEN_W as i32, 127), + Rect::new(0, 129, SCREEN_W as i32, 127), + ] + } else { + vec![Rect::new(0, 0, SCREEN_W as i32, SCREEN_H as i32)] + }; + for (p, view) in views.iter().enumerate() { + let Some(ei) = race + .entrants + .iter() + .position(|e| e.driver == Driver::Human(p)) + else { + continue; + }; + let e = &race.entrants[ei]; + // Look ahead in the direction of travel. + let target = track::to_screen(e.car.x + e.car.vx * 0.3, e.car.y + e.car.vy * 0.3); + cams[p].0 += (target.0 - cams[p].0) * 0.12; + cams[p].1 += (target.1 - cams[p].1) * 0.12; + draw_world(f, data, race, *view, &mut cams[p], false, 1.0); + draw_hud(f, race, ei, *view); + } + if players == 2 { + f.fill(Rect::new(0, 127, SCREEN_W as i32, 2), 0x000000); + } + if let Some(n) = race.countdown() { + f.text_centered(160, 110, &n.to_string(), YELLOW, 5); + } else if race.race_time() < 1.0 { + f.text_centered(160, 110, "GO!", YELLOW, 5); + } +} + +/// Draws the track and cars into `view`, centred on `cam` (screen-space track pixel). +pub fn draw_world( + f: &mut Frame, + data: &GameData, + race: &Race, + view: Rect, + cam: &mut (f32, f32), + _demo: bool, + _zoom: f32, +) { + let cx = + (cam.0 - view.w as f32 / 2.0).clamp(0.0, (track::W as i32 - view.w).max(0) as f32) as i32; + let cy = + (cam.1 - view.h as f32 / 2.0).clamp(0.0, (track::H as i32 - view.h).max(0) as f32) as i32; + f.blit_track(&race.ground, view, cx, cy); + + for p in &race.particles { + let x = view.x + p.x as i32 - cx; + let y = view.y + p.y as i32 - cy; + if x >= view.x && x < view.x + view.w && y >= view.y && y < view.y + view.h { + f.put(x, y, p.colour); + } + } + + let mut order: Vec = (0..race.entrants.len()).collect(); + order.sort_by(|&a, &b| { + race.entrants[a] + .car + .screen_pos() + .1 + .total_cmp(&race.entrants[b].car.screen_pos().1) + }); + for i in order { + let e = &race.entrants[i]; + let c = &e.car; + let v = &data.vehicles[c.vehicle]; + let (sx, sy) = c.screen_pos(); + let x = view.x + sx as i32 - cx - (vehicle::SPR_W as i32 / 2); + let y = view.y + sy as i32 - cy - (vehicle::SPR_H as i32 / 2); + let pix = v.frame(c.tilt, vehicle::heading_frame(c.heading)); + f.sprite_shadow(pix, x + 3, y + 2, view); + f.sprite(pix, &palette::car_palette(c.team), x, y, view); + if let Driver::Human(p) = e.driver { + let tag = format!("P{}", p + 1); + let tx = x + 32 - 5; + let ty = y - 2; + if ty > view.y && ty < view.y + view.h - 8 { + f.text_shadow(tx, ty, &tag, if p == 0 { 0xff8070 } else { 0x80a0ff }, 1); + } + } + } +} + +fn draw_hud(f: &mut Frame, race: &Race, ei: usize, view: Rect) { + let e = &race.entrants[ei]; + let n = race.track.gates.len(); + let lap = (e.laps_done(n) + 1).min(race.laps); + let standings = race.standings(); + let pos = standings.iter().position(|&i| i == ei).unwrap_or(0) + 1; + let (x, y) = (view.x + 6, view.y + 5); + f.text_shadow(x, y, &format!("LAP {}/{}", lap, race.laps), WHITE, 1); + f.text_shadow( + x, + y + 10, + &format!("POS {}/{}", ordinal(pos), race.entrants.len()), + YELLOW, + 1, + ); + let t = e.finished.unwrap_or_else(|| race.race_time()); + let ts = fmt_time(t); + f.text_shadow( + view.x + view.w - 6 - (ts.len() as i32 * 6), + y, + &ts, + WHITE, + 1, + ); + if let Some(l) = e.last_lap { + let ls = format!("LAST {}", fmt_time(l)); + f.text_shadow( + view.x + view.w - 6 - (ls.len() as i32 * 6), + y + 10, + &ls, + GREY, + 1, + ); + } + if e.finished.is_some() { + let finish_pos = race.finish_order.iter().position(|&i| i == ei).unwrap_or(0) + 1; + f.text_centered( + view.x + view.w / 2, + view.y + view.h / 2 - 20, + &format!("FINISHED {}", ordinal(finish_pos)), + YELLOW, + 2, + ); + } +} + +fn draw_results(f: &mut Frame, data: &GameData, race: &Race) { + f.clear(0x101820); + f.text_centered(160, 16, "RESULTS", YELLOW, 3); + f.text_centered(160, 44, &race.track.name, GREY, 1); + let n = race.track.gates.len(); + f.text_shadow(16, 64, "POS DRIVER CAR TIME BEST", GREY, 1); + let leader_passed = race.entrants.iter().map(|e| e.passed).max().unwrap_or(0); + for (row, &i) in race.standings().iter().enumerate() { + let e = &race.entrants[i]; + let time = match e.finished { + Some(t) => fmt_time(t), + None => { + let behind = leader_passed.saturating_sub(e.passed).div_ceil(n); + if behind > 0 { + format!("+{behind} LAP") + } else { + "RUNNING".into() + } + } + }; + let best = e.best_lap.map(fmt_time).unwrap_or_else(|| "-".into()); + let car = &data.vehicles[e.car.vehicle].name; + let line = format!( + "{:<4} {:<10} {:<11} {:<8} {}", + ordinal(row + 1), + e.name, + truncate(car, 11), + time, + best + ); + let colour = if matches!(e.driver, Driver::Human(_)) { + YELLOW + } else { + WHITE + }; + f.text_shadow(16, 80 + row as i32 * 12, &line, colour, 1); + } + f.text_centered(160, 236, "PRESS ENTER", GREY, 1); +} + +fn truncate(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +pub fn ordinal(n: usize) -> String { + let suffix = match (n % 10, n % 100) { + (1, x) if x != 11 => "ST", + (2, x) if x != 12 => "ND", + (3, x) if x != 13 => "RD", + _ => "TH", + }; + format!("{n}{suffix}") +} + +pub fn fmt_time(t: f32) -> String { + let tenths = (t * 10.0).round() as u32; + format!("{}:{:02}.{}", tenths / 600, (tenths / 10) % 60, tenths % 10) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formatting() { + assert_eq!(ordinal(1), "1ST"); + assert_eq!(ordinal(12), "12TH"); + assert_eq!(ordinal(23), "23RD"); + assert_eq!(fmt_time(83.46), "1:23.5"); + } +} diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..b5d8feb --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,202 @@ +//! Procedural sound: an engine note per human player, tyre squeal and knocks. +//! +//! The retail samples are on disk 1, which is not in this dump, so everything +//! is synthesised. Parameters are passed to the audio thread through atomics. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +pub const VOICES: usize = 2; + +#[derive(Default)] +struct VoiceParams { + rpm: AtomicU32, + skid: AtomicU32, + bump: AtomicU32, + active: AtomicU32, +} + +#[derive(Default)] +pub struct Shared { + voices: [VoiceParams; VOICES], + master: AtomicU32, +} + +fn store(a: &AtomicU32, v: f32) { + a.store(v.to_bits(), Ordering::Relaxed); +} + +fn load(a: &AtomicU32) -> f32 { + f32::from_bits(a.load(Ordering::Relaxed)) +} + +pub struct Audio { + shared: Arc, + #[cfg(feature = "audio")] + _stream: Option, +} + +impl Audio { + pub fn new(enabled: bool) -> Audio { + let shared = Arc::new(Shared::default()); + store(&shared.master, 0.5); + #[cfg(feature = "audio")] + { + let stream = if enabled { + backend::open(shared.clone()) + } else { + None + }; + Audio { + shared, + _stream: stream, + } + } + #[cfg(not(feature = "audio"))] + { + let _ = enabled; + Audio { shared } + } + } + + pub fn set_voice(&self, i: usize, active: bool, rpm: f32, skid: f32, bump: f32) { + let v = &self.shared.voices[i]; + store(&v.active, if active { 1.0 } else { 0.0 }); + store(&v.rpm, rpm); + store(&v.skid, skid); + // Knocks are latched until the audio thread consumes them. + if bump > load(&v.bump) { + store(&v.bump, bump); + } + } + + pub fn silence(&self) { + for i in 0..VOICES { + self.set_voice(i, false, 0.0, 0.0, 0.0); + } + } +} + +/// Synth state for one voice, run on the audio thread. +#[derive(Default)] +struct Synth { + phase: f32, + sub_phase: f32, + rpm: f32, + skid: f32, + knock: f32, + noise: u32, + lp: f32, + skid_lp: f32, + level: f32, +} + +impl Synth { + fn sample(&mut self, p: &VoiceParams, rate: f32) -> f32 { + let target_level = load(&p.active); + self.level += (target_level - self.level) * 0.0008; + self.rpm += (load(&p.rpm) - self.rpm) * 0.002; + self.skid += (load(&p.skid) - self.skid) * 0.004; + let b = load(&p.bump); + if b > 0.0 { + self.knock = self.knock.max(b); + store(&p.bump, 0.0); + } + + self.noise ^= self.noise << 13; + self.noise ^= self.noise >> 17; + self.noise ^= self.noise << 5; + let white = (self.noise as f32 / u32::MAX as f32) * 2.0 - 1.0; + + // Engine: a buzzy pulse with a sub-octave, low-passed. + let freq = 38.0 + self.rpm * 150.0; + self.phase = (self.phase + freq / rate).fract(); + self.sub_phase = (self.sub_phase + freq * 0.5 / rate).fract(); + let pulse = if self.phase < 0.3 { 1.0 } else { -0.45 }; + let sub = if self.sub_phase < 0.5 { 0.5 } else { -0.5 }; + let raw = pulse + sub + white * 0.15; + self.lp += (raw - self.lp) * (0.06 + self.rpm * 0.12); + let engine = self.lp * 0.32; + + // Tyres: band-limited noise. + self.skid_lp += (white - self.skid_lp) * 0.35; + let squeal = (white - self.skid_lp) * self.skid.min(1.0) * 0.22; + + let knock = white * self.knock * 0.6; + self.knock *= 0.9985; + + (engine + squeal + knock) * self.level + } +} + +#[cfg(feature = "audio")] +mod backend { + use super::{load, Shared, Synth, VOICES}; + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + use cpal::{FromSample, SizedSample}; + use std::sync::Arc; + + pub fn open(shared: Arc) -> Option { + let host = cpal::default_host(); + let device = host.default_output_device()?; + let config = device.default_output_config().ok()?; + let fmt = config.sample_format(); + let config: cpal::StreamConfig = config.into(); + let stream = match fmt { + cpal::SampleFormat::F32 => build::(&device, &config, shared), + cpal::SampleFormat::I16 => build::(&device, &config, shared), + cpal::SampleFormat::U16 => build::(&device, &config, shared), + _ => None, + }?; + stream.play().ok()?; + Some(stream) + } + + fn build( + device: &cpal::Device, + config: &cpal::StreamConfig, + shared: Arc, + ) -> Option + where + T: SizedSample + FromSample, + { + let rate = config.sample_rate.0 as f32; + let channels = config.channels as usize; + let mut synths: [Synth; VOICES] = Default::default(); + for (i, s) in synths.iter_mut().enumerate() { + s.noise = 0x1234_5678 ^ (i as u32 * 0x9e37_79b9); + } + device + .build_output_stream( + config, + move |data: &mut [T], _| { + let master = load(&shared.master); + for frame in data.chunks_mut(channels) { + let mut l = 0.0; + let mut r = 0.0; + for (i, s) in synths.iter_mut().enumerate() { + let v = s.sample(&shared.voices[i], rate); + // Player one leans left, player two right. + let pan = if i == 0 { 0.35 } else { 0.65 }; + l += v * (1.0 - pan) * 2.0; + r += v * pan * 2.0; + } + let (l, r) = ((l * master).clamp(-1.0, 1.0), (r * master).clamp(-1.0, 1.0)); + for (c, out) in frame.iter_mut().enumerate() { + let v = if channels == 1 { + (l + r) * 0.5 + } else if c % 2 == 0 { + l + } else { + r + }; + *out = T::from_sample(v); + } + } + }, + |err| eprintln!("audio stream error: {err}"), + None, + ) + .ok() + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..df02583 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,187 @@ +//! Locates and decodes the original game data. +//! +//! Data can be supplied as the original ADF images, as the LHA archives taken +//! off them, or as an already-extracted directory tree containing `track/` and +//! `vehicles/`. Nothing from the original game is embedded in this program. + +pub mod palette; +pub mod track; +pub mod vehicle; + +use crate::formats::{adf::Adf, crm, lha}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +pub use track::{Track, TrackFile}; +pub use vehicle::Vehicle; + +/// All game files found, keyed by `dir/name` in lower case +/// (e.g. `track/track1`, `vehicles/f1.aga`). +#[derive(Default)] +pub struct FileSet { + pub files: BTreeMap>, + pub warnings: Vec, +} + +impl FileSet { + pub fn scan(root: &Path) -> FileSet { + let mut fs_ = FileSet::default(); + fs_.scan_dir(root, 0); + fs_ + } + + fn scan_dir(&mut self, dir: &Path, depth: usize) { + let Ok(rd) = fs::read_dir(dir) else { + self.warnings + .push(format!("cannot read directory {}", dir.display())); + return; + }; + let mut entries: Vec = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + if depth < 4 { + self.scan_dir(&path, depth + 1); + } + } else if let Ok(data) = fs::read(&path) { + let name = path.to_string_lossy().to_string(); + self.add(&name, data); + } + } + } + + /// Adds a file, unpacking ADF images and LHA archives recursively. + pub fn add(&mut self, path: &str, data: Vec) { + let lower = path.to_lowercase(); + if lower.ends_with(".adf") { + match Adf::open(data).and_then(|a| a.files()) { + Ok(files) => { + for f in files { + self.add(&format!("{path}/{}", f.path), f.data); + } + } + Err(e) => self.warnings.push(format!("{path}: {e}")), + } + } else if lower.ends_with(".lha") || lower.ends_with(".lzh") { + match lha::parse(&data) { + Ok(entries) => { + for e in entries { + match e.data { + Some(d) => self.add(&e.path, d), + None => self.warnings.push(format!( + "{path}: {} uses unsupported method {}", + e.path, e.method + )), + } + } + } + Err(e) => self.warnings.push(format!("{path}: {e}")), + } + } else if let Some(key) = logical_key(path) { + self.files.insert(key, data); + } + } + + pub fn get(&self, key: &str) -> Option<&[u8]> { + self.files.get(key).map(|v| v.as_slice()) + } +} + +/// Reduces a path to `dir/name` if `dir` is one the game uses. +fn logical_key(path: &str) -> Option { + let parts: Vec<&str> = path.split(['/', '\\']).filter(|s| !s.is_empty()).collect(); + if parts.len() < 2 { + return None; + } + let dir = parts[parts.len() - 2].to_lowercase(); + let name = parts[parts.len() - 1].to_lowercase(); + matches!(dir.as_str(), "track" | "vehicles" | "palettes").then(|| format!("{dir}/{name}")) +} + +/// Unpacks CrunchMania data, passing plain data through untouched. +pub fn unpack(d: &[u8]) -> Result, crm::CrmError> { + if crm::is_crunched(d) { + crm::decrunch(d) + } else { + Ok(d.to_vec()) + } +} + +pub struct GameData { + pub track_files: Vec, + pub vehicles: Vec, + pub files: FileSet, + pub warnings: Vec, +} + +impl GameData { + pub fn load(root: &Path) -> Result { + let files = FileSet::scan(root); + let mut warnings = files.warnings.clone(); + + let mut track_files = Vec::new(); + for (key, data) in &files.files { + if let Some(name) = key.strip_prefix("track/") { + match TrackFile::parse(name, data) { + Ok(t) => track_files.push(t), + Err(e) => warnings.push(format!("{key}: {e}")), + } + } + } + track_files.sort_by_key(|t| (t.number, t.name.clone())); + + let mut vehicles = Vec::new(); + for (key, data) in &files.files { + if let Some(name) = key.strip_prefix("vehicles/") { + match Vehicle::parse(name, data) { + Ok(Some(v)) => vehicles.push(v), + Ok(None) => warnings.push(format!("{key}: sprite data is blank, skipped")), + Err(e) => warnings.push(format!("{key}: {e}")), + } + } + } + vehicles.sort_by_key(|v| vehicle::display_order(&v.id)); + + if track_files.is_empty() || vehicles.is_empty() { + return Err(format!( + "no usable game data under {} (found {} track files, {} vehicles).\n\ + Point --data at a folder containing the Super Skidmarks ADF images.{}", + root.display(), + track_files.len(), + vehicles.len(), + if warnings.is_empty() { + String::new() + } else { + format!("\n {}", warnings.join("\n ")) + } + )); + } + Ok(GameData { + track_files, + vehicles, + files, + warnings, + }) + } + + /// Flattened (file, section) list of every playable track. + pub fn track_list(&self) -> Vec<(usize, usize)> { + let mut v = Vec::new(); + for (fi, tf) in self.track_files.iter().enumerate() { + for si in 0..tf.sections.len() { + v.push((fi, si)); + } + } + v + } + + pub fn load_track(&self, file: usize, section: usize) -> Result { + let tf = &self.track_files[file]; + let mut t = tf.decode(section)?; + if let Some(p) = palette::override_for(&self.files, &tf.name, section) { + t.palette = p; + } + Ok(t) + } +} diff --git a/src/data/palette.rs b/src/data/palette.rs new file mode 100644 index 0000000..a9a63d6 --- /dev/null +++ b/src/data/palette.rs @@ -0,0 +1,132 @@ +//! Colour palettes. +//! +//! The track and vehicle files carry no colour tables; the retail game sets +//! them from its main executable, which lives on disk 1. Until that disk is +//! available we use a reconstructed palette. Indices on the retail tracks are +//! ordered dark-to-light, with 0-7 used for scenery and 8-15 for the driving +//! surface, so two ramps give a faithful-looking result. +//! +//! Real palettes can be dropped in as text files (16 lines of `RRGGBB`, +//! `#` comments allowed) next to the game data: +//! palettes/track1-3.pal one track +//! palettes/track1.pal all six tracks in TRACK1 +//! palettes/default.pal every track + +use super::FileSet; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + pub const fn to_u32(self) -> u32 { + ((self.0 as u32) << 16) | ((self.1 as u32) << 8) | self.2 as u32 + } + + pub fn scale(self, f: f32) -> Rgb { + let s = |c: u8| ((c as f32 * f).round().clamp(0.0, 255.0)) as u8; + Rgb(s(self.0), s(self.1), s(self.2)) + } + + pub fn mix(self, o: Rgb, t: f32) -> Rgb { + let m = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; + Rgb(m(self.0, o.0), m(self.1, o.1), m(self.2, o.2)) + } +} + +pub const TRACK_DEFAULT: [Rgb; 16] = [ + // scenery: deep shadow through sunlit grass + Rgb(0x0c, 0x1a, 0x0e), + Rgb(0x18, 0x2c, 0x14), + Rgb(0x24, 0x40, 0x1a), + Rgb(0x34, 0x56, 0x20), + Rgb(0x48, 0x6c, 0x28), + Rgb(0x60, 0x84, 0x34), + Rgb(0x84, 0x9c, 0x4c), + Rgb(0xb0, 0xb8, 0x7c), + // driving surface: tarmac, kerbs and markings + Rgb(0x3a, 0x36, 0x34), + Rgb(0x50, 0x4c, 0x48), + Rgb(0x66, 0x62, 0x5c), + Rgb(0x7c, 0x78, 0x70), + Rgb(0x94, 0x90, 0x86), + Rgb(0xae, 0xaa, 0x9e), + Rgb(0xcc, 0xc8, 0xbc), + Rgb(0xf0, 0xee, 0xe4), +]; + +/// Per-player body colours for the three-colour vehicle sprites +/// (index 1 = tyres and shadow, 2 = trim, 3 = bodywork). +pub const TEAM_COLOURS: [Rgb; 8] = [ + Rgb(0xe0, 0x30, 0x28), + Rgb(0x30, 0x60, 0xe8), + Rgb(0xf0, 0xc8, 0x20), + Rgb(0x30, 0xb0, 0x48), + Rgb(0xe0, 0x60, 0xd0), + Rgb(0x20, 0xc0, 0xc8), + Rgb(0xf0, 0x80, 0x20), + Rgb(0xe8, 0xe8, 0xe8), +]; + +pub fn car_palette(team: usize) -> [u32; 4] { + let c = TEAM_COLOURS[team % TEAM_COLOURS.len()]; + [ + 0, + Rgb(0x10, 0x10, 0x14).to_u32(), + c.scale(0.7).to_u32(), + c.mix(Rgb(0xff, 0xff, 0xff), 0.35).to_u32(), + ] +} + +pub fn parse(text: &str) -> Option<[Rgb; 16]> { + let mut out = [Rgb(0, 0, 0); 16]; + let mut n = 0; + for line in text.lines() { + let line = line + .split('#') + .next() + .unwrap_or("") + .trim() + .trim_start_matches("0x"); + if line.is_empty() { + continue; + } + if n == 16 || line.len() != 6 { + return None; + } + let v = u32::from_str_radix(line, 16).ok()?; + out[n] = Rgb((v >> 16) as u8, (v >> 8) as u8, v as u8); + n += 1; + } + (n == 16).then_some(out) +} + +pub fn override_for(files: &FileSet, track_file: &str, section: usize) -> Option<[Rgb; 16]> { + let base = track_file.to_lowercase(); + [ + format!("palettes/{base}-{}.pal", section + 1), + format!("palettes/{base}.pal"), + "palettes/default.pal".into(), + ] + .iter() + .find_map(|k| { + files + .get(k) + .and_then(|d| parse(&String::from_utf8_lossy(d))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_palette_file() { + let mut text = String::from("# test\n"); + for i in 0..16 { + text.push_str(&format!("{:02x}{:02x}{:02x}\n", i, i * 2, i * 3)); + } + let p = parse(&text).unwrap(); + assert_eq!(p[15], Rgb(15, 30, 45)); + assert!(parse("ffffff\n").is_none()); + } +} diff --git a/src/data/track.rs b/src/data/track.rs new file mode 100644 index 0000000..cd2373e --- /dev/null +++ b/src/data/track.rs @@ -0,0 +1,217 @@ +//! Track files (`track/TRACKn`). +//! +//! File layout: +//! 0x00 7 x BE32 end offsets. Section 0 runs from 0x1c to the first offset +//! and holds the selector thumbnails; sections 1..6 are tracks. +//! +//! Each track section (281240 bytes once decrunched): +//! 0x0000 height map, 64x64 BE16 8.8 fixed point, one cell per 16x8 pixels +//! 0x2000 8192 bytes, unused (always zero on the retail disks) +//! 0x4000 BE16 gate count, start x, start y (screen pixels), flags +//! 0x4008 gates: x1 y1 x2 y2 BE16, world coordinates in 1/32 units +//! 0x4A98 four 1024x512 bitplanes (65536 bytes each), giving 16 colours +//! +//! The world is an isometric ground plane rotated 45 degrees to the picture: +//! screen x = wx - wy + 512, screen y = (wx + wy) * 31/64 - 252. The constants +//! were fitted so that 96% of gate samples over all 24 tracks land on road +//! pixels (against 40-70% for a plain 2:1 squash). The stored start point +//! only lands on the road on 14 of the 24 tracks, so the grid is placed from +//! the gates instead. + +use super::palette::{self, Rgb}; +use super::unpack; +use crate::formats::{be16, be32}; + +pub const W: usize = 1024; +pub const H: usize = 512; +pub const HM_W: usize = 64; +pub const HM_H: usize = 64; +pub const CELL_W: f32 = (W / HM_W) as f32; +pub const CELL_H: f32 = (H / HM_H) as f32; +const SECTION_SIZE: usize = 0x44a98; +const GATES_AT: usize = 0x4000; +const PLANES_AT: usize = 0x4a98; +const PLANE_SIZE: usize = W * H / 8; +const SECTIONS: usize = 7; +const ISO_X0: f32 = 512.0; +const ISO_Y_SCALE: f32 = 31.0 / 64.0; +const ISO_Y0: f32 = -252.0; + +pub fn to_screen(wx: f32, wy: f32) -> (f32, f32) { + (wx - wy + ISO_X0, (wx + wy) * ISO_Y_SCALE + ISO_Y0) +} + +pub fn on_screen(sx: f32, sy: f32, margin: f32) -> bool { + sx >= margin && sy >= margin && sx < W as f32 - margin && sy < H as f32 - margin +} + +pub struct TrackFile { + pub name: String, + pub number: u32, + /// Packed track sections (index 0 = first playable track). + pub sections: Vec>, +} + +#[derive(Clone, Copy, Debug)] +pub struct Gate { + pub a: (f32, f32), + pub b: (f32, f32), +} + +impl Gate { + pub fn mid(&self) -> (f32, f32) { + ((self.a.0 + self.b.0) * 0.5, (self.a.1 + self.b.1) * 0.5) + } +} + +pub struct Track { + pub name: String, + /// Palette indices, W x H. + pub pixels: Vec, + pub palette: [Rgb; 16], + /// Height samples in 8.8 fixed point. + pub height: Vec, + /// Gates in world coordinates. + pub gates: Vec, + /// Start position and flags as stored. Kept for reference: the start + /// point does not line up with the road reliably, and the flags' meaning + /// is unknown. + #[allow(dead_code)] + pub start: (f32, f32), + #[allow(dead_code)] + pub flags: u16, +} + +impl TrackFile { + pub fn parse(name: &str, d: &[u8]) -> Result { + if d.len() < SECTIONS * 4 { + return Err("file too short".into()); + } + let ends: Vec = (0..SECTIONS).map(|i| be32(d, i * 4) as usize).collect(); + let mut start = SECTIONS * 4; + let mut sections = Vec::new(); + for (i, &end) in ends.iter().enumerate() { + if end < start || end > d.len() { + return Err(format!("section {i} has a bad offset")); + } + if i > 0 { + sections.push(d[start..end].to_vec()); + } + start = end; + } + let number = name + .trim_start_matches(|c: char| !c.is_ascii_digit()) + .parse() + .unwrap_or(u32::MAX); + Ok(TrackFile { + name: name.to_string(), + number, + sections, + }) + } + + pub fn track_name(&self, section: usize) -> String { + format!("{}-{}", self.name.to_uppercase(), section + 1) + } + + pub fn decode(&self, section: usize) -> Result { + let raw = unpack(&self.sections[section]).map_err(|e| e.to_string())?; + Track::from_section(self.track_name(section), &raw) + } +} + +impl Track { + pub fn from_section(name: String, d: &[u8]) -> Result { + if d.len() < SECTION_SIZE { + return Err(format!( + "track section is {} bytes, expected {SECTION_SIZE}", + d.len() + )); + } + let height = (0..HM_W * HM_H).map(|i| be16(d, i * 2)).collect(); + + let count = be16(d, GATES_AT) as usize; + let start = (be16(d, GATES_AT + 2) as f32, be16(d, GATES_AT + 4) as f32); + let flags = be16(d, GATES_AT + 6); + if count == 0 || GATES_AT + 8 + count * 8 > PLANES_AT { + return Err(format!("implausible gate count {count}")); + } + let gates = (0..count) + .map(|i| { + let o = GATES_AT + 8 + i * 8; + let w = |k: usize| be16(d, o + k * 2) as f32 / 32.0; + Gate { + a: (w(0), w(1)), + b: (w(2), w(3)), + } + }) + .collect(); + + let planes = &d[PLANES_AT..PLANES_AT + 4 * PLANE_SIZE]; + let mut pixels = vec![0u8; W * H]; + for (p, plane) in planes.chunks_exact(PLANE_SIZE).enumerate() { + for (byte_i, &byte) in plane.iter().enumerate() { + if byte == 0 { + continue; + } + let base = byte_i * 8; + for bit in 0..8 { + if byte & (0x80 >> bit) != 0 { + pixels[base + bit] |= 1 << p; + } + } + } + } + + Ok(Track { + name, + pixels, + palette: palette::TRACK_DEFAULT, + height, + gates, + start, + flags, + }) + } + + /// Ground height (in height units) at a screen position, bilinear. + pub fn height_at(&self, sx: f32, sy: f32) -> f32 { + let fx = (sx / CELL_W - 0.5).clamp(0.0, (HM_W - 1) as f32); + let fy = (sy / CELL_H - 0.5).clamp(0.0, (HM_H - 1) as f32); + let (x0, y0) = (fx as usize, fy as usize); + let (x1, y1) = ((x0 + 1).min(HM_W - 1), (y0 + 1).min(HM_H - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let h = |x: usize, y: usize| self.height[y * HM_W + x] as f32 / 256.0; + let top = h(x0, y0) * (1.0 - tx) + h(x1, y0) * tx; + let bot = h(x0, y1) * (1.0 - tx) + h(x1, y1) * tx; + top * (1.0 - ty) + bot * ty + } + + /// Height at a world position. + pub fn height_world(&self, wx: f32, wy: f32) -> f32 { + let (sx, sy) = to_screen(wx, wy); + self.height_at(sx, sy) + } + + pub fn pixel(&self, sx: f32, sy: f32) -> u8 { + let x = (sx as isize).clamp(0, W as isize - 1) as usize; + let y = (sy as isize).clamp(0, H as isize - 1) as usize; + self.pixels[y * W + x] + } + + /// The upper half of the palette is used for the driving surface: the + /// fourth bitplane traces the road on every retail track. + pub fn on_road(&self, sx: f32, sy: f32) -> bool { + self.pixel(sx, sy) & 8 != 0 + } + + pub fn on_road_world(&self, wx: f32, wy: f32) -> bool { + let (sx, sy) = to_screen(wx, wy); + on_screen(sx, sy, 0.0) && self.on_road(sx, sy) + } + + pub fn rgb(&self) -> Vec { + let pal: Vec = self.palette.iter().map(|c| c.to_u32()).collect(); + self.pixels.iter().map(|&i| pal[i as usize]).collect() + } +} diff --git a/src/data/vehicle.rs b/src/data/vehicle.rs new file mode 100644 index 0000000..f70473d --- /dev/null +++ b/src/data/vehicle.rs @@ -0,0 +1,108 @@ +//! Vehicle sprite banks (`vehicles/*.aga`). +//! +//! 409600 bytes once decrunched: 25 tilt blocks x 32 headings x 512 bytes. +//! Each frame is a 64x32 AGA sprite with two bitplanes stored per line +//! (8 bytes plane 0, 8 bytes plane 1), giving three colours plus transparent. +//! +//! Tilt block = roll * 5 + pitch, with 2 meaning level on both axes. +//! Heading frame 12 faces screen-right and frames advance clockwise in +//! 11.25 degree steps. + +use super::unpack; + +pub const SPR_W: usize = 64; +pub const SPR_H: usize = 32; +pub const HEADINGS: usize = 32; +pub const TILTS: usize = 25; +pub const LEVEL: usize = 12; +const FRAME_BYTES: usize = 512; +const BANK_BYTES: usize = TILTS * HEADINGS * FRAME_BYTES; + +pub struct Vehicle { + pub id: String, + pub name: String, + /// Palette indices 0..3, TILTS * HEADINGS frames of SPR_W x SPR_H. + pub frames: Vec, +} + +impl Vehicle { + /// Returns `Ok(None)` for banks that are present but blank. + pub fn parse(file: &str, d: &[u8]) -> Result, String> { + let raw = unpack(d).map_err(|e| e.to_string())?; + if raw.len() < BANK_BYTES { + return Err(format!("{} bytes, expected {BANK_BYTES}", raw.len())); + } + if raw.iter().all(|&b| b == 0) { + return Ok(None); + } + let mut frames = vec![0u8; TILTS * HEADINGS * SPR_W * SPR_H]; + for (f, src) in raw[..BANK_BYTES].chunks_exact(FRAME_BYTES).enumerate() { + let dst = &mut frames[f * SPR_W * SPR_H..(f + 1) * SPR_W * SPR_H]; + for y in 0..SPR_H { + let line = &src[y * 16..y * 16 + 16]; + for x in 0..SPR_W { + let mask = 0x80 >> (x & 7); + let p0 = (line[x >> 3] & mask != 0) as u8; + let p1 = (line[8 + (x >> 3)] & mask != 0) as u8; + dst[y * SPR_W + x] = p0 | (p1 << 1); + } + } + } + let id = file.trim_end_matches(".aga").to_lowercase(); + Ok(Some(Vehicle { + name: display_name(&id), + id, + frames, + })) + } + + pub fn frame(&self, tilt: usize, heading: usize) -> &[u8] { + let f = tilt.min(TILTS - 1) * HEADINGS + heading % HEADINGS; + &self.frames[f * SPR_W * SPR_H..(f + 1) * SPR_W * SPR_H] + } +} + +/// Heading frame for a world-space angle (radians, 0 = +wx). World +wx runs +/// down-right on screen, so screen-right (frame 12) is world angle -45 degrees. +pub fn heading_frame(angle: f32) -> usize { + let steps = (angle + std::f32::consts::FRAC_PI_4) / (std::f32::consts::TAU / HEADINGS as f32); + (12 + steps.round() as i32).rem_euclid(HEADINGS as i32) as usize +} + +pub fn tilt_block(pitch: i32, roll: i32) -> usize { + ((roll.clamp(-2, 2) + 2) * 5 + (pitch.clamp(-2, 2) + 2)) as usize +} + +fn display_name(id: &str) -> String { + match id { + "vw" => "BEETLE".into(), + "f1" => "FORMULA 1".into(), + other => other.to_uppercase(), + } +} + +pub fn display_order(id: &str) -> usize { + const ORDER: [&str; 8] = [ + "mini", "vw", "porsche", "f1", "midget", "truck", "police", "cow", + ]; + ORDER.iter().position(|&o| o == id).unwrap_or(ORDER.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heading_frames() { + use std::f32::consts::{FRAC_PI_4, PI}; + assert_eq!(heading_frame(-FRAC_PI_4), 12); + assert_eq!(heading_frame(FRAC_PI_4), 20); + assert_eq!(heading_frame(PI - FRAC_PI_4), 28); + assert_eq!(heading_frame(-PI + FRAC_PI_4), 4); + } + + #[test] + fn level_block() { + assert_eq!(tilt_block(0, 0), LEVEL); + } +} diff --git a/src/formats/adf.rs b/src/formats/adf.rs new file mode 100644 index 0000000..2871457 --- /dev/null +++ b/src/formats/adf.rs @@ -0,0 +1,228 @@ +//! Amiga Disk File reader supporting OFS and FFS volumes (DD and HD images). + +use super::{be32, latin1}; +use std::collections::HashSet; +use std::fmt; + +const BSIZE: usize = 512; +const HASH_SIZE: usize = 72; +const T_HEADER: u32 = 2; +const T_LIST: u32 = 16; +const ST_ROOT: i32 = 1; +const ST_USERDIR: i32 = 2; +const ST_FILE: i32 = -3; + +#[derive(Debug)] +pub enum AdfError { + NotDos, + BadBlock(u32), + Corrupt(&'static str), +} + +impl fmt::Display for AdfError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AdfError::NotDos => write!(f, "not an AmigaDOS disk image"), + AdfError::BadBlock(b) => write!(f, "block {b} is outside the image"), + AdfError::Corrupt(why) => write!(f, "corrupt filesystem: {why}"), + } + } +} + +impl std::error::Error for AdfError {} + +pub struct AdfFile { + /// Path inside the volume, `/`-separated. + pub path: String, + pub data: Vec, +} + +pub struct Adf { + data: Vec, + ffs: bool, + root: u32, + pub volume: String, +} + +impl Adf { + pub fn open(data: Vec) -> Result { + if data.len() < 4 * BSIZE || &data[0..3] != b"DOS" { + return Err(AdfError::NotDos); + } + let ffs = data[3] & 1 != 0; + let root = (data.len() / BSIZE / 2) as u32; + let mut adf = Adf { + data, + ffs, + root, + volume: String::new(), + }; + let rb = adf.block(root)?; + if be32(rb, 0) != T_HEADER || be32(rb, BSIZE - 4) as i32 != ST_ROOT { + return Err(AdfError::Corrupt("root block has the wrong type")); + } + adf.volume = name_of(rb); + Ok(adf) + } + + fn block(&self, n: u32) -> Result<&[u8], AdfError> { + let start = n as usize * BSIZE; + self.data + .get(start..start + BSIZE) + .ok_or(AdfError::BadBlock(n)) + } + + /// Every regular file on the volume, depth first. + pub fn files(&self) -> Result, AdfError> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + self.walk_dir(self.root, "", &mut out, &mut seen, 0)?; + Ok(out) + } + + fn walk_dir( + &self, + dir: u32, + prefix: &str, + out: &mut Vec, + seen: &mut HashSet, + depth: usize, + ) -> Result<(), AdfError> { + if depth > 32 { + return Err(AdfError::Corrupt("directory nesting too deep")); + } + let db = self.block(dir)?; + for slot in 0..HASH_SIZE { + let mut entry = be32(db, 24 + slot * 4); + while entry != 0 { + if !seen.insert(entry) { + return Err(AdfError::Corrupt("loop in hash chain")); + } + let hb = self.block(entry)?; + if be32(hb, 0) != T_HEADER { + return Err(AdfError::Corrupt("hash chain points at a non-header block")); + } + let name = name_of(hb); + let path = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + match be32(hb, BSIZE - 4) as i32 { + ST_USERDIR => self.walk_dir(entry, &path, out, seen, depth + 1)?, + ST_FILE => out.push(AdfFile { + data: self.read_file(entry)?, + path, + }), + _ => {} // links and other oddities are not used by the game disks + } + entry = be32(hb, BSIZE - 16); + } + } + Ok(()) + } + + fn read_file(&self, header: u32) -> Result, AdfError> { + let hb = self.block(header)?; + let size = be32(hb, 324) as usize; + if size > self.data.len() { + return Err(AdfError::Corrupt("file larger than the disk")); + } + // Data block pointers are stored in reverse in each header/extension + // block: slot 71 is the first block. + let mut blocks = Vec::new(); + let mut cur = header; + let mut hops = 0; + loop { + let b = self.block(cur)?; + if cur != header && be32(b, 0) != T_LIST { + return Err(AdfError::Corrupt("bad file extension block")); + } + let count = (be32(b, 8) as usize).min(HASH_SIZE); + for i in 0..count { + blocks.push(be32(b, 24 + (HASH_SIZE - 1 - i) * 4)); + } + cur = be32(b, BSIZE - 8); + hops += 1; + if cur == 0 || hops > self.data.len() / BSIZE { + break; + } + } + + let mut data = Vec::with_capacity(size); + for blk in blocks { + if data.len() >= size { + break; + } + let b = self.block(blk)?; + if self.ffs { + data.extend_from_slice(b); + } else { + let n = (be32(b, 12) as usize).min(BSIZE - 24); + data.extend_from_slice(&b[24..24 + n]); + } + } + if data.len() < size { + return Err(AdfError::Corrupt( + "file data shorter than its header claims", + )); + } + data.truncate(size); + Ok(data) + } +} + +fn name_of(block: &[u8]) -> String { + let len = (block[BSIZE - 80] as usize).min(30); + latin1(&block[BSIZE - 79..BSIZE - 79 + len]) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a minimal one-file OFS DD image to exercise the reader. + fn tiny_ofs(contents: &[u8]) -> Vec { + let mut img = vec![0u8; 880 * 2 * BSIZE]; + img[0..4].copy_from_slice(b"DOS\0"); + let put = |img: &mut Vec, blk: usize, off: usize, v: u32| { + img[blk * BSIZE + off..blk * BSIZE + off + 4].copy_from_slice(&v.to_be_bytes()); + }; + let root = 880; + put(&mut img, root, 0, T_HEADER); + put(&mut img, root, BSIZE - 4, ST_ROOT as u32); + let fh = 882; + // Hash slot is irrelevant to the reader; use slot 0. + put(&mut img, root, 24, fh as u32); + put(&mut img, fh, 0, T_HEADER); + put(&mut img, fh, BSIZE - 4, ST_FILE as u32); + let name = b"hello"; + img[fh * BSIZE + BSIZE - 80] = name.len() as u8; + img[fh * BSIZE + BSIZE - 79..fh * BSIZE + BSIZE - 79 + name.len()].copy_from_slice(name); + put(&mut img, fh, 324, contents.len() as u32); + let chunks: Vec<&[u8]> = contents.chunks(BSIZE - 24).collect(); + put(&mut img, fh, 8, chunks.len() as u32); + for (i, chunk) in chunks.iter().enumerate() { + let db = 883 + i; + put(&mut img, fh, 24 + (HASH_SIZE - 1 - i) * 4, db as u32); + put(&mut img, db, 12, chunk.len() as u32); + img[db * BSIZE + 24..db * BSIZE + 24 + chunk.len()].copy_from_slice(chunk); + } + img + } + + #[test] + fn reads_ofs_file() { + let contents: Vec = (0..1500u32).map(|i| (i * 7) as u8).collect(); + let adf = Adf::open(tiny_ofs(&contents)).unwrap(); + let files = adf.files().unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "hello"); + assert_eq!(files[0].data, contents); + } + + #[test] + fn rejects_non_dos() { + assert!(matches!(Adf::open(vec![0; 901120]), Err(AdfError::NotDos))); + } +} diff --git a/src/formats/crm.rs b/src/formats/crm.rs new file mode 100644 index 0000000..90f394f --- /dev/null +++ b/src/formats/crm.rs @@ -0,0 +1,289 @@ +//! CrunchMania decruncher (`CrM!`, `CrM2` and the delta-sampled `Crm!`/`Crm2`). +//! +//! Header: 4-byte id, 2 unused bytes, BE32 unpacked size, BE32 packed size. +//! The packed stream is read backwards, byte by byte, LSB first, and the +//! output is also written from the end towards the start. The final six +//! bytes of the packed data seed the bit buffer. +//! +//! The bitstream layout follows the description in Teemu Suutari's +//! "ancient" decompression library (BSD-2-Clause). + +use super::{be16, be32}; +use std::fmt; + +#[derive(Debug, PartialEq)] +pub enum CrmError { + NotCrunched, + Truncated, + BadHuffman, + BadReference, + SizeMismatch, +} + +impl fmt::Display for CrmError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + CrmError::NotCrunched => "not CrunchMania data", + CrmError::Truncated => "packed stream ended early", + CrmError::BadHuffman => "invalid Huffman table", + CrmError::BadReference => "back-reference outside the output", + CrmError::SizeMismatch => "output size does not match the header", + }; + f.write_str(s) + } +} + +impl std::error::Error for CrmError {} + +pub fn is_crunched(d: &[u8]) -> bool { + d.len() >= 20 && matches!(&d[0..4], b"CrM!" | b"CrM2" | b"Crm!" | b"Crm2") +} + +struct BackBits<'a> { + d: &'a [u8], + start: usize, + pos: usize, + buf: u32, + len: u32, +} + +impl BackBits<'_> { + #[inline] + fn bits(&mut self, mut count: u32) -> Result { + let mut ret = 0u32; + let mut shift = 0u32; + while count > 0 { + if self.len == 0 { + if self.pos <= self.start { + return Err(CrmError::Truncated); + } + self.pos -= 1; + self.buf = self.d[self.pos] as u32; + self.len = 8; + } + let take = count.min(self.len); + ret |= (self.buf & ((1 << take) - 1)) << shift; + self.buf >>= take; + self.len -= take; + count -= take; + shift += take; + } + Ok(ret) + } + + #[inline] + fn bit(&mut self) -> Result { + self.bits(1) + } +} + +/// Canonical-style decoder: codes of each depth are consecutive. +struct Huffman { + max_depth: u32, + first: [u32; 16], + count: [u32; 16], + offset: [usize; 16], + values: Vec, +} + +impl Huffman { + fn read(br: &mut BackBits, code_len: u32) -> Result { + let max_depth = br.bits(4)?; + if max_depth == 0 { + return Err(CrmError::BadHuffman); + } + let mut lengths = [0u32; 16]; + for i in 0..max_depth { + lengths[i as usize] = br.bits((i + 1).min(code_len))?; + } + let mut h = Huffman { + max_depth, + first: [0; 16], + count: [0; 16], + offset: [0; 16], + values: Vec::new(), + }; + let mut code = 0u32; + for depth in 1..=max_depth { + let di = depth as usize; + h.first[di] = code >> (max_depth - depth); + h.count[di] = lengths[di - 1]; + h.offset[di] = h.values.len(); + for _ in 0..lengths[di - 1] { + h.values.push(br.bits(code_len)?); + code += 1 << (max_depth - depth); + } + if code > (1 << max_depth) { + return Err(CrmError::BadHuffman); + } + } + Ok(h) + } + + #[inline] + fn decode(&self, br: &mut BackBits) -> Result { + let mut code = 0u32; + for depth in 1..=self.max_depth as usize { + code = (code << 1) | br.bit()?; + let rel = code.wrapping_sub(self.first[depth]); + if rel < self.count[depth] { + return Ok(self.values[self.offset[depth] + rel as usize]); + } + } + Err(CrmError::BadHuffman) + } +} + +pub fn decrunch(d: &[u8]) -> Result, CrmError> { + if !is_crunched(d) { + return Err(CrmError::NotCrunched); + } + let lzh = d[3] == b'2'; + let sampled = d[2] == b'm'; + let raw = be32(d, 6) as usize; + let packed = be32(d, 10) as usize; + if packed < 6 || packed + 14 > d.len() || raw == 0 || raw > 64 << 20 { + return Err(CrmError::Truncated); + } + let tail = packed + 14 - 6; + let shift = be16(d, tail + 4) as u32; + if shift > 16 { + return Err(CrmError::Truncated); + } + let mut br = BackBits { + d, + start: 14, + pos: tail, + buf: be32(d, tail) >> (16 - shift), + len: shift + 16, + }; + + let mut out = vec![0u8; raw]; + let mut o = raw; + + macro_rules! put { + ($b:expr) => {{ + if o == 0 { + return Err(CrmError::SizeMismatch); + } + o -= 1; + out[o] = $b; + }}; + } + macro_rules! copy { + ($dist:expr, $count:expr) => {{ + let (dist, count) = ($dist as usize, $count as usize); + if dist == 0 || count > o || o + dist > raw { + return Err(CrmError::BadReference); + } + for _ in 0..count { + o -= 1; + out[o] = out[o + dist]; + } + }}; + } + + if lzh { + loop { + let lengths = Huffman::read(&mut br, 9)?; + let distances = Huffman::read(&mut br, 4)?; + let items = br.bits(16)? + 1; + for _ in 0..items { + let sym = lengths.decode(&mut br)?; + if sym & 0x100 != 0 { + put!(sym as u8); + } else { + let count = sym + 3; + let dbits = distances.decode(&mut br)?; + let dist = if dbits == 0 { + br.bits(1)? + 1 + } else { + (br.bits(dbits)? | (1 << dbits)) + 1 + }; + copy!(dist, count); + } + } + if br.bit()? == 0 { + break; + } + } + } else { + const LEN_BASE: [u32; 4] = [0, 2, 6, 22]; + const LEN_BITS: [u32; 4] = [1, 2, 4, 8]; + const DIST_BASE: [u32; 3] = [0, 32, 544]; + const DIST_BITS: [u32; 3] = [5, 9, 14]; + while o > 0 { + if br.bit()? != 0 { + put!(br.bits(8)? as u8); + continue; + } + // Length class: 0, 10, 110, 111 + let li = if br.bit()? == 0 { + 0 + } else if br.bit()? == 0 { + 1 + } else if br.bit()? == 0 { + 2 + } else { + 3 + }; + let mut count = LEN_BASE[li] + br.bits(LEN_BITS[li])? + 2; + if count == 23 { + // Literal run + let run = if br.bit()? != 0 { + br.bits(5)? + 15 + } else { + br.bits(14)? + 15 + }; + for _ in 0..run { + put!(br.bits(8)? as u8); + } + continue; + } + if count > 23 { + count -= 1; + } + // Distance class: 0 -> 1, 10 -> 0, 11 -> 2 + let di = if br.bit()? == 0 { + 1 + } else if br.bit()? == 0 { + 0 + } else { + 2 + }; + let dist = DIST_BASE[di] + br.bits(DIST_BITS[di])?; + copy!(dist, count); + } + } + + if o != 0 { + return Err(CrmError::SizeMismatch); + } + if sampled { + let mut acc = 0u8; + for b in out.iter_mut() { + acc = acc.wrapping_add(*b); + *b = acc; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_plain_data() { + assert_eq!(decrunch(&[0u8; 64]), Err(CrmError::NotCrunched)); + } + + #[test] + fn rejects_truncated_header() { + let mut d = b"CrM2\0\0".to_vec(); + d.extend_from_slice(&100u32.to_be_bytes()); + d.extend_from_slice(&1000u32.to_be_bytes()); + d.extend_from_slice(&[0; 16]); + assert_eq!(decrunch(&d), Err(CrmError::Truncated)); + } +} diff --git a/src/formats/lha.rs b/src/formats/lha.rs new file mode 100644 index 0000000..44b2ed2 --- /dev/null +++ b/src/formats/lha.rs @@ -0,0 +1,169 @@ +//! LHA/LZH archive reader (header levels 0, 1 and 2). +//! +//! The game disks only use the `-lh0-` (stored) method because every file is +//! already CrunchMania-packed, so compressed methods are reported rather than +//! decoded. + +use super::{latin1, le16, le32}; +use std::fmt; + +#[derive(Debug)] +pub enum LhaError { + Truncated(usize), + BadHeader(usize), +} + +impl fmt::Display for LhaError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + LhaError::Truncated(at) => write!(f, "archive truncated at byte {at}"), + LhaError::BadHeader(at) => write!(f, "unrecognised LHA header at byte {at}"), + } + } +} + +impl std::error::Error for LhaError {} + +pub struct LhaEntry { + /// Path inside the archive, `/`-separated. + pub path: String, + pub method: String, + /// File contents for stored entries, `None` for methods we do not decode. + pub data: Option>, +} + +pub fn parse(d: &[u8]) -> Result, LhaError> { + let mut out = Vec::new(); + let mut p = 0usize; + while p < d.len() && d[p] != 0 { + if p + 22 > d.len() { + return Err(LhaError::Truncated(p)); + } + let method = &d[p + 2..p + 7]; + if method[0] != b'-' || method[4] != b'-' { + return Err(LhaError::BadHeader(p)); + } + let method = latin1(method); + let mut packed = le32(d, p + 7) as usize; + let level = d[p + 20]; + + let mut name = String::new(); + let mut dir = String::new(); + let data_start; + + match level { + 0 | 1 => { + let header_len = d[p] as usize + 2; + let name_len = d[p + 21] as usize; + if p + 22 + name_len > d.len() || p + header_len > d.len() { + return Err(LhaError::Truncated(p)); + } + name = latin1(&d[p + 22..p + 22 + name_len]); + let mut q = p + header_len; + if level == 1 { + // Extended headers follow the base header and are counted + // in the packed size. + let mut next = le16(d, q - 2) as usize; + while next != 0 { + if q + next > d.len() || next < 3 { + return Err(LhaError::Truncated(q)); + } + read_ext(&d[q..q + next - 2], &mut name, &mut dir); + packed = packed.checked_sub(next).ok_or(LhaError::BadHeader(q))?; + q += next; + next = le16(d, q - 2) as usize; + } + } + data_start = q; + } + 2 => { + let header_len = le16(d, p) as usize; + if p + header_len > d.len() || header_len < 26 { + return Err(LhaError::Truncated(p)); + } + let mut q = p + 26; + let mut next = le16(d, p + 24) as usize; + while next != 0 { + if q + next > p + header_len || next < 3 { + return Err(LhaError::BadHeader(q)); + } + read_ext(&d[q..q + next - 2], &mut name, &mut dir); + q += next; + next = le16(d, q - 2) as usize; + } + data_start = p + header_len; + } + _ => return Err(LhaError::BadHeader(p)), + } + + let data_end = data_start + packed; + if data_end > d.len() { + return Err(LhaError::Truncated(data_start)); + } + let path = join_path(&dir, &name); + let data = match method.as_str() { + "-lh0-" | "-lz4-" => Some(d[data_start..data_end].to_vec()), + _ => None, + }; + if method != "-lhd-" { + out.push(LhaEntry { path, method, data }); + } + p = data_end; + } + Ok(out) +} + +fn read_ext(ext: &[u8], name: &mut String, dir: &mut String) { + match ext.first() { + Some(1) => *name = latin1(&ext[1..]), + Some(2) => *dir = latin1(&ext[1..]), + _ => {} + } +} + +fn join_path(dir: &str, name: &str) -> String { + let norm = |s: &str| -> String { + s.chars() + .map(|c| if c == '\\' || c == '\u{ff}' { '/' } else { c }) + .collect::() + .trim_matches('/') + .to_string() + }; + let (dir, name) = (norm(dir), norm(name)); + if dir.is_empty() { + name + } else { + format!("{dir}/{name}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn level0_entry(name: &str, body: &[u8]) -> Vec { + let mut h = vec![0u8; 22]; + h[2..7].copy_from_slice(b"-lh0-"); + h[7..11].copy_from_slice(&(body.len() as u32).to_le_bytes()); + h[11..15].copy_from_slice(&(body.len() as u32).to_le_bytes()); + h[20] = 0; + h[21] = name.len() as u8; + h.extend_from_slice(name.as_bytes()); + h.extend_from_slice(&[0, 0]); // CRC, unchecked + h[0] = (h.len() - 2) as u8; + h.extend_from_slice(body); + h + } + + #[test] + fn parses_stored_level0() { + let mut arc = level0_entry("dir\\a.bin", b"hello"); + arc.extend(level0_entry("b", b"world!")); + arc.push(0); + let e = parse(&arc).unwrap(); + assert_eq!(e.len(), 2); + assert_eq!(e[0].path, "dir/a.bin"); + assert_eq!(e[0].data.as_deref(), Some(&b"hello"[..])); + assert_eq!(e[1].data.as_deref(), Some(&b"world!"[..])); + } +} diff --git a/src/formats/mod.rs b/src/formats/mod.rs new file mode 100644 index 0000000..71351a0 --- /dev/null +++ b/src/formats/mod.rs @@ -0,0 +1,30 @@ +//! Readers for the Amiga container formats the game ships in. +//! +//! The original release is a set of ADF floppy images. Each image holds one +//! LHA archive (stored, no compression), and the files inside those archives +//! are individually packed with CrunchMania. + +pub mod adf; +pub mod crm; +pub mod lha; + +pub(crate) fn be16(d: &[u8], off: usize) -> u16 { + u16::from_be_bytes([d[off], d[off + 1]]) +} + +pub(crate) fn be32(d: &[u8], off: usize) -> u32 { + u32::from_be_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]) +} + +pub(crate) fn le16(d: &[u8], off: usize) -> u16 { + u16::from_le_bytes([d[off], d[off + 1]]) +} + +pub(crate) fn le32(d: &[u8], off: usize) -> u32 { + u32::from_le_bytes([d[off], d[off + 1], d[off + 2], d[off + 3]]) +} + +/// Amiga filenames are ISO-8859-1; map each byte straight to its code point. +pub(crate) fn latin1(bytes: &[u8]) -> String { + bytes.iter().map(|&b| b as char).collect() +} diff --git a/src/game/ai.rs b/src/game/ai.rs new file mode 100644 index 0000000..b76fcbe --- /dev/null +++ b/src/game/ai.rs @@ -0,0 +1,184 @@ +//! Computer drivers. +//! +//! Each driver follows a line through the gates (at its own offset across +//! the track) by pure pursuit: aim at a point a speed-dependent distance +//! further along the line. Short look-ahead rays nudge the chosen heading +//! away from the grass, and a driver that stops moving backs out. + +use super::car::{angle_diff, Car, Controls}; +use crate::data::track::{Gate, Track}; + +const RAYS: i32 = 7; +const RAY_STEP: f32 = 0.13; + +#[derive(Clone, Debug)] +pub struct Ai { + /// Where across each gate this driver aims (0 = end a, 1 = end b). + pub line: f32, + /// Fraction of the car's top speed this driver uses. + pub pace: f32, + /// Index of the path segment the car is on (gate i-1 to gate i). + seg: Option, + stuck_for: f32, + blocked_for: f32, + reversing: f32, +} + +impl Ai { + pub fn new(line: f32, pace: f32) -> Ai { + Ai { + line, + pace, + seg: None, + stuck_for: 0.0, + blocked_for: 0.0, + reversing: 0.0, + } + } + + fn point(&self, g: &Gate) -> (f32, f32) { + ( + g.a.0 + (g.b.0 - g.a.0) * self.line, + g.a.1 + (g.b.1 - g.a.1) * self.line, + ) + } + + /// Point `dist` along the driving line from the car's projection onto it. + fn pursue(&mut self, car: &Car, gates: &[Gate], next_gate: usize, dist: f32) -> (f32, f32) { + let n = gates.len(); + // Search a few segments ahead of the last known one; never jump back, + // so figure-of-eight crossings don't confuse it. Resync to the lap + // counter if we drift too far from it. + let mut seg = self.seg.unwrap_or(next_gate); + let ahead = (next_gate + n - seg) % n; + if ahead > 3 && ahead < n - 1 { + seg = next_gate; + } + let mut best = (f32::MAX, seg, 0.0f32); + for k in 0..4 { + let i = (seg + k) % n; + let a = self.point(&gates[(i + n - 1) % n]); + let b = self.point(&gates[i]); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = (dx * dx + dy * dy).max(1e-3); + let t = (((car.x - a.0) * dx + (car.y - a.1) * dy) / len2).clamp(0.0, 1.0); + let d = (a.0 + dx * t - car.x).hypot(a.1 + dy * t - car.y); + if d < best.0 - 1.0 { + best = (d, i, t); + } + } + let (_, mut i, t) = best; + self.seg = Some(i); + + let mut a = self.point(&gates[(i + n - 1) % n]); + let mut b = self.point(&gates[i]); + let mut remaining = dist + (b.0 - a.0).hypot(b.1 - a.1) * t; + for _ in 0..n { + let len = (b.0 - a.0).hypot(b.1 - a.1); + if remaining <= len { + let k = remaining / len.max(1e-3); + return (a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k); + } + remaining -= len; + i = (i + 1) % n; + a = b; + b = self.point(&gates[i]); + } + b + } + + pub fn drive( + &mut self, + car: &Car, + track: &Track, + next_gate: usize, + dt: f32, + racing: bool, + ) -> Controls { + if !racing { + return Controls::default(); + } + let gates = &track.gates; + let speed = car.speed(); + let look = 34.0 + speed * 0.32; + let target = self.pursue(car, gates, next_gate, look); + let far = self.pursue(car, gates, next_gate, look * 2.2); + + let want = (target.1 - car.y).atan2(target.0 - car.x); + + if self.reversing > 0.0 { + self.reversing -= dt; + let diff = angle_diff(want, car.heading); + return Controls { + throttle: 0.0, + brake: 1.0, + steer: -diff.signum(), + }; + } + if speed < 10.0 { + self.stuck_for += dt; + if self.stuck_for > 1.0 { + self.stuck_for = 0.0; + self.reversing = 0.8; + } + } else { + self.stuck_for = 0.0; + } + + // Pick the heading closest to the target that keeps us on the road. + let ray_len = 18.0 + speed * 0.25; + let mut best = (f32::MAX, want); + for k in -RAYS..=RAYS { + let cand = want + k as f32 * RAY_STEP; + let mut grass = 0.0; + for step in 1..=4 { + let d = ray_len * step as f32 / 4.0; + if !track.on_road_world(car.x + cand.cos() * d, car.y + cand.sin() * d) { + grass += 1.0 / step as f32; + } + } + let cost = (k as f32 * RAY_STEP).abs() + grass * 0.9; + if cost < best.0 { + best = (cost, cand); + } + } + let diff = angle_diff(best.1, car.heading); + let steer = (diff * 3.0).clamp(-1.0, 1.0); + // Boxed in (nose in the scenery or against the edge): back out. + if best.0 > 1.6 && speed < 30.0 { + self.blocked_for += dt; + if self.blocked_for > 0.5 { + self.blocked_for = 0.0; + self.reversing = 0.7; + } + } else { + self.blocked_for = 0.0; + } + + // Slow for what is coming: compare heading with the far probe. + let bend = angle_diff((far.1 - car.y).atan2(far.0 - car.x), car.heading).abs(); + let corner_speed = car.spec.top * self.pace * (1.0 - (bend / 1.3).min(0.75)); + let (mut throttle, mut brake) = (1.0, 0.0); + if speed > corner_speed { + throttle = 0.0; + if speed > corner_speed + 25.0 { + brake = 0.7; + } + } else if diff.abs() > 0.8 { + throttle = 0.6; + } + if diff.abs() > 2.3 && speed < 35.0 { + // Facing the wrong way: reverse-turn round. + return Controls { + throttle: 0.0, + brake: 1.0, + steer: -steer, + }; + } + Controls { + throttle, + brake, + steer, + } + } +} diff --git a/src/game/car.rs b/src/game/car.rs new file mode 100644 index 0000000..0b05582 --- /dev/null +++ b/src/game/car.rs @@ -0,0 +1,333 @@ +//! Vehicle handling. +//! +//! The retail executable is not available, so handling is a reconstruction: +//! an arcade model with separate forward and lateral friction, which gives +//! the long power-slides the series is known for. Physics runs on the +//! isometric ground plane (see `data::track::to_screen`). + +use crate::data::track::{self, Track}; +use crate::data::vehicle; +use std::f32::consts::{PI, TAU}; + +#[derive(Clone, Copy, Debug)] +pub struct Spec { + /// Top speed on tarmac, world pixels per second. + pub top: f32, + pub accel: f32, + pub brake: f32, + /// Lateral grip: how quickly sideways motion is killed (1/s). + pub grip: f32, + /// Steering rate at speed (rad/s). + pub turn: f32, + pub mass: f32, + /// Speed and grip multiplier off the road. + pub offroad: f32, +} + +pub fn spec_for(id: &str) -> Spec { + let base = Spec { + top: 200.0, + accel: 170.0, + brake: 320.0, + grip: 5.0, + turn: 3.6, + mass: 1.0, + offroad: 0.55, + }; + match id { + "f1" => Spec { + top: 240.0, + accel: 210.0, + grip: 6.5, + turn: 3.7, + mass: 0.9, + offroad: 0.45, + ..base + }, + "porsche" => Spec { + top: 225.0, + accel: 190.0, + grip: 5.2, + turn: 3.6, + ..base + }, + "mini" => Spec { + top: 190.0, + accel: 200.0, + grip: 5.8, + turn: 4.4, + mass: 0.8, + ..base + }, + "vw" => Spec { + top: 185.0, + accel: 160.0, + grip: 4.6, + turn: 3.6, + mass: 1.0, + ..base + }, + "midget" => Spec { + top: 205.0, + accel: 185.0, + grip: 4.0, + turn: 4.0, + mass: 0.85, + offroad: 0.6, + ..base + }, + "truck" => Spec { + top: 195.0, + accel: 140.0, + grip: 4.2, + turn: 3.0, + mass: 1.8, + offroad: 0.75, + ..base + }, + "police" => Spec { + top: 215.0, + accel: 180.0, + grip: 5.0, + turn: 3.5, + mass: 1.2, + ..base + }, + "cow" => Spec { + top: 170.0, + accel: 150.0, + grip: 7.0, + turn: 4.6, + mass: 1.4, + offroad: 0.9, + ..base + }, + _ => base, + } +} + +pub const RADIUS: f32 = 11.0; +const SLOPE_GRAVITY: f32 = 110.0; +/// Cap on slope acceleration so the steep banking on some tracks cannot pin +/// a car against the outside of a bend. +const SLOPE_MAX: f32 = 45.0; + +#[derive(Clone, Copy, Default, Debug)] +pub struct Controls { + pub throttle: f32, + pub brake: f32, + /// -1 = left, 1 = right. + pub steer: f32, +} + +#[derive(Clone, Debug)] +pub struct Car { + pub vehicle: usize, + pub spec: Spec, + pub team: usize, + pub x: f32, + pub y: f32, + pub vx: f32, + pub vy: f32, + /// World angle, 0 = +x, increasing clockwise on screen. + pub heading: f32, + pub controls: Controls, + /// Sideways speed this frame, used for skid marks and sound. + pub slip: f32, + pub on_road: bool, + pub tilt: usize, + /// Throttle/rpm proxy for the engine sound, 0..1. + pub rpm: f32, + /// Strength of the last collision, decays each frame. + pub bump: f32, +} + +impl Car { + pub fn new(vehicle: usize, id: &str, team: usize, x: f32, y: f32, heading: f32) -> Car { + Car { + vehicle, + spec: spec_for(id), + team, + x, + y, + vx: 0.0, + vy: 0.0, + heading, + controls: Controls::default(), + slip: 0.0, + on_road: true, + tilt: vehicle::LEVEL, + rpm: 0.0, + bump: 0.0, + } + } + + pub fn screen_pos(&self) -> (f32, f32) { + track::to_screen(self.x, self.y) + } + + pub fn speed(&self) -> f32 { + self.vx.hypot(self.vy) + } + + pub fn forward(&self) -> (f32, f32) { + (self.heading.cos(), self.heading.sin()) + } + + pub fn step(&mut self, track: &Track, dt: f32, top_scale: f32) { + self.on_road = track.on_road_world(self.x, self.y); + let surf = if self.on_road { 1.0 } else { self.spec.offroad }; + let c = self.controls; + let (fx, fy) = self.forward(); + let (rx, ry) = (-fy, fx); + let mut vf = self.vx * fx + self.vy * fy; + let mut vr = self.vx * rx + self.vy * ry; + let top = self.spec.top * surf * top_scale; + + if c.throttle > 0.0 && vf < top { + let headroom = (1.0 - (vf / top).max(0.0).powi(2)).max(0.05); + vf += self.spec.accel * c.throttle * headroom * dt; + } + if c.brake > 0.0 { + if vf > 4.0 { + vf = (vf - self.spec.brake * c.brake * dt).max(0.0); + } else if vf > -top * 0.35 { + vf -= self.spec.accel * 0.6 * c.brake * dt; + } + } + let drag = if self.on_road { 0.35 } else { 1.6 }; + vf -= vf * drag * dt; + if c.throttle == 0.0 && c.brake == 0.0 { + vf -= vf.signum() * (30.0 * dt).min(vf.abs()); + } + + // Sideways friction: grip drops as the slide gets faster, so once a + // car breaks away it keeps sliding. + let grip = self.spec.grip * surf.max(0.6) / (1.0 + vr.abs() / 120.0); + vr *= (-grip * dt).exp(); + self.slip = vr.abs(); + + let steer_auth = (vf.abs() / 45.0).min(1.0) * vf.signum(); + let turn = self.spec.turn * (1.0 - (vf.abs() / (top * 2.2)).min(0.35)); + self.heading = (self.heading + c.steer * turn * steer_auth * dt).rem_euclid(TAU); + + self.vx = fx * vf + rx * vr; + self.vy = fy * vf + ry * vr; + + // Hills: the height map is sampled in screen space. + let (gx, gy) = gradient(track, self.x, self.y); + let (mut ax, mut ay) = (-gx * SLOPE_GRAVITY, -gy * SLOPE_GRAVITY); + let a = ax.hypot(ay); + if a > SLOPE_MAX { + ax *= SLOPE_MAX / a; + ay *= SLOPE_MAX / a; + } + self.vx += ax * dt; + self.vy += ay * dt; + + let (ox, oy) = (self.x, self.y); + self.x += self.vx * dt; + self.y += self.vy * dt; + self.keep_in_bounds(ox, oy); + + // Body tilt from the slope along and across the car. + let (fx, fy) = self.forward(); + let along = gx * fx + gy * fy; + let across = gx * -fy + gy * fx; + let q = |s: f32| (s / 0.035).round() as i32; + self.tilt = vehicle::tilt_block(-q(along), q(across)); + + let target_rpm = if c.throttle > 0.0 { + 0.35 + 0.65 * (vf.abs() / top).min(1.0) + } else { + (vf.abs() / top).min(1.0) * 0.7 + }; + self.rpm += (target_rpm - self.rpm) * (1.0 - (-6.0 * dt).exp()); + self.bump *= (-8.0 * dt).exp(); + } + + /// The picture edge is a wall. Slide along it by keeping whichever axis + /// of the move stays inside, so cars never get pinned in a corner. + fn keep_in_bounds(&mut self, ox: f32, oy: f32) { + const MARGIN: f32 = 3.0; + let inside = |x: f32, y: f32| { + let (sx, sy) = track::to_screen(x, y); + track::on_screen(sx, sy, MARGIN) + }; + if inside(self.x, self.y) { + return; + } + let hit = self.speed() / 200.0; + if inside(self.x, oy) { + self.y = oy; + self.vy *= -0.3; + } else if inside(ox, self.y) { + self.x = ox; + self.vx *= -0.3; + } else { + self.x = ox; + self.y = oy; + self.vx *= -0.3; + self.vy *= -0.3; + } + self.bump = self.bump.max(hit.min(0.6)); + } + + /// Rear wheel contact points in screen space, for skid marks. + pub fn rear_wheels(&self) -> [(f32, f32); 2] { + let (fx, fy) = self.forward(); + let (rx, ry) = (-fy, fx); + let (bx, by) = (self.x - fx * 10.0, self.y - fy * 10.0); + [ + track::to_screen(bx + rx * 6.0, by + ry * 6.0), + track::to_screen(bx - rx * 6.0, by - ry * 6.0), + ] + } +} + +/// Height change per world unit along world x and y. +fn gradient(track: &Track, wx: f32, wy: f32) -> (f32, f32) { + let d = 6.0; + let gx = (track.height_world(wx + d, wy) - track.height_world(wx - d, wy)) / (2.0 * d); + let gy = (track.height_world(wx, wy + d) - track.height_world(wx, wy - d)) / (2.0 * d); + (gx, gy) +} + +/// Pushes overlapping cars apart and exchanges momentum. +pub fn collide(a: &mut Car, b: &mut Car) { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let d2 = dx * dx + dy * dy; + let min = RADIUS * 2.0; + if d2 >= min * min || d2 < 1e-6 { + return; + } + let d = d2.sqrt(); + let (nx, ny) = (dx / d, dy / d); + let overlap = min - d; + let (ia, ib) = (1.0 / a.spec.mass, 1.0 / b.spec.mass); + let push = overlap / (ia + ib); + a.x -= nx * push * ia; + a.y -= ny * push * ia; + b.x += nx * push * ib; + b.y += ny * push * ib; + + let vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny; + if vn < 0.0 { + let j = -(1.0 + 0.45) * vn / (ia + ib); + a.vx -= j * ia * nx; + a.vy -= j * ia * ny; + b.vx += j * ib * nx; + b.vy += j * ib * ny; + let hit = (-vn / 150.0).min(1.0); + a.bump = a.bump.max(hit); + b.bump = b.bump.max(hit); + // A glancing knock spins the cars a little. + let spin = (nx * a.forward().1 - ny * a.forward().0) * hit * 0.25; + a.heading = (a.heading + spin).rem_euclid(TAU); + b.heading = (b.heading - spin).rem_euclid(TAU); + } +} + +pub fn angle_diff(to: f32, from: f32) -> f32 { + (to - from + PI).rem_euclid(TAU) - PI +} diff --git a/src/game/mod.rs b/src/game/mod.rs new file mode 100644 index 0000000..007a99b --- /dev/null +++ b/src/game/mod.rs @@ -0,0 +1,361 @@ +//! Race rules: grid, countdown, gate-based lap counting and finishing order. + +pub mod ai; +pub mod car; + +use crate::data::track::{Gate, Track}; +use crate::data::vehicle::Vehicle; +use ai::Ai; +use car::{Car, Controls}; + +pub const DT: f32 = 1.0 / 50.0; +const COUNTDOWN: f32 = 3.0; +/// Gates are extended by this fraction at each end when testing crossings, +/// so a car clipping the verge still counts. +const GATE_SLACK: f32 = 0.35; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Driver { + Human(usize), + Cpu, +} + +pub struct Entrant { + pub car: Car, + pub driver: Driver, + pub ai: Ai, + pub name: String, + /// Gates crossed in order since the start. + pub passed: usize, + pub next_gate: usize, + pub lap_start: f32, + pub best_lap: Option, + pub last_lap: Option, + pub finished: Option, +} + +impl Entrant { + pub fn laps_done(&self, gates: usize) -> usize { + self.passed.saturating_sub(1) / gates + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Phase { + Countdown, + Racing, + Done, +} + +pub struct Race { + pub track: Track, + pub entrants: Vec, + pub laps: usize, + pub time: f32, + pub phase: Phase, + pub finish_order: Vec, + /// Track image with skid marks burnt in, 0RGB. + pub ground: Vec, + pub particles: Vec, + done_timer: f32, + rng: u32, +} + +pub struct Particle { + pub x: f32, + pub y: f32, + pub vx: f32, + pub vy: f32, + pub life: f32, + pub colour: u32, +} + +pub struct EntrantSetup { + pub vehicle: usize, + pub driver: Driver, + pub team: usize, + pub name: String, +} + +impl Race { + pub fn new( + track: Track, + vehicles: &[Vehicle], + setup: &[EntrantSetup], + laps: usize, + seed: u32, + ) -> Race { + let gates = track.gates.clone(); + // Gate 0 is the start/finish line; the grid forms up behind it. + let start_gate = 0; + let path: Vec<(f32, f32)> = gates.iter().map(Gate::mid).collect(); + + let mut rng = seed | 1; + let mut entrants = Vec::new(); + for (i, s) in setup.iter().enumerate() { + // Two-wide grid, rows 30 units apart, following the road back + // from the line. + let row = (i / 2) as f32; + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let ((px, py), heading) = behind(&path, 18.0 + row * 30.0); + let (rx, ry) = (-heading.sin(), heading.cos()); + let (x, y) = (px + rx * side * 12.0, py + ry * side * 12.0); + let car = Car::new(s.vehicle, &vehicles[s.vehicle].id, s.team, x, y, heading); + let line = 0.3 + 0.4 * rand01(&mut rng); + let pace = 0.86 + 0.12 * rand01(&mut rng); + entrants.push(Entrant { + car, + driver: s.driver, + ai: Ai::new(line, pace), + name: s.name.clone(), + passed: 0, + next_gate: start_gate, + lap_start: 0.0, + best_lap: None, + last_lap: None, + finished: None, + }); + } + let ground = track.rgb(); + Race { + track, + entrants, + laps, + time: 0.0, + phase: Phase::Countdown, + finish_order: Vec::new(), + ground, + particles: Vec::new(), + done_timer: 0.0, + rng, + } + } + + pub fn countdown(&self) -> Option { + (self.phase == Phase::Countdown).then(|| (COUNTDOWN - self.time).ceil().max(1.0) as u32) + } + + pub fn race_time(&self) -> f32 { + (self.time - COUNTDOWN).max(0.0) + } + + pub fn humans_finished(&self) -> bool { + self.entrants + .iter() + .all(|e| e.driver == Driver::Cpu || e.finished.is_some()) + } + + pub fn step(&mut self, human: &[Controls]) { + self.time += DT; + if self.phase == Phase::Countdown && self.time >= COUNTDOWN { + self.phase = Phase::Racing; + } + let racing = self.phase != Phase::Countdown; + let rt = self.race_time(); + let n = self.track.gates.len(); + + for e in self.entrants.iter_mut() { + let controls = match (e.driver, e.finished) { + (_, Some(_)) => e.ai.drive(&e.car, &self.track, e.next_gate, DT, racing), + (Driver::Human(p), None) if racing => human.get(p).copied().unwrap_or_default(), + (Driver::Human(_), None) => Controls::default(), + (Driver::Cpu, None) => e.ai.drive(&e.car, &self.track, e.next_gate, DT, racing), + }; + e.car.controls = controls; + // Finished cars cruise round on autopilot at reduced pace. + let pace = if e.finished.is_some() { 0.6 } else { 1.0 }; + let (px, py) = (e.car.x, e.car.y); + e.car.step(&self.track, DT, pace); + + // Gate crossing, allowing one gate to be skipped. + for skip in 0..2 { + let gi = (e.next_gate + skip) % n; + if crosses(&self.track.gates[gi], (px, py), (e.car.x, e.car.y)) { + let before = e.passed; + e.passed += skip + 1; + e.next_gate = (gi + 1) % n; + if before == 0 { + // First time over the line: the clock starts. + e.lap_start = rt; + } else if e.finished.is_none() && (e.passed - 1) / n > (before - 1) / n { + let lap = rt - e.lap_start; + e.lap_start = rt; + e.last_lap = Some(lap); + e.best_lap = Some(e.best_lap.map_or(lap, |b: f32| b.min(lap))); + } + break; + } + } + } + + for i in 0..self.entrants.len() { + for j in i + 1..self.entrants.len() { + let (a, b) = self.entrants.split_at_mut(j); + car::collide(&mut a[i].car, &mut b[0].car); + } + } + + for (i, e) in self.entrants.iter_mut().enumerate() { + if e.finished.is_none() && e.passed > self.laps * n { + e.finished = Some(rt); + self.finish_order.push(i); + } + } + + self.effects(); + + if self.phase == Phase::Racing && self.humans_finished() { + self.done_timer += DT; + if self.done_timer > 3.0 { + self.phase = Phase::Done; + } + } + } + + fn effects(&mut self) { + let mut spawn = Vec::new(); + for e in &self.entrants { + let c = &e.car; + let speed = c.speed(); + if c.slip > 55.0 && speed > 40.0 && c.on_road { + let strength = ((c.slip - 55.0) / 120.0).clamp(0.08, 0.35); + for (wx, wy) in c.rear_wheels() { + burn(&mut self.ground, wx, wy, strength); + } + } + if !c.on_road && speed > 50.0 { + spawn.push((c.rear_wheels()[0], c.track_colour(&self.track))); + } + } + for ((x, y), colour) in spawn { + let a = rand01(&mut self.rng) * std::f32::consts::TAU; + self.particles.push(Particle { + x, + y, + vx: a.cos() * 12.0, + vy: a.sin() * 6.0 - 10.0, + life: 0.5, + colour, + }); + } + for p in self.particles.iter_mut() { + p.x += p.vx * DT; + p.y += p.vy * DT; + p.life -= DT; + } + self.particles.retain(|p| p.life > 0.0); + } + + /// Indices of entrants from first to last. + pub fn standings(&self) -> Vec { + let n = self.track.gates.len(); + let mut order = self.finish_order.clone(); + let mut rest: Vec = (0..self.entrants.len()) + .filter(|i| !order.contains(i)) + .collect(); + rest.sort_by(|&a, &b| { + let (ea, eb) = (&self.entrants[a], &self.entrants[b]); + eb.passed.cmp(&ea.passed).then_with(|| { + let d = |e: &Entrant| { + let m = self.track.gates[e.next_gate % n].mid(); + (m.0 - e.car.x).hypot(m.1 - e.car.y) + }; + d(ea).total_cmp(&d(eb)) + }) + }); + order.extend(rest); + order + } +} + +impl Car { + fn track_colour(&self, track: &Track) -> u32 { + let (sx, sy) = self.screen_pos(); + track.palette[track.pixel(sx, sy) as usize] + .scale(1.15) + .to_u32() + } +} + +fn burn(ground: &mut [u32], sx: f32, sy: f32, strength: f32) { + use crate::data::track::{H, W}; + let (x, y) = (sx as isize, sy as isize); + for dx in 0..2 { + let (px, py) = (x + dx, y); + if px < 0 || py < 0 || px >= W as isize || py >= H as isize { + continue; + } + let p = &mut ground[py as usize * W + px as usize]; + let k = 1.0 - strength; + let ch = |v: u32, s: u32| ((((v >> s) & 0xff) as f32 * k) as u32) << s; + *p = ch(*p, 16) | ch(*p, 8) | ch(*p, 0); + } +} + +/// The point `dist` world units back along the gate path from gate 0, and +/// the direction of travel there. +fn behind(path: &[(f32, f32)], mut dist: f32) -> ((f32, f32), f32) { + let n = path.len(); + let mut i = 0; + for _ in 0..n { + let (a, b) = (path[(i + n - 1) % n], path[i]); + let len = (b.0 - a.0).hypot(b.1 - a.1).max(1e-3); + let heading = (b.1 - a.1).atan2(b.0 - a.0); + if dist <= len { + let t = dist / len; + return ((b.0 + (a.0 - b.0) * t, b.1 + (a.1 - b.1) * t), heading); + } + dist -= len; + i = (i + n - 1) % n; + } + let (a, b) = (path[n - 1], path[0]); + (b, (b.1 - a.1).atan2(b.0 - a.0)) +} + +fn crosses(g: &Gate, p: (f32, f32), q: (f32, f32)) -> bool { + let (dx, dy) = (g.b.0 - g.a.0, g.b.1 - g.a.1); + let a = (g.a.0 - dx * GATE_SLACK, g.a.1 - dy * GATE_SLACK); + let b = (g.b.0 + dx * GATE_SLACK, g.b.1 + dy * GATE_SLACK); + let cross = |o: (f32, f32), u: (f32, f32), v: (f32, f32)| { + (u.0 - o.0) * (v.1 - o.1) - (u.1 - o.1) * (v.0 - o.0) + }; + let d1 = cross(a, b, p); + let d2 = cross(a, b, q); + let d3 = cross(p, q, a); + let d4 = cross(p, q, b); + (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0) +} + +fn rand01(state: &mut u32) -> f32 { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + (*state >> 8) as f32 / (1u32 << 24) as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_crossing() { + let g = Gate { + a: (0.0, 0.0), + b: (0.0, 10.0), + }; + assert!(crosses(&g, (-1.0, 5.0), (1.0, 5.0))); + assert!(!crosses(&g, (1.0, 5.0), (2.0, 5.0))); + // Within the slack beyond the gate end. + assert!(crosses(&g, (-1.0, 12.0), (1.0, 12.0))); + } + + #[test] + fn grid_follows_path_backwards() { + let path = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let ((x, y), h) = behind(&path, 30.0); + assert!((x - 0.0).abs() < 1e-3 && (y - 30.0).abs() < 1e-3); + assert!((h + std::f32::consts::FRAC_PI_2).abs() < 1e-3); + let ((x, y), _) = behind(&path, 130.0); + assert!((x - 30.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e1069c4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,402 @@ +//! Skids: a cross-platform revival of Super Skidmarks (Acid Software, 1995). +//! +//! The engine reads the original Amiga data (ADF images, the LHA archives on +//! them, or extracted files) at run time; no game assets are bundled. + +mod app; +mod audio; +mod data; +mod formats; +mod game; +mod render; + +use app::{App, Input}; +use data::GameData; +use minifb::{Key, KeyRepeat, Scale, ScaleMode, Window, WindowOptions}; +use render::{SCREEN_H, SCREEN_W}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +const USAGE: &str = "\ +skids - Super Skidmarks revival + +USAGE: + skids [--data DIR] [--scale 1|2|4] [--mute] play + skids info [--data DIR] list what was found in the data + skids extract [--data DIR] --out DIR write PNGs of every track and car + skids render [--data DIR] --out FILE.png [--track N] [--seconds S] [--players 1|2] [--menu] + simulate a race (or the menu) and save a frame + skids bench [--data DIR] [--seconds S] race computer cars on every track + skids trace [--data DIR] --out FILE.png [--track N] [--seconds S] + draw the computer cars' paths on a track + +The data directory should hold the Super Skidmarks ADF images (or the LHA +archives from them, or extracted track/ and vehicles/ folders). It defaults to +the current directory, then the directory the executable is in. + +CONTROLS: + Player 1: arrow keys (or W A S D in one-player games) + Player 2: W A S D + P pauses, Esc leaves the race."; + +struct Args { + cmd: String, + data: Option, + out: Option, + scale: u32, + mute: bool, + track: usize, + seconds: f32, + players: usize, + menu: bool, +} + +fn parse_args() -> Result { + let mut a = Args { + cmd: "play".into(), + data: None, + out: None, + scale: 2, + mute: false, + track: 1, + seconds: 12.0, + players: 1, + menu: false, + }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + let mut val = |name: &str| it.next().ok_or(format!("{name} needs a value")); + match arg.as_str() { + "-h" | "--help" | "help" => return Err(String::new()), + "--data" | "-d" => a.data = Some(val("--data")?.into()), + "--out" | "-o" => a.out = Some(val("--out")?.into()), + "--scale" => a.scale = val("--scale")?.parse().map_err(|_| "bad --scale")?, + "--track" => a.track = val("--track")?.parse().map_err(|_| "bad --track")?, + "--seconds" => a.seconds = val("--seconds")?.parse().map_err(|_| "bad --seconds")?, + "--players" => a.players = val("--players")?.parse().map_err(|_| "bad --players")?, + "--mute" => a.mute = true, + "--menu" => a.menu = true, + "info" | "extract" | "render" | "bench" | "trace" | "play" => a.cmd = arg, + other => return Err(format!("unknown argument {other}")), + } + } + Ok(a) +} + +fn find_data(explicit: Option) -> PathBuf { + if let Some(p) = explicit { + return p; + } + let looks_right = |d: &Path| { + std::fs::read_dir(d).is_ok_and(|rd| { + rd.flatten().any(|e| { + let n = e.file_name().to_string_lossy().to_lowercase(); + n.ends_with(".adf") || n.ends_with(".lha") || n == "track" || n == "data" + }) + }) + }; + let mut candidates = vec![PathBuf::from(".")]; + if let Some(dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + { + candidates.push(dir); + } + candidates.push(PathBuf::from("data")); + candidates + .into_iter() + .find(|d| looks_right(d)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + if !e.is_empty() { + eprintln!("error: {e}\n"); + } + eprintln!("{USAGE}"); + return if e.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::from(2) + }; + } + }; + let root = find_data(args.data.clone()); + let data = match GameData::load(&root) { + Ok(d) => d, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + for w in &data.warnings { + eprintln!("warning: {w}"); + } + let result = match args.cmd.as_str() { + "info" => { + info(&data); + Ok(()) + } + "extract" => extract(&data, args.out.as_deref()), + "render" => render_shot(data, &args), + "bench" => bench(data, &args), + "trace" => trace(data, &args), + _ => play(data, &args), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +fn info(data: &GameData) { + println!("Tracks:"); + for (f, s) in data.track_list() { + let tf = &data.track_files[f]; + match tf.decode(s) { + Ok(t) => println!(" {:<10} {:>2} gates", t.name, t.gates.len()), + Err(e) => println!(" {:<10} ERROR {e}", tf.track_name(s)), + } + } + println!("Vehicles:"); + for v in &data.vehicles { + println!(" {:<10} ({})", v.name, v.id); + } +} + +fn extract(data: &GameData, out: Option<&Path>) -> Result<(), String> { + use data::{track, vehicle}; + let out = out.ok_or("extract needs --out DIR")?; + std::fs::create_dir_all(out).map_err(|e| e.to_string())?; + for (f, s) in data.track_list() { + let t = data.load_track(f, s)?; + let mut img = t.rgb(); + // Overlay the lap gates so the track data can be checked by eye. + for g in &t.gates { + let steps = 64; + for i in 0..=steps { + let k = i as f32 / steps as f32; + let (x, y) = + track::to_screen(g.a.0 + (g.b.0 - g.a.0) * k, g.a.1 + (g.b.1 - g.a.1) * k); + if (0.0..track::W as f32).contains(&x) && (0.0..track::H as f32).contains(&y) { + img[y as usize * track::W + x as usize] = 0xff2020; + } + } + } + let path = out.join(format!("{}.png", t.name.to_lowercase())); + render::save_png(&path, track::W, track::H, &img).map_err(|e| e.to_string())?; + println!("wrote {}", path.display()); + } + for (vi, v) in data.vehicles.iter().enumerate() { + // Sheet: 32 headings across, 25 tilt blocks down. + let (w, h) = ( + vehicle::SPR_W * vehicle::HEADINGS, + vehicle::SPR_H * vehicle::TILTS, + ); + let pal = data::palette::car_palette(vi); + let mut img = vec![0x203018u32; w * h]; + for tilt in 0..vehicle::TILTS { + for hd in 0..vehicle::HEADINGS { + let fr = v.frame(tilt, hd); + for y in 0..vehicle::SPR_H { + for x in 0..vehicle::SPR_W { + let i = fr[y * vehicle::SPR_W + x]; + if i != 0 { + img[(tilt * vehicle::SPR_H + y) * w + hd * vehicle::SPR_W + x] = + pal[i as usize]; + } + } + } + } + } + let path = out.join(format!("car-{}.png", v.id)); + render::save_png(&path, w, h, &img).map_err(|e| e.to_string())?; + println!("wrote {}", path.display()); + } + Ok(()) +} + +/// Runs a race with computer drivers in the human seats and saves the screen. +fn render_shot(data: GameData, args: &Args) -> Result<(), String> { + let out = args.out.clone().ok_or("render needs --out FILE.png")?; + let mut app = App::new(data, audio::Audio::new(false)); + if args.menu { + for _ in 0..(args.seconds / game::DT) as usize { + app.update(&Input::default()); + } + app.draw(); + return render::save_png(&out, SCREEN_W, SCREEN_H, &app.frame.buf) + .map_err(|e| e.to_string()); + } + let track = args.track.saturating_sub(1); + let players = args.players.clamp(1, 2); + let mut race = app.build_race(track, players, app.settings.cars, 3, 3, 7)?; + // Let the CPU drive the human cars too. + let mut cams = [(0.0, 0.0); 2]; + for (i, cam) in cams.iter_mut().enumerate() { + if let Some(e) = race + .entrants + .iter() + .find(|e| e.driver == game::Driver::Human(i)) + { + *cam = e.car.screen_pos(); + } + } + let frames = (args.seconds / game::DT) as usize; + let mut frame = render::Frame::new(); + for _ in 0..frames { + let controls: Vec<_> = race + .entrants + .iter_mut() + .filter(|e| matches!(e.driver, game::Driver::Human(_))) + .map(|e| e.ai.drive(&e.car, &race.track, e.next_gate, game::DT, true)) + .collect(); + race.step(&controls); + app::draw_race(&mut frame, &app.data, &race, &mut cams, players); + } + render::save_png(&out, SCREEN_W, SCREEN_H, &frame.buf).map_err(|e| e.to_string())?; + let n = race.track.gates.len(); + for &i in &race.standings() { + let e = &race.entrants[i]; + println!( + "{:<10} laps {} gates {} best {:?}", + e.name, + e.laps_done(n), + e.passed, + e.best_lap + ); + } + println!("wrote {}", out.display()); + Ok(()) +} + +/// Races four computer cars on every track and reports how they got on: +/// a quick health check for the track decoding and the AI. +fn bench(data: GameData, args: &Args) -> Result<(), String> { + let app = App::new(data, audio::Audio::new(false)); + let frames = (args.seconds / game::DT) as usize; + println!( + "{:<10} {:>5} {:>8} {:>9} {:>8}", + "TRACK", "GATES", "LAPS", "BEST LAP", "ON ROAD" + ); + for ti in 0..app.data.track_list().len() { + let mut race = app.build_race(ti, 0, [0, 0], 4, 99, ti as u32 + 1)?; + let (mut road, mut samples) = (0usize, 0usize); + for _ in 0..frames { + race.step(&[]); + for e in &race.entrants { + road += e.car.on_road as usize; + samples += 1; + } + } + let n = race.track.gates.len(); + let laps: Vec = race + .entrants + .iter() + .map(|e| e.laps_done(n).to_string()) + .collect(); + let best = race + .entrants + .iter() + .filter_map(|e| e.best_lap) + .fold(f32::MAX, f32::min); + let best = if best == f32::MAX { + "-".to_string() + } else { + app::fmt_time(best) + }; + println!( + "{:<10} {:>5} {:>8} {:>9} {:>7.0}%", + race.track.name, + n, + laps.join(","), + best, + 100.0 * road as f32 / samples as f32 + ); + } + Ok(()) +} + +fn trace(data: GameData, args: &Args) -> Result<(), String> { + use data::track; + let out = args.out.clone().ok_or("trace needs --out FILE.png")?; + let app = App::new(data, audio::Audio::new(false)); + let mut race = app.build_race(args.track.saturating_sub(1), 0, [0, 0], 4, 99, 1)?; + let mut img = race.track.rgb(); + let plot = |img: &mut Vec, wx: f32, wy: f32, c: u32| { + let (x, y) = track::to_screen(wx, wy); + if track::on_screen(x, y, 0.0) { + img[y as usize * track::W + x as usize] = c; + } + }; + for (gi, g) in race.track.gates.iter().enumerate() { + let c = if gi == 0 { 0xffff00 } else { 0xff2020 }; + for i in 0..=64 { + let k = i as f32 / 64.0; + plot( + &mut img, + g.a.0 + (g.b.0 - g.a.0) * k, + g.a.1 + (g.b.1 - g.a.1) * k, + c, + ); + } + } + const TRAIL: [u32; 4] = [0x00ffff, 0xff00ff, 0x40ff40, 0xffffff]; + for _ in 0..(args.seconds / game::DT) as usize { + race.step(&[]); + for (i, e) in race.entrants.iter().enumerate() { + plot(&mut img, e.car.x, e.car.y, TRAIL[i % 4]); + } + } + let n = race.track.gates.len(); + for e in &race.entrants { + println!( + "{:<8} laps {} next gate {}", + e.name, + e.laps_done(n), + e.next_gate + ); + } + render::save_png(&out, track::W, track::H, &img).map_err(|e| e.to_string()) +} + +fn play(data: GameData, args: &Args) -> Result<(), String> { + let scale = match args.scale { + 1 => Scale::X1, + 4 => Scale::X4, + _ => Scale::X2, + }; + let opts = WindowOptions { + scale, + resize: true, + scale_mode: ScaleMode::AspectRatioStretch, + ..WindowOptions::default() + }; + let mut window = Window::new("Skids - Super Skidmarks revival", SCREEN_W, SCREEN_H, opts) + .map_err(|e| e.to_string())?; + // PAL Amigas ran at 50 frames per second; the simulation is tied to it. + window.set_target_fps(50); + let mut app = App::new(data, audio::Audio::new(!args.mute)); + while window.is_open() && !app.quit { + let input = Input { + held: window.get_keys(), + pressed: window + .get_keys_pressed(KeyRepeat::Yes) + .into_iter() + .filter(|&k| k != Key::Unknown) + .collect(), + }; + app.update(&input); + app.draw(); + window + .update_with_buffer(&app.frame.buf, SCREEN_W, SCREEN_H) + .map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/src/render/font.rs b/src/render/font.rs new file mode 100644 index 0000000..a104b37 --- /dev/null +++ b/src/render/font.rs @@ -0,0 +1,67 @@ +//! A small 5x7 bitmap font (original artwork for this project). + +pub const GLYPH_W: usize = 5; +pub const ADVANCE: usize = 6; + +pub fn glyph(c: char) -> [u8; 7] { + match c.to_ascii_uppercase() { + '0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E], + '1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E], + '2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F], + '3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E], + '4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02], + '5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E], + '6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E], + '7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + '8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E], + '9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C], + 'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + 'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E], + 'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E], + 'D' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C], + 'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F], + 'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10], + 'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F], + 'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + 'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], + 'J' => [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C], + 'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], + 'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F], + 'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11], + 'N' => [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], + 'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + 'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10], + 'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D], + 'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11], + 'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E], + 'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + 'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + 'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04], + 'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A], + 'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11], + 'Y' => [0x11, 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04], + 'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F], + '.' => [0, 0, 0, 0, 0, 0x0C, 0x0C], + ',' => [0, 0, 0, 0, 0x0C, 0x04, 0x08], + ':' => [0, 0x0C, 0x0C, 0, 0x0C, 0x0C, 0], + '-' => [0, 0, 0, 0x1F, 0, 0, 0], + '+' => [0, 0x04, 0x04, 0x1F, 0x04, 0x04, 0], + '/' => [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10], + '!' => [0x04, 0x04, 0x04, 0x04, 0x04, 0, 0x04], + '?' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0, 0x04], + '<' => [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02], + '>' => [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08], + '(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], + ')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], + '\'' => [0x04, 0x04, 0x08, 0, 0, 0, 0], + '"' => [0x0A, 0x0A, 0, 0, 0, 0, 0], + '#' => [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A], + '=' => [0, 0, 0x1F, 0, 0x1F, 0, 0], + '_' => [0, 0, 0, 0, 0, 0, 0x1F], + _ => [0; 7], + } +} + +pub fn text_width(s: &str, scale: usize) -> usize { + s.chars().count() * ADVANCE * scale +} diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..eaaf334 --- /dev/null +++ b/src/render/mod.rs @@ -0,0 +1,228 @@ +//! Software rendering into a 0RGB framebuffer at the Amiga's PAL resolution. + +pub mod font; + +use crate::data::track; +use crate::data::vehicle::{SPR_H, SPR_W}; + +pub const SCREEN_W: usize = 320; +pub const SCREEN_H: usize = 256; + +#[derive(Clone, Copy, Debug)] +pub struct Rect { + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +impl Rect { + pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Rect { + Rect { x, y, w, h } + } +} + +pub struct Frame { + pub buf: Vec, +} + +impl Frame { + pub fn new() -> Frame { + Frame { + buf: vec![0; SCREEN_W * SCREEN_H], + } + } + + pub fn clear(&mut self, c: u32) { + self.buf.fill(c); + } + + #[inline] + pub fn put(&mut self, x: i32, y: i32, c: u32) { + if x >= 0 && y >= 0 && (x as usize) < SCREEN_W && (y as usize) < SCREEN_H { + self.buf[y as usize * SCREEN_W + x as usize] = c; + } + } + + pub fn fill(&mut self, r: Rect, c: u32) { + let x0 = r.x.max(0) as usize; + let y0 = r.y.max(0) as usize; + let x1 = ((r.x + r.w).max(0) as usize).min(SCREEN_W); + let y1 = ((r.y + r.h).max(0) as usize).min(SCREEN_H); + for y in y0..y1 { + self.buf[y * SCREEN_W + x0..y * SCREEN_W + x1.max(x0)].fill(c); + } + } + + /// Darkens a region, for menus drawn over a running race. + pub fn shade(&mut self, r: Rect, keep: u32) { + let x0 = r.x.max(0) as usize; + let y0 = r.y.max(0) as usize; + let x1 = ((r.x + r.w).max(0) as usize).min(SCREEN_W); + let y1 = ((r.y + r.h).max(0) as usize).min(SCREEN_H); + for y in y0..y1 { + for p in &mut self.buf[y * SCREEN_W + x0..y * SCREEN_W + x1.max(x0)] { + let ch = |s: u32| ((((*p >> s) & 0xff) * keep) >> 8) << s; + *p = ch(16) | ch(8) | ch(0); + } + } + } + + pub fn frame_rect(&mut self, r: Rect, c: u32) { + self.fill(Rect::new(r.x, r.y, r.w, 1), c); + self.fill(Rect::new(r.x, r.y + r.h - 1, r.w, 1), c); + self.fill(Rect::new(r.x, r.y, 1, r.h), c); + self.fill(Rect::new(r.x + r.w - 1, r.y, 1, r.h), c); + } + + /// Copies part of a track-sized image into `view`, with (cx, cy) the + /// track pixel at the view's top-left corner. + pub fn blit_track(&mut self, ground: &[u32], view: Rect, cx: i32, cy: i32) { + for row in 0..view.h { + let sy = view.y + row; + let ty = cy + row; + if sy < 0 || sy as usize >= SCREEN_H { + continue; + } + for col in 0..view.w { + let sx = view.x + col; + let tx = cx + col; + if sx < 0 || sx as usize >= SCREEN_W { + continue; + } + let c = + if tx >= 0 && ty >= 0 && (tx as usize) < track::W && (ty as usize) < track::H { + ground[ty as usize * track::W + tx as usize] + } else { + 0x101010 + }; + self.buf[sy as usize * SCREEN_W + sx as usize] = c; + } + } + } + + /// Draws a 64x32 indexed sprite (index 0 transparent) clipped to `clip`. + pub fn sprite(&mut self, pix: &[u8], pal: &[u32; 4], x: i32, y: i32, clip: Rect) { + for row in 0..SPR_H as i32 { + let sy = y + row; + if sy < clip.y || sy >= clip.y + clip.h || sy < 0 || sy as usize >= SCREEN_H { + continue; + } + for col in 0..SPR_W as i32 { + let i = pix[(row * SPR_W as i32 + col) as usize]; + if i == 0 { + continue; + } + let sx = x + col; + if sx < clip.x || sx >= clip.x + clip.w || sx < 0 || sx as usize >= SCREEN_W { + continue; + } + self.buf[sy as usize * SCREEN_W + sx as usize] = pal[i as usize]; + } + } + } + + /// Soft drop shadow under a sprite: darkens what is already drawn. + pub fn sprite_shadow(&mut self, pix: &[u8], x: i32, y: i32, clip: Rect) { + for row in 0..SPR_H as i32 { + let sy = y + row; + if sy < clip.y || sy >= clip.y + clip.h || sy < 0 || sy as usize >= SCREEN_H { + continue; + } + for col in 0..SPR_W as i32 { + if pix[(row * SPR_W as i32 + col) as usize] == 0 { + continue; + } + let sx = x + col; + if sx < clip.x || sx >= clip.x + clip.w || sx < 0 || sx as usize >= SCREEN_W { + continue; + } + let p = &mut self.buf[sy as usize * SCREEN_W + sx as usize]; + *p = (*p >> 1) & 0x7f7f7f; + } + } + } + + pub fn text(&mut self, x: i32, y: i32, s: &str, c: u32, scale: usize) { + let mut cx = x; + for ch in s.chars() { + let g = font::glyph(ch); + for (row, bits) in g.iter().enumerate() { + for col in 0..font::GLYPH_W { + if bits & (0x10 >> col) != 0 { + for dy in 0..scale { + for dx in 0..scale { + self.put( + cx + (col * scale + dx) as i32, + y + (row * scale + dy) as i32, + c, + ); + } + } + } + } + } + cx += (font::ADVANCE * scale) as i32; + } + } + + /// Text with a one-pixel drop shadow so it reads over the track. + pub fn text_shadow(&mut self, x: i32, y: i32, s: &str, c: u32, scale: usize) { + self.text(x + scale as i32, y + scale as i32, s, 0x000000, scale); + self.text(x, y, s, c, scale); + } + + pub fn text_centered(&mut self, cx: i32, y: i32, s: &str, c: u32, scale: usize) { + let w = font::text_width(s, scale) as i32; + self.text_shadow(cx - w / 2, y, s, c, scale); + } + + pub fn image(&mut self, src: &[u32], w: usize, h: usize, x: i32, y: i32) { + for row in 0..h { + for col in 0..w { + self.put(x + col as i32, y + row as i32, src[row * w + col]); + } + } + } +} + +/// Box-filtered downscale of a track-sized image. +pub fn downscale(src: &[u32], w: usize, h: usize) -> Vec { + let mut out = vec![0; w * h]; + let (fx, fy) = (track::W / w, track::H / h); + for y in 0..h { + for x in 0..w { + let (mut r, mut g, mut b) = (0u32, 0u32, 0u32); + for yy in y * fy..(y + 1) * fy { + for xx in x * fx..(x + 1) * fx { + let p = src[yy * track::W + xx]; + r += (p >> 16) & 0xff; + g += (p >> 8) & 0xff; + b += p & 0xff; + } + } + let n = (fx * fy) as u32; + out[y * w + x] = ((r / n) << 16) | ((g / n) << 8) | (b / n); + } + } + out +} + +pub fn save_png(path: &std::path::Path, w: usize, h: usize, px: &[u32]) -> std::io::Result<()> { + let file = std::io::BufWriter::new(std::fs::File::create(path)?); + let mut enc = png::Encoder::new(file, w as u32, h as u32); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + let mut wr = enc.write_header().map_err(std::io::Error::other)?; + let bytes: Vec = px + .iter() + .flat_map(|&p| [(p >> 16) as u8, (p >> 8) as u8, p as u8]) + .collect(); + wr.write_image_data(&bytes).map_err(std::io::Error::other) +} + +impl Default for Frame { + fn default() -> Self { + Self::new() + } +}