Repository: rustzx/rustzx (language: rust)

Issue:
Add support for compressed assets (tapes, snapshots)
Add support for `.gz` compressed assets loading (use `flate2` with miniz_oxide backend)

Also, use `.gz` for test assets to minimize repository assets footprint.


Discussion / hints:

Fix patch:
diff --git a/.cargo/config.toml b/.cargo/config.toml
index aee7c78..e0d3538 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -3,3 +3,4 @@ rustzx-fmt = "fmt --all"
 rustzx-clippy = "clippy --workspace --all-features"
 rustzx-run = "run --release --bin rustzx --"
 rustzx-install = "install --path rustzx"
+rustzx-build-assets = "run -p rustzx-test --bin build-assets"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 709df8b..1c90350 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,6 +38,9 @@ jobs:
           toolchain: stable
           override: true
           components: rustfmt, clippy
+      - name: Install linux dev dependencies
+        if: ${{ matrix.os_name == 'linux' }}
+        run: sudo apt-get install -y libasound2-dev
       - name: Rustfmt
         uses: actions-rs/cargo@v1
         with:
@@ -58,11 +61,16 @@ jobs:
         with:
           command: build
           args: --release -p rustzx-core -p rustzx-utils --verbose
+      - name: Clippy(rustzx-core/rustzx-utils) - Minimal
+        uses: actions-rs/cargo@v1
+        with:
+          command: clippy
+          args: -p rustzx-core -p rustzx-utils --verbose
       - name: Test
         uses: actions-rs/cargo@v1
         with:
           command: test
-          args: --workspace --all-features --verbose
+          args: --workspace --exclude rustzx-test --exclude rustzx-z80 --all-features --verbose
       - name: Upload artifacts
         uses: actions/upload-artifact@v2
         with:
@@ -70,3 +78,18 @@ jobs:
           path: |
             target/release/rustzx${{ matrix.executable_suffix }}
             target/release/vtx${{ matrix.executable_suffix }}
+  integration-test:
+    name: "rustzx-test integration tests"
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions-rs/toolchain@v1
+        with:
+          profile: minimal
+          toolchain: stable
+          override: true
+      - name: Test
+        uses: actions-rs/cargo@v1
+        with:
+          command: test
+          args: -p rustzx-test
diff --git a/.gitignore b/.gitignore
index 39d0eb8..3cd6174 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@
 # RustZX-specific
 *.rustzx.last.sna
 *.rustzx.prev.sna
+/artifacts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e1c6f4..d3c8af1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+### RustZX v0.15
+<!-- START_CHANGELOG|v0.15.0 -->
+- **[Feature]** Added new `cpal` sound backend (#101)
+- **[Feature]** Added support for `.gz`-compressed assets (#109)
+- **[Feature]** Added support for non-standard Kempston joy buttons in rustzx-core (#113)
+- **[Fix]** Fixed bug with integer overflow panic in tape loader (#100)
+- **[Testing]** Added integration tests for rustzx-core (#92)
+- **[Refactoring]** Removed cyclic dependencies between `rustzx-core` and `rustzx-utils`(#99)
+<!-- END_CHANGELOG|v0.15.0 -->
+
 ### RustZX v0.14
 - **[Refactoring]** Moved z80 emulation to the separate crate (`rustzx-z80`)
 - **[Feature]** Added support for `*.scr` screenshot format loading
diff --git a/Cargo.lock b/Cargo.lock
index 881e4c0..72533cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -23,6 +23,28 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "alsa"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75c4da790adcb2ce5e758c064b4f3ec17a30349f9961d3e5e6c9688b052a9e18"
+dependencies = [
+ "alsa-sys",
+ "bitflags",
+ "libc",
+ "nix",
+]
+
+[[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 = "ansi_term"
 version = "0.11.0"
@@ -34,9 +56,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.40"
+version = "1.0.44"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b"
+checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1"
 
 [[package]]
 name = "atty"
@@ -57,17 +79,42 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
 
 [[package]]
 name = "aym"
-version = "0.1.1"
+version = "0.15.0"
 dependencies = [
  "libm",
  "num-traits",
 ]
 
+[[package]]
+name = "base64"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+
+[[package]]
+name = "bindgen"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2da379dbebc0b76ef63ca68d8fc6e71c0f13e59432e0987e508c1820e6ab5239"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "lazy_static",
+ "lazycell",
+ "peeking_take_while",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+]
+
 [[package]]
 name = "bitflags"
-version = "1.2.1"
+version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
 [[package]]
 name = "block-buffer"
@@ -78,17 +125,47 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "bumpalo"
+version = "3.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9df67f7bf9ef8498769f994239c45613ef0c5899415fb58e9add412d2c1a538"
+
 [[package]]
 name = "byteorder"
 version = "1.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
 
+[[package]]
+name = "bytes"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040"
+
 [[package]]
 name = "cc"
 version = "1.0.67"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd"
+dependencies = [
+ "jobserver",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cexpr"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27"
+dependencies = [
+ "nom",
+]
 
 [[package]]
 name = "cfg-if"
@@ -115,6 +192,17 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "clang-sys"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10612c0ec0e0a1ff0e97980647cb058a6e7aedb913d01d009c406b8b7d0b26ee"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
 [[package]]
 name = "clap"
 version = "2.33.3"
@@ -139,6 +227,66 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "combine"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a909e4d93292cd8e9c42e189f61681eff9d67b6541f96b8a1a737f23737bd001"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
+
+[[package]]
+name = "coreaudio-rs"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11894b20ebfe1ff903cbdc52259693389eea03b94918a2def2c30c3bf227ad88"
+dependencies = [
+ "bitflags",
+ "coreaudio-sys",
+]
+
+[[package]]
+name = "coreaudio-sys"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b7e3347be6a09b46aba228d6608386739fb70beff4f61e07422da87b0bb31fa"
+dependencies = [
+ "bindgen",
+]
+
+[[package]]
+name = "cpal"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98f45f0a21f617cd2c788889ef710b63f075c949259593ea09c826f1e47a2418"
+dependencies = [
+ "alsa",
+ "core-foundation-sys",
+ "coreaudio-rs",
+ "jni",
+ "js-sys",
+ "lazy_static",
+ "libc",
+ "mach",
+ "ndk 0.3.0",
+ "ndk-glue 0.3.0",
+ "nix",
+ "oboe",
+ "parking_lot",
+ "stdweb",
+ "thiserror",
+ "web-sys",
+ "winapi",
+]
+
 [[package]]
 name = "cpufeatures"
 version = "0.1.3"
@@ -213,6 +361,17 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "derivative"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "digest"
 version = "0.9.0"
@@ -262,9 +421,9 @@ dependencies = [
 
 [[package]]
 name = "env_logger"
-version = "0.8.3"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f"
+checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
 dependencies = [
  "atty",
  "humantime",
@@ -344,6 +503,23 @@ dependencies = [
  "version_check",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
+dependencies = [
+ "cfg-if 1.0.0",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
+
 [[package]]
 name = "heck"
 version = "0.3.2"
@@ -374,6 +550,53 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
 
+[[package]]
+name = "instant"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "716d3d89f35ac6a34fd0eed635395f4c3b76fa889338a4632e5231a8684216bd"
+dependencies = [
+ "cfg-if 1.0.0",
+]
+
+[[package]]
+name = "jni"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
+dependencies = [
+ "cesu8",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "jobserver"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc9ffccd38c451a86bf13657df244e9c3f37493cce8e5e21e940963777acc84"
+dependencies = [
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -383,11 +606,27 @@ dependencies = [
  "spin",
 ]
 
+[[package]]
+name = "lazycell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
+
 [[package]]
 name = "libc"
-version = "0.2.93"
+version = "0.2.103"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41"
+checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6"
+
+[[package]]
+name = "libloading"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f84d96438c15fcd6c3f244c8fce01d1e2b9c6b5623e9c711dc9286d8fc92d6a"
+dependencies = [
+ "cfg-if 1.0.0",
+ "winapi",
+]
 
 [[package]]
 name = "libm"
@@ -395,6 +634,15 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a"
 
+[[package]]
+name = "lock_api"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109"
+dependencies = [
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.14"
@@ -404,6 +652,15 @@ dependencies = [
  "cfg-if 1.0.0",
 ]
 
+[[package]]
+name = "mach"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "memchr"
 version = "2.3.4"
@@ -429,6 +686,109 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "nanoid"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
+dependencies = [
+ "rand",
+]
+
+[[package]]
+name = "ndk"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8794322172319b972f528bf90c6b467be0079f1fa82780ffb431088e741a73ab"
+dependencies = [
+ "jni-sys",
+ "ndk-sys",
+ "num_enum",
+ "thiserror",
+]
+
+[[package]]
+name = "ndk"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d64d6af06fde0e527b1ba5c7b79a6cc89cfc46325b0b2887dffe8f70197e0c3c"
+dependencies = [
+ "bitflags",
+ "jni-sys",
+ "ndk-sys",
+ "num_enum",
+ "thiserror",
+]
+
+[[package]]
+name = "ndk-glue"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5caf0c24d51ac1c905c27d4eda4fa0635bbe0de596b8f79235e0b17a4d29385"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "log",
+ "ndk 0.3.0",
+ "ndk-macro",
+ "ndk-sys",
+]
+
+[[package]]
+name = "ndk-glue"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e9e94628f24e7a3cb5b96a2dc5683acd9230bf11991c2a1677b87695138420"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "log",
+ "ndk 0.4.0",
+ "ndk-macro",
+ "ndk-sys",
+]
+
+[[package]]
+name = "ndk-macro"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05d1c6307dc424d0f65b9b06e94f88248e6305726b14729fd67a5e47b2dc481d"
+dependencies = [
+ "darling",
+ "proc-macro-crate 0.1.5",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c44922cb3dbb1c70b5e5f443d63b64363a898564d739ba5198e3a9138442868d"
+
+[[package]]
+name = "nix"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa9b4819da1bc61c0ea48b63b7bc8604064dd43013e7cc325df098d49cd7c18a"
+dependencies = [
+ "bitflags",
+ "cc",
+ "cfg-if 1.0.0",
+ "libc",
+]
+
+[[package]]
+name = "nom"
+version = "5.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
+dependencies = [
+ "memchr",
+ "version_check",
+]
+
 [[package]]
 name = "num-derive"
 version = "0.3.3"
@@ -459,11 +819,56 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "num_enum"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9bd055fb730c4f8f4f57d45d35cd6b3f0980535b056dc7ff119cee6a66ed6f"
+dependencies = [
+ "derivative",
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "486ea01961c4a818096de679a8b740b26d9033146ac5291b1c98557658f8cdd9"
+dependencies = [
+ "proc-macro-crate 1.1.0",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "oboe"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e15e22bc67e047fe342a32ecba55f555e3be6166b04dd157cd0f803dfa9f48e1"
+dependencies = [
+ "jni",
+ "ndk 0.4.0",
+ "ndk-glue 0.4.0",
+ "num-derive",
+ "num-traits",
+ "oboe-sys",
+]
+
+[[package]]
+name = "oboe-sys"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338142ae5ab0aaedc8275aa8f67f460e43ae0fca76a695a742d56da0a269eadc"
+dependencies = [
+ "cc",
+]
+
 [[package]]
 name = "once_cell"
-version = "1.7.2"
+version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3"
+checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
 
 [[package]]
 name = "opaque-debug"
@@ -471,12 +876,49 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
+[[package]]
+name = "parking_lot"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216"
+dependencies = [
+ "cfg-if 1.0.0",
+ "instant",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "winapi",
+]
+
 [[package]]
 name = "paste"
 version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58"
 
+[[package]]
+name = "peeking_take_while"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c9b1041b4387893b91ee6746cddfc28516aff326a3519fb2adf820932c5e6cb"
+
 [[package]]
 name = "png"
 version = "0.16.8"
@@ -489,6 +931,31 @@ dependencies = [
  "miniz_oxide 0.3.7",
 ]
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
+
+[[package]]
+name = "proc-macro-crate"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785"
+dependencies = [
+ "toml",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebace6889caf889b4d3f76becee12e90353f2b8c7d875534a71e5742f8f6f83"
+dependencies = [
+ "thiserror",
+ "toml",
+]
+
 [[package]]
 name = "proc-macro-error"
 version = "1.0.4"
@@ -531,11 +998,51 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "rand"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+ "rand_hc",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7"
+dependencies = [
+ "rand_core",
+]
+
 [[package]]
 name = "redox_syscall"
-version = "0.2.6"
+version = "0.2.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041"
+checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff"
 dependencies = [
  "bitflags",
 ]
@@ -563,22 +1070,30 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9b1a3d5f46d53f4a3478e2be4a5a5ce5108ea58b100dcd139830eae7f79a3a1"
 
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
 [[package]]
 name = "rustzx"
-version = "0.14.0"
+version = "0.15.0"
 dependencies = [
  "anyhow",
+ "cpal",
  "env_logger",
  "log",
  "rustzx-core",
  "rustzx-utils",
  "sdl2",
  "structopt",
+ "strum",
 ]
 
 [[package]]
 name = "rustzx-core"
-version = "0.14.0"
+version = "0.15.0"
 dependencies = [
  "aym",
  "bitflags",
@@ -586,25 +1101,56 @@ dependencies = [
  "enum_dispatch",
  "from_variants",
  "lazy_static",
+ "rustzx-z80",
+ "strum",
+]
+
+[[package]]
+name = "rustzx-test"
+version = "0.15.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "expect-test",
+ "nanoid",
  "png",
+ "rustzx-core",
  "rustzx-utils",
- "rustzx-z80",
+ "sha2",
+ "wav",
 ]
 
 [[package]]
 name = "rustzx-utils"
-version = "0.14.0"
+version = "0.15.0"
 dependencies = [
+ "flate2",
+ "log",
  "rustzx-core",
 ]
 
 [[package]]
 name = "rustzx-z80"
-version = "0.14.0"
+version = "0.15.0"
 dependencies = [
  "paste",
 ]
 
+[[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 = "scopeguard"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
+
 [[package]]
 name = "sdl2"
 version = "0.34.5"
@@ -632,6 +1178,12 @@ dependencies = [
  "version-compare",
 ]
 
+[[package]]
+name = "serde"
+version = "1.0.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913"
+
 [[package]]
 name = "sha2"
 version = "0.9.5"
@@ -645,12 +1197,30 @@ dependencies = [
  "opaque-debug",
 ]
 
+[[package]]
+name = "shlex"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fdf1b9db47230893d76faad238fd6097fd6d6a9245cd7a4d90dbd639536bbd2"
+
+[[package]]
+name = "smallvec"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309"
+
 [[package]]
 name = "spin"
 version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
 
+[[package]]
+name = "stdweb"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef5430c8e36b713e13b48a9f709cc21e046723fe44ce34587b73a830203b533e"
+
 [[package]]
 name = "strsim"
 version = "0.8.0"
@@ -687,6 +1257,27 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "strum"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7ac893c7d471c8a21f31cfe213ec4f6d9afeed25537c772e08ef3f005f8729e"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "syn"
 version = "1.0.70"
@@ -758,6 +1349,15 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "toml"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "typenum"
 version = "1.13.0"
@@ -813,7 +1413,7 @@ checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
 
 [[package]]
 name = "vtx"
-version = "0.1.2"
+version = "0.15.0"
 dependencies = [
  "aym",
  "byteorder",
@@ -827,7 +1427,7 @@ dependencies = [
 
 [[package]]
 name = "vtx-bin"
-version = "0.1.2"
+version = "0.15.0"
 dependencies = [
  "anyhow",
  "structopt",
@@ -835,12 +1435,77 @@ dependencies = [
  "wav",
 ]
 
+[[package]]
+name = "walkdir"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56"
+dependencies = [
+ "same-file",
+ "winapi",
+ "winapi-util",
+]
+
 [[package]]
 name = "wasi"
 version = "0.10.0+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
 
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce"
+dependencies = [
+ "cfg-if 1.0.0",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b"
+dependencies = [
+ "bumpalo",
+ "lazy_static",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0237232789cf037d5480773fe568aac745bfe2afbc11a863e97901780a6b47cc"
+
 [[package]]
 name = "wav"
 version = "1.0.0"
@@ -850,11 +1515,21 @@ dependencies = [
  "riff",
 ]
 
+[[package]]
+name = "web-sys"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38eb105f1c59d9eaa6b5cdc92b859d85b926e82cb2e0945cd0c9259faa6fe9fb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "winapi"
-version = "0.3.7"
+version = "0.3.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f10e386af2b13e47c89e7236a7a14a086791a2b88ebad6df9bf42040195cf770"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
 dependencies = [
  "winapi-i686-pc-windows-gnu",
  "winapi-x86_64-pc-windows-gnu",
diff --git a/Cargo.toml b/Cargo.toml
index 8b9c004..42589d1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,10 +1,11 @@
 [workspace]
 members = [
-    "rustzx-z80",
+    "aym",
     "rustzx-core",
+    "rustzx-test",
     "rustzx-utils",
+    "rustzx-z80",
     "rustzx",
     "vtx",
     "vtx/vtx-bin",
-    "aym"
 ]
diff --git a/README.md b/README.md
index 0aedff0..e921cf6 100644
--- a/README.md
+++ b/README.md
@@ -30,18 +30,20 @@ ZX Spectrum emulator written in Rust
 - Kempston mouse emulation
 - Extended 128K keys emulation (arrows, backspace, caps lock)
 - Quick save/load
+- Compressed assets support (only `.gz` for now)
 - Separate `no_std` core library which can be used to port emulator
   almost anywhere.
     - Global allocator is still needed, but all dynamic
        allocations were minimized
     - All resource-heavy features are configurable via cargo `features`
 
-## Compiling from the latest master
+## Install
 1. Sure that you have C compiller and CMake to
 build bundled `sdl2`
-2. Install it with cargo
+2. (Linux-specific) Install required development packages: [`libasound2-dev`]
+3. Install it with cargo
 ```bash
-cargo install --path ./rustzx
+cargo install rustzx
 ```
 
 ## How to use
diff --git a/aym/Cargo.toml b/aym/Cargo.toml
index 2dcdb10..4fd2b49 100644
--- a/aym/Cargo.toml
+++ b/aym/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "aym"
-version = "0.1.1"
+version = "0.15.0"
 description = "AY/YM sound chip emulation library"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
 license = "MIT"
diff --git a/monorepo.toml b/monorepo.toml
new file mode 100644
index 0000000..6062189
--- /dev/null
+++ b/monorepo.toml
@@ -0,0 +1,29 @@
+[workspace]
+root_crate = "rustzx"
+
+[github]
+repo = "pacmancoder/rustzx"
+
+[release]
+check_version_raised = true
+allow_non_path_dev_dependencies = false
+publish_interval_seconds = 60
+
+[release.github]
+create_tag = true
+create_release_page = true
+check_commit_pushed = true
+print_to_stdout = true
+tag_name_template = "v{{version}}"
+release_page_title_template = "RustZX v{{version}}"
+release_page_body_template = "{{changelog}}"
+release_page_upload_artifacts = true
+
+[artifacts]
+directory = "artifacts"
+
+[changelog]
+file = "CHANGELOG.md"
+start_marker_template = "<!-- START_CHANGELOG|v{{version}} -->"
+end_marker_template = "<!-- END_CHANGELOG|v{{version}} -->"
+print_to_stdout = true
diff --git a/rustzx-core/Cargo.toml b/rustzx-core/Cargo.toml
index a54dc15..870d3d8 100644
--- a/rustzx-core/Cargo.toml
+++ b/rustzx-core/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "rustzx-core"
-version = "0.14.0"
+version = "0.15.0"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
 description = "ZX Spectum emulator core library"
 repository = "https://github.com/pacmancoder/rustzx"
@@ -10,7 +10,7 @@ edition = "2018"
 
 [features]
 default = []
-full = ["ay", "precise-border", "embedded-roms", "autoload"]
+full = ["ay", "precise-border", "embedded-roms", "autoload", "strum"]
 precise-border = []
 embedded-roms = []
 sound = []
@@ -18,14 +18,11 @@ ay = ["aym", "sound"]
 autoload = []
 
 [dependencies]
-bitflags = "1"
-lazy_static = { version = "1", features = ["spin_no_std"] }
+bitflags = "1.3"
+lazy_static = { version = "1.1", features = ["spin_no_std"] }
 displaydoc = { version = "0.2", default-features = false }
 from_variants = "0.6"
 enum_dispatch = "0.3"
-aym = { version = "0.1.1", path = "../aym", optional = true }
-rustzx-z80 = { version = "0.14", path = "../rustzx-z80" }
-
-[dev-dependencies]
-png = "0.16"
-rustzx-utils = { version = "0.14", path =  "../rustzx-utils", features = ["std"] }
+aym = { version = "0.15", path = "../aym", optional = true }
+rustzx-z80 = { version = "0.15", path = "../rustzx-z80" }
+strum = { version = "0.22", default-features = false, features = ["derive"], optional = true }
diff --git a/rustzx-core/src/emulator/fastload/tap.rs b/rustzx-core/src/emulator/fastload/tap.rs
index 2768741..72a39aa 100644
--- a/rustzx-core/src/emulator/fastload/tap.rs
+++ b/rustzx-core/src/emulator/fastload/tap.rs
@@ -65,7 +65,7 @@ pub fn fast_load_tap<H: Host>(emulator: &mut Emulator<H>) -> Result<()> {
                 }
             }
             // move destination pointer and decrease count of remaining bytes
-            dest += 1;
+            dest = dest.wrapping_add(1);
             length -= 1;
         } else {
             // this happens if requested length and provided are not matched
diff --git a/rustzx-core/src/emulator/mod.rs b/rustzx-core/src/emulator/mod.rs
index 14d18bd..54efe48 100644
--- a/rustzx-core/src/emulator/mod.rs
+++ b/rustzx-core/src/emulator/mod.rs
@@ -166,6 +166,12 @@ impl<H: Host> Emulator<H> {
         self.controller.tape.stop();
     }
 
+    /// Rewinds tape. May return error if underlying tape asset failed to
+    /// perform seek operaion to go back to the the beginning of the tape
+    pub fn rewind_tape(&mut self) -> Result<()> {
+        self.controller.tape.rewind()
+    }
+
     pub fn screen_buffer(&self) -> &H::FrameBuffer {
         self.controller.screen.frame_buffer()
     }
@@ -175,6 +181,14 @@ impl<H: Host> Emulator<H> {
         self.controller.border.frame_buffer()
     }
 
+    pub fn set_io_extender(&mut self, extender: H::IoExtender) {
+        self.controller.io_extender = Some(extender);
+    }
+
+    pub fn io_extender(&mut self) -> Option<&mut H::IoExtender> {
+        self.controller.io_extender.as_mut()
+    }
+
     pub fn border_color(&self) -> ZXColor {
         self.controller.border_color
     }
@@ -205,7 +219,7 @@ impl<H: Host> Emulator<H> {
         self.controller.send_mouse_wheel(dir);
     }
 
-    pub fn send_mouse_pos(&mut self, x: i8, y: i8) {
+    pub fn send_mouse_pos_diff(&mut self, x: i8, y: i8) {
         self.controller.send_mouse_pos_diff(x, y);
     }
 
diff --git a/rustzx-core/src/host/io.rs b/rustzx-core/src/host/io.rs
index 9cc0f51..ce33612 100644
--- a/rustzx-core/src/host/io.rs
+++ b/rustzx-core/src/host/io.rs
@@ -16,6 +16,12 @@ pub struct BufferCursor<T: AsRef<[u8]>> {
     pos: usize,
 }
 
+impl<T: AsRef<[u8]>> BufferCursor<T> {
+    pub fn into_inner(self) -> T {
+        self.data
+    }
+}
+
 impl<T: AsRef<[u8]>> BufferCursor<T> {
     pub fn new(data: T) -> Self {
         Self { data, pos: 0 }
diff --git a/rustzx-core/src/host/mod.rs b/rustzx-core/src/host/mod.rs
index 12cadda..4768904 100644
--- a/rustzx-core/src/host/mod.rs
+++ b/rustzx-core/src/host/mod.rs
@@ -49,6 +49,34 @@ impl<T> ScreenAsset for T where T: LoadableAsset + SeekableAsset {}
 pub trait SnapshotAsset: LoadableAsset + SeekableAsset {}
 impl<T> SnapshotAsset for T where T: LoadableAsset + SeekableAsset {}
 
+/// Allows to extend base rustzx-core functionality by providing
+/// interface for user-defined IO ports handling
+pub trait IoExtender {
+    /// Write byte value to io extender
+    fn write(&mut self, port: u16, data: u8);
+    /// Read byte value from io extender
+    fn read(&mut self, port: u16) -> u8;
+    /// Return true if io externder can process
+    /// incomming read/write operation for a
+    /// given port
+    fn extends_port(&self, port: u16) -> bool;
+}
+
+/// Does not extend any IO
+pub struct StubIoExtender {}
+
+impl IoExtender for StubIoExtender {
+    fn write(&mut self, _: u16, _: u8) {}
+
+    fn read(&mut self, _: u16) -> u8 {
+        0
+    }
+
+    fn extends_port(&self, _: u16) -> bool {
+        false
+    }
+}
+
 /// Represents set of required types for emulator implementation
 /// based on `rustzx-core`.
 pub trait Host {
@@ -59,5 +87,8 @@ pub trait Host {
     type TapeAsset: LoadableAsset + SeekableAsset;
     /// Frame buffer implementation
     type FrameBuffer: FrameBuffer;
+    /// Type which should provide methods to measure time intervals
     type EmulationStopwatch: Stopwatch;
+    /// RustZX debug port implementation
+    type IoExtender: IoExtender;
 }
diff --git a/rustzx-core/src/lib.rs b/rustzx-core/src/lib.rs
index 20774ce..5a571ee 100644
--- a/rustzx-core/src/lib.rs
+++ b/rustzx-core/src/lib.rs
@@ -1,5 +1,4 @@
 #![no_std]
-#![allow(dead_code)]
 
 pub(crate) mod emulator;
 pub(crate) mod settings;
@@ -13,6 +12,9 @@ pub use emulator::Emulator;
 pub use settings::RustzxSettings;
 pub use utils::EmulationMode;
 
+#[cfg(feature = "strum")]
+pub use strum::IntoEnumIterator as IterableEnum;
+
 extern crate alloc;
 
 pub type Result<T> = core::result::Result<T, error::Error>;
diff --git a/rustzx-core/src/zx/constants.rs b/rustzx-core/src/zx/constants.rs
index 1c3c510..32abbd1 100644
--- a/rustzx-core/src/zx/constants.rs
+++ b/rustzx-core/src/zx/constants.rs
@@ -11,16 +11,13 @@ pub const SCREEN_HEIGHT: usize = CANVAS_HEIGHT + BORDER_ROWS * 8 * 2;
 /// Frames per second
 pub const FPS: usize = 50;
 
-/// addresses
-pub(crate) const BITMAP_BASE_ADDR: u16 = 0x4000;
-pub(crate) const ATTR_BASE_ADDR: u16 = 0x5800;
-pub(crate) const ATTR_MAX_ADDR: u16 = 0x5AFF;
 /// relative addresses
 pub(crate) const BITMAP_MAX_REL: u16 = 0x17FF;
 pub(crate) const ATTR_BASE_REL: u16 = 0x1800;
 pub(crate) const ATTR_MAX_REL: u16 = 0x1AFF;
 /// on all spectrums theese values are fixed
 pub(crate) const CLOCKS_PER_COL: usize = 4;
+#[cfg(feature = "precise-border")]
 pub(crate) const PIXELS_PER_CLOCK: usize = 2;
 /// size of screen in rows, cols
 pub(crate) const ATTR_COLS: usize = CANVAS_WIDTH / 8;
diff --git a/rustzx-core/src/zx/controller.rs b/rustzx-core/src/zx/controller.rs
index 56390df..2245083 100644
--- a/rustzx-core/src/zx/controller.rs
+++ b/rustzx-core/src/zx/controller.rs
@@ -1,7 +1,7 @@
 //! Contains ZX Spectrum System contrller (like ula or so) of emulator
 use crate::{
     error::Error,
-    host::{Host, HostContext},
+    host::{Host, HostContext, IoExtender},
     settings::RustzxSettings,
     utils::screen::bitmap_line_addr,
     zx::{
@@ -39,6 +39,7 @@ pub(crate) struct ZXController<H: Host> {
     pub border: ZXBorder<H::FrameBuffer>,
     pub kempston: Option<KempstonJoy>,
     pub mouse: Option<KempstonMouse>,
+    pub io_extender: Option<H::IoExtender>,
     #[cfg(feature = "sound")]
     pub mixer: ZXMixer,
     pub keyboard: [u8; 8],
@@ -63,6 +64,7 @@ pub(crate) struct ZXController<H: Host> {
 
 impl<H: Host> ZXController<H> {
     /// Returns new ZXController from settings
+    #[allow(clippy::let_and_return)]
     pub fn new(settings: &RustzxSettings, host_context: H::Context) -> Self {
         let (memory, paging, screen_bank);
         match settings.machine {
@@ -105,6 +107,7 @@ impl<H: Host> ZXController<H> {
             border,
             kempston,
             mouse,
+            io_extender: None,
             #[cfg(feature = "sound")]
             mixer,
             keyboard: [0xFF; 8],
@@ -147,6 +150,7 @@ impl<H: Host> ZXController<H> {
     }
 
     /// returns current frame emulation pos in percents
+    #[cfg(feature = "sound")]
     fn frame_pos(&self) -> f64 {
         let val = self.frame_clocks as f64 / self.machine.specs().clocks_frame as f64;
         if val > 1.0 {
@@ -327,11 +331,6 @@ impl<H: Host> ZXController<H> {
         self.passed_frames = 0;
     }
 
-    /// Returns current clocks from frame start
-    pub fn clocks(&self) -> usize {
-        self.frame_clocks
-    }
-
     pub fn write_7ffd(&mut self, val: u8) {
         if !self.paging_enabled {
             return;
@@ -384,7 +383,8 @@ impl<H: Host> ZXController<H> {
 
     pub(crate) fn set_border_color(
         &mut self,
-        #[allow(unused_variables)] clocks: usize,
+        #[cfg(feature = "precise-border")] clocks: usize,
+        #[cfg(not(feature = "precise-border"))] _clocks: usize,
         color: ZXColor,
     ) {
         self.border_color = color;
@@ -492,9 +492,17 @@ impl<H: Host> Z80Bus for ZXController<H> {
         // all contentions check
         self.io_contention_first(port);
         self.io_contention_last(port);
+
+        let io_extender_value = self
+            .io_extender
+            .as_mut()
+            .and_then(|e| e.extends_port(port).then(|| e.read(port)));
+
         // find out what we need to do
         let [_, h] = port.to_le_bytes();
-        let output = if port & 0x0001 == 0 {
+        let output = if let Some(value) = io_extender_value {
+            value
+        } else if port & 0x0001 == 0 {
             // ULA port
             let mut tmp: u8 = 0xFF;
             for n in 0..8 {
@@ -536,8 +544,15 @@ impl<H: Host> Z80Bus for ZXController<H> {
     fn write_io(&mut self, port: u16, data: u8) {
         // first contention
         self.io_contention_first(port);
+
         // find active port
-        if port & 0xC002 == 0xC000 {
+        if self
+            .io_extender
+            .as_ref()
+            .map_or(false, |e| e.extends_port(port))
+        {
+            self.io_extender.as_mut().unwrap().write(port, data);
+        } else if port & 0xC002 == 0xC000 {
             self.select_ay_reg(data);
         } else if port & 0xC002 == 0x8000 {
             self.write_ay_port(data);
diff --git a/rustzx-core/src/zx/joy/kempston.rs b/rustzx-core/src/zx/joy/kempston.rs
index 585bb50..5726849 100644
--- a/rustzx-core/src/zx/joy/kempston.rs
+++ b/rustzx-core/src/zx/joy/kempston.rs
@@ -1,10 +1,18 @@
 /// Kempston key type. Port bit encoded in enum values
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
+#[derive(Clone, Copy)]
 pub enum KempstonKey {
     Right = 0x01,
     Left = 0x02,
     Down = 0x04,
     Up = 0x08,
     Fire = 0x10,
+    /// Undocumented extra button 1
+    Ext1 = 0x20,
+    /// Undocumented extra button 2
+    Ext2 = 0x40,
+    /// Undocumented extra button 3
+    Ext3 = 0x80,
 }
 
 /// Kempston Joystick
diff --git a/rustzx-core/src/zx/joy/sinclair.rs b/rustzx-core/src/zx/joy/sinclair.rs
index 2b0851a..9a34029 100644
--- a/rustzx-core/src/zx/joy/sinclair.rs
+++ b/rustzx-core/src/zx/joy/sinclair.rs
@@ -1,5 +1,7 @@
 use crate::zx::keys::ZXKey;
 
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
+#[derive(Debug, Clone, Copy)]
 pub enum SinclairKey {
     Left,
     Right,
@@ -8,6 +10,8 @@ pub enum SinclairKey {
     Fire,
 }
 
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
+#[derive(Debug, Clone, Copy)]
 pub enum SinclairJoyNum {
     Fist,
     Second,
diff --git a/rustzx-core/src/zx/keys.rs b/rustzx-core/src/zx/keys.rs
index 9fbbeec..e421964 100644
--- a/rustzx-core/src/zx/keys.rs
+++ b/rustzx-core/src/zx/keys.rs
@@ -2,7 +2,8 @@
 
 /// Struct, which contains mast and port of key
 #[rustfmt::skip]
-#[derive(Clone, Copy)]
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
+#[derive(Debug, Clone, Copy)]
 pub enum ZXKey {
     // Port 0xFEFE
     Shift, Z, X, C, V,
@@ -22,7 +23,8 @@ pub enum ZXKey {
     Space, SymShift, M, N, B,
 }
 
-#[derive(Clone, Copy)]
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
+#[derive(Debug, Clone, Copy)]
 pub enum CompoundKey {
     ArrowLeft,
     ArrowRight,
diff --git a/rustzx-core/src/zx/memory.rs b/rustzx-core/src/zx/memory.rs
index a01cc09..a8604a1 100644
--- a/rustzx-core/src/zx/memory.rs
+++ b/rustzx-core/src/zx/memory.rs
@@ -6,7 +6,6 @@ pub const PAGE_SIZE: usize = 16 * 1024;
 pub const SIZE_16K: usize = PAGE_SIZE;
 pub const SIZE_32K: usize = PAGE_SIZE * 2;
 pub const SIZE_48K: usize = PAGE_SIZE * 3;
-pub const SIZE_64K: usize = PAGE_SIZE * 4;
 pub const SIZE_128K: usize = PAGE_SIZE * 8;
 // count of all memory blocks
 pub const MEM_BLOCKS: usize = 4;
@@ -14,11 +13,9 @@ pub const MEM_BLOCKS: usize = 4;
 /// Rom can be:
 /// - 16K (Sinclair48K)
 /// - 32K (Sinclair128K, 2+)
-/// - 64k (Amstrad 3+)
 pub enum RomType {
     K16,
     K32,
-    K64,
 }
 
 /// Ram can be:
@@ -63,7 +60,6 @@ impl ZXMemory {
         let rom_size = match rom_type {
             RomType::K16 => SIZE_16K,
             RomType::K32 => SIZE_32K,
-            RomType::K64 => SIZE_64K,
         };
         ZXMemory {
             rom: vec![0; rom_size],
@@ -82,15 +78,6 @@ impl ZXMemory {
         }
     }
 
-    /// Returns value of data with relative `addr` at `bank`
-    pub fn paged_read(&self, page: Page, addr: u16) -> u8 {
-        assert!((addr as usize) < PAGE_SIZE);
-        match page {
-            Page::Rom(page) => self.rom[(page as usize) * PAGE_SIZE + addr as usize],
-            Page::Ram(page) => self.ram[(page as usize) * PAGE_SIZE + addr as usize],
-        }
-    }
-
     /// Writes value to memory
     pub fn write(&mut self, addr: u16, value: u8) {
         let page = self.map[(addr as usize) / PAGE_SIZE];
@@ -155,13 +142,4 @@ impl ZXMemory {
         let shift = page as usize * PAGE_SIZE;
         &self.ram[shift..shift + PAGE_SIZE]
     }
-
-    /// Dumps current address space
-    pub fn dump(&self) -> Vec<u8> {
-        let mut out = Vec::new();
-        for n in 0..SIZE_64K {
-            out.push(self.read(n as u16));
-        }
-        out
-    }
 }
diff --git a/rustzx-core/src/zx/mouse/kempston.rs b/rustzx-core/src/zx/mouse/kempston.rs
index 44dad98..4f1eda1 100644
--- a/rustzx-core/src/zx/mouse/kempston.rs
+++ b/rustzx-core/src/zx/mouse/kempston.rs
@@ -19,6 +19,7 @@ impl Default for KempstonMouse {
     }
 }
 
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
 #[derive(Clone, Copy, Debug)]
 pub enum KempstonMouseButton {
     Left = 0x01,
@@ -27,6 +28,7 @@ pub enum KempstonMouseButton {
     Additional = 0x08,
 }
 
+#[cfg_attr(feature = "strum", derive(strum::EnumIter))]
 #[derive(Clone, Copy, Debug)]
 #[repr(i8)]
 pub enum KempstonMouseWheelDirection {
diff --git a/rustzx-core/src/zx/sound/ay.rs b/rustzx-core/src/zx/sound/ay.rs
index 0584b69..d0c77df 100644
--- a/rustzx-core/src/zx/sound/ay.rs
+++ b/rustzx-core/src/zx/sound/ay.rs
@@ -27,8 +27,11 @@ impl ZXAyChip {
             ZXAYMode::ACB => AyMode::ACB,
         };
 
+        let mut ay = AymPrecise::new(SoundChip::AY, mode, AY_FREQ, sample_rate);
+        ay.enable_dc_filter();
+
         Self {
-            ay: AymBackend::new(SoundChip::AY, mode, AY_FREQ, sample_rate),
+            ay,
             current_reg: 0,
             regs: [0; 16],
         }
@@ -53,11 +56,6 @@ impl ZXAyChip {
 impl SampleGenerator<f64> for ZXAyChip {
     fn gen_sample(&mut self) -> SoundSample<f64> {
         let sample = self.ay.next_sample();
-
-        // Place -1..1 sample in 0..1 range
-        let left = (sample.left + 1f64) / (2f64);
-        let right = (sample.right + 1f64) / (2f64);
-
-        SoundSample::new(left, right)
+        SoundSample::new(sample.left, sample.right)
     }
 }
diff --git a/rustzx-core/src/zx/sound/beeper.rs b/rustzx-core/src/zx/sound/beeper.rs
index c92e2f7..d4679fd 100644
--- a/rustzx-core/src/zx/sound/beeper.rs
+++ b/rustzx-core/src/zx/sound/beeper.rs
@@ -17,6 +17,13 @@ impl ZXBeeper {
 
 impl SampleGenerator<f64> for ZXBeeper {
     fn gen_sample(&mut self) -> SoundSample<f64> {
+        // - Beeper intentionally made produce only positive half-wave 0..0.5
+        // range instead of -0.25..0.25) because of current emulator lack of
+        // dc filtering.
+        // - Beeper only produces a quater of available sample
+        // range because relatively to AY chip, square wave of a beeper is
+        // too loud
+
         const EAR_SAMPLE_FACTOR: f64 = 0.5;
         const MIC_SAMPLE_FACTOR: f64 = EAR_SAMPLE_FACTOR / 5.0;
 
diff --git a/rustzx-core/src/zx/sound/mixer.rs b/rustzx-core/src/zx/sound/mixer.rs
index 41a706a..fce6a40 100644
--- a/rustzx-core/src/zx/sound/mixer.rs
+++ b/rustzx-core/src/zx/sound/mixer.rs
@@ -7,6 +7,8 @@ use crate::zx::{
     },
 };
 
+// TODO(#117): Implement DC filtering for sound mixing
+
 #[cfg(feature = "ay")]
 use crate::zx::sound::ay::{ZXAYMode, ZXAyChip};
 
@@ -23,9 +25,6 @@ pub(crate) struct ZXMixer {
     last_pos: usize,
     last_sample: SoundSample<f32>,
     master_volume: f64,
-    beeper_volume: f64,
-    #[cfg(feature = "ay")]
-    ay_volume: f64,
     #[cfg(feature = "ay")]
     use_ay: bool,
     use_beeper: bool,
@@ -51,9 +50,6 @@ impl ZXMixer {
             last_pos: 0,
             last_sample: SoundSample::new(0.0, 0.0),
             master_volume: 0.5,
-            beeper_volume: 1.0,
-            #[cfg(feature = "ay")]
-            ay_volume: 1.0,
             #[cfg(feature = "ay")]
             use_ay,
             use_beeper,
diff --git a/rustzx-core/src/zx/sound/sample.rs b/rustzx-core/src/zx/sound/sample.rs
index 71517f9..fb2a9d1 100644
--- a/rustzx-core/src/zx/sound/sample.rs
+++ b/rustzx-core/src/zx/sound/sample.rs
@@ -3,13 +3,11 @@ use core::{
     ops::{Add, Mul, MulAssign, Sub},
 };
 
-/// Raw Sample can be only f64 or i16
 pub trait RawSample: Clone + Copy + MulAssign + Mul + Add + Sub {}
 impl RawSample for f64 {}
 impl RawSample for f32 {}
 impl RawSample for i16 {}
 
-const ERROR_SIZE: u16 = 100;
 // Sound sample type
 // Have it's have two special cases: `SoundSample<f64>`
 // And `SoundSample<i16>`
diff --git a/rustzx-core/src/zx/tape/tap.rs b/rustzx-core/src/zx/tape/tap.rs
index 4dafdc6..65a3e7e 100644
--- a/rustzx-core/src/zx/tape/tap.rs
+++ b/rustzx-core/src/zx/tape/tap.rs
@@ -261,7 +261,6 @@ impl<A: LoadableAsset + SeekableAsset> TapeImpl for Tap<A> {
     }
 
     fn rewind(&mut self) -> Result<()> {
-        self.state = TapeState::Stop;
         self.curr_bit = false;
         self.curr_byte = 0x00;
         self.block_bytes_read = 0;
diff --git a/rustzx-core/src/zx/video/border.rs b/rustzx-core/src/zx/video/border.rs
index 9d7031e..192c893 100644
--- a/rustzx-core/src/zx/video/border.rs
+++ b/rustzx-core/src/zx/video/border.rs
@@ -29,11 +29,6 @@ impl BeamInfo {
         BeamInfo { line, pixel, color }
     }
 
-    /// checks if beam is on first pixel
-    fn is_first_pixel(&self) -> bool {
-        (self.line == 0) && (self.pixel == 0)
-    }
-
     /// resets position
     fn reset(&mut self) {
         self.line = 0;
diff --git a/rustzx-core/src/zx/video/screen.rs b/rustzx-core/src/zx/video/screen.rs
index 98e7a35..80c74a0 100644
--- a/rustzx-core/src/zx/video/screen.rs
+++ b/rustzx-core/src/zx/video/screen.rs
@@ -90,7 +90,6 @@ pub struct ZXScreen<FB: FrameBuffer> {
     back_buffer: FB,
     banks: [ScreenBank; 2],
     active_bank: usize,
-    next_bank: usize,
 }
 
 impl<FB: FrameBuffer> ZXScreen<FB> {
@@ -124,7 +123,6 @@ impl<FB: FrameBuffer> ZXScreen<FB> {
                 },
             ],
             active_bank: 0,
-            next_bank: 0,
         }
     }
 
diff --git a/rustzx-utils/Cargo.toml b/rustzx-utils/Cargo.toml
index 442fb72..0299a9d 100644
--- a/rustzx-utils/Cargo.toml
+++ b/rustzx-utils/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "rustzx-utils"
-version = "0.14.0"
+version = "0.15.0"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
 description = "Additional helpers for rustzx-core based emulators"
 repository = "https://github.com/pacmancoder/rustzx"
@@ -9,8 +9,10 @@ license = "MIT"
 edition = "2018"
 
 [dependencies]
-rustzx-core = { version = "0.14", path = "../rustzx-core" }
+rustzx-core = { version = "0.15", path = "../rustzx-core" }
+log = { version = "0.4", optional = true }
+flate2 = { version = "1.0", optional = true }
 
 [features]
 default = []
-std = []
+std = [ "log", "flate2" ]
diff --git a/rustzx/src/host/io.rs b/rustzx-utils/src/io/file.rs
similarity index 79%
rename from rustzx/src/host/io.rs
rename to rustzx-utils/src/io/file.rs
index 66b94c0..6c65869 100644
--- a/rustzx/src/host/io.rs
+++ b/rustzx-utils/src/io/file.rs
@@ -1,3 +1,5 @@
+use super::{into_std_seek_pos, DynamicAssetImpl, DynamicDataRecorderImpl};
+
 use rustzx_core::{
     error::IoError,
     host::{DataRecorder, LoadableAsset, SeekFrom, SeekableAsset},
@@ -48,10 +50,5 @@ impl DataRecorder for FileAsset {
     }
 }
 
-fn into_std_seek_pos(pos: SeekFrom) -> std::io::SeekFrom {
-    match pos {
-        SeekFrom::Start(offset) => std::io::SeekFrom::Start(offset as u64),
-        SeekFrom::End(offset) => std::io::SeekFrom::End(offset as i64),
-        SeekFrom::Current(offset) => std::io::SeekFrom::Current(offset as i64),
-    }
-}
+impl DynamicAssetImpl for FileAsset {}
+impl DynamicDataRecorderImpl for FileAsset {}
diff --git a/rustzx-utils/src/io/gzip.rs b/rustzx-utils/src/io/gzip.rs
new file mode 100644
index 0000000..7e17a36
--- /dev/null
+++ b/rustzx-utils/src/io/gzip.rs
@@ -0,0 +1,47 @@
+use super::DynamicAssetImpl;
+
+use rustzx_core::{
+    error::IoError,
+    host::{BufferCursor, LoadableAsset, SeekFrom, SeekableAsset},
+};
+
+use std::{
+    io::{self, Read},
+    vec,
+    vec::Vec,
+};
+
+use flate2::read::GzDecoder;
+
+pub struct GzipAsset {
+    buffer: BufferCursor<Vec<u8>>,
+}
+
+impl GzipAsset {
+    pub fn new(file: impl Read) -> Result<Self, io::Error> {
+        // ZX Spectrum assets are small enough to use RAM for unpacked data
+        let mut buffer = vec![];
+        let _ = GzDecoder::new(file).read_to_end(&mut buffer)?;
+        Ok(Self {
+            buffer: BufferCursor::new(buffer),
+        })
+    }
+
+    pub fn into_vec(self) -> Vec<u8> {
+        self.buffer.into_inner()
+    }
+}
+
+impl SeekableAsset for GzipAsset {
+    fn seek(&mut self, pos: SeekFrom) -> Result<usize, IoError> {
+        self.buffer.seek(pos)
+    }
+}
+
+impl LoadableAsset for GzipAsset {
+    fn read(&mut self, buf: &mut [u8]) -> Result<usize, IoError> {
+        self.buffer.read(buf)
+    }
+}
+
+impl DynamicAssetImpl for GzipAsset {}
diff --git a/rustzx-utils/src/io/mod.rs b/rustzx-utils/src/io/mod.rs
new file mode 100644
index 0000000..ab39876
--- /dev/null
+++ b/rustzx-utils/src/io/mod.rs
@@ -0,0 +1,60 @@
+mod file;
+mod gzip;
+
+use rustzx_core::{
+    error::IoError,
+    host::{BufferCursor, DataRecorder, LoadableAsset, SeekFrom, SeekableAsset},
+};
+
+use std::boxed::Box;
+
+pub use file::FileAsset;
+pub use gzip::GzipAsset;
+
+pub trait DynamicAssetImpl: LoadableAsset + SeekableAsset {}
+
+impl<T: AsRef<[u8]>> DynamicAssetImpl for BufferCursor<T> {}
+
+pub struct DynamicAsset {
+    inner: Box<dyn DynamicAssetImpl>,
+}
+
+impl<T: DynamicAssetImpl + 'static> From<T> for DynamicAsset {
+    fn from(inner: T) -> Self {
+        Self {
+            inner: Box::new(inner),
+        }
+    }
+}
+
+impl LoadableAsset for DynamicAsset {
+    fn read(&mut self, buf: &mut [u8]) -> Result<usize, IoError> {
+        self.inner.read(buf)
+    }
+}
+
+impl SeekableAsset for DynamicAsset {
+    fn seek(&mut self, pos: SeekFrom) -> Result<usize, IoError> {
+        self.inner.seek(pos)
+    }
+}
+
+pub trait DynamicDataRecorderImpl: DataRecorder {}
+
+pub struct DynamicDataRecorder {
+    inner: Box<dyn DynamicDataRecorderImpl>,
+}
+
+impl DataRecorder for DynamicDataRecorder {
+    fn write(&mut self, buf: &[u8]) -> Result<usize, IoError> {
+        self.inner.write(buf)
+    }
+}
+
+pub(crate) fn into_std_seek_pos(pos: SeekFrom) -> std::io::SeekFrom {
+    match pos {
+        SeekFrom::Start(offset) => std::io::SeekFrom::Start(offset as u64),
+        SeekFrom::End(offset) => std::io::SeekFrom::End(offset as i64),
+        SeekFrom::Current(offset) => std::io::SeekFrom::Current(offset as i64),
+    }
+}
diff --git a/rustzx-utils/src/lib.rs b/rustzx-utils/src/lib.rs
index 82b4197..7ea3918 100644
--- a/rustzx-utils/src/lib.rs
+++ b/rustzx-utils/src/lib.rs
@@ -6,3 +6,6 @@ extern crate std;
 pub mod palette;
 #[cfg(feature = "std")]
 pub mod stopwatch;
+
+#[cfg(all(feature = "std"))]
+pub mod io;
diff --git a/rustzx-z80/Cargo.toml b/rustzx-z80/Cargo.toml
index cc461eb..64551fb 100644
--- a/rustzx-z80/Cargo.toml
+++ b/rustzx-z80/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "rustzx-z80"
-version = "0.14.0"
+version = "0.15.0"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
 description = "Z80 CPU emulator library"
 repository = "https://github.com/pacmancoder/rustzx"
@@ -9,4 +9,4 @@ license = "MIT"
 edition = "2018"
 
 [dev-dependencies]
-paste = "1"
+paste = "1.0"
diff --git a/rustzx/Cargo.toml b/rustzx/Cargo.toml
index c48b070..fdc12e3 100644
--- a/rustzx/Cargo.toml
+++ b/rustzx/Cargo.toml
@@ -1,8 +1,8 @@
 [package]
 name = "rustzx"
-version = "0.14.0"
+version = "0.15.0"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
-description = "ZX Spectum emulator"
+description = "ZX Spectrum emulator"
 repository = "https://github.com/pacmancoder/rustzx"
 readme = "../README.md"
 keywords = ["emulator", "game", "z80"]
@@ -13,9 +13,17 @@ edition = "2018"
 
 [dependencies]
 sdl2 = { version = "0.34", features = ["unsafe_textures", "bundled", "static-link"] }
-rustzx-core = { version = "0.14", path =  "../rustzx-core", features = ["full"] }
-rustzx-utils = { version = "0.14", path =  "../rustzx-utils", features = ["std"] }
+rustzx-core = { version = "0.15", path =  "../rustzx-core", features = ["full"] }
+rustzx-utils = { version = "0.15", path =  "../rustzx-utils", features = ["std"] }
 log = "0.4"
-anyhow = "1"
+anyhow = "1.0"
 structopt = "0.3"
-env_logger = "0.8"
+strum = { version = "0.22", default-features = false, features = ["derive", "std"] }
+env_logger = "0.9"
+cpal = { version = "0.13", default-features = false, optional = true }
+
+[features]
+default = ["sound-cpal"]
+# cpal requries ALSA development headers on Linux, if this feature
+# is disabled, RustZX will be built only with SDL audio backend support
+sound-cpal = ["cpal"]
diff --git a/rustzx/src/app/events/events_sdl.rs b/rustzx/src/app/events/events_sdl.rs
index ba1a75c..4848074 100644
--- a/rustzx/src/app/events/events_sdl.rs
+++ b/rustzx/src/app/events/events_sdl.rs
@@ -26,7 +26,6 @@ pub struct EventsSdl {
     kempston_enabled: bool,
     mouse_enabled: bool,
     mouse_locked: bool,
-    screen_scale: usize,
     mouse_sensitivity: usize,
     enable_joy_keyaboard_layer: bool,
     mouse_x_counter: i32,
@@ -55,7 +54,6 @@ impl EventsSdl {
             mouse_enabled: settings.enable_mouse,
             mouse_locked: false,
             kempston_enabled: !settings.disable_kempston,
-            screen_scale: settings.scale,
             enable_joy_keyaboard_layer: false,
             mouse_sensitivity: settings.mouse_sensitivity,
             mouse_x_counter: 0,
diff --git a/rustzx/src/app/rustzx.rs b/rustzx/src/app/rustzx.rs
index 37ca7f6..0a9b064 100644
--- a/rustzx/src/app/rustzx.rs
+++ b/rustzx/src/app/rustzx.rs
@@ -5,13 +5,13 @@
 use crate::{
     app::{
         events::{Event, EventDevice, EventsSdl},
-        settings::Settings,
-        sound::{SoundDevice, SoundSdl},
+        settings::{Settings, SoundBackend},
+        sound::{SoundDevice, DEFAULT_SAMPLE_RATE},
         video::{Rect, TextureInfo, VideoDevice, VideoSdl},
     },
-    host::{self, AppHost, AppHostContext, DetectedFileKind, FileAsset},
+    host::{self, AppHost, AppHostContext, DetectedFileKind},
 };
-use anyhow::anyhow;
+use anyhow::{anyhow, Context};
 use rustzx_core::{
     host::SnapshotRecorder,
     zx::constants::{
@@ -19,6 +19,7 @@ use rustzx_core::{
     },
     Emulator,
 };
+use rustzx_utils::io::FileAsset;
 use std::{
     fs::{self, File},
     path::{Path, PathBuf},
@@ -54,8 +55,12 @@ pub struct RustzxApp {
 impl RustzxApp {
     /// Starts application itself
     pub fn from_config(settings: Settings) -> anyhow::Result<RustzxApp> {
-        let snd: Option<Box<dyn SoundDevice>> = if !settings.disable_sound {
-            Some(Box::new(SoundSdl::new(&settings)))
+        let snd = if !settings.disable_sound {
+            let backend = create_sound_backend(&settings).context(
+                "Failed to initialize sound subsystem, try other sound backend or --nosound option",
+            )?;
+
+            Some(backend)
         } else {
             None
         };
@@ -64,8 +69,12 @@ impl RustzxApp {
         let tex_canvas = video.gen_texture(CANVAS_WIDTH as u32, CANVAS_HEIGHT as u32);
         let scale = settings.scale as u32;
         let events = Box::new(EventsSdl::new(&settings));
+        let sample_rate = snd
+            .as_ref()
+            .map(|s| s.sample_rate())
+            .unwrap_or(DEFAULT_SAMPLE_RATE);
 
-        let mut emulator = Emulator::new(settings.to_rustzx_settings(), AppHostContext)
+        let mut emulator = Emulator::new(settings.to_rustzx_settings(sample_rate), AppHostContext)
             .map_err(|e| anyhow!("Failed to construct emulator: {}", e))?;
 
         if let Some(rom) = settings.rom.as_ref() {
@@ -137,7 +146,7 @@ impl RustzxApp {
             let emulator_dt = self
                 .emulator
                 .emulate_frames(MAX_FRAME_TIME)
-                .map_err(|e| anyhow!("Emulation step failed: {}", e))?;
+                .map_err(|e| anyhow!("Emulation step failed: {:#?}", e))?;
             // if sound enabled sound ganeration allowed then move samples to sound thread
             if let Some(ref mut snd) = self.snd {
                 // if can be turned off even on speed change, so check it everytime
@@ -203,7 +212,7 @@ impl RustzxApp {
                         self.emulator.send_compound_key(key, state);
                     }
                     Event::MouseMove { x, y } => {
-                        self.emulator.send_mouse_pos(x, y);
+                        self.emulator.send_mouse_pos_diff(x, y);
                     }
                     Event::MouseButton(buton, pressed) => {
                         self.emulator.send_mouse_button(buton, pressed);
@@ -305,3 +314,14 @@ impl RustzxApp {
         Path::new("default.rustzx.prev.sna").to_owned()
     }
 }
+
+fn create_sound_backend(settings: &Settings) -> anyhow::Result<Box<dyn SoundDevice>> {
+    use crate::app::sound;
+
+    let backend: Box<dyn SoundDevice> = match settings.sound_backend {
+        SoundBackend::Sdl => Box::new(sound::SoundSdl::new(settings)?),
+        #[cfg(feature = "sound-cpal")]
+        SoundBackend::Cpal => Box::new(sound::SoundCpal::new(settings)?),
+    };
+    Ok(backend)
+}
diff --git a/rustzx/src/app/settings.rs b/rustzx/src/app/settings.rs
index e23a79a..9f9fe9b 100644
--- a/rustzx/src/app/settings.rs
+++ b/rustzx/src/app/settings.rs
@@ -2,13 +2,28 @@ use rustzx_core::{
     zx::{machine::ZXMachine, sound::ay::ZXAYMode},
     EmulationMode, RustzxSettings,
 };
-
 use std::path::PathBuf;
-
 use structopt::StructOpt;
+use strum::{EnumString, EnumVariantNames, VariantNames};
+
+#[cfg(feature = "sound-cpal")]
+const DEFAULT_SOUND_BACKEND_VALUE: &str = "cpal";
+
+#[cfg(not(feature = "sound-cpal"))]
+const DEFAULT_SOUND_BACKEND_VALUE: &str = "sdl";
+
+#[derive(Clone, Copy, Debug, EnumString, EnumVariantNames)]
+#[strum(serialize_all = "snake_case")]
+pub enum SoundBackend {
+    Sdl,
+    #[cfg(feature = "sound-cpal")]
+    Cpal,
+}
 
 /// Structure to handle all emulator runtime settings
 #[derive(StructOpt)]
+#[structopt(about = env!("CARGO_PKG_DESCRIPTION"))]
+#[structopt(name = "RustZX")]
 pub struct Settings {
     /// Specify machine type for launch. Possible values:
     ///   [`48k`, `48`] - Sinclair ZX Spectrum 48K
@@ -56,13 +71,19 @@ pub struct Settings {
     /// Disable sound
     #[structopt(long = "nosound")]
     pub disable_sound: bool,
-    /// Set custom sound latency. Defaults to 1024 samples
-    #[structopt(long, default_value = "1024", parse(try_from_str = sound_latency_from_str))]
-    pub sound_latency: usize,
-    /// Set custom sound sample rate. Defaults to 44100 samples per second
-    #[structopt(long, default_value = "44100", parse(try_from_str = sound_sample_rate_from_str))]
-    pub sound_sample_rate: usize,
-
+    /// Set custom sound latency
+    #[structopt(long, parse(try_from_str = sound_latency_from_str))]
+    pub sound_latency: Option<usize>,
+    /// Set custom sound sample rate
+    #[structopt(long, parse(try_from_str = sound_sample_rate_from_str))]
+    pub sound_sample_rate: Option<usize>,
+    /// Sound backend to use
+    #[structopt(
+        long,
+        default_value = DEFAULT_SOUND_BACKEND_VALUE,
+        possible_values = &SoundBackend::VARIANTS
+    )]
+    pub sound_backend: SoundBackend,
     /// Set path to custom rom file. in case of multipart ROMs for 128k, the first part file,
     /// extension of which should end with `.0`
     #[structopt(long, conflicts_with = "file-autodetect")]
@@ -151,7 +172,7 @@ fn sound_sample_rate_from_str(s: &str) -> Result<usize, anyhow::Error> {
 }
 
 impl Settings {
-    pub fn to_rustzx_settings(&self) -> RustzxSettings {
+    pub fn to_rustzx_settings(&self, sound_sample_rate: usize) -> RustzxSettings {
         let ay_enabled = (matches!(self.machine, ZXMachine::Sinclair128K) || self.force_enable_ay)
             && (!self.force_disable_ay);
 
@@ -167,7 +188,7 @@ impl Settings {
             sound_enabled: !self.disable_sound,
             sound_volume: 100,
             load_default_rom: self.rom.is_none(),
-            sound_sample_rate: self.sound_sample_rate,
+            sound_sample_rate,
             autoload_enabled: !self.disable_autoload,
         }
     }
diff --git a/rustzx/src/app/sound/mod.rs b/rustzx/src/app/sound/mod.rs
index ad051dc..5ae6898 100644
--- a/rustzx/src/app/sound/mod.rs
+++ b/rustzx/src/app/sound/mod.rs
@@ -1,13 +1,21 @@
-//! platform-independent traits. Submodules with backends will be selectable
-//! via cargo features in future
+#[cfg(feature = "sound-cpal")]
+mod sound_cpal;
 mod sound_sdl;
 use rustzx_core::zx::sound::sample::SoundSample;
 
+#[cfg(feature = "sound-cpal")]
+pub use sound_cpal::SoundCpal;
 pub use sound_sdl::SoundSdl;
 
-// default sample type
+pub const CHANNEL_COUNT: usize = 2;
+pub const DEFAULT_SAMPLE_RATE: usize = 44100;
+pub const DEFAULT_LATENCY: usize = 512;
+
 pub type ZXSample = SoundSample<f32>;
+
 pub trait SoundDevice {
-    // blocking function to send new sample
+    /// Send new sample to the sound device
     fn send_sample(&mut self, sample: ZXSample);
+    /// Return selected device sample rate
+    fn sample_rate(&self) -> usize;
 }
diff --git a/rustzx/src/app/sound/sound_cpal.rs b/rustzx/src/app/sound/sound_cpal.rs
new file mode 100644
index 0000000..a8eb87e
--- /dev/null
+++ b/rustzx/src/app/sound/sound_cpal.rs
@@ -0,0 +1,107 @@
+//! Real Audio SDL backend
+use crate::app::{
+    settings::Settings,
+    sound::{SoundDevice, ZXSample, CHANNEL_COUNT},
+};
+use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
+use std::sync::mpsc;
+
+pub struct SoundCpal {
+    tx: mpsc::Sender<ZXSample>,
+    sample_rate: usize,
+    // Keep stream alive until Drop
+    _stream: cpal::Stream,
+}
+
+impl SoundCpal {
+    /// Constructs sound backend from settings
+    pub fn new(settings: &Settings) -> anyhow::Result<SoundCpal> {
+        let host = cpal::default_host();
+        let device = host
+            .default_output_device()
+            .ok_or_else(|| anyhow::anyhow!("Failed to acquire cpal sound host"))?;
+
+        let config = device
+            .supported_output_configs()?
+            .find(|c| {
+                if let Some(sample_rate) = settings.sound_sample_rate {
+                    if sample_rate < c.min_sample_rate().0 as usize
+                        || sample_rate > c.max_sample_rate().0 as usize
+                    {
+                        return false;
+                    }
+                }
+
+                c.channels() == CHANNEL_COUNT as u16
+            })
+            .ok_or_else(|| {
+                anyhow::anyhow!("Sound device does not support required configuration")
+            })?;
+
+        let config = if let Some(sample_rate) = settings.sound_sample_rate {
+            config.with_sample_rate(cpal::SampleRate(sample_rate as u32))
+        } else {
+            config.with_max_sample_rate()
+        };
+
+        let sample_rate = config.sample_rate().0 as usize;
+
+        let (tx, rx) = mpsc::channel();
+
+        let stream = match config.sample_format() {
+            cpal::SampleFormat::I16 => create_stream::<i16>(&device, &config.into(), rx)?,
+            cpal::SampleFormat::U16 => create_stream::<u16>(&device, &config.into(), rx)?,
+            cpal::SampleFormat::F32 => create_stream::<f32>(&device, &config.into(), rx)?,
+        };
+
+        Ok(SoundCpal {
+            tx,
+            sample_rate,
+            _stream: stream,
+        })
+    }
+}
+
+impl SoundDevice for SoundCpal {
+    fn send_sample(&mut self, sample: ZXSample) {
+        self.tx.send(sample).unwrap();
+    }
+
+    fn sample_rate(&self) -> usize {
+        self.sample_rate
+    }
+}
+
+fn create_stream<T>(
+    device: &cpal::Device,
+    config: &cpal::StreamConfig,
+    samples_rx: mpsc::Receiver<ZXSample>,
+) -> anyhow::Result<cpal::Stream>
+where
+    T: cpal::Sample,
+{
+    let channels = config.channels as usize;
+
+    let stream = device.build_output_stream(
+        config,
+        move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
+            for frame in out.chunks_mut(channels) {
+                match samples_rx.try_recv().ok() {
+                    Some(zx_sample) => {
+                        let left: T = cpal::Sample::from(&zx_sample.left);
+                        let right: T = cpal::Sample::from(&zx_sample.right);
+                        frame[0] = left;
+                        frame[1] = right;
+                    }
+                    None => {
+                        frame[0] = cpal::Sample::from(&0f32);
+                        frame[1] = cpal::Sample::from(&0f32);
+                    }
+                }
+            }
+        },
+        |_| {},
+    )?;
+    stream.play()?;
+    Ok(stream)
+}
diff --git a/rustzx/src/app/sound/sound_sdl.rs b/rustzx/src/app/sound/sound_sdl.rs
index a7a6117..be26e57 100644
--- a/rustzx/src/app/sound/sound_sdl.rs
+++ b/rustzx/src/app/sound/sound_sdl.rs
@@ -1,14 +1,16 @@
-//! Real Audio SDL backend
-use super::{SoundDevice, ZXSample};
-use crate::{app::settings::Settings, backends::SDL_CONTEXT};
+use crate::{
+    app::{
+        settings::Settings,
+        sound::{SoundDevice, ZXSample, CHANNEL_COUNT, DEFAULT_LATENCY, DEFAULT_SAMPLE_RATE},
+    },
+    backends::SDL_CONTEXT,
+};
 use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired};
-use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
-
-const CHANNEL_COUNT: usize = 2;
+use std::sync::mpsc;
 
 /// Struct which used in SDL audio callback
 struct SdlCallback {
-    samples: Receiver<ZXSample>,
+    samples: mpsc::Receiver<ZXSample>,
 }
 
 impl AudioCallback for SdlCallback {
@@ -18,9 +20,12 @@ impl AudioCallback for SdlCallback {
     fn callback(&mut self, out: &mut [f32]) {
         for chunk in out.chunks_mut(CHANNEL_COUNT) {
             // recieve samples from channel
-            if let Ok(sample) = self.samples.recv() {
+            if let Ok(sample) = self.samples.try_recv() {
                 chunk[0] = sample.left;
                 chunk[1] = sample.right;
+            } else {
+                chunk[0] = 0f32;
+                chunk[1] = 0f32;
             }
         }
     }
@@ -28,39 +33,43 @@ impl AudioCallback for SdlCallback {
 
 /// Represents SDL audio backend
 pub struct SoundSdl {
-    sender: SyncSender<ZXSample>,
-    device: AudioDevice<SdlCallback>,
+    sender: mpsc::Sender<ZXSample>,
+    sample_rate: usize,
+    _device: AudioDevice<SdlCallback>, // Should be alive until Drop invocation
 }
 
 impl SoundSdl {
     /// constructs sound backend from settings
-    pub fn new(settings: &Settings) -> SoundSdl {
-        // init backend
+    pub fn new(settings: &Settings) -> anyhow::Result<SoundSdl> {
         let mut audio_subsystem = None;
         SDL_CONTEXT.with(|sdl| {
             audio_subsystem = sdl.borrow_mut().audio().ok();
         });
-        if let Some(audio) = audio_subsystem {
-            // prepare specs
-            let desired_spec = AudioSpecDesired {
-                freq: Some(settings.sound_sample_rate as i32),
-                channels: Some(CHANNEL_COUNT as u8),
-                samples: Some(settings.sound_latency as u16),
-            };
-            let (tx, rx) = sync_channel(settings.sound_latency as usize);
-            let device_handle = audio
-                .open_playback(None, &desired_spec, |_| SdlCallback { samples: rx })
-                .expect("[ERROR Sdl audio device error, try --nosound]");
-            // run
-            device_handle.resume();
-            // save device and cahnnel handles
-            SoundSdl {
-                sender: tx,
-                device: device_handle,
-            }
-        } else {
-            panic!("[ERROR] Sdl audio error, try --nosound");
-        }
+        let audio = audio_subsystem
+            .ok_or_else(|| anyhow::anyhow!("Failed to initialize SDL audio backend"))?;
+
+        // Basically, SDL shits its pants if desired sound sample rate is not specified
+        let sample_rate = settings.sound_sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE);
+
+        // SDL sets awfully big latency by default
+        let latency = settings.sound_latency.unwrap_or(DEFAULT_LATENCY);
+
+        let desired_spec = AudioSpecDesired {
+            freq: Some(sample_rate as i32),
+            channels: Some(CHANNEL_COUNT as u8),
+            samples: Some(latency as u16),
+        };
+        let (tx, rx) = mpsc::channel();
+        let device_handle = audio
+            .open_playback(None, &desired_spec, |_| SdlCallback { samples: rx })
+            .map_err(|e| anyhow::anyhow!("Failed to start SDL sound stream: {}", e))?;
+        device_handle.resume();
+
+        Ok(SoundSdl {
+            sender: tx,
+            sample_rate,
+            _device: device_handle,
+        })
     }
 }
 
@@ -68,4 +77,8 @@ impl SoundDevice for SoundSdl {
     fn send_sample(&mut self, sample: ZXSample) {
         self.sender.send(sample).unwrap();
     }
+
+    fn sample_rate(&self) -> usize {
+        self.sample_rate
+    }
 }
diff --git a/rustzx/src/backends/mod.rs b/rustzx/src/backends/mod.rs
index 9615a40..188ac36 100644
--- a/rustzx/src/backends/mod.rs
+++ b/rustzx/src/backends/mod.rs
@@ -3,4 +3,4 @@ use sdl2::{self, Sdl};
 use std::cell::RefCell;
 
 thread_local! (pub static SDL_CONTEXT: RefCell<Sdl> = RefCell::new(
-        sdl2::init().expect("[ERROR] Sdl init error, try --nosound")));
+        sdl2::init().expect("SDL init failed")));
diff --git a/rustzx/src/host/mod.rs b/rustzx/src/host/mod.rs
index fdaa87d..3bd936a 100644
--- a/rustzx/src/host/mod.rs
+++ b/rustzx/src/host/mod.rs
@@ -1,14 +1,17 @@
 mod frame_buffer;
-mod io;
 
 use anyhow::{anyhow, bail, Context};
 use frame_buffer::{FrameBufferContext, RgbaFrameBuffer};
-pub use io::FileAsset;
 use rustzx_core::{
-    host::{FrameBuffer, Host, HostContext, RomFormat, RomSet, Screen, Snapshot, Tape},
+    host::{
+        FrameBuffer, Host, HostContext, RomFormat, RomSet, Screen, Snapshot, StubIoExtender, Tape,
+    },
     zx::machine::ZXMachine,
 };
-use rustzx_utils::stopwatch::InstantStopwatch;
+use rustzx_utils::{
+    io::{DynamicAsset, FileAsset, GzipAsset},
+    stopwatch::InstantStopwatch,
+};
 use std::{collections::VecDeque, fs::File, path::Path};
 
 const SUPPORTED_SNAPSHOT_FORMATS: [&str; 1] = ["sna"];
@@ -21,7 +24,8 @@ impl Host for AppHost {
     type Context = AppHostContext;
     type EmulationStopwatch = InstantStopwatch;
     type FrameBuffer = RgbaFrameBuffer;
-    type TapeAsset = FileAsset;
+    type IoExtender = StubIoExtender;
+    type TapeAsset = DynamicAsset;
 }
 
 pub struct AppHostContext;
@@ -33,11 +37,11 @@ impl HostContext<AppHost> for AppHostContext {
 }
 
 pub struct FileRomSet {
-    pages: VecDeque<FileAsset>,
+    pages: VecDeque<DynamicAsset>,
 }
 
 impl RomSet for FileRomSet {
-    type Asset = FileAsset;
+    type Asset = DynamicAsset;
 
     fn format(&self) -> RomFormat {
         RomFormat::Binary16KPages
@@ -54,7 +58,26 @@ pub enum DetectedFileKind {
     Screen,
 }
 
-pub fn load_tape(path: &Path) -> anyhow::Result<Tape<FileAsset>> {
+pub enum DetectedContainerKind {
+    None,
+    Gzip,
+}
+
+pub fn load_asset(path: &Path) -> anyhow::Result<DynamicAsset> {
+    let container_kind = detect_container(path);
+
+    let file = File::open(path).with_context(|| "Failed to open tape file")?;
+
+    match container_kind {
+        DetectedContainerKind::None => Ok(FileAsset::from(file).into()),
+        DetectedContainerKind::Gzip => {
+            let gzip = GzipAsset::new(file)?;
+            Ok(gzip.into())
+        }
+    }
+}
+
+pub fn load_tape(path: &Path) -> anyhow::Result<Tape<DynamicAsset>> {
     if !file_extension_matches_one_of(path, &SUPPORTED_TAPE_FORMATS) {
         bail!("Invalid tape format");
     }
@@ -63,12 +86,12 @@ pub fn load_tape(path: &Path) -> anyhow::Result<Tape<FileAsset>> {
         bail!("Provided tape file does not exist");
     }
 
-    File::open(path)
-        .with_context(|| "Failed to open tape file")
-        .map(|file| Tape::Tap(file.into()))
+    load_asset(path)
+        .map(Tape::Tap)
+        .with_context(|| "Failed to load tape file")
 }
 
-pub fn load_snapshot(path: &Path) -> anyhow::Result<Snapshot<FileAsset>> {
+pub fn load_snapshot(path: &Path) -> anyhow::Result<Snapshot<DynamicAsset>> {
     if !file_extension_matches_one_of(path, &SUPPORTED_SNAPSHOT_FORMATS) {
         bail!("Invalid snapshot format");
     }
@@ -77,12 +100,12 @@ pub fn load_snapshot(path: &Path) -> anyhow::Result<Snapshot<FileAsset>> {
         bail!("Provided snapshot file does not exist");
     }
 
-    File::open(path)
-        .with_context(|| "Failed to open snapshot file")
-        .map(|file| Snapshot::Sna(file.into()))
+    load_asset(path)
+        .map(Snapshot::Sna)
+        .with_context(|| "Failed to load snapshot file")
 }
 
-pub fn load_screen(path: &Path) -> anyhow::Result<Screen<FileAsset>> {
+pub fn load_screen(path: &Path) -> anyhow::Result<Screen<DynamicAsset>> {
     if !file_extension_matches_one_of(path, &SUPPORTED_SCREEN_FORMATS) {
         bail!("Invalid screen format");
     }
@@ -91,15 +114,13 @@ pub fn load_screen(path: &Path) -> anyhow::Result<Screen<FileAsset>> {
         bail!("Provided screen file does not exist");
     }
 
-    File::open(path)
-        .with_context(|| "Failed to open screen file")
-        .map(|file| Screen::Scr(file.into()))
+    load_asset(path)
+        .map(Screen::Scr)
+        .with_context(|| "Failed to load screen file")
 }
 
-fn load_rom_asset(path: &Path) -> anyhow::Result<FileAsset> {
-    File::open(path)
-        .with_context(|| "Failed to load rom asset")
-        .map(|file| file.into())
+fn load_rom_asset(path: &Path) -> anyhow::Result<DynamicAsset> {
+    load_asset(path).with_context(|| "Failed to load rom asset")
 }
 
 pub fn load_rom(path: &Path, machine: ZXMachine) -> anyhow::Result<FileRomSet> {
@@ -123,7 +144,15 @@ pub fn load_rom(path: &Path, machine: ZXMachine) -> anyhow::Result<FileRomSet> {
             if !rom0_path.exists() {
                 bail!("Provided 128K ROM0 file does not exist");
             }
-            let rom1_path = rom0_path.to_owned().with_extension("1");
+            let rom1_path = if is_container(rom0_path) {
+                let container_ext = rom0_path.extension().unwrap().to_string_lossy();
+                let mut new_path = rom0_path.to_owned();
+                new_path.set_extension(""); // removes just container extension
+                new_path.with_extension(format!("1.{}", container_ext))
+            } else {
+                rom0_path.to_owned().with_extension("1")
+            };
+
             if !rom1_path.exists() {
                 bail!("Provided 128K ROM1 file does not exist");
             }
@@ -152,7 +181,31 @@ pub fn detect_file_type(path: &Path) -> anyhow::Result<DetectedFileKind> {
     }
 }
 
+fn is_container(path: &Path) -> bool {
+    !matches!(detect_container(path), DetectedContainerKind::None)
+}
+
+fn detect_container(path: &Path) -> DetectedContainerKind {
+    let ext = path
+        .extension()
+        .unwrap_or_default()
+        .to_str()
+        .unwrap_or_default()
+        .to_lowercase();
+
+    match ext.as_str() {
+        "gz" => DetectedContainerKind::Gzip,
+        _ => DetectedContainerKind::None,
+    }
+}
+
 fn file_extension_matches(path: &Path, expected: &str) -> bool {
+    let mut path = path.to_owned();
+    // Ignore outer container extension during comparison
+    if is_container(&path) {
+        path.set_extension("");
+    }
+
     let actual = path
         .extension()
         .unwrap_or_default()
@@ -180,6 +233,8 @@ mod tests {
         assert!(file_extension_matches(&Path::new("test.tap"), "tap"));
         assert!(file_extension_matches(&Path::new("test.TAP"), "tap"));
         assert!(file_extension_matches(&Path::new("test.tAp"), "tap"));
+        assert!(file_extension_matches(&Path::new("test.tap.gz"), "tap"));
+        assert!(file_extension_matches(&Path::new("test.tap.gZ"), "tap"));
     }
 
     #[test]
diff --git a/rustzx/src/main.rs b/rustzx/src/main.rs
index 45d98da..b1b7f94 100644
--- a/rustzx/src/main.rs
+++ b/rustzx/src/main.rs
@@ -1,5 +1,3 @@
-#![allow(dead_code)]
-
 mod app;
 mod backends;
 mod host;
@@ -13,7 +11,9 @@ fn main() {
     let settings = Settings::from_args();
     let result = RustzxApp::from_config(settings)
         .and_then(|mut emulator| emulator.start())
-        .map_err(|e| log::error!("ERROR: {}", e));
+        .map_err(|e| {
+            log::error!("ERROR: {:#}", e);
+        });
 
     if result.is_err() {
         std::process::exit(1);
diff --git a/vtx/Cargo.toml b/vtx/Cargo.toml
index 1387132..8f3d099 100644
--- a/vtx/Cargo.toml
+++ b/vtx/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "vtx"
-version = "0.1.2"
+version = "0.15.0"
 description = "VTX sound format parsing and playback library"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
 edition = "2018"
@@ -10,12 +10,12 @@ repository = "https://github.com/pacmancoder/rustzx"
 
 [dependencies]
 delharc = { version = "0.3", default-features = false }
-thiserror = "1"
-byteorder = "1"
+thiserror = "1.0"
+byteorder = "1.4"
 num-traits = { version = "0.2", default-features = false }
 num-derive = "0.3"
-aym = { version = "0.1.1", path = "../aym" }
+aym = { version = "0.15", path = "../aym" }
 
 [dev-dependencies]
-expect-test = "1"
+expect-test = "1.1"
 sha2 = "0.9"
diff --git a/vtx/vtx-bin/Cargo.toml b/vtx/vtx-bin/Cargo.toml
index 4326207..fc0e236 100644
--- a/vtx/vtx-bin/Cargo.toml
+++ b/vtx/vtx-bin/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "vtx-bin"
 description = "VTX sound format toolbox"
-version = "0.1.2"
+version = "0.15.0"
 authors = ["The RustZX contributors", "Vladyslav Nikonov <mail@pacmancoder.xyz>"]
 edition = "2018"
 license = "MIT"
@@ -13,7 +13,7 @@ name = "vtx"
 path = "src/main.rs"
 
 [dependencies]
-vtx = { version = "0.1", path = ".." }
+vtx = { version = "0.15", path = ".." }
 structopt = "0.3"
-wav = "1"
-anyhow = "1"
+wav = "1.0"
+anyhow = "1.0"


Test patch:
diff --git a/.github/workflows/zexall.yml b/.github/workflows/test-rustzx-z80.yml
similarity index 70%
rename from .github/workflows/zexall.yml
rename to .github/workflows/test-rustzx-z80.yml
index 0afb40e..a691683 100644
--- a/.github/workflows/zexall.yml
+++ b/.github/workflows/test-rustzx-z80.yml
@@ -1,4 +1,4 @@
-name: Zexall Z80 test suite
+name: rustzx-z80 tests
 
 on:
   push:
@@ -18,8 +18,23 @@ env:
 # free tier, overall zexall test suite execution time will
 # be only as long as alu8r test duration (longest test)
 jobs:
-  batch1:
-    name: "Batch 1"
+  unit_tests:
+    name: "Unit tests"
+    runs-on: ubuntu-20.04
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions-rs/toolchain@v1
+        with:
+          profile: minimal
+          toolchain: stable
+          override: true
+      - name: Test
+        uses: actions-rs/cargo@v1
+        with:
+          command: test
+          args: -p rustzx-z80
+  zexall_batch_1:
+    name: "Zexall tests batch 1"
     runs-on: ubuntu-20.04
     steps:
       - uses: actions/checkout@v2
@@ -33,8 +48,8 @@ jobs:
         with:
           command: test
           args: -p rustzx-z80 -- --ignored zexall_alu8r
-  batch2:
-    name: "Batch 2"
+  zexall_batch_2:
+    name: "Zexall tests batch 2"
     runs-on: ubuntu-20.04
     steps:
       - uses: actions/checkout@v2
diff --git a/rustzx-core/test_data/.gitignore b/rustzx-core/test_data/.gitignore
deleted file mode 100644
index 01eef47..0000000
--- a/rustzx-core/test_data/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-# Do not include temporary tests output in the repo
-actual/**
diff --git a/rustzx-core/test_data/asset/make_in_docker.cmd b/rustzx-core/test_data/asset/make_in_docker.cmd
deleted file mode 100644
index b787e67..0000000
--- a/rustzx-core/test_data/asset/make_in_docker.cmd
+++ /dev/null
@@ -1,1 +0,0 @@
-docker run -v "%cd%:/src/" -it z88dk/z88dk sh /src/make.sh
diff --git a/rustzx-core/test_data/asset/make_in_docker.sh b/rustzx-core/test_data/asset/make_in_docker.sh
deleted file mode 100644
index 5cd7265..0000000
--- a/rustzx-core/test_data/asset/make_in_docker.sh
+++ /dev/null
@@ -1,1 +0,0 @@
-docker run -v "${PWD}:/src/" -it z88dk/z88dk sh /src/make.sh
diff --git a/rustzx-core/test_data/asset/simple_tape.tap b/rustzx-core/test_data/asset/simple_tape.tap
deleted file mode 100644
index 7e7ff65..0000000
Binary files a/rustzx-core/test_data/asset/simple_tape.tap and /dev/null differ
diff --git a/rustzx-core/test_data/expected/fastload_test/border_1.png b/rustzx-core/test_data/expected/fastload_test/border_1.png
deleted file mode 100644
index 1bacd7e..0000000
Binary files a/rustzx-core/test_data/expected/fastload_test/border_1.png and /dev/null differ
diff --git a/rustzx-core/test_data/expected/fastload_test/screen_1.png b/rustzx-core/test_data/expected/fastload_test/screen_1.png
deleted file mode 100644
index a9318a2..0000000
Binary files a/rustzx-core/test_data/expected/fastload_test/screen_1.png and /dev/null differ
diff --git a/rustzx-core/test_data/expected/fastload_test/screen_2.png b/rustzx-core/test_data/expected/fastload_test/screen_2.png
deleted file mode 100644
index 4f62802..0000000
Binary files a/rustzx-core/test_data/expected/fastload_test/screen_2.png and /dev/null differ
diff --git a/rustzx-core/tests/integration/framework.rs b/rustzx-core/tests/integration/framework.rs
deleted file mode 100644
index ffbce97..0000000
--- a/rustzx-core/tests/integration/framework.rs
+++ /dev/null
@@ -1,218 +0,0 @@
-use std::{
-    path::{Path, PathBuf},
-    time::Duration,
-};
-
-use rustzx_core::{
-    host::{BufferCursor, FrameBuffer, FrameBufferSource, Host, HostContext, Tape},
-    zx::{
-        machine::ZXMachine,
-        sound::ay::ZXAYMode,
-        video::colors::{ZXBrightness, ZXColor},
-    },
-    EmulationMode, Emulator, RustzxSettings,
-};
-use rustzx_utils::{palette::rgba::ORIGINAL as DEFAULT_PALETTE, stopwatch::InstantStopwatch};
-
-fn make_png_palette() -> Vec<u8> {
-    DEFAULT_PALETTE
-        .iter()
-        .fold(Vec::with_capacity(4 * 16), |mut buffer, color| {
-            buffer.extend_from_slice(&color[0..3]);
-            buffer
-        })
-}
-
-// TODO(#83): Add tests for gigascreen
-
-struct FrameContent {
-    buffer: Vec<u8>,
-    width: usize,
-    height: usize,
-}
-
-impl FrameBuffer for FrameContent {
-    type Context = TesterFrameBufferContext;
-
-    fn new(
-        width: usize,
-        height: usize,
-        _source: FrameBufferSource,
-        context: Self::Context,
-    ) -> Self {
-        if context.use_gigascreen {
-            unimplemented!("Gigascreen tests are not yet implemented");
-        } else {
-            let buffer_size = (width * height) / 2;
-            Self {
-                buffer: vec![0u8; buffer_size],
-                width,
-                height,
-            }
-        }
-    }
-
-    fn set_color(&mut self, x: usize, y: usize, color: ZXColor, brightness: ZXBrightness) {
-        let pixel_index = x + y * self.width;
-        let buffer_index = pixel_index / 2;
-        // 0xF0 mask for even pixels, 0x0F mask for odd pixels
-        let mask = 0xF0 >> (pixel_index % 2) * 4;
-        let indexed_color = (color as u8) + (brightness as u8) * 8;
-        // 0x0A => 0xAA, 0x03  => 0x33, etc.
-        let color_overlay_byte = indexed_color | (indexed_color << 4);
-        // clear previous color nibble and set to new value
-        self.buffer[buffer_index] =
-            (self.buffer[buffer_index] & (!mask)) | (color_overlay_byte & mask)
-    }
-}
-
-impl FrameContent {
-    pub fn to_png(&self) -> Vec<u8> {
-        let mut out = vec![];
-
-        {
-            let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
-            encoder.set_depth(png::BitDepth::Four);
-            encoder.set_color(png::ColorType::Indexed);
-            encoder.set_palette(make_png_palette());
-            let mut writer = encoder.write_header().expect("Failed to write PNG header");
-            writer
-                .write_image_data(&self.buffer)
-                .expect("Failed to write PNG data");
-        }
-
-        out
-    }
-}
-
-#[derive(Clone)]
-struct TesterFrameBufferContext {
-    use_gigascreen: bool,
-}
-
-#[derive(Default)]
-struct TesterContext;
-
-impl HostContext<TesterHost> for TesterContext {
-    fn frame_buffer_context(&self) -> <FrameContent as FrameBuffer>::Context {
-        TesterFrameBufferContext {
-            use_gigascreen: false,
-        }
-    }
-}
-
-struct TesterHost;
-
-impl Host for TesterHost {
-    type Context = TesterContext;
-    type EmulationStopwatch = InstantStopwatch;
-    type FrameBuffer = FrameContent;
-    type TapeAsset = BufferCursor<Vec<u8>>;
-}
-
-pub struct RustZXTester {
-    emulator: Emulator<TesterHost>,
-    test_name: String,
-}
-
-pub mod presets {
-    use super::*;
-
-    pub fn settings_48k_nosound() -> RustzxSettings {
-        RustzxSettings {
-            machine: ZXMachine::Sinclair48K,
-            emulation_mode: EmulationMode::FrameCount(1),
-            tape_fastload_enabled: true,
-            kempston_enabled: true,
-            mouse_enabled: true,
-            ay_mode: ZXAYMode::ABC,
-            ay_enabled: false,
-            beeper_enabled: false,
-            sound_enabled: false,
-            sound_volume: 100,
-            sound_sample_rate: 44100,
-            load_default_rom: true,
-            autoload_enabled: true,
-        }
-    }
-}
-
-impl RustZXTester {
-    pub fn new(test_name: &str, settings: RustzxSettings) -> Self {
-        let emulator = Emulator::new(settings, TesterContext::default())
-            .expect("Failed to initialize emulator");
-
-        Self {
-            emulator,
-            test_name: test_name.to_owned(),
-        }
-    }
-
-    fn assets_folder(&self) -> PathBuf {
-        Path::new("test_data/asset").to_owned()
-    }
-
-    fn expected_data_folder(&self) -> PathBuf {
-        Path::new("test_data/expected").join(&self.test_name)
-    }
-
-    fn actual_data_folder(&self) -> PathBuf {
-        Path::new("test_data/actual").join(&self.test_name)
-    }
-
-    pub fn load_tape(&mut self, name: impl AsRef<Path>) {
-        let path = self.assets_folder().join(name);
-        let content = std::fs::read(path).expect("Failed to read test tape file");
-        self.emulator
-            .load_tape(Tape::Tap(BufferCursor::new(content)))
-            .expect("Failed to load test tape");
-    }
-
-    fn get_screen(&self) -> Vec<u8> {
-        self.emulator.screen_buffer().to_png()
-    }
-
-    fn get_border(&self) -> Vec<u8> {
-        self.emulator.border_buffer().to_png()
-    }
-
-    pub fn emulate_for(&mut self, duration: Duration) {
-        const FRAME_HOST_DURATION_LIMIT: Duration = Duration::from_millis(100);
-        const FRAME_EMULATED_DURATION: Duration = Duration::from_millis(20);
-
-        let mut emulated_duration = Duration::from_secs(0);
-        while emulated_duration < duration {
-            self.emulator
-                .emulate_frames(FRAME_HOST_DURATION_LIMIT)
-                .expect("Emulation failed");
-            emulated_duration += FRAME_EMULATED_DURATION;
-        }
-    }
-
-    pub fn compare_buffer_with_file(&self, actual: Vec<u8>, name: impl AsRef<Path>) {
-        let path = name.as_ref();
-        let expected = std::fs::read(self.expected_data_folder().join(&path)).unwrap_or_default();
-
-        if actual != expected {
-            eprintln!("Integration test failed, writing actual data...");
-            // Wirte actual output for further investigation
-            std::fs::create_dir_all(self.actual_data_folder())
-                .expect("Failed to create expected data dir");
-            std::fs::write(self.actual_data_folder().join(path), actual)
-                .expect("Failed to write actual data");
-
-            panic!(
-                "Comparison with {} failed; Actual data has been saved for further investigation",
-                path.display()
-            );
-        }
-    }
-
-    pub fn expect_screen(&self, name: impl AsRef<Path>) {
-        self.compare_buffer_with_file(self.get_screen(), name);
-    }
-
-    pub fn expect_border(&self, name: impl AsRef<Path>) {
-        self.compare_buffer_with_file(self.get_border(), name);
-    }
-}
diff --git a/rustzx-core/tests/integration/main.rs b/rustzx-core/tests/integration/main.rs
deleted file mode 100644
index 7e10a4d..0000000
--- a/rustzx-core/tests/integration/main.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-mod framework;
-
-mod tape;
-
-extern crate std;
diff --git a/rustzx-core/tests/integration/tape.rs b/rustzx-core/tests/integration/tape.rs
deleted file mode 100644
index 3f21dfd..0000000
--- a/rustzx-core/tests/integration/tape.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-use crate::framework::{presets, RustZXTester};
-use std::time::Duration;
-
-#[test]
-fn fastload_test() {
-    let mut tester = RustZXTester::new("fastload_test", presets::settings_48k_nosound());
-    tester.load_tape("simple_tape.tap");
-    tester.emulate_for(Duration::from_millis(45));
-    tester.expect_screen("screen_1.png");
-    tester.emulate_for(Duration::from_millis(10));
-    tester.expect_screen("screen_2.png");
-    tester.expect_border("border_1.png");
-}
diff --git a/rustzx-test/Cargo.toml b/rustzx-test/Cargo.toml
new file mode 100644
index 0000000..17a81e8
--- /dev/null
+++ b/rustzx-test/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "rustzx-test"
+version = "0.15.0"
+edition = "2018"
+publish = false
+description = "RustZX emulator integration tests"
+
+[dependencies]
+anyhow = "1.0"
+base64 = "0.13"
+expect-test = "1.1"
+nanoid = "0.4"
+png = "0.16"
+rustzx-core = { version = "0.15", path = "../rustzx-core", features = ["full"] }
+rustzx-utils = { version = "0.15", path =  "../rustzx-utils", features = ["std"] }
+sha2 = "0.9"
+wav = "1.0"
diff --git a/rustzx-test/src/bin/build-assets.rs b/rustzx-test/src/bin/build-assets.rs
new file mode 100644
index 0000000..9afbec5
--- /dev/null
+++ b/rustzx-test/src/bin/build-assets.rs
@@ -0,0 +1,216 @@
+use anyhow::Context;
+use std::{
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+const DOCKER_IMAGE_REPO: &str = "https://github.com/z88dk/z88dk.git";
+const DOCKER_IMAGE_COMMIT: &str = "d61f6bb46ec15775cccf543f5941b6a2d6864ecf";
+const DOCKER_IMAGE_NAME: &str = "rustzx/z88dk";
+const DOCKER_IMAGE_FILE: &str = "z88dk.Dockerfile";
+
+const HEX_ALPHABET: [char; 16] = [
+    '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f',
+];
+const SHORTENED_COMMIT_LENGTH: usize = 7;
+
+fn image_tagged_name(name: impl AsRef<str>, tag: impl Into<String>) -> String {
+    let mut tag = tag.into();
+    tag.truncate(SHORTENED_COMMIT_LENGTH);
+
+    format!("{}:{}", name.as_ref(), tag)
+}
+
+// Running docker container OS may differ from the host, therefore
+// we can't use Path/PathBuf
+struct ForeignOsPath(String);
+
+impl ForeignOsPath {
+    fn new(path: impl Into<String>) -> Self {
+        Self(path.into())
+    }
+}
+
+struct DockerMountPoint {
+    dir: PathBuf,
+    mount: ForeignOsPath,
+}
+
+enum DockerAction {
+    ShellScript { path: ForeignOsPath },
+}
+
+struct DockerContainer {
+    image: String,
+    name: Option<String>,
+    mount_points: Vec<DockerMountPoint>,
+    remove_after_run: bool,
+    action: Option<DockerAction>,
+}
+
+impl DockerContainer {
+    pub fn from_image(image: impl Into<String>) -> Self {
+        Self {
+            image: image.into(),
+            remove_after_run: false,
+            mount_points: vec![],
+            name: None,
+            action: None,
+        }
+    }
+
+    pub fn name(mut self, name: impl Into<String>) -> Self {
+        self.name.replace(name.into());
+        self
+    }
+
+    pub fn mount(mut self, mount_point: DockerMountPoint) -> Self {
+        self.mount_points.push(mount_point);
+        self
+    }
+
+    pub fn remove_after_run(mut self) -> Self {
+        self.remove_after_run = true;
+        self
+    }
+
+    pub fn startup_script(mut self, path: ForeignOsPath) -> Self {
+        self.action.replace(DockerAction::ShellScript { path });
+        self
+    }
+
+    pub fn run(self) -> Result<(), anyhow::Error> {
+        let mut cmd = Command::new("docker");
+        cmd.arg("run");
+        if let Some(name) = self.name {
+            cmd.args(&["--name", &name]);
+        }
+        for mount_point in self.mount_points {
+            cmd.args(&[
+                "-v",
+                &format!("{}:{}", mount_point.dir.display(), mount_point.mount.0),
+            ]);
+        }
+        if self.remove_after_run {
+            cmd.arg("--rm");
+        }
+        if matches!(&self.action, Some(DockerAction::ShellScript { .. })) {
+            cmd.arg("-it");
+        }
+        cmd.arg(self.image);
+        if let Some(action) = self.action {
+            match action {
+                DockerAction::ShellScript { path } => {
+                    cmd.args(&["sh", &path.0]);
+                }
+            }
+        }
+
+        execute_command_transparent(cmd)?;
+        Ok(())
+    }
+}
+
+struct DockerImage {
+    url: String,
+    name: Option<String>,
+    dockerfile: Option<String>,
+}
+
+impl DockerImage {
+    pub fn present_on_machine(image_name: &str) -> Result<bool, anyhow::Error> {
+        let mut cmd = Command::new("docker");
+        cmd.args(["images", "-q", image_name]);
+
+        let output = cmd
+            .output()
+            .with_context(|| "Failed to query docker images")
+            .and_then(|out| {
+                String::from_utf8(out.stdout).with_context(|| "Failed to parse docker stdout")
+            })?;
+
+        Ok(!output.is_empty())
+    }
+
+    pub fn from_git(repo: impl AsRef<str>, commit: impl AsRef<str>) -> Self {
+        let url = format!("{}#{}", repo.as_ref(), commit.as_ref());
+
+        Self {
+            url,
+            name: None,
+            dockerfile: None,
+        }
+    }
+
+    pub fn with_name(mut self, name: impl Into<String>) -> Self {
+        self.name.replace(name.into());
+        self
+    }
+
+    pub fn with_dockerfile(mut self, dockerfile: impl Into<String>) -> Self {
+        self.dockerfile.replace(dockerfile.into());
+        self
+    }
+
+    pub fn build(self) -> anyhow::Result<()> {
+        let mut cmd = Command::new("docker");
+        cmd.arg("build");
+        if let Some(dockerfile) = self.dockerfile {
+            cmd.args(["-f", &dockerfile]);
+        }
+        if let Some(name) = self.name {
+            cmd.args(["-t", &name]);
+        }
+        cmd.arg(self.url);
+        cmd.spawn()?.wait()?;
+        execute_command_transparent(cmd)?;
+        Ok(())
+    }
+}
+
+fn to_container_name(prefix: &str) -> String {
+    format!("{}-{}", prefix, nanoid::nanoid!(8, &HEX_ALPHABET))
+}
+
+fn execute_command_transparent(mut cmd: Command) -> anyhow::Result<()> {
+    println!("Running command with args: {:#?}", cmd);
+
+    let status = cmd.spawn()?.wait()?;
+
+    if !status.success() {
+        anyhow::anyhow!(
+            "Command execution failed with code {}",
+            status.code().unwrap_or(1)
+        );
+    }
+
+    Ok(())
+}
+
+fn main() -> anyhow::Result<()> {
+    if !DockerImage::present_on_machine(DOCKER_IMAGE_NAME)
+        .with_context(|| "Failed to check for docker image presence")?
+    {
+        DockerImage::from_git(DOCKER_IMAGE_REPO, DOCKER_IMAGE_COMMIT)
+            .with_name(image_tagged_name(DOCKER_IMAGE_NAME, DOCKER_IMAGE_COMMIT))
+            .with_dockerfile(DOCKER_IMAGE_FILE)
+            .build()
+            .with_context(|| "Failed to build docker image")?;
+    }
+
+    let mut assets_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+    assets_dir.push(Path::new("test_data"));
+
+    DockerContainer::from_image(image_tagged_name(DOCKER_IMAGE_NAME, DOCKER_IMAGE_COMMIT))
+        .name(to_container_name("rustzx-assets"))
+        .mount(DockerMountPoint {
+            dir: assets_dir,
+            mount: ForeignOsPath::new("/src/"),
+        })
+        .remove_after_run()
+        .startup_script(ForeignOsPath::new("/src/make.sh"))
+        .run()
+        .expect("Failed to build assets");
+
+    Ok(())
+}
diff --git a/rustzx-test/src/framework.rs b/rustzx-test/src/framework.rs
new file mode 100644
index 0000000..4e9114c
--- /dev/null
+++ b/rustzx-test/src/framework.rs
@@ -0,0 +1,510 @@
+use expect_test::Expect;
+use rustzx_core::{
+    host::{
+        BufferCursor, FrameBuffer, FrameBufferSource, Host, HostContext, IoExtender, RomFormat,
+        RomSet, Snapshot, Tape,
+    },
+    zx::{
+        keys::ZXKey,
+        machine::ZXMachine,
+        sound::ay::ZXAYMode,
+        video::colors::{ZXBrightness, ZXColor},
+    },
+    EmulationMode, Emulator, RustzxSettings,
+};
+use rustzx_utils::{
+    io::{DynamicAsset, GzipAsset},
+    palette::rgba::ORIGINAL as DEFAULT_PALETTE,
+    stopwatch::InstantStopwatch,
+};
+use std::{
+    collections::VecDeque,
+    env,
+    io::Cursor,
+    path::{Path, PathBuf},
+    time::Duration,
+};
+
+const DEFAULT_SOUND_BITRATE: usize = 44100;
+const FRAME_HOST_DURATION_LIMIT: Duration = Duration::from_millis(100);
+const FRAME_EMULATED_DURATION: Duration = Duration::from_millis(20);
+const DEFAULT_SYNC_TIMEOUT: Duration = Duration::from_secs(3);
+
+// TODO(#83): Add tests for gigascreen
+
+struct FrameContent {
+    buffer: Vec<u8>,
+    width: usize,
+    height: usize,
+}
+
+impl FrameBuffer for FrameContent {
+    type Context = TesterFrameBufferContext;
+
+    fn new(
+        width: usize,
+        height: usize,
+        _source: FrameBufferSource,
+        context: Self::Context,
+    ) -> Self {
+        if context.use_gigascreen {
+            unimplemented!("Gigascreen tests are not yet implemented");
+        } else {
+            let buffer_size = (width * height) / 2;
+            Self {
+                buffer: vec![0u8; buffer_size],
+                width,
+                height,
+            }
+        }
+    }
+
+    fn set_color(&mut self, x: usize, y: usize, color: ZXColor, brightness: ZXBrightness) {
+        let pixel_index = x + y * self.width;
+        let buffer_index = pixel_index / 2;
+        // 0xF0 mask for even pixels, 0x0F mask for odd pixels
+        let mask = 0xF0 >> ((pixel_index % 2) * 4);
+        let indexed_color = (color as u8) + (brightness as u8) * 8;
+        // 0x0A => 0xAA, 0x03  => 0x33, etc.
+        let color_overlay_byte = indexed_color | (indexed_color << 4);
+        // clear previous color nibble and set to new value
+        self.buffer[buffer_index] =
+            (self.buffer[buffer_index] & (!mask)) | (color_overlay_byte & mask)
+    }
+}
+
+impl FrameContent {
+    pub fn to_png(&self) -> Vec<u8> {
+        let mut out = vec![];
+
+        {
+            let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
+            encoder.set_depth(png::BitDepth::Four);
+            encoder.set_color(png::ColorType::Indexed);
+            encoder.set_palette(make_png_palette());
+            let mut writer = encoder.write_header().expect("Failed to write PNG header");
+            writer
+                .write_image_data(&self.buffer)
+                .expect("Failed to write PNG data");
+        }
+
+        out
+    }
+}
+
+#[derive(Clone)]
+struct TesterFrameBufferContext {
+    use_gigascreen: bool,
+}
+
+#[derive(Default)]
+struct TesterContext;
+
+impl HostContext<TesterHost> for TesterContext {
+    fn frame_buffer_context(&self) -> <FrameContent as FrameBuffer>::Context {
+        TesterFrameBufferContext {
+            use_gigascreen: false,
+        }
+    }
+}
+
+#[derive(Default)]
+pub struct DebugPort {
+    stdin: VecDeque<u8>,
+    stdout: VecDeque<u8>,
+}
+
+impl IoExtender for DebugPort {
+    fn write(&mut self, _: u16, data: u8) {
+        self.stdout.push_back(data);
+    }
+
+    fn read(&mut self, _: u16) -> u8 {
+        self.stdin.pop_front().unwrap_or(0)
+    }
+
+    fn extends_port(&self, port: u16) -> bool {
+        port == 0xCCCC
+    }
+}
+
+impl DebugPort {
+    pub fn put_byte(&mut self, b: u8) {
+        self.stdin.push_back(b);
+    }
+
+    pub fn take_byte(&mut self) -> Option<u8> {
+        self.stdout.pop_front()
+    }
+
+    pub fn put_text(&mut self, s: &str) {
+        self.stdin.extend(s.as_bytes())
+    }
+
+    pub fn take_text(&mut self) -> String {
+        let s = Vec::from(std::mem::take(&mut self.stdout));
+        String::from_utf8(s).expect("Invalid debug port stdout")
+    }
+
+    pub fn take_bufer(&mut self) -> Vec<u8> {
+        Vec::from(std::mem::take(&mut self.stdout))
+    }
+
+    pub fn reset(&mut self) {
+        self.stdin.clear();
+        self.stdout.clear();
+    }
+}
+
+struct TesterHost;
+
+impl Host for TesterHost {
+    type Context = TesterContext;
+    type EmulationStopwatch = InstantStopwatch;
+    type FrameBuffer = FrameContent;
+    type IoExtender = DebugPort;
+    type TapeAsset = DynamicAsset;
+}
+
+pub struct RustZXTester {
+    emulator: Emulator<TesterHost>,
+    sound_buffer: Option<Vec<i16>>,
+    test_name: String,
+    sync_timeout: Duration,
+}
+
+pub mod presets {
+    use super::*;
+
+    pub fn settings_48k_nosound() -> RustzxSettings {
+        RustzxSettings {
+            machine: ZXMachine::Sinclair48K,
+            emulation_mode: EmulationMode::FrameCount(1),
+            tape_fastload_enabled: true,
+            kempston_enabled: false,
+            mouse_enabled: false,
+            ay_mode: ZXAYMode::ABC,
+            ay_enabled: false,
+            beeper_enabled: false,
+            sound_enabled: false,
+            sound_volume: 100,
+            sound_sample_rate: DEFAULT_SOUND_BITRATE,
+            load_default_rom: true,
+            autoload_enabled: true,
+        }
+    }
+
+    pub fn settings_128k_nosound() -> RustzxSettings {
+        RustzxSettings {
+            machine: ZXMachine::Sinclair128K,
+            ..settings_48k_nosound()
+        }
+    }
+
+    pub fn settings_48k() -> RustzxSettings {
+        RustzxSettings {
+            sound_enabled: true,
+            ay_enabled: true,
+            beeper_enabled: true,
+            ..settings_48k_nosound()
+        }
+    }
+
+    pub fn settings_128k() -> RustzxSettings {
+        RustzxSettings {
+            machine: ZXMachine::Sinclair128K,
+            ..settings_48k()
+        }
+    }
+}
+
+impl RustZXTester {
+    pub fn new(test_name: &str, settings: RustzxSettings) -> Self {
+        let emulator = Emulator::new(settings, TesterContext::default())
+            .expect("Failed to initialize emulator");
+
+        Self {
+            emulator,
+            test_name: test_name.to_owned(),
+            sound_buffer: None,
+            sync_timeout: DEFAULT_SYNC_TIMEOUT,
+        }
+    }
+
+    fn assets_folder(&self) -> PathBuf {
+        Path::new("test_data").to_owned()
+    }
+
+    fn actual_data_folder(&self) -> PathBuf {
+        Path::new("test_data/actual").join(&self.test_name)
+    }
+
+    fn load_asset_data(&mut self, name: impl AsRef<Path>) -> Vec<u8> {
+        let path = self.assets_folder().join(name);
+        let content = std::fs::read(&path).expect("Failed to load asset");
+
+        if path
+            .extension()
+            .map(|e| e.to_str().unwrap() == "gz")
+            .unwrap_or_default()
+        {
+            GzipAsset::new(Cursor::new(content))
+                .expect("Failed to decompress gz")
+                .into_vec()
+        } else {
+            content
+        }
+    }
+
+    fn load_asset(&mut self, name: impl AsRef<Path>) -> DynamicAsset {
+        BufferCursor::new(self.load_asset_data(name)).into()
+    }
+
+    pub fn load_tap(&mut self, name: impl AsRef<Path>) {
+        let asset = self.load_asset(name);
+        self.emulator
+            .load_tape(Tape::Tap(asset))
+            .expect("Failed to load test TAP");
+    }
+
+    pub fn load_sna(&mut self, name: impl AsRef<Path>) {
+        let asset = self.load_asset(name);
+        self.emulator
+            .load_snapshot(Snapshot::Sna(asset))
+            .expect("Failed to load test SNA")
+    }
+
+    pub fn load_single_page_rom(&mut self, name: impl AsRef<Path>) {
+        let rom_data = self.load_asset_data(name);
+        struct DiagRomSet {
+            pages: VecDeque<Vec<u8>>,
+        }
+
+        impl RomSet for DiagRomSet {
+            type Asset = BufferCursor<Vec<u8>>;
+
+            fn format(&self) -> RomFormat {
+                RomFormat::Binary16KPages
+            }
+
+            fn next_asset(&mut self) -> Option<Self::Asset> {
+                Some(BufferCursor::new(self.pages.pop_front().unwrap()))
+            }
+        }
+
+        let rom_set = DiagRomSet {
+            pages: VecDeque::from(vec![rom_data, vec![0u8; 16 * 1024]]),
+        };
+
+        self.emulator.load_rom(rom_set).unwrap();
+    }
+
+    fn get_screen(&self) -> Vec<u8> {
+        self.emulator.screen_buffer().to_png()
+    }
+
+    fn get_border(&self) -> Vec<u8> {
+        self.emulator.border_buffer().to_png()
+    }
+
+    fn update_sound(&mut self) {
+        if let Some(sound_buffer) = &mut self.sound_buffer {
+            while let Some(sample) = self.emulator.next_audio_sample() {
+                let normalize = |s| (s * i16::MAX as f32) as i16;
+                sound_buffer.push(normalize(sample.left));
+                sound_buffer.push(normalize(sample.right));
+            }
+        }
+    }
+
+    pub fn emulate_for(&mut self, duration: Duration) {
+        let mut emulated_duration = Duration::from_secs(0);
+        while emulated_duration < duration {
+            self.emulator
+                .emulate_frames(FRAME_HOST_DURATION_LIMIT)
+                .expect("Emulation failed");
+            self.update_sound();
+            emulated_duration += FRAME_EMULATED_DURATION;
+        }
+    }
+
+    pub fn emulate_frame(&mut self) {
+        self.emulate_for(FRAME_EMULATED_DURATION);
+    }
+
+    pub fn compare_buffer_with_file(
+        &self,
+        actual: Vec<u8>,
+        name: impl AsRef<Path>,
+        expect: Expect,
+    ) {
+        if TestEnv::save_test_data_enabled() {
+            self.save_actual_data(&actual, name.as_ref());
+        }
+
+        expect.assert_eq(&actual.fingerprint());
+    }
+
+    fn save_actual_data(&self, actual: &[u8], filename: &Path) {
+        let filename = self.actual_data_folder().join(filename);
+        eprintln!("Saving actual test data file {}", filename.display());
+
+        std::fs::create_dir_all(self.actual_data_folder())
+            .expect("Failed to create actual data dir");
+        std::fs::write(filename, actual).expect("Failed to write actual data");
+    }
+
+    pub fn expect_screen(&self, name: impl AsRef<Path>, expect: Expect) {
+        self.compare_buffer_with_file(self.get_screen(), make_screen_filename(name), expect);
+    }
+
+    pub fn expect_border(&self, name: impl AsRef<Path>, expect: Expect) {
+        self.compare_buffer_with_file(self.get_border(), make_border_filename(name), expect);
+    }
+
+    pub fn expect_text(&self, name: impl AsRef<Path>, text: String, expect: Expect) {
+        self.compare_buffer_with_file(text.into_bytes(), make_text_filename(name), expect);
+    }
+
+    pub fn emulator(&mut self) -> &mut Emulator<impl Host> {
+        &mut self.emulator
+    }
+
+    pub fn send_keystrokes(&mut self, keystrokes: &[&[ZXKey]], keystroke_delay: Duration) {
+        let mut first = true;
+        for keys in keystrokes {
+            if !first {
+                self.emulate_for(keystroke_delay);
+            }
+            first = false;
+
+            for key in *keys {
+                self.emulator.send_key(*key, true);
+            }
+
+            self.emulate_for(keystroke_delay);
+
+            for key in *keys {
+                self.emulator.send_key(*key, false);
+            }
+        }
+    }
+
+    pub fn start_sound_capture(&mut self) {
+        // Pre-allocate 1Mb of memory
+        self.sound_buffer.replace(Vec::with_capacity(1024 * 1024));
+    }
+
+    pub fn expect_sound(&mut self, name: impl AsRef<Path>, expect: Expect) {
+        let data = self
+            .sound_buffer
+            .take()
+            .expect("Sound is not being recorded");
+
+        let mut wav_data = std::io::Cursor::new(vec![]);
+        let wav_header = wav::Header::new(
+            wav::header::WAV_FORMAT_PCM,
+            2,
+            DEFAULT_SOUND_BITRATE as u32,
+            16,
+        );
+        wav::write(wav_header, &wav::BitDepth::Sixteen(data), &mut wav_data)
+            .expect("Failed to generate wav");
+
+        self.compare_buffer_with_file(wav_data.into_inner(), make_sound_filename(name), expect);
+    }
+
+    pub fn enable_debug_port(&mut self) {
+        self.emulator.set_io_extender(DebugPort::default());
+    }
+
+    pub fn debug_port(&mut self) -> &mut DebugPort {
+        self.emulator
+            .io_extender()
+            .expect("Debug port is not enabled for the current test")
+    }
+
+    pub fn sync_target(&mut self) {
+        if !self.debug_port().stdout.is_empty() || !self.debug_port().stdin.is_empty() {
+            panic!(
+                "ERROR: Test may be incorrect, there were some unprocessed data in the target's \
+                 port before sync"
+            );
+        }
+
+        // Host (this test executable) writes and then reads port,
+        // while target (emulated snapshot) reads and then writes,
+        // this allows to sync both sides and doesn not produce any
+        // deadlocks
+        self.debug_port().put_byte(1);
+
+        let mut sync_duration = Duration::default();
+        loop {
+            self.emulate_for(FRAME_EMULATED_DURATION);
+            sync_duration += FRAME_EMULATED_DURATION;
+
+            if sync_duration > self.sync_timeout {
+                panic!("Timeout reached when trying to sync host with target");
+            }
+
+            // Try to consume incomming signal and finish sync
+            if self.debug_port().take_byte().is_some() {
+                break;
+            }
+        }
+    }
+
+    pub fn set_sync_timeout(&mut self, timeout: Duration) {
+        self.sync_timeout = timeout;
+    }
+}
+
+struct TestEnv;
+
+impl TestEnv {
+    fn save_test_data_enabled() -> bool {
+        env::var("RUSTZX_SAVE_TEST_DATA")
+            .map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
+            .unwrap_or(false)
+    }
+}
+
+fn make_png_palette() -> Vec<u8> {
+    DEFAULT_PALETTE
+        .iter()
+        .fold(Vec::with_capacity(4 * 16), |mut buffer, color| {
+            buffer.extend_from_slice(&color[0..3]);
+            buffer
+        })
+}
+
+fn make_screen_filename(name: impl AsRef<Path>) -> PathBuf {
+    name.as_ref().with_extension("screen.png")
+}
+
+fn make_border_filename(name: impl AsRef<Path>) -> PathBuf {
+    name.as_ref().with_extension("border.png")
+}
+
+fn make_sound_filename(name: impl AsRef<Path>) -> PathBuf {
+    name.as_ref().with_extension("wav")
+}
+
+fn make_text_filename(name: impl AsRef<Path>) -> PathBuf {
+    name.as_ref().with_extension("txt")
+}
+
+trait Fingerprintable {
+    fn fingerprint(&self) -> String;
+}
+
+impl Fingerprintable for Vec<u8> {
+    fn fingerprint(&self) -> String {
+        use sha2::{Digest, Sha256};
+
+        let mut hasher = Sha256::default();
+        hasher.update(&self);
+        let hash = hasher.finalize();
+        base64::encode(hash)
+    }
+}
diff --git a/rustzx-test/src/lib.rs b/rustzx-test/src/lib.rs
new file mode 100644
index 0000000..0c71749
--- /dev/null
+++ b/rustzx-test/src/lib.rs
@@ -0,0 +1,1 @@
+pub mod framework;
diff --git a/rustzx-core/test_data/asset/.gitignore b/rustzx-test/test_data/.gitignore
similarity index 61%
rename from rustzx-core/test_data/asset/.gitignore
rename to rustzx-test/test_data/.gitignore
index 0aa9b3b..67522b8 100644
--- a/rustzx-core/test_data/asset/.gitignore
+++ b/rustzx-test/test_data/.gitignore
@@ -1,2 +1,3 @@
+actual/**
 build/**
 out/**
diff --git a/rustzx-test/test_data/diag_rom_v56.gz b/rustzx-test/test_data/diag_rom_v56.gz
new file mode 100644
index 0000000..9443630
Binary files /dev/null and b/rustzx-test/test_data/diag_rom_v56.gz differ
diff --git a/rustzx-test/test_data/diag_rom_v56_started.128k.sna.gz b/rustzx-test/test_data/diag_rom_v56_started.128k.sna.gz
new file mode 100644
index 0000000..18ca571
Binary files /dev/null and b/rustzx-test/test_data/diag_rom_v56_started.128k.sna.gz differ
diff --git a/rustzx-test/test_data/kempston_joy.48k.sna.gz b/rustzx-test/test_data/kempston_joy.48k.sna.gz
new file mode 100644
index 0000000..6868ede
Binary files /dev/null and b/rustzx-test/test_data/kempston_joy.48k.sna.gz differ
diff --git a/rustzx-test/test_data/keyboard.48k.sna.gz b/rustzx-test/test_data/keyboard.48k.sna.gz
new file mode 100644
index 0000000..dd04b12
Binary files /dev/null and b/rustzx-test/test_data/keyboard.48k.sna.gz differ
diff --git a/rustzx-core/test_data/asset/make.sh b/rustzx-test/test_data/make.sh
similarity index 63%
rename from rustzx-core/test_data/asset/make.sh
rename to rustzx-test/test_data/make.sh
index 08fc3a7..762c524 100644
--- a/rustzx-core/test_data/asset/make.sh
+++ b/rustzx-test/test_data/make.sh
@@ -80,8 +80,8 @@ ${ZMAKEBAS_BIN} \
     -l \
     -a 10 \
     -n screen \
-    -o "${BUILD_DIR}/loaders/screen.tap" \
-    "${SRC_DIR}/loaders/screen.bas"
+    -o "${BUILD_DIR}/loader_screen.tap" \
+    "${SRC_DIR}/loader_screen.bas"
 if [ $? -ne 0 ]; then
     log_error "Failed to build screen loader"
     exit 1
@@ -92,13 +92,49 @@ log_unindent
 log_info "Building simple_tape..."
 log_indent
 z88dk-appmake +zx \
-    -b "${SRC_DIR}/simple_tape/loading.scr" \
+    -b "${SRC_DIR}/rustzx.scr" \
     -o "${BUILD_DIR}/simple_tape_loaderless.tap" \
     --blockname screen \
     --org 16384 \
     --noloader
-cat "${BUILD_DIR}/loaders/screen.tap" \
+cat "${BUILD_DIR}/loader_screen.tap" \
     "${BUILD_DIR}/simple_tape_loaderless.tap" \
-    > "${OUT_DIR}/simple_tape.tap"
+    > "${BUILD_DIR}/simple_tape.tap"
+gzip --stdout "${BUILD_DIR}/simple_tape.tap" > "${OUT_DIR}/simple_tape.tap.gz"
 log_success "Done"
 log_unindent
+
+function build_sna {
+    local APP_NAME="$1"
+    local ADDITIONAL_ARGS=""
+    local EXT_PREFIX="48k"
+
+    if [ "$2" = "128k" ]; then
+        ADDITIONAL_ARGS="-Cz --128"
+        local EXT_PREFIX="128k"
+    fi
+
+    log_info "Building ${APP_NAME} SNA (${EXT_PREFIX})..."
+    log_indent
+    zcc +zx \
+        -lm \
+        -o "${BUILD_DIR}/${APP_NAME}.${EXT_PREFIX}.o" \
+        -create-app \
+        -Cz --sna \
+        -Cz -o \
+        -Cz "${BUILD_DIR}/${APP_NAME}.${EXT_PREFIX}.sna" \
+        "${ADDITIONAL_ARGS}" \
+        "${SRC_DIR}/${APP_NAME}.c" \
+        "${SRC_DIR}/librustzx.c" \
+        && gzip \
+            --stdout "${BUILD_DIR}/${APP_NAME}.${EXT_PREFIX}.sna" \
+            > "${OUT_DIR}/${APP_NAME}.${EXT_PREFIX}.sna.gz"
+    log_success "Done"
+    log_unindent
+}
+
+build_sna sound 48k
+build_sna sound 128k
+build_sna mouse 48k
+build_sna kempston_joy 48k
+build_sna keyboard 48k
diff --git a/rustzx-test/test_data/mouse.48k.sna.gz b/rustzx-test/test_data/mouse.48k.sna.gz
new file mode 100644
index 0000000..7d50f6c
Binary files /dev/null and b/rustzx-test/test_data/mouse.48k.sna.gz differ
diff --git a/rustzx-test/test_data/simple_tape.tap.gz b/rustzx-test/test_data/simple_tape.tap.gz
new file mode 100644
index 0000000..6cf098c
Binary files /dev/null and b/rustzx-test/test_data/simple_tape.tap.gz differ
diff --git a/rustzx-test/test_data/sound.128k.sna.gz b/rustzx-test/test_data/sound.128k.sna.gz
new file mode 100644
index 0000000..e77a57e
Binary files /dev/null and b/rustzx-test/test_data/sound.128k.sna.gz differ
diff --git a/rustzx-test/test_data/sound.48k.sna.gz b/rustzx-test/test_data/sound.48k.sna.gz
new file mode 100644
index 0000000..d6c793b
Binary files /dev/null and b/rustzx-test/test_data/sound.48k.sna.gz differ
diff --git a/rustzx-test/test_data/src/kempston_joy.c b/rustzx-test/test_data/src/kempston_joy.c
new file mode 100644
index 0000000..1fdbf0e
--- /dev/null
+++ b/rustzx-test/test_data/src/kempston_joy.c
@@ -0,0 +1,16 @@
+#include "librustzx.h"
+
+static unsigned char get_kempston_joy_state(void) __z88dk_callee {
+    __asm__("in a, ($1f)");
+    __asm__("mov l, a");
+    __asm__("ret");
+}
+
+void main() {
+    // Respond to each sync request from the rust test
+    for (;;) {
+        rustzx_sync_with_host();
+        rustzx_port_write_byte_hex(get_kempston_joy_state());
+        rustzx_port_write_char(',');
+    }
+}
diff --git a/rustzx-test/test_data/src/keyboard.c b/rustzx-test/test_data/src/keyboard.c
new file mode 100644
index 0000000..b658f4d
--- /dev/null
+++ b/rustzx-test/test_data/src/keyboard.c
@@ -0,0 +1,58 @@
+#include "librustzx.h"
+
+#define KEYS_COUNT 8
+
+
+static unsigned char keyboard_state[8] = { 0, 0, 0, 0, 0, 0, 0, 0 };
+
+static unsigned char query_keyboard_state(void) __z88dk_callee {
+    __asm__("ld c, $FE");
+
+    __asm__("ld b, $FE");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 0), a");
+    __asm__("ld b, $FD");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 1), a");
+    __asm__("ld b, $FB");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 2), a");
+    __asm__("ld b, $F7");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 3), a");
+    __asm__("ld b, $EF");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 4), a");
+    __asm__("ld b, $DF");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 5), a");
+    __asm__("ld b, $BF");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 6), a");
+    __asm__("ld b, $7F");
+    __asm__("in a, (c)");
+    __asm__("ld (_keyboard_state + 7), a");
+    __asm__("ret");
+}
+
+void send_keyboard_state() {
+    query_keyboard_state();
+
+    for (int i = 0; i < 8; ++i) {
+        // bitwise OR operation with each read byte is
+        // required to ignore other ULA port content
+        // (3 most significant bits). This is reuiqred
+        // to make keyboard test independent from
+        // other ULA functionality tests
+        rustzx_port_write_byte_hex(keyboard_state[i] | 0xE0);
+    }
+}
+
+void main() {
+    // Respond to each sync request from the rust test
+    for (;;) {
+        rustzx_sync_with_host();
+        send_keyboard_state();
+        rustzx_port_write_char('\n');
+    }
+}
diff --git a/rustzx-test/test_data/src/librustzx.c b/rustzx-test/test_data/src/librustzx.c
new file mode 100644
index 0000000..50976fa
--- /dev/null
+++ b/rustzx-test/test_data/src/librustzx.c
@@ -0,0 +1,51 @@
+#include "librustzx.h"
+
+static const char HEX_ALPHABET[16] = {
+    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+};
+
+static unsigned char read_debug_port(void) __z88dk_callee {
+    __asm__("ld a, $CC");
+    __asm__("in a, ($CC)");
+    __asm__("mov l, a");
+    __asm__("ret");
+}
+
+static volatile unsigned char _rustzx_debug_port_tmp = 0;
+
+static unsigned char write_debug_port(void) __z88dk_callee {
+    __asm__("ld a, (__rustzx_debug_port_tmp)");
+    __asm__("ld b, $CC");
+    __asm__("ld c, $CC");
+    __asm__("out (c), a");
+    __asm__("ret");
+}
+
+void rustzx_port_write_str(char* s) {
+    while (*s) {
+        _rustzx_debug_port_tmp = *(s++);
+        write_debug_port();
+    }
+}
+
+void rustzx_port_write_char(char x) {
+    _rustzx_debug_port_tmp = x;
+    write_debug_port();
+}
+
+void rustzx_port_write_byte_hex(unsigned char x) {
+    _rustzx_debug_port_tmp = HEX_ALPHABET[(x & 0xF0) >> 4];
+    write_debug_port();
+    _rustzx_debug_port_tmp = HEX_ALPHABET[x & 0x0F];
+    write_debug_port();
+}
+
+unsigned char rustzx_port_read_byte() {
+    return read_debug_port();
+}
+
+void rustzx_sync_with_host() {
+    while (!read_debug_port()) {}
+    _rustzx_debug_port_tmp = 1;
+    write_debug_port();
+}
diff --git a/rustzx-test/test_data/src/librustzx.h b/rustzx-test/test_data/src/librustzx.h
new file mode 100644
index 0000000..156a4a7
--- /dev/null
+++ b/rustzx-test/test_data/src/librustzx.h
@@ -0,0 +1,6 @@
+void rustzx_port_write_str(char* s);
+void rustzx_port_write_char(char x);
+void rustzx_port_write_byte_hex(unsigned char x);
+unsigned char rustzx_port_read_byte();
+
+void rustzx_sync_with_host();
diff --git a/rustzx-core/test_data/asset/src/loaders/screen.bas b/rustzx-test/test_data/src/loader_screen.bas
similarity index 100%
rename from rustzx-core/test_data/asset/src/loaders/screen.bas
rename to rustzx-test/test_data/src/loader_screen.bas
diff --git a/rustzx-test/test_data/src/mouse.c b/rustzx-test/test_data/src/mouse.c
new file mode 100644
index 0000000..91d9cfa
--- /dev/null
+++ b/rustzx-test/test_data/src/mouse.c
@@ -0,0 +1,176 @@
+#include <graphics.h>
+#include <conio.h>
+
+// We can't use here z88dk implementation is does not allow to
+// do not well things like additional mouse buttons
+static unsigned char io_kempston_state(void) __z88dk_callee {
+    __asm__("ld a, $FA");
+    __asm__("in a, ($DF)");
+    __asm__("mov l, a");
+    __asm__("ret");
+}
+
+static unsigned char io_kempston_mouse_x(void) __z88dk_callee {
+    __asm__("ld a, $FB");
+    __asm__("in a, ($DF)");
+    __asm__("mov l, a");
+    __asm__("ret");
+}
+
+static unsigned char io_kempston_mouse_y(void) __z88dk_callee {
+    __asm__("ld a, $FF");
+    __asm__("in a, ($DF)");
+    __asm__("mov l, a");
+    __asm__("ret");
+}
+
+static struct {
+    unsigned char prev_x;
+    unsigned char prev_y;
+    unsigned char prev_state;
+    unsigned char x;
+    unsigned char y;
+    unsigned char state;
+} g_kemp_mouse;
+
+static void kemp_poll(void) {
+    g_kemp_mouse.prev_x = g_kemp_mouse.x;
+    g_kemp_mouse.prev_y = g_kemp_mouse.y;
+    g_kemp_mouse.prev_state = g_kemp_mouse.state;
+
+    g_kemp_mouse.x = io_kempston_mouse_x();
+    g_kemp_mouse.y = 255 - io_kempston_mouse_y();
+    g_kemp_mouse.state = io_kempston_state();
+}
+
+static void kemp_init(void) {
+    g_kemp_mouse.prev_x = g_kemp_mouse.x = io_kempston_mouse_x();
+    g_kemp_mouse.prev_y = g_kemp_mouse.y = 255 - io_kempston_mouse_y();
+    g_kemp_mouse.prev_state = g_kemp_mouse.state = io_kempston_state();
+}
+
+static unsigned char kemp_left_button_pressed(void) { return g_kemp_mouse.state & 0x01; }
+
+static unsigned char kemp_right_button_pressed(void) { return g_kemp_mouse.state & 0x02; }
+
+static unsigned char kemp_middle_button_pressed(void) { return g_kemp_mouse.state & 0x04; }
+
+static unsigned char kemp_ext_button_pressed(void) { return g_kemp_mouse.state & 0x08; }
+
+static char kemp_wheel_diff(void) {
+    unsigned char prev_wheel = (g_kemp_mouse.prev_state & 0xF0);
+    unsigned char wheel = (g_kemp_mouse.state & 0xF0);
+    return (char)(wheel - prev_wheel) / 16;
+}
+
+static char kemp_x_diff(void) { return (char)(g_kemp_mouse.x - g_kemp_mouse.prev_x); }
+
+static char kemp_y_diff(void) { return (char)(g_kemp_mouse.y - g_kemp_mouse.prev_y); }
+
+static const char CURSOR_SPRITE[8] = { 0xE0, 0xF8, 0xFE, 0x7F, 0x7C, 0x3E, 0x37, 0x13 };
+
+static int is_cursor_pixel_set(int cursor_x, int cursor_y, int screen_x, int screen_y) {
+    if ((screen_x < cursor_x)
+        || (screen_x >= cursor_x + 8)
+        || (screen_y < cursor_y)
+        || (screen_y >= cursor_y + 8))
+    {
+        return 0;
+    }
+
+    int rel_x = screen_x - cursor_x;
+    int rel_y = screen_y - cursor_y;
+
+    return CURSOR_SPRITE[rel_y] & (0x80 >> rel_x);
+}
+
+static void paint_cursor(int xpos, int ypos, int prev_xpos, int prev_ypos) {
+    textcolor(BLACK);
+    for (int y = 0; y < 8; y++) {
+        for (int x = 0; x < 8; x++) {
+            int screen_x = xpos + x;
+            int screen_y = ypos + y;
+
+            int prev_screen_x = prev_xpos + x;
+            int prev_screen_y = prev_ypos + y;
+
+            int is_prev_set = is_cursor_pixel_set(xpos, ypos, prev_screen_x, prev_screen_y);
+            int is_now_set = is_cursor_pixel_set(xpos, ypos, screen_x, screen_y);
+
+            // clear pixels in the previous cursor area
+            if (!is_prev_set) {
+                unplot(prev_screen_x, prev_screen_y);
+            }
+
+            // set pixels in the new pixels area
+            if (is_now_set) {
+                plot(screen_x, screen_y);
+            }
+        }
+    }
+}
+
+static void draw_button_box(unsigned char index, unsigned char pressed) {
+    if (pressed) {
+        textcolor(RED); drawb(32 * index, 0, 3, 3);
+    } else {
+        textcolor(WHITE); drawb(32 * index, 0, 3, 3);
+    }
+}
+
+static void draw_scroll_box(unsigned char prev_ypos, unsigned char ypos) {
+    textcolor(WHITE);
+    drawb(0, prev_ypos, 3, 3);
+    textcolor(RED);
+    drawb(0, ypos, 3, 3);
+}
+
+void main() {
+    cclg();
+    kemp_init();
+    unsigned char xpos = 0;
+    unsigned char ypos = 0;
+    unsigned char prev_xpos = 0;
+    unsigned char prev_ypos = 0;
+
+    unsigned char wheel = 0;
+
+    while(1) {
+        kemp_poll();
+
+        short virtual_x = (short)xpos + kemp_x_diff();
+        short virtual_y = (short)ypos + kemp_y_diff();
+        if (virtual_y > 191) virtual_y = 1;
+        if (virtual_y < 0) virtual_y = 192;
+
+        prev_xpos = xpos;
+        prev_ypos = ypos;
+        xpos = (unsigned char) virtual_x;
+        ypos = (unsigned char) virtual_y;
+
+        paint_cursor(xpos, ypos, prev_xpos, prev_ypos);
+
+        char wheel_diff = kemp_wheel_diff();
+        if (wheel_diff != 0) {
+            unsigned char prev_wheel = wheel;
+            if (wheel_diff > 0) {
+                if ((short)wheel + wheel_diff < 192)
+                    wheel = (short)wheel + wheel_diff;
+                else
+                    wheel = 191;
+            }
+            if (wheel_diff < 0) {
+                if ((short)wheel + wheel_diff >= 0)
+                    wheel = (short)wheel + wheel_diff;
+                else
+                    wheel = 0;
+            }
+            draw_scroll_box(prev_wheel, wheel);
+        }
+
+        draw_button_box(1, kemp_left_button_pressed());
+        draw_button_box(2, kemp_right_button_pressed());
+        draw_button_box(3, kemp_middle_button_pressed());
+        draw_button_box(4, kemp_ext_button_pressed());
+    }
+}
diff --git a/rustzx-core/test_data/asset/src/simple_tape/loading.scr b/rustzx-test/test_data/src/rustzx.scr
similarity index 100%
rename from rustzx-core/test_data/asset/src/simple_tape/loading.scr
rename to rustzx-test/test_data/src/rustzx.scr
diff --git a/rustzx-test/test_data/src/sound.c b/rustzx-test/test_data/src/sound.c
new file mode 100644
index 0000000..8bae494
--- /dev/null
+++ b/rustzx-test/test_data/src/sound.c
@@ -0,0 +1,13 @@
+#include <sound.h>
+#include <psg.h>
+
+void main() {
+	psg_init();
+	psg_channels(chanAll, chanNone); // set all channels to tone generation
+    psg_tone(0, psgT(130.8)); // produce a C tone on the first channel
+    psg_tone(1, psgT(164.8)); // produce a E tone on the second channel
+    psg_tone(2, psgT(195.9)); // produce a G tone on the third channel
+    psg_envelope(envUH, psgT(16), chanAll); // set a raising volume envelope on all channels
+    bit_play("EmDCDCD"); // play some random tune on beeper
+    psg_envelope(envD, psgT(16), chanAll); // set a fading volume envelope on all channels
+}
diff --git a/rustzx-core/test_data/asset/src/third-party/zmakebas-1.3.tar.gz b/rustzx-test/test_data/src/third-party/zmakebas-1.3.tar.gz
similarity index 100%
rename from rustzx-core/test_data/asset/src/third-party/zmakebas-1.3.tar.gz
rename to rustzx-test/test_data/src/third-party/zmakebas-1.3.tar.gz
diff --git a/rustzx-test/tests/diag_rom.rs b/rustzx-test/tests/diag_rom.rs
new file mode 100644
index 0000000..fa0bf26
--- /dev/null
+++ b/rustzx-test/tests/diag_rom.rs
@@ -0,0 +1,117 @@
+use expect_test::expect;
+use rustzx_core::zx::{keys::ZXKey, machine::ZXMachine};
+use rustzx_test::framework::{presets, RustZXTester};
+use std::time::Duration;
+
+const DIAG_ROM_NAME: &str = "diag_rom_v56.gz";
+const DIAG_ROM_MENU_SNAP_128K_NAME: &str = "diag_rom_v56_started.128k.sna.gz";
+
+fn load_diag_rom(t: &mut RustZXTester) {
+    t.load_single_page_rom(DIAG_ROM_NAME);
+}
+
+fn load_diag_rom_menu_sna(t: &mut RustZXTester, machine: ZXMachine) {
+    if machine != ZXMachine::Sinclair128K {
+        panic!("Non-128K DiagROM snapshots are missing");
+    }
+    load_diag_rom(t);
+    // NOTE: DiagROM is a little bit tricky about loading its state via snapshot, therefore
+    // here we actually load SNA of emulator a few moments before showing actual DiagROM menu
+    t.load_sna(DIAG_ROM_MENU_SNAP_128K_NAME);
+    t.emulate_for(Duration::from_millis(1000));
+}
+
+#[test]
+fn diag_rom_mem_48k() {
+    let mut t = RustZXTester::new("diag_rom_mem_48k", presets::settings_48k_nosound());
+    load_diag_rom(&mut t);
+    t.emulate_for(Duration::from_secs(50));
+    t.expect_screen(
+        "result",
+        expect![[r#"JYQl8vwFrIjaC+tZT34FBtey/aJHuylNvjldBTDIL0Q="#]],
+    );
+}
+
+#[test]
+fn diag_rom_mem_128k() {
+    let mut t = RustZXTester::new("diag_rom_mem_128k", presets::settings_128k_nosound());
+    load_diag_rom(&mut t);
+    t.emulate_for(Duration::from_secs(50));
+    t.expect_screen(
+        "result",
+        expect![[r#"JYQl8vwFrIjaC+tZT34FBtey/aJHuylNvjldBTDIL0Q="#]],
+    );
+}
+
+#[test]
+fn diag_rom_mem_banked_128k() {
+    let mut t = RustZXTester::new("diag_rom_mem_banked_128k", presets::settings_128k_nosound());
+    load_diag_rom_menu_sna(&mut t, ZXMachine::Sinclair128K);
+    t.send_keystrokes(&[&[ZXKey::N7], &[ZXKey::N1]], Duration::from_millis(100));
+    t.emulate_for(Duration::from_secs(100));
+    t.expect_screen(
+        "result",
+        expect![[r#"27D2yD4yM6WJAv3vO3NyofTuSVfGOQXo8V0qrbc5wVk="#]],
+    );
+}
+
+#[test]
+fn diag_rom_second_screen_bank_128k() {
+    let mut t = RustZXTester::new(
+        "diag_rom_second_screen_bank_128k",
+        presets::settings_128k_nosound(),
+    );
+    load_diag_rom_menu_sna(&mut t, ZXMachine::Sinclair128K);
+    t.send_keystrokes(&[&[ZXKey::N7], &[ZXKey::N2]], Duration::from_millis(100));
+    t.emulate_for(Duration::from_secs(3));
+    t.expect_screen(
+        "screen1",
+        expect![[r#"+uu521vfpyy9IUSYYiqbiOu9s0NIV7gW8Co/ZXKRi2I="#]],
+    );
+    t.emulate_for(Duration::from_secs(4));
+    t.expect_screen(
+        "screen2",
+        expect![[r#"ff2AqkCUYcPdp+yLMsOg8s3DyBpwC6YEvyUMR/RWPSs="#]],
+    );
+}
+
+#[test]
+fn diag_rom_contention_128k() {
+    let mut t = RustZXTester::new("diag_rom_contention_128k", presets::settings_128k_nosound());
+    load_diag_rom_menu_sna(&mut t, ZXMachine::Sinclair128K);
+    t.send_keystrokes(&[&[ZXKey::N7], &[ZXKey::N5]], Duration::from_millis(100));
+    t.emulate_for(Duration::from_secs(3));
+    t.expect_screen(
+        "result",
+        expect![[r#"ImqjTr7XsJrZsDPYqB/wRgYachkgCHHPbCc+rH3Clg8="#]],
+    );
+}
+
+#[test]
+fn diag_rom_ula_128k() {
+    let mut t = RustZXTester::new("diag_rom_ula_128k", presets::settings_128k_nosound());
+    load_diag_rom_menu_sna(&mut t, ZXMachine::Sinclair128K);
+    t.send_keystrokes(&[&[ZXKey::N6], &[ZXKey::N1]], Duration::from_millis(100));
+    t.emulate_for(Duration::from_secs(3));
+    t.expect_screen(
+        "result",
+        expect![[r#"I2YQuImYmwfzNB9Y48M4ce2JVwDsQcEO2jamkLoBYxs="#]],
+    );
+}
+
+#[test]
+fn diag_rom_interrupt_128k() {
+    let mut t = RustZXTester::new("diag_rom_interrupt_128k", presets::settings_128k_nosound());
+    load_diag_rom_menu_sna(&mut t, ZXMachine::Sinclair128K);
+    t.send_keystrokes(&[&[ZXKey::N6], &[ZXKey::N4]], Duration::from_millis(100));
+    t.emulate_for(Duration::from_secs(2));
+    t.expect_screen(
+        "frame1",
+        expect![[r#"47E1ralzgtRVSmo6f1e9bMHyw1S+b4F/zQOTVo0bfq0="#]],
+    );
+    t.emulate_for(Duration::from_secs(2));
+    t.expect_screen(
+        "frame2",
+        expect![[r#"ZmdepGrZWq8SvP+GeXziGeuNj0T0nR0VL6XGXPN6DAw="#]],
+    );
+}
diff --git a/rustzx-test/tests/joy.rs b/rustzx-test/tests/joy.rs
new file mode 100644
index 0000000..814be1a
--- /dev/null
+++ b/rustzx-test/tests/joy.rs
@@ -0,0 +1,118 @@
+use expect_test::expect;
+use rustzx_core::{
+    zx::{
+        joy::{
+            kempston::KempstonKey,
+            sinclair::{SinclairJoyNum, SinclairKey},
+        },
+        keys::{CompoundKey, ZXKey},
+    },
+    IterableEnum,
+};
+use rustzx_test::framework::{presets, RustZXTester};
+
+#[test]
+fn kempston_joy() {
+    let mut settings = presets::settings_48k_nosound();
+    settings.kempston_enabled = true;
+    let mut t = RustZXTester::new("kempston_joy", settings);
+    t.enable_debug_port();
+    t.load_sna("kempston_joy.48k.sna.gz");
+
+    let mut out = String::new();
+
+    // Check emulator initial state (all bits should set to 0)
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+
+    let keys = KempstonKey::iter().collect::<Vec<_>>();
+
+    // Press all sequential
+    for k in &keys {
+        t.emulator().send_kempston_key(*k, true);
+        t.sync_target();
+        t.emulate_frame();
+        out += &t.debug_port().take_text();
+    }
+
+    // Release all sequential
+    for k in &keys {
+        t.emulator().send_kempston_key(*k, false);
+        t.sync_target();
+        t.emulate_frame();
+        out += &t.debug_port().take_text();
+    }
+
+    expect![[r#"00,01,03,07,0F,1F,3F,7F,FF,FE,FC,F8,F0,E0,C0,80,00,"#]].assert_eq(&out);
+}
+
+#[test]
+fn sinclair_joy() {
+    let mut t = RustZXTester::new("sinclair_joy", presets::settings_48k_nosound());
+    t.enable_debug_port();
+    // We use keyboard SNA here because technically, sinclair joystick emulates key presses
+    t.load_sna("keyboard.48k.sna.gz");
+
+    let mut out = String::new();
+
+    let joys = SinclairJoyNum::iter().collect::<Vec<_>>();
+    let buttons = SinclairKey::iter().collect::<Vec<_>>();
+
+    for joy in joys {
+        out += &format!("JOY_BUTTONS({:?})\n", joy);
+        for button in &buttons {
+            t.emulator().send_sinclair_key(joy, *button, true);
+            t.sync_target();
+            t.emulate_frame();
+            out += &t.debug_port().take_text();
+            t.emulator().send_sinclair_key(joy, *button, false);
+        }
+    }
+
+    out += "SIMULTANEOUS\n";
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Fist, SinclairKey::Right, true);
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Second, SinclairKey::Left, true);
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Fist, SinclairKey::Right, false);
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Second, SinclairKey::Left, false);
+
+    out += "WITH_OTHER_KEYS\n";
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Fist, SinclairKey::Fire, true);
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Fist, SinclairKey::Left, true);
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Second, SinclairKey::Fire, true);
+    t.emulator().send_key(ZXKey::A, true); // unrelated key
+    t.emulator().send_key(ZXKey::N5, true); // overlaps with joy key
+    t.emulator().send_compound_key(CompoundKey::Delete, true); // overlaps with joy key (N0)
+    t.emulator().send_compound_key(CompoundKey::Break, true); // unrelated compound key
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+    // Check that joy key release does not clear unrelated keys/compound keys
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Fist, SinclairKey::Fire, false);
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Fist, SinclairKey::Left, false);
+    t.emulator()
+        .send_sinclair_key(SinclairJoyNum::Second, SinclairKey::Fire, false);
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+
+    // Expect full key press/release log. See reasoning about comparing hashes
+    // in keyboard.rs
+    t.expect_text(
+        "log",
+        out,
+        expect![[r#"F6bYEdfQ8M9gpyCivt2vuKMws83uDEmuB3Q7OQlXucU="#]],
+    );
+}
diff --git a/rustzx-test/tests/keyboard.rs b/rustzx-test/tests/keyboard.rs
new file mode 100644
index 0000000..37255b2
--- /dev/null
+++ b/rustzx-test/tests/keyboard.rs
@@ -0,0 +1,117 @@
+use expect_test::expect;
+use rustzx_core::{
+    zx::keys::{CompoundKey, ZXKey},
+    IterableEnum,
+};
+use rustzx_test::framework::{presets, RustZXTester};
+
+#[test]
+fn standard_keys() {
+    let mut t = RustZXTester::new("standard_keys", presets::settings_48k_nosound());
+    t.enable_debug_port();
+    t.load_sna("keyboard.48k.sna.gz");
+
+    let mut out = String::new();
+
+    // Check emulator initial state (all bits should set to 1)
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+
+    let keys = ZXKey::iter().collect::<Vec<_>>();
+
+    // Press all sequential
+    for k in &keys {
+        t.emulator().send_key(*k, true);
+        t.sync_target();
+        t.emulate_frame();
+        out += &t.debug_port().take_text();
+    }
+
+    // Release all sequential
+    for k in &keys {
+        t.emulator().send_key(*k, false);
+        t.sync_target();
+        t.emulate_frame();
+        out += &t.debug_port().take_text();
+    }
+
+    // Expect full key press/release log. Here we just compare hashes because
+    // keyboard input log is too big to inline it directly into the test file
+    t.expect_text(
+        "log",
+        out,
+        expect![[r#"lF44fsm0VLApiGX1LTQOYRgj40hPrX9L5SaA3nF/i6w="#]],
+    );
+}
+
+#[test]
+fn compound_keys() {
+    let mut t = RustZXTester::new("compound_keys", presets::settings_48k_nosound());
+    t.enable_debug_port();
+    t.load_sna("keyboard.48k.sna.gz");
+
+    let mut out = String::from("SEQUENTIAL\n");
+
+    let keys = CompoundKey::iter().collect::<Vec<_>>();
+
+    let mut prev = None;
+
+    // Separate key presses
+    for k in &keys {
+        if let Some(prev) = prev {
+            t.emulator().send_compound_key(prev, false);
+        }
+        t.emulator().send_compound_key(*k, true);
+        t.sync_target();
+        t.emulate_frame();
+        out += &t.debug_port().take_text();
+        prev = Some(*k);
+    }
+
+    // Reset keyboard to its initial state by releasing last pressed key
+    // and write this initial state to the log
+    out += "INITIAL\n";
+    t.emulator().send_compound_key(prev.unwrap(), false);
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+
+    // Check that simultaneously pressed compound keys do not cancel each other.
+    // resulting keyboard state should be logical "AND" (because keys are inversed)
+    out += "SIMULTANEOUS\n";
+    t.emulator().send_compound_key(CompoundKey::ArrowLeft, true);
+    t.emulator()
+        .send_compound_key(CompoundKey::ArrowRight, true);
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+    t.emulator()
+        .send_compound_key(CompoundKey::ArrowLeft, false);
+    t.emulator()
+        .send_compound_key(CompoundKey::ArrowRight, false);
+
+    // Check that compound keys can be pressed simulateneously with standard keys
+    out += "WITH_STANDARD\n";
+    t.emulator().send_compound_key(CompoundKey::ArrowLeft, true);
+    t.emulator().send_key(ZXKey::N5, true); // overlaps with compound
+    t.emulator().send_key(ZXKey::A, true); // unrelated key
+    t.sync_target();
+    t.emulate_frame();
+    // After releasing compound key, overlapping standard key N5 should be still
+    // notified by the emualtor as pressed
+    t.emulator()
+        .send_compound_key(CompoundKey::ArrowLeft, false);
+    out += &t.debug_port().take_text();
+    t.sync_target();
+    t.emulate_frame();
+    out += &t.debug_port().take_text();
+
+    // Expect full key press/release log. Here we just compare hashes because
+    // keyboard input log is too big to inline it directly into the test file
+    t.expect_text(
+        "log",
+        out,
+        expect![[r#"v01HM6RHAtHfvFEnvCXae4dl1FrHEISrnDgljzvMcoE="#]],
+    );
+}
diff --git a/rustzx-test/tests/mouse.rs b/rustzx-test/tests/mouse.rs
new file mode 100644
index 0000000..da8f36e
--- /dev/null
+++ b/rustzx-test/tests/mouse.rs
@@ -0,0 +1,114 @@
+use expect_test::expect;
+use rustzx_core::zx::mouse::kempston::{KempstonMouseButton, KempstonMouseWheelDirection};
+use rustzx_test::framework::{presets, RustZXTester};
+use std::time::Duration;
+
+#[test]
+fn kempston_mouse() {
+    let mut settings = presets::settings_48k_nosound();
+    settings.mouse_enabled = true;
+
+    let mut tester = RustZXTester::new("kempston_mouse", settings);
+    tester.load_sna("mouse.48k.sna.gz");
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "sna_loaded",
+        expect![[r#"7AVgP7YkJPt4y4x1NnlMyWSo6wntyPaRcrVHBmfSfFk="#]],
+    );
+
+    tester.emulator().send_mouse_pos_diff(64, 32);
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "cursor_moved_1",
+        expect![[r#"KXWv0nZ+P2/PCWAWXh0eIOsncVrPISgYqggniZifLQs="#]],
+    );
+
+    tester.emulator().send_mouse_pos_diff(-48, -16);
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "cursor_moved_2",
+        expect![[r#"HUIX0sv7yUhYjAU6rNxF4ODzLhm5WYGgyHKcJ3z0EuI="#]],
+    );
+
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Left, true);
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "left_pressed",
+        expect![[r#"zJJGnWCk90irqNKUEeEAMoYxutt2sI6b1oaFN9sRWmA="#]],
+    );
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Left, false);
+
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Right, true);
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "right_pressed",
+        expect![[r#"WDsRHUt7EzlfGvlPMOsPvmCzdP0ob15l5FARzE390b8="#]],
+    );
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Right, false);
+
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Middle, true);
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "middle_pressed",
+        expect![[r#"RaAwafxap1fFOUTBOeaNHlSgIEW+kRN41umpEc/ccdk="#]],
+    );
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Middle, false);
+
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Additional, true);
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "ext_pressed",
+        expect![[r#"ffhRpkdMjqHxAns5Wb40Sp2IzMR6gYuxJXrRiOB7ZNQ="#]],
+    );
+    tester
+        .emulator()
+        .send_mouse_button(KempstonMouseButton::Additional, false);
+
+    (0..10).for_each(|_| {
+        tester
+            .emulator()
+            .send_mouse_wheel(KempstonMouseWheelDirection::Down)
+    });
+    tester.emulate_for(Duration::from_millis(250));
+    (0..10).for_each(|_| {
+        tester
+            .emulator()
+            .send_mouse_wheel(KempstonMouseWheelDirection::Down)
+    });
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "wheel_2",
+        expect![[r#"oeQS+og5xrGSyz3Zk+f3L4U93aB2azba0jl0mVU96xI="#]],
+    );
+
+    (0..10).for_each(|_| {
+        tester
+            .emulator()
+            .send_mouse_wheel(KempstonMouseWheelDirection::Up)
+    });
+    tester.emulate_for(Duration::from_millis(250));
+    (0..10).for_each(|_| {
+        tester
+            .emulator()
+            .send_mouse_wheel(KempstonMouseWheelDirection::Up)
+    });
+    tester.emulate_for(Duration::from_millis(250));
+    tester.expect_screen(
+        "wheel_3",
+        expect![[r#"I+2mija0+YU60eHjAehkN9MpfgMli2ym7pMoChVbcFo="#]],
+    );
+}
diff --git a/rustzx-test/tests/sound.rs b/rustzx-test/tests/sound.rs
new file mode 100644
index 0000000..7309e0a
--- /dev/null
+++ b/rustzx-test/tests/sound.rs
@@ -0,0 +1,27 @@
+use expect_test::expect;
+use rustzx_test::framework::{presets, RustZXTester};
+use std::time::Duration;
+
+#[test]
+fn sound_48k() {
+    let mut tester = RustZXTester::new("sound_48k", presets::settings_48k());
+    tester.load_sna("sound.48k.sna.gz");
+    tester.start_sound_capture();
+    tester.emulate_for(Duration::from_secs(2));
+    tester.expect_sound(
+        "beeper_plus_ay",
+        expect![[r#"bEAIV7LVE7591l6VUrCE1ySbEyBSNeVwLjWhDm9Jlpc="#]],
+    );
+}
+
+#[test]
+fn sound_128k() {
+    let mut tester = RustZXTester::new("sound_128k", presets::settings_128k());
+    tester.load_sna("sound.128k.sna.gz");
+    tester.start_sound_capture();
+    tester.emulate_for(Duration::from_secs(2));
+    tester.expect_sound(
+        "beeper_plus_ay",
+        expect![[r#"u8WCHu89dFvnMInLGaDFV4ha6FatBtXLJ6szqiUg+ys="#]],
+    );
+}
diff --git a/rustzx-test/tests/tape.rs b/rustzx-test/tests/tape.rs
new file mode 100644
index 0000000..f1d615f
--- /dev/null
+++ b/rustzx-test/tests/tape.rs
@@ -0,0 +1,229 @@
+use expect_test::expect;
+use rustzx_core::zx::keys::ZXKey;
+use rustzx_test::framework::{presets, RustZXTester};
+use std::time::Duration;
+
+#[test]
+fn no_fastload() {
+    let mut settings = presets::settings_48k_nosound();
+    settings.tape_fastload_enabled = false;
+    settings.autoload_enabled = false;
+
+    let mut tester = RustZXTester::new("no_fastload", settings);
+    tester.load_tap("simple_tape.tap.gz");
+    // Wait for ROM to load
+    tester.emulate_for(Duration::from_millis(2000));
+    // Emulate LOAD ""
+    tester.send_keystrokes(
+        &[
+            &[ZXKey::J],
+            &[ZXKey::SymShift, ZXKey::P],
+            &[ZXKey::SymShift, ZXKey::P],
+            &[ZXKey::Enter],
+        ],
+        Duration::from_millis(100),
+    );
+
+    // Check that tape is not loading until signaled manually
+    tester.emulate_for(Duration::from_millis(100));
+    tester.expect_screen(
+        "empty",
+        expect![[r#"nI+vo8GaRwKwWTPTP2f22Wcgm9nEwMlm16+Cmzird2w="#]],
+    );
+    tester.expect_border(
+        "empty",
+        expect![[r#"CkU7FUXUKUZneunabAn/h+88EDIxzvO1aqCl5LadYEs="#]],
+    );
+
+    tester.emulator().play_tape();
+    tester.emulate_for(Duration::from_millis(2000));
+
+    // Check tack tape is started loading
+    tester.expect_border(
+        "sync_pulses",
+        expect![[r#"Oc++rVrRSea7L5+dCz066kS/mPzhKZ8MhhvVo+8r5iY="#]],
+    );
+
+    // Check that data block started loading
+    tester.emulate_for(Duration::from_millis(3100));
+    tester.expect_border(
+        "data_pulses",
+        expect![[r#"pf8oQYn3t7yAJuLr0MjOpROdoDJ1wK1CsqQvsasuDew="#]],
+    );
+
+    // Check that Loader has been loaded
+    tester.emulate_for(Duration::from_millis(100));
+    tester.expect_screen(
+        "block_1",
+        expect![[r#"+o3MYnfBeDMtimIE/+6+o2/9h1OgtZ8izbO7b/jOiMc="#]],
+    );
+
+    // Check that second block has been loaded.
+    tester.emulate_for(Duration::from_millis(45000));
+    tester.expect_border(
+        "end",
+        expect![[r#"tmGY7e4h+XA3px6BcqnCXF83NEdBqVw8PW9sQtpMAvM="#]],
+    );
+    tester.expect_screen(
+        "block_2",
+        expect![[r#"zDQzdQr19uTYaZouk7ex+pkylk2TRFAuenooMVFjkyQ="#]],
+    );
+}
+
+#[test]
+fn no_fastload_128k() {
+    let mut settings = presets::settings_128k_nosound();
+    settings.tape_fastload_enabled = false;
+    settings.autoload_enabled = false;
+
+    let mut tester = RustZXTester::new("no_fastload_128k", settings);
+    tester.load_tap("simple_tape.tap.gz");
+    // Wait for ROM to load
+    tester.emulate_for(Duration::from_millis(3000));
+
+    // Emulate Enter keypress to load tape
+    tester.send_keystrokes(&[&[ZXKey::Enter]], Duration::from_millis(100));
+
+    // Check that tape is not loading until signaled manually
+    tester.emulate_for(Duration::from_millis(100));
+    tester.expect_screen(
+        "ready_to_load",
+        expect![[r#"jSZUNHDpTpRwuQydjVmchehIKSlgP+bhcKE8bi+yZoc="#]],
+    );
+    tester.expect_border(
+        "ready_to_load",
+        expect![[r#"CkU7FUXUKUZneunabAn/h+88EDIxzvO1aqCl5LadYEs="#]],
+    );
+
+    tester.emulator().play_tape();
+    tester.emulate_for(Duration::from_millis(2000));
+
+    // Check tack tape is started loading
+    tester.expect_border(
+        "sync_pulses",
+        expect![[r#"lbeRI1CgCfyRmOpuVsyzKEzvnnJe/3CZJUj+BEaypwE="#]],
+    );
+
+    // Check that data block started loading
+    tester.emulate_for(Duration::from_millis(3000));
+    tester.expect_border(
+        "data_pulses",
+        expect![[r#"TCEfW/Ng3an2OgjmHdfYMvSdUVUt3RuEw+kYGgoEIh8="#]],
+    );
+
+    // Check that Loader has been loaded
+    tester.emulate_for(Duration::from_millis(100));
+    tester.expect_screen(
+        "block_1",
+        expect![[r#"FfpfX8Nl7RPODebDhyDPqEpNWieSSG7PYBXvg9ty7k0="#]],
+    );
+
+    // Switching to the next block is already tested by `no_fastload` test, therefore
+    // we can skip this for 128K test
+}
+
+#[test]
+fn tape_stop() {
+    let mut settings = presets::settings_48k_nosound();
+    settings.tape_fastload_enabled = false;
+    settings.autoload_enabled = false;
+
+    let mut tester = RustZXTester::new("tape_stop", settings);
+    tester.load_tap("simple_tape.tap.gz");
+    // Wait for ROM to load
+    tester.emulate_for(Duration::from_millis(2000));
+    // Emulate LOAD ""
+    tester.send_keystrokes(
+        &[
+            &[ZXKey::J],
+            &[ZXKey::SymShift, ZXKey::P],
+            &[ZXKey::SymShift, ZXKey::P],
+            &[ZXKey::Enter],
+        ],
+        Duration::from_millis(100),
+    );
+
+    tester.emulator().play_tape();
+    tester.emulate_for(Duration::from_millis(2000));
+
+    // Check tack tape is started loading
+    tester.expect_border(
+        "sync_pulses",
+        expect![[r#"Oc++rVrRSea7L5+dCz066kS/mPzhKZ8MhhvVo+8r5iY="#]],
+    );
+
+    // Check that stop actually stopped tape loading
+    tester.emulator().stop_tape();
+    tester.emulate_for(Duration::from_millis(100));
+    tester.expect_border(
+        "stopped",
+        expect![[r#"rIAW+jIqzRy5w0Xd+cqKAa6JVIgUhU5eTCvJ/kTmLuw="#]],
+    );
+}
+
+#[test]
+fn tape_rewind() {
+    let mut settings = presets::settings_48k_nosound();
+    settings.tape_fastload_enabled = false;
+    settings.autoload_enabled = false;
+
+    let mut tester = RustZXTester::new("tape_rewind", settings);
+    tester.load_tap("simple_tape.tap.gz");
+    // Play tape for some time while ROM loads
+    tester.emulator().play_tape();
+    tester.emulate_for(Duration::from_millis(4000));
+    // Emulate LOAD ""
+    tester.send_keystrokes(
+        &[
+            &[ZXKey::J],
+            &[ZXKey::SymShift, ZXKey::P],
+            &[ZXKey::SymShift, ZXKey::P],
+            &[ZXKey::Enter],
+        ],
+        Duration::from_millis(100),
+    );
+
+    tester.emulator().rewind_tape().unwrap();
+    tester.emulate_for(Duration::from_millis(8000));
+
+    // Check tack tape is started loading after rewind
+    tester.expect_screen(
+        "loaded",
+        expect![[r#"+o3MYnfBeDMtimIE/+6+o2/9h1OgtZ8izbO7b/jOiMc="#]],
+    );
+}
+
+#[test]
+fn fastload() {
+    let mut tester = RustZXTester::new("fastload", presets::settings_48k_nosound());
+    tester.load_tap("simple_tape.tap.gz");
+    tester.emulate_for(Duration::from_millis(45));
+    tester.expect_screen(
+        "running",
+        expect![[r#"zoRX/GvcS0zqOJj3V0cmoZe56CNK2nXiJeH8pF8u1eg="#]],
+    );
+    tester.emulate_for(Duration::from_millis(10));
+    tester.expect_screen(
+        "finished",
+        expect![[r#"zDQzdQr19uTYaZouk7ex+pkylk2TRFAuenooMVFjkyQ="#]],
+    );
+    tester.expect_border(
+        "finished",
+        expect![[r#"tmGY7e4h+XA3px6BcqnCXF83NEdBqVw8PW9sQtpMAvM="#]],
+    );
+}
+
+#[test]
+fn fastload_128k() {
+    let mut tester = RustZXTester::new("fastload_128k", presets::settings_128k_nosound());
+    tester.load_tap("simple_tape.tap.gz");
+    tester.emulate_for(Duration::from_millis(100));
+    tester.expect_screen(
+        "loaded",
+        expect![[r#"zDQzdQr19uTYaZouk7ex+pkylk2TRFAuenooMVFjkyQ="#]],
+    );
+    tester.expect_border(
+        "loaded",
+        expect![[r#"tmGY7e4h+XA3px6BcqnCXF83NEdBqVw8PW9sQtpMAvM="#]],
+    );
+}