From 01d96d3200342b4747fb51afc20074e284e6d7f1 Mon Sep 17 00:00:00 2001 From: OCbwoy3 Date: Wed, 17 Dec 2025 23:19:04 +0200 Subject: [PATCH] chore: init clean tree --- .editorconfig | 5 + .gitignore | 19 + .prettierignore | 1 + .prettierrc | 7 + LICENSE | 25 ++ README.md | 26 ++ asset/.gitignore | 13 + asset/README.md | 61 +++ asset/chr/.gitignore | 5 + asset/icon.png | Bin 0 -> 49519 bytes asset/preview.png | Bin 0 -> 8308881 bytes build.sh | 4 + bun.lock | 66 +++ install-dev.sh | 53 +++ install.sh | 53 +++ package.json | 21 + src/audio/decoder.ts | 82 ++++ src/audio/pitch.ts | 51 +++ src/audio/player.ts | 124 ++++++ src/bootsequence/dia.ts | 156 +++++++ src/bootsequence/font.ts | 216 ++++++++++ src/bootsequence/questions.ts | 466 +++++++++++++++++++++ src/config.ts | 40 ++ src/desktop.ts | 47 +++ src/index.ts | 3 + src/intro/text-layer.ts | 174 ++++++++ src/lib/greetd.ts | 260 ++++++++++++ src/renderer/assets.ts | 15 + src/renderer/cli.ts | 104 +++++ src/renderer/debug-hud.ts | 103 +++++ src/renderer/fps.ts | 27 ++ src/renderer/index.ts | 155 +++++++ src/renderer/layout.ts | 65 +++ src/renderer/lazy-resource.ts | 45 ++ src/renderer/video.ts | 94 +++++ src/renderer/window.ts | 98 +++++ src/renderers/device_contact/index.ts | 240 +++++++++++ src/renderers/error/index.ts | 192 +++++++++ src/renderers/index.ts | 55 +++ src/renderers/recoverymenu/index.ts | 338 +++++++++++++++ src/renderers/types.ts | 35 ++ src/types.ts | 6 + src/ui/app.ts | 574 ++++++++++++++++++++++++++ tsconfig.json | 22 + tty.sh | 6 + 45 files changed, 4152 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 asset/.gitignore create mode 100644 asset/README.md create mode 100644 asset/chr/.gitignore create mode 100644 asset/icon.png create mode 100644 asset/preview.png create mode 100755 build.sh create mode 100644 bun.lock create mode 100755 install-dev.sh create mode 100755 install.sh create mode 100644 package.json create mode 100644 src/audio/decoder.ts create mode 100644 src/audio/pitch.ts create mode 100644 src/audio/player.ts create mode 100644 src/bootsequence/dia.ts create mode 100644 src/bootsequence/font.ts create mode 100644 src/bootsequence/questions.ts create mode 100644 src/config.ts create mode 100644 src/desktop.ts create mode 100644 src/index.ts create mode 100644 src/intro/text-layer.ts create mode 100644 src/lib/greetd.ts create mode 100644 src/renderer/assets.ts create mode 100644 src/renderer/cli.ts create mode 100644 src/renderer/debug-hud.ts create mode 100644 src/renderer/fps.ts create mode 100644 src/renderer/index.ts create mode 100644 src/renderer/layout.ts create mode 100644 src/renderer/lazy-resource.ts create mode 100644 src/renderer/video.ts create mode 100644 src/renderer/window.ts create mode 100644 src/renderers/device_contact/index.ts create mode 100644 src/renderers/error/index.ts create mode 100644 src/renderers/index.ts create mode 100644 src/renderers/recoverymenu/index.ts create mode 100644 src/renderers/types.ts create mode 100644 src/types.ts create mode 100644 src/ui/app.ts create mode 100644 tsconfig.json create mode 100755 tty.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3f570f4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +[*] +root = true +indent_size = 4 +indent_style = tab +insert_final_newline = true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e37b6ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +out +dist +*.tgz +coverage +*.lcov +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json +.env +.env.development.local +.env.test.local +.env.production.local +.env.local +.eslintcache +.cache +*.tsbuildinfo +.idea +.DS_Store diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c97f963 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.sh diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5b6fcbd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "trailingComma": "none", + "tabWidth": 4, + "useTabs": true, + "semi": true, + "singleQuote": false +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d732a62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2025 OCbwoy3 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Additional Asset Disclaimer + +The MIT license in this repository applies only to the original source code and documentation, not the assets. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a859a98 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +![](asset/preview.png) + +# DEVICE_CONTACT + +greetd greeter inspired by Deltarune. For ricing purposes or similar. + +***LINUX ONLY*** + +## Setup + +***WARNING: THIS IS NOT SECURE.*** This project is intended for Linux ricing purposes. +It will automatically log into your account because it uses plaintext credentials WHICH ARE NOT SECURE! + +Put your username in `/etc/deltaboot/private/username`. + +Put your password in `/etc/deltaboot/private/password`. + +Private credentials are expected to be owned by root: +- `/etc/deltaboot/private` - `chmod 700` - `u+rx` `(dr-x------)` +- `/etc/deltaboot/private/*` - `chmod 600` - `u+r` `(-r--------)` + +## ASSETS + +This repo does not contain any Deltarune assets. You will need to obtain them manually. + +See `asset/README.md` for details. diff --git a/asset/.gitignore b/asset/.gitignore new file mode 100644 index 0000000..96e35cb --- /dev/null +++ b/asset/.gitignore @@ -0,0 +1,13 @@ +font/* +AUDIO_APPEARANCE.png +AUDIO_ANOTHERHIM.ogg +AUDIO_DRONE.ogg +goner_bg_loop.mkv +snd_menumove.wav +snd_select.wav +* +!chr/ +!.gitignore +!icon.png +!preview.png +!README.md diff --git a/asset/README.md b/asset/README.md new file mode 100644 index 0000000..4d0edbb --- /dev/null +++ b/asset/README.md @@ -0,0 +1,61 @@ +## Asset & Copyright Disclaimer + +This repository does **not** include any copyrighted assets from *DELTARUNE*. No game assets are distributed here. + +No game assets are distributed with this repository. You will need to find and extract the assets yourself, provided you bought [the game](https://store.steampowered.com/app/1671210/DELTARUNE/). (Or are using the free demo from Steam) + + +*DELTARUNE* and all related assets are © Toby Fox. + +This is a fan-made, non-commercial project and is not affiliated with or endorsed by the game’s creators. + +### Asset layout + +These files are not included in this repository and must be supplied by the user. + +``` +src/asset +├── AUDIO_ANOTHERHIM.ogg +├── AUDIO_APPEARANCE.wav +├── AUDIO_DRONE.ogg +├── bg_fountain1_0.png +├── chr +│ ├── kris.png +│ ├── noelle.png +│ ├── ralsei.png +│ └── susie.png +├── font +│ ├── fnt_comicsans.png +│ ├── fnt_dotumche.png +│ ├── fnt_ja_comicsans.png +│ ├── fnt_ja_dotumche.png +│ ├── fnt_ja_mainbig.png +│ ├── fnt_ja_main.png +│ ├── fnt_ja_small.png +│ ├── fnt_ja_tinynoelle.png +│ ├── fnt_mainbig.png +│ ├── fnt_main.png +│ ├── fnt_small.png +│ ├── fnt_tinynoelle.png +│ ├── glyphs_fnt_comicsans.csv +│ ├── glyphs_fnt_dotumche.csv +│ ├── glyphs_fnt_ja_comicsans.csv +│ ├── glyphs_fnt_ja_dotumche.csv +│ ├── glyphs_fnt_ja_mainbig.csv +│ ├── glyphs_fnt_ja_main.csv +│ ├── glyphs_fnt_ja_small.csv +│ ├── glyphs_fnt_ja_tinynoelle.csv +│ ├── glyphs_fnt_mainbig.csv +│ ├── glyphs_fnt_main.csv +│ ├── glyphs_fnt_small.csv +│ └── glyphs_fnt_tinynoelle.csv +├── goner_bg_loop.mp4 +├── goner_bg.mkv +├── icon.png +├── IMAGE_DEPTH_0.png +├── IMAGE_SOUL_BLUR_0.png +├── preview.png +├── README.md +├── snd_menumove.wav +└── snd_select.wav +``` diff --git a/asset/chr/.gitignore b/asset/chr/.gitignore new file mode 100644 index 0000000..850acfd --- /dev/null +++ b/asset/chr/.gitignore @@ -0,0 +1,5 @@ +kris.png +noelle.png +ralsei.png +susie.png +!.gitignore diff --git a/asset/icon.png b/asset/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..23436c847dcd502181c04703ec9c9edf7d70e2d3 GIT binary patch literal 49519 zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4kiW$2A`O3a~K#H7>k44ofvPP)Tw7+U|>mi z^mSxl*x1kgCy|wbfk7eJBgmJ5p-PQ`p`nF=;TK5r3kHT#0|tgy2@DKYGZ+}e3+C(! zv|(Ui;4JWnEM{QPQwCwiilz2t3=9eko-U3d6^smFjVtRI|NsBbFbW1P1VFJqaJG-y z%rF`O1Dps(BVaTF1~>!;*!s~NFd6{^90CJu{b&vtjer3TfdRIDGzW}EzyOE909!wr z14biYfJ0z_tsl(+qY*H`Auzz!kLG~U2pHfH7+~v1bHHc>3~&exu=S%kU^D^-I0OdR zdV1u5JDsJBu(bdK?A^g_Jv}00aEHJEyKXcBMk8Q=LtucdAI$*+904o?Z2v%8&&bG# z)d_T4JUDolDdGwm}nCM^yvTL!j2V(>f6E4V}Auk$;w}m|OkzI&~J?J8U_;5k? z6&YbfhDBu9g6v4F>|s&~lM$L&g9E=HBN*UUL#ixwasV0r$C{eRFbCPgSj|LMfXt@E zE|M%lwiJ_1Yy?o^TufUbBADKw+!jnTA$DPs^t21pcDM+>W+>%Ogj+)lgDTcBG7_7l zh;b5rH4q0AALUq0!&wJFOvkEcKm`B8072U+bUCR3N}+W)BY>bk>81b@@^I_WxNvbQ zGUyoxLm&dMhAjRtqA*o5Ffc%(8NV%99Y(6)fJFf1e#379<#7NBN=QsV*my{?t;1Id zU2Uz3puaOMC^>j$j}BwVQCJYtL@Mh)?f8SD{&C%IwsRYPuuRSUk$873ISu0`P=}1(>i4hq0px&0%#qMWSdXVltH;&!?OsY zJY2|*eo{3Kfo3SOm*Bs_hYh$Km@=89oh8|K|Ikw#xOmy`NXjC*iMXgh#D#v zL6C5P7)=L~&ejp>Ba&Q27j&- zG(lzrfRY0}d_=4r^mGq(?HU5TV*HUn-Qd7)7cFGT%mK7;3K?e7KlJg~NJjZHw1=kg zgasLKNsJa+*htMBKwc6c#^;!7Xb~2ewh%8uw%@540mM6(5|yAOK{yV6Kr@z%K*1BQ zc%;zmC5bx(Dq%8wPDaq<4>kO5pv1bNmjm#J6txzwA=^T`&>z~BFmf2d*_0$kxFN_4 z{GpGmk~}tNwpMi1)M>@{pN5h@iU0`JhD0D1q69^ z!R;l_Qn+>qV>r|SR0#-3aL}J5vk0QUL$TU8fI&|!s|Q^K;3=A^N2=77-%7~l{XVCzS7z-R;va0m>r^`kjpGy(=V1P0jp(Ht-u z0RtQY18n_h4j7Gq0SknkFu)-&z}63* z9B^6rXVwT`3ov*hV}MiTKv_Q;0Rt8CPW(FlMB z#6VG_5in3854CqR0-ymgP}FDy3{=QN?H!E(Xg~}UH5vf}74lGfM!lvNA9*C?tCX`7$t6sWC7#v@kIIVqjosc)`F>YQVtoDuIE)Y6b&? zc)^@qfi?^b44efXk;M!QddeWoSh3W;jDbOc!PCVtq=J!wb=t2?Mg|6k{|uvGGz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtw~<0Mn57!Keqw34vnX`AV?00OaT&)iW9bqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiTJ zLtu0=c#ucXsPjfcfchaIDl3&Tx)y-?;W}#KXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2n?h!}OoXb6mkz-S1JhQMeD&^iR%t(}*Tt_7fV zD36*y8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71b zCV0T zjE2By2#kinXb6mkz-S1JhQMeDkQV}>;^$V4t_2`3>_&BshQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz%U4b(aGRp5Q(E+7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu;s^boMNZ)hA{3xFOfqx{hj7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC}Auu`_JhI|@)VHG{Fd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O!yoK;*E3;s zEx_=P=TRSyhQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`Tg5Ez{dCKyDc3PwXZhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjD`Rt1f-WwX}>Z@XM|}U%%c{J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjD`TEA#k_e-h#BX0Hc$^l!p1J{?QN^4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7$hMu zIsrULB7)k^*ei9dl(e+~)ONwBd7~jP8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Uh0u0;7|`0~z|Gc8`VtZ9?EK zt1;i`S^(OF?Wn1vAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFl0htbTW9zMAfK6M?+vV1V%%Em=M^qcB}<5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5TJbs>~idL8eI!O`*0q$VKf9rLtr!nMnhmU1V%$(Gz5lO2#ih!53>jz_0DJrjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2DQ3IT<=$9YHB0t~M> z9rev<2#kinXb6mkz-S22D+ER-gXtC0qxOu3z-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`SdA+XG(cIN0>0Mf#2RP|^GjE2By2#kgR zQV5Jr1|tQ=D04IfMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3P$Xb23B5XigKzJ=(u0LruOqz(>ekGf(s1V%$(@P)wWjPT%#lTjCq zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1J&=7E(%5#?JwE!bDj7Ggpdh31B)0IMu9MKk-E@Ro4Pg z&1ItojfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2n>P{7@Z6r1*0J_1ViA)2K9*1wE#mf;zpf38UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0`v?4 z)@i>oM`wiT8RDaMjfTKz2#kgRMImr>uGr+!wEz?a;i%@(5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2cK_M_Y89ag_d(^9=Aut*O zqaiRF0z)_iJQd`WN7n)j;Rqac`e+D@hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeD&_4u5Cxhu9=A$-_hQMeDjE2By2#kinXb6mk07)SrH*Imq z=vn}hf^1arXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk z00RTV=w$F{G8hek(GVC7fzc2c4S~@R7!83@Fd71bHw2s;zE2um3ov-&XVhh*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFsMUdbTW8QN6)C^MnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V(NM>^)@pc62Sk$c_I|pO1#XXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1Jh5!{pfOXoh%+VQPDg@-H5u+h68UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*AwaDVXqVqpKDriwTA?{=&S(gXhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjO-8?oeUn?@jvSO(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85J69Ubw z;=4!J0t}w`7ygp503$8dNBuh*0;3@?8UmvsFd71M34zhc zV7dhJs3oHzFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqalD90w-=|2ac`(QDa6!U^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%%E zxDc54%KX>pS^(n0YE;!|2#kgRB_S|68B9s&jp`i@fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5EuocAut*Oq=bOPjfTKz2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#^y3tkZsF zj?M^^6LO<^MniyxA&}yGTW@qN01X3p)acO=7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2cAt5k289YKFdK8R?z-R~zi4d5u zS<7y8Ex?e7s!>OdhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kin;17Y($>6~sN24wr4S~@R7!85Z5Ev05aF+d-@aS5A5fRm+o*WH< z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC70Xl`i=wvXR z0(#V%(GVC7fzc2c4S~@R7!85Z5WpD%vx}3qjjjd289bvBqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0_26j=wvW?;WnykGz3ONU^E0qLtr!n zMnhmU1V%$(Gz6#;0uvt8i;b=Yph{4V8ZsIJqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*A%GqNCwt9`MrVZ4LuHgd8UmvsFd71*Aut*OqaiRF0;3@?3PwX< zGz4fK0+;(X-WpvCK=WW8wO}*^MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3O? z2#ih!kMIOA>iy9W7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;sI)}iH_C)s4wE%Pu z^ik_ZLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhm^hQR1#@W_n!QD2XSz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2n;|7EKT}hJ-QZP00Ms0%Fz%Q4S~@R7!85Z z5Eu=C(GVC7fzc2cp&>9j89YKGe$?BeAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71M4FT;xFWE-d0?;+cM=cu-fzc2c4S~@R7!85Z5Eu=C(GVD&3?5Ab zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqhK@yMniz6A;6|$ zrcR}`01gfN>uKuNQNu?=U^E0qLtr!nMnhmU1cqG*jLrxTyC@y?&}ayZhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2Bq2!XHPj*XrRFc>0Y z)D5E{Fd71*AuxhNV01Eg1V{d;*GEHOGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nXc7XPkN+;B(zyUMao?z+qaiRF0wW{@Mkj+u zNJNi%b2J1-Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONfF2>B>|DKgbS(fqLVDB=!XYp^8B92gMpcZ4z-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinC>RZa(GVC7 z0m305()LMaFs%h(o%SnpbVitPyp5_D4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!83T5dxzVzylIZ7nvSx z9!zTi1|$$hEgcPk(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GZ|v2#ih!(=c#HjUEjFtRWCrmESzN765C|j0%p1 zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb4a*1V$%=sTY=`CXI%`Xb6mk0KpKrxN&*(=vn}R!8EF1Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnixKAuu`_Ooc!k zHDWXbM!{$ZjE2By2oMnho=V=LqiX?(2&hpdqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Uj=b0oG~1GDl~GsT7!_#*Bu*Xb6mkz-S1J zhQMeDjD`T#5IEkX@N9G~0M?)x6&wwL(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GZ|*2#ih!(>8oZO&<+`(GVC7fzc2c4S~@R7!85Z5E#KBP#AeO ze{?Ot2u=W_ULOsC(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!82|4uR3h z-~o<+QR_!TU^E0qLtr!nMnhmU1V%$(Gz3ONU^E1VWe8k3VtsmaEx@ph+EGu9hQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin&5?aSwEzTzX;i^z2#kinXb6mkz-S1JhQMeDjD`TcLtu0=nBE~iYTsxGjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2B43W1aFjO#|%0t}-F9rem+ z2#kinXb6mkz-R~zs}LBS3?5d|IqI3w5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!83@Fd71*Aut*ObPEA)f$UwQYXRsM+@ltahQMeDjE2By2#_8Eqm#j; zhuNr((GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!83D9s<0}Hnxwh1sLIpVAT7gAut3&V01Eg2t?AT6GuZ}Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1O`nA9N)U& z>gZa4K@%OL4#67&tkZsFj?M_<4Wd!W(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc3PU|^>rR%YXL?l zgGW>DXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mg(GVCWAuu`tOq+<@@4(|U#MT1PCLl&l9Swoe5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Ez&c7@Z6rm~bDpby$Z0yGDxJ=vshb9mS)b8x4Wc5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S^vL0;7|`Lm-kyoj4i-qaiSa zLSStv1Ka3YfFTrNqfQ+Sfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!84890IJP}7508exXb6mkz-S1JhQMeDjE2By2#kinXb6mkzz__9(~!Q_VAsJ_t<7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S^980(C(G<)do>Moe^%dUP}dMnhmU1V%$(Gz3ONU^E1V zO$dxm1`nI49QDX(2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kgR4MM=`-?6)+YXN8wu%kwfhQMeDjE2By2#kinXb8|g1V$%=X&=U;HjIYAXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-R~z$q>jl{`7Km zEx?eBx=}}uhQMeDjE2By2!KOibTSwm45P$o2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinC>RZa(GVC7fzc2^3W4D1C+T=cK=Q0PsYsTOGDkyT zGz3ONfQlh7IwMTQz#KJdGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(NQc0?$8+sR&jlFLQ8?;&+7fT7c2X;1QaXN4-570;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?{6b)K0+`G= zZE$n68D47v$P9>4ZKEMD8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?3I=5ej7|m*%4ivN)F2N5n;YNmkFEt6+IptwE#GSXH;S|1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(u!g|sWbj~(nNfF*hQMeDjE2A{7!85Z5Eu=C(GVB}A#l9;-R;q} z0D~YRMx8Jk0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*Oqai>>2#ih! zlM!g6T1G=)Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3OK2;9?G6d7F$Fajca)Qh7bFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiSiLx6SKuguXI;b9!Xqh1>gfzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@RAUOow{BCrNt_2`D*hV#shQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2n@y$7@Z6rjIlE6rqK`>4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C0SJNSsPs9bYXJrz;76?-4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@Rpiu~n zP6pE`U`LG|4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!83@Fd71*Aut*OqaiSaLcmn$ zrReBdfFTrNqfQ+Sfzc2c4S~@R7!85Z5Eu=C(GVae1V$%=i3z1qHKQRg8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Un*M1X_f8UyQB=7`D+n>ao!f z7!85Z5Eu=C(GVEXAuu`_Jffq2)bpbuFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*AuystV11m>`O&oiBRUC;dVVwnMnhmU1crGCuul7xIXWXe z%p-Z!d!r#R8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OXdw_Vae30{S^%^_8Rd?Kz-R~z#}F8u3?7cLI_js<5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CQ7{?;qaiRF0;3@?8Uk2DK))hj z$LLxBtU)s>I1EEzbTW7tM(U`SMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1TaEi#p;vnBWf+c=wvWP8WOdhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb2DtfzioefFSct#~gLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nhG+hQMeDjE2By2#kinXb7N%!03!HT7Zml zM?+vV1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz5l4 z2t>4)^bD4@0K+0cM?Enb0;3@?8UmvsF!VxTbTWA8#o4HfM?+vV1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E2qhQNlGOG`)B0^kjz zQOVH|7!83D5dx!=!6PD?M?EZjmnRP zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjDpb+ z7!85Z5Eu=C(GVCCA)uBui*Ix-08&)3PWzQPIwOn}AfwFD5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVEQ zAuuv0fS1?$t{q(qFqG5nsM|+FU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^D~>guuw03?>jLqte4W1bme2=Z&rf z7~XL_>bubp7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4FLv*(aGS^R4^I>qaiSoLSRY7$^WBk0Y*}ckNR~q1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qa0rY}29My# zANBfZ2#kinXb6m~5D=2v={XaEQrK zKa7UJXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#^&5qm#j81>2~m z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fx#UDwil0kjIIS3+;KGOy3r6A4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;sT8F^sWH7Bmc+~vS5Eu=C(GVC7fzc2c1*0J_ z8UmvsFd71*Aut*O^bG;-4a#YwYXRsR=A*WahQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2n@;)7@Z6rl+iNksL>D@4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;s zaza3#`(XL#S^#oFZdA``2#kinXb6mkz-S1JhQMeDjE2By2#kinunYm#X}>Z@XM~4k z)Q);;Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V&N_cq_X;8eI!8 zl45++ucIL_8UmvsFd71*Aut*OqaiRF0>p*D=wvW);WVmhGz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU@(S&QTUyIqiX>MW6X@YX*2{zLtr!n zMnhmU1V%$(5QMRZa(GVC7fzc2c4S~@RAUyj7|oRoVXtK z>1YUyhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2n?4H5Mj)GHo6vIxWwkDKSo1fGz4fC0;7|`Gz;2MgGWPPGz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1gIYZm*2AnkFEuv zez=aBI4nbeb=t4Y(HY@k8MULH8V!Nb5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85p6#}<%Bx^?30+1G`qm#j;h1aO+ z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7zLvtFd71*Aut*OqaiS8LtvOr0857~u^n9tFlbZVsKZ7>U^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E119sr;UcdXb6mkz-R~%8v<8z+3$|71t2!G zM%9gmz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjEoQ%oeUlYqaiRF0;3@?8UmvsFd70wI|OFWRO26A3ox|faMbmqAut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OGzo#x$zYlU>!_ilAut*OqaiRF z0;3@?8UmvsFd72%2!W-o?gpc40q7CZqjrpjz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb23&5Ez{d9*VIx>gLf97!85Z5Eu=C(GVC7fzc2c4S~@R7!3in z>-&U8*8+^DgV7Ke4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(Gb8B0;7|` zcmiisYBU5!Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmQhQNdy@s*=%0S05tjJjzw z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONV337C`otM|qcg&TEK)|DGa3S;Aut*O zqaiRF0;3@?3PwXB}<5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC70a}HCqfFAR(X{}y z3f)n2M?+vV1V%$(Gz3OK2#ih!kAO%X_2Os;Ci6wE$F&iP6bmss`t%VWS~18UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFmNF-R40H{`94h> zT?;U9>1NdC(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7zLvtFd71*Aut*OgFFO=>SXXB57trV(KZB}7JhUdT?;_lFdj91Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$($cF&yv|pK{Gr~hY8b>`a8UiCH1Wer1#75TwjGXu$_33B`jE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk0A(RCIvGq^_>Jlw z4S~@R7!85Z5TH*8^iTfzb95~LeZqRwmeCLx4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!8485(1->!NVjXN4+r`0;3@?8UmvsFd72GHw1Ee z!?Q-$0u0~y9rf902#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kgRtwLaQGMH8&J8JGI7!85Z5Eu=C(GVC7fzc2c4FSqS;KQAN+eX&{P#%t>CX9x_ zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#m-O7@Z6rkCq4v z4S~@R7!85Z5Eu=C(GVC7fzc2c{2?&)SZmVgT7bbHPopjz4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVEKA;3EASLW!9@F0$$QKyZDz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQP=R0pD4-6-L(rjJ$Xs_3>y3jE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kgREka;)GME;jI%?)<2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1Jh5+dy zkh|en+~`^W(!*_3$7l$QhQMeDjE2By2#kinXb6mkz-S1JhQMeD;0S@y$zU7-Gb%h9 z0;3@?8UmvsFd71*Aut*OqaiQ~MnhmU1V%$(Gz3ONU^E0qLx6rE&{976!suE6`i1wX zO`{<&8UmvsFd71*Aut*OqaiRF0wW^?Mkj+uMm&%Dax?@+Ltr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^D~lV%-V3ouw?XVhJzAut*OqaiRF0;3@? z8UjN)1V$%=hjjFfI({?+MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1cq`5?3v4MJh~QOD97Na+ebrSGz3ONU^D~Y1Db6 zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z+(LjuE1PR{Ex>S#)lvV9hQMeD48{-`oeUm~u`=qW(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5EuocAut*OqaiRF0;3@?8UmvsFg!!xK>h#oqiX?%XWWkZ zY7mCN=w$F9jFeF)jfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2DQ3xNd>)mTT@0?;#FM<;{n8QPD z9rUBV!yN)b`}Qh~t_8pyKBF?DAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?3PwXr7623yqj)p~MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!ns2&2Iz1xaLXN0L9tfK~whQKHo4S~@R7!85Z5Eu=C(GVC7fzc2k6aot@ zZZVFo1t1hkqw=F6Fd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3^7SqO|y z22&P(qq;{!U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nNDF}z_I8^`*8-3hW}~V{ zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%%EtPmKT3??hsMm3Fwz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#_8E+e2U88eI!Odbo}17!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu;s@_2s}T{Wiq-JfRqp$RXZ92qaiRF0;3@?8UmvsFd71*Aut*O z6oXdVKilfg6(;!z7m zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtqq)hQMeDjE2By z2#kinXb6mkz=#NeCuIZW@&y-XS1AIk9(i zEx_=O<5Ay@hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-R~zf)E&;3?2lLFzSTS5EwZjurT0h%II2vkrUseJ{=8#(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fl)9T0;3@?kRdQS z89b1oKWg`A2#kinXb6m?5U9Ao@@8}`z(|VmQNNCcz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD40H&vPWzQPIwL&Lu`p`?Xb6mkz-S1J zhQMeD4DS$_-O@gNbS=Q}j^k0^jfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2n^B?7@Z6rq!BaftkDn{4S~@R7!85Z5Eu=C(GVE=A<%o)@z3a5 zfT16cqdpi7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVD_Auu`_ zJXB+D)ZL>YFd71*Aut*OqaiRF0;3@?8UmvsFqlI?W_P;u=vsim97CgS8x4Wc5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c0U6z-S1JhQMeDjE2By2#kinXb23M5Ez{d9x_oi>d?^;7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7%@2JN{Ltr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nhHwasP6iL*$QyO~ zXb6mkz-S1JhQMeDjE2By2n=ut*!G3*8(j-9z>zR&{b&e`hQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S2I3W3qdU|bVjfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQPpw!02S~z{kO;3r0g=Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU}%NFtfB|& zN6!TqT5&e&+R+dg4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!84u83O7PZ{&^62#?IT zANBQU2#kinXb6mg(GVC7fzc2c4S~@R7!85Z5Eu=C(GVat1VWeaB#*8IAT`uR)sKe2 zXb6mkz-S1JhQMeDjE2By2#kinXb6mkzyOB8=w$E!2L7nkqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Auu#T;2P6wi_x_JLnE$6T{#*8qaiRF0;3@?8Umvs zFd71*Aut*Oh!7Z^3`T^-C~GtXMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMneES1hzlvJTSTz06kPj`J*8)8UmvsFd71*Aut*OqaiRPLSS?fX^17!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Wo=vWKIBYVVluEx)uOOn2rjMhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2A{7!85Z5Eu=C(GVC7fzc2c4S@j<0Wv3p2i)DG4j9M~h}y%U zHM$mHAY)+E?$Hn!4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2k90IJajTL z{?QN^4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CksSi8(|%=+&Ipg}G%)J> z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2^4FQ(r0lK4W0Z_wals6gzqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UjN!1V$%=hiJr&I(sw(MnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz6#}0vD2M6-L(rP&-sd%^MAY(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fq@Hw(aGR}3;R)0$h@uS`z4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5EuocAut*OqaiRF0;3@?8UmvsFd71*Autj{ zAn){H$kknMtJze>!^=LLtr!nMnhmU1V%$(Gz3ONU^E0q zLx4~S1pSfk8eI!OD3nI!M?+vV1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtu!9!02S~5Rbr7=Z}WKXb6mkz-S1JhQMeDjE2By2#kinXb6mk0BIr6{j<+;bS(gB zVK%CIGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nhGqziP6iLncpG)~Xb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDkRAd*qqbCyt_2`H+(vbbhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2Av4S~_g;2|1uqs|@;fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu;sQbM3~)i2J`wE(1q*r?jk5Eu=C(GVC7fzc2c4S~@R7!85Z z5E%L)Fgh7L^y6^U2csb{8UmvsFd71*AutL?Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMneE!2z>m$KxcF<06c^m|JT9Uqm0oI7!85Z5Eu=C(GVC7fzc2c0U0;3@?8Uj=afnXK(w$Zf!R0+yaLqJis%bO?MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz11?2xQFMqdmG7U@*qasGCMZU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtsEcfOXoh z%+VR)0SWq1OGiUsGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMniylA;7j+ zgK=~%0QJIi)TGf67!85Z5Eu=C(GVC7fzc2c4S~@R7%m|&IvG4%Vsg|UqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OBQXU2UjL^ux)xw0#{8(CM?+vV z1V%$(Gz3ONU^E0qLx6}77@Z6zB9KOvjE2By2#kinXb6mkz-S1JhQKHo4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85J7Xs(n7TS)k1sHtsGU}qy5Eu=C(GVC7fzc2cJRvYT z89aF6V$>z0Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3^7TnIdV6#Zm$EdX(0HL7Yf1V%$(Gz3O)2#ih!kKo83_4;TCjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz|aeUxof=* zN7n)jy?7gS@n{GPNC>b_`;|F5BRn8MKWgb{2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#^v2dRjh;qiX?339(VN z3=E@_!K0~QGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V+JV2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb24E5Ljp1dz_550Hc$^gE>-1-8LEmqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0wjdM=mancfi*;wnmqZwhK#iUL&SHZ&KwPa(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVai1V$%=i3+As zMWZ1wR6>ABZPN15wE#mUwnp7K8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0yGT))@i>oM`wg-8pNZ9kA}c#2#kgReL_It z<>ptTYXRsJ)}ywJhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zg3%Bd4S~@R7!84;5(1->!9yjcM%_6Y0;3@?8UmvsFd72bL!j+7`lx-Q zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiSqLtu0=cqqr-sM|+FU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%;(ggYjCjjjb48F4-8%h3=R4S~@R7!85Z5Eu=C z(GVC7fzc44VhD^*22(LGM~xZ{fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC70cwZ9)IYP2jjjctcBqb;HyQ$?Aut*OqaiRF0;3@?8UiCD1V$%=M@Br4 z`f@Y`MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhoeg}|Kz zzEh)X0ft_@jkpiQ=p!NsMm;$i0z)YTG$$k} zj;;k5N-;L-R@#REu9;wTn@4Ac(L-dEKNfzc2c`XTU2ZQ7R6wE!sb zNbE!~NE0b)LFz~GXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeD42KXHoeUlhu{i37(GVC7fzc2c4S``D0!g)wA4b;#4C^Q!_1tI( zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1Jg3%Bd4S~@Rph*afP6pE? zSVs*V4S~@R7!85Z5TJGlkh8m&96i)__o#WJAwXdWtVr#y99;`QVIYob9}R)g5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!84u8Umw}!6P;1NBun-0;3@? z8UmvsFd71*Aut*OqaiTJL%{yuSM$-e0E0Y|Mx8er0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OgF6IRr~S$toe>_~aWv|>(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fe{u0vYVX+M%Mz2ut*>E?q~>%hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2+$}5Mkj-56tJVlj)uT!2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk0Er>cX0c}B z=vn|018r3KXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1Jh5+smV8JalDl-}aqaiQ~ zMnhmU1O`V4j9vgdIO1W{6{8_A8UmvsFd71*Aut*OgEs_lwU-zf83(VUM_q;%0{wH^ zH;t|ZKns*n?g$To(XwrXCxB7!kA}c#2#kinXb6mkz-S0iB?Lw%gQ*gXqlS!zz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`UHLjc!A3Ctxh`O!&XScr_$qaiRzLxB74 zj+D{00Px6xm0fTFGzLt5v}{8Qm{IO%2#kinXb6mkz-S1JhQMeDUqiX@^7v7^bjfTKz2#kin$PIzf z$>5P2_oF@^4S~@R7!85Z5Eu=C(GVC7fzc2c1*0J_8UmvsFvLQD8j~N?Fk^@X+o*F# zLtr!n24M(Lqb#C^8G|rrMx8Vo0;3@?5<(z%@vVuYYXL?=Opp3;Gz11k2#ih!4~l3Q zb;M{0jE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQM$Sf!Ff=zed*r4EIcxK3?5wZG3uJp5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc4a8UobZ0Y^=oSq6hcM%{o20pE9P4d}iW0Ac+oi^d^ez4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@Rpj`-zP6pF1Y)4HVp&>9ju{%N|e$?AE3xUx*MYEtC zHF%JOfXL$?+@os&21#U$I%6~hMnhmU1V(5GFfuZZP@j)_do%<_Ltwatz-W0k++uXp zKcgWq8UmvsFbYOPU^E1%5&|cC%!)^6gsBpgqlS!zz-S1JhQMeD48{-`9SsxVx)uNu7Ng{72#kgRIUz9G&LJn%M)iz_z-R~z#t;}S&jw?xjJjzw z1V%$(Gz3ONU^E0qLtr?C!02S~aEi@QzYKT?P_m7Y61@ZN@lgkihQMeDjE2By2#kin zXb6mkz-R~%9|BSrHvAi13qX8$jjE(+2#ij`&@^~Q4Id4G(GZ|x2#l6zbPViKt42d$ zGz3ONU^E0qLtr!nhExcQP6iLD=o)qGXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQLq`fhP;}?ow+l!05!sP)-S>ZXXSS(GWlmfzk2|IW$JuqaiRF0;3@?8UmvsFd71* zAwb;_7@Z8JZg`HGHW~t>Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0yGT)N+wMx z(MwZLjSR!L=B>C&t+fCnBg961Iif;zBBs??f$k7lO4S^9I0EgcPk z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVB~qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFziCWBtz%Z=vshb7p+6uL#)$&Wsc4W59ufzb^K@ujE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2Afhrnuu z<%4rA!02S~0H=&m>qkRiGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONfLb9iIsr_rkR;ZeTN9738a)?)*w7kP zHyQ$?Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*AwXsbj7|oV8E~W8MneEw2rx1-ViSgoiM_p5Ho6u79vq{L(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5EuocAut*OqaiRF0;3@?8Umvs zFd70h41v+fU>XMQsL`V#Fd71*A%GMDX~G8@N7n)%1;{9KGz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnizcA;3EASLW!9FpUFw z)cDa57!85Z5Eu=C5gr2fN^)gJ*8+_2L@?_8(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2cm=GA93?7(pAGLKf1V%$(Gz3ONU^E0qLtq4i zK*fZ+8%EaxjDUz9_2Os{=Q%A!cJw!(NqaiRF0;3@? z8UiCA1cdUgrjD)!7y%JI>c!CzKn?+HZDEl7XnPnG3Zr;51V%$(Gz3ONU^E0qLtwCk z!05%ngC!KR@^GwPes5Eu=C(GVC7 zfzc2c4S_)(0;7|`gFJ#poi`c+qaiRF0;3@?8UiCd1SlOqp;Z4!4}Wa_CnZctQH#y4 zQL)hw7!85Z5Eu=C(GVC7fzc2c4S~@R80ZinwM|Q^`hoV}sQu)Jz(xDgCLC)4$TxIU z=V%CwhQMeDjE2By2v9i$M(+%!a)6E+HyQ$?Aut*OqaiRF0;3@?8Un*P1V#t8hI8yv z<~LH(#^^nIl!f(R)r~C;jK=j~jjd64jfTKz2#kinXb2345WrUVBKvE!9!3t0QTAvE zP(1|LjOQwjt_7fau#Ory8UmvsK$8$)o%SnpbVisaK|5;bPzV7+t!6@gpjWG!QgXx;PW#~Zm9YVTRg)2gf2In{D|(ip~A-%H^jJqH2#PQrBOAbAuuvSfZVbLq>mP5 z3&>nzV**(%E!;xL9mw_*QacdtBjl#RC{Ie5!rVU^wy=;Gr3Wem$Vs;_w+_nm%bfD* z)#zFPSQL%YLnH(k85kJ;4-vPHI&(Axs1^d0R6ta7!~hyJC@WDi!h_U!94`JQBdAE$ zLTcEOVgV`fK#4m@v4<4*52RXB!(+IF0jd7R6-UD*JaGAs9^#b51+~MURJTyuetLvA zx*fRuOfSFTvLD@@r1I(Iep1~`rRB3E5l_ zdnmXO% zC8Kr?iV&bjWi%)}O283At-_>5ybZN5AP}V#Nz)>H2|0+E@TbIG#F$UWjRPr9iQjR# zd#Hv9wZdYk`X5((4um)*aY1SLQ(`x^u%gsG*vzDr7_ok*m7j>UpA!Gm%I%c637=ki zgb6PD2UA#)6ApvP{kXyyn>eX{rI$Z(xr<)*V+$)9IDeOH$2JFN35U8F`FDcgCT6m36p_#H>qwObuT%ob7<)y zHcbpvS|HYa_(Gi$w^PG@TyCSpT_7`Q={Au0FifdCVde~GnqFZ^C=5nISR;o4J;Hq? zgeg735joxnu}4Cf6AFV-`Oy%7g}~@!Ff1fS=@AhEG_R1#*9u)8qH6&LYvoAuIKz|j z2Wyz%aWYv_0~yBH9E>a6DRCz*GqKq}&|;MMlTx=2g>a%)_zVTVQW~GM)K6(V5F7rK z+Cy#_5bHj2%_m16xnV(xU&ytS9RFd{Ly6n5nMWvng1La;&SPnHe4>k9K!g#1*Qp0#4(l4oQ z#1;qSx|pxY9m)JW%3ZbhB{TIgsL%_>Y?I9w}ip6#Plec%`;kl%`ig;ZLdg)bcMOcT&q9 zV$2~oOvv>Ex%Ls`7h=_r>vm$zCso~G2oqA=Ot0{xG+gNAPEx`Sm)gPRS90Aml)@Yq z7v%aCrk~z4y~CK2@S(R`VR1pOn@8OX3&G(<50&<~-%qi_|ns4LgzTC&VVjee`x4 zy4y){E4tpn#2@HzBsMIDLwFCiupSLlVpAXW)D4F)r(S4`nl#cvfN7+8c+|h6Aut*O zgD(U?jWG}&EG`>NZlPwF3DH+}->3M40@w03bhuFg4?v60-)ATPX3zsNSI& z0-*EnHG`h&2CKVh5e6g4uf&EQC`>?@SoMQV z9mwq|rLOLVHjlOSVHHO1l0FYln7!gB*hc%Gy8c4gT9RtJ3FVqejV$B;3 zLt;aUI_ic~cn@~J(A$0VwvRd~a@3U35TI`e4Ay)y7~M4(-Gnc-fZPkh_|yyqxxwxS zko!P*D1;@l-YEiQ(ddxuk68SZ`or7aL1D*hZwZbc7Md~U}lhi=YD;*%II%MJ-8 z45_sMB>Io8Q9qd7M%RECwG12r!!bP!M?ZlhYk-K+@Eo9kAGMY)Auw3dH@Z8}`E&^k zA}txrZbSCMP;n=+oAI#+tA7ToyYPi2Om48deXzR^7LKIQ!_iNq_HxANB5N2n=)x4Aq);sJVNnxp$z`B`xhAs&O$?-Hj_Qv7JwhOYNvQ^+N!ZooQ_EhEACNJ%%%#*Ekeetq zV^sfW2#kinz=pu+WbnX-|ETSwAuyssV6@>tpXeS9XZnOQF}94x1u>ySoSM-=bmGEk zR26MPV7TPv;o=W!$MSFw7h3plv`nUUDjd1y(IS1)GBlSQUBOSrS^%(hl8ZCkb}hFpKoWhn=un;KyZF=><} zHv~rGfZUK9)i*LjVDrKqOGeiMjLf(v-`At%EcszajLwl5zV!Apz3n3=-bdAphQMeD zkRJl0lfmSN-KfqX7y_e%GKff{S6B?khQna=(_nPdNCm?&E(VjINGZEOYC(9Uro~Zz zQ$GX-Q(hp(cSFs+#Q14c4f!E3njXjxyHTB^AwX#ejMV%$QvFS7dKuL}{6kZ@qJ=}*J9)#Ra5BCsq!$8S{{7y<(4U{`a z?WJA_fa08breQaUlr%*T_h5JBsOV@2jE2BS41v)yXL!c?K*TFCb=^R?ixx3FK>nEY zz3DNDYXNBC`++f&9$`*Q8ix6v9`1qJHA;_$z;F$L(aGT98ndJRA{YY570yVhP>}sh zFn}mjFx;8 zei_v}bV6V-=7oU>y`h}{KygT`Fe5iyu<4_vJE`S=kh_P1J4x{`HT(k#7gFpTNouJP zzPQ4R+;GRHkCyJl<^ECe!4U$K#yz(5G&p=V>WaY-0+gmNe13qXJzCm<&whMzFt@?f z3`RG?+(r&fFaMy2BQb7;>7kb!iE$IDYKF4gN%bo(^`J0-#U(B^l!}AwpwtXp`pI=W zHhr{oC#w6k)7~eI*tGztK{3i3HX$%N89Z#Fa?~S~gaEe61($jBt8^ePAjNGE9i!xE z2#_BFv`TLyzJPi%UIl3TI4 zf2fGjDx8ti9ks#&*-UD=8)Oc$xzuv&s5wJ81j?RXOBjZ00Y)c-hj5x4b^5@B0HMkO zRDn?9GD7B4Vm7_>QW^%tx|7(jqLq6<<`U}$V*NylI*|ROs3)YB9)5w@L2b7a@*Az? z>E$0-_!06Ct=t1LmsmFta^tA{&yY)(+I^(BixRce^apbIP~s+1^n&c9rXNVLo7!q=?GI2`k?Lk-J+yWoscsupKNo z4_byXK6_zuq{PEOyA|eUa%gJ1os@7R$GzC}fb68UKd{*|DmEGdLox(dr~S$toe>_A zQ8()70SN(q%~gL!*8&Vk(4$)ls@>7W$l-&`p=K)qWERLwa@;Z)^bDliVRnPUkeXou zGnblX4uo0M@(0KqYKAe$ERdN45hkN{j?56CR(b=O14_Tt3Pq4vATz0D-e59kxcZA) zVLZ^`1G68L2C3zz!C(%^KQO-y2KS7*VQ_}PK&KIy{h;(ZI74XERg{DPrSS?&AC$OZ zRPSgA5DJ08n9j&`6G$JW`HoO{4K;aCn9$nIl!p0Ga3iTeM2h*Mkj-ZR(y@Rb~FSAbqLU^l7&^`)bc#cJW|}NlYC5cFs=n4 z#Si$x4;Ci))X+l?ban_m6_`H56aEw>^2msB@UTRq4v)bba#-8O38Xb22M2rvzVrK5I^ zhQMeD48IVdMdb>rv_Y6wp+!rxY2{{WnLCpGPDU8U#7wrryB1(H{gV-@qgqBofQS$P zl~o{2gwjzZR15)XhdH&)qoP;IHfpf>i5B$`*?}{vaWn*I90H^Hk2V23T8_{rTt`hE z4FOt&z+fu}LGA%zO8rU=GbnWfDf($qZVt6DASJv8qgqgSgD}0^Mo+uwh$r;08;pLU zMmidd{z4D?Q9fxQFdXA_IQogSSRHuPqj50saWLuv`h@_feg$DtY$R1ZDQ1Jz(%O8G z9U}#gh9zoD)3SUAg*6DH+DIQB$X*bpk8Q-+Ld&ow&IQz0H5~m!{ZJY;aioR7=w$Fn zi}6waj)njYLV(&8EIk`V^t6ixK~J)ggW11>*=^_nJ(}jxLu8ac8UmDs04V>1FlDBY zrkg1H&T%^`j*ztgM1==wK|E|#50w)()1i1*H=Z zrm1I1Fnq9t3%ZjBQy8JU4IiIUcMsP1z~|>txxpU-gDqWw+ylacG2|%C!^HTB+I9@q zup}ldhLRdkID_y|a`RAe>tG32P~HULp%QMR?i>vPx`qJjv|pK{Gs1KY@=?o1Ltr!n z2!+5vv>*n;PD0^;O`g&g%~0^?P;eu*xS>RBApATKc2W{Xqk4yT2#lu3fr;Od67FQy zovXI`+hbh|Fj&HkOn(hbVjHz}Gz1tJ7^snENAurkQW_0`(GVC7fx!_1v@DNlY4+d< zJxo`OL_cGO0;&i#+6|~CGSKb#DRg@WJD(b1Jy_B(HT*Kz<7?D?qai>^2#ih!QxbZk zdPhTGGz6#`0)wUTK#r$I8yw_>+Nhq5wv~brzhBLLoWw4qv7{V5mW*?BP6+VKZtseL`R~4bmr^N3t!$p}Zarei-a&kQ!k;TApFXGBv^#(^NW$ zED1|rjAbprko6B8f((}xqhW?CWJbkDLx8jp7%X)R$Q>X|n(IeZkA}d&hrnP=w}YjQ z94zjjB$W&YKTzWTQN5!fKuQRVP6m?_TBB-5Ltr!nXchv4v2jK-&yE^A8UhTXM-+`F zf{`5pqhYwZlv0NcQ{iib?W)Gn)U%54(|~lNRN~ysbUT zSk?lN6r=;0c1d#RsNx|J0)r(z(c2yLwvUv=K+UpeF#D5~Fd58hhhrEICO-|NTR?dc zga>o_8+F@g2n@Cm7@Z6rY%wzGp3x8(LLoq{N_QX|56HniSlof^CTg&UtG}obHl&&{ zn&wFju~GG-A%GbIgRM*&Z0^AfG3tp7CBF_Ow^A>LAor?LtFA$J7rnxiUiP5-j~qTV z!kS+GrG{I{38Rsu2NcF2Obb8LE1l57?IXcVTE+WF2s?88I7q`g{)oUNO4kCA6C618 zjFuZXgJxJrjK(kaXryNOFdD|#!+5BRg3<^G)50I4X@(Y|I%?*i3IWz>zcNQ>ga=iW zj5=mC1V%$(D2D*8S|qeGcPNMTsM`le2#lu9!4VHb$rYnLtbvQIkrwupmQVB!FOYjd zm{uu--u|JLKSs?ZKLqHR1}II-^mHpIL_zKaVGx@Zn3n#brP-v01uesnRJV+(AKoD_ zn9@BpLwdOSi<)6X6SIE3XSFACEdaI3x#1GmgV`U0*=;mQ$7C2vX?mu27=zpk!en@r zC@u8%4^fU9RWup`R1SgB$zUo6=%{g{Aut*Oq=mp}WlLIkk*az$d`S&4+N&Rp6WWLI zs12hbK<^NsWv7(hei^loz99fgJ0MJ7TWN0_HR`(I7%sFA`B5842?0> z1V&m2j7|oRv=|@t?`Q~6DFmq1V55?AsAdeg4M?gvVAP;N8v>(ge$YnEsKZ7>fZ`As z?q&Z#`Hx(C$<;@3LVy}T5BouN3J623q$x!Y|IpL}G%}nT;XfL-)Q=o`gefTQL74ik z$25^1{=u}BG!c4*1!=AtI;w|491oq)9GNa1O+(a+_rY48jm9zc;&{}gfeQh8q+?JX z24Q;ml97?IOtx<0=vn}J1oo&MgC+!628~Nb9WojMqaiTjLVz9(i4hm5qaGgAAuyWe z2X*v}I&L%sMnhl_ga9?lNqP++kP}?=b{jRqlN^7J>KVBqK<_k8N=Vb&ZKL^*_}Br3 z6}{sJ=DLBh7iRw`J+eapl=eq<2%!2N6dr>qPYx!xpoY{a4><%t=?0lU%Elf7gE>9Z z+ijz1274MAS)x@djjoQowE&|#gGW{>ANB2M2#kin5DNi%H;9H<@QgZlGz3ONU^E0q zLx4aC40K1A6#Ge0OTgQMP8w97gD^cEHqhZo4g2XGW~9UqHT*}4eWPm04*^g*0%7t^ zAYJE3OCR(OUs#L`l)W(fN9oZJpdUSPf;pqw$P8_D0Ka++j0PWI$;Egz4$u z(L71d&>ppGP=o;Mv|pK{TY^!e;@!%4nUS{^05uFod7~jfQV7tywkFAOql)1nFxtw2 z2L;89(KJtS5RMu!8UmvsFoHt>Rv+OiOJVYZA&98$7F>R&wO^_24_dpI-sTTfyo>_; zAwcbPJ5cdVZGYeowNcs85Eu=Cp%?dlfkqM-BGiLSO{EjxVn9GExbHSNC&S6k;hHW+ z)BJEsrK5fsZXqz(%ALXH9$LmXsBEOA`S{EpOyLZ2%Rq(&zHl0q8@?ehkZBH-=II@> zAUg-r&y@NX0cw=1qh-WUiS1E$(kle0k*~;g>u7!? zH{?e34fPNhJs*0g$Ka^@M?+vV1gH}N_ba|!!?G4&w1GgK;2kw(=!C#%njboGHR{sQ z5Eu;s{2@Te00TA3LXiC+jNg^Clck0qXy-ceOr~~tQp3FA7|!Iy4yrCn!j>9g3bG%B zQC&qp9yRx>1 zI%G5iMnizUApq(iQqn;M=>?fVUsvO{Z7{h7w=0K_3@H8w(l?{=J&>V4YWF~gz-ay( z=old1{?W3F{IDCu&1%Xw*b(Au!PKJX+>riaa02NEnL{NfQDn?uaR)`77$%xQ>0%(gGMv%^ zC@hDQf9R1eM#GC9p*+0o7)>+7D?SN<(fB45LZkA-IRrrMih=asX#N|>&>yvX(1k!} zPg(KkSDIww_dv(lULItMg5e9)o7V86l0xqH&dEUNVjoh>L4|oLHQ4a z2Qsusb@M>Fb=233LYS%jL{IF zK?s2I*=RbaLBNh0IT`{ahQM&nhl9aypt6p{C>d2g8UiCK1V$%=M^to=dUhy;0I2dD zt-OXpOpUs6Gz3ONU^E0qLtr!nMnhmQg#f4xMNRim+dOjJHrg&EH{?e3(K7_7onNSJ z9wID6XC%6lw-#WiX%~KokVIzD+dgEw2bv8E?}2v1sQsfMFd71bHv~q@2>fv~ zlIsurAwwHkYKJ$q%_G;>qxp>7kQ>!E(n4T#GI$h>hQMeD&@}``D`>g~_o!uqAOuF! z{2+*gQ74Rsz-S1Jh5()rKrg392Q}~n76nqEa2_mSOO7`{?k7jzs2=* z5&B>hF|G#bBSzh*nt=;}!4gO0q_2SswV`SAXc!O8cpKubu9ki#PTsiyL)_z|&L0hd z(GVC70cwW8XnT#Cp*d>SXb8|h1V$%==^y5!HjakCXb6mkz-S1JhQMeDjE2By2#kin zXb23F5EvcQ7$gxfh@CMyc}2U(8O?vR3tL*6JXqol=0}iwNXe%l^)S7o^k@i-h5+^u z0Ob=>@)dSd27~CwTdwM(YXPvu1Soz%@r+GwRBZ5t04Tl&%2k6UoeX3c3{=>U+B+Ho z)C_^q$zW=R=BQbtAut*OqaiRF0;3@?8UmvsFd71*Aut*Oqai?K2#mA=4Um5+9ZUl0 z2bn`;5Ho<(6RUPq-N*_7Qqs{#2pLdbp>>!LG9M%la>rl}Z;%@Yv-?KfHW~t>A;18x z`$zNYXi5Nwz?zx&$_L3>fWcnBfx~MM5~F!}5Jt+VlZI>vj7|m**{BhQMeD zjE2By2#kinC>RZa(GVC7fuR@zqk|elG1f-iJX}IxH2)2km>l)TXb6mkz-S1JhQMeD zjE2B)3IS@j(H8y)2pJ@60ftjFjrwIMhXCueUzwvb!b3R*N8LUe0;3@?8UmvsFd71* zAut*OqaiRF0;3@?WI}-2qZ~uV<46t#g~3qT9szPMlJiHILpTIRgRzCm zsMu%-jE2By2#kinXb6mkz-S1JhQMeDjE2By2v8{mMh7^k6p&D3Km)|YgegcJRLdwe zfVT zQF=54Mniz!AuwA0;16+7_<+i3{HiFErM3B#xo%W9EdeNf(bD{pY&N<1iXL$SavMGD z8OiZK>i5wQ7!85Z5Eu;s;zIybFA=YLWT+gir$V@T~Nuwb^ zatN?a`<2N=vaV4LqaiRF0;3@?8Ulkp1VAIBgWi#Ja$s>NM=O2T0?^50qt=id0;BnY zd!xcpNm!0m4)b{ZcuF-OTWW+ou?}6~h@GWt^q-A)Jo3Ci;CgMV6RMlt*jE2BS3;}Y>tdSU2qkbL@ z0cwW;sO|@0YMVQ1-e?Gnh5+dyFnU%r>0vgiV>ARtLtr!nMnhmU1V%%E&LJ?`fv0nT z4-4z``l6Ik)&kJYSEHR>x&`%sTQnNi10Dr%2Y~7cTEqt}%!E67ltEDljK&Q`!8fXT zGz3ONU^E0s3ISTxvm`lURPksC3^4%8H$%+HRB`TT8;vT#IBLjf2v9u)Mt25NJxE6l z91Vfd5Wp7#BefxfFHCUBjW(Weh0M?rA5HUvBfduCVQ|F5s4GT8fNml1cgPS7DMjOQx1s`1V zXqtxy#VBJm1V%$(Gz3Ou2!QGV5FU}99|E4n*50Xo{3DkdYXL^%eQ+fJP<(*!;BwW- za}BmUHyA?!pF8o%5%SAu8YC1#qw=F6K>ZLH$b2`Dc2hqjM@^(>2vAbj(9@mNunUw= zK$se4jhZnU0;3^7!w_Jd_A7JrtY{hr@Tk$FAut*OLoWm=*2B1b=uJL?eKOndHHYASL;}6tOXd}VLR%((GVC7fzc2c z4S~@RphgIQ+Fj(;*VM3maGL=N8)~|6u!i$sbr&_me$=ed5ExD&FuFZ>IK}3uUq(Y< zGz3ON04)Tl-2g>1ev~^J0;3@?8UmvsFd71bCIqN87y&W|ga?hshPguqQ&D0LS|KggUx9{i)u8-Nf1 zrHuiwcGSwz5Eu=C(GVDPAutl^No-~QVDUG|9oXDA=n~4PgJ=>0p!lbW!Gp+9Z0UO> z_7;sBKaXsLUppgSc zLK?;ve2qR_GNuwb!*h64AR)p9pd{W|Ju!jky{v#zUhNIfS z9>3&-Ii+c0IED#1;W(;iXomo$`Cw@Ke$@5IAu!z20xti7#6cK2EJiAOv|byym>=$G z5SRZz;vhV5Ay3$5a@{wYp9qKTsEW}L7!3j1hXA={80}p)Y6CSw0205qq7u>u$65f0 zd#K^F!D+@w^&iOJAUrr@Wzq3CH1R z%5Y5^!_{B(N`s>#i}VWVQF}&1VC06ta4n~a@fR^_MsCU&^*KF4V7R71V*Evn8hXUR zaIu4$;W^s=94@J1Wcy<@9gpnzr-kpSQO*vBbV-eHphf&p+swi0&%gDx52>{lfEYg# zqlVgHKWg4+2v9o&Mkj-*9ipS=jfTKz2oN0tgQX%JEbhP+aMWrf4|aco{67@j2y!R6 zc(C|yu(*TVa2(Y)3aAkR)XE3cDx<04r%^K)7$}VoYNd<85*MWShZME6@E@gdM~RyT z(r!xJJ=pY88jb^<2B{sMr1*yvwZyoS(zqbTj!`wEAut*OqaiSyLx39PD7DM>;T+!N z`fZ@Xc%bYhH=HO<&qK|>gu;mw|B#}VkiDbwR11MaJ9i(X)>;6nIcg{vG&&hP6k=)A zjiVtj2t$C@Gq_6ZZNwIIxrzTYU^kS;12<6=E31Kh=M=VMxHD+)5$=E9yxv> zM-MgqHxPMhAnc@Om<>m>209&5BTUKh137xA;nq18Opeb6MNQHPKf0;Bnlfng-)KWfE4S&5Iznue2Ks2nt-#*Kym z-9lhAAJQhMsZmc+!whQr4&)vXrly(5W`WEGVPvtP#->(&8p`1?l-)km(%Yze2T=&H zPWzQPIwL%YB4pGlqaiTVLV#K=2#~)itBK1GEI2{_S^&!I2DyId9S{r|yKCJiRH&>}`@;XhiKNejQz)6Ah5CPUH9^o-L{yM}KF zj7|m*-?$z1*=Pt1xe%a7WellRwrN3F0R)((k$Fg@%VwPQ2{24e`Y3`R$dx@j~7MnhoegaD}VK+TF2WERLwT7&?|Y+9H* z)Xjv23$4N(IecJtA&U()HYl8^=?{=uATwzZ4j{8>VeUYg2?{54KlMaq{v~HE0LZL? z^2eyX*h2t0ZfOxe$mU~r!KmnH2oM(nu(U*rbV{5HaI1p34Y!I>nb8m!+93c-a+ zaK|Gp;s<0VEdJ@~dyu{Kv~Sd|(GVC7fsqjcpz>@W$`_EmBO`=SeTnREV#6I-52|US zJZvEV3ol~B7n>Qh5`(#oR_2eII~oE*B?Lw%gNI5?jkRlc=f%<`$|NI)V%v3~@M| zgBujS)J~%?^FU?~blAb%GSKcAwSP1OMnhmU1V%#ue+YoeYf9=3kY11(_-!Fx7GxI4 zOyYHruM%Vz`MT++6BHh_b}OyTM-MHKpU}ky3m@b*YKI5RJdoJ~?N69n2HG8?_K$|Z zU6~f6Qk}J4S^vX0-y?>+7&F!JdoMs1`W&%a`g{ZeV{O*rQcv?gY2O+3}9wa zY7VLTLH1I^{kZG}nGLc7mpnP*F!MlWlcOJ`2WAF{JzOy;e8}-9NZ)V`li}bVfKRTrUjkJc1LzsMkkBU?_wD za!pQ5t&6OlR{kT#9kjBWp5`KlB`JPJ)<;kG4i~$S!;hG7M^;a(Fd)Vqw6c5D+|dvi z4S~@RpdNo2D@c2VM1Y?#JoC@5U$;SZ2KgDo`Z;qSrb=TY~JhQMeD&?W>xWgNB36qtD+v#I53 zn3>cvkDlg$!eJ=8A33Z*enI9BMmETev8A@ zqoe*A4S@j)0Z@fb>k63G<_}QNk!3AC!W-FPqhXI68W1);;$Sq~Az_V9k`neHwdnds z`J*8~ZU~H~Cvrn>VD*9GjUMGGx*Z^UN%1+lep2*OLoKQ9rG}mO%mBHER$&4%8=u{y za?}d}P*_tj91`ji+&yZ}Xb4a{1V-~8wL^4pnFoqr`nX6e z#^5=@wE*;S%BU@)Aut*O0}=wD@@6o!uju8D!QfX?BO2spQ23Cl51V?BIUw_}$qfZD zko$*%J7I1FxtHGIO>g^PejTMpLtvza0MkhI_^7`}Ltr!ns2>8Lh6D9Y9XuuuM*j^) zHw~T`qrFRpd-x9p|AE|y3)4OYjM^|70;3@?8Un*31VH8J@bC#?UyLNb5)O?~6@wuJ zXq_f$ZT?^gLu$EUG_0rKS3@{)fY$;Hm-IoYKSn2SAnBXZaD9; zFJ?#Mboj;TNc7R@WbjCg^-({Mh5!x0aIG8%v%dzj+h~~72943fHT;LGzXnah8Fk2z z3W3r5Fr=ajr(;Ls5qr=K=5QIzZo?j$qoTM%U^FhVhsZ5j!hQKfnfze8m{t-MH=JXG9WE)4rA2~Ee*`pyayh30!e~-rJ@CpG` z-;A{MJ<|M(8ruWU8x4QdI2aD$F&zAW8Y`o`(GVC7fzc4a9|EK09{%v5mMkQkw|1CK z7+niMtuQ6l97xy`t9tmW8x4=)AIBrfhoh6hBPq5={W=-~bPR#vR9OxczYG<3(kmI# z`wXk$6#m1>FZ4=pqxKBX5E#u1qwzXCLx2H%Dg0>q9z1`D%wsI1d$f5{ijI=a0~UWhn!=7623;AWSIS zM&(CCU?_#a=w$Fvimg$%j)uT!2+%zQhFZlx)Z9z=2pzSMU?jSvQT?MKFmgkH78CNLWfEq38j0bI zFCEY#JTOC@x+3`eLtUE&pJ^l6?}INk7#MWqi{}jBT7bbABvf(J=wvWef^pQ4(GVC7 z0c;^K)GN}V>i(hXZft32sEG~rupjE~A8P4k)V+f@1V;11;Efwx%0plXP5>vmU7t9*7J%}o zA2opj!gkc1 zLnj14`2d86j*CZKN{tX032_ecBM4I?yavLIq3rg72(O`J=SYZKkRL&KpyOpQxM85f zVATH65FjT6M)M9ip*C{#4CV9zaytlvVhcut*py&O$3$`24RRj{LbMtd};^Uetd32mm5lMMR)rse>4QB6#}FAfLb9r zG|d6UKL`&^*W-3I$WI`QTg9l%PznKZ;*uOa^bQk{8$p=f_R`uuke@)9*7gj9`2*!I zTG&eqGY2A0hMJw^ggZHUhFZ9cx)*y04EDGm?CwJkRgil@7@Ih{*kI#hb2q)+I2hbO zFF%9)3c~cVk6!kG`~<@EvTxL$(GVEO5Ez{d9>~xiwR>2Gz-R@6J6b6S8?mdo3+TQU zfCBg84uk=g8ESqSYVO4sy+gq-L&1&s!gy3}Gz2IO0Z<+QVM@&z)jvE!V7R0OkUv0p zc*F|C7sJgz5MPl_k`fN2s3qMc)Ymc8+>0v&K>h$>T=D}cK3Lr~kYO@v_rQe!Dd~|E zwFBoP>^2W&w`2Fws3^7&7z*(Waw7<1b3aUskUUJ?K++&Lf-t?^0J0N=>22>|uy45d z8{`iV9t>eR>W0w}pjQa6(95DxdqzWGGz3ON02BhCik4RA#emEw7lYhIuHK=b59YtY z81}I6gSipLN6@~^_N9om03daP*&hgZW3mRT+d=NabnU3fXb6m~5E#t|BP+g%^(`!2 z47GX(=5AvBIg->3x3Gcv2Ui+}$q&UihPfS=-v+CnVD28Q?xUu=2HLM6`>B;aKxR=h zY(~u@Jp^EJfGbX6@}!$gT^%5I;Bq5%Z5lOgD2D(joq*gw*wPBjT_Cp+gF)&Cn;)>b z8{|%qIIZ0Zvj=3?NXD>mpmlgtJB(rOrnb4G=8cBHPziz2$>5<9Q={%25g{;ADkYdd z2TP*?=AMy|$Y6dQ?qLt}=U|Cbn0tnMxYH!OEm;{E5o-ZxV#TPT0~Z3IyfKjZ0%ZTd z`F_;qp%enhX=NbOETJ$%b|)dVLtP%^*TEbY^bTK8c+lHUdfA8Ux4|6e$nK<9*idQ@ z$j#)2DM&A+_74{Q!^tm$B}^%C$8e30;p#6+;(8?Lg~dC$Wi7SB2<8rQ%me8q$NeCE z`rQ%J!;-)2n?kVU}Rum_&<~!JnGib z5Ev>UKu#q%QY$BN!ipN{V5EjOa@Y;EuqVd7Lm^&?@dt7|QHwnk!iHLY9W{q`AwX&R z#g-0nnJICim4n)A0kGLmyU?PZ$+*IZkPSoCzl8iX805)~6KcDCq{lHS@i5ZEirQ&! zAk8Bs-f8L1;gF`t4a>pgcXIszOB;j9FEBTa(t|$)NJ)2tEsc@lc5E>*(Eb_e?#C7< z*uuh`5ZS8S+-8@c`*)i)XfB!vL$ zv|pK{Gr}YV*{I^t5FkDTNUZ@!Ybpi?Qp1e+*g#b|8rG=c0prm+{HYo4*xX7@^I>5^ zYZ{wBXkq723>#X6!>F0m2?0veEh*`dT)RlIgF2x?8&gKE{|`U5I6&>S0JMoCWK+qF zFJv=_VGoz|O^jcORYNH3N5hQRkQ}<|2&DmP`E0nRB~rt0FopxEet@Nk!RQy5+eYcZ z9|FXryTO>oh;cW*&>swbBG(Ut!R^RlORhhV%|K_<(vQTtmzHio_wP{P6B};mei;to zNUq<}!-5chID{1;KaR?eh5)4@Fgh7bX$X$$9}NKlAwW!}H(Ft#1|l(GhN^=wZ#0Yv zhY>avXb%Rzk?V)S;C4b`O{(AM?I%L+qqlnqh38<9ClqG5+&EmrlHBlsh0|~i z7nuJ>>Cq6NRS1ku2Gc5JN6j4#0kjanR?&@CUTA@YgNrTfa43ZfkA^KgC`K7WFa$=^ z{1A+^5#(fY(kF88jF!*Hp+T5UN_dZkE#dGSRWTX@qaiT3Lm)ls-2TzE00R?8#MC!~ zy`CY)53tZ3?0$i{bCezp0a}CrDR~8({eva{km`@Y;%;nVIuymw!+$h;?} zp#_Bk=@?(QkA^Af;W?^fGz3ONU^E0qLjVy1*y@-8s9P-a&a+ZwEdVzEAbdM|f(~+6 zjIu{=2oTCAxI$(C$_89c0gDsskHO|{u;WLG5fK8!rcrExF`90%h03VdXb6mk0J$N+ zLaxS9eWM{T8UmvsFd71*Aut*OqaiRF0;3@?8UpkQfzg2g`h@eSEu$eY8UmvsFcL#x zIMgx7egF-;BlAaMoDMxd@4kKT997l=3_Y)px|pI6faP^izQ<+|OdgxusMu%-jE2By z2#kinXb6m`5MZ75D|2*4ctl0{sAoq*U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(_=dpf4AAh6+fkoU76PEWfotXzm&qVKxa6oI-tsp*iyCVIXzfmF_;u8b z(GVC7fzc2c4S~@R7|0M{8c54W?H&z*(GVC7fl)9T0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*Oqai^15FlmJloYkJcloFd!!QIgBgJk~V=cfi44P3djfTKz z2#kgR^+RBEXE61{bkxMr5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVDPApo1?#kETqCO_yLKI)*+5Eud>fGb}O0jJa1i6!TPG)C6~&^gdY ztsCJXFgh7L!V|!#_eVouGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3Og2#ikpj;#0|_3dZ~jE2By2n?DK@cXiN-soBYx<|+8G0}7n@=*&%Ltr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON z01^VD3p60%FiMVwz-S0iF$7qr{mL9NvU<01_6XbnEx^!-t5H{uhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb23A5Ez{d9vbm9>dMg&7!85Z5E#xO!0qvre{?OtaE{?o zzm0~#Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-R~z zNC=Eh1`kNEk6Jn!0;3@?8UmvsFd71*Api-1>$SJ@N7n*C!eW#h4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7zLvtFd71*AuyCffYKwP85tReviC>bJ{kg}Auw1&z_Vvt@#u{3 zV2zzoca4U?Xb6mkz-S1JhQMeDjE2B)4uO@z+eX&{4Ce?Q_1kC&jE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMG5fzip}!4M0hZWs-L(GVC7fzc2c4S~@R z7!85Z5Eu=CkrM*k)8||mT?;UB;(OGmqaiRF0;3@?8UmvsFd71*Aut*OqaiRNLV(h( z!6PD&Mm;$i0;3@?AR#b189X4tK5FS`2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6nN z5ODq*c6xLzz(|bwQ9qA{z-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD3`_`&P6iK5 zxR2U88UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OBP#?p-nzDKbS=Qhit|z5 zj)uT!2#kinXb6mkzz7KeYU~Ogy)<})B$iPy8Uh0l0;7|`0}$w=R*r_iXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2BS3V}66LjOnC0t}-VKhS7jH;lY9>Xp$D z7!85Z5Eu=C(GVC7fzc2c_8~AjBRuS*c+`WVAut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Autdj@J)Qj<mqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71M2?1)(29KT%O_wMewS?Xwz~1>r zcyuiQy+fTS`&fulHL7Sd1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMpy_?a}s!j1>LB3M?+vV1O_k!=)D_w&SK$3qiX>M zFv3Tz9u0xf5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2kEd*#W5j?spn6x+;RXrL4qai?E2#oFqCNJDZb&(YUv+o#R9bF4RR?v-V z8V!Nb5Eu=C(GVC7fzc2c4S~@R7!83@Fd71*Aut*OqaiRF0;3@?8UmvsFd72GI0SG_ zwhr`!DK2*n}|4kTO3ovM7XVhV%Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Auu2zKxncRn>;-xh_TsA z%>bf@AE@cxQL{!vU^E1Va0swY`;|F5BRqs7aMbCeAut*OqaiQ^LSTBpxgDeD0t|tO z8g=4m2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD&@%)mnQ_IXm(p2Q zT;|d#J(w~vOv@CX5x;o*%@UyO#pXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S22F9cfiV--f%0?;qKM{OEvAuu`# zJk(-r)V-r2FnmK`bZ7AJjoVS5jfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mk0QEy)ZR-mQ3f2M)_leTs?my}$s8JI~Ltr!nXc_{n z(|%=+&Ir>qh(`?{4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GUQIz;K)?1^EetLH-*7_?qG#5en7*>~!5>efE*uSk z(GVC7fzc2c4FRf#0I41I(T+1!gMZYp(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z)*(P@pOY4WG1`x%MW~LNIT`|^AwahfV4e0Wb96?SZoxfj(P#(^;}F;}J!AXmT7Y33 z!J}Rq4S~@R7!3i!AwX;oeY6)%I4nn1jE2By2#kW!5Eu=C(GVC7fzc2c4S~@R7!85Z z5Fi`^#P&l8n}kp?+Gjk|m>S0Y+->Z>NUC+fVyEiYT9TBjE2By2#kinXb6mkz-S22GXzE_ zgXtOCqjrskz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQQDWft4aJCycHI7#eX!EmuCCEt5hmvqsGs4S~@R7!85Z5Ex7$FghbVm||npEu$eY z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Auy03 zQ1No6@aS5AfsBDsyGKJ{Gz3ONfT|%dIvGsW;2bqFs?@@gNI?Hj(TY{1V%$( zGz3ONU^E0q!DtAKhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2C-3<1(7fcu5E6pgM07@4VM)YqdSFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd70wCj?lh{mLYLMtJD> zZ`7sK4S^Td-C?6^0jL|Mqo$39z-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk0L?;RbTXJ`K|5;jXb23a5Lk7QD|d7) zz;KGuQNN6az-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2n_!a7@Z6r{&76&!_g2J4S~@RpezJle={6i3qV;Aj_Mu_fzc2c4S~@R z7!85Z5Eu=CQ7{?;qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Uh0m0;7|`0}<+@ zc8-R?Xb6mkz-S1JhQJUBfq(Pu*N&bGFhnA2)S06pFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*A%GSFqm#jC0W!)R4S~@R7!85Z5Eu=C(GVC7 zfzc2Ehk#OX>(tS;0N|h)B}PMFGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(h=u^`v|pK{Gr~hO@U0%G*8&g- zq*3Y75Eu=C(GVC7fzc2c4S~@R7{VdII_+2H=#20Xj=)i;kA}c#2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgR86nV?8uE8^EdUt-H>zbc1V%$( zGz3ONU^E1VS_q6z29JW#5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2cF(I&`e9O|&wE!a~x<@@a8UmvsFycdCbTW9vCxOub7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z5g|}%z0Q1eEx?F~>QPUQh5#%CMkj+|Au&pihQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQKfl0hu4GtVY)Y4AaQP z!%=w$FP zkKj@7jfMb`As{zR#eZ}y0Fi+;s%$g_MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E1VLI{jb1`mZ;8g=7n2#kinXb8|W1P-lh zi5y)EK+_-|HGDJ#MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz1191V$%=2O!W#tsD)3(GVC7fzc2c4S_)u0!x0aIy$-*V9-Rzs6$3W zU^E0qLtr!nMnhmU1V+JV2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk098YPv*%m! z=!`H`gLKre(GVC7fzc2c4S~@R7!85Z5Ev36;O}d3e{?Otkcg^LM~;TTXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgR`5`bm8BBiIjp`f?fzc2c4S~@R z7!85Z5Eu=C(GVC7fgu& zE~9G!Mqa#+`gk-1MnhmU1h9s{=wvX~U>Ox01*0J_8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OLoEdUaA^DB(c%|Dz!=8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Un*61V$%-hfP!x z;gOeDg{5$=1t3EIsFKkT7!85Z5Eu=C(GVC7fzc2c1*0J_8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UjNm1V$%=hfGwBI&?GyC<_7ixz9h0t_7eh z3`cd3hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz>o?7)@i>oM`whGRFsW6b~FS=Ltr!nNDYBZ#~G7H*8-3lYNP5$Ltr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(IE29H zWbkl^#Zfpm208z~G3BQCEzHz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2A<2m#hga<)Hj5=X71V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0M34#A&JNJ*S1)wAZNA-?|z-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQRO)fzip};Tf-^z8VdI(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7#Sh(`gq{e(X{|0Bd$k%IT`|^Aut*OqaiRF0;6Cw1V%$(Gz3Tvfzioe zl7nni!)OSMhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-R~zh7e%P zu$()(7GN;M#HbraLtr!nMnhmU1V%$(Gz3ONV6cY3=w$F3oxvrbksAWAut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAwX^juul7xIXWXuZs?8b8x4U$6asa{*Ord11sFsTGU}Ak5Eu=C(GVC7fzc2c1*0J_ z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?2tr_VGI$U~!l)BQ zLtr!nMniyt5Qv|$VDwx73c_zx>u3m!hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2n^d07@Z6rwoyCkvC$A14S~@R7!85p9|G>~J#R+O z1sMMEJnF;I5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVan1V$%=$qTnpU85l|8UmvsFd71*Aut*OqaiS`A&~Z`bpGgCfPsyJQQJpDU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONV33Ev=w$F9kDyWKjfTKz z2#kinXb6mkz-S1JhQMeDjD`Su2&hC)em%Mt06kPj`J*8)8UmvsFd71*AutL?Ltr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nFhbyYzeK_4j4(#9j0%i~z-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMG9fw?A^?MK%F491ulb<=1FjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb23}5Ez{d9;`7l>aNic7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GY-z zz(GsBL!)Z}V4*QekA}c#2#kinXb6mkz-S1JhQMeDjE2By2#kinXb2D)0;7|`L1YUyhQMeD49gH0oeUn9(K_m>(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!83T76Kd7L#;>G z0t~T88+Gnz2n_ZR7@Z6r>@hUzzR?gE4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fngp3_p1L*8(j-9%p-X~yf-=-JRre7 zYUyYQjE2A{7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@RU|?VnXX`&fqqP8|lfk1YY%~N$Ltr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nhD!*r zPWzQPItx5xVpBqc^%0HM0t}fj7RZa(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5THT` zj7|nqArME67!85Z5Eu=C(GVC70UCus1eZ?6=vn|81@5S^qaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?+(Te=GI+Sh@TmVrLtr!nMnhmU z1V%$(Gz5ld2;_PREg4-4FhnD7)Y+pUFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFgQYhb=t4Y(HY^v5f`Jb7!85Z5Eu=C(GVC7fzc2c4S~@R7!3h% z2<)@^S~R*A02~yf#ApbNhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk z0EHniIvGr10FG)O4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!3g`g+QsDe%a_+04fFM zs4=4A7 zbTW8o#M7uNM?+vV1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nhG7WY z3)2r6T?;S_BX-nFqaiRF0;3@?8UmvsFd71*Auw1%fOXoh%+VR)!4eyz?idY$(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVCRA;2gg1v<>2VK03#{JNBue)0;3@?8Uh0v0;7|` z0~-9JmXC(OXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjFb>KzhUj{(X{|0CALTXIT`|kBLqe#g9k@EjJjep1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhouhJepI z1@_Uk0K+$a2d&RWCxZuV)Qmc8Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONVCaVcTjBo~1GE-^b=t4Y(HY^P zpM*wzFd71*Aut*OqhK@yMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E24Auu`t3=W1tLAVu}FBqV;0E5E+qmCF2fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc44VhD^*22(LGM~xZ{0rEp&iTAm~qiX@k55G~JqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O=pisV8H^qxqx{hj z7!85Z5TJJmyi&hsH@X&p-l0Bf-)IPohQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2n_oW7@Z6r_E9|Q!O;*H4S~@R7!85J90Je7l1I-4 z7|ang>bB7k7!85Z5EuocAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFqA{!WVcz#=#21Cj=@p4kA}c#2#kinXb6mkz-R~z(Gd7Pv&?n$T!0}Od85uA4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S^vO0;7|`Lnf+59Xc8U zqaiRF0;3@?8UmvsFd71*AuudLApFywQ=@AEhDB74dSWyLMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^D~%4o&-O2e0c! zT{ap5qaiRF0;3@?8UmvsFd71*Au!lNV01=!u*JxzdqzWGGz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1cpTjOsHdiI=U8MSVZNhCq_eHGz3ONU^E0qLtr!n zMnho8hQR1#@Q{tVQHPI)z-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-R~z(hv|dJfA(f7GRJ@&Zx6SLtr!nMnhmU1V%$(B!$4}WbjCe?NPsuhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2+%kLUcC>Q zIJy>q#(_L){AdV_hQMeDi~=GAut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UlDjzXXqB z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!3ishCo*3Q>nqb7GQKTn68Oo)UweK7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5TIQMj7|X4 zE^IMOp6vgwY4ENEz;N!Uz-S1JhQMeDjE2A{7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fgv0Mqm#iyIPylFKBPim_Uz|fqiX?% zRFsW6b~FS=Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz5lt2(V83l{q>iJj5e$)cKj1V$%=35C$8{AdV_hQMeDjE2By2#kinXb6mkz-S1N6avfGUPq3u z1t2NNMiq~Sz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb8|G1V$%= zX%eiXhK`27Xb6mkz-S1JhQMeDjE2By2#kinXb6nR5U48se06j!z=(|cQBRMCz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2CV3<1_@zcNQ>ga>8Rj5=yG1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONUOw{4S~@R7!85Z5Ezspz&h<$ z=ID&@pp2SPM~#NSXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeD45|>g^|LBwbS=Q3ijq;sjE2By2oMINlfi_;XjH{$2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2An z4uOkH7I2TQ1sK5zUHQ?s24^hQMeDjE2By2#kinXb6mk zz-S1JhQMeD466{x&^*jBx)xwqMd_$#MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONV7Q0C=w$G4kKs}OjfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb4ao0+V>O z-;b^Zpg0&u4Hyl9(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc44a|n!12GcpfN39zT zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5ExP+aFP4l*3q>9Ln_Kf9XlEV zqaiRF0;3@?8UmvsFd71*Aut*OqagqbfzioeSV)Z0qaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqalD20(%ZEH5pwCfD$C5oY4>%4S`WG8UmvsFd71* zAut*OLn;JDCxeGnbd5T8Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONfG!~r8 zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjMxz1emUEJxUK~loeUna$$He|qaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0>dr@Sf~BU z9GwLwHd-}uB4vl`S^#3hU{u{`2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinkO_g&$>1RqRih3a$Pn1YoN#`0 zEx;b;_m76aXb6mkz-S1JhQMeDjE2By2#kinXb4a#1b%JNeKxulfJ%WmYRqT| zjE2A{7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CK@b9?lfi=^5=Nac8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*AuzxpV0^qWWOOaS07t^8^`jv$8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd72X4uR3hU}}fxsClCyFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0z)bUl)rxQA6*MDq@rxpv7;d{8UmvsFd71*Aut*OqaiRF0;3@?8UjRy z!02Q!kpVTTY%~N$Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONVAzGg zuAF3<(X{}>E?P%DG#UbZ@XN2LwG0GSXfzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c5h36gyM5#6T7VG| z)uWyq4S`WG8UmvsFd70QJp@K4gGYJ_81?^X2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6m;5P0u;;P2>KfDshoqh1{ifzc2c4S^9J0;7|` zBRm0&dVe$oMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3OS2w2ZCJu|u%V1z{Ws5eJLU^E0EAuu`_3<-x(ax?@+Ltr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONVAzJhnMvX; zqiX?%ZS;RJHSX}>Z@XM~4* z5+3!yC>RZa(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GY-yz~}@pBpetR7@j&PIFHn|0PsKJ17*;N6`i#iMHhC=A3=?V}+u8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0)r_8Mkj*@Q*4a7Wi$jv zLtsEcz<*+E-{@L^0SWq1OGiUsGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nXcz*clfg6$+)<-PLtr!nMnhmU1O|NwFflpp99;`A z=%Z=WfukWX3PwXBfzIx-lF=DqvVv|@(`X2chQMeDjE2By2#kinkPZQl71w8st_2vZs8W7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4FL*6;AV4m(CAtK3IlOe`)CM^hQMeDjE2By z2#kinXb6mkz|anX(aGSU9e<;)9}R)g5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Won54=ZNB8C?s25iFwuqaiRF0;3@?8UmvsFd70QE(As=gGXGH zk9v4C1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz5lm z2pqHcvu$)Oz%Y*BQLl}H(GVC7fzc2kB?Lw%gGmXkQMIEXFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0)sXLwgel$A6*MDXrpJ; zVWS~18Uh0m0<6=1Wsc4W4@Ah1+Bq5mqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*AwZ=Nh(3_Ndvq-Tl>&3rm|+$Iqm#kI zEJ8=UGa3S;Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd70QCj`8@CXTKJz!}}6lfgKHW>jJ{1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz5lL z2#ll&V99q$i$~W246XDz>e^8-8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Ukd7z(|@5CNqdewGEFDi2mhgF}fCD zc*NzXFGfRPGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1c(X&)@i>oM`whI3aU{>qaiRF0z)+fQqHY)8eI!8RAX<{-J>Be z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zl!U!6u1V%$(Gz3ONU@(WkdZC+lM%Mxi<`^1v+h_=khQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`SBLtu0=n5MxyYWQdfjE2By z2#kinXb6mkz`%sSxdert(X{{r6ZWIFj)Kt;7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7%U+$IvG4zVq(-CqaiRF0;3@?8UmvsFd71*Aut*O)Cqy! z%09-?wE)x!%TZHCLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E1V zbO?-21`p}z8+H6>2#kinXb6mkz-S1JhQMeDjE2By2#kgRatNGxxvgY$EdX+ejIu{V zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V(fSuul7xIXWXeqLaX==SM?e zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^s-p@8mS+(X{}>Atp!tFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*Oqai@u5Ez{drfztSnl>5&qaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsK+h0xQj+;Kx)y+*AwFu?C>RZa(GVC7fzc2c4S~@R7!85Z5Eu=C zAru0mlfgqMvPPXc8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3^- z2!S-dxZR^`0TAIa${G!U(GVC7fzc2c4S~@R7!85Z5Eu;s^bi=G3`P%;QT}KMjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2Bq4*|0UUUs8v0S0?4 zjk<3%1V%$(Gz3ONU^E0qLtwx|V01Egz@uQ)0iz)>8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd72Hhk)hok|U#Q0f-N;QI(@1Fd71*Aut*O zBRT|Fr~S$toe>_9yc0$6+(X{|WBd$ilXb6mkz~Bji(aGS!6BnZ{84ZEa z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@Rzzl&)Th2<3t_8phmr;??5Fi)=qm#h|gJ@L2Xb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz`%rn?iyG2 z(X{~NhW+SdFu5T&s&6y|MnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1O{0M466y?U!VWRjIIS3WGQUaIin#k z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8Uh0o0<6=1We%$u;eiRpQCkN-1fu1fAC0aB82ES?1*0J_8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0)r_8 zMkj*@Q*4a7Wi$jvLxA)Us7^{eHo6vo^l%&1F&YA+Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?>_cF5GI-cW@u&w!Ltr!nMnhou zhQN;8%!<*q0K+$aM}0OL0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsKv@WkP6ksJextfaLtr!nMnhmU1V%$(Gz11F1iqbkxO;Rhz`%t4 zsI8+RFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd70wBm_n$ zgNH~&jXHBQ1V%$(Gz3ONU^E0qLtr!nMneD=0vBT4KaH*hfQ7~=JqkucU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz4&lKzjd-g3%daoWV0HF&YA+Aut*O zqaiRF0;3@?8UmvsFd71*AuyOiz_{}$^XOWD!4xB-ZW#@M(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R80;Z1IvG6JV`$WUqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8Um|$rY#s<3ox1vMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONfW#0O zoeU;1z($pihQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb8|V1gt-0?jBtW zK+g~#wQDp4MnhmU1V%$(Gz3ONU^E0qLtr!nMnhouguv)z@bHPtQJ;*4z-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin@CpHreXF`h*8&W$I2{F}Aut*OqaiRF z0;3@?8UmvsFd71M4S~_gV7dnPsAZ!eFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd72HguoZ!((=)@0K|mUsG30(0*mLwBo89zj5=jB1V%$(Gz3ON zU^E1vAuu{43=N1;YBU5!Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhnrL!hw6_3Y?cfPs#MQTsY(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@Rz#amllfl@-WmI%D1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLx4UZFpEb(b95~LeZqRwmeCLx4S~@R7!85Z5Eu=C z(GVC7fzc2c4S^9B0;7|`BP_y4y*nBLqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71bCj_ck&&G_d1sFW>G3t`h5Eu=C(GVC7fzc2c4S~@R7!8487Xqx)er1l% z2oJkx9re&?2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2n_BJ zxcl(F>gZa4Q7{?;qaiRF0;3@?8UmvsFd7214}sCiVA_ZAs12hbFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OkPv8IoZ&jU761|!qvU7^ zjE2By2#kinXb8|e1V$%=X&%I*7L10#Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDFff#dF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OBQXT-X5Csgx)xw0#{8(C2RH;qCxZt#0!FPL4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4T0et0{gVJ zc}CX)3=m+Q_A7IAMtFb%f7II15Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GZ|^2n?PH;EAnW`$pFSP&!B_%iRA@8=MnhmU z1V%%EULnA;`}63z0Q3s%QF}&1U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMp_7Xc5f>goe>^su|DeG(GVC7fzc2c4S~@R7z80u$7Hr> z^jv^J5D}wJ7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5E#}WFgh7LtfP0-bE6?J8UmvsFd71*Aut*OqaiTpLg3q8&0C{u0R~;Pj5=sE1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmYhQR1#@NkUPQ9q4_z-S1J zhQMeDjE2By2#kinXb6mkz@Q9)Thk4@N7n)j%BUH2)MyBdhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinund9G$>3obt)reA4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!83z76N8mNgGDj0t~W98FkKR2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz;F+N z(aGT99>b&l8x4Wc5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fdLMItf2n&qiX>M zI1)y!r)~%wXtJ-Ru8E_jjfTKz2#kinXb6mkz-S1JhQMeDjD`U1LSS@8n08@1YVv3Z zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J;1E!0tG_wA7GMM?fKjiH zhQMeDjE2By2#kinXb6mkz-R~zf)E&;3?2lLFzSTS5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Exb=Aob^O_2^oFVHKsLo*4~+(GVC7fzc2c4S~@R z7@8q4IvG4P<89Q{qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?C__MkhvDVuT7W?rHKUFi4S~@R7!85Z5Ey|WFgh7L0waCY%cCJM8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*AwYu=_+1kp zGP)Lk1_3*2>T zZFq#h2}Tcr(X{}>BQ8gMF&YA+Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFf>Adb=t4Y(HY^P5m%$G91Ve?8UosWN1aF4 z0u0sI8+G?+2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQN>xfzip}Asv0Ajvozy(GVC7fx#RCtp;f;N7n)j<`^1v+h_=khQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2DQ4uR3h;Ncy= zqrMvrfzc2c4S~@R7!84e0XK!LD@WG?3{2RM+BzBnqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0?-f`oeYKs#3(fy0;3@?8UmvsFd71*Aut*O zqai?@5ZJiZx^HwX0CmE0)RfT>7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@Rpdu#=w$FPi_lT;jE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mk06oE(jAMzTYXRsP;-hwrhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjD`U5Auu`_Ong|4svHf0(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5I}^$&%zB|qiX>W;W5e@4S~@R7!85Z5Eu=C(GVC7fzc2c4S_)y0;7|` zgDy%&9W)vOqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OgFOV? z-@iXPx)xxt$I__#MnhmU1V%$(Gz3ONU^E0qLtu0=cr*=+hQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2oN6v*Be$!kFEtEKD$G2)qcg$-83UtskA}c#2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz|a6b;yVtEt_2txaW(47(GVC7fzc3vhrsA$ zFgz$m8KWUE8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0+=BXqHV7=x)uO4Tt-DkLtyZS!02S~;E$tG7mkL&Xb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zz=VL$;UlAK0R|@MsbTBrWH2>Cany{_5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVB}AwbOuU{O((w$XC| z20^5bI$<;fMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1Q=MS{mP`~jPPiXj#Q8qr?+r)Ex<^P{ZW69hQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-R~z(hwM(3?8HrGwQ6-5Ev>UFzbGP%;;Kxp%Po8?i>w)(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7)c>8IvG5YVtdrDqaiRF z0;3^7-4L)UmwY_B7J#~8I%?Wz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S0iEd)jtP-Oqm#kIJc38PHyQ$?Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd725L!ffGRr2Us0Nmj-v}HyogNJtfjkJT7y zGI&sXV$^Z84gr(e8Cj!i0cah{qvnr>z-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk05wBkbTXKlp*d>SC>V|*@Iv+D zs?oIo!!c$@{WKZ^qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFw8?>bTW9DNARfkMnhmU1V%%Ek`S=D@+)R^EdV7UII4Fv1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^D~#0u1ID8g<)f2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb2Dr0oG~1GDl~G35C+A{AdV_hQMeD zjE2By2#kinXb6m?5QzU%@N;x6z(|VmQNNCcz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb24B5Ez{d9>$S7>b21j7!85Z5Eu=C(GVC7fzc2c1*0KAN(gAq zmA^N-7J!rx8&x|R0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OgF6I9 zCxZuf{EWJ8Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E2i5dsd|6Kh7-0?;F*N9`C5 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!3hBhQR1#FdYMX)T+@C7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVDXA+Se1@b&0gfWa3pqb?c^fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4FSqSV01E=@-Q4VVKf9rLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz5lb2u$9&Bye;sz|f4lQCE+Kz-S1JhQMeDjE2By2#kinXb6mk05KuJ zI_+2H=!`HiAvLOIGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( z6bzpb=xscDWppjT@QKe+pNxjUXb6mkz-S1JhQMeDjD`Tt5Ez{d#u+rD5~Cq78Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFziBrWzXoj0K+a) zM?Ewe0;3@?8UmvsFd725LSS?<7+1)QijRiCXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQM$NfuGAo-;SOOFq~p^)GwnUFd71*AwVz$ zMkj*_2GOX3(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5E$AaaJj2DZ*(od(2m1V*N=t(IUz7Q8B9*7jp`WNVYB`}x)xyg$MdKUM?+vV1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qS_q6z29LBD zANB8O2#kW!5I_!rMcRpCqiX?>Lu8aa8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*O!zTndyT6r;&Ik{m_#E}gXb6mkz-S1JhQMeD z5E}w1fk)nst_2`Av_{p9hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2Av4S~_g;2|1uqs|@;fzc2c4S~@R7!85Z5Eu;s@{f+nNS^)CG zZdBK32#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2n?+d7@Z6r zTJbgN+R+dg4S~@R7!85Z5Eu=C(GVC7fzc2kKLmpBziAj<3qXGOjp`f?fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CAr=ColfgqQ!bY7t8UmvsFd71*Aut*O zqaiRF0;3@?8UmwWGz7>A0pr>E9;0gk$O*YoJ)`431HOwqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd72GGz9#G1r8TUwbTM%Mz67-*x)M?+vV1V%$(c!vP%v|pK{Gs43=jz@hr8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiTNL*SI5 z*X+@?0K+_zN4+;10%V53=wvXN0XM2`Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz11X1UQv7{*JB%7~n`4%+`-i z1`p=g8Fkxe2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2A{7!85Z5WpG&Yhpv@;a>|dIvI>L;6?>ULtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhoeg}~?p@X(7hvRvFKwQw%}wE$!pJF00k1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(u!aEZv|pK{Gs1&4 zc1GPb8Uj=afu-3+Hlu3+s1lT;hKz>5Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2Av3xUze;2{=aqs|=-fzc2c4FRG~(`Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E1%8v?oS zyLm^~0#G+hM@<_Ifzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85p5CWrgWGQ*8+^x*dO)xXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-R~%2!YYbU;+U&Dm@wkqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71bGX!!>(|t$R0u0W$8FkfY2#kinXb6mkz-S1JhQMeD zjE2By2n>!87@Z6r9Pu#fiqQ}l4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2kJ_K&AJ~VA~EdcT1HL7wn1V%$(Gz3ONU^E0qLtr!nMra6(P6m(Ah#&R# zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz|ahVRL=ar zqiX?%X55XsdNc$^Ltr!nMnhmU1n3?DtkZsFj?M_vJ=jMr91Vfd5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5EuocAut*Oq=vv>F6YyuYXL|NwNdq> zAut*OqaiTTLtu0=c%-L*QU8yIz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2n_BJIQYL^WOOaS;EtnF*Nuk2APs@h$>2d6F{92J z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4FS_fs})Aq0*s~uazkKrGML3{{WTf_qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?ltLhPr>5-aT7aPxW20^z4S~@R7!83T5CWrhE}PtfcoJ&YT{@JjE2By2#kinXb6mk zz-S1JhQMeDjD`R-1X!p2${d{$h6ctcH5vk=Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;6Cw1V%$(Gz91q0;^BSpBh~YK%cN4wPiE}MnhmU1V%$(Gz3ONU^E0qb_k44 z29NCcANBoc2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgR zJwt%)&c}$+wE*-C@lm@*Ltr!nMnhmU1V%$(M2EoWWblZN{!!15hQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2+%78M8Z|ijIITsS7?vg zGa3S;Aut*OqagqafzioeP$-Pz(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5TIQM$W411IJy>qcHui}@@NQ*h5(Kb7@Z8p z5ip~|qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmwWGz3ONU^E0qLxA!Skly+#VRS73<>5GL0$CxzI_+2H=!`H~K{u*tGz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E19hd_bGy^Mig3otqvj5~ftWky3_Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU`U0)=mhYfh_2aJ z{1*=NT7W?j5TlM54S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@Rpi&5oP6kscAV-ZE4FR%4!0OBy`_Z)kWC!1< z#?cTM4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC70qkIOG8lWfjEatiz-S1Jh5%hcU{2bj+R?QDbP4KFOGZOrGz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtx~Ez{xJN($N{= zkr(fyJ{}E$(GVC7fzc2c@DOlflC?UPpa38UmvsFd71*Aut*OqaiRF0;3@?2t#1=+{2$o*8&W} zh#7U#Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2n^E@7@Z6rrV%^p ztvjIIS3R8cbOn9&dz4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5E%X;Fgh7L{Ns4khod1d8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd6~_8UnUwbW29p0t{#rj9NbYLcpPUf6ego)u@j~Ltr!nMnhmU1V%$( zGz3ONU^E0qLx46RFghbln{XX9bug%1`oYB8+Gw$2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1Jg3%Bd4S~@R7!83z8v^f`Tw+Jp0u0*d8Fkob2#kinXb6mk zz-S1Jun-uX3?5+-KI+}k5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC70UCwCwK>KcN7n+-C~!xO9Swoe5Eu=C(GVaj1X!p2${d{$CM)Ph zHI0VAXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjD`R>1Qf3~W{$1}00+e=F&YA+Au!lNV01Egu*JxzdqzWGGz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1cpcm6sR2Q zA6*MDL?UZMI&*X~ctl3~sHaCmU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhl}jE2By2#kinXb4ag0!Fu2tR3WQ0Y)c-DT?7y&7&bO z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFnB^>bOM+@ak0Pa;>|(67JxnhF>1?b2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgRokD@E^5(Gz3ONU>JwM zHr>DXN7n)j;|L!0+Gq%jhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2n_NN7@Z6r1*0J_8UmvsFd71*Awc~Q@OpJBWOOY6^}}`4#L*BK z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!3guLSS?< zn1sL@RXQ31qaiRF0;3@?8UmvsFd71cL*Sn3alg^E0EEM7RK;irjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`TMLtu0=nARaYYW`>ljE2By2#kin zXb6mkz-S1JhQP=Pfg@Yy@{g_s7&-Ah>eJB>7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c>>xyFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?(nDZ$GI*q?fKmUChQMeDkRJk>zNgoYt_2`J{6=+-hQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb8|W1V$%=X&Stv zhL488Xb6mkz-R~z*ATcUd%kycEx>S%-BEvyhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2DQ2!YYb;NcOEqrMmgqaiRF0;3@?8UmvsK)(=B zH*!2Px)y+b;XP{8Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mk0J$Nsyf30~bVium&>Ph^8UmvsFd71*Aut*OqaiRF0;3@?vO-{`{ieC2YXL@9 zoR9i;Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1crDBj7|m*@dzAs z{%8n{hQMeDjE2By2#kinXb6mkz-S1Jh5+qDK(%}8g3+}Av=8S|8%9H5Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!ns2c*Klfl#t&r#DxLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1cp}#JinzKHM$mHc*W_cZ$?94Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONV8n#L=w$GSiRw|0j)uT!2#kinXb6mkz-S1JhQKHo4S~@R7!85Z5Eu=C(GVal z1okNe-x*yCKw6lMsvZr2(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC+Auu`_JeXo*)GebS zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UlkO1YWu@kgy|E`qqdBOz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-R~z{t&nlQYSgO7GUtl)2ItaLtr!n zMnhmU1V%$(Gz5qYfzioeA_Hnv*=PuihQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2B)4gp@_rGG}(0u1LE9`)O32#kinXb6nN5Ez{d9*MC& z>gUlA7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c1*0J_8UmvsFd71* zAut*Oh!9Y}ekFQzEdU}sMp>gFFd72GCIm(&gNIF2j(TJ?1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnizq5a`!g*f_ct zfYeYMRXNM zHET2kMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1O_k!Mkj!Y2>i{>Kjnt^S^y#fVN}Uz2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjDpb+7!83T z6#}D^!9yy#Mjblz_q1V+JV z2#kinXb6mkz-S1JhQP22fzuDZwT-R?7*Y33H7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c;UO?O89c%hz^L~}Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!n z7#J7?einZmT?;Up6h=c}Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nhFJ)V zP6iLN2p#p#Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1Jh5%_H@a&+}{?WAnq=nh2 z>d_Dw4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!84;5dx!=!9ydSMqN1?0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Awc~Qa44(S9$gDS{cs&MaWn)*Ltr!nMnhmU z1V%$(Gz3ONU^E0qLtwCk0PD10nWHnpgC#ab-7y*hqaiRF0;3@?8UmvsFd71*Aut*O zqhK@yMnhmU1V%$(Gz3ONfIcB`GeD?pbS(gV!g|z}(GVC7fzc2c4S~@R7!85Z5Eu=C z0S$rC$>0GE{!zzMe8UmvsFd71*Aut*OqaiSmAuu`_JdmM3YWHXejE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgRy+R-~Vzu?? zS^#>5_NYChAut*OqaiRF0)rw1Mkj*@MKp{$Vl)IsLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%%Eb|G*ii^FSlEdcGpchuz35Eu=C z!5RXilfi>EW=7pL8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmwWGz3ONU^E0qLtr!nMniz|5Lo}DCS`Oj0OjF0YQoSA0oG~1GDl~Ghi2T3x_UGO zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMneF12rT+>LTkjY1sI(S#+^V$Wky3_Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU`U0)=man* zx?&4!AB_050HA;v#iJoG8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Un*51V$%=hf7S3`h)Bcm~A-e!{}N7vV(6_ z<7fzshQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinC>RZaK^g+1lfi>DVn&@c8UmvsK$j3mpYN+Ux)y*gK|N~8Xb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`T+L!h&(taNln znC`(oYT;-IjE2By2#kinfQP_C_SwHj*8&W9G>kf6Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLx8p+Fgh7b+wdJVeKZ6{Ltr!nMnhmU z1V%$(kcEJfWJl!aT7W?oDWlFA4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@Rph*afP6pE?SVs*V4S~@R7!85Z5Eu=C(GVC7fzc2cgdy#Fu;*8YW-*kjE2By2#kinXb6mkz-S1JhQMeD zjE2C#hQR1#@W6)usO_U6Fd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8Uj=gfr@M<;nB4KR1els14lz(Gz3ONU^E0qLtr!nMnhmU1cqD)j7|m*xhNZT@Ms8( zhQMeDjE2By2#kinXb6mkz-S1JhQKHo4S~@R7!85Z5Eu=C(GVC70h}Q)F)P<=bS(hR z;2D(|4S~@R7!85Z5Eu=C(GVC}Auu`_JhI|@)VHG{Fd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Auw!1z>nd@m(jHV!!~+HJvJHwqaiRF0;3^7 zP6)70`;|F5BTP=njp`W4oXN-oxXb23l5Ez{d9%KYULK z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4FMuUV1EzK|IxJoLd?^;7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!3i+Lx6SKugnoLBTRW8OFDAut*OqaiRF0;6Cw1V%$(Gz3ONU^E0qLtr!n z=oSJqPA|0>T?;_B;2yPTGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLttoy!02S~ z(2B27*N%q3Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDKto`{f1TXXwE)n- z7^Ox-U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLjX$%j7|n)36xQx(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CK^+2h<^~L-YXJsz6pcD=Gz3ONU^E0q zLtr!nMnhmU1V%$(FoeM9Wbj~!g;6(*hQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeD5E%lqOcn)?t_2`6utt@QhQMeDjE2By2#kinXb6n-5MZ75 zD|2*4c%-L-QU8yIz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQKHo4S~@R7!85Z5Eu=C z(GVC7fgunA+s>G>kFEt60ueRp#L*BK4S~@R7!83z6#}D^!GkI~MjbO60;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*A%GSFTO+QN zjIISh3zSjrXb6mkz-R~%69S`?!Ni2psG89b7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S@j&fhS_jCr8%;3_!q-S~*NY zV01Egm_+2LH%3EXGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(ScSkLk9W^U*8)%!rK6L<6b0X?=Ft!s4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7zLvtFd71*Aut*OqaiRF z0;3@?h(dsM+ON!EFbgbx-6CRiEx;g3SEEiD4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7z80O3?_pIK`4$o zVcO zh0>_}Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2BS4S~_g;E@{hqy8QZfzc2c4S}H@0&hCBQbyMT4DC1^b^T}vjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgRjlt+-FpUFv)cDa5 z7!85Z5Eu=C(GVC70cwZ9tBZ-pN7n*SJ5)!_8x4Wc5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5EzCbklr_=aCAm^7)I=Fgh7bv!ER{cr*k?Ltr!nMnhmU1V%$(Gz3ONU^E0qLx4&lPP{7@Z6r1d%Z6gwYTf4S~@R7!85Z5EuocAut*O zqaiRF0;3@?8UmvsFd70_LSS!<;nmT#09XQMRA@8=MnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1c(cP(aB)q!f901Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kgR9Yf%gnYrWWS^zo*_^4H*AwZoFSUfi-i8>~Ynlc&!qaiRF0;3@?8UmvsK)nzc zoe`#9SdN-B8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zKv4*2A4{zpT?;@_5RPgd4S~@R7!85Z5Eu=C(GVC70i+NZoeV|_j8W!j2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkzyO7Sjj`~)(X{{r z6!@doj)uT!2#kinXb23~5Ez{d9aWod7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=CQ7{?;qaiRF0;3@?8UmvsFd71*AuvQjU{dUyh|#qGLnN|BojDo;qaiS)LSS?< zct}OpsAES%U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(ScSml*z}j9YXOE;l#Y64FonSAWbj~$jZwFZhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQNpk zfx|0i){d?PplNioPWzQPIwMTeARaY*Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E2i7Xm|J0(j5VHNm56 z0qB<|Mr|4mfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S`WG8UmvsFd71*Aut*OqaiR1LSQIN1`mT^9`yqGA>dKUP&K+1fc)?q)j1jhqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAuv)yV01Egq{jTHzehu0D1?Ah`iYIBYXOErOpUs6Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhl_hQR1#@F0woQ74Utz-S1J zhQMeDFfgpyxa0ijT7c1%Fd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0u+P*>$G2)qcg%31mLLF(GVC7fzc2c4S~@R7!3hxhrqnA z4M#`U0#G|tN6i}zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2cnISMb89XxMebm>ZAut*OqaiRF0;3@?8UmvsF!(~?`azL9qiX>MU%ZUEXfy;y zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONV918R=w$Gajk-~XkA}c# z2#kinXb6mkz-S1JhQMeDjE2DQ34yySZt0J%1sFc@IqH+q5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~TC0;7|`gCibBT`?L0qaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0wXm9dR2O_h<-=hQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`SB zLtu0=n5MxyYWQdfjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk0PYa@m@n=; zx)uO;_>9VohQMeDjE2By2#kinXb6mkz-S1JhQMeDjD`S(A;3EASLW!9Fol6Qs(myB zMnhmU1V%$(Gz3ONU=)moz-S1JhQMeDjE2By2#kinXb6mk0BIp`XutKd(X{}ih1sa; z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVaf1V$%=$qBVlJ)BwTyE+k5SfW2+%bISf~BU9GwxSYmkpxHW~t>Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd70QAp|T|Pnt5i7GR)bdUP^) zpkrXv{?QN^4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@Rpi&47iV5IAR}t~iwE$F#(NSYYLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMniysfniWg29L(XhzdtY5f*8+^_Brxjv(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fl)9T0;3@?8UmvsFd7212!YYbU|NLgsF|Z7Fd70x zhQP`_-wQ|A0uUKkqsm4@U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%%Ev=H#@+EzL`BTQPDjjA3Efzc2c4S~@Rph5^Jdne?M zt_7e%K#m$Q8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OctT)wG8j+bj7p7$z-S1JhQMeDjE2By2v9u)L^8rNM%MyRJy=H#91Vfd z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;s%n%rz48{zT zQIXLQ7!85Z5Eu=C(GVC7fzc2c4FQ^jK=>BNlcQ?^XcDxehK`27Xb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kgRmJk@748{^DqhK@yMnhmU1V%$(Gz3ON zU^E0qLtr!nMnizgA+T@8M2XS008|dtQR7BKU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%#ue+Z0D2ICK(QQ6TD7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu;s z3PWH<+*HTWwEz?b;;8n~5EyJBaG=?~X0W+w)IFmiFd71*Aut*OqaiRF0;3@?8Umvs zFd72bLtu197<;&kijIcBXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP!s|+ z<{8bSYXK+dhe;0;7|`Lo(V%9X%QXqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFbYOP zU^E0qLtr!nMnhmU1V%$(Gz3ONU^D~UMUe@52=42c9W>d4U$7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!8485(2E#er3{U zMtGP6*r+#14uKgzJ*r370+1YRqZ&p-U^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONfbtL+oeZWt3`b2E4S~@Rz#0NM z*H69}T?>FUXhsD`Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0q zLtr!nM!{$ZjE2By2#kgR9YSDqGMEm5JZi;g2#kinXb6nB5IDA2n|*XGz=(_XQ4f!X zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD46YCu zoeUma@iFR}(GVC7fzc2c4S~@R81f;&b3;XLbS=P;kH%3CjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2n>S|7@Z6r29Y@Gh0zcg4S~@R7!85Z z5Eu=C(GVCUA;4sL`2XlyfI$)&qs|x&fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC70S4A-zcNQ>ghvy?Xb6mkz-S1JhQMeDjE2By2#kinXb6mk0A(Ry zF*!G6bS(g7VK}OLGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1ZWTf zqm#ij2-Hy{N5NfIECfWky3_Gz3ONU^E0qLtr!nMnhmU1V%$(aD>3< zWbojKhf!CIhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQQDW z0g?PUMx$#1hDKbCx^grGMnhmU1V%$(Gz3ONU^E0+r~S$toe>^Q2csb{8UmvsFd71* zAut*OqaiRF0;6Cw1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz18R081#tl+m>SghFXl zel!F|Ltr!nMnhmU1O{0Mj7|m*vIrS<&S(gXhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2n?7hfZ-66qkb3- zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVDJAuu`_JltY*)IXykK)(=(h-{A;T?;_J@E)~kGz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V{>j(aB(vf@@Uq zXb6mkz-S1Jh5#f4R+ikkGP)K35*DN6Xb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz$h3Efzc2coFOne89X@SWz)&;hK0r`JsJX|Aut*OqaiRF0;3@?8UmvsfG-5*BuQkCt_8ps zLZfn{Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Auz~7V01Eg zkVVL-b4Ej8Gz3ONU^E0qLtr!nMnhmU1V%$(@P)unU;ZDXYXJsdyo|bNGz3ONU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1n`Hz=wvYd@EMgI4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!84u9RfbrnqQ5s1sK_BVAS`cAut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0z)N?=Gz2IKfyZ-Yk|@zXs&_O5MnhmU1V%$(Gz3ONU@(Tj=#21SjFnM0jfTKz2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2BKguurGmg1vp0R|%EN9`O9 zfzc2c4S~@R7!85Z5Eu;snuNgUWH3#Fb=1(&5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC~A@D6u@9*eZfT0yq4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!3h%2oNy=eCBN&aNic7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@Rpn3=x=Dd>~T?;_!AZGz3ONU^E0qLtr!nMnhmU1O{mcj7|m*(uf&# z)@TTfhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP&EW1 z0)OlpT?;_fARRSqGz3ONU^E0qLtr!n273sMP6iM57#eloXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6O052Y84t_7el5J$C- zhQMeDjE2Av41v+f;2{`kqfQ6B}I%Frt&d zsOLvRU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(6pV(zXb6mkz-S1J zhQMeDjE2By2n@>*7@Z6rmeD%usnHN1G6Zrq>aHAJ3qWLGjVc=rfzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CAr=ChUEfMa zXM~4Xq>VavGz3ONU^E1%5CZf4zI-2D3qXZ{95rGz1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^D~ARz}@48UmvsFd71*Aut*OqaiRF0;3@?8Uj=ffl_tf`J-zAs2r%H#*K!+Xb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2A<4}sCi;6WZiqs|)*fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4FL*6V9mk@x}$3WC=A3=?V}+u{6e7RfA#S5)Toa}Ltr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nu!X?rj4-xP85J81fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!84;9s+t-l8=q91sLkFIO_h<5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GZ|01V$%=DGI((&7&bO8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiT( zLZDc*G-`A$z~GCQQ5TJdz-S1JhQMeDjE2By2#kinXb6mk0KG$CbTXLUAwFu~Xb6mk zz-S1JhQKHo4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5TIQMWZe2ZX>=_B?ZS7| zFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*A%H6cIu-oON7n-23Y}5$(GVC7fzc2c4S~@R z7?B~sI_+2H=#21)jQUYekA}c#2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb23K5LmWg!`{)g0K+9VNBuDx0;3@?8UoY}fzioe>W1g2X`>-9 z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqai@A5cqBK-g|T{0KGzc)Sl507=j@%IvG3!BW={lqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?3PwXWdX7GR{O z0z&>DoeU-vLZkAdAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiSuLLe%vOp}PU0Hc$^gDKKR-7*>iqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0+fUR>$G2)qqD%2gy5jpJ2C%P3K44o2E9K<9XJ{SqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*Oqai?Q z2#ih!lNw^9>PJIhsDwbZk%z$OT7aPvTchqA4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fl)9T0;3@?8UmvsFd71*Aut*OR1bmC$zZAn>8OFDAut*OqaiTx zA#h$`=jgcr10M~eE*K4g(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S@j-fzip}0S*39%SS_CGz3ONU^E0qLxAcbuqDm+_2{_(R1els z14lz(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONVCaRw z=w$HFi?dM|kA}c#2#kinXb6mkz-S1Jh5(Kb;Nf$gH@X%8N8pSKkA}c#2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2C-3jx+?zcNQ>ghyVykNS8t1V%$( zGz3ONU^E0qLtr!nMnhnjg+M0jmq(*(0ft$mj(TS_1V%$(Gz3ONU^E0qLtr!nMnhmU z1V%$(Gz3ONU^E0qLtr!nr~pPMgQ*aRqehH|z-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQI)Yz;%Ji*GAU@3{c>YT00s7qaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8Ulko z1V$%=2YU>Sx^FZDMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz74Q06W|Jp3$`c zSc7I%a5Mx)Ltr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMneD`0;7|`;9wXfMnhmU1V%$( zGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtqGnz~)`7yGGXn450`cb?RscjE2By z2#kinXb6mkz-S1JhQMeD3`_`&P6iK5xR2U88UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8Ukd6fbM1XH=}C-$O^hqO`{<&8UmvsFd71*Aut*OqaiTz zLV$JJuguXI;gJ{bqdp!Dfzc2c4S~@R7zLvtFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiTlLZEJPEc@tMfFT!cqYfSofzc2c4S~@R7!82|4uR3h-~o<+QR_!TU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMneE^2=pwR zux)fL0Nx-Pl^hL$(GVC7fzc4a9s;A2!PvuPRCF{1MnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^D~5i|-p<3ozi(FzSHO z5E!x{Fgh7LWTS4>;iDli8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0>d~2zJ|V?GrAUF7)LNYyf!)+Opj0=wPQ2{MnhmU z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(6pV(zXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeD5FG*&tP;Jbuoi%I+ON#f8DXMBeN^FS2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb24M5Ez{R z9^COm$aQTR`V~}I3qZ)4QTfpj7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!84;76PM_!9y*^M%_Ca0`L%!c=Yb~ z=vn}HaEvlWLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0!LSS?<7$rDHIin#k8UmvsFhoM&c`5(2(X{|WB(g@GIT`|^ zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?3PwXAmX5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVCpA@ICcqG)tRc;v+Qs82^jU^E0q zLtr!nMnhmU1cr17Y&$I)Ho6vINJrtQ<3~eaGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E0qLtr!nMnhmU1V%%E&LJ>58BFH@AGK~Y1V%$(Gz3ONU^E0qLtr!nMnhmU1V|16 z1y1GzqiX?34z^JZqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Au!TI zV01Egq^E#U|Br^iXb6mkz-S1JhQMeDjE2By2#kinXb24I5MYv5VH#ZvFsP$w)N!LB zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiS8Ltu0=c+f`8sKZ7>U^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMtBJ1q&1!#T?;V66TztWM?+vV1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%%Ek`Nf345lRXM)i({z-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kgRO+w(e{PXJ3wE#2;+EGJCLtuD^fKtQV`r+-vQQwV*z-S1JhQMeDjE2By z2#_8Eqcg&!huNr((GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GZ|X2>cgqJw3V>fF?mZYUpSPjE2By2#kinXb6mkz-S1J$PgHv3?7luKI-Yw5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=CK^6kbHhX%Ht_2um zkuvI>(GVC7fzc2c4S~@R80sM~IvG6FV{p{{qaiRF0;3@?8UmvsFbYOPU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1crDByb9izGrAUFh)3e6^G8EqGz3ONU{Hp@ z=w$GqjFwSHjfTKz2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1Jh!B{~lQ4U9Ex?F~>QPUQhQNS?0PD10nWHnp0}}M3mX3zNXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#nwmsN8?*>F8R35u5<%<@M3YV0wl0s6C@0Fd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*A;93zbc=yLYXL?l zgGZCwXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2A{7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVCWAuu`tJjf&R*}{c+^jQlq$OB^3d7~jP8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UiGR!02Q!Nx?O$cr*m)8UmM-E(MRS1)yt?k6Jbw0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O!zct;r~S$toe>^J5jyIX z(GVC7fngE?v)MQKjjjb4CXqSnjnNPo4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5E!@+7@Z6rxUe6!c{Bt@Ltr!nMnhmU1TaEiTEGsL z(X{{=!7?f^8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiQ~MnhmU1V%$( zGz3ONfQ%3roeU-;&_=b4hQMeDjE2By2#kinXb6mk01ZN5(`-Y>(X{|H2-s00M?+vV z1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhnvhQR1#@JNmMQGbty zz-S1JhQMeDjE2By2#kinXb23V5SS2t$$WGzz#xi{QKyWCz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinun2+C$>3oTjia6z4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7_uSIUCk*xx)xx_M&GEzM?+vV1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU{Hqu>$G2)qcg&TI*LXeHyQ$?Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiR%LmoM`wfwZS;&fY%~N$Ltr!nMnhmU1V%$(Gz3P$Xb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2CF4uO+J-~WxS1sKv%IO_P(5E$wq zFgh7L)MIee{i7i;8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0)s9Dwu>=L9$gDC=%QuBIcRh;c*I5dsE0>GU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ON zU^E117y=Ea=Y1R$YXL?lgJ~GcqehR0z-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1Jh5$W7U~~eQo}rD+uA6M@ zXAX+B0N5NiDmEGdqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFbYOPU^E0q zLtr!nMnhmU1V%$(Gz3ONU^E0qLtv2d8IipS+4S~@R7_K2O<8Og31A|1Dr;B6AaP{M;zeYn~Gz3ON zU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!n208>v WigrFMyQ{eo( literal 0 HcmV?d00001 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..e4de048 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +# trap "rm -r dist" EXIT +mkdir dist +bun build --compile --production --outfile=dist/deltarune_device_contact ./src/index.ts +bun build --compile --outfile=dist/deltarune_device_contact_devel ./src/index.ts diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..52a4e5a --- /dev/null +++ b/bun.lock @@ -0,0 +1,66 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bootseq", + "dependencies": { + "@kmamal/sdl": "^0.11.13", + "@napi-rs/canvas": "^0.1.84", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@kmamal/sdl": ["@kmamal/sdl@0.11.13", "", { "dependencies": { "tar": "^7.4.3" } }, "sha512-9WmxYNtCggi7Ovq1cU7m/s5WXD/+eKQxDMnL3bU8B5vr5GlaLg4xLykDCpcbWKkJJ2i6llTrdL7LiqikwDFz4w=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.84", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.84", "@napi-rs/canvas-darwin-arm64": "0.1.84", "@napi-rs/canvas-darwin-x64": "0.1.84", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84", "@napi-rs/canvas-linux-arm64-gnu": "0.1.84", "@napi-rs/canvas-linux-arm64-musl": "0.1.84", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-musl": "0.1.84", "@napi-rs/canvas-win32-x64-msvc": "0.1.84" } }, "sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.84", "", { "os": "android", "cpu": "arm64" }, "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.84", "", { "os": "linux", "cpu": "arm" }, "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.84", "", { "os": "linux", "cpu": "none" }, "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og=="], + + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + + "@types/node": ["@types/node@25.0.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg=="], + + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + } +} diff --git a/install-dev.sh b/install-dev.sh new file mode 100755 index 0000000..4ab30a5 --- /dev/null +++ b/install-dev.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="/opt/deltabootd_DEVICE_CONTACT" +BIN_NAME="deltarune_device_contact" +OUT_BIN="$INSTALL_DIR/dist/$BIN_NAME" +GREETD_CONFIG="${GREETD_CONFIG:-/etc/greetd/config.toml}" +GREETD_USER="${GREETD_USER:-greeter}" +BACKUP_SUFFIX="$(date +%Y%m%d%H%M%S)" + +log() { printf '[install] %s\n' "$*"; } + +command -v bun >/dev/null 2>&1 || { echo "bun is required on PATH"; exit 1; } + +log "Copying project to $INSTALL_DIR" +sudo mkdir -p "$INSTALL_DIR" +sudo rsync -a --delete \ +--exclude node_modules \ +--exclude dist \ +--exclude .git \ +"$REPO_DIR"/ "$INSTALL_DIR"/ + +log "Installing dependencies in $INSTALL_DIR" +sudo env -C "$INSTALL_DIR" bun install + +log "Installing N-API dependencies in $INSTALL_DIR" +sudo env -C "$INSTALL_DIR" bun pm trust --all + + +log "Building binary -> $OUT_BIN" +sudo env -C "$INSTALL_DIR" bun build --compile --production --outfile="$OUT_BIN" ./src/index.ts +sudo chmod +x "$OUT_BIN" + +if [[ -f "$GREETD_CONFIG" ]]; then + log "Backing up greetd config to ${GREETD_CONFIG}.${BACKUP_SUFFIX}.bak" + sudo cp "$GREETD_CONFIG" "${GREETD_CONFIG}.${BACKUP_SUFFIX}.bak" +else + log "greetd config not found, creating $GREETD_CONFIG" + sudo mkdir -p "$(dirname "$GREETD_CONFIG")" +fi + +log "Writing greetd config to launch $OUT_BIN as $GREETD_USER" +sudo tee "$GREETD_CONFIG" >/dev/null </dev/null 2>&1 || { echo "bun is required on PATH"; exit 1; } + +log "Copying project to $INSTALL_DIR" +sudo mkdir -p "$INSTALL_DIR" +sudo rsync -a --delete \ +--exclude node_modules \ +--exclude dist \ +--exclude .git \ +"$REPO_DIR"/ "$INSTALL_DIR"/ + +log "Installing dependencies in $INSTALL_DIR" +sudo env -C "$INSTALL_DIR" bun install + +log "Installing N-API dependencies in $INSTALL_DIR" +sudo env -C "$INSTALL_DIR" bun pm trust --all + + +log "Building binary -> $OUT_BIN" +sudo env -C "$INSTALL_DIR" bun build --compile --production --outfile="$OUT_BIN" ./src/index.ts +sudo chmod +x "$OUT_BIN" + +if [[ -f "$GREETD_CONFIG" ]]; then + log "Backing up greetd config to ${GREETD_CONFIG}.${BACKUP_SUFFIX}.bak" + sudo cp "$GREETD_CONFIG" "${GREETD_CONFIG}.${BACKUP_SUFFIX}.bak" +else + log "greetd config not found, creating $GREETD_CONFIG" + sudo mkdir -p "$(dirname "$GREETD_CONFIG")" +fi + +log "Writing greetd config to launch $OUT_BIN as $GREETD_USER" +sudo tee "$GREETD_CONFIG" >/dev/null < { + console.debug("[debug] [audio/decoder] decodeOggToPCM", { filePath, sampleRate, channels }) + const args = [ + '-v', + 'error', + '-i', + filePath, + '-f', + 's16le', + '-ac', + String(channels), + '-ar', + String(sampleRate), + 'pipe:1', + ] + + if (!ffmpegPath) { + throw "FFmpeg is not installed!" + } + + console.debug("[debug] [audio/decoder] spawn ffmpeg", args) + + const ffmpeg = spawn(ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const chunks: Buffer[] = [] + const stderr: Buffer[] = [] + + ffmpeg.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk)) + ffmpeg.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)) + + const exitCode: number = await new Promise((resolve, reject) => { + ffmpeg.on('error', reject) + ffmpeg.on('close', resolve) + }) + + console.debug("[debug] [audio/decoder] ffmpeg exited with code", exitCode) + + if (exitCode !== 0) { + const message = Buffer.concat(stderr).toString() || `ffmpeg exited with code ${exitCode}` + throw new Error(`Failed to decode audio: ${message}`) + } + + + const pcm = Buffer.concat(chunks) + const bytesPerSample = 2 + const bytesPerFrame = channels * bytesPerSample + const durationSeconds = pcm.length / (bytesPerFrame * sampleRate) + + console.debug("[debug] [audio/decoder] successfully decoded pcm") + + return { + pcm, + sampleRate, + channels, + format: 's16', + bytesPerSample, + bytesPerFrame, + durationSeconds, + } +} diff --git a/src/audio/pitch.ts b/src/audio/pitch.ts new file mode 100644 index 0000000..df6aa78 --- /dev/null +++ b/src/audio/pitch.ts @@ -0,0 +1,51 @@ +import type { DecodedAudio } from './decoder' + +export type PitchRampOptions = { + durationSeconds?: number + startRatio?: number + endRatio?: number +} + +export function createPitchRampBuffer( + audio: DecodedAudio, + { + durationSeconds = 1, + startRatio = 0, + endRatio = 1.0, + }: PitchRampOptions = {}, +): Buffer { + console.debug("[debug] [audio/pitch] new PitchRampBuffer", { durationSeconds, startRatio, endRatio }) + const totalFrames = Math.min( + Math.floor(durationSeconds * audio.sampleRate), + Math.floor(audio.pcm.length / audio.bytesPerFrame), + ) + + if (totalFrames <= 0) return Buffer.alloc(0) + + const result = Buffer.alloc(totalFrames * audio.bytesPerFrame) + let sourcePosition = 0 + + for (let frame = 0; frame < totalFrames; frame++) { + const progress = totalFrames > 1 ? frame / (totalFrames - 1) : 1 + const rate = startRatio + (endRatio - startRatio) * progress + + const baseFrame = Math.floor(sourcePosition) + const nextFrame = Math.min(baseFrame + 1, Math.floor(audio.pcm.length / audio.bytesPerFrame) - 1) + const fraction = sourcePosition - baseFrame + + for (let channel = 0; channel < audio.channels; channel++) { + const baseIndex = (baseFrame * audio.channels + channel) * audio.bytesPerSample + const nextIndex = (nextFrame * audio.channels + channel) * audio.bytesPerSample + + const sampleA = audio.pcm.readInt16LE(baseIndex) + const sampleB = audio.pcm.readInt16LE(nextIndex) + const sample = sampleA + (sampleB - sampleA) * fraction + + result.writeInt16LE(Math.round(sample), (frame * audio.channels + channel) * audio.bytesPerSample) + } + + sourcePosition += rate + } + + return result +} diff --git a/src/audio/player.ts b/src/audio/player.ts new file mode 100644 index 0000000..ec3a964 --- /dev/null +++ b/src/audio/player.ts @@ -0,0 +1,124 @@ +import sdl, { type Sdl } from '@kmamal/sdl' + +import { resolveAssetPath } from '../renderer/assets' +import { createPitchRampBuffer, type PitchRampOptions } from './pitch' +import { decodeOggToPCM } from './decoder' + +export type AudioLoopOptions = { + sampleRate?: number + channels?: number + pitchRamp?: PitchRampOptions +} + +export class AudioLoopPlayer { + #playbacks: Sdl.Audio.AudioPlaybackInstance[] = [] + #firstBuffer: Buffer + #baseBuffer: Buffer + #queueCheck: ReturnType | undefined + #stopped = false + #usedFirstFor = new WeakSet() + + private constructor(firstBuffer: Buffer, baseBuffer: Buffer, playbacks: Sdl.Audio.AudioPlaybackInstance[]) { + console.debug("[debug] [audio/player] new AudioLoopPlayer") + playbacks.forEach((pb) => { + console.debug(`[debug] [audio/player] ctor: provided audio device: ${pb.device.name}`) + }) + this.#firstBuffer = firstBuffer + this.#baseBuffer = baseBuffer + this.#playbacks = playbacks + } + + static async fromAsset(relativePath: string, options: AudioLoopOptions = {}): Promise { + console.debug("[debug] [audio/player] fromAsset", relativePath, options) + const { sampleRate = 48_000, channels = 2, pitchRamp } = options + const assetPath = resolveAssetPath(relativePath) + const decoded = await decodeOggToPCM(assetPath, { sampleRate, channels }) + + const rampBuffer = createPitchRampBuffer(decoded, pitchRamp) + const replacedBytes = rampBuffer.length > 0 ? Math.min(rampBuffer.length, decoded.pcm.length) : 0 + const tail = decoded.pcm.subarray(replacedBytes) + const firstBuffer = rampBuffer.length > 0 ? Buffer.concat([rampBuffer, tail]) : decoded.pcm + const baseBuffer = decoded.pcm + + const devices = selectPlaybackDevices() + const playbacks = devices.map((device) => + sdl.audio.openDevice(device, { + format: decoded.format, + channels: decoded.channels as 1 | 2 | 4 | 6, + frequency: decoded.sampleRate, + }) + ) + + return new AudioLoopPlayer(firstBuffer, baseBuffer, playbacks) + } + + start(): void { + if (this.#playbacks.length === 0) return + if (this.#queueCheck) clearInterval(this.#queueCheck) + console.debug("[debug] [audio/player] start") + + this.#playbacks.forEach((pb) => pb.clearQueue()) + this.#stopped = false + this.#usedFirstFor = new WeakSet() + + // Prime queue for seamless start. + this.#playbacks.forEach((pb) => { + this.#enqueueNextLoop(pb) + this.#enqueueNextLoop(pb) + pb.play(true) + }) + + this.#queueCheck = setInterval(() => this.#ensureQueued(), 100) + this.#queueCheck.unref?.() + } + + stop(): void { + console.debug("[debug] [audio/player] stop") + if (this.#playbacks.length === 0) return + this.#stopped = true + if (this.#queueCheck) { + clearInterval(this.#queueCheck) + this.#queueCheck = undefined + } + this.#playbacks.forEach((pb) => { + pb.clearQueue() + pb.close() + }) + this.#playbacks = [] + } + + get playing(): boolean { + return this.#playbacks.some((pb) => pb.playing) + } + + #enqueueNextLoop(playback: Sdl.Audio.AudioPlaybackInstance): void { + if (!playback) return + const alreadyUsedFirst = this.#usedFirstFor.has(playback) + const buffer = alreadyUsedFirst ? this.#baseBuffer : this.#firstBuffer + + playback.enqueue(buffer) + this.#usedFirstFor.add(playback) + console.debug("[debug] [audio/player] enqueued next loop") + } + + #ensureQueued(): void { + if (this.#playbacks.length === 0 || this.#stopped) return + const minQueueBytes = this.#baseBuffer.length * 2 + this.#playbacks.forEach((pb) => { + while (pb.queued < minQueueBytes) { + this.#enqueueNextLoop(pb) + } + }) + } +} + +function selectPlaybackDevices(): Sdl.Audio.PlaybackDevice[] { + const playbackDevices = sdl.audio.devices.filter( + (d): d is Sdl.Audio.PlaybackDevice => d.type === 'playback' + ) + + if (playbackDevices.length > 0) return playbackDevices + + // last resort + return [{ type: 'playback' }] +} diff --git a/src/bootsequence/dia.ts b/src/bootsequence/dia.ts new file mode 100644 index 0000000..5481bdc --- /dev/null +++ b/src/bootsequence/dia.ts @@ -0,0 +1,156 @@ +type ResolvableText = string | ((answers: Record) => string); + +type Question = { + t: "q", + text: ResolvableText, + answers: { + text: string, + value: string + }[], + id: string +} + +type Dia = { + t: "d", + text: ResolvableText +} + +type Wai = { + t: "w", + time: number +} + +type Fun = { + t: "f", + f: () => any +} + +type chrt = "kris" | "susie" | "ralsei" | "noelle" +type desktopt = "hyprland" | "plasma" + +let chr: chrt = "kris"; +let desktop: desktopt = "hyprland"; + +export function setChar(newchr: chrt) { + chr = newchr; +} + +export function setDesktop(newdesktop: desktopt) { + desktop = newdesktop; +} + +// TODO: Work on this a bit more +export const QUESTIONS: (Question | Dia | Wai | Fun)[] = [ + { + t: "w", + time: 4000 + }, + { + t: "q", + id: "char", + text: "SELECT THE VESSEL YOU PREFER.", + answers: [ + { + text: "RALSEI", + value: "ralsei" + }, + { + text: "SUSIE", + value: "susie" + }, + { + text: "KRIS", + value: "kris" + }, + { + text: "NOELLE", + value: "noelle" + } + ] + }, + { + t: "d", + text: "YOU HAVE CHOSEN A WONDERFUL FORM." + }, + { + t: "d", + text: () => `NOW LET US SHAPE ${(["noelle", "susie"]).includes(chr) ? "HER" : chr === "ralsei" ? "HIS" : "THEIR"} MIND AS YOUR OWN.` + }, + { + t: "q", + id: "desktop", + text: () => `WHAT IS ${(["noelle", "susie"]).includes(chr) ? "HER" : chr === "ralsei" ? "HIS" : "THEIR"} FAVORITE DESKTOP ENVIRONMENT?`, + answers: [ + { + text: "HYPRLAND", + value: "hyprland" + }, + { + text: "KDE", + value: "plasma" + } + ] + }, + { + t: "d", + text: () => `${desktop === "hyprland" ? "HYPRLAND" : "KDE"}, INTERESTING CHOICE..` + }, + { + t: "q", + id: "color", + text: "YOUR FAVORITE COLOR PALETTE?", + answers: [ + { + text: "LATTE", + value: "latte" + }, + { + text: "FRAPPE", + value: "frappe" + }, + { + text: "MACCHIATO", + value: "macchiato" + }, + { + text: "MOCHA", + value: "mocha" + } + ] + }, + { + t: "q", + id: "gift", + text: () => `PLEASE GIVE ${(["noelle", "susie"]).includes(chr) ? "HER" : chr === "ralsei" ? "HIM" : "THEM"} A GIFT.`, + answers: [ + { + text: "KINDNESS", + value: "kindness" + }, + { + text: "MIND", + value: "mind" + }, + { + text: "AMBITION", + value: "ambition" + }, + { + text: "BRAVERY", + value: "bravery" + } + ] + }, + { + t: "d", + text: "THANK YOU FOR YOUR TIME." + }, + { + t: "d", + text: () => `YOUR WONDERFUL CREATION, ${chr.toUpperCase()}` + }, + { + t: "d", + text: "WILL NOW BE" + } +] diff --git a/src/bootsequence/font.ts b/src/bootsequence/font.ts new file mode 100644 index 0000000..4c925d1 --- /dev/null +++ b/src/bootsequence/font.ts @@ -0,0 +1,216 @@ +import fs from "fs"; +import { type CanvasRenderingContext2D, type Image } from "@napi-rs/canvas"; +import { loadImageAsset, resolveAssetPath } from "../renderer/assets"; + +type Glyph = { + x: number; + y: number; + w: number; + h: number; + shift: number; + offset: number; +}; + +export type GlyphMap = Map; + +export type BitmapFont = { + atlas: Image; + glyphs: GlyphMap; + lineHeight: number; +}; + +function loadGlyphs(csvRelativePath: string): GlyphMap { + const csvPath = resolveAssetPath(csvRelativePath); + const raw = fs.readFileSync(csvPath, "utf8"); + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + const glyphs: GlyphMap = new Map(); + + for (let i = 1; i < lines.length; i++) { + const parts = lines[i]!.split(";"); + if (parts.length < 7) continue; + + const [ + charCodeStr, + xStr, + yStr, + wStr, + hStr, + shiftStr, + offsetStr + ] = parts; + + const code = Number(charCodeStr); + const glyph: Glyph = { + x: Number(xStr), + y: Number(yStr), + w: Number(wStr), + h: Number(hStr), + shift: Number(shiftStr), + offset: Number(offsetStr) + }; + + if (Number.isFinite(code)) glyphs.set(code, glyph); + } + + return glyphs; +} + +function computeLineHeight(glyphs: GlyphMap): number { + let maxHeight = 0; + for (const glyph of glyphs.values()) { + if (glyph.h > maxHeight) maxHeight = glyph.h; + } + return maxHeight + 4; +} + +export async function loadBitmapFont( + atlasRelativePath = "font/fnt_main.png", + glyphsRelativePath = "font/glyphs_fnt_main.csv" +): Promise { + const glyphs = loadGlyphs(glyphsRelativePath); + const lineHeight = computeLineHeight(glyphs); + const atlas = await loadImageAsset(atlasRelativePath); + return { atlas, glyphs, lineHeight }; +} + +type DrawOptions = { + align?: "left" | "center"; + color?: string; + alpha?: number; + scale?: number; // <— TEXT SCALE +}; + +function normScale(scale: number | undefined): number { + const s = scale ?? 1; + return Number.isFinite(s) && s > 0 ? s : 1; +} + +export function measureTextWidth( + text: string, + font: BitmapFont, + options: Pick = {} +): number { + const scale = normScale(options.scale); + let width = 0; + for (const ch of text) { + const glyph = font.glyphs.get(ch.codePointAt(0) ?? 0); + width += (glyph?.shift ?? 0) * scale; + } + return width; +} + +export function drawBitmapText( + ctx: CanvasRenderingContext2D, + font: BitmapFont, + text: string, + x: number, + y: number, + options: DrawOptions = {} +): void { + const { atlas, glyphs, lineHeight } = font; + const align = options.align ?? "left"; + const color = options.color; + const alpha = options.alpha ?? 1; + const scale = normScale(options.scale); + + let cursor = x; + if (align === "center") { + cursor = x - measureTextWidth(text, font, { scale }) / 2; + } + + const previousAlpha = ctx.globalAlpha; + ctx.globalAlpha = previousAlpha * alpha; + + const startX = cursor; + + for (const ch of text) { + const glyph = glyphs.get(ch.codePointAt(0) ?? 0); + if (!glyph) { + cursor += 8 * scale; + continue; + } + + (ctx as any).drawImage( + atlas as any, + glyph.x, + glyph.y, + glyph.w, + glyph.h, + cursor + glyph.offset * scale, + y, + glyph.w * scale, + glyph.h * scale + ); + + cursor += glyph.shift * scale; + } + + if (color && color.toLowerCase() !== "white") { + const width = cursor - startX; + ctx.save(); + ctx.globalAlpha = previousAlpha * alpha; + ctx.globalCompositeOperation = "source-atop"; + ctx.fillStyle = color; + ctx.fillRect(startX, y, width, lineHeight * scale); + ctx.restore(); + } + + ctx.globalAlpha = previousAlpha; +} + +export function drawBitmapTextPerGlyph( + ctx: CanvasRenderingContext2D, + font: BitmapFont, + text: string, + startX: number, + y: number, + options: DrawOptions = {} +): void { + const { atlas, glyphs } = font; + const color = options.color; + const alpha = options.alpha ?? 1; + const scale = normScale(options.scale); + + let cursor = startX; + + const previousAlpha = ctx.globalAlpha; + ctx.globalAlpha = previousAlpha * alpha; + + for (const ch of text) { + const glyph = glyphs.get(ch.codePointAt(0) ?? 0); + if (!glyph) { + cursor += 8 * scale; + continue; + } + + (ctx as any).drawImage( + atlas as any, + glyph.x, + glyph.y, + glyph.w, + glyph.h, + cursor + glyph.offset * scale, + y, + glyph.w * scale, + glyph.h * scale + ); + + if (color && color.toLowerCase() !== "white") { + ctx.save(); + ctx.globalAlpha = previousAlpha * alpha; + ctx.globalCompositeOperation = "source-atop"; + ctx.fillStyle = color; + ctx.fillRect( + cursor + glyph.offset * scale, + y, + glyph.w * scale, + glyph.h * scale + ); + ctx.restore(); + } + + cursor += glyph.shift * scale; + } + + ctx.globalAlpha = previousAlpha; +} diff --git a/src/bootsequence/questions.ts b/src/bootsequence/questions.ts new file mode 100644 index 0000000..ebbc4bf --- /dev/null +++ b/src/bootsequence/questions.ts @@ -0,0 +1,466 @@ +import sdl from "@kmamal/sdl"; +import { type CanvasRenderingContext2D, type Image } from "@napi-rs/canvas"; + +import { loadImageAsset } from "../renderer/assets"; +import { type BitmapFont, drawBitmapTextPerGlyph, loadBitmapFont, measureTextWidth } from "./font"; +import { QUESTIONS, setChar, setDesktop } from "./dia"; +import { homedir } from "os"; +import { join } from "path"; +import { writeFileSync } from "fs"; + +type ResolvableText = string | ((answers: Record) => string); +type BootsequenceAnswerKey = keyof BootsequenceAnswers; + +type QuestionAnswer = { + text: string; + value: string; +}; + +type QuestionEntry = { + t: "q"; + id: string; + text: ResolvableText; + answers: QuestionAnswer[]; +}; + +type DialogueEntry = { + t: "d"; + text: ResolvableText; +}; + +type FunctionEntry = { + t: "f", + f: () => any +} + +type WaitEntry = { + t: "w"; + time: number; +}; + +type SequenceEntry = QuestionEntry | DialogueEntry | WaitEntry | FunctionEntry; + +const TYPEWRITER_SPEED = 16; // chars/s +const DIALOGUE_HOLD_MS = 1200; +const HEART_SCALE = 1.1; +const TYPEWRITER_DISABLED = false; +const ANSWER_FADE_MS = 220; +const BLUR_OFFSETS = [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1] +] as const; + +type KeyInput = { + key: string | null; + scancode: number; + ctrl: number; + shift: number; + alt: number; + super: number; +}; + +import type { BootsequenceAnswers } from "../types"; + +export type BootSequenceUI = { + update: (deltaMs: number) => void; + render: (ctx: CanvasRenderingContext2D) => void; + handleKey: (input: KeyInput) => void; + isFinished: () => boolean; + getAnswers: () => BootsequenceAnswers; +}; + +function wrapLines(text: string, font: BitmapFont, maxWidth: number): string[] { + const tokens = text.split(/(\s+)/); + const lines: string[] = []; + let current = ""; + + for (const token of tokens) { + const next = current + token; + if (measureTextWidth(next.trimEnd(), font) <= maxWidth) { + current = next; + continue; + } + if (current.trim().length > 0) { + lines.push(current.trimEnd()); + } + current = token.trimStart(); + } + + if (current.trim().length > 0) { + lines.push(current.trimEnd()); + } + + if (lines.length === 0) return [text]; + return lines; +} + +export async function createBootSequenceUI( + baseWidth: number, + baseHeight: number +): Promise { + const questionFont = await loadBitmapFont(); + const answerFont = await loadBitmapFont(); + const heart = await loadImageAsset("IMAGE_SOUL_BLUR_0.png"); + const CHARACTER_IDS = ["ralsei", "susie", "kris", "noelle"] as const; + type CharacterId = (typeof CHARACTER_IDS)[number]; + const characterSprites: Record = { + ralsei: await loadImageAsset("chr/ralsei.png"), + susie: await loadImageAsset("chr/susie.png"), + kris: await loadImageAsset("chr/kris.png"), + noelle: await loadImageAsset("chr/noelle.png") + }; + const isCharacterId = (value: string | undefined): value is CharacterId => + CHARACTER_IDS.includes(value as CharacterId); + + let currentIndex = 0; + let visibleChars = 0; + let selection = 0; + let finished = false; + const answers: BootsequenceAnswers = { + char: "", + desktop: "", + color: "", + gift: "" + }; + let dialogueHold = 0; + let loggedCompletion = false; + const textCache = new WeakMap(); + const graphemeCache = new WeakMap(); + let waitElapsed = 0; + let answerAlpha = 0; + const lineCache = new WeakMap(); + + const currentEntry = (): SequenceEntry | FunctionEntry | undefined => QUESTIONS[currentIndex]; + const resolveText = (entry: QuestionEntry | DialogueEntry): string => { + const cached = textCache.get(entry); + if (cached) return cached; + const rawText = entry.text; + const resolved = typeof rawText === "function" ? rawText(answers) : rawText; + textCache.set(entry, resolved); + return resolved; + }; + const graphemesForEntry = (entry: SequenceEntry | undefined): string[] => { + if (!entry) return []; + if (entry.t === "w") return []; + if (entry.t === "f") return []; + const cached = graphemeCache.get(entry); + if (cached) return cached; + const graphemes = Array.from(resolveText(entry)); + graphemeCache.set(entry, graphemes); + return graphemes; + }; + const linesForEntry = (entry: SequenceEntry): string[] => { + if (entry.t === "w") return []; + if (entry.t === "f") return []; + const cached = lineCache.get(entry); + if (cached) return cached; + const lines = wrapLines(resolveText(entry), questionFont, baseWidth * 0.9); + lineCache.set(entry, lines); + return lines; + }; + const resetForEntry = () => { + visibleChars = 0; + selection = 0; + dialogueHold = 0; + waitElapsed = 0; + answerAlpha = 0; + }; + + const advance = () => { + currentIndex += 1; + if (currentIndex >= QUESTIONS.length) { + finished = true; + } else { + resetForEntry(); + } + }; + + const skipTypewriter = () => { + const entry = currentEntry(); + if (!entry) return; + if (entry.t === "w") { + waitElapsed = entry.time; + return; + } + if (entry.t === "f") { + entry.f(); + return; + } + visibleChars = graphemesForEntry(entry).length; + }; + + const handleConfirm = () => { + const entry = currentEntry(); + if (!entry) return; + if (entry.t === "f") { + advance(); + return; + } + const fullyRevealed = + entry.t === "w" ? waitElapsed >= entry.time : visibleChars >= graphemesForEntry(entry).length; + if (!fullyRevealed) { + skipTypewriter(); + return; + } + if (entry.t === "d") { + advance(); + return; + } + if (entry.t === "w") { + advance(); + return; + } + const picked = entry.answers[selection]; + if (picked) { + if (isAnswerKey(entry.id)) { + answers[entry.id] = picked.value; + } + if (entry.id === "char" && isCharacterId(picked.value)) { + setChar(picked.value); + } + if (entry.id === "desktop") { + setDesktop(picked.value as any); + } + console.debug(`[debug] [bootsequence/questions] answer ${entry.id}: ${picked.value} (${picked.text})`); + } + advance(); + }; + + const handleMove = (dir: -1 | 1) => { + const entry = currentEntry(); + if (!entry || entry.t !== "q") return; + const fullyRevealed = visibleChars >= graphemesForEntry(entry).length; + if (!fullyRevealed) return; + const next = (selection + dir + entry.answers.length) % entry.answers.length; + selection = next; + }; + + const update = (deltaMs: number) => { + if (finished) { + console.debug("[debug] [bootsequence/questions] finish", deltaMs, finished, loggedCompletion) + if (!loggedCompletion) { + loggedCompletion = true; + console.info("[debug] [bootsequence/questions] finished questions", answers); + writeFileSync(join(homedir(), ".deltaboot.json"), JSON.stringify(answers)) + } + return; + } + const entry = currentEntry(); + if (!entry) return; + if (entry.t === "w") { + waitElapsed += deltaMs; + if (waitElapsed >= entry.time) advance(); + return; + } + if (entry.t === "f") { + entry.f(); + return; + } + const totalGraphemes = graphemesForEntry(entry).length; + if (TYPEWRITER_DISABLED) { + visibleChars = totalGraphemes; + } else { + const step = (deltaMs / 1000) * TYPEWRITER_SPEED; + visibleChars = Math.min(totalGraphemes, visibleChars + step); + } + const fullyRevealed = visibleChars >= totalGraphemes; + if (entry.t === "d" && fullyRevealed) { + dialogueHold += deltaMs; + if (dialogueHold >= DIALOGUE_HOLD_MS) { + advance(); + } + } + const targetAlpha = + entry.t === "q" && fullyRevealed + ? 1 + : 0; + const delta = deltaMs / ANSWER_FADE_MS; + if (targetAlpha > answerAlpha) { + answerAlpha = Math.min(targetAlpha, answerAlpha + delta); + } else { + answerAlpha = Math.max(targetAlpha, answerAlpha - delta); + } + }; + + const renderQuestionText = (ctx: CanvasRenderingContext2D, entry: SequenceEntry) => { + const graphemes = graphemesForEntry(entry); + const visibleCount = Math.floor(visibleChars); + const linesFull = linesForEntry(entry); + let remaining = visibleCount; + const startX = baseWidth * 0.08; + const startY = baseHeight * 0.04; + + for (let i = 0; i < linesFull.length; i++) { + const fullLine = linesFull[i] ?? ""; + const lineGraphemes = Array.from(fullLine); + const take = Math.max(0, Math.min(lineGraphemes.length, remaining)); + remaining = Math.max(0, remaining - take); + const line = lineGraphemes.slice(0, take).join(""); + const y = startY + i * questionFont.lineHeight; + + let cursor = startX; + for (const ch of line) { + const glyph = questionFont.glyphs.get(ch.codePointAt(0) ?? 0); + const glyphWidth = glyph?.shift ?? 8; + const drawX = cursor + (glyph?.offset ?? 0); + + ctx.save(); + ctx.globalAlpha = 0.3; + for (const [ox, oy] of BLUR_OFFSETS) { + drawBitmapTextPerGlyph(ctx, questionFont, ch, (drawX + ox), (y + oy) - 15, { align: "left" }); + } + ctx.restore(); + + drawBitmapTextPerGlyph(ctx, questionFont, ch, drawX, y - 15, { align: "left" }); + + cursor += glyphWidth; + } + } + }; + + const renderAnswers = ( + ctx: CanvasRenderingContext2D, + answersList: QuestionAnswer[], + visible: boolean + ) => { + if (!visible && answerAlpha <= 0) return; + const startX = baseWidth * 0.28; + const startY = baseHeight * 0.45; + const alpha = answerAlpha; + for (let i = 0; i < answersList.length; i++) { + const answer = answersList[i]!; + const y = startY + i * answerFont.lineHeight * 1.1; + const isActive = i === selection; + const color = "white"; + let cursor = startX; + ctx.save(); + ctx.globalAlpha = alpha; + for (const ch of answer.text) { + const glyph = answerFont.glyphs.get(ch.codePointAt(0) ?? 0); + const glyphWidth = glyph?.shift ?? 8; + const drawX = cursor + (glyph?.offset ?? 0); + + ctx.save(); + ctx.globalAlpha = alpha * 0.3; + for (const [ox, oy] of BLUR_OFFSETS) { + drawBitmapTextPerGlyph(ctx, answerFont, ch, (drawX + ox) - 30, y + oy, { align: "left" }); + } + ctx.restore(); + + drawBitmapTextPerGlyph(ctx, answerFont, ch, drawX - 30, y, { align: "left" }); + cursor += glyphWidth; + } + ctx.restore(); + if (isActive) { + const heartX = startX - heart.width * HEART_SCALE - 12; + const heartY = y - heart.height * HEART_SCALE * 0.2; + ctx.save(); + ctx.globalAlpha = alpha; + drawHeart(ctx, heart, heartX - 25, heartY + 2); + ctx.restore(); + } + } + }; + + const renderDialogue = (ctx: CanvasRenderingContext2D, entry: DialogueEntry) => { + renderQuestionText(ctx, entry); + }; + + const renderCharacterPreview = (ctx: CanvasRenderingContext2D, character: CharacterId) => { + const sprite = characterSprites[character]; + if (!sprite) return; + const maxWidth = baseWidth * 0.35; + const maxHeight = baseHeight * 0.55; + const scale = Math.min(2, Math.min(maxWidth / sprite.width, maxHeight / sprite.height)); + const drawWidth = sprite.width * scale; + const drawHeight = sprite.height * scale; + const drawX = (baseWidth - drawWidth) / 2; + const drawY = (baseHeight - drawHeight) / 2; + + ctx.save(); + ctx.globalAlpha = 0.9; + (ctx as any).drawImage(sprite, drawX + 30, drawY + 30, drawWidth, drawHeight); + ctx.restore(); + }; + + const render = (ctx: CanvasRenderingContext2D) => { + if (finished) return; + const entry = currentEntry(); + if (!entry) return; + const selectedChar = answers["char"]; + const showCharacter = + entry.t === "q" && entry.id === "char" && visibleChars >= graphemesForEntry(entry).length + ? entry.answers[selection]?.value + : selectedChar; + if (isCharacterId(showCharacter)) { + renderCharacterPreview(ctx, showCharacter); + } + if (entry.t === "w") return; + if (entry.t === "f") return; + if (entry.t === "d") { + renderDialogue(ctx, entry); + } else { + renderQuestionText(ctx, entry); + const fullyRevealed = visibleChars >= graphemesForEntry(entry).length; + renderAnswers(ctx, entry.answers, fullyRevealed); + } + }; + + const handleKey = (input: KeyInput) => { + if (finished) return; + const key = (input.key ?? "").toLowerCase(); + const sc = input.scancode; + const ctrlHeld = input.ctrl > 0 || key === "control" || key === "ctrl"; + + if (ctrlHeld) { + skipTypewriter(); + return; + } + + if ( + key === "arrowup" || + key === "up" || + sc === sdl.keyboard.SCANCODE.UP + ) { + handleMove(-1); + return; + } + if ( + key === "arrowdown" || + key === "down" || + sc === sdl.keyboard.SCANCODE.DOWN + ) { + handleMove(1); + return; + } + if ( + key === "enter" || + key === "return" || + key === "z" || + sc === sdl.keyboard.SCANCODE.RETURN || + sc === sdl.keyboard.SCANCODE.SPACE + ) { + handleConfirm(); + } + }; + + return { + update, + render, + handleKey, + isFinished: () => finished, + getAnswers: () => ({ ...answers }) + }; +} + +function isAnswerKey(value: string): value is BootsequenceAnswerKey { + return value === "char" || value === "desktop" || value === "color" || value === "gift"; +} + +function drawHeart(ctx: CanvasRenderingContext2D, heart: Image, x: number, y: number): void { + ctx.save(); + (ctx as any).drawImage(heart, x, y, heart.width * HEART_SCALE, heart.height * HEART_SCALE); + ctx.restore(); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..5b2416b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; + +const PASSWORD_PATH = "/etc/deltaboot/private/password"; +const USERNAME_PATH = "/etc/deltaboot/private/username"; + +/** + * Private credentials are expected to be owned by root: + * /etc/deltaboot/private -root 700 (dr-x------) + * /etc/deltaboot/private/* - root 600 (-r--------) + */ + +export function getDefaultPassword(): string { + try { + return readFileSync(PASSWORD_PATH, "utf8").trim(); + } catch (error) { + if (isIgnorableFsError(error)) { + return ""; + } + console.warn(`[config] failed to read default password from ${PASSWORD_PATH}`, error); + return ""; + } +} + +export function getDefaultUser(): string { + try { + const value = readFileSync(USERNAME_PATH, "utf8").trim(); + return value || "ralsei"; + } catch (error) { + if (isIgnorableFsError(error)) { + return "ralsei"; + } + console.warn(`[config] failed to read default user from ${USERNAME_PATH}`, error); + return "ralsei"; + } +} + +function isIgnorableFsError(error: unknown): error is { code?: string } { + const code = (error as { code?: string } | undefined)?.code; + return code === "ENOENT" || code === "EACCES"; +} diff --git a/src/desktop.ts b/src/desktop.ts new file mode 100644 index 0000000..916b8f8 --- /dev/null +++ b/src/desktop.ts @@ -0,0 +1,47 @@ +import { getDefaultPassword, getDefaultUser } from "./config"; +import { GreetdClient } from "./lib/greetd"; + +export const GREETD_SOCKET = process.env.GREETD_SOCK ?? "/run/dummy-greetd.sock"; +const KNOWN_SESSIONS: Record = { + hyprland: "Hyprland", + plasma: "startplasma-wayland" +}; +const GREETD_TIMEOUT_MS = Number(process.env.GREETD_TIMEOUT_MS ?? 5_000); + +export async function handoffToGreetd(desktopHint?: string): Promise { + console.debug("[desktop] starting greetd handoff", { + socket: GREETD_SOCKET, + desktopHint + }); + const username = + process.env.GREETD_USERNAME ?? + getDefaultUser() ?? + process.env.USER ?? + "greeter"; + const password = getDefaultPassword(); + const sessionCommand = resolveSessionCommand(desktopHint); + + console.debug("[desktop] using credentials", { username, sessionCommand }); + const client = new GreetdClient({ ipcSocketPath: GREETD_SOCKET, timeoutMs: GREETD_TIMEOUT_MS }); + await client.login({ + username, + password, + cmd: sessionCommand, + env: [] + }); +} + +function resolveSessionCommand(desktopHint?: string): string { + const candidate = + desktopHint ?? + process.env.DESKTOP_SESSION_FRIENDLY_NAME ?? + process.env.XDG_CURRENT_DESKTOP ?? + ""; + const normalized = candidate.trim().toLowerCase(); + const mapped = KNOWN_SESSIONS[normalized]; + if (mapped) return mapped; + if (!candidate.trim()) { + throw new Error("No desktop session hint available for greetd handoff"); + } + return candidate.trim(); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4ea92cc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import { runDeviceContactUI } from "./ui/app"; + +await runDeviceContactUI(); diff --git a/src/intro/text-layer.ts b/src/intro/text-layer.ts new file mode 100644 index 0000000..70c6a15 --- /dev/null +++ b/src/intro/text-layer.ts @@ -0,0 +1,174 @@ +import fs from "fs"; + +import { + createCanvas, + type Canvas, + type CanvasRenderingContext2D, + type Image +} from "@napi-rs/canvas"; + +import { loadImageAsset, resolveAssetPath } from "../renderer/assets"; + +type Glyph = { + x: number; + y: number; + w: number; + h: number; + shift: number; + offset: number; +}; + +type GlyphMap = Map; + +const FONT_ATLAS_PATH = "font/fnt_main.png"; +const FONT_GLYPHS_PATH = "font/glyphs_fnt_main.csv"; +const BLUR_SCALE = 1; +const BLUR_RADIUS = 2; + +function loadGlyphs(): GlyphMap { + const csvPath = resolveAssetPath(FONT_GLYPHS_PATH); + const raw = fs.readFileSync(csvPath, "utf8"); + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + const glyphs: GlyphMap = new Map(); + + // First line is metadata; skip it. + for (let i = 1; i < lines.length; i++) { + const parts = lines[i]!.split(";"); + if (parts.length < 7) continue; + const [ + charCodeStr, + xStr, + yStr, + wStr, + hStr, + shiftStr, + offsetStr + ] = parts; + + const code = Number(charCodeStr); + const glyph: Glyph = { + x: Number(xStr), + y: Number(yStr), + w: Number(wStr), + h: Number(hStr), + shift: Number(shiftStr), + offset: Number(offsetStr) + }; + if (Number.isFinite(code)) glyphs.set(code, glyph); + } + + return glyphs; +} + +function computeLineHeight(glyphs: GlyphMap): number { + let maxHeight = 0; + for (const glyph of glyphs.values()) { + if (glyph.h > maxHeight) maxHeight = glyph.h; + } + return maxHeight + 4; // seperation between lines +} + +function measureTextWidth(text: string, glyphs: GlyphMap): number { + let width = 0; + for (const ch of text) { + const glyph = glyphs.get(ch.codePointAt(0) ?? 0); + width += glyph?.shift ?? 0; + } + return width; +} + +function drawBitmapText( + ctx: CanvasRenderingContext2D, + text: string, + atlas: Image, + glyphs: GlyphMap, + x: number, + y: number +): void { + let cursor = x; + for (const ch of text) { + const glyph = glyphs.get(ch.codePointAt(0) ?? 0); + if (!glyph) { + cursor += 8; // missing glyph? + continue; + } + (ctx as any).drawImage( + atlas as any, + glyph.x, + glyph.y, + glyph.w, + glyph.h, + cursor + glyph.offset, + y, + glyph.w, + glyph.h + ); + cursor += glyph.shift; + } +} + +export type IntroTextLayer = { + canvas: Canvas; + redraw: (text: string) => void; +}; + +export async function createIntroTextLayer( + width: number, + height: number, + initialText: string +): Promise { + const glyphs = loadGlyphs(); + const lineHeight = computeLineHeight(glyphs); + const atlas = await loadImageAsset(FONT_ATLAS_PATH); + const canvas = createCanvas(width, height); // final composite + const ctx = canvas.getContext("2d"); + const textCanvas = createCanvas(width, height); // crisp text only + const textCtx = textCanvas.getContext("2d"); + + const blurCanvas = createCanvas( + Math.max(1, Math.round(width * BLUR_SCALE)), + Math.max(1, Math.round(height * BLUR_SCALE)) + ); + const blurCtx = blurCanvas.getContext("2d"); + + const redraw = (text: string) => { + textCtx.clearRect(0, 0, width, height); + textCtx.imageSmoothingEnabled = false; + blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height); + + const lines = text.split(/\r?\n/); + const totalHeight = lines.length * lineHeight; + const startY = (height - totalHeight) / 2; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const textWidth = measureTextWidth(line, glyphs); + const x = (width - textWidth) / 2; + const y = startY + i * lineHeight; + drawBitmapText(textCtx, line, atlas, glyphs, x, y); + } + + // Pixelated blur: downscale the text, blur at the lower resolution, upscale without smoothing. + blurCtx.imageSmoothingEnabled = false; + blurCtx.filter = `blur(${BLUR_RADIUS}px)`; + blurCtx.drawImage( + textCanvas as any, + 0, + 0, + blurCanvas.width, + blurCanvas.height + ); + blurCtx.filter = "none"; + + ctx.clearRect(0, 0, width, height); + ctx.imageSmoothingEnabled = false; + ctx.globalAlpha = 0.9; + ctx.drawImage(blurCanvas as any, 0, 0, width, height); + ctx.globalAlpha = 1; + ctx.drawImage(textCanvas as any, 0, 0, width, height); + }; + + redraw(initialText); + + return { canvas, redraw }; +} diff --git a/src/lib/greetd.ts b/src/lib/greetd.ts new file mode 100644 index 0000000..0e98dc9 --- /dev/null +++ b/src/lib/greetd.ts @@ -0,0 +1,260 @@ +import net from "node:net"; +import os from "node:os"; + +export interface GreetdLoginOptions { + username: string; + password: string; + cmd: string | string[]; + env?: string[]; +} + +export const GREETD_IPC_SOCKET_PATH_ENV_NAME = "GREETD_SOCK"; + +export type AuthenticationMsgType = "visible" | "secret" | "info" | "error"; +export type ResponseErrorType = "auth_error" | "error"; + +export type Request = + | { type: "create_session"; username: string } + | { type: "post_auth_message_response"; response: string | null } + | { type: "start_session"; cmd: string[]; env: string[] } + | { type: "cancel_session" }; + +export type Response = + | { type: "success" } + | { type: "error"; error_type: ResponseErrorType; description: string } + | { type: "auth_message"; auth_message_type: AuthenticationMsgType; auth_message: string }; + +type Endianness = "LE" | "BE"; + +function getNativeEndianness(): Endianness { + return os.endianness(); +} + +function writeU32(buffer: Buffer, value: number, endian: Endianness, offset = 0): void { + if (endian === "LE") buffer.writeUInt32LE(value >>> 0, offset); + else buffer.writeUInt32BE(value >>> 0, offset); +} + +function readU32(buffer: Buffer, endian: Endianness, offset = 0): number { + return endian === "LE" ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset); +} + +function parseResponse(value: unknown): Response { + if (!value || typeof value !== "object") { + throw new Error("Invalid greetd response: not an object"); + } + const record = value as Record; + const type = record.type; + if (type === "success") { + return { type: "success" }; + } + if (type === "error") { + const errorType = record.error_type; + const description = record.description; + if (errorType !== "auth_error" && errorType !== "error") { + throw new Error(`Invalid greetd response: unknown error_type ${String(errorType)}`); + } + if (typeof description !== "string") { + throw new Error("Invalid greetd response: missing description"); + } + return { type: "error", error_type: errorType, description }; + } + if (type === "auth_message") { + const authMessageType = record.auth_message_type; + const authMessage = record.auth_message; + if ( + authMessageType !== "visible" && + authMessageType !== "secret" && + authMessageType !== "info" && + authMessageType !== "error" + ) { + throw new Error( + `Invalid greetd response: unknown auth_message_type ${String(authMessageType)}` + ); + } + if (typeof authMessage !== "string") { + throw new Error("Invalid greetd response: missing auth_message"); + } + return { type: "auth_message", auth_message_type: authMessageType, auth_message: authMessage }; + } + + throw new Error(`Invalid greetd response type: ${String(type)}`); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + let timeout: NodeJS.Timeout | undefined; + return Promise.race([ + promise.finally(() => { + if (timeout) clearTimeout(timeout); + }), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs).unref(); + }) + ]); +} + +export class GreetdIPC { + private readonly socket: net.Socket; + private readonly endian: Endianness; + private buffer = Buffer.alloc(0); + private frameQueue: Buffer[] = []; + private frameWaiters: Array<{ resolve: (frame: Buffer) => void; reject: (err: unknown) => void }> = + []; + private closedError: unknown | null = null; + + private constructor(socket: net.Socket, endian: Endianness) { + this.socket = socket; + this.endian = endian; + + socket.on("data", (chunk: Buffer) => { + const data = Buffer.from(chunk); + this.buffer = this.buffer.length === 0 ? data : Buffer.concat([this.buffer, data]); + this.drainFrames(); + }); + socket.on("error", (error) => this.closeWithError(error)); + socket.on("close", () => this.closeWithError(new Error("greetd socket closed"))); + } + + static async new(socketPath: string | null, endian: Endianness = getNativeEndianness()): Promise { + const path = + socketPath ?? + (process.env[GREETD_IPC_SOCKET_PATH_ENV_NAME] as string | undefined) ?? + null; + if (!path) { + throw new Error(`${GREETD_IPC_SOCKET_PATH_ENV_NAME} is not set and no socketPath was provided`); + } + + const socket = net.createConnection({ path }); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + return new GreetdIPC(socket, endian); + } + + close(): void { + this.closeWithError(null); + } + + private closeWithError(error: unknown | null): void { + if (this.closedError !== null) return; + this.closedError = error ?? new Error("greetd socket closed"); + + for (const waiter of this.frameWaiters.splice(0)) { + waiter.reject(this.closedError); + } + this.frameQueue = []; + this.buffer = Buffer.alloc(0); + this.socket.destroy(); + } + + private drainFrames(): void { + while (this.buffer.length >= 4) { + const payloadLen = readU32(this.buffer, this.endian, 0); + if (!Number.isFinite(payloadLen) || payloadLen < 0) { + this.closeWithError(new Error("Invalid greetd frame length")); + return; + } + const totalLen = 4 + payloadLen; + if (this.buffer.length < totalLen) return; + const frame = this.buffer.subarray(4, totalLen); + this.buffer = this.buffer.subarray(totalLen); + if (this.frameWaiters.length > 0) { + const waiter = this.frameWaiters.shift(); + waiter?.resolve(frame); + } else { + this.frameQueue.push(frame); + } + } + } + + private async readFrame(): Promise { + if (this.closedError) throw this.closedError; + if (this.frameQueue.length > 0) return this.frameQueue.shift() as Buffer; + return await new Promise((resolve, reject) => { + this.frameWaiters.push({ resolve, reject }); + }); + } + + async sendMsg(request: Request): Promise { + if (this.closedError) throw this.closedError; + const payload = Buffer.from(JSON.stringify(request), "utf8"); + const header = Buffer.alloc(4); + writeU32(header, payload.length, this.endian, 0); + const msg = payload.length === 0 ? header : Buffer.concat([header, payload]); + await new Promise((resolve, reject) => { + this.socket.write(msg, (err) => (err ? reject(err) : resolve())); + }); + } + + async readMsg(): Promise { + const frame = await this.readFrame(); + let parsed: unknown; + try { + parsed = JSON.parse(frame.toString("utf8")); + } catch (error) { + throw new Error(`Failed to parse greetd JSON response: ${String(error)}`); + } + return parseResponse(parsed); + } +} + +export class GreetdClient { + private ipcSocketPath: string; + private timeoutMs: number; + + constructor(options: { ipcSocketPath?: string; timeoutMs?: number } = {}) { + const { ipcSocketPath = "/run/greetd.sock", timeoutMs = 5_000 } = options; + this.ipcSocketPath = ipcSocketPath; + this.timeoutMs = timeoutMs; + } + + async login(options: GreetdLoginOptions): Promise { + if (!options.username) throw new Error("username is a required parameter."); + const cmd = Array.isArray(options.cmd) ? options.cmd : [options.cmd]; + if (cmd.length === 0 || cmd.every((part) => !part.trim())) { + throw new Error("cmd is a required parameter."); + } + + const ipc = await GreetdIPC.new(this.ipcSocketPath); + try { + let stage: "create_session" | "start_session" = "create_session"; + await ipc.sendMsg({ type: "create_session", username: options.username }); + + while (true) { + const response = await withTimeout( + ipc.readMsg(), + this.timeoutMs, + `greetd timeout waiting for ${stage} response` + ); + + if (response.type === "error") { + throw new Error(`greetd error: ${response.description}`); + } + + if (response.type === "auth_message") { + const reply = + response.auth_message_type === "secret" + ? (options.password ?? "") + : response.auth_message_type === "visible" + ? "" + : null; + await ipc.sendMsg({ type: "post_auth_message_response", response: reply }); + continue; + } + + if (response.type === "success") { + if (stage === "create_session") { + stage = "start_session"; + await ipc.sendMsg({ type: "start_session", cmd, env: options.env ?? [] }); + continue; + } + return; + } + } + } finally { + ipc.close(); + } + } +} diff --git a/src/renderer/assets.ts b/src/renderer/assets.ts new file mode 100644 index 0000000..12abd88 --- /dev/null +++ b/src/renderer/assets.ts @@ -0,0 +1,15 @@ +import path from "path"; + +import { loadImage, type Image } from "@napi-rs/canvas"; + +const ASSET_ROOT = path.resolve(__dirname, "..", "..", "asset"); + +export function resolveAssetPath(relativePath: string): string { + return path.join(ASSET_ROOT, relativePath); +} + +export async function loadImageAsset(relativePath: string): Promise { + console.debug("[debug] [renderer/assets] loadImageAsset " + relativePath) + const assetPath = resolveAssetPath(relativePath); + return loadImage(assetPath); +} diff --git a/src/renderer/cli.ts b/src/renderer/cli.ts new file mode 100644 index 0000000..e7a8004 --- /dev/null +++ b/src/renderer/cli.ts @@ -0,0 +1,104 @@ +export type CliConfig = { + rendererId?: string; + debugGlobalHud: boolean; + debugRendererHud: boolean; + crashRecoverySession?: string | true; + errorScreenRequested?: boolean; + errorScreenMessage?: string; + errorScreenTitle?: string; + errorScreenHint?: string; + debugLogFile?: string; + helpRequested: boolean; +}; + +export function parseCli(argv: string[]): CliConfig { + // Bun passes: [bunPath, scriptPath, ...] + const args = argv.slice(2); + const config: CliConfig = { + debugGlobalHud: false, + debugRendererHud: false, + helpRequested: false + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ""; + if (arg === "--help" || arg === "-h") { + config.helpRequested = true; + continue; + } + if (arg === "--renderer" && args[i + 1] && !args[i + 1]!.startsWith("--")) { + config.rendererId = args[i + 1]!; + i += 1; + continue; + } + if (arg.startsWith("--renderer=")) { + config.rendererId = arg.split("=")[1]; + continue; + } + if (arg === "--debug") { + config.debugGlobalHud = true; + config.debugRendererHud = true; + continue; + } + if (arg === "--debug-global") { + config.debugGlobalHud = true; + continue; + } + if (arg === "--debug-renderer") { + config.debugRendererHud = true; + continue; + } + if (arg === "--error-screen") { + config.errorScreenRequested = true; + if (args[i + 1] && !args[i + 1]!.startsWith("--")) { + config.errorScreenMessage = args[i + 1]!; + i += 1; + } + continue; + } + if (arg.startsWith("--error-screen=")) { + config.errorScreenRequested = true; + config.errorScreenMessage = arg.split("=").slice(1).join("="); + continue; + } + if (arg === "--error-title" && args[i + 1] && !args[i + 1]!.startsWith("--")) { + config.errorScreenTitle = args[i + 1]!; + i += 1; + continue; + } + if (arg.startsWith("--error-title=")) { + config.errorScreenTitle = arg.split("=").slice(1).join("="); + continue; + } + if (arg === "--error-hint" && args[i + 1] && !args[i + 1]!.startsWith("--")) { + config.errorScreenHint = args[i + 1]!; + i += 1; + continue; + } + if (arg.startsWith("--error-hint=")) { + config.errorScreenHint = arg.split("=").slice(1).join("="); + continue; + } + if (arg === "--debug-log-file" && args[i + 1] && !args[i + 1]!.startsWith("--")) { + config.debugLogFile = args[i + 1]!; + i += 1; + continue; + } + if (arg.startsWith("--debug-log-file=")) { + config.debugLogFile = arg.split("=").slice(1).join("="); + continue; + } + if (arg === "--crash-recovery") { + const maybeSession = args[i + 1]; + if (maybeSession && !maybeSession.startsWith("--")) { + config.crashRecoverySession = maybeSession; + i += 1; + } else { + config.crashRecoverySession = "Hyprland"; + } + continue; + } + } + + return config; +} diff --git a/src/renderer/debug-hud.ts b/src/renderer/debug-hud.ts new file mode 100644 index 0000000..501faf2 --- /dev/null +++ b/src/renderer/debug-hud.ts @@ -0,0 +1,103 @@ +import type { CanvasRenderingContext2D } from "@napi-rs/canvas"; + +import type { Layout } from "./layout"; + +export type DebugStats = Record; + +export type DebugHudOptions = { + showGlobal: boolean; + showRenderer: boolean; + showCustom?: boolean; +}; + +export type DebugHudData = { + global: DebugStats | string[]; + renderer: { + id: string; + label: string; + stats: DebugStats; + fps: number; + }; + custom?: DebugStats; +}; + +export type DebugHud = { + draw: (ctx: CanvasRenderingContext2D, layout: Layout, data: DebugHudData) => void; +}; + +export function createDebugHud(options: DebugHudOptions): DebugHud { + const padding = 8; + const lineHeight = 16; + const bg = "rgba(0, 0, 0, 0.65)"; + const fg = "yellow"; // global + const rendererFg = "#ff66cc"; // renderer + const customFg = "#00c6ff"; // custom + + const drawBlock = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + title: string, + stats: DebugStats | string[], + color: string + ): { width: number; height: number } => { + let lines: string[] = []; + if (Array.isArray(stats)) { + lines = stats + } else { + const keys = Object.keys(stats); + lines = [title, ...keys.map((k) => `${k}: ${String(stats[k])}`)]; + } + ctx.font = "14px \"JetBrains Mono\", monospace"; + const textWidth = Math.max(...lines.map((l) => ctx.measureText(l).width)); + const height = lines.length * lineHeight + padding * 2; + const width = textWidth + padding * 2; + + ctx.save(); + ctx.fillStyle = bg; + ctx.fillRect(x, y, width, height); + ctx.fillStyle = color; + ctx.textBaseline = "top"; + lines.forEach((line, i) => { + ctx.fillText(line, x + padding, y + padding + i * lineHeight); + }); + ctx.restore(); + + return { width, height }; + }; + + const draw = (ctx: CanvasRenderingContext2D, layout: Layout, data: DebugHudData) => { + if (!options.showGlobal && !options.showRenderer && !options.showCustom) return; + ctx.save(); + ctx.imageSmoothingEnabled = false; + ctx.globalAlpha = 0.9; + + let cursorY = padding; + const originX = padding; + + if (options.showGlobal) { + const { height } = drawBlock(ctx, originX, cursorY, "Global", data.global, fg); + cursorY += height + padding; + } + + if (options.showRenderer) { + const { height } = drawBlock( + ctx, + originX, + cursorY, + `Renderer: ${data.renderer.label}`, + { fps: data.renderer.fps.toFixed(2), ...data.renderer.stats }, + rendererFg + ); + cursorY += height + padding; + } + + if (options.showCustom && data.custom) { + drawBlock(ctx, originX, cursorY, "Custom", data.custom, customFg); + } + + ctx.restore(); + }; + + return { draw }; +} diff --git a/src/renderer/fps.ts b/src/renderer/fps.ts new file mode 100644 index 0000000..e9dad20 --- /dev/null +++ b/src/renderer/fps.ts @@ -0,0 +1,27 @@ +export type FpsCounter = { + tick: (nowMs: number) => void; + value: number; +}; + +export function createFpsCounter(sampleWindowMs = 500): FpsCounter { + let lastSampleStart = Date.now(); + let frameCount = 0; + let currentFps = 0; + + const tick = (nowMs: number) => { + frameCount += 1; + const elapsed = nowMs - lastSampleStart; + if (elapsed >= sampleWindowMs) { + currentFps = (frameCount * 1000) / elapsed; + frameCount = 0; + lastSampleStart = nowMs; + } + }; + + return { + tick, + get value() { + return currentFps; + } + }; +} diff --git a/src/renderer/index.ts b/src/renderer/index.ts new file mode 100644 index 0000000..c2c0d4a --- /dev/null +++ b/src/renderer/index.ts @@ -0,0 +1,155 @@ +import type { Events } from "@kmamal/sdl"; +import { + createCanvas, + type Canvas, + type CanvasRenderingContext2D +} from "@napi-rs/canvas"; + +import { SDLWindow, type WindowProps } from "./window"; + +type RenderFrame = ( + ctx: CanvasRenderingContext2D, + size: { width: number; height: number } +) => void | Promise; +type RendererOptions = WindowProps & { window?: SDLWindow }; + +export class Renderer { + readonly window: SDLWindow; + readonly canvas: Canvas; + readonly ctx: CanvasRenderingContext2D; + #animation: ReturnType | undefined; + #pixelBuffer: Buffer | undefined; + #stride = 0; + #size: { width: number; height: number }; + #stop: (() => void) | undefined; + + constructor(options: RendererOptions = {}) { + console.debug("[debug] [renderer] new Renderer") + const { window: providedWindow, ...windowProps } = options; + this.window = providedWindow ?? new SDLWindow(windowProps); + + const { width, height } = this.window.size; + this.#size = { width, height }; + this.canvas = createCanvas(width, height); + this.ctx = this.canvas.getContext("2d"); + this.ctx.imageSmoothingEnabled = false; + this.#syncPixelBuffer(); + } + + get size(): { width: number; height: number } { + return this.#size; + } + + resize(width: number, height: number): void { + this.#size = { width, height }; + if (this.canvas.width === width && this.canvas.height === height) { + return; + } + this.canvas.width = width; + this.canvas.height = height; + this.ctx.imageSmoothingEnabled = false; + this.#syncPixelBuffer(); + } + + present(): void { + if (!this.#pixelBuffer) { + this.#syncPixelBuffer(); + } + this.window.renderFromBuffer( + this.canvas.width, + this.canvas.height, + this.#stride, + this.#pixelBuffer! + ); + } + + #syncPixelBuffer(): void { + this.#pixelBuffer = this.canvas.data(); + this.#stride = Math.floor( + this.#pixelBuffer.byteLength / Math.max(1, this.canvas.height) + ); + } + + requestStop(): void { + this.#stop?.(); + } + + async run(renderFrame: RenderFrame): Promise { + console.debug("[debug] [renderer] starting render") + + const listeners: Array<() => void> = []; + let rendering = false; + const addListener = ( + event: E, + handler: (event: Extract) => void + ) => { + listeners.push(this.window.on(event, handler)); + }; + + const renderOnce = async () => { + await renderFrame(this.ctx, this.size); + this.present(); + }; + + await renderOnce(); + + await new Promise((resolve) => { + let stopped = false; + + const cleanup = () => { + if (this.#animation) { + clearInterval(this.#animation); + this.#animation = undefined; + } + this.#stop = undefined; + listeners.splice(0).forEach((off) => off()); + }; + + const stop = () => { + if (stopped) return; + stopped = true; + cleanup(); + this.window.destroy(); + resolve(); + }; + this.#stop = stop; + + const tick = () => { + if (rendering) return; + rendering = true; + void renderOnce().finally(() => { + rendering = false; + }); + }; + + this.#animation = setInterval(tick, 1000 / 60); + this.#animation.unref?.(); + + addListener("resize", async (event) => { + this.resize(event.pixelWidth, event.pixelHeight); + tick(); + }); + + addListener("expose", () => { + tick(); + }); + + addListener("keyDown", (event) => { + if (event.key === "Escape" || event.key === "Q") { + stop(); + } + }); + + addListener("beforeClose", (event) => { + event.prevent(); + stop(); + }); + + addListener("close", () => stop()); + }); + } +} + +export function createRenderer(options: RendererOptions = {}): Renderer { + return new Renderer(options); +} diff --git a/src/renderer/layout.ts b/src/renderer/layout.ts new file mode 100644 index 0000000..ca45c32 --- /dev/null +++ b/src/renderer/layout.ts @@ -0,0 +1,65 @@ +export type Layout = { + width: number; + height: number; + viewScale: number; + boxWidth: number; + boxHeight: number; + boxX: number; + boxY: number; + contentScale: number; + drawWidth: number; + drawHeight: number; + x: number; + y: number; + centerX: number; + centerY: number; +}; + +export function createLayoutCalculator(options: { + baseWidth: number; + baseHeight: number; + viewWidth: number; + viewHeight: number; +}): (size: { width: number; height: number }) => Layout { + let cachedLayout: Layout | undefined; + + return (size: { width: number; height: number }): Layout => { + const { width, height } = size; + if (cachedLayout && cachedLayout.width === width && cachedLayout.height === height) { + return cachedLayout; + } + + const viewScale = Math.min(width / options.viewWidth, height / options.viewHeight); + const boxWidth = options.viewWidth * viewScale; + const boxHeight = options.viewHeight * viewScale; + const boxX = (width - boxWidth) / 2; + const boxY = (height - boxHeight) / 2; + + const contentScale = Math.min(boxWidth / options.baseWidth, boxHeight / options.baseHeight); + const drawWidth = options.baseWidth * contentScale; + const drawHeight = options.baseHeight * contentScale; + const x = boxX + (boxWidth - drawWidth) / 2; + const y = boxY + (boxHeight - drawHeight) / 2; + const centerX = boxX + boxWidth / 2; + const centerY = boxY + boxHeight / 2; + + cachedLayout = { + width, + height, + viewScale, + boxWidth, + boxHeight, + boxX, + boxY, + contentScale, + drawWidth, + drawHeight, + x, + y, + centerX, + centerY + }; + + return cachedLayout; + }; +} diff --git a/src/renderer/lazy-resource.ts b/src/renderer/lazy-resource.ts new file mode 100644 index 0000000..2b21c6c --- /dev/null +++ b/src/renderer/lazy-resource.ts @@ -0,0 +1,45 @@ +export type LazyResource = { + load: () => Promise; + unload: () => void; + isLoaded: () => boolean; +}; + +export function createLazyResource( + loader: () => Promise, + dispose?: (value: T) => void +): LazyResource { + let cached: T | null = null; + let inflight: Promise | null = null; + + const load = async (): Promise => { + if (cached) return cached; + if (inflight) return inflight; + + inflight = (async () => { + const value = await loader(); + cached = value; + inflight = null; + return value; + })(); + + return inflight; + }; + + const unload = () => { + if (cached && dispose) { + try { + dispose(cached); + } catch (error) { + console.error("[lazy-resource] failed to dispose resource", error); + } + } + cached = null; + inflight = null; + }; + + return { + load, + unload, + isLoaded: () => cached !== null + }; +} diff --git a/src/renderer/video.ts b/src/renderer/video.ts new file mode 100644 index 0000000..18ed1eb --- /dev/null +++ b/src/renderer/video.ts @@ -0,0 +1,94 @@ +import { ImageData } from "@napi-rs/canvas"; + +import { resolveAssetPath } from "./assets"; + +type VideoLoaderOptions = { + width: number; + height: number; + fps?: number; + maxFramesInMemory?: number; + frameSampleStep?: number; +}; + +export type VideoFrameSequence = { + width: number; + height: number; + fps: number; + durationMs: number; + frames: ImageData[]; +}; + +export async function loadVideoFrames( + relativePath: string, + options: VideoLoaderOptions +): Promise { + const targetFps = options.fps ?? 30; + const assetPath = resolveAssetPath(relativePath); + const maxFrames = options.maxFramesInMemory ?? 0; + const sampleStep = Math.max(1, options.frameSampleStep ?? 1); + + const ffmpeg = Bun.spawn( + [ + "ffmpeg", + "-v", "error", + "-i", assetPath, + "-an", + "-vf", `scale=${options.width}:${options.height}`, + "-r", `${targetFps}`, + "-f", "rawvideo", + "-pix_fmt", "rgba", + "-" + ], + { stdout: "pipe", stderr: "pipe" } + ); + + const frameSize = options.width * options.height * 4; + const frames: ImageData[] = []; + let residual = new Uint8Array(0); + let decodedFrameCount = 0; + + const stderrPromise = ffmpeg.stderr ? Bun.readableStreamToText(ffmpeg.stderr) : Promise.resolve(""); + + for await (const chunk of ffmpeg.stdout) { + const merged = new Uint8Array(residual.length + chunk.length); + merged.set(residual, 0); + merged.set(chunk, residual.length); + residual = merged; + + while (residual.length >= frameSize) { + const frameBytes = residual.slice(0, frameSize); + residual = residual.slice(frameSize); + decodedFrameCount += 1; + + if (decodedFrameCount % sampleStep !== 0) { + continue; + } + + const clamped = new Uint8ClampedArray(frameBytes.buffer, frameBytes.byteOffset, frameBytes.byteLength); + const image = new ImageData(clamped, options.width, options.height); + if (maxFrames > 0 && frames.length >= maxFrames) { + frames.shift(); + } + frames.push(image); + } + } + + const exitCode = await ffmpeg.exited; + const stderr = await stderrPromise; + if (exitCode !== 0) { + throw new Error(`ffmpeg exited with code ${exitCode}${stderr ? `: ${stderr}` : ""}`); + } + + if (frames.length === 0) { + throw new Error("No frames decoded from video"); + } + + const effectiveFrameCount = decodedFrameCount; + return { + width: options.width, + height: options.height, + fps: targetFps, + durationMs: (effectiveFrameCount / targetFps) * 1000, + frames + }; +} diff --git a/src/renderer/window.ts b/src/renderer/window.ts new file mode 100644 index 0000000..c6deb38 --- /dev/null +++ b/src/renderer/window.ts @@ -0,0 +1,98 @@ +import assert from "assert"; + +import sdl, { type Events, type Sdl } from "@kmamal/sdl"; +import { createCanvas, Image, type CanvasRenderingContext2D } from "@napi-rs/canvas"; + +export type WindowProps = { + title?: string; + width?: number; + height?: number; + visible?: boolean; + fullscreen?: boolean; + resizable?: boolean; + borderless?: boolean; + alwaysOnTop?: boolean; +}; + +export class SDLWindow { + #window: Sdl.Video.Window | undefined; + + constructor(props: WindowProps = {}) { + console.debug("[debug] [renderer/window] new SDLWindow", props) + this.#window = sdl.video.createWindow({ + ...props, + title: props.title ?? "SDL Application" + }); + if (process.env.NODE_ENV === "development") { + this.#window.on("resize", (e) => { + this.#window?.setTitle(`${props.title ?? "SDL Application"} [${e.pixelWidth}x${e.pixelHeight}]`) + }) + } + } + + get size(): { width: number; height: number } { + const { pixelWidth, pixelHeight } = this.Window; + return { width: pixelWidth, height: pixelHeight }; + } + + get Window(): Sdl.Video.Window { + if (!this.#window) throw "Window not present"; + return this.#window; + } + + on( + event: EventName, + handler: ( + event: Extract + ) => void + ): () => void { + const target = this.Window as unknown as { + on: (event: Events.Window.Any["type"], listener: (event: Events.Window.Any) => void) => void; + off?: ( + event: Events.Window.Any["type"], + listener: (event: Events.Window.Any) => void + ) => void; + removeListener?: ( + event: Events.Window.Any["type"], + listener: (event: Events.Window.Any) => void + ) => void; + }; + + target.on(event, handler as (event: Events.Window.Any) => void); + return () => { + if (typeof target.off === "function") { + target.off(event, handler as (event: Events.Window.Any) => void); + return; + } + if (typeof target.removeListener === "function") { + target.removeListener( + event, + handler as (event: Events.Window.Any) => void + ); + } + }; + } + + renderFromBuffer(width: number, height: number, stride: number, buffer: Buffer): void { + this.Window.render(width, height, stride, "rgba32", buffer); + } + + renderFromContext(ctx: CanvasRenderingContext2D): void { + const { width, height } = this.size; + const buffer = Buffer.from(ctx.getImageData(0, 0, width, height).data); + this.renderFromBuffer(width, height, width * 4, buffer); + } + + setIconFromImage(image: Image): void { + const canvas = createCanvas(image.width, image.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image as any, 0, 0); + const data = ctx.getImageData(0, 0, image.width, image.height).data; + this.Window.setIcon(image.width, image.height, image.width * 4, "rgba32", Buffer.from(data)); + } + + destroy(): void { + this.Window.destroy(); + this.#window = undefined; + } +} diff --git a/src/renderers/device_contact/index.ts b/src/renderers/device_contact/index.ts new file mode 100644 index 0000000..94f0c70 --- /dev/null +++ b/src/renderers/device_contact/index.ts @@ -0,0 +1,240 @@ +import { + createCanvas, + type Canvas, + type CanvasRenderingContext2D +} from "@napi-rs/canvas"; + +import { createLazyResource } from "../../renderer/lazy-resource"; +import type { Layout } from "../../renderer/layout"; +import { loadVideoFrames, type VideoFrameSequence } from "../../renderer/video"; +import { AudioLoopPlayer } from "../../audio/player"; +import type { RendererInstance, RendererProps } from "../types"; + +const BACKGROUND_VIDEO = { + path: "goner_bg_loop.mp4", + width: 160 * 2, + height: 90 * 2, + fps: 30 +} as const; +const MAX_FRAMES_IN_MEMORY = Number(process.env.GONER_VIDEO_MAX_FRAMES ?? "0"); +const FRAME_SAMPLE_STEP = Number(process.env.GONER_VIDEO_FRAME_SAMPLE ?? "1"); + +const VIDEO_LOOP_CROSSFADE_MS = 600; +const OVERLAY_FADE_DURATION_MS = 2500; + +type GonerBackgroundResources = { + video: VideoFrameSequence; + videoCanvas: Canvas; + videoCtx: CanvasRenderingContext2D; + videoBlendCanvas: Canvas; + videoBlendCtx: CanvasRenderingContext2D; + audio?: AudioLoopPlayer; +}; + +export function createDeviceContactBackgroundRenderer(_props: RendererProps = {}): RendererInstance { + console.debug(`[debug] [renderers/device_contact] new RendererInstance`, _props); + let overlayStart = Date.now(); + let videoTimeMs = 0; + + let lastVideoMeta: { durationMs: number; width: number; height: number; fps: number } | undefined; + const resources = createLazyResource( + async () => { + const video = await loadVideoFrames(BACKGROUND_VIDEO.path, { + width: BACKGROUND_VIDEO.width, + height: BACKGROUND_VIDEO.height, + fps: BACKGROUND_VIDEO.fps, + maxFramesInMemory: Number.isFinite(MAX_FRAMES_IN_MEMORY) ? MAX_FRAMES_IN_MEMORY : 0, + frameSampleStep: Number.isFinite(FRAME_SAMPLE_STEP) && FRAME_SAMPLE_STEP > 0 ? FRAME_SAMPLE_STEP : 1 + }); + lastVideoMeta = { + durationMs: video.durationMs, + width: video.width, + height: video.height, + fps: video.fps + }; + + const videoCanvas = createCanvas(video.width, video.height); + const videoCtx = videoCanvas.getContext("2d"); + const videoBlendCanvas = createCanvas(video.width, video.height); + const videoBlendCtx = videoBlendCanvas.getContext("2d"); + let audio: AudioLoopPlayer | undefined; + try { + audio = await AudioLoopPlayer.fromAsset("AUDIO_ANOTHERHIM.ogg"); + audio.start(); + } catch (error) { + console.error("[renderers/device_contact] failed to start audio loop", error); + } + + return { + video, + videoCanvas, + videoCtx, + videoBlendCanvas, + videoBlendCtx, + audio + }; + }, + (resource) => { + if (resource.audio) { + try { + resource.audio.stop(); + } catch (error) { + console.error("[renderers/device_contact] failed to stop audio loop", error); + } + } + resource.video.frames.length = 0; + resource.videoCanvas.width = 0; + resource.videoCanvas.height = 0; + resource.videoBlendCanvas.width = 0; + resource.videoBlendCanvas.height = 0; + } + ); + + const render = async ({ ctx, deltaMs, layout }: { ctx: CanvasRenderingContext2D; deltaMs: number; layout: Layout; }) => { + const { + video, + videoCanvas, + videoCtx, + videoBlendCanvas, + videoBlendCtx + } = await resources.load(); + + const clampedDelta = Math.max(0, deltaMs); + videoTimeMs += clampedDelta; + while (videoTimeMs >= video.durationMs) { + videoTimeMs -= video.durationMs; + } + + drawVideoBackground( + ctx, + layout, + videoTimeMs, + video, + videoCanvas, + videoCtx, + videoBlendCanvas, + videoBlendCtx + ); + + drawOverlay(ctx, layout, overlayStart); + }; + + return { + id: "device_contact", + label: "DEVICE CONTACT", + render, + unload: () => { + overlayStart = Date.now(); + videoTimeMs = 0; + resources.unload(); + }, + isLoaded: resources.isLoaded, + getDebugStats: () => { + const meta = lastVideoMeta; + return { + loaded: resources.isLoaded(), + videoMs: Number.isFinite(videoTimeMs) ? videoTimeMs.toFixed(2) : 0, + durationMs: meta?.durationMs, + fps: meta?.fps, + width: meta?.width, + height: meta?.height, + maxFrames: MAX_FRAMES_IN_MEMORY || "all", + sampleStep: FRAME_SAMPLE_STEP + }; + }, + getDebugHudStats: () => ({ + offsetMs: videoTimeMs.toFixed(0), + sample: FRAME_SAMPLE_STEP + }) + } as RendererInstance; +} + +function drawOverlay( + ctx: CanvasRenderingContext2D, + layout: Layout, + overlayStart: number +) { + const overlayProgress = Math.min( + 1, + Math.max(0, (Date.now() - overlayStart) / OVERLAY_FADE_DURATION_MS) + ); + const overlayAlpha = 1 - overlayProgress * 0.7; // fade 1 -> 0.3 + ctx.save(); + ctx.globalAlpha = overlayAlpha; + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, layout.width, layout.height); + ctx.restore(); +} + +function drawVideoBackground( + ctx: CanvasRenderingContext2D, + layout: Layout, + videoTimeMs: number, + video: VideoFrameSequence, + videoCanvas: Canvas, + videoCtx: CanvasRenderingContext2D, + videoBlendCanvas: Canvas, + videoBlendCtx: CanvasRenderingContext2D +) { + const { + boxX, + boxY, + boxWidth, + boxHeight + } = layout; + + const elapsed = videoTimeMs % video.durationMs; + const frameIndex = Math.floor((elapsed / 1000) * video.fps) % video.frames.length; + const frame = video.frames[frameIndex] ?? video.frames[0]; + + let blendFrame: VideoFrameSequence["frames"][number] | undefined; + let blendAlpha = 0; + if (elapsed >= video.durationMs - VIDEO_LOOP_CROSSFADE_MS) { + const fadeT = (elapsed - (video.durationMs - VIDEO_LOOP_CROSSFADE_MS)) / VIDEO_LOOP_CROSSFADE_MS; + blendAlpha = Math.min(1, Math.max(0, fadeT)); + const loopElapsed = elapsed - (video.durationMs - VIDEO_LOOP_CROSSFADE_MS); // 0..crossfade + const blendIndex = Math.floor((loopElapsed / 1000) * video.fps) % video.frames.length; + blendFrame = video.frames[blendIndex] ?? video.frames[0]; + } + + // Draw the raw frame to an offscreen canvas, then scale to the target size. + videoCtx.clearRect(0, 0, video.width, video.height); + videoCtx.putImageData(frame as any, 0, 0); + if (blendFrame && blendAlpha > 0) { + videoBlendCtx.clearRect(0, 0, video.width, video.height); + videoBlendCtx.putImageData(blendFrame as any, 0, 0); + videoCtx.save(); + videoCtx.globalAlpha = blendAlpha; + (videoCtx as any).drawImage(videoBlendCanvas as any, 0, 0); + videoCtx.restore(); + } + + const scale = boxHeight / video.height; + const scaledWidth = video.width * scale; + + // crop horizontal + let srcX = 0; + let srcWidth = video.width; + let destWidth = scaledWidth; + if (scaledWidth > boxWidth) { + const cropWidth = boxWidth / scale; + srcX = (video.width - cropWidth) / 2; + srcWidth = cropWidth; + destWidth = boxWidth; + } + + const drawX = boxX + (boxWidth - destWidth) / 2; + const drawY = boxY; + + ctx.save(); + ctx.imageSmoothingEnabled = false; + ctx.beginPath(); + ctx.rect(boxX, boxY, boxWidth, boxHeight); + ctx.clip(); + (ctx as any).drawImage( + videoCanvas as any, + srcX, 0, srcWidth, video.height, + drawX, drawY, destWidth, boxHeight + ); + ctx.restore(); +} diff --git a/src/renderers/error/index.ts b/src/renderers/error/index.ts new file mode 100644 index 0000000..4dcb18e --- /dev/null +++ b/src/renderers/error/index.ts @@ -0,0 +1,192 @@ +import type { CanvasRenderingContext2D } from "@napi-rs/canvas"; + +import { + drawBitmapTextPerGlyph, + loadBitmapFont, + measureTextWidth +} from "../../bootsequence/font"; +import { loadImageAsset } from "../../renderer/assets"; +import { createLazyResource } from "../../renderer/lazy-resource"; +import type { Layout } from "../../renderer/layout"; +import type { RendererInstance, RendererProps } from "../types"; + +type ErrorRendererProps = { + title?: string; + message?: string | string[]; + hint?: string; +}; + +type ErrorResources = { + font: Awaited>; + heart: Awaited>; +}; + +function wrapBitmapText( + text: string, + maxWidth: number, + measure: (t: string) => number +): string[] { + const words = text.split(/\s+/); + const lines: string[] = []; + + let current = ""; + + for (const w of words) { + const next = current ? `${current} ${w}` : w; + if (measure(next) <= maxWidth) { + current = next; + } else { + if (current) lines.push(current); + current = w; + } + } + + if (current) lines.push(current); + return lines; +} + +export function createErrorRenderer(props: RendererProps = {}): RendererInstance { + console.debug(`[debug] [renderers/error] new RendererInstance`, props); + + const config: ErrorRendererProps = { + title: typeof props.title === "string" ? props.title : "ERROR", + message: + typeof props.message === "string" + ? props.message + : Array.isArray(props.message) + ? props.message.map(String).join("\n") + : "Something went wrong.", + hint: typeof props.hint === "string" ? props.hint : "Press Enter to exit." + }; + + const resources = createLazyResource(async () => { + const font = await loadBitmapFont(); + const heart = await loadImageAsset("IMAGE_SOUL_BLUR_0.png"); + return { font, heart }; + }); + + let blinkMs = 0; + let acknowledged = false; + + const render = async ({ + ctx, + layout, + deltaMs + }: { + ctx: CanvasRenderingContext2D; + layout: Layout; + deltaMs: number; + }) => { + const { font, heart } = await resources.load(); + blinkMs += deltaMs; + const pulse = 0.5 + Math.sin(blinkMs / 300) * 0.5; + + ctx.save(); + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, layout.width, layout.height); + + ctx.translate(layout.x, layout.y); + ctx.imageSmoothingEnabled = false; + const scale = layout.contentScale; + ctx.scale(scale, scale); + + const w = layout.drawWidth / scale; + const h = layout.drawHeight / scale; + const cx = w / 2; + + /* ---------- title ---------- */ + const title = config.title ?? "ERROR"; + const titleScale = 1.2; + const titleWidth = measureTextWidth(title, font, { scale: titleScale }); + const titleY = font.lineHeight * 1.5; + + drawBitmapTextPerGlyph( + ctx, + font, + title, + cx - titleWidth / 2, + titleY, + { + scale: titleScale + } + ); + + /* ---------- body ---------- */ + const bodyScale = 0.7; + const maxWidth = w * 0.9; + const messageText = Array.isArray(config.message) + ? config.message.join(" ") + : config.message ?? ""; + const wrapped = wrapBitmapText( + messageText, + maxWidth / bodyScale, + (t) => measureTextWidth(t, font) + ); + + ctx.save(); + ctx.scale(bodyScale, bodyScale); + const bodyCx = cx / bodyScale; + const bodyStartY = h / bodyScale / 2 - (wrapped.length * font.lineHeight) / 2; + + for (let i = 0; i < wrapped.length; i++) { + const line = wrapped[i] ?? ""; + const lw = measureTextWidth(line, font); + drawBitmapTextPerGlyph( + ctx, + font, + line, + bodyCx - lw / 2, + bodyStartY + i * font.lineHeight + ); + } + + ctx.restore(); + + /* ---------- hint ---------- */ + const hint = config.hint ?? ""; + const hintScale = 0.2; + const hintWidth = measureTextWidth(hint, font, { scale: hintScale }); + const hintY = h - font.lineHeight * 2; + + ctx.globalAlpha = 0.7 + pulse * 0.3; + + drawBitmapTextPerGlyph( + ctx, + font, + hint, + cx - hintWidth / 2, + hintY, + { + scale: hintScale + } + ); + + ctx.restore(); + }; + + return { + id: "errormessage", + label: "Error", + render, + handleKey(key) { + if (!key) return true; + }, + shouldExit() { + return acknowledged; + }, + getResult() { + return { acknowledged, title: config.title, message: config.message }; + }, + unload() { + // nothing to dispose + }, + isLoaded: resources.isLoaded, + getDebugStats() { + return { + loaded: resources.isLoaded(), + acknowledged, + blinkMs: Number(blinkMs.toFixed(1)) + }; + } + }; +} diff --git a/src/renderers/index.ts b/src/renderers/index.ts new file mode 100644 index 0000000..28f18b8 --- /dev/null +++ b/src/renderers/index.ts @@ -0,0 +1,55 @@ +import { createDeviceContactBackgroundRenderer } from "./device_contact"; +import { createErrorRenderer } from "./error"; +import { createRecoveryMenuRenderer } from "./recoverymenu"; +import type { RendererFactory, RendererInstance, RendererProps, RendererRegistry } from "./types"; + +const registry: Record = { + device_contact: { label: "DEVICE CONTACT", factory: createDeviceContactBackgroundRenderer }, + recoverymenu: { label: "Recovery", factory: createRecoveryMenuRenderer }, + errormessage: { label: "Error", factory: createErrorRenderer } +}; + +const aliases: Record = { + "goner-bg": "device_contact", + error: "errormessage" +}; + +const resolveId = (id: string): keyof typeof registry => { + return aliases[id] ?? (id as keyof typeof registry); +}; + +export function createRendererRegistry(options: { defaultId?: string; rendererProps?: Record } = {}): RendererRegistry { + console.debug(`[debug] [renderers] new RendererRegistry`, options); + const rendererPropsById = options.rendererProps ?? {}; + let activeId: keyof typeof registry | null = null; + let activeInstance: RendererInstance | null = null; + + const switchTo = (id: string, propsOverride?: RendererProps): RendererInstance => { + const resolvedId = resolveId(id); + const target = registry[resolvedId]; + if (!target) { + throw new Error(`[debug] [renderer] Renderer "${id}" not found`); + } + if (activeInstance) { + console.warn(`[debug] [renderers] unloading current ${activeInstance.id}`); + activeInstance.unload(); + } + activeId = resolvedId; + activeInstance = target.factory(propsOverride ?? rendererPropsById[resolvedId]); + return activeInstance; + }; + + if (options.defaultId) { + switchTo(options.defaultId); + } + + const getActive = () => { + if (!activeInstance || !activeId) { + throw new Error("No active renderer"); + } + return activeInstance; + }; + const list = () => Object.entries(registry).map(([id, meta]) => ({ id, label: meta.label })); + + return { getActive, switchTo, list }; +} diff --git a/src/renderers/recoverymenu/index.ts b/src/renderers/recoverymenu/index.ts new file mode 100644 index 0000000..90778cd --- /dev/null +++ b/src/renderers/recoverymenu/index.ts @@ -0,0 +1,338 @@ +import type { CanvasRenderingContext2D } from "@napi-rs/canvas"; +import * as sdl from "@kmamal/sdl"; + +import { decodeOggToPCM } from "../../audio/decoder"; +import { AudioLoopPlayer } from "../../audio/player"; +import { loadImageAsset, resolveAssetPath } from "../../renderer/assets"; +import { createLazyResource } from "../../renderer/lazy-resource"; +import type { Layout } from "../../renderer/layout"; +import { + drawBitmapTextPerGlyph, + loadBitmapFont, + measureTextWidth +} from "../../bootsequence/font"; +import type { RendererInstance, RendererProps } from "../types"; + +/* ---------------- */ + +function wrapBitmapText( + text: string, + maxWidth: number, + measure: (t: string) => number +): string[] { + const words = text.split(/\s+/); + const lines: string[] = []; + + let current = ""; + + for (const w of words) { + const next = current ? `${current} ${w}` : w; + if (measure(next) <= maxWidth) { + current = next; + } else { + if (current) lines.push(current); + current = w; + } + } + + if (current) lines.push(current); + return lines; +} + +/* ---------------- */ + +type AudioSamplePlayer = { + play(): void; + dispose(): void; +}; + +type MenuResources = { + font: Awaited>; + heart: Awaited>; + drone: AudioLoopPlayer; + sndMove: AudioSamplePlayer; + sndSelect: AudioSamplePlayer; +}; + +type RecoveryMenuProps = { + question?: string; + yesLabel?: string; + noLabel?: string; +}; + +/* ---------------- */ + +async function loadSample(alwaysTry: string[]): Promise { + for (const rel of alwaysTry) { + const path = resolveAssetPath(rel); + try { + const decoded = await decodeOggToPCM(path); + const playbackDevice = + sdl.audio.devices.find((d) => d.type === "playback") ?? + { type: "playback" as const }; + + const device = sdl.audio.openDevice(playbackDevice as any, { + format: decoded.format, + channels: decoded.channels as 1 | 2 | 4 | 6, + frequency: decoded.sampleRate + }); + + return { + play() { + device.clearQueue(); + device.enqueue(decoded.pcm); + device.play(false); + }, + dispose() { + device.clearQueue(); + device.close(); + } + }; + } catch { } + } + + throw new Error("sample load failed"); +} + +/* ---------------- */ + +export function createRecoveryMenuRenderer( + props: RendererProps = {} +): RendererInstance { + console.debug(`[debug] [renderers/recoverymenu] new RendererInstance`, props); + + const config: RecoveryMenuProps = { + question: + typeof props.question === "string" + ? props.question + : "????????/?", + yesLabel: typeof props.yesLabel === "string" ? props.yesLabel : "Yes", + noLabel: typeof props.noLabel === "string" ? props.noLabel : "No" + }; + + const resources = createLazyResource(async () => { + const font = await loadBitmapFont(); + const heart = await loadImageAsset("IMAGE_SOUL_BLUR_0.png"); + const drone = await AudioLoopPlayer.fromAsset("AUDIO_DRONE.ogg"); + const sndMove = await loadSample(["snd_menumove.wav", "snd_menumode.wav"]); + const sndSelect = await loadSample(["snd_select.wav"]); + return { font, heart, drone, sndMove, sndSelect }; + }); + + let blinkMs = 0; + let selection: "yes" | "no" = "yes"; + let confirmed = false; + + const render = async ({ + ctx, + layout, + deltaMs + }: { + ctx: CanvasRenderingContext2D; + layout: Layout; + deltaMs: number; + }) => { + const { font, heart, drone } = await resources.load(); + if (!drone.playing) drone.start(); + + blinkMs += deltaMs; + const pulse = 0.5 + Math.sin(blinkMs / 300) * 0.5; + + ctx.save(); + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, layout.width, layout.height); + + ctx.translate(layout.x, layout.y); + ctx.imageSmoothingEnabled = false; + + const scale = layout.contentScale; + ctx.scale(scale, scale); + + const w = layout.drawWidth / scale; + const h = layout.drawHeight / scale; + const cx = w / 2; + + const question = config.question ?? ""; + const yes = config.yesLabel ?? "Yes"; + const no = config.noLabel ?? "No"; + + /* ---------- question ---------- */ + + const QUESTION_SCALE = 0.62; + const QUESTION_MAX_WIDTH = w * 0.9; + + ctx.save(); + ctx.scale(QUESTION_SCALE, QUESTION_SCALE); + + const scx = cx / QUESTION_SCALE; + const sh = h / QUESTION_SCALE; + + const wrapped = wrapBitmapText( + question, + QUESTION_MAX_WIDTH / QUESTION_SCALE, + (t) => measureTextWidth(t, font) + ); + + const qStartY = sh - font.lineHeight * 5; + + for (let i = 0; i < wrapped.length; i++) { + const line = wrapped[i]; + const lw = measureTextWidth(line ?? "", font); + drawBitmapTextPerGlyph( + ctx, + font, + line ?? "", + scx - lw / 2, + (qStartY + i * font.lineHeight) - 50 + ); + } + + ctx.restore(); + + /* ---------- options ---------- */ + + const ANSWER_SCALE = 0.8; + const spacing = font.lineHeight * ANSWER_SCALE * 1.2; + const yesW = measureTextWidth(yes, font, { scale: ANSWER_SCALE }); + const noW = measureTextWidth(no, font, { scale: ANSWER_SCALE }); + + const yesY = h - font.lineHeight * 3; + const noY = yesY + spacing; + const yesX = cx - yesW / 2; + const noX = cx - noW / 2; + + drawBitmapTextPerGlyph( + ctx, + font, + yes, + yesX, + yesY, + { + scale: ANSWER_SCALE + } + ); + + drawBitmapTextPerGlyph( + ctx, + font, + no, + noX, + noY, + { + scale: ANSWER_SCALE + } + ); + + const heartSize = font.lineHeight * 0.75; + const selW = selection === "yes" ? yesW : noW; + const selY = selection === "yes" ? yesY : noY; + const heartX = cx - selW / 2 - heartSize - 6; + const heartY = selY + (font.lineHeight * ANSWER_SCALE - heartSize) / 2; + + ctx.globalAlpha = 0.7 + pulse * 0.3; + (ctx as any).drawImage( + heart as any, + 0, + 0, + heart.width, + heart.height, + heartX, + heartY, + heartSize, + heartSize + ); + + ctx.restore(); + }; + + const onSelect = (dir: "yes" | "no") => { + const changed = dir !== selection; + selection = dir; + void resources.load().then((r) => { + (changed ? r.sndMove : r.sndSelect).play(); + }); + }; + + return { + id: "recoverymenu", + label: "Recovery Menu", + render, + handleKey(key) { + if (!key) return true; + + const k = key.toLowerCase(); + + if ( + k === "arrowup" || + k === "up" || + k === "w" || + k === "k" + ) { + onSelect(selection === "yes" ? "no" : "yes"); + return true; + } + + if ( + k === "arrowdown" || + k === "down" || + k === "s" || + k === "j" + ) { + onSelect(selection === "yes" ? "no" : "yes"); + return true; + } + + if (k === "arrowleft" || k === "left") { + onSelect("yes"); + return true; + } + + if (k === "arrowright" || k === "right") { + onSelect("no"); + return true; + } + + if ( + k === "enter" || + k === "return" || + k === " " || + k === "space" || + k === "z" + ) { + confirmed = true; + onSelect(selection); + return true; + } + + return true; + }, + shouldExit() { + return confirmed; + }, + getResult() { + return selection; + }, + unload() { + if (!resources.isLoaded()) return; + void resources.load().then((r) => { + r.drone.stop(); + r.sndMove.dispose(); + r.sndSelect.dispose(); + }); + }, + isLoaded: resources.isLoaded, + getDebugStats() { + return { + loaded: resources.isLoaded(), + selection, + blinkMs: Number(blinkMs.toFixed(1)) + }; + }, + getDebugHudStats() { + return { + selection, + dronePlaying: resources.isLoaded() + }; + } + }; +} diff --git a/src/renderers/types.ts b/src/renderers/types.ts new file mode 100644 index 0000000..5252407 --- /dev/null +++ b/src/renderers/types.ts @@ -0,0 +1,35 @@ +import type { CanvasRenderingContext2D } from "@napi-rs/canvas"; + +import type { Layout } from "../renderer/layout"; + +export type RendererProps = Record; + +export type RendererRenderArgs = { + ctx: CanvasRenderingContext2D; + layout: Layout; + deltaMs: number; +}; + +export type RendererDebugHud = (ctx: CanvasRenderingContext2D, layout: Layout) => void; + +export type RendererInstance = { + id: string; + label: string; + render: (args: RendererRenderArgs) => Promise | void; + handleKey?: (key: string | null) => boolean | void; + unload: () => void; + isLoaded: () => boolean; + getDebugStats?: () => Record; + getDebugHudStats?: () => Record; + + shouldExit?: () => boolean; + getResult?: () => unknown; +}; + +export type RendererFactory = (props?: RendererProps) => RendererInstance; + +export type RendererRegistry = { + getActive: () => RendererInstance; + switchTo: (id: string, propsOverride?: RendererProps) => RendererInstance; + list: () => Array<{ id: string; label: string }>; +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2df0b9f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,6 @@ +export type BootsequenceAnswers = { + char: string; + desktop: string; + color: string; + gift: string; +}; diff --git a/src/ui/app.ts b/src/ui/app.ts new file mode 100644 index 0000000..e802845 --- /dev/null +++ b/src/ui/app.ts @@ -0,0 +1,574 @@ +import { createWriteStream, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { type CanvasRenderingContext2D } from "@napi-rs/canvas"; + +import { createBootSequenceUI } from "../bootsequence/questions"; +import { GREETD_SOCKET, handoffToGreetd } from "../desktop"; +import type { BootsequenceAnswers } from "../types"; +import { loadImageAsset } from "../renderer/assets"; +import { parseCli } from "../renderer/cli"; +import { createDebugHud } from "../renderer/debug-hud"; +import { createFpsCounter } from "../renderer/fps"; +import { createLayoutCalculator } from "../renderer/layout"; +import { createRenderer } from "../renderer/index"; +import { SDLWindow } from "../renderer/window"; +import { createRendererRegistry } from "../renderers"; +import type { RendererInstance, RendererProps } from "../renderers/types"; + +const BUNVERS = `Bun ${Bun.version} ${process.platform} ${process.arch}`; +const DEFAULT_DEBUG_LOG_FILE = "/tmp/device_contact.debug.log"; +const LOG_LIFETIME_MS = 8_000; +const LOG_FADE_MS = 3_000; +const LOG_MAX_LINES = 64; +const debugLogEntries: DebugLogEntry[] = []; + +export async function runDeviceContactUI(argv: string[] = process.argv) { + const cli = parseCli(argv); + const isDev = process.env.NODE_ENV === "development"; + const debugOptions = { + showGlobal: isDev || cli.debugGlobalHud || process.env.DEBUG_UI === "true", + showRenderer: isDev || cli.debugRendererHud || process.env.DEBUG_RENDERER === "true" || cli.debugGlobalHud, + showCustom: isDev || cli.debugGlobalHud || process.env.DEBUG_UI === "true" + }; + const debugLoggingEnabled = debugHudOptionsEnabled(debugOptions); + const debugLogFile = + cli.debugLogFile ?? + process.env.DEBUG_LOG_FILE ?? + (debugLoggingEnabled ? DEFAULT_DEBUG_LOG_FILE : undefined); + const restoreDebug = + debugLoggingEnabled || debugLogFile + ? hookDebugLogs({ filePath: debugLogFile }) + : () => { }; + + console.debug("[debug] ESTABLISHING CONNECTION"); + console.debug("[debug]", BUNVERS); + + const isCrashRecovery = !!(cli.crashRecoverySession ?? process.env.CRASH_RECOVERY_SESSION); + const forcedErrorScreen = Boolean(cli.errorScreenRequested); + const requestedRenderer = cli.rendererId ?? "device_contact"; + const defaultRendererId = forcedErrorScreen + ? "errormessage" + : isCrashRecovery + ? "recoverymenu" + : requestedRenderer; + const shouldRunBootSequence = defaultRendererId !== "recoverymenu" && defaultRendererId !== "errormessage"; + const isTTY = process.env.SDL_VIDEODRIVER === "kmsdrm"; + const isCage = process.env.IS_CAGE === "true"; + + const windowOptions = { + // DO NOT CHANGE TITLE + title: "DEVICE CONTACT (DELTARUNE Chapter 1)", + width: 1920, + height: 1080, + fullscreen: true + }; + + const window = new SDLWindow(windowOptions); + window.Window.setFullscreen(true); + + // will segfault bun if ran in tty + if (!isTTY && !isCage) { + window.on("keyUp", (e) => { + if (e.key === "f11") { + window.Window.setFullscreen(!window.Window.fullscreen); + } + }); + } + + const icon = await loadImageAsset("icon.png"); + window.setIconFromImage(icon); + window.Window.setResizable(true); + window.Window.setAccelerated(true); + + if (isTTY) { + console.debug("[debug] KMSDRM detected, What the fuck?? Deltarune in the TTY?"); + } + if (isCage) { + console.debug("[debug] Cage detected, are you trying to make a login manager or something?"); + setInterval(() => { + try { + if (!window.Window.fullscreen) window.Window.setFullscreen(true); + } catch { } + }, 100) + } + + // Base dim for UI/layout (matches the original background logical size). + const baseWidth = 160; + const baseHeight = 120; + const viewWidth = 1280; + const viewHeight = 960; + const uiScale = 0.6; + const crashRecoverySession = cli.crashRecoverySession ?? process.env.CRASH_RECOVERY_SESSION; + + const renderer = createRenderer({ window }); + renderer.ctx.imageSmoothingEnabled = false; + + const rendererPropsById: Record = { + recoverymenu: { + question: crashRecoverySession + ? `${crashRecoverySession} crashed. Do you want to restart it?` + : "?????", + yesLabel: "Yes", + noLabel: "No", + session: crashRecoverySession + }, + errormessage: { + title: cli.errorScreenTitle ?? process.env.ERROR_TITLE ?? "ERROR", + message: cli.errorScreenMessage ?? process.env.ERROR_MESSAGE ?? "An unexpected error occurred.", + hint: cli.errorScreenHint ?? process.env.ERROR_HINT ?? "Switch between VT's with CTRL+ALT+F[0-9]." + } + }; + + const rendererRegistry = createRendererRegistry({ rendererProps: rendererPropsById }); + let activeRenderer: RendererInstance | null = null; + let rendererExit: { id: string; result: unknown } | null = null; + let fatalErrorProps: RendererProps | null = null; + const requireActiveRenderer = () => { + if (!activeRenderer) { + throw new Error("Active renderer not initialized"); + } + return activeRenderer; + }; + const logSelectedRenderer = () => { + const current = requireActiveRenderer(); + console.debug("[debug] renderer selected", current.id); + }; + if (crashRecoverySession) { + console.debug("[debug] crash recovery mode", crashRecoverySession); + } + if (debugLogFile) { + console.debug("[debug] writing debug log to", debugLogFile); + } + + const debugHud = createDebugHud(debugOptions); + const globalFps = createFpsCounter(); + const rendererFps = createFpsCounter(); + + const getLayout = createLayoutCalculator({ + baseWidth, + baseHeight, + viewWidth, + viewHeight + }); + + let bootUI = shouldRunBootSequence + ? await createBootSequenceUI(baseWidth, baseHeight) + : null; + let bootAnswers: BootsequenceAnswers | null = null; + let contactComplete = false; + + const rendererIds = Array.from(new Set(rendererRegistry.list().map((r) => r.id))); + let lastFrameMs = Date.now(); + const requestRendererExit = () => { + const current = requireActiveRenderer(); + if (rendererExit) return; + rendererExit = { + id: current.id, + result: current.getResult ? current.getResult() : undefined + }; + renderer.requestStop(); + }; + const switchRenderer = (id: string) => { + if (activeRenderer && id === activeRenderer.id) return activeRenderer; + rendererExit = null; + const next = rendererRegistry.switchTo(id, rendererPropsById[id]); + renderer.ctx.clearRect(0, 0, renderer.canvas.width, renderer.canvas.height); + activeRenderer = next; + lastFrameMs = Date.now(); + return activeRenderer; + }; + activeRenderer = switchRenderer(defaultRendererId); + logSelectedRenderer(); + + if (cli.helpRequested) { + console.log(`Usage: bun run src/ui/app.ts [options] +Options: + --renderer Select renderer by id (default: ${defaultRendererId}) + --debug Enable all debug HUD panels + --debug-global Enable global debug HUD + --debug-renderer Enable renderer debug HUD + --error-screen [msg] Start on error screen (optional message) + --error-title Set error screen title + --error-hint Set error screen hint + --debug-log-file Write debug logs to file (default: ${DEFAULT_DEBUG_LOG_FILE}) + --crash-recovery [id] Start in crash recovery mode (optional session id) + --help, -h Show this help message`); + process.exit(0); + } + + window.on("keyDown", (e) => { + const currentRenderer = requireActiveRenderer(); + currentRenderer.handleKey?.(e.key ?? null); + bootUI?.handleKey({ + key: e.key ?? null, + scancode: e.scancode ?? 0, + ctrl: e.ctrl ?? 0, + shift: e.shift ?? 0, + alt: e.alt ?? 0, + super: e.super ?? 0 + }); + }); + + const drawFrame = async ( + _ctx: CanvasRenderingContext2D, + size: { width: number; height: number } + ): Promise => { + const currentRenderer = requireActiveRenderer(); + const { ctx } = renderer; + ctx.imageSmoothingEnabled = false; + const layout = getLayout(size); + const { + width, + height, + contentScale, + x, + y + } = layout; + const now = Date.now(); + const deltaMs = now - lastFrameMs; + lastFrameMs = now; + globalFps.tick(now); + + ctx.clearRect(0, 0, width, height); + + await currentRenderer.render({ ctx, deltaMs, layout }); + rendererFps.tick(now); + if (currentRenderer.shouldExit?.()) { + if (currentRenderer.id === "recoverymenu") { + activeRenderer = switchRenderer("device_contact"); + logSelectedRenderer(); + if (!bootUI) { + bootUI = await createBootSequenceUI(baseWidth, baseHeight); + } + } else { + requestRendererExit(); + } + } + + // Text/UI layer: above BG/overlay, below FPS. + ctx.save(); + const uiOffsetX = (contentScale - contentScale * uiScale) * baseWidth * 0.5; + const uiOffsetY = (contentScale - contentScale * uiScale) * baseHeight * 0.5; + ctx.translate(x + uiOffsetX, y + uiOffsetY); + ctx.scale(contentScale * uiScale, contentScale * uiScale); + if (bootUI) { + bootUI.update(deltaMs); + bootUI.render(ctx); + if (bootUI.isFinished()) { + if (!bootAnswers) { + bootAnswers = bootUI.getAnswers(); + } + contactComplete = true; + renderer.requestStop(); + window.Window.destroy(); + } + } + ctx.restore(); + + debugHud.draw(ctx, layout, { + global: [ + `${globalFps.value.toFixed(2)} FPS`, + `${window.Window.display.name ? (process.env.MONITOR_SN ? window.Window.display.name.replaceAll((process.env.MONITOR_SN || "") + " ", "") : window.Window.display.name) : "unknown"} ${window.Window.display.frequency}hz [${process.env.SDL_VIDEODRIVER ?? "sdl2"}]`, + `activeRenderer: ${currentRenderer.id}`, + `crashRecoverySession: ${crashRecoverySession ?? "none"}`, + `${BUNVERS}` + ], + renderer: { + id: currentRenderer.id, + label: currentRenderer.label, + stats: { + ...(currentRenderer.getDebugStats ? currentRenderer.getDebugStats() : {}), + ...(currentRenderer.getDebugHudStats ? currentRenderer.getDebugHudStats() : {}) + }, + fps: rendererFps.value + }, + custom: { + greetdSocket: GREETD_SOCKET, + tty: isTTY, + cage: isCage + } + }); + if (debugLoggingEnabled) { + drawDebugLogs(ctx, layout, now); + } + }; + + console.debug("[debug] reached main"); + try { + await renderer.run(drawFrame); + if (rendererExit) { + console.debug("[debug] renderer exit requested", rendererExit); + } + } finally { + requireActiveRenderer().unload(); + } + + if (contactComplete) { + const desktopHintRaw = + (bootAnswers as BootsequenceAnswers | null | undefined)?.desktop ?? + crashRecoverySession ?? + process.env.DESKTOP_SESSION_FRIENDLY_NAME ?? + process.env.XDG_CURRENT_DESKTOP; + const desktopHint = typeof desktopHintRaw === "string" ? desktopHintRaw : undefined; + try { + await handoffToGreetd(desktopHint); + } catch (error) { + console.error("[ui/app] greetd handoff failed\n", error); + console.error("[ui/app] Press CTRL+ALT+F[0-9] to switch to a different VT"); + if (process.env.NODE_ENV !== "development") { + process.exit(1); + } + } + } + + if (fatalErrorProps) { + return + } + + restoreDebug(); +} + +if (import.meta.main) { + await runDeviceContactUI(); +} + +type DebugLogEntry = { + message: string; + ts: number; +}; + +function debugHudOptionsEnabled(options: { showGlobal: boolean; showRenderer: boolean; showCustom?: boolean }) { + return options.showGlobal || options.showRenderer || Boolean(options.showCustom); +} + +function hookDebugLogs(options?: { filePath?: string }): () => void { + const originalDebug = console.debug; + let logStream = createDebugLogStream(options?.filePath, originalDebug); + if (logStream) { + logStream.on("error", (error) => { + originalDebug("[debug] debug log stream error", error); + logStream?.destroy(); + logStream = null; + }); + } + + console.debug = (...args: unknown[]) => { + const message = formatDebugMessage(args); + const now = Date.now(); + debugLogEntries.push({ message, ts: now }); + pruneOldLogs(now); + if (debugLogEntries.length > 100) { + debugLogEntries.splice(0, debugLogEntries.length - 100); + } + if (logStream) { + try { + logStream.write(`[${new Date(now).toISOString()}] ${message}\n`); + } catch (error) { + originalDebug("[debug] failed to write debug log to file", error); + logStream = null; + } + } + originalDebug(...args); + }; + return () => { + console.debug = originalDebug; + debugLogEntries.length = 0; + if (logStream) { + logStream.end(); + logStream = null; + } + }; +} + +function formatDebugMessage(args: unknown[]): string { + return args + .map((arg) => { + if (typeof arg === "string") return arg; + if (arg instanceof Error) { + return `${arg.name}: ${arg.message}`; + } + try { + return JSON.stringify(arg, (_k, v) => { + if (typeof v === "bigint") return v.toString(); + return v; + }); + } catch { + return String(arg); + } + }) + .join(" "); +} + +function drawDebugLogs(ctx: CanvasRenderingContext2D, layout: { width: number; height: number }, now: number) { + const padding = 8; + const lineHeight = 18; + pruneOldLogs(now); + const visible = debugLogEntries.slice(-LOG_MAX_LINES); + if (visible.length === 0) return; + + ctx.save(); + ctx.font = "14px \"JetBrains Mono\", monospace"; + ctx.textBaseline = "bottom"; + + let cursorY = layout.height - padding; + const cursorX = padding; + + for (const entry of [...visible].reverse()) { + const age = now - entry.ts; + const fadeStart = LOG_LIFETIME_MS - LOG_FADE_MS; + const alpha = age <= fadeStart ? 1 : Math.max(0, (LOG_LIFETIME_MS - age) / LOG_FADE_MS); + const text = entry.message; + + ctx.globalAlpha = alpha; + ctx.fillStyle = "#66ccff"; + ctx.fillText(text, cursorX, cursorY); + cursorY -= lineHeight; + } + + ctx.restore(); +} + +function pruneOldLogs(now: number) { + for (let i = debugLogEntries.length - 1; i >= 0; i--) { + if (now - debugLogEntries[i]!.ts >= LOG_LIFETIME_MS) { + debugLogEntries.splice(i, 1); + } + } +} + +async function runErrorScreen( + props: RendererProps, + options?: { debugOptions?: { showGlobal: boolean; showRenderer: boolean; showCustom?: boolean } } +) { + const isTTY = process.env.SDL_VIDEODRIVER === "kmsdrm"; + const isCage = process.env.IS_CAGE === "true"; + const windowOptions = { + // DO NOT CHANGE TITLE + title: "DEVICE CONTACT (DELTARUNE Chapter 1)", + width: 1920, + height: 1080, + fullscreen: true + }; + const window = new SDLWindow(windowOptions); + window.Window.setFullscreen(true); + if (!isTTY && !isCage) { + window.on("keyUp", (e) => { + if (e.key === "f11") { + window.Window.setFullscreen(!window.Window.fullscreen); + } + }); + } + const icon = await loadImageAsset("icon.png"); + window.setIconFromImage(icon); + window.Window.setResizable(true); + window.Window.setAccelerated(true); + + const renderer = createRenderer({ window }); + const rendererRegistry = createRendererRegistry({ rendererProps: { errormessage: props } }); + let activeRenderer: RendererInstance | null = rendererRegistry.switchTo("errormessage"); + let rendererExit: { id: string; result: unknown } | null = null; + const requestRendererExit = () => { + const current = activeRenderer; + if (!current || rendererExit) return; + rendererExit = { + id: current.id, + result: current.getResult ? current.getResult() : undefined + }; + renderer.requestStop(); + }; + window.on("keyDown", (e) => { + activeRenderer?.handleKey?.(e.key ?? null); + }); + + const baseWidth = 160; + const baseHeight = 120; + const viewWidth = 1280; + const viewHeight = 960; + const uiScale = 0.6; + const getLayout = createLayoutCalculator({ + baseWidth, + baseHeight, + viewWidth, + viewHeight + }); + const debugHud = createDebugHud( + options?.debugOptions ?? { + showGlobal: true, + showRenderer: true, + showCustom: true + } + ); + const globalFps = createFpsCounter(); + const rendererFps = createFpsCounter(); + let lastFrameMs = Date.now(); + + const drawFrame = async (_ctx: CanvasRenderingContext2D, size: { width: number; height: number }) => { + const currentRenderer = activeRenderer!; + const { ctx } = renderer; + ctx.imageSmoothingEnabled = false; + const layout = getLayout(size); + const { width, height, contentScale, x, y } = layout; + const now = Date.now(); + const deltaMs = now - lastFrameMs; + lastFrameMs = now; + globalFps.tick(now); + + ctx.clearRect(0, 0, width, height); + + await currentRenderer.render({ ctx, deltaMs, layout }); + rendererFps.tick(now); + if (currentRenderer.shouldExit?.()) { + requestRendererExit(); + } + + ctx.save(); + const uiOffsetX = (contentScale - contentScale * uiScale) * baseWidth * 0.5; + const uiOffsetY = (contentScale - contentScale * uiScale) * baseHeight * 0.5; + ctx.translate(x + uiOffsetX, y + uiOffsetY); + ctx.scale(contentScale * uiScale, contentScale * uiScale); + ctx.restore(); + + debugHud.draw(ctx, layout, { + global: [ + `${globalFps.value.toFixed(2)} FPS`, + `${window.Window.display.name ? (process.env.MONITOR_SN ? window.Window.display.name.replaceAll((process.env.MONITOR_SN || "") + " ", "") : window.Window.display.name) : "unknown"} ${window.Window.display.frequency}hz [${process.env.SDL_VIDEODRIVER ?? "sdl2"}]`, + `activeRenderer: ${currentRenderer.id}`, + `${BUNVERS}` + ], + renderer: { + id: currentRenderer.id, + label: currentRenderer.label, + stats: { + ...(currentRenderer.getDebugStats ? currentRenderer.getDebugStats() : {}), + ...(currentRenderer.getDebugHudStats ? currentRenderer.getDebugHudStats() : {}) + }, + fps: rendererFps.value + }, + custom: { + greetdSocket: GREETD_SOCKET, + tty: isTTY, + cage: isCage + } + }); + if (debugHudOptionsEnabled(options?.debugOptions ?? { showGlobal: true, showRenderer: true, showCustom: true })) { + drawDebugLogs(ctx, layout, now); + } + }; + + await renderer.run(drawFrame); + if (rendererExit) { + console.debug("[debug] error renderer exit requested", rendererExit); + } + activeRenderer?.unload(); +} + +function createDebugLogStream(filePath: string | undefined, debug: typeof console.debug) { + if (!filePath) return null; + try { + mkdirSync(dirname(filePath), { recursive: true }); + return createWriteStream(filePath, { flags: "a" }); + } catch (error) { + debug("[debug] failed to open debug log file", { filePath, error }); + return null; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bcc9986 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/tty.sh b/tty.sh new file mode 100755 index 0000000..84dc5ea --- /dev/null +++ b/tty.sh @@ -0,0 +1,6 @@ +#!/bin/bash +export SDL_VIDEODRIVER=kmsdrm +export SDL_KMSDRM_HWCURSOR=0 +export SDL_HINT_RENDER_SCALE_QUALITY=0 +export NODE_ENV=development +bun run src/ui/app.ts