From d0bb3cc75cf37eeba7f05c89a9a2f04229fc6cc6 Mon Sep 17 00:00:00 2001 From: Minepig <30530115+Minepig@users.noreply.github.com> Date: Fri, 25 Oct 2024 20:42:08 +0800 Subject: [PATCH] [+] Slide code support & split multiple patches (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 功能拆分 将不同的功能分拆到不同文件 * Slide code notation support This is part of Maimai DX 2077 patch set. New MA2 commands: NMSSS, BRSSS, EXSSS, BXSSS, CNSSS --- AquaMai/AquaMai.csproj | 7 +- AquaMai/Fix/BreakSlideJudgeBlink.cs | 24 + AquaMai/Fix/FanJudgeFlip.cs | 32 ++ AquaMai/Fix/FixCircleSlideJudge.cs | 43 ++ ...deAutoPlayTweak.cs => FixSlideAutoPlay.cs} | 2 +- AquaMai/Fix/SlideJudgeTweak.cs | 89 ---- AquaMai/Libs/System.Numerics.dll | Bin 0 -> 133976 bytes AquaMai/MaimaiDX2077/CustomNoteTypePatch.cs | 426 ++++++++++++++++ AquaMai/MaimaiDX2077/CustomSlideNoteData.cs | 51 ++ AquaMai/MaimaiDX2077/MaiGeometry.cs | 109 ++++ AquaMai/MaimaiDX2077/ParametricSlidePath.cs | 226 +++++++++ AquaMai/MaimaiDX2077/SlideCodeParser.cs | 260 ++++++++++ AquaMai/MaimaiDX2077/SlideDataBuilder.cs | 475 ++++++++++++++++++ AquaMai/MaimaiDX2077/SlidePathGenerator.cs | 132 +++++ AquaMai/Main.cs | 26 +- .../UX/{CustomNoteSkin.cs => CustomSkins.cs} | 69 ++- AquaMai/UX/CustomTrackStartDiff.cs | 66 +++ AquaMai/UX/DisableTrackStartTabs.cs | 30 ++ AquaMai/UX/JudgeDisplay4B.cs | 75 +++ AquaMai/UX/RealisticRandomJudge.cs | 25 + AquaMai/UX/TrackStartProcessTweak.cs | 22 +- 21 files changed, 2078 insertions(+), 111 deletions(-) create mode 100644 AquaMai/Fix/BreakSlideJudgeBlink.cs create mode 100644 AquaMai/Fix/FanJudgeFlip.cs create mode 100644 AquaMai/Fix/FixCircleSlideJudge.cs rename AquaMai/Fix/{SlideAutoPlayTweak.cs => FixSlideAutoPlay.cs} (99%) delete mode 100644 AquaMai/Fix/SlideJudgeTweak.cs create mode 100644 AquaMai/Libs/System.Numerics.dll create mode 100644 AquaMai/MaimaiDX2077/CustomNoteTypePatch.cs create mode 100644 AquaMai/MaimaiDX2077/CustomSlideNoteData.cs create mode 100644 AquaMai/MaimaiDX2077/MaiGeometry.cs create mode 100644 AquaMai/MaimaiDX2077/ParametricSlidePath.cs create mode 100644 AquaMai/MaimaiDX2077/SlideCodeParser.cs create mode 100644 AquaMai/MaimaiDX2077/SlideDataBuilder.cs create mode 100644 AquaMai/MaimaiDX2077/SlidePathGenerator.cs rename AquaMai/UX/{CustomNoteSkin.cs => CustomSkins.cs} (81%) create mode 100644 AquaMai/UX/CustomTrackStartDiff.cs create mode 100644 AquaMai/UX/DisableTrackStartTabs.cs create mode 100644 AquaMai/UX/JudgeDisplay4B.cs create mode 100644 AquaMai/UX/RealisticRandomJudge.cs diff --git a/AquaMai/AquaMai.csproj b/AquaMai/AquaMai.csproj index c5f711bd..c3fd3675 100644 --- a/AquaMai/AquaMai.csproj +++ b/AquaMai/AquaMai.csproj @@ -1,4 +1,4 @@ - + Release @@ -71,6 +71,9 @@ Libs\System.Configuration.dll + + Libs\System.Numerics.dll + Libs\System.Core.dll @@ -282,7 +285,7 @@ Libs\UnityEngine.XRModule.dll - + diff --git a/AquaMai/Fix/BreakSlideJudgeBlink.cs b/AquaMai/Fix/BreakSlideJudgeBlink.cs new file mode 100644 index 00000000..6c5fd10f --- /dev/null +++ b/AquaMai/Fix/BreakSlideJudgeBlink.cs @@ -0,0 +1,24 @@ +using HarmonyLib; +using Monitor; +using UnityEngine; + +namespace AquaMai.Fix; + +public class BreakSlideJudgeBlink +{ + /* + * 这个 Patch 让 BreakSlide 的 Critical 判定也可以像 BreakTap 一样闪烁 + * 推荐与自定义皮肤一起使用 (否则视觉效果可能并不好) + */ + [HarmonyPostfix] + [HarmonyPatch(typeof(SlideJudge), "UpdateBreakEffectAdd")] + private static void FixBreakSlideJudgeBlink( + SpriteRenderer ___SpriteRenderAdd, SpriteRenderer ___SpriteRender, + SlideJudge.SlideJudgeType ____judgeType, SlideJudge.SlideAngle ____angle + ) + { + if (!___SpriteRenderAdd.gameObject.activeSelf) return; + float num = ___SpriteRenderAdd.color.r; + ___SpriteRenderAdd.color = new Color(num, num, num, 1f); + } +} \ No newline at end of file diff --git a/AquaMai/Fix/FanJudgeFlip.cs b/AquaMai/Fix/FanJudgeFlip.cs new file mode 100644 index 00000000..5d3aeb45 --- /dev/null +++ b/AquaMai/Fix/FanJudgeFlip.cs @@ -0,0 +1,32 @@ +using HarmonyLib; +using Monitor; + +namespace AquaMai.Fix; + +public class FanJudgeFlip +{ + /* + * 这个 Patch 让 Wifi Slide 的判定显示有上下的区别 (原本所有 Wifi 的判定显示都是朝向圆心的), 就像 majdata 里那样 + * 这个 bug 产生的原因是 SBGA 忘记给 Wifi 的 EndButtonId 赋值了 + * 不过需要注意的是, 考虑到圆弧形 Slide 的判定显示就是永远朝向圆心的, 我个人会觉得这个 Patch 关掉更好看一点 + */ + [HarmonyPostfix] + [HarmonyPatch(typeof(SlideFan), "Initialize")] + private static void FixFanJudgeFilp( + int[] ___GoalButtonId, SlideJudge ___JudgeObj + ) + { + if (null != ___JudgeObj) + { + if (2 <= ___GoalButtonId[1] && ___GoalButtonId[1] <= 5) + { + ___JudgeObj.Flip(false); + ___JudgeObj.transform.Rotate(0.0f, 0.0f, 180f); + } + else + { + ___JudgeObj.Flip(true); + } + } + } +} \ No newline at end of file diff --git a/AquaMai/Fix/FixCircleSlideJudge.cs b/AquaMai/Fix/FixCircleSlideJudge.cs new file mode 100644 index 00000000..99dfde86 --- /dev/null +++ b/AquaMai/Fix/FixCircleSlideJudge.cs @@ -0,0 +1,43 @@ +using System; +using HarmonyLib; +using Manager; +using Monitor; +using Process; +using UnityEngine; + +namespace AquaMai.Fix; + +public class FixCircleSlideJudge +{ + /* + * 这个 Patch 让圆弧形的 Slide 的判定显示与判定线精确对齐 (原本会有一点歪), 就像 majdata 里那样 + * 我觉得这个 Patch 算是无副作用的, 可以默认开启 + */ + [HarmonyPostfix] + [HarmonyPatch(typeof(SlideRoot), "Initialize")] + private static void FixJudgePosition( + SlideRoot __instance, SlideType ___EndSlideType, SlideJudge ___JudgeObj + ) + { + if (null != ___JudgeObj) + { + float z = ___JudgeObj.transform.localPosition.z; + if (___EndSlideType == SlideType.Slide_Circle_L) + { + float angle = -45.0f - 45.0f * __instance.EndButtonId; + double angleRad = Math.PI / 180.0 * (angle + 90 + 22.5 + 2.6415); + ___JudgeObj.transform.localPosition = new Vector3(480f * (float)Math.Cos(angleRad), 480f * (float)Math.Sin(angleRad), z); + ___JudgeObj.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, angle); + } + else if (___EndSlideType == SlideType.Slide_Circle_R) + { + float angle = -45.0f * __instance.EndButtonId; + double angleRad = Math.PI / 180.0 * (angle + 90 - 22.5 - 2.6415); + ___JudgeObj.transform.localPosition = new Vector3(480f * (float)Math.Cos(angleRad), 480f * (float)Math.Sin(angleRad), z); + ___JudgeObj.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, angle); + } + } + } + + +} diff --git a/AquaMai/Fix/SlideAutoPlayTweak.cs b/AquaMai/Fix/FixSlideAutoPlay.cs similarity index 99% rename from AquaMai/Fix/SlideAutoPlayTweak.cs rename to AquaMai/Fix/FixSlideAutoPlay.cs index 1e3b1916..88a41024 100644 --- a/AquaMai/Fix/SlideAutoPlayTweak.cs +++ b/AquaMai/Fix/FixSlideAutoPlay.cs @@ -5,7 +5,7 @@ namespace AquaMai.Fix; -public class SlideAutoPlayTweak +public class FixSlideAutoPlay { /* 这个 Patch 用于修复以下 bug: * SlideFan 在 AutoPlay 时, 只有第一个箭头会消失 diff --git a/AquaMai/Fix/SlideJudgeTweak.cs b/AquaMai/Fix/SlideJudgeTweak.cs deleted file mode 100644 index d0a8a546..00000000 --- a/AquaMai/Fix/SlideJudgeTweak.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using HarmonyLib; -using Manager; -using Monitor; -using Process; -using UnityEngine; - -namespace AquaMai.Fix; - -public class SlideJudgeTweak -{ - /* - * 这个 Patch 让 BreakSlide 的 Critical 判定也可以像 BreakTap 一样闪烁 - */ - [HarmonyPostfix] - [HarmonyPatch(typeof(SlideJudge), "UpdateBreakEffectAdd")] - private static void FixBreakSlideJudgeBlink( - SpriteRenderer ___SpriteRenderAdd, SpriteRenderer ___SpriteRender, - SlideJudge.SlideJudgeType ____judgeType, SlideJudge.SlideAngle ____angle - ) - { - if (!___SpriteRenderAdd.gameObject.activeSelf) return; - float num = ___SpriteRenderAdd.color.r; - ___SpriteRenderAdd.color = new Color(num, num, num, 0.3f); - if (num > 0.9f) - { - ___SpriteRender.sprite = GameNoteImageContainer.JudgeSlideCriticalBreak[(int) ____judgeType, (int) ____angle]; - } - else if (num < 0.1f) - { - ___SpriteRender.sprite = GameNoteImageContainer.JudgeSlideCritical[(int) ____judgeType, (int) ____angle]; - } - } - - /* - * 这个 Patch 让圆弧形的 Slide 的判定显示与判定线精确对齐 (原本会有一点歪), 就像 majdata 里那样 - */ - [HarmonyPostfix] - [HarmonyPatch(typeof(SlideRoot), "Initialize")] - private static void FixCircleSlideJudgePosition( - SlideRoot __instance, SlideType ___EndSlideType, SlideJudge ___JudgeObj - ) - { - if (null != ___JudgeObj) - { - float z = ___JudgeObj.transform.localPosition.z; - if (___EndSlideType == SlideType.Slide_Circle_L) - { - float angle = -45.0f - 45.0f * __instance.EndButtonId; - double angleRad = Math.PI / 180.0 * (angle + 90 + 22.5 + 2.6415); - ___JudgeObj.transform.localPosition = new Vector3(480f * (float)Math.Cos(angleRad), 480f * (float)Math.Sin(angleRad), z); - ___JudgeObj.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, angle); - } - else if (___EndSlideType == SlideType.Slide_Circle_R) - { - float angle = -45.0f * __instance.EndButtonId; - double angleRad = Math.PI / 180.0 * (angle + 90 - 22.5 - 2.6415); - ___JudgeObj.transform.localPosition = new Vector3(480f * (float)Math.Cos(angleRad), 480f * (float)Math.Sin(angleRad), z); - ___JudgeObj.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, angle); - } - } - } - - /* - * 这个 Patch 让 Wifi Slide 的判定显示有上下的区别 (原本所有 Wifi 的判定显示都是朝向圆心的), 就像 majdata 里那样 - * 这个 bug 产生的原因是 SBGA 忘记给 Wifi 的 EndButtonId 赋值了 - * 不过需要注意的是, 考虑到圆弧形 Slide 的判定显示就是永远朝向圆心的, 我个人会觉得这个 Patch 关掉更好看一点 - * 所以这里把 Patch 注释掉了 - */ - // [HarmonyPostfix] - // [HarmonyPatch(typeof(SlideFan), "Initialize")] - private static void FixFanJudgeFilp( - int[] ___GoalButtonId, SlideJudge ___JudgeObj - ) - { - if (null != ___JudgeObj) - { - if (2 <= ___GoalButtonId[1] && ___GoalButtonId[1] <= 5) - { - ___JudgeObj.Flip(false); - ___JudgeObj.transform.Rotate(0.0f, 0.0f, 180f); - } - else - { - ___JudgeObj.Flip(true); - } - } - } -} diff --git a/AquaMai/Libs/System.Numerics.dll b/AquaMai/Libs/System.Numerics.dll new file mode 100644 index 0000000000000000000000000000000000000000..2ac827b85f2db51d6e1ee5a18c34d07beb929e98 GIT binary patch literal 133976 zcmeFa3xHfjl{a2N#+rl83hsof)t-2`6}ZZV-ooH@WaM@5O@BqmFpv44xqTJ|HED8y_wG) z@xhMMKX=52XK&jx@SNSdF4=w2j)99W+PQPr6$4wQ26kV$b70%ffs>zp?!b;+Tc=ib zc4m*Xq|bVaF{gL2p;a= zfHCP9ZW{1YWAMr=rmngI@agZea#2?B9se6JX7kG3dv;%pOyP~PljwVKkNvGRX6?$| zQN3>_J^*g_55QhP-)IE%qutFn29RQ#pU|Z2!i=GfMf|v3~m2 zD~HfrXJ&9kX4evw4$$65=KJYPY2f#$v#TGOyN>|N6)={)BVDfN)BcUyMn7IbW;W$l zmf}`TDes^!ygyR=)v8}Q$E(1if-2>Gv^vX3|3Yu?WzfK2m)871aeV*oAD| zcIxdpycyBsrQihugj!>0v9}jF3B8sZn})KQEIs5`OI4e_(qob>X$uV?5elZ=9W@8L z98xenhzdcuu{>K^_2Y8Yk|{mZQGJG`IA$r#Ltd%+G9}~0C{33)5!vEO+E(oEzgTmp zkZM#f^sj`%ydm=E-^pa9LhO5n(Qi)HlvcHLEe9e|uGSqW52@+OH$Wkq0{6Mn zsvkzQE&;7%i&jo)ZTBNSxBC~BwvYBG8KJs7+cyHvd}RB6&GV;nL3ylG5lYiLQ&N?B zEL%O6rYrZOaTW;w&8V-~N2OI!Hw?8mAN5Q&kQUpTQ4Z&5h6SppXGMBqJHA*%tIvJu z>oKnk#gWWlT609}Evv`|nk}NwtQ}NthG4lAyH(rVk2cX^u5WN>+uJ(}zF%2R+ja_` zORKIYoGv$}wT!88N5ECS%O3*Qb~yIz zKAN5p-5cyV3W~nk4Nf?A4k+qiIea#z3D^JKN@yrmzF`E;(&>MB`G!$|%S&2(Fst{{ zs9&lj2M~Ukdvmo>&Fmc;P>FX{(B>66V-{wE%9qjP3c<|PGFqN3c9Xf20*EY!4PEVDA$FHVk#Y` zm|cP9vSBco1(+h`)NLVV5!KlzanH`FGb+jHY~T@e*3p}*t#s;N}1%lbcA^3B6>TSY)Y~2*Lz1MXC&hN`wg!>R_0tuJJBcgh* zKk)Fw4_|Xk_l$h6tZ1q@!BtBs`3IfuuY#B`sZd(k?hmh2THA-0Ys1@Tw!E#a>C*Nr zoYktlKb7@*rKFbev##MjvGRDs!H$EnT1N?zA)DnXDIffK zFx1_y^J=i*_*X)N)L_P$Q_5nXYQDDAQ%RR>iOcP$Z`v%dj^b5cwF5(OORJ7d4ISal z9t2u^&>5FZQlh`Lcfg6q$6K)Ag3e&U#>^n~TB;WlWq87b=(8BiFQ!YyRBb(d>}o$u z-!$l#P#lD*SBj5++LA3BVh}MlZ-A?9z|}^+_aDRb68hd0JTN?Dn6Pa%M&FtlJT*0# z28Beq(R&bTfLmYr7~4q)nZ~Nmgvr5nOPah3x(@AR62c=Ev$Y`1qKRynEsbQ*D1hm~ zALBOmlrShwmqt$DtxT`*oX8}ZC7PSzEP7ecZiXRdfg882JkoZr7iL>#8QNeq%!*^J zMmT}`IoDI6g->w)E}`X&jsF4?aUC$*F59hdn%38*4Mdm6`BskAqh4Yt38hS^<3jERM?ShGQ&B>=5KXxLLmgX8ib4u`Zuv=QSG&R&e zhry%FS$kE;B4b96Atx?uH8pf>fI+gH>hDmlAi*dqb9)8(-f z$>+$Eah+jG$Wh&*Z+1im=Jg`K-H^B}1zS4lcIrI0!oaRs*!=<)u!8?{(8Mv93^YG~X7LUdiVUcFRZXlJ0Gl>6t zYY9yMw!i4Z&%(Hw2ig5YN$n2>B5KtykD(W>;SZ|e+boWO-mxyfya|iM%G1y)dS5Yy zP^X-ABOhyR(MUesiPS+LV)2$hyg*w{A(H4sR8lSmc|l%T!N!II?AYMHQwwSO$+)Oo>M?KuMIhjU+Q z@h~(6|44hsR*S$Z>>bN>dfv)2aDT5$yvHTp=MwLSO?w7TS^jK)we|$)fy5h?M|yVW zI-G^;;z2h#MzEA8T50-v+uwFVJ9ds5ia4cXP<;s-w(Z&-j1O8m+VC(|ur!&{D(ptg z}b$+8(FHeV(4(r?nFa$|HT+{)|ngTrHApr`@J24JT4lwT=|# z_U$8%ui-90s~pBEFI}D41pJL}E&zZ2EdD+`gTJ`6m&{@myC)QG2BVe)w#%w3yvjy& z_+^*szT+yiS(?@6Wz}*Xlc=3kF})T{ z*^vIp2J@lQm$b85%9XGk18v*ghra1fsM!ML={zDn3zVjFskGDNLy6L!3DT#p!K`lj z&_3o2q9e&(ZZvDb*HegM#cv4SUiI+cr{+p=7- z;l3(^-F6G~4{Xf(n1`EaUo z^hdRm`Ug^2QDV9*VexlVI?PTNb6ocY^)Q!WSQTcV%3PR6dMh>xv!IUIG&A}YiN_np2+5A`h>TJPI62o-i?3!QM`$IT}>*td>#1xU-m%7(avq($z*L z&@IYUvI0*=8t6xah*dLnkPaHy;e-wtD{`-M)sJeMLh)*FvZ$M_Q=1>Tf^@>vv_brK?jsi49~?P`Q|r zt?P#Cr*pRp&T7`Hj>fPeK$Z`>MtC}`Wg-27bq6}g#4}*%&AoC6u$?Ow^H*rK#-xGy zU>7HF>j(9h8xBnyajvjj&i2l+G&zh0IL~5o=Cfq1Z=x8-cgq~OVXyKoveaRt0?K26-4))DOM{{dKG{IOp5`Hv_#UDz<0`Z4DKIVxP#-nsNYZS^V5K~N`XiPA1yEkT=#IK=$Y;!uGWhZa^2 zEoQ_aA`QzSQxS))I%q0ZM>w>wa%eG?AdoFVn_@QlkiPP_xG$z}fz851V7FgFi)n1- z$UJDbNK3;Lss4M0+cLd$YN~(DrmdATQ9O3$p}uey zYPvtS1uh|n-C8b2H^MVwwuDQDM@=G#?yHLjitT?hUdJrjKTszpZtoi5Hu59`&sRol zSi}9k^p5_4Fe$5B?8V9s3>3lm35zX_pRnWVVF8P0(*d7NBWM;E!aIW_)tkpPnp@vx zWbMagoL4&LKWu!n3FZ9LT0_Gw%ADu7KX#cqcy?{!j67te?IzdRSTV8LK^rXIxD6|& z4VD;;AG{~bNoWVB4?^#0I}ctlY7SEL5Ju?xC9Hc2orl)VCFm9G(j4OPJ)QTRMd8BiD-gDkFfp`3iVQWbIp4?2Y{0jCGQs&?m_ zmxGT4``Sty=f$>--Gu&&bBVItByg{&6{@4RSo-uuJIq-3<6|h4_9T z-Af%N4OVw{eL`V}p6BhQ^!l8wD*Oe_tLkK>4? zgER451>B`)>pVVG)7mi1=kDr2+_)$6O=0S#M` z<)Ix}DhCI#Mzf7ppQ|Yzmuc}k4Jp{D^Z(Cx_9V52Ix&7&yz@N21oxn%8d>C$1O_wI zD=9sKa-KxY8?fLSZ(&rv3D$5bW@K3+ak}_@SJs$jWXPCr=jQX^f?%G`uGfPPXbGJ_ zm6b-;Y8?fqheqruvZE#%Guc4K6d0H>V7>vujBl1%`AS(Om8;=Q1SLpKV=EIrTOST$ ztFrnFzdW3FoniYL>uw_7xXoWxoWossz8$u*M{o|>;veYnIUEJSO|(6mT<;v@YB<`# z=1P9$$yjK%sOwyf4Ne(jpx^KGME4<8)SVof{ZssePM=m0L%ARtI}4OXfMIA2_Bb>z zivi4%ByZhg?F5J6uNu=zQHCZko|JdBCu@7IrItkW8Q4oupP}OlOKPj@(T?g2Zc8K9 zuKiK0*&<&_e2fCiG|xn|^Q-e2go(Gq6A)U%k2LbAgqC`x%9~-=7+-#=@;0Vqu6!pRz^l9k zutkQ)fgS?$WAK2@!_exX)9Rse7d55&9y&@}#c?4N@0mu4OyD?vQ5KDQX^XD-N=mj| z%Js7BmpX-68&+`DNv=P$PE)GZc8w{rqBbhW%lH!gi;l6U)LFx*q6RYZpjl~Urc;%> z!70|Q_0CK%`i<0J3Q-d^BKt^NA@@K__k-4d;_QeDn{acjEl?1?Z)!s|L7m5yISsj_ z3xY)BV31hd3Eh7TEqRsOwLR>cVIGA~3lnN@U3hFJon3i5F|4A8B1Lz3iI$>k-FUe1 zR?_p+g0F07B_)W%Wr+%c_`q^#4bQ+S6_N4-5hugH3V7IaW2gk>6@bvaMl0=?}8UytBu2%pP&N zs`7VaRE`oy_88+**E3gvPi)#_&j?eRJW=376X*JHU{OtEr@ymVxefHHcC?pp>aB(y zYj%RUaGtapfGU|`(?}XrQ_D)q?yJ*2{Q?O;lYa2*O(pSCF@If>PU||EMdK- z&N!+cTLr;|EwtW2m8a4uPQ9;Qc_aAuD{sIxA=lxhYOwI56a$CVb$H~o3OrzKKy$J# zC$BYft_Ds?CTf>L8`Ip-9^6=>W{N4EowR*m&+%Y49Q%gY#uM#hBN0k;=XKJV(J$9< z>`>I5T8)s*q0!dDxt?AdVJ4Ny9nc z&WaaW?~6lsij=Rd2zEFdk~7vQ5Q_@@o#*d3i0g$}bQ~Vgth!Iz40dSTeiGV5{L$Vf zo;=7zd|@?eB3GO5n!p+I`=NTwwV-Ol(0^BY2{R6QunT7kMbAyB??DwE&sMOGYQgBq z1w~=gtwNM%?ab<5L2UmZ6lACI=vHb~Kv!3@z7!b^y9UA5 z`^rUXe(kxdyQBuKse!HfkEAirCI(Zbt96s^G#g=}FyDvcbELtfm^+JeUh(#+1y+z+>`;88+QpFi3c z#;C^_%Hi3<&hxSRj@yMhcH((*zIW%bM}(c3olG^F^L+QSSl7G-;oXr zZ4SIJ9fs->!mxn4g|HBee%VUmQ8P9mNDMB-F{xZK%iFKd~9sW*1T; z(cEjtYQaT?G!`9pst7v(II-CwVp&k<)$IlTbRu4fI}zA*Qe>PCAH^~1`1%1$=(Bu` z8$`7$w22JHQA3H~RCfPBLbgFSVbjrKIe-2jbB4QLkGL)~-nm^`#*iCA9r#C9G{#DlR_t zH9}l9p~yk%U~Nc~c!Z0YPY5Cb{W;0-BnzMf8GNLcC=Gw!wv;X@KKG-^YGx2Ti)C4~OIL1eHVJw6)Wm-431`2_HVU)j_77Y-2_Cm!1inbY#~h9#^5DrRl+<&Ss^N zF?Eln>7k>&%0FU2WPQ2iu(S6GfI*692eUlU*^MQz^)OPT^Z3Q+iea>Y=X!SFMd4OH zm0pG8{dzKpkN6VkH8{F;IMv=~osFIK??Y2Mk~D)6ye(o(5yR5(BGoqr|I%0aC=2yg z`4^;hxsXovHSj!0PsdGcMHGv-rHI<;>m4&{nAlu-09lL#OC!f|HpL{S3QA#$a?fJ0 z8>llYN>x`gN`=96BQP zuYwIc)3WTo51BaGh3EK)OL4NRj3?DtTjEi5C#F><>|=QV<9K%8KQR7%7K|?e+WDN{ zdUvkNb9%=_hpO3~VWP=e!A_>rJ2467!;}CS0+@7Vt9$!8^nwXHE$~kSa$)Dm_h=$t zM}U#u*;t1MwFdJN!jR4#2!e8*8BjZkYfrGsWieRI<5_x)(~M>GhsYjZ2%Pgdkzx+N zBBWBWN-XPT=RAE&^V@#d}YWNp6c`Y92j2r z5o$R#v^3MV{d}k?QuOJ+hh0Q{aRQ;VE*E`!FBN<26u(mqBP>i8F?2YHn4A|DcS5br zaGtQ-2zwAc^b&$%Sq5b{!VsCIu(yBU;%x{#N^-aR`C%dK-Eq(n3`>~oZJ>}Iy+J27 zU*`FVzx*9uROf!$)1haKBRf|OVZWc#1Pt1!A0J{@6b7$;IwxCEIMq3wP+Sd{=Box665>@XpeyRWb4r|9vfF+#dudb zxVmzhhkc0`tVOK{9_r2)u*q~H(un+@1`K&Z?D+*hwf8RYoG4Y+p^&O9bY%9Pg!F!_ z;1dK+CV>4c0iHtO9RPgGYrfEttbCSj;!R?vKPT;%@NOgc5VXZI1FJAQZyTD+Ps)A700_d#n+Y=K;JZsvX0slZV)h(JC_>f0FfU7)Y( z$r&{)h7n8_lM^?Bj8+YkyPk^57}`Pq@MsUNlf&3J=pSy}i?zy;NS!!Z#PyWnPNp0E z**Xgt`CnuKoKMAAT(W5iUbgK_50>zH3;w?;OfA^5nXe%t4o=kO@uiocM3|}pci=&^ zkFqqL{O7UJA;)@pkKYcr?Wgu&t*qSx4KkL+W`VVTb^;{THpd0obYj ztB-#X?u-a!G3Y%A7Kpsw-T(WXus{hoLrBwgcm0_y?CfKZpV`=VUor8`dByzhYf(X7bPn@-&IR(-?sc~$Hpnu?&f1k)JM5g+RrM;I4eg!| zyL4BehfvrvU_O28leb@pDa!H<(z!rQh1te_&Qu5wN9#Df ze;m@q1!1G)=X1oAgS~|NPuRMxZ&PDKQo$oI z9pSIrY&tJt5ms;AHt=u?A4TGrfzSWf!w=&D+IlG*%XcM8=jdUQ z4OKx(o_+**=6hJ1&%;refiVPhJZ(U}K8^e*317lXh-2Tt5 z%0-RTDg`J*s48L~;FcEr?ft8_Y?czQv}#_#?4#0_JJHO6&F2MssZaC~<$_}|E5%RB zGn`=hS=>*;_C#th;OvH%^JT4#Q|8}J4fbL?GQcsdXW<=KuE3G|Gkg#OcWirn1#;tk zCIXG}K~MEI1o}9a5F*#-`!{-gDoaqTA0ZFl|Mzeln43!2Q2GUSqRE@b^P&bLB=j!t zgj2`S2Dx8Lzz^i6@$p*Vp{wwGZG5qu-|im(as#~h<9P`ch930%3;C`U&g1qEh1qa< zr=$8|Z%5YOkqc7e{iz{8*opQL;H5DBQsZ2H!XG7ueio!I#JL?mCB?~9fc@-X{0oe_ zmKT#lKT8D{=4$t>@OJhOZ1Hz)u6B7lstY{6ZSQoI1K$JW8|SzXxLg!8sw=(4TfF}H zTQ~a`;oTa)@^wyll<^|qVtPcZi~UH(IjD$09;Z9{d$4cP-?Ig(LY_fhCRl>3B`)Vd z<}5(Y0+(|SbNZ0e=W_7wyY9$2-r$yIw8yV|#oFU*aQa0I*B*2FyQ9Vk&W{=*gq4+T z2Ql~2=5gQ8`>1z06y{ZaPL-_JOhu_Q=)ZW(iWR)X ze!*`-eN7bbzLEDm-(^j_()nZFsokHr#7|w~XD)#VE2y5M^ry73DJ5{7X8OV%w$<2} zFW34U2-vM_Av6?$YtOaNFV-(udy;1*){Nse`Bf#YN(_`KBD0!x*rubEG*^PwnuwOI zhOHx3z;ip+=*(2khYr$mUA;V$-bjPSZbm(gt&V1WV+tEA&4vY(bUL%K2(u28&3Xn0 zZGjX_;f0n6QY^GhpL78CwGj`MRcH*SKcsB|2Hoqa4_<=+ikt~6mRU(dreo))f{ppW zy6Xqmq|=xZFJR|Y-K(frrt!pXYGbF%d2mf86QP}{#Ia*yrS#-Z$czF4*#Hhvg&kOi3f2Re+cikMZmY(}!S} zOwQzay;sRV0I!0L-9X1Wva*uZ8D()#2+f)k;nNCox)gwkHtbA=lZiB~G=zbe{#0kw z)U1-04n?n`$d~E7Cfk7ON^}Is)TU$VLQfR$S31a~^no}kjl0U0S4GMO%YO2m6}@{u z^R+dV`9Rm0YZy4|T0VL!RLmzb^f_uBIR}ZVVVBzSjo6m>cWUwi)Ij<>NH3#0@V9nj z<$C}Tqa@7h0G|Z^+~QR-T3DMz;fLKdT}A2Bul7`eDRcY#*@}8}pL;*^Jkf*iS~huB2A!3KSh2 zlXvW}@S9Z_Pr|oQkTlk1B;(Sg<#0$3%gGiW1R_Q>HeUDm=?b5Ca3Oqh5ko#(^t?ym z(HD6g-&B)JQ%y?GH&|Yqr$Fw6NFlO&jV2CTQ4Rt9Ml;Vv^K~%_vo+#cOIfbdmg{!i zT3tOdDXT-(E?7Vh%;uhR%4L>!wFF^S8-M_65jKyqs1({)kD>j!)`>;Y(EKSB(S6LhR&tGS+lxJkcfI< zO}@5;MK^qC=od$1?iuZD0c(#X-hsO<$=Z`zB<@yvO8o9Py}R>x>Ekk)$}Y_z!CCB5 z=sq<=@98P4_|&7C4*Y>{mUTG2|DUXfnB}J$O&z;fUg2u>|a@n;Kl5(N7)@d>eJ11JQ6 zqPZjr6xO!*M4{j314_n2?}7l>n2bH!z zWnGIlG8LO=eRK7UHdH@zsiof5o@%4s)~YW`sJFGPve(-gJ-N3pE1VpE{hIK zUb%~@;`1u2K3VTH=V}A%L3KMq^|&^wE8E&1XlnyIq473!Csfx}|IM=C>;H@caCXPP zlZndlT*Ts}1>+7JxivT@63SL!HrV|EY&I@(n~nSE--61eh&p7`kRm&<&)Y{Ys(s!aWbuC|-lzj5|=WUyA*`kSMXAgpmIx zrb$PhyS;#I;tv}hR2ViqC^t4dC@wcVC?+>ND4~Z95314YO6{kRURQcQ<@35S`ze~& zmEBLNysq4Ss@Lnv@26m1SEq8))umi?bqfbxSI>T`UE)d4d_Y+NFXGIUA)UUJD^ZYg zrb%H=BL`(r!z5)=QG`!T3agqFZZ#@e?P^&@)KP0L0SNO_BE|t&Tb?iea_A#Af3)GEvS`Aw-yo^t-`(~ zAXV7G1f&YJ=c-VB_;##Tp#lj=6{?VcRAI-vD%2uBmPt*B8VN`h8Y2OzLQP5)^Z`|g zujNyhUxhB8_TR0`JhbUU**t)z>#~D4T(jobtGW`!1Q?mC?1Y4{-NGI&p_^ik>TVf@ zwRzHcts==)s!{)SW)iK=#Bs-k@y_kcXy-~#W5F8O{*o>R(CT6UtuCg~V8e@pqYzlD zkS+$$>S6$`F1A&qi}9p1*2jceolL0J%eI#EGPc(5K!e)L09w5apw-K2(AvvxALbL_ z_H^lIQJ9Q-x6ddB%-tmjB=&35BAiW&Y<34qtk>p?@!GUVX47K3HZ7*hv>pDgU6IL- zVU-C$OC|s31XGFfqa@ntxuBd*x((m5eGm$qc^9%rBU;)$J355GSW z;)-28?F+R5PG~M)e2DEwaUnB0f@`sVK|h=U+e(UcNYZ#sW! zzeyL$rZxwn&0`WhsI#ffsbu)tNrz@tULeILDv&M<0Ljew@*TGkWh2 z8h?LGfe)*v-1s{aK4z}Hd9ldH=}c0#_tFwmKCN`|LpFBcp7H9L8(n2 zenbNdE6YIjW z=$mAA#|aw>hS%b0uX#L^mgl0KNMPeHdnpcr22C9FOf`!E&RZ7o^I6SeIzTFu!X;b4 z2Pi`ITliuI9u0^t@HGm)654EJVDoAlIlNGSpT$E~s@c#Hua9qar4_HyP-Ec-vn}O- zlqIynJGp#`6=VWri2+$M;@RV^)YT`%Yc$l=<7XBeWprj;{k|n5ahFYtUD>o~)~3bt zG3{2|IV@bL+eOgr+0?Dr<%m48mS^Lcwd1+n$NBh0uN~0p)f5YFcI9?&h_aF{>#8U# zkwm2yhO{S?*EH?XfsOcV%*GskqEgk)U#-!mmv@R_TClvX+=1@S_9G1|P=xVnGtAj2G<)Kg10mT_Pq#95q znLrg?sKl$xr3ufkO9Q&-PbTV8SLfF*`~*%{WD&A@v=(7l(6pr*Cd3<>tt8#cNScpY zyRhOtwhlh*?v;VmvgNi8n_r`U&ZU3zD;ezi$N{%WbHfE8`e>KSa${LJdWmtE$=SAi8yRbbhk zt%CT5W4j9C8;)%%i0>&oMLmiN;=7GFvLd=w@#8yl?ZVTpUkbmqf&IVtHfUY1O~ekb z7l_v;UavhU5U)+V4pL4e$aS&iQLRyL!OqH%sD&n&XN=i#JcGS^+ z)}}Lc1Qo5YdM#pvWW5=8{|F{9*B@f3A^0eb?S%^6Op0{XRZ0fp8Z>o?73EsCfzs9v z>0080u5^cZ$TiBZ(EY==d$`xr0|lHXV=_^h<^ztPvKcq{A>SauvpV>W^fgkl_f{xc z#-&<7Gte9wfSN(GZnHRe)EM|aQLExqK2~R{Glo5dSaB~-c_*fd_yI9=*BwYVQ#K?F z@SQWsP$eU@bFiojFA_OeqAu1aSGEsXTR@PL?vVP5Aj*~c3X37LK1HxS&iV?93z6Ca zgPwGQ)K?4{_-#)C*k?wb)fL({?Ne?ip1NKDT|blkAiBmO$d=9!WQ*d-A4ZF$*;bx} z?+Kcnaz|h-;lMqJtD!&yxX3=R#_2Ci1WpO4h{3ai%HiwZ|tbTP7<U9 zTA?q5^Pnj38XSpq7i6D)g1pl~kZ-1dDDZoAM9Tvx0+12{^8j=>A%k1aJg+T?Aeilcy!x2nQ+JW_CZb@kJxy ziNh25i@}rRAED?RmB?O#Z2Y8FIz+ahb2NgP9C^iBzPJ=k3cznuEd!7xuLO<+M^eJg zmgNAXgun^_8S+ZtDDb)zOf4f*N7`2CO3*s}6m|vX8RpbREb)68S60 zEb^&WdJH|Yi+5=g#$a0n-(d6e=?8MD&O-8mVo+HJt0<-l>4$pzU>b$=zIUR|1I0`s z4HG*cQ@*ZG=j%*7iz@hgZ0X0{~}lX$dC zF*VJgCjpTyEX*pT+H4`JhK1D&@eQ=%YJ6#?r*$Cj#VubYicwJIj*@wWUiC(pebgagF2W}iIOkxBvT?A( z>2^3h5l#pkSzbGwg2O3BIG9^?4G0|054!sdfDTR?=CrE~b5fIrSw`jrhYbskG%Pr5 zm{}1H8x|aCSa8@d6U5;Rq{|5abePgG=XGtE)21}cGFA{AHY_;Ou;8#^W<@w`Sa76a z!C}Kp5QkHoZrTFiOd<_SAV+F_`}p0*2iRkn5kN~i$4L|3L% z*C?SW+2y%TV|Hb(QczIwvllFH)+k z@;Z%fSEwMqh@)vK6gvi43Ap5{X(?nT)kfBQxoTPpnaM&Lt8eFRrln9U8HF@XU#^;# zLNQ?!(oB81YFY}#c2OwNlUy||h0J7|v{T=P08C4v7%>XjExueeErrZvOl*~QbJ(->%BWAN)r_qd zMqbN$t=C0f%V_IudQe-fw+(4EQR`e$w`G~unIf-cko7kGs2$pefWT{+pY@r@YgwK3 zk;rQqTfI$hYGc+l0$A~jX})N+F^4`IZC0nl**;Z88!de{+APo)tv2S+XG5nN)NSdr z(Poo6LK|(Csrqch^qDZ<#(v4^vH3*(Ev6+kOb#^{1*mBdQsr@2V6t)!nRm71s5X_? zs!daB)h6Sp?L4;DrsYSqX|}D}R8p%pO$N1{OV`?DE2>QcZ`G!GwrbP3P@DKIpHCX7 ztsU;<)}cek!P+`#tlVx=;60MNWiG_m+D>k*ZRI9nt8FW{)^>7hZ6~+ZwsNzmRolv~ zwVm8r+sUo9t=t4yn_@-%tpnZ3ttRQ@*1>J%W@f84`wv>v+T?(8Yi%dD*0yppvsK&5 zt+k!pTHDF3wXNLDY}K}MYi%dD)^>7hZ7Vkc)}|nl+&X}q-0F&)-0Fm^+`P4FTe-Ei zlUr*$xwW>Ho3~bNE4S8ma%*iTx7N0D^VX_u<<{CxZmsR)*4n(n&Uth5(JyAqzrOCk zk+_U__w>ikLhjLz9rMlhG21EC*lu{q1EnNURyd{y1wO$X`MP>wmd9tPJ`x|ev?Dz ziTH!FM>L2;pHIXeSXHzSMTzR3h(AOPPsATj#2<{DbQXUi{`h|+{`eK*FaE`6RtumFwE;B!fcgVtwq2 zHc_9|CTg^jVHb?8Zxb9JZ-@zwk2k~wABYRi&)By5?V%3)@dobY{tC?SuT2mCuh;^< zEYS_o<;xOGRGy2+Htsh(QlGkI<;AFogqxNlle zewU{3E=|c@nqs>&Wp-%_?9v?mE=@^Yno_znC3I;@=hBqSr74w5QzA{{tX+@%jy=U$ z+WyENVC>!Y*ja$XEs!%O2jnXq7vi^yl6o3|XUO=02_Ckp^5f=`Vd1;*4Tw8Y;O~92 zT;GCgteU^~t;o=-`rg9Pu+j5rqff=d%E3_|AHhW;i?3bt9v$jU%hT)5j6EVNKeM)H zAFW+qP}CEA_*o%+TSkxU;b&C!Whgy?hu>n2pFnIB)B1+IL(b?c@(vk4io_!yDLD+n z_cqcz^1(AB{0S4B|G;ks1K=;V;h0Ds&$H@y(vOE(MfpVFny|}#{h`=h=m8cEzT|nq zA%UiRJtFK0d*nI0J%0#@M-l9K!#Y4dO1H-c>r*N(`jqEke}el3k=~wypMqgG$k!;c zJSm6=y?P!Ac&k_A#}Wd|jXedJs&qlWdSouOQAx+n4aSm)uSnXm7A3hyC4ll6C7bx+ zg}K_7ytGpZ&c#WMJ3u)TZy^FmmH^Ex)7-O{eJ!#Fa>o7Xke$EAGll#WF=AFc;TDJR zBDT;VDe=?2@-(cO4rGOi^IL(;_~<N3`H@IFk;CvfywWD-K6-vu-70pH5&bTu8b8uY0~8>!2c@$x5%#)Qk9Grl^Z> z{;n6J2uIp5h;W(>PlJ(x0aL;OHD=(^FEVgwv4mr3%)oPA?N(JH&%&Mx8{uE9MfFH= zBUA6xX2Jemx++Ic{gnP@o*(&F^-=gCZ?f#mVSQ~LzYn2e(45sFwKqpuXEaCqcFooK zZjR6I9mt=yV~2>g^SB$(G2`((r~I`sDyk>XWB57Ns8jq_a_lIZPi}2KlZ;C_oP4>) z<5KwYM`{P3PnxqGC*RC=5ctL0(ZG*!-}SZ~)zy)nxIg8v;&CdO&7tCm!?mJV(WS0< zjCRpAeaabXw}m6vE3#_xMQ8GOcLurDWKKPDgzJX9*8j{M-5eB_Jr+VDlg zm8+F;i?>t0&_yI00c>ANRkU3Wtl5JoFQ|S~UtEwfesh~AY81ftsd!FD0US=|F&zL0 z)<|Dojai-NaPU4=Lh|Ec!c-42iuT%++&bW%G)2(7#Wbj7YJ`wj#@NMo@3RcA! z5%O~7SddoaN{DUgwTa&cvNK2Z+mDk11_Jqb5%}2r`1#O-N6~kk_;GA|Vo4N6kRstk zJ8uLj6F}YwQYe7D+4;=lZ&b;96pq+b8?C7}Qd4b|;&gm-?H@(9jge6JT5~%mGsI%~ zq2==BoA4KLKBJkfY387|jeN5=k3^FRey~NxW2_;%%2+!beNi)+xV#DTc97iy`eMdw zaE>g5l}HPS-Q4$KWmK|7dB|qKW#u6@N6bW@Fv(`M)syfQLdO$x!4p}V!BbJ*Yh4p2 zHx>hGim{#QpdR9N7U@-G7QfY?9SST)b;^ZcIksX7Wo2QD1J7zkIx*rx6l671Oo=|i z)W%|(T(k()v5>G_v^N{|n5}7t8!E>-kd?_r%DF}jAFXrM!;{Xr>H$jUT=gg>qa6Ci zggFx0-OBNlRj2lt3E>CWL`btbSG#xvMrRs7T2}J{97e6xF$QkwuERVxLOu;Vz1ahx!>Kwnd$FX?>z&|1OM!TUJ% z*O_5QoAE7puigq_&&E3E!+ene`EswT2+SAGksp_U`NBBzV*=DR&u*ciCOE4F>*8j$ zVBOrT78+uWX`L3e1JXJvT8`5?Ct5z!Iwe}}Kis>}0 zDg&cy6f^MXctZmYzL>?&x8U85B$7G)^jI1of1ezSe}30rw+ay6a-0V_9(#N+C4HgK?R~!Y-$qCE;y6?VsFECq zstnbb<4_f&=A^^nk?C8z!dyZHD}8HI$fL5AzO^UhQSnMYQiJ#>*Pq&DSm{fDDh;fb zq`y#6`%@d@4v_Yz68sbBPoKZ#)7}s5VHS>)P%!qkXs12MLMa7RohM8ue9$GZjl8H6L!rwjyNVTYVad@Mf~t7-^Xf=26kcrqXv06wU1GgcQ%RlAo%_x^E#bP z>f2deEpvHd+(3WMMxGcqP{tGE=F!K^Pt}X)Sm$%emdECEN%2N<<^E60Q^=mdZ`BQ^mp3WcAH1=P(Vv@&TOjO?1+JD&qUmM?l`91bu7DSK0=w;laB0K^k z%MQdNFv{ydJOU%rZ5uOxuaSxD!)RsqfQB_)n$_%94R|RIcbF{h&zAZ8q22=c4XW$J z(aJ@C;w;L*FIUQMo-xBNGBiVId1e52pZIH0l+y2u7q=!i|pZ+`lB zGFkZowRDQN`Oa92$_|*UkGY5YRJ5midkekG$*x{2Uleg{ zbckI;wyIYwct~BZ_;Lgm7#DzzORM)Oox&_Sk5paPL_WWzXtqbZ%!h976Al95q}7P> zpuc6*z=H=7RIXU|q?`ODSiX4Yc!C}HvNw2XL>s^6-A3DTXGtR3iIL3Xt7!ZUTHQCU zgU8ESbnr`v?P)V=ZZu z_!gOv8fBIyvr06FM}DL??5Bv#WDfBQ@1xApB!5RyM%kye2_>OvzI_wdy{4HP8;1#g zJ6`@Af$J!Qt9LyC1XtLt;1JqehDZ&@;H6a5IzmWNLW~}ghzanxJXx`(bSjIXm<6Ui{t@`jIRA<@j>XtQm)U9MCskEHc3$|2us?d-BF`UQu^Pk4Pwct^W8{#*i##DbJWL8Q z2j(2H4)%3o^D1H;3aM9lz}$N9rB6!iQ4u{O;!cX_5fOJ#q!pBaHsg$waOYnX2hC^$ zGd4TsFi2~>!FUkyfQ`=>6Y;E0HqamBXf>|{^?5jBB6hewEo@-BhC0&ch4>_a4Uy!_ z1lV^7lP*vq|7V~*gqyZK#!2z^EcdnRGV&}gigP8&5b7}K$kU>rUpcKRXAiLn#U z#~i+9BB{|Nm6^t9CL_F)h4e-pC-nEvM4S9`tg;+~Svh4ThC~XH6pDCLU9m_ZL3AVp zuc1w#&l3h229ded8Rs#dedGs~9PUd|x>iy-FD2S+(;`}IDUnmAG9!HQqYi9MzSL#b za#|KS`Qa{r{J;b8TroFRiJk%9#KRXDlpH?UVA&NLeHCgmhE;6Fc&ue(JSG|A@or;0 z$x@Px@krXncuX?JQ`7kc({#Ocy!+*8K=Lx2ItM8KX z+?h-|8js4OL$s0Np_y}tkII!}2RaK(t^;!w`s=aAnEwsmDNzB$VDxJxR=3#b}ja zFBFUZBgDJ&4=diKR&BgXrjYEjiod=3erI7s;vN?^B!-JWhBNj-sA1FjNGt{cW3a2YKP(Sq1s{Dq1s{D>~;<+ zvo_3TU7IATM%?1GtU9BJo0t(mTo-=Fv6WGD39*$k+ds;RHQ~0dva^X|+Zjc%?X1#< z-QHDkwKi3ch#vR+9`k-hM#Qn60KP(PlA@5WL z;&#!(XysdISeQuA^=G6u2E5ZB`{Y}ynIHj7D1d>6M~@3^}@-x`-}zl-3bYz^}?GS5Um&9 zGAoD2%V(}2D570KFj<+PSZ({Bic*1fCq*hiQ^iz0#+VCA|`2+j@=fucFWN ztQVSWtubb_hxqbwHKTF5Z=Mg@SE4U;4Z}6Z8j&KXP?{+Ew$12~U#>Ys@p9#wLo_T` ztT|K?J?e`$2ile(IN_hfj;)+1(_Lh=PdT^X{@jQUu0`T}v^S5nNUV7<6KfvKNKeWY z%*2)^x(I^cjQ_4jd+mF>izzqOCnw|nc(pq77^8L$_kGDQ?_`yCGKqa)8J-*y9|J#7 z2;y{hC*yS9ZJe%Is(c%#OO}#loKEsKPG^#FI`1}4mn?0YaXNXhaXOQX(|NaXx@0MX z_%TLdblA@@s3*^zcpA<7D6=%#iJ(5?91Y{d3n2^tR2F4h(;A$U9pz62&|J2wj3}p? z+v-eZNdkH6)Yuk6Z~1e(WpnJGMv?pN(t-_+x#y)HBZ~HM9Ss{5D~87E`pFs$Ri*I? zR7NK%29{WeQ|t$U;(GX`vUd3Kd&KD-aZYve`6I3|-0WthQ2Hu`o7`?WluX}_WTFK| zUnZ)O93$4Zb@H>$APiD|7R$x4)WiNw7t%gvldT!ekFCVRBYIXjjczrfX+K zNC*P(Sh@9zh0xIDhT>^JugKbxF0aSQiN*|WuK9zXCoMRv^)*?v>uZW?*Vhym$@w;H zyuFGO9>mmXmWNA-tkWwO-o)2wl?(6IbSew)Z^!GxowTaDAhq@WR;#K$A5St~lfO6- zuBXWk*VCfo7)_S31(xw1XO3lLm1VrgS!EepXBqEmblN9#@xZnAp?F}~p?F}~p?F}~ zY#u(XLfS<;>Dna8*d%M|QeF)CM_MIXy|YpTuv!GLqFdL;tEzD5e^ym#0I;gEZ!JUl zwUe1`RjXoM->M3rRTThR)v68Eb6eGR8;Ms%Nvo<2Y`Od&#rV;Ij^#fAJM7!YV#VL$ z2tbP?04=f`l_SRQD`9!oq_^B z%gdIq$K;t3=syqfIG^Q}5y}>ftXB8mMQT-es7vrj3w~kUx$E&6O-u~<%j4g3SFapi zIW{~t!k3Rsl4}fe`ZJ7K_5x!*h2t5xy=~_um}c=~23UM00_2@{uKD<0BftH! z?9}s4<68YIfWLDR%GO=BYYUO_=i!0cGr#r5qchzAe%modM%nr)__q-M-h+SZfpm+n zv_M{XcpWPu&3{NzHj~4@8vYI8Ul;zla(7SIyd>_}RN#?V`~~Kl^GjWUxnkZ~<-k0- z@J~H~*%C14oFuPmO6HxoDT+11z$FjVBK+<27>$H@< zTdwz~$@4o>yk4!fPS3LS(=%)_ljn7BimmJt>3+SKl;7;H$OWcDxZRZ_S5xU* zfcd!&rvFy1+oklYBKuj3nSW4p;uVQ(kMGhpv%oILi26KD@#0O@lS{vd(7QA zVm{w|Ny>iN$FjYENF}kDW&6y(Nm;#@W!D)Tp@Y4zPhD9$9?+tW45*yjLCovSVu8LU z(4XN;wJbX;VA-u^uRx#ZCUl#5xj@SW^LFzNfnFrg9p+mCeOaJ8O~IokMoMh`Z3Z7k zK>KGvON37II#G5ubRC+z0ObJv26_y!TeV)GbRVI=0aQf2@8Vx*-pjFu)W4^UIbNW1 z4?NU;)cCmvzL4Z~UV+!GC0;LFz^j)2TyV~k{55iYZE=0axd+Z)Oq|QRcs-|+*H1{! zeUkGY$=NSCw@J=k$$6>dyhUA)2J|_6LNzQi3xn6R%O3rHq|Mfkrm6DvBrIkxXu8A`9pDF3LNGlIWi<5#gE;+{+ ziL*dj|GsGHH=?oogxY(Tu(VJ3Pt0d}rKCS9?f$37oTG*Q&2oLVm z^?7OilOp>LY3l?j{f6*xt4LRs7AJa1c|zJcS*X2FsNFA|zf0u$XUX}V^k!16-8Ka=D2l02`^#ML*;x_MnUe{_7r{BFUqxPC^i zsfA46hqTu1Foi|yJ37q6c|Xx%PFysKR!&Vmt7F7`z-;tJOmoqbfcc^F3FCqJulz9>uOvB(`%lEY3(oNx>&Ank?V4~ z{xz;6=37fnn}Ipi!CJS-HC}6nyS`>P_FwW=k2(%l{3)F$Ma|7 zy3-^5zme-#Zz4Ji%sq-7SehvB$%!(whZ|r^+uJ9?yzgezl zb}*eX7vTDQxgOJT!T4DN^wTfw`$_LJ;YzL$=qB^o;uiGD?FyxD4*m*dZxrY?>5Iyj zpzK`=nX_`20UAES;O{kn_Kf?c5kXgtC(Kq0eQo|VX!!<#uEiTs&jc@bYFYY~h3m&t z=Is{h4G6u*LPrVoQGs5ZzOV3SC>vYK_FkNRk3c6_XmP-@4Ho)k7oq1_DAWBqw0DC* zpEO7I-GH*UTIlZ*uQO@$OT|Rl&EskF>}Apdpg$kam@f+Sy^bUMZX3^!=p)81FRY6%Kmqc$Ya@pck7bEq=#%x4G&lmR)NG7rz_O`U;^> znp1Nx?dUca2=rn=uK;v;1btw<$6PPa_c~6^?db^3Yqbphcd99xk4Df($LE<}M$q4l z_nNDYW(zL{<|oHX=2H>$nenn&gjiC`zBt}zo)ba;JU-tHGDwqpUmIUw-pg=J?B(Ca z7n+Yp&=1BJo68s$n`_a+&&K=B_g4{mjrmRRtMLKzs|fn9@gs~s?8^L!Wu_28xrycG z2n+o>=$)vT;RsqhQ8jBVbVYFV#7eW?LYJ3DCWg&L7P{4+m>4nJEp+eviHR|@$3otc ziHT#(J_~&~F)=Z2USy%;=S@sZnpatC!23tsGJ&^IKh0+Ligp)m{?=}GlI^Y zSZkK@I2@$yFlS7xGpjB1{@#g+lgt4N{d&>F#8b?xEOb}*#KfuQc7bj(Z&=WrIL&;} zLc6ltC(bZmw9x6j&zm^Q{LVuEV4gQ|o>@Jn?cKWQr4vmvyjr2xFM9RF1?CmU2$cS_ z1+SafWNwL|H%x3cw?)ufCN45>ilBE)Y%y<-p!ZB%Y~C9|ADY-|J{m!HPfVGQN6;rG zo@G93q3H#mnYhG!C4#;*@oe+;2>O?aZRR@>^v#Lw<|h&Korz1$uOsM36PKBXBj~}2 z9VUION;JLT*AqL@`n)vO+5W?KiuR zQ)vCdk%?=}^%e?xMkWrJX$!q-{>a3&=Cu}j5iqYacUtHwKrb}!wNQUJF>$@=IbLag zvu9%B#pXE{dU)}~#7oVaEc9L2(#y>K7J5Bw{uL&Fg4R2uYvJULX1;}fkY70YDs!}j zPC(hs=2#2ei?UnHDHgh9$-+r^bPMeP^jdR)h0f?&HhHVrZlNFKmrdSguC~w#C_89g zVxfCccDwm=3th5g+2kGO4Hnu1=uPIG7CNJ=I{9Yv5exkwU!8ob`HY25K-pd9ehb}; zvbUQbSm=@^)ya37Usz}lpm&*vEp$fL$mF|CevNA42l% zOj_uYB_op`HYZtV51@~lvn+H**Tm$<%mo(uL4IQL9<#$jC!p-_&D9pV7iAweFSF1k zOC}~iX>PI59zdTmZxZNQvnlz-@lTt#uO*+aHpk@GOn%1vlRz&_Kiu^?^I7vH3k}bI z_xNYc*9CgD`N)E&On%nGug+4tMtZIfR!=Lp1^`5f~#bAdpdEp|+P-P{yGrEyS~vZn~9`OW-~j(^vzj-XGDf6qK$q4eKo zUuV8=UTC2QayL(Y-@M6UUOWFafCjnQfO&Iq*E7uz&GibU?<~9y(0gpz|6Xz`pa(6q zyLhVkkvZ#Bt=C&TIQhTKz*Ci%iR8h_pO}|M(5d)6<}XIjGtGnMUoG@9f5+s5<~tGe zrpce1XP>52ZuLJp{vYPY5wv6Sm*zX`wd`X5?UTPU{~vpA0^e3~wGYqSE6eh}WhZvT zl8``Rw0M(k8Er3$tbDP95rYBvy zMD;Ps?-*0BOUkIhEkzEReA?jJras`(B`O$y=Zo)|f8$D#PqwRgEmP)+H2F?~yULX= z|GY%`rNb{%W-g6$SGls}QOk(iC`@vPD@UGfa4VqsTuC2fCBL_)&J%gEg1PfWZP6R9 ze7W4 z>kxV03gXTX55fv&$Z;nq?kb(Q66VflE1x0znY%!oHcnQ~klPII{&DG*Gv)ekQM?Pp z|BRbZIa_XJ?tJm|IBVq*^1hYicfQyL&MqSz#9bf`9)DP+Q&xM3J6{}^VXrKe^E;K_ zYvZaaU6PI}pmD*X$&HoO@)Cpl*5qR<>tsJx@yKt*0m~~JE4sO|Q$A;K_nU66^vRbMXEEn)uI!S3HMnVcH&=Gcg4J9G z%PYAzR|aH>!R;!%xpK9P8r;sDn=5;ziIz}NM{6eBQyG-!8C(%0>zA7i?)1#fm22b+ zinDYV-Cr4!pBh{kTtrUorBu|sW36;CN3)5CE7wU6bDJ<)d%SYJqrC0g+$M2b&gRN9<(UTeUdHCiv*g1DcQ5?DFKKfCX@2{p&6Pip)dsfSDqsuHMqAB@?816!JPoV^W?lWD$U=)?|k{~1eag+BYB#^NwK-| zLV2UXEiBkvd69hG;IgJ}uG}OoA(hINQ*W-kSl*uC@~bYDk280^u%_f!T`u2O9Jf7J z%42xV{Q_~;#3}Mhc^q@p!!8q7%TDGtSsp2xQgyZLQGS+fMQ4>?EuS#BiMi9@_maUK zntKSij}?bh4y%gEnGq#fd2UJ7k7YE$RaM;}w;5bYN>$YsX&q1@Q&Q@yZkDGdxRR>d zep*qq>M?0MInJF`^|-u5acBz$t9~WFFt}-X(W)oqX{T^V zv<1IHcVb7!y>ydbYUjpChQnV7p7 ze)k&Op}AYYy`(r;!R=Kq%2lUxNN|r-y)9>-LEI+MZ+fEY&vO0u6t~3myQ;s)jJ$0E50qa@%7;~-%h=g*ZfDgl8TnzH`?5-C>(7aE zsnwcReQun~s@65@d5SwvORY}PK1^^~)n@Je^OfJ-@Uv)Je-!7ks?)Tg3l#S|_+@Ap zUKr=Hsx!6oE>hfSDdVfNwRbitu4`&xb*?t<62;w|URXU&8+WPV&K^IbdXhH#GR1Z4 zh1EschX&UVzk{@=E?0hcOtx3g)V5urxI&Y?`f%;3D;4+ioa*Xgt>bFNm1VtCbp)R# zK)SwWYD2X{do!l|cICEImuR`y5~pmwRI9j7`I)D-RhMc{{aA6YnpaepYppjcZdUf{ z>MCv0ZHjxx9I19|pBS8@;Kb@0?fE;D-^A2Nb)EM3or=3mKfStMn{>D0wog92x=~wo zkK(kV)2o}b<@d$8bE@ZS%YVik?v6n_?<-KM?tu;RktmT0d(LLA*4S5+_5c5Wq3-5tv{@u=eH z?l@MP`k3OVw>VaNlR0&F9IGw*CHYZ{xw-mStvkWpQhkE9F2UVhy;9puoZKXSKH+E8 z9okEeEAFxMt<_%b`6m=NEBnc6pLWNu;@or9UD}6FD(;B`UanrH&3{U9C8;~BzpdS2 za1UkftnSe=pJqSw_U~5rX%T}9gX`B`Vvb7tQFTb`d4@u&(uTFuh(p<_v=Qw|gQL<$ zw6~d~yZ`g*h?ep!g;aNcL~|>S%O25IB)D6u*K21h4)ey&stwu;2KS^++_-I&$|mtx zx^QpMZZx=}RN+2Rd*9%8nT7i#4UY#nh zFh_OU?7l&JhdEWZH)^I~il^#!i{>*ps@pBvCCqKYO7|l77VS2J8*e7=7Y4UQBW|0) zJ;mJX%&FdDib|1$(gDvD%E#p;6+SB_wKom!vGf)0JGGYADBdQqbF$Zcm-e{9%`E6~-=mei zuKXMY>)b!pdJJw>_D1*p+SUa3efI;}l$|Q%otYcm4{FO2-1pr>+Jy!;EBgZX!)xt08QgZ{{+PDN;ItyI`*Ce~g1f@~E6tPOHoKqFzHM;#o4oF4v~YsE!o5v9 z$>1iYu5fSHP9u)nmnX}Aqn(rBZgD@SUBw~K5GDC{xqqkKsQkd)=l;Dm`%e^)Y~mO0 z7qk}Qprt1cXm`J;1q|+{9IyK&?M8$9c&gX^iuNaiTQgyWdss8Qsp36-zzX*cZMxzt z)AGFTSG6XCdnMQFeqHM}xLt)__Z!-o32w9dP3__YcZK_H?Z*bU6S=>mJ)YpMaKESh z)!@vzE8OpEy9}Zt~|{5&Cw6 zdnI>=+pdcbIi6)#;SRS`FHUfIHKlrWf}31Zt{-J^JCVCfU!UOeYO3^`49=YUqT8+i z)Zn^{N^5HLody>MSF2C@2>G!kyKCz8VuKrRCa&4wwrIrp4DKoBPBFMQn7hp2&S367 z=9FF6>z0qHM5;%q*R8}EEp&tKU{2X}gI>#=vg=0u3y!Dkx=HW(JEfvp=qCLs=E$z= zYnt@-Psk5r8@;|}zP{Dq9!o!}<|w`WQ{^|a;Fy}Db?aw}a}=zoS*V|FaI>WACy?bb{fe3s z^df`XKKY88mHMFurxjgMpyWez0O}Bo0g1f#Zp!*DN zV(L*ftMwk@3@h)^*C)8m)xG+~3GOE~YxG|vxLc|t`a8rypVWG<)jvsa_qo^UKf;>` z%I5#4W>9~IIJWuvnhm-IZyFGHQ_dANC+TM!+{aU|s5x2B)|KCy2}jjL^*V!l`hcTq zHtHuS4mN*9&8hlN4epiPD{4;D|7vi%3a_X+L!V>fRB#7eUvs8jmEdlz`M%z4a66Iv z+4^Y-?)sW@^al;joO@KwdHRzE*Io2T&H4Io4K57s0(}Q@u=yACCu=U!Uss&SO??)e zIfeYx8pK69iYh)#p*QqCoEWFcMT5&xnIkSuOz#>;F8f9mGM7U>Ihn!}YC^f3lTI%G zJQn3s-1@2USem$Yk11&4J(NPFoSc_(*Aux@x$c=V5|Uz(uO>`}`#AXGq^d+1DUx0G zM&qRi#CmExrMxFvLoV}JCu7TwFCZk9Xm9B!yWcQ@@;Vtcf;5q@NgP-}`I3vqF1q2e z819;&@H+dN*f-^$@+CPnaoqtF_eQSA5tpx1gJaA4pNpHUlfBVO=B7k#YGQ=myCi!{ zE!llfwM?~;9OnOMbszn2=t0?)Y8ehIB+Va&4V9WW(?s`LW;VIp#&^n3*#38A(Vb%6 zr~6rG88tB%4J6L?OoZXplKpwu=n-N`8s;_k>HcZH|wl>2o!T>5xmDRxT=E!nnIiQ@K;VXr(z9MURA+hWjTdwd8YZG_eb% zlcI-ZaI*Z0_Or_fYT{Y6x>Bex7n4Y!jeOz-ZZO4r1)-N;%xmvwtI z>vl;IrJwBbTMoHz7ZvML)PhED;zKHWLEI#i`!Y^nl`)xqkNdKh5^Zd}M_7dy^W$y4 zY9&-(u!_t6D7T!-#**nLCsRGnPihaTchlJgXAp5*m6~!_E}v(P*algG36`MYE#Uf8 z(u1lyYOC=c9oNNKXw{|YQ1-ws6S&lVw!jfBp#|eo9|b)~QP1uF43?pp^{J5JCTm-Y zK8|$|$5Qqaw^a&xIj3XgI{E|mYcH9}U1{T=xtEjM#(zFma>^=JGA8>b(`vkt9P;0% z)wpG343j*fi0k2B$z^n3KQ;wT+`;1qavx3qjonvpt)+9VskksbguD)j9v^5DHv?0| zETEZpW=PSUM|IZ>)I=N8c|6{m#N(wAS{L`Cua{yAj{^xc!(|4CB#gVn&|Ycc5pK0c z=y(bplPDd+c=+kuGT*~(#l?_K6DOb)x_Fp#RAp&pcb?i{F93zvH~p{0+FQzMOq*;= z$+`_dT9dd0h_Msvmh_w|*1*LgV!$-kc{=MnLkz+tQ``v55_W{l=1Fgk*a(+gaR)GN zMU`AHhXZvn3;mQyNT3wS?&F|I<(?wOv+vkC)x`;8YE~E1cB@}dDlUl-L zt5)<3^fkH|%B2>*IhWE=_wo?a(|MG#|Jy%}rZ8riMrGH5CZYPHC%M)Xt?s^4`97_I zJ?r9!Nmfl-(u69nqLl^|t>W&D{%7vc-6i~x`@0X(o@nB&q*g`cr7+ntcduV1t)ELY znaeUl$6Ko1U6Q3$xg@)MUHacoVQ)Dmw{NPIQ+*sE>Y8V!dyA{2s=)oC39vgXVis_jumgKU9n)!uwG1QQ9&=bU%bkUTkoev4r-)&G9Jn^)YT*A&z8)pN8#~Mba_i(f5bF<u$`$5c ziA!Zh(XVla+w%OEfVY`;h->7=qBq4Bj&+Uf*WMI&!sT6HzxFP^SecsoS77DjPsPtT z%tP{b*lTS>B1Kdp|=-FPicoXiD==N$)5;BzHj@56Sl! z7ZwnGXX6a%+?;HjUa%S1FY0r%MMS&_TraN5%f@bUUw*cTig5+mIL+-2;F*kPi(i92 zk8{6}@e-Ed3eh$pTg1fliP>Va_$}~8anYn~eC_ne$=Tu#u>*LIxO7UkxKA8?K(=^5 z`~^59woJ_yTSZ%8ws>584tz>nQXi4&$}i|2&fnvGX%b^%`!Pal{qhD8`trPsx* zgR{k(;t#-g#JWSWv5R5ObeebW0DdB_n~^QP5XT=%i%D5Cp?~=_&?48(g8rrJuxz}+ zzY{o4UT`?{FYU9lu|Iw%&?753OT0)`j_oMpFAD7N?r@>k-q@;%U6IA z`48ZFSyT%B%M*Z6c`xuZ`2p}uSypDj?p=Rb8fNEy&{xQB17k7>+$m2Z%D@!J-;?IQu%qJkV952iU-PG-I1M9WKX< z7|<(*fUCtvz%b*9BBiz%`(W~cKj73Z5QU&G7xRGEiSrwZv08ISL!)>Nxdg=G{Q4u$ zV%);`2;&osZ!xAx3S*TA%bzvOV!D>GnX!Y>%NS*hF4q1qn9zj7-jq}<5`R`?OoP3 zwI<5Fmg#234#ogulzpR2pT!ts+`>4-_yprH<6Dd(g-eveIWo>-Y-S9kylSaGGRky} zafor4QJ5)~m9dtwgE7h&V;o`}W)!I$p0Sp(gE7h&V;o`}W)v0*A7dP19A*@0EOQ#C z##qZPwM=&~M%g9Gbc}I`U51z*W)$h10%I*>M>?h4!E}@{#x60YhZu+1WteG^!6{@= zEGyHsj2(0zdancl%Da=AuwDILjZWfv>c2Qt>O zOD)rNj2-OK!L)}l$}Ul+H!{Z9CC2nMj6>`)#Pmaq!|XE5^bSUm$MWQHxtO*xeIR2U zqla-L<28&AG45cL`4sm+#yUn1V>F*ywv9|*!}t*64n|o(vFaE-j2juRVSI>j2csOv z=`+?bdKfn{Uc>kh;|@kSp2F8orglb5A+%0q7sd|8C}WIqh;f)v6jDelV=ZF`W0Wz* zIK(*2D2g~dV=ZF`W0Wz*IK(*2D5i0E##+XXX{7%SrlU+pnT|0XV|s|`A*P2Jg_YA` ztYz$Aj55X;hZu($#ep22v6iueG0GTY9AX@16bEs5##+V>#wcTqafor4Q5;O+V~j(L z!;In(PKU9Uv4b(n7-Jk_9A*^LIXq)6V+UiDF~&H=ILs(!aCpXA#tz0PV~lZ#ahOpY z%HbJn89NxGj4{R`#$iS=lfu_Bb}&X6V~j(L!;E4Ur_5N(*ufZOj4=)|4l|0wI6Px5 zV+UiDF~&H=ILs&x=kScRj2(radF+I#E=5Q>= zTE-5>C}WIqh;f)v6mxjSTE-5>C}WIqh;f)v9KqokI~b#kF~%XrVMbx&kc_pA9gI=N z7~>G*Fr%<@c*a`B4#p^BjB$u@m{B-5JYy|m2V;~m#yG?{%qW~3p0Sp(gE7h&V;o`} zW)vkHp0Sp(gE7h&V;o`}W)!6yp0Sp(gE7h&V;o`}W)x){p0Sp(gE7h&V;o`}W)$Td zp0Sp(gE7h&V;o`}W)u}1p0Sp(gE7h&V;o`}W)vG*Fr#pDc*a`B4#p^BjB$u@m{HVlc*a`B z4#p^BjB$u@m{AKj)3r?3GTp&+2h&leqfEz`jxjyN^bpg-jG~@X zuIH2)YZ*Hjql_`eA;w`w(ZJyuYZ*Hjql_`eA;w`w(a7N$YZ*Hjql_`eA;w`wF_*(L z)-rZ5Mj2y_LyW_WqKU&Z);3XzYMJg}x`XK`V~lZ#ahQFFnHKX{5A!%3rmalZGF{7b z2h$x)N12W?9b-Di^bpfSOb;_X%(R%#>Cfl%nYJ=r%XBT%9ZYvH9c4Pobc}I`U51z* zX0%{+^m}DEQ4mip@VFR%gNz%*i}H2(j(lH!BtMf1+TGe#?f2TS_NMlM_OZ4LC;eLV9DR~rq#vx$(W~$_ZQ!Y!nCgp*Yp_E^y zJe%@z%G)U)rwDwU;1KgH^AY9>^O5Gc=2r6(^Gb8Kd5w9!`FrNG%@>f zYoLG1c%Yf+PqGP%a{dI&WGtEdXV4F&lgsNF?*q5w6MbRkhrn?~gugZs{?5!;^a<$a ziYOg-?mx)ANIP=GB^C7Vis)1p73R_WJkV}4;jJ3sHpWNuC$Kx=ts3Dr#_vaz=DAuyC8pm zs4o!gvx~KUZzLG9$8)rc{$QXlf*9Qc2#y~lKrS8}jk_K>+8vO@0ZDMhZ-En6{8sxq zds_M&?Lp8Bg1*+^y5_zc15 z_bLZV>kxGjPoE=6W`|hHG9!|4HMHeWvLlu50Yt_RvO^x0-MJnaF$EX96QyvX6u9EI zz=@Wl21W2v((r;GBEO`Vz5n zWn07(33%&5AUw&DUJ(v-_qBz*xDh=*(c)j#>krNI`out; z&xa~p?O%i|D%KxD;MIPg=xPrIdV4`M2EwpZufGkqR|tHIzpp#8D$bEgo5S-xA>TUq z)_J}D9)Ad`^@+Mr_e$KBkw9ahmn~vpFw!>A-yaOIZ#e7^MV5Mc0zO0PWhjqN1p8Ok z2O{eNVSinpPxu0B13o{9y8eE;;68$qAktd8s7h=I_JyJ9K%d`NAL!}n3_=#eYlQC# zh9I9$toMmNzbDk%0m&_pb8vb^AqAFoaukWqUB#;t7FQ^0s+BJ$|uXT?W-w;is0Q`vYwKe+5e*7WBr#%pXKWcb@n)8*-eQ4(bzWM(3ZIMBE zgc)J*@iYx-mIeBJ!F6GSV?8eFQ~GQU$EgK@z5!;Mq1iy6Cp1Vt8=8zt5*rrAD^T=P zHHwCwAP}ZB;HL(Ms*0~hbr{8J2!>Fipbz!QBpGf4%oFNmL8uHNv;mQ1$A*DW2nCEg z8S>YA!YWSdDx&%s8~tANRzdLXA+#h0HQ4G2!3l}`>DnF|==0+4FzP}q^mj8C?ECgW zoFe}|a%~Jo8iK))4>|zX)f9li`NdHI*d`t59!5hO0ns@?=^<}aS!kXgm4x8F5w9hb zcc3dkJtvv(h);rA683Z_xi9n&;D+j1><D##3ta_jm@|V8kQg<6e~YQq&J(?(W&#hqeQ^F(ebW`Wu?GLpFxf!M}J_rm6&^3-E zWN2=&zc&~eam8?Aad05Xp)urHN8U6nSc1m28%+^)DvDn1?F>W|i9x;S4@A7HSVc6r z744qBRn(uXq9&;mo$`G2nxFv~D6SHFuv)P$;DZloS&c2Ibd7Jn#yQY~4xoo_QVe?{RHo4Z65af0=eDGHqx0cm!t&mDaSW>(-MKGmUhY8%8n6xY zQ039ZOY=0bzJ~@?FhWBe&~>0!bOs}lVDE_DDJqmf1~o?4$x6oPtB+?=qO*@;H2V9* z0Kq8rvNY9B5*pB{aw7(*h=)tiJEagF!>hcL zL!guG3mc))Oi&LNMiYq9?*dO>pnt&dq88bhK8Kt8+ISq(98S7nQC_roDp#d*^xz|R zrBUkm1QEBKnkgC@FP?`_$$rk&&F{AT` zRO@Q=MWchn`>~O}YLq_@`YDGISJexR@bMHzT;i(j-xfFF13A%8nj z{eyH-gLE<|fd-+|A5xjI0~wGpNM}Akp%S-i@}yaKxIbS^HO6*#oIixb(U(!b#f(Y} z{c(b84ktpj1lKX$b<9AJS{k}ggyt|t8vf9tuJ(07qvnkU5wm&>t_&MdGDGd*wxFk9 zw5=mRCyr6X(SCnFrP$uvkGYHo7zy@cnjJz2499@n;4Z}SQ6$nY2KvR^_5H$+AIt`Z zPm|?EA!4B0o`EnL6tG6(4LukgiUxEIBYhku;&n=sq7h3bgY^S}9-rZWfrLS|hX&OU zqdjQ2^s)9;U3q-|km$i$0xnCBqNcTvdt8gj{mH@Fv zhB0xM=DBm{qN~94o>C1ZsDzyL16^J4QTkPa8cy-~3mOIw21e*w1~Cc<_%LMWSsBk& zjZxExa@cD`4vIC_2?H#ZQF|~cppqcT0X7{^Js4S)kTE`6R^i()$i(CfzMRWg#}QLb zOu>^EA_a}ENbmFgJ^e@n-Rlypl%T?SXp+1Tw5Y4A0aMw8U(x_~#0&9Z=Lpl!sM^ag zrlGyPSYV^JJs9Fe8M>17D?Q8$1qb@u{QVf?W5^|nY2bxb6}}XE`J!4i6pxl+)Y2gh z=g_o`c2nV&^cz9eA^_52Ygp`8eG5!xH@juGq)C*h!}+R4I}90lNEN@2Mzh6@0W9DI z(HnTfbQi*EXt+Wb?*QRQ|A)C9LcvKTjK0%YxQn}>E5-d9!u>eLqu6K?ipd~CLFkGX z{NnKv-qiTfG=7Y~>L`M^A;-pxPtmzD;s&2cHeT}LP$FE?sGRI3j3G2l zL4|i)+EMk)cT8B&5>jih#}gV^o(LOFv%2+Y4i*-kP&c&a=?nJJG#!g3fv~Y?tZ432 z{j?S(JOn&~j>~S0Ch2|%@3}!A6~b7*9jTd-@y>`FdOnN~LTEjq4;~8{ex!D+A@xN_ z2R)5gqpj=fGkW}lK>kkHRJ@Nz!Gh3|7r?y++P`po%}w2ltP!@mIIpb6s6w=auq3js zATTj~ZHLnQ5tS&l?O|$d`MMH4RT%ePx8bO!WN0*07{zkU#GQ=BClLf|sG;C`#iFy| zkytDG3+g#g@Bt!wjjJ;HmSAvoT?9p?MJuC;6{w~8yWK}9`K;+QL8kx zZh>yd7}2PrVH6(dinRE=}{K+*dX9lG;Bf9PyqVzCH>Sp;6B2b1C0S+kWhT6M9+Fe zcx7iFRwUzOe7TbN_^8L2*{i;QBOp{f0*xYwhTibV!XLsw8!IJ-C6ete!a5R`>Ush& zNW)omVYJpt1wig$GFkQT18pG|6VL*OX}*q8NU|S7V@8W^(}>#K7iW|;Hv0Q}f`jaf z0R`U!LQU`k$jggZ{!lADITN1VAeNMA9xT+l%Hkl%ME`VD8;|9<{lH2sY|wa&#Uv)V zXgk$1D8&c8GBD8M3C9a)3~tb*F7~e(z}Va(b=E3tec0t0ybMV5!Iz*x&A zk4~&Mib2mhqp42n*?9STQPN6Uyb%@S7%Wg5c_)@(+F?Xt zsu_CVN7>g0Lm^ND##ISqFu2PwqEZ1Cc-CWrx?a%WWj(Bvsti}ooNm?l^a3j*cnoPg z$HO8I*_qS94^6;PYpQ5^rp7}`gZI#bw$W4FJ7+OREPz~R#95k`Mk3|@);;6O$G4iNI zoI!!vQ3FcL1X5D;fZ;?MS0wV`UIY_bzSH|)Oc?TkYsNINi&oGFM}~Up{!F+V!-aV3 zWmrLiMZ3JR84sgqwyfk2C)kx3^C}NKc*MjA%R#;U2xbhzTRa;E#Rh(YKCKP!u!OO* z2oH`#u~;YuaK%C*_O;;|9gdYKhHEii)~JJ9x8d7|@GHdva1^s1;p-8;TQuV=mxvHY zEW)>uDUB}dUJBw{t1CzOQ>ZS4@53)bZ_pgNd-#QjhxW^mr;}3?8Edf%4~sY8L>?Yl zVOLZTsbH;?ay@dago_X|A{HU9#fXQ=JwlF_nsVtw9#~QpqHuR9RC%0uOF>NA-FJym zUiRN>YU}&Ya_mk zN+E>a2+cI14nkZ?vZ;1(LD9lFP)goLZ62= zPTff>`M&ExMUTC+=zb*k#i(vr{*m$UW5eyKm70rtkjw-tFEAWxqr|cC_;ZmawbiQC z!(M0jwjs;_%1d#YVQBL}lL=C*j&%m?BqhV67KMzET3+%qQWlrK@Wg?CJ>ZD>n?Anb z!yCeD4ie_^`GqoX5-tZOF0&O^p)S?xQmZbF>M~zlTGXZ0WR=pA0?2D7Qq!$MGJ68C z*(p{{W@nqpKYC?~RpdvnXWWuX9{JHNnYmIg61hd#cU~mSRxLX_KMQFfNiExA!K-xH z+1YqaEI-R4^z8iU*@aUPXet6Bc}qG$27*u33e(bsF7u;Ll$wRiFU_BhQ;f7s zf-E@X&B~jFOQ9ui7Lv}&%7RB$RtkAoEY=ihu~@Ve_~4qBD>a#0SR^dSnG&{GGDQj# z%;3#FV)CNzYG#X;!-dXC$-#%$v-9TU=0!ivcjcFAnpuO4IwX=B)Ii0Xo??~xuIyB+ zp6{|`TOd8O1YJy5D%qH(n{#xNRpg|k3sZJZN_Ku}j*if>P=|Cy0_kZaau$k0dfk|Z z#4~XebY2UmteaA0{y0-QAT`yLKQ7ynt*1(hDHWw6$S|jxEEyT;DXBUxgceIqYI zN@0p6&7uJ*0vAL?G!-guDnb;bW+M^^q?DFM9xA{{U z3hAe&vVbH9OF^ZB3yPk>K{5~y1stDRVEDj4BP}B%gMy(bBok6W%7tl$_R~mjX=#&F zC&G&ZB2&ZwNr|VYLpTe585yWMd)|ti6qpjU33^gSDoUH8AtgP9lF^}lbH1xEB^y@j zNVQsYcyXR&D*4ghLPr_6sQ;ueP7T$q&n?su$xQmlI|TKccZkKDs#9FJz~)oUR8Te; zRBEajw+jBCE4omm*$@C^da8w7=#N}0kVsxdW~L=Ig)W(ynmH4y%mm2%tPHreWahQt zUv+kBYMh2sHASw5#`3BWi4wt;5+OD-CzUY%$Kf&&CzIr^&P+!vN)m}?rKYPG9G22- z%s@W1%;r_Yx>O>0)ss>u#M2vnJuG$RXotOXhop|vaMR7r&1=blJiZ(-dDVFxxS&oE zkA9Q)k`alJXUN@>oJU>@v`h|DQYR(5C(|%qR#IbTCg}{9OcXs40uHHZne0q%FdI|^ zBt$qEqareMQ?n8wIA(ro&S(d&wY>SzGMX!@jQJFfDhC9r9}KFW;{r(AEl3`4p_cCzlMLl^cH%7)?9>WtU*SVoUsH z?0bS!jL{o)(<`>{_=jdacyxh&GKgL~0=EWGSbt_5={1YtV@01&QLXT&+0QD34>L#o zEcLuJ3-UpdaU@a&el7^+6y+>jx|10@95tIT1=k8;?Sx-BI_FY|Q7XzXFmfS$36ilP zK3`UP&?Fvq<0q!Jk6Obe?g-<>H zQAwJRD*3AN)WOFFpGNr5zXlFF*U*Zc<#xc=#j;l*bR9z3S$c{~F>UzO0c{B5K>DPy zM#xbPRAq>3q=`#R@>5DwPKrr2Hy3mXa(A*Hm8uj!GO}{`QD~B`9uy6)$+pOsV$Ve^ z7t*Cv%ivG;&;+_1?xf*zge-?I)dz(kx&dKIz|(|+wBH1dWS+}Z8S=p!<@nXZpRB1I zTq8?IdM0>Vx_KHPlTsBa zm=vPSkP1a~6N*Rbq{=8kk;za<*<^}jRAj7p5gq@?Oi3AbASs5-f~t+wN-5J#M^#PD zhf=vRBryV=xF#3@`}i6P<;shRoz4MiqV~2BhsZshy6sl zd-p6|F_uqA174Kd4;@kI)$DdZk^4uuNSH!Wcdo|9G;ASX;;D0| zK?QL%B%$GonpKr?H%+G8ZvucttZ9l8XitRA%~D0-jPK`6*QzGSPsI<^eSB zrLxe_hx|#}2Dp-zsPwALG>4$kHRVLUR5p@?<`6Wqq10%|Lb{>BWCPsQyoYESB9b=A zmj*;6kqS$4(TJUfha^8yBo)<&Dwi6Z(eR0MLOQR*mB#a=C7Lgh%ruOnK_Tgc<``S+}%Z|o=X-#A)+dYI7<>4F#{#~+65 zCz8f?my{+2-LQ}SM8e$$;sG8)cww`&*oF55pj}$NqG<>%X6+|(-!WFRbf2wD413fA zuKh~W*X~Q={Y0zxjh1mP>{LBC#9QFdEiGm4r}i+WO;`YHphuhZ;=q2^Cj5A`ju-G< zh4)M$s~WWLC$_NL#LW*|D8|vn5&Y}lU)q8DM{AQ_;7OLU7@rvPqxYr>+#2-8>aq8J zqTyFY%RY9}sqVA@EQ6LoHsBf`M~qC0$W7g$G*zO9|DOG)Zie<UYKTVV zdw<3fed=E?0lgBo9BJ=Alf&qkJDTjv%Oh0v^4&M3^RF14j#-Ks-QOh74b+|e%}D)~ zbAU19k|T9 zWTqJQI z66n#)l@cWGpgjm*Sv*{=N8NFcg=FJ(q2jA1)P229l_Pvd8>h2LJ>j)kuJH%c+={b4P?lF^hYVO0EVq15Q zO)bgDPE13+E3qyydPD4<)V%vfuH`D)N(|)xv-`Lz;|Y_+^*~VpbUXAa6S>oKvO(Sy zhmGv;@5euS(nqVvKG7JDs-_y$`m5#T7H%=S!L1szk9PNh6~_~aZ+ug{+D=Rr7k8&j zy7++aDE2bpVDDN2A$9@2*tN^@?&~wgpZMe-j*MPaETgB5*#upT&Gfwiby!Amd@J;b zTjJb`+1B_u#Ivo&5t#0hVjKONZ8c7Ca`&loMQ2+RTgv&cGJ9 z*Lc~k<+iO3;1C!(xD;nk;0!IPnX`3s{y6-wFAH0T%z2Y=O~G}JX3oH6HhvZORpZx! z-zo%knatP(Lc7q$5u}-E8(ZO2?9;+tIHGek&{Jt+8N`S_bxUX?c@? zmb^)Du)s?crVylo#fCWS`yxtGaG61nNsxso*vdv&n1bDA61#!ug55{#mX?B@Zj-W8 z1yaW@HFeFOo+fnWu?0~y7Fg9v~fRXB%U(ndWLnyY70DryA5Y1bqQn}(fzmRt*B z!w}NQ4-6~-m1kiNo8!#vnr2a+*lI@vYuIF!X=$h-QfXRNLh+D?%qhc?D)h8m+SVpa zd`+Wmiz+HAQo~Nge0#jIOHqh~DU>20Qdt2vlvvN^qGiKcE%~Li6%z)NUrHMyvmh=6 z&eia#2<*m#eqe5eBomiDowrf)4o~c>gd{+0b|Xw9^`&JH(Edo?$O#M9up<*$=FO%H zHk#$lCX>Sjd(OzrW+Q3lp+8*VmN%OXWHy_|Y&MG7Y%H_cOlGrz%w|)X&1N;5jb*lx zJ_<g&0japWtt3b7h#a+flrLOr^Qz-SBa+L6L#uJ6a&Zt&ww83uvNYyV$h=0R zO)8>&bR=RUwir?Z@Fr1lffuFCl!}2Zc9bSuIL$^%b3Vs{9Z({;P!wG9=99=wz?kAB zCrFNL2}H2?M6ggGC<`Fjp$0ur@e4D}pbImp@qi!8fRL1X3+Iji7X)u?>?l`lP`Otg++fV@>ZBE*eFhr20&Xi z6D`+FVKL1_#|eUdIH8YyY}nXtlsski`)XZ?PtVB*!kcTI+gxtb90C*oqUSaDbcNhx1)>q)0C* zt|)fdoaMzhr6W^{RAay-M7k6y{=PX&+5|qlE)MCcT;cPrhy;T@;T1mrT3=x8ic!bg z7Ki&F#&_pSacZs2<|whzpSOZ9_7a<&i87mmuZDlA1E1TlJIT)o2nt-8-RX3>JY8M( z3V)Z~6(zPZ zufy&y?X0lbOG_P|&I%tg_LP-+9L`cd^6+?F9v_nJa`-Cjc7LbMRpKi1lz7WLr7m9y z27#p&E^nEutF+V8W%HKU%Uzz5a;M$yb2&;&OT9kG=<#|hI!j%pt}aKXugq_^`JH7p zud}4oS?;m>9A#xbPlc_bw4$`Ew8H7FC`Cp-PkEQq@3qLLZy~MAtf%4 z2Nmmcz;3cJ*WQf?M*6+Mf`-a`ciOt#pl??3L`d`)Xh&EbuE<1P@1$ueq!jM3k>w z3fqK0dtHj7b-HKv6@XUTQg9pnlPieT2>P{G5tk#w2AaYVO9hzue z=LuW!aSCg&%i4(pD1G5d*{lwuB`17riq5VKTLXPo)qGmjiNV%D*xJqKEQYKRe4hc} zx8i);_?hokJ~~m(TGZDwXvMLYbA0$%5}%Ln=}~@RT>RE>{DW1}-i{P{{rIw$kA=dK zwmp83fygSW$J&PxriuqtUqvdGlkgP)d@9S@i|>3`JN;IC#U;$rsKomCIPHY*C~<=! zD<55GjRdV5aJXl&+$@(-R#Xm*D1+NQLmtKQ;C9H{PalV&vrv<=897M1SQgSTz#bGa zQD8EQfcolGv8<;w5~*XHY{eVPZc>q%-=N0okENjVxM6~BEZ{OL!Z9F%_g zw$7ipGU}hX`kqUF_VEeMz%y%J*!tYJw%&a9)i-T_zTkz~-XHw2^|qSU@c9>gT9W_X z{Z};o<<-=moYHvrQ_FsGU+ul)&v+yyye9VQ6zze~k6UX#t~hba?OPnddc<&><0UpleqJ59@8d-tG=Gf(gO?2re)41WIc{GZA#miJD3`FoS+ zZM!ab$9w0ky8F=QZus^oUG=98pE%=Zxwjo1ez@_zv#$Eb-4$8RLzWI?R$sIH>XTb< z{L>p+^U7;3ec68QbqhWjns?H{pEX}I|9JOBFEw7i_}5oHf7i8lXCC=Lx7DwHwYzP3*QeoI(OHU$%6mKMipCpH@Pzo{87aQFLyE<1jcto> zoc``b-#(;e%NNz&_x}1I4Wr^Go|LA_}Hy>Sn&C$KfUTE;2d;ZT~p7+GvzddE+ z;S0)tcJSQbn#=#^&@C7IWThu#&csXg>5Gmz@ygV*!WSJ`_vfs?55AUig#GyM{3*~j zFzvu6-~IiukL=9Jt2*JA&CVseM$7+y-2Y1&z@if7RFdWhQ7e9Q3RMNJxbUCy7ve^^ zwT^LDF$JEfjSf%l^cQ7RK7Q_XOs<#eH@g;)2t~|unSEYCz!;?6#V*^4xW-Xa` zcV@9zg!g2cahmE}JYlB8b=vV0VkUk?A8fl5(RMrgF%xd|N0Jat*9Q!m!at5*I)0S> zcKon%BryHqfTV*g{Aln@?m76?fG)%JVEiJW&A86MZ-YVGKu1A)am9(T_?EB=gEM2r zTc9Z~YGeKeIu%!35`RQ99~}?s5PUDW0AHORk8l4@!dHN&;9JR4@s;5sd?(q8uOuG? z8FKN3#d}>&X zBUPMP0Jl8&$8M;7yfd@CBf2rTI*idaUwBZ z&ufEKEQM_>2GtB(S;+4pEQHN0g1->|WBSXNloXcy!7V@)(lxjiAG(H{;z)tXe~r+L zM0)hPKD{v4jnvdjw(7-!AOc%C44r^P8^-Bc4-~M|35Xlt8ctjJ=#e$OLQhjrNPv_^ zzhp>nx$Nn5Hi2AokXAfz^)ArJdnxggPsvUfLIdrnlO}#hPI;~4G_it-zYh3}q%-=g z2lC(3!ERQ(#fG!t`5`>fgE&3bYUD$wIpJH+$g_p*eLieTLi_P8m$YS|C#lqdjAg}21beLC4zm~!)++xzH77+clFpb$JXke>Xgb? zg8MvfpYiRbIA0qjq*t@ovemdzx3n42gYx%qoOC0<0MF>@y^4CnI(+b_gD2`yZ|a}~ z7q2%p!d>Fd^q}N%?Ty?$)Y8#?*bZO1N5Y(Yd|M1W&{y1H!g^U_^TK({mm9?Y@&6fp zh#|@S;^Wi^{@>sK?=-M{0g9|?!glI{`R264&X|A3-?L<@7CUu;3r?k)l=d83mN~8C zjQP^9n>3jsY%9#^hnuBIo?5O+Q*5EF#Wpd?CC8SgV;pI^R*MQe@ERT0WF7a>q-#I> zF!JV~N_TdjJUwk^`nKcF+L|lwIPCJP`-0Y=e;zwEBV;?(^qK8c{nePRNlhzoqHA`2 zJZ(dFZ%o`yHDq#8vWBoYv{~3rwrAMV%=!|Oxj_xVzf)nYJ0^uNO>9e)j0|Tj_g}w8YZdP-k_}Q@x^z zS$MwUvN`M(c!*YU99TTkvDppQ7WrS1&yZi=i~P1z!ijUa{uv>6Vj@z1#c5#?}doT1!5B{D{ZjdhX}XUU1}wr)Io!(zh;|_QQKV zsylaS+56)^e{RWs~# zpSMMxy>{!;yB2+RSN z9DBei%O3gY0R7n3mOsAR_q~rDmaEH7J7#WP#zl{ue(gQ)YA{rJ-KlaA8X2%nLtPh6 zH%+olNLaJmEX@|!I-NnS+X^W_wrPTC+@tT$Fn{~=4YR-Zt4+}-Z~x=_!Rhsn+FHpw z&(v(2w|TCu!9L4&C>c*iLDGZkMg8>gaJ+06#w&XGU{BDxIN%N1C)mbO(3FBKhtq~9 zZ-?U{n$uOGBs!QRijI5Zi(;HG zIwfa#$Cn3nUe(=x&G&`=%Pei}$*)!&bnDXD53c*=;19yNAGKE>f6UT{wl^J6^VF}p zKELh8bKbr3Z|?6E&e`_K&W@hxZ<{Qo;o-KYSDyUw8E1d%?VtZ>@l}n_e{|Cu$DKI; z)w>J6JY}5ymFXMbId{^sLenLyD&O6l|6}_XJD!~L{O7h)&HZQrm#fw<-IKYvg}Wtv zjIwMS&-`C$Rq!?eS)*PsYoyhqZ&6tr=<|I1x+s>&2e3FzUy4t(e`p2EZDkG{-iIis z_ODd2rNsW%+vl%p3E#^0wGMXwWb14HNE0nrU3kmim#%ud@%d*TUA}0`^=nV+oqPXp zTtB*JbJ3@t`cHj*;`Vd@zxK{Ltcq<7_;gE3w=@!a(?}yBDGkzH(j5|eQz9)XASEFJ zBHbMl(%mhBfJh05_zfKEoa?#g-sijbIp@3I`NwC^xMwyqv(|dw_cy;a^GPBV<&9Zv zKZ|-`lMl)Y+x4en*NLjKvOIo%`tL4nWIu>wy~hPJ0i!?<<|pTdajoVNUiKj$+Qrft96q#khiNFYdRfU)X-6J z&Fkgn#OoI@`uB~MrmewOn2eNO?P}?IK#z9E4|(e|=C(gBw>)lcql(U9?(||9`YreI zAn7sen$9=RcEYS#5p@ka%b9gjX~+yDS4gf>*z@)hbNX9{Dm<|^H}Qxa0}aGeejN!{ zfRS(kgpGvyCBWmuA^(ux;qxa#cqN@ zSl{|SA_yKB0)92ZjU8$9M!m_>HMSfQii_5n=uakU|GypKfOZSCTA;Ch8R2+XIXM4Q zBi!%o+XV!9d~ujm2#|OX{Fy5HOgw$rXkr4e`>5OX@RmLB1jQ?3<@Ib;up=Gz$~L? zTFc3_3cnWn$|Y$%Q~KtfYGE@L$ja}0n2yTU-e_b2d29-p%4ZeqCZs%*%YUnz*T@!m zPL;SfZ``Q0X*r+(Yw>PM{J0OJ@{3SeNJ}G0XC1;!g)a9cI7;xMxxh;4Hbn0cf{L4os;PABe%s- zhpxD-&}evEqvvVtDG9fGM!p&}EzM~hmE9e~Y2Zj9TAXCoeeI1nHQfU;R>LXYP_a;E z0;~xm!8l7pm3QOPk23s4ckm(Z!l{d@L?lY&eDNt1=A3v8{DdAMJpGDo2Bn8W(yEn< zYcuFZLd+8nS^9akg+;}|*_7BQts1FKw0g>LkF7n22}h<|;v$i~=^P~rkgQ*ib}rB) zC%ATip<*&1sF);>?}LD5`zNQ;BGD-Z2ey>OW*zJH!903ZT7soAV>9RX^^C`^dw#VD3r_K+}Y&R^< zWZvPX2qqIQ-LB#Lv5Qw)VM?M87H|?d01;-N~4zw zvMkEuOP%p;Qo=8NOntNGj#!S5FNix@(@etMPB{Jwg-HlMA%}^-Bis;^%jK$znnSqI73h8$n6&xrx%Oz|vdhFTnbgClM zlOm_GSs+pg-}QPvTPcAWX~d>UPu2gjCd@KT-p~<~tor~7^O=<*RA$NRW2gJ^Q{g0A zU($>U*fKo{Yi4Lyu^2AuBWhWeJj7=Mc_Eol;Sms&5dYiBg@=a)LkSUqb+F&A&_yvy zum^$??7+do|6?%{pJ`PLL#+4n%YDJ$O8_@4Hn43=LLQ7vTNq zRA1hqn*qhK1VNFn0@>)<_gWk_!b_da!{rfk6+rc1U?YBP*cY| zd6I6`8-4elfh66A42s*MJIU_YW>m5Z^h&*(NTtiNx=PHm#E!S~;cE$q>UWmQYX>|d zXw%-h7UqMq?4O4fw7N}-`#N0fk=c`A&3WimHt@len%?sOqaw!-$-Kv_ z7`u>ksn)n1S%GR9UdjYU^VnbI>Zph!Na$1OUD8FKGXmUK-UHSy&w>bV5ZCD+~#YF3*ZMskUjt$ zzd#rOKPdg-Z+#^Yzrc?_`nBHz3A~GgqeH*|p?&>Pc$D9aaF-b~cd>KXurl+U&W6h! zqd9*^=ld4MqUhZLZFh$hmpqtTMa_5XNKaL&DoTQ(NwuO0J@YiY2t}ASgdoW*b#$CC zlIc?h5hJOMm@WgxWuMZy`wyBP&>`cQ;4`Wa4@k@mn`?l~*-+CXNTQrS6=2y}FNgD0 zUwP|1ksVr)?r*7k3t2z*#5WotrT@NS)w%P`G95&iVy@t!s(>32Tsl*<%T#oyR#qBn z#0GZtVdWdI?$dtS{jSs}n zWkbsn&LXuLZU4B7VDTX~j8Mq18n3G-Ip!h%2iRwkWf5ZZ0(FuSm|_Tq>Yl1O52**tKJY92eA&qD!^0<`S@txSm>#vCZBvJ zt5leqg@h=mZ0YSJ*z;zV@@Nx&0Zs1}x>xJ$(6besJSvANV#YERqm1|b*l^nTu8MjL zI-e$P(R37Qt5gODenzPr;*zu)U30NIZ%O7coaIE?qgc(i)M1#rQX#-sB;s$un_^Yy zmgf&s7a3CBZ@m79xH(i>sDvbXs-_TxKAPKBpQaj=OvB9%#6?FC6!FgYxCn#av3@rV zE=A_iC9c8$O{@8f;hGCv4FpEO`{%d_lE4gs%SFzM#XmWO|4}qsUCNbTFL$Cw_sr^@ zp!ICP9D|O0+DS!59?rK>bnWWl);xmsvyoj~Zm1C^H01oOd;6vNhj>*-?PXtC?el6L ziy$c)jEV@n0e6mOY@#=YKGrG$5>>g8Qg@NB?svEy1}n|BNOU>sDua15(D>{kwkNPA z=~eIX8^L+CKl|ilRZLz`c*N7lO>a7#Bv4D@%~|BH;~S1m_E_b-@{KXwtTonv-obgK z>cIF`kcPs$x%!Cgm6)hfeir4_4F{_$Jv^)qk>D9~~*QM-wIL4ZTLAtkDmW@mMYL+AJuO<}2%GNbH<`e2z7|tI* zjG-VG`b0J0>?4*e<=WS7z%_>(I{qn#%E$L?Dot{s(usY>$H2dK=9w?g7LWYVb-Smy z%5Rzw;hzjS=PK~J#F249v39Zds-%gw^WHqVCn7&ntlzpnHt8&MQ?Z4UUp^A9Ufhxm zdoxg3YI&%^nn>FS*15+bOMimV4Q`o}$Y~;vYPabb=mgw9kXYO%y0SGfO)3sdSXArVdsZ1_|qt-dl3kz5Y$1cAY~+sFFZ^> zzz3+1#Xurog9E^DIR7XjNnBtY6*C8WXDb(bC(lcY^V2Z%H%0?)LVQ7VF25N*FN5x5 zb)Pflq9qne3s^9%&Qh9_+*!W;rtZ~BVMS?Ej}oNVL?JL|1^+~=Bag_msm;AXSAN?% zEbwY;)JL14%s8TJI~(Q}H&hkol4>g{JSKVAQuCo2Fz_!B8$;h%E!Lv$crRGnH!}6w zO^V!dg`Farw}oOH+R~`4dZ@b6YSzr9??(!C?prRH9c61g`w(BrsLn7*+aQ|JL|loB zi=!vRuqc|qSNP$CDYUJg@P%5Cc-a|7S{QNY`YyUPq!mIePORekfTxwb&B&RJ<*v7# z2n(vm)s2|ae4PcECoUNETQUB_La$|o%>{1P1)p+|OeGvd9mT*N^>?~_I+Ay?$1ZRM zb#T4#np8SR)0z_%e5q#p*u>=)ONpX1Ys*yx$0pMg+D2#Fo=DV&GFVL+HPb2Q2|o%Q z3D`(&;+VTv(z6+7Er=OuYx(4o?LJbg-L;m)kbAtpg{(gHwus62`o=c)9t*5i;Ww4{3+I591M8dMTA*3pgyi_45f10; z6@!c)Xng6~<}QP{5MA7B7Z)Atp)}!b-wZIP zPkrx(cN3q3#8CA6u7Vp6ZlDPvG zQFBq)1AQMR^MAMW&Z0pBju9CN@j50v90@!=jK6bytdIiU&BbHZ?22e}i6|pY)C@=f z2;!gZ`zOciAMW8@yipnv=C$E!0_YkM);DGXJa+`lrTuLM50GnItN^(KF#H!T2LKub zSTH-#|Ixne04Q65&%jhbIAlOW_zXh6{EVaU%NLA!2rj5LGG_17+Fsv0t78;6eh8@rET*)b!}#L* zT?2}3ntVr>mtjZPEaoGM^nDCFI}VoDj;GzmNz@_wMh54UGMJmSFH>W@I(#|}!BB)N z0B1qHB(Sl6?iIb{uRp^)Kk`>7tSpG+8>IsP?3G_}xPM@*St)D&btfNB@WBBiGB?P5 z=!In8EwPoA;JSQ{RoCP306|WF*Z~|n7_te1tb-t{AV>oORaycL=P9|qXmdL6QO)ql z+p&7b_pt$pc3v)mmBU1RejrHFU!W`e{KNr(8LpT+j%+rwZj-;<_bC<+EY>?#B$6(d z`-ozpEqSZe**`W&fT1Bk;b3UxmhRJ^YO;~$&=aS`JW=FA@HE_z4`xJo)BlA124TYI z1nAW3z%mZ~5yV9;reuiz`Jh_x+fDn(p5#%U!Mt45H*X0oIk6Mc$v z&^t1soF7hMEbNluh>7r-7)e%_Xqg`ulNi9~5kegI-yU5U@x73q3AT6-T-=@0uB zkBN?~uZGoJR|&s^0240Y;&wBISI^3_*YJAVU7h@0HG@}*rp_qsw|dmW79+`vAvrkA z(&q(t&TlGItZU(=PaNJfP2((cM;)FR`M@?suq#}MaV2xp#CKWp*&6Rj)uLv3?8v>6 zn?tIHZ?j{VE4E8b;^&+e?L7`RPDU8SO_K0=d9E6Sa*G69{lNV~oE#>%ohp9Ndv=Wo zM3{UmuZ(FMa#6lR^9k(Y@Ctb>mL~P#KAI}ogtn<}N_IgxXWTDSO9hg&O{BDas zFw;ZO7(PeS5EdrF8`Y zRKK4AOKbgm1q`Q7M4rvI7<_hNpu@na|E5*{$+wljKU!Lxz@+~lEUnAMzo@14IBnf~ zDVjIT6)7UI3F}y8f5IZU;|;X7&EYmTPOyuG!AG|YH~&($K-)XkyB^*+hKIvdp;~$( z=D`_eWgk3whKs`V2<>?0hjU&x;1uSEzVSb+HCY!X3fziLDM;E4CxY0WqB<3*5H&OSdcGZYBjqD8amc7BBR^$H zG#J%4(k0We)KiD4UMg)|yDieWw?~+Z-p@O@_$fSUKNf*AP@kv34a?cK3jDR)^952q zuV3iouPrS!c>J&B*sm-t96k&A@#xeC?_C6N6JN&m@D{FaRVkmdm)>ard)e6OgD^dTVl z@pQBf9T`lR<7zYsN1kjP{MnRcZ4I1-dc&EY?EDTj4m#o}wf?*7A2B${P4f&!6oeh` zXh}*-+(zJE%ur$?7!jf6QVi)7P((6HMHr&%6A~+J_zc0Ly~^Lk)sV1=F8Eoc@vuhV zcE32E0pb;RH2McU&8%0?jm5_(>5Nt~vVBUR1eA-R`tNDPpA1zy3&yqMW2jXJB?r?T z60>;UMfqH$Dk!QwJ?gL&J`q2l8ysRFCi#pVb+BQG8MjD-8lzAqK;WL*!(_AO8^WGt ziw7F4dmmnS;}O`po9%$oQ6`SM22#T*$K8+-Y+JL{yFl`W6&Ux(d$onlhHR8O-dj1? zX&QUC=B|09JRPOKXH2-2bB|XhXNNvv^IowCM1#FU@`Yrd_ztz!sCk1fqC{Djw!2t*m;h%cw}q|0`Wa`V^@|7 z!`%#+Jyg$$ zt9Pi!qO!(tV7bS|uFD~9^48Z&cuuN1-g?KCcU-rx>um}7dqTwY-53KZME)8-swm^& zHuwE(`1%5*cXRg7>ka+D4l-W#h1z_$@fE|%mFjB72)MXROq5!)00i{{!hrEtRSh%x zVh$Ay%wJbky9@$gFyN*E&s+f>g2I0h1b8kNFZ^BpKU-A;|KYv7g56UmY%;c@NxNrI zYz3~`y-F?Wba7JVyBpd{&#IC5NDyRd{n{`n=6LUR;!dLP^0hufdeOx{2747OJ`#Gx z(=^04&XCroI87#X!%}~EAw{(mjis%4D*yU(FVy0RggZTkB#0a4Zf`hL=yBQC3s95> z$|S1vCfnKIT?;Jtdu{h`=w=ceNYyU#nHJiaa(m=vm|(Gvh{hhT%p+eJ)Ah`eVOqb^ zn1Scs7$@}ZDMYD=4w*P_!p?Lw2(qmFm2WSTPp5nBo}puB>}g)N=*;(Y^o#q1^J$6)2`}oUGHjBD7sv01qkj#D+dw$n_@}DgPlM5a zgfTj?-hcx~->kWcsJOJ5_Yyor6Uqfh!8RJ@;9CCTd*?# zaT)-s#f6Zp|3k1-0V#nLzb*rJ1jYYZG-WsW8tMMY{sCn#{TJE`Nd!USK#-Vk{P;rI z3;6eN@1?cp;ACZMFo_)5zMy!R%HWGncUOdw*_cvZ3z6oGR*<{L@h@+fc1s9(c+u?R`Q%2s1VkM4 z(!)tHw?4qG%g#r{NZGbLwPcIWU>0G%t0iMXiE3r1os_T|aL^pJFLizXOkl8q`<)%# z^R;4z?ZLTy%;HCkiAf5WLg;%aq2rWo97M~z9jyI2Y2`BfXq{-Sokh>rN~dPM!Ba3tZ!S|fT#E7|v&Z+5 z%yU~4Wj1@#rl$hLF>({0#NdVL{o_4?%w&e8zMRCt9X%7V6&>W%5Md;Dq#>jdSIVo6 zMn)C8vz?>}jSFHgF&TH}&DggRk1}-=CSk@ir5f(+C+48a-NJt4PdNy~)KQX>BO>We z!QDBWm6h)0b?x+4JY~U0X&V2-wBx3`RdR{TZ(Th|wl;Ymc@oKe9xtP@bbVcXdKUH; z-G9?cp!f{5jUXSnu;6NI5+gM9SVQr4lfUM*Ob;9mYOi-{4gS{gd|3r#`@#dmvdQuMpxl7_Ozu4mXxxeJV@?% zdh3Y(DnH>8_^g+jJF*XB7!s1o%AcpTFL#s$tA$(I(G8ntlJ!LGNtfy>+={6_*|#!3RLv+y51=AXo)fE&h(jr}hK> zyY%>OB0yn9fG8&bw2|+e_op5o<+Gua-DNgEvGI2JoX-wz@9yqW;B47ZDMlr$*Qp_< z0v=z)WAOZm|Pa=9enflrNZ~*x1i?FSNJ$U0H6W$af87;zbJgb zr!R~DbJyq(Q~q}i3O&8v%xDhGVKy_)TQhESWryLZXO2Mp?L>S%!c(oujHjrJ(6Mtmt-xw`&XDoTu;rcx?d`e?U*jnC7$WabNw1J zexny2k(N{k&CaaM*k@N8;ZIQ_9PjK3TwAZl9+AE_w$;T{^Yc8uA4}r-)EPg+t&cg5 zM0>oFYOdy`3*!pYf@3%?-|uBN7j`f+4ywU2;=CP1swp zsA@~c_w*j;l;!&(ZOGZ3o7%T-t6R{GM}Fuc;GiLRo2ZX}D^7YX4}xgWWQksS{`B2S zYMx-Ngxjr@oIv7qIiz{-(TUbF3g^L4`{L{}UI|hwV^x-*6a%DDwOk6jigq$&vrPiV)K@mc0@2w}X3ZbQRC0T>8aND>qewfK-o|5v%GC_Kjk^u#Fu_u-VY)&R&&whoYekwNnt?vQ11hx0(^Q;ov zo<2v$c|Q1&rP8eHHQa0e6Fer=704r-%s2U^;|*DI!MyFqXf;zm%NjZQwM z+s1UG7GvpI_RvcQo7%hpS--%_tEaQ|bv)d6Sa|I(rqRu5)~c!kDzSPBfBgkBayHDY z@7ib6AkWQogWYX6&1&Sg1Dc`Mqf~{1SLBeV%==i`I|Falb_x!1U7HZEk~xwcCo8oU z(cVj#A{DhkdK}Tx4=!J9_qaNqfzfd!%cq&=aBay!i!TOoh^}TOcgrZl*qBAi%X@_ulC}fuu)-ItG;&~&wACedw=VD|KHDo{mJd3zxBQU@AbW5pxC*n=RuIOOvwLa#r+TF9)-b%zW6KH zvgvFVl4G;kIi{Oe@cz2yUJv`P%)N_uSaAPn?)|djpO||+>=$C*7jtj>2XpV{7juua z_&;mzxrP{DwaQSz?G*A)XAxb47kH#>Q#%!}oWEX^<42Jw&}`}9zI=Z)#hWI2bd&B5 z3`uTel(P@fbkKZd&egp&0cre6*7gas2VQ%wW_A+qGvYej_f!YR{XN7s-t$atqa<$^ z;7`wG1SybicHPXL#i~JDF3YVaClWi~)WI+GQ26Vadq1H&KSJ1l*W7#h#2aUxT1SV$ z%#EkLa7~?ry6)5Xu#Ie_HuGNHG&l1in~9yK0^IPp|f zSjKtdZN&Ls$=v(xi37~N2;A;--#&49r-rq9760(IxtEHNW|4ly=#UxKmZG;(zd>gg z=AL6)a>xB+Y;@1f>$ZY0b=cSGQs_fCrxFnA7Zo^q_N08%1>fXdMJQ7PrD-Is z;EASzU)PBs+I8LenY<3ko;}A(N30ylvXjRhTh%^Z?~GS{mX0#e!FH9RcJpCwK^b$> z9*E|05ps2c2Tt#9h)6vt3zBv*+Z5kF=$`7m#usIpCcAmU+Go^Q$180(); + + var nextId = targetArray.Length; + object[][] newEntries = + [ + [nextId++, "NMSSS", "过新过热Slide", NotesTypeID.Def.Slide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0], + [nextId++, "BRSSS", "过新过热BreakSlide", NotesTypeID.Def.BreakSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0], + [nextId++, "EXSSS", "过新过热ExSlide", NotesTypeID.Def.ExSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0], + [nextId++, "BXSSS", "过新过热ExBreakSlide", NotesTypeID.Def.ExBreakSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0], + [nextId++, "CNSSS", "过新过热ConnSlide", NotesTypeID.Def.ConnectSlide, SlideType.Slide_MAX, 8, Ma2Category.MA2_Note, 2, 2, 2, 2, 2, 2, 0], + ]; + + // Ma2fileRecordID.Ma2fileRecord_Data is private, so we need this shit. + var structType = targetArray.GetValue(0).GetType(); + var constructor = AccessTools.Constructor(structType, + [ + typeof(int), typeof(string), typeof(string), typeof(NotesTypeID.Def), typeof(SlideType), typeof(int), + typeof(Ma2Category), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), + typeof(int) + ]); + + Ma2FileRecordData = Array.CreateInstance(structType, targetArray.Length + newEntries.Length); + for (var i = 0; i < targetArray.Length; i++) + { + Ma2FileRecordData.SetValue(targetArray.GetValue(i), i); + } + + for (var i = 0; i < newEntries.Length; i++) + { + var j = targetArray.Length + i; + var obj = constructor.Invoke(newEntries[i]); + Ma2FileRecordData.SetValue(obj, j); + } + + arrayTraverse.SetValue(Ma2FileRecordData); + TotalMa2RecordCount = Ma2FileRecordData.Length; + LastMa2RecordID = TotalMa2RecordCount - 1; + MelonLogger.Msg($"[CustomNoteType] MA2 record data extended, total count: {TotalMa2RecordCount}"); + + // Initialize related classes ... + SlideDataBuilder.InitializeHitAreasLookup(); + MelonLogger.Msg($"[CustomNoteType] HitAreasLookup initialized, total count: {SlideDataBuilder.HitAreasLookup.Count}"); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(Ma2fileRecordID), "findID")] + public static bool FindIDPrefix(string enumName, ref Ma2fileRecordID.Def __result) + { + // I don't know why but patching findID() leads to a completely invalid result + // Sometimes it will even throw an exception + // So I can only prefix it and override it + __result = Ma2fileRecordID.Def.Invalid; + for (var i = 0; i < TotalMa2RecordCount; i++) + { + var item = Ma2FileRecordData.GetValue(i); + if (Traverse.Create(item).Field("enumName").Value == enumName) + { + __result = (Ma2fileRecordID.Def) i; + } + } + + return false; + } + + [HarmonyPatch] + public static class Ma2RecordValidation + { + public static IEnumerable TargetMethods() + { + return + [ + // AccessTools.Method(typeof(Ma2fileRecordID), "findID"), + AccessTools.Method(typeof(Ma2fileRecordID), "clamp"), + AccessTools.Method(typeof(Ma2fileRecordID), "getClampValue"), + AccessTools.Method(typeof(Ma2fileRecordID), "isValid"), + AccessTools.Method(typeof(Ma2fileRecordID_Extension), "isValid"), + ]; + } + + public static IEnumerable Transpiler(IEnumerable instructions) + { + + foreach (var inst in instructions) + { + if (inst.LoadsConstant(142)) + { + var instNew = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(CustomNoteTypePatch), "TotalMa2RecordCount")); + yield return instNew; + } + else if (inst.LoadsConstant(141)) + { + var instNew = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(CustomNoteTypePatch), "LastMa2RecordID")); + yield return instNew; + } + else + { + yield return inst; + } + } + } + } + + /* + * ========== ========== ========== ========== ========== ========== ========== ========== + * 以下内容是给新的 MA2 语法写解析器 + */ + + /* + * 给新建的 noteData 初始化应有的数据, 仅仅是照搬了 NotesReader.loadNote + */ + public static void PrepareBasicNoteData(NoteData noteData, NotesReader reader, + MA2Record record, int index, ref int noteIndex, OptionMirrorID mirrorMode) + { + noteData.type = record.getType().getNotesTypeId(); + noteData.time.init(record.getBar(), record.getGrid(), reader); + noteData.end = noteData.time; + noteData.startButtonPos = MaiGeometry.MirrorInfo[(int) mirrorMode, record.getPos()]; + noteData.index = index; + var num = record.getGrid() % 96; + if (num == 0) + { + noteData.beatType = NoteData.BeatType.BeatType04; + } + else if (num % 48 == 0) + { + noteData.beatType = NoteData.BeatType.BeatType08; + } + else if (num % 24 == 0) + { + noteData.beatType = NoteData.BeatType.BeatType16; + } + else if (num % 16 == 0) + { + noteData.beatType = NoteData.BeatType.BeatType24; + } + else + { + noteData.beatType = NoteData.BeatType.BeatTypeOther; + } + noteData.indexNote = noteIndex; + ++noteIndex; + } + + /* + * 给新建的 noteData 填入基本的 slide 相关数据, 仅仅是照搬了 NotesReader.loadNote + */ + public static void PrepareBasicSlideData(NoteData noteData, NotesReader reader, MA2Record record, int noteIndex, + ref int slideIndex, OptionMirrorID mirrorMode) + { + noteData.indexSlide = slideIndex++; + var slideData = noteData.slideData; + var slideWaitLen = record.getSlideWaitLen(); + var slideShootLen = record.getSlideShootLen(); + slideData.targetNote = MaiGeometry.MirrorInfo[(int) mirrorMode, record.getSlideEndPos()]; + slideData.shoot.time.init(record.getBar(), record.getGrid() + slideWaitLen, reader); + slideData.shoot.index = noteIndex; + slideData.arrive.time.init(record.getBar(), record.getGrid() + slideWaitLen + slideShootLen, reader); + slideData.arrive.index = noteIndex; + noteData.end = slideData.arrive.time; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NotesReader), "loadNote")] + public static bool LoadCustomNote(NotesReader __instance, ref bool __result, NotesData ____note, int ____playerID, + MA2Record rec, int index, ref int noteIndex, ref int slideIndex) + { + if (rec.getType() < Ma2fileRecordID.Def.End) + { + // builtin record type + return true; + } + + MelonLogger.Msg($"[CustomNoteType] Custom note | {rec._str.Count} | {rec.getStr(0)} {rec.getStr(1)} {rec.getStr(2)} {rec.getStr(3)} {rec.getStr(4)} {rec.getStr(5)} {rec.getStr(6)} {rec.getStr(7)} {rec.getStr(8)}"); + + var flag = true; + switch (rec.getType().getEnumName()) + { + case "NMSSS": + case "BRSSS": + case "EXSSS": + case "BXSSS": + case "CNSSS": + var noteData = new CustomSlideNoteData(); + var mirrorMode = Singleton.Instance.GetGameScore(____playerID).UserOption.MirrorMode; + PrepareBasicNoteData(noteData, __instance, rec, index, ref noteIndex, mirrorMode); + PrepareBasicSlideData(noteData, __instance, rec, noteIndex, ref slideIndex, mirrorMode); + var success = noteData.ParseSlideCode(rec.getStr(7), mirrorMode); + if (success) + { + ____note._noteData.Add(noteData); + } + else + { + flag = false; + } + break; + default: + flag = false; + break; + } + __result = flag; + return false; + } + + /* + * ========== ========== ========== ========== ========== ========== ========== ========== + * 以下内容是为了实现自定义 Slide + * + */ + + /* + * 把 GetSlidePath 和 GetSlideHitArea 和 GetSlideLength 重定向到我可以控制的函数上, 并且多推几个参数进来 + */ + [HarmonyPatch] + public static class SlideNoteDataHack + { + public static IEnumerable TargetMethods() + { + return + [ + AccessTools.Method(typeof(SlideRoot), "Initialize"), + AccessTools.Method(typeof(SlideRoot), "GetSlideArrowNum", [typeof(NoteData)]), + AccessTools.Method(typeof(StarNote), "Initialize"), + AccessTools.Method(typeof(BreakStarNote), "Initialize"), + ]; + } + + public static IEnumerable Transpiler(IEnumerable instructions) + { + var methodGetSlidePath = AccessTools.Method(typeof(SlideManager), "GetSlidePath"); + var methodGetSlidePathRedirect = AccessTools.Method(typeof(CustomNoteTypePatch), "GetSlidePathRedirect"); + var methodGetSlideHitArea = AccessTools.Method(typeof(SlideManager), "GetSlideHitArea"); + var methodGetSlideHitAreaRedirect = AccessTools.Method(typeof(CustomNoteTypePatch), "GetSlideHitAreaRedirect"); + var methodGetSlideLength = AccessTools.Method(typeof(SlideManager), "GetSlideLength"); + var methodGetSlideLengthRedirect = AccessTools.Method(typeof(CustomNoteTypePatch), "GetSlideLengthRedirect"); + var fieldSlideData = AccessTools.Field(typeof(NoteData), "slideData"); + + var oldInstList = new List(instructions); + var newInstList = new List(); + CodeInstruction instToInject = null; + + for (var i = 0; i < oldInstList.Count; ++i) + { + var inst = oldInstList[i]; + if (inst.LoadsField(fieldSlideData)) + { + // 以 GetSlidePath 为例, 我们需要把下面这个调用: + // Singleton.Instance.GetSlidePath( + // noteData.slideData.type, noteData.startButtonPos, + // noteData.slideData.targetNote, this.ButtonId + // ) + // 里的 noteData 拿到手 + // 所以就记录上一次 ldfld NoteData::slideData 的位置, 往前找一个 IL code + // 找到的就是 load 这个 noteData 的位置 + // 然后在后续调用 GetSlidePath 时, 先重复一遍 load 把这个 noteData 入栈, 然后重定向到一个新的函数上去 + instToInject = oldInstList[i-1]; + newInstList.Add(inst); + } + else if (inst.Calls(methodGetSlidePath)) + { + newInstList.Add(instToInject!.Clone()); + newInstList.Add(new CodeInstruction(OpCodes.Call, methodGetSlidePathRedirect)); + instToInject = null; + } + else if (inst.Calls(methodGetSlideHitArea)) + { + newInstList.Add(instToInject!.Clone()); + newInstList.Add(new CodeInstruction(OpCodes.Call, methodGetSlideHitAreaRedirect)); + instToInject = null; + } + else if (inst.Calls(methodGetSlideLength)) + { + newInstList.Add(instToInject!.Clone()); + newInstList.Add(new CodeInstruction(OpCodes.Call, methodGetSlideLengthRedirect)); + instToInject = null; + } + else + { + newInstList.Add(inst); + } + } + return newInstList; + } + } + + + public static List GetSlidePathRedirect(SlideManager instance, SlideType slideType, int start, int end, + int starButton, NoteData noteData) + { + // MelonLogger.Msg($"[CustomNoteType] GetSlidePath Redirected!"); + // MelonLogger.Msg($"{noteData.indexNote} {noteData.indexSlide} {slideType} {start} {end} {starButton}"); + if (noteData is CustomSlideNoteData data) + { + // MelonLogger.Msg($"[CustomNoteType] Successfully injected custom path {data.SlideCode}"); + return data.SlidePathList[starButton]; + } + return instance.GetSlidePath(slideType, start, end, starButton); + } + + public static List GetSlideHitAreaRedirect(SlideManager instance, SlideType slideType, + int start, int end, int starButton, NoteData noteData) + { + // MelonLogger.Msg($"[CustomNoteType] GetSlideHitArea Redirected!"); + // MelonLogger.Msg($"{noteData.indexNote} {noteData.indexSlide} {slideType} {start} {end} {starButton}"); + if (noteData is CustomSlideNoteData data) + { + // MelonLogger.Msg($"[CustomNoteType] Successfully injected custom hit areas {data.SlideCode}"); + return data.SlideHitAreasList[starButton]; + } + return instance.GetSlideHitArea(slideType, start, end, starButton); + } + + public static float GetSlideLengthRedirect(SlideManager instance, SlideType slideType, + int start, int end, NoteData noteData) + { + // MelonLogger.Msg($"[CustomNoteType] GetSlideLength Redirected!"); + // MelonLogger.Msg($"{noteData.indexNote} {noteData.indexSlide} {slideType} {start} {end}"); + if (noteData is CustomSlideNoteData data) + { + // MelonLogger.Msg($"[CustomNoteType] Successfully injected custom path length {data.SlideCode}"); + return data.SlidePathLength; + } + return instance.GetSlideLength(slideType, start, end); + } + + + + [HarmonyPatch] + public static class Debuging + { + public static IEnumerable TargetMethods() + { + return + [ + AccessTools.Method(typeof(SlideRoot), "Initialize"), + // AccessTools.Method(typeof(SlideRoot), "GetSlideArrowNum", []), + // AccessTools.Method(typeof(SlideRoot), "GetSlideArrowNum", [typeof(NoteData)]), + // AccessTools.Method(typeof(SlideRoot), "GetArrowData"), + // AccessTools.Method(typeof(SlideRoot), "totalDistance"), + // AccessTools.Method(typeof(SlideRoot), "GetActiveArrowNum"), + // AccessTools.Method(typeof(SlideJudge), "SetJudgeType"), + ]; + } + + public static void Prefix(MethodBase __originalMethod, object[] __args) + { + var msg = "[CustomNoteType] Before "; + msg += __originalMethod.DeclaringType!.FullName + "." + __originalMethod.Name + " ("; + var infos = __originalMethod.GetParameters() + .Select((x, i) => x.ParameterType.FullName + " " + x.Name + " = " + GetString(__args[i])) + .ToArray(); + msg += infos.Length > 0 ? infos.Aggregate((a, b) => a + ", " + b) : "void"; + msg += ")"; + MelonLogger.Msg(msg); + } + + public static void Postfix(MethodBase __originalMethod, object[] __args) + { + var msg = "[CustomNoteType] After "; + msg += __originalMethod.DeclaringType!.FullName + "." + __originalMethod.Name + " ("; + var infos = __originalMethod.GetParameters() + .Select((x, i) => x.ParameterType.FullName + " " + x.Name + " = " + GetString(__args[i])) + .ToArray(); + msg += infos.Length > 0 ? infos.Aggregate((a, b) => a + ", " + b) : "void"; + msg += ")"; + MelonLogger.Msg(msg); + } + + public static string GetString(object value) + { + if (value is CustomSlideNoteData data) + { + return $""; + } + + if (value is NoteData data2) + { + return $""; + } + + return value.ToString(); + } + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/CustomSlideNoteData.cs b/AquaMai/MaimaiDX2077/CustomSlideNoteData.cs new file mode 100644 index 00000000..1abc3f2f --- /dev/null +++ b/AquaMai/MaimaiDX2077/CustomSlideNoteData.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using DB; +using Manager; +using MelonLoader; +using UnityEngine; + +namespace AquaMai.MaimaiDX2077; + +public class CustomSlideNoteData: NoteData +{ + public string SlideCode; + public List> SlidePathList = new List>(); + public List> SlideHitAreasList = new List>(); + public float SlidePathLength; + + public bool ParseSlideCode(string slideCode, OptionMirrorID mirrorMode) + { + if (string.IsNullOrEmpty(slideCode)) + { + return false; + } + + SlidePathList.Clear(); + SlideHitAreasList.Clear(); + + this.SlideCode = slideCode; + var path = SlideCodeParser.Parse(slideCode); + if (path == null) + { + return false; + } + + var arrowData = SlideDataBuilder.BuildArrowData(path); + SlidePathLength = (float)path.GetPathLength(); + var hitAreaData = SlideDataBuilder.BuildHitAreas(path); + for (var i = 0; i < 8; i++) + { + SlidePathList.Add(SlideDataBuilder.ConvertAndRotateArrowData(arrowData, i, mirrorMode)); + SlideHitAreasList.Add(SlideDataBuilder.ConvertAndRotateHitAreas(hitAreaData, i, mirrorMode)); + } + + var msg = string.Join(", ", + hitAreaData.Select(x => x.PanelAreas).Select(x => string.Join("/", x.Cast()))); + MelonLogger.Msg(msg); + + this.slideData.type = path.GetEndType(mirrorMode); + + return true; + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/MaiGeometry.cs b/AquaMai/MaimaiDX2077/MaiGeometry.cs new file mode 100644 index 00000000..d0c4fab7 --- /dev/null +++ b/AquaMai/MaimaiDX2077/MaiGeometry.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Manager; + +namespace AquaMai.MaimaiDX2077; + +public static class MaiGeometry +{ + public struct CircleStruct(Complex center, double radius) + { + public Complex Center = center; + public double Radius = radius; + } + + public static readonly double CanvasWidth = 1080.0; + public static readonly double MainRadius = 480.0; + public static readonly double CenterRadius = MainRadius * Math.Cos(Math.PI * 3 / 8); + public static readonly double GroupBRadius = CenterRadius / Math.Cos(Math.PI / 8); + + private static readonly double _b = Math.Cos(Math.PI / 8) / 2; + private static readonly double _a = 1 - _b; + private static readonly double _theta = Math.PI / 4; + private static readonly double _s = (_a * _a + _b * _b - 2 * _a * _b * Math.Cos(_theta)) / + (2 * _a - 2 * _b * Math.Cos(_theta)); + + public static readonly double PPQQRadius = MainRadius * _b; + public static readonly double TransferRadius = MainRadius * (_b + _s); + public static readonly double EdgeTransferAngle = _theta; + public static readonly double PPQQTransferAngle = + Math.Acos((_s * _s + _b * _b - (_a - _s) * (_a - _s)) / (2 * _b * _s)); + + public static readonly double DefaultDistance = MainRadius * Math.PI / 32; + + public static readonly int[,] MirrorInfo = new int[4, 17] + { + { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }, // Normal + { 7, 6, 5, 4, 3, 2, 1, 0, 15, 14, 13, 12, 11, 10, 9, 8, 16 }, // L <-> R + { 3, 2, 1, 0, 7, 6, 5, 4, 11, 10, 9, 8, 15, 14, 13, 12, 16 }, // U <-> D + { 4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11, 16 } // rotate 180 deg + }; + + /// + /// Note: idx is 1-based, not 0-based + /// + public static Complex PointGroupA(int idx) + { + var angle = Math.PI * (5.0 / 8.0 - idx / 4.0); + return Complex.FromPolarCoordinates(MainRadius, angle); + } + + /// + /// Note: idx is 1-based, not 0-based + /// + public static Complex PointGroupB(int idx) + { + var angle = Math.PI * (5.0 / 8.0 - idx / 4.0); + return Complex.FromPolarCoordinates(GroupBRadius, angle); + } + + public static Complex Center() + { + return Complex.Zero; + } + + /// + /// idx 0 is center circle, idx 1~8 are ppqq circles, idx 9 is outer circle + /// + public static CircleStruct GetCircle(int idx) + { + if (idx == 0) + { + return new CircleStruct(Complex.Zero, CenterRadius); + } + + if (idx == 9) + { + return new CircleStruct(Complex.Zero, MainRadius); + } + + var angle = Math.PI * (3.0 / 4.0 - idx / 4.0); + var center = Complex.FromPolarCoordinates(PPQQRadius, angle); + return new CircleStruct(center, PPQQRadius); + } + + /// + /// Note: idx is 1-based, not 0-based + /// + /// CircleStruct TransferCircle, double TransferStartAngle, double TransferEndAngle + public static Tuple TransferOutData(int idx, bool isccw) + { + var ppqqRad = Math.PI * (3.0 / 4.0 - idx / 4.0); + double startAngle, endAngle; + if (isccw) + { + startAngle = ppqqRad - PPQQTransferAngle; + endAngle = ppqqRad + EdgeTransferAngle; + } + else + { + startAngle = ppqqRad + PPQQTransferAngle; + endAngle = ppqqRad - EdgeTransferAngle; + } + var d = MainRadius - TransferRadius; + var center = Complex.FromPolarCoordinates(d, endAngle); + return new Tuple(new CircleStruct(center, TransferRadius), + Math.IEEERemainder(startAngle, Math.PI * 2), Math.IEEERemainder(endAngle, Math.PI * 2)); + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/ParametricSlidePath.cs b/AquaMai/MaimaiDX2077/ParametricSlidePath.cs new file mode 100644 index 00000000..5b928025 --- /dev/null +++ b/AquaMai/MaimaiDX2077/ParametricSlidePath.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using DB; +using Manager; + +namespace AquaMai.MaimaiDX2077; + +public class ParametricSlidePath +{ + public enum ParseMarker + { + None = 0, + SmoothAlign, + ForceAlign, + SharpCorner + } + + public abstract class PathSegment + { + public ParseMarker ParseMarker = ParseMarker.None; + public double ArrowDistance = MaiGeometry.DefaultDistance; + + public abstract bool DoAngleLerp { get; } + + public abstract Complex GetPointAt(double t); + + public abstract Complex GetTangentAt(double t); + + public abstract double GetSegmentLength(); + + public void SetParseMarker(ParseMarker marker) => ParseMarker = marker; + + public void SetArrowDistance(double distance) => ArrowDistance = distance; + } + + public class LineSegment(Complex start, Complex end) : PathSegment + { + public readonly Complex StartPoint = start; + public readonly Complex EndPoint = end; + + public override bool DoAngleLerp { get; } = false; + + public override Complex GetPointAt(double t) + { + return StartPoint + (EndPoint - StartPoint) * t; + } + + public override Complex GetTangentAt(double t) + { + var v = EndPoint - StartPoint; + return v / v.Magnitude; + } + + public override double GetSegmentLength() + { + return (EndPoint - StartPoint).Magnitude; + } + } + + public class ArcSegment(MaiGeometry.CircleStruct circle, double startAngle, double endAngle) : PathSegment + { + public readonly MaiGeometry.CircleStruct Circle = circle; + public readonly double StartAngle = startAngle; + public readonly double EndAngle = endAngle; + + public override bool DoAngleLerp { get; } = true; + + public override Complex GetPointAt(double t) + { + var angle = StartAngle + t * (EndAngle - StartAngle); + return Circle.Center + Complex.FromPolarCoordinates(Circle.Radius, angle); + } + + public override Complex GetTangentAt(double t) + { + var angle = StartAngle + t * (EndAngle - StartAngle); + if (StartAngle < EndAngle) + { + return Complex.FromPolarCoordinates(1, angle) * Complex.ImaginaryOne; + } + else + { + return Complex.FromPolarCoordinates(-1, angle) * Complex.ImaginaryOne; + } + } + + public override double GetSegmentLength() + { + return Math.Abs(EndAngle - StartAngle) * Circle.Radius; + } + } + + public class CircleSegment(MaiGeometry.CircleStruct circle, double startAngle, bool isCcw) : PathSegment + { + public readonly MaiGeometry.CircleStruct Circle = circle; + public readonly double StartAngle = startAngle; + public readonly bool IsCcw = isCcw; + + public override bool DoAngleLerp { get; } = true; + + public override Complex GetPointAt(double t) + { + double angle; + if (IsCcw) + { + angle = StartAngle + t * Math.PI * 2f; + } + else + { + angle = StartAngle - t * Math.PI * 2f; + } + + return Circle.Center + Complex.FromPolarCoordinates(Circle.Radius, angle); + } + + public override Complex GetTangentAt(double t) + { + double angle; + if (IsCcw) + { + angle = StartAngle + t * Math.PI * 2f; + return Complex.FromPolarCoordinates(1, angle) * Complex.ImaginaryOne; + } + else + { + angle = StartAngle - t * Math.PI * 2f; + return Complex.FromPolarCoordinates(-1, angle) * Complex.ImaginaryOne; + } + } + + public override double GetSegmentLength() + { + return Math.PI * Circle.Radius * 2; + } + } + + + public readonly PathSegment[] Segments; + public readonly double[] Fractions; + public readonly double[] AccumulatedLengths; + + public ParametricSlidePath(IEnumerable pathSegments) + { + Segments = pathSegments.ToArray(); + if (Segments.Length == 0) + { + throw new ArgumentException("At least one path segment is required."); + } + var lengths = Segments.Select(s => s.GetSegmentLength()); + var sum = 0.0; + AccumulatedLengths = lengths.Select(x => (sum += x)).ToArray(); + Fractions = AccumulatedLengths.Select(x => x / sum).ToArray(); + } + + public PathSegment GetSegmentAt(double t, out double segmentT) + { + if (t <= 0.0) + { + segmentT = 0.0; + return Segments[0]; + } + + if (t >= 1.0) + { + segmentT = 1.0; + return Segments[Segments.Length - 1]; + } + + var idx = Array.BinarySearch(Fractions, t); + if (idx < 0) + { + idx = ~idx; // first entry > t + } + // if idx >= 0 then idx is the entry == t + // so Fractions[idx-1] < t and Fractions[idx] >= t + // Note: Fractions[i] marks the end point of Segments[i] + + if (idx >= Segments.Length) + { + segmentT = 1.0; + return Segments[Segments.Length - 1]; + } + + if (idx == 0) + { + segmentT = t / Fractions[0]; + return Segments[0]; + } + + segmentT = (t - Fractions[idx - 1]) / (Fractions[idx] - Fractions[idx - 1]); + return Segments[idx]; + } + + public double GetPathLength() => AccumulatedLengths[AccumulatedLengths.Length - 1]; + + public Complex GetPointAt(double t) + { + var segment = GetSegmentAt(t, out var segT); + return segment.GetPointAt(segT); + } + + public Complex GetTangentAt(double t) + { + var segment = GetSegmentAt(t, out var segT); + return segment.GetTangentAt(segT); + } + + public SlideType GetEndType(OptionMirrorID mirrorMode) + { + var lastSegment = Segments[Segments.Length - 1]; + var flip = mirrorMode == OptionMirrorID.LR || mirrorMode == OptionMirrorID.UD; + if (lastSegment is CircleSegment circle) + { + return circle.IsCcw != flip ? SlideType.Slide_Circle_L : SlideType.Slide_Circle_R; + } + + if (lastSegment is ArcSegment arc) + { + return (arc.EndAngle > arc.StartAngle) != flip ? SlideType.Slide_Circle_L : SlideType.Slide_Circle_R; + } + + return SlideType.Slide_Straight; + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/SlideCodeParser.cs b/AquaMai/MaimaiDX2077/SlideCodeParser.cs new file mode 100644 index 00000000..fc3ad74e --- /dev/null +++ b/AquaMai/MaimaiDX2077/SlideCodeParser.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using MelonLoader; + +namespace AquaMai.MaimaiDX2077; + +public static class SlideCodeParser +{ + public enum CommandType + { + Invalid = -1, + NodeA = 0, + NodeB = 1, + NodeC = 2, + OrbitCCW = 3, + OrbitCW = 4, + NodeEnd = 5 + } + + public struct Command(CommandType type, int value) + { + public CommandType Type = type; + public int Value = value; + + public static bool IsSame(Command a, Command b) + { + return a.Type == b.Type && a.Value == b.Value; + } + } + + public static readonly char[] CommandChars = + [ + 'A', 'B', 'C', 'P', 'Q', 'K' + ]; + + public static int TryParseDigit(char c) + { + if (c >= '0' && c <= '9') return c - '0'; + return -1; + } + + public static List ParseCommands(string code) + { + if (!CommandChars.Contains(code[1])) + { + throw new ArgumentException($"the 2nd char should be a command"); + } + + if (code[code.Length - 2] != 'K') + { + throw new ArgumentException($"should end with 'K' command"); + } + + var commands = new List(); + var currentType = CommandType.NodeA; + var value = TryParseDigit(code[0]); + if (value < 0) throw new ArgumentException($"invalid char '{code[0]}'"); + + commands.Add(new Command(currentType, value)); + + for (var ptr = 1; ptr < code.Length; ptr++) + { + var ch = code[ptr]; + if (CommandChars.Contains(ch)) + { + currentType = (CommandType) Array.IndexOf(CommandChars, ch); + if (currentType == CommandType.NodeC) + { + commands.Add(new Command(CommandType.NodeC, 0)); + } + } + else + { + value = TryParseDigit(ch); + if (value < 0) throw new ArgumentException($"invalid char '{ch}'"); + if (currentType == CommandType.NodeC) + { + throw new ArgumentException($"digit should not follow 'C'"); + } + commands.Add(new Command(currentType, value)); + } + } + return commands; + } + + public static Complex GetNodePosition(Command cmd) + { + switch (cmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeEnd: + return MaiGeometry.PointGroupA(cmd.Value); + case CommandType.NodeB: + return MaiGeometry.PointGroupB(cmd.Value); + case CommandType.NodeC: + return MaiGeometry.Center(); + default: + throw new ArgumentException($"invalid type for node: {cmd.Type}"); + } + } + + public static void NodeToNode(SlidePathGenerator generator, Command last, Command current) + { + if (Command.IsSame(last, current)) return; + generator.LineToPoint(GetNodePosition(current)); + } + + public static void NodeToOrbit(SlidePathGenerator generator, Command last, Command current) + { + var isCcw = (current.Type == CommandType.OrbitCCW); + var node = GetNodePosition(last); + var orbit = MaiGeometry.GetCircle(current.Value); + var diff = node - orbit.Center; + if (Math.Abs(diff.Magnitude - orbit.Radius) < 0.1) + { + if (last.Type == CommandType.NodeA && current.Value == 9) + { + generator.TrySetLastParseMarker(ParametricSlidePath.ParseMarker.ForceAlign); + } + return; // node on circle, do nothing + } + + if (diff.Magnitude < orbit.Radius) + throw new ArgumentException($"impossible: {last.Type}{last.Value} -> Orbit{current.Value}"); + + generator.TangentToCircle(orbit, isCcw); + } + + public static void OrbitToNode(SlidePathGenerator generator, Command last, Command current) + { + var isCcw = (last.Type == CommandType.OrbitCCW); + var node = GetNodePosition(current); + var orbit = MaiGeometry.GetCircle(last.Value); + var diff = node - orbit.Center; + if (Math.Abs(diff.Magnitude - orbit.Radius) < 0.1) + { + generator.ArcToAngle(orbit.Center, diff.Phase, isCcw, false); + return; + } + + if (diff.Magnitude < orbit.Radius) + throw new ArgumentException($"impossible: Orbit{last.Value} -> {current.Type}{current.Value}"); + + generator.ArcToTangentTowards(node, orbit.Center, isCcw); + generator.LineToPoint(node); + } + + public static void OrbitToOrbit(SlidePathGenerator generator, Command last, Command current) + { + if (current.Type != last.Type) throw new ArgumentException($"orbit type mismatch"); + + var isCcw = (last.Type == CommandType.OrbitCCW); + var lastOrbit = MaiGeometry.GetCircle(last.Value); + var currentOrbit = MaiGeometry.GetCircle(current.Value); + if (current.Value == last.Value) + { + generator.FullCircle(lastOrbit.Center, isCcw); + return; + } + + if (last.Value == 0 && current.Value == 9 || last.Value == 9 && current.Value == 0) + throw new ArgumentException($"impossible: Orbit{last.Value} -> Orbit{current.Value}"); + + if (current.Value == 9) + { + var data = MaiGeometry.TransferOutData(last.Value, isCcw); + generator.ArcToAngle(lastOrbit.Center, data.Item2, isCcw, false); + generator.ArcToAngle(data.Item1.Center, data.Item3, isCcw, false); + generator.TrySetLastParseMarker(ParametricSlidePath.ParseMarker.SmoothAlign); + return; + } + + if (last.Value == 9) + { + var data = MaiGeometry.TransferOutData(current.Value, !isCcw); + generator.ArcToAngle(lastOrbit.Center, data.Item3, isCcw, true); + generator.ArcToAngle(data.Item1.Center, data.Item2, isCcw, false); + return; + } + + generator.ExternTangentTransfer(lastOrbit.Center, currentOrbit, isCcw); + } + + public static ParametricSlidePath Parse(string code) + { + try + { + var commands = ParseCommands(code); + var lastCmd = commands[0]; + // The first command is guarantee to be 'A' + var generator = SlidePathGenerator.BeginAt(MaiGeometry.PointGroupA(lastCmd.Value)); + + for (var i = 1; i < commands.Count; i++) + { + var cmd = commands[i]; + switch (cmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeB: + case CommandType.NodeC: + case CommandType.NodeEnd: + switch (lastCmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeB: + case CommandType.NodeC: + NodeToNode(generator, lastCmd, cmd); + break; + case CommandType.OrbitCCW: + case CommandType.OrbitCW: + OrbitToNode(generator, lastCmd, cmd); + break; + case CommandType.NodeEnd: + throw new ArgumentException($"'K' should be the last command"); + default: + throw new ArgumentOutOfRangeException(); + } + break; + case CommandType.OrbitCCW: + case CommandType.OrbitCW: + switch (lastCmd.Type) + { + case CommandType.NodeA: + case CommandType.NodeB: + case CommandType.NodeC: + NodeToOrbit(generator, lastCmd, cmd); + break; + case CommandType.OrbitCCW: + case CommandType.OrbitCW: + OrbitToOrbit(generator, lastCmd, cmd); + break; + case CommandType.NodeEnd: + throw new ArgumentException($"'K' should be the last command"); + default: + throw new ArgumentOutOfRangeException(); + } + break; + default: + throw new ArgumentOutOfRangeException(); + } + + lastCmd = cmd; + } + + return generator.GeneratePath(); + } + catch (ArgumentException e) + { + var msg = $"Invalid code: {code}"; + if (e.Message != "") + { + msg += $", {e.Message}"; + } + MelonLogger.Error(msg); + return null; + } + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/SlideDataBuilder.cs b/AquaMai/MaimaiDX2077/SlideDataBuilder.cs new file mode 100644 index 00000000..857db2c2 --- /dev/null +++ b/AquaMai/MaimaiDX2077/SlideDataBuilder.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using DB; +using Manager; +using MelonLoader; +using Vector4 = UnityEngine.Vector4; + +namespace AquaMai.MaimaiDX2077; + +public static class SlideDataBuilder +{ + public readonly struct ArrowData(Complex point, Complex tangent, double length) + { + public readonly Complex Point = point; + public readonly Complex Tangent = tangent; + public readonly double Length = length; + } + + public static List BuildArrowData(ParametricSlidePath path) + { + var result = new List(); + var totalLength = path.GetPathLength(); + var totalSegCount = path.Segments.Length; + + var length = 0.0; + var segIdx = 0; + var isSwitching = false; + + while (length < totalLength) + { + var t = length / totalLength; + var pt = path.GetPointAt(t); + var tg = path.GetTangentAt(t); + + var t2 = (length + 10.0) / totalLength; + if ((path.GetTangentAt(t2) - tg).Magnitude < 0.2) // 0.2 -> ~ 11.48 deg apart + { + // use secant instead of tangent (for better visual quality) + tg = path.GetPointAt(t2) - pt; + tg /= tg.Magnitude; + } + + if (isSwitching) + { + // around connecting point of 2 segments, smoothing the transition + var last = result[result.Count - 1]; + var vec = pt - last.Point; + vec /= vec.Magnitude; + if ((tg - last.Tangent).Magnitude < 0.2) + { + var x = 0.5 * (last.Tangent + vec); + x /= x.Magnitude; + result[result.Count - 1] = new ArrowData(last.Point, x, last.Length); + tg = 0.5 * (tg + vec); + tg /= tg.Magnitude; + } + } + + result.Add(new ArrowData(pt, tg, length)); + isSwitching = false; + + var nextLength = length + path.Segments[segIdx].ArrowDistance; + if (segIdx < totalSegCount - 1 && nextLength >= path.AccumulatedLengths[segIdx]) + { + isSwitching = true; + + if (path.Segments[segIdx].ParseMarker == ParametricSlidePath.ParseMarker.ForceAlign) + { + // in this case the next point is forced to be 1 unit after the connecting point + nextLength = path.AccumulatedLengths[segIdx] + path.Segments[segIdx + 1].ArrowDistance; + + // P.S. 这种情况一般是出现在一条直线连接到外圈, 这个处理是为了让外圈的箭头对齐 + } + + if (path.Segments[segIdx + 1].ParseMarker == ParametricSlidePath.ParseMarker.SmoothAlign) + { + // arrow distance of the next segment is tempered in order to align arrow + var delta = path.AccumulatedLengths[segIdx + 1] - length; + var n = Math.Round(delta / MaiGeometry.DefaultDistance); + path.Segments[segIdx + 1].SetArrowDistance(delta / n); + nextLength = length + delta / n; + + // P.S. 这种情况出现在 ppqq 圈进入外圈, 可以把转移轨道的箭头间距微调一下, 也是让外圈对齐 + } + + segIdx++; + } + + length = nextLength; + } + + // 把路径终点补上 + result.Add(new ArrowData(path.GetPointAt(1.0), path.GetTangentAt(1.0), totalLength)); + + return result; + } + + /// + /// Convert arrow data to sinmai format (Vector4) + /// + /// arrow data generated by BuildArrowData() + /// button index of slide-star + /// mirror mode in user option + /// sinmai format arrow data, referenced to slide-star + public static List ConvertAndRotateArrowData(IEnumerable data, int starButton, + OptionMirrorID mirrorMode) + { + // SBGA 用 Vector4 存储了 slide 箭头的坐标与取向 + // x, y 是平面坐标, z 是从起点到此处的路径长度 (px), w 是旋转的角度 (0 ~ 360 deg) (注意与切线方向差了 180 度) + // 坐标原点是屏幕中心, x 轴向右, y 轴向上 + // w 的零点是朝向正右 (对应于箭头朝向正左), 逆时针为正方向 + // 此外, sinmai 实际上是把所有 slide 路径相对于星星头存储的, 再在 SlideRoot 里通过 transform 转到合适的位置 + // 判定区也是相对于星星头存储, 用 InputManager.ConvertTouchPanelRotatePush() 执行旋转 + // 但是 slide code 定义的是绝对位置, 所以要逆向转回去, 以保证无论星星头在哪个键获取到的路径在处理过后都是一样的 + // 然后还需要处理镜像的问题 + + var arrowList = new List(); + var rotor = Complex.FromPolarCoordinates(1.0, Math.PI / 4.0 * starButton); + foreach (var arrow in data) + { + var pos = arrow.Point; + var tangent = arrow.Tangent; + switch (mirrorMode) + { + case OptionMirrorID.Normal: + break; + case OptionMirrorID.LR: + pos = Complex.Conjugate(pos) * -1.0; + tangent = Complex.Conjugate(tangent) * -1.0; + break; + case OptionMirrorID.UD: + pos = Complex.Conjugate(pos); + tangent = Complex.Conjugate(tangent); + break; + case OptionMirrorID.UDLR: + pos *= -1.0; + tangent *= -1.0; + break; + default: + break; + } + pos *= rotor; + tangent *= rotor; + var angle = tangent.Phase * 180.0 / Math.PI + 180.0; // Phase is in [-PI, PI] + arrowList.Add(new Vector4((float) pos.Real, (float) pos.Imaginary, (float) arrow.Length, (float) angle)); + } + return arrowList; + } + + public readonly struct HitAreaData(double push, double release, int[] areas) + { + public readonly double PushDistance = push; + public readonly double ReleaseDistance = release; + public readonly int[] PanelAreas = areas; + } + + public static readonly Dictionary HitAreasLookup = new Dictionary(); + + public static void InitializeHitAreasLookup() + { + for (var i = 0; i < 8; i++) + { + for (var j = 0; j < 8; j++) + { + var diff = (j - i) & 7; // you know this is actually % 8 ... for same negative number compat + int tmp, tmp2; + + // Ai -> Aj + var key = (i << 5) | j; + switch (diff) + { + case 1: + case 7: + HitAreasLookup[key] = + [ + new HitAreaData(0.32, 0.68, [i]), + new HitAreaData(1.00, 1.00, [j]) + ]; + break; + case 2: + tmp = (i + 1) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.20, 0.38, [i]), + new HitAreaData(0.62, 0.80, [tmp, tmp | 8]), + new HitAreaData(1.00, 1.00, [j]) + ]; + break; + case 6: + tmp = (i - 1) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.20, 0.38, [i]), + new HitAreaData(0.62, 0.80, [tmp, tmp | 8]), + new HitAreaData(1.00, 1.00, [j]) + ]; + break; + default: + break; + } + // Bi -> Bj + key = ((i | 8) << 5) | (j | 8); + switch (diff) + { + case 1: + case 7: + HitAreasLookup[key] = + [ + new HitAreaData(0.44, 0.56, [i | 8]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + break; + case 2: + tmp = (i + 1) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.22, 0.35, [i | 8]), + new HitAreaData(0.65, 0.78, [tmp | 8, 16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + break; + case 6: + tmp = (i - 1) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.22, 0.35, [i | 8]), + new HitAreaData(0.65, 0.78, [tmp | 8, 16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + break; + case 3: + tmp = (i + 1) & 7; + tmp2 = (i + 2) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.15, 0.28, [i | 8]), + new HitAreaData(0.48, 0.52, [tmp | 8, 16]), + new HitAreaData(0.72, 0.85, [tmp2 | 8, 16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + break; + case 5: + tmp = (i - 1) & 7; + tmp2 = (i - 2) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.15, 0.28, [i | 8]), + new HitAreaData(0.48, 0.52, [tmp | 8, 16]), + new HitAreaData(0.72, 0.85, [tmp2 | 8, 16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + break; + default: + break; + } + // Ai <-> Bj + key = (i << 5) | (j | 8); + var key2 = ((j | 8) << 5) | i; + switch (diff) + { + case 0: + HitAreasLookup[key] = + [ + new HitAreaData(0.60, 0.75, [i]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + HitAreasLookup[key2] = + [ + new HitAreaData(0.25, 0.40, [j | 8]), + new HitAreaData(1.00, 1.00, [i]) + ]; + break; + case 1: + case 7: + HitAreasLookup[key] = + [ + new HitAreaData(0.45, 0.77, [i]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + HitAreasLookup[key2] = + [ + new HitAreaData(0.23, 0.55, [j | 8]), + new HitAreaData(1.00, 1.00, [i]) + ]; + break; + case 3: + tmp = (i + 1) & 7; + tmp2 = (i + 2) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.25, 0.34, [i]), + new HitAreaData(0.54, 0.68, [i | 8, tmp | 8]), + new HitAreaData(0.85, 0.90, [tmp2 | 8, 16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + HitAreasLookup[key2] = + [ + new HitAreaData(0.10, 0.15, [j | 8]), + new HitAreaData(0.32, 0.46, [tmp2 | 8, 16]), + new HitAreaData(0.66, 0.75, [i | 8, tmp | 8]), + new HitAreaData(1.00, 1.00, [i]) + ]; + break; + case 5: + tmp = (i - 1) & 7; + tmp2 = (i - 2) & 7; + HitAreasLookup[key] = + [ + new HitAreaData(0.25, 0.34, [i]), + new HitAreaData(0.54, 0.68, [i | 8, tmp | 8]), + new HitAreaData(0.85, 0.90, [tmp2 | 8, 16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + HitAreasLookup[key2] = + [ + new HitAreaData(0.10, 0.15, [j | 8]), + new HitAreaData(0.32, 0.46, [tmp2 | 8, 16]), + new HitAreaData(0.66, 0.75, [i | 8, tmp | 8]), + new HitAreaData(1.00, 1.00, [i]) + ]; + break; + default: + break; + } + // C <-> Bj + key = (16 << 5) | (j | 8); + key2 = ((j | 8) << 5) | 16; + HitAreasLookup[key] = + [ + new HitAreaData(0.50, 0.70, [16]), + new HitAreaData(1.00, 1.00, [j | 8]) + ]; + HitAreasLookup[key2] = + [ + new HitAreaData(0.30, 0.50, [j | 8]), + new HitAreaData(1.00, 1.00, [16]) + ]; + } + } + } + + public static List BuildHitAreas(ParametricSlidePath path) + { + var nodeList = new List>(); + var totalLength = path.GetPathLength(); + var count = (int)Math.Round(totalLength / 10.0); + int? lastNode = null; + var enterLength = 0.0; + for (var i = 0; i < count; i++) + { + var t = (double)i / count; + var pt = path.GetPointAt(t); + int? node = null; + + if (pt.Magnitude < 55.0) + { + node = 16; + } + else for (var j = 0; j < 8; j++) + { + var phi = Math.PI * (3.0 / 8.0 - j / 4.0); + if ((pt - Complex.FromPolarCoordinates(440.0, phi)).Magnitude < 80.0) + { + node = j; + break; + } + + if ((pt - Complex.FromPolarCoordinates(210.0, phi)).Magnitude < 45.0) + { + node = j | 8; + break; + } + } + + if (lastNode != node) + { + var length = t * totalLength; + if (lastNode == null) + { + enterLength = length; + } + else + { + nodeList.Add(new Tuple(lastNode.Value, (length + enterLength) / 2.0)); + if (node != null) + { + enterLength = length; + } + } + } + + lastNode = node; + } + nodeList.Add(new Tuple(lastNode!.Value, totalLength)); + nodeList[0] = new Tuple(nodeList[0].Item1, 0.0); + + var result = new List(); + result.Add(new HitAreaData(0.0, 0.0, [nodeList[0].Item1])); + for (var i = 1; i < nodeList.Count; i++) + { + var key = (nodeList[i - 1].Item1 << 5) | nodeList[i].Item1; + var segmentLength = nodeList[i].Item2 - nodeList[i - 1].Item2; + var data = HitAreasLookup[key]; + var area = result[result.Count - 1]; + result[result.Count - 1] = new HitAreaData( + area.PushDistance + segmentLength * data[0].PushDistance, + area.ReleaseDistance + segmentLength * data[0].ReleaseDistance, + area.PanelAreas + ); + for (var j = 1; j < data.Length; j++) + { + result.Add(new HitAreaData( + segmentLength * (data[j].PushDistance - data[j - 1].ReleaseDistance), + segmentLength * (data[j].ReleaseDistance - data[j].PushDistance), + data[j].PanelAreas + )); + } + } + + double lastPushDistance = 0.0; + if (path.GetEndType(OptionMirrorID.Normal) == SlideType.Slide_Straight) + { + var diff = nodeList[nodeList.Count - 1].Item1 - nodeList[nodeList.Count - 2].Item1; + diff %= 8; + lastPushDistance = diff switch + { + 1 or 2 or 6 or 7 => 130.0, + _ => 159.0 + }; + } + else + { + lastPushDistance = 175.0; + } + + var last2ndArea = result[result.Count - 2]; + var lastArea = result[result.Count - 1]; + var distance = last2ndArea.ReleaseDistance + lastArea.PushDistance + lastArea.ReleaseDistance; + result[result.Count - 2] = new HitAreaData(last2ndArea.PushDistance, distance - lastPushDistance, last2ndArea.PanelAreas); + result[result.Count - 1] = new HitAreaData(lastPushDistance, 0.0, lastArea.PanelAreas); + + return result; + } + + + + /// + /// Convert hit area data to sinmai format (Vector4) + /// + /// hit area data generated by BuildHitAreas() + /// button index of slide-star + /// mirror mode in user option + /// sinmai format arrow data, referenced to slide-star + public static List ConvertAndRotateHitAreas(IEnumerable data, int starButton, + OptionMirrorID mirrorMode) + { + var hitAreaList = new List(); + foreach (var hitAreaData in data) + { + var hitArea = new SlideManager.HitArea(); + hitArea.PushDistance = hitAreaData.PushDistance; + hitArea.ReleaseDistance = hitAreaData.ReleaseDistance; + foreach (var pad in hitAreaData.PanelAreas) + { + var converted = MaiGeometry.MirrorInfo[(int) mirrorMode, pad]; + converted = converted == 16 ? 16 : (converted - starButton) & 0b111 | converted & 0b1000; + hitArea.HitPoints.Add((InputManager.TouchPanelArea) converted); + } + hitAreaList.Add(hitArea); + } + return hitAreaList; + } +} \ No newline at end of file diff --git a/AquaMai/MaimaiDX2077/SlidePathGenerator.cs b/AquaMai/MaimaiDX2077/SlidePathGenerator.cs new file mode 100644 index 00000000..a22484f6 --- /dev/null +++ b/AquaMai/MaimaiDX2077/SlidePathGenerator.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace AquaMai.MaimaiDX2077; + +public class SlidePathGenerator +{ + public List PathSegments = new List(); + public Complex CurrentEndPoint = Complex.Zero; + + public static SlidePathGenerator BeginAt(Complex point) + { + var obj = new SlidePathGenerator(); + obj.CurrentEndPoint = point; + return obj; + } + + public static double CalcTangentAngle(Complex point, MaiGeometry.CircleStruct circle, bool isCcw) + { + var hypot = point - circle.Center; + var angleDelta = Math.Acos(circle.Radius / hypot.Magnitude); + var tanAngle = hypot.Phase + (isCcw ? angleDelta : -angleDelta); + return Math.IEEERemainder(tanAngle, Math.PI * 2.0); + } + + public void TrySetLastParseMarker(ParametricSlidePath.ParseMarker marker) + { + if (PathSegments.Count <= 0) return; + PathSegments[PathSegments.Count - 1].SetParseMarker(marker); + } + + public void LineToPoint(Complex point) + { + PathSegments.Add(new ParametricSlidePath.LineSegment(CurrentEndPoint, point)); + CurrentEndPoint = point; + } + + public void TangentToCircle(MaiGeometry.CircleStruct circle, bool isCcw) + { + var inAngle = CalcTangentAngle(CurrentEndPoint, circle, isCcw); + var inPoint = Complex.FromPolarCoordinates(circle.Radius, inAngle) + circle.Center; + LineToPoint(inPoint); + } + + /// Note: endAngle should be in range [-PI, PI] + public void ArcToAngle(Complex center, double endAngle, bool isCcw, bool skipIfZero) + { + var diff = CurrentEndPoint - center; + var circle = new MaiGeometry.CircleStruct(center, diff.Magnitude); + var startAngle = diff.Phase; + // startAngle and endAngle in range [-PI, PI] + if (isCcw) + { + if (startAngle > endAngle) + { + startAngle -= 2 * Math.PI; + } + + if (Math.Abs(endAngle - startAngle) < 0.001) + { + if (skipIfZero) return; + endAngle += 2 * Math.PI; + } + } + else + { + if (startAngle < endAngle) + { + startAngle += 2 * Math.PI; + } + + if (Math.Abs(endAngle - startAngle) < 0.001) + { + if (skipIfZero) return; + endAngle -= 2 * Math.PI; + } + } + + var seg = new ParametricSlidePath.ArcSegment(circle, startAngle, endAngle); + PathSegments.Add(seg); + CurrentEndPoint = seg.GetPointAt(1f); + } + + public void ArcToTangentTowards(Complex target, Complex center, bool isCcw) + { + var diff = CurrentEndPoint - center; + var endAngle = CalcTangentAngle(target, new MaiGeometry.CircleStruct(center, diff.Magnitude), !isCcw); + ArcToAngle(center, endAngle, isCcw, false); + } + + public void FullCircle(Complex center, bool isCcw) + { + var diff = CurrentEndPoint - center; + var circle = new MaiGeometry.CircleStruct(center, diff.Magnitude); + PathSegments.Add(new ParametricSlidePath.CircleSegment(circle, diff.Phase, isCcw)); + // CurrentEndPoint not changed + } + + public void ExternTangentTransfer(Complex currentCenter, MaiGeometry.CircleStruct targetCircle, bool isCcw) + { + var diff = CurrentEndPoint - currentCenter; + double endAngle; + if (Math.Abs(diff.Magnitude - targetCircle.Radius) < 0.001) + { + // two circles are approximately same radius + var vector = targetCircle.Center - currentCenter; + vector *= isCcw ? -Complex.ImaginaryOne : Complex.ImaginaryOne; + endAngle = vector.Phase; + } + else if (targetCircle.Radius > diff.Magnitude) + { + // target circle larger + var helperCircle = new MaiGeometry.CircleStruct(targetCircle.Center, targetCircle.Radius - diff.Magnitude); + endAngle = CalcTangentAngle(currentCenter, helperCircle, isCcw); + } + else + { + var helperCircle = new MaiGeometry.CircleStruct(currentCenter, diff.Magnitude - targetCircle.Radius); + endAngle = CalcTangentAngle(targetCircle.Center, helperCircle, !isCcw); + } + ArcToAngle(currentCenter, endAngle, isCcw, false); + var inPoint = Complex.FromPolarCoordinates(targetCircle.Radius, endAngle) + targetCircle.Center; + LineToPoint(inPoint); + } + + public ParametricSlidePath GeneratePath() + { + return new ParametricSlidePath(PathSegments); + } +} \ No newline at end of file diff --git a/AquaMai/Main.cs b/AquaMai/Main.cs index f15be7a3..62c5cf8e 100644 --- a/AquaMai/Main.cs +++ b/AquaMai/Main.cs @@ -6,10 +6,12 @@ using System.Threading; using AquaMai.Fix; using AquaMai.Helpers; +using AquaMai.MaimaiDX2077; using AquaMai.Resources; using AquaMai.Utils; using AquaMai.UX; using MelonLoader; +using Monitor; using Tomlet; using UnityEngine; @@ -162,7 +164,7 @@ public override void OnInitializeMelon() Patch(typeof(DebugFeature)); if (GameInfo.GameVersion >= 23000) Patch(typeof(FixConnSlide)); - Patch(typeof(SlideAutoPlayTweak)); + Patch(typeof(FixSlideAutoPlay)); // Rename: SlideAutoPlayTweak -> FixSlideAutoPlay, 不过这个应该无副作用所以不需要改配置文件 if (GameInfo.GameVersion >= 24000) Patch(typeof(FixLevelDisplay)); // UX @@ -173,6 +175,28 @@ public override void OnInitializeMelon() // Utils Patch(typeof(JudgeAdjust)); Patch(typeof(TouchPanelBaudRate)); + + // New Features & Changes + // 现在自定义皮肤相关的功能应该有 CustomSkin, JudgeDisplay4B, CustomTrackStartDiff + // 后续应该还会接着做, 所以也许可以考虑把自定义皮肤相关的部分单独分一类 ? + Patch(typeof(CustomSkins)); // Rename: CustomNoteSkin -> CustomSkins + Patch(typeof(JudgeDisplay4B)); + Patch(typeof(CustomTrackStartDiff)); + + Patch(typeof(RealisticRandomJudge)); // 本来是用来调试判定显示4B的, 觉得还挺有趣就单独做成功能了 + + Patch(typeof(DisableTrackStartTabs)); // 从 TrackStartProcessTweak 里单独拆出来了 + + // 以下三项拆分自 SlideJudgeTweak + Patch(typeof(FanJudgeFlip)); + Patch(typeof(BreakSlideJudgeBlink)); + Patch(typeof(FixCircleSlideJudge)); // 这个我觉得算无副作用, 可以常开 + + // 这是一项往 Sinmai 里加各种新 note 的企划, 目前只完成了可高度自定义形状的星星 + // 未来还会缓慢更新, 我建议单开一个功能分类 + // 注意需要往 UserLib 里放入 System.Numeric.dll + Patch(typeof(CustomNoteTypePatch)); + # if DEBUG Patch(typeof(LogNetworkErrors)); # endif diff --git a/AquaMai/UX/CustomNoteSkin.cs b/AquaMai/UX/CustomSkins.cs similarity index 81% rename from AquaMai/UX/CustomNoteSkin.cs rename to AquaMai/UX/CustomSkins.cs index a9d299ee..8768aea0 100644 --- a/AquaMai/UX/CustomNoteSkin.cs +++ b/AquaMai/UX/CustomSkins.cs @@ -10,13 +10,18 @@ namespace AquaMai.UX; -public class CustomNoteSkin +public class CustomSkins { private static readonly List ImageExts = [".png", ".jpg", ".jpeg"]; private static readonly List SlideFanFields = ["_normalSlideFan", "_eachSlideFan", "_breakSlideFan", "_breakSlideFanEff"]; + private static readonly List CustomTrackStartFields = ["_musicBase", "_musicTab", "_musicLvBase", "_musicLvText"]; private static Sprite customOutline; private static Sprite[,] customSlideFan = new Sprite[4, 11]; + + public static readonly Sprite[,] CustomJudge = new Sprite[2, ((int)NoteJudge.ETiming.End + 1)]; + public static readonly Sprite[,,,] CustomJudgeSlide = new Sprite[2, 3, 2, ((int)NoteJudge.ETiming.End + 1)]; + public static readonly Texture2D[] CustomTrackStart = new Texture2D[4]; private static bool LoadIntoGameNoteImageContainer(string fieldName, int? idx1, int? idx2, Texture2D texture) { @@ -105,8 +110,17 @@ private static void LoadNoteSkin() var fieldName = '_' + args[0]; int? idx1 = (args.Length < 2) ? null : (int.TryParse(args[1], out var temp) ? temp : null); int? idx2 = (args.Length < 3) ? null : (int.TryParse(args[2], out temp) ? temp : null); + int? idx3 = (args.Length < 4) ? null : (int.TryParse(args[3], out temp) ? temp : null); Traverse traverse; + + if (CustomTrackStartFields.Contains(fieldName)) + { + var i = CustomTrackStartFields.IndexOf(fieldName); + CustomTrackStart[i] = texture; + MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}"); + continue; + } if (fieldName == "_outline") { @@ -115,6 +129,59 @@ private static void LoadNoteSkin() continue; } + if (fieldName == "_judgeNormal" || fieldName == "_judgeBreak") + { + if (!idx1.HasValue) + { + MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs a index"); + continue; + } + + var i = (fieldName == "_judgeBreak") ? 1 : 0; + CustomJudge[i, idx1.Value] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 1f); + MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}"); + continue; + } + + if (fieldName == "_judgeSlideNormal" || fieldName == "_judgeSlideBreak") + { + if (!idx1.HasValue || !idx2.HasValue || !idx3.HasValue) + { + MelonLogger.Msg($"[CustomNoteSkin] Field {fieldName} needs 3 indices"); + continue; + } + + var i = (fieldName == "_judgeSlideBreak") ? 1 : 0; + Vector2 pivot; + switch (idx1.Value) + { + case 0 when idx2.Value == 0: + pivot = new Vector2(0f, 0.5f); + break; + case 0 when idx2.Value == 1: + pivot = new Vector2(1f, 0.5f); + break; + case 1 when idx2.Value == 0: + pivot = new Vector2(0f, 0.3f); + break; + case 1 when idx2.Value == 1: + pivot = new Vector2(1f, 0.3f); + break; + case 2 when idx2.Value == 0: + pivot = new Vector2(0.5f, 0.8f); + break; + case 2 when idx2.Value == 1: + pivot = new Vector2(0.5f, 0.2f); + break; + default: + pivot = new Vector2(0.5f, 0.5f); + break; + } + CustomJudgeSlide[i, idx1.Value, idx2.Value, idx3.Value] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), pivot, 1f); + MelonLogger.Msg($"[CustomNoteSkin] Successfully loaded {name}"); + continue; + } + if (SlideFanFields.Contains(fieldName)) { if (!idx1.HasValue) diff --git a/AquaMai/UX/CustomTrackStartDiff.cs b/AquaMai/UX/CustomTrackStartDiff.cs new file mode 100644 index 00000000..8ec82d97 --- /dev/null +++ b/AquaMai/UX/CustomTrackStartDiff.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using HarmonyLib; +using Monitor; +using UI; +using UnityEngine; +using UnityEngine.UI; + +namespace AquaMai.UX; + +public class CustomTrackStartDiff +{ + // 自定义在歌曲开始界面上显示的难度 (并不是真的自定义难度) + // 需要启用自定义皮肤功能 + // 会加载四个图片资源: musicBase, musicTab, musicLvBase, musicLvText + + [HarmonyPostfix] + [HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")] + private static void DisableTabs( + MultipleImage ____musicBaseImage, + MultipleImage ____musicTabImage, + SpriteCounter ____difficultySingle, + SpriteCounter ____difficultyDouble, + Image ____levelTextImage, + List ____musicLevelSpriteSheets, + TimelineRoot ____musicDetail + ) + { + var texture = CustomSkins.CustomTrackStart[0]; + if (texture != null) + { + ____musicBaseImage.MultiSprites[6] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); + ____musicBaseImage.ChangeSprite(6); + } + + texture = CustomSkins.CustomTrackStart[1]; + if (texture != null) + { + ____musicTabImage.MultiSprites[6] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); + ____musicTabImage.ChangeSprite(6); + } + + texture = CustomSkins.CustomTrackStart[2]; + if (texture != null) + { + var lvBase = Traverse.Create(____musicDetail).Field("_lv_Base").Value; + lvBase.MultiSprites[6] = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); + lvBase.ChangeSprite(6); + } + + texture = CustomSkins.CustomTrackStart[3]; + if (texture != null) + { + var original = ____musicLevelSpriteSheets[0].Sheet; + var sheet = new Sprite[original.Length]; + for (var i = 0; i < original.Length; i++) + { + var sprite = original[i]; + sheet[i] = Sprite.Create(texture, sprite.textureRect, new Vector2(0.5f, 0.5f), 100f); + } + + ____difficultySingle.SetSpriteSheet(sheet); + ____difficultyDouble.SetSpriteSheet(sheet); + ____levelTextImage.sprite = sheet[14]; + } + } +} \ No newline at end of file diff --git a/AquaMai/UX/DisableTrackStartTabs.cs b/AquaMai/UX/DisableTrackStartTabs.cs new file mode 100644 index 00000000..24536538 --- /dev/null +++ b/AquaMai/UX/DisableTrackStartTabs.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using HarmonyLib; +using Monitor; +using UI; +using UnityEngine; +using UnityEngine.UI; + +namespace AquaMai.UX; + +public class DisableTrackStartTabs +{ + // 在歌曲开始界面, 把 TRACK X 字样, DX/标准谱面的显示框, 以及画面下方的滴蜡熊隐藏掉, 让他看起来不那么 sinmai, 更像是 majdata + + [HarmonyPostfix] + [HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")] + private static void DisableTabs( + SpriteCounter ____trackNumber, SpriteCounter ____bossTrackNumber, SpriteCounter ____utageTrackNumber, + MultipleImage ____musicTabImage, GameObject[] ____musicTabObj, GameObject ____derakkumaRoot + ) + { + ____trackNumber.transform.parent.gameObject.SetActive(false); + ____bossTrackNumber.transform.parent.gameObject.SetActive(false); + ____utageTrackNumber.transform.parent.gameObject.SetActive(false); + ____musicTabImage.gameObject.SetActive(false); + ____musicTabObj[0].gameObject.SetActive(false); + ____musicTabObj[1].gameObject.SetActive(false); + ____musicTabObj[2].gameObject.SetActive(false); + ____derakkumaRoot.SetActive(false); + } +} \ No newline at end of file diff --git a/AquaMai/UX/JudgeDisplay4B.cs b/AquaMai/UX/JudgeDisplay4B.cs new file mode 100644 index 00000000..92645931 --- /dev/null +++ b/AquaMai/UX/JudgeDisplay4B.cs @@ -0,0 +1,75 @@ +using HarmonyLib; +using Manager; +using Monitor; +using UnityEngine; + +namespace AquaMai.UX; + +public class JudgeDisplay4B +{ + // 精确到子判定的自定义判定显示, 需要启用自定义皮肤功能 (理论上不启用自定义皮肤不会崩游戏, 只不过此时这个功能显然不会生效) + + [HarmonyPostfix] + [HarmonyPatch(typeof(SlideJudge), "Initialize")] + private static void SlideJudgeDisplay4B( + SpriteRenderer ___SpriteRenderAdd, SpriteRenderer ___SpriteRender, + SlideJudge.SlideJudgeType ____judgeType, SlideJudge.SlideAngle ____angle, + NoteJudge.ETiming judge, float msec, bool isBreak + ) + { + var i = isBreak ? 1 : 0; + Sprite sprite = CustomSkins.CustomJudgeSlide[i, (int)____judgeType, (int)____angle, (int)judge]; + if (sprite != null) { + ___SpriteRender.sprite = sprite; + } + + if (isBreak && judge == NoteJudge.ETiming.Critical) + { + sprite = CustomSkins.CustomJudgeSlide[i, (int)____judgeType, (int)____angle, (int) NoteJudge.ETiming.End]; + if (sprite != null) + { + ___SpriteRenderAdd.sprite = sprite; + } + } + } + + + [HarmonyPostfix] + [HarmonyPatch(typeof(JudgeGrade), "Initialize")] + private static void JudgeGradeDisplay4B( + SpriteRenderer ___SpriteRender, + NoteJudge.ETiming judge, float msec, NoteJudge.EJudgeType type + ) + { + var i = (type == NoteJudge.EJudgeType.Break) ? 1 : 0; + Sprite sprite = CustomSkins.CustomJudge[i, (int)judge]; + if (sprite != null) { + ___SpriteRender.sprite = sprite; + } + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(JudgeGrade), "InitializeBreak")] + private static void JudgeGradeBreakDisplay4B( + SpriteRenderer ___SpriteRenderAdd, + NoteJudge.ETiming judge, float msec, NoteJudge.EJudgeType type + ) + { + if (judge == NoteJudge.ETiming.Critical) + { + var sprite = CustomSkins.CustomJudge[1, (int) NoteJudge.ETiming.End]; + if (sprite != null) + { + ___SpriteRenderAdd.sprite = sprite; + } + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(JudgeGrade), "InitializeBreak")] + private static void InitializeBreakFix(ref NoteJudge.EJudgeType type) + { + type = NoteJudge.EJudgeType.Break; + } + +} \ No newline at end of file diff --git a/AquaMai/UX/RealisticRandomJudge.cs b/AquaMai/UX/RealisticRandomJudge.cs new file mode 100644 index 00000000..d145b59f --- /dev/null +++ b/AquaMai/UX/RealisticRandomJudge.cs @@ -0,0 +1,25 @@ +using HarmonyLib; +using Manager; + +namespace AquaMai.UX; + +public class RealisticRandomJudge +{ + // 让 AutoPlay 的随机判定模式真的会随机产生所有的判定 (精确到子判定) + // 原本的随机判定只会等概率产生 Critical, LateGreat1st, LateGood, Miss(TooLate) + // 这里改成三角分布产生从 Miss(TooFast) ~ Critical ~ Miss(TooLate) 的所有 15 种判定结果 + // 当然, 此处并不会考虑原本那个 Note 是不是真的有对应的判定 (比如 Slide 实际上不应该有小 p 之类的) + + [HarmonyPostfix] + [HarmonyPatch(typeof(GameManager), "AutoJudge")] + private static NoteJudge.ETiming RealAutoJudgeRandom(NoteJudge.ETiming retval) + { + if (GameManager.AutoPlay == GameManager.AutoPlayMode.Random) + { + var x = UnityEngine.Random.Range(0, 8); + x += UnityEngine.Random.Range(0, 8); + return (NoteJudge.ETiming) x; + } + return retval; + } +} \ No newline at end of file diff --git a/AquaMai/UX/TrackStartProcessTweak.cs b/AquaMai/UX/TrackStartProcessTweak.cs index a16655d4..ebea887e 100644 --- a/AquaMai/UX/TrackStartProcessTweak.cs +++ b/AquaMai/UX/TrackStartProcessTweak.cs @@ -1,8 +1,10 @@ -using HarmonyLib; +using System.Collections.Generic; +using HarmonyLib; using Monitor; using Process; using UI; using UnityEngine; +using UnityEngine.UI; namespace AquaMai.UX; @@ -10,7 +12,6 @@ public class TrackStartProcessTweak { // 总之这个 Patch 没啥用, 是我个人用 sinmai 录谱面确认时用得到, 顺手也写进来了 // 具体而言就是推迟了歌曲开始界面的动画便于后期剪辑 - // 然后把“TRACK X”字样和 DX/标准谱面的显示框隐藏掉, 让他看起来不那么 sinmai, 更像是 majdata [HarmonyPrefix] [HarmonyPatch(typeof(TrackStartProcess), "OnUpdate")] @@ -65,19 +66,6 @@ ProcessDataContainer ___container return true; } - [HarmonyPostfix] - [HarmonyPatch(typeof(TrackStartMonitor), "SetTrackStart")] - private static void DisableTabs( - SpriteCounter ____trackNumber, SpriteCounter ____bossTrackNumber, SpriteCounter ____utageTrackNumber, - MultipleImage ____musicTabImage, GameObject[] ____musicTabObj - ) - { - ____trackNumber.transform.parent.gameObject.SetActive(false); - ____bossTrackNumber.transform.parent.gameObject.SetActive(false); - ____utageTrackNumber.transform.parent.gameObject.SetActive(false); - ____musicTabImage.gameObject.SetActive(false); - ____musicTabObj[0].gameObject.SetActive(false); - ____musicTabObj[1].gameObject.SetActive(false); - ____musicTabObj[2].gameObject.SetActive(false); - } + } +