From b0a4ad19645b2280429397973f6861000add9254 Mon Sep 17 00:00:00 2001 From: ITotalJustice <47043333+ITotalJustice@users.noreply.github.com> Date: Mon, 29 Aug 2022 09:50:27 +0100 Subject: [PATCH] web port (#82) * web port a few misc changes were made to the core and other frontends core: - audio callback now requires a buffer to be set for the samples to be written to. this is much more optimal than spamming the audio callback and pushing 2 samples at a time to the sdl_audio_stream. frontend: - added the layer viewer back to imgui frontend, there's some UB involved though, see the comments! - added a sdl2 base which sdl2 frontends will inherit from. not really sure if this was a good idea or not - sdl2 frontend implements my hacky audio catch-up thing (idk what to name it) where it will tick the core if not enough samples are generated. - zlib/minizip is now linked publically in the frontend_base. i don't like having to do this, but its due to a bug in emsdk with std::filesystem::directory_iterator() with -pthread linked. the wasm32-ld will have a meltdown ad fail to link. only work around is to enable lto and to use directory_iterator in the exe file. i haven't yet reported the bug. --- .gitignore | 2 + CMakeLists.txt | 32 + CMakePresets.json | 42 +- README.md | 13 + assets/buttons/a.png | Bin 0 -> 1057 bytes assets/buttons/b.png | Bin 0 -> 1053 bytes assets/buttons/dpad_down.png | Bin 0 -> 455 bytes assets/buttons/dpad_left.png | Bin 0 -> 525 bytes assets/buttons/dpad_right.png | Bin 0 -> 549 bytes assets/buttons/dpad_up.png | Bin 0 -> 452 bytes assets/buttons/fastForward.png | Bin 0 -> 15165 bytes assets/buttons/fullscreen.png | Bin 0 -> 443 bytes assets/buttons/l.png | Bin 0 -> 814 bytes assets/buttons/larger.png | Bin 0 -> 15118 bytes assets/buttons/license.txt | 14 + assets/buttons/musicOn.png | Bin 0 -> 15197 bytes assets/buttons/r.png | Bin 0 -> 1026 bytes assets/buttons/select.png | Bin 0 -> 735 bytes assets/buttons/setting_sandwich.png | Bin 0 -> 329 bytes assets/buttons/start.png | Bin 0 -> 774 bytes assets/menu/back.png | Bin 0 -> 432 bytes assets/menu/export.png | Bin 0 -> 669 bytes assets/menu/import.png | Bin 0 -> 633 bytes assets/menu/load.png | Bin 0 -> 377 bytes assets/menu/open.png | Bin 0 -> 439 bytes assets/menu/save.png | Bin 0 -> 394 bytes assets/menu/title.png | Bin 0 -> 771 bytes assets/web/README.md | 25 + assets/web/android-chrome-144x144.png | Bin 0 -> 4517 bytes assets/web/android-chrome-192x192.png | Bin 0 -> 5814 bytes assets/web/android-chrome-256x256.png | Bin 0 -> 7509 bytes assets/web/android-chrome-36x36.png | Bin 0 -> 1738 bytes assets/web/android-chrome-384x384.png | Bin 0 -> 11669 bytes assets/web/android-chrome-48x48.png | Bin 0 -> 2020 bytes assets/web/android-chrome-512x512.png | Bin 0 -> 13185 bytes assets/web/android-chrome-72x72.png | Bin 0 -> 2660 bytes assets/web/android-chrome-96x96.png | Bin 0 -> 3267 bytes assets/web/apple-touch-icon.png | Bin 0 -> 4877 bytes assets/web/browserconfig.xml | 10 + assets/web/favicon-16x16.png | Bin 0 -> 934 bytes assets/web/favicon-32x32.png | Bin 0 -> 1653 bytes assets/web/favicon.ico | Bin 0 -> 15086 bytes assets/web/mstile-144x144.png | Bin 0 -> 4496 bytes assets/web/mstile-150x150.png | Bin 0 -> 4190 bytes assets/web/mstile-310x310.png | Bin 0 -> 8282 bytes assets/web/safari-pinned-tab.svg | 579 +++++++++++ assets/web/site.webmanifest | 57 + src/CMakeLists.txt | 93 +- src/core/apu/apu.cpp | 28 +- src/core/backup/backup.hpp | 3 +- src/core/backup/eeprom.cpp | 3 +- src/core/backup/flash.cpp | 5 +- src/core/backup/sram.cpp | 3 +- src/core/fwd.hpp | 35 + src/core/gba.cpp | 26 +- src/core/gba.hpp | 18 +- src/frontend/CMakeLists.txt | 23 +- src/frontend/emscripten/CMakeLists.txt | 47 + src/frontend/emscripten/README.md | 11 + src/frontend/emscripten/emscripten.html | 324 ++++++ src/frontend/emscripten/main.cpp | 1213 ++++++++++++++++++++++ src/frontend/emscripten/netlify.toml | 5 + src/frontend/frontend_base.cpp | 385 ++++++- src/frontend/frontend_base.hpp | 11 +- src/frontend/imgui/CMakeLists.txt | 4 - src/frontend/imgui/backend/sdl2/main.cpp | 11 +- src/frontend/imgui/imgui_base.cpp | 39 +- src/frontend/imgui/imgui_base.hpp | 14 +- src/frontend/sdl2/CMakeLists.txt | 5 +- src/frontend/sdl2/main.cpp | 610 +---------- src/frontend/sdl2_base/CMakeLists.txt | 17 + src/frontend/sdl2_base/sdl2_base.cpp | 806 ++++++++++++++ src/frontend/sdl2_base/sdl2_base.hpp | 89 ++ 73 files changed, 3857 insertions(+), 745 deletions(-) create mode 100644 assets/buttons/a.png create mode 100644 assets/buttons/b.png create mode 100644 assets/buttons/dpad_down.png create mode 100644 assets/buttons/dpad_left.png create mode 100644 assets/buttons/dpad_right.png create mode 100644 assets/buttons/dpad_up.png create mode 100644 assets/buttons/fastForward.png create mode 100644 assets/buttons/fullscreen.png create mode 100644 assets/buttons/l.png create mode 100644 assets/buttons/larger.png create mode 100644 assets/buttons/license.txt create mode 100644 assets/buttons/musicOn.png create mode 100644 assets/buttons/r.png create mode 100644 assets/buttons/select.png create mode 100644 assets/buttons/setting_sandwich.png create mode 100644 assets/buttons/start.png create mode 100644 assets/menu/back.png create mode 100644 assets/menu/export.png create mode 100644 assets/menu/import.png create mode 100644 assets/menu/load.png create mode 100644 assets/menu/open.png create mode 100644 assets/menu/save.png create mode 100644 assets/menu/title.png create mode 100644 assets/web/README.md create mode 100644 assets/web/android-chrome-144x144.png create mode 100644 assets/web/android-chrome-192x192.png create mode 100644 assets/web/android-chrome-256x256.png create mode 100644 assets/web/android-chrome-36x36.png create mode 100644 assets/web/android-chrome-384x384.png create mode 100644 assets/web/android-chrome-48x48.png create mode 100644 assets/web/android-chrome-512x512.png create mode 100644 assets/web/android-chrome-72x72.png create mode 100644 assets/web/android-chrome-96x96.png create mode 100644 assets/web/apple-touch-icon.png create mode 100644 assets/web/browserconfig.xml create mode 100644 assets/web/favicon-16x16.png create mode 100644 assets/web/favicon-32x32.png create mode 100644 assets/web/favicon.ico create mode 100644 assets/web/mstile-144x144.png create mode 100644 assets/web/mstile-150x150.png create mode 100644 assets/web/mstile-310x310.png create mode 100644 assets/web/safari-pinned-tab.svg create mode 100644 assets/web/site.webmanifest create mode 100644 src/frontend/emscripten/CMakeLists.txt create mode 100644 src/frontend/emscripten/README.md create mode 100644 src/frontend/emscripten/emscripten.html create mode 100644 src/frontend/emscripten/main.cpp create mode 100644 src/frontend/emscripten/netlify.toml create mode 100644 src/frontend/sdl2_base/CMakeLists.txt create mode 100644 src/frontend/sdl2_base/sdl2_base.cpp create mode 100644 src/frontend/sdl2_base/sdl2_base.hpp diff --git a/.gitignore b/.gitignore index d8eeb4a..98ded3e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ imgui.ini CMakeUserPresets.json rtc.md n64-emu +deploy_netlify.yml +deploy_pages.yml diff --git a/CMakeLists.txt b/CMakeLists.txt index 02ccc52..12501d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,37 @@ cmake_minimum_required(VERSION 3.20.0) +if (VCPKG) + # checks the documented env variable for vcpkg + if (DEFINED ENV{VCPKG_ROOT} AND EXISTS $ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake) + message(STATUS "found VCPKG_ROOT, using system installed vcpkg at: $ENV{VCPKG_ROOT}") + set(vcpkg_toolchain_file $ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake) + + # this checks for github actions installed vcpkg + elseif (DEFINED ENV{VCPKG_INSTALLATION_ROOT} AND EXISTS $ENV{VCPKG_INSTALLATION_ROOT}/scripts/buildsystems/vcpkg.cmake) + message(STATUS "found VCPKG_INSTALLATION_ROOT, using system installed vcpkg at: $ENV{VCPKG_INSTALLATION_ROOT}") + set(vcpkg_toolchain_file $ENV{VCPKG_INSTALLATION_ROOT}/scripts/buildsystems/vcpkg.cmake) + + # fallback to fetching latest vcpkg from git + else() + message(STATUS "installing latest vcpkg from git root: $ENV{VCPKG_ROOT} insta: $ENV{VCPKG_INSTALLATION_ROOT}") + + include(FetchContent) + FetchContent_Declare(vcpkg GIT_REPOSITORY https://github.com/microsoft/vcpkg.git) + FetchContent_MakeAvailable(vcpkg) + + set(vcpkg_toolchain_file ${vcpkg_SOURCE_DIR}/scripts/buildsystems/vcpkg.cmake) + endif() + + # if this was already set, then we need to chainload the toolchain file + # vcpkg offers support for this, which basically does include(toolchain) + # need to make sure that we don't chainload our own toolchain file! + if (CMAKE_TOOLCHAIN_FILE AND NOT ${CMAKE_TOOLCHAIN_FILE} STREQUAL ${vcpkg_toolchain_file}) + set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE ${CMAKE_TOOLCHAIN_FILE}) + endif() + + set(CMAKE_TOOLCHAIN_FILE ${vcpkg_toolchain_file}) +endif() + project(notorious_beeg VERSION 0.0.3 DESCRIPTION "GBA emulator written in c++23" diff --git a/CMakePresets.json b/CMakePresets.json index f30694f..3b06828 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -29,23 +29,17 @@ { "name": "vcpkg", "displayName": "vcpkg", - "binaryDir": "${sourceDir}/build/${presetName}", - "toolchainFile": "$env{VCPKG_INSTALLATION_ROOT}/scripts/buildsystems/vcpkg.cmake", + "inherits": ["imgui"], "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "SINGLE_FILE": true, - "IMGUI": true + "VCPKG": true } }, { "name": "vcpkg-dev", "displayName": "vcpkg-dev", - "binaryDir": "${sourceDir}/build/${presetName}", - "toolchainFile": "$env{VCPKG_INSTALLATION_ROOT}/scripts/buildsystems/vcpkg.cmake", + "inherits": ["imgui-dev"], "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug", - "GBA_DEV": true, - "IMGUI": true + "VCPKG": true } }, { @@ -90,6 +84,26 @@ "GBA_DEV": true, "SDL2": true } + }, + { + "name": "emsdk", + "displayName": "emsdk", + "binaryDir": "${sourceDir}/build/${presetName}", + "toolchainFile": "$env{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "SINGLE_FILE": true + } + }, + { + "name": "emsdk-dev", + "displayName": "emsdk-dev", + "binaryDir": "${sourceDir}/build/${presetName}", + "toolchainFile": "$env{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "EMRUN": true + } } ], "buildPresets": [ @@ -124,6 +138,14 @@ { "name": "vcpkg-sdl2-dev", "configurePreset": "vcpkg-sdl2-dev" + }, + { + "name": "emsdk", + "configurePreset": "emsdk" + }, + { + "name": "emsdk-dev", + "configurePreset": "emsdk-dev" } ] } diff --git a/README.md b/README.md index 99de301..1879272 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,18 @@ v0.0.3-dev gba emulator witten in c++23. +[![Netlify Status](https://api.netlify.com/api/v1/badges/bfd72dd6-557d-41a3-87ac-480c686624a2/deploy-status)](https://app.netlify.com/sites/notorious-beeg/deploys) + +--- + +## web builds + +web builds are the easiest way to quickly test a game. builds are automatically built from master. please report any bugs you find, giving as much info as possible such as browser, os, game etc. + +gh-pages version doesn't support threads / mutexs. may crash. + +netlify version supports threads / mutexes, won't crash. + --- ## changelog @@ -198,3 +210,4 @@ gba emulator witten in c++23. - xproger for openlara (fixed several bugs in my emu) - zayd for info on rtc - pokeemerald for a being a good reference +- kenney for the onscreen control buttons diff --git a/assets/buttons/a.png b/assets/buttons/a.png new file mode 100644 index 0000000000000000000000000000000000000000..a1b6d68d6e8e84e723a7fed9d7df4750442ed571 GIT binary patch literal 1057 zcmV++1m63JP)2YXX%yp~nh-M2gA?hFi~yRN&x@6DU{X5O1G7DE(G(=PJ)d|arWN~La! zSHp?Ixw%{}58s7r#G)ebu<+oSOy-t&y%YfMs8G0;dCh!09={eUXz)M+oR&_fE2@Aw zPy>GR>{}a{S~i=#9U!>>)ckGztjyf(*9ufIS$$D>hQLi4ntvGJ$q)lGY4{<^j>3zB@f{)z1HuST zhUnW+CoFv)N}m`Nc)me|iF#Dv?G&Cl^5Z-OEwSX)X=qV}w^P?qov?dc-#s(Mhzg<_ zC&4qr(J9mC|q*wVPY7*FUzBd=Wws;=d#jrJE`g3iq})LJ{Df zBowv&?@EXrz|m+lCfN^CoBM7L2+6rIrMTa4ID9Ju`yfPq-)^^Gkc$&i0%&bIn8K`Xi^@#R*iVCxs}P*SjCi5Hunu(R3769X+m7B+|| z72M4D;n@#yAUT9u(UN-E&m+MB8kK&z1gOFyw4o_qg4r4=Ch8#=O0Lx%nM21ZfIK^J zd-5!LkdDUekM+>i)i-PEm;7)za82lglgZ?bSQG?WCIH?28;nz+n&%p3d3O!7#QSf5 zMi}*gqZ)9b1s0M%T00000NkvXXu0mjfg@Evm literal 0 HcmV?d00001 diff --git a/assets/buttons/b.png b/assets/buttons/b.png new file mode 100644 index 0000000000000000000000000000000000000000..18649f4660860b3bcaa2fdf5440090d2296b1941 GIT binary patch literal 1053 zcmV+&1mgRNP)SegT!#p5KoH^S zA-d-31ZB=cnG?eT&ozj!P!9{doxnSX-p}JO5(`d^h7?wKJFy+r3A)8~-7Kd!EDc43z?hv#CL0L1=WP)|vDxV!Q95O*VDKgD{Zp?+`G&^*z z!xSvw5o{pi!PA(K>* z%a26>c%6j8p8xCsgeJ^9h`shT0@!Z1U)AgNr>d$x@Avy}*X#9Wzt3nix)fqdT)gB1^o!HR;x7$phG`!GMRiJfCm7UN~K3TfzbvkYXq@UsXQToCao~Y zcDwzh?>C>%zn}xe-W+ZP;CMW~)c5<%8un&&-#F4B)9Li1zTalE`HAjGW5)u`0yOIP znKks7Aa*L#2u!Eb>8q;xtl4b77z_sQmdoWgL5+xvo{h8X#)z<#yUxz}F`&9wEWQd8 zP!%Dh&y2yw47k~B_NiDbJ`^gXPe~;XNhL~aI!tR)Vx7Y-E!x`QU~31dF^BEGuw{$j zn3IEBeZzdHhRfj_GA0hU_|hokaP*5#C~-KkM4QbmY%V`?TIrmi=_1c|Y9kQ5y{LP!R}1h2t>hC*38T8$K&x^kuWqEnE-V6HyBo+YJC;0yt{;1;{7kMZfT-J zaTJUI001p?MObuGZ)S9NVRB^vVtFoNY;SL5WO*)Qa(QrcZ!T$VVP|D7P) XZ)9b1s0M%T00000NkvXXu0mjftk2;g literal 0 HcmV?d00001 diff --git a/assets/buttons/dpad_down.png b/assets/buttons/dpad_down.png new file mode 100644 index 0000000000000000000000000000000000000000..0557ceec9a9a84d8d805e02d80ba588c9bc0dfee GIT binary patch literal 455 zcmeAS@N?(olHy`uVBq!ia0vp^wm|I7!3HD`OZJ;GFffLCx;TbZ+fHj~m z;icpX4~|CTfJzqYC~t4;{s)N`47{p)-8XCrInE+%*ur{q?%bR9CsQV$HPN3v_oZ6b z3?$`9~LhFW>RGn0xzP=$?|$Q1L@E8dip#ojH7n}H&D=s|wJ8sjY)SHGZ2A1~TsgEq~?bG{y z>SA@#r0<++cDD{H1w_RG!&$e)HKHUqKdq!Zu_%=xsZuW~CqF+WrBW}ssIs6WUoSH^ jF+Eie7*j?ju6fD%DXB#(*y`T_H8Oa*`njxgN@xNA0}sHS literal 0 HcmV?d00001 diff --git a/assets/buttons/dpad_left.png b/assets/buttons/dpad_left.png new file mode 100644 index 0000000000000000000000000000000000000000..3ab41503b3f3fc957dceadbc47b4bd76be89932b GIT binary patch literal 525 zcmV+o0`mQdP)x6_lC79+Fr3% zL)$x8a5Mz4plB#y!O)Pvf}o*+iK8KciK3x`iJ>8biJ+l_v7>1KV@1;f#)hT|j0H^_ zm^Ye6FfTN%V2jZ-gDpbS4n{^ZFd~{^5z!2ah-OGcG|q|Yx_)lk_Okf zBVuU>BV%a>TZE+*Y%!KbFfS}^VBT1oz*w-ffU#j|0As~M2V=)V1{1+T1rx(U1QW$V z0~5zW0tnJ001p? zMObuGZ)S9NVRB^vVtFoNY;SL5WO*)Qa(QrcZ!T$VVP|D7P)Z)9b1s0M%T P00000NkvXXu0mjf*0$LN literal 0 HcmV?d00001 diff --git a/assets/buttons/dpad_right.png b/assets/buttons/dpad_right.png new file mode 100644 index 0000000000000000000000000000000000000000..c717a8dcbc939499c07c0ce4984ac0367cfa249d GIT binary patch literal 549 zcmV+=0^0qFP)002D*1^@s6(3B4)k5B~-4wi)$4wmf^_9Q_;;en9`2lohrTMQyiQ@h&nom2ns zB3-cclVdyfos(pv=W%}Ox<21=98F|dc9@&GOBBj3%Cdaf1S~eFC5obWRw##1sC&ag zfC_?z0>zGn1jUMl2E~Sj2*rYh3Pp~E3`L5C4n>9~0g41m3Y0gNBq%Q|X;7=NBtosi zk_zRFB^k;IOFEP_mO@!#DU>CaLRn%dlqFW|P<`KjbY1t}G|ek37DA7jrs>QChfhF^6k2Y&v!dk$*dsd$3PZF@kar`0~vyi~9LA7oB zMm8IwfN?-wTxTJGaX?*N!N(590rl^{Va#9#i5lwWt^?XE$-bYZV2(Sb3g);6uV9X+ zJJP|N&kZGmtvZpG3bs1eK_Zw}7L7D8@BA!DU?iD;QozV^N+p1iW-FtEk>|l9gRxXt zpn|d0?jVA(R)wK~vDX_Sfd#2#MF9&|BaHwSwE8|~u&Sy~!S8j570k4IlN&=~1KaGz zkO;w!X_`KmHva&5k=#ATB4}a&001p?MObuGZ)S9NVRB^vVtFoNY;SL5WO*)Qa(Qrc nZ!T$VVP|D7P)Z)9b1s0M%T00000NkvXXu0mjf${OgN literal 0 HcmV?d00001 diff --git a/assets/buttons/dpad_up.png b/assets/buttons/dpad_up.png new file mode 100644 index 0000000000000000000000000000000000000000..a2b194ede9056d6d1af9d5daaf42d9c1658e1463 GIT binary patch literal 452 zcmeAS@N?(olHy`uVBq!ia0vp^wm|H|!3HGn$?WfCU|JB!TyS9Kg%T?l z%wG00ifd4KzuHOvTvVNlju@mP_g#a;=Y&dpB>Zv5Z;HlzK1!`8#{Oio^_`54>^oi^(RZ>symCA`ta37Ec+;7S58gbu@L|${g%2Jb z*!M8$;Jycs4z7E6u_5;0VrKbPckX-K{c?Lm*RkypTgSJDC7ipCC7gd>n>zEh$-ak6 zzvj2vTliML`iUdT1k0gQ7S`HrCw4_ett?yrCxGTWkE^4US@7$da52U env6_b^OEyZQj1ow)xQI3WbkzLb6Mw<&;$U09KP-V literal 0 HcmV?d00001 diff --git a/assets/buttons/fastForward.png b/assets/buttons/fastForward.png new file mode 100644 index 0000000000000000000000000000000000000000..91f4b4948f6e001b5dc2d646457c22645f214bb0 GIT binary patch literal 15165 zcmeI3du$X%9LKkjVp}M|R0R=qx%wh-w~yYn?B?!Bx$AL74w_O!@qyj$PTRG2yXWq< zcP%kAJ|dV9p&> zq4e1G5B`OI(T_MhF=wQ*fz!;%Jyq8dA+;cocN+kbU);q{(1JE!1ReKNY$q^NmK z_FoP4+QG#XCG1msdad5r22s`%9!b&rfhV0vLN!JC*QAq@JPa(l9}KEm!1c$6AG>H( z3AncSVr(oK0z+za+yI-$H}=Tm!?K{b)&v{;X%QMEfF;rCL|ik)bikGI6=7^QGcGzK zv4#V#c6&g&H`YambOX>nkIyZ$JV&<)9?s_#IBpfqvs??qu?#P`IZpJpiCin4{kVb+ zF!CG9fY=?5WQT+Q1zbaxl@u8!l}dS1Egsz%WH><(7?x*v-VGIQb4;_Ov|BTq9h00- z7?`r5CM{LhXxmrn*GH^?%ViIg|77QtNahF9%q%-dkx5HQhV!sYm84kg#%YN}#Z1%c z28Eeb9MJ3;O9G}FnEHqz11N8|E^KGp2~tgU`OWvPYxf#!avTd6wJ5Y8D%EjZ^f6sB5I(h&V7Lxm^06h~OuEBJpf zSFk{C0=mIrqN)kE&KNSjJ#YJ5-3*>CZq0SX)mbh~etF<&CQ7n>Vh2qLF0P-+oN&3e z!c93D_CSuH!kW&me9O|SkC7SzTG4hd*{Y6JMpfO!)Ca7TWPtWTxFax!kYiY!QQcV{ zlC(jf1eqMPTtjJ!pRrdht`;wv(^|Zix%w+#d%q%!1G1D`hFC!30P zm!@vLaVpwfnp((wL{qIGpB=5F8OgGLpd1*R;X@e+w)%W6K0j01T$<6Pra`|skD|o=6tzg7 zeDc!>Mb$KShTD76-(EgxbrDd(JGKj0&;kyY`=%KRGSL=p*e5mQ601>018EOwaSTp8DY6OK-lg zZ`B*Fr|83{kJQamtVe_VZFf%Besb~H`IYMe$M5d{N4~?E-m3>br`#7#T|1!f_|AU8 OwXiP3deBkby*WFm1L z{4b6(Lo!Nfi=t?fW!X$y0Z-F(rC--Rd8g^B4_cvF3Gqwc1i$hmNfuWKeK8MBynvW1 zAJfGptyHMudnW(^2tWV=5Wp$mY_NkiQ!LI+@uLDXO|!R&H$jXB(6;TVs;Zq$d|lU# zm%o4w1kSxpp&la7^Ub3;ypMCQ0gxW=rq7@Nht6EYKZB6>8U#R=N>m;f@%Iq2a=j|S zUu$G#S#F1nV<#p60SFK{fUNnLZI0S(2RT!mYcKhz6XVNdM|GJ-@i{pY$$JGcjhY5J zPNUzRM*G>xO90zHAs;FK{(**_m;eMI00DjrAjCC;-l0AGRRb(cgt%@1&9{w}IF6^? zDoZE;1vEqZ1sAiTWv9CE#{d8TEp$a#bW?9;ba!ELWdLG%E@EtNZ)9Y7E@N_eaCC1j lX>DO=WiC)oM=~@;Zewp`Wpbznf9?PP002ovPDHLkV1iyrv|9iG literal 0 HcmV?d00001 diff --git a/assets/buttons/l.png b/assets/buttons/l.png new file mode 100644 index 0000000000000000000000000000000000000000..54ae3caf5793373aab76c09b17a47203f82c3e1f GIT binary patch literal 814 zcmV+}1JV46P)q(2jbCV$BZ34cFfo*$VGaW06kc18gh^Zar_V^QqpgL$dcOD zS5c%$ipes;t0;;^S(bSt)q1^tZ$9fWLf_r*_a*%;-6KooqQTO@x7+PU^LcCloH>N< zW$sz#dHxa=G#XF?uGnlgpIpF3NKMZSezuXS?RL8_kKq1^d3F_)nwxd4mV(wN^v_fc zl2lcC#*LYgD%Z*7a{0<>6kH3vE4{M{c#Uy5gaYmX$Wl~gJzG{MLSyi*(lq^JfOiYg zn@ZDzG#d&V1LGVbxdAc4yM^e?)rn=z6Il~ef#(b&ZPZhN*9na|c|VU~CLU~!Mv*GK zPG(1SVz;=?EwjdmilEe!;F)kaz|Gk=llg|~K33&Xu8m3V09sE_c1$!Gu%27x#WTZE z0n(sUSo>V8g>afJo$Hi>1suT!7!OWk0>|0Xu}(2u#TxF?@It54>HGKY(hl*OBm%&B z(k7v_tbZW_Y}Y|8d*2WNwtHhOala4&wsArLvt!bIoL}HzxN(-eO7Ns3fV}^2HD%k>@anf7roARL=mcutNCYB|>n1u{Szi>i{ z;lvV(%_kI>KRK;*1hkvT^PJuYfOi*lnQ`C{sytP7aca}zlqUKmIO>}aYp)ouJ!DdW z=(GV(Y%QNeT0q6RCVFi`78^v74sIrX7`%%^$sy5>mfG8X4g?2uboxaLP{t;-(X=m- zoQ)b2^%M+MH|qxW(76VX!GY_^vs69}jaiSY==$oLp85wD28ZrRYw&8dddpI{Xl4@7 z?q@KjKsDd3O?&r3vc>x!kDOo4oQp7+0000jbVXQnQ*UN;cVTj60AhJAVr*}3WMp|R sV{&KQKGBibQV{c?-a;OG>?f?J)07*qoM6N<$g8wdRJOBUy literal 0 HcmV?d00001 diff --git a/assets/buttons/larger.png b/assets/buttons/larger.png new file mode 100644 index 0000000000000000000000000000000000000000..4c9c07360d175c6911c9e55a54ca33e8e12f24ae GIT binary patch literal 15118 zcmeI3eTWog9LJx_GQIQ4OUM+Abxkm8cV71GcA8z?+r9OUTD|VhJQLKpndkNfcW1Vl zS$FSBGBvZT{)m#u5TXz&l8!9Gh>8k|h)59fg^aMW7m6e_Ex4Z9*Js~+F7VH1U~hl- zJm25dyAX?Erwz{=V2C`sVG=oZ0B_tttqMIVLkd^&^F0EjE|8A;v>EypNKs#?hT$9o?*9jX#? zuJGJMd*ae_A>aDGSWa)#Sc zBpS+?Fc^!MhC}~_oa2_25f~RCgENWQGuEtppCHZ-LYq8S+VZiAZD)Jl;nKAEhMDu7mG2Bu&d zrI?5U*K{tKaoy6*alHk=N_R_)mO9pgh27IK4d&GrX?9OBtx!|Nr4~^k3sD0~mTnB| zdMaEj*MaigJEBp?iuJ0d=sELtk*QZ#Yo=-hW0D2KsHVNBnq4eE%yWW=6IlNpEc(l0 zCPihPM?zQBv55u`USI=)FNk|!%6bp92NlVZX5@lvYkd+4p{eRN)K7B8Qmf7t4ArUDl{7-X)=<%jE~PM5_6q(V z%uOt?n?P=8m_F4+TW10r-=4R9t`5NmYFo=4DRqVmQ&}E#nhBC@pV(njLW?Uhg%hsa zR=ufWhCNVG&}2<#R=y4Ct;fiXL#<}J*KO6JRYBF-#MH;EoMgaA672}WAyhQ1&1mf` zk4st-Dq*G!t=v$bA~N=>rPSI*E4J3IWx4(u*Ira)VN5sDk`-3dQW7#5Eh#e9#_E`2 zM7kohZzieM5b9YmSvy)c4;F@QlTC=k!fhF*N@m6Kb0-$13Zf^^t6GlQYzkE zpSt17sd#sNYBh76rdnaXG+JFVmSz7y6=4jahcX!U`+Ods$kaF2XAG!$m>P+x=qxqu z9pXdtD&*96G)>&lDiP}_B^FBodweQXO0g9^oT2AJrt)A|TSS#@d6k3ejIOdVZbt{f zX%J(Ii--spP+T|-VoY%n5#a)g3#UPhDJ~)+TtIQ*G>9?9MMQ)PC@!1^F{ZePh;RYL zh0`F$6c-T@E}*z@8pN35A|k>C6cL`1lN;=*YVV~UH22p3RXI1OS^^!Ly@ctI`vwyLOfCmt$1VUaO{4Fh0m!ld{IUfA;duZS>d$Qc zG>)`?^~WN^`IEn$IMx0!J7@M5&*g&`zC3;4=z-2Sv-rlZvp*~}jt^h@aEJQXYsZ#+ z9|_D|1=_BizY2W*$&cUsx}+_h3?I4K@h~`XX1h4=r(HKZc+twPd)s~Yo6F85r?`vi z!Q=CpJxeA|Exm8vUFQDCZA1CBoV5M=wFftzpL+S*WB+`Xdg7@kSN!{W*P|VK;!m%7C$^FA3U!D5MP?Y^((-`|1mxb5uaKd%8`dU_T( Ylzjg?@2gX1(ELGv@0!@*o-I571IkZyzW@LL literal 0 HcmV?d00001 diff --git a/assets/buttons/license.txt b/assets/buttons/license.txt new file mode 100644 index 0000000..3390a9e --- /dev/null +++ b/assets/buttons/license.txt @@ -0,0 +1,14 @@ + +############################################################################### + + Onscreen Controls by Kenney Vleugels (www.kenney.nl) + + ------------------------------ + + License (CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You may use these graphics in personal and commercial projects. + Credit (Kenney or www.kenney.nl) would be nice but is not mandatory. + +############################################################################### \ No newline at end of file diff --git a/assets/buttons/musicOn.png b/assets/buttons/musicOn.png new file mode 100644 index 0000000000000000000000000000000000000000..fc90e71dbeff342d95fd21543b325ec22dbf8b0f GIT binary patch literal 15197 zcmeI3e{2(F7{?Eo1}2+i#vnwfLSa z<`Q5e0*1t31YrT;4-7Fu3>r~H!w?kF1<@cHNF-_&kcb40hz8L2`tx1a^3H@m-%Gka zd*AQ#eeeCe&-?cNxs8p>o+vAwT}n|@Swp?A34ZhDXX?H1@7%>34#2NzvHEtMqV9XZ z{7j;Tc05E;!mCP4o6#0{Qk2z*O$w?Vz?O)_pqiqbixV+P?gj?k0Xmha%X0Pb5euyZ zU6xiyfDOdFpi8Om*FbasvKG0&TNZ+r#qLsPLWBkpU`TW#5{~L(!evSNiZC{t84I11 z7~L+5#~hGu3pCPRRRgrc=CH~v&(Sr4jdR!qj$26cELX*FEW-;{juY)QB3DhPFN?br zMoujl5}SPf^lB?3m8dT%@d?%By&ecrL2>2uexM5A(VKrM>zJ=$+Do$jp7qIXz zii?6*%l6s~}Z7tU|~xEn-7lC{$%nPI7GeIBTCO_n0-5whlsT-pl*z z>T20)-s|VMI=f)^avneDuU+K#+BvT$V~eDYw*{Kg3xa-4jU=~sOwx2;lL{~#bT+)E26YdDi zA(S#4n^D|Z?vkRNAn0Z?&@v7ADNe>*wXiaF(Ne8r*D_OocO z7fJ&g6I@tCsDR)?X<%c53yTO95L_q?Y)o)r5upNt3#EaL2`(%mR6uZ{G_Wzjg++u4 z2riTcHYT{Rh)@B+h0?&r1Q!+&Dj>K}8rYcN!XiQi1Q$vJ8xveuM5ut^LTO-Qf(wfX z6%bq~4QxzsVG*GMf(xaAjR`I+B2++dp){~D!G%SH3J5Ng1~w+Ru!v9r!G+Sm#sn7@ z5h@_KP#W0yj<`y5uUdjAe7`adU!)YA{Uh)tOj@pQ3Q$!4qZGAf9Yy{9FZ}+6qIy`0 zy1a^_#BCHcQ+;LSr+$i>m-rSyMjw!968<`<_Y~>=+q&@zUy(?{C^N^Ze?8dbaB9?y?1&my}p$textkC!eI= znKFC#J>gpW@E`MEUi!$j{ohV*o%Z{{lzp2)5w_W6x!-V$7J}+yW za`nx#Lsw?e<-@Y}!pKMV@+y4fN{P^C=w_DdYy>=$_^r_|<$A7+h;?Z@-f0z>= ze)j8~{hzFIZJK^VI(2%h_SUWsZXN3z60Qq1gSQ7}Y)DRcQ0UYT;sAP9>exQ()vq+GjL2wSGSMu=8N4uph|Qo>cHOP4>uDwRainN(3i z(9S~$5(s#3H!rNgEUS*Yv-{r8zJZ5@bNlhTnR##Cd-KfY5SwY57nWriLhNF(_*8tl zG6H?8)v5)53)jeHrNPU>i7>}a_QkOlve6vTxXzNZ zK!vk`m4`}Y!iIB1!#a!ZDy8l&%m=C0>(}e``q%x5Hk-}9_xt@JC5ZomMA!iD&uooG zqfY_g-yjtB`WFTuG{-a`=XoNyHRi9)${ye(?z< z1t*p`+5C*;@*}5}E*aXM=Q&Jm1cvAHx`a4z$W!^;smrG}Bb?IYdI>SrP0+PgOrkYp zvH~&I0zA5vd_pM!rR$oQXc02qAfi-o^ZbXYH*p|1gj&&(dfCqt&H);gez^pw!XmWV zlrO;?jT8g*;0z@<>Q3yT4GJJr2ktzroI!0!hvhT8eAw8p5(I7 zU}OT&-Opg01Jdga|s%o6Vp#Mw#sg<;O<0000jbVXQnQ*UN;cVTj60AhJAVr*}3 wWMp|RV{&KQKGBibQV{c?-a;OG>?f?J)07*qoM6N<$g15})OaK4? literal 0 HcmV?d00001 diff --git a/assets/buttons/select.png b/assets/buttons/select.png new file mode 100644 index 0000000000000000000000000000000000000000..4969cdd335d7551bfe0790ae6109979d3cd51231 GIT binary patch literal 735 zcmV<50wDc~P)VR`kyMKJY=kdwBJp&-uRZy~8U>EF2EUBag?k$0A0! z-R>vs^(ojMGY3;2DwLO-d+)wSX`P0}uuv z48j0}0SJRI0AT>aAPhhlfG`MKA6~Ebna}5YZl+=cgFz(_2)u+a4efUO$3z!{!C=VG z_xt_pi6Ba)(g(^J4u`k&`4~*Xv)6E_yT? zsoN5^D9_fyNDN&hNzooJm&>2@!q~s=M%2zK!ivRWRg`nZg6Gw0wJ-d5I2=B>U?FO= zP$*Pw(IxT=9*@WOqMS_%<2k)v?|hZtZyRBaM&rA-JGo>L3WXFSVe`7O|LGD>S~uOOn--SK<=%@XArQ92Y*A5^xB4zIo6Q!q{A-tJ)J`Ii zIN~|+c>Fblt$5eATCMiB#;~6(Aj|+Z8~m6`rSe8aVB_1jZ3)xQ5{YKB*|NPbR_NcN zu_cqq94{Kf`12YUW)%E}nD^{NHxA{KItkwev0}uvb0Kx!-K^TBA0AUaYAPhj*t_s7p zMb?2_Ve29bG2Oa|Hk`utMdpVb{oT>c>2yBc`?J*>iepY<DO=WiC)oM=~@;Zewp`Wpbzn Rf9?PP002ovPDHLkV1nKySyli5 literal 0 HcmV?d00001 diff --git a/assets/buttons/setting_sandwich.png b/assets/buttons/setting_sandwich.png new file mode 100644 index 0000000000000000000000000000000000000000..7bdadd48e60e49d4f66b1630ab0e84b8bc1cf495 GIT binary patch literal 329 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUt>z*!-Ar-gY&NAdWWFXRJT*Y$y zOr&$ibdE0^vOsXxCh~*CGd0uoTQ2!>yM204{UKv|+xx|y|DH+)pN^{TFr1gwdr7e+ zyY^nytJchC!eNPK(m%ZO56r(+T|ZCt)#(Mib;^b_+nN$EF7DS}hjvpIzX`GlA_*|4%Ebv7cZpYRDD=5;6)v_PRh8 zpa26Si+}?|qaKS%pX3_dH^1C@QxcpW?`SmGuO2q1_;~6R*X{!vXAb>i@_r?f)Va$V zQ*NBqf{Irtt#G+J&q)NS{oc#QhluEtiqRN7je7(%v#Pn1>U|1QMxaKA2r=%9G TV5@%z)X3oJ>gTe~DWM4frn`Ww literal 0 HcmV?d00001 diff --git a/assets/buttons/start.png b/assets/buttons/start.png new file mode 100644 index 0000000000000000000000000000000000000000..c60040457700a5e25c1482a940fe952786014d65 GIT binary patch literal 774 zcmV+h1Nr=kP)up<`^cJv(%0kzMdqf&_Yy0#k?rkxasn zV3$%`4N}*x3Z)s_j5RSm-t3!w5c%-?gAdBg_#S>g&U^DbjG~aT*=*NbF4qpJJ&M!m zyh&bff&a&E2WubH4Rxe&vTh)x&Bozy#Mjcun_UEA8ELx;E`Qd#OezS%T0j_t0SE&S z24Mig0E9spfG_}I5C$L&Kp2GmJKS#fzSrx$4JjKgtkr70xzN;XHk;GFTCMi-!k|1J z&pv;=Mx)VWeW6fD)t+xUou2C*hn|a#F>~In5LT&Fp6i9NSS*%igX8-?l9ClhtKaYU zAnX#T82aTWlgSspy-+AT)sD?%GLKkTqT}UqxsoiX zwOai!8Y>gra=H9Y`>WOJ3pTb~F4v5N1p_geDrRT)RzEniaGK3#OE$Q8Jbq}jsuNuj$$f8inGgv3do}nZ zkw_fs2_yHQhHP-%ZubN08;wR|9@d^tr;qi71%tu6+GRb2380~gW<)ZXJdzELo0YFc zeS^W^Otk1YCB?@1e7-vnCV)JDP4r|Hi^XTMzw}fpb;R2Je*b~?bJ`W4o-khQcs%|L zVG=~6(fjzX5dYRg7=SPUVGsr&3_uu!0SE&S24Mig0EBI)Fl<|79jGg8U1TBFTNlv{ zr?7p|=0hF*+0o5zw_o4+wbdKK*-ZARBkJlW>zOpR3VB!20000jbVXQnQ*UN;cVTj6 z0AhJAVr*}3WMp|RV{&KQKGBibQV{c?-a;OG>?f?J)07*qoM6N<$ Ef)O!d7XSbN literal 0 HcmV?d00001 diff --git a/assets/menu/back.png b/assets/menu/back.png new file mode 100644 index 0000000000000000000000000000000000000000..74ba7d126927a04c770379e21376bc68bb70ea9a GIT binary patch literal 432 zcmeAS@N?(olHy`uVBq!ia0vp^nLw<}#0(@~>7Q8)q&Ne7LR`5yVxK&D^8f$;nV)qu zfr5-l-tI0;Y1}m_Kn{C}r>`sfGbUzM6V3&V>YPBK2c9mDAsXlRUOFhqtjNI{U~!@G zTQX+~+pm1pnmeJ_eDY>iT)4PhYlpz!y`6T?OSRVBU;DeG@%Xl*PI(gBJPkjU<~D!L zvTcibVBvUk<&1>(*x9A|(bqEM7Szs3i#UF7`o&Bs*~eREz1w8?`NqUMg+@Hb<*xCb zKeYX-@m3#akW|@+@eL9C5J}$qRJ84_i@>Ij0007FNkl!&Ey>9wG20m{RzH1{;_&$N`t`8>cCEl+ zLE1H+SRk&7I5p8?= zsh$)8H9FHflgX;g*6{KJ1*b8q$Z0{r3==o+ZE9kEsWW%a)t>2OJz0F?7 zR3?qYv0(L=A2~JxjUtj2M-8Mtpe&!S0+JPhy%=6LFqtxf#FREKKu~!@P|@^&f(xCO z1_wpRzVs<)l}wr;eub#5@D=vTzq|7$H!muYGuCqkz9pE~KhZsy$lpCAulde`q?9BC z0X@GsB*j^DS)~-Ap5|pIGgT_tbXSm&@Tg&rf`A>{y&&>H+!Z8d>SFgBYYfhaxgh;1 z>g_F~@mwjl6_Bh_nXzkSZ-u>*W!5WQOnHhp5;=nw(Y8b@0TV5)sIPC&%OORU)`;Md zGt6k5wL1#f4D_=?8bxYEn8=w0=|=%(x%8G&CE7BOm~2<>ay8|S0o0G>eZNwKVLwl8 z<{9|vXPvz#vCesm=`BN2m+lr|Sx;giS$#=4JNov36#->n^e&^U=vNUX5KE^!j0006$NklE-wlFX-WRxm?oPT|wF>15zLroPFRlt7f}v?W`c|3jtX# zNJydJct)ZZq#y*eydWWk4lsT?NaKwWgDA8n2rq@3LdRux942G6eLEauQ~q`Qte4Zl4Mqrb<{_9CF_m*%pt~m+$W^EOHtO+vH4w<7Q8)q&Ne7LR`5yVxK&D^8f$;nV)qu zfr5-l-tI0;Y1}m_Kn{C}r>`sfGbUzM6V3&V>YPBKm7Xq+AsXkeUOFgvM1g}fz~X}A zi{nBT4*%Ec#0v|XzN?(xELq9NEVbx9*X(bv-m9tV=9uX&mzCPw8t)e9_P8T2X>sR5 zjZN$u`P; zrI~4J_AUwgG;y7>`;yy_pE56=abS`3;?Fr7qU#rnNF=|xd?jynsHy!`rFZ{R`FNMt zckv%EQh#|*D)JxD(W)h`5hW>!C8<`)MX5lF!N|bKNY?;}3`2~Jt&EMVj7_x-46F7Q8)q&Ne7LR?w-f*wA6`2YX^b59dg zfP#!k-tI0;Y1}m_Kn{C}r>`sfGbUzM6V3&V>YPBK=bkQ(AsXk;UJB$ptiZvRQ1O86 zsogs^or%BWQ|%M3n!bHJX>0GxKMb-{U*zk5D>K%9H$&W9N%+0C#CnA@r@7ZJt6R3Y z{ozBuuG#nevQj$^N!VNEWwz98o9K4yl*q9S(^8X!#r!4H+b(VC(mKuLKFwqG_6^Q@ z%Vy5*{+?Q7Xx8~KDQDxP_Qdn2i%k0VnVf99Cwa@WOE+I^?dv;QdSMaMHXqM4`S;!6 zwvPMkX^H;JviD~F&*P2Hlf0ewgDWdFF#b%M{X~^}ZJASU_B>j^GubarT3&3ggjo9X z(`oFVK7{7Q8)q&Ne7LR`5yVxK&D^8f$;nV)qu zfr5-l-tI0;Y1}m_Kn{C}r>`sfGbUzM6V3&V>YPBKU7jwEAsXke1|1YUY{1dXEi%~#^~->P{}6Be#zN$*%H z7xeV@$rr{;&OX@n@K3gG`ZxW@j8PiOwW0nJLO@@rmbgZgq$HN4S|t~y0x1R~10y3{ z10XUCF*3F?HnK7{)iyA&GB9{5!Uys;LPKtTN@iLmZVgjBw`~P#VDNPHb6Mw<&;$VC CR*~2M literal 0 HcmV?d00001 diff --git a/assets/menu/title.png b/assets/menu/title.png new file mode 100644 index 0000000000000000000000000000000000000000..f03628eff83e6433909e311ed2ddab3ef8b20f19 GIT binary patch literal 771 zcmV+e1N{7nP)j0008XNklMJ7-nFAykF;Udj#Iwdw3jt9g-f91W3~N z`~4Y>L+

riUA`B#wic_n=+|h27 zxI|JRg_5%-4@pv(t}8C{xMF)Jl00&}n61?HR@EU1dSXeW9)LY?GkuEQ#lb->Nu^@E zZ+57p45%t5s?DuP>KRZ*q*Wzf1+$f8(HpAW+ezvnrUqj*Nh*-NDnNWLx9ck?)DoNgZt^2)FGeQ#e;MVy5?X*w&%2bin z@XK)}lQUzkCxt{(B-1qHcq6n0-|N*x;B`J!Nh-*m}XO_1_P2@4_G3(m1K$n)poZgiGryEc_K+_@;sI#ZwRUT3uQS?hcsPM5_yuC z@oQR7QW|dy5YJh=dyv$5U}wOHV(|P?>|2@aA$Ce75lL#0_B(Iy@B;NFE$TN>QYeFq zYQJZqy)YOeNs$W`0B>Y%^_}u<^b?c^2qlcNwhttp://www.example.com, you should be able to access a file named http://www.example.com/favicon.ico. + +Insert the following code in the `head` section of your pages: + + + + + + + + + + + + + +*Optional* - Check your favicon with the [favicon checker](https://realfavicongenerator.net/favicon_checker) \ No newline at end of file diff --git a/assets/web/android-chrome-144x144.png b/assets/web/android-chrome-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..1a20b6f8fb6161a22ef99e18b6cbd8b5c37fea1b GIT binary patch literal 4517 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 literal 0 HcmV?d00001 diff --git a/assets/web/android-chrome-192x192.png b/assets/web/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f732116850026745133fd896954672021c797323 GIT binary patch literal 5814 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 V!RG)ik#;=}2k+FI%+06=ab1mHt&yGI`_ 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 literal 0 HcmV?d00001 diff --git a/assets/web/android-chrome-36x36.png b/assets/web/android-chrome-36x36.png new file mode 100644 index 0000000000000000000000000000000000000000..e91d043e0c888864d221d9410427013898a8e076 GIT binary patch literal 1738 zcmV;*1~vJKP)Px#32;bRa{vGf6951U69E94oEQKA0{~D=R7Ff_aS;&> z5D*MRPkRy)4ns|OL{EG~Pkcd3b|WV^5)us^AS*#jbv#9BDK0=585=h~UqnuO93CV? zO?W~~cRxvRLrr)@PJ1>!UqnuNJw|LEASXmmdOk>RL{54Y6%<5Idq7HZEHXwM9U?Y8 zTs}x{KS^;vNpThy7C}pOA|)^-C^kY(cqlAAH9T2EPI)>)Whg8>C@nq}6%##2Y(GhG zBq%j9IaE7DXhctYE;LIcCNdWo7(z^TDlb736A&96AtWa>CMr2ZPkS#lOh8IO?N|0ct1#QIYMPJI8rh>R5UwRMNoZ4QGXd48%0olMNxhj85;ip ziyj&vxg!5S8$qcc;gb$px+4EVPJ2X8eM3%s{{V_UNNz+=d^bN`yCVN09VU7lk})_^ zIYVS193vkgC_qYbKS^&LA0^Vx$4*#+`2&^y0gm+roaqa)!yWL)8|%9v{=OjjBp)d= zI#e_}S35*!Fg8s>O?w|3AtM|lM^b*y%f&w$K2~0WprEQ68XZGTcfP#3{s4@?9`x)9 zs+J8~k`7uiI8g2gr}GA(%Npd@6V2rew!0zy-w(gi7TC`h+T;zn+Y-x<5Mb35(A*Kn z$Q$M$93+|zT+JBWy&(HGJz7XzdNw{=uC1>)KwnB!fKr8O8yg=vLSlG)k5XHLA|);+ zAuJ{-H*y0+7oY;L{b;A|fnR6+M{^ zT-_1F-4Mb&Mr!N|t(~5yM_7D%9g=Guf~p|nEE_PVrmA6SiyR&zs~_Eq5@$tHeOaJg zhZJo-8$iJx@jy&@mk(mE9oI}&d~b7zN>qGDQF($Eaxpeeij0{hB{57GNM&n-))m)* z7=cZ3bF>@Im6o1paFB}>c99WlyBNhQFG6&Ajzm;-Zgr8FoT`$QqllN z`}RHo5(M(wlR*jRV323&_u0ey4s^*C!Y$P%rclP^ARH?^Zh@VSv(ZYHog(4ZJi6NQ zk%dR|f*(kAh~%5>El(11msrDg2I0007#NklmxWWEDlk*)f2)h@z~N09d8Eu$+v7AX`vSSTHgO3k?cl6I76q6IN$n;MNI(fRyB{ z)WtQ`nf?8l)ip~~v+`0dEI9^}&=Fv8bB3z9n=?PV0SF=^fkbxp{G93~7gvS>Wqbk{ z+}XiuLUZ@+?~8?izS*_rXG=5^xO{3=DqN91h${To_G?uLDhNMY8wQCnQ3eB3 z=Ah89AAdUP^4r6K03;0p?K%A^AQhp^CPoaLW>ylatceMsLCH(&zHL~Slb@fnZo})l zsd+#JL1L;ZO49tmgvqF(Y^TS|!wgJxK`9-%$$7aQDWSkr$IN44ZYZrR&jm@voJ{uo zS}qd2qC8@(l5A{}tYSQ(yedl8Lj1;zoX9zWn@ht%$WdFD4+wOfw6%n66Cgx@G{a;ABePT>%h=S&#LUDT g#0SfONT5nC0O}VJbn-$ql>h($07*qoM6N<$f&uW#ga7~l literal 0 HcmV?d00001 diff --git a/assets/web/android-chrome-384x384.png b/assets/web/android-chrome-384x384.png new file mode 100644 index 0000000000000000000000000000000000000000..760478807061d453274b341da835ec20175e4c21 GIT binary patch literal 11669 zcmcJ#g;!MH7dL!|p}QFvV(63>knZjh=|&U<>7JoPqy-590cntsQbHKITPf)V>5!gz z`98n3-hbd(&t3bRd-lGcyYIex-xF)!6Qi%IMubm?4*&p>hPtvL0Dutx6>I+05G7hZLFg0 z`i7Q{o({_NP(P5Uv?U#s;h_Pcg+35H0}BHaI~_d}lpgj#{|CwVKjee_q45CtAN&8; z0*|#Nm0bSE!dQ6ZH9Q|nn?JDPa&`~3jOr^P3B&(gWnmY5qUa>8^5T)O3Ih`xJC_)b zfC2~469G}J2Y{HYm4v+AgSLk@7oUu%l$oHIo{+e{u!NzAizrl`v z!fp<6OKvbTO(?{XjOQyPZPS$Fzp#9SpBVA*%d|258|>?p*YsAi2%%?SUL$Th!TG`` zsMy4Ux&tMnfLLZ$9xWqp8PylDkQ{B3ASDflW85M}CRQmWXKr3mL3%+A`%p1i3khl6 zJ^ZR;oWh>Yc0-GZ78W!Ik0igSdi&4TN?b=dWeXVvBT)%8HC;D%-;_^Tg<8);Iq10( zKNOafR5r7qv|oq1ypFeWiecyE4~s~s{Q50ACR5iZw7$OaTTNYaQ`46(rT(GmiB#{h zKUZkzSf!+9d;3LRVrE!YKGlZ@y19pp;3D#g{cRn*+F@u@^Orq@39Y2r`bG}V3T&T# zaxr@6-N?B88P~#+O0yNZX!7Be9z=B?Ky=XlfSRuFO|{bg7rMMLq{tO9^Pbo zb6(ir5oaDY4~0&Z>K|mn;w>Qi&Ws-|1G3xqJ2WaMQ4x=1+cTc0uKh*(jhb9Q#GTS<+DD1 z_K&dnP_eWloJoY2IJfbk!pkJ)G0|jC0Dk5`jlJLiRlpkNCoBz~hG49oF&_PwDdf&~ zFR669-glLabG%2mUEmKh5tbI=eQ4KpHcRgJo{TI-mkFN@Qrf2N8oyb>`S3vmpNhXr zvuEFs(wVKVGQzt|Ik()btSdJc^S$v;Wx8nINxn*10h5W><`XA%x3#`1FFspmQ87hW zvuRShy!0aQrYl-#IGrLni2q1u6=t24lg&rY{xeEbU}SJOsnobU8zaZOKz}etw|~jr z-vt3LpW{y9rJ`%7g5iWGIXXIPxcWppin&#qW~CI&Y;SE+?HT^2>klbtUECDS9C_P8PW}i>kSqN9QeW?k-kj)ogJ4@riq(B8P82GFtN-HPqx~~Of&_?L>C7KWY zc*bJVD|Hs`>TB;bT5$=UqqBs!k>fy?)SYT>yqZ5A^th~=Rdt0>u&4m&581Sf?8CdGDWqzTMXJn0knr!jJ#Me*so&0qI`wHj!Z$ zUo{k!6eNdJA)gc$FV%AfA>9<{qeiaNo+^Q8_kH{@dcPBy?RsA-GPkTVo%+*PkG8kN z{rxE+j2!rwbRGwTq~-D0pPy_fvjw&M!(Lw58#*bhkcbE^%pv0)?~ZtLmn z8yFbqOHPR^2K&Q4pqXG0SzCGtl13%iJJ^rU{k!zjkbBegZ$ zAMQ_h6U7p3Pw*N_cTP+37T;zjsgIl;F_0URYal2?3*o?y^afowHe?FD*Ju8T|7**S z<|%}tUq0&AH3L*CuYHfVIR-(>xf zmNB4!2~woVK{D7?)Xj+IYp&M`UC$}=zGH{q@m}pj1XI~WGJegNB6UV8NFtxx-e_YQ z31UV5hMg5(&Wt@$ERU~Gle6xZ<3m-irQ>4dpc<2aNl-BhmiBNGzlYMewcDTc3U&h{ znANiGZl{SJj?|sf>dXO0>QMsCEN58-!82Q8slcGGs~9Z2q(%{#_%-$J%Xbs=U;Cr< zg)6;aW46gcd?-ov_SV+c+S*#l1Vmq0GzMNrfczFRk5yY9W{#xoz#tqYs%VQl#ITRg zGz593V03TlO-ApisS>Am(?+YQzKdLJd4g4l-xR$w3`}CmTDJ3h;=kj*SZM?mgK*Xb zs+~Kl%gbx$+=`CZ4?3kIMz9mR)M5C0{@~uV4lzj?Wb0;xKy}~o zoKgU%)nDIUy<)q&32a$hBIlevxVX5m1<|L{zg6s}LvZVR_uYI7LDbONCb`=*To?4y zrK-6wxm}2Go)iM#uK#Ewur?Ksm?>d>)IlU? z3FnqBV29x-Wf-dRLO%n6AsGuhg9+p%jI@)iMekc=Wxd36a%b2Q`jpiHHqd_o2H#N% zntEgj6=M_ObP{Qgcu>eE0W(LFdDtpnBfPyaCHf>l`iR*(X4JIvhhQb8F26S*Y1|Wl z*nB_e#viPVn7BuhOA;{}8zu%ftk1XXlcV;^JZ#j?B9# zQ&`0A*`-ki2PtKehAVV@-62qrOwiEKq`7bD=6(!r!4dj-sQt^8ALe@d-6XzAX}RM= z|Co%D`e}-iNPs{Xi7+|B-JrV51KYdDt@RP4Bchm~ZjFghM$`Uida zB}neo=x3RrT#w^Z8%R}-Y)F1t+3u3kN_Py!a`0JVmbg13`rjXiz62|@l*jI55i5;= z^&JTE{Z9uVg4IvRwS|SbjF*`3ww;nXP^!Px>E$5ZEMcrly@2x708eTbppP&&|BO!N zSzJs)HF<3xGGX%Dg#O!~drXdwDh|5*EFIwgmzqLu1D-vEzOhMUlnLDJCnG)X`klgA z9rwNdmlAZP7%{QaFxo_=&}q{x74Vw1MkeZ@v*Q)O4g#xWBL3>vcfF7(H7(BA6XS0B-OFcwto+z`W6Ll!cqWSb zsEg1_)mmPIN*nR1Hus-VT)Pw{T0u7S^_5((KX3C*I?#hTyais6#SZ*Z-f9jEx-4iZ-p*&EkSYZf&6jc# zsGRuS^JKS8|I8+FW_o(|ayA@8Qrqh_7yn_PC@1pbR@4l;a)c76 zT4ic3`bjv3I>Ga@vLZH^{Z)J86;rIiUFKE$l9aWn#T3S0*kn`;LP*nHLmMhlqhNXYUmL!C=2PY-xC(~0 z5Er$QoSl1O4BF8EkMh2*mN@aC2+RZDKD;V=DP&zPO-Khpvr^0k_}aU^l)aTu(!ADG z)(70WZXlpB*GDpAD%E#cTd{IqucA$u-ocOBHYsX0jm)B}(j2Ek7(|{T{l;wg?vLdz z%Ri!H0^Xxl_#a{-a&jakh;jh$cK*VGh>?S8h>L)#uBS61JJ^RwjJC+hti%xo(1gzDT18}n{rD4TTBatv+{%iaeu@oddC6S3(>6Oc(e$h zyE@vpQMnEai6eK_kVW{bUVK>JCR)D9eP6TsWFYxujgkl`U4FZ6G%bd)OyMubG;hhZq&?!O!QxgF&`nYcU4 z4V3Ogq=eLLava%3 z7ibHoR*E|G(6udd+IbpV6#b@atOSb%QRgmn2r`>1Az1{~U1Fe@4e#8sJj|Qbnm>i3 zy_(+N100;ttT)vvgoZf2tUdZR<@y$fbAd{cejlCH&~GXc&G}6(w^%g{!E$;1cg&aV zN~3r2Lri!pD4En>)aqr_2naGf^9lBEPYz%V(Tp1$q;1>qBaQGypSV{C{FsYg z`7Gwu4;a{1&PuJ%W2i$w`%?Ri0S~DuYECmTE2%RV@wIVl*~kA7w7@gJ@dz{%_k#O7 zS<^Y31X(knI563`0uu{cXl^RNO_3CH1wp*>hYR@20!0jLsN$CMe|o=UhbccBls}Bl zesaa?jRT#-Sq-H`;Ap*tPZ^gtsv6I`iLI(oAPNqX7pvF1_4$EJ7$tuJ!MZj;+Vgtn zCr!#VdYa>GCS8vHZWq8bCrU0n>v!c^dWq_F?#6}r4;#QF=y7VsBrT^A@xpU~N=T;>!r=9CQ99)~+QPyypT4 zeyW<(2-rtre={&hDFQU!m$I`6tz!oi60E$5q!jfAj5E>8K9_#!ax7+1NkFSI({o8C^LZ58}M*Sy-t;q zt9ae@T=P`*C%eiQMhMdDMKmKGV!4|BM%e;;Kt~pET8Qf8sGeZ@0D|5k{|xwlo2SHwn+K;vc=3jK1l8w)=0Ywcw`A*75L!1hpI*H z4CCdgNH|^3h&2#$))*+#Q)ykIzj!uv`u!fayWtT5BESNmUJPqq z=x&_Mr7LHZz@uOJd+* zI)^42q75-Wumqp2vjO$Z;PO7Qwm01avgZ?hjPfyrFp4Quq4?)N`^I$xHC)y69<>t- z&(%m#IPo7?TTFmbRH5tYs&f&CMTrrgnAQc`Io?&uh`Gx5%2u|hEonl4wC>~h8P>i} zw5%}P0*C=VO1lDroAFVnf1n@Dy(+E0nuz_$^p>Q!)Xk@3rvj?;rOsPV;_PE%l-m}U z4%tR>5$!jE_JZ7xIy6YhfX0CYjx#yElxq;W(jM42I6(<0_!bWt54|B2jTv>Evvc4ij`RM`1Dg()-Y}9oqTNhIN5nO_TJ%T zNJ!pruIKe109KqI`UES6whGgw;Pe!0G~ID5(!Q==b-;mN960Xw`{wklu7j(_jE_1| zV1Rkt{R*?&UY3GrPi#x&d?z0?>x5#;S9al943jx?0T9*KpdlenT9AKm=fbQRA0@s@ zU$3Bw+`~HK(5n8?hj9HW3m`I*JA%0}S(+TU^-f8AGo>S#i5@26zf1vLsT;b~HbTOR ziGXxOC*FQb)kENE?x%=Wsyf_f!Cd%tI3&5%qM}NN-O7In!zV9VmL# zEorJ~u+C+{_8a&49Z<@%PL2z-*D_SwdCFxSB0pdM!7$qgW%O3OS=C)CyqFT#oNUiw z1`FnZxd5(|mJtATN9&cy>e{S<&TN+fe)6&CBQ5=-DlA;DC^bPVa|V~V1i+a_1_nYr z95}|iG&Xr}|2?y9!5s%lQR=8th+hZN82Ob`&U&7hPD$s?9*&KO#6ZfKe9T{P+@wxK z8vrc5weO6{-mJ4%nVjwtp5fR6EBOnN_i?``>Mjj{A0_3T6Mbta=&WaoETCS84Zykl z=+dxjZd4h$i1yE%|0On?0X69*yooq4;8@p$A#Q67ccqOC;o|EG)^-zYkblj#QNTZD zF-wQ2Zdbm=5(NK<0FI%kvU18-{2@&b(@j{Jy~4ZA?=LPKtMMRQuV`1`p98dRje%6~ zEnfk?WH5m0mNB6+Is&lJp?8Uxw$v{opG$xLIl*n>#QT`n;y~RzK`z)U`jWx3X_s@x z;;oM{^)+y;XTt7`x)*@6Ax@+Wa#afBHvYWdq@Hr+4o?f@+DAV8E!tf1_P5k2d2|9= z`CNVjPB8C6f%v*tYJ-@$Sk)`u8qMzj^d)9cW@Iu#s5dR0j&=vX3U%K#_G~>Ak zXeGB?)2%iuNPtZ@DS|qeGwR%gciAPoj%kgel7^0?zz#%CY-`(H2|UH zzGDaS&@jPIM<>v16Pfa;Evayeg zy#V$_=$hOX94-TJBP%dTl5HeeBi;B_Z%$(5JU)ogbD<_#eYa9??r+B5~l*5#26C$ zddD%@>J)rB+}UTdz!DBPu}tKm^8|#wbnecp=ISuwvV)9Dd!1` zQHPwG!I%sA%S}z{{yy234L)ihGP22i8FX{-h1Ba5opXfF@d`fjD>edxXNh@NhfXKm z$=}Mf&X!!*9(|#i0j^$+TA2`F!rs!zS1JSD6;lT>d{&)UK(VR|HFX~dk=;^02(}G= z6Xuu2gDJlT9=A&ghVTShzqCI%e|#IZBYD8Q>eC39yj6P8;{-;w==tL=7tNo)DgB~B z2dv>tWWA(l>2u3f@J@e)4}=u_o2&LbEaxFWxR4?tO!A~6fQHK(+9hjish?~jK;BVm zZjkT4?f;&NVH^C~2@jA+#X-z*orp?y-QH4g(yV#|BqU66@%$sB@wxZ>+g0VBz?HPD zc#Ejyzm?;BdH{RGv@AW-WykbqP@BqokaU3hQ8)GW=;?wr>oTxKy&E#y#pww=Z_sQV zmNv@mJE=R2&bk&AI>2v>qz62q#RB?|<0KunR9e=44zXDNX=ntw!U_Z z5~O?}J_MI4P~jT`V8RMl_wbK2s?hpj9aEL$OCmGR|5AM9~J$TfGifGN(w2*DxP6hq24JX-aYk6PpF*Wpoq*tIyP>wWGqh@SEZ zq9d7TZ|!PxceihS=qf>=qfw2u@s1nd#xQcoD#(M3@jU_&_K?|fiR4`+yiU+LIZww! zKAi+1w>(cAg=zJ!R62v6M=(qLsB+Fl*4&<7eicVw-+ZC8*5Z}PNHTd`eQECUmA8-irkTWn0hOzZpbmrX z!>s;1f7(G(TUuNRN}a;qw{Inlc>wEHGT+I-n|~B%eC&SIY_`Y<{kT=qvP3v%@OKk) zEcIJM{{x49?qEI@z)t;qnYSEp&|+ff6>!A1IQWoD5a=V!+@`bnAz&%+iQu^rZk%3k zN1x$f`?K_OTG#KKy4jXBGLB7Md4>sgaRSf3UY4`BvuB2eY_@nc#tKgfl-9GpKYMI) z^}+gM{r1$92msh7{Kz?(oLOe@X!ZcXEPiSJsvpaI3b+f*-G0I?KbLtfvD=XbRGNMy zw_@im!fy@aF$>2;C!We2aQc8M1+$n|>-w>Sl^ z?OxJ=n2vZN)br|zxFR2cOoM*YBg%^fJO#j`G-YqsC|+{*y;0!2oUH0X$+%JZFWMUw z0l(@)xS4TSa&`T3zpU>ZOr34$%6qt^l|=eEblnX>PoW&KI%SR^uE7_3p(i~rs(ftc z>U{!&18Z*&4|Y$SZ2LHy>#Du+R;HGl<~w)B0513E&A-#npl72s;;7ZNySvk_Hh0by zDmXDr|AQ7YI9tGiV6i>wNj7<O z{rh)AJxzD*bHBA)X=*Jjqq_k`zuRc`k%u3|0|5lF<^vD8Zl$!J-0DyB-mWY^ipF5C zpB;Spri4J}f;gf+X^Z)8%)i57ZXg16*{h4?%2xs|$8xCPs%6CN45D-h@ zV|E<)65=?>+ML3a6Zj~upEB`{=v1azx0qK_4v-YXdvf?A8k_Sinc2oID}SGRD?;r< z$DNPB&dT9e6q0ePtC=?I*C5OcpbeO3y0A|XO6_{Wl{MPMauTy*9=)rIZNck$o9jbkp< zFbARX1b$ zd>)5jmdjF#=(a-=)&HHus<%Z2lpEu}!3Z(ik0@JqH+J@6ENrX#o2F3k%6?<4TrM`R zIG?&WY;{^@@!$qOjFvl$_F2%TU;&|873Mjot$y)wFE*Bg1FIWLy9&S356jxv#JG}~ z7en^arF*8omZulNdl3U9t!T;XC#>bJRbayUS9BqI&9r6XgQW!)DCL!8Th@p}Oy(}g ziYyInqT$DpOV{G|xW2 zZ%~r2O^S0hUe1piDd(>~A3HIY zff;>yzZnlozf>hD7b0kaOBs8@Jo{ra*&pLG;%B17y_dVXA`2N6h0HQJ-g@>_$j!f^ z;@;tf!R2aGIiZhZKgj0)t?`FSZx$pk(0x1ze&NM=i%ePY8Y4EkdDe33>h`os^^u2Y z{Gc#7weQ52g*UbQvn+ihBcCmv)ZF}uw=MP*VUP1Sco}f`;@Q4LV5Y#+s+mFSdRh3G z%*_JTs^l4VR=YkyZ{_E08gGtcQU;2 zQIQFHWnC{#w?;e%-<;KmT;}vm`A!!lZx{$}mL1>R$0$3s2eKb3J5P5mz4!LQGTw~7bd7y~#`wb8UGNij&c3^`7|!R}H+5Vc zbzUry@ij7O3_68pUgp%Qj*)+FDlR@``v=@E2kt+*4M$HcUI))vU03~q)~kAVZ_C-h zQ|7C*-d0Y^@d%z&dlr%z>dYd+fj833tbw1^=6;z7Sj%SXKcr#!{WR&u{w5!%Y;%*$ z&+F2+Y^jP@l9#s2e7x9tol#rb0}XdK%9W(l>Bz_mABKtJz93#c@}224%;20zPe1nx z)cdYQ@}o*xn@M=ZlL?3NaSkWy^se%Q-T;Hw$`fAaz3pt=f7AUL`Xkb=5KgvizIld} z#w*&sUq=@)v=~Ow$fPqmt^Tq%%+o%$G0WsKMi9ch303NOzyh- zALZP{WJ%SPHn1mrvg4KLA%{=9Ywp(<#F`e>ua#Kle!!ztOaJ~0f242jV?)Qa>ZZ=( z*z+Y+OUuu~+2rvVm5RGHPSx;Cym|b63{LVnd0F~jxKy80(Ox~7tn+_5w`aLsced&9 zd?S(P zfh3RqVe94p)qxa4gDU-{LU}@-r#vWn8fa|hZD{8Q^YCT zd;OFU_l+qR*P!0eu*Rk#0A?lNcOal(rO-;D1XIA25u#m=VAeWg*wE~+{_f#!`fi*d dgnFJPKd?tb=6$O8_~U=JYN+TcS1HPx#32;bRa{vGf6951U69E94oEQKA0{~D=R7Ff_aTgdF zK}&TP7#KuPdqPckBPTUNO?VX+86F`iLrr-X7#J-wNj*kv7Z(^UGf5X17(PgDLQQx? zPI^R7d@nXmGdfo{KVCLITtiKHJ49$BCNdTm78@KOL{ED{Om`U?96?KUKuU8zNN^h+ zAwo=cBquW(8y`GIYdAn*H9T5FPI^O5c|1jGLQHlZASf3Y85$cN78VvnPI?s;6Fo<4 zK}&TfEIctcQ9DFuEHOquNpK}8H#k6GLr!@qEk79=8x<84A|)^u78Ms478Vv3K1gps zN^(L>cRxsPKS*#uN^&JBHY+ehC@noSJ6A00ta79pjxFY)-8y`kde?m=rAtNmR0E_$ph(k?yIYD9_8z4*<^~W3O;Sav&47AM{-ohU7>I$sy2&K;#+|?A(yCD0^8R8rs zA}%vYPg#RCJXlFmcI5+pBquXHMrtD_Fq)jG9vmYdA128f<ZUbb6EC5W(aPw`Ln~Xc}$k3$XqGjaeIDP<6GRH&F2fm`7NB^#hS%6-36V{Ow(h~%`gbN`a z78Vf65|x@-0tTt6Q7o|_F`xkuc`lIK%n&Y#N-H|Nzk1S?DM?^3Wy+-L{fCRvSQg&j zbq(S+Hx33KNrY32($n)J&_VjN%FM?PZxn)E>ZQ)WD-3scebTx2(a2!s%9Ur%{5Uu1 z%dY1)AxgY`7(C^GjL1kJQMi8k#}6qmaQgJSH?P`0fBSx7by9ui+l3HAnfw^sje&Gu zT_#Y&n-0`~a6CwPZXi&@v_%^Wa@HMOQM~p- zq85WkK)|7c%L`sym=FxofN(s}H9+NH4I4J*@XG6$%Z@%bSbAU{3`MS~E8QM0^YS#5j}HW1#kC=9Maiow>J1)Q{&u9^_l8y_AX zj;1<1yysRVEXTx3Ni%TRSXeT#1P4c^Mh38?t*JiSQw35520`&XN!7iJVj;x>3zMa& zz5=k2QD;=+*HmT&W%RJ%sQSri{Y7ieS6823Q`Da}xjw2e3{(cNDr@pft0?esgUSs~ zZW94nD=`h#NKok#mXHt@9LvHITL@+WL8PjNn7zI%BcB5g2fQrfHsVuI;}dMgbQ-10!xuLZvdWtc3tJHjdFcYLEf|3d`saB^>EX>4U6ba`-PAZc)PV*mhnoa6Eg2ys>@D9TUE%t_@^00ScnE@KN5BNI!L z6ay0=M1VBIWCJ6!R3OXP)X2ol#2my2%YaCrN-hBE7ZG&wLN%2D0000$F9eAQ>eOQE@=}GXqTWFrvVF)!l8vy_l=f58eJj&w( z00C?)?Tsx1!}tY+{<|xmaQ@%z|7(L!KuADPgm&w{M}7g}|D*jsN&YkN-}V3Q{|otl z-W5{O`X@+9^NgaJIjy0nZvF45W^r8kB=*QLNhxJPVR1PHJ=#TSIZY)EYb8y4IYpeZ z#%U$>Q}RkD6;(|XRE(50ENNXqVKHNy1RApT2$c#lzje0@2nY)bhzJRYVg<#81+n6S zk}^WalxXxq%14EyE%>yI`8Dl$w4J$h^aV8Jg^p_ps`YiZ8(GKiAamW=b!VC0&al+1 z(HH-vdunW(Fwa;aDkj}1^0!%d)$P620sMBG;5-G~=n|Oe5cuADmxD*;}_!j}5J^jI)t8QTZEm1-~$jldK=7SnG#5 zKYzlK_uzN_Fuz}Bc)7$-wt{|McZ5PlJlI9t`_9$liqUpFpHL@uXkZy#FLrPMyHzi? zFCi%Pg@57`cK0K8uR(OX_iNiAS1+mIqltY2Nn}GxR=Y`LP22e5Fh^&r&|-_wQoG<> zo6v&(sfZTgB z+(IQy+w*>z;*#=u0_q0*8m5-+4qV!nL5a8S6yCU*n|w9f(Cln*SdzAYnx?jSV#?$5 zZ|(eoZstEJ3yQq$TJGfL73t`H?d8kYr=60b;_jWvxLjCR<`)=$w$RfdJJc46n*|AE zaP3J!iPNLNbI;uLc-749qP~FOI^Y}5c1raPizux?3?l8U&VXp-l}%wd1jDn$g(mW` znKPc@)ONLUH}>|=f|<+)tqbP`EpQY&^9gd-WnirMu^Hb^p3Sa1 zxsX|+k@>kK{c(*mPjXJ_xmpFDb2`sd?QBa@by_Y9zs)Q{|GlF*DQ12zO~=?=x-ei+ zclJw+&a{Q1=hXDW13icPrH-u+$2F**ABMTbz-Z?D|FMzmrNd4v`IQZ-PFJ4$dO5Ak z>!`oGZ?z*GvS57ivPO^2XmIt9RZcjp>{Dqx-Lq9?`uN0X`4>_93FUr2zL*ogc>axj zd^;i8ucBwhSv_Tig2U`2UJypp`I&P&2X2c|EE&mPWc^#_cya4J-_dY~wjScpjPbzP z>o~&D8vJ9%fmGu~wJVedSi2`T2(5FwMO14&dSn@k5b^HF9bDTh(eanR2 zwnIE~58^(kIgB_%Qg9Z&c8=a$SRz4Vs^>gRBaHA96GbR)^I}2AEU4V~H#$kWP$AQ| ziYPed|GrfM`3a3*;D|{{?zCHedw+i6)%c%C=P%zK-Ft5ty30$f(I=EUULTu$$9?I9 zwCwxZJhj2gcj{%Zy)$Dqt7_k&D_EqZV^yo$a%kP964%nEi>=)1F84nhPhHk5ZEcZ7 zBsn&k+U>;6*XyD$UTHThg@-jty`zKQFJv3M`AFb-zk`EsnY7H4;|iI9XEpkY84Qxg zZ?!PB(t9D3j*6qp?K1_ksypsnP|5#;`QTQJX5=wVy(U#NG_@7|WRCaCPw9zwQ(LTym*yZQ*Uy2@=rK4*b> zC)e2ta(dQSNX_W9=053N7v}?0>!&T$EDjd#fbA0z${r~$KN_!{eccxj9qr_Vr+QfT ztcA*5E-ERhu8t;t`M}&ky>pNwuMynh)vT+Vl5%)>{`}b3CSLlb^4InLUcBXovsXN!<+kN1*doh(zPc&BK4Lzz44 zNp98kKKVx|UNxHAnWCEbh+=)D5Md7sVh-SOd$^&BSfV7GCqDiq=9?6Q*`9f#e~Q|e zQh$%0EgkVlV50io8N^X`!mImu*xF__A9mNC&1>vQC(2{DioE*klfp?M55{!503|v) z$NN38v0o)5N$fMD^F>O{sRj24QWqu7{#Z}rHPzS2>(d!;(kx-}Ea;y(L9RM@4uqv^ zH13vdm9;r$!-p65g-U78Snun*efxG_-#SAf6aYC&dKiWcKRfjZx;x7{m%*=xFU1k1 zTqx1b6z}OGanZ~hH*VfiLaYS%K_MGm@9(O@n25r?{c4<3{)|StXBoj~YL3x?Ws-X? z?B{(@QR|ZsyM2^Yfr#-lKe*>nnL2JavV?tNB)L%su=VQt8mf?}i^XIu~Ccs6#xE=xQ%qQEAWfSP|ztWUn#q zv98DGQjR(XA34}Z6Ci_++QzG5jxl{>$=1R>gJbW5y(ATZef8Ykp82IWHwn5f4X#AtyTRjT~^qLIN4X1?fP2JzWD`s{s zI}$n|O>)8BMnXPpy(Z94(kl%!2ErNKUQQA@4}NvWVpnR)$%o22R#b@#$0_6bkUlVx zTRs?%nK#eiWc9loxMvXab=>;`7ew~o92yia*;s$&BxS(q4v!h5nK5)cB_6p(z5IRQ z_tX&^J{1q*0BAWVA(zZ`zCS2?a@!|c$c7u{%g|8{L@Ggp2cz4SwL(|i1xmLdZeqXe_fJt#gH_Wo;R*(SJq_RPB@{X zVDw-lkQ{W{aQSLO1Kp>-AEx?b%sO-`wEi|X>^`hZB%A0Dd;h*Mn&(qZC`y~rhG}Br z4eD|k3bVmG9dhwVpN4A7^wTT49E`2gMpw7Dk4^#G3CAX=7>ww|;swlhNaY*OY(wu< zebJR0L+~_7*uCBHBfHTbSl-`Vo4~K8G1s zeAC4Sp(nzbv3hhKDBMH+EPbmgshqb(Mc&UT*n+y0DYquqy3qHBK=gta?o4YE2Nnru3?;k ziw&On$5#OUolf&3yNK0ga1OLktT)(hlTYob?pdrZ-m@5hAA#9o2T07=2*?%w4b{=? z(zt{rybbu3oLXl7`MbOWgH6*kAGVZZqO0ES5YMqE?9VEsxn|w;`!ilDPb+GnDRpDE zhiIx9KGV@x)P7TVq&|9;{%XW*cm9cp?WL!NY1_*q)p0v3{m=13RsKCr?)jSQhdb^f z`joL~_xSbQua9q=%`ph0Lfq?*K7g)*Cy|I}8*#9V6sQnYQ6*!eh73JHXC?=Ghe$_3 zGvkyQf6BV3$GUqr7uEIzg-yAQXnKQcp+xBkN!(xVyqm_R|5Oc9huOgc*lfySv3q_t zQ&>X!6XYueC+j3Notv;dP8EVHxE#s46~=?rr7(FNtV&LgKfXb|bc8bY9G@MG=b?rv zQ69@tuJcYOoX&s8U?z#ngWtb(^*OaZiKbV%4`Y0a9($htwp z{U@0v!>$onuI>Fis9^ zt@65U>EC&?H#=}}_Mjl1<7JLHScWmc>MqbEEry_Ng^*m5-SAi@Y(C-*e|8kq{j9-e z=Eun(N_%)PrE9@@no6FoGfTIEd9it%!S43a59WBE!<%N)sO%R68My31KN%!;XiQ5>lrO+x@;Ykl|z=r@g;K8f}um{Y<$gl$NXk}S){?H}Ry7Nj(2*5WB)8E8!5MH~#h#xsl< zHXwO*k27XGa|B@wHJe1!)b9t>p0V1N4`F#Q&1+0(x=UC^0eY+C4<;g)j-SpO3dn4d zzGT?0rS>f{O56uwHM-aPD`vAZt>d^*$I-doZC6{N7baR9E|H5?kvz>rxqCO5f3;(=oJGu zn8-dW7-F#7PtsdOOak&aK1P-@=cNC8k-AFk*wv>F`v1KMV%RoCK_zFgv!@m~*^U4t z85W55BR3U4d0n5pSD>$e0B(pcfE;di=p0})f?kUiuk->FZRgqS`rLsz$4R;`|Bw> z&0u2Pkigr<`<5(Qrf)_abI}EQJEG8%DS$LsKMvA+5EY)MeDMY`lNRfu9polnd^V-N zI=BdyyI#hxSh$2_8DQl57*xB203!VEhf`FxGwb?N_`{I#22ly{?B)e+1BCR`NvlBL z*P^l)1!vG-ScQRTCQVRv@VI^)4-2lZz>n1a(+U$Miyd_yE^Uhx<9>9p1u1cj8A@y8 zZ6G&q7JTBD04oUu8L@$KD9cYx++^$aZO04-GgfY3omD~fx=u|vLDdL(!oUFt`~+#S zcjZv%%j}Gs0w*ygekp78v09ekqgumSt7!aTPhKOiSuf`Y(aDJ>Wmkh={juDoM{@nV zy5Bk79LKWxlf1x-UtCalPJJBG3+^Rcq+1$3T)9|5$@{?j&JlxB0Y$blqM1leu$rY{ zxV~CW@t^#M?nJ4A>v9`EofjX>%)LHQ-g0~AeL3!@o6V`WazGipiGOsdg%3p%9YQEF zPk**Q=-IkX%%e(KA9poHtlc=jT^~_~Iu$G8p1-J`_XyVIh+Vb{mAOWTb-2ZhBo**L zm=-?hDWCfvCt~6{{X8-BkX-wRtxJcWh2@ZnnnKuxWL7IaIK6tG~?6g1ci&F=ZD^cZOrZ>Kwr4GkPFMSGghmw+N4# zHr{9C=0W`5pMzz1njh|+qNMiOD#JxYgl)SsichjFp;yKszAx{pnQUP zbO*v{0KW*qe%8?udMHxyk|Y6;{o@~Ar}|XTw2VXGyw$`tVTuCxe~LwFhpr0J2hcZX z&lpEo$Q%KgSAd?d5YW%6csw+P|HcC)eO!B=?2Uo~Px-SiIcW^phh}GHq)E;bOT*tJ{ohMQUn08av7DX@!dRPFCbV<-x*W( zRkGRLb^h{`5k(tM2B@bk=jY}iac{nVl?8mwV*)0vO;?1+ShQ-!Pb&lzxtuXV$cNWj z+T)0kf5Mdv?W_N2#lX9kz}ncq8Q}^LxG1UCd-GPADX68h9|?3-AsH(Kc)!k8*(3Hz zuXv*xH+frc*f-ZVU4I48&_6G2gD;wbdd}_bgW@2b(ehzxfy{gUiBYCxN`+vzqUdpR zwO2W7U(lr4djQ$=PslOAe2uMpecs$Q2S}WJ5#0M)1gL)^WIZa=@~-Bsy%yJ+>kr2% z-k{}rH9cX@Y8|Jgw*pIlEDB#qcx6eaxs#ktQRoxEG}kqKW(MfY^%8FUz(NQ;2v_uA zc^ZQi65Wv`F+x9C!dFoDi<5W&S>RlIO;Z&Dff1T!az9vxNf$k!mh+XidWXf2>Lbqq z8^4ra314fuZ8BL{aRSqf?es{jSzFE+jg)kxu-T#FuzI5xd4$8hfr zkdUjdw{6e5ACH$JOw=IL2ji8W=-r&BJ5dc!rpGB}RouM?bl%H>Zes7F56BxZsev%q z3klAsMITa{7D)We4h+;90$_YnuHatbFPA=TZGI`4clqxeL5X1GTz*Qox0ne)N**Av zm2JF&U{v?J#qKW+tvm(Iz{%e}N3w)j!bF(*5LnF*2wgfX=^w^*xjuP3U{zMP|E(x< zphGqj5MmjwUOYYfCYy3gqGkadhsF`I6x2bstlnhCgR6opU{|5d)*5{BgErVbe-`?z zY}T8A(n4C`Yt~!o0As7v$6rmCgiQtyp4&dhzxWDfUxKYSMmf|cTa%e!jVl@Ek&J{0 zg6@_ebY$%IW@gW(Wmz`6?ewHWE|u%=ChrzOarPy&#>l6q?1+cMC(fW*A|p*@h`&IQyoz=q6wVHQN{sKna>!|A$#Km-2o-Vh5;8Tcl!42M~fMnb^J2| zK;(wPQ4m?CnrW?h?_3DCSFh-|h61nG=Pu^l9XcqBYczGWL|v8ot1F|L^BKYzZ*pMM z`LA=;*;}@7Zqa7&X0pIvMvtYHtKWVvOJ+-Y+)k1(OFh?c$Fl9suG4mM^lFp}564TV zpSzNL<&IU3Bf~#mU3l~&_Q#tK_cV;+5>ANZxEr?3!6sVBg>)Xt3;ZNBE=DUqGv~)| z)j`I=wMnmE)|U z*DZhan@ckpc!gQmKS84U1&If5+FqoIm@G*(SAtOA_%|?uQ8}5U5M^d{<{0d*4cm#l z!O~~tcp5z=lXpJV7C;#GL@e9;eUoa&KCheZ-@&_nvu*99Pc zuYZbXBS5{uJB{{~bG~jM9p>BuCp}$nMMEZE!x%`?dc{v8QvyP6Dzp|R`-(zbkbevy zt2FSzFe_T8y^JFQTGLeT1!I=Kt}BD@`8Yf9=M4xAh_^-9sIGn-v2g2SHdz;=16Fi4 z`52T16flOCKzZ=90el;;8u&9wS{B}~g^tlMS!SY;dp{h@Oh=OA0jyaH7l2%I(r$8q zHov{kTkv8Gi-MH2z*XA{FwslP0g|$iW`9%nHJMD)yhcvwJ4>O~5la+~Z!@>1m<5dT zyAJ^8G=x-ka+=k;Syjz~un5;PfP3>Cs#ncKL(iRorfS0rSmiGPE)%rKqbb`KS&?Fx zb!JhmBBQoE2?HoMXc}Ou=}tfY;eu*m5ZlhA542=Mg6rHKLQH`6;-?1EExenaM%JD` zBP_aggOFP^WxzsGsG6bjMTbeq{_~PE9DIlFO_5*NzK{>nmZ1sQYfJ-RUo@NQ{blgY z$c_J+0wOMAFgjg}L26u`%?9gO=3X{EqX_TZDMlAJM+a&S!@qZ*0WcBF^62$*!gNt; z)sz7rLGik68TQ=qLv76L?C?*u5SnIG~42Um|c$HACuX(#Um&S5ymTT6l6aKny<)J!JS2 z0c90m`ng0sIxTZ&aawBaAHzC5z`(bANBOQrrE(hWu4bVKV4{aS#Rjm*rKbb1FRv?) z0soAjnd<-0VQ5kX9TH$YnBU@q5dWOl4s6I+%O=);Zl(Zml7uc{Ym)H$4>t|HF95t@ zNJxA7x;|)A1&JIw|H7q-#1_L9m;2dl-ogH5un5FJMCUpJ%{6Syf6WSQ(f~MZOk~RQ z_`L&0k$-8#3d^+vWC`d$TBAvxXp#~UNRI$g!FsDWFcky#V;($j(&-_?>Fb6-U zRsc^XVgHhh-lMH2Q87v**Wk+tjFi9LDh3Fm%yo@4)o5rDsIUhd{K>Q~Akr%8Kf@u6 zqK!rjDYq3tt07}HTx(Bas159&V|W2E5-d+SbN!3uCb)OV1&<$qU8#DC2IQbUJ5V{N z0)Aai34yS5|K*O=PlcS^Vzn2~|JBecIe{tZZe!sEIx8?5^KTKT-2et|q+J-f05!9^~Tm$of z!Po4#@w$Ks4g3-i9t&&e-vKWoE`3SbWUA{Ax{xnu1>)lt>Qnp2scX|tG_AYA0l9hS zId6mZRuBnjZudSupcZ_Jm=6^HJXHg`Z5|_W^{P{h1SUNRs2BIEjHw7_YpIko!#GIa zFJ%*(ULEjHdEU-4W{OR%oN0$!7?^alHFoeYYGTj!wwORkj#`Y*P_o6{UMsZ@AIg@F z31+$0I;i_+U9F%H_w{E75@Ks|MoE*C);WMgWCk6f?LSI@^Y#W%G-U=OXUvORN&VwG z-2;n=ZL4nx#d^&SfNd6b&p~;FhN&|^!r?&YY0FvWJaT+nTvCUq`&qot1Pq+O1)#WL z%A@P|m24O)IpFl!tU%?Mc^-}IlgtwhRMra9c9;KwhYcKh)2N0=m*V(W$aTy>T#Wj2 z!dV$;y#?u&e46VrT|4CaZ69lW%n`K7BNUl1@tQe0-uK4&IruFAiM<4h!>2Od@~Ey0 zQa@{$?6`Rx6$X)_>6w5=*ToD}VZA7GG$axRG`V9AET?st>XEP*R#sd!Ou@HiTGpGu z0Um)+Xx0vnyGqkdzS?B2wraNq6;lf)LK zS!OOIsU~)%k`WH=0Pg^VE^9n#j4g&*BK(Dbmjm`K(0>c%nNyU2iHR*|4PP1TEWl{<5ghC? zk1BQ3;s4;tXzrw>78iOMKsW;AJVr&&J@^l)(BE6$2*O1Y63kd#+jNmOe#18cv-%Jd z0fk-AzXLo{P`Hn1K0zdt^FO@h`Z$IU+=UU4*>Lb$uhm-#kbmnIpaQ-;F*H4bLfXOj zyh`q9TOFadjwMHi$2#taWvvyQtF_-yk6toH7{5d?&fKnAHBY6rt)D z9Vw%!v26B=E)0-P08Yj03^B9xNJT_*Ss9eH1V3Eze5KcMYnD2+B==p^AG`#;*l*7~ z5@KYuxZ}3Xyj899pYHakLoI!{I>us#n3OIMfFdC=@Xz7^)`q(bBMKfK_-2bf)1RYO zXoLhD4y$2no09-3;mzgL$4Am!9tZE8W{6o^IN+Pt?>WcnCTi9Rv*dtQ4JP2GiPZK! zn*e^rY)?6bw39`mhZOPL!!=a;DbUFe$$`^eFh$Rwta3rIJkLCw$lvXT^x6lB<9ej8 zFeP}XFk}fZF&a0qIKFtH`{&~i(8Pm)1K|hwQ07_cRmvrxZU(oJ`&o-A57g#6;jX2r z>8y22$lCjGxFA3J4$OlS!T{cwbuSiMNHg1$wCd(!Xo#!AU%K?5hm(uC*^{ZKxvKVMB@>bBpQn?ql%YPUl3T3$K z=LOZ}`&A9k|I*oCTQm{?HAn!@g;~Oc4VABx*`BwrtH|qDP<&w0>`c(z9e*aPDqDo7 zsy1&T9)E`p@W8^5Q(PU|xwL=YHs-7`pzNF~u+obeH4$G7AdGBGO0WPWxFS3^NfY&! z?TRYZXU4iuUka8>7LQVr-U~d!0&>9(@D+C>*%j@Fqo#NE3>J9sLI5+bO?p1ioMf`Y z<3Aa91-b>U(I`%Ed?DnD9c9tMaey#ARKb#bu@LJ(E-&j)nxQ!2<@yqTPO?xYx!2D0 zbfnta2jd&sg)waO7xMZHzqf;N2&eOTctTX6@q8nXCci|J?rr#fQx+4w98qbG?)6Ey zR7xV(WHLJGu9(SABI%eb>d!?>P6Uary?NyPa1V^Qh9HEw8QnY7aY_EGoi0yzVO=dO zQ`E2FKH|XV=H8zzrxUnik|gE?mza8o{bfL&s_BBAGFZCCC%T?!u_fp4g%pXbtM zsA%s8_BC(gPhFjeh9tG=Az3D@Ke&>k#Y);D`fD?+$)QNYl~oG;tlb9pKCU98+`Jej z814hY`o3Isa_E_Wu5(a-Rpb{vfk*|FJ0wAXPLL6AR&zTk;}WC(%-)^ zdg<;g5@P}GcIl~u)M|>~*KpbrqRyvEERF28~)p%MG3G;4=0UvH7q4! z-pUBy^dNuEQMgUW*AtRn(>m?Ofn_=52R5uV4hNMV%!4{h=>vjBLRB$0uQiQoZOqs^_p{0&LlE-&Ppz80oOVEv zpD;)$>v}Y1ee%j(hnM!}RNnK^RjhPTS_>$S+^rdZ?NFcLb*qj);+-H%sKdMITKe&j zXZrkyTk0i`%BWd?;HVM!qo1;iU73#@9`MU&h~|nO#U0zf=r32=;B$Dri%X8Fra$>E z&NkNldDI;w{2I&H{r&3_)Z+IlrxNV}mu{&w2raLi@ryQ$a!9xsxXTiibKUHSA~{?T zmY{mxq9&k3VQN9;#$Pq&h)c^$l^lyq5lOHy)`zdH z%c{?6YsC(>Z{aVxuzVvLH%b!TAki;gzodurA*HwO9>1Pp>Ef{_yy$a1$*P)jlOKIL z;cm&|DN#wR{+zq}nXFA>k=aoY2NqBwN~wTb1zr+pVm-5gBQ^?Fe!cFZaJFKx^cTEQuz$oB0x3Q8mX zfr!Dh|4iEa_~lgt)OX_AqvTtVOY1Gc3ysKg*EM57d%uXJfq1&7qLd_Kq=ayR??KlS zw_^#|-RtN2HY;8Q#kj^Z&EwBET9dv$*zZ7ISg;i_yA0z>7(#Cv(cfS+N>PaaXxPgZ ziH#3!zKE4gXnS-^cmMYOkLFF{c=#C{-V$7(%Vv1zRHOrr_vR%7Wq7iKr+FEM^(Rph z?Xi8ErQ)eOqDucjd#$LS{vBf72iP$FR1ri;2hylF3TT*DscNrcKQ4>VC&+l-hsQef6jn9kJ02Y>65ob?=XmAnkJYr zf+=rKU)r9k9;S=S^?5{}}_sFeP|(4?Pq%~vc+h%CR06}27gSB$GYGW ziRfZhb7ogfw!DP!gbCb(#=wPLNEg>2=-7!I6dV7V7)ivRTdnVlj-&QJe1ZS?X~VX3 z{4sn%%A52)Y5H@~-R7#pigV~hKLtcwvg?$IO*)Y(dwNz@aZij)TqzKK60>!k75@$X z2Ug8=Q{ln~=F36+K6C~Zhi}{ySp}EfG%Cn6u=(1L<2N;u_cl5Urjja|{g91O z^|l|xlg;Ufkqya3A&m+}RIUZ7=p^}+tN{L{?%-55WFx#$zbpQw|ApE93AU*U`QwS- zmD61&?};aGk5wAcw++f15YMl<_x#v>B2D(Et$=HM<-7K_;*NdfrVCSKgYQdG9n6D9 zBiTG2#*wVJo9G9j-PQNHb1Jy^?l!fs zH#QFV5@wC$QkMEcE@wtI6ntKe-#{6>WBxv#&{3!R@j3Ui{7tTyy66jy#LaG}kKBo! z!;GeEs>v~%q9(CcSKOX$a9>KzF;%N(<|q%<-Po=?C5{e>{9QL@ag(AF-GDmh$%TBZ zbHW_o&_CZD)g2uZxjt1?5a!5N-L%$=jpMHg>UHf*IdWpI(`i?^XZM5gf&Py!F-l9m zLD$(yd(IAnzOUx;hx@Mdx?!`6IkB6(`m&=-+nc_38oT5UPZsHa-&OB=l~~CAx}<>H zufcrET`6(!DBsYx9+kaLaSF%fhS+nSPq5iRy?iVo?gdPA8r99V;zTlMb)37ExyZqZ zfBKCtGlV;0I`&IAP!-R)Mn}6QX6t@z6zN~?o;A^5i09@z9Im)Cr#xrP0_R12x*TDj zz?;5Zkr?sQ{r8|=(5Cx#I`v^N+cL^K2-$Dx z;n{m(bd1g75|W+LC^H^V`(`ybn1{~}jybDBb`<0`^8&*%^o_(u8-8Q?07`&WWF1N7(I&Ohdk)K2FO4heqX zla|y!nz3^2d_=?W1?}OAiaMhDtmjf~!%RaK#aCyq!v64;3E}9_)_p=c!<4;^!aJ98 zzpB2Ph?;o5OxeWi3UCdjlytIxhILoGc6nO!K5cn&UqGF0NZg=5fK<)vh z%;bsWW5cB4TkqIXh!uD7Nn{n-#u6?=dDH^D6JBMo*64UPb*Jm`rt(hdr`Pc^@i}Fm zHX|J2i^vS=H9>j6So}odw!uHu9Xh8dp3d3AIF;&gElQe=t%?5074&@$QBw5_ubW&(>pw zNU%$A3@|-n^r8z(vE#LVA)~Xnn?Z~`cHu{}+WxUR%lgvewhEjT;uR_iG&clLeg3Ca zWF5`YR3nV6#3!r}dR!Gdz+*BVQ;Ha#uc2VSJl&G&5P3%ToA7V8(#|jops2?Pw=8XO^nkOd*?_y&uDCDc!*!%C0}f0Y^X0bFf`H|fSA1Lzs%?` zM``IU>(5_K{k8)Lai-(mOq}AJ7I(Q2oLD1Bwb=(DUS*H{JT%bJ@})(nh5j>SMle4P Y7I@hs|DII4|IdOkS+D|s2CXUh;*{H0P083i(nub#^6Q*fRy4N-2oI9!2tjQux&Y3w!z^T zH6lip07szU2;?7uBg||fz=NtfiDrMw(k>3KWn^I&M>1d;S@@e;ha%A!Gn;Tlm=Xes zMxn9Fa0KO$m$79a91f17z~o*M6-<&%4(n1JK^!V8n&yx|Wkn%Td%!B#1EOh@?TC8T zXcZg?2$sy5krs9_7Pc`U9Ri60@&5=T3~V-}`lxB>f(XX6U`zXWikUZvsg5_Wu#E=$ z!Eq1`EP+Xo)SMY{$U4--Dp>EJv%aybDg7A84qB2vQyC7oVkdzMw5t84IV4)Klfe-r zQsp<0se!_)qcrJ`$ux(g-@t`7wcmIYVRWP)Bt@Yxwg_4c=9MknnC^Jm!amW`F8-xx zoTXjN#Pb1r1ib^h1@4bPpxRV_ncJKo>e5uz_g7(NoOU02F*-Pem|KK4tcn$H@5t6R zU>-C(@=)d5Gv!zFvh6E7Z;Zfa29O`RFzc^&4NNOMUKcI+Al>p9{iPrEN$(&Vy+;j$ z#fOAnGO-H6B60JOL>An*8?*WX_HzgyfYijN~9zHW8|7cFGt5#*4?S86V_4fuO-xy`Y^h;nkr!c&evakI8*I&8k zFBInI7dpC~Jbm_hbV7c8U0u^%0fA^l=Oht`#+v&mqY~*9q&|~#IJ8*nQ)5f&+3W_?VCAib%a{%Z(yA1G)4Rp?fzth1 zu`{p5GhRquwqhsZ_Z_$g4e3y6=A|Q+tt+-%{|(mo9avs<(Bmbro5t$ zc^(+c+t7OVMlfL66q3iTBs5+UwigYG-iG zF{Vk&B+091=e{?cUY*`s`f#>u@|>ZgH|UotCin_?ixh z_Co}Upxi~JT!&O?re`>2oAsr&Y3w89Y@CNgj3uLl$S+U}M-*suCn^V8$e zwrS$+=FQ$lDVa7~i?;MYygldGJ#K4s8saS_XzTWx?*iV3d!MS!4bSCn6fnv(HFRu3 z+pvQ}#H$aNT9%Uq{Xc~%1fbC5Av9O(VWv}MY;CTZ^bq~z%=X-f-FMtSlN<8mPX~i5 zWOIs6H6>SOy~sHg#Y|P9G|hHdW-tztVX2y2#O(p?&F_=3b*taz26FJ2>Gt&>|DHVE zq_mjZzcxKRwy0nt^M0*HLt_6h>sl*mf+=O@agR5)y-^!`d%9z3{Yvhz^pN&B^_|qF zxD|Jja-sQ^Yu*)IHVS<1=G@N-R!sd=L()s{#>$Tr556bevhLt~!#uD5#+YaoET@Ad zRzdptHb;y_N$Q9mc&vnKCSGq@+aTKD8PcWcX*zmoJK}duXxyoBnF&%jgX1=fN%Yr= z6l~N|>Zd#W|LrIu8^=H{?IcI+GcFwAWGavpSCEYNE-rNso~vC&QNK7$Cu|&}xCR=6A@dm^fG7nTeJ6vliorNkleZ zS$JQUbh>}#zZZt0jA>6cTUDE2&thq5U0TOLIu8IlZ*3ZQZoc|r!7Ua_lovEcK!lmy z!}f*LluI=i`ugnh)IXiH`UVe*kN_&G!>%x05{{6x@YLdkx20%`xF6YgZw7F*qO`<~ zuN=!UsOHo>TJ`x0XW%DFtn1)J-Xz}sC+x|aQ|_*$ZfZcJx$Em@#RZ~+15=NGSF0q& ztu`b#Z%c03>=M5LccX_FszV@u3|XC&4Kjeb=U+H8yl}ktiLSrZ4h4nkwSe<^t4IxL z7Ou>ux13PFuO#8Nm2`l3p_sYZwI?ra3mSzNb@`WSaQ=N{aTrf;-g!_*CR3G~N48#@ zR8-t0zHOTv!HOhc9b0XuA~sgI74av;%>|X^cf{kbnLn7OOjwS1G#ch(-Rs!w7g;ny zS0hj8F$PsHHPxAf^r4{)z&t;KKVLO;u;5M@8AuAQ6PJptD)T({40gWlvQp7<(C%Gj z^}{z(@3OVzl|Oq5`kpv&Hj^^neO}ZsyP}WpyceCG6=FnHQg+$Ag|MEm z3Z3=t++6)W({8rxG~29H9ctbKlP4aE4PNHly?5KF0Ir|f*1g6Rzm1b+6FE??%Y3~|q<9r5HP;bwyz&d%A zozoJ1{_sfgxqqkp(&Qn9my4qf^yL(*a&iW?F_<2K#L%QmVBS7QCRY&usAw@}7~8Nh z_D|=vs0RS4pC>I%_p9Pm@7SNQ7GxQa?B(T^-1YTgc<70kYI?A_VgMa#P5h6uD^Fy~ zP(!9BpZq6vFK2klUc}}fQO@)@LgmR)bR+JcQ^}b#HMqFUo5&tbv`nIJC8Tu6p&R1W z&WBz-=M9U9^CJvrQ0KacEu@6Q;>lBNdH9U3T6fxz)I!5cCud8df1_cKNj=N_EUs8W zW3a^GOZD>@Uw(H7!q!c0GtAoT0V6Q*<9P`k+UAC#>@7&TR?g+OieKN@5nTC##HDka zqFE(E-j6N)td;dZoi~hcYZ=h!WkVPWnbo%*9V^$^(jq4#W?9hQu3hTz_v-f0pBEgn zDV`9nM_r;!{sMm`w^JPVD5s;b*szF`0l~-pu~G41{@CEKC|>}GD|)vgEfMZYAPhPT z4cmNh0w6dklCP8^PLY)ZgD7HYDF%=HAh<>jc4%r`*gM>d?iC+OVU#gRz-MKJs1*xx Q9!LS$SlQF+EqJN_10dFW?*IS* literal 0 HcmV?d00001 diff --git a/assets/web/android-chrome-96x96.png b/assets/web/android-chrome-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..d5c377ffa62639a7e63af2b111e565b669a900e6 GIT binary patch literal 3267 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 literal 0 HcmV?d00001 diff --git a/assets/web/browserconfig.xml b/assets/web/browserconfig.xml new file mode 100644 index 0000000..bf6874e --- /dev/null +++ b/assets/web/browserconfig.xml @@ -0,0 +1,10 @@ + + + + + + + #0075c0 + + + diff --git a/assets/web/favicon-16x16.png b/assets/web/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..ca65c3f3aacc3934685d519b2b07a5fd2658f1cd GIT binary patch literal 934 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstUx|vage(c z!@6@aFM;f%0X`wFKADBA%nVY}awayBF8(#H0kxXC_O1bSu7PzToY%UH43x@<} zpDMS|DrQE8@VGioc7}k6Y8^v2phl1AN@u?+Aae38xA(}ga!xh1iL`V~wsuYfB4^(c zAn6)V1GE=t6p#T#K4JA>7lIt=>{nxG5uhxsrfck>B&h-vcMGg@@vj3abMPwkh^efs zpAj6>YHAz51JZYIs6xyFkcprTy@hER4x2f`nKe8{HDZAI+yLi>A^>L|XWnw{znccQtdG5gl zQ-HRlcg&o<{Ji+h=RDg>s;2H!{F|8B^Pi!|#v}6~{~qSW+`w37uqg@h3ua)I>Ob8Y z(AFmS!tv-ICYHj;SB&2bWR7z1Gcis5!&M)6kNq9jBcp3y!vkgZEKliMxAxi6A6tIk zJ9q!>*K_)t7X!`VEbxc~#xJ)p2s1Lwnj--e-0$h)7$R{wIYB|zqcAIbdVS+`6&FOl*p3y4n$!iru%nj3Y#R-@IBeGicIe zF?rc{FBhIlxOx3rZ2VQ5wf6e9nr4^WXC7R&r9A6!%cEPKPj}udtXrqVqqc4G?boZK zXH{(q5@L?YsgS(lY^D9_t+uSJ@tePQ=gi$9;qtV!=M2|d8JRassjU?=Zc0eBs9yc@ zrI(ktB$Z{yOo@f(riKMgocuujQa(fDMm+^Ju8mDVuY^~HM3e+2mMat#<)>xlq$-qD z7Nja<7L+72FjUNW{E3I7Fib<^l>g~7o=<}qn3cKplDUPIg}o<>FbgZVG?*MtVOHK8 sqHy}gl@mwK9FaM~e!9V9ftMb`D{;Y+Pfn&&fmSehy85}Sb4q9e04cFo6#xJL literal 0 HcmV?d00001 diff --git a/assets/web/favicon-32x32.png b/assets/web/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..dedf85693bce525533ffaec411ec062f99578779 GIT binary patch literal 1653 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 literal 0 HcmV?d00001 diff --git a/assets/web/mstile-144x144.png b/assets/web/mstile-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..2e00cd5bf41836ab921b5c92c2d92d1d697151dc GIT binary patch literal 4496 zcmZ8kcQhN`+fR@-c5IR$b{cAH#cIUfgbFn(t-a@$5SpM?HIyo`TprE8%J0Muo${w09uJ}?Ytgay=of`1197y;Jib_V7?A%a3M9$tR9h~yPx zx(&rzMA8wuA$37V2nrX13e#P>Au5g%g2L1>Zo;C{bVSR*hYrHv2#jgifB7)_3TUjm zf||wu^3}ARghi#0a@us7G)kQgN=Pd~VIp)FYaXd%97IPnu>*!gLG+?R-n1q#ph8+B&zE!L?RIN$;%+Ti66X72E<>*;P z{{?2kV4@x80-F)Xt8D+$M1GwD?smYpy5JkV(3Jtfxg&<`tNd6!%cyDY-feLC0aM;2 zPyc(wVWarjBFB5Ui1;x7HGTWQo^TlI)qf6|KEusgPb)0lfL!^+ZYFc<*vj{w& z&Y-}|CUey+q+^7CyhLR9gUHWz;hk>S+GpP3q5dx8t8pKpOZ`HNO=2f=khTq$nnT71 zikHka_1s+>ETv#F7vwbd&J|rT_P4;3CwK?vIcYOoT|4JXe}M`W)r?d%Yz97d$SGpP zBv6vl@~zEHYpiwOSzc%xdg)t6Ofp4YfTPg5ZXyV&hWdt4fqA2Au_o8!Qc|e~LK+{t z+q&9mmtiV*GK&8p<<`{HlPI|kgk%qIVnXs`Ej`!5!l&jqUsafrB3vOfoE#E)-_|*i z1`e|bA!TMibE{4L)5IEE4yBn1Xd9Wk%yRcqsW}CYt85a&O}*l*qKMu> z8II2035jW%EITxC*T8!MOz!Zh#E8X1L^>X`DUjkP(th1cT@@osc z*Bvmh)D;&c(}(p>>`n`BUHbbLL7~z6GTcO8*Dh*$^C2;uXM(S*lUpHiKyZ^hb;J8{ z)Q38gc;*Fb>`0-?(zT^~a!b1ox<%KJ95MP1RXCianb=dzGmNb!E`X(uAGR5F!BuRI z=Hy_0Gk3O>mC4lGc)CnGHpnomq2K#kX|L$#Nb2A{?W~p6nAU^s_bTm6hs(Lw6C+7 ze(h4YE_6HnWwE`yC^pkDBmXTzM+r^1KuLd_Qp(($y2~m}UHo@ckd`SZEc_mcz93K7 zZL7#T1KyoAvtbpG>5!6AU??|Z3co2B9FOk+CglP0(-FqsoVZLN%8nSeqB~Apo1f|t z7uKEH-1P65UR&tO#vtY^Quig#s*yRql=GA|$b zptt(@ICn|sGnAx1_~_xrYS5z>=uV7ej7Q@|!%=}SPg4TQrWm{aZLLUOsW={WlX7?U z@2Y^pT$4?S7t65v%-4+7D$cxdB&0Z=0Omtk1kbL$xOFNv^nTA=jOBQdw{gfI%p?6J z7({w!EXmDjun;DW7R{*!v{&v4hz%uA_~wQ3qp7YwCU7V5Icb3%gJL#40|SF;H;|4k zp$C%s$2*mOk0Vj801ayNCNbpRmczCf`x|d~2lb0ILt!(TD7G71PhMn+m#^J-Y5u^J zXc6b*`%3WLMS+@f0SaooIEe zgmn}JDN(cgA9*NMVLZegw*VxHpaEyCLvyAqBXR#+D*C4gP z8?g^o%1{2p-$A^!S2j53u*3@a`Ln`{w#K*mOTgsd^T^1ES^4AC9g-MRbOC!x+tj*` z2+GN6Zl2^lstc`1V+NPC-^-ff@uY0e5lf_-`oCjxqm4Nc55Pn<&fE373rDS^nA>ca z0WW!HZ{Mx}r-&i*p%#sxv2Q{yzP`TE-_iN7q4oYZLiqdw;<*PJk1wW#xt& zKNZoAx*G!uRURw^?!jkt6(XSLlVlEzKg#0~S@(^RqKw^T(-8)&l)~6)n~##S0y;oc zDRsSSabsK@+QUE@e+xWx7fGp??c|X3&c>bn*%}`$A5?9Bp|iDMa;3T~Z7Ph4GraVD z;pmlUvhQ09DXL;LLtTMaYdtYE18!m>1Ylt262IVk5#*?d*Au-S%z!FMn_|^+6=#Q-j3Z&_kt|Ad_LOruuFJja(WHL`f z8xtz7-(4#(U3>@iweT2|Kh*-5zT!JB{xm%;%UM+^m@hguKE6LcVlA9g+yU%xkZI2A zX0Mn8A>WJM=MEUm#edqLSI!xRT*;%smMUs|b4*KpDkna~R#-8{*;I1j5i8OOAGDfq zdE`()=4jhU1b_T|11JY-Q9AAFk`Tqy-T2Gj#Z&mbZ31|t6e9bEu`Z&5su41z;V8OU zdD0F%v23?YVG+>AT5SzYDd|;T@DaI;V09iaz(dIy5xE)`g^izO%R(Idpa``#z_+@w1ES-)D;dI8_d;;cW;iTRin*=RpAjQ(kz}<( z0N!{q-7Zc{b;eiIBrOBgU)IrWrdJuDYhq5x55KBdqbHsj>+iS{fLXi|4VOOX*jDM6N^!+YsXt_`ar0DL78D%f>AywE_y{|sP^!SVPfecTsRxOZll6622 zuUX+{A{^s5#cM7038b2T`N7nc+3>KbA@LG&2jAWIky9FWmNh3;Z|bo9Mmb+FDpCD- ztt|=X*bIAL2261pwNbNmCNyvV=iK{u2NN9FgVw5z3dkY2Beatz=(Y>?X!#P0F+%y7 zN8E`~#US~V?HA9J-)qAqw>-*(^ck*`u~TG815QkaORO2_99|4X&b?i~5gk*XR5Ul& z=pW@cX;dWT-(*cRyX7VN2qXtcQTSM-@W_kf+SJ`GVV@*6E>S=ZJ0o~_e%l{?{*ApE zNArXvH;irPQ`@>O%bc^KV23yaVdtnAj-Wq^=N`X1lJ;RN4s1zDMn>%!0Lb530Clr? zZ#CO8`K*P*vAYUDH|VPcb`;_X?m>sM#!mJ3vBYE1d|uv(d-q=IZ|HqbxxU6%VZ_>9 z#KnMGVS((Ak>=I}@)f?#9EgG9{S2k`v3_EBvO5<=%N4w&!j!dL|2j6M@u@mDCX^jd zPMI2wk7^#CKfaevamLNzjy;~3n9YX@Q~n_L&`<~SOEH$#h-S)VS@CoN81Xr0-w~vs zV86n~dA^~ldC$#wa*r$n)+tlDKsx=VI1-i)&%3JljE1|-lAMPoP-)km?>|&XXDfgU zfumMzIB^#2oN3PdP)rn2oKBK)7tzdjUQh>W-0pSO_Xdi%40`7aT?x4$ZiNFhS`^tTZy5Mwzc zxy_wLr8H-1i)~q2hq8QMqv?lYw)v*#tENu>S1oNVp9>jXJ=5R3-L_lxlT+`>_(8gb z-K8+M@LhEXMuY^06ScH{c90#&ey2yP%ZrEKPfxo;i3dW>kS`Z=ul#t@a;2FR>-_g2 z|5nb;Gi7++(cMvbg733wymR3Uzk5{#C31k4U|v%@3@&fyv8o#`XYkj0JSdP+Kec(V zapRPIJi^Ulb?jZc&!pm|@t3|;9W6aucEgI63m443SIaq;@OTAlE&tFoxbEiU9ljEp zxk$+#lJ#>?P`=i0Xu;Q&LERV8z!~`o6;|dmeU=c&e%%4sTg6}(&Hp|S-5#wmL&MFeN$6_Wp z%RV4`v8aE?LuyuhnNCA4*lC7D=hs};%ST8rk(%HCGF^7k*C~V~!Y@T=ZlFyH=4f`f z&PLk9Si??JrD#a?xUbACbyOu?4mSBuNoH+sD|<|)N78VZ#_Y4%_yoI`wAl6BNw*p}f>CPOa5?;A_4;g5VHh9IYe?2FVfX?Guk8?Jy#jiGx<~YI1 zbye%7K)0uoHVd_5z4Q6nmOMcIB_j%zjgR%JBM%_ArcWB8szEY?|}#>274fUg2QhD08x*YezJi>?2*Wi zR(<^@%Qyg|1grc_Rvrl+vus{Q9)vzwwcVXjqTUYCH#yeR(cb~@INwJ$E;5w|tP603 U|HP{0(6<1X7+C7pV%_5Z2Wx=&*#H0l literal 0 HcmV?d00001 diff --git a/assets/web/mstile-150x150.png b/assets/web/mstile-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..1ed92d988ac846e74fd15b9b983f772d6e05c51e GIT binary patch literal 4190 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 bvnCjC2SDk2q$Hm9pBOb%bd + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/web/site.webmanifest b/assets/web/site.webmanifest new file mode 100644 index 0000000..6112b70 --- /dev/null +++ b/assets/web/site.webmanifest @@ -0,0 +1,57 @@ +{ + "name": "TotalGBA", + "short_name": "TotalGBA", + "icons": [ + { + "src": "/android-chrome-36x36.png", + "sizes": "36x36", + "type": "image/png" + }, + { + "src": "/android-chrome-48x48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "/android-chrome-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "/android-chrome-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/android-chrome-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "/android-chrome-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "fullscreen", + "description": "gba emulator written in c++23", + "categories": ["entertainment", "games"], + "orientation": "landscape" +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 24b5c29..f32caff 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -32,6 +32,7 @@ list(APPEND gcc_flags -Wuninitialized -Wno-unused-parameter + -Wno-unknown-attributes -fimplicit-constexpr -Wmissing-requires @@ -45,58 +46,60 @@ endif() list(APPEND clang_flags -Wall - -Wextra + # -Wextra -Wfatal-errors - -Wpedantic - -Wshadow - -Wdouble-promotion - -Wformat=2 - -Wundef - -Wmissing-include-dirs - -Wstrict-aliasing - -Wstrict-overflow=5 - -Walloca - -Wwrite-strings - -Wdate-time - -Wpacked - -Wnested-externs - -Wcast-qual - -Wcast-align - -Wunused-macros - -Wreserved-id-macro - -Wbad-function-cast - -Wbitfield-enum-conversion - - -Wextra-semi-stmt - -Wold-style-cast - -Wcovered-switch-default - - -Wno-unused-parameter - -Wno-unused-variable - -Wextra-semi-stmt - -Wold-style-cast - - # can try enabling this just to see what breaks (still compiles as of clang 11) - # be sure to enable (well, disable) the below flags else a *lot* of "errors" - # -Weverything - -Wno-c++98-compat - -Wno-c++98-compat-pedantic - -Wno-c++20-compat - -Wno-missing-braces # false positive - -Wno-unused-parameter - -Wno-unused-variable - -Wno-conversion - -Wno-sign-conversion - -Wno-missing-prototypes - -Wno-padded - -Wno-switch-enum + # -Wpedantic + -fno-rtti -fno-exceptions + # -Wshadow + # -Wdouble-promotion + # -Wformat=2 + # -Wundef + # -Wmissing-include-dirs + # -Wstrict-aliasing + # -Wstrict-overflow=5 + # -Walloca + # -Wwrite-strings + # -Wdate-time + # -Wpacked + # -Wnested-externs + # -Wcast-qual + # -Wcast-align + # -Wunused-macros + # -Wreserved-id-macro + # -Wbad-function-cast + # -Wbitfield-enum-conversion + + # -Wextra-semi-stmt + # -Wold-style-cast + # -Wcovered-switch-default + + # -Wno-unused-parameter + # -Wno-unused-variable + # -Wextra-semi-stmt + # -Wold-style-cast + + # # can try enabling this just to see what breaks (still compiles as of clang 11) + # # be sure to enable (well, disable) the below flags else a *lot* of "errors" + # # -Weverything + # -Wno-c++98-compat + # -Wno-c++98-compat-pedantic + # -Wno-c++20-compat + # -Wno-missing-braces # false positive + # -Wno-unused-parameter + # -Wno-unused-variable + # -Wno-conversion + # -Wno-sign-conversion + # -Wno-missing-prototypes + # -Wno-padded + # -Wno-switch-enum + -Wno-unknown-attributes ) if (CMAKE_BUILD_TYPE STREQUAL "Debug") list(APPEND clang_flags -Og) # always enable otherwise debug builds would be too slow else() - list(APPEND clang_flags -Ofast) + list(APPEND clang_flags -O3) endif() list(APPEND msvc_flags diff --git a/src/core/apu/apu.cpp b/src/core/apu/apu.cpp index b6b245d..3ed61dc 100644 --- a/src/core/apu/apu.cpp +++ b/src/core/apu/apu.cpp @@ -993,7 +993,15 @@ auto reset(Gba& gba, bool skip_bios) -> void // todo: reset all apu regs properly if skipping bios APU.fifo[0].reset(); APU.fifo[1].reset(); - scheduler::add(gba, scheduler::Event::APU_SAMPLE, on_sample_event, SAMPLE_TICKS); + if (gba.audio_callback && !gba.sample_data.empty() && gba.sample_rate_calculated) + { + scheduler::add(gba, scheduler::Event::APU_SAMPLE, on_sample_event, gba.sample_rate_calculated); + } + else + { + scheduler::remove(gba, scheduler::Event::APU_SAMPLE); + } + // scheduler::add(gba, scheduler::Event::APU_SAMPLE, on_sample_event, SAMPLE_TICKS); if (skip_bios) { @@ -1075,16 +1083,28 @@ auto Fifo::sample() const -> s8 return this->current_sample >> VOL_SHIFT[this->volume_code]; } +static auto push_sample(Gba& gba, s16 left, s16 right) +{ + gba.sample_data[gba.sample_count++] = left; + gba.sample_data[gba.sample_count++] = right; + + if (gba.sample_count >= gba.sample_data.size()) + { + gba.audio_callback(gba.userdata); + gba.sample_count = 0; + } +} + auto sample(Gba& gba) { - if (gba.audio_callback == nullptr) [[unlikely]] + if (gba.audio_callback == nullptr || gba.sample_data.empty()) [[unlikely]] { return; } if (!is_apu_enabled(gba)) [[unlikely]] { - gba.audio_callback(gba.userdata, 0, 0); + push_sample(gba, 0, 0); return; } @@ -1161,7 +1181,7 @@ auto sample(Gba& gba) sample_right <<= scales[resample_mode]; } - gba.audio_callback(gba.userdata, sample_left, sample_right); + push_sample(gba, sample_left, sample_right); } auto on_sample_event(Gba& gba) -> void diff --git a/src/core/backup/backup.hpp b/src/core/backup/backup.hpp index 8b17f5d..9873780 100644 --- a/src/core/backup/backup.hpp +++ b/src/core/backup/backup.hpp @@ -11,7 +11,7 @@ namespace gba::backup { -enum class Type +enum class Type : u8 { NONE, // no backup chip EEPROM, // 512bytes @@ -31,6 +31,7 @@ struct Backup }; Type type; + bool dirty_ram; }; STATIC auto find_type(std::span rom) -> Type; diff --git a/src/core/backup/eeprom.cpp b/src/core/backup/eeprom.cpp index fefc3b6..ac423cf 100644 --- a/src/core/backup/eeprom.cpp +++ b/src/core/backup/eeprom.cpp @@ -120,7 +120,7 @@ auto Eeprom::read([[maybe_unused]] Gba& gba, [[maybe_unused]] u32 addr) -> u8 return value; } -auto Eeprom::write([[maybe_unused]] Gba& gba, [[maybe_unused]] u32 addr, u8 value) -> void +auto Eeprom::write(Gba& gba, [[maybe_unused]] u32 addr, u8 value) -> void { this->bits <<= 1; this->bits |= value & 1; // shift in only 1 bit at a time @@ -178,6 +178,7 @@ auto Eeprom::write([[maybe_unused]] Gba& gba, [[maybe_unused]] u32 addr, u8 valu this->bits = 0; } } + gba.backup.dirty_ram = true; } break; } diff --git a/src/core/backup/flash.cpp b/src/core/backup/flash.cpp index 21b5758..8700d01 100644 --- a/src/core/backup/flash.cpp +++ b/src/core/backup/flash.cpp @@ -92,7 +92,7 @@ auto Flash::read([[maybe_unused]] Gba& gba, u32 addr) const -> u8 return this->data[this->bank + addr]; } -auto Flash::write([[maybe_unused]] Gba& gba, u32 addr, u8 value) -> void +auto Flash::write(Gba& gba, u32 addr, u8 value) -> void { addr &= 0xFFFF; @@ -121,6 +121,7 @@ auto Flash::write([[maybe_unused]] Gba& gba, u32 addr, u8 value) -> void else if (this->command == SingleData) { this->data[this->bank + addr] = value; + gba.backup.dirty_ram = true; } // there's 2 exit sequences for chipID used in different chips // games don't bother to detect which chip is what. @@ -163,6 +164,7 @@ auto Flash::write([[maybe_unused]] Gba& gba, u32 addr, u8 value) -> void case EraseAll: std::ranges::fill(this->data, 0xFF); + gba.backup.dirty_ram = true; break; default: @@ -177,6 +179,7 @@ auto Flash::write([[maybe_unused]] Gba& gba, u32 addr, u8 value) -> void for (auto i = 0; i < 0x1000; i++) { this->data[this->bank + page + i] = 0xFF; + gba.backup.dirty_ram = true; } } else diff --git a/src/core/backup/sram.cpp b/src/core/backup/sram.cpp index e3d21da..3919105 100644 --- a/src/core/backup/sram.cpp +++ b/src/core/backup/sram.cpp @@ -42,7 +42,8 @@ auto Sram::read([[maybe_unused]] Gba& gba, u32 addr) const -> u8 auto Sram::write([[maybe_unused]] Gba& gba, u32 addr, u8 value) -> void { - this->data[ addr & SRAM_MASK] = value; + this->data[addr & SRAM_MASK] = value; + gba.backup.dirty_ram = true; } } // namespace gba::backup::eeprom diff --git a/src/core/fwd.hpp b/src/core/fwd.hpp index 4844c74..d34f8f8 100644 --- a/src/core/fwd.hpp +++ b/src/core/fwd.hpp @@ -5,6 +5,41 @@ #include +#ifdef EMSCRIPTEN +#include + +namespace std::ranges { + +constexpr auto fill(auto& array, auto value) -> void +{ + for (auto& entry : array) + { + entry = value; + } +} + +constexpr auto copy(const auto& src, auto dst) -> void +{ + std::size_t i = 0; + + for (auto& entry : src) + { + dst[i++] = entry; + } +} + +} // namespace std::ranges + +namespace std { + +[[noreturn]] inline void unreachable() +{ + __builtin_unreachable(); +} + +} // namespace std +#endif // EMSCRIPTEN + #if 0 #include #include diff --git a/src/core/gba.cpp b/src/core/gba.cpp index 6ad41da..9afc992 100644 --- a/src/core/gba.cpp +++ b/src/core/gba.cpp @@ -208,6 +208,23 @@ auto Gba::setkeys(u16 buttons, bool down) -> void } } +auto Gba::set_audio_callback(AudioCallback cb, std::span data, u32 sample_rate )-> void +{ + this->audio_callback = cb; + this->sample_data = data; + this->sample_count = 0; + this->sample_rate_calculated = 280896 * 60 / sample_rate; + + if (this->audio_callback && !this->sample_data.empty() && this->sample_rate_calculated) + { + scheduler::add(*this, scheduler::Event::APU_SAMPLE, apu::on_sample_event, this->sample_rate_calculated); + } + else + { + scheduler::remove(*this, scheduler::Event::APU_SAMPLE); + } +} + auto Gba::get_render_mode() -> u8 { return ppu::get_mode(*this); @@ -285,7 +302,6 @@ auto Gba::savestate(State& state) const -> bool return true; } -// load a save from data, must be used after a game has loaded auto Gba::loadsave(std::span new_save) -> bool { using enum backup::Type; @@ -303,7 +319,13 @@ auto Gba::loadsave(std::span new_save) -> bool std::unreachable(); } -// returns empty spam if the game doesn't have a save +auto Gba::is_save_dirty()-> bool +{ + const auto result = this->backup.dirty_ram; + this->backup.dirty_ram = false; + return result; +} + auto Gba::getsave() const -> std::span { using enum backup::Type; diff --git a/src/core/gba.hpp b/src/core/gba.hpp index 812f512..b4dcf39 100644 --- a/src/core/gba.hpp +++ b/src/core/gba.hpp @@ -34,12 +34,16 @@ enum Button : u16 DIRECTIONAL = UP | DOWN | LEFT | RIGHT, // causes a reset if these buttons are pressed all at once RESET = A | B | START | SELECT, + + // all button pressed at once, useful for clearing all buttons + // at the same time. + ALL = A | B | SELECT | START | RIGHT | LEFT | UP | DOWN | R | L, }; struct State; struct Header; -using AudioCallback = void(*)(void* user, s16 left, s16 right); +using AudioCallback = void(*)(void* user); using VblankCallback = void(*)(void* user); using HblankCallback = void(*)(void* user, u16 line); @@ -75,14 +79,18 @@ struct Gba // load a save from data, must be used after a game has loaded [[nodiscard]] auto loadsave(std::span new_save) -> bool; - // returns empty spam if the game doesn't have a save + // checks if the save has been written to. + // the flag is cleared upon calling this function. + [[nodiscard]] auto is_save_dirty()-> bool; + // returns empty span if the game doesn't have a save + // call is_save_dirty() first to see if the game needs saving! [[nodiscard]] auto getsave() const -> std::span; // OR keys together auto setkeys(u16 buttons, bool down) -> void; auto set_userdata(void* user) { this->userdata = user; } - auto set_audio_callback(AudioCallback cb) { this->audio_callback = cb; } + auto set_audio_callback(AudioCallback cb, std::span data, u32 sample_rate = 65536) -> void; auto set_vblank_callback(VblankCallback cb) { this->vblank_callback = cb; } auto set_hblank_callback(HblankCallback cb) { this->hblank_callback = cb; } @@ -93,6 +101,10 @@ struct Gba bool bit_crushing{false}; void* userdata{}; + std::span sample_data; + std::size_t sample_count; + std::uint32_t sample_rate_calculated; + AudioCallback audio_callback{}; VblankCallback vblank_callback{}; HblankCallback hblank_callback{}; diff --git a/src/frontend/CMakeLists.txt b/src/frontend/CMakeLists.txt index aeb1625..337beaa 100644 --- a/src/frontend/CMakeLists.txt +++ b/src/frontend/CMakeLists.txt @@ -8,6 +8,7 @@ target_link_libraries(frontend_base PUBLIC GBA) target_include_directories(frontend_base PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) set_target_properties(frontend_base PROPERTIES CXX_STANDARD 23) + target_add_common_cflags(frontend_base PRIVATE) target_apply_lto_in_release(frontend_base) @@ -18,18 +19,17 @@ FetchContent_Declare(minizip GIT_REPOSITORY https://github.com/madler/zlib.git GIT_TAG v1.2.12 GIT_PROGRESS TRUE - CONFIGURE_COMMAND "" - BUILD_COMMAND "" ) set(FOUND_MINIZIP FALSE) + # NOTE: these seems to be broken, tested using ubuntu in vm # if (VCPKG_TOOLCHAIN) # set(DISABLE_INSTALL_TOOLS ON) # find_package(minizip CONFIG) # if (DEFINED minizip_FOUND) -# target_link_libraries(frontend_base PRIVATE minizip::minizip) +# target_link_libraries(frontend_base PUBLIC minizip::minizip) # set(FOUND_MINIZIP TRUE) # message(STATUS "using vcpkg minizip") @@ -42,8 +42,8 @@ set(FOUND_MINIZIP FALSE) find_path(minizip_inc minizip) if (minizip_lib AND minizip_inc) - target_link_libraries(frontend_base PRIVATE ${minizip_lib}) - target_include_directories(frontend_base PRIVATE ${minizip_inc}) + target_link_libraries(frontend_base PUBLIC ${minizip_lib}) + target_include_directories(frontend_base PUBLIC ${minizip_inc}) # this has to be linked after minizip! find_package(ZLIB REQUIRED) @@ -70,21 +70,28 @@ if (NOT FOUND_MINIZIP) ${minizip_SOURCE_DIR}/contrib/minizip/unzip.c ) - target_include_directories(minizip PRIVATE ${minizip_SOURCE_DIR}/contrib/minizip/) + target_include_directories(minizip PUBLIC ${minizip_SOURCE_DIR}/contrib/minizip/) # need the paths to be minizip/unzip.h target_include_directories(minizip PUBLIC ${minizip_SOURCE_DIR}/contrib/) find_package(ZLIB REQUIRED) - target_link_libraries(minizip PRIVATE ZLIB::ZLIB) - target_link_libraries(frontend_base PRIVATE minizip) + target_link_libraries(minizip PUBLIC ZLIB::ZLIB) + target_link_libraries(frontend_base PUBLIC minizip) message(STATUS "using github minizip") endif() if (IMGUI) + add_subdirectory(sdl2_base) add_subdirectory(imgui) endif() if (SDL2) + add_subdirectory(sdl2_base) add_subdirectory(sdl2) endif() + +if (EMSCRIPTEN) + add_subdirectory(sdl2_base) + add_subdirectory(emscripten) +endif() diff --git a/src/frontend/emscripten/CMakeLists.txt b/src/frontend/emscripten/CMakeLists.txt new file mode 100644 index 0000000..ecc4858 --- /dev/null +++ b/src/frontend/emscripten/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.20.0) + +project(notorious_beeg_EMSDK LANGUAGES CXX) + +add_executable(notorious_beeg_EMSDK main.cpp) + +target_link_libraries(notorious_beeg_EMSDK PRIVATE sdl2_base) + +target_add_common_cflags(notorious_beeg_EMSDK PRIVATE) +target_apply_lto_in_release(notorious_beeg_EMSDK) + +set_target_properties(notorious_beeg_EMSDK PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + CXX_STANDARD 23 + SUFFIX ".html" + OUTPUT_NAME "index" +) + +option(EM_USE_THREADS + "enables building with threads + this limits the number of browsers / hosts this + emulator can be run on, however, audio should + pop less frequently!" OFF) + +option(EMRUN "build with emrun support, used for testing" OFF) + +if (EM_USE_THREADS) + target_link_options(notorious_beeg_EMSDK PRIVATE "-pthread") +endif() + +if (EMRUN) + target_link_options(notorious_beeg_EMSDK PRIVATE "--emrun") +endif() + +file(COPY "${CMAKE_SOURCE_DIR}/assets/buttons" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}/assets") +file(COPY "${CMAKE_SOURCE_DIR}/assets/menu" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}/assets") +file(COPY "${CMAKE_SOURCE_DIR}/assets/web" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") +file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/netlify.toml" DESTINATION "${CMAKE_BINARY_DIR}/bin/") + +target_link_options(notorious_beeg_EMSDK PRIVATE + "-s;--shell-file;${CMAKE_CURRENT_SOURCE_DIR}/emscripten.html" + "-s;EXPORTED_FUNCTIONS=[_malloc,_free]" + "-sINITIAL_MEMORY=150mb" + "-lidbfs.js" + "--preload-file;${CMAKE_CURRENT_BINARY_DIR}/assets/@assets" + "--use-preload-plugins" +) diff --git a/src/frontend/emscripten/README.md b/src/frontend/emscripten/README.md new file mode 100644 index 0000000..488edb6 --- /dev/null +++ b/src/frontend/emscripten/README.md @@ -0,0 +1,11 @@ +# emscripten-port + +the src is very similar to the base sdl2 port. +i am not a fan of having #ifdef everywhere in the code, so i decided to make 2 seperate code paths + +## how the audio works + +so the idea is simple. if the audio thread doesnt have enough samples, it'll run until it has enough samples. +so both the main thread and audio thread can run the emu core. + +it's the same code i used in my totalgb emulator, which produced good results on every browser. downside is if too many samples are generated, then samples are simply dropped, which is not ideal, and will sound awful. diff --git a/src/frontend/emscripten/emscripten.html b/src/frontend/emscripten/emscripten.html new file mode 100644 index 0000000..08fc978 --- /dev/null +++ b/src/frontend/emscripten/emscripten.html @@ -0,0 +1,324 @@ + + + + + + TotalGBA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

emscripten
+
Downloading...
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + {{{ SCRIPT }}} + + diff --git a/src/frontend/emscripten/main.cpp b/src/frontend/emscripten/main.cpp new file mode 100644 index 0000000..9b8b83c --- /dev/null +++ b/src/frontend/emscripten/main.cpp @@ -0,0 +1,1213 @@ +// Copyright 2022 TotalJustice. +// SPDX-License-Identifier: GPL-3.0-only +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +// intellisense is having a bad day, i cba to fix it +#if 1 +#include +#include +#include +#else +#include +#include +#include +#endif + +namespace { + +enum class Menu +{ + ROM, + SIDEBAR, +}; + +enum TouchID : int +{ + TouchID_A, + TouchID_B, + TouchID_L, + TouchID_R, + TouchID_UP, + TouchID_DOWN, + TouchID_LEFT, + TouchID_RIGHT, + TouchID_START, + TouchID_SELECT, + TouchID_OPTIONS, + TouchID_TITLE, + TouchID_OPEN, + TouchID_SAVE, + TouchID_LOAD, + TouchID_BACK, + TouchID_IMPORT, + TouchID_EXPORT, + TouchID_FULLSCREEN, + TouchID_AUDIO, + TouchID_FASTFORWARD, + TouchID_MAX, +}; + +constexpr auto get_touch_id_asset(TouchID id) +{ + switch (id) + { + case TouchID_A: return "assets/buttons/a.png"; + case TouchID_B: return "assets/buttons/b.png"; + case TouchID_L: return "assets/buttons/l.png"; + case TouchID_R: return "assets/buttons/r.png"; + case TouchID_UP: return "assets/buttons/dpad_up.png"; + case TouchID_DOWN: return "assets/buttons/dpad_down.png"; + case TouchID_LEFT: return "assets/buttons/dpad_left.png"; + case TouchID_RIGHT: return "assets/buttons/dpad_right.png"; + case TouchID_START: return "assets/buttons/start.png"; + case TouchID_SELECT: return "assets/buttons/select.png"; + case TouchID_OPTIONS: return "assets/buttons/setting_sandwich.png"; + case TouchID_TITLE: return "assets/menu/title.png"; + case TouchID_OPEN: return "assets/menu/open.png"; + case TouchID_SAVE: return "assets/menu/save.png"; + case TouchID_LOAD: return "assets/menu/load.png"; + case TouchID_BACK: return "assets/menu/back.png"; + case TouchID_IMPORT: return "assets/menu/import.png"; + case TouchID_EXPORT: return "assets/menu/export.png"; + // case TouchID_FULLSCREEN: return "assets/buttons/fullscreen.png"; + // case TouchID_AUDIO: return "assets/buttons/volume_on.png"; + // case TouchID_FASTFORWARD: return "assets/buttons/fastForward.png"; + case TouchID_FULLSCREEN: return "assets/buttons/larger.png"; + case TouchID_AUDIO: return "assets/buttons/musicOn.png"; + case TouchID_FASTFORWARD: return "assets/buttons/fastForward.png"; + case TouchID_MAX: return "NULL"; + } + + return "NULL"; +} + +struct TouchButton +{ + SDL_Texture* texture{}; + SDL_Rect rect{}; + int w{}; + int h{}; + bool enabled{}; + bool dragable{}; +}; + +struct TouchCacheEntry +{ + SDL_FingerID finger_id; + TouchID touch_id; + bool down; +}; + +struct RomEventData +{ + ~RomEventData() + { + if (data != nullptr) + { + std::free(this->data); + } + } + + char name[256]{}; + std::uint8_t* data{}; + std::size_t len{}; +}; + +struct ButtonEventData +{ + int type; + int down; +}; + +std::uint32_t ROM_LOAD_EVENT = 0; + +// returns the number of files zipped +auto zip_saves() -> std::size_t +{ + // auto zfile = zipOpen2_64(nullptr, APPEND_STATUS_CREATE, nullptr, &def); + auto zfile = zipOpen64("TotalGBA_saves.zip", APPEND_STATUS_CREATE); + if (!zfile) + { + std::printf("failed to zip open in memory\n"); + return 0; + } + + // const char* folders[] = { "/save", "/state" }; + const char* folders[] = { "/save" }; + std::size_t count{}; + + for (auto& folder : folders) + { + for (auto& entry : std::filesystem::recursive_directory_iterator{folder}) + { + if (entry.is_regular_file()) + { + // get the fullpath + const auto path = entry.path().string(); + std::ifstream fs{path, std::ios::binary}; + + if (fs.good()) + { + // read file into buffer + const auto file_size = entry.file_size(); + std::vector buffer(file_size); + fs.read(buffer.data(), buffer.size()); + + // open the file inside the zip + if (ZIP_OK != zipOpenNewFileInZip(zfile, + path.c_str(), // filepath + nullptr, // info, optional + nullptr, 0, // extrafield and size, optional + nullptr, 0, // extrafield-global and size, optional + "TotalGBA", // comment, optional + Z_DEFLATED, // mode + Z_DEFAULT_COMPRESSION // level + )) { + std::printf("failed to open file in zip: %s\n", path.c_str()); + continue; + } + + // write out the entire file + if (Z_OK != zipWriteInFileInZip(zfile, buffer.data(), buffer.size())) + { + std::printf("failed to write file in zip: %s\n", path.c_str()); + } + else + { + count++; + } + + // don't forget to close when done! + if (Z_OK != zipCloseFileInZip(zfile)) + { + std::printf("failed to close file in zip: %s\n", path.c_str()); + } + } + else + { + std::printf("failed to open file %s\n", path.c_str()); + } + } + } + } + + zipClose(zfile, "TotalGBA"); + + // if (mzmem.buf) + // { + // hacky_ptr = mzmem.buf; + // return mzmem.size; + // } + + return count; +} + +struct App final : frontend::sdl2::Sdl2Base +{ + App(int argc, char** argv); + ~App() override; + +public: + auto loadsave(const std::string& path = "") -> bool override; + auto savegame(const std::string& path = "") -> bool override; + + auto loadstate(const std::string& path = "") -> bool override; + auto savestate(const std::string& path = "") -> bool override; + +private: + auto render() -> void override; + + auto change_menu(Menu new_menu) -> void; + + auto on_touch_button_change(TouchID touch_id, bool down) -> void; + auto is_touch_in_range(int x, int y) -> int; + + auto on_touch_up(std::span cache, SDL_FingerID id) -> void; + auto on_touch_down(std::span cache, SDL_FingerID id, int x, int y) -> void; + auto on_touch_motion(std::span cache, SDL_FingerID id, int x, int y) -> void; + + auto on_key_event(const SDL_KeyboardEvent& e) -> void override; + auto on_user_event(SDL_UserEvent& e) -> void override; + auto on_controlleraxis_event(const SDL_ControllerAxisEvent& e) -> void override; + auto on_controllerbutton_event(const SDL_ControllerButtonEvent& e) -> void override; + auto on_mousebutton_event(const SDL_MouseButtonEvent& e) -> void override; + auto on_mousemotion_event(const SDL_MouseMotionEvent& e) -> void override; + auto on_touch_event(const SDL_TouchFingerEvent& e) -> void override; + + auto is_fullscreen() const -> bool override; + auto toggle_fullscreen() -> void override; + + auto resize_emu_screen() -> void override; + auto rom_file_picker() -> void override; + + auto on_speed_change() -> void; + auto on_audio_change() -> void; + +public: + TouchButton touch_buttons[TouchID_MAX]{}; + TouchCacheEntry touch_entries[10]{}; // 10 fingers max + TouchCacheEntry mouse_entries[1]{}; // 1 mouse max + bool touch_hidden{false}; + + Menu menu{Menu::ROM}; + SDL_TimerID sram_sync_timer; +}; + +constexpr auto get_scale(float minw, float minh, float w, float h) +{ + const auto scale_w = w / minw; + const auto scale_h = h / minh; + return std::min(scale_w, scale_h); +} + +auto em_set_loadrom_button_visibility(bool visible) +{ + EM_ASM({ + let button = document.getElementById('RomFilePicker'); + if (arguments[0]) { + button.style.visibility = 'visible'; + } else { + button.style.visibility = 'hidden'; + } + }, visible); + + EM_ASM({ + let button = document.getElementById('DlSaves'); + if (arguments[0]) { + button.style.visibility = 'visible'; + } else { + button.style.visibility = 'hidden'; + } + }, visible); +} + +auto em_idbfs_mkdir(const std::string& path, bool mount = true) -> void +{ + EM_ASM({ + let path = UTF8ToString(arguments[0]); + let mount = arguments[1]; + + if (!FS.analyzePath(path).exists) { + FS.mkdir(path); + } + + if (mount) { + FS.mount(IDBFS, {}, path); + } + }, path.c_str(), mount); +} + +auto em_idbfs_syncfs(bool populate = false) -> void +{ + EM_ASM({ + let populate = arguments[0]; + + FS.syncfs(populate, function (err) { + if (err) { + console.log(err); + } + }); + }, populate); +} + +auto em_loop(void* user) -> void +{ + auto app = static_cast(user); + app->step(); +} + +auto sdl2_sram_timer_callback(Uint32 interval, void* user) -> Uint32 +{ + auto app = static_cast(user); + if (app->has_rom) + { + std::scoped_lock lock{app->core_mutex}; + app->savegame(""); + } + // std::printf("callback!\n"); + return interval; +} + +auto sdl2_audio_callback(void* user, Uint8* data, int len) -> void +{ + auto app = static_cast(user); + app->fill_audio_data_from_stream(data, len); +} + +auto on_vblank_callback(void* user) -> void +{ + auto app = static_cast(user); + app->update_pixels_from_gba(); +} + +auto on_audio_callback(void* user) -> void +{ + auto app = static_cast(user); + app->fill_stream_from_sample_data(); +} + +App::App(int argc, char** argv) : frontend::sdl2::Sdl2Base(argc, argv) +{ + if (!running) + { + return; + } + + running = false; + + if (!init_audio(this, sdl2_audio_callback, on_audio_callback)) + { + return; + } + + ROM_LOAD_EVENT = SDL_RegisterEvents(1); + + gameboy_advance.set_userdata(this); + gameboy_advance.set_vblank_callback(on_vblank_callback); + gameboy_advance.set_audio_callback(on_audio_callback, sample_data); + + // setup idbfs + em_idbfs_mkdir("/save"); + em_idbfs_mkdir("/state"); + em_idbfs_syncfs(true); + + for (auto i = 0; i < TouchID_MAX; i++) + { + int w; + int h; + if (auto pixel_data = emscripten_get_preloaded_image_data(get_touch_id_asset(static_cast(i)), &w, &h)) + { + if (auto tex = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ABGR8888, SDL_TEXTUREACCESS_STATIC, w, h)) + { + if (!SDL_UpdateTexture(tex, nullptr, pixel_data, 4 * w)) + { + if (i <= TouchID_OPTIONS) + { + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + SDL_SetTextureAlphaMod(tex, 150); + touch_buttons[i].enabled = true; + } + + // dpad and a,b,l,r are dragable + if (i <= TouchID_RIGHT) + { + touch_buttons[i].dragable = true; + } + + touch_buttons[i].texture = tex; + touch_buttons[i].w = w; + touch_buttons[i].h = h; + } + else + { + emscripten_console_logf("failed to update pixel data sadly: %s\n", SDL_GetError()); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Failed to update pixle data", SDL_GetError(), nullptr); + SDL_DestroyTexture(tex); + } + } + + std::free(pixel_data); + } + else + { + emscripten_console_logf("failed to load pixel data via emscripten: %s\n", get_touch_id_asset(static_cast(i))); + } + } + + sram_sync_timer = SDL_AddTimer(1000 * 3, sdl2_sram_timer_callback, this); + // set fullscreen + // set_window_size_from_renderer(); + + running = true; +} + +App::~App() +{ + if (sram_sync_timer) + { + SDL_RemoveTimer(sram_sync_timer); + } + + for (auto& entry : touch_buttons) + { + SDL_DestroyTexture(entry.texture); + } +} + +auto App::loadsave(const std::string& path) -> bool +{ + std::string new_path = "/save/" + create_save_path(rom_path); + return frontend::Base::loadsave(new_path); +} + +auto App::savegame(const std::string& path) -> bool +{ + std::string new_path = "/save/" + create_save_path(rom_path); + if (frontend::Base::savegame(new_path)) + { + em_idbfs_syncfs(); + return true; + } + // SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "save", "failed to save", nullptr); + return false; +} + +auto App::loadstate(const std::string& path) -> bool +{ + std::string new_path = "/state/" + create_state_path(rom_path); + if (frontend::Base::loadstate(new_path)) + { + return true; + } + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "loadstate", "failed to loadstate", nullptr); + return false; +} + +auto App::savestate(const std::string& path) -> bool +{ + std::string new_path = "/state/" + create_state_path(rom_path); + if (frontend::Base::savestate(new_path)) + { + em_idbfs_syncfs(); + return true; + } + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "savestate", "failed to savestate", nullptr); + return false; +} + +auto App::render() -> void +{ + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_RenderClear(renderer); + + update_texture_from_pixels(); + + // render gba + SDL_RenderCopy(renderer, texture, nullptr, &emu_rect); + + if (menu == Menu::SIDEBAR) + { + const auto [w, h] = get_renderer_size(); + const auto side_scale = get_scale(115*2, 35*6, w, h); + + SDL_Rect r1 = {}; + r1.x = 0; r1.y = 2 * side_scale; r1.w = 115 * side_scale; r1.h = h - 13 * side_scale; + SDL_Rect r2 = {}; + r2.x = w - (115 * side_scale); r2.y = 2 * side_scale; r2.w = 115 * side_scale; r2.h = h - 13 * side_scale; + + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 180); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_RenderFillRect(renderer, &r1); + SDL_RenderFillRect(renderer, &r2); + } + + // render buttons + if (!touch_hidden) + { + for (auto& entry : touch_buttons) + { + if (entry.texture && entry.enabled) + { + SDL_RenderCopy(renderer, entry.texture, nullptr, &entry.rect); + } + } + } + + SDL_RenderPresent(renderer); +} + +auto App::change_menu(Menu new_menu) -> void +{ + if (menu == new_menu) + { + return; + } + + menu = new_menu; + emu_run = false; + + switch (menu) + { + case Menu::ROM: + emu_run = true; + em_set_loadrom_button_visibility(false); + break; + + case Menu::SIDEBAR: + // unset all buttons + set_button(gba::Button::ALL, false); + em_set_loadrom_button_visibility(true); + break; + } + + for (std::size_t i = 0; i < SDL_arraysize(touch_buttons); i++) + { + auto& e = touch_buttons[i]; + + if (i <= TouchID_OPTIONS) + { + e.enabled = menu == Menu::ROM; + } + else + { + e.enabled = menu == Menu::SIDEBAR; + } + } +} + +auto App::on_touch_button_change(TouchID touch_id, bool down) -> void +{ + // emscripten_console_logf("[TOUCH-CHANGE] id: %d\n", touch_id); + if (down) + { + emscripten_vibrate(50); + } + + switch (touch_id) + { + case TouchID_A: set_button(gba::Button::A, down); break; + case TouchID_B: set_button(gba::Button::B, down); break; + case TouchID_L: set_button(gba::Button::L, down); break; + case TouchID_R: set_button(gba::Button::R, down); break; + case TouchID_UP: set_button(gba::Button::UP, down); break; + case TouchID_DOWN: set_button(gba::Button::DOWN, down); break; + case TouchID_LEFT: set_button(gba::Button::LEFT, down); break; + case TouchID_RIGHT: set_button(gba::Button::RIGHT, down); break; + case TouchID_START: set_button(gba::Button::START, down); break; + case TouchID_SELECT: set_button(gba::Button::SELECT, down); break; + + case TouchID_OPTIONS: + if (down) + { + change_menu(Menu::SIDEBAR); + } + break; + + case TouchID_TITLE: + break; + + case TouchID_OPEN: + break; + + case TouchID_SAVE: + if (down) + { + savestate(); + change_menu(Menu::ROM); + } + break; + + case TouchID_LOAD: + if (down) + { + loadstate(); + change_menu(Menu::ROM); + } + break; + + case TouchID_BACK: + if (down) + { + change_menu(Menu::ROM); + } + break; + + case TouchID_IMPORT: + if (down) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, "Unimplemented", "feature not yet implemented!", nullptr); + } + break; + + case TouchID_EXPORT: + break; + + case TouchID_FULLSCREEN: + if (down) + { + toggle_fullscreen(); + change_menu(Menu::ROM); + } + break; + + case TouchID_AUDIO: + if (down) + { + emu_audio_disabled ^= 1; + on_audio_change(); + change_menu(Menu::ROM); + } + break; + + case TouchID_FASTFORWARD: + if (down) + { + emu_fast_forward ^= 1; + on_speed_change(); + change_menu(Menu::ROM); + } + break; + + case TouchID_MAX: + break; + } +} + +auto App::is_touch_in_range(int x, int y) -> int +{ + for (std::size_t i = 0; i < SDL_arraysize(touch_buttons); i++) + { + const auto& e = touch_buttons[i]; + + if (e.enabled) + { + if (x >= e.rect.x && x <= (e.rect.x + e.rect.w)) + { + if (y >= e.rect.y && y <= (e.rect.y + e.rect.h)) + { + // emscripten_console_logf("in range! x: %d y: %d\n", x, y); + return i; + } + } + } + } + + // emscripten_console_logf("not in range!\n"); + return -1; +} + +auto App::on_touch_up(std::span cache, SDL_FingerID id) -> void +{ + // emscripten_console_logf("[TOUCH-UP] id: %zd\n", id); + + for (auto& i : cache) + { + if (i.down && i.finger_id == id) + { + i.down = false; + on_touch_button_change(i.touch_id, false); + break; + } + } +} + +auto App::on_touch_down(std::span cache, SDL_FingerID id, int x, int y) -> void +{ + // emscripten_console_logf("[TOUCH-DOWN] x: %d y: %d id: %zd\n", x, y, id); + + if (touch_hidden) + { + touch_hidden = false; + return; + } + + const auto touch_id = is_touch_in_range(x, y); + + if (touch_id == -1) + { + return; + } + + // find the first free entry and add it to it + for (auto& i : cache) + { + if (!i.down) + { + i.finger_id = id; + i.touch_id = static_cast(touch_id); + i.down = true; + + on_touch_button_change(i.touch_id, true); + break; + } + } +} + +auto App::on_touch_motion(std::span cache, SDL_FingerID id, int x, int y) -> void +{ + if (touch_hidden) + { + touch_hidden = false; + return; + } + + // emscripten_console_logf("[TOUCH-MOTION] x: %d y: %d id: %zd\n", x, y, id); + + // check that the button press maps to a texture coord + const int touch_id = is_touch_in_range(x, y); + + if (touch_id == -1) + { + return; + } + + for (auto& i : cache) + { + if (i.touch_id == touch_id) + { + return; + } + } + + // this is pretty inefficient, but its simple enough and works. + on_touch_up(cache, id); + + // check if the button is dragable! + if (touch_buttons[touch_id].dragable) + { + on_touch_down(cache, id, x, y); + } +} + +auto App::on_key_event(const SDL_KeyboardEvent& e) -> void +{ + touch_hidden = true; + Sdl2Base::on_key_event(e); +} + +auto App::on_user_event(SDL_UserEvent& e) -> void +{ + if (e.type == ROM_LOAD_EVENT) + { + auto data = static_cast(e.data1); + + std::scoped_lock lock{core_mutex}; + if (loadrom_mem(data->name, {data->data, data->len})) + { + change_menu(Menu::ROM); + + char buf[100]; + gba::Header header{gameboy_advance.rom}; + std::sprintf(buf, "%s - [%.*s]", "Notorious BEEG", 12, header.game_title); + SDL_SetWindowTitle(window, buf); + + emscripten_console_logf("[EM] loaded rom! name: %s len: %zu\n", data->name, data->len); + } + + delete data; + } +} + +auto App::on_controlleraxis_event(const SDL_ControllerAxisEvent& e) -> void +{ + touch_hidden = true; + Sdl2Base::on_controlleraxis_event(e); +} + +auto App::on_controllerbutton_event(const SDL_ControllerButtonEvent& e) -> void +{ + touch_hidden = true; + Sdl2Base::on_controllerbutton_event(e); +} + +auto App::on_mousebutton_event(const SDL_MouseButtonEvent& e) -> void +{ + // we already handle touch events... + if (e.which == SDL_TOUCH_MOUSEID) + { + return; + } + + const auto [x, y] = get_window_to_render_scale(e.x, e.y); + + switch (e.type) + { + case SDL_MOUSEBUTTONUP: + on_touch_up(mouse_entries, e.which); + break; + + case SDL_MOUSEBUTTONDOWN: + on_touch_down(mouse_entries, e.which, x, y); + break; + } +} + +auto App::on_mousemotion_event(const SDL_MouseMotionEvent& e) -> void +{ + // we already handle touch events! + if (e.which == SDL_TOUCH_MOUSEID) + { + return; + } + + const auto [x, y] = get_window_to_render_scale(e.x, e.y); + + // only handle left clicks! + if (e.state & SDL_BUTTON(SDL_BUTTON_LEFT)) + { + on_touch_motion(mouse_entries, e.which, x, y); + } +} + +auto App::on_touch_event(const SDL_TouchFingerEvent& e) -> void +{ + // we need to un-normalise x, y + const auto [ren_w, ren_h] = get_renderer_size(); + const int x = e.x * ren_w; + const int y = e.y * ren_h; + + switch (e.type) + { + case SDL_FINGERUP: + on_touch_up(touch_entries, e.fingerId); + break; + + case SDL_FINGERDOWN: + on_touch_down(touch_entries, e.fingerId, x, y); + break; + + case SDL_FINGERMOTION: + on_touch_motion(touch_entries, e.fingerId, x, y); + break; + } +} + +auto App::is_fullscreen() const -> bool +{ + return EM_ASM_INT( + let result = + document.fullscreenElement || + document.mozFullScreenElement || + document.documentElement.webkitFullscreenElement || + document.documentElement.webkitCurrentFullScreenElement || + document.webkitFullscreenElement || + document.webkitCurrentFullScreenElement || + document.msFullscreenElement; + + if (document.fullscreenElement) { + console.log("got fullscreenElement"); + } + if (document.mozFullScreenElement) { + console.log("got mozFullScreenElement"); + } + if (document.documentElement.webkitFullscreenElement) { + console.log("got documentElement.webkitFullscreenElement"); + } + if (document.documentElement.webkitCurrentFullScreenElement) { + console.log("got documentElement.webkitCurrentFullScreenElement"); + } + if (document.webkitFullscreenElement) { + console.log("got webkitFullscreenElement"); + } + if (document.webkitCurrentFullScreenElement) { + console.log("got webkitCurrentFullScreenElement"); + } + if (document.msFullscreenElement) { + console.log("got msFullscreenElement"); + } + + console.log("is_fullscreen result:", result != null); + return result != null; + ); +} + +auto App::toggle_fullscreen() -> void +{ + if (is_fullscreen()) + { + EM_ASM( + if (document.exitFullscreen) { + document.exitFullscreen(); + console.log("got exitFullscreen"); + } else if (document.mozExitFullScreen) { + document.mozExitFullScreen(); + console.log("got mozExitFullScreen"); + } else if (document.webkitExitFullScreen) { + document.webkitCancelFullScreen(); + console.log("got webkitCancelFullScreen"); + } else if (document.webkitCancelFullScreen) { + document.webkitCancelFullScreen(); + console.log("got webkitCancelFullScreen"); + } else if (document.msExitFullScreen) { + document.msExitFullScreen(); + console.log("got msExitFullScreen"); + } + ); + + // emscripten_exit_fullscreen(); + } + else + { + // works, but removes html buttons, so the file picker breaks... + // emscripten_request_fullscreen("#canvas", 1); + + EM_ASM( + if (document.documentElement.requestFullscreen) { + document.documentElement.requestFullscreen(); + console.log("got requestFullscreen"); + } else if (document.documentElement.mozRequestFullScreen) { + document.documentElement.mozRequestFullScreen(); + console.log("got mozRequestFullScreen"); + } else if (document.documentElement.webkitRequestFullScreen) { + document.documentElement.webkitRequestFullScreen(); + console.log("got webkitRequestFullScreen"); + } else if (document.documentElement.msRequestFullScreen) { + document.documentElement.msRequestFullScreen(); + console.log("got msRequestFullScreen"); + } + ); + } +} + +auto App::resize_emu_screen() -> void +{ + Sdl2Base::resize_emu_screen(); + + const auto [w, h] = get_renderer_size(); + const float scale2 = scale / 2.0; + const auto side_scale = get_scale(115*2, 35*6, w, h); + + for (auto& entry : touch_buttons) + { + entry.rect.w = entry.w * scale2; + entry.rect.h = entry.h * scale2; + } + + for (std::size_t i = 0; i < TouchID_MAX; i++) + { + auto& entry = touch_buttons[i]; + + if (i <= TouchID_OPTIONS) + { + entry.rect.w = entry.w * scale2; + entry.rect.h = entry.h * scale2; + } + else + { + entry.rect.w = entry.w * side_scale; + entry.rect.h = entry.h * side_scale; + } + } + + #if 0 + touch_buttons[TouchID_A].rect.x = (w - touch_buttons[TouchID_A].rect.w) - (5 * scale2); + touch_buttons[TouchID_A].rect.y = (h - touch_buttons[TouchID_A].rect.h) - (5 * scale2); + + touch_buttons[TouchID_B].rect.x = (touch_buttons[TouchID_A].rect.x - touch_buttons[TouchID_B].rect.w) - (10 * scale2); + touch_buttons[TouchID_B].rect.y = touch_buttons[TouchID_A].rect.y; + #else + touch_buttons[TouchID_A].rect.x = (w - touch_buttons[TouchID_A].rect.w) - (20 * scale2); + touch_buttons[TouchID_A].rect.y = (h - touch_buttons[TouchID_A].rect.h) - (40 * scale2); + + touch_buttons[TouchID_B].rect.x = (touch_buttons[TouchID_A].rect.x - touch_buttons[TouchID_B].rect.w) - (20 * scale2); + touch_buttons[TouchID_B].rect.y = touch_buttons[TouchID_A].rect.y; + + #endif + + #if 0 + touch_buttons[TouchID_UP].rect.x = 30 * scale2; + touch_buttons[TouchID_UP].rect.y = h - 82 * scale2; + + touch_buttons[TouchID_DOWN].rect.x = touch_buttons[TouchID_UP].rect.x; + touch_buttons[TouchID_DOWN].rect.y = (touch_buttons[TouchID_UP].rect.y + touch_buttons[TouchID_UP].rect.h); + + touch_buttons[TouchID_LEFT].rect.x = 5 * scale2; + touch_buttons[TouchID_LEFT].rect.y = h - 60 * scale2; + + touch_buttons[TouchID_RIGHT].rect.x = (touch_buttons[TouchID_LEFT].rect.x + touch_buttons[TouchID_LEFT].rect.w) + (5 * scale2); + touch_buttons[TouchID_RIGHT].rect.y = touch_buttons[TouchID_LEFT].rect.y; + #else + touch_buttons[TouchID_UP].rect.x = 79 * scale2; + touch_buttons[TouchID_UP].rect.y = h - 200 * scale2; + + touch_buttons[TouchID_DOWN].rect.x = touch_buttons[TouchID_UP].rect.x; + touch_buttons[TouchID_DOWN].rect.y = (touch_buttons[TouchID_UP].rect.y + touch_buttons[TouchID_UP].rect.h) + (6 * scale2); + + touch_buttons[TouchID_LEFT].rect.x = 25 * scale2; + touch_buttons[TouchID_LEFT].rect.y = h - 154 * scale2; + + touch_buttons[TouchID_RIGHT].rect.x = (touch_buttons[TouchID_LEFT].rect.x + touch_buttons[TouchID_LEFT].rect.w) + (16 * scale2); + touch_buttons[TouchID_RIGHT].rect.y = touch_buttons[TouchID_LEFT].rect.y; + #endif + + #if 0 + touch_buttons[TouchID_L].rect.x = 10 * scale2; + touch_buttons[TouchID_L].rect.y = 10 * scale2; + + touch_buttons[TouchID_R].rect.x = w - touch_buttons[TouchID_R].rect.w - 10 * scale2; + touch_buttons[TouchID_R].rect.y = 10 * scale2; + #else + touch_buttons[TouchID_L].rect.x = 25 * scale2; + touch_buttons[TouchID_L].rect.y = 25 * scale2; + + touch_buttons[TouchID_R].rect.x = w - touch_buttons[TouchID_R].rect.w - 25 * scale2; + touch_buttons[TouchID_R].rect.y = 25 * scale2; + #endif + + touch_buttons[TouchID_TITLE].rect.x = 5 * side_scale; + touch_buttons[TouchID_TITLE].rect.y = 10 * side_scale; + + touch_buttons[TouchID_OPEN].rect.x = touch_buttons[TouchID_TITLE].rect.x; + touch_buttons[TouchID_OPEN].rect.y = (touch_buttons[TouchID_TITLE].rect.y + touch_buttons[TouchID_TITLE].rect.h) + (7 * side_scale); + + touch_buttons[TouchID_SAVE].rect.x = touch_buttons[TouchID_TITLE].rect.x; + touch_buttons[TouchID_SAVE].rect.y = (touch_buttons[TouchID_OPEN].rect.y + touch_buttons[TouchID_OPEN].rect.h) + (5 * side_scale); + + touch_buttons[TouchID_LOAD].rect.x = touch_buttons[TouchID_TITLE].rect.x; + touch_buttons[TouchID_LOAD].rect.y = (touch_buttons[TouchID_SAVE].rect.y + touch_buttons[TouchID_SAVE].rect.h) + (5 * side_scale); + + touch_buttons[TouchID_BACK].rect.x = touch_buttons[TouchID_TITLE].rect.x; + touch_buttons[TouchID_BACK].rect.y = (touch_buttons[TouchID_LOAD].rect.y + touch_buttons[TouchID_LOAD].rect.h) + (5 * side_scale); + + touch_buttons[TouchID_IMPORT].rect.x = (w - touch_buttons[TouchID_IMPORT].rect.w) - (5 * side_scale); + touch_buttons[TouchID_IMPORT].rect.y = ((touch_buttons[TouchID_TITLE].rect.y + touch_buttons[TouchID_TITLE].rect.h) * 2) + (5 * side_scale); + + touch_buttons[TouchID_EXPORT].rect.x = touch_buttons[TouchID_IMPORT].rect.x; + touch_buttons[TouchID_EXPORT].rect.y = (touch_buttons[TouchID_IMPORT].rect.y + touch_buttons[TouchID_IMPORT].rect.h) + (5 * side_scale); + + touch_buttons[TouchID_FULLSCREEN].rect.w /= 2; + touch_buttons[TouchID_FULLSCREEN].rect.h /= 2; + touch_buttons[TouchID_FULLSCREEN].rect.x = (w) - (65 * side_scale); + touch_buttons[TouchID_FULLSCREEN].rect.y = touch_buttons[TouchID_BACK].rect.y + (10 * side_scale); + + touch_buttons[TouchID_AUDIO].rect.w /= 2; + touch_buttons[TouchID_AUDIO].rect.h /= 2; + touch_buttons[TouchID_AUDIO].rect.x = (touch_buttons[TouchID_FULLSCREEN].rect.x + touch_buttons[TouchID_FULLSCREEN].rect.w) + (10 * side_scale); + touch_buttons[TouchID_AUDIO].rect.y = touch_buttons[TouchID_BACK].rect.y + (10 * side_scale); + + touch_buttons[TouchID_FASTFORWARD].rect.w /= 2; + touch_buttons[TouchID_FASTFORWARD].rect.h /= 2; + touch_buttons[TouchID_FASTFORWARD].rect.x = (touch_buttons[TouchID_FULLSCREEN].rect.x - touch_buttons[TouchID_FULLSCREEN].rect.w) - (10 * side_scale); + touch_buttons[TouchID_FASTFORWARD].rect.y = touch_buttons[TouchID_BACK].rect.y + (10 * side_scale); + + // TouchID_FASTFORWARD + // show start/selct on bottom, looks cramped imo + #if 0 + touch_buttons[TouchID_START].rect.x = w / 2 + 5 * scale2; + touch_buttons[TouchID_START].rect.y = h - touch_buttons[TouchID_START].rect.h - 10 * scale2; + + touch_buttons[TouchID_SELECT].rect.x = w / 2 - touch_buttons[TouchID_SELECT].rect.w - 5 * scale2; + touch_buttons[TouchID_SELECT].rect.y = h - touch_buttons[TouchID_SELECT].rect.h - 10 * scale2; + + touch_buttons[TouchID_OPTIONS].rect.x = w / 2 - touch_buttons[TouchID_OPTIONS].rect.w / 2; + touch_buttons[TouchID_OPTIONS].rect.y = 10 * scale2; + #else + touch_buttons[TouchID_START].rect.x = w / 2 + 5 * scale2; + touch_buttons[TouchID_START].rect.y = 10 * scale2; + + touch_buttons[TouchID_SELECT].rect.x = w / 2 - touch_buttons[TouchID_SELECT].rect.w - 5 * scale2; + touch_buttons[TouchID_SELECT].rect.y = 10 * scale2; + + touch_buttons[TouchID_OPTIONS].rect.x = w / 2 - touch_buttons[TouchID_OPTIONS].rect.w / 2; + touch_buttons[TouchID_OPTIONS].rect.y = h - touch_buttons[TouchID_SELECT].rect.h - 10 * scale2; + #endif + + // resize button + {const auto& rect = touch_buttons[TouchID_OPEN].rect; + const auto [btnx, btny] = get_render_to_window_scale(rect.x, rect.y); + const auto [btnw, btnh] = get_render_to_window_scale(rect.w, rect.h); + + EM_ASM({ + let button = document.getElementById('RomFilePicker'); + button.style.left = arguments[0] + 'px'; + button.style.top = arguments[1] + 'px'; + button.style.width = arguments[2] + 'px'; + button.style.height= arguments[3] + 'px'; + }, btnx, btny, btnw, btnh);} + + {const auto& rect = touch_buttons[TouchID_EXPORT].rect; + const auto [btnx, btny] = get_render_to_window_scale(rect.x, rect.y); + const auto [btnw, btnh] = get_render_to_window_scale(rect.w, rect.h); + + EM_ASM({ + let button = document.getElementById('DlSaves'); + button.style.left = arguments[0] + 'px'; + button.style.top = arguments[1] + 'px'; + button.style.width = arguments[2] + 'px'; + button.style.height= arguments[3] + 'px'; + }, btnx, btny, btnw, btnh);} +} + +auto App::rom_file_picker() -> void +{ + EM_ASM( + let rom_input = document.getElementById("RomFilePicker"); + rom_input.click(); + ); +} + +auto App::on_speed_change() -> void +{ + if (emu_fast_forward) + { + gameboy_advance.set_audio_callback(on_audio_callback, sample_data, 65536 / 2); + } + else + { + gameboy_advance.set_audio_callback(on_audio_callback, sample_data); + } +} + +auto App::on_audio_change() -> void +{ + if (emu_audio_disabled) + { + SDL_AudioStreamClear(audio_stream); + gameboy_advance.set_audio_callback(nullptr, sample_data); + } + else + { + gameboy_advance.set_audio_callback(on_audio_callback, sample_data); + } +} + +} // namespace + +extern "C" { + +EMSCRIPTEN_KEEPALIVE auto em_load_rom_data(const char* name, uint8_t* data, int len) -> void +{ + emscripten_console_logf("[EM] loading rom! name: %s len: %d\n", name, len); + + if (len <= 0) + { + emscripten_console_logf("[EM] invalid rom size!\n"); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "loadrom", "invalid rom size, less than or equal to zero!", nullptr); + return; + } + + auto event_data = new RomEventData; + std::strcpy(event_data->name, name); + event_data->data = static_cast(data); + event_data->len = len; + + SDL_Event event{}; + event.user.type = ROM_LOAD_EVENT; + event.user.data1 = event_data; + SDL_PushEvent(&event); +} + +EMSCRIPTEN_KEEPALIVE auto em_zip_all_saves() -> std::size_t +{ + const auto result = zip_saves(); + if (!result) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, "No save files found!", "Try saving in game first\n\nIf you know there was a save file created, please contact me about the bug!", nullptr); + } + return result; +} + +EMSCRIPTEN_KEEPALIVE auto main(int argc, char** argv) -> int +{ + auto app = std::make_unique(argc, argv); + emscripten_set_main_loop_arg(em_loop, app.get(), 0, true); + return 0; +} + +} // extern "C" diff --git a/src/frontend/emscripten/netlify.toml b/src/frontend/emscripten/netlify.toml new file mode 100644 index 0000000..28aed49 --- /dev/null +++ b/src/frontend/emscripten/netlify.toml @@ -0,0 +1,5 @@ +[[headers]] + for = "/*" + [headers.values] + Cross-Origin-Opener-Policy = "same-origin" + Cross-Origin-Embedder-Policy = "require-corp" diff --git a/src/frontend/frontend_base.cpp b/src/frontend/frontend_base.cpp index 47e1000..efa78af 100644 --- a/src/frontend/frontend_base.cpp +++ b/src/frontend/frontend_base.cpp @@ -2,69 +2,186 @@ // SPDX-License-Identifier: GPL-3.0-only #include "frontend_base.hpp" +#include +#include +#include #include #include #include +#include +#include #include +#include namespace frontend { +namespace { -Base::Base(int argc, char** argv) +struct MzMem { - if (argc < 2) + union { - return; + const std::uint8_t* const_buf; + std::uint8_t* buf; + }; + std::size_t size; + std::size_t offset; + bool read_only; +}; + +auto minizip_tell64_file_func(void* opaque, [[maybe_unused]] void* stream) -> ZPOS64_T +{ + auto mem = static_cast(opaque); + return mem->offset; +} + +auto minizip_seek64_file_func(void* opaque, [[maybe_unused]] void* stream, ZPOS64_T offset, int origin) -> long +{ + auto mem = static_cast(opaque); + std::size_t new_offset = 0; + + switch (origin) + { + case ZLIB_FILEFUNC_SEEK_SET: + new_offset = offset; + break; + + case ZLIB_FILEFUNC_SEEK_CUR: + new_offset = mem->offset + offset; + break; + + case ZLIB_FILEFUNC_SEEK_END: + new_offset = (mem->size - 1) + offset; + break; + + default: + return -1; } - if (!loadrom(argv[1])) + if (new_offset > mem->size) { - return; + return -1; } - if (argc == 3) + mem->offset = new_offset; + + return 0; +} + +auto minizip_open64_file_func(void* opaque, [[maybe_unused]] const void* filename, [[maybe_unused]] int mode) -> void* +{ + return opaque; +} + +auto minizip_read_file_func(void* opaque, [[maybe_unused]] void* stream, void* buf, unsigned long size) -> unsigned long +{ + auto mem = static_cast(opaque); + + if (mem->size <= mem->offset + size) { - std::printf("loading bios\n"); - const auto bios = loadfile(argv[2]); - if (bios.empty()) - { - return; - } + size = mem->size - mem->offset; + } - if (!gameboy_advance.loadbios(bios)) + std::memcpy(buf, mem->const_buf + mem->offset, size); + mem->offset += size; + + return size; +} + +auto minizip_write_file_func([[maybe_unused]] void* opaque, [[maybe_unused]] void* stream, [[maybe_unused]] const void* buf, [[maybe_unused]] unsigned long size) -> unsigned long +{ + auto mem = static_cast(opaque); + + if (mem->read_only) + { + return 0; + } + + if (mem->size <= mem->offset + size) + { + mem->buf = static_cast(std::realloc(mem->buf, mem->offset + size)); + if (mem->buf == nullptr) { - return; + return 0; } + + mem->size = mem->offset + size; } + + std::memcpy(mem->buf + mem->offset, buf, size); + mem->offset += size; + + return size; } -Base::~Base() +auto minizip_close_file_func([[maybe_unused]] void* opaque, [[maybe_unused]] void* stream) -> int { - closerom(); + return 0; } -auto Base::dumpfile(const std::string& path, std::span data) -> bool +auto minizip_testerror_file_func([[maybe_unused]] void* opaque, [[maybe_unused]] void* stream) -> int { - std::ofstream fs{path.c_str(), std::ios_base::binary}; + return 0; +} - if (fs.good()) +#if 0 +auto zipall_internal(zipFile zf, const std::string& folder) -> void +{ + // for (auto& folder : folders) { - fs.write(reinterpret_cast(data.data()), data.size()); - - if (fs.good()) + for (auto& entry : std::filesystem::recursive_directory_iterator{folder}) { - return true; + if (entry.is_regular_file()) + { + // get the fullpath + const auto path = entry.path().string(); + std::ifstream fs{path, std::ios::binary}; + + if (fs.good()) + { + // read file into buffer + const auto file_size = entry.file_size(); + std::vector buffer(file_size); + fs.read(buffer.data(), buffer.size()); + + // open the file inside the zip + if (ZIP_OK != zipOpenNewFileInZip(zf, + path.c_str(), // filepath + nullptr, // info, optional + nullptr, 0, // extrafield and size, optional + nullptr, 0, // extrafield-global and size, optional + "TotalGBA", // comment, optional + Z_DEFLATED, // mode + Z_DEFAULT_COMPRESSION // level + )) { + std::printf("failed to open file in zip: %s\n", path.c_str()); + continue; + } + + // write out the entire file + if (Z_OK != zipWriteInFileInZip(zf, buffer.data(), buffer.size())) + { + std::printf("failed to write file in zip: %s\n", path.c_str()); + } + + // don't forget to close when done! + if (Z_OK != zipCloseFileInZip(zf)) + { + std::printf("failed to close file in zip: %s\n", path.c_str()); + } + } + else + { + std::printf("failed to open file %s\n", path.c_str()); + } + } } } - - return false; } +#endif // basic rom loading from zip, will flesh this out more soon -auto Base::loadzip(const std::string& path) -> std::vector +auto loadzip_internal(unzFile zf) -> std::vector { - std::vector data; - auto zf = unzOpen64(path.c_str()); - if (zf != nullptr) { unz_global_info64 global_info; @@ -72,7 +189,7 @@ auto Base::loadzip(const std::string& path) -> std::vector { bool found = false; - for (std::uint32_t i = 0; !found && i < global_info.number_entry; i++) + for (std::uint64_t i = 0; !found && i < global_info.number_entry; i++) { if (UNZ_OK == unzOpenCurrentFile(zf)) { @@ -81,11 +198,16 @@ auto Base::loadzip(const std::string& path) -> std::vector if (UNZ_OK == unzGetCurrentFileInfo64(zf, &file_info, name, sizeof(name), nullptr, 0, nullptr, 0)) { - if (std::string_view{ name }.ends_with(".gba")) + if (std::string_view{ name }.ends_with(".gba") || std::string_view{ name }.ends_with(".GBA")) { + std::vector data; data.resize(file_info.uncompressed_size); - unzReadCurrentFile(zf, data.data(), data.size()); - found = true; + const auto result = unzReadCurrentFile(zf, data.data(), data.size()); + + if (result > 0 && file_info.uncompressed_size == static_cast(result)) + { + return data; + } } } @@ -99,26 +221,151 @@ auto Base::loadzip(const std::string& path) -> std::vector } } } + } + + return {}; +} + +const zlib_filefunc64_def zlib_filefunc64{ + .zopen64_file = minizip_open64_file_func, + .zread_file = minizip_read_file_func, + .zwrite_file = minizip_write_file_func, + .ztell64_file = minizip_tell64_file_func, + .zseek64_file = minizip_seek64_file_func, + .zclose_file = minizip_close_file_func, + .zerror_file = minizip_testerror_file_func, + .opaque = nullptr, +}; + +} // namespace + +Base::Base(int argc, char** argv) +{ + if (argc < 2) + { + return; + } + + if (!loadrom(argv[1])) + { + std::printf("loading rom from argv[1]: %s\n", argv[1]); + return; + } + + if (argc == 3) + { + std::printf("loading bios from argv[2]: %s\n", argv[2]); + const auto bios = loadfile(argv[2]); + if (bios.empty()) + { + return; + } + + if (!gameboy_advance.loadbios(bios)) + { + return; + } + } +} + +Base::~Base() +{ + closerom(); +} + +auto Base::dumpfile(const std::string& path, std::span data) -> bool +{ + std::ofstream fs{path.c_str(), std::ios_base::binary}; + + if (fs.good()) + { + fs.write(reinterpret_cast(data.data()), data.size()); + + if (fs.good()) + { + return true; + } + } + + return false; +} + +#if 0 +auto Base::zipall(const std::string& folder, const std::string& output) -> bool +{ + if (auto zfile = zipOpen64(nullptr, APPEND_STATUS_CREATE)) + { + zipall_internal(zfile, folder); + zipClose(zfile, "TotalGBA"); + // todo: error handling! + return true; + } + + return false; +} + +auto Base::zipall_mem(const std::string& folder) -> std::vector +{ + MzMem mzmem{ + .buf = nullptr, + .size = 0, + .offset = 0, + .read_only = false, + }; + + auto def = zlib_filefunc64; + def.opaque = &mzmem; + + if (auto zfile = zipOpen2_64(nullptr, APPEND_STATUS_CREATE, nullptr, &def)) + { + zipall_internal(zfile, folder); + zipClose(zfile, "TotalGBA"); + + if (mzmem.buf) + { + // wasteful code. it's this way because of how the js calls into c + std::vector buf; + buf.resize(mzmem.size); + std::free(mzmem.buf); + return buf; + } + } + return {}; +} +#endif + +auto Base::loadzip(const std::string& path) -> std::vector +{ + if (auto zf = unzOpen64(path.c_str()); zf != nullptr) + { + auto data = loadzip_internal(zf); unzClose(zf); + return data; } - return data; + return {}; } auto Base::loadfile(const std::string& path) -> std::vector { if (path.ends_with(".zip")) { - printf("attempting to load via zip\n"); - // load zip - return loadzip(path); + std::printf("attempting to load via zip\n"); + if (auto zf = unzOpen64(path.c_str()); zf != nullptr) + { + // don't const as it prevents move + auto data = loadzip_internal(zf); + unzClose(zf); + return data; + } } else { std::ifstream fs{ path.c_str(), std::ios_base::binary }; - if (fs.good()) { + if (fs.good()) + { fs.seekg(0, std::ios_base::end); const auto size = fs.tellg(); fs.seekg(0, std::ios_base::beg); @@ -138,6 +385,39 @@ auto Base::loadfile(const std::string& path) -> std::vector return {}; } +auto Base::loadfile_mem(const std::string& path, std::span data) -> std::vector +{ + if (path.ends_with(".zip")) + { + MzMem mzmem{ + .const_buf = data.data(), + .size = data.size(), + .offset = 0, + .read_only = true + }; + + auto def = zlib_filefunc64; + def.opaque = &mzmem; + + std::printf("attempting to load via zip\n"); + if (auto zf = unzOpen2_64(path.c_str(), &def); zf != nullptr) + { + // don't const as it prevents move + auto unziped_data = loadzip_internal(zf); + unzClose(zf); + return unziped_data; + } + } + else + { + std::vector output; + output.assign(data.begin(), data.end()); + return output; + } + + return {}; +} + auto Base::replace_extension(std::filesystem::path path, const std::string& new_ext) -> std::string { return path.replace_extension(new_ext).string(); @@ -188,6 +468,29 @@ auto Base::loadrom(const std::string& path) -> bool return true; } +auto Base::loadrom_mem(const std::string& path, std::span data) -> bool +{ + closerom(); + + rom_path = path; + const auto rom_data = loadfile_mem(path, data); + if (rom_data.empty()) + { + return false; + } + + if (!gameboy_advance.loadrom(rom_data)) + { + return false; + } + + emu_run = true; + has_rom = true; + loadsave(rom_path); + + return true; +} + auto Base::loadsave(const std::string& path) -> bool { const auto save_path = create_save_path(path); @@ -203,6 +506,12 @@ auto Base::loadsave(const std::string& path) -> bool auto Base::savegame(const std::string& path) -> bool { + // is save isn't dirty, then return early + if (!gameboy_advance.is_save_dirty()) + { + return true; + } + const auto save_path = create_save_path(path); const auto save_data = gameboy_advance.getsave(); if (!save_data.empty()) diff --git a/src/frontend/frontend_base.hpp b/src/frontend/frontend_base.hpp index db054e3..af61728 100644 --- a/src/frontend/frontend_base.hpp +++ b/src/frontend/frontend_base.hpp @@ -22,15 +22,18 @@ struct Base virtual auto loop() -> void = 0; static auto dumpfile(const std::string& path, std::span data) -> bool; + static auto zipall(const std::string& folder, const std::string& output) -> bool; + static auto zipall_mem(const std::string& folder) -> std::vector; static auto loadzip(const std::string& path) -> std::vector; static auto loadfile(const std::string& path) -> std::vector; + static auto loadfile_mem(const std::string& path, std::span data) -> std::vector; static auto replace_extension(std::filesystem::path path, const std::string& new_ext = "") -> std::string; static auto create_save_path(const std::string& path) -> std::string; static auto create_state_path(const std::string& path, int slot = 0) -> std::string; - protected: virtual auto loadrom(const std::string& path) -> bool; + virtual auto loadrom_mem(const std::string& path, std::span data) -> bool; virtual auto closerom() -> void; virtual auto loadsave(const std::string& path) -> bool; @@ -64,8 +67,12 @@ struct Base bool enabled_rewind{false}; // when true, the emulator is rewinding bool emu_rewind{false}; - // keeps ascpect ratio when resizing the sreen + // keeps ascpect ratio when resizing the screen bool maintain_aspect_ratio{true}; + // + bool emu_fast_forward{false}; + // + bool emu_audio_disabled{false}; }; } // namespace frontend diff --git a/src/frontend/imgui/CMakeLists.txt b/src/frontend/imgui/CMakeLists.txt index 6f4c4a0..2d70ef2 100644 --- a/src/frontend/imgui/CMakeLists.txt +++ b/src/frontend/imgui/CMakeLists.txt @@ -9,16 +9,12 @@ FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git GIT_TAG v1.88 GIT_PROGRESS TRUE - CONFIGURE_COMMAND "" - BUILD_COMMAND "" ) FetchContent_Declare(imgui_club GIT_REPOSITORY https://github.com/ocornut/imgui_club.git GIT_TAG d4cd9896e15a03e92702a578586c3f91bbde01e8 GIT_PROGRESS TRUE - CONFIGURE_COMMAND "" - BUILD_COMMAND "" ) FetchContent_MakeAvailable(imgui) diff --git a/src/frontend/imgui/backend/sdl2/main.cpp b/src/frontend/imgui/backend/sdl2/main.cpp index 526023c..45744a3 100644 --- a/src/frontend/imgui/backend/sdl2/main.cpp +++ b/src/frontend/imgui/backend/sdl2/main.cpp @@ -1,6 +1,7 @@ // Copyright 2022 TotalJustice. // SPDX-License-Identifier: GPL-3.0-only +#include #include #include @@ -62,6 +63,7 @@ struct App final : ImguiBase SDL_AudioSpec aspec_wnt{}; SDL_AudioSpec aspec_got{}; std::mutex audio_mutex{}; + std::vector sample_data; int sample_rate{65536}; std::unordered_map controllers; @@ -82,12 +84,11 @@ auto audio_callback(void* user, Uint8* data, int len) -> void SDL_AudioStreamGet(app->audio_stream, data, len); } -auto push_sample_callback(void* user, std::int16_t left, std::int16_t right) -> void +auto push_sample_callback(void* user) -> void { auto app = static_cast(user); std::scoped_lock lock{app->audio_mutex}; - const std::int16_t samples[2] = {left, right}; - SDL_AudioStreamPut(app->audio_stream, samples, sizeof(samples)); + SDL_AudioStreamPut(app->audio_stream, app->sample_data.data(), app->sample_data.size() * 2); } App::App(int argc, char** argv) : ImguiBase{argc, argv} @@ -167,6 +168,8 @@ App::App(int argc, char** argv) : ImguiBase{argc, argv} return; } + sample_data.resize((aspec_got.samples * aspec_got.channels) & ~0x1); + std::printf("[SDL-AUDIO] format\twant: 0x%X \tgot: 0x%X\n", aspec_wnt.format, aspec_got.format); std::printf("[SDL-AUDIO] freq\twant: %d \tgot: %d\n", aspec_wnt.freq, aspec_got.freq); std::printf("[SDL-AUDIO] channels\twant: %d \tgot: %d\n", aspec_wnt.channels, aspec_got.channels); @@ -183,7 +186,7 @@ App::App(int argc, char** argv) : ImguiBase{argc, argv} gameboy_advance.set_userdata(this); // gameboy_advance.set_hblank_callback(on_hblank_callback); - gameboy_advance.set_audio_callback(push_sample_callback); + gameboy_advance.set_audio_callback(push_sample_callback, sample_data); // Setup Platform/Renderer backends ImGui_ImplSDL2_InitForSDLRenderer(window, renderer); diff --git a/src/frontend/imgui/imgui_base.cpp b/src/frontend/imgui/imgui_base.cpp index fbb9197..7fe8990 100644 --- a/src/frontend/imgui/imgui_base.cpp +++ b/src/frontend/imgui/imgui_base.cpp @@ -28,23 +28,26 @@ auto draw_grid(int size, int count, float thicc, int x, int y) auto on_hblank_callback(void* user, std::uint16_t line) -> void { - // if constexpr (!debug_mode) - // { - // return; - // } - - // if (line >= 160) - // { - // return; - // } - - // for (auto i = 0; i < 4; i++) - // { - // if (layers[i].enabled) - // { - // layers[i].priority = gameboy_advance.render_mode(layers[i].pixels[line], 0, i); - // } - // } + // this is UB because the actual ptr is whatever inherts the base + auto app = static_cast(user); + + if constexpr (!ImguiBase::debug_mode) + { + return; + } + + if (line >= 160) + { + return; + } + + for (auto i = 0; i < 4; i++) + { + if (app->layers[i].enabled) + { + app->layers[i].priority = app->gameboy_advance.render_mode(app->layers[i].pixels[line], 0, i); + } + } } template @@ -75,6 +78,8 @@ ImguiBase::ImguiBase(int argc, char** argv) : frontend::Base{argc, argv} //ImGui::StyleColorsClassic(); io.Fonts->AddFontFromMemoryCompressedTTF(trim_font_compressed_data, trim_font_compressed_size, 20); + + gameboy_advance.set_hblank_callback(on_hblank_callback); } ImguiBase::~ImguiBase() diff --git a/src/frontend/imgui/imgui_base.hpp b/src/frontend/imgui/imgui_base.hpp index 4fcb633..9f38d0f 100644 --- a/src/frontend/imgui/imgui_base.hpp +++ b/src/frontend/imgui/imgui_base.hpp @@ -62,7 +62,13 @@ struct ImguiBase : frontend::Base auto render_layers() -> void; auto toggle_master_layer_enable() -> void; -protected: +public: + #if DEBUGGER == 0 + static constexpr inline bool debug_mode{false}; + #else + static constexpr inline bool debug_mode{true}; + #endif +public: Rect emu_rect{}; int emu_scale{scale}; @@ -88,12 +94,6 @@ struct ImguiBase : frontend::Base Layer layers[4]{ {TextureID::layer0}, {TextureID::layer1}, {TextureID::layer2}, {TextureID::layer3} }; -#if DEBUGGER == 0 - static constexpr inline bool debug_mode{false}; -#else - static constexpr inline bool debug_mode{true}; -#endif - bool viewer_io{false}; bool show_grid{false}; diff --git a/src/frontend/sdl2/CMakeLists.txt b/src/frontend/sdl2/CMakeLists.txt index ffb7ff0..6a35193 100644 --- a/src/frontend/sdl2/CMakeLists.txt +++ b/src/frontend/sdl2/CMakeLists.txt @@ -4,10 +4,7 @@ project(notorious_beeg_SDL2 LANGUAGES CXX) add_executable(notorious_beeg_SDL2 main.cpp) -find_package(SDL2 CONFIG REQUIRED) - -target_link_libraries(notorious_beeg_SDL2 PRIVATE frontend_base) -target_link_libraries(notorious_beeg_SDL2 PRIVATE SDL2::SDL2 SDL2::SDL2main) +target_link_libraries(notorious_beeg_SDL2 PRIVATE sdl2_base) target_add_common_cflags(notorious_beeg_SDL2 PRIVATE) target_apply_lto_in_release(notorious_beeg_SDL2) diff --git a/src/frontend/sdl2/main.cpp b/src/frontend/sdl2/main.cpp index 9fea33d..e04468e 100644 --- a/src/frontend/sdl2/main.cpp +++ b/src/frontend/sdl2/main.cpp @@ -9,137 +9,47 @@ #include #include -#include +#include #include #include namespace { -struct App final : frontend::Base +struct App final : frontend::sdl2::Sdl2Base { App(int argc, char** argv); - ~App() override; - -public: - auto loop() -> void override; private: - auto poll_events() -> void; - auto run() -> void; - auto render() -> void; - - auto on_key_event(const SDL_KeyboardEvent& e) -> void; - auto on_display_event(const SDL_DisplayEvent& e) -> void; - auto on_window_event(const SDL_WindowEvent& e) -> void; - auto on_dropfile_event(SDL_DropEvent& e) -> void; - auto on_controlleraxis_event(const SDL_ControllerAxisEvent& e) -> void; - auto on_controllerbutton_event(const SDL_ControllerButtonEvent& e) -> void; - auto on_controllerdevice_event(const SDL_ControllerDeviceEvent& e) -> void; - - auto is_fullscreen() const -> bool; - auto toggle_fullscreen() -> void; - auto get_window_size() const -> std::pair; - auto set_window_size(std::pair new_size) const -> void; - auto resize_emu_screen() -> void; - auto open_url(const char* url) -> void; - -public: - SDL_Window* window{}; - SDL_Renderer* renderer{}; - SDL_Texture* texture{}; - - SDL_Rect emu_rect{}; - - SDL_AudioDeviceID audio_device{}; - SDL_AudioStream* audio_stream{}; - SDL_AudioSpec aspec_wnt{}; - SDL_AudioSpec aspec_got{}; - std::mutex audio_mutex{}; - int sample_rate{65536}; - bool has_focus{true}; - - std::unordered_map controllers{}; + auto render() -> void override; }; auto sdl2_audio_callback(void* user, Uint8* data, int len) -> void { auto app = static_cast(user); - std::scoped_lock lock{app->audio_mutex}; - - // this shouldn't be needed, however it causes less pops on startup - if (SDL_AudioStreamAvailable(app->audio_stream) < len * 2) - { - std::memset(data, app->aspec_got.silence, len); - return; - } - - SDL_AudioStreamGet(app->audio_stream, data, len); + app->fill_audio_data_from_stream(data, len, false); } auto on_vblank_callback(void* user) -> void { auto app = static_cast(user); - void* texture_pixels{}; - int pitch{}; - - SDL_LockTexture(app->texture, nullptr, &texture_pixels, &pitch); - SDL_ConvertPixels( - App::width, App::height, - SDL_PIXELFORMAT_BGR555, app->gameboy_advance.ppu.pixels, App::width * sizeof(std::uint16_t), // src - SDL_PIXELFORMAT_BGR555, texture_pixels, pitch // dst - ); - SDL_UnlockTexture(app->texture); + app->update_pixels_from_gba(); } -auto on_audio_callback(void* user, std::int16_t left, std::int16_t right) -> void +auto on_audio_callback(void* user) -> void { auto app = static_cast(user); - std::scoped_lock lock{app->audio_mutex}; - const std::int16_t samples[2] = {left, right}; - SDL_AudioStreamPut(app->audio_stream, samples, sizeof(samples)); + app->fill_stream_from_sample_data(); } -App::App(int argc, char** argv) : frontend::Base(argc, argv) +App::App(int argc, char** argv) : frontend::sdl2::Sdl2Base(argc, argv) { - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER | SDL_INIT_TIMER)) + if (!running) { - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + printf("not running\n"); return; } - SDL_DisplayMode display = {}; - if (!SDL_GetCurrentDisplayMode(0, &display)) - { - // if the current scale would scale the screen bigger than - // the display region, then change the scale variable - if (width * scale > display.w || height * scale > display.h) - { - update_scale(display.w, display.h); - } - } - - window = SDL_CreateWindow("Notorious BEEG", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width*scale, height*scale, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); - if (!window) - { - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); - return; - } - - renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED); - if (!renderer) - { - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); - return; - } - - texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_BGR555, SDL_TEXTUREACCESS_STREAMING, width, height); - if (!texture) - { - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); - return; - } - - SDL_SetWindowMinimumSize(window, width, height); + running = false; #if SDL_BYTEORDER == SDL_BIG_ENDIAN const auto rmask = 0xff000000; @@ -160,50 +70,14 @@ App::App(int argc, char** argv) : frontend::Base(argc, argv) SDL_FreeSurface(icon); } - // setup emu rect - resize_emu_screen(); - - SDL_RenderSetVSync(renderer, 1); - - aspec_wnt.freq = sample_rate; - aspec_wnt.format = AUDIO_S16; - aspec_wnt.channels = 2; - aspec_wnt.silence = 0; - aspec_wnt.samples = 2048; - aspec_wnt.padding = 0; - aspec_wnt.size = 0; - aspec_wnt.userdata = this; - aspec_wnt.callback = sdl2_audio_callback; - - // allow all apsec to be changed if needed. - // will be coverted and resampled by audiostream. - audio_device = SDL_OpenAudioDevice(nullptr, 0, &aspec_wnt, &aspec_got, SDL_AUDIO_ALLOW_ANY_CHANGE); - if (audio_device == 0) + if (!init_audio(this, sdl2_audio_callback, on_audio_callback)) { return; } - audio_stream = SDL_NewAudioStream( - aspec_wnt.format, aspec_wnt.channels, aspec_wnt.freq, - aspec_got.format, aspec_got.channels, aspec_got.freq - ); - - if (!audio_stream) - { - return; - } - - std::printf("[SDL-AUDIO] format\twant: 0x%X \tgot: 0x%X\n", aspec_wnt.format, aspec_got.format); - std::printf("[SDL-AUDIO] freq\twant: %d \tgot: %d\n", aspec_wnt.freq, aspec_got.freq); - std::printf("[SDL-AUDIO] channels\twant: %d \tgot: %d\n", aspec_wnt.channels, aspec_got.channels); - std::printf("[SDL-AUDIO] samples\twant: %d \tgot: %d\n", aspec_wnt.samples, aspec_got.samples); - std::printf("[SDL-AUDIO] size\twant: %u \tgot: %u\n", aspec_wnt.size, aspec_got.size); - - SDL_PauseAudioDevice(audio_device, 0); - gameboy_advance.set_userdata(this); gameboy_advance.set_vblank_callback(on_vblank_callback); - gameboy_advance.set_audio_callback(on_audio_callback); + gameboy_advance.set_audio_callback(on_audio_callback, sample_data); // need a rom to run atm if (has_rom) @@ -219,467 +93,17 @@ App::App(int argc, char** argv) : frontend::Base(argc, argv) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", "Failed to loadrom!", nullptr); } -}; - -App::~App() -{ - for (auto& [_, controller] : controllers) - { - SDL_GameControllerClose(controller); - } - - if (audio_device != 0) { SDL_CloseAudioDevice(audio_device); } - if (audio_stream != nullptr) { SDL_FreeAudioStream(audio_stream); } - if (texture != nullptr) { SDL_DestroyTexture(texture); } - if (renderer != nullptr) { SDL_DestroyRenderer(renderer); } - if (window != nullptr) { SDL_DestroyWindow(window); } - - SDL_Quit(); -} - -auto App::loop() -> void -{ - while (running) - { - poll_events(); - run(); - render(); - } -} - -auto App::poll_events() -> void -{ - SDL_Event e{}; - - while (SDL_PollEvent(&e) != 0) - { - switch (e.type) - { - case SDL_QUIT: - running = false; - break; - - case SDL_KEYDOWN: - case SDL_KEYUP: - on_key_event(e.key); - break; - - case SDL_DISPLAYEVENT: - on_display_event(e.display); - break; - - case SDL_WINDOWEVENT: - on_window_event(e.window); - break; - - case SDL_CONTROLLERAXISMOTION: - on_controlleraxis_event(e.caxis); - break; - - case SDL_CONTROLLERBUTTONDOWN: - case SDL_CONTROLLERBUTTONUP: - on_controllerbutton_event(e.cbutton); - break; - - case SDL_CONTROLLERDEVICEADDED: - case SDL_CONTROLLERDEVICEREMOVED: - case SDL_CONTROLLERDEVICEREMAPPED: - on_controllerdevice_event(e.cdevice); - break; - - case SDL_DROPFILE: - on_dropfile_event(e.drop); - break; - - case SDL_DROPTEXT: - case SDL_DROPBEGIN: - case SDL_DROPCOMPLETE: - - case SDL_APP_TERMINATING: - case SDL_APP_LOWMEMORY: - case SDL_APP_WILLENTERBACKGROUND: - case SDL_APP_DIDENTERBACKGROUND: - case SDL_APP_WILLENTERFOREGROUND: - case SDL_APP_DIDENTERFOREGROUND: - case SDL_LOCALECHANGED: - case SDL_SYSWMEVENT: - case SDL_TEXTEDITING: - case SDL_TEXTINPUT: - case SDL_KEYMAPCHANGED: - case SDL_MOUSEMOTION: - case SDL_MOUSEBUTTONDOWN: - case SDL_MOUSEBUTTONUP: - case SDL_MOUSEWHEEL: - case SDL_JOYAXISMOTION: - case SDL_JOYBALLMOTION: - case SDL_JOYHATMOTION: - case SDL_JOYBUTTONDOWN: - case SDL_JOYBUTTONUP: - case SDL_JOYDEVICEADDED: - case SDL_JOYDEVICEREMOVED: - case SDL_CONTROLLERTOUCHPADDOWN: - case SDL_CONTROLLERTOUCHPADMOTION: - case SDL_CONTROLLERTOUCHPADUP: - case SDL_CONTROLLERSENSORUPDATE: - case SDL_FINGERDOWN: - case SDL_FINGERUP: - case SDL_FINGERMOTION: - case SDL_DOLLARGESTURE: - case SDL_DOLLARRECORD: - case SDL_MULTIGESTURE: - case SDL_CLIPBOARDUPDATE: - case SDL_AUDIODEVICEADDED: - case SDL_AUDIODEVICEREMOVED: - case SDL_SENSORUPDATE: - case SDL_RENDER_TARGETS_RESET: - case SDL_RENDER_DEVICE_RESET: - case SDL_USEREVENT: - break; - } - } -} - -auto App::run() -> void -{ - if (emu_run && has_focus) - { - gameboy_advance.run(); - } } auto App::render() -> void { + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); SDL_RenderClear(renderer); - SDL_RenderCopy(renderer, texture, nullptr, &emu_rect); - SDL_RenderPresent(renderer); -} - -auto App::on_key_event(const SDL_KeyboardEvent& e) -> void -{ - const auto down = e.type == SDL_KEYDOWN; - const auto ctrl = (e.keysym.mod & KMOD_CTRL) > 0; - const auto shift = (e.keysym.mod & KMOD_SHIFT) > 0; - - if (ctrl) - { - if (down) - { - return; - } - - if (shift) - { - } - else - { - switch (e.keysym.scancode) - { - case SDL_SCANCODE_F: - toggle_fullscreen(); - break; - - case SDL_SCANCODE_P: - emu_run ^= 1; - break; - - case SDL_SCANCODE_R: - if (enabled_rewind) - { - emu_rewind ^= 1; - } - break; - - case SDL_SCANCODE_S: - savestate(rom_path); - break; - - case SDL_SCANCODE_L: - loadstate(rom_path); - break; - - case SDL_SCANCODE_EQUALS: - case SDL_SCANCODE_KP_PLUS: - scale++; - set_window_size({width * scale, height * scale}); - break; - - case SDL_SCANCODE_MINUS: - case SDL_SCANCODE_KP_MINUS: - if (scale > 1) - { - scale--; - set_window_size({width * scale, height * scale}); - } - break; - - default: break; // silence enum warning - } - } + update_texture_from_pixels(); - return; - } - - switch (e.keysym.scancode) - { - case SDL_SCANCODE_X: set_button(gba::A, down); break; - case SDL_SCANCODE_Z: set_button(gba::B, down); break; - case SDL_SCANCODE_A: set_button(gba::L, down); break; - case SDL_SCANCODE_S: set_button(gba::R, down); break; - case SDL_SCANCODE_RETURN: set_button(gba::START, down); break; - case SDL_SCANCODE_SPACE: set_button(gba::SELECT, down); break; - case SDL_SCANCODE_UP: set_button(gba::UP, down); break; - case SDL_SCANCODE_DOWN: set_button(gba::DOWN, down); break; - case SDL_SCANCODE_LEFT: set_button(gba::LEFT, down); break; - case SDL_SCANCODE_RIGHT: set_button(gba::RIGHT, down); break; - - #ifndef EMSCRIPTEN - case SDL_SCANCODE_ESCAPE: - running = false; - break; - #endif // EMSCRIPTEN - - default: break; // silence enum warning - } -} - -auto App::on_display_event(const SDL_DisplayEvent& e) -> void -{ - switch (e.event) - { - case SDL_DISPLAYEVENT_NONE: - case SDL_DISPLAYEVENT_ORIENTATION: - case SDL_DISPLAYEVENT_CONNECTED: - case SDL_DISPLAYEVENT_DISCONNECTED: - break; - } -} - -auto App::on_window_event(const SDL_WindowEvent& e) -> void -{ - switch (e.event) - { - case SDL_WINDOWEVENT_SHOWN: - case SDL_WINDOWEVENT_HIDDEN: - case SDL_WINDOWEVENT_EXPOSED: - case SDL_WINDOWEVENT_MOVED: - case SDL_WINDOWEVENT_RESIZED: - break; - - case SDL_WINDOWEVENT_SIZE_CHANGED: - resize_emu_screen(); - break; - - case SDL_WINDOWEVENT_MINIMIZED: - case SDL_WINDOWEVENT_MAXIMIZED: - case SDL_WINDOWEVENT_RESTORED: - case SDL_WINDOWEVENT_ENTER: - case SDL_WINDOWEVENT_LEAVE: - break; - - case SDL_WINDOWEVENT_FOCUS_GAINED: - has_focus = true; - break; - - case SDL_WINDOWEVENT_FOCUS_LOST: - has_focus = false; - break; - - case SDL_WINDOWEVENT_CLOSE: - case SDL_WINDOWEVENT_TAKE_FOCUS: - case SDL_WINDOWEVENT_HIT_TEST: - break; - } -} - -auto App::on_dropfile_event(SDL_DropEvent& e) -> void -{ - if (e.file != nullptr) - { - loadrom(e.file); - SDL_free(e.file); - } -} - -auto App::on_controlleraxis_event(const SDL_ControllerAxisEvent& e) -> void -{ - // sdl recommends deadzone of 8000 - constexpr auto DEADZONE = 8000; - constexpr auto LEFT = -DEADZONE; - constexpr auto RIGHT = +DEADZONE; - constexpr auto UP = -DEADZONE; - constexpr auto DOWN = +DEADZONE; - - switch (e.axis) - { - case SDL_CONTROLLER_AXIS_LEFTX: - case SDL_CONTROLLER_AXIS_RIGHTX: - if (e.value < LEFT) - { - set_button(gba::LEFT, true); - } - else if (e.value > RIGHT) - { - set_button(gba::RIGHT, true); - } - else - { - set_button(gba::LEFT, false); - set_button(gba::RIGHT, false); - } - break; - - case SDL_CONTROLLER_AXIS_LEFTY: - case SDL_CONTROLLER_AXIS_RIGHTY: - if (e.value < UP) - { - set_button(gba::UP, true); - } - else if (e.value > DOWN) - { - set_button(gba::DOWN, true); - } - else - { - { - set_button(gba::UP, false); - set_button(gba::DOWN, false); - } - } - break; - - // don't handle yet - case SDL_CONTROLLER_AXIS_TRIGGERLEFT: - case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: - return; - - default: return; // silence enum warning - } -} - -auto App::on_controllerbutton_event(const SDL_ControllerButtonEvent& e) -> void -{ - const auto down = e.type == SDL_CONTROLLERBUTTONDOWN; - - switch (e.button) - { - case SDL_CONTROLLER_BUTTON_A: set_button(gba::A, down); break; - case SDL_CONTROLLER_BUTTON_B: set_button(gba::B, down); break; - case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: set_button(gba::L, down); break; - case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: set_button(gba::R, down); break; - case SDL_CONTROLLER_BUTTON_START: set_button(gba::START, down); break; - case SDL_CONTROLLER_BUTTON_GUIDE: set_button(gba::SELECT, down); break; - case SDL_CONTROLLER_BUTTON_DPAD_UP: set_button(gba::UP, down); break; - case SDL_CONTROLLER_BUTTON_DPAD_DOWN: set_button(gba::DOWN, down); break; - case SDL_CONTROLLER_BUTTON_DPAD_LEFT: set_button(gba::LEFT, down); break; - case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: set_button(gba::RIGHT, down); break; - - default: break; // silence enum warning - } -} - -auto App::on_controllerdevice_event(const SDL_ControllerDeviceEvent& e) -> void -{ - switch (e.type) - { - case SDL_CONTROLLERDEVICEADDED: { - const auto itr = controllers.find(e.which); - if (itr == controllers.end()) - { - auto controller = SDL_GameControllerOpen(e.which); - if (controller != nullptr) - { - std::printf("[CONTROLLER] opened: %s\n", SDL_GameControllerNameForIndex(e.which)); - } - else - { - std::printf("[CONTROLLER] failed to open: %s error: %s\n", SDL_GameControllerNameForIndex(e.which), SDL_GetError()); - } - } - else - { - std::printf("[CONTROLLER] already added, ignoring: %s\n", SDL_GameControllerNameForIndex(e.which)); - } - } break; - - case SDL_CONTROLLERDEVICEREMOVED: { - const auto itr = controllers.find(e.which); - if (itr != controllers.end()) - { - std::printf("[CONTROLLER] removed controller\n"); - - // have to manually close to free struct - SDL_GameControllerClose(itr->second); - controllers.erase(itr); - } - } break; - - case SDL_CONTROLLERDEVICEREMAPPED: - std::printf("mapping updated for: %s\n", SDL_GameControllerNameForIndex(e.which)); - break; - } -} - -auto App::is_fullscreen() const -> bool -{ - const auto flags = SDL_GetWindowFlags(window); - return (flags & (SDL_WINDOW_FULLSCREEN | SDL_WINDOW_FULLSCREEN_DESKTOP)) != 0; -} - -auto App::toggle_fullscreen() -> void -{ - if (is_fullscreen()) - { - SDL_SetWindowFullscreen(window, 0); - } - else - { - SDL_SetWindowFullscreen(window, SDL_WINDOW_FULLSCREEN); - } -} - -auto App::get_window_size() const -> std::pair -{ - int w; - int h; - SDL_GetRendererOutputSize(renderer, &w, &h); - - return {w, h}; -} - -auto App::set_window_size(std::pair new_size) const -> void -{ - const auto [w, h] = new_size; - - SDL_SetWindowSize(window, w, h); -} - -auto App::resize_emu_screen() -> void -{ - const auto [w, h] = get_window_size(); - update_scale(w, h); - - if (maintain_aspect_ratio) - { - const auto [scx, scy, scw, sch] = scale_with_aspect_ratio(w, h); - - emu_rect.x = scx; - emu_rect.y = scy; - emu_rect.w = scw; - emu_rect.h = sch; - } - else - { - emu_rect.x = 0; - emu_rect.y = 0; - emu_rect.w = w; - emu_rect.h = h; - } -} - -auto App::open_url(const char* url) -> void -{ - SDL_OpenURL(url); + SDL_RenderCopy(renderer, texture, nullptr, &emu_rect); + SDL_RenderPresent(renderer); } } // namespace diff --git a/src/frontend/sdl2_base/CMakeLists.txt b/src/frontend/sdl2_base/CMakeLists.txt new file mode 100644 index 0000000..bd1a971 --- /dev/null +++ b/src/frontend/sdl2_base/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.20.0) + +project(sdl2_base LANGUAGES CXX) + +add_library(sdl2_base sdl2_base.cpp) + +find_package(SDL2 CONFIG REQUIRED) + +target_link_libraries(sdl2_base PUBLIC frontend_base) +target_link_libraries(sdl2_base PUBLIC SDL2::SDL2 SDL2::SDL2main) + +target_include_directories(sdl2_base PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +set_target_properties(sdl2_base PROPERTIES CXX_STANDARD 23) + + +target_add_common_cflags(sdl2_base PRIVATE) +target_apply_lto_in_release(sdl2_base) diff --git a/src/frontend/sdl2_base/sdl2_base.cpp b/src/frontend/sdl2_base/sdl2_base.cpp new file mode 100644 index 0000000..819d084 --- /dev/null +++ b/src/frontend/sdl2_base/sdl2_base.cpp @@ -0,0 +1,806 @@ +// Copyright 2022 TotalJustice. +// SPDX-License-Identifier: GPL-3.0-only +#include "sdl2_base.hpp" +#include + +namespace frontend::sdl2 { + +Sdl2Base::Sdl2Base(int argc, char** argv) : frontend::Base{argc, argv} +{ + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER | SDL_INIT_TIMER)) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + return; + } + + window = SDL_CreateWindow("Notorious BEEG", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width*scale, height*scale, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + if (!window) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + return; + } + + renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED); + if (!renderer) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + return; + } + + texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_BGR555, SDL_TEXTUREACCESS_STREAMING, width, height); + if (!texture) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + return; + } + + SDL_SetWindowMinimumSize(window, width, height); + + // setup emu rect + resize_emu_screen(); + + SDL_RenderSetVSync(renderer, 1); + + running = true; +} + +auto Sdl2Base::init_audio(void* user, SDL_AudioCallback sdl2_cb, gba::AudioCallback gba_cb, int sample_rate) -> bool +{ + aspec_wnt.freq = sample_rate; + aspec_wnt.format = AUDIO_S16; + aspec_wnt.channels = 2; + aspec_wnt.silence = 0; + aspec_wnt.samples = 2048; + aspec_wnt.padding = 0; + aspec_wnt.size = 0; + aspec_wnt.userdata = user; + aspec_wnt.callback = sdl2_cb; + + // allow all apsec to be changed if needed. + // will be coverted and resampled by audiostream. + audio_device = SDL_OpenAudioDevice(nullptr, 0, &aspec_wnt, &aspec_got, SDL_AUDIO_ALLOW_ANY_CHANGE); + if (audio_device == 0) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + return false; + } + + if (aspec_got.size <= 1) + { + return false; + } + + // has to be power of 2 + sample_data.resize((aspec_got.samples * aspec_got.channels) & ~0x1); + + audio_stream = SDL_NewAudioStream( + aspec_wnt.format, aspec_wnt.channels, aspec_wnt.freq, + aspec_got.format, aspec_got.channels, aspec_got.freq + ); + + if (!audio_stream) + { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Error", SDL_GetError(), nullptr); + return false; + } + + std::printf("[SDL-AUDIO] format\twant: 0x%X \tgot: 0x%X\n", aspec_wnt.format, aspec_got.format); + std::printf("[SDL-AUDIO] freq\twant: %d \tgot: %d\n", aspec_wnt.freq, aspec_got.freq); + std::printf("[SDL-AUDIO] channels\twant: %d \tgot: %d\n", aspec_wnt.channels, aspec_got.channels); + std::printf("[SDL-AUDIO] samples\twant: %d \tgot: %d\n", aspec_wnt.samples, aspec_got.samples); + std::printf("[SDL-AUDIO] size\twant: %u \tgot: %u\n", aspec_wnt.size, aspec_got.size); + + SDL_PauseAudioDevice(audio_device, 0); + + gameboy_advance.set_audio_callback(gba_cb, sample_data); + + return true; +} + +Sdl2Base::~Sdl2Base() +{ + for (auto& [_, controller] : controllers) + { + SDL_GameControllerClose(controller); + } + + if (audio_device != 0) { SDL_CloseAudioDevice(audio_device); } + if (audio_stream != nullptr) { SDL_FreeAudioStream(audio_stream); } + if (texture != nullptr) { SDL_DestroyTexture(texture); } + if (renderer != nullptr) { SDL_DestroyRenderer(renderer); } + if (window != nullptr) { SDL_DestroyWindow(window); } + + SDL_Quit(); +} + +auto Sdl2Base::set_button(gba::Button button, bool down) -> void +{ + std::scoped_lock lock{core_mutex}; + frontend::Base::set_button(button, down); +} + +auto Sdl2Base::loop() -> void +{ + while (running) + { + step(); + } +} + +auto Sdl2Base::step() -> void +{ + static Uint64 start = 0; + static Uint64 now = 0; + + constexpr auto div_60 = 1000.0 / 60.0; + static auto delta = div_60; + + if (start == 0) [[unlikely]] // only happens on startup + { + start = SDL_GetPerformanceCounter(); + } + + poll_events(); + run(delta / div_60); + render(); + + now = SDL_GetPerformanceCounter(); + const auto freq = static_cast(SDL_GetPerformanceFrequency()); + delta = static_cast((now - start) * 1000.0) / freq; + start = now; +} + +auto Sdl2Base::poll_events() -> void +{ + SDL_Event e{}; + + while (SDL_PollEvent(&e) != 0) + { + switch (e.type) + { + case SDL_QUIT: + running = false; + break; + + case SDL_KEYDOWN: + case SDL_KEYUP: + on_key_event(e.key); + break; + + case SDL_DISPLAYEVENT: + on_display_event(e.display); + break; + + case SDL_WINDOWEVENT: + on_window_event(e.window); + break; + + case SDL_CONTROLLERAXISMOTION: + on_controlleraxis_event(e.caxis); + break; + + case SDL_CONTROLLERBUTTONDOWN: + case SDL_CONTROLLERBUTTONUP: + on_controllerbutton_event(e.cbutton); + break; + + case SDL_CONTROLLERDEVICEADDED: + case SDL_CONTROLLERDEVICEREMOVED: + case SDL_CONTROLLERDEVICEREMAPPED: + on_controllerdevice_event(e.cdevice); + break; + + case SDL_DROPFILE: + on_dropfile_event(e.drop); + break; + + case SDL_DROPTEXT: + case SDL_DROPBEGIN: + case SDL_DROPCOMPLETE: + case SDL_APP_TERMINATING: + case SDL_APP_LOWMEMORY: + case SDL_APP_WILLENTERBACKGROUND: + case SDL_APP_DIDENTERBACKGROUND: + case SDL_APP_WILLENTERFOREGROUND: + case SDL_APP_DIDENTERFOREGROUND: + case SDL_LOCALECHANGED: + case SDL_SYSWMEVENT: + case SDL_TEXTEDITING: + case SDL_TEXTINPUT: + case SDL_KEYMAPCHANGED: + break; + + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + on_mousebutton_event(e.button); + break; + + case SDL_MOUSEMOTION: + on_mousemotion_event(e.motion); + break; + + case SDL_MOUSEWHEEL: + case SDL_JOYAXISMOTION: + case SDL_JOYBALLMOTION: + case SDL_JOYHATMOTION: + case SDL_JOYBUTTONDOWN: + case SDL_JOYBUTTONUP: + case SDL_JOYDEVICEADDED: + case SDL_JOYDEVICEREMOVED: + case SDL_CONTROLLERTOUCHPADDOWN: + case SDL_CONTROLLERTOUCHPADMOTION: + case SDL_CONTROLLERTOUCHPADUP: + case SDL_CONTROLLERSENSORUPDATE: + break; + + case SDL_FINGERDOWN: + case SDL_FINGERUP: + case SDL_FINGERMOTION: + on_touch_event(e.tfinger); + break; + + case SDL_DOLLARGESTURE: + case SDL_DOLLARRECORD: + case SDL_MULTIGESTURE: + case SDL_CLIPBOARDUPDATE: + case SDL_AUDIODEVICEADDED: + case SDL_AUDIODEVICEREMOVED: + case SDL_SENSORUPDATE: + case SDL_RENDER_TARGETS_RESET: + case SDL_RENDER_DEVICE_RESET: + break; + + // handled below + case SDL_USEREVENT: + break; + } + + if (e.type >= SDL_USEREVENT) + { + on_user_event(e.user); + } + } +} + +auto Sdl2Base::run(double delta) -> void +{ + // todo: handle menu != Menu::ROM + if (!emu_run || !has_focus || !has_rom) + { + return; + } + + std::scoped_lock lock{core_mutex}; + + // just in case something sends the main thread to sleep + // ie, filedialog, then cap the max delta to something reasonable! + // maybe keep track of deltas here to get an average? + delta = std::min(delta, 1.333333); + auto cycles = 280896 * delta; + if (emu_fast_forward) + { + cycles *= 2; + } + gameboy_advance.run(cycles); +} + +auto Sdl2Base::on_key_event(const SDL_KeyboardEvent& e) -> void +{ + const auto down = e.type == SDL_KEYDOWN; + const auto ctrl = (e.keysym.mod & KMOD_CTRL) > 0; + const auto shift = (e.keysym.mod & KMOD_SHIFT) > 0; + + if (ctrl) + { + if (down) + { + return; + } + + if (shift) + { + switch (e.keysym.scancode) + { + case SDL_SCANCODE_L: + rom_file_picker(); + break; + + default: break; // silence enum warning + } + } + else + { + switch (e.keysym.scancode) + { + case SDL_SCANCODE_F: + toggle_fullscreen(); + break; + + case SDL_SCANCODE_P: + emu_run ^= 1; + break; + + case SDL_SCANCODE_R: + if (enabled_rewind) + { + emu_rewind ^= 1; + } + break; + + case SDL_SCANCODE_S: + savestate(rom_path); + break; + + case SDL_SCANCODE_L: + loadstate(rom_path); + break; + + case SDL_SCANCODE_EQUALS: + case SDL_SCANCODE_KP_PLUS: + scale++; + set_window_size({width * scale, height * scale}); + break; + + case SDL_SCANCODE_MINUS: + case SDL_SCANCODE_KP_MINUS: + if (scale > 1) + { + scale--; + set_window_size({width * scale, height * scale}); + } + break; + + + default: break; // silence enum warning + } + } + + return; + } + + switch (e.keysym.scancode) + { + case SDL_SCANCODE_X: set_button(gba::A, down); break; + case SDL_SCANCODE_Z: set_button(gba::B, down); break; + case SDL_SCANCODE_A: set_button(gba::L, down); break; + case SDL_SCANCODE_S: set_button(gba::R, down); break; + case SDL_SCANCODE_RETURN: set_button(gba::START, down); break; + case SDL_SCANCODE_SPACE: set_button(gba::SELECT, down); break; + case SDL_SCANCODE_UP: set_button(gba::UP, down); break; + case SDL_SCANCODE_DOWN: set_button(gba::DOWN, down); break; + case SDL_SCANCODE_LEFT: set_button(gba::LEFT, down); break; + case SDL_SCANCODE_RIGHT: set_button(gba::RIGHT, down); break; + + #ifndef EMSCRIPTEN + case SDL_SCANCODE_ESCAPE: + running = false; + break; + #endif // EMSCRIPTEN + + default: break; // silence enum warning + } +} + +auto Sdl2Base::on_display_event(const SDL_DisplayEvent& e) -> void +{ + switch (e.event) + { + case SDL_DISPLAYEVENT_NONE: + std::printf("SDL_DISPLAYEVENT_NONE\n"); + break; + + case SDL_DISPLAYEVENT_ORIENTATION: + std::printf("SDL_DISPLAYEVENT_ORIENTATION\n"); + break; + + case SDL_DISPLAYEVENT_CONNECTED: + std::printf("SDL_DISPLAYEVENT_CONNECTED\n"); + break; + + case SDL_DISPLAYEVENT_DISCONNECTED: + std::printf("SDL_DISPLAYEVENT_DISCONNECTED\n"); + break; + } +} + +auto Sdl2Base::on_window_event(const SDL_WindowEvent& e) -> void +{ + switch (e.event) + { + case SDL_WINDOWEVENT_SHOWN: + // std::printf("SDL_WINDOWEVENT_SHOWN\n"); + break; + + case SDL_WINDOWEVENT_HIDDEN: + // std::printf("SDL_WINDOWEVENT_HIDDEN\n"); + break; + + case SDL_WINDOWEVENT_EXPOSED: + // std::printf("SDL_WINDOWEVENT_EXPOSED\n"); + break; + + case SDL_WINDOWEVENT_MOVED: + // std::printf("SDL_WINDOWEVENT_MOVED\n"); + break; + + case SDL_WINDOWEVENT_RESIZED: + // std::printf("SDL_WINDOWEVENT_RESIZED\n"); + break; + + case SDL_WINDOWEVENT_SIZE_CHANGED: + // std::printf("SDL_WINDOWEVENT_SIZE_CHANGED\n"); + resize_emu_screen(); + break; + + case SDL_WINDOWEVENT_MINIMIZED: + // std::printf("SDL_WINDOWEVENT_MINIMIZED\n"); + break; + + case SDL_WINDOWEVENT_MAXIMIZED: + // std::printf("SDL_WINDOWEVENT_MAXIMIZED\n"); + break; + + case SDL_WINDOWEVENT_RESTORED: + // std::printf("SDL_WINDOWEVENT_RESTORED\n"); + break; + + case SDL_WINDOWEVENT_ENTER: + // std::printf("SDL_WINDOWEVENT_ENTER\n"); + break; + + case SDL_WINDOWEVENT_LEAVE: + // std::printf("SDL_WINDOWEVENT_LEAVE\n"); + break; + + case SDL_WINDOWEVENT_FOCUS_GAINED: + // std::printf("SDL_WINDOWEVENT_FOCUS_GAINED\n"); + has_focus = true; + break; + + case SDL_WINDOWEVENT_FOCUS_LOST: + // std::printf("SDL_WINDOWEVENT_FOCUS_LOST\n"); + has_focus = false; + break; + + case SDL_WINDOWEVENT_CLOSE: + // std::printf("SDL_WINDOWEVENT_CLOSE\n"); + break; + + case SDL_WINDOWEVENT_TAKE_FOCUS: + // std::printf("SDL_WINDOWEVENT_TAKE_FOCUS\n"); + break; + + case SDL_WINDOWEVENT_HIT_TEST: + // std::printf("SDL_WINDOWEVENT_HIT_TEST\n"); + break; + } +} + +auto Sdl2Base::on_dropfile_event(SDL_DropEvent& e) -> void +{ + if (e.file != nullptr) + { + loadrom(e.file); + SDL_free(e.file); + } +} + +auto Sdl2Base::on_controlleraxis_event(const SDL_ControllerAxisEvent& e) -> void +{ + // touch_hidden = true; + + // sdl recommends deadzone of 8000 + constexpr auto DEADZONE = 8000; + constexpr auto LEFT = -DEADZONE; + constexpr auto RIGHT = +DEADZONE; + constexpr auto UP = -DEADZONE; + constexpr auto DOWN = +DEADZONE; + + switch (e.axis) + { + case SDL_CONTROLLER_AXIS_LEFTX: + case SDL_CONTROLLER_AXIS_RIGHTX: + if (e.value < LEFT) + { + set_button(gba::LEFT, true); + } + else if (e.value > RIGHT) + { + set_button(gba::RIGHT, true); + } + else + { + set_button(gba::LEFT, false); + set_button(gba::RIGHT, false); + } + break; + + case SDL_CONTROLLER_AXIS_LEFTY: + case SDL_CONTROLLER_AXIS_RIGHTY: + if (e.value < UP) + { + set_button(gba::UP, true); + } + else if (e.value > DOWN) + { + set_button(gba::DOWN, true); + } + else + { + { + set_button(gba::UP, false); + set_button(gba::DOWN, false); + } + } + break; + + // don't handle yet + case SDL_CONTROLLER_AXIS_TRIGGERLEFT: + case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: + return; + + default: return; // silence enum warning + } +} + +auto Sdl2Base::on_controllerbutton_event(const SDL_ControllerButtonEvent& e) -> void +{ + const auto down = e.type == SDL_CONTROLLERBUTTONDOWN; + + switch (e.button) + { + case SDL_CONTROLLER_BUTTON_A: set_button(gba::Button::A, down); break; + case SDL_CONTROLLER_BUTTON_B: set_button(gba::Button::B, down); break; + case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: set_button(gba::Button::L, down); break; + case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: set_button(gba::Button::R, down); break; + case SDL_CONTROLLER_BUTTON_START: set_button(gba::Button::START, down); break; + case SDL_CONTROLLER_BUTTON_GUIDE: set_button(gba::Button::SELECT, down); break; + case SDL_CONTROLLER_BUTTON_DPAD_UP: set_button(gba::Button::UP, down); break; + case SDL_CONTROLLER_BUTTON_DPAD_DOWN: set_button(gba::Button::DOWN, down); break; + case SDL_CONTROLLER_BUTTON_DPAD_LEFT: set_button(gba::Button::LEFT, down); break; + case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: set_button(gba::Button::RIGHT, down); break; + + default: break; // silence enum warning + } +} + +auto Sdl2Base::on_controllerdevice_event(const SDL_ControllerDeviceEvent& e) -> void +{ + switch (e.type) + { + case SDL_CONTROLLERDEVICEADDED: { + const auto itr = controllers.find(e.which); + if (itr == controllers.end()) + { + auto controller = SDL_GameControllerOpen(e.which); + if (controller != nullptr) + { + std::printf("[CONTROLLER] opened: %s\n", SDL_GameControllerNameForIndex(e.which)); + } + else + { + std::printf("[CONTROLLER] failed to open: %s error: %s\n", SDL_GameControllerNameForIndex(e.which), SDL_GetError()); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Controller", SDL_GetError(), nullptr); + } + } + else + { + std::printf("[CONTROLLER] already added, ignoring: %s\n", SDL_GameControllerNameForIndex(e.which)); + } + } break; + + case SDL_CONTROLLERDEVICEREMOVED: { + const auto itr = controllers.find(e.which); + if (itr != controllers.end()) + { + std::printf("[CONTROLLER] removed controller\n"); + + // have to manually close to free struct + SDL_GameControllerClose(itr->second); + controllers.erase(itr); + } + } break; + + case SDL_CONTROLLERDEVICEREMAPPED: + std::printf("mapping updated for: %s\n", SDL_GameControllerNameForIndex(e.which)); + break; + } +} + +auto Sdl2Base::get_window_to_render_scale(int mx, int my) const -> std::pair +{ + const auto [win_w, win_h] = get_window_size(); + const auto [ren_w, ren_h] = get_renderer_size(); + + // we need to un-normalise x, y + const int x = static_cast(mx) * (static_cast(ren_w) / static_cast(win_w)); + const int y = static_cast(my) * (static_cast(ren_h) / static_cast(win_h)); + + return {x, y}; +} + +auto Sdl2Base::get_render_to_window_scale(int mx, int my) const -> std::pair +{ + const auto [win_w, win_h] = get_window_size(); + const auto [ren_w, ren_h] = get_renderer_size(); + + // we need to un-normalise x, y + const int x = static_cast(mx) * (static_cast(win_w) / static_cast(ren_w)); + const int y = static_cast(my) * (static_cast(win_h) / static_cast(ren_h)); + + return {x, y}; +} + +auto Sdl2Base::is_fullscreen() const -> bool +{ + const auto flags = SDL_GetWindowFlags(window); + return (flags & (SDL_WINDOW_FULLSCREEN | SDL_WINDOW_FULLSCREEN_DESKTOP)) != 0; +} + +auto Sdl2Base::toggle_fullscreen() -> void +{ + if (is_fullscreen()) + { + SDL_SetWindowFullscreen(window, 0); + } + else + { + SDL_SetWindowFullscreen(window, SDL_WINDOW_FULLSCREEN); + } +} + +auto Sdl2Base::get_window_size() const -> std::pair +{ + int w; + int h; + SDL_GetWindowSize(window, &w, &h); + + return {w, h}; +} + + +auto Sdl2Base::get_renderer_size() const -> std::pair +{ + int w; + int h; + SDL_GetRendererOutputSize(renderer, &w, &h); + + return {w, h}; +} + +auto Sdl2Base::set_window_size(std::pair new_size) const -> void +{ + const auto [w, h] = new_size; + + SDL_SetWindowSize(window, w, h); +} + +auto Sdl2Base::set_window_size_from_renderer() -> void +{ + SDL_DisplayMode display{}; + if (!SDL_GetCurrentDisplayMode(0, &display)) + { + set_window_size({display.w, display.h}); + } +} + +auto Sdl2Base::resize_emu_screen() -> void +{ + const auto [w, h] = get_renderer_size(); + update_scale(w, h); + + if (maintain_aspect_ratio) + { + const auto [scx, scy, scw, sch] = Base::scale_with_aspect_ratio(w, h); + + emu_rect.x = scx; + emu_rect.y = scy; + emu_rect.w = scw; + emu_rect.h = sch; + } + else + { + emu_rect.x = 0; + emu_rect.y = 0; + emu_rect.w = w; + emu_rect.h = h; + } +} + +auto Sdl2Base::open_url(const char* url) -> void +{ + SDL_OpenURL(url); +} + +auto Sdl2Base::fill_audio_data_from_stream(Uint8* data, int len, bool tick_rom) -> void +{ + audio_mutex.lock(); + + const auto available = SDL_AudioStreamAvailable(audio_stream); + + bool not_enough_samples = false; + + // if theres too little samples and ticking gba isn't an option + if (available < len / 2 || (available < len && !tick_rom)) + { + not_enough_samples = true; + } + + // too many samples behind so no point catching up, or emu isn't running. + if (not_enough_samples || !emu_run || !has_focus || !has_rom || !running || emu_audio_disabled) + { + audio_mutex.unlock(); + std::memset(data, aspec_got.silence, len); + return; + } + + // this shouldn't be needed, however it causes less pops on startup + if (available < len) + { + // with this locked, nothing else writes to audiostream + std::scoped_lock lock{core_mutex}; + // need to unlock this because gba callback locks this mutex + audio_mutex.unlock(); + + while (SDL_AudioStreamAvailable(audio_stream) < len) + { + gameboy_advance.run(1000); + } + + // need to block otherwise race condition with get() + audio_mutex.lock(); + } + + SDL_AudioStreamGet(audio_stream, data, len); + audio_mutex.unlock(); +} + +auto Sdl2Base::fill_stream_from_sample_data() -> void +{ + std::scoped_lock lock{audio_mutex}; + + const int max_latency = (aspec_got.size / 2) * 3; + + // safety net for if something strange happens with the sdl audio stream + // or the callback code where we have way too many samples, specifically about + // 3 frames worth. at that point, start discarding samples for a bit. + // not the best solution at all, but it'll do for now + if (max_latency > SDL_AudioStreamAvailable(audio_stream)) + { + SDL_AudioStreamPut(audio_stream, sample_data.data(), sample_data.size() * 2); + } +} + +auto Sdl2Base::update_pixels_from_gba() -> void +{ + if (has_new_frame) + { + std::printf("[WARNING] dropping frame, vblank called before previous frame was displayed!\n"); + return; + } + std::memcpy(pixels, gameboy_advance.ppu.pixels, sizeof(gameboy_advance.ppu.pixels)); + has_new_frame = true; +} + +auto Sdl2Base::update_texture_from_pixels() -> void +{ + core_mutex.lock(); + if (has_new_frame) + { + has_new_frame = false; + + void* texture_pixels{}; + int pitch{}; + + SDL_LockTexture(texture, nullptr, &texture_pixels, &pitch); + SDL_ConvertPixels( + width, height, + SDL_PIXELFORMAT_BGR555, pixels, width * sizeof(std::uint16_t), // src + SDL_PIXELFORMAT_BGR555, texture_pixels, pitch // dst + ); + SDL_UnlockTexture(texture); + } + core_mutex.unlock(); +} + +} // namespace frontend::sdl2 diff --git a/src/frontend/sdl2_base/sdl2_base.hpp b/src/frontend/sdl2_base/sdl2_base.hpp new file mode 100644 index 0000000..c00b1b8 --- /dev/null +++ b/src/frontend/sdl2_base/sdl2_base.hpp @@ -0,0 +1,89 @@ +// Copyright 2022 TotalJustice. +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include "../frontend_base.hpp" + +#include +#include +#include + +namespace frontend::sdl2 { + +struct Sdl2Base : frontend::Base +{ + Sdl2Base(int argc, char** argv); + ~Sdl2Base() override; + +public: + virtual auto init_audio(void* user, SDL_AudioCallback sdl2_cb, gba::AudioCallback gba_cb, int sample_rate = 65536) -> bool; + + auto set_button(gba::Button button, bool down) -> void override; + + auto loop() -> void override; + virtual auto step() -> void; + + virtual auto poll_events() -> void; + virtual auto run(double delta) -> void; + virtual auto render() -> void = 0; + + virtual auto on_key_event(const SDL_KeyboardEvent& e) -> void; + virtual auto on_display_event(const SDL_DisplayEvent& e) -> void; + virtual auto on_window_event(const SDL_WindowEvent& e) -> void; + virtual auto on_dropfile_event(SDL_DropEvent& e) -> void; + virtual auto on_user_event(SDL_UserEvent& e) -> void {} + virtual auto on_controlleraxis_event(const SDL_ControllerAxisEvent& e) -> void; + virtual auto on_controllerbutton_event(const SDL_ControllerButtonEvent& e) -> void; + virtual auto on_controllerdevice_event(const SDL_ControllerDeviceEvent& e) -> void; + virtual auto on_mousebutton_event(const SDL_MouseButtonEvent& e) -> void {} + virtual auto on_mousemotion_event(const SDL_MouseMotionEvent& e) -> void {} + virtual auto on_touch_event(const SDL_TouchFingerEvent& e) -> void {} + + virtual auto is_fullscreen() const -> bool; + virtual auto toggle_fullscreen() -> void; + // convert x,y window coor to renderer + virtual auto get_window_to_render_scale(int mx, int my) const -> std::pair; + // convert x,y renderer coor to window + virtual auto get_render_to_window_scale(int mx, int my) const -> std::pair; + + virtual auto get_renderer_size() const -> std::pair; + virtual auto get_window_size() const -> std::pair; + virtual auto set_window_size(std::pair new_size) const -> void; + virtual auto set_window_size_from_renderer() -> void; + virtual auto resize_emu_screen() -> void; + + virtual auto open_url(const char* url) -> void; + virtual auto rom_file_picker() -> void {} + + // if tick_rom is true, when the stream doesnt have enough samples + // itll run the rom until enough samples are generated. + virtual auto fill_audio_data_from_stream(Uint8* data, int len, bool tick_rom = true) -> void; + virtual auto fill_stream_from_sample_data() -> void; + + virtual auto update_pixels_from_gba() -> void; + virtual auto update_texture_from_pixels() -> void; + +public: + SDL_Window* window{}; + SDL_Renderer* renderer{}; + SDL_Texture* texture{}; + + SDL_Rect emu_rect{}; + + SDL_AudioDeviceID audio_device{}; + SDL_AudioStream* audio_stream{}; + SDL_AudioSpec aspec_wnt{}; + SDL_AudioSpec aspec_got{}; + std::mutex audio_mutex{}; + std::mutex core_mutex{}; + std::vector sample_data{}; + bool has_focus{true}; + + std::uint16_t pixels[160][240]{}; + bool has_new_frame{false}; + + std::unordered_map controllers{}; +}; + +} // namespace frontend::sdl2