From 9a720313c7e83ce6f6b9d2a5e0be5d9afb89a3c6 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:32:05 +0200 Subject: [PATCH] Prepare Kanto Gear 1.0.0 public test --- .gitignore | 24 + LICENSE | 21 + README.md | 143 ++ kanto-gear-map.png | Bin 0 -> 12978 bytes lower-summary-current.png | Bin 0 -> 11361 bytes mod/kanto_gear/main.lua | 2530 ++++++++++++++++++++++++++ mod/kanto_gear/manifest.json | 17 + mod/kanto_gear/tests/compat_test.lua | 12 + top-current.png | Bin 0 -> 651999 bytes 9 files changed, 2747 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 kanto-gear-map.png create mode 100644 lower-summary-current.png create mode 100644 mod/kanto_gear/main.lua create mode 100644 mod/kanto_gear/manifest.json create mode 100644 mod/kanto_gear/tests/compat_test.lua create mode 100644 top-current.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4575a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +/.codex-temp-dualscreen/ +/backups/ +/diagnostics/ +/dist/ +/docs/ +/poc/ +/scripts/ +/upstream/ + +/kanto-gear-lower-*.png +/top-nonvoxel-fight.png + +*.apk +*.jks +*.keystore +*.love +*.p12 +*.sav +*.tar +*.tmp +*.zip + +!.github/ +!mod/kanto_gear/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c8206c3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AverageConsumer + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fca3b90 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# Kanto Gear + +Kanto Gear turns a supported dual-screen Android handheld into a Gen 1 +companion: the game stays on the main display while maps, party information, +battles, menus and contextual touch controls move to the second display. + +> [!IMPORTANT] +> This is an **unofficial public test build**, not an official Gen1Recomp +> release. It is currently tested on an **AYN Thor running Android 13**. +> Other Android dual-display devices, docks and HDMI setups need community +> testing. + + + +
+
+
+
AN=~@u8Q*n%Y<;)8Yre94!_ Dr>DAyEZ_gm5*7~-6z`lio|5P;2$Zxtf!X)4F{mM-^x;o_~rsV0d$SZI9 z)2~Gyec;8ZX8JC&9QWqoa&Sp|@3&vRk(YKA=UcH6!)PrWn|{A})3q5DF1vx-rlo!M z#kLvXCu7F$?;+^V&=(s&-ImTA?EEylC*Zpy4|}s@r4QF_eDVQrf5G+Wisae*W_Wc> z?^#;2GXMHdgCTy$uD>Tp4*r~cCTQh${~0N!z9MJoEFc%^`f>cRFY*s4V9B*9;Ie-G z&Nsj%D@%xk3z~B9UF0%#>H_34ZQ2^-GGj&}a(VAP5Lckgpa1VXxKrcXwb`nt)Aad> z=_?M$P)+9zhvg}6zP5ja9TcH!2YDWNJpHV|(o?{9dFT4v|K$!v{L)CZTIhFU1yu9C z?`3j|h^;A)6T2%GL6Ao0sJ(PWpr-F)z=K%0L#q)iCz`?qwZ!qL$9GZKo(4}D 16T`31D`o|c9oqfSmg>SQ=ZyLrze zFv>#arjP3*EpX2|g`BJPq Rqb{#AZp -z`zv$x?@WA!<+&c}trgmEhEmN~G=X zj~0^oTbj-{@V{fJYRD=@+6_rHD((Q9?cCU0n&{+yF|w-HB*QdKRxh}aS3$bUNbUDF zI8_?R*fMP-d+=~k8anO)Nt079T|+;!Wm#nEm`-nfm%CF{U2OF| hq++al>$ zF12^0+*0!oZE1Q`g0aX0+w2lis|CSq#6Btc;pv_rH@-_olPl(-UF7xGZems#oPbV3 z&!x?!KuitkDo=mR`)D*5cx)dxMJM8`CgCi|CXJE$@U%Jxz7$5d;eeV|A^kId+xw~% zJ^E;P&!nOTEqs9BJE%IPaabldd)3`|;QxO5RFr8V-lrRCpr^`HHCLtWl&S9|pDhHb z5*H;?IP8>^E=9bqFo>}kUcM$Kg{BNN+P@pBn)%u- 8m(i~jw3;BEaP`$TJ!mEypV^| zav`rn ~I;+a5ywP_o87M)OG&3jcr(vVT2+UY9q&7B&!_B9W})+%Sp4S`H=A2J)FGyXt%x= z8D z>x1l&M0Xr#7^LJKGFrbf#z{07!xSy3wZ|k36YeO5~E~q z=haR34~2HF;z{quBFUAJ!Pv4alg|!lvkUD)=S9FubUKnH>F&Vf^~;}$+|Uqxo>58~ zeC$C%WTIp`P;FYb26>NEN!uHP^?dW@ooJb;%YF}s@ABLARhtmzttsZa)Xjx4M7_PB z0I_$_{bHh1nZw!~lmrK9E+Off3$lHaWWPy0d_Y0e3_4Q%4bkxC!D;nPD6=18yImZ~ zUq2m@@>nUBwhz!j1zHFqF*s(LOh=Rz?+OGyaU)EP=|dMsC=2tK=ei^^lwv;1=BP06}u^$X1+maoxJ_990q2J|@v6;v(_MmU4fLb*|tt`zeS3MuH$q!oq%x0g!+F*{RK zB+Nli)a~QH;VcNc-GakdKtn8vfn6~IEAz>Il-UhNgAZ-(*fEw6i(Yp_7+@(Wt~RC7 zZ!)$+dd3!e-(nu&>FG?k9zAyg2W 54H#^2 CngTuQlZ!_3qxQbF$zbMR_DNGOW7PQ(i8&Ot_g<5&CK$kCEkHa5z!Xmg2 zXd~Ts#o$4MmK#@{VkZ=wNVR56f@#GiHlnqSEPl7l)M9D-`B-i`- )6ZBbOm!^}RPS!fLsrt9|-srMB_4-Ohyp3h 1VM_Rm?ukFajHSkIxJ_%4WM`E9Zc=^+BbxQf?!SEbIh?rY=m=8SbzHylL6!w VWNUb48AM!`4LCp zYSWr-q+@F?1-rx1t8CQ|VWMDyLW%TE!uJ#pwbq$zraxUXtpa6*y7K^s3{IF%`wTd~ z0%6lG-_8MTeSC5}e(1(xb3mK-F;KuQzU0ENdfCZc46Vu)b1(@d&00r}$xGo*DFr5U zVHd=?_}tAA2U7;e@D#yvn@Q qD(hnVpkZ&9K*z7Yv8( zUAfogL<4Ccl7fNvYe)tbd(52W&%dc;V^}eh=)t=}DV1G{v=MzrpmV&<1=WK-Sj2+T zrC_{|PGV?VYGKs4u&qaK6rD&tHxx$5-en_^K1YQCDdVIORd?O~Vm^zTuB4Z0)p|d7 zB#6CLVSccWL;(Yuk%7%4+Bxro2a2Im!``yY)tG``ZPPY?mSh#nN)F_AE9>LKb2rc< zWV+tPJsH^nZPg97E#jS%Pf3*ys6g|4AAjcAS&w8@l2e2%(DKu0C1P7O(R~P8rw9vG zkcw!^d_D`wI&KxqtT1XH%Nvvf1YM|gA+ii5KNVptX8j V`}7Y8pIk;3uk1>e{~m@i6eEsPRRdJ?1{mw)b{S{TwMsj;1t$N{ ziNlaU?^%!ltEvs2wWb{~ViG{#h}nCS s6@RX?PznY z Fnbo%*^j5TKkQr%_^}k zVJdbXx=rzkJusmyFqvqbNuWkO&nOa&NLJF c-&PMpAZZn)n_|^pWsNo!O|} zgL0x9$Sa_JMWI) Gq?jIqv0gj~J zQn!&bYR4C5Y2p}$OxPmtN!zdK$<|~%jen=BCzc5Z6Y|yls8< 0B4oU^jca?ngG9MusZR~Lac+Wd@bw{*ZB+lN3m%HQxncF% zRO$T1E*%Fa`rj`n4n8MNB_ jwm^6HSW9%Dvs zOd0Qtex5 &vH}s4EL`~4$tlWcxWld%)4Ett1K~`9piiYP9I-BOw&=Pb2TB# z>zXms*h2Swg ~1&yL|IZVEH@LmzC1b6eSoZOc^R;vdt_ z78c`|HMcBKU35R8n%C2UkCNpzkQ)X=1oQ%3eV}lBPo^5D6nk2H+cCk%kvP~9<6r5+ z^{;gBPjq_-d%re{fPY)#p>c^HvII0%FAbKFZkI_Pq?HyVoXyUL#=0IWN!~Pqjw!_N zc#UvPyy~E_A4>U#ofzX0&++Wb@NrrW2H2JZclop!yxiz{QulLd`H9nItBFR6y2i=z zVa$hp)s^)Hoa7x`4~5*bZ&f}ICkPb@TwJcbkJ^d&+Ch&BI=ncZ zXgOO;!AL0g{{w+=V>tdeGv1^rEl?EqYAs2}*A~U0jpwQ+{ex8LI!VPK){yQK?Ifqv zpZLT-5Cj&WlcN$H;63`gLfILnScJUKhTg-NYyS$ESufxGN8t3VFx}oCEE#^sbIO6r zuzA>k6JH|cpZvt1`(v5GNb(YcEX-@biIW+|YOjC3r?%{6Q7 ~{Abrh!SAz3A`x zV%5Fb) T9I;bL+JCrtI9t z87hsCC&HUV6kP7bv9lc2icMRMmVlK_V}qFi>Y8~j2@B$U`T#6#pqNCELTJ=+9NoU8 zh|Y!c8VVE0-~iyiv*rO6z*?pUhuK_O=TmWupuvt(D{gCt)9ZYs7%69^+-ywxnlYUC z;o|sUwO4uKsf#k!xKq-nA{=5j;Lkv#|6v?|TurMNB+XWpqx3@8v4FOE+~zy11gd`T zdFqn*`C?EuvUf3tCt|tS 0h`T>dN>yyd`$WWkbHz}=QJbEA1uu-u zz32r_6`Ap=M}~d2ilmg h&Lh2v}@ z^=Vm{f;a(u8%oAE{F0ASIJ3=GU;xOAwd6{yk`mc_)i-(0ZEZY<$&amg^Pl1!T92cY zed00?(Ehn0zKsHahH3sbHUCgDv}Nc&F^#+speRDtTSKlr{qGjx->LtMNg2$P=_m`< zipvU*k^*u3zSC=7ICoE9hpo6GMFJ6Qx2<&YBvz)Urb-o|qIPp*I7JZsJDoWSl=XE$ z49BSNeWV{5)>)pmuG+Mm&QWvzhJ1VUORc*xgC_qEQ9?M1diHYD_3DP< 8b+sp6%gP(lFego>bZmaACaB@*~jJl4`a*KyOKgsv6 zHKkW14lk& 0Yqy#FJ(N5-3lV;tl%ZV$+I4wDu4?9D` zew0)XX}YwQp}rDO;G)E}o;?8hiPvA*SU<14FW+3@> c)?PZ0AI8k&7Rx9}=v7ZeWkl^hsautZKrz9kRD>pt?} zM>Teu-b2ybzS@r087 KFa|mwXV%M z5Jk^M^c3zy10P9&QXpk-uswJQ&4VvP=zrSv|FRwagKmyZ7tIm3-dYG ^a999Akd5CLTLU3zPAB;Fw0sNv=jm|7I0RWNjoz|l_a1z7 z1O% ^=Kfn9ar~8?dhzl1~-&knb zP=kw~ou**oc}x3O?RMz%vNQMk6JnqIt(;v#Do8lgY&XB)CSis1nX_tZk9FSPp8CRj z=Wu=FQuE4gP0DC!TKocv#=PTDz)zojdpcn5*-zfeqtop9onJM&zjNT)M_+!idE5J` zR~WZSyG3RGVW{=nW!iBoTi-;-h9A1`Thf~=UeVI-pbxHW9C1%L@AcF0nUK=C7-~gG zR)+_(mWKXl>@13n&B4aTJ(k@3Ige0=MRT@#UFs}0&wKnJpu&=~{4j9a9KzrI_F94; zlI7uVA?O3hZ|i4!kJEnbV#YZI@$dHP+j9SH%jpYPhYhE(gxRO9J=Qa4eYotx_wgSr zh|$^PfBxYz3wBiglM!d%^Iu2hUwyC2&a>xSHdo{)3U(oiDbC!zrQ^EZDpSct(vYHI zLOHk)ycBx$yg}p){{ij%z7dWdWO334US`c&0xxssY=jp}%NTfh`)v?rVA}^37GT-D zIea!)GBW vc*xHu%ohS(`9^A;X=qWK=6teTqFT4N7*Htl$0wpxb*Ht%b7u^{#yGV_ z>ysPi`3g}`E}y>##W2l>D$dJdj}qx;R(WR?39G+RP(CrI3#eKOOR#9>NCxl#G0Fe= ze{JH$-p2z^ZVXDPO*f>fRdrgD3qr8aR*fkNg+}3QMl&P9XIb|ysAq8p#V%;nkhiMy zXH7U$Gl#YW^C?mnj2t4EzmpQibL#wMLZ+573nB~?Zy($FB9zfgYcwKsDA@OSI|!S| z`Q&?bMAO$vEnXImUPyP^F>bAMG)Lnp|1d0s^7p74#t3HTbpnk8lOW0t9! =kRJh)HuN<)w O#fYyjotIBnOjXJccpS%MaM)Xlj!Q-k<{aq>r4Mu_lF~1VO>W<4u~gbWL>_s0yVm zfnKZ*M@500;@s})PI#!Sov7JU%LqP>$>V%un(t_btgb=13A}V?oR_0*05LZZ;AB2d z?Cx}cx0-1v+xTFObpTOM#&Gp74z=fEx`698fq?RrHR95&Vma0jQ%JC72ldsg9!#&F z7&P@u;v2?q^(=i_@oJ1FCzY&=RMFXTY;12Wqq$*j$uSI7N$;RFkIl(_F>?sO)vNJc zE^7%+1;Z+0v9Eah$^d{`wIo>MuMQPnO>QmVslHo&^$vjZH3hLg?cght|I<<3?OLN+ z9Of(TP9Gr;%Q fER2Xk1_Q={} zL~Xi^I(fv1EUXorz-GxujzxS;K{kkGXh_Hoexx`V;H2t7o~exu9DH9W=0?W0u8OMX zqv&mG3#oF$)AEKLd XrVRvn9aj%P z86dC_+!uP$RwKpS?(Og6RDc)J!R*#wj%MQ*XGSD3HKt;89)C4cVH!>(srv@-@(m9r zX%vnqpcl5nL(njnRwAQ}9Z6D;Wyu!Ne( PKVwAvqh7~8n*|1DjsjJz~#z*brW%4!4W!k-fJPJud;F$Y{vJ>@b zQJdnFC_GkX+;d`HFykEvIu{-LF ov0>*#&sP+!glR_AYg10XGTOxDpg!j;+0E*_UN!u z2^n=6-+5{>t=+-~meUWLurgKAgiSP=!_>SBL9~SkVvM}O24V~HUr}_9Wz$sAZ{)C2 z)RUa5s*fuyN*1nm5m0%xrcFjyCMcvL=ZO7;y3@gcy)j&l&H#E 6cN*zD`0k8(&k)3r%=8`;L9L3PwL%wwKZ8K#&LlU%g?;n(vVswoUBj=5|Hz zn0Ymsmh;F!ZchZZaRDs^2%K^x?j&O;CsJJxG8>VAfa`RbdPvDt)=)r}+cZ7e)Ou}{ zGK&9E{UjOJPAB9a$_;G3Cg_r~>v`rH32=w`DAwRz%vcLSp34yc?9HUg&JKCIt{-bW zct!78q^@D9mSN(QL0C4uJcSI}KasX;Kwv1VXZQmiSd8P5YQqxV&2I($9B9TtH@i$m z$i{nKM&XZ&D_(($C{^wGl4u)TvzibhyJ4=IO v&! z8+5njr^ynp*v-?6fa{EKNo>)$39jnhT86X&7otJm@|;K+H8YhYrhpBKf+FD)0{x7{ z>!-PdI&I0Y*0y3F0%yWugVjhHcy7N0-Y)G2Tx_dE$G&oC7sO{EM0{C^id)QH)j6uW z9C!uRC-mbNx&NK-bBeV}ZS{QvX>u2>YZbp_D??q{U0qA`(&|AoheC$-AS|63(mX20 z!$}M i zMBmGR>$Ul<2Z*-J{~=!c2Gewpl0wvqa$V@`#y}tP_(raNKwU6*xkaZNLQp{&TKLvB z_AW-lsx o4*hx zFir(MSP?qmX|N8*A%yF1-2e!2(WUza+7_7sHp hJ;2LE4fw0HrA{8!P0xuU)>U1taA?4dsdL~ARtCa# zT?LQ?L4gRo{ALU*5&_pIl4U?Ot}QI@SYv%1&&K~4jb`;Fc%EYcKG2s R1K~wHI z4?!GYa;~5QPqneno;_QPw#(Y0@+=S;#`+~|tgUw5s*|u&apI~-nD!)IcHnHH8JQ-8 zvfJURD1qyb`q{IulGF*FTh|xflCZ@CK`m>nnR3VTf!l#V0K-TqJQ|_t32Dv@);4jR zL1?uTG~|# }#*SFL3=_8UMU 2D~+R)9Tt}e@ug9{mmgW*ro$Qp1juuJ90^8#52F}_i^I$F#lwi)`HC$ zN0Pd#mP1g569Uh@86%GPO#9lNcKF0^y>E$pN+n|`<-xK_iPr0KYPFs|*_SbemVhFB z5EwQ^5mS-}3?$syLGaju<9t9nl7u$WgLe}tgt~?9Okq6`W^&+2?e9Jp%h15@=I-~& z1+xVM82xc>w)_IO0BqBV)}w|Z8RM?k+N{94>38e=fk6lr=5v_-om0_6OYu>>6pB>d zZr{BKJVuG28AqO+f0pChKmYXnUYFNq2wEV8zXkZ+kK}tAp0W@9P0WO>ug|IaX0LK@ zi|wtoCwqpPBR2oXT>kc8zne+IWg|;WSUrk4C=L7SCe;dj1TYPz|LbMVd1t7+Umy&- z4%iPei^K{+|NOyY(y7D4>701o67GP8x?8vpC+}Y!_mtYUu!mVQH3K}^I5`UZYk~gd zU2uCt8aVAu Pkl9t4Fvdu!@Lf@!XJ-O}D~0W}hz=;hBU zlfQSVdCrZL9J8;cR92dSG(rBLpgiNV%2-U?Xjdsu;Gtz&Kz! v>Vok;wyaC;4P_kYL*`>h7zLYA?WWx#oDKDuW}bA@ z!4@6yxpRWreWJ9IB&?*(g9f{1zKPdS!e7@i{&ih2#n&8{Nxp_;_iU!VkD5LKf8A2t zHoNCNGMRFY4k`uY{q4+?@O26L*R{9*L@oeyOS?m5H24Cl2Pe++YiBG304FuBH;$SY zqd}VqW#lBDo;s_Z{*3Z9%PGwGs7aXaONC#VQF&~}H_m#^L;qmcPNiFHR9V?PPR6~2 Pz2Nux&aJG!hoAZ{N>xpm literal 0 HcmV?d00001 diff --git a/mod/kanto_gear/main.lua b/mod/kanto_gear/main.lua new file mode 100644 index 0000000..cf0d411 --- /dev/null +++ b/mod/kanto_gear/main.lua @@ -0,0 +1,2530 @@ +local WIDTH, HEIGHT = 160, 144 +local HEADER = 20 +local G + +local KANTO_PALETTE = { + { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, +} +local INK, DARK, MID, PAPER +local RADAR_RED = { 220 / 255, 38 / 255, 28 / 255, 1 } +local CHOICE_QUIET = 0.32 +local SECONDARY_BACKGROUND +local RADAR_FRAMES = 16 +local PC_LIST_KINDS = { + pc_box_withdraw = true, pc_box_deposit = true, + pc_box_release = true, pc_box_change = true, + pc_item_withdraw = true, pc_item_deposit = true, pc_item_toss = true, +} + +local function validPalette(palette) + if type(palette) ~= "table" then return false end + for i = 1, 4 do + local color = palette[i] + if type(color) ~= "table" or type(color[1]) ~= "number" + or type(color[2]) ~= "number" or type(color[3]) ~= "number" then + return false + end + end + return true +end + +local function inverted(palette) + return { palette[4], palette[3], palette[2], palette[1] } +end + +local function luma(color) + return color[1] * 0.2126 + color[2] * 0.7152 + color[3] * 0.0722 +end + +local function rgba(color) + return { color[1] / 255, color[2] / 255, color[3] / 255, 1 } +end + +local function rgb24(color) + return math.floor(color[1]) * 0x10000 + + math.floor(color[2]) * 0x100 + + math.floor(color[3]) +end + +local function fillerColor(palette) + if luma(palette[1]) < luma(palette[4]) then return palette[2] end + local darkest = palette[4] + return math.max(darkest[1], darkest[2], darkest[3]) < 48 + and palette[3] or darkest +end + +local function usePalette(palette) + palette = validPalette(palette) and palette or KANTO_PALETTE + PAPER, MID, DARK, INK = rgba(palette[1]), rgba(palette[2]), + rgba(palette[3]), rgba(palette[4]) + SECONDARY_BACKGROUND = rgb24(fillerColor(palette)) +end + +usePalette(KANTO_PALETTE) +assert(validPalette(KANTO_PALETTE) + and inverted(KANTO_PALETTE)[1] == KANTO_PALETTE[4] + and fillerColor({ { 255, 255, 255 }, { 200, 100, 100 }, + { 120, 20, 80 }, { 0, 0, 0 } })[1] == 120 + and SECONDARY_BACKGROUND == 0x0F380F, "theme palette helpers") + +local function choiceReady(now, readyAt) + return now >= readyAt +end + +local function pageSwipeAllowed(mode, battle) + return mode == "active" and not battle +end + +local function pagedIndex(index, count, direction) + return math.max(1, math.min(count, index + direction * 4)) +end + +local function pageWindow(index, count) + local first = math.floor((math.max(1, index or 1) - 1) / 4) * 4 + 1 + return first, math.min(4, math.max(0, count - first + 1)) +end + +local function pcListKind(state) + return state and PC_LIST_KINDS[state.kind] and state.kind or nil +end + +local function assistEnabled(profile, custom) + if profile == "purist" then return false end + if profile == "enhanced" then return true end + return custom == true +end + +local function savedSteps(value) + return math.max(0, math.floor(tonumber(value) or 0)) +end + +local FIELD_MOVES = { + CUT = "CASCADEBADGE", SURF = "SOULBADGE", + STRENGTH = "RAINBOWBADGE", FLASH = "BOULDERBADGE", + FLY = "THUNDERBADGE", DIG = false, TELEPORT = false, + SOFTBOILED = false, +} + +local function hasUnlockedTool(save) + local inv = save and save.inventory or {} + for _, item in ipairs({ "BICYCLE", "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do + if (inv[item] or 0) > 0 then return true end + end + for _, mon in ipairs(save and save.party or {}) do + for _, move in ipairs(mon.moves or {}) do + local badge = FIELD_MOVES[move.id] + if badge ~= nil and (badge == false or inv[badge]) then return true end + end + end + return false +end + +local function addEncounters(rows, bySpecies, slots, method, buckets) + slots = slots or {} + local weights, previous = {}, 0 + for index, slot in ipairs(slots) do + local threshold = buckets and buckets[index] + local weight = threshold and threshold - previous or 1 + previous = threshold or previous + weights[slot.species] = (weights[slot.species] or 0) + weight + local row = bySpecies[slot.species] + if not row then + row = { species = slot.species, minLevel = slot.level, + maxLevel = slot.level, methods = {}, methodSet = {} } + rows[#rows + 1], bySpecies[slot.species] = row, row + else + row.minLevel = math.min(row.minLevel, slot.level) + row.maxLevel = math.max(row.maxLevel, slot.level) + end + end + local total, seen = buckets and buckets[#slots] or #slots, {} + for _, slot in ipairs(slots) do + if not seen[slot.species] then + seen[slot.species] = true + local chance = math.floor(weights[slot.species] * 100 / total + 0.5) + local row, odds = bySpecies[slot.species] + odds = row.methodSet[method] + if odds then + odds.min, odds.max = math.min(odds.min, chance), math.max(odds.max, chance) + else + odds = { name = method, min = chance, max = chance } + row.methodSet[method] = odds + row.methods[#row.methods + 1] = odds + end + end + end +end + +local function methodLines(methods) + local lines = { "" } + for _, odds in ipairs(methods) do + local chance = odds.min == odds.max and tostring(odds.min) + or (odds.min .. "-" .. odds.max) + local method = odds.name .. " " .. chance .. "%" + local joined = lines[#lines] == "" and method or lines[#lines] .. "/" .. method + if #joined <= 14 then + lines[#lines] = joined + else + lines[#lines + 1] = method + end + end + return lines[1] or "", lines[2] or "" +end + +local function profileFromAssists(get) + local hints, details = get("type_hints"), get("move_details") + local guide, area = get("guide"), get("area") + local radar, catchOdds = get("item_radar"), get("catch_odds") + if hints and details and guide and area and radar and catchOdds then + return "enhanced" + end + if not hints and not details and not guide and not area and not radar + and not catchOdds then + return "purist" + end + return "custom" +end + +local function checklistPages(sections) + local out = {} + for _, section in ipairs(sections) do + local done = 0 + for _, row in ipairs(section.rows) do + if row.done then done = done + 1 end + end + local perPage = section.perPage or 4 + local count = math.max(1, math.ceil(#section.rows / perPage)) + for page = 1, count do + out[#out + 1] = { name = section.name, rows = section.rows, + done = done, total = #section.rows, page = page, pages = count, + perPage = perPage } + end + end + return out +end + +local function itemfinderNear(px, py, x, y) + local function near(origin, value, high) + return value > math.max(origin - 5, 0) and value <= origin + high + end + return near(px, x, 5) and near(py, y, 4) +end + +local function battleFocusChanged(a, b) + return (a and a.menuIndex) ~= (b and b.menuIndex) + or (a and a.moveIndex) ~= (b and b.moveIndex) + or (a and a.partyIndex) ~= (b and b.partyIndex) + or (a and a.subIndex) ~= (b and b.subIndex) + or (a and a.itemIndex) ~= (b and b.itemIndex) + or (a and a.summaryPage) ~= (b and b.summaryPage) + or (a and a.mimicIndex) ~= (b and b.mimicIndex) +end + +local function supportedBattleUI(state) + if not state then return false end + local kind = state.battleKind and state:battleKind() or state.kind + return kind ~= "link" and kind ~= "oldman" +end + +local function bottomOwnsBattleUI(enabled, active, available, ready, + battleState) + return enabled and active and available and ready + and supportedBattleUI(battleState) or false +end + +local function mirroredBattleMenu(state) + return state and (state.isPartyMenu or state.screenId == "BagMenu" + or state.screenId == "SummaryMenu" or state.kind == "pp_item_move") or false +end + +assert(not choiceReady(0.31, 0.32) and choiceReady(0.32, 0.32), + "choice quiet gate") +assert(pageSwipeAllowed("active", nil) + and not pageSwipeAllowed("transition", nil) + and not pageSwipeAllowed("loading", nil) + and not pageSwipeAllowed("active", {}), "disabled screen swipe gate") +assert(pagedIndex(1, 9, 1) == 5 and pagedIndex(9, 9, 1) == 9 + and pagedIndex(5, 9, -1) == 1, "touch list paging") +do + local first, count = pageWindow(6, 9) + assert(first == 5 and count == 4 + and pcListKind({ kind = "pc_box_withdraw" }) == "pc_box_withdraw" + and not pcListKind({ kind = "bag" }), "PC touch list identity") +end +assert(not assistEnabled("purist", true) + and assistEnabled("enhanced", false) + and assistEnabled("custom", true), "assist profiles") +do + local values = { type_hints = true, move_details = true, + guide = true, area = true, item_radar = true, + catch_odds = true } + local function get(key) return values[key] end + assert(profileFromAssists(get) == "enhanced", "all assists enhanced") + values.guide = false + assert(profileFromAssists(get) == "custom", "mixed assists custom") + values.type_hints, values.move_details, values.area, values.item_radar, + values.catch_odds = false, false, false, false, false + assert(profileFromAssists(get) == "purist", "all assists purist") +end +do + local pages = checklistPages({ + { name = "TRAINERS", rows = { { done = true }, { done = false } } }, + { name = "ITEMS", rows = {} }, + { name = "HIDDEN", perPage = 3, rows = { {}, {}, {}, {}, {} } }, + }) + assert(#pages == 4 and pages[1].name == "TRAINERS" + and pages[2].name == "ITEMS" and pages[2].total == 0 + and pages[3].name == "HIDDEN" and pages[4].page == 2, + "separate area checklist pages") +end +assert(itemfinderNear(10, 10, 15, 14) + and not itemfinderNear(10, 10, 5, 10) + and not itemfinderNear(10, 10, 10, 15), "native itemfinder radius") +assert(supportedBattleUI({ kind = "wild" }) + and supportedBattleUI({ battleKind = function() return "safari" end }) + and not supportedBattleUI({ kind = "link" }) + and not supportedBattleUI({ battleKind = function() return "oldman" end }), + "safe battle UI ownership") +assert(savedSteps(nil) == 0 and savedSteps("42") == 42, + "saved step counter") +assert(not hasUnlockedTool({}) + and hasUnlockedTool({ inventory = { BICYCLE = 1 } }) + and not hasUnlockedTool({ party = { { moves = { { id = "CUT" } } } } }) + and hasUnlockedTool({ inventory = { CASCADEBADGE = 1 }, + party = { { moves = { { id = "CUT" } } } } }), "tool unlock state") +do + local rows, by = {}, {} + addEncounters(rows, by, { { species = "TEST", level = 3 }, + { species = "TEST", level = 5 } }, "WALK") + addEncounters(rows, by, { { species = "TEST", level = 4 } }, "SURF") + assert(#rows == 1 and rows[1].minLevel == 3 and rows[1].maxLevel == 5 + and #rows[1].methods == 2 and rows[1].methods[1].min == 100, + "guide encounter merge") +end +assert(not battleFocusChanged({}, {}) + and battleFocusChanged({ moveIndex = 1 }, { moveIndex = 2 }) + and battleFocusChanged({ itemIndex = 1 }, { itemIndex = 2 }), + "battle focus sync") +do + local state = {} + assert(bottomOwnsBattleUI(true, true, true, true, state) + and not bottomOwnsBattleUI(true, true, true, false, state) + and not bottomOwnsBattleUI(true, true, false, true, state) + and mirroredBattleMenu({ isPartyMenu = true }) + and mirroredBattleMenu({ screenId = "BagMenu" }) + and mirroredBattleMenu({ screenId = "SummaryMenu" }) + and mirroredBattleMenu({ kind = "pp_item_move" }) + and not mirroredBattleMenu({ screenId = "TownMap" }), + "stable upper battle UI ownership") +end + +local FONT = { + A="01110100011000111111100011000110001", B="11110100011000111110100011000111110", + C="01111100001000010000100001000001111", D="11110100011000110001100011000111110", + E="11111100001000011110100001000011111", F="11111100001000011110100001000010000", + G="01111100001000010111100011000101110", H="10001100011000111111100011000110001", + I="11111001000010000100001000010011111", J="00111000100001000010100101001001100", + K="10001100101010011000101001001010001", L="10000100001000010000100001000011111", + M="10001110111010110101100011000110001", N="10001110011010110011100011000110001", + O="01110100011000110001100011000101110", P="11110100011000111110100001000010000", + Q="01110100011000110001101011001001101", R="11110100011000111110101001001010001", + S="01111100001000001110000010000111110", T="11111001000010000100001000010000100", + U="10001100011000110001100011000101110", V="10001100011000110001100010101000100", + W="10001100011000110101101011101110001", X="10001100010101000100010101000110001", + Y="10001100010101000100001000010000100", Z="11111000010001000100010001000011111", + ["0"]="01110100011001110101110011000101110", + ["1"]="00100011000010000100001000010001110", + ["2"]="01110100010000100010001000100011111", + ["3"]="11110000010000101110000010000111110", + ["4"]="00010001100101010010111110001000010", + ["5"]="11111100001000011110000010000111110", + ["6"]="01110100001000011110100011000101110", + ["7"]="11111000010001000100010000100001000", + ["8"]="01110100011000101110100011000101110", + ["9"]="01110100011000101111000010000101110", + [":"]="00000001000010000000001000010000000", + ["."]="00000000000000000000000000000000100", + ["-"]="00000000000000011111000000000000000", + ["/"]="00001000100010001000100001000000000", + ["+"]="00000001000010011111001000010000000", + ["%"]="11001110100010001000101101100100000", + ["?"]="01110100010001000100001000000000100", + ["<"]="00010001000100010000010000010000010", + [">"]="01000001000001000001000100010001000", + ["!"]="00100001000010000100001000000000100", +} + +local function color(c) G.setColor(c[1], c[2], c[3], c[4]) end + +local function box(mode, x, y, w, h, c) + color(c) + G.rectangle(mode, x, y, w, h) +end + +local function clean(value) + return tostring(value or ""):upper() + :gsub("Ä", "AE"):gsub("Ö", "OE"):gsub("Ü", "UE") + :gsub("É", "E"):gsub("_", " "):gsub("[^A-Z0-9 :%%%+%-%./%?!<>]", "") +end + +assert(clean("40%") == "40%", "percent glyph whitelist") + +local function fit(value, chars) + local out = clean(value) + if #out > chars then out = out:sub(1, math.max(1, chars - 1)) .. "." end + return out +end + +local function text(value, x, y, c, scale) + value, scale = clean(value), scale or 1 + color(c) + local cursor = x + for index = 1, #value do + local glyph = FONT[value:sub(index, index)] + if glyph then + for row = 0, 6 do + for column = 0, 4 do + if glyph:sub(row * 5 + column + 1, row * 5 + column + 1) == "1" then + G.rectangle("fill", cursor + column * scale, y + row * scale, + scale, scale) + end + end + end + end + cursor = cursor + 6 * scale + end +end + +local function centered(value, y, c, scale) + value, scale = clean(value), scale or 1 + text(value, math.floor((WIDTH - #value * 6 * scale) / 2), y, c, scale) +end + +local function outline(x, y, w, h, c) + box("line", x + 0.5, y + 0.5, w - 1, h - 1, c or INK) +end + +local function hpBar(x, y, w, hp, maxHp) + local ratio = math.max(0, math.min(1, (hp or 0) / math.max(1, maxHp or 1))) + box("fill", x, y, w, 4, DARK) + box("fill", x + 1, y + 1, math.floor((w - 2) * ratio), 2, + ratio > 0.5 and PAPER or ratio > 0.2 and MID or INK) +end + +local function button(x, y, w, h, label, selected) + box("fill", x, y, w, h, selected and DARK or MID) + outline(x, y, w, h, INK) + local c = selected and PAPER or INK + text(fit(label, math.floor((w - 8) / 6)), + x + math.max(4, math.floor((w - #fit(label, math.floor((w - 8) / 6)) * 6) / 2)), + y + math.floor((h - 7) / 2), c) +end + +local function inside(x, y, left, top, width, height) + return x >= left and x < left + width and y >= top and y < top + height +end + +return function(mod) + mod.options:define({ + { key = "theme", label = "BOTTOM THEME", type = "choice", + default = "kanto", choices = { + { "KANTO GREEN", "kanto" }, { "MATCH GAME", "match" }, + { "OG", "og" }, { "OG INVERTED", "og_inv" }, + { "SGB", "sgb" }, { "ADVANCED", "advanced" }, + { "VERSION COLOR", "version" }, + } }, + { key = "profile", label = "PROFILE", type = "choice", + default = "enhanced", choices = { + { "PURIST", "purist" }, { "ENHANCED", "enhanced" }, + { "CUSTOM", "custom" }, + }, sync = true, sets = { + purist = { type_hints = false, move_details = false, + guide = false, area = false, item_radar = false, + catch_odds = false }, + enhanced = { type_hints = true, move_details = true, + guide = true, area = true, item_radar = true, + catch_odds = true }, + custom = { profile = profileFromAssists }, + } }, + { key = "type_hints", label = "ASSIST TYPE HINTS", + type = "toggle", default = true, + sets = { [false] = { profile = profileFromAssists }, + [true] = { profile = profileFromAssists } } }, + { key = "move_details", label = "ASSIST MOVE INFO", + type = "toggle", default = true, + sets = { [false] = { profile = profileFromAssists }, + [true] = { profile = profileFromAssists } } }, + { key = "guide", label = "ASSIST GUIDE PAGE", + type = "toggle", default = true, + sets = { [false] = { profile = profileFromAssists }, + [true] = { profile = profileFromAssists } } }, + { key = "area", label = "ASSIST AREA PAGE", + type = "toggle", default = true, + sets = { [false] = { profile = profileFromAssists }, + [true] = { profile = profileFromAssists } } }, + { key = "item_radar", label = "ASSIST ITEM RADAR", + type = "toggle", default = true, + sets = { [false] = { profile = profileFromAssists }, + [true] = { profile = profileFromAssists } } }, + { key = "catch_odds", label = "ASSIST CATCH ODDS", + type = "toggle", default = true, + sets = { [false] = { profile = profileFromAssists }, + [true] = { profile = profileFromAssists } } }, + { key = "display_target", label = "BOTTOM SCREEN", type = "choice", + default = "auto", choices = { + { "AUTO", "auto" }, { "HANDHELD", "handheld" }, + { "EXTRA SCREEN", "secondary" }, + } }, + { key = "hide_upper_battle_ui", label = "HIDE UPPER BATTLE UI", + type = "toggle", default = false }, + }) + local function assist(key) + return assistEnabled(mod.options:get("profile"), mod.options:get(key)) + end + + local runtime = rawget(_G, "love") + local system = runtime and runtime.system + if not system or not system.hasSecondaryDisplay + or not system.presentSecondaryDisplay + or not system.pollSecondaryDisplayTouch then + mod.log:warn("host has no secondary-display bridge; mod stays inactive") + return + end + + G = runtime.graphics + if not G then + mod.log:warn("host has no graphics runtime; mod stays inactive") + return + end + + local PaletteFX = require("src.render.PaletteFX") + + local canvas = G.newCanvas(WIDTH, HEIGHT, { dpiscale = 1 }) + canvas:setFilter("nearest", "nearest") + if not canvas.requestImageData or not canvas.pollImageData then + mod.log:warn("host has no asynchronous display readback; mod stays inactive") + return + end + local game + local active = false + local dirty = true + local readbackPending = false + local page = "MAP" + local guidePage = 1 + local areaPage = 1 + local radarOpen = false + local radarFrame = 0 + local radarStarted = 0 + local tools = {} + local toolsKey = "" + local steps = 0 + local mapId = nil + local mapAsset = nil + local spriteCache = {} + local touchDown = nil + local battle = nil + local moveInfo = nil + local intentId = 0 + local nextPoll = 0 + local nextClock = 0 + local lastScreenKey = nil + local worldStarted = false + local externalLoading = false + local pendingFly = nil + local pendingAction = nil + local fieldChoice = nil + local choiceTop = nil + local choiceReadyAt = 0 + local choiceNudgeUntil = 0 + local choiceCommitted = nil + local loggedTick = false + local loggedPresent = false + local displayReady = false + local nextPresentAttempt = 0 + local themeKey = nil + + local function romThemePalette(name) + local palettes = game and game.data and game.data.palettes + return palettes and ((palettes.cgbBase and palettes.cgbBase[name]) + or (palettes.palettes and palettes.palettes[name])) + end + + local function themePalette(theme) + if theme == "match" then + theme = ({ + ogred = "version", gbc = "sgb", redpp = "advanced", + og = "og", og_inv = "og_inv", gbc_inv = "sgb_inv", + classic = "kanto", + })[PaletteFX.mode] or "kanto" + end + if theme == "og" then return PaletteFX.GRAYS end + if theme == "og_inv" then return inverted(PaletteFX.GRAYS) end + if theme == "version" then return PaletteFX.ogBg() end + if theme == "sgb" or theme == "sgb_inv" then + local palette = romThemePalette("MEWMON") + return theme == "sgb_inv" and palette and inverted(palette) or palette + end + if theme == "advanced" then + local palettes = game and game.data and game.data.palettes + local yellow = palettes and palettes.cgbBase + and palettes.cgbBase.MEWMON + local pack = PaletteFX.gbcPack() + return yellow or (pack and pack.palettes and pack.palettes.MEWMON) + end + return KANTO_PALETTE + end + + local function refreshTheme(force) + local theme = mod.options:get("theme") or "kanto" + local key = theme .. (theme == "match" and (":" .. PaletteFX.mode) or "") + if not force and key == themeKey then return end + usePalette(themePalette(theme)) + themeKey, dirty = key, true + end + + local function reloadSteps() + steps = savedSteps(mod.save:get("steps", 0)) + dirty = true + end + + local function pageNames() + local out = { "MAP" } + if assist("guide") then out[#out + 1] = "GUIDE" end + if assist("area") then out[#out + 1] = "AREA" end + out[#out + 1] = "STEPS" + out[#out + 1] = "PARTY" + out[#out + 1] = "TOOLS" + return out + end + + local function refreshTools() + local nextTools = mod.world and mod.world.availableFieldActions + and mod.world:availableFieldActions() or {} + local keys = {} + for i, action in ipairs(nextTools) do + local context = {} + for _, rod in ipairs(action.rods or {}) do context[#context + 1] = rod.id end + for _, source in ipairs(action.sources or {}) do + context[#context + 1] = "s" .. source.slot + for _, target in ipairs(source.targets or {}) do + context[#context + 1] = "t" .. target.slot + end + end + keys[i] = tostring(action.id) .. ":" .. tostring(action.label) + .. ":" .. table.concat(context, ",") + end + local nextKey = table.concat(keys, "|") + tools = nextTools + if nextKey ~= toolsKey then + toolsKey = nextKey + pendingAction = nil + fieldChoice = nil + dirty = true + end + end + + local function locationEntries() + local townMap = game and game.data and game.data.field + and game.data.field.townMap + return townMap and (townMap.locations or townMap) or {} + end + + local function locationEntry(id) + return id and locationEntries()[id] + end + + local function areaName(id) + local entry = locationEntry(id) + return fit((entry and (entry.name or entry.label)) or id or "KANTO", 23) + end + + local function areaMaps(id) + local entry = locationEntry(id) + if not entry then return { id } end + local c = entry.coords or entry + local name = entry.name or entry.label + local x, y = tonumber(c.x or c.col), tonumber(c.y or c.row) + if not name or not x or not y then return { id } end + local out = {} + for candidate, other in pairs(locationEntries()) do + local oc = other.coords or other + if (other.name or other.label) == name + and tonumber(oc.x or oc.col) == x and tonumber(oc.y or oc.row) == y then + out[#out + 1] = candidate + end + end + if #out == 0 then out[1] = id end + table.sort(out) + return out + end + + local function guideData() + local rows, bySpecies = {}, {} + local data, field = game.data, game.data.field or {} + local fishing = require("src.world.FieldDefaults").field(data, "fishing") or {} + for _, id in ipairs(areaMaps(mapId)) do + local encounter = data.encounters and data.encounters[id] + local buckets = data.constants and data.constants.encounterBuckets + addEncounters(rows, bySpecies, + encounter and encounter.grass and encounter.grass.slots, "WALK", + encounter and encounter.grass and (encounter.grass.buckets or buckets)) + addEncounters(rows, bySpecies, + encounter and encounter.water and encounter.water.slots, "SURF", + encounter and encounter.water and (encounter.water.buckets or buckets)) + local super = field.superRod and field.superRod[id] + if (encounter and encounter.water) or super then + for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do + local def = fishing[rod] or {} + local slots = def.always and { def.always } or def.pool + if def.perMap then slots = field[def.perMap] and field[def.perMap][id] end + addEncounters(rows, bySpecies, slots, + ({ OLD_ROD = "OLD", GOOD_ROD = "GOOD", SUPER_ROD = "SUPER" })[rod]) + end + end + end + + local dex = game.save.pokedex or {} + local ownedDex = dex.owned or {} + local owned, dexTotal = 0, (data.constants and data.constants.dexSize) or 151 + for species, def in pairs(data.pokemon or {}) do + if def.dex and ownedDex[species] then owned = owned + 1 end + end + local areaCaught = 0 + for _, row in ipairs(rows) do + local def = data.pokemon[row.species] or {} + row.name, row.caught = def.name or row.species, ownedDex[row.species] == true + if row.caught then areaCaught = areaCaught + 1 end + end + return { name = areaName(mapId), rows = rows, caught = areaCaught, + complete = #rows > 0 and areaCaught == #rows, + dexCaught = owned, dexTotal = dexTotal, + pages = math.max(1, math.ceil(#rows / 3)) } + end + + local function areaData() + local sections = { { name = "TRAINERS", rows = {} }, + { name = "ITEMS", rows = {} }, { name = "HIDDEN", rows = {}, + perPage = assist("item_radar") and 3 or 4 } } + local data, save = game.data, game.save + local field = data.field or {} + for _, id in ipairs(areaMaps(mapId)) do + local map = data.maps and data.maps[id] + for _, obj in ipairs(map and map.objects or {}) do + local key = id .. "_obj_" .. tostring(obj.index) + if obj.trainerClass then + local trainer = data.trainers and data.trainers[obj.trainerClass] + local label = trainer and trainer.name + or tostring(obj.trainerClass):gsub("^OPP_", "") + if tostring(obj.trainerClass):match("^OPP_RIVAL") then + label = save.player.rival or label + end + local done = save.defeatedTrainers + and save.defeatedTrainers[key] == true or false + if not done and map.label and data.trainerHeader then + local header_ = data:trainerHeader(map.label, obj.index) + done = header_ and header_.event and save.flags + and save.flags[header_.event] == true or false + end + sections[1].rows[#sections[1].rows + 1] = { + label = label, done = done, + } + elseif obj.item and obj.item ~= "0" and obj.item ~= 0 then + local item = data.items and data.items[obj.item] + sections[2].rows[#sections[2].rows + 1] = { + label = item and item.name or obj.item, + done = save.itemsTaken and save.itemsTaken[key] == true or false, + } + end + end + for _, hidden in ipairs(field.hiddenItems and field.hiddenItems[id] or {}) do + local item = data.items and data.items[hidden.item] + local key = id .. "_" .. hidden.x .. "_" .. hidden.y + sections[3].rows[#sections[3].rows + 1] = { + label = item and item.name or hidden.item, + done = save.hiddenTaken and save.hiddenTaken[key] == true or false, + } + end + for _, hidden in ipairs(field.hiddenCoins and field.hiddenCoins[id] or {}) do + local key = id .. "_" .. hidden.x .. "_" .. hidden.y + sections[3].rows[#sections[3].rows + 1] = { + label = tostring(hidden.coins) .. " COINS", + done = save.hiddenTaken and save.hiddenTaken[key] == true or false, + } + end + end + local screens = checklistPages(sections) + return { name = areaName(mapId), screens = screens, pages = #screens } + end + + local function hasItemfinder() + return ((game.save.inventory or {}).ITEMFINDER or 0) > 0 + end + + local function radarSignals() + local world = game and game.overworld + local player = world and world.player + if not player then return {} end + local field = game.data.field or {} + local hidden = field.hiddenItems and field.hiddenItems[mapId] or {} + local taken, out = game.save.hiddenTaken or {}, {} + for _, item in ipairs(hidden) do + local key = mapId .. "_" .. item.x .. "_" .. item.y + if not taken[key] + and itemfinderNear(player.cellX, player.cellY, item.x, item.y) then + out[#out + 1] = { + dx = item.x - player.cellX, dy = item.y - player.cellY, + } + end + end + return out + end + + local function loadMap() + if mapAsset ~= nil then return mapAsset or nil end + local townMap = game and game.data and game.data.field + and game.data.field.townMap + local bg = townMap and townMap.background + if not (bg and bg.map and bg.tiles and bg.tiles.path) then + mapAsset = false + return nil + end + local ok, image = pcall(G.newImage, bg.tiles.path) + if not ok then + mapAsset = false + return nil + end + image:setFilter("nearest", "nearest") + local iw, ih = image:getDimensions() + local across = math.floor(iw / 8) + local quads = {} + for i = 0, across * math.floor(ih / 8) - 1 do + quads[i] = G.newQuad((i % across) * 8, math.floor(i / across) * 8, + 8, 8, iw, ih) + end + mapAsset = { image = image, quads = quads, map = bg.map } + return mapAsset + end + + local function sprite(species, side) + local def = game and game.data and game.data.pokemon + and game.data.pokemon[species] + local path = def and (side == "back" and def.spriteBack or def.spriteFront) + if not path then return nil end + local key = side .. ":" .. path + if spriteCache[key] == nil then + local ok, image = pcall(G.newImage, path) + if ok then image:setFilter("nearest", "nearest") end + spriteCache[key] = ok and image or false + end + return spriteCache[key] or nil + end + + local function drawSprite(species, side, x, y, maxW, maxH, tint) + local image = sprite(species, side) + if not image then + box("fill", x + 4, y + 4, maxW - 8, maxH - 8, DARK) + return + end + local iw, ih = image:getDimensions() + local scale = math.min(maxW / iw, maxH / ih) + color(tint or { 1, 1, 1, 1 }) + G.draw(image, x + (maxW - iw * scale) / 2, + y + (maxH - ih * scale) / 2, 0, scale, scale) + end + + local function battery() + local _, percent = system.getPowerInfo() + percent = tonumber(percent) + box("line", 143.5, 6.5, 12, 7, PAPER) + box("fill", 155, 9, 2, 3, PAPER) + if percent then + box("fill", 145, 8, math.floor(9 * math.max(0, math.min(100, percent)) / 100), + 4, PAPER) + end + end + + local function header(title, back) + box("fill", 0, 0, WIDTH, HEADER, DARK) + if back then + text("<", 4, 6, PAPER) + text(fit(title, 12), 16, 6, PAPER) + else + text(fit(title, 14), 5, 6, PAPER) + end + text(os.date("%H:%M"), 106, 6, PAPER) + battery() + end + + local function stackHas(target) + for _, state in ipairs(game and game.stack and game.stack.states or {}) do + if state == target then return true end + end + return false + end + + local function screenById(id) + local states = game and game.stack and game.stack.states or {} + for i = #states, 1, -1 do + if states[i].screenId == id then return states[i] end + end + end + + local function pcSession() + local root = screenById("BoxMenu") + if root then return "pokemon", root end + root = screenById("PlayerPC") + if root then return "items", root end + end + + local function pcList() + local states = game and game.stack and game.stack.states or {} + for i = #states, 1, -1 do + if pcListKind(states[i]) then return states[i] end + end + end + + local function battleState() + local states = game and game.stack and game.stack.states or {} + for i = #states, 1, -1 do + if states[i].isBattleState then return states[i] end + end + end + + local function screenState() + local top = game and game.stack and game.stack:top() + local world = game and game.overworld + if not (world and stackHas(world)) then + worldStarted = false + return "title", top, 0 + end + if not worldStarted then + if top ~= world then return "title", top, 0 end + worldStarted = true + end + if world.transitioning then + local alpha = math.min(1, (top and top.t or 0) + / math.max(1, top and top.frames or 1)) + if top and top.phase == "in" then alpha = 1 - alpha end + return "transition", top, alpha + end + if externalLoading then return "loading", top, 0.72 end + if top and top.isTextBox then return "textbox", top, 0.58 end + if world.flyAnim or world.teleportOut then return "locked", top, 0.58 end + if top == world then return "active", top, 0 end + return "locked", top, 0.58 + end + + local function dialogueChoice() + local top = game and game.stack and game.stack:top() + if not top then return end + if top.onChoose and top.index and not top.items then + return top, { "YES", "NO" } + end + if top.items and not top.screenId and not pcListKind(top) then + local labels = {} + for i, item in ipairs(top.items) do + labels[i] = item.label or tostring(i) + end + if #labels > 0 then return top, labels end + end + end + + local function trackChoice(top, now) + if top == choiceTop then return false end + choiceTop = top + choiceCommitted = nil + choiceNudgeUntil = 0 + choiceReadyAt = top and now + CHOICE_QUIET or 0 + return true + end + + local function choiceWindow(labels, index) + local count = math.min(4, #labels) + local start = math.max(1, math.min((index or 1) - 1, + #labels - count + 1)) + return start, count + end + + local function drawTitle() + box("fill", 0, 0, WIDTH, HEIGHT, DARK) + color(PAPER) + G.circle("line", 80, 29, 15) + G.circle("line", 80, 29, 6) + for i = 0, 7 do + local a = i * math.pi / 4 + box("fill", math.floor(78 + math.cos(a) * 17), + math.floor(27 + math.sin(a) * 17), 4, 4, PAPER) + end + centered("KANTO GEAR", 56, PAPER, 2) + box("fill", 28, 78, 104, 1, PAPER) + centered("DUAL DISPLAY", 87, PAPER) + centered("LINK READY", 104, MID) + centered("START GAME ABOVE", 125, PAPER) + end + + local function drawDim(alpha, canContinue) + color({ 0, 0, 0, alpha }) + G.rectangle("fill", 0, 0, WIDTH, HEIGHT) + if canContinue then + box("fill", 22, 61, 116, 22, DARK) + outline(22, 61, 116, 22, PAPER) + centered("TAP TO CONTINUE", 69, PAPER) + end + end + + local function drawDialogueChoice(top, labels) + header("CHOOSE") + if #labels == 2 then + centered("MAKE A CHOICE", 37, DARK) + button(6, 62, 72, 44, labels[1], top.index == 1) + button(82, 62, 72, 44, labels[2], top.index == 2) + else + local start, count = choiceWindow(labels, top.index) + for row = 1, count do + local index = start + row - 1 + button(8, 24 + (row - 1) * 27, 144, 24, + labels[index], top.index == index) + end + end + if choiceNudgeUntil > love.timer.getTime() then + centered("PAUSE THEN CHOOSE", 134, DARK) + end + end + + local function drawMapFallback() + local townMap = game.data.field and game.data.field.townMap + local locations = townMap and (townMap.locations or townMap) or {} + for id, entry in pairs(locations) do + local c = entry.coords or entry + local x, y = tonumber(c.x or c.col), tonumber(c.y or c.row) + if x and y then + local px, py = 22 + x * 7, 24 + y * 6 + box("fill", px, py, 4, 4, id == mapId and INK or DARK) + end + end + end + + local function entryCoords(entry) + local c = entry and (entry.coords or entry) + return c and tonumber(c.x or c.col), c and tonumber(c.y or c.row) + end + + local function mapPoint(entry) + local x, y = entryCoords(entry) + if not (x and y) then return nil end + return 20 + (x * 8 + 16) * 0.75, + 22 + y * 8 * 0.75 + end + + local function outside(map) + local tileset = map and map.def and map.def.tileset + for _, id in ipairs(game.data.field.outsideTilesets or {}) do + if id == tileset then return true end + end + return false + end + + local function flyTargets() + local out, field = {}, game.data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = game.data.maps and game.data.maps[id] + local x, y = mapPoint(locationEntry(id)) + if x and game.save.visited and game.save.visited[id] + and field.flyWarps and field.flyWarps[id] and outside({ def = def }) then + out[#out + 1] = { id = id, name = areaName(id), x = x, y = y } + end + end + return out + end + + local function canFly() + return mod.world and mod.world.canFly and mod.world:canFly() + and #flyTargets() > 0 + end + + local function drawMap() + header(canFly() and "MAP + FLY" or "MAP") + local asset = loadMap() + if asset then + color({ 1, 1, 1, 1 }) + for i, tile in ipairs(asset.map) do + local quad = asset.quads[tile] + if quad then + local col, row = (i - 1) % 20, math.floor((i - 1) / 20) + if row > 0 then + G.draw(asset.image, quad, 20 + col * 6, 22 + (row - 1) * 6, 0, 0.75, 0.75) + end + end + end + local px, py = mapPoint(locationEntry(mapId)) + if px then + box("fill", px + 0.5, py + 0.5, 5, 5, PAPER) + outline(px + 0.5, py + 0.5, 5, 5, INK) + end + else + drawMapFallback() + end + box("fill", 4, 126, 152, 14, DARK) + centered(areaName(mapId), 130, PAPER) + end + + local function drawFlyPrompt() + drawDim(0.54, false) + box("fill", 10, 38, 140, 91, MID) + outline(10, 38, 140, 91, PAPER) + centered("FLY TO", 49, DARK) + centered(fit(pendingFly.name, 20), 66, INK) + button(18, 91, 58, 27, "YES", true) + button(84, 91, 58, 27, "NO", false) + end + + local function foot(x, y) + box("fill", x + 7, y, 7, 11, DARK) + box("fill", x + 2, y + 8, 11, 9, DARK) + box("fill", x, y + 4, 3, 4, DARK) + box("fill", x + 4, y + 1, 3, 4, DARK) + end + + local function drawSteps() + header("STEPS") + foot(69, 29) + centered(tostring(steps), 55, INK, 2) + centered("TOTAL", 82, DARK) + button(34, 105, 92, 28, "RESET", false) + end + + local function drawGuide() + local guide = guideData() + guidePage = math.max(1, math.min(guidePage, guide.pages)) + header(("GUIDE %d/%d"):format(guidePage, guide.pages)) + text(fit(guide.name, 15), 4, 23, DARK) + text(("DEX %d/%d"):format(guide.dexCaught, guide.dexTotal), 94, 23, INK) + + box("fill", 4, 34, 152, 12, guide.complete and DARK or MID) + local status = guide.complete and "+ AREA COMPLETE +" + or #guide.rows > 0 and ("AREA " .. guide.caught .. "/" .. #guide.rows) + or "NO WILD ENCOUNTERS" + centered(status, 37, guide.complete and PAPER or INK) + + for slot = 1, 3 do + local row = guide.rows[(guidePage - 1) * 3 + slot] + if row then + local y = 48 + (slot - 1) * 31 + box("fill", 3, y, 154, 29, row.caught and MID or PAPER) + outline(3, y, 154, 29, INK) + local tint = not row.caught and DARK or nil + drawSprite(row.species, "front", 5, y + 1, 27, 27, tint) + text(fit(row.name, 12), 35, y + 3, INK) + if row.caught then text("CAUGHT", 113, y + 3, DARK) end + local levels = row.minLevel == row.maxLevel + and ("L" .. row.minLevel) + or ("L" .. row.minLevel .. "-" .. row.maxLevel) + local methods1, methods2 = methodLines(row.methods) + text(methods1, 35, y + 13, DARK) + text(methods2, 35, y + 21, DARK) + text(levels, 122, y + 15, DARK) + end + end + end + + local function drawArea() + local area = areaData() + areaPage = math.max(1, math.min(areaPage, area.pages)) + local screen = area.screens[areaPage] + header(("AREA %d/%d"):format(areaPage, area.pages)) + text(fit(area.name, 23), 5, 23, DARK) + local complete = screen.total > 0 and screen.done == screen.total + box("fill", 4, 34, 152, 12, complete and DARK or MID) + local status = screen.total == 0 and ("NO " .. screen.name) + or complete and ("+ " .. screen.name .. " CLEARED +") + or ("%s %d/%d"):format(screen.name, screen.done, screen.total) + centered(status, 37, complete and PAPER or INK) + + for slot = 1, screen.perPage do + local row = screen.rows[(screen.page - 1) * screen.perPage + slot] + if row then + local y = 49 + (slot - 1) * 22 + box("fill", 3, y, 154, 20, row.done and MID or PAPER) + outline(3, y, 154, 20, INK) + text(fit(row.label, 18), 8, y + 7, INK) + text(row.done and "DONE" or "OPEN", 128, y + 7, DARK) + end + end + if screen.name == "HIDDEN" and assist("item_radar") then + button(20, 118, 120, 20, + hasItemfinder() and "SCAN" or "NEED ITEMFINDER", false) + end + end + + local function drawRadar() + header("ITEM RADAR", true) + text(fit(areaName(mapId), 23), 5, 22, DARK) + local gx, gy, cell, cols, rows = 25, 34, 10, 11, 9 + box("fill", gx, gy, cols * cell, rows * cell, PAPER) + for col = 0, cols do + box("fill", gx + col * cell, gy, 1, rows * cell, DARK) + end + for row = 0, rows do + box("fill", gx, gy + row * cell, cols * cell, 1, DARK) + end + + local cx, cy = gx + 5 * cell + cell / 2, gy + 4 * cell + cell / 2 + local progress = math.min(1, radarFrame / RADAR_FRAMES) + local radius = math.sqrt((5 * cell) ^ 2 + (4 * cell) ^ 2) * progress + if radius > 0 then + G.setScissor(gx, gy, cols * cell, rows * cell) + color(DARK) + G.circle("line", cx, cy, radius) + G.setScissor() + end + local signals = radarSignals() + for _, signal in ipairs(signals) do + local distance = math.sqrt((signal.dx * cell) ^ 2 + + (signal.dy * cell) ^ 2) + if distance <= radius then + box("fill", gx + (signal.dx + 5) * cell + 3, + gy + (signal.dy + 4) * cell + 3, 5, 5, RADAR_RED) + end + end + box("fill", cx - 3, cy - 3, 6, 6, INK) + centered(radarFrame < RADAR_FRAMES and "SCANNING" + or #signals == 0 and "NO SIGNAL" + or ("SIGNALS " .. #signals), 132, DARK) + end + + local function drawTools() + header("TOOLS") + if #tools == 0 then + box("fill", 12, 42, 136, 58, MID) + outline(12, 42, 136, 58, INK) + if hasUnlockedTool(game and game.save) then + centered("NO ACTION HERE", 59, INK) + centered("CONTEXT REQUIRED", 76, DARK) + else + centered("NO TOOLS UNLOCKED", 59, INK) + centered("KEEP EXPLORING", 76, DARK) + end + return + end + for i, action in ipairs(tools) do + if i > 6 then break end + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + button(3 + col * 78, 25 + row * 38, 76, 34, action.label, false) + end + end + + local function drawActionPrompt() + drawDim(0.54, false) + box("fill", 10, 38, 140, 91, MID) + outline(10, 38, 140, 91, PAPER) + centered("USE", 49, DARK) + centered(fit(pendingAction.label, 20), 66, INK) + button(18, 91, 58, 27, "YES", true) + button(84, 91, 58, 27, "NO", false) + end + + local function partyData() + local out = {} + for i, mon in ipairs(game and game.save and game.save.party or {}) do + local def = game.data.pokemon[mon.species] + out[i] = { + slot = i, species = mon.species, + name = mon.nickname or (def and def.name) or mon.species, + level = mon.level, hp = mon.hp, + maxHp = mon.stats and mon.stats.hp or mon.hp, + status = mon.status, + } + end + return out + end + + local function partyCard(mon, x, y, selected) + box("fill", x, y, 75, 36, selected and DARK or MID) + outline(x, y, 75, 36, INK) + if not mon then + text("-", x + 35, y + 14, DARK) + return + end + drawSprite(mon.species, "front", x + 2, y + 2, 27, 27) + text(fit(mon.name, 7), x + 29, y + 4, selected and PAPER or INK) + text("L" .. tostring(mon.level or 0), x + 29, y + 14, + selected and PAPER or DARK) + hpBar(x + 29, y + 25, 41, mon.hp, mon.maxHp) + if (mon.hp or 0) <= 0 then + text("FNT", x + 52, y + 14, selected and PAPER or INK) + elseif mon.status then + text(fit(mon.status, 3), x + 52, y + 14, selected and PAPER or INK) + end + end + + local function drawParty(list, title, back, activeSpecies, selectedSlot) + header(title or "PARTY", back) + for i = 1, 6 do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + local mon = list[i] + partyCard(mon, 3 + col * 78, 23 + row * 39, + selectedSlot and selectedSlot == i + or (not selectedSlot and mon and (mon.active + or (activeSpecies and mon.species == activeSpecies)))) + end + end + + local function drawNormalParty() + drawParty(partyData(), "PARTY", false) + end + + local function drawFieldChoice() + if fieldChoice.kind == "fish" then + header("CHOOSE ROD", true) + for i, rod in ipairs(fieldChoice.action.rods or {}) do + button(14, 30 + (i - 1) * 37, 132, 32, rod.label, false) + end + return + end + local choices = fieldChoice.kind == "soft_source" + and fieldChoice.action.sources or fieldChoice.source.targets + drawParty(choices, fieldChoice.kind == "soft_source" + and "HEAL WITH" or "HEAL TARGET", true) + end + + local function drawBattleRoot() + header("BATTLE") + button(3, 24, 76, 54, "FIGHT", battle.menuIndex == 1) + button(81, 24, 76, 54, "PKMN", battle.menuIndex == 2) + button(3, 81, 76, 56, "ITEM", battle.menuIndex == 3) + button(81, 81, 76, 56, "RUN", battle.menuIndex == 4) + end + + local function drawSafari() + header("SAFARI") + button(3, 24, 76, 54, "BALL x" .. tostring(battle.safariBalls or 0), + battle.menuIndex == 1) + button(81, 24, 76, 54, "BAIT", battle.menuIndex == 2) + button(3, 81, 76, 56, "THROW ROCK", battle.menuIndex == 3) + button(81, 81, 76, 56, "RUN", battle.menuIndex == 4) + end + + local function drawMimic() + header("MIMIC") + for i, move in ipairs(battle.mimicMoves or {}) do + button(8, 25 + (i - 1) * 28, 144, 25, + move.name or move.id or tostring(i), battle.mimicIndex == i) + end + end + + local function effectLabel(mult) + if mult == nil then return "--" end + if mult == 0 then return "0X" end + if mult > 10 then return mult >= 40 and "4X" or "2X" end + if mult < 10 then return mult <= 2 and "1/4" or "1/2" end + return "1X" + end + + local function chanceLabel(chance) + if chance == nil then return "--" end + if chance == math.floor(chance) then return ("%d%%"):format(chance) end + return ("%.1f%%"):format(chance) + end + + local function moveCard(move, x, y, selected) + local disabled = move.disabled or move.pp <= 0 + local dark = disabled or selected + box("fill", x, y, 76, 53, dark and DARK or MID) + outline(x, y, 76, 53, INK) + text(fit(move.name, 10), x + 4, y + 4, dark and PAPER or INK) + text(("PP %d/%d"):format(move.pp or 0, move.maxPp or 0), x + 4, y + 19, + dark and PAPER or DARK) + text(fit(move.type or "STATUS", 7), x + 4, y + 34, + dark and PAPER or DARK) + if assist("type_hints") then + text(effectLabel(move.effectiveness), x + 56, y + 34, + dark and PAPER or INK) + end + if assist("move_details") then + outline(x + 63, y + 2, 11, 11, dark and PAPER or INK) + text("?", x + 66, y + 4, dark and PAPER or INK) + end + end + + local function drawMoves() + header("MOVES", true) + for i = 1, 4 do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + local move = battle.moves[i] + if move then + moveCard(move, 3 + col * 78, 24 + row * 56, + battle.moveIndex == i) + end + end + end + + local function drawMoveInfo(move) + header(fit(move.name, 12), true) + box("fill", 12, 29, 136, 104, MID) + outline(12, 29, 136, 104, INK) + centered(move.type or "STATUS", 39, INK, 2) + text("POWER", 25, 72, DARK) + text(tostring(move.displayPower or "--"), 108, 72, INK) + text("HIT CHANCE", 25, 86, DARK) + text(chanceLabel(move.hitChance), 98, 86, INK) + text("PP", 25, 100, DARK) + text(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 88, 100, INK) + if assist("type_hints") then + text("EFFECT", 25, 114, DARK) + text(effectLabel(move.effectiveness), 108, 114, INK) + end + end + + local function drawBattleParty(menu) + if menu.submenu then + header(fit((battle.party[menu.index] or {}).name or "POKEMON", 12), true) + for i, item in ipairs(menu.subItems or {}) do + button(14, 29 + (i - 1) * 35, 132, 30, + item.label or tostring(i), menu.subIndex == i) + end + return + end + drawParty(battle.party or {}, "PARTY", true, nil, menu.index) + end + + local function drawBattleItems(menu) + header("ITEMS", true) + local odds = {} + if assist("catch_odds") then + for _, item in ipairs(battle.items or {}) do + odds[item.id] = item.catchChance + end + end + local first, count = choiceWindow(menu.items or {}, menu.index) + for row = 1, count do + local index, item = first + row - 1, menu.items[first + row - 1] + local right = item.right or "" + if odds[item.value] ~= nil then + right = right .. " " .. chanceLabel(odds[item.value]) + end + button(8, 25 + (row - 1) * 28, 144, 25, + (item.label or tostring(index)) .. " " .. right, + menu.index == index) + end + end + + local function drawPpItemMoves(menu) + header("CHOOSE MOVE", true) + for i, item in ipairs(menu.items or {}) do + button(8, 25 + (i - 1) * 28, 144, 25, + (item.label or tostring(i)) .. " PP " .. (item.right or "--"), + menu.index == i) + end + end + + local function drawBattleSummary(summary) + local mon, data = summary.mon, game.data + local def = data.pokemon[mon.species] or {} + local page, level = summary.page or 1, mon.level or 0 + header("STATS " .. page .. "/2") + if page == 1 then + local stats = mon.stats or {} + local TypeChart = require("src.battle.TypeChart") + local function typeName(index) + local id = def.types and def.types[index] + return id and TypeChart.displayName(id) or "--" + end + drawSprite(mon.species, "front", 4, 23, 43, 43) + text(fit(mon.nickname or def.name or mon.species, 17), 51, 24, INK) + text(("NO.%03d LV.%d"):format(def.dex or 0, level), + 51, 36, DARK) + text(("HP %d/%d"):format(mon.hp or 0, stats.hp or 0), 51, 48, INK) + hpBar(51, 59, 105, mon.hp, stats.hp) + text("STATUS " .. (mon.status or "OK"), 51, 65, DARK) + box("fill", 4, 76, 152, 1, DARK) + text("ATK " .. tostring(stats.attack or 0), 5, 81, INK) + text("DEF " .. tostring(stats.defense or 0), 5, 92, INK) + text("SPD " .. tostring(stats.speed or 0), 5, 103, INK) + text("SPC " .. tostring(stats.special or 0), 5, 114, INK) + text("TYPE1 " .. typeName(1), 77, 81, DARK) + text("TYPE2 " .. typeName(2), 77, 92, DARK) + text("OT " .. fit(mon.ot or game.save.player.name or "RED", 10), + 77, 103, DARK) + text(("ID %05d"):format(mon.otId or game.save.player.id or 0), + 77, 114, DARK) + else + text(fit(mon.nickname or def.name or mon.species, 17), 5, 25, INK) + text("LV." .. tostring(level), 116, 25, DARK) + text("EXP " .. tostring(mon.exp or 0), 5, 39, DARK) + if level < 100 then + local Growth = require("src.pokemon.Growth") + local nextExp = math.max(0, + Growth.expForLevel(def.growthRate, level + 1) - (mon.exp or 0)) + text(("NEXT L.%d %d"):format(level + 1, nextExp), 5, 51, DARK) + else + text("NEXT MAX", 5, 51, DARK) + end + box("fill", 4, 61, 152, 1, DARK) + for i = 1, 4 do + local move = mon.moves and mon.moves[i] + local moveDef = move and data.moves[move.id] + local maxPP = moveDef and (moveDef.pp + + (move.ppUps or 0) * math.floor(moveDef.pp / 5)) or 0 + local y = 66 + (i - 1) * 14 + text(fit(moveDef and moveDef.name or move and move.id or "-", 14), + 6, y, INK) + text(move and ("PP %d/%d"):format(move.pp or 0, maxPP) + or "PP --", 103, y, DARK) + end + end + button(103, 125, 53, 15, page == 1 and "NEXT" or "CLOSE", false) + end + + local function drawPcRoot(kind, root) + if kind == "items" then + header("ITEM PC") + local labels = { "WITHDRAW", "DEPOSIT", "TOSS", "LOG OFF" } + for i, label in ipairs(labels) do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + button(3 + col * 78, 24 + row * 57, 76, row == 0 and 54 or 56, + label, root.index == i) + end + return + end + + local boxes = game.save.boxes or {} + local current = game.save.currentBox or 1 + header("POKEMON PC") + centered(("BOX %d %d/20"):format(current, #(boxes[current] or {})), + 22, DARK) + local labels = { "WITHDRAW", "DEPOSIT", "RELEASE", "BOXES" } + for i, label in ipairs(labels) do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + button(3 + col * 78, 34 + row * 40, 76, 36, label, root.index == i) + end + local extras = #root.items - 4 + if extras == 1 then + button(18, 116, 124, 24, "LOG OFF", root.index == 5) + else + button(3, 116, 76, 24, "PRINT", root.index == 5) + button(81, 116, 76, 24, "LOG OFF", root.index == 6) + end + end + + local function pcMonCard(mon, x, y, selected) + box("fill", x, y, 76, 45, selected and DARK or MID) + outline(x, y, 76, 45, INK) + if not mon then return end + local def = game.data.pokemon[mon.species] or {} + drawSprite(mon.species, "front", x + 2, y + 3, 32, 32) + text(fit(mon.nickname or def.name or mon.species, 6), x + 35, y + 9, + selected and PAPER or INK) + text("LV." .. tostring(mon.level or 0), x + 35, y + 25, + selected and PAPER or DARK) + end + + local function drawPcBoxList(list) + local boxes = game.save.boxes or {} + local current = game.save.currentBox or 1 + local mons = boxes[current] or {} + local first, count = pageWindow(list.index, #list.items) + local action = ({ pc_box_withdraw = "WITHDRAW", + pc_box_release = "RELEASE" })[list.kind] or "POKEMON" + header(action, true) + local pages = math.max(1, math.ceil(#list.items / 4)) + centered(("BOX %d %d/20 %d/%d"):format(current, #mons, + math.floor((math.max(1, list.index) - 1) / 4) + 1, pages), 22, DARK) + if #list.items == 0 then + centered("NOTHING HERE", 61, INK) + button(34, 101, 92, 28, "BACK", false) + return + end + for slot = 1, count do + local index = first + slot - 1 + local col, row = (slot - 1) % 2, math.floor((slot - 1) / 2) + local item = list.items[index] + pcMonCard(mons[item and item.value or index], + 3 + col * 78, 38 + row * 49, list.index == index) + end + end + + local function drawPcBoxChange(list) + local boxes = game.save.boxes or {} + local current = game.save.currentBox or 1 + header("CHANGE BOX", true) + centered("BOX USED", 22, DARK) + for i = 1, 12 do + local col, row = (i - 1) % 3, math.floor((i - 1) / 3) + local label = (i == current and "*" or "") + .. i .. " " .. #(boxes[i] or {}) + button(3 + col * 52, 33 + row * 27, 50, 24, + label, list.index == i) + end + end + + local function drawPcItemList(list) + local titles = { pc_item_withdraw = "WITHDRAW", + pc_item_deposit = "DEPOSIT", pc_item_toss = "TOSS" } + header(titles[list.kind] or "ITEMS", true) + if #list.items == 0 then + centered("NOTHING HERE", 56, INK) + button(34, 94, 92, 30, "BACK", false) + return + end + local first, count = pageWindow(list.index, #list.items) + for row = 1, count do + local index, item = first + row - 1, list.items[first + row - 1] + button(8, 25 + (row - 1) * 28, 144, 25, + (item.label or tostring(index)) .. " " .. (item.right or ""), + list.index == index) + end + centered(("PAGE %d/%d"):format(math.floor((list.index - 1) / 4) + 1, + math.max(1, math.ceil(#list.items / 4))), 136, DARK) + end + + local function drawPcQuantity(quantity, list) + local item = list and list.items and list.items[list.index] + header("QUANTITY", true) + centered(fit(item and item.label or "ITEM", 20), 28, INK) + button(8, 51, 43, 38, "-", false) + box("fill", 55, 51, 50, 38, PAPER) + outline(55, 51, 50, 38, INK) + centered(tostring(quantity.qty or 1), 63, INK, 2) + button(109, 51, 43, 38, "+", false) + button(8, 104, 90, 29, "CONFIRM", false) + button(102, 104, 50, 29, "CANCEL", false) + end + + local function drawPc(kind, root, top) + local list = pcList() + if top and top.screenId == "SummaryMenu" then + drawBattleSummary(top) + elseif kind == "items" and top and top.qty and top.max and top.onDone then + drawPcQuantity(top, list) + elseif list and list.kind == "pc_box_deposit" then + drawParty(partyData(), "DEPOSIT", true, nil, list.index) + elseif list and (list.kind == "pc_box_withdraw" + or list.kind == "pc_box_release") then + drawPcBoxList(list) + elseif list and list.kind == "pc_box_change" then + drawPcBoxChange(list) + elseif list and list.kind:find("^pc_item_") then + drawPcItemList(list) + else + drawPcRoot(kind, root) + end + end + + local function drawBattleLocked() + header("BATTLE") + if mod.options:get("hide_upper_battle_ui") + and battle.message and #battle.message > 0 then + box("fill", 6, 30, 148, 106, DARK) + outline(6, 30, 148, 106, PAPER) + local first = math.max(1, #battle.message - 1) + text(fit(battle.message[first], 22), 14, 51, PAPER) + text(fit(battle.message[first + 1] or "", 22), 14, 72, PAPER) + if battle.prompt == "advance" then + box("fill", 14, 96, 132, 1, MID) + centered("TAP TO CONTINUE", 110, MID) + end + return + end + if battle.prompt == "advance" then + button(22, 58, 116, 32, "CONTINUE", false) + end + end + + local function drawBattle() + local top = game and game.stack and game.stack:top() + local party = top and top.isPartyMenu and top + local bag = top and top.screenId == "BagMenu" and top + local ppMoves = top and top.kind == "pp_item_move" and top + local summary = top and top.screenId == "SummaryMenu" + and screenById("PartyMenu") and top + if ppMoves then + drawPpItemMoves(ppMoves) + elseif party then + drawBattleParty(party) + elseif bag then + drawBattleItems(bag) + elseif summary then + drawBattleSummary(summary) + elseif battle.prompt == "safari" then + drawSafari() + elseif battle.prompt == "mimic" then + drawMimic() + elseif moveInfo then + drawMoveInfo(moveInfo) + elseif battle.prompt == "moves" then + drawMoves() + elseif battle.prompt ~= "menu" then + drawBattleLocked() + else + drawBattleRoot() + end + end + + local function moveDef(id) + return game and game.data and game.data.moves and game.data.moves[id] + end + + local function drawLearnMove(learn, top) + local newDef = moveDef(learn.newMoveId) or {} + local newName = newDef.name or learn.newMoveId or "MOVE" + if learn.selecting and top == learn then + header("FORGET MOVE") + text(fit(newName, 14), 5, 25, INK) + text(fit(newDef.type or "STATUS", 8), 101, 25, DARK) + for i = 1, 4 do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + local mv = learn.mon.moves[i] + local def = mv and moveDef(mv.id) or {} + button(3 + col * 78, 43 + row * 33, 76, 29, + def.name or (mv and mv.id) or "-", learn.index == i) + end + button(34, 112, 92, 25, "CANCEL", learn.index == 5) + return + end + + header("NEW MOVE") + drawSprite(learn.mon.species, "front", 5, 25, 42, 42) + local monDef = game.data.pokemon[learn.mon.species] or {} + text(fit(learn.mon.nickname or monDef.name or learn.mon.species, 16), + 51, 27, DARK) + text(fit(newName, 16), 51, 42, INK) + if assist("move_details") then + text(fit(newDef.type or "STATUS", 9), 51, 56, DARK) + text("PP " .. tostring(newDef.pp or 0), 112, 56, DARK) + end + box("fill", 7, 75, 146, 1, DARK) + centered("FOLLOW TOP SCREEN", 83, INK) + if top and top.onChoose and (top.index == 1 or top.index == 2) then + button(18, 106, 58, 27, "YES", top.index == 1) + button(84, 106, 58, 27, "NO", top.index == 2) + elseif top and top.isTextBox then + drawDim(0.48, top.waiting or (top.done and not top.choice)) + end + end + + local function draw() + G.push("all") + G.setCanvas(canvas) + G.origin() + G.setScissor() + G.setShader() + G.setBlendMode("alpha") + G.clear(PAPER[1], PAPER[2], PAPER[3], PAPER[4]) + G.setLineWidth(1) + local mode, top, fade = screenState() + local learn = screenById("MoveLearnMenu") + local pcKind, pcRoot = pcSession() + local choice, labels = dialogueChoice() + if learn then + drawLearnMove(learn, top) + elseif choice then + drawDialogueChoice(choice, labels) + elseif battle then + drawBattle() + elseif pcKind then + drawPc(pcKind, pcRoot, top) + elseif fieldChoice then + drawFieldChoice() + elseif mode == "title" then + drawTitle() + elseif radarOpen then + drawRadar() + elseif page == "MAP" then + drawMap() + elseif page == "GUIDE" then + drawGuide() + elseif page == "AREA" then + drawArea() + elseif page == "STEPS" then + drawSteps() + elseif page == "PARTY" then + drawNormalParty() + else + drawTools() + end + if not learn and not battle and not choice + and not (pcKind and mode == "locked") + and mode ~= "title" and mode ~= "active" then + drawDim(fade, mode == "textbox" + and top and (top.waiting or top.done) and not top.choice) + if mode == "loading" then + box("fill", 27, 57, 106, 30, DARK) + outline(27, 57, 106, 30, PAPER) + centered("LOADING AREA", 69, PAPER) + end + end + if not learn and not battle and mode == "active" then + if pendingFly then drawFlyPrompt() + elseif pendingAction then drawActionPrompt() end + end + outline(1, 1, WIDTH - 2, HEIGHT - 2, INK) + G.setCanvas() + G.pop() + end + + local function pumpDisplay() + local shown = false + if readbackPending then + local image = canvas:pollImageData() + if image then + shown = system.presentSecondaryDisplay(image, SECONDARY_BACKGROUND, + mod.options:get("display_target")) + readbackPending = false + displayReady = shown + if not shown then + dirty = true + nextPresentAttempt = love.timer.getTime() + 0.25 + end + end + end + if not readbackPending and dirty then + draw() + if canvas:requestImageData() then + readbackPending = true + dirty = false + end + end + return shown + end + + local function refreshBattle() + local nextBattle = mod.battle and mod.battle:snapshot() or nil + if nextBattle then + local raw = battleState() + local top = game and game.stack and game.stack:top() + nextBattle.menuIndex = raw and raw.menuIndex + nextBattle.moveIndex = raw and raw.moveIndex + nextBattle.mimicIndex = raw and raw.mimicIndex + nextBattle.partyIndex = top and top.isPartyMenu and top.index or nil + nextBattle.subIndex = top and top.isPartyMenu and top.submenu + and top.subIndex or nil + nextBattle.itemIndex = top and top.screenId == "BagMenu" + and top.index or nil + nextBattle.summaryPage = top and top.screenId == "SummaryMenu" + and top.page or nil + for _, side in ipairs({ "player", "enemy" }) do + local source, copy = raw and raw[side], nextBattle[side] + if source and copy then + copy.hp = math.max(0, math.floor(source.shownHP + or source.mon.hp or copy.hp or 0)) + copy.status = source.shownStatus or copy.status + end + end + end + local changed = (not battle) ~= (not nextBattle) + or (battle and nextBattle and battle.revision ~= nextBattle.revision) + or (battle and nextBattle and battleFocusChanged(battle, nextBattle)) + or (battle and nextBattle and ( + (battle.player and battle.player.hp) ~= (nextBattle.player and nextBattle.player.hp) + or (battle.enemy and battle.enemy.hp) ~= (nextBattle.enemy and nextBattle.enemy.hp))) + battle = nextBattle + if changed then + if not battle then + moveInfo = nil + else + radarOpen = false + end + dirty = true + end + end + + local function submit(kind, fields) + if not (battle and mod.battle) then return end + intentId = intentId + 1 + fields = fields or {} + fields.id, fields.revision, fields.kind = intentId, battle.revision, kind + local ok, err = mod.battle:submit(fields) + if not ok then mod.log:warn("battle intent %s rejected: %s", kind, err) end + refreshBattle() + end + + local function back() + if moveInfo then + moveInfo = nil + elseif battle and battle.prompt == "moves" then + submit("back") + end + dirty = true + end + + local function press(key) + game.input:overlayPressed(key) + game.input:overlayReleased(key) + end + + local function useTool(action, opts) + local ok, err = mod.world:useFieldAction(action.id, opts) + if not ok then mod.log:warn("field action %s rejected: %s", + tostring(action.id), tostring(err)) end + refreshTools() + dirty = true + end + + local function tapFieldChoice(x, y) + if y < HEADER and x < 24 then + if fieldChoice.kind == "soft_target" + and #(fieldChoice.action.sources or {}) > 1 then + fieldChoice = { kind = "soft_source", action = fieldChoice.action } + else + fieldChoice = nil + end + dirty = true + return + end + if fieldChoice.kind == "fish" then + for i, rod in ipairs(fieldChoice.action.rods or {}) do + if inside(x, y, 14, 30 + (i - 1) * 37, 132, 32) then + local action = fieldChoice.action + fieldChoice = nil + useTool(action, { rod = rod.id }) + return + end + end + return + end + local choices = fieldChoice.kind == "soft_source" + and fieldChoice.action.sources or fieldChoice.source.targets + if y < 23 then return end + local col, row = x >= 81 and 1 or 0, math.floor((y - 23) / 39) + local selected = choices[row * 2 + col + 1] + if not selected then return end + if fieldChoice.kind == "soft_source" then + fieldChoice = { kind = "soft_target", action = fieldChoice.action, + source = selected } + dirty = true + else + local action, source = fieldChoice.action, fieldChoice.source + fieldChoice = nil + useTool(action, { sourceSlot = source.slot, targetSlot = selected.slot }) + end + end + + local function tapLearn(learn, top, x, y) + if top and top.isTextBox then + if top.waiting or (top.done and not top.choice) then press("a") end + return + end + if learn.selecting and top == learn then + local slot + if inside(x, y, 34, 112, 92, 25) then + slot = #learn.mon.moves + 1 + elseif y >= 43 and y < 109 then + local col, row = x >= 81 and 1 or 0, math.floor((y - 43) / 33) + slot = row * 2 + col + 1 + end + if slot and slot <= #learn.mon.moves + 1 then + learn.index = slot + press("a") + end + return + end + if top and top.onChoose and (top.index == 1 or top.index == 2) then + if inside(x, y, 18, 106, 58, 27) then + top.index = 1 + press("a") + elseif inside(x, y, 84, 106, 58, 27) then + top.index = 2 + press("a") + end + end + end + + local function tapBattle(x, y) + local top = game and game.stack and game.stack:top() + if top and top.kind == "pp_item_move" then + if y < HEADER and x < 24 then + press("b") + else + local row = math.floor((y - 25) / 28) + 1 + if x >= 8 and x < 152 and row >= 1 + and row <= #(top.items or {}) then + top.index = row + press("a") + end + end + return + end + if top and top.screenId == "SummaryMenu" and screenById("PartyMenu") then + if inside(x, y, 103, 125, 53, 15) then press("a") end + return + end + if top and top.isPartyMenu then + if y < HEADER and x < 24 then + press("b") + elseif top.submenu then + local index = math.floor((y - 29) / 35) + 1 + if x >= 14 and x < 146 and index >= 1 + and index <= #(top.subItems or {}) then + top.subIndex = index + press("a") + end + elseif y >= 23 then + local col, row = x >= 81 and 1 or 0, math.floor((y - 23) / 39) + local slot = row * 2 + col + 1 + if battle.party[slot] then + top.index = slot + press("a") + end + end + return + end + if top and top.screenId == "BagMenu" then + if y < HEADER and x < 24 then + press("b") + else + local first, count = choiceWindow(top.items or {}, top.index) + local row = math.floor((y - 25) / 28) + 1 + if x >= 8 and x < 152 and row >= 1 and row <= count then + top.index = first + row - 1 + press("a") + end + end + return + end + if battle.prompt == "safari" then + if y < 24 then return end + local col, row = x >= 81 and 1 or 0, y >= 81 and 1 or 0 + local action = ({ "ball", "bait", "rock", "run" })[row * 2 + col + 1] + submit("safari", { action = action }) + return + end + if battle.prompt == "mimic" then + local index = math.floor((y - 25) / 28) + 1 + if x >= 8 and x < 152 and index >= 1 + and index <= #(battle.mimicMoves or {}) then + submit("mimic", { index = index }) + end + return + end + if y < HEADER and x < 24 + and (moveInfo or battle.prompt == "moves") then + back() + return + end + if moveInfo or battle.prompt == "locked" then return end + if battle.prompt == "advance" then + submit("advance") + return + end + if battle.prompt == "moves" then + if y < 24 or y >= 136 then return end + local col, row = x >= 81 and 1 or 0, y >= 80 and 1 or 0 + local slot = row * 2 + col + 1 + local move = battle.moves[slot] + if not move then return end + local cardX, cardY = 3 + col * 78, 24 + row * 56 + if assist("move_details") + and inside(x, y, cardX + 62, cardY, 14, 14) then + local raw = battleState() + if raw then raw.moveIndex = slot end + moveInfo = move + dirty = true + else + submit("move", { slot = slot }) + end + return + end + if battle.prompt ~= "menu" or y < 24 then return end + local col, row = x >= 81 and 1 or 0, y >= 81 and 1 or 0 + local choice = row * 2 + col + 1 + local raw = battleState() + if raw and game.stack:top() == raw then + raw.menuIndex = choice + press("a") + end + dirty = true + end + + local function tapPc(kind, root, top, x, y) + if top and top.screenId == "SummaryMenu" then + if inside(x, y, 103, 125, 53, 15) then press("a") end + dirty = true + return + end + + local list = pcList() + if kind == "items" and top and top.qty and top.max and top.onDone then + if y < HEADER and x < 24 then + press("b") + elseif inside(x, y, 8, 51, 43, 38) then + press("down") + elseif inside(x, y, 109, 51, 43, 38) then + press("up") + elseif inside(x, y, 8, 104, 90, 29) then + press("a") + elseif inside(x, y, 102, 104, 50, 29) then + press("b") + end + dirty = true + return + end + + if list and top == list then + if y < HEADER and x < 24 then + press("b") + elseif #list.items == 0 then + if inside(x, y, 34, kind == "items" and 94 or 101, + 92, kind == "items" and 30 or 28) then press("b") end + elseif list.kind == "pc_box_deposit" and y >= 23 then + local col, row = x >= 81 and 1 or 0, math.floor((y - 23) / 39) + local index = row * 2 + col + 1 + if list.items[index] then list.index = index; press("a") end + elseif list.kind == "pc_box_withdraw" + or list.kind == "pc_box_release" then + local first, count = pageWindow(list.index, #list.items) + for slot = 1, count do + local col, row = (slot - 1) % 2, math.floor((slot - 1) / 2) + if inside(x, y, 3 + col * 78, 38 + row * 49, 76, 45) then + list.index = first + slot - 1 + press("a") + break + end + end + elseif list.kind == "pc_box_change" then + for i = 1, math.min(12, #list.items) do + local col, row = (i - 1) % 3, math.floor((i - 1) / 3) + if inside(x, y, 3 + col * 52, 33 + row * 27, 50, 24) then + list.index = i + press("a") + break + end + end + else + local first, count = pageWindow(list.index, #list.items) + local row = math.floor((y - 25) / 28) + 1 + if x >= 8 and x < 152 and row >= 1 and row <= count then + list.index = first + row - 1 + press("a") + end + end + dirty = true + return + end + + if top ~= root then return end + if kind == "items" then + for i = 1, 4 do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + if inside(x, y, 3 + col * 78, 24 + row * 57, + 76, row == 0 and 54 or 56) then + root.index = i + press("a") + break + end + end + else + for i = 1, 4 do + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + if inside(x, y, 3 + col * 78, 34 + row * 40, 76, 36) then + root.index = i + press("a") + dirty = true + return + end + end + end + if kind == "pokemon" and y >= 116 then + local extras = #root.items - 4 + local index = extras == 1 and 5 or (x >= 81 and 6 or 5) + if root.items[index] then root.index = index; press("a") end + end + dirty = true + end + + local function tapDialogueChoice(top, labels, x, y) + local now = love.timer.getTime() + if trackChoice(top, now) then dirty = true end + if choiceCommitted == top then return end + if not choiceReady(now, choiceReadyAt) then + choiceReadyAt = now + CHOICE_QUIET + choiceNudgeUntil = now + 0.55 + dirty = true + return + end + + local selected + if #labels == 2 then + if inside(x, y, 6, 62, 72, 44) then selected = 1 end + if inside(x, y, 82, 62, 72, 44) then selected = 2 end + else + local start, count = choiceWindow(labels, top.index) + for row = 1, count do + if inside(x, y, 8, 24 + (row - 1) * 27, 144, 24) then + selected = start + row - 1 + break + end + end + end + if not selected then return end + top.index = selected + if top.clampScroll then top:clampScroll() end + choiceCommitted = top + press("a") + end + + local function tap(x, y) + local learn = screenById("MoveLearnMenu") + if learn then + tapLearn(learn, game.stack:top(), x, y) + return + end + local choice, labels = dialogueChoice() + if choice then + tapDialogueChoice(choice, labels, x, y) + return + end + local mode, top = screenState() + if mode == "title" then + press("a") + return + end + if battle then + tapBattle(x, y) + return + end + if mode == "textbox" then + if top and (top.waiting or top.done) and not top.choice then + press("a") + end + return + end + local pcKind, pcRoot = pcSession() + if pcKind then + tapPc(pcKind, pcRoot, top, x, y) + return + end + if mode ~= "active" then return end + if radarOpen then + if y < HEADER and x < 24 then + radarOpen = false + else + radarFrame, radarStarted = 0, love.timer.getTime() + end + dirty = true + return + end + if fieldChoice then + tapFieldChoice(x, y) + return + end + if pendingFly then + if inside(x, y, 18, 91, 58, 27) then + local target = pendingFly + pendingFly = nil + if canFly() then + local ok, err = mod.world:flyTo(target.id) + if not ok then mod.log:warn("fly rejected: %s", tostring(err)) end + end + dirty = true + elseif inside(x, y, 84, 91, 58, 27) then + pendingFly, dirty = nil, true + end + return + end + if pendingAction then + if inside(x, y, 18, 91, 58, 27) then + local action = pendingAction + pendingAction = nil + useTool(action) + elseif inside(x, y, 84, 91, 58, 27) then + pendingAction, dirty = nil, true + end + return + end + if page == "MAP" and canFly() then + local best, distance + for _, target in ipairs(flyTargets()) do + local d = (x - target.x) ^ 2 + (y - target.y) ^ 2 + if d <= 144 and (not distance or d < distance) then + best, distance = target, d + end + end + if best then pendingFly, dirty = best, true end + return + end + if page == "AREA" and assist("item_radar") then + local area = areaData() + local screen = area.screens[math.max(1, math.min(areaPage, area.pages))] + if screen.name == "HIDDEN" and hasItemfinder() + and inside(x, y, 20, 118, 120, 20) then + radarOpen, radarFrame, radarStarted = true, 0, love.timer.getTime() + dirty = true + end + return + elseif page == "STEPS" and inside(x, y, 34, 105, 92, 28) then + steps = 0 + mod.save:set("steps", steps) + dirty = true + mod.log:info("step counter reset") + elseif page == "PARTY" and y >= 23 then + local col, row = x >= 81 and 1 or 0, math.floor((y - 23) / 39) + local mon = game.save.party[row * 2 + col + 1] + if mon then + local ok, err = pcall(function() + require("src.core.Sound").playCry(game.data, mon.species) + end) + if not ok then mod.log:warn("cry failed: %s", tostring(err)) end + end + elseif page == "TOOLS" then + for i, action in ipairs(tools) do + if i > 6 then break end + local col, row = (i - 1) % 2, math.floor((i - 1) / 2) + if inside(x, y, 3 + col * 78, 25 + row * 38, 76, 34) then + if action.id == "dig" or action.id == "teleport" then + pendingAction = action + elseif action.id == "fish" and #(action.rods or {}) > 1 then + fieldChoice = { kind = "fish", action = action } + elseif action.id == "fish" then + useTool(action, { rod = action.rods[1].id }) + elseif action.id == "softboiled" then + if #(action.sources or {}) == 1 then + fieldChoice = { kind = "soft_target", action = action, + source = action.sources[1] } + else + fieldChoice = { kind = "soft_source", action = action } + end + else + useTool(action) + end + dirty = true + break + end + end + end + end + + local function swipe(dx) + if pendingFly or pendingAction or fieldChoice or screenById("MoveLearnMenu") + or dialogueChoice() or radarOpen then return end + if not pageSwipeAllowed(screenState(), battle) then return end + refreshTools() + local names = pageNames() + local index = 1 + for i, name in ipairs(names) do + if name == page then index = i break end + end + index = ((index - 1 + (dx < 0 and 1 or -1)) % #names) + 1 + page = names[index] + mod.log:info("page %s", page) + dirty = true + end + + local function swipeVertical(dy, allowPage) + if radarOpen then return end + local top = game and game.stack and game.stack:top() + if pcSession() and pcListKind(top) and #(top.items or {}) > 4 then + top.index = pagedIndex(top.index, #top.items, dy < 0 and 1 or -1) + dirty = true + return + end + if top and top.screenId == "BagMenu" and #(top.items or {}) > 4 then + top.index = pagedIndex(top.index, #top.items, dy < 0 and 1 or -1) + dirty = true + return + end + local choice, labels = dialogueChoice() + if choice and #labels > 4 then + choice.index = pagedIndex(choice.index, #labels, dy < 0 and 1 or -1) + if choice.clampScroll then choice:clampScroll() end + dirty = true + return + end + if allowPage and pageSwipeAllowed(screenState(), battle) + and not fieldChoice and (page == "GUIDE" or page == "AREA") then + local pages_ = page == "GUIDE" and guideData().pages or areaData().pages + local current = page == "GUIDE" and guidePage or areaPage + current = math.max(1, math.min(pages_, + current + (dy < 0 and 1 or -1))) + if page == "GUIDE" then guidePage = current else areaPage = current end + dirty = true + end + end + + local function touchEvent(value) + local action, sx, sy = value:match("^(%a+),(%d+),(%d+)$") + local x, y = tonumber(sx), tonumber(sy) + if not action then + sx, sy = value:match("^(%d+),(%d+)$") + x, y, action = tonumber(sx), tonumber(sy), "tap" + end + if action == "down" and x then + local mode = screenState() + touchDown = { x = x, y = y, + pageSwipe = pageSwipeAllowed(mode, battle), + input = mode == "title" or mode == "active" or mode == "textbox" or battle + or dialogueChoice() or screenById("MoveLearnMenu") or pcSession() } + elseif action == "cancel" then + touchDown = nil + elseif action == "tap" and x then + tap(x, y) + elseif action == "up" and x and touchDown then + local down = touchDown + local dx, dy = x - down.x, y - down.y + touchDown = nil + if math.abs(dx) >= 24 and math.abs(dx) > math.abs(dy) * 1.25 then + if down.pageSwipe then swipe(dx) end + elseif math.abs(dy) >= 24 and math.abs(dy) > math.abs(dx) * 1.25 then + swipeVertical(dy, down.pageSwipe) + elseif dialogueChoice() and (math.abs(dx) >= 12 or math.abs(dy) >= 12) then + return + elseif down.input then + tap(x, y) + end + mod.log:info("touch up x=%d y=%d", x, y) + end + end + + mod.events:on("game.ready", function(payload) + game = payload.game + refreshTheme(true) + reloadSteps() + local player = game.save and game.save.player + mapId = player and player.map + local voxel = mod.find("DRAMATIC_SHAPE") + if voxel and voxel.exports.isLoading then + externalLoading = voxel.exports.isLoading() == true + end + active, dirty = true, true + mod.log:info("ready") + end) + + mod.events:on("save.created", reloadSteps) + mod.events:on("save.loaded", reloadSteps) + + mod.events:on("map.entered", function(payload) + mapId, pendingFly, pendingAction, fieldChoice, dirty = + payload.mapId, nil, nil, nil, true + guidePage, areaPage = 1, 1 + radarOpen = false + end) + + mod.events:on("mod.DRAMATIC_SHAPE.loading_changed", function(payload) + externalLoading = payload and payload.loading == true + dirty = true + end) + + mod.events:on("mod.options_changed", function(payload) + if payload and payload.mod == "kanto_gear" then + if payload.key == "theme" then refreshTheme(true) end + if not assist("move_details") then moveInfo = nil end + if page == "GUIDE" and not assist("guide") then + page = "MAP" + end + if page == "AREA" and not assist("area") then page = "MAP" end + if not assist("item_radar") then radarOpen = false end + dirty = true + end + end) + + mod.events:on("world.stepped", function(payload) + steps = steps + 1 + mod.save:set("steps", steps) + mapId = payload.mapId or mapId + if radarOpen then radarOpen, dirty = false, true end + if page == "STEPS" then dirty = true end + end) + + mod.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if next(state) == false then return false end + local raw = battleState() + local top = game and game.stack and game.stack:top() + local owned = bottomOwnsBattleUI( + mod.options:get("hide_upper_battle_ui"), active, + system.hasSecondaryDisplay(), displayReady, raw) + return not (owned + and (state == raw or (state == top and top.isTextBox))) + end) + + mod.hooks:wrap("battle.move_grid_navigation", function(next, state) + if next(state) == true then return true end + return bottomOwnsBattleUI( + mod.options:get("hide_upper_battle_ui"), active, + system.hasSecondaryDisplay(), displayReady, state) + end) + + mod.hooks:wrap("screen.render_visible", function(next, state) + if next(state) == false then return false end + local owned = bottomOwnsBattleUI( + mod.options:get("hide_upper_battle_ui"), active, + system.hasSecondaryDisplay(), displayReady, battleState()) + return not (owned and mirroredBattleMenu(state)) + end) + + for _, event in ipairs({ + "battle.started", "battle.turn_started", "battle.move_used", + "battle.damage_dealt", "battle.status_inflicted", + "battle.battler_switched", "battle.turn_ended", "battle.ended", + "pokemon.caught", + }) do + mod.events:on(event, function() dirty = true end) + end + + -- Existing render hook provides a tick without another engine patch. + mod.hooks:wrap("render.letterbox", function(next, context) + next(context) + if not active then return end + if not loggedTick then + loggedTick = true + mod.log:info("display available=%s", tostring(system.hasSecondaryDisplay())) + end + + local now = love.timer.getTime() + if now >= nextPoll then + nextPoll = now + 0.05 + refreshTheme() + for _ = 1, 32 do + local event = system.pollSecondaryDisplayTouch() + if not event then break end + touchEvent(event) + end + refreshBattle() + if page == "TOOLS" or pendingAction then refreshTools() end + local mode, top = screenState() + local learn = screenById("MoveLearnMenu") + local currentPcList = pcList() + local currentChoice = dialogueChoice() + if trackChoice(currentChoice, now) then dirty = true end + if choiceNudgeUntil > 0 and now >= choiceNudgeUntil then + choiceNudgeUntil = 0 + dirty = true + end + if radarOpen and radarFrame < RADAR_FRAMES then + local frame = math.min(RADAR_FRAMES, + math.floor(math.max(0, now - radarStarted) / 0.05)) + if frame ~= radarFrame then radarFrame, dirty = frame, true end + end + local screenKey = table.concat({ mode, tostring(top), + tostring(page), tostring(guidePage), tostring(areaPage), + tostring(radarOpen), + tostring(top and top.waiting), tostring(top and top.done), + tostring(top and top.index), tostring(top and top.kind), + tostring(top and top.qty), tostring(top and top.page), + tostring(currentPcList), tostring(currentPcList and currentPcList.index), + tostring(learn and learn.selecting), + tostring(learn and learn.index), tostring(externalLoading), + tostring(pendingFly and pendingFly.id), + tostring(pendingAction and pendingAction.id), + tostring(fieldChoice and fieldChoice.kind), + tostring(fieldChoice and fieldChoice.source + and fieldChoice.source.slot) }, ":") + if screenKey ~= lastScreenKey or mode == "transition" then + lastScreenKey, dirty = screenKey, true + end + end + if now >= nextClock then + nextClock = now + 60 + dirty = true + end + local displayAvailable = system.hasSecondaryDisplay() + if not displayAvailable then displayReady = false end + if displayAvailable and not displayReady then dirty = true end + if (dirty or readbackPending) and (readbackPending or displayAvailable + or now >= nextPresentAttempt) then + local shown = pumpDisplay() + if shown and not loggedPresent then + loggedPresent = true + mod.log:info("first frame submitted=true") + end + end + end, -1000) +end diff --git a/mod/kanto_gear/manifest.json b/mod/kanto_gear/manifest.json new file mode 100644 index 0000000..10a70d2 --- /dev/null +++ b/mod/kanto_gear/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "kanto_gear", + "name": "Kanto Gear", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "category": "UI", + "profile": "content", + "game_version": "0.0.0-dev || >=0.1.58 <2.0.0", + "affects_link": false, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "permissions": ["engine_internals"], + "description": "Dual-screen map, party, battle, PC and contextual touch controls for supported Gen1Recomp Android hosts. Public hardware test; currently verified on AYN Thor.", + "github": "AverageConsumer/kanto-gear" +} diff --git a/mod/kanto_gear/tests/compat_test.lua b/mod/kanto_gear/tests/compat_test.lua new file mode 100644 index 0000000..791876b --- /dev/null +++ b/mod/kanto_gear/tests/compat_test.lua @@ -0,0 +1,12 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local path = os.getenv("KANTO_GEAR_MOD_PATH") or "mods/kanto_gear" +local run = T.sdk.loadMod(path, { data = T.fixtures.load() }) + +T.eq(#run.errors, 0, + "Kanto Gear loads clean: " .. table.concat(run.errors, "; ")) +T.check(run.loader.exports.kanto_gear ~= nil, "Kanto Gear registers") + +run.release() +T.finish("Kanto Gear compatibility") diff --git a/top-current.png b/top-current.png new file mode 100644 index 0000000000000000000000000000000000000000..d7da660607025656b8f0bd8f0192d759ceb36437 GIT binary patch literal 651999 zcmV*KKxMy)P) +{<@AzB|aRBjrob?M241D^{(3VUdyRg#q)m0KXqe z5AU1* w4+)#9b0{8)i|q_IGCYWaNIXvRy}%^= zDc i2??57&XOeiOrcFbaz!Zdtnu;vT~`+eEW+%d^0m0%=d7Q z@Q4@hWn^Z2K<0~ZH#al)FgN?{|NcdJ k8YS)ha$ZlWjHjw57^-v@b8wDJAzU?0i~T(j73gZX1>7d%tv`X$ExNlr<}4PP?T) z0~kwxhcFBfSWhc2ZyxH^wTtrlW8D4nPV)KV@1MZgFL}mdUQbg{L2&qfv%GxS#f83o z@^3=ec56W^)jesy->Y#g(0ek4I|y=M`i8(1umnR}s}!eLZVDtJRL%XP}xK zn|4<%Hc< V0;mxd6 yEuaFtyTdtabq1qT58kDH<^DEaAJl3K z05ILZ_O{~o8vFURSa6Qe7ht|X=rMSgHdIZ|t;2!ume!}~v*VZ$3RO+VKj(4`aOW6A zRiK(_y;M^$It|b$TSsU59?3Ne$`;FwP6PbM|JgSI0G)$IIx}ZJ_-+Vy9N^jgb6Ad! z_CW)!u8myL!W NlR%8nnkEBjkDcYE%*WDJVA>ty+Lsem!>Qb(U) zzdJkTi|vU_eP@>w?Csd4vWv(bEwp{no1oi{uPcA9GgS(K%l;HQZ7YA3D3mK{^wH52 znpQ3Mm2@8f5c(cey;^oH!>TJv5O~nE!n=uRd g|09AU;$fqvD3B}7c9Z)MGvj$K9?D{@;*!Yx}cK4lk13Pls}_C{L%qFIzGoY z4|kX7O$zl7{s=$$tACAefA{xSy8cK1{GY7cHh?Sj9RPmL-^X{}``wkV|KSh*_)6{H z@6DGZKH>e+ 5qX00P0J}Sj<+QSX{oSK6bi=~0JIcz>Y|-x> zgmN!62&7Enh8$j`s~C9ln5k0el)#`WOqIZ(6b6+tl18iN=c|r&;N>3gSM3`B{Or!R zuJn9=@>l;F0IpoeuRr~8y)QwflH>28|NvRDnteXhtYLYnBhLpMN@7Y2U9zbvMl^0N`*p)zRr^ za*u!2x#*+g^DEagZyxwU9|Hi+E~SjNw=-Yfu6jLBFI_Zme+vNc=KJ5M-8Z>^k@(qP z{!hv0TYL3nViwIsA>&|7APDn%0#zm8%#GSopi%{t5~x((2_gAA`qk%h>|6G%zBR@C zNoqQkbE{>oJ|DF$`Mcb^Rjor k z=bw*D%Qg>Y^*sKV;gb(~Sj=v$hevoN(EFtWRn@>bgJIaPqB)6k`La >B$DHOgCR=Q_MMouAAVTVIrj9Wwr=Nyukti z(VmZ>)IMm$ycv2YBPlz$3=ck=HL5#!?LF|2%W@In+TJfWx*1+fFgTy!V(<)_Nzpts zVcr97A+zBVz-N!Hx=1f3e#@?j|IEL%{H!RP$`Ze&S62|fWi1F%D2MsP@4WVA>o2?E zRi40VShlWtZ-jzq!xitE$n08jYnP%wn4;ZE6&{=m2q6-s?RlTe$0&GgPYNgm6PJS! z1%wb#qJWf<$E7z^c5u#}WQ#8|kn1wpHJZI{EV`{aeaXHPi|<@X+O4X4VI7t4Q0>cV zo+uPZ2H_Sj5`3RUa|NJ~*&lG3F?5qZ|MS`+YpUEJ?W*M)rH_u!bBe_*0>%(9hJi1Y zTYXRJE@I-?`qB3R0H6KhM`(2q0RX0D!>}Ca6$TbtY_SRyMUp0zQa}TM>W%-!>91e} z_vLkv#EcL?7{;VA2jc)^0`}gQK{Xwkrc1+7O@|NyArlFqyts3-hgOxru0sCKZM>#v zv?*|O4Gu~SZRZ#wFwVgMxFG|-9N^K%KS+#SisZ3j#RRgQkjDTL0(l)ceET2d`^Q2A z%!eO(7(V)c&^B~A$8rJVj&r5;C^q61Jl4@|;;&i{LIC^S#p}nsJB)Stp)c30d15@( z 8 bGLo!|+RyqLew25K zBtaz+9=+>xmcF0Tv$Q+W%UM3=1r`lhA+EfwUx+O6d;8b& *v6igc=c{7OmiM#CL)VNmMkRkXXYFO01TUg8VWQ38dOj{l@zyx=&KDDh{Owc zi{}Ft3NW981%NWzg{B)Y0qB7RF2ZEZi9HKlch xd?epL~;Ea% sOOO}woNgIK9E+p{aY=YkL5JyK}pmW!PW>HXs;MP|oVKBo> z48&3H7yumq?c?O@;K+q;5%|-LL5={-mWIitpM1A>q%uPa%&Xq>D#UNpfi)4o2`yDZ z7VYlX<-~8=g_2{l?Us_ig+}KSzvVYO@BTMMOcWKL%TRX|No#fmQeR7P_n>B9gaBhw zna4R#J%1R75CVh{00OSZBH~zX1c|cDrmpUjFIYD&dJ)#RtK5XKvA7S5pG_&9{2WDN zQTb(y7XiI-Y6vETfDk;IGX%ySLDTZUxrhiO0!5K?k)i;U5Rm9QlzQ{tqM<5cebXY8 zR WH*$x4&xP-~=wHYvB;IrcFY>PFU!DMv27TI3 zq*u-PRPMpry4?AwbG(kXcnNWh1cN~8Sx1v`l^I{beIStKSoBX-4KyOJJH~n9(I;gR zB(jJfxXJuN2?3>27TIY#5S&9PF_xRx&uhhIPc>@+jh?p?={xQ;3m#<)yx1A%7>6wJ zJZ1-f9{}J5Q;eQ`52h+3BN$ZbS>k93rb?5x=k|}Ppj3sa+c*NyH8W)MQ2s!_dswb> zVnQI~96pPPULo)cw|t(wwZFQz=gWZVy m5mV*QsW6^gG!8y3#IPA)Csj4oH&XFUJ6-64}R+7hY{n3AA_i a#SsgIV1Pm(#A4^L_vkeF{6<@j>9XHBK9|Ke%)QEb<2wHEOVvt- zKPxz1#$$P%<2OtC0<*+#7G=b*H_UUU*+_+pJ%AUWv9Bee3AdP!5%~;;oqF`<- 5)?(L9R_-f zmkgPo-F?!5ZWs^(P*n{oAO&>=KtUd+R0W~=cP1ZBP{8-5U@Sz~cnr;?P-q<5bwRj{ z2zS4u$S}~?1kIwQpR4fnS0kvV0@YB!LxHnL`7jjzxV*1+TP{7Cxh0Wb1{A$-RZV(- z5Jj$6zCWFJd7?y#-=CPMjq5Fjai)d$Y!%0lI9dk)xQZl4?H$=J0}{e_6%;-2*MTxw zs~0>0y1=cS7ABWIyiu44p@SnS17w#mr>kRp;lt_bpgMuwNuQ(L(aOoj!a6wV2bf&= zuxS@UEE9P|Bwl^>msXMgGxxgclR3Vv#C^A%gv}QyCw*tzy}HNmY-4L9eAmHFEQ=Z) zEGtqqR+jFbBIP+s<9Vk1o$e2&;GCk_s2Q$U03qiPT%cw97`b5-)>@7osQ{5AQ5@1* zhOsW*?ScnXF`#O{L&g1h^{E#SrB)MLY_SxwT# =$$gPhA~2GZz6NsLbbijZ*`xu8NsR*G!%OODw4cmMWl^9%mQ@8F|9{WILD z8G< n}k&nSRhZ_;gfDFU`cDn(Oap*N%%y&!!5BsB; z<6kpneR;yf0s?tWjF0ewkZJ35%}a#t#gj-F% p&qf|LMlRZ{Sx5%5`7G$Ov;D-dClLEBkv2A<#MK!V6h0 z+;!je;TA$(XOSh1zj?TeZyxUA()DoZdbx7hIrE+9UdqVjc&_{x56VL`uYzUBUvC)X z(lmORRDrQm*Odw2EvhjhpMt7#gcvOqHne1>Vgb*2_f|nflYKDeupA9kC1~s!aQ2KD zFQn|tl6U|?#``YfJToZ!CoVodK0*87Me{;?6{zS1*gk?QBXl`JPe7SS5?0d;G$Ic| z8ihg5=HiY&PbZ^NYus%9YQ_Ti)?-jzfo4iCLIBWLB$l2hVzL)Va|~z0P7`%@WjEX9 zt38i4vEx2VatwFxnMqjb(b=r a|m^UI4)5ysK)|TQdH{8ltW(!h`=?KI{p3h_C$KVZKiU z@TVM_-$whOfzG}I&7d=zrT%F&9wLp|>EnL-6@YMtnDzs7Teo038t%N^0^XMAKL6;K zHNt2YynSmkS1D0D|B8yUGbHL9;y2gaY=IfduEbKi^jLagw#`zKS5&rH-su{+&hpo- z^{XP&m%t3)x-KY?N(l;J3fwKpwNnPcg$x-f1dh%G7?VaDv9+@TB_gd!hD0vQf1IVQ z<~3OOJdZQoFfHy?Y!sI9EV#=Z+-cf4*smu4|K!u-C0gI&MMKqCD?7gI2#{eMfq5Zj zvv6G>A(M^EaQ6TgJR*z%LUb_3Bf}QyDM$zbjdTK1K6>%?7|#QmWAdrf|m^^m<3EUux_%y!2k3abUre?#?O_-V__1X=oEjKj_nwDQLe)XgO z0iX9rc&pt&%g|;*GREF|TP%+{c5g9Cm)VssUJpt{%hEQboDd&K-~ZhXMTOoQfRshz zhzB9#(m;jF@x~a3;K2Fi7?cuFDkC0fDg_~O&!{NMRt Pf1ZLmJ0>#Mu(7O#q8}l_h=Uy((a=VlAlX!Xj_-Rs5am zcp=a9qrh8~6U@NHjBP^v8%jMOV+>6Luq_R}fsc@h `$+Gp@`Q%Q&VAT^(f9P`TLE7F5Do5%NP{M(El-s>@rr%c;J3-un}`*Hcg{y zNF&zs2V=M+sZV|W1n^5PH8^{6aZX>NS#x|A3W!jEhXR}luu!1Yl~Yw?&w$aCb?9~~ z(8X4MmB^fm2f_H<$CF?7p*bX}Pf>uzL6oatUa<6+7~}H*&hBzT)H2wVe8#Fta`E3^ z46L8w_J#}?rDQJwB;i}aV`Pcnyf<&Tcdx5Gf^VP8HUKmZHJC53BIfC@M$qh-WCcQ3 zKyN7EZ1y)P8l)6a%rOiA=AH5-s4OIM=T4?BKR=E`kK_Ck`JAVPsN0G?PWlhz$+{}y zSO@_T%09=$wrVQS8?!=mOor#sPCZ_EIQy((&uB4m;5{=iekQ*=x24dK^^DddPwYh= z*@6S!C``!SxX-GgfS3wQv*Pzz(AR8#c_`q0(f~lAN8?}D u@YDaj&Yzu(^%p`7&|${UW76XTl8I- zlgq2Lk*aeQC$BT{QL#y-{BZ5+q6_Z{N%(5fTJ^9$!qeddPlr-?D6{DP!*>p6j-5>8 z+WB-yC4p?$^d8^hg~p9+=b}ckUco7{IT;0tUV*jW_bpaNOyCyDV*vQ^Cm-NDb3BlP zz(Wvt=#Qo1(GBKD$N>PV$Z(!s_@eFMUo6xWsaA@ @p=S6cYd3EqsXK`3(E(F{#)q}Yq zgBLQ8Z^=;BAAKmpSeul>h;qWmVXaM7O>&T4C%*iT|5@d^$N$TJvs7K_K5g+TKU<>w znnh2bj#n=LSY{Kw;c(ebJr`V;&O7UwEC4i}=TBq3YN$g!RcSM78SmC86MO%z8;YVV zMIL7%guv(h1@Gr`K>w+W(3iT=-8ZE^@c3`N4IqGDbtn-DktHHBR{G7uyznH6UrBg` z9s~0@LXRWx7}~c}<%2g0=aRki&^XXAI@yNxzB)F`Jy%(adFp1}(_C;Ma5-2Yy?c#l z!%jmr$<^yd-kY&CHvpKX15KN^X^~_v0ECUlpc( eK5HM^NW>bZ_x1Mm%?l4Tq-bJ3LvJ-N_6LD_SjIL!`5}H`C$52fL)B;p1eP YYgTI z_){*)(}+CwNAkB_?Dd%^V>t_wTr#SrBTz1&8VVFD2@zmD^Hb4V*)S|H=Xwd>s@%WI z4cmQKuX~YX?qVng$viPUMV_z~z~I=+5y5R|AA#PNB)I^rdm79r6kRqSVSI(v(W5Wb zip%9UfQyLV1%zOM_^m!yY_2ZxJ9}oSaL<(lbO}j3uitZyZ2Z!^ME~N)B1FliaV72R z{0dr43;n@#xmvP?>s)w+DNc$0QxpQmfU#FXHdcro@xFmE0aXnmLl9)b&&IjClsu7~ z>u}i(*0gS16mPCBt}wpu2?wvW sWPPx*mdPO@2C@;7-#{2;=zJ z)8S->Jl>g6oNw_eK!~f(NUJouj!L$;F8uHZf1I0Z>}WnAAp iL~T)rz5zNG2F=r?qmcIVDf`MPs% Vw6Wc@<7do1D@x78lPh<0B!VOG*vXs{O>n9_27FIUcEICli&WCoxC%QG3dIL zc*@3oUgRm8J hF7&j3tETymuA*<<9+l z-66?i7}0xWG*z5DlpzCSfsaLA@}tuLZ+>eB{*+;I5y0+h*ng*yq^FSZAVf-36h#jq z02VMnQE+}=8qL@{8eab`3P2hljE=p`axZXsUx$+9K$T(QW|disNZ&%a1*GQ;@mnlX zRkn=yU4is1_2exjpNpigm?=M(@D=6tZz5Lh-th&8rB&3iWThnm+UIBn>Q -XvsD3t! zo=eqryOx>zy9^%oM+tdzU|R?oL)XyZG7gV1xFLtnLL85$Go >R~huYhCE#-6m|J^nLZS4DvO1q12aT%jd0jvLH;Th z>D>Habw`(Yudt{sS*G-8?ps|i>MgDt<>Paunr`?feVKDZYu2m|{`nHXTY|6>3=Fi) zlxWSMf&vt1ALwv)B|{Ng(%ky8B**-TB)*D2)y2a?tW^MmGY_pj1N|pcP=i3#38+q> z>YJ_X2z{R1w?a41k|T>a{4OENl_hY_5#{m}P&EarrNC?vXchqvXOb`g0KCDhgd@gH zso*1O^6c0|8WvE$1*+=MR1F#c#VRO1Vr?R1j?OZ)TIs~>`!0O#2%4#YY6R1sB!3+z z6) xh(9C4#895fB&5*{Bp=KxeTBNEeHU+xA&5U&Yz`u?t}my|6-63#WE}%ktA~2 zx{&mDkr0_z`W1=ava73e{Faq1l$-gG{U~zJx$MG<+UEAR9z@}DotmEixLyZp=nvAv zRow8*!D0oAp6BxUkQUKW3u?bMB;Lp 12wAt|5$+mm@#O5yt?~_oPf3hfvh1 z3R5L;`WM%CBU{wKinH$A%Q7b`BN{9>fKPfR=0)Y$;?;nv=} 6v0jsO4yUmAk@U;jOrQS_fDpMMN9+Qi1>@l6xQ1Lqs?Lk~gd!M*sK zB&5Gal*H(J;VQj|NQsNZ +&p6Adb`nv-Du;%CqGTjEy`!i zg@6~PI_)^5H2`o3w2V4)*t}im?EAC6DPs<4TfgW=VG+r oP;-c=Am_agF}2}NOr>IqUr3;lD$P_m72xe zlkus8cbgcWdwBYzC`YBP!_rIy$AI@NOKQjq++uRh2<^9~5lO6o>J$J0=M2gkV`zpR zM=)jZ#v!IB4XCOPz=9s-UlSK17d_95LIY=^GzO6eR?pD?001BWNkl Yjl{cG3lF4POqU+Ku{UuHU7rnMNC-*1cL7QE0>D^#AW2zG zhFV^fS2s%-B2vy3bNFTvc0SF8aJ~wrk9I&I!_TTcxj}BunY){ (ZMPBa*%;GNd3OG6V9xgX(K~0~eY~lIz2e%SZ&AC%KPh{$r7ZuO>v1A@8DcGW^|U z2~5in$H_JAE&w3#U3k+8+{qZ;bPU&(#yS0q696(bgK0Z3%?1oh&f)Fd{dM2MtAi`P zvbEo}QY)l69oVcPy;i7y@JGO!m7`nK2PI_2Bl%904g^m>1OR+=e2&BDbBvfc29S`) zaX9NNaU6Rc18FGhxZP#3+q#v0Ie0I3?vua$-|= zQrEBEPB}LBEv}PFY!N|R$^X6}1o)<>Qn>fdm+|S(e+;4!sb5h@WRNS@>zSyaFUWI$ z)!+?h)4HyW0A|(+fbP1gTdfa)Krm$peF0TZC%xg|0*&TQ;%Ox~17j@m3{1TP36Y*= zlu{6)f)Fah)&?A*CqPsMR9B#C N*sx3H@G?Twc3MeWfDGg7-0e%xqOPn{Q53xUp90yCbN;jtA|krJ_hgnJ z=|8X`o=h=4cA#noC{;lSNxV`O09GXrm_8iK&NFsF-~*lsW}HCP3Dij8?z}!hoTZb6 z n;QU) gKMqbL2>=odQA&f#*%XuaQV+^J>8;f6-;gufHTWqn#=A!5YoDHwNZfL`I zpds44Y5?5bI0|zqIvViB_w&X#u`D!J9^mzF?BH z}7LJ}>FT|4LA z8u}dlQyY&ye+YX=hqa>tiZtSC9#8-t0wFtgba8=cKS=6}F+5H?-gNd5Y6D61Mn%k) ziv72v9Nw8nYPQqZzyiR|YjVvnc^AWr37R_=8ap;fL?EF+JdZ9jhEqbIcRJ3jTk>~w zk0uGJL=**-lH_w%XD4PpWRF!S-U_5|Zq1xS_~xppEL2YcvFZq&uTH3HX_?jv54Vaq zUlvynm@*eDTj1ZMSJ4%oWm);Fbi0o6LbeQzLnjv^VU+b@>guqJLYU{-{qGA0O*f(G zCK~PK_S)+HZ^3xycT3O3=~Vh->1Es-pGDu#5uW~aLeM-JPGCm!sX-}BN?x8Y*7N4l zH4OY!=B>HSZk22sjqkiy{;q8TK03ahh*L3f43uPCDv-qhaLV6D_pATy!g?7C;Ra*4 zQA(!X=oA3p2GgbDI8j9br3#EWxs|i{3}s|c(M)g7L{Oz`n>nzo>DKaCHI(KOwDA@- z@X_%({=;{kcQ3YB8Uk?kzlJzG(2O_zl13b>8euNGlghWYMpT5MUW9dTbLO>wT{G6t z?tJT-YTiuTeful8`}S8V&ntybfBENs0s!burOrtR9vSNLBwZhmJ}ZRa?jC`v5~!3w zQz=w+s{>yLyithI;|Trg5bGP^0lW%WAS6*42oQ?MgOtE*rMEOXOLzQ=qJT!;o}g-V zSWba-G8Y0g@=Dc9NKUgw;SN@hfqxyiN>pt{0W^a^GY9|`LI90DDPo`hZI}?aMZ_dJ z=PXhdvmC((NFm<2+lSqi#G&2QB2spi3@c9R#c-AoasjqFvaa#D58a~B9Tom$gy~5G zwv*588s{gI{ftA-$ zr3gbw_5y%vD@lJbhcqqo?lpJv<-;k5+vgyv^c)5N>bDXC zGkK v(wTq1(ER z!PywEy?G0*JsYY ziR0=hSb%D(9aLt7AOv82YJPi8lDzlfo9>A6F zr@ZRLf#J81h~@LW$oeP#3jm0StRH{!0p2c b&MwJmfT{C7Xg&2 zaA+HjDN{V{wo@7L$De$FUn3uvUU&C3S#FvMXYH}_bY`ogZcSHP 1J(02UAg|@g7l;8SC z3(Hg)2_;T<3dKCXrt_IhlLFm_G)nEZfso0y&+|E;0E~&~*sGz~3%%(6NAtPH!L#L4 zzf`gdMpI3^Kc6?Up1tvRswtq_5`@tz65j?2N{XVaoIw7%5PMFeG@P02t9k1j`|mX( z@)lq|L+Ep`$m2NlIhuzNX;`>F)sVBt_`*-fUe%;Y28|XubS7`s%ZU zwB=lY`5bYYPR;}b19WG0MvMCcl#{)(StaLzT;=gu^y~%XV+lhafqEuMSx-PE3ancc z0EJ+(f5DU|pR@gp^L$cWnGqhssXQ5bx)T+E0_Y8SPw~5#g`z~c+6V!u&`cK%0m9Z} z5X>igtuDp%VzUe@0HCq2VRX8>8%Zzt6$~c|mu{2F@PCc{^ms7btap8jEw-o!!Eh~+ zLb7>5CX@1wlxf ;>sr|G>3fy?7z?tYs)7A}V;psIq#t_~schEYX1O95auH3$HlakzsJ zj 3P?mCmy3KN^Dlq26vY$Q zNXjj0dLD{RmtCvOJ-bZpb5MgY&h|nqS9@~Z(pZM$*?=v~JPKdIoRekeHsF4?kWf_} z4OK_OmO1xw#PM6dnJfF_Pd-S_hoO%!^wAqhPsFUpF#wpF4MVqJ>UQ!S07AxA_Bigj zQlZo_4g4o>+`-wzP4b!o0IosTA?8gW+i|?b7T1bcw^9seT|`yb*Y)E0@8gSPRdc{G zY?bSua??|y2Yo(^@irv<7IkrRc~Dm~{HRpj*ERlK7G-4hjp6!R>@Wktscc6h1lWE4 z^XyH7fB3y?t2<5)HV?%J&?~o 02U1J zCoBnZsM)i>e(VLhDAY0lsG1H1K;UsOPZDiZQ=l3IGzzIG$zIvj(OHljzdk(IS#vd9 z#r5}wp(ucAP^d;pF4?R-N#^+E!iS lOxZsHeevH@)n3-pwF&P{82Y?X?6`@y9P*XW!(glL~4EgKwL{3r7t!CmX$dbIp zdCG>0N#xz@jnj$Q>c}71&m4=52?$D7*B9hHUnj2O1-iu+*9~&*GD!-W314EGHaznY zsIKH9WsFWw(CF67FKGsQIK6z5e7Czgym1KCpty98fEMuR^D{j9JiX@Kue^ievh8~T zdM9H*0G$H|?SqEQ0h}#_S;9F9rzL o;NL#797vLes@M4OiXHQ`? zR5_ s8cn4dlkW`C!i2lw0`)lSx(g z1S(#fR|g;c>7PCCE^Ki_2v7eSxF*u4jI3G4>#pn-UCMW3ja!PhRSRz*mBk+pq}x?9 zllJS4)~wUPjwNNb?|!+H9J_QqJnWC~M%%%TSuWH4ub*$V^IN<;5Ct>F`tR|}xe#aU zs`DtBvu;W=a<6Fv3!H)nFGC)f8-(x9rr88g@}s6HHe-EG9BFk~xYIF=MurS7BcV5( zrucWtapv3JbL+< &8`7J^Xyi9y-Z0NHC*hXMLM@jn zb3`We@D{HcQm>!FalA~+XGG``0)Rwb$oZEec?K;Rewu9hEcb#T@Tbu9$^_6$BlZS1 z91838ON6+r^y-a~_3n-5&CpF3N*)5ir6f94RRbXuPmZz)K3|?UVv8-ds0YD-z?JjG zMEe+2BcQq@^UN!T&YzG|8OAxY2-BT3$1Z>*d~ 5U($M9l;r%#^1YN{|>8bE>W z>vpCuvR_6+%{?Olz!fr-G-n*{Fob&EfYY)-Gy >p9Jv69EL-n5Cwn-w;;QZ zk7 ^Z|BR|TWk0Q_(Lzmp#npmirTjOtxJrw-MCYI*O@9AuFPFX=p-A$ylX z%^#_zPr}k@>}nXD2lYF0MRdM8M)+vw>J`0Q%;y$cyi^D#9GuBe*=969@*sU-%e(=L zMlwL^!5v&WP$NqjN;Mb-qgunTbKgC8e5L+xu^4Wj!)(7821z^=r8&K~xEWM=|JDuv zToziM`2sB9@FyH}dH~b3Ve1xb4L}h6o8-em$Cyk8pa7rGE89Ho8t7@2@HpTsFFCgI|S2y2!G-y?b@vj35*PB3;}S!n?#<%4uS4y z5pkUVnG7c%1gL$OKOYmvG<0A^9@N9jLarGBoIjqX`JNRLeM|}D+Nh~Oh_SX=uZFTU zceug{xV*@`JE)>6H)}p$X**J-tXjs4ch#Q6FaCx-$1Pqw)Eja6Qa3f~wrYaw!FLMz zuD94?J*a2Uw9clwQS8k4?;?az6or8w;L$+p8rp`2wxMCiG7_)gdB_sSn}G0N{?q5( zg)LrSB;sUpph}3X`#y`pr{%9(wD^}9w`Pwgk+ n>O-i80>NlPNRLi~TqJ__zJvC@^b!aIhRdT7 z9(;BRyQ?QesLZu10J!kr;;1k4HpYwLq?ae62hZHx_tve9(ntX9TPDnQ$$bgH>gdU5 z7II84eSH4OeONmh>>UlGi)cPH1oq#J*7?lB>Gs3ihJARN?+XApyD5>Y>IA!QHS&vZ z =qW~mqK(|adEi>}&1yHa>oR0EJxi}AT47+7wGH_+6>}V5gIoeD; zlE6kz5ITHAZo)YSCPNqJr>D>z0>f5ie}oD^u7& 73V2>HlN&j?|LjFq zke8P>EUt0_UQnby>TGN_(g$E=ls?iG9%5cuAYQb`u*FM&RLNR?{w8#aKOHjsba*ni z%y-%vbV^`S0z*}y5(UPJd6u`>;$=da2k%w&&tj&6y5=RTN)szYY>D3gu0<09_^nQB zsXDSGaqKb7ID;FeVfy=?)Oo~V`T@XHX+p%9DuF>MY=5)J }``~+?wQ;keR z29L31e)GTndoaxgOuGTomiiULioRQ$m(K3O^`R32jS^^)4nfQ=SP|C6g3Q*_X%y#s z0a0`leU2Pfmh{bf`o4%*ItTD`#WIEk*~@0kcgm}cvCQNpE_X4Vm`R>vYkd)B*9uPN zCxhN%i>qK&^7>^&@9*TIpsE@ul>|c3t5+VgXAedYB8`0^I0qL|UTEzSiUs&cmG@%e zK&h^3i 8f6KJMPAYj)J@qYCE57BNO$S)i~Yk>A0Ge=-r9X+SDR^rKCiHTAb z<8vS5b05yGj{SEVptM|)iZ>P03~3yXyg@eHy#QLbOi-PmdCPzhGIzSwiVb2?+Ahf@ zDg?~73c&=%mjNc1KJ1PLtE0=j>ak~V!5i2e1~c;I=sewsI93--9Y!fq6g0Xz^lS*| zB)4u}RcAC+KuAwocM!s{cF<^>a5|>+4hFzQH`A`bWRMS6EtHh3&3PlY45%7~-I1ZR zMdq5^ @J*jN|M?dKw!I4WZ&g?*buwgwZv_Q Xty>;>Aa=?`7l&dCd1Y z6a`S#f;=$?+x+wrlu$T2g%fRb>Ut!z|Kpzl_kIJT{sr9W7~XUYcPi!K{)P0AGEE0Y zhFCEy2d3q~upMaHi>;(DVqJN!>xKc}qQn@a@IBt^sw$m*-`$UskE$w3;C7>#)WaA< z$T)-mLdGSD%mg^+2tp5(N&{m`2#`tl#6`7iFn4BJ$J`SOClz8weZRb$+l#1w@JB#o zaIh{AMS-nrurXsN4Ltp@@;W92_{jwT;PkV92bZzPOSv9-Ox5*McZYBNgS3BK_T!6> zegQU+ %|L#9rSl=>yz~ZHJ#uxZT@iLLVS)`+A(*%;L{;b%~WiBhRH<(+m zo*{C?iixjZUc3S>$6LHoD0 Ro=e Cl}!?;k&?~zRa^1NWFUj>EY{{kKpU)@P-VH z1FaU}Tf~3xd*7BHat^Q}_q~5M&V4_AAm^rR?%MWkQxeAj{BfA%;w{!4E6%KrhT*Bq z4LiQ@;dFHzywk{e!{)jXeVF$B+~#6 7}a%P29P6fM(a0#4!Mf{UnTYOxBKjZqD2I@;Dzldo;bo;jP o~fvMc&2$z|=8l=)h^4ASy-owhD!inNGyt z?52@BU^jFK0gMN!$FX(?((5?cQ409e0I^gq$^KbmNHWwwqj256AAk@dNn;Z21ggA_ zOZr)ctFEF|RPH=1-{!m;i^TDAwbxb#C~3{D=F1g1W{b_lUmgu2qa*<#L^5~8k#{&_ zQ9g74#b`V?fMpbE^KInQqjlb1O{LI~&8u9`x;==z`TDMhQNX~s48Q0_#IZ^g=ov3R zRZ(E8Dkvqdc(}zaHVGbY0~vLmLHf|O*fUCo7G+%i `U}Y8aVU5GUdW;D{}uq?Gu6iMiS&e*a~e&;Tbq=^ zPzg-6UP$Y*dGEl^?8kokZz`|-?E^Vx11=CS4xe*;?9JCp#d?@UNXOmH;9oq5Q>X30 zcBInIe;|)c# l3&PIcR>XMi;Ba7g& z9OHGNL<@5^^MxW(CPqLYa2WuB&p*8nXIDq#t^tKAu(C{cMpzKYc=%>W+&DCJyRiod zKyAu0?b~v55$CebMjle)I->p ~B?!iVa&@%o#GXziyS$BA)r#@lxkd5elnf%B(> z 1Jx-S`?6r8W9FjA2q++mydWlPogE7hGSt`be1egCnS_Q0fUaV}M-P5l z4SG+*^dSKlU* +M||P6x1>>83SHfJcudLt@L5R>Q!!KgGE3!fwlRPHQLEr|Gb|FELuX z4p_)=dUBG4!~iZq(Y`hu742I_rEFRBI5r$L`5g4+eb@&E?53G)LW|zUfE+{FHr&%G zC`mnmf*{Oz9p~5XxpMF7yy{#mLsYMqEH2c%ob~N;djuu-XNxVC#=XO8i~dK?CYg6R zQnm^K2$3U=(|md)B8wFWB0YMFVe2;m*SQamJ%HP$f%XzM96`w734zI<{P*C4G4B8B z|4oQv{*Au}!;HN8EC;Hl!)bP)Y8nj7DNUWT#TM6vnBbLDL%p##bv*=uB#2|;SbF^l zsH%~W$C9X##t^D1|N35(N^8UBkxhrD8O*aDq5uFO07*naRM6T68tpE=zz^}x-LK~h zROI>&ncSDS-WXmmh3ikCav4DO=NviW7{Cm9yd{obE}UN$_9j;pqSqnv%YBC;4b=#c z4Iws~6E%IeEnD1ai^$VhfD3^jkh0D3MF@ouC{Ut#j!MH^NcpB|PG|067WI9A7hxM& zG7q9Cur>Kx+ZymBGE(~Lo4*OX`I~dE<(y&akKuaqH}w25rojZBKf&<9zryaD|M^<_ z2LJ|DlKBx{wPWPh%v5(JqY~tEI{$yuXPIxyxM!}8l=#g%U1U*p9LMQ##sUE4MDZrz z8bd(OzO{9Z)WPU1NRll-SCn+W^%a?OCvX`~|HfS8=yPp&|91}X@dxws|7<+g%oxuS z+T{(H{2qX#nw6GTabpMr0kvkn$?M0K;;%xYB&O$3O}T)nh5}#=iuq(OXS2`LALWnP zZ55NgBo-6`5DI_*geo8urON*;w%B4duz eNc^H+7FXx19d|>FN>!apUbvghS8baIqrAv0RRk3g%E&l$#&oW`ujNk{1V<% zd2MfWl90DCVuCjGIW(`0&S4WfhYqMpAl}WlEegB4^s4|1IUs=E$q2pUF**khbW;V7 zrpop$;Pg=s{j&-BXOiIEz1z-J)Y!40D1g&3(LWvI@q>r3J36e62IF>0PMN}ikBc0c zIZpQgDXGi^rad1tXrk4%L3NstR-@h&O}h&)AiNL|R-Q;T90~y$7FsZ1ItZYK4cxl3 zheoI1Nn2%DT>xhSquvxJN6(VyPE{q7Yq*c;aUWw}OT&Ceuo?!Os9nJY#+Or!`YswB zIYt{DD{^?tdtDSa P?e6;?ME@X?he&%1y&=TTKH_hAj|W-dCezVLb5Q zv CSmb$jdU9NRe6U*_t%md(4?MYknvR}$Hpe$Cg!DKb z4?Gz*yFt- Cy7B+~aPiLxh$HM$(v zk=p8%nQb;A$(Hm!tXq4P{jl_eSh=AsUL`CXDEYCgNUbOqjpVVb!Dl7pa+bs?5Xmju zm&6c&!N>=RNT%4ZL{VgTdrHVl#|24`W5Ci>JRXekdOIUT&GCrg$T?~Pct80h1c&QQ z;09B8{zQ8Hu>hVw#pvl@003TZIkm&lmce-EAFq7v78{I`d7)~isPigzS~4`4G!O&w z#Bo+$5Xg?`#0YWT3ok^p=5atsVp*n fz|?IRngvU@Gv95PhMn}Qh`)F!H)L N8{<-vj4>!9ei{w=elnm?xN?W)(Y|G2mMiUtO>bpzFC;t9$=Kzu@3xvO7QcD z_|2vbaHk{a`m7u;Q0}Oa#iH}i8*A1kijq(3s_~{VN2^qR2msK1-APIuz6U(|oAlhe zJZcq1nNgf>vBegf1)oR*4AoRXR7u8~D|q#iOxTt I9s08tSEI98 zucj<%Y&8koK?tfTpc+ZM&$ig&Wrw{(F}W=Fq76L`76`BapxzEl-2fR=&?wZq5CWb% zN%B%D8Sg*aZh|xUju9FWbQz&AC43*ja43{WC#2W(Dp&`m52cc*d%KA@zuAPU(F&PI zGM#&Oqfj_*2;eSQC@qKY|MUoefNeLSn<_fD C=RZpgs)PUz-{?ejrU#q5HZCHs z+}`mBox=t?2Mz4rZDIFLD=nYtTdQm3j>U2j3px5HBmC@#e*>$dL)B!U<>sM@;jxQJ zFP#gU`(~kD0i&rxfh1U`y&xid9kg~Wpc@gjBHJI45}46L>4FJ eG1O;^gQUW|P2h zG(Z75cgmHaE-rIp${hjzM9u}AageZ)>%48Y&}usvjLy>T0MPBsJ1azXIKdl-@J0+q zgGvu%0A>@pcH6hgpN&6BLwU! dF$wVKh~Qfrs2Y z5dDn}OI{Ty3V~%dV3~3b|KSh*7_YY+H~`+sC=Rnq$QW5AWQ?p5k_&-HgE4UY$5$qi zw|KF!j5h9CR;1|NRuqc59?4rgAE;Ez5zBQEvLNwHzL>gjLk^FHNof5t5Jky*;sF?V zL1p *ydQ-Nl&$gRYfV;yG7hgz4 ze;jr?WDKKzUfbl4MCm aATPY{G!Jq6 zOWoX(<#bG3Jd1PPO7F<-ofdxWyKf|A5|7!K1l-FIiP6b0 php=~aSUVbsuHfL^R;EdLO&p#% z)5+xa1QVEC`Z&8khSk<2QL7U`ALHp4=XjDCMz6p1X6}|6Z4-bD56u}cx)|f>lP9p6 z8U$p{ $(-*5DH# u7VWpj}d*ia-<>qU8wMJV8A`VqI~jW`I+E68-aWIc i^4llDj$E2Bid{oAyVuXW#p*ai>?fC$pi|B9619 zF#t>l47Sk(kdD98N0W#YnkA1H62~isyQ_DiqF9YKXUXENti44&T<=I(vGz(s?=&}g zmN-^51%dBE)hZNdVsRMjml;`a5ys^_-1q$4Uyt!hCFI4_#iPMEA$+r;z015;Vidhs zo;TLzHS@}$&MUHDoqFzM@fIuNJ1ctrt^@4z|2ZK~OqC`fdI5&F+f8_E^)x_RTo0<= z^-58?<{E66 urq zBk$+yLOwZG8J00}y#lUt>%py4Ky()uEU_4;{kIXe@8{0W^mR_>hkw_GXr6+q3W%yC z-h8Sdl~6=gHbTm~F|Xn!`OoJ@mtLV^=d}s=S#xp0EnZ0|L_unhS^e@85P(*DE&%`! z1y~SyC`KMsEEKRi8p0^=!o?RZR5PA;()NnPZr0N{EwcfW(+xv66!_lDuMFFC=nVpY zbbW$ R;uW1Md6%7kIV4FCzp8qB#njK*f-I?P?DU+F`iwVVSF(gVc_zM+5F*+ zJp-x{hs*`+j*i#96?+7y?_KLwI`0Kj2F_gcgCmU3J&Z4WI9(l1phJo0Egd*`znQGt z(|+0?A%Nkri?T)CYU`ky{B6yq3TMxNvzxiWNG>v)johIL&Lm+Rc?h$Bp}8vwHdQ0& z95&H8kl`ptJvVpl(Q){!kmC(>O?;f~KjkTLw_j)e1-gH-EhhV=TD9us20-aDvMx zQ 71%o(0I8%Njm8*dA|e-myL+#{Ci@gH96fsqv#G&qX(<<~0o~W+y*fL3 z(l21vqgDuk%P&H>!vN<0&)%EFSdye?VxRBZ+hfbf%!pi?T~*y(UCk~Inj(iBkr+V> zAwdU0gbtds4!X^ikf77th0#SPLCZ;ifPp|!fW#46Gh}hmbXRp%S65cnmTT l(}k9d<|(EywB5{b`{dRo ziX#Gl7{REkFdHh(1Dw{$Y|ROd;y6KXC=#niU4<+IvZ?^8i;?eQbWGPx3iY{lCs!gT zF%3RAPF2fhJ$=?yz*qwe&Vq4f_nH9SFoY7-;51D*EeqZ-fWsWk)5;IJ;`7w#*n$!n zpyaa>IkX%`VsEp^*1Qs1_u9f~5x)?@?wVeAF{*hoC{H-LhiV0xoDSS82Hs1xfD62m z`Av%F`-K$Z#nAyE0e2G(rm8@Zs5-__RhWtlMV4V}>W$0Uy22IWoPiC?6?{$mXF*MT zze)U25_>KyeygpQ-{Eg6l(av`l)H;4c%Kc%0(sghki>D4;Kv*7j7(-Ky@T-tUPRl= zN2fj7=5TpK0l?93wq4-chI2o?mo;>hitpwnC(bUZFxUY&!vGZo;!m3H~%8dc}&}?;Sc|ju&<(AVK&KY!ouX$CHEK3 z)m&70NiVUh@L_?z?oF8uTeU@RH`sX6)bjS`_B}4I*oXh9xXVJ2|LQM*kN%$v)orYN znSE~DE(Z@P6{4)F?R|c3;))+UmCQQAf(NgyYZy&ourC|Vx;B+9U;boO>@Y{}JN*!J z&CAdFxbx#?*0$PC14tS`fa3fK0D>rqk);0sY899y2}tc2tt|^@&t0g7oc>dw>hda| z=K^rup)g7RIzb+7BdP7r-Og&tnDnnFGRkbo@CMf#g*EBMusUzAol$~>lE5<{s}f{Y zfmR_vRCO79UyMpbZ&85^V5$naiWFX9g{wjm6T|@li2*67LDO`|x&q%TD;ybQSpDaY#yBuoTj$atiF*pfSOuD69Biq?}+>G8%y*UwYJ5tgxxSOJaut4%vo5gsUeWg zvSS4VID07)m7@YtTeI3|Z`N_r-NR%U!K$k;n;Pu44!b=)dep3I!h|AI3s3qXykUgX z-T|zpMv2~CdajFV)GET?>F5BVL@gtHt%e4xsR=}`P?wr2^s9}!ma3l#yg>xF7iLOn zT8<8X93h;D{So&}tWS|R29T4-&FW{74szZPsH}BvF4!9 z808d6Ody92YHf?=fG&hV+fB}Dj*TZ81(JdgByk7FM>O3;^Ok|Y1)xqa_9qyphjjY| z68rYH@ZEIiz4GMir`c~d>l)4PO{zzUx>GoZBv&K*;|Ox-z-bzgWwHM;ERfjVu_xp? zrMXVo=X~E~dLlE2f`pR6V3u7Gva#84 IiUGSTWebY09CtMrO2C#WfV))j&yNcxV^Z@vzYa1SMQ@0=7SNqcD)I^-YjqM z4@6?+-~97`nthI6{u1LzY^%H|h9AcWe*DJ(0Mo3&wAnxBTo$WWjUzX8#FJ&{vJ733 zp~^Cf)9u1j^@`q?+>kZH%qM-;%B2EJR_zJHii}=?p-BGnYTsa-2TJQsHJ`!6T9=8@ zNwp0%k4u@Md?jt*Wstp>2C?kQ_H@eOWm&1PI{=W>(L4N>CcqZNB6K1 o_e=tCE2um_e_aP}#mR?NJRvpDR1tg(_Y~&N_SfP#)KqNSb>r@i4^Xyg_ z;fo0!5UQFEL(u>k;KXxtEssdTb)c9{jPF8KbqtjwD93Fmss=TQVOaF}XF*p$mF18K z-4F@KFqptMpFy)_C_1%cG@)R0oMd@?Ul)A#|9yt~mWlcnjY8Z}+m+jmPsI*p_o+za zbeh{4d90Z-Hh-&DB8?+2$p}|=Yd9TB)DZ#<4m{L03}{{po!fPEZZ|~YIDKyoPF(IC zMn4>U%UJ{i2#p?8uZ7Nb9a>#Q^Lw^Hs1XoM2JAOcqPE_#a}gCGAaQ~)NH91VWqF93 z8@D0LK;YB<(D*D@7N;q3(cR}J7SoBtM#sk93p#OYH8fZ)9Zp9tk$eDqN7G!yJQ(R_ zCT$G66Vy8v?1q7Rt%un*Zr>BSeS+GU!J^M^93}#BY_+th#Iayk4NBb7^OO_ENkk}d z45W(V`NXlq-6KgvEyXkPxK&LYvttAKX)|iMq0yutB8dSdqSo2 Akd$!^8co?zAm+G4foDyt2|55Y@(6ec}X1 z2M5`2HtR}eGewt)4G cxm7O)|6U8xt zgkT&+h!O%nqWSrJ?%njPKZGJHnexw6 sSj^L$1lcwpwv}-Up(q$Zk)B!
!g5-#f*^a;cTX<2Nxg;?1m=VVS1oGIhYfzNS9cbVA-hW)K zW0D|@d~A?!5D&gY)6fx(69jRBpfTTad)hNi2$@6JF5eo=3wi{Ahqo0Roi4CFUlpO8 z;$G8AS+!;>ydL=FU;V|Um&vPrtr^N+prH5#kc9aHCG-~-t0biO4DlFHPNv#>A$ zd}Tw5Sp~mJW|>@4B9F@L&i}(CNrIwDkX5ma7%}1?h986(VN3>XBuPG7b&%}i)(`5d zG_*GuZz4Gx1RivK{=oL8fyC67GD_`$tJFcSGnl&Atg9K>I}Oups9S~kY4$QZd8}kn z>9VZgSmb69_+^!!v+YAxB`B(tS+eq @6aWLlEl>iY7tP^E3;zTjaq_9gK|`d3vi* zBz>dS*LhuV@w~m~RqS3iI?Zjs1H7leop;vp^7va}JtOGMriS6656z vWH^~zdJ@_{l~(-X6k#^+;9L8#4$m4Z-8&U z{2H|l1GRM{Bj7D!0`Ta61Q?!xQ}*U%5<)QPg^2tFs@Ft&n>vT^d5(2UgOiecQ4nQ? z)YohnR(=f?RmR!j2)FKcaCSICd#i@lRxN$T2=2bO&WZ#8jLx|GpHrByj(;3Pc=@87 zzk2t<20Cdyy)m5xW+bH37VmSXX<~Sq<|9rO-DWMfbDpN`X2=L!l9Hiet<-%~tr)en z)XI4hL+8lYB5}-!TCY#7vun16M#nBsdQ^0TTt?t>>&}kG7<)`PO_$%jw 0qDO;Br@ zaGGhIN~Ac!WGE8-{nKg}heRDgP9xGZOQEXc3K+FKcQ*lmRK)C$sM?raOS@rG*YrYU z#bhMbXV{&H>yPR8i5CdOvMfo0{r@zmiUL&;?a6q2a(If}=HjkIgb>6@f^irj`XB!* zB!e-&`py3fKTZ%40_|u25mZfwX*n<~2c~7iuxe0M4Vv-#^|xOgu4uruq++mQ_g65D z`i2cY-*B)oma6G+8r6Y&MI3MZK00#<@;DCRg)Y3bf8I;oD|+KoM8_Wi0KV%DQWpgJ z-ZB*V4I`B+o`t1tm=|9$ujCb>yuzCVKIpAok#Bt#c}zf!TL9XxQa64d0AT#qA0bI% zcmW+tdVvcs7-JH+@Y7|(mw!ofgQht_0qU-Ris=DamSJ#$k&elYmCo_1{1x)R;Ms)j z&3O+mLM)dcU5Fsz3tF(86;?PuKE0s*^JQUloCkr|HZ);=Fh(A5GUkGzyDvo{Mv}UE zn$~=JcjgvX(gaN_^yaj8DY)U+GIc9#I~tv#R%=h+V1*Ul5L_F1odBViAo2+00)Im7 z)Dz(|AqkQshAb @=TFsiC<>i8pEv(lrRa>#=rVS_0Kixu!m6uqwmF|h4&!~J zq&x*sQlE~VHBj3fAoOB{Zi2~)nbz5YqG&VgT&X@y(q#!iLhgP85Nl_U>}r)PP;FT} zO(~nHZv#sBs+vy8M@179vNP+->dV!YAxdJz+L^FSR%u-%HSbJh>lU;GkJF%uplfH7 zREnaN&hac|FCj_g_rRY(a>meYiKa%Nr7u*k70P)34@J_pz4ZYAO4Ob_?*l%tck%=P zC1|>bG(ADLzmG3J2Rip1+}U_oC_{`EIQ)*@V~p@^Y?+V^2|FL=eIp_7Zi-$klAq%z zK0-gi@W2y@V^x>o^+S05FtdjaCbSK)n1Yn=(j^3=V;`#5!sfje8XaJ*pf=vvX%;v! zaYmmIU~oD{=tVd>dr|3 $ z2kxepk=`K8%5cdEis-(`$;dcNpoV3#iM8XQOSjEPwF)p|TuIAmbS%`@oV2YfzUViy za{{3Bn}J-3%oeS0ZDi{hIVUJ*`QtFcvSDD@b+P|)4_1S!mh}z5kZe|IS{^wSj@8?b zv-NBlvmR$;EwM&`4LG)rU=n4v*m-^UlSBV7GJDbwp@ucwy1kBir#O?;5xx~kT0%r6 zG9$BZ@7#sc&QD;~ygDJ0a3xp}!03#U*^(rc$m8OVaEE^Bau$hWfRe|mDuV#H^??6P zg`EiiNHl*nha@2>acpJ56cl+`lVSNCKaDsBfLhap-IQU~@?)`QE5Ba!gK%#V0=sTt zJn(RQNEOG8ck`eH_967beAs<@y}>$?BtcaaG}Hd+^T8MoT3kwkZ~v-tKSBsbsRg4K zC-9>PsQ&|e{@vfh3tgC+4MVqJ>NZTxf}-d!brYs;PYtqqlW~LQT62ia-ol8&`L=Iw zEB@}U|Jlm6aX~1G21jYaF{zcq8uRuCfOq}~zj*v}yjLKRSvnL(9DBhyBVrhF%xtU; zMFkw-w0{88uwZI-HV(T^JmlKbfBAF|nk++;C1_LBg~jJ_=4fc@!&_K!ZtvFFxQWf$ z3M*VSWLbf2(lHDBE8zamD);-v z$Xq+hioK?;iKB#CS1z^d#eXX~_Z#HZH>5|8N7#7h=H-4^;btTD6GQ<4N$3Q&YD&;- z8Cp8$JL!s6uDOD+dO#^!KocZ!jL=J pp#T6N07*naRAQ7TYLl4Y<*!48UJPy0#>U+yj$csaWT&|e zOn}J~0Bte **P=sGbsf=@sG6vM*_>RT3cTR~%s)=hXS2rwjv+;_V6=$kOlCmbXg z7l>m?lAs!jpf0r Gu*m!8>%9s#_^#g2Z%yb$;eLv zV0h}{V1Ew)(7sDMG&< Kj0i289id z$^+s_pPnx^X1aBIo7!g|9>T2A9LGdPS#iu=KHbh$$FB<{NrI`VFf}!E;{J64{OCdT z#3w@-`tZUDqBz3X>m!N-_<;*Aq;16K-~Bxts{vKfU}_eOlt5Nh?ad~RUnh~uIf&z% zRQ(lRUoZlfCyoUa2;=VKpJ&8!9EXVG5WNu%++vDjFPy*&+$>lH0R5~xFqd@nsY1`A z?~j&g;2+<+jh^SDn=Zq++J2G8r95ZnlGv@VEM7NT_!V9aEKLJ5b>fxDRk_<45<*jc z0Koouwi_A%+Mj#{X+Ms-%#BEz(ybSlR@Lx#+7^Csw!m-Xi(O>-GI25Iz*SiNuCN%G z5Np-jEQ68100i HVz0hQ-)^d >xVLN7s6eJ5Matg?e(u?EW>_hCCt5CTLIwWAE85N5nn+BR_eP^!*g=_g+^ z@jIne9h`WGABhtsgaCx)6HX`hS;RrDWkA(ssD_++nkz_>B+IdNnYaq|D9N9nrQx1N zusaJr9xF@j3nK{$;`CopZK@bDluIDLauijKiqN3W*z}D+f+SM`FAGK*0|cseF`GfP z-G$TEGUahFq1RnC uig&WHMkQKo3RTw>v-?9Qj=9SWr{z zxpCh^-KJxvkmcsG#2my-iOhnaXgM|1+cxgro&7!|r4Kon1@gh(9?XUYMUgP(2xa%M z2UWjY+Rv^V03_77Ab{bShe^ yiSTlkQJ1v?}OC5`XEE^qV(LDLLq z8trJe>+8b0Up)T#TmrcWLJ~w#h> #%9e<1 s?%j2@=qprM!H*<1*Mch!Zc5&rmaPuHb z609u$@qDg<02iF_K3~Uz% ?1yl_sH!HjTo2ef_LmJp ztH@veDAUMUa)B Kx8D}Nmn2m2nTWzlc#{z{-OSD@Nn#{~q`A_R)Y4YYtZqw4Eu0z2 zr`s}QO@gdRnHrTvyi`}LqDbQmvcFgkq$;Sr?;&b^^LAX=8wAbD70C1Q5CgdaIZpp3 zG0DgwyIoB@);0`*1U?V=`&i}j;?4p|=*^VG^jRV?LDZ)m=AugWB4Bpek=li7NRVXu z90jB0>&xm2AR~?AkU-Vx^9caDa}@zVwEcywWaRH w*gdT zs9E`4==Q?W`RUwuN*&bDPXL09Jl=Yzo{``Tc1x@Lo|oid7$0DG;$wUoz**CQBS6(< zpb7LQBRu^`RJ$~58`!$n0?8glkvIl`;~q6dbJldMsncyUitV~f=yhNg%#(fqtEp$t zeSO`)=+vi{*BpvUZ$|n_kl^+LO!{G_lD6AA?21$d#eBqs5R5rH?MX_+_m1jlbS$X4 zirokIaCSgd$l(b+7sM{y+XSK#8GzRdA%`_IIyUNS4(D{7%k-Q|CG|SL6^5r{?7yUJ zwbfAJ4I= ejQ&Ra(ohQ)0q$j>& zt~4S9{V+nD5O{HnDD6i*I_+h|jiJa;B^kD^LX~7{E2}CCyL_oK?Gx#W3=)>_ KMT3M;Je_G06arv?>B3uZU4!GqJVIPHvv@e{^q}*8n-W1sWumikqe1r zt^=}mfBJk3TX*=#hq`dP@QSWo5)(vTB9OgJ71Zssn6haGW^t&C^Q-)ZvwM3NCw~d- z_yGucF=y-2j?=!*+ &9jbAER zj@H@^om5kGnr~In=|467TQ!ys#u0)ERSK9j6{@MgYH6@i8mae^DOU(22`Q y z^_FC5wgqDXL>@sL5h$tzMQ8u$`go9^D@9Skr^%TWSw;v!6ou4&)=i*jsfF_*KlVD? zoj3pe^xC6nTP;lZUNQ7!6M=lR@b*j{t7J`tgeVmTPE*M;B`g4tBmp5Y2q93lykn6h zF+#ZuS(Tt@`Ell1IWJOQMJR%n8~Lx-gAurk*`K`4P`K#A=f6BFt!HXlrtsx0n3pAK zft;wtSp$A8!1~=L>KzM){XwamB2jzy-3`1r`X>7xc`+q?K?&dczn!X%3utXRQ@=AZ z`}lcI)HZIJIPN_M+Q9K>fa5Qo&n2+|ps{Y32;au7@_Cwdet M0o1;V~P5IQ$@M+ZqLX?*(K7`1iUb}*aj{D9m(9EnVqdKi!cfB>TtAA}$y zk5!$XYt@j^-}5j&%~hj~qMa@Pn _v9bBEdf?==`14k^1;OCN#okNW zUw1k>+(8K745OE4U 3XYGCbnHtj*vgwi;zSK zCW8o8L&YRj(@K&AZy3QFM%g{cu1_iNaSAV0R0A|cgWb~dduGn-fvpZwOY=(dSfcOt zz!d}k3`dzdKI7HF07~RBkIYUxMgKrNj@t|2m{cWC(y;@}9Ue?p@006 piSqc(6bP=c!u4Pd|I1b`{Ftqt#}!ss z;T2(;u&mfDJ>c@BPeYQrEqDdDg}$Hn^XC!BJ k4lfc;a`O z!yONQl#iPWqkV!rCR&==+hI9y!wDs3n<{{ceCrJ3aX(X6GG(nSD^NHOWk&YKA%PL* z)?>kVK *(=q(<3dQAvFlXI@4&g*H;+<9p%O8!pWep&&HG?svO{#_N1|F0;s z7z2Rth~BK#@z(@NlB5JIfq82Pv#vpRROnMCsQ{pL$I40InW(NbuK?CGZlW!5fP}AX zs_rAml*PFsHbFv$kW(gBl0`-AvKHD=KoF;Mtg=cc?^zDdNuSn3)6 #HRHGfP#%~ zoD%OzoGK;=RYMbN09mCIaGEVa W=Il3f7@q|vfMG6so>TR8riw&ayf-Ng$Pl*qf;Ad z0*q6)gwctQ+PZ=Ih5<-YP99ef$J6?RqZ1#K7bZGs9^vM1DL8!6OIOlA{n2rWIJTP_ z0?q}*O)be4ua4$s4Lk3P=YbHwu+-dYoKPiaG01V;_Yj8(`X?ha*K1HT89_+zr9qeG zECztit%mTsYE5nuf*Trr@$r`ckXcr*-OJBwQEd!}L@gl%-52zlBr$;!)Y0hJSsm(j z2MB>+T#lZf)(7_bA>33UZZ|YoZ5>uy2S_z>Tm(NjWaM%7v(xB7MUw@hR(I0()ik2E zLb1LmdCcd*X2h{$(QBMhdgd4e$m(P`IoUUjJSGXCgbr$Ls!H9yU8Te>OjsRuE4H~F ztZZ@TLsb;i(x}v82GZF|zj=cWHZSs;V>Px~VTCKlvU-xmT9)&vkAF3a@vG4hLf?aC z MH=WtS3VTBczN2y<0@wmOJC-6Mo1X-40>vEPPg}E)vvn^+0FfWRc z5Tus xEW+B}F7saySu|Rc`dyc#I1`@x7AHy!nPZyuqV)f<_zw eKC0@88C@1=W!0@7Jm=w5OdnLI?&2K2*1bTBC&rKcIbd z<|r_&K2_%?wtG1Fc7#@K6Kl8YXm2;D+sJmg|JB@} l~@!Sv~1l|1+zWwqW z)Yf&{?~?%CVr^!GasL?O{k#tSy=wdFFc8~^&UOurb*iMj^DfJAN^t8Tcg_lRWx0_1 z$1eJ(V>H(sG&gK8H>-Q!yN@E63OOrd(nT$`uZ|-EC7{2*vF6~`y>edJ)}})}bh5fs zhj A4l4#nNA^8KDf^x8G+n*e}D+n(X7v?kW0fMGYcy=Lu_qDg?}V&uE@ z{Osr3-`N!(NV{&VxU)AHvnOMh7#NjHuGsHt#}2FebcGcz2N!YiTkbfQu+1VHaNGQ9 z7^5;DW1)HaNlkQ4h*C>_KThDsG5jb-L=udn7*U)Eq_UwZFw#k}D2|~jua^tM3M*VD zUg$AMIhK>6;`yz5q-V}yR^f(ERkFEgo;)t%y|q?GB;T%2+e!o@mYD*166I|j%PDS} zHJG*o!=mFc)2_j^YcMQ36)asHE<2#}ci#D 6E%~Bp-^MYxDr$Ho er3v6 z0C@NP9c=8hg#EXW6LX7_qVK-{9t>MYCnd6Hk+ckK8 i05mt~Gx7OfJw>;_kH)rz#w`QQZ4=afq>mRz<#WN`KXF9xqo4i|01$W~ zzWU-T)He;(H%*`p>~ci5W{QIryRGMWkO|4uq@$C7+V^@u6vkM;lUouEPTh>mWyqAs zDYowA7PEmL<7|I`_I3lE?HcN9Htv#jF<28oZOzKQvhr&!QBB%Eso~|bXRuqT?KOP( zqX+|Ui~*P7WppCipY1*ruW=kEc=`MptfmgTrGcpBs9%Fu9jBEoDv{k@s-_O=Xs+3) zuRC!2p0FFsd7Ra4(k%rN7&R4>UI-;6d;wtdfmk1>X_mTHg6%a3SkB|&sf)cAFJU(| z$f>3Ka6 REgXMaFVY3@DKiE>V8oaW`Fyq zbONM*dH?|M+%Y^aXYjx44PYoLtTgBaz)F=o1ONH`_R7iVhT+2O?uE3JOYi*V#J4V_ zq`i#eEEridcc(=PsMwDzbijE9p9kbhl)u+OdQOWxy$Kcyu|9wtG|s2|1)vUh5Y?a0 z-|##ji;?0%FoCY0Uxi5qEhuquI&k%3TVaLEz^f*HZxU)7mlKIfVw%r)(R|K~>|wck z)kz!gzQ04QoGbF}0*fnY0ZOiRcIh#eA!&h}sO|Rmb1P>6j@*WeL%6AH>^u^ifma @8i*DU!bvVp&L%nxMe_+JdAOGqoeufhtGE$Ov0?J#w`QR_ar!2EFUym##G0H z{R#F?o&cbEgYW;WgK;;=a!${x4+D#qRmLrB?l$qmA61+mLdjpII_CQ=jrF{&?%oiO zfAbXpQ04K40W?FLxg(tA&fO)o@jE~Ie)gG7)&zcxCtrUHXI)2q-GJ04xZ5t&tzu%w zC=j(L-95-bYMs6AK-J|;am;>3HzR6Og^x4NzGmgtRJbWo+gz_<>tVU?!$@nko6{^> zB2yLe@RZt9_fN*KY#X&TkqCPE)rs)Ey;aT&lo7S3E?&NPk*kgs&|JqAU^$^Pklaq2 zSxoniC=sn{Dmu67=%Buk+F0i-qp5 RN3_UdJCLT)k z`F_?P!cbM1iVR($#E!gjPg>zML6%l4#mI}{C)_fUfDqcJXxKgop@dFC2o2_!(sVr% zZ5k9=N?q-)`G?#9NRk9oQ( xX3h5lK$l9SM@OKqGTba`;UKul!xxx+DB$_~Zrc558AW-6jdWhG#=M2J!+IUO>kT zz40lc t=x8iy% zIMz%c CB8WDRx Y?Y>>) z s?j~%h7&GdzC_*|RKI@gDD)hdHT?wVIlI*@N*RQxpN=h3G2PObCH31-j zNHK`aEA5t<@Jq`KwR}%Wv|MMO4V^$vB*b#X&HWaG5xs$%K(}QmnhZs!P6fK7KzD%A zaai6jRpbs1ygbh?cmi%8-Tpod`-9THjC^cuI!I!gixz;8YqzK}b>U@1?b%-SZy9$3 zA&; jGigX>J4Nfab2{z{p3QgzWE4u)TgCXi|c YmZ_RCRtB?6#KK zLT~Q2u(8_`9{bIEzgOCaVOjtHv#VzHQ4Luja#``yNMiu#?|T^RPZ0S@sx;<7ZN{G- z912UPS&e7vbs*1MtpVFVpk$?8I6v&X0aSg=Fgo_}_%~nC9Lb=>F#tdZwtvVIpY#_p zqLPrjDJ>Gp55E5p8q4d`p?_+fGGR*yFg}~0-WJ#OPsRYDf14Xlrpzs-vju2A oBMdof=-XJO zZg>+BPGWe&7-n6D;c)injr=paxA>+b2VLop0(hxCn^{*N$ufWde6MiQFZa2}hboxL zkP*wk>~1kIwWkYBGZxr;3JB{+SI-m7DwU+Wzqd{;8+V)K;E!ntqZnQiBT4cjeA^qq z4P!(JwU*Tt8HS?5RAs1=1Y1?1DDsL_Ug6b1O37USBY%p+L{z G((zlFl2d z2tyA-J0VFK(F~68nK9!!bL~w8KlbZbCWNlg-p4cd6;J)U`A0(F1umjEgda}e1unuk z#CS45l+ZqisaY^|8=9iQ)NL4A?(_l)_`ARUX8>js8OCq@7xRDMHADT=KLb(=+!d}D z?ESbXWJv-PDNQZNo^M)!Zp`kBP7@Jz2l?`WOM_MjAy{vsq)2Yy`U$Dm8FHGlajki) zdIOFy{>9^;XP?`1?3*iz<&e%Ts2g*uvq|rYjfC2lND_V}ZGI(*Cv27-kg;>N;HKv}L=l%A_Oo!k zDz0T=pI~S}uV2z+{}QLR650KCj^kbk(<{z`3(jL1vd}J9F>aXG8 TQv6fCTQF;(Y$S;d0QM<7D+kYCK>?7X91o(qm$Z< zs6Bp8gK#>{ZD0bpJuz6Os$2jiTj(GDLpo{06Secy!aNE2 KQ%m8x5>MbL)%`TFWrSnrzAy*N#MS>QXp0^i3rlJLa!TzLR!8{w|Zcrjd zD^ ZU%kNT#O6UP8N z9K#dit#I^hP8 Bek&g`M5yeybiHNTo=LUDTm?3SM8JN9sv zm38)_P@mH>@xS}v9^x?0awc0T;k#C)I8HB^QCDw->&^;S3t5q2x99|G=p_IlaQh;C zU^T_565UFbIO7 O~3SBu3 %7|m6gmFsj5RznbLQxbJ zh-3Eo=HpToW0C~ZpnZ`V=Jy8~qT`S7cgG(I%RV_g#e-(8v~ANg;D7z!mzKXT1OC@v z3i|`)-**$q;}tFlvVicTfUA5qWhL*^^eXfSJM)KpD_jF+5zCB7{?YxPRqmT45ysvC zp6?38ac`76HMMH%h|+fLY~ts3)|xsqxb9y*%_H|&8%PKcrumnHI6;^s_$@s_klY;7 z^Rh7@k6v{Z&JDGAeV6USM^OM()lnfE<4UWZT;_F2Rm3m9-DNd|qRy#`?E#V`grcZ3 z4?2tgWgsOv++sCb7*gTOd1m7I?JsL4zxY$8mU|tuD^rd!>$lDeb3X4eD-JA4#fgF$ z@tc{#aeDbxD)S5HktmjTfv0h{>Qze1$JO?HI839+NRqBAT^|nIj*1yWGwGKkp`r4@ z6p`n`cl|jzxzgpnMusjsUyG8z*+wY0Mb`o8{3mDm3ANW@sQs$IDT>L0_sm-eUg4`6 zG7Lw7qEo_mbR5pkNj~K^S0`<_{R)z{J)NXw`)%xOVdDp)ns%OjG9wiMz#9kn^4G-< z^ge#_yB}nBvkZdjkONZ<9Y62k%g<8P@pd(#%`hw9@hmkhP*&1T10f_Ui&yy0BWf2V zX^VAQgcM~8UmkZqY?oT5B9Z;#*VF6MX>QXT#lvSpofZL4(gJ|$w(#BuyO|9(!)9Tk zZl0BPx4)0~KKKCupuIyU{}&;#i`yMBtMQc32C50|dDja-rWkK*Td1w;*nFo}Qm+G; zNgM;IE&Gv=`UWG9>ACaZVUq64NMnZHo+mv2&DsXG?za#HF&=;Z6>1wg6ivpRAGZ+t zWtF04Qi4Y{WLOQ63~Sx7FgnS9f8Y+`DL&R7 NpOnB$nY(hiQ&GGl`Jx`X%DX;6tf;7Ihe%x)p?G3-3w7j+t0kw|YMj5Jt^zqNEe zFLYr55Oht1K2UF4up1KEDNorybrEuD#T#oj`XjMj(rqO>kUx$g1vct!8)hA_>js== z_4~@IiR91LJ+TdP2Of?N4pZeZeHJJEvixv-piU7bgVA zXK|(o1S67BCO6+UETZ=1(f54xJ+XiDU4K-$EhA3Y_Dz#sqbl-vg*OC?MiolZDzJWq z3qh7uIA#NmIXjr{
N$=6K32m3WIEEL|abe#N&@igULXrgNDS@o7^0(i3 zsKpMu9NI{<3))kH>xdND9ByzyBvIsdS&iK^(xz;8Ieqe9S~Q@w(Bq0+9V3^mlE92e z&L2r$L7ZoGD-^n@&}*5)%`G!Mhf5PFGq#=05-xTe?pjNgh8Rl_=1X0>Mah1$6!}}; ze~B8MQyn~${5%JQq6!wWVk|@Tg&_%nq=>(P#3U zi3?&$-**ZlLl(>lmMO|B>Q-%kAeJj-Cm^p|zB0Z4i!nJDgw-aPbkDgncdmsiecfZ~ zc{j`=?ZYLXS6XIOy-0Pu(%ffs8u47U{30Z^B>Y+7VkjzmNeoB|I4kEFOz)ES;`@Ji zzqE|y*#&^(=Y4GKG_kQuiQc{;l3Ty|>!+ptci!6&zVk%y-QT8Uo+L|vj!aF Y)Z`vIOmI)?jgVdsuWaN8}q56ef(2wL`zTtrRFz~{AX zY|{yR)sVBlo%;^DPsReNTt$+y9Jxi(SdyfL$X%gM>E9Q>Jj)1L-J-v_vm+9w?l;J- zkPG!;V0C)$$COm9QqmSl+G#j?*2AMusY;lgr~KSZ(EW5DM}s`~FauA_wl*CcJ?mlb z_(^uI_-p2%jWMWo_IQrfI3TKo0pRFq&g68O+kgv #`vP-yA zy|{NYM&MD>^wFnZl-3)zg4%`wZ;%tcR#VGfSyo$zZYueFwJc$9NJ-B-KW+jD;o%q| zNwEDRZgPN-Ox>6*LT+CjhY8}4Kz79mh4E;FW~zQv4SFtk3thV@P8hWASea5+oixzd zZlHB5Rd}D3t*MJm+PEif6GsVp$KoXG+IAhdoqt!jAQh&5=21)Sv HaZqn5>>$=R#Qd8UdQ470j# >o4XPr+R1_GB z3`4y>tHie*yandXMO8 Lj!WCF@aCd7C_B^4L}hz%ITKCuETmU>yTO#dZOh193IH((=0UTb zk-}M<7RB)5xRU(saX~SJkP8XZKE3_ZbMJdqh$FI)y>sN>95uAMJmI$tIO3#&oG4!Z z;(Yr>s=_A5Gb_|7_nK57oA~W6I=4^e^%dtrLgXjeBw1D6*q3R?5SE|kd0XHlNHVj! zKChatB0yl~b~BDsxX@C?r}-e2AD7y1F4#G{pfYfYLlTgM)VosQ8NECbSSqrs2s; zP%D;QW?MCdnO#JUC_w_@!*A2;YBglRnsqw)y9B1)`lWVG;_48Ww_EbC(@{Z4bN5W8 z8u@Z7AeKFKTwZ+HEFo_wlv%m!LQUTS&-$!-ows?tMqJKiB_Jfv8GRXw;3!&h@O8}~ zF;xVy_x-t*z2~?;m?D==_!bG@qN2B`#4Ubj lvbSOE?@cdC(vDtYbmgBVb zj9g_oY1em}knhgE8LE`RBvT3Z@#wQJ5N%sfO&O{wqw~N)_q%b~nz^DrfJpcPSjd8T z8iC9IKBrE}!p@Ecq$I6CWX~XJ*<~LvB6F9LnGbK5b^$P74SV#o2ZXupHS2>u`s|DJ zubA(6R=JGu1yZtKEtCFL=ch`AyxG{r(bFC_@3nCBv bL zK!Sn)eMJ@W!54$fM%#n~wGjiYVM!Utf+18>u53RB2Oi9LEhA0a+cdYQ=r&VkvWp6S zfZ*V}9{S_s>^Sed|NXrFDXCkP^H?Z_08v2uG~K-cth#~bx{c;~bsk1mOCjfRy6|*g ze5OY3DQfFRrbhP15scc37%?S+v{ev>W>7IqcV`H~8 zv>KZ@>m8-`&>D=Ig4UhF`=QXOF$zIlEXd0r(PPSigSunGZW?&lx}V*{lM(;i6ZB8Y z0sMpoZ6% S#^oL9m7>ytC6 zl0=E(G!L<%C{Sf-MI661xTsoS%D{cm36_hhk8k+c@l6G9quRENOl3C@j5zLlV!!Uu zX)hyQOjUuQC@@v}u4typJX4)*-})v&-T3`iU4^A^(JA@MJNgPMoF9@T!P02ohy9%e z^-uo{&(i$k%J2XC@ZAw6?g-vw4Bw^hOOwe6?qB~4n5sep^U`+BDztMeTn@@tZOIm? z>K#)O+D?Hi%Se)V`kX!Hsl#SZs81(-XV
d8NcwRZ zf?!eJaZY(m^;jgHgd&P%Oe=FC@^|`iitBSeZqaMOU0*V SK@E9)4bWM49m*ozxLcPk0UZVNCR$g;X#V&NO-yni^F$aqa2A^<{RS8LS z?og>qslzl#3Lwj~JC2I_YV18n$02lEhN8(Z8w!90I=Bm-uF3N@Op}r#nodcTBo5J# z0i^2vh7A1e|9XV+SR_Io|4kq7{9a#JR#f!@lx$5Bg5&2s96w75-(5-`&$5CpleAQY z%yRBN`s@otUXqctFb8<_**@@DzDK9IU8)mbE=fp6_-1NaO{yf+s_d?_a?%E2{0*JJ z-`s6sZCAr{Hzl!M Y}mk;Ot &KmgTvk!&4LR>3HSt&Q7+C zJLJ?4C@YNt0M FbBukK8KAx;B;cC}UWEq$PY2ZD lrMa6#O(cKUV04Vf-lVTHN4%x7Je zWmvTatXhRN^@smx>V8R*z;{PT{)hi2TlV#D{uR79M)2c520+^}%c{Y!X`r%YJ83&t z%baRfpr~x=nJ9K?aT;Yx*|#a{g(bq4(}Znoc@zauHGLi{TmYTqF55pZfTB8>w|wRK zAwqz3VuYV)CWNH ^u76E^qm%(fF3Mc8ki<}6ZrO3bHs)zxtjF?TRN;xBx zrUktwq_mtqhKcaKd|>WDDU``}UHFPth)RXKj}S;{dms^|P#*#k^T(^KEMnP5GRo^m z0H~@ANYp}HlBM)t;T*BHm?M=XQe}mX*$?9)9O2xDxqPoJUBs1w9-ViKC^;uwTIH^m zUB0M*c;P01m*=i96}+-nw`FLiBB*(58#9RKs&{n-7}48MRG ;C7ItuX=Yq}|CU|N12k3K{qMEiu0)vn6JPBS001 zPFn}GYSm+zr1j7Zp9`d_?}pgeZ33We ^Adv zWkKXPEhjOLU;k^Wpk<0&Rt{gEK|1kXst 6=B1@$jmF8*F8vr|9h%3|xPP<4(Nj-U5% z_$19ueA|Mmr*`AIjPBk(y1&}T7r)B4@7x#dwE1I(euyuA^+lE|yK&2elWxj#9T$my z0O%c*7GZGUp}#jl^R@*gtrKKm5|5!<3j9$7e-!2R=iJLG%k|r# KzR6^_Mi@puTB9QKg(v25@lL$7HmT5y}kpj-8du6v;hX!~gc# zW7r)7Af%L=xrp`8G#Iih*vooh1XY%xDyvBLn}SBiMx&D|(S^9r{L5q@dZC^jjWC*s zkqUQrcYv+@yCln5flk35y2ic}U^MVBIB@~LhT9K2 R%$;2X!-G5kGxo24E_G(8&vX(^oY23Bmv^yLQZY?Ezw}uhn?>rj !cXd&N)C z>M;^`x!iG;DBTE)o-g5zHD~>-O&8bj&Mao$mckBK^a8wTeQ+U=;S~mq3I|V5QWgzg zAbXPqw2f0Xp}7ZMlPDlY+{)}PUCc-0OR;#-B;<;6lJMt&G3iK&W9~S`b5(VZa@$-U zA9~T7tyq(bly`-4=*r`qci)P)v2@O7chOhl%=J7GEERTGv_gws$Mcqdy$HrJBA-At zq$^SOet+jb!QF>H1ORN_zK`*+U%B0vzxwOavUh*-5AhfO(|=o99>)>xbbNvE?KG<` zRe4xexqtD4@8>GIZr{bz=ld`Yfwi`YW?h39#K52r|JQvykZj-p*gJ6H0~jNp#P+vJ zMQ66mk(UT0EpE^5hgOlb%?$^H5F9`2