From d6c74bb7bbb7228af9e715e3f091fbc591d6ea9f Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 2 Sep 2026 09:12:04 -0400 Subject: [PATCH] CLOSES #2080, CLOSES #2082, CLOSES #2084, CLOSES #2085, CLOSES #2086, CLOSES #2090, CLOSES #2091, CLOSES #2093, CLOSES #2094, CLOSES #2095, CLOSES #2099, CLOSES #2100, CLOSES #2101, CLOSES #2102 --- poison_flicker_bug1872.png | Bin 10413 -> 0 bytes src/battle/gen2/Battle.lua | 38 ++- src/battle/gen2/BgEffects.lua | 56 ++++- src/battle/gen2/Effects.lua | 14 ++ src/core/Game2.lua | 8 +- src/render/GbcPalette.lua | 22 +- src/render/TextBox.lua | 10 +- src/script/gen2/Specials.lua | 24 +- src/ui/gen2/BattleAnimView.lua | 15 +- src/ui/gen2/BattleState.lua | 85 ++++++- src/ui/gen2/BattleTransition.lua | 4 +- src/ui/gen2/ContestMenu.lua | 23 +- src/ui/gen2/DayCareMenu.lua | 14 +- src/ui/gen2/HeldItemMenu.lua | 8 +- src/ui/gen2/MailMenu.lua | 8 +- src/ui/gen2/MailboxMenu.lua | 8 +- src/ui/gen2/MartMenu.lua | 12 +- src/ui/gen2/SaveMenu.lua | 8 +- src/ui/gen2/StartMenu.lua | 94 +++++-- src/ui/gen2/Typer.lua | 6 + src/world/gen2/World.lua | 150 ++++++++++- .../battle_scy_deform_bug1895_test.lua | 6 +- tests/drivers/crystal_bug_contest_bug2086.lua | 89 +++++++ .../crystal_curse_anim_bug2099_test.lua | 95 +++++++ tests/drivers/crystal_curse_dig_bug2100.lua | 103 ++++++++ tests/drivers/crystal_fade_2094.lua | 132 ++++++++++ tests/drivers/crystal_move_deleter_2095.lua | 54 ++++ tests/drivers/crystal_sleep_hud_bug2082.lua | 90 +++++++ tests/drivers/gen2_bodyslam_faint_bug2102.lua | 98 ++++++++ tests/drivers/gen2_dig_anim_2101.lua | 102 ++++++++ .../drivers/gen2_magnitude_prompt_bug2093.lua | 102 ++++++++ tests/drivers/gen2_transition_speckle.lua | 101 ++++++++ tests/drivers/gold_forget_move_sfx.lua | 84 +++++++ tests/drivers/gold_save_prompt_bug2085.lua | 56 +++++ tests/drivers/grass_over_bug2080_test.lua | 90 +++++++ tests/drivers/issue2084_arrow.lua | 47 ++++ tests/engine/gen2_anim_param_feed_sweep.lua | 232 ++++++++++++++++++ .../engine/gen2_bg_effect_registry_audit.lua | 100 ++++++++ .../engine/gen2_body_slam_bounce_bug2102.lua | 44 ++++ .../engine/gen2_curse_anim_param_bug2099.lua | 102 ++++++++ tests/engine/gen2_dig_hide_mon_bug2101.lua | 30 +++ .../engine/gen2_lcd_row_plus_one_bug2102.lua | 142 +++++++++++ .../gen2_night_shade_top_row_bug1921.lua | 12 +- tests/engine/gen2_no_checkhit_chain_audit.lua | 202 +++++++++++++++ .../gen2_save_menu_translation_test.lua | 9 + tests/engine/gen2_scy_deform_blit_bug1895.lua | 26 +- tests/engine/gen2_text_markers_lint.lua | 107 ++++++++ .../gen2_vanish_reveal_both_sides_bug2101.lua | 164 +++++++++++++ tests/engine/gen2_yesno_typing_bug2085.lua | 126 ++++++++++ tests/engine/textbox_arrow_bug2084.lua | 73 ++++++ tests/gen2_arrow_blink_test.lua | 26 ++ tests/gen2_battle_anims_test.lua | 22 +- tests/gen2_battle_return_fade_test.lua | 64 +++++ tests/gen2_battle_test.lua | 70 +++++- tests/gen2_battle_ui_test.lua | 13 + tests/gen2_contest_test.lua | 91 +++++++ tests/gen2_dig_anim_2101_test.lua | 96 ++++++++ tests/gen2_move_effects_test.lua | 44 ++++ tests/gen2_sleep_counter_test.lua | 26 ++ tests/gen2_text_flow_test.lua | 92 +++++++ tests/gen2_transition_stale_test.lua | 80 ++++++ tools/audit_effect_chains.py | 113 +++++++++ 62 files changed, 3825 insertions(+), 137 deletions(-) delete mode 100644 poison_flicker_bug1872.png create mode 100644 tests/drivers/crystal_bug_contest_bug2086.lua create mode 100644 tests/drivers/crystal_curse_anim_bug2099_test.lua create mode 100644 tests/drivers/crystal_curse_dig_bug2100.lua create mode 100644 tests/drivers/crystal_fade_2094.lua create mode 100644 tests/drivers/crystal_move_deleter_2095.lua create mode 100644 tests/drivers/crystal_sleep_hud_bug2082.lua create mode 100644 tests/drivers/gen2_bodyslam_faint_bug2102.lua create mode 100644 tests/drivers/gen2_dig_anim_2101.lua create mode 100644 tests/drivers/gen2_magnitude_prompt_bug2093.lua create mode 100644 tests/drivers/gen2_transition_speckle.lua create mode 100644 tests/drivers/gold_forget_move_sfx.lua create mode 100644 tests/drivers/gold_save_prompt_bug2085.lua create mode 100644 tests/drivers/grass_over_bug2080_test.lua create mode 100644 tests/drivers/issue2084_arrow.lua create mode 100644 tests/engine/gen2_anim_param_feed_sweep.lua create mode 100644 tests/engine/gen2_bg_effect_registry_audit.lua create mode 100644 tests/engine/gen2_body_slam_bounce_bug2102.lua create mode 100644 tests/engine/gen2_curse_anim_param_bug2099.lua create mode 100644 tests/engine/gen2_dig_hide_mon_bug2101.lua create mode 100644 tests/engine/gen2_lcd_row_plus_one_bug2102.lua create mode 100644 tests/engine/gen2_no_checkhit_chain_audit.lua create mode 100644 tests/engine/gen2_text_markers_lint.lua create mode 100644 tests/engine/gen2_vanish_reveal_both_sides_bug2101.lua create mode 100644 tests/engine/gen2_yesno_typing_bug2085.lua create mode 100644 tests/engine/textbox_arrow_bug2084.lua create mode 100644 tests/gen2_arrow_blink_test.lua create mode 100644 tests/gen2_battle_return_fade_test.lua create mode 100644 tests/gen2_dig_anim_2101_test.lua create mode 100644 tests/gen2_transition_stale_test.lua create mode 100644 tools/audit_effect_chains.py diff --git a/poison_flicker_bug1872.png b/poison_flicker_bug1872.png deleted file mode 100644 index b1fae8cec4de26d5bba430d555e7768f24bd9e13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10413 zcmZ{Kc_37K^!UAVX^6Vov}s1~l~hQtgx6qt)sqnIvQD8wq9kL#MWs@CDan@CVkuii zL>MY6)d=BLGRQ9LSjU*(xifmp@B97!HFH0o^Ev0-v!C<1=gqenPMf-5DuN)>jJ9mr zfgn=w7ek~l_}OwzMjw7=nw#v}oc~p{di83NXgI$*rdpKR@>R4|c%O?Ps(Xw!ZP>+* z`5tQ(aVO`|S@f2lc1FjdS7~1MX7OIBH0GYP>jSsmph224l;tlrpBlYmxqe->bYk#t zJK7%06hD)Hu4CfYZm`~nCLE{_++g8e+1Y40y*2N+gOiZ@dTG9GlS`YiDBMxhHKSj& zLUv+rM&;bWzJ0)X>VEzf_((Zv!RhD%)d_iZ!d^F8 zXd~BYFcRvvL8v-XA?jK(dMhw0?v;tb0*X@EF+uggeU_pP*LLBD=lx#5-(!DAW8V(# z7g?;t8XD4ieBD*RKf6I|+H;o%W4DiMPAo4L33>`O9~Io%YIoQJsX6|oAj@>LtivGW zwDXdjDQ8yY@8*`@dsVC{Kebbk%>VhAT4}>dtJf7{_#?HDCgnGN?WsN`Nr|&RI;3>J z|H5xt#z~F2M84<;y{X~8vqvke2IXz;w_7RA4UPMpK)-0EJKgTDtr69M=6(l_MsGS6 z@NEN#pUf`Yc9uOlc1)0W{qbM*Ss4>6J7)(T?91=HH{6fSHmdVEKPcQa_J>Y{WH*RA z9#EKI)MD(WwXWl;D0$)O#{q5?SL8uDN9#YS8Fw0lRPfiN)SQVE3&4Aq_9oE66&g}{ zJg2^94^R&)9WK=>PyqqyZJhg2;S0YC%lPR3^ik$SkY-@jwuToszTQ97c2#8cS7V@P z?|;v_=ikfLDL6}>wXfRw$#nkFu|6eEs#3O_YXnG_phlV*&?p=%<^t9Hb`H{uBsQVf z#^=kUR&_UoUZ)PDp2P=6e%g*Ty3_LPHpwWHzyXI8Yzup&X2$B6!9J z|Gxs~vI*Cr<|0^7C0FMeAeWon3)52xYQ}65lLQo@5jO)EQUq0J4o(c9+ql^HO(n{c z+*N`e3eH+ag7N%-867&pK-LJi0Dl*$oKZN>cT#4<1`pj+={qXMCZ?mB({6}^M8EX7 ztmL6;(hNibMUrDB&~adL+e=KfXcS3o{$JkDT_50M;I)9QB|P-~OH4jbG$4!M%KPeN zw+s4rF|G%w9c@l>sUQ6LGS$MWMr10TwaV;ec>4p$I_2+v|$4>o^r$r-@4)BfS7h^$3< zdHkqp?iz;g&|vWaSJR;vPZp>69*r(wZ^LYhvSPX|x9FX3K&z`A%%1HQ4)6Ii{AHc+ zu#CH!p^YGoevHBS6xDTp*k|Y2H7B#%wPx|Gx2-JbG*rSfo~Za9;E%GUVjkuh89V}o zJX0e=eP%w#Qc{x#*%L{3yU#~-4r}DMP~9~W^M`B<8rejaRu*kHIPYxt^+{_K8#$^QE3L4@>;OsmuUVh{ zy*uL`RJas9joIT@!)^CQ3TUQS?3;EqBFyJkb)55YHqOzYG6;DZkt$0g9u#3R4QP(# z+?wM)cAI|Ji@i?eIbWc%Zro`>e(Pw@WFULzO2MhRVW0sPkmIvfN+;q@!MUC>UA;+c ziF4`)J$wL{p(5vD%nj+V?PAENMzpq>pw=2j4>do9hnxirLV<+Z2k*9p7 z93CFDF-UAhmE<|McY`JGXCQfUtJZ?i2lpX)7DR{ccHml*#=;Gl+ojG_h}V5b&m+UOtSP)YW(i2`bMD zss1Ux2aC*2?-@gJjy96An^{lQ^t7uV#Aq!0S^t36)>0z7JM#iKSl4)-1bvI-xi=9} zGa&j33OcC_ByeWNH!srbUtw=2X)Gi*Klsl9iwi5vvJpQNci%#bUuiasX<}?5s2@o` zK6gqlLTyGr^(*2KYA(OVPBg1$0=DUIu%!MAeH-?tAT(Y)gIM9#rvU#V8O*(3f1CAV z?V&V+yPE(?w?83%aW;4(yT9%*K-k2ce#F)L;yGZV^c$MnuQ({8^vLO~z{{`al|%56 zV0;KsQ3aN=%OO5WLMGJ|@*&sZ77L5$tLogSjW-)aoy30G@AiTLK=L zphkSUQF!+lOa6uq!1sNxCgJ7KUgQy*P%d~`q7bonw{4_t5Wu@GfLtTfgf{ZGM{WG{ z(bzoVn8CI}utAfZik#VI_q2!HgW5=IZf!>8AylSV44D;zP3W$|Ss{rPhH{(5PwF@; zbXkSn(JP0I=8@WsxlYx5xwHLospIzY9x#sgW!SvNWPWyO^P?qVbl~$QfUa~YTTOP% z-@k_I71Op$FtXKN;dX;4sJ|2?riVC-H1BJ>?i zyEV{{yi0}rht8|*3wFd2EmfYlc&2vmJ6clM5_2^ zE{@wOrqJ;rf&Ggfo3M9!WY|PzaF4!SKEP}xnaMeslSy4-zT&0(Q5_$|S^oXswhpIA z%Z`+m&GRBl*yVlb1lS zyLL`tzO<{*(L8IcbegJ@q@qrI5?ywCxH`Cb4(WJ~=q|q){jDBb@_ZnmXqs9gDmYWN zM&j1U{0pb&fTpfdxrb%4tGXYKCssE@ZvuH@y+{KvqHIDva4y6^DEJI>AlMLCFY?6* zf=*FX*VD7?vk+62_k}BR6u6p*8b}y|o-ssR=C6207P%KqMSHqRfuz19h-UQcH!v+W2?)4?yP@2MOCCD`eHYnrD% zF@g9s`lFB5x}q&jHtK{C2Ya>{*Nl`R7y0@Oj6ceGW?C*QJhjHW*NGo=y0on>W8+GiwJ1)%#~N z2rtt?hoe3__3zoaOgP)b22p#{lH=Z*y1l7s4on?w`e_QBsnqlSHNRb}B0(@1G5!(L zJWd)?3D)*2xA?F|WOt3#Kxxf9K;_zqp76)h|FYJs>&a@wS1o2EXw=M~gyTqgdK^gtGQ&+_H*8j`QD2R|-Q5>#spW%9F9khi<=$#1lQdK*cZ9toid=v&5krJh z%RTeYXwMBDUNa8n0ToxgYJZMx!p^=Yzu2}%JFuZJ@UW#Rn>p__Et*(FG{i8QMv+fs zWcT(a61f`t8vKYc+jPx`e0slR-N~C6cY0Q)mBc$088BCqtTc32;$enn-0?@_2S)Pbp1~Tcr*%D)kobBs-$YPf_3TrSDr-gTXKD6G4nnQ>?L%wR z42$>Z!LLiVqz*0rK;|M?t}^({uG0I^p~i_<)~duMC~i;>n?C$#X>g~0>8nBPN0#ar zKy|I|&~0v%H8D+(BbO z9H#MPq76YF+PV>UPBqg2^wp=NSjNB%2sr$>kjU;-{s;ZRpC6S3J+ugt>6JVifUZ73n?|DYhh9zHoA^ln59&i!3P?4DQY}7{X^+L=jCp`WOB>NWijdx# z(CW#bCm?SUy(@qd|4#2bGzxd9n#Cq+NJ}96p{D>5eQ+?7!ZnkNup>t#)ni53Qj^NH z(=Cl2tn&$|#)WLX%k#x)5Gve$hWzfJ7wvlFM*hflC2i>4_CoL0)1Z}?SToYMZ}9Dk z7mh=t<0*8XzB@VxL4~2z!}1>chIjh7pFr9$N3J~E?Ol9Ue8bS zPo9svHTZl@!5?C*kXWtnkJdHMJa@%+jD798bckDa>mgxpXrV!e<^_A`Ad1E1?*f+Tz#i2-byk9C?_-XRt>2bNfK_;dUGrX0>UcF}e@m+riA$MVDL8cVu9evSLHJ5M zU8y78pcoKMn|_%K&hzwu{ssC<=J38z)k?!(#%wsBfDKI=Z_p^FrEkOweL&7w93YT~ zm;v9yWd1w=`(!ef*jMiu z?UxQ&*dmXTi(6XIoMLT~;Q{1r*qi~8*lc-cUn%oYKbH#JT>HffTV$t3w&B{jzhgvz zqz%34aK(5n6ir~^yf@#EJ$-wI!r(k)BWVncB72R{vw$2bjwiA?Uk(KwBrO#N1Ha@~ zCrXPWkUhzN1A|j7&hOqxS}$470|O=v;tCspiwD6S6y9U*XF5sY$5L6*NF4e4(7HQ~ zT1!;GspyB+twx1h;-Pn4e#`YAJ<|+hgS2E_;!}i;r9O~}%0yf@s;R^Dz}?gm@<;#) z8|>mUz5K`cmOTQwX1Xx%Wyv7w0hgG@7d!dd_U)P5i zmuZipjlT$b6vU`Xix;GLBkM#qAj|y>B3A>-z6%3raF=mU36|9P%!kt9 zMO_1s?xvUO?hK%f&PTQ#7*YZel5%*mI2Uic8+Ys2G~_)s3`(>3?`1cxqe&*n&A6FB z4!Y+||4~NZw)eGU;3A*-fFm<1dWHva|GTPQR>dvwE|RUstqSh3L-S{E*x(d{D_dLL z!p#Fp`t)uN&3D+m$tjIg+FDwCxXwLA6viss7qo)Ps+0&1eWrrm(Q756fZP$)X~i6h zevV)Jo>ZRgJ6(Fb%;}H{9Xc}ya%Phs;j8!o4!){(NPpsQ#|fRx*T^3XRML>{N@w`l zaZ+%e6PRoW?pmSW z+VZJej>bDI4l4V5s9P4ug;^aEk4n6WyWV%U6jVt%H0IS^v+j0zCyd-aL*}x6#As#` zmGy_X84F`9bMqDhf~kk?;TCs(d_Q-lLeg16?Y2w$cj@tqD181GXj;L7G8dfjog(;cI+{vnnsM{R&bP(whU&^PrnIv# zZk=L$Riy_xq-jDcMo#A4Jr+;VI+b2evHNJA`~!2aXIV+Mg|xGbOi-fr;1A64YE3%+ z*H)AZMlQ9rKdk``^&-0%j;LxwOU>kPM4|7ChbU)sknAO4qOr-(LBZdj!!>wD7RBl? znDpQZn_B3Tqm}r^r>zsm20AC_G9m2lr-5I_e~eL9#QAp9wF}7@36l^a^iZ6j^j-R7 zl^ow_+Q|-=*1RXC45hGnpOpaR|DZb#!B7YTI~cVfa|)kIic_e{nJ?YCspaHYZ`)hU zq}1*E-xc;h=PUaWhw&20d6)#N0A5M7Cod-;JP3Vd#AAY2wKt}Kj#+a|_f1BB;=d)w zuV9%Db_@P}Wk8PISjIHgch7?52I5F_tS-Iv<2dqV9K;FrlczwZ-hB~ga&~K-?Q%Ps zeKdenEYg%4cwlBZ{tsj@v=WnhmAQ49Q+ebZ_YzezRd7j#xB;;k=D5y_?6MwLB=8d? z_}6ug+?KKZRD_q46z1Wq$CuMTJ*?CSAOa*rqCLm;rXJmzH-e6hif!abb>9Ylj^}@l za8Db?XI`YU!o)*l&cQ`?QXB5VLi{aQSWDv^SQN-zaARWUdI=V#OVqa1hJ`Qr*kQm4 zNp9W{#dqodv@qO+V|l#lBxP^GdtFS z)Im>uZ3)0cO!f6Msns>3le&VdHxr9mXa894JF(O`uAC2}qPG&2cnhs=SvlTT&nkG| zDjCF!JHWwtu8IF(r1|U9Hn`sYo~bd3a`evC+;f)BbvS1drIvVjFIKv8VcXx(|2N$LX40PJ6oAV2I$5goh+c6`44> zU)QG!P*TDWERxtZCNL?`72D~qan*dd!y|7>$Xf*ZJ?&nk8g4V7b`9&h^UZT(<=>!B z?6c2PGvdfb|0QPO_2tQ*%W0fkC9-HkT;y0h^hg=_Nhmd=`{@)K=Lu|pt&2Ch)H%JR z5Azws_p!PcSZxF*w%NP@?YYPo2OH}d(oI8+}gJvs|#Sxpx^mTRe2A2I|yDZIpL zhpb_P>k6w(zQ#gc0r#2j=L_v0Z=II z{4qJ?1WP=gl~RO-kwelQ;d(Rvf3)J55f1S_Nu~4vStYhK8j`R(cm5x+7j+>~Y;<;3 zBcqG>G>FduiyO&7bY;}C{5D@40bYJ&CWR^qOmbp&Gx{p7c3rS1*R?WG=;y}|a?!2J3iI)?q7hx^?XIg& zjJec%eC8X_i_8piBJQNYwB+*u;$SwOrhQDu-Ya^dvnh`rQ|9zi%rA53McXjfj59wk znr}~ot8($3m>b0K&c5-)tGUZ8)9QDR&A@pUmviCL(2WZl#~d%X*7U3phEUzr67v*y zm;rdXeP2()GPoBs9eYTJt7p&ec>!PJsPQF@YRL;5o%K#W;OEjgS%0tl9GDR>J|6ss zCwF(drvc%jo9;4y5CnyMob^$^!1c$f_`VR(hv_}6E6whHx=3?L*#^idm2U*t{>1hqJ(w+)R^bEn^5{{-2No zj{kxbqsdiM|1ZT!q%Ju6Kgdr~DF5D+eovOh*)2%BmEDR~O$aREI`KbgRQ+FC(K0`OIc~B3o!rovf3QLF8#&prIovmsNPUrAQz-EJG9A9C~87h0V zcfe+U97e4JNJR@6>B$!K#XcgbfP!Z%#FgJuphdnxRX7Ar({*RCzDW>(*pl^cUO!Uj z#(%J_)-9|qJoH7?#2A~=g7Ze4?i#r0uf0ia+lSOm z?eeK3i3<9Wiaij&foQ<2aAFb@&L)1pziFdUNU$*(oS}WaGqfP^m(LT`5XQEAL;HQU z>R$^Pkkagj-B;K*qoI4+tX@}PlGwjOAnfb=bC}ohbJ;R+ zQibZj^6$GCvzYZ0yu@rfvB?a7>a&mZYXuFU-hlXjx3$giwBn#_ZufA@@B< zBwXx7Y@cIsXMXvg)`lv6K6kD}OX9uLr=I)V#^;?$=(0295_#@D*U_52Xqb23R8Xel zeCw73H2x^(zA&KOexyEET3v|502hn_WYFgs{pytjVTY}ic34LdS20Gj;UY=#9ai-_ zZ_d>xqKUE5fOUIPJInbE$-10JlFVh~QWz+l?TRUSFS1;DlqQx3y9GTf%=YQN^?lf8 zeC+5pw^YwX7wWl+O8F{>k3os?)tlIG_;dQm?o7R$igzM6OZVHAtKNvSVpwTm$I#0A z%LR`gI@e8z;Jcr9?wVvqN-^Jz7)I$Jw(mQQDG2ZHN6Uk{)@Mdd`LfasPe?CrF}3&g2iMu#3x?^qpk z%~@WXA4pa}z~25>gZ|Gxm@yL9QDP-pshU`STv24dMyeM~1XLMX-FKG8s246GE#@UQ zJ~1kYd7)+9Q$u`TxnNm+iukU*7|UFwoxCt3;!4Yy3-5LGVa`jS{)&fw`k>phpx0>d zjGeQ=EoaU>lBWjrY66&PMnTHLjK8>vbi#3RwY6X9mR8c+W~prznJn}>E^ikF`jw|C z8{L;Ri}2FRapv?K!cGcC?@C)-gB=Cfq2!|%b5UQx=m3040Ppl0oA{JY#@V!j|1cDW_;x?P&6HX<)txGf(j`SZ=C#)pkZ-Nhf3{9*LO8>Y-bz$b7bTqGi zcoozctV5=~$Sf7VC#Y{>kBx+0eD-BGQ+6d${Wmc?Kx(DM8zAUfXNSE8^nIr3bcy6h zSruX9&eqxaQ~Yehr&;g=czH`eX^R=m+RP7G9p}ufXj&@yRwa7P=V%mbQuM{xiMmIN z(wa_n+%>ou=qXxCO0W?ff@xozH&Ty70`FlEc2GV9Q+t<+P1)9}5SqwS)ZP{1_M`yD zP={!oVGLS38JuSfWdaW!oAyYc#U^40m*ARh;dx{;Rg8^5pH5*D1zqG#HB1HDuKE7Y zjlZ)o2U}ZT<_jMf)b5q^1{n{IuC$Di4(V7)ChK5w)EFxzDFu9;W2xrT8ZN>G1uvG` zHg@}~w%1chZYRuWOs(Kmn18;>W?gE zFu$r*N7fZS5f$wn>}hIkb= 1 and jt <= 3 then incJt(st) elseif jt == 4 then - self.hidden[self:sideKey(st)] = false endEffect(st) end end @@ -832,6 +841,25 @@ E.BATTLE_BG_EFFECT_BETA_PURSUIT = function(self, st) end end +-- engine/battle_anims/bg_effects.asm:1444 +E.BATTLE_BG_EFFECT_BODY_SLAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + st.turn = self:playerSide(st) and u8(-2) or 2 + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + E.BATTLE_BG_EFFECT_WOBBLE_MON = function(self, st) local jt = st.jt if jt == 0 then @@ -921,7 +949,7 @@ E.BATTLE_BG_EFFECT_BOUNCE_DOWN = function(self, st) if jt == 0 then incJt(st) self:clearLYOverrides(0) - self:setLCDStatCustoms1("SCY", st) + self:setLCDStatCustoms2("SCY", st) self.lyEnd = u8(self.lyEnd + 1) st.turn = 1 st.param = 0x20 @@ -1285,8 +1313,24 @@ end -- Nothing is left unmodelled. The name stays so a caller (and the tests) can -- still ask, and so the answer is checkable rather than a claim in a comment. local UNMODELLED = {} -for _, name in ipairs(UNMODELLED) do - E[name] = function(_, st) endEffect(st) end +local UNMODELLED_SET = {} +for _, name in ipairs(UNMODELLED) do UNMODELLED_SET[name] = true end + +local DROPPED = {} +BgEffects.DROPPED = DROPPED +BgEffects.strict = os.getenv("POKEPORT_DEV") == "1" + +local function dropUnknown(st) + local name = st.func + endEffect(st) + if UNMODELLED_SET[name] then return end + if not DROPPED[name] then + DROPPED[name] = true + DROPPED[#DROPPED + 1] = name + end + if BgEffects.strict then + error("BgEffects: no entry for " .. tostring(name)) + end end -------------------------------------------------------------------------- @@ -1302,7 +1346,7 @@ function Pool:playFrame() else -- An id with no entry would otherwise sit in the pool forever and -- keep the animation from ending. - endEffect(st) + dropUnknown(st) end end end diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 9182fa2d..19182574 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -49,6 +49,20 @@ Effects.STAT_CHANGES = { EFFECT_SPEED_DOWN_2 = { "speed", -2, "foe" }, } +-- pokegold data/moves/effects.asm:187-352, :1488, :2068 +Effects.NO_CHECKHIT = { + EFFECT_ATTACK_UP = true, + EFFECT_DEFENSE_UP = true, + EFFECT_SP_ATK_UP = true, + EFFECT_EVASION_UP = true, + EFFECT_ATTACK_UP_2 = true, + EFFECT_DEFENSE_UP_2 = true, + EFFECT_SPEED_UP_2 = true, + EFFECT_SP_DEF_UP_2 = true, + EFFECT_DEFENSE_CURL = true, + EFFECT_CURSE = true, +} + -- The secondary versions, rolled against the move's effect chance after a hit. Effects.STAT_CHANGES_ON_HIT = { EFFECT_ATTACK_UP_HIT = { "attack", 1, "self" }, diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 2176cca6..026cdb08 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -607,11 +607,13 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- The slot is written here rather than through Mon.learnMove, so -- pokemon.move_learned is raised here too. ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) - -- engine/pokemon/learn.asm:115, data/text/common_3.asm:119 - self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") + -- engine/pokemon/learn.asm:225-229, data/text/common_3.asm:165-173 + self:say(("1, 2 and…" .. TextBox.PAUSE .. " Poof!" .. TextBox.PAUSE + .. "\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") :format(name, oldName, name, moveName), function() finish(true) end, - TextBox.soundOpts(self, "Sfx_DexFanfare5079")) + TextBox.soundOpts(self, "Sfx_DexFanfare5079", + { pauseSounds = { "Sfx_SwitchPokemon" } })) end, }) end diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 37ac59b2..5b12a81e 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -85,14 +85,14 @@ vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { local REMAP_SOURCE = [[ extern int remapCount; extern float remapTol; -extern vec3 remapSrc[32]; -extern vec3 remapDst[32]; +extern vec3 remapSrc[64]; +extern vec3 remapDst[64]; vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { vec4 px = Texel(tex, uv); vec3 mapped = px.rgb; float best = remapTol; - for (int i = 0; i < 32; i++) { + for (int i = 0; i < 64; i++) { if (i >= remapCount) { break; } vec3 d = px.rgb - remapSrc[i]; float dist = dot(d, d); @@ -108,9 +108,10 @@ vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { } ]] --- The compiled-in array length above. A map's eight BG palettes are 32 --- colours before dedupe, which is the worst case this has to hold. -GbcPalette.REMAP_MAX = 32 +-- The compiled-in array length above. A map's eight BG palettes plus its +-- eight OBJ palettes are 64 colours before dedupe, which is the worst case +-- this has to hold (home/fade.asm:32-38). +GbcPalette.REMAP_MAX = 64 -- Squared RGB distance, in 0..1 units: three 8-bit steps. GbcPalette.REMAP_TOLERANCE = (3 / 255) ^ 2 @@ -362,10 +363,9 @@ end -- order -- the reading that matches "the whole picture flashes". Nothing here -- is approximate when `ambiguous` is 0. -- --- A map's eight BG palettes are 32 entries, which is REMAP_MAX exactly, so a --- map whose palettes share nothing at all fills the array and the OBJ list is --- dropped. BG is walked first for that reason too: losing the sprite guard --- costs a few sprite pixels, losing a BG palette would cost the effect. +-- BG and OBJ together are 64 entries before dedupe, which is REMAP_MAX. BG +-- is walked first anyway: losing the sprite guard costs a few sprite pixels, +-- losing a BG palette would cost the effect. function GbcPalette.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) local src, dst = {}, {} local seen = {} @@ -447,7 +447,7 @@ end function GbcPalette.useRemapUniforms(uniforms) local sh = GbcPalette.remapShader() if not (sh and uniforms) then return false end - -- pcall rather than an assert: a driver that will not take a 32-entry vec3 + -- pcall rather than an assert: a driver that will not take a 64-entry vec3 -- array should drop the effect, not take the battle down with it. local ok = pcall(function() sh:send("remapCount", uniforms.count) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 52c2e0d7..006eb2ed 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -388,7 +388,7 @@ end function TextBox:update(dt) local input = self.game.input - self.blink = (self.blink + 1) % 60 + self.blink = (self.blink + 1) % 480 -- home/text.asm:506 if self.preSound then if not self.preStarted then @@ -691,12 +691,16 @@ function TextBox:draw() pen = pen + Font.advanceOf(code) end end - if self:arrowVisible() and self.blink < 30 then + -- pokegold home/joypad.asm:430 + local arrowOn = self.blink % 60 < 30 + if gold then arrowOn = self.blink % 32 < 16 end + if self:arrowVisible() and arrowOn then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) + -- pokegold home/text.asm:549 drawGlyph(Theme.moreArrow or 0xEE, (self.boxTx + self.boxTw - 2) * 8, - (self.boxTy + self.boxTh - 1) * 8 - 4) + (self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4)) end if finishGlyph then finishGlyph() end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 00c12118..0957fa0f 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -687,8 +687,8 @@ H.DayCareMon2 = function(vm) dayCareMon(vm, "lady") end -- its own common_* text: the extractor does not reach the common banks. local MOVE_DELETER_TEXT = { intro = Strings.source( - "Um… Oh, yes, I'm\nthe MOVE DELETER.\n\nI can make #MON\n" - .. "forget moves.\n\nShall I make a\n#MON forget?"), + "Um… Oh, yes, I'm\nthe MOVE DELETER.\fI can make #MON\n" + .. "forget moves.\fShall I make a\n#MON forget?"), declined = Strings.source("No? Come visit me\nagain."), whichMon = Strings.source("Which #MON?"), egg = Strings.source("An EGG doesn't\nknow any moves!"), @@ -788,23 +788,23 @@ end -- `special` byte only points at the routine, not at the text bank behind it. local NAME_RATER_TEXT = { hello = Strings.source( - "Hello, hello! I'm\nthe NAME RATER.\n\nI rate the names\nof #MON.\n\n" + "Hello, hello! I'm\nthe NAME RATER.\fI rate the names\nof #MON.\f" .. "Would you like me\nto rate names?"), comeAgain = Strings.source("OK, then. Come\nagain sometime."), - whichMon = Strings.source("Which #MON's\nnickname should I\nrate for you?"), + whichMon = Strings.source("Which #MON's\nnickname should I\vrate for you?"), egg = Strings.source("Whoa… That's just\nan EGG."), perfectName = Strings.source( - "Hm… {STRBUF}?\nWhat a great name!\nIt's perfect.\n\nTreat {STRBUF}\n" + "Hm… {STRBUF}?\nWhat a great name!\vIt's perfect.\fTreat {STRBUF}\n" .. "with loving care."), betterName = Strings.source( - "Hm… {STRBUF}…\nThat's a fairly\ndecent name.\n\nBut, how about a\n" - .. "slightly better\nnickname?\n\nWant me to give it\na better name?"), - whatName = Strings.source("All right. What\nname should we\ngive it, then?"), - finished = Strings.source("That's a better\nname than before!\n\nWell done!"), + "Hm… {STRBUF}…\nThat's a fairly\vdecent name.\fBut, how about a\n" + .. "slightly better\vnickname?\fWant me to give it\na better name?"), + whatName = Strings.source("All right. What\nname should we\vgive it, then?"), + finished = Strings.source("That's a better\nname than before!\fWell done!"), sameName = Strings.source( - "It might look the\nsame as before,\n\nbut this new name\n" - .. "is much better!\n\nWell done!"), - named = Strings.source("All right. This\n#MON is now\nnamed {STRBUF}."), + "It might look the\nsame as before,\fbut this new name\n" + .. "is much better!\fWell done!"), + named = Strings.source("All right. This\n#MON is now\vnamed {STRBUF}."), } -- IsNewNameEmpty: the typed name is empty if every character up to the diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 6038a8bc..a8a82a96 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -244,9 +244,10 @@ local function bgpBands(bg) local base = bg.bgp or GbcPalette.BGP_IDENTITY local order, bands = {}, {} for row = 0, SCREEN_H - 1 do - local inWindow = row >= bg.lyStart and row < bg.lyEnd + -- home/lcd.asm:12 + local inWindow = row > bg.lyStart and row <= bg.lyEnd -- Outside the window the register still reads whatever wBGP holds. - local byte = inWindow and (bg.lyBackup[row] or base) or base + local byte = inWindow and (bg.lyBackup[row - 1] or base) or base local band = bands[byte] if not band then band = { byte = byte, rows = {} } @@ -265,6 +266,7 @@ local function bgpBands(bg) end) return order end +BattleAnimView.bgpBands = bgpBands -- engine/battle_anims/bg_effects.asm:2638 function BattleAnimView.scanlines(bg) @@ -272,9 +274,10 @@ function BattleAnimView.scanlines(bg) local scy = signed(bg.scy) for row = 0, SCREEN_H - 1 do local dx, src = 0, row + scy + -- home/lcd.asm:12 local inWindow = bg.lcdc and bg.lcdc ~= "BGP" - and row >= bg.lyStart and row < bg.lyEnd - local byte = bg.lyBackup[row] or 0 + and row > bg.lyStart and row <= bg.lyEnd + local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0 if inWindow then local value = signed(byte) if bg.lcdc == "SCX" then dx = -value else src = src + value end @@ -342,8 +345,8 @@ function BattleAnimView:present(runner, drawBg, battle) -- Shaderless boot: the panel is raw grayscale, so there are no palettes to -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. if bg.lcdc == "BGP" then - for row = math.max(0, bg.lyStart), math.min(bg.lyEnd, SCREEN_H) - 1 do - local veil = BattleAnimView.palVeil(bg.lyBackup[row]) + for row = bg.lyStart + 1, math.min(bg.lyEnd, SCREEN_H - 1) do + local veil = BattleAnimView.palVeil(bg.lyBackup[row - 1]) if veil ~= 0 then local shade = veil > 0 and 0 or 1 G.setColor(shade, shade, shade, math.min(1, math.abs(veil))) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index fbcdaffe..c07b087c 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -69,6 +69,9 @@ local MESSAGE_FRAMES = 48 -- engine/battle/effect_commands.asm:6661 local MOVE_DELAY_FRAMES = 40 +-- ../pokecrystal/home/text.asm:895 +local TEXT_PAUSE_FRAMES = 30 + -- home/hm_moves.asm:17-25 IsHMMove's .HMMoves. local HM_MOVES = { CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, @@ -345,6 +348,7 @@ function BattleState.new(game, opts) -- Which side's pic box stays EMPTY until a send-out redraws it: a catch -- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide. self.picHidden = { player = false, enemy = false } + self.vanishReveal = {} -- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in -- from opposite sides before the first message. self.slideFrame = 0 @@ -625,12 +629,14 @@ function BattleState:push(event) end function BattleState:pushAll(events) + self:latchVanished(events) for _, event in ipairs(events or {}) do self:push(event) end end -- LearnMove returns before HandleEnemyMonFaint's send-out/prize arms -- (engine/battle/core.asm:1959-2010). function BattleState:pushFront(events) + self:latchVanished(events) for i = #(events or {}), 1, -1 do table.insert(self.queue, 1, events[i]) end @@ -1488,12 +1494,13 @@ function BattleState:playHitSound(effectiveness) else self:playSfx("Sfx_Damage") end end -function BattleState:animForMove(moveId, side, param, effectiveness) +function BattleState:animForMove(moveId, side, param, effectiveness, noAfterAnim) local key = self.anims and self.anims.moves and self.anims.moves[moveId] local started = self:startAnim(key, { turn = self:turnFor(side), animId = moveId, isMove = true, param = param, }) - if started then + -- engine/battle/effect_commands.asm:5456 + if started and not noAfterAnim then -- BattleAnimRunScript (anim_commands.asm:55-72): after the move script -- restores HUDs it immediately runs wBattleAfterAnim (the hit shake). -- Queue it so stepAnim chains without waiting on the next event. @@ -1547,7 +1554,9 @@ function BattleState:stepAnim(input) if input and (input:wasPressed("b") or input:wasPressed("start")) then -- Cut short: only the explicit latches (a caught mon) survive a skip. self:latchCaughtPic() + local ended = self.anim self.anim = nil + self:clearVanishReveal(nil, ended) -- Cart still reaches the after-anim arm after a move script ends; a skip -- of the move should not drop the hit shake that follows it. if self:startPendingAfterAnim() then return end @@ -1555,13 +1564,62 @@ function BattleState:stepAnim(input) end if not self.anim:step() then self:latchCaughtPic() + local ended = self.anim -- pokegold data/moves/animations.asm .Click: anim_keepsprites means -- the OAM outlives the script, so keep the runner for drawing too. if not self.anim.keepSprites then self.anim = nil end + self:clearVanishReveal(nil, ended) if self:startPendingAfterAnim() then return end return self:endSendOutAnim() end self:revealSentOut() + self:revealVanished() +end + +local VANISH_SIDES = { "player", "enemy" } + +-- engine/battle/misc.asm:1-13 +function BattleState:latchVanished(events) + for _, event in ipairs(events or {}) do + if event.wasVanished and event.side then + self.picHidden[event.side] = true + self.vanishReveal[event.side] = { side = event.side } + end + end +end + +function BattleState:armVanishReveal(side) + local latch = self.vanishReveal and self.vanishReveal[side] + if not latch then return false end + latch.anim = self.anim + return true +end + +function BattleState:clearVanishReveal(side, ended) + local latches = self.vanishReveal + if not latches then return end + for _, key in ipairs(VANISH_SIDES) do + local latch = latches[key] + if latch and (side == nil or side == key) + and (ended == nil or latch.anim == ended) then + latches[key] = nil + self.picHidden[key] = false + end + end +end + +-- engine/battle_anims/bg_effects.asm:377 +function BattleState:revealVanished() + local anim, latches = self.anim, self.vanishReveal + if not (anim and latches) then return end + for _, side in ipairs(VANISH_SIDES) do + local latch = latches[side] + if latch and latch.anim == anim and self.picHidden[side] + and anim.bg.picSize[side] ~= nil then + self.picHidden[side] = false + latches[side] = nil + end + end end -- ../pokecrystal/engine/battle_anims/bg_effects.asm:709-720 @@ -1954,6 +2012,10 @@ function BattleState:advanceQueue() if event.kind == "move" and (event.missed or event.animDelay) then self.messageDelay = MOVE_DELAY_FRAMES end + elseif event.textPause then + -- ../pokecrystal/home/text.asm:887-896 + self.messageTimer = 0 + self.messageDelay = TEXT_PAUSE_FRAMES else self.messageTimer = MESSAGE_FRAMES end @@ -1983,15 +2045,24 @@ function BattleState:advanceQueue() -- data/moves/effects.asm:1705-1711 local animId = event.moveAnim or (event.kind == "move" and not event.deferAnim and event.move) + if event.wasVanished and (not animId or event.missed) then + self:clearVanishReveal(event.side) + end if animId and not event.missed then self.afterAnimPlayed = nil self.pendingAfterAnim = nil - if not self:animForMove(animId, event.side, event.animParam, - event.effectiveness) then + local started = self:animForMove(animId, event.side, event.animParam, + event.effectiveness, event.noAfterAnim) + if event.wasVanished + and not (started and self:armVanishReveal(event.side)) then + self:clearVanishReveal(event.side) + end + if not started then -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim -- (anim_commands.asm:55-72 .disabled fallthrough). local options = self.game and self.game.options - if options and options.battleScene == false then + if options and options.battleScene == false + and not event.noAfterAnim then if self:animForId(self:afterAnimFor(event.side), event.side) then self:playHitSound(event.effectiveness) self.afterAnimPlayed = true @@ -4355,8 +4426,8 @@ function BattleState:drawLiftedRows() self.liftedPass = true -- engine/battle_anims/anim_commands.asm:1308 local previousBgp = GbcPalette.setBgp(self.anim and self.anim.bg.bgp or nil) - if enemyLift then self:drawPic(battle.enemy, false) end - if playerLift then self:drawPic(battle.player, true) end + if enemyLift then self:drawPic(self:activeMon("enemy"), false) end + if playerLift then self:drawPic(self:activeMon("player"), true) end GbcPalette.setBgp(previousBgp) self.liftedPass = nil G.pop() diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index cbb61401..1a778fb3 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -20,9 +20,7 @@ -- -- Note the cart's own bug, kept here: the level test reads wEnemyMonLevel -- BEFORE the enemy mon is loaded, so "stronger" is decided against whatever --- the previous battle left there. This port has no such stale byte, so it --- compares honestly -- the one place the port is deliberately not bug-exact, --- because the alternative is emulating an uninitialised variable. +-- the previous battle left there (engine/battle/battle_transition.asm:164). -- -- Everything that decides WHICH tiles go black is a pure function below and is -- covered by tests; the state at the bottom is the only part that draws. diff --git a/src/ui/gen2/ContestMenu.lua b/src/ui/gen2/ContestMenu.lua index 4afa8b89..a3c884e8 100644 --- a/src/ui/gen2/ContestMenu.lua +++ b/src/ui/gen2/ContestMenu.lua @@ -34,6 +34,8 @@ local BugContest = require("src.core.gen2.BugContest") local Chrome = require("src.ui.gen2.Chrome") local Strings = require("src.core.Strings") +local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local ContestMenu = {} ContestMenu.__index = ContestMenu @@ -82,6 +84,19 @@ ContestMenu.TEXT = { end, } +-- pokecrystal engine/events/bug_contest/display_stats.asm:84-87 +-- pokecrystal home/text.asm:316,408 +ContestMenu.TEXT_CRYSTAL = { + stock = " STOCK ", + this = " THIS ", +} + +function ContestMenu.labels() + local text = GameVersion.engine() == "crystal" and ContestMenu.TEXT_CRYSTAL + or ContestMenu.TEXT + return text.stock, text.this +end + function ContestMenu:wantsFillScale() return true end function ContestMenu:drawsWidescreen() return true end @@ -168,12 +183,16 @@ function ContestMenu:drawYesNo() end function ContestMenu:drawPanel() + -- pokecrystal engine/events/bug_contest/display_stats.asm:5 + local wasBattle = Font.useBattleExtra(true) Chrome.clear() - self:drawMonBox(STOCK_BOX_Y, ContestMenu.TEXT.stock, self.stock) - self:drawMonBox(THIS_BOX_Y, ContestMenu.TEXT.this, self.caught) + local stock, this = ContestMenu.labels() + self:drawMonBox(STOCK_BOX_Y, stock, self.stock) + self:drawMonBox(THIS_BOX_Y, this, self.caught) Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) Chrome.print(Strings(ContestMenu.TEXT.askSwitch), TEXT_X, TEXT_Y) self:drawYesNo() + Font.useBattleExtra(wasBattle) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/gen2/DayCareMenu.lua b/src/ui/gen2/DayCareMenu.lua index 8677cd85..9abb338b 100644 --- a/src/ui/gen2/DayCareMenu.lua +++ b/src/ui/gen2/DayCareMenu.lua @@ -556,18 +556,26 @@ function DayCareMenu:drawYesNo(choice) Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) end +-- ../pokecrystal/engine/events/daycare.asm:107 +function DayCareMenu:yesNoVisible() + local confirm = self.confirm + if not confirm or confirm.page < #confirm.pages then return false end + return not Typer.typing(self) +end + function DayCareMenu:drawPanel() local typed = self.typer == nil or self.typer:done() if self.message then self:drawTextBox(Typer.text(self, self.message.pages[self.message.page])) - if typed and self.message.page < #self.message.pages then + if typed and self.message.page < #self.message.pages + and Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end elseif self.confirm then self:drawTextBox(Typer.text(self, self.confirm.pages[self.confirm.page])) - if self.confirm.page >= #self.confirm.pages then + if self:yesNoVisible() then self:drawYesNo(self.confirm.choice) - elseif typed then + elseif typed and Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end else diff --git a/src/ui/gen2/HeldItemMenu.lua b/src/ui/gen2/HeldItemMenu.lua index f472d380..11dc853b 100644 --- a/src/ui/gen2/HeldItemMenu.lua +++ b/src/ui/gen2/HeldItemMenu.lua @@ -33,6 +33,7 @@ local CommonText = require("src.core.gen2.CommonText") local Mail = require("src.core.gen2.Mail") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +local Typer = require("src.ui.gen2.Typer") local HeldItemMenu = {} HeldItemMenu.__index = HeldItemMenu @@ -291,6 +292,7 @@ end -- ------------------------------------------------------------------ update function HeldItemMenu:updateMessage(input) + Typer.step(self) if not (input:wasPressed("a") or input:wasPressed("b")) then return end local message = self.message if message.page < #message.pages then @@ -302,6 +304,7 @@ function HeldItemMenu:updateMessage(input) end function HeldItemMenu:updateConfirm(input) + Typer.step(self) local confirm = self.confirm if confirm.page < #confirm.pages then if input:wasPressed("a") or input:wasPressed("b") then @@ -368,7 +371,8 @@ end function HeldItemMenu:drawPanel() if self.message then self:drawTextBox(self.message.pages[self.message.page]) - if self.message.page < #self.message.pages then + if self.message.page < #self.message.pages + and Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end love.graphics.setColor(1, 1, 1, 1) @@ -378,7 +382,7 @@ function HeldItemMenu:drawPanel() self:drawTextBox(self.confirm.pages[self.confirm.page]) if self.confirm.page >= #self.confirm.pages then self:drawYesNo(self.confirm.choice) - else + elseif Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/gen2/MailMenu.lua b/src/ui/gen2/MailMenu.lua index 0a2b7b41..f5c46079 100644 --- a/src/ui/gen2/MailMenu.lua +++ b/src/ui/gen2/MailMenu.lua @@ -30,6 +30,7 @@ local CommonText = require("src.core.gen2.CommonText") local Mail = require("src.core.gen2.Mail") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +local Typer = require("src.ui.gen2.Typer") local MailMenu = {} MailMenu.__index = MailMenu @@ -208,6 +209,7 @@ function MailMenu:choose() end function MailMenu:updateMessage(input) + Typer.step(self) if not (input:wasPressed("a") or input:wasPressed("b")) then return end local message = self.message if message.page < #message.pages then @@ -219,6 +221,7 @@ function MailMenu:updateMessage(input) end function MailMenu:updateConfirm(input) + Typer.step(self) local confirm = self.confirm if confirm.page < #confirm.pages then if input:wasPressed("a") or input:wasPressed("b") then @@ -284,7 +287,8 @@ end function MailMenu:drawPanel() if self.message then self:drawTextBox(self.message.pages[self.message.page]) - if self.message.page < #self.message.pages then + if self.message.page < #self.message.pages + and Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end love.graphics.setColor(1, 1, 1, 1) @@ -294,7 +298,7 @@ function MailMenu:drawPanel() self:drawTextBox(self.confirm.pages[self.confirm.page]) if self.confirm.page >= #self.confirm.pages then self:drawYesNo(self.confirm.choice) - else + elseif Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/gen2/MailboxMenu.lua b/src/ui/gen2/MailboxMenu.lua index d45af9e1..b416f01c 100644 --- a/src/ui/gen2/MailboxMenu.lua +++ b/src/ui/gen2/MailboxMenu.lua @@ -34,6 +34,7 @@ local CommonText = require("src.core.gen2.CommonText") local Mail = require("src.core.gen2.Mail") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +local Typer = require("src.ui.gen2.Typer") local MailboxMenu = {} MailboxMenu.__index = MailboxMenu @@ -266,6 +267,7 @@ end -- ------------------------------------------------------------------ update function MailboxMenu:updateMessage(input) + Typer.step(self) if not (input:wasPressed("a") or input:wasPressed("b")) then return end local message = self.message if message.page < #message.pages then @@ -277,6 +279,7 @@ function MailboxMenu:updateMessage(input) end function MailboxMenu:updateConfirm(input) + Typer.step(self) local confirm = self.confirm if confirm.page < #confirm.pages then if input:wasPressed("a") or input:wasPressed("b") then @@ -388,7 +391,8 @@ end function MailboxMenu:drawPanel() if self.message then self:drawTextBox(self.message.pages[self.message.page]) - if self.message.page < #self.message.pages then + if self.message.page < #self.message.pages + and Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end love.graphics.setColor(1, 1, 1, 1) @@ -398,7 +402,7 @@ function MailboxMenu:drawPanel() self:drawTextBox(self.confirm.pages[self.confirm.page]) if self.confirm.page >= #self.confirm.pages then self:drawYesNo(self.confirm.choice) - else + elseif Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/gen2/MartMenu.lua b/src/ui/gen2/MartMenu.lua index 707068e4..8a24cb96 100644 --- a/src/ui/gen2/MartMenu.lua +++ b/src/ui/gen2/MartMenu.lua @@ -976,6 +976,13 @@ function MartMenu:drawYesNo(choice) Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) end +-- ../pokecrystal/engine/items/mart.asm:517 MartConfirmPurchase +function MartMenu:yesNoVisible() + local confirm = self.confirm + if not confirm or confirm.page < #confirm.pages then return false end + return not Typer.typing(self) +end + -- Whatever the overlays sit on top of. function MartMenu:drawUnder() local phase = self.phase @@ -1015,12 +1022,13 @@ function MartMenu:drawPanel() self:drawTextBox(Typer.text(self, self.message.pages[self.message.page])) -- LoadBlinkingCursor puts the ▼ at hlcoord 18, 17 while a `para` waits. if self.message.page < #self.message.pages - and (self.typer == nil or self.typer:done()) then + and (self.typer == nil or self.typer:done()) + and Typer.arrowOn(self) then Chrome.print(DOWN_ARROW, 18, 17) end elseif self.confirm then self:drawTextBox(Typer.text(self, self.confirm.pages[self.confirm.page])) - if self.confirm.page >= #self.confirm.pages then + if self:yesNoVisible() then self:drawYesNo(self.confirm.choice) end end diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 610fe8a1..76214b08 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -248,6 +248,12 @@ function SaveMenu:prompt() return twoLines(Strings("Would you like to\nsave the game?")) end +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +function SaveMenu:yesNoVisible() + if self.phase ~= "confirm" and self.phase ~= "overwrite" then return false end + return self.typedPhase == self.phase and not Typer.typing(self) +end + function SaveMenu:drawPanel() local summary = Save.summary(self.save) Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H) @@ -272,7 +278,7 @@ function SaveMenu:drawPanel() Chrome.print(lines[1] or "", 1, 14) Chrome.print(lines[2] or "", 1, 16) - if self.phase == "confirm" or self.phase == "overwrite" then + if self:yesNoVisible() then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) diff --git a/src/ui/gen2/StartMenu.lua b/src/ui/gen2/StartMenu.lua index 0d0a4d3a..85cbfa00 100644 --- a/src/ui/gen2/StartMenu.lua +++ b/src/ui/gen2/StartMenu.lua @@ -19,6 +19,7 @@ -- hook name and the same (game, items) payload: Gold's POKEGEAR row is simply -- one more entry in the list the hook receives. +local BugContest = require("src.core.gen2.BugContest") local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") @@ -149,7 +150,8 @@ function StartMenu.new(game, opts) -- one more for the cursor column (STATICMENU_CURSOR) and one more for the -- top spacing this menu does not opt out of -- so (10,0) becomes (12,2), -- and Chrome.List puts the cursor at x - 1 = 11. - x = 12, y = 2, spacing = 2, + -- pokecrystal engine/menus/start_menu.asm:164 + x = 12, y = self.contest and 4 or 2, spacing = 2, -- Two rows per entry inside an 18-row screen leaves room for eight, which -- is exactly the vanilla count. A mod that adds a row scrolls rather than -- drawing off the bottom of the frame; Chrome.List puts the ▼ hint on. @@ -193,18 +195,34 @@ end function StartMenu:visibleItems() local available = self:availability() + local contest = BugContest.isActive(self.save) + self.contest = contest local playerName = (self.save and self.save.player and self.save.player.name) or "GOLD" local out = {} for _, item in ipairs(ITEMS) do - if not item.need or available[item.need] then - out[#out + 1] = { - label = item.label or playerName, - value = item.id, - desc = item.desc, - translateLabel = item.label ~= nil, - translateDesc = item.desc ~= nil, - } + local id = item.id + -- pokecrystal engine/menus/start_menu.asm:309 + local hidden = contest and (id == "pack" or id == "quit") + if not hidden and (not item.need or available[item.need]) then + if contest and id == "save" then + -- pokecrystal engine/menus/start_menu.asm:330 + out[#out + 1] = { + label = Strings.source("QUIT"), value = "quitContest", + -- pokecrystal engine/menus/start_menu.asm:231 + desc = Strings.source("Quit and\nbe judged."), + translateLabel = true, + translateDesc = true, + } + else + out[#out + 1] = { + label = item.label or playerName, + value = id, + desc = item.desc, + translateLabel = item.label ~= nil, + translateDesc = item.desc ~= nil, + } + end end end return out @@ -241,6 +259,12 @@ function StartMenu:choose(id, index) self.confirmChoice = 2 return end + if id == "quitContest" then + -- pokecrystal engine/menus/start_menu.asm:411 + self.phase = "confirmContest" + self.confirmChoice = 1 + return + end if self.onChoose then self.onChoose(id) end end @@ -253,6 +277,14 @@ function StartMenu:confirmQuit() end end +-- pokecrystal engine/events/bug_contest/contest.asm:31 +function StartMenu:confirmQuitContest() + self.phase = nil + self:close() + local world = self.game and self.game.world + if world and world.bugContestResults then world:bugContestResults() end +end + function StartMenu:close() StartMenu.lastIndex = self.list.index if self.onClose then self.onClose() end @@ -261,14 +293,16 @@ end function StartMenu:update(_dt) local input = self.game and self.game.input if not input then return end - if self.phase == "confirm" then + if self.phase == "confirm" or self.phase == "confirmContest" then if input:wasPressed("up") or input:wasPressed("down") then self.confirmChoice = self.confirmChoice == 1 and 2 or 1 elseif input:wasPressed("a") then - if self.confirmChoice == 1 then - self:confirmQuit() - else + if self.confirmChoice ~= 1 then self.phase = nil + elseif self.phase == "confirmContest" then + self:confirmQuitContest() + else + self:confirmQuit() end elseif input:wasPressed("b") or input:wasPressed("start") then self.phase = nil @@ -283,15 +317,45 @@ function StartMenu:update(_dt) self.list:update(input) end +-- pokecrystal engine/menus/menu_2.asm:145 +function StartMenu:drawContestStatus() + Chrome.textbox(0, 0, 17, 5) + Chrome.print("CAUGHT", 1, 1) + local mon = BugContest.caughtMon(self.save) + Chrome.print(mon and (mon.nickname or mon.name or mon.species) or "None", + 8, 1) + if mon then + Chrome.print("LEVEL", 1, 3) + Chrome.print(tostring(mon.level or 1), 7, 3) + end + Chrome.print("BALLS:", 1, 5) + Chrome.print(tostring(BugContest.ballsLeft(self.save)), 8, 5) +end + function StartMenu:draw() -- AutomaticGetMenuBottomCoord: bottom = top + 2 * items + 1, so the box is -- two rows per entry plus its two border rows. A menu that a mod has grown -- past the screen scrolls instead of overflowing (Chrome.List draws the ▼ -- hint when there is more below). - local height = math.min(#self.items * 2 + 2, Chrome.SCREEN_H) - Chrome.box(10, 0, 10, height) + local top = self.contest and 2 or 0 + local height = math.min(#self.items * 2 + 2, Chrome.SCREEN_H - top) + if self.contest then self:drawContestStatus() end + Chrome.box(10, top, 10, height) self.list:draw() + if self.phase == "confirmContest" then + -- pokecrystal data/text/common_2.asm:1381 + Chrome.textbox(0, 12, 18, 4) + Chrome.print("Would you like to", 1, 14) + Chrome.print("end the Contest?", 1, 16) + -- pokecrystal home/menu.asm:418 + Chrome.box(14, 7, 6, 5) + Chrome.print("YES", 16, 8) + Chrome.print("NO", 16, 10) + Chrome.cursor(15, self.confirmChoice == 1 and 8 or 10) + return + end + if self.phase == "confirm" then Chrome.textbox(0, 12, 18, 4) local prompt = Strings(Strings.source("Return to the\ntitle screen?")) diff --git a/src/ui/gen2/Typer.lua b/src/ui/gen2/Typer.lua index 81d4d45c..a4e73a3b 100644 --- a/src/ui/gen2/Typer.lua +++ b/src/ui/gen2/Typer.lua @@ -139,11 +139,17 @@ function Typer.turn(screen, record) end function Typer.step(screen) + screen.arrowBlink = ((screen.arrowBlink or 0) + 1) % 32 local typer = screen.typer if not typer then return true end return typer:tick() end +-- ../pokegold/home/joypad.asm:430 +function Typer.arrowOn(screen) + return ((screen.arrowBlink or 0) % 32) < 16 +end + -- ../pokecrystal/home/print_text.asm:59 function Typer.typing(screen) local typer = screen.typer diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index f9220a18..0b94d299 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -592,6 +592,9 @@ function World.new(game) -- STATUSFLAGS_NO_WILD_ENCOUNTERS_F, driven by the `wildoff` / `wildon` -- script commands. noWildEncounters = false, + -- ../pokegold/engine/battle/battle_transition.asm:164 + staleBattleMonLevel = 0, + staleEnemyMonLevel = 0, -- Blocks CUT and WHIRLPOOL have swapped out on the loaded map, as -- { mapId = { [index] = original } }. The cart edits wOverworldMapBlocks, -- a BUFFER, and LoadMapAttributes refills it from ROM on every map load -- @@ -1194,6 +1197,7 @@ function World:load() reloadMap = function(setup) if setup then self:forceMapMusic() + self:battleReturnFade() -- Script_reloadmap re-enters through MAPSTATUS_ENTER (engine/overworld/ -- scripting.asm:1108-1116), so a wild battle re-arms EnterMap's cooldown. self.wildCooldown = 5 @@ -3546,6 +3550,32 @@ function World.flyCancelBlankFrames(partySize) return FLY_CANCEL_BLANK_FRAMES + FLY_CANCEL_ICON_FRAMES * (partySize or 0) end +-- engine/tilesets/timeofday_pals.asm:65-91, home/fade.asm:22-120 +World.FADE_RAMP = { + white = { { 3, 2, 1, 0 }, { 2, 1, 0, 0 }, { 1, 0, 0, 0 }, { 0, 0, 0, 0 } }, + black = { { 3, 2, 1, 0 }, { 3, 3, 2, 1 }, { 3, 3, 3, 2 }, { 3, 3, 3, 3 } }, +} + +function World.fadeRampRow(fade, level) + local ramp = fade and World.FADE_RAMP[fade] + if not ramp then return nil end + local step = math.max(1, math.min(FADE_STEPS, + math.ceil((level or 1) * FADE_STEPS))) + return ramp[step] +end + +-- home/fade.asm:22 +function World.fadeRampByte(row) + return row[1] * 64 + row[2] * 16 + row[3] * 4 + row[4] +end + +-- data/maps/setup_scripts.asm:124-139 +function World:battleReturnFade() + if self.mapSetup then return end + self.fade, self.fadeLevel = "white", 1 + self.mapSetup = { phase = "in", step = FADE_STEPS, wait = FADE_STEP_FRAMES } +end + -- --------------------------------------------------------------------------- -- The three roaming beasts (engine/overworld/wildmons.asm). -- --------------------------------------------------------------------------- @@ -6623,9 +6653,45 @@ function World:playBattleMusic(opts) return song end +-- ../pokecrystal/engine/battle/start_battle.asm:15 +function World.transitionStatusByte(status, turns) + if status == "sleep" then return math.min(turns or 1, 7) end + if status == "poison" or status == "toxic" then return 8 end + if status == "burn" then return 16 end + if status == "freeze" then return 32 end + if status == "paralyze" then return 64 end + return 0 +end + +-- ../pokecrystal/engine/battle/start_battle.asm:15-35 +function World:transitionBattleMonLevel() + local GameVersion = require("src.core.GameVersion") + local save = self.game and self.game.save + local engine = GameVersion.engine((save and save.version) or GameVersion.get()) + if engine ~= "crystal" then return self.staleBattleMonLevel or 0 end + for _, mon in ipairs((save and save.party) or {}) do + if (mon.hp or 0) > 0 then + return World.transitionStatusByte(mon.status, mon.statusTurns) + end + end + return 0 +end + +-- ../pokegold/engine/battle/core.asm:5995 +function World:recordStaleBattleLevels(battle) + if battle.enemy and battle.enemy.level then + self.staleEnemyMonLevel = battle.enemy.level + end + if battle.player and battle.player.level then + self.staleBattleMonLevel = battle.player.level + end +end + -- DoBattleTransition. Returns true when the wipe took the screen, false when -- there is nothing to wipe (a headless run, or a battle started before the map -- is up) and the battle should just come straight in. +-- ../pokegold/engine/battle/core.asm:7782-7783 +-- ../pokegold/engine/battle/battle_transition.asm:164 function World:pushBattleTransition(battle, opts, onDone) local game = self.game if not (game and game.stack and self.map) then return false end @@ -6633,8 +6699,8 @@ function World:pushBattleTransition(battle, opts, onDone) world = self, trainer = opts and opts.trainer and true or false, environment = self.map.def and self.map.def.environment, - playerLevel = battle and battle.player and battle.player.level, - enemyLevel = battle and battle.enemy and battle.enemy.level, + playerLevel = self:transitionBattleMonLevel(), + enemyLevel = self.staleEnemyMonLevel or 0, onDone = onDone, }) return true @@ -6704,10 +6770,13 @@ function World:startBattle(opts, onDone) -- World:startCatchTutorial sets it. tutorial = opts.tutorial, onDone = function(outcome) + self:recordStaleBattleLevels(battle) -- WildBattleScript's reloadmapafterbattle (engine/overworld/events.asm:1158-1162) self.wildCooldown = 5 self.battleActive = nil game.stack:pop() + -- engine/overworld/events.asm:1158-1162, data/maps/setup_scripts.asm:124 + if not self:scriptRunning() then self:battleReturnFade() end -- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2. -- The port never forfeits or draws a battle, so "lose" is the only -- other outcome startBattle's onDone hands back; VAR.BATTLERESULT @@ -8750,10 +8819,8 @@ function World:drawGrassOver(entity, ox, oy, s) local tilePalettes = tileset.tilePalettes local tilesPerRow = tileset.tilesPerRow or 16 local aw, ah = atlas:getDimensions() - -- Only draw the bottom 8px tile row of the cell (ty = py + 8) over the feet, - -- matching Gen 1's drawCellBottomRaw. Starting at py + 4 sampled the top - -- tile row and drew grass tufts over the face and torso. - local rx, ry = entity.px, entity.py + 8 + -- engine/overworld/map_objects.asm:2876, data/sprites/facings.asm:45-56 + local rx, ry = entity.px, entity.py + 4 self.grassQuad = self.grassQuad or G.newQuad(0, 0, 8, 8, aw, ah) local quad = self.grassQuad G.setColor(1, 1, 1, 1) @@ -10988,6 +11055,69 @@ function World:refreshColorMode() self:rebuildNeighbors() end +-- engine/tilesets/timeofday_pals.asm:65-91 +function World:drawFadeRemap(s, w, h) + local row = World.fadeRampRow(self.fade, self.fadeLevel) + if not row then return false end + local byte = World.fadeRampByte(row) + if byte == GbcPalette.BGP_IDENTITY then + self:drawWorldBody(s) + return true + end + if not GbcPalette.remapShader() then return false end + local def = self.map and self.map.def + if not (def and self.palettes) then return false end + local cache = self.fadeRemapCache + if not (cache and cache.byte == byte and cache.def == def + and cache.daytime == self.daytime and cache.palettes == self.palettes + and cache.mode == GbcPalette.mode + and cache.custom == GbcPalette.customRamp) then + local bg = Palettes.bgSet(self.palettes, def, self.daytime) + if not bg then return false end + -- home/fade.asm:32-38 + local pals = {} + for _, colors in ipairs(bg) do pals[#pals + 1] = colors end + for _, colors in ipairs(Palettes.objectSet(self.palettes, self.daytime) + or {}) do + pals[#pals + 1] = colors + end + local uniforms = GbcPalette.remapUniforms(pals, byte) + if not uniforms then return false end + cache = { byte = byte, def = def, daytime = self.daytime, + palettes = self.palettes, mode = GbcPalette.mode, + custom = GbcPalette.customRamp, uniforms = uniforms } + self.fadeRemapCache = cache + end + local canvas = self.fadeCanvas + if canvas and (canvas:getWidth() ~= w or canvas:getHeight() ~= h) then + if canvas.release then canvas:release() end + canvas = nil + self.fadeCanvas = nil + end + if not canvas then + local ok, made = pcall(love.graphics.newCanvas, w, h) + if not ok or not made then return false end + made:setFilter("nearest", "nearest") + canvas = made + self.fadeCanvas = canvas + end + local G = love.graphics + local previous = G.getCanvas() + G.push() + G.origin() + G.setCanvas(canvas) + G.clear(0, 0, 0, 1) + self:drawWorldBody(s) + G.setCanvas(previous) + G.pop() + local bound = GbcPalette.useRemapUniforms(cache.uniforms) + G.setColor(1, 1, 1, 1) + G.draw(canvas, 0, 0) + if not bound then return false end + GbcPalette.clear() + return true +end + function World:draw() local G = love.graphics local w, h = Playfield.dimensions() @@ -11053,13 +11183,17 @@ function World:draw() -- quad. Everything after -- the encounter pic and the survey HUD -- stays -- flat, the same split the Gen 1 renderer makes; a pipeline's finished image -- lands in exactly the same place. + local fadeStepped = false if override then G.setColor(1, 1, 1, 1) G.draw(override, 0, 0) elseif tilt then self:drawTilted(w, h, s, gw, gh) else - self:drawWorldBody(s) + if self.fade and (self.fadeLevel or 1) < 1 then + fadeStepped = self:drawFadeRemap(s, w, h) + end + if not fadeStepped then self:drawWorldBody(s) end end -- ../pokecrystal/engine/events/map_name_sign.asm:114 @@ -11108,7 +11242,7 @@ function World:draw() -- doors, the Radio Tower takeover, Lugia's chamber); the port has no -- palette-cycle fade, so the honest stand-in is the flat sheet the cart's own -- fade ends on, over the world and under the text box the script is running. - if self.fade then + if self.fade and not fadeStepped then -- fadeLevel is the map setup chain's four-step ramp; a fade special sets it -- to 1 because RotateThreePalettes* has already finished by the time the -- script that called it runs on. diff --git a/tests/drivers/battle_scy_deform_bug1895_test.lua b/tests/drivers/battle_scy_deform_bug1895_test.lua index 1cf16c85..ae063077 100644 --- a/tests/drivers/battle_scy_deform_bug1895_test.lua +++ b/tests/drivers/battle_scy_deform_bug1895_test.lua @@ -83,9 +83,9 @@ return function(game) end if dup then worst = "two source rows landed on one scanline" end if bg.lcdc then - local last = math.min(bg.lyEnd, BattleAnimView.SCREEN_H) - 1 - for row = bg.lyStart, last do - if not seen[row] and (bg.lyBackup[row] or 0) ~= 0x90 then + local last = math.min(bg.lyEnd, BattleAnimView.SCREEN_H - 1) + for row = bg.lyStart + 1, last do + if not seen[row] and (bg.lyBackup[row - 1] or 0) ~= 0x90 then worst = "scanline " .. row .. " was left blank" end end diff --git a/tests/drivers/crystal_bug_contest_bug2086.lua b/tests/drivers/crystal_bug_contest_bug2086.lua new file mode 100644 index 00000000..90b31330 --- /dev/null +++ b/tests/drivers/crystal_bug_contest_bug2086.lua @@ -0,0 +1,89 @@ +local U = require("tests.drivers.util") + +local BugContest = require("src.core.gen2.BugContest") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +local function statMon(species, level, hp) + return { + species = species, name = species, level = level, + hp = hp, maxHp = hp, + stats = { hp = hp, attack = 30, defense = 30, speed = 30, + specialAttack = 30, specialDefense = 30 }, + dvs = { attack = 2, defense = 2, speed = 2, special = 2 }, + } +end + +return function(game) + local fails = 0 + local function say(line) print("[2086] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world, save = game.world, game.save + if not (world and world.map and world.vm and save) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + save.party = { statMon("CYNDAQUIL", 10, 30) } + BugContest.start(save) + BugContest.catch(save, statMon("CATERPIE", 9, 20)) + world:warpToMapId("NATIONAL_PARK", 10, 20, "down") + U.wait(45) + ok(world.map and world.map.def and world.map.def.id == "NATIONAL_PARK", + "standing in the park mid-contest") + + U.tap(game, "start") + U.wait(20) + local menu = game.stack and game.stack:top() + ok(menu and menu.items ~= nil, "the START menu opened") + local hasSave, quitIndex = false, nil + for index, item in ipairs((menu and menu.items) or {}) do + if item.value == "quitContest" then quitIndex = index end + if item.value == "save" then hasSave = true end + end + ok(quitIndex ~= nil, "the QUIT row is on the menu") + ok(not hasSave, "and SAVE is not") + ok(menu and menu.list and menu.list.y == 4, "the list sits two rows down") + U.shot(game, SHOT_DIR .. "/crystal_bug2086_startmenu.png") + say("the shot must show CAUGHT CATERPIE / LEVEL 9 / BALLS: 19 top-left") + + local Screens = require("src.ui.Screens") + Screens.push(game, "Gen2ContestMenu", { + save = save, stock = BugContest.caughtMon(save), + caught = statMon("PINSIR", 13, 40), + onClose = function() game.stack:pop() end, + }) + U.wait(10) + U.shot(game, SHOT_DIR .. "/crystal_bug2086_switch.png") + say("the shot must show the bold :L levels and ten-tile STOCK/THIS headers") + U.tap(game, "b") + U.wait(10) + + ok(game.stack and game.stack:top() == menu, + "the START menu is on top again") + if quitIndex and menu and menu.list then menu.list.index = quitIndex end + U.tap(game, "a") + U.wait(5) + ok(menu and menu.phase == "confirmContest", "QUIT asks to end the Contest") + U.shot(game, SHOT_DIR .. "/crystal_bug2086_confirm.png") + U.tap(game, "a") + U.wait(30) + ok(world.vm:running(), "YES starts BugContestResultsWarpScript") + for _ = 1, 600 do + if not world.vm:running() then break end + U.tap(game, "a") + U.wait(4) + end + ok(not world.vm:running(), "the judging script ran to completion") + ok(not BugContest.isActive(save), "and the contest is over") + U.shot(game, SHOT_DIR .. "/crystal_bug2086_judged.png") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_curse_anim_bug2099_test.lua b/tests/drivers/crystal_curse_anim_bug2099_test.lua new file mode 100644 index 00000000..93a7b940 --- /dev/null +++ b/tests/drivers/crystal_curse_anim_bug2099_test.lua @@ -0,0 +1,95 @@ +-- pokecrystal engine/battle/move_effects/curse.asm:36 +-- pokecrystal data/moves/animations.asm:3232 +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-curse-anim-2099" + os.execute('mkdir -p "' .. out .. '" 2>/dev/null') + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[curse-anim] " .. (cond and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "the crystal world did not boot") + + local function fight(leadSpecies, tag) + local lead = Mon.new(game.data, leadSpecies, 42) + assert(lead, "could not build a " .. leadSpecies) + lead.moves = { { id = "CURSE", pp = 10, maxPp = 10 } } + game.save.party = { lead } + game.save.inventory = { POKE_BALL = 5 } + + local wild = Mon.new(game.data, "HITMONTOP", 30) + assert(wild, "could not build a wild HITMONTOP") + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + + assert(world:startBattle({ wild = wild }), "startBattle failed") + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + assert(screen and screen.battle, "battle screen never came up") + local seen + local battle = screen.battle + local emit = battle.emit + battle.emit = function(self, event) + local outEvent = emit(self, event) + if not seen and event and event.kind == "move" + and event.move == "CURSE" then + seen = outEvent + end + return outEvent + end + + for _ = 1, 900 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok(screen.phase == "menu", tag .. ": reached the battle menu") + U.tap(game, "a") + U.wait(4) + U.tap(game, "a") + + for tick = 1, 150 do + if tick % 10 == 0 then + U.shot(game, ("%s/%s-t%03d.png"):format(out, tag, tick)) + end + U.wait(1) + end + battle.emit = emit + return screen, seen + end + + local function endBattle(screen) + for _ = 1, 900 do + if game.stack:top() ~= screen then break end + screen.battle.enemy.hp = 0 + U.tap(game, "a") + U.wait(2) + end + end + + local screen, ev = fight("SNORLAX", "snorlax") + ok(ev ~= nil and ev.move == "CURSE", "SNORLAX queued a CURSE move event") + ok(ev ~= nil and ev.animParam == 1, + "non-ghost CURSE carries animParam 1 (speed-line streaks, not the doll)") + endBattle(screen) + + local screen2, ev2 = fight("GASTLY", "gastly") + ok(ev2 ~= nil and ev2.move == "CURSE", "GASTLY queued a CURSE move event") + ok(ev2 ~= nil and ev2.animParam == nil, + "ghost CURSE keeps animParam nil (the nail-and-doll arm)") + endBattle(screen2) + + print("[curse-anim] " .. (fails == 0 and "PASS all claims" or + (fails .. " claims failed")) .. " -- shots in " .. out) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_curse_dig_bug2100.lua b/tests/drivers/crystal_curse_dig_bug2100.lua new file mode 100644 index 00000000..55f354b4 --- /dev/null +++ b/tests/drivers/crystal_curse_dig_bug2100.lua @@ -0,0 +1,103 @@ +-- pokegold data/moves/effects.asm:1488 +-- pokegold engine/battle/move_effects/curse.asm:58 +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[curse-dig] " .. (cond and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "the crystal world did not boot") + + local function missedText(screen) + for _, event in ipairs(screen.queue or {}) do + if event.kind == "message" and event.text + and event.text:find("attack missed", 1, true) then + return event.text + end + end + return nil + end + + local function fight(leadSpecies, tag) + local lead = Mon.new(game.data, leadSpecies, 42) + assert(lead, "could not build a " .. leadSpecies) + lead.moves = { { id = "CURSE", pp = 10, maxPp = 10 } } + game.save.party = { lead } + game.save.inventory = { POKE_BALL = 5 } + + local wild = Mon.new(game.data, "HITMONTOP", 30) + assert(wild, "could not build a wild HITMONTOP") + wild.moves = { { id = "DIG", pp = 10, maxPp = 10 } } + wild.stats.speed = 1 + + assert(world:startBattle({ wild = wild }), "startBattle failed") + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + assert(screen and screen.battle, "battle screen never came up") + + for _ = 1, 900 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok(screen.phase == "menu", tag .. ": reached the battle menu") + + screen.battle:volatile(screen.battle.enemy).vanished = true + screen.battle:volatile(screen.battle.enemy).chargeMove = "DIG" + + U.tap(game, "a") + U.wait(4) + U.tap(game, "a") + for _ = 1, 60 do + if screen.phase ~= "menu" then break end + U.wait(1) + end + for _ = 1, 400 do + if screen.phase == "menu" or game.stack:top() ~= screen then break end + U.tap(game, "a") + U.wait(2) + end + return screen + end + + local function endBattle(screen) + for _ = 1, 900 do + if game.stack:top() ~= screen then break end + screen.battle.enemy.hp = 0 + U.tap(game, "a") + U.wait(2) + end + end + + local screen = fight("SNORLAX", "snorlax") + local stages = screen.battle.stages.player + ok(stages.speed == -1, "snorlax: Speed fell against a dug-in target") + ok(stages.attack == 1, "snorlax: Attack rose") + ok(stages.defense == 1, "snorlax: Defense rose") + ok(missedText(screen) == nil, + "snorlax: no 'attack missed!' line was printed") + endBattle(screen) + + local screen2 = fight("GASTLY", "gastly") + local enemyVol = screen2.battle:volatile(screen2.battle.enemy) + ok(enemyVol.cursed == nil, + "gastly: the Ghost arm fails against a dug-in target, no curse lands") + ok(missedText(screen2) == nil, + "gastly: the failure is not an 'attack missed!' line") + endBattle(screen2) + + print("[curse-dig] " .. (fails == 0 and "PASS all claims" or + (fails .. " claims failed"))) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_fade_2094.lua b/tests/drivers/crystal_fade_2094.lua new file mode 100644 index 00000000..3929e8c2 --- /dev/null +++ b/tests/drivers/crystal_fade_2094.lua @@ -0,0 +1,132 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-fade2094" + os.execute("mkdir -p '" .. out .. "'") + local shotN = 0 + local function shot(tag) + shotN = shotN + 1 + U.shot(game, string.format("%s/%03d_%s.png", out, shotN, tag)) + end + local fails = 0 + local function ok(cond, msg) + if cond then + print("[fade2094] ok " .. msg) + else + fails = fails + 1 + print("[fade2094] FAIL " .. msg) + end + return cond + end + + U.wait(60) + local world = game.world + if not ok(world and world.map, "booted into the overworld") then + error("fade2094: no world") + end + local save, data = game.save, game.data + + save.party = { Mon.new(data, "CYNDAQUIL", 12) } + + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:warpToMapId("NEW_BARK_TOWN", 13, 7, "up") + for _ = 1, 60 do + if not world:busy() then break end + U.wait(1) + end + U.wait(10) + ok(world.map.id == "NEW_BARK_TOWN", "standing outside the player's house") + shot("town") + + U.hold(game, "up", 24) + local entered = false + for _ = 1, 90 do + U.wait(1) + shot("door_in") + if world.map and world.map.id == "PLAYERS_HOUSE_1F" + and not world:busy() then + entered = true + break + end + end + ok(entered, "the door warp finished (watch the shots for the 4-step wash)") + U.wait(10) + shot("inside") + + U.hold(game, "down", 4) + local exited = false + for _ = 1, 90 do + U.wait(1) + shot("door_out") + if world.map and world.map.id == "NEW_BARK_TOWN" + and not world:busy() then + exited = true + break + end + end + ok(exited, "the way back out finished") + U.wait(10) + shot("outside") + + local wild = Mon.new(data, "SENTRET", 3) + ok(world:startBattle({ wild = wild }), "a wild battle starts") + for _ = 1, 360 do + U.wait(1) + if shotN < 260 then shot("battle_wipe") end + local top = game.stack:top() + if top and top.screenId == "Gen2BattleState" then break end + end + local top = game.stack:top() + ok(top and top.screenId == "Gen2BattleState", "the battle screen is up") + U.wait(240) + shot("battle_menu") + + local popped = false + local function waitShooting(frames) + for _ = 1, frames do + U.wait(1) + local t = game.stack:top() + if not (t and t.screenId) then + if not popped then + popped = world.mapSetup ~= nil or world.fade ~= nil + end + shot("battle_return") + end + end + end + for _ = 1, 12 do + U.tap(game, "a") + waitShooting(20) + top = game.stack:top() + if not (top and top.screenId) then break end + U.tap(game, "down") + U.wait(4) + U.tap(game, "right") + U.wait(4) + U.tap(game, "a") + waitShooting(30) + U.tap(game, "a") + waitShooting(30) + top = game.stack:top() + if not (top and top.screenId) then break end + end + for _ = 1, 240 do + top = game.stack:top() + if not (top and top.screenId) then break end + U.tap(game, "a") + waitShooting(10) + end + ok(popped, "the battle popped into the return fade") + for _ = 1, 20 do + shot("battle_return") + U.wait(1) + end + ok(world.mapSetup == nil and world.fade == nil, + "the return fade ran its eight frames and cleared") + shot("done") + + print(string.format("[fade2094] %s (%d shots in %s)", + fails == 0 and "ALL OK" or (fails .. " FAILURES"), shotN, out)) +end diff --git a/tests/drivers/crystal_move_deleter_2095.lua b/tests/drivers/crystal_move_deleter_2095.lua new file mode 100644 index 00000000..31ed8bb3 --- /dev/null +++ b/tests/drivers/crystal_move_deleter_2095.lua @@ -0,0 +1,54 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +-- pokecrystal maps/MoveDeletersHouse.asm:9-14 + +return function(game) + local shots = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + local fails = 0 + local function say(line) print("[2095] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world, save = game.world, game.save + if not (world and world.map and save) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + world:warpToMapId("MOVE_DELETERS_HOUSE", 2, 4, "up") + U.wait(30) + ok(world.map and world.map.id == "MOVE_DELETERS_HOUSE", + "standing in the MOVE DELETER's house") + + U.tap(game, "a") + U.wait(120) + U.shot(game, shots .. "/2095_page1.png") + U.wait(120) + U.shot(game, shots .. "/2095_page1_still.png") + + U.tap(game, "a") + U.wait(60) + U.shot(game, shots .. "/2095_page2.png") + + U.tap(game, "a") + U.wait(60) + U.shot(game, shots .. "/2095_page3_yesno.png") + + U.tap(game, "down") + U.wait(10) + U.tap(game, "a") + U.wait(60) + U.shot(game, shots .. "/2095_declined.png") + + U.tap(game, "a") + U.wait(30) + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_sleep_hud_bug2082.lua b/tests/drivers/crystal_sleep_hud_bug2082.lua new file mode 100644 index 00000000..686f7a2e --- /dev/null +++ b/tests/drivers/crystal_sleep_hud_bug2082.lua @@ -0,0 +1,90 @@ +-- pokegold engine/battle/effect_commands.asm:175-181 +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-sleep-hud-2082" + os.execute('mkdir -p "' .. out .. '" 2>/dev/null') + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[sleep-hud] " .. (cond and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "the crystal world did not boot") + world:warpToMapId("NEW_BARK_TOWN", 4, 6, "down") + U.wait(15) + + local lead = Mon.new(game.data, "SLOWPOKE", 30) + assert(lead, "could not build a SLOWPOKE") + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + game.save.party = { lead } + + local wild = Mon.new(game.data, "RATTATA", 3) + assert(wild, "could not build a wild RATTATA") + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + + assert(world:startBattle({ wild = wild }), "startBattle failed") + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + assert(screen and screen.battle, "battle screen never came up") + + for _ = 1, 900 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok(screen.phase == "menu", "reached the battle menu") + + local mon = screen.battle.player + mon.status = "sleep" + mon.statusTurns = 1 + screen:syncShownStatus() + U.wait(6) + ok(screen.shownStatus and screen.shownStatus.player == "sleep", + "the HUD picked up the SLP tag before the turn") + U.shot(game, out .. "/asleep_menu.png") + + U.tap(game, "a") + U.wait(4) + U.tap(game, "a") + + local stale, woke = 0, false + for tick = 1, 300 do + local queue = screen.queue or {} + local queuedWake = false + for _, ev in ipairs(queue) do + if ev.text and ev.text:find("woke up!", 1, true) then + queuedWake = true + end + end + local shown = screen.shownStatus and screen.shownStatus.player + if mon.status == nil then + woke = true + if not queuedWake and #queue > 0 and shown == "sleep" then + stale = stale + 1 + end + end + if tick % 5 == 0 then + U.shot(game, ("%s/t%03d.png"):format(out, tick)) + end + if tick % 8 == 0 then U.tap(game, "a") end + U.wait(1) + end + + ok(woke, "the sleeper woke during the turn") + ok(stale == 0, + "the SLP tag never outlived the woke-up line (stale frames: " .. stale .. ")") + ok(mon.status == nil, "the status byte stayed clear") + + print("[sleep-hud] " .. (fails == 0 and "PASS all claims" or + (fails .. " claims failed")) .. " -- shots in " .. out) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gen2_bodyslam_faint_bug2102.lua b/tests/drivers/gen2_bodyslam_faint_bug2102.lua new file mode 100644 index 00000000..c0e3fcb9 --- /dev/null +++ b/tests/drivers/gen2_bodyslam_faint_bug2102.lua @@ -0,0 +1,98 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function giveMoves(mon, game, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function runToMenu(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-bodyslam-2102" + U.wait(45) + local world = game.world + assert(world and world.map, "gen 2 world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + local player = Mon.new(game.data, "SNORLAX", 50) + giveMoves(player, game, { "BODY_SLAM", "HEADBUTT", "SPLASH" }) + game.save.party = { player } + local foe1 = Mon.new(game.data, "HITMONTOP", 42) + giveMoves(foe1, game, { "SPLASH" }) + local foe2 = Mon.new(game.data, "PIDGEOTTO", 40) + giveMoves(foe2, game, { "SPLASH" }) + assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY", + party = { foe1, foe2 } } }), "trainer startBattle failed") + local screen = battleScreen(game) + assert(runToMenu(game, screen, 400), "never reached the menu") + U.shot(game, out .. "/00-lead-out.png") + + local sawAnim, sawLift, mixed, shot = false, false, false, 0 + for _ = 1, 4 do + if screen.battle.enemy ~= foe1 then break end + if not runToMenu(game, screen, 400) then break end + foe1.hp = 1 + screen:submit({ kind = "move", move = "BODY_SLAM" }) + for _ = 1, 900 do + if screen.anim then + sawAnim = true + local pic = screen:animPicState("enemy") + if pic and pic.lifted then + sawLift = true + local shown = screen:activeMon("enemy") + if shown and shown.species ~= foe1.species then mixed = true end + if shot % 4 == 0 then + U.shot(game, ("%s/01-bodyslam-%03d.png"):format(out, shot)) + end + shot = shot + 1 + end + elseif sawAnim and screen.phase == "menu" and #screen.queue == 0 then + break + end + U.tap(game, "a") + U.wait(1) + end + if screen.battle.enemy ~= foe1 then break end + end + check(sawAnim, "Body Slam's animation ran") + check(sawLift, "and it lifted the target's rows") + check(screen.battle.enemy ~= foe1, "the 1-HP target fainted to the hit") + check(not mixed, "the lifted band keeps the fainting mon until the send-out") + U.wait(30) + U.shot(game, out .. "/02-after.png") + + if #failures > 0 then + print(("[driver] FAIL gen2 bodyslam faint 2102 (%d)"):format(#failures)) + else + print("[driver] PASS gen2 bodyslam faint 2102 in " .. out) + end + love.event.quit() +end diff --git a/tests/drivers/gen2_dig_anim_2101.lua b/tests/drivers/gen2_dig_anim_2101.lua new file mode 100644 index 00000000..2eb1bf49 --- /dev/null +++ b/tests/drivers/gen2_dig_anim_2101.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + if screen.phase ~= "menu" then U.tap(game, "a") end + U.wait(3) + end + return false +end + +local function giveMoves(mon, game, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-dig-anim-2101" + U.wait(45) + local world = game.world + assert(world and world.map, "gen 2 world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + local player = Mon.new(game.data, "DUGTRIO", 30) + giveMoves(player, game, { "DIG" }) + game.save.party = { player } + local foe = Mon.new(game.data, "SNORLAX", 40) + giveMoves(foe, game, { "TACKLE" }) + assert(world:startBattle({ wild = foe }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 200) + + screen:submit({ kind = "move", move = "DIG" }) + local sawAnim, animEnded = false, false + for _ = 1, 600 do + if screen.anim then sawAnim = true + elseif sawAnim then animEnded = true break end + U.tap(game, "a") + U.wait(2) + end + check(sawAnim, "the charge animation ran") + check(animEnded, "and finished") + U.shot(game, out .. "/01-charge-end.png") + check(not screen.afterAnimPlayed, "no hit shake rode the charge turn") + local vol = screen.battle:volatile(screen.battle.player) + check(vol.vanished == true, "the user is underground") + local function boxEmpty() + local vol = screen.battle:volatile(screen.battle.player) + return screen:picBoxCleared("player") or (vol.vanished and true or false) + end + local revealed, revealedInAnim, shotTurn2 = false, false, false + for _ = 1, 900 do + if not boxEmpty() then + revealed = true + revealedInAnim = screen.anim ~= nil + break + end + if screen.anim and not shotTurn2 then + shotTurn2 = true + U.shot(game, out .. "/02-between-turns.png") + end + U.tap(game, "a") + U.wait(2) + end + U.shot(game, out .. "/03-turn2-latched.png") + check(revealedInAnim, "the box stays empty into the attack turn") + check(revealed, "ENTER_MON brings the user back") + U.shot(game, out .. "/04-reappear.png") + drain(game, screen, 600) + check(screen.picHidden.player == false + and next(screen.vanishReveal or {}) == nil, + "no latch left behind") + U.shot(game, out .. "/05-done.png") + + if #failures > 0 then + for _, what in ipairs(failures) do print("[FAIL] " .. what) end + error(#failures .. " checks failed") + end + print("[driver] dig anim checks passed") + print("[driver] shots in " .. out) +end diff --git a/tests/drivers/gen2_magnitude_prompt_bug2093.lua b/tests/drivers/gen2_magnitude_prompt_bug2093.lua new file mode 100644 index 00000000..d1b9816d --- /dev/null +++ b/tests/drivers/gen2_magnitude_prompt_bug2093.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +local function giveMoves(mon, game, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-magnitude-prompt" + U.wait(45) + local world = game.world + assert(world and world.map, "gen 2 world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + local player = Mon.new(game.data, "DUGTRIO", 30) + giveMoves(player, game, { "MAGNITUDE" }) + game.save.party = { player } + local foe = Mon.new(game.data, "RATTATA", 10) + giveMoves(foe, game, { "TACKLE" }) + assert(world:startBattle({ wild = foe }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 200) + + screen:submit({ kind = "move", move = "MAGNITUDE" }) + local line + for _ = 1, 900 do + if screen.message and screen.message:match("^Magnitude %d+") then + line = screen.message + break + end + U.wait(1) + end + check(line ~= nil, "the Magnitude number line came up") + + local held = true + for _ = 1, 150 do + if screen.anim then + held = false + break + end + U.wait(1) + end + -- pokegold data/text/battle.asm:1017-1021 + check(held, "the number line holds with no quake frames while idle") + check(screen.message == line, "the box still shows the number line") + U.shot(game, out .. "/01-magnitude-held.png") + + -- pokegold engine/battle/move_effects/magnitude.asm:20-22 + U.tap(game, "a") + local quaked = false + for _ = 1, 120 do + if screen.anim then + quaked = true + break + end + U.wait(1) + end + local options = game.options + if options and options.battleScene == false then quaked = true end + check(quaked, "the quake animation runs only after the press") + U.shot(game, out .. "/02-magnitude-anim.png") + + drain(game, screen, 600) + check((foe.hp or 0) < foe.maxHp, "the rolled power landed damage") + + if #failures > 0 then + for _, what in ipairs(failures) do print("[FAIL] " .. what) end + error(#failures .. " checks failed") + end + print("[driver] magnitude prompt checks passed") + print("[driver] shots in " .. out) +end diff --git a/tests/drivers/gen2_transition_speckle.lua b/tests/drivers/gen2_transition_speckle.lua new file mode 100644 index 00000000..633b6489 --- /dev/null +++ b/tests/drivers/gen2_transition_speckle.lua @@ -0,0 +1,101 @@ +-- ../pokegold/engine/battle/battle_transition.asm:164 +-- ../pokecrystal/engine/battle/start_battle.asm:15-35 +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-speckle" + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[speckle2090] " .. (cond and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "the gen2 world did not boot") + + local lead = Mon.new(game.data, "CYNDAQUIL", 14) + assert(lead, "could not build a CYNDAQUIL") + game.save.party = { lead } + + ok((world.staleEnemyMonLevel or 0) == 0, + "a fresh session has no leftover enemy level") + + local function waitBattleScreen() + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + return nil + end + + local function endBattle(screen) + for _ = 1, 900 do + if game.stack:top() ~= screen then break end + screen.battle.enemy.hp = 0 + U.tap(game, "a") + U.wait(2) + end + for _ = 1, 120 do + if not world.battleActive then break end + U.tap(game, "a") + U.wait(2) + end + end + + local wild = Mon.new(game.data, "PIDGEY", 10) + assert(wild, "could not build a wild PIDGEY") + assert(world:startBattle({ wild = wild }), "the wild battle did not start") + local screen = waitBattleScreen() + assert(screen, "the wild battle screen never came up") + endBattle(screen) + U.wait(30) + + ok(world.staleEnemyMonLevel == 10, + "the wild battle left its level behind: " + .. tostring(world.staleEnemyMonLevel)) + + local trainerMon = Mon.new(game.data, "RATTATA", 4) + assert(trainerMon, "could not build the trainer's RATTATA") + ok(world:startBattle({ trainer = { + class = 1, classId = "YOUNGSTER", name = "YOUNGSTER JOEY", + trainerName = "JOEY", className = "YOUNGSTER", + party = { trainerMon }, + } }), "the trainer battle started") + + local transition + for _ = 1, 900 do + local top = game.stack:top() + if top and top.style and top.phase then + transition = top + break + end + U.wait(1) + end + ok(transition ~= nil, "the transition screen came up") + if transition then + ok(transition.style == "speckle", + "the second battle picked the speckle outro: " + .. tostring(transition.style)) + local shot = 0 + for _ = 1, 1200 do + if game.stack:top() ~= transition then break end + if transition.phase == "outro" then + shot = shot + 1 + U.shot(game, out .. ("/outro-%02d.png"):format(shot)) + if shot >= 4 then break end + U.wait(6) + else + U.wait(1) + end + end + ok(shot > 0, "shot the outro mid-speckle, see " .. out) + end + + print(("[speckle2090] done, %d failures"):format(fails)) + U.wait(30) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_forget_move_sfx.lua b/tests/drivers/gold_forget_move_sfx.lua new file mode 100644 index 00000000..09f839b1 --- /dev/null +++ b/tests/drivers/gold_forget_move_sfx.lua @@ -0,0 +1,84 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local player = Mon.new(game.data, "TOTODILE", 30) + assert(player, "could not build a TOTODILE") + player.moves = { + { id = "SCRATCH", pp = 35, maxPp = 35 }, + { id = "LEER", pp = 30, maxPp = 30 }, + { id = "RAGE", pp = 20, maxPp = 20 }, + { id = "WATER_GUN", pp = 25, maxPp = 25 }, + } + game.save.party = { player } + + print("[driver] overworld learn: listen for the poof at 'Poof!'," + .. " then the fanfare at 'learned'") + local learned = nil + game:learnMoveOn(player, "ICE_PUNCH", function(ok) learned = ok end) + + local list + for _ = 1, 200 do + local top = game.stack:top() + if top and top.forget then list = top break end + U.tap(game, "a") + U.wait(8) + end + assert(list, "the forget-move list never appeared") + U.wait(10) + U.tap(game, "a") + + U.wait(150) + for _ = 1, 200 do + if learned ~= nil then break end + U.tap(game, "a") + U.wait(10) + end + assert(learned == true, "the overworld learn did not finish") + U.wait(30) + + print("[driver] battle learn: same two sounds, in-battle message path") + assert(world:startBattle({ wild = Mon.new(game.data, "RATTATA", 5) }), + "startBattle failed") + local st + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then st = top break end + U.wait(1) + end + assert(st and st.battle, "battle screen is not on the stack") + for _ = 1, 1800 do + if st.phase == "menu" then break end + if (st.messageTimer or 0) > 0 and not st.anim then + U.tap(game, "a") + else + U.wait(1) + end + end + assert(st.phase == "menu", "battle never reached the command menu") + + st.pendingLearn = { index = 1, move = "MEGA_PUNCH", moveName = "MEGA PUNCH" } + st.phase = "choose-forget" + st.forgetIndex = 1 + st.messageTimer = 0 + st.message = "Which move should\nbe forgotten?" + U.wait(10) + U.tap(game, "a") + for _ = 1, 120 do + if (st.messageTimer or 0) > 0 then + U.tap(game, "a") + U.wait(12) + else + U.wait(1) + end + end + + print("[driver] PASS both learn paths shown; verify poof + fanfare by ear") + U.wait(60) + love.event.quit() +end diff --git a/tests/drivers/gold_save_prompt_bug2085.lua b/tests/drivers/gold_save_prompt_bug2085.lua new file mode 100644 index 00000000..f2a2cac5 --- /dev/null +++ b/tests/drivers/gold_save_prompt_bug2085.lua @@ -0,0 +1,56 @@ +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +local U = require("tests.drivers.util") + +local SaveMenu = require("src.ui.gen2.SaveMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-save-prompt" + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local closed = false + local menu = SaveMenu.new(game, { + existed = true, + writer = function() return true end, + onDone = function() + closed = true + game.stack:pop() + end, + }) + game.stack:push(menu) + + U.wait(3) + assert(menu.typer and not menu.typer:done(), + "confirm question should still be typing") + assert(not menu:yesNoVisible(), "YES/NO must stay down while typing") + U.shot(game, out .. "/1-confirm-typing.png") + + for _ = 1, 600 do + if menu.typer and menu.typer:done() then break end + U.wait(1) + end + U.wait(2) + assert(menu:yesNoVisible(), "YES/NO should be up after the question") + U.shot(game, out .. "/2-confirm-done.png") + + U.tap(game, "a") + U.wait(2) + assert(menu.phase == "overwrite", "YES should open the overwrite prompt") + assert(not menu:yesNoVisible(), "overwrite YES/NO must wait for its text") + U.shot(game, out .. "/3-overwrite-typing.png") + + for _ = 1, 600 do + if menu.typer and menu.typer:done() then break end + U.wait(1) + end + U.wait(2) + assert(menu:yesNoVisible(), "overwrite YES/NO should be up after the text") + U.shot(game, out .. "/4-overwrite-done.png") + + U.tap(game, "b") + U.wait(5) + assert(closed, "B should close the save menu without saving") + + print("[driver] PASS gold save prompt shots in " .. out) +end diff --git a/tests/drivers/grass_over_bug2080_test.lua b/tests/drivers/grass_over_bug2080_test.lua new file mode 100644 index 00000000..8d73dea3 --- /dev/null +++ b/tests/drivers/grass_over_bug2080_test.lua @@ -0,0 +1,90 @@ +local U = require("tests.drivers.util") + +-- pokecrystal engine/overworld/map_objects.asm:2924 +-- pokecrystal data/sprites/facings.asm:47-52 +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + + local function say(line) print("[2080] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId("ROUTE_29", 10, 5, "down") + U.wait(20) + ok(world.map and world.map.id == "ROUTE_29", "warped to ROUTE_29") + + local map = world.map + local gx, gy + for cy = 0, (map.height or 0) * 2 - 1 do + for cx = 0, (map.width or 0) * 2 - 1 do + if world:grassAt(cx, cy) and world:grassAt(cx + 1, cy) + and world:grassAt(cx, cy + 1) then + gx, gy = cx, cy + break + end + end + if gx then break end + end + ok(gx ~= nil, "found a tall grass cell on ROUTE_29") + if not gx then + love.event.quit(1) + return + end + say(("grass cell = (%d, %d)"):format(gx, gy)) + + world:warpToMapId("ROUTE_29", gx, gy, "down") + U.wait(20) + local p = world.player + ok(p and p.inGrass == true, "player.inGrass is set standing in the grass") + ok(p and not p.moving, "player is standing still") + + local atlas = world:grassAtlasFor(world.map.def) + ok(atlas ~= nil, "the colour-0 keyed grass atlas exists") + + local records = {} + local origDraw = love.graphics.draw + love.graphics.draw = function(img, quad, x, y, ...) + if img == atlas and type(quad) ~= "number" and quad ~= nil then + local okv, _, _, w, h = pcall(quad.getViewport, quad) + if okv then + records[#records + 1] = { x = x, y = y, w = w, h = h } + end + end + return origDraw(img, quad, x, y, ...) + end + U.wait(3) + love.graphics.draw = origDraw + + ok(#records > 0, "drawGrassOver drew grass over the standing player") + local maxH, ys = 0, {} + for _, r in ipairs(records) do + if r.h > maxH then maxH = r.h end + ys[r.y] = true + end + local distinctYs = 0 + for _ in pairs(ys) do distinctYs = distinctYs + 1 end + ok(maxH == 4, + "strip is tile-unaligned (py+4): every slice is 4 px tall, saw max " + .. tostring(maxH)) + ok(distinctYs >= 2, + "strip crosses both tile rows: " .. distinctYs .. " distinct slice rows") + + U.shot(game, SHOT_DIR .. "/2080-grass-over.png") + say("compare the shot against issue #2080's expected screenshot: grass " + .. "tufts overlap the sprite's lower half, legs show through colour 0") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/issue2084_arrow.lua b/tests/drivers/issue2084_arrow.lua new file mode 100644 index 00000000..8b90d9f8 --- /dev/null +++ b/tests/drivers/issue2084_arrow.lua @@ -0,0 +1,47 @@ +local U = require("tests.drivers.util") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local function say(line) print("[2084] " .. line) end + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen2 world did not boot") + love.event.quit(1) + return + end + + -- pokegold maps/DayCare.asm:71 + world:warpToMapId("DAY_CARE", 3, 3, "left") + U.wait(45) + say("talking to the Day Care man") + U.tap(game, "a") + U.wait(40) + U.shot(game, SHOT_DIR .. "/2084_man_a.png") + U.wait(16) + U.shot(game, SHOT_DIR .. "/2084_man_b.png") + say("expect the arrow at tile 18,17 in exactly one of the two man shots") + for _ = 1, 8 do + U.tap(game, "a") + U.wait(20) + end + + -- pokegold maps/DayCare.asm:72 + world:warpToMapId("DAY_CARE", 4, 3, "right") + U.wait(30) + say("talking to the Day Care lady") + U.tap(game, "a") + U.wait(40) + U.shot(game, SHOT_DIR .. "/2084_lady_a.png") + U.wait(16) + U.shot(game, SHOT_DIR .. "/2084_lady_b.png") + say("expect the arrow at tile 18,17 in exactly one of the two lady shots") + for _ = 1, 8 do + U.tap(game, "a") + U.wait(20) + end + + say("done; shots in " .. SHOT_DIR) + love.event.quit(0) +end diff --git a/tests/engine/gen2_anim_param_feed_sweep.lua b/tests/engine/gen2_anim_param_feed_sweep.lua new file mode 100644 index 00000000..f3da047b --- /dev/null +++ b/tests/engine/gen2_anim_param_feed_sweep.lua @@ -0,0 +1,232 @@ +-- engine/battle/effect_commands.asm:121 (CheckTurn zeroes wBattleAnimParam) +-- engine/battle_anims/anim_commands.asm:550 (BattleAnimCmd_IfParamEqual) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +local TYPES = {} +for i, id in ipairs({ "NORMAL", "GHOST", "GROUND", "FLYING", "FIGHTING", + "GRASS", "DARK", "BUG", "WATER" }) do + TYPES[id] = { id = id, index = i - 1, + category = (id == "GRASS" or id == "WATER" or id == "DARK") + and "special" or "physical" } +end + +local function move(id, effect, power, type, accuracy) + return { id = id, name = id, power = power, type = type, + accuracy = accuracy or 100, pp = 20, effect = effect } +end + +local MOVES = { + TACKLE = move("TACKLE", "EFFECT_NORMAL_HIT", 35, "NORMAL"), + CURSE = move("CURSE", "EFFECT_CURSE", 0, "GHOST"), + DIG = move("DIG", "EFFECT_FLY", 60, "GROUND"), + FLY = move("FLY", "EFFECT_FLY", 70, "FLYING"), + RAZOR_WIND = move("RAZOR_WIND", "EFFECT_RAZOR_WIND", 80, "NORMAL"), + SKULL_BASH = move("SKULL_BASH", "EFFECT_SKULL_BASH", 100, "NORMAL"), + SKY_ATTACK = move("SKY_ATTACK", "EFFECT_SKY_ATTACK", 140, "FLYING"), + SOLARBEAM = move("SOLARBEAM", "EFFECT_SOLARBEAM", 120, "GRASS"), + SELFDESTRUCT = move("SELFDESTRUCT", "EFFECT_SELFDESTRUCT", 200, "NORMAL"), + EXPLOSION = move("EXPLOSION", "EFFECT_SELFDESTRUCT", 250, "NORMAL"), + BIDE = move("BIDE", "EFFECT_BIDE", 0, "NORMAL"), + ROAR = move("ROAR", "EFFECT_FORCE_SWITCH", 0, "NORMAL"), + WHIRLWIND = move("WHIRLWIND", "EFFECT_FORCE_SWITCH", 0, "NORMAL"), + PRESENT = move("PRESENT", "EFFECT_PRESENT", 1, "NORMAL", 90), + HI_JUMP_KICK = move("HI_JUMP_KICK", "EFFECT_JUMP_KICK", 85, "FIGHTING", 90), + JUMP_KICK = move("JUMP_KICK", "EFFECT_JUMP_KICK", 70, "FIGHTING", 95), + COMET_PUNCH = move("COMET_PUNCH", "EFFECT_MULTI_HIT", 18, "NORMAL", 85), + DOUBLESLAP = move("DOUBLESLAP", "EFFECT_MULTI_HIT", 15, "NORMAL", 85), + FURY_SWIPES = move("FURY_SWIPES", "EFFECT_MULTI_HIT", 18, "NORMAL", 80), + DOUBLE_KICK = move("DOUBLE_KICK", "EFFECT_DOUBLE_HIT", 30, "FIGHTING"), + TRIPLE_KICK = move("TRIPLE_KICK", "EFFECT_TRIPLE_KICK", 10, "FIGHTING", 90), + DESTINY_BOND = move("DESTINY_BOND", "EFFECT_DESTINY_BOND", 0, "GHOST"), + BEAT_UP = move("BEAT_UP", "EFFECT_BEAT_UP", 10, "DARK"), + FURY_CUTTER = move("FURY_CUTTER", "EFFECT_FURY_CUTTER", 10, "BUG", 95), + MOONLIGHT = move("MOONLIGHT", "EFFECT_MOONLIGHT", 0, "NORMAL"), + MORNING_SUN = move("MORNING_SUN", "EFFECT_MORNING_SUN", 0, "NORMAL"), + SYNTHESIS = move("SYNTHESIS", "EFFECT_SYNTHESIS", 0, "GRASS"), + OCTAZOOKA = move("OCTAZOOKA", "EFFECT_ACCURACY_DOWN_HIT", 65, "WATER", 85), + PURSUIT = move("PURSUIT", "EFFECT_PURSUIT", 40, "DARK"), + SUBSTITUTE = move("SUBSTITUTE", "EFFECT_SUBSTITUTE", 0, "NORMAL"), +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + SNORLAX = { id = "SNORLAX", index = 143, name = "SNORLAX", + baseStats = { hp = 160, attack = 110, defense = 65, speed = 30, + specialAttack = 65, specialDefense = 110 }, + types = { "NORMAL", "NORMAL" }, catchRate = 25, baseExp = 154, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = {}, evolutions = {} }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, items = {} } + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function lowRoll() return 0 end + +local function newBattle(moveId, random) + local player = Mon.new(DATA, "SNORLAX", 50, { dvs = perfect }) + player.moves = { { id = moveId, pp = 20, maxPp = 20 } } + local wild = Mon.new(DATA, "SNORLAX", 50, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Battle.new({ data = DATA, party = { player }, wild = wild, + random = random or lowRoll }), player, wild +end + +local function moveEvent(events) + for _, e in ipairs(events or {}) do + if e.kind == "move" then return e end + end +end + +local function turn(battle, player, wild, moveId) + battle.events = {} + battle:useMove(player, wild, moveId) + return moveEvent(battle.events) +end + +local function once(moveId, random) + return function() + local battle, player, wild = newBattle(moveId, random) + return turn(battle, player, wild, moveId) + end +end + +local ENFORCED = { + CURSE = { param = 1, cite = "move_effects/curse.asm:39", run = once("CURSE") }, + DIG = { param = 1, cite = "effect_commands.asm:5500", charge = true }, + FLY = { param = 1, cite = "effect_commands.asm:5500", charge = true }, + RAZOR_WIND = { param = 1, cite = "effect_commands.asm:5500", charge = true }, + SKULL_BASH = { param = 1, cite = "effect_commands.asm:5500", charge = true }, + SKY_ATTACK = { param = 1, cite = "effect_commands.asm:5500", charge = true }, + SOLARBEAM = { param = 1, cite = "effect_commands.asm:5500", charge = true }, +} + +local KNOWN_MISSING = { + SELFDESTRUCT = { param = 1, cite = "move_effects/selfdestruct.asm:14", + run = once("SELFDESTRUCT") }, + EXPLOSION = { param = 1, cite = "move_effects/selfdestruct.asm:14", + run = once("EXPLOSION") }, + BIDE = { param = 1, cite = "move_effects/bide.asm:93", run = function() + local battle, player, wild = newBattle("BIDE") + local ev + for _ = 1, 4 do + ev = turn(battle, player, wild, "BIDE") + if not battle:volatile(player).bideTurns then break end + end + return ev + end }, + ROAR = { param = 1, cite = "effect_commands.asm:5160", run = once("ROAR") }, + WHIRLWIND = { param = 1, cite = "effect_commands.asm:5160", + run = once("WHIRLWIND") }, + PRESENT = { param = 3, cite = "move_effects/present.asm:42", + run = once("PRESENT") }, + HI_JUMP_KICK = { param = 1, cite = "effect_commands.asm:2246", + run = once("HI_JUMP_KICK", function(n) return (n or 256) - 1 end) }, + JUMP_KICK = { param = 1, cite = "effect_commands.asm:2246", + run = once("JUMP_KICK", function(n) return (n or 256) - 1 end) }, + COMET_PUNCH = { param = 1, cite = "effect_commands.asm:1994", + run = once("COMET_PUNCH") }, + DOUBLESLAP = { param = 1, cite = "effect_commands.asm:1994", + run = once("DOUBLESLAP") }, + FURY_SWIPES = { param = 1, cite = "effect_commands.asm:1994", + run = once("FURY_SWIPES") }, + DOUBLE_KICK = { param = 1, cite = "effect_commands.asm:1994", + run = once("DOUBLE_KICK") }, + TRIPLE_KICK = { param = 1, cite = "move_effects/triple_kick.asm:27", + run = once("TRIPLE_KICK") }, + DESTINY_BOND = { param = 1, cite = "effect_commands.asm:2394", + run = once("DESTINY_BOND") }, +} + +local DEAD_ARMS = { + BEAT_UP = "effect_commands.asm:121", + FURY_CUTTER = "effect_commands.asm:121", + MOONLIGHT = "effect_commands.asm:121", + MORNING_SUN = "effect_commands.asm:121", + OCTAZOOKA = "effect_commands.asm:121", + PURSUIT = "effect_commands.asm:121", + SYNTHESIS = "effect_commands.asm:121", + SUBSTITUTE = "move_effects/substitute.asm:63", +} + +local function sortedKeys(t) + local keys = {} + for k in pairs(t) do keys[#keys + 1] = k end + table.sort(keys) + return keys +end + +for _, id in ipairs(sortedKeys(ENFORCED)) do + local spec = ENFORCED[id] + if spec.charge then + local battle, player, wild = newBattle(id) + local ev = turn(battle, player, wild, id) + check(ev ~= nil, id .. ": the charge turn queues a move event") + eq(ev and ev.animParam, spec.param, + ("%s: charge turn stamps animParam %d (%s)"):format(id, spec.param, spec.cite)) + local ev2 = turn(battle, player, wild, id) + check(ev2 ~= nil, id .. ": the strike turn queues a move event") + eq(ev2 and ev2.animParam, nil, id .. ": the strike turn leaves animParam nil") + else + local ev = spec.run() + check(ev ~= nil, id .. ": queues a move event") + eq(ev and ev.animParam, spec.param, + ("%s: stamps animParam %d (%s)"):format(id, spec.param, spec.cite)) + end +end + +for _, id in ipairs(sortedKeys(KNOWN_MISSING)) do + local spec = KNOWN_MISSING[id] + check(ENFORCED[id] == nil, id .. ": listed once, not in both sets") + local ev = spec.run() + check(ev ~= nil, id .. ": queues a move event") + check(ev and ev.animParam ~= spec.param, + ("%s: animParam %d (%s) is not stamped yet; move it to ENFORCED"):format( + id, spec.param, spec.cite)) +end + +for id in pairs(DEAD_ARMS) do + check(ENFORCED[id] == nil and KNOWN_MISSING[id] == nil, + id .. ": a dead arm is listed nowhere else") +end + +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local anims = loadfile(cache .. "/data/generated/battle_anims.lua") +local data = anims and anims() +if data and data.scripts and data.moves then + local branching = {} + for id, key in pairs(data.moves) do + for _, row in ipairs(data.scripts[key] or {}) do + if row[1] == "if_param_equal" or row[1] == "if_param_and" then + branching[id] = true + break + end + end + end + for _, id in ipairs(sortedKeys(branching)) do + check(ENFORCED[id] or KNOWN_MISSING[id] or DEAD_ARMS[id], + id .. ": the cart script branches on the param, so it is classified") + end + for _, set in ipairs({ ENFORCED, KNOWN_MISSING, DEAD_ARMS }) do + for _, id in ipairs(sortedKeys(set)) do + check(branching[id], id .. ": the cart script really branches on the param") + end + end +else + check(true, "no Gold cache at " .. cache .. " (cart script cross-check SKIP)") +end + +T.finish("gen2 anim param feed sweep") diff --git a/tests/engine/gen2_bg_effect_registry_audit.lua b/tests/engine/gen2_bg_effect_registry_audit.lua new file mode 100644 index 00000000..643eff69 --- /dev/null +++ b/tests/engine/gen2_bg_effect_registry_audit.lua @@ -0,0 +1,100 @@ +-- engine/battle_anims/bg_effects.asm:1 (BattleBGEffects jumptable) +-- constants/battle_anim_constants.asm (BATTLE_BG_EFFECT_*) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local BgEffects = require("src.battle.gen2.BgEffects") + +local MANIFESTS = { + crystal = "tools/rom_manifest_crystal.json", + gold = "tools/rom_manifest_gold.json", + silver = "tools/rom_manifest_silver.json", +} + +local function readAll(path) + local f = assert(io.open(path, "r"), path) + local src = f:read("*a") + f:close() + return src +end + +local function manifestIds(path) + local src = readAll(path) + local block = src:match('"battleBgEffectOrder"%s*:%s*%[(.-)%]') + check(block ~= nil, path .. " carries battleBgEffectOrder") + local ids, seen = {}, {} + for id in (block or ""):gmatch('"(BATTLE_BG_EFFECT_[A-Z0-9_]+)"') do + if not seen[id] then + seen[id] = true + ids[#ids + 1] = id + end + end + return ids +end + +local E = BgEffects.EFFECTS +local unmodelled = {} +for _, name in ipairs(BgEffects.UNMODELLED) do unmodelled[name] = true end + +local union = {} +for game, path in pairs(MANIFESTS) do + local ids = manifestIds(path) + check(#ids >= 50, ("%s lists %d BG effect ids"):format(game, #ids)) + for _, id in ipairs(ids) do + union[id] = true + check(E[id] ~= nil or unmodelled[id], + ("%s: %s has an E entry or an UNMODELLED cite"):format(game, id)) + end +end + +check(union.BATTLE_BG_EFFECT_BODY_SLAM, "the cart lists BODY_SLAM") +check(type(E.BATTLE_BG_EFFECT_BODY_SLAM) == "function", + "BODY_SLAM is modelled (bg_effects.asm:1444)") + +local source = readAll("src/battle/gen2/BgEffects.lua") +for _, name in ipairs(BgEffects.UNMODELLED) do + check(E[name] == nil, name .. ": UNMODELLED entries carry no E entry") + check(union[name], name .. ": UNMODELLED names a cart id") + local cited = false + for line in source:gmatch("[^\n]+") do + if line:find(name, 1, true) and line:find("%.asm:%d+") then cited = true end + end + check(cited, name .. ": UNMODELLED entry cites its bg_effects.asm routine") +end + +for id in pairs(E) do + check(union[id], id .. ": every E entry names a cart id") +end + +eq(#BgEffects.DROPPED, 0, "nothing dropped before any pool ran") +local wasStrict = BgEffects.strict +BgEffects.strict = false +local pool = BgEffects.new({ battleBgEffectOrder = { "BATTLE_BG_EFFECT_NOT_A_THING" } }, + { battleTurn = 0 }) +local st = pool:queue(0, 0, 0, 0) +check(st ~= nil and st.func == "BATTLE_BG_EFFECT_NOT_A_THING", "an unknown id still queues") +pool:playFrame() +check(st and st.func == nil, "and is ended on the first frame") +eq(#BgEffects.DROPPED, 1, "but the drop is recorded") +eq(BgEffects.DROPPED[1], "BATTLE_BG_EFFECT_NOT_A_THING", "by name") +pool:queue(0, 0, 0, 0) +pool:playFrame() +eq(#BgEffects.DROPPED, 1, "and only once per id") + +BgEffects.strict = true +pool:queue("BATTLE_BG_EFFECT_ALSO_NOT_A_THING", 0, 0, 0) +local ok, err = pcall(pool.playFrame, pool) +check(not ok and tostring(err):find("ALSO_NOT_A_THING", 1, true) ~= nil, + "strict mode raises on the unknown id") +BgEffects.strict = wasStrict + +local known = BgEffects.new({ battleBgEffectOrder = { "BATTLE_BG_EFFECT_BODY_SLAM" } }, + { battleTurn = 0 }) +known:queue(0, 0, 0, 0) +local before = #BgEffects.DROPPED +known:playFrame() +eq(#BgEffects.DROPPED, before, "a modelled id is never recorded") + +T.finish("gen2 bg effect registry audit") diff --git a/tests/engine/gen2_body_slam_bounce_bug2102.lua b/tests/engine/gen2_body_slam_bounce_bug2102.lua new file mode 100644 index 00000000..a09e6673 --- /dev/null +++ b/tests/engine/gen2_body_slam_bounce_bug2102.lua @@ -0,0 +1,44 @@ +-- engine/battle_anims/bg_effects.asm:1444 (#2102) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BgEffects = require("src.battle.gen2.BgEffects") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +T.check(BgEffects.EFFECTS.BATTLE_BG_EFFECT_BODY_SLAM ~= nil, + "BODY_SLAM has a BG-effect body, not the silent unknown-id fallback") + +-- data/moves/animations.asm:2096 BattleAnim_BodySlam +local slam = BgEffects.new({}, { battleTurn = 0 }) +slam:queue("BATTLE_BG_EFFECT_BODY_SLAM", 0, 1, 0) +slam:playFrame() +T.eq(slam.lcdc, "SCX", "the lunge shoves the user sideways") +T.eq(slam.lyStart, 0x2d, "SetLCDStatCustoms2 opens the player band at $2d") +T.eq(slam.lyEnd, 0x5f, "and one past $5e") +slam:playFrame() +slam:playFrame() +T.eq(slam.lyBackup[0x30], 0xfe, "the player's rows step two pixels right") +T.check(slam:activeCount() >= 1, "and the struct stays alive for the return") + +-- engine/battle_anims/bg_effects.asm:1765 BattleBGEffect_BounceDown +local bounce = BgEffects.new({}, { battleTurn = 0 }) +bounce:queue("BATTLE_BG_EFFECT_BOUNCE_DOWN", 0, 1, 0) +bounce:playFrame() +T.eq(bounce.lcdc, "SCY", "BounceDown displaces the user vertically") +T.eq(bounce.lyStart, 0x2d, "through SetLCDStatCustoms2's $2d player band") +T.eq(bounce.lyEnd, 0x5f, "ending one past $5e") + +-- home/lcd.asm:12 +local strays = 0 +for _ = 1, 16 do + bounce:playFrame() + for _, line in ipairs(BattleAnimView.scanlines(bounce)) do + if line.dest == 0x5f and line.src == 0x5f then strays = strays + 1 end + end +end +T.eq(strays, 0, "no frame leaves the pic's bottom scanline behind") + +T.finish("gen2 body slam bounce bug 2102") diff --git a/tests/engine/gen2_curse_anim_param_bug2099.lua b/tests/engine/gen2_curse_anim_param_bug2099.lua new file mode 100644 index 00000000..c099150e --- /dev/null +++ b/tests/engine/gen2_curse_anim_param_bug2099.lua @@ -0,0 +1,102 @@ +-- engine/battle/move_effects/curse.asm:36 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") +local Effects = require("src.battle.gen2.Effects") + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GHOST = { id = "GHOST", index = 1, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + CURSE = { id = "CURSE", name = "CURSE", power = 0, type = "GHOST", + accuracy = 100, pp = 10, effect = "EFFECT_CURSE" }, +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + SNORLAX = { id = "SNORLAX", index = 143, name = "SNORLAX", + baseStats = { hp = 160, attack = 110, defense = 65, speed = 30, + specialAttack = 65, specialDefense = 110 }, + types = { "NORMAL", "NORMAL" }, catchRate = 25, baseExp = 154, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = {}, evolutions = {} }, + GASTLY = { id = "GASTLY", index = 92, name = "GASTLY", + baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35 }, + types = { "GHOST", "GHOST" }, catchRate = 190, baseExp = 95, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = {}, evolutions = {} }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, items = {} } + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function highRoll(n) return (n or 1) - 1 end + +local function newBattle(species) + local player = Mon.new(DATA, species, 50, { dvs = perfect }) + player.moves = { { id = "CURSE", pp = 10, maxPp = 10 } } + local wild = Mon.new(DATA, "SNORLAX", 50, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Battle.new({ data = DATA, party = { player }, wild = wild, + random = highRoll }), player, wild +end + +local function moveEvent(events) + for _, e in ipairs(events or {}) do + if e.kind == "move" then return e end + end +end + +do + local battle, player, wild = newBattle("SNORLAX") + battle.events = {} + battle:useMove(player, wild, "CURSE") + local ev = moveEvent(battle.events) + T.check(ev ~= nil, "the non-ghost CURSE turn queues a move event") + T.eq(ev and ev.animParam, 1, + "non-ghost CURSE carries animParam 1, the stat-streak script arm") + local stages = battle.stages[battle:sideOf(player)] + T.eq(stages.attack, 1, "and ATTACK still rises") + T.eq(stages.defense, 1, "and DEFENSE still rises") + T.eq(stages.speed, -1, "and SPEED still drops") +end + +do + local battle, player, wild = newBattle("SNORLAX") + local stages = battle.stages[battle:sideOf(player)] + stages.attack = Effects.MAX_STAGE + stages.defense = Effects.MAX_STAGE + battle.events = {} + battle:useMove(player, wild, "CURSE") + local ev = moveEvent(battle.events) + T.check(ev ~= nil, "the capped turn still queues a move event") + T.eq(ev and ev.animParam, nil, + "the cantraise arm leaves animParam nil") +end + +do + local battle, player, wild = newBattle("GASTLY") + battle.events = {} + battle:useMove(player, wild, "CURSE") + local ev = moveEvent(battle.events) + T.check(ev ~= nil, "the ghost CURSE turn queues a move event") + T.eq(ev and ev.animParam, nil, + "ghost CURSE leaves animParam nil, the nail-and-doll script arm") + T.check(battle:volatile(wild).cursed == true, "and the target is cursed") +end + +T.finish("gen2 curse anim param bug 2099") diff --git a/tests/engine/gen2_dig_hide_mon_bug2101.lua b/tests/engine/gen2_dig_hide_mon_bug2101.lua new file mode 100644 index 00000000..a364631d --- /dev/null +++ b/tests/engine/gen2_dig_hide_mon_bug2101.lua @@ -0,0 +1,30 @@ +-- engine/battle_anims/bg_effects.asm:343 (#2101) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local BgEffects = require("src.battle.gen2.BgEffects") + +do + local bg = BgEffects.new({}, { battleTurn = 0 }) + local st = bg:queue("BATTLE_BG_EFFECT_HIDE_MON", 0, 0, 0) + local side = bg:sideKey(st) + bg:playFrame() + T.eq(bg.hidden[side], true, "HideMon clears the battler's box") + for _ = 1, 6 do bg:playFrame() end + T.eq(bg:activeCount(), 0, "the effect ends after its five steps") + T.eq(bg.hidden[side], true, "and the box stays cleared when it does") +end + +do + local bg = BgEffects.new({}, { battleTurn = 0 }) + local st = bg:queue("BATTLE_BG_EFFECT_HIDE_MON", 0, 0, 0) + local side = bg:sideKey(st) + for _ = 1, 6 do bg:playFrame() end + T.eq(bg.hidden[side], true, "hidden going into the redraw") + bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0) + bg:playFrame() + T.eq(bg.hidden[side], false, "ShowMon is what redraws the box") +end + +T.finish("gen2 dig hide mon bug 2101") diff --git a/tests/engine/gen2_lcd_row_plus_one_bug2102.lua b/tests/engine/gen2_lcd_row_plus_one_bug2102.lua new file mode 100644 index 00000000..7ad01d44 --- /dev/null +++ b/tests/engine/gen2_lcd_row_plus_one_bug2102.lua @@ -0,0 +1,142 @@ +-- home/lcd.asm:12 (#2102) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local BgEffects = require("src.battle.gen2.BgEffects") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +local GbcPalette = require("src.render.GbcPalette") + +local function byRow(lines) + local rows = {} + for _, line in ipairs(lines) do rows[line.dest] = line end + return rows +end + +local function displacement(bg) + local total = 0 + for row = bg.lyStart, bg.lyEnd do + local byte = bg.lyBackup[row] or 0 + if byte ~= 0 then total = total + 1 end + end + return total +end + +local function runToPeak(bg, frames) + local peak, peakRows = -1, nil + for _ = 1, frames do + bg:playFrame() + local d = displacement(bg) + if d > peak then peak, peakRows = d, byRow(BattleAnimView.scanlines(bg)) end + end + return peakRows, peak +end + +local function assertBand(label, rows, lyStart, lyEnd, register) + local top = rows[lyStart] + check(top ~= nil and top.src == lyStart and top.dx == 0, + label .. (": drawn row 0x%02x (LY = lyStart) is untouched"):format(lyStart)) + local identity = 0 + for row = lyStart + 1, lyEnd do + local line = rows[row] + if line and line.src == row and line.dx == 0 then identity = identity + 1 end + end + eq(identity, 0, label .. ": no identity row inside the pic band") + local bottom = rows[lyEnd] + if register == "SCX" then + check(bottom ~= nil and bottom.dx ~= 0, + label .. (": drawn row 0x%02x (LY = lyEnd) is shoved sideways"):format(lyEnd)) + else + check(bottom == nil or bottom.src ~= lyEnd, + label .. (": drawn row 0x%02x (LY = lyEnd) is moved or blanked"):format(lyEnd)) + end + local below = rows[lyEnd + 1] + check(below ~= nil and below.src == lyEnd + 1 and below.dx == 0, + label .. (": drawn row 0x%02x below the band is untouched"):format(lyEnd + 1)) +end + +-- engine/battle_anims/bg_effects.asm:1765 BattleBGEffect_BounceDown +do + local bg = BgEffects.new({}, { battleTurn = 0 }) + bg:queue("BATTLE_BG_EFFECT_BOUNCE_DOWN", 0, 1, 0) + bg:playFrame() + eq(bg.lyStart, 0x2d, "player BounceDown opens the band at $2d") + eq(bg.lyEnd, 0x5f, "one past $5e") + local rows, peak = runToPeak(bg, 20) + check(peak >= 0x30, ("the peak displaces %d rows"):format(peak)) + assertBand("player BounceDown", rows, 0x2d, 0x5f, "SCY") +end + +do + local bg = BgEffects.new({}, { battleTurn = 0 }) + bg:queue("BATTLE_BG_EFFECT_BOUNCE_DOWN", 0, 0, 0) + bg:playFrame() + eq(bg.lyStart, 0x00, "enemy BounceDown opens the band at $00") + eq(bg.lyEnd, 0x37, "one past $36") + local rows, peak = runToPeak(bg, 20) + check(peak >= 0x30, ("the peak displaces %d rows"):format(peak)) + local identity = 0 + for row = 1, 0x37 do + local line = rows[row] + if line and line.src == row and line.dx == 0 then identity = identity + 1 end + end + eq(identity, 0, "enemy BounceDown: no identity row inside the pic band") + check(rows[0x37] == nil or rows[0x37].src ~= 0x37, + "enemy BounceDown: drawn row 0x37 (LY = lyEnd) is moved or blanked") + check(rows[0x38] ~= nil and rows[0x38].src == 0x38, + "enemy BounceDown: drawn row 0x38 below the band is untouched") +end + +-- engine/battle_anims/bg_effects.asm:1444 BattleBGEffect_BodySlam +do + local bg = BgEffects.new({}, { battleTurn = 0 }) + bg:queue("BATTLE_BG_EFFECT_BODY_SLAM", 0, 1, 0) + bg:playFrame() + eq(bg.lcdc, "SCX", "player BodySlam shoves sideways") + local rows = runToPeak(bg, 6) + assertBand("player BodySlam", rows, 0x2d, 0x5f, "SCX") +end + +do + local bg = BgEffects.new({}, { battleTurn = 0 }) + bg:queue("BATTLE_BG_EFFECT_BODY_SLAM", 0, 0, 0) + bg:playFrame() + eq(bg.lyEnd, 0x37, "enemy BodySlam ends one past $36") + local rows = runToPeak(bg, 6) + check(rows[0] ~= nil and rows[0].dx == 0, "enemy BodySlam: drawn row 0 is untouched") + local identity = 0 + for row = 1, 0x37 do + local line = rows[row] + if line and line.dx == 0 then identity = identity + 1 end + end + eq(identity, 0, "enemy BodySlam: no identity row inside the pic band") + check(rows[0x37] ~= nil and rows[0x37].dx ~= 0, + "enemy BodySlam: drawn row 0x37 (LY = lyEnd) is shoved") + check(rows[0x38] ~= nil and rows[0x38].dx == 0, + "enemy BodySlam: drawn row 0x38 is untouched") +end + +-- engine/battle_anims/bg_effects.asm:2585 (rBGP band) +do + local base = GbcPalette.BGP_IDENTITY + local bg = { bgp = base, lcdc = "BGP", lyStart = 0x2d, lyEnd = 0x5f, lyBackup = {} } + for row = 0x2d, 0x5e do bg.lyBackup[row] = 0xff end + local rowByte = {} + for _, band in ipairs(BattleAnimView.bgpBands(bg)) do + for _, row in ipairs(band.rows) do rowByte[row] = band.byte end + end + eq(rowByte[0x2d], base, "BGP: drawn row 0x2d (LY = lyStart) keeps wBGP") + eq(rowByte[0x2e], 0xff, "BGP: drawn row 0x2e takes the byte written at LY 0x2d") + eq(rowByte[0x5f], 0xff, "BGP: drawn row 0x5f takes the byte written at LY 0x5e") + eq(rowByte[0x60], base, "BGP: drawn row 0x60 is back on wBGP") + local off = 0 + for row = 0x2e, 0x5f do + if rowByte[row] == base then off = off + 1 end + end + eq(off, 0, "BGP: no drawn row inside the band keeps the base palette") +end + +T.finish("gen2 lcd row plus one bug 2102") diff --git a/tests/engine/gen2_night_shade_top_row_bug1921.lua b/tests/engine/gen2_night_shade_top_row_bug1921.lua index bc194752..ca2cc3c8 100644 --- a/tests/engine/gen2_night_shade_top_row_bug1921.lua +++ b/tests/engine/gen2_night_shade_top_row_bug1921.lua @@ -32,7 +32,7 @@ for _ = 1, 16 do if seen[line.dest] then duplicates = duplicates + 1 end seen[line.dest] = true end - for row = bg.lyStart, bg.lyEnd - 1 do + for row = bg.lyStart + 1, bg.lyEnd do if not seen[row] then gaps = gaps + 1 end end end @@ -55,14 +55,14 @@ end do local lines = byRow(BattleAnimView.scanlines(panel({ - lyBackup = { [0] = 0xff, [1] = 0xfe } }))) - T.eq(lines[0] and lines[0].src, 0, "an rSCY of -1 on scanline 0 holds row 0") - T.eq(lines[1] and lines[1].src, 0, "and -2 on scanline 1 holds it too") + lyBackup = { [0] = 0xfe, [1] = 0xfd } }))) + T.eq(lines[1] and lines[1].src, 0, "an rSCY of -2 stored on LY 0 holds row 0") + T.eq(lines[2] and lines[2].src, 0, "and -3 stored on LY 1 holds it too") end do local lines = byRow(BattleAnimView.scanlines(panel({ - lyEnd = SCREEN_H, lyBackup = { [SCREEN_H - 1] = 4 } }))) + lyEnd = SCREEN_H, lyBackup = { [SCREEN_H - 2] = 4 } }))) T.eq(lines[SCREEN_H - 1] and lines[SCREEN_H - 1].src, SCREEN_H - 1, "a wobble past the bottom of the panel holds the last row") end @@ -70,7 +70,7 @@ end do local lines = byRow(BattleAnimView.scanlines(panel({ lyBackup = { [0] = 0x90 } }))) - T.eq(lines[0], nil, "the $90 displacement still sinks its row into blank") + T.eq(lines[1], nil, "the $90 displacement still sinks its row into blank") end do diff --git a/tests/engine/gen2_no_checkhit_chain_audit.lua b/tests/engine/gen2_no_checkhit_chain_audit.lua new file mode 100644 index 00000000..b4be8b49 --- /dev/null +++ b/tests/engine/gen2_no_checkhit_chain_audit.lua @@ -0,0 +1,202 @@ +-- data/moves/effects.asm (tools/audit_effect_chains.py) +-- engine/battle/effect_commands.asm:1713 (CheckHit .FlyDigMoves) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local Effects = require("src.battle.gen2.Effects") + +local CART = { + EFFECT_MIRROR_MOVE = true, -- data/moves/effects.asm:180 + EFFECT_ATTACK_UP = true, -- data/moves/effects.asm:187 + EFFECT_DEFENSE_UP = true, -- data/moves/effects.asm:199 + EFFECT_SPEED_UP = true, -- data/moves/effects.asm:211 + EFFECT_SP_ATK_UP = true, -- data/moves/effects.asm:223 + EFFECT_SP_DEF_UP = true, -- data/moves/effects.asm:235 + EFFECT_ACCURACY_UP = true, -- data/moves/effects.asm:247 + EFFECT_EVASION_UP = true, -- data/moves/effects.asm:259 + EFFECT_RESET_STATS = true, -- data/moves/effects.asm:788 + EFFECT_CONVERSION = true, -- data/moves/effects.asm:781 + EFFECT_HEAL = true, -- data/moves/effects.asm:997 + EFFECT_TOXIC = true, -- data/moves/effects.asm:1039 + EFFECT_LIGHT_SCREEN = true, -- data/moves/effects.asm:1011 + EFFECT_OHKO = true, -- data/moves/effects.asm:917 + EFFECT_SUPER_FANG = true, -- data/moves/effects.asm:1237 + EFFECT_MIST = true, -- data/moves/effects.asm:953 + EFFECT_FOCUS_ENERGY = true, -- data/moves/effects.asm:960 + EFFECT_ATTACK_UP_2 = true, -- data/moves/effects.asm:272 + EFFECT_DEFENSE_UP_2 = true, -- data/moves/effects.asm:284 + EFFECT_SPEED_UP_2 = true, -- data/moves/effects.asm:296 + EFFECT_SP_ATK_UP_2 = true, -- data/moves/effects.asm:308 + EFFECT_SP_DEF_UP_2 = true, -- data/moves/effects.asm:320 + EFFECT_ACCURACY_UP_2 = true, -- data/moves/effects.asm:332 + EFFECT_EVASION_UP_2 = true, -- data/moves/effects.asm:344 + EFFECT_TRANSFORM = true, -- data/moves/effects.asm:1004 + EFFECT_REFLECT = true, -- data/moves/effects.asm:1012 + EFFECT_SUBSTITUTE = true, -- data/moves/effects.asm:1084 + EFFECT_METRONOME = true, -- data/moves/effects.asm:1141 + EFFECT_SPLASH = true, -- data/moves/effects.asm:1156 + EFFECT_PSYWAVE = true, -- data/moves/effects.asm:1238 + EFFECT_COUNTER = true, -- data/moves/effects.asm:1270 + EFFECT_SKETCH = true, -- data/moves/effects.asm:1338 + EFFECT_DEFROST_OPPONENT = true, -- data/moves/effects.asm:1345 + EFFECT_SLEEP_TALK = true, -- data/moves/effects.asm:1352 + EFFECT_DESTINY_BOND = true, -- data/moves/effects.asm:1359 + EFFECT_HEAL_BELL = true, -- data/moves/effects.asm:1395 + EFFECT_MEAN_LOOK = true, -- data/moves/effects.asm:1452 + EFFECT_NIGHTMARE = true, -- data/moves/effects.asm:1459 + EFFECT_CURSE = true, -- data/moves/effects.asm:1488 + EFFECT_PROTECT = true, -- data/moves/effects.asm:1495 + EFFECT_SPIKES = true, -- data/moves/effects.asm:1502 + EFFECT_PERISH_SONG = true, -- data/moves/effects.asm:1517 + EFFECT_SANDSTORM = true, -- data/moves/effects.asm:1524 + EFFECT_ENDURE = true, -- data/moves/effects.asm:1531 + EFFECT_SAFEGUARD = true, -- data/moves/effects.asm:1670 + EFFECT_BATON_PASS = true, -- data/moves/effects.asm:1721 + EFFECT_MORNING_SUN = true, -- data/moves/effects.asm:1770 + EFFECT_SYNTHESIS = true, -- data/moves/effects.asm:1777 + EFFECT_MOONLIGHT = true, -- data/moves/effects.asm:1784 + EFFECT_RAIN_DANCE = true, -- data/moves/effects.asm:1811 + EFFECT_SUNNY_DAY = true, -- data/moves/effects.asm:1818 + EFFECT_BELLY_DRUM = true, -- data/moves/effects.asm:1835 + EFFECT_PSYCH_UP = true, -- data/moves/effects.asm:1842 + EFFECT_MIRROR_COAT = true, -- data/moves/effects.asm:1849 + EFFECT_TELEPORT = true, -- data/moves/effects.asm:2034 + EFFECT_DEFENSE_CURL = true, -- data/moves/effects.asm:2068 +} + +local DEFERRED = { + EFFECT_MIRROR_MOVE = true, + EFFECT_SPEED_UP = true, + EFFECT_SP_DEF_UP = true, + EFFECT_ACCURACY_UP = true, + EFFECT_RESET_STATS = true, + EFFECT_CONVERSION = true, + EFFECT_HEAL = true, + EFFECT_TOXIC = true, + EFFECT_LIGHT_SCREEN = true, + EFFECT_OHKO = true, + EFFECT_SUPER_FANG = true, + EFFECT_MIST = true, + EFFECT_FOCUS_ENERGY = true, + EFFECT_SP_ATK_UP_2 = true, + EFFECT_ACCURACY_UP_2 = true, + EFFECT_EVASION_UP_2 = true, + EFFECT_TRANSFORM = true, + EFFECT_REFLECT = true, + EFFECT_SUBSTITUTE = true, + EFFECT_METRONOME = true, + EFFECT_SPLASH = true, + EFFECT_PSYWAVE = true, + EFFECT_COUNTER = true, + EFFECT_SKETCH = true, + EFFECT_DEFROST_OPPONENT = true, + EFFECT_SLEEP_TALK = true, + EFFECT_DESTINY_BOND = true, + EFFECT_HEAL_BELL = true, + EFFECT_MEAN_LOOK = true, + EFFECT_NIGHTMARE = true, + EFFECT_PROTECT = true, + EFFECT_SPIKES = true, + EFFECT_PERISH_SONG = true, + EFFECT_SANDSTORM = true, + EFFECT_ENDURE = true, + EFFECT_SAFEGUARD = true, + EFFECT_BATON_PASS = true, + EFFECT_MORNING_SUN = true, + EFFECT_SYNTHESIS = true, + EFFECT_MOONLIGHT = true, + EFFECT_RAIN_DANCE = true, + EFFECT_SUNNY_DAY = true, + EFFECT_BELLY_DRUM = true, + EFFECT_PSYCH_UP = true, + EFFECT_MIRROR_COAT = true, + EFFECT_TELEPORT = true, +} + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +eq(count(CART), 56, "the cart has 56 effect chains with no checkhit") + +for effect in pairs(Effects.NO_CHECKHIT) do + check(CART[effect], effect .. ": NO_CHECKHIT names a chain the cart runs without checkhit") + check(not DEFERRED[effect], effect .. ": enforced and deferred are disjoint") +end + +for effect in pairs(CART) do + check((Effects.NO_CHECKHIT[effect] and true or false) ~= (DEFERRED[effect] and true or false), + effect .. ": every cart no-checkhit chain is either enforced or deferred") +end + +for effect in pairs(DEFERRED) do + check(CART[effect], effect .. ": DEFERRED only names cart chains") +end + +for _, effect in ipairs({ "EFFECT_NORMAL_HIT", "EFFECT_SLEEP", "EFFECT_ATTACK_DOWN", + "EFFECT_FORCE_SWITCH", "EFFECT_FLY", "EFFECT_LEECH_SEED", "EFFECT_ENCORE" }) do + check(not Effects.NO_CHECKHIT[effect] and not CART[effect], + effect .. ": a chain with checkhit is never exempted") +end + +local function chainsWithoutCheckhit(pret) + local f = io.open(pret .. "/data/moves/effects.asm", "r") + if not f then return nil end + local src = f:read("*a") + f:close() + local consts = {} + local cf = io.open(pret .. "/constants/move_effect_constants.asm", "r") + if not cf then return nil end + for line in cf:lines() do + local name = line:match("^%s*const%s+(EFFECT_[A-Z0-9_]+)") + if name then consts[#consts + 1] = name end + end + cf:close() + local labels = {} + local pf = io.open(pret .. "/data/moves/effects_pointers.asm", "r") + if not pf then return nil end + for line in pf:lines() do + local label = line:match("^%s*dw%s+([%a_][%w_]*)") + if label then labels[#labels + 1] = label end + end + pf:close() + if #consts ~= #labels then return nil end + local chains, current = {}, nil + for raw in (src .. "\n"):gmatch("([^\n]*)\n") do + local line = raw:gsub(";.*$", "") + local label = line:match("^([%a_][%w_]*):") + if label then + current = label + chains[current] = chains[current] or {} + else + local cmd = line:match("^%s+([a-z][%w_]*)") + if cmd and current then chains[current][cmd] = true end + end + end + local out = {} + for i, effect in ipairs(consts) do + local chain = chains[labels[i]] + if chain and not chain.checkhit then out[effect] = true end + end + return out +end + +for _, pret in ipairs({ "../pokecrystal", "../pokegold" }) do + local derived = chainsWithoutCheckhit(pret) + if derived then + for effect in pairs(derived) do + check(CART[effect], ("%s: %s is in the pinned cart list"):format(pret, effect)) + end + for effect in pairs(CART) do + check(derived[effect], ("%s: %s still has no checkhit"):format(pret, effect)) + end + else + check(true, "no pret checkout at " .. pret .. " (re-derivation SKIP)") + end +end + +T.finish("gen2 no-checkhit chain audit") diff --git a/tests/engine/gen2_save_menu_translation_test.lua b/tests/engine/gen2_save_menu_translation_test.lua index 91dad6af..6f6f3fc0 100644 --- a/tests/engine/gen2_save_menu_translation_test.lua +++ b/tests/engine/gen2_save_menu_translation_test.lua @@ -36,6 +36,13 @@ local function drawnAt(x, y) return nil end +local function typeOut(menu) + for _ = 1, 600 do + menu:update(0) + if menu.typer and menu.typer:done() then return end + end +end + -- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua). -- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at -- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3). @@ -50,6 +57,7 @@ local SAVE = { player = { name = "GOLD" } } -- ---------------------------------------------- vanilla: no mod catalog do local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + typeOut(menu) drawn = {} menu:drawPanel() T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD", @@ -103,6 +111,7 @@ do }) local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + typeOut(menu) drawn = {} menu:drawPanel() T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD", diff --git a/tests/engine/gen2_scy_deform_blit_bug1895.lua b/tests/engine/gen2_scy_deform_blit_bug1895.lua index 4164486e..fe8e7818 100644 --- a/tests/engine/gen2_scy_deform_blit_bug1895.lua +++ b/tests/engine/gen2_scy_deform_blit_bug1895.lua @@ -24,16 +24,16 @@ end do local lines = byRow(BattleAnimView.scanlines( bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 2 } }))) - T.eq(lines[10] and lines[10].src, 12, "rSCY 2 on LY 10 samples BG row 12") - T.eq(lines[10] and lines[10].dest, 10, "and still draws on scanline 10") - T.eq(lines[10] and lines[10].dx, 0, "rSCY never moves the row sideways") + T.eq(lines[11] and lines[11].src, 13, "rSCY 2 stored on LY 10 lands on 11") + T.eq(lines[11] and lines[11].dest, 11, "and draws on scanline 11") + T.eq(lines[11] and lines[11].dx, 0, "rSCY never moves the row sideways") end do local lines = byRow(BattleAnimView.scanlines( bg({ lcdc = "SCX", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 2 } }))) - T.eq(lines[10] and lines[10].src, 10, "rSCX leaves the sampled row alone") - T.eq(lines[10] and lines[10].dx, -2, "and shifts the destination x instead") + T.eq(lines[11] and lines[11].src, 11, "rSCX leaves the sampled row alone") + T.eq(lines[11] and lines[11].dx, -2, "and shifts the destination x instead") end do @@ -52,7 +52,7 @@ do "scanline " .. line.dest .. " samples inside the panel") end local gaps = 0 - for row = 0, 0x35 do + for row = 1, 0x36 do if not seen[row] then gaps = gaps + 1 end end T.eq(gaps, 0, "a per-scanline rSCY sine leaves no blank rows in the window") @@ -62,7 +62,7 @@ do local lines = byRow(BattleAnimView.scanlines( bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 0x90, [120] = 0x90 } }))) - T.eq(lines[10], nil, "a $90 row inside the window is skipped") + T.eq(lines[11], nil, "a $90 stored on LY 10 skips scanline 11") T.check(lines[120] ~= nil, "a $90 row outside the window still draws") end @@ -70,17 +70,17 @@ end do local lines = byRow(BattleAnimView.scanlines( bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, - lyBackup = { [0] = 0xff, [1] = 0xfe } }))) - T.eq(lines[0] and lines[0].src, 0, + lyBackup = { [0] = 0xfe, [1] = 0xfd } }))) + T.eq(lines[1] and lines[1].src, 0, "a window row that wobbles above the panel clamps to row 0") - T.eq(lines[1] and lines[1].src, 0, "two rows above the panel clamp as well") - T.eq(lines[1] and lines[1].dest, 1, "and still land on their own scanline") + T.eq(lines[2] and lines[2].src, 0, "two rows above the panel clamp as well") + T.eq(lines[2] and lines[2].dest, 2, "and still land on their own scanline") end do local lines = byRow(BattleAnimView.scanlines( bg({ lcdc = "SCY", lyStart = 0x80, lyEnd = SCREEN_H, - lyBackup = { [SCREEN_H - 1] = 2 } }))) + lyBackup = { [SCREEN_H - 2] = 2 } }))) T.eq(lines[SCREEN_H - 1] and lines[SCREEN_H - 1].src, SCREEN_H - 1, "and a window row that wobbles below it clamps to the last row") end @@ -89,7 +89,7 @@ do local lines = byRow(BattleAnimView.scanlines( bg({ lcdc = "SCY", scy = 0xfc, lyStart = 0, lyEnd = 0x36, lyBackup = { [0] = 0xff } }))) - T.eq(lines[0], nil, "an hSCY shake keeps its blank rows, window or not") + T.eq(lines[1], nil, "an hSCY shake keeps its blank rows, window or not") end do diff --git a/tests/engine/gen2_text_markers_lint.lua b/tests/engine/gen2_text_markers_lint.lua new file mode 100644 index 00000000..c0cc7cd1 --- /dev/null +++ b/tests/engine/gen2_text_markers_lint.lua @@ -0,0 +1,107 @@ +-- macros/scripts/text.asm:11 (line / cont / para) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check = T.check +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local TextBox = require("src.render.TextBox") + +local FILES = { + "src/script/gen2/Specials.lua", + "src/script/gen2/specials/battle_tower.lua", + "src/script/gen2/specials/crystal_extras.lua", + "src/script/gen2/specials/crystal_story.lua", + "src/script/gen2/specials/unown_words.lua", +} + +local KNOWN = { + ["Give a nickname to\nthe {STRBUF} you\nreceived?"] = true, -- data/text/common_2.asm:1085 + ["This Bug-Catching\nContest winner is\f%s,\nwho caught a\n%s!"] = true, -- data/text/common_2.asm:972 + ["It's {STRBUF}\nthat was left with\nthe DAY-CARE MAN."] = true, -- data/text/common_2.asm:818 + ["It's {STRBUF}\nthat was left with\nthe DAY-CARE LADY."] = true, -- data/text/common_2.asm:810 + ["Which #MON\nshould I photo-\ngraph?"] = true, -- data/text/common_1.asm:1865 + ["The {STRBUF} POKéMON\nmust all be different kinds."] = true, -- data/text/common_3.asm:1156 + ["The {STRBUF} POKéMON\nmust not hold the same items."] = true, -- data/text/common_3.asm:1166 +} + +local ESC = { n = "\n", v = "\v", f = "\f", t = "\t", r = "\r", + ["\\"] = "\\", ['"'] = '"', ["'"] = "'" } + +local function unescape(raw) + return (raw:gsub("\\(.)", function(c) return ESC[c] or ("\\" .. c) end)) +end + +local function pageIsPlayable(page, conts) + if #page <= 2 then return true end + for i = 3, #page do + if not (conts and conts[i]) then return false end + end + return true +end + +local function literals(src) + local out = {} + local i, line = 1, 1 + local n = #src + while i <= n do + local c = src:sub(i, i) + if c == "\n" then + line = line + 1 + i = i + 1 + elseif src:sub(i, i + 1) == "--" then + i = (src:find("\n", i, true) or n + 1) + elseif c == '"' or c == "'" then + local j = i + 1 + while j <= n do + local d = src:sub(j, j) + if d == "\\" then j = j + 2 + elseif d == c then break + else j = j + 1 end + end + out[#out + 1] = { line = line, text = unescape(src:sub(i + 1, j - 1)) } + i = j + 1 + else + i = i + 1 + end + end + return out +end + +local function unplayable(text) + local pages = TextBox.paginate((text:gsub("{[%w_]+}", "X"))) + for p, page in ipairs(pages) do + if not pageIsPlayable(page, pages.contBefore[p]) then return p, #page end + end +end + +local seenKnown = {} +local swept = 0 +for _, path in ipairs(FILES) do + local f = assert(io.open(path, "r"), path) + local src = f:read("*a") + f:close() + for _, lit in ipairs(literals(src)) do + if lit.text:find("\n", 1, true) then + swept = swept + 1 + local page, lines = unplayable(lit.text) + local label = ("%s:%d %q"):format(path, lit.line, lit.text:sub(1, 48)) + if KNOWN[lit.text] then + seenKnown[lit.text] = true + check(page ~= nil, label .. " is still on the KNOWN list for a reason") + else + check(page == nil, label .. (page and + (": page %d runs %d lines with no cont/para mark"):format(page, lines) + or "")) + end + end + end +end + +check(swept > 100, ("swept %d multi-line strings"):format(swept)) +for text in pairs(KNOWN) do + check(seenKnown[text], ("KNOWN entry still present in source: %q"):format(text:sub(1, 40))) +end + +T.finish("gen2 text markers lint") diff --git a/tests/engine/gen2_vanish_reveal_both_sides_bug2101.lua b/tests/engine/gen2_vanish_reveal_both_sides_bug2101.lua new file mode 100644 index 00000000..596c1e5f --- /dev/null +++ b/tests/engine/gen2_vanish_reveal_both_sides_bug2101.lua @@ -0,0 +1,164 @@ +-- engine/battle/effect_commands.asm:5421 (#2101) +-- engine/battle_anims/bg_effects.asm:377 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, +} + +local MOVES = { + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + DIGGER = { + id = "DIGGER", index = 50, name = "DIGGER", + baseStats = { hp = 40, attack = 55, defense = 40, speed = 60, + specialAttack = 40, specialDefense = 40 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 50, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "DIG" } }, + evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 } + +local function newScreen() + Input:init() + local player = Mon.new(DATA, "DIGGER", 20, { dvs = dvs }) + player.moves = { { id = "DIG", pp = 10, maxPp = 10 } } + local wild = Mon.new(DATA, "DIGGER", 20, { dvs = dvs }) + wild.moves = { { id = "DIG", pp = 10, maxPp = 10 } } + local save = { party = { player }, inventory = {} } + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end }, + } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = function() return 0 end }) + local screen = BattleState.new(game, { battle = battle, save = save }) + screen.queue = {} + screen.phase = "resolving" + return screen +end + +local function strike(side, missed) + return { kind = "move", side = side, move = "DIG", wasVanished = true, + missed = missed or nil, text = side .. " DIG" } +end + +local function noLatch(screen) + return screen.vanishReveal.player == nil and screen.vanishReveal.enemy == nil +end + +local idle = { wasPressed = function() return false end } +local skip = { wasPressed = function(_, b) return b == "b" end } + +local function fakeAnim(screen) + local runs = {} + screen.animForMove = function(self) + local anim = { animId = "DIG", bg = { picSize = {} }, + step = function() return false end, + done = function() return true end } + runs[#runs + 1] = anim + self.anim = anim + return true + end + return runs +end + +for _, order in ipairs({ { "player", "enemy" }, { "enemy", "player" } }) do + local screen = newScreen() + screen:pushAll({ strike(order[1]), strike(order[2]) }) + eq(screen.picHidden.player, true, order[1] .. " first: player box empty") + eq(screen.picHidden.enemy, true, order[1] .. " first: enemy box empty") + screen:advanceQueue() + eq(screen.picHidden[order[1]], false, + order[1] .. " first: the faster mon comes back on its own strike") + eq(screen.picHidden[order[2]], true, + order[1] .. " first: the slower one is still down") + screen:advanceQueue() + eq(screen.picHidden[order[2]], false, + order[1] .. " first: the slower mon comes back on its strike") + check(noLatch(screen), order[1] .. " first: no latch is left behind") +end + +do + local screen = newScreen() + screen:pushAll({ strike("player", true), strike("enemy") }) + screen:advanceQueue() + eq(screen.picHidden.player, false, "a missed strike still reveals the user") + eq(screen.picHidden.enemy, true, "and leaves the foe's latch alone") + screen:advanceQueue() + eq(screen.picHidden.enemy, false, "the foe's own strike reveals it") + check(noLatch(screen), "miss: no latch is left behind") +end + +for _, order in ipairs({ { "player", "enemy" }, { "enemy", "player" } }) do + local screen = newScreen() + local runs = fakeAnim(screen) + screen:pushAll({ strike(order[1]), strike(order[2]) }) + screen:advanceQueue() + eq(#runs, 1, order[1] .. " anim: the first strike starts its script") + eq(screen.vanishReveal[order[1]].anim, runs[1], + order[1] .. " anim: the latch is armed on that script") + check(screen.vanishReveal[order[2]] + and screen.vanishReveal[order[2]].anim == nil, + order[1] .. " anim: the other latch stays unarmed") + eq(screen.picHidden[order[1]], true, + order[1] .. " anim: the box stays empty while the script runs") + screen:stepAnim(idle) + eq(screen.picHidden[order[1]], false, + order[1] .. " anim: the script's end brings the mon back") + eq(screen.picHidden[order[2]], true, + order[1] .. " anim: the other mon is still down") + screen:advanceQueue() + eq(#runs, 2, order[1] .. " anim: the second strike starts its script") + screen:stepAnim(skip) + eq(screen.picHidden[order[2]], false, + order[1] .. " anim: a B skip brings the second mon back") + check(noLatch(screen), order[1] .. " anim: no latch is left behind") +end + +do + local screen = newScreen() + local runs = fakeAnim(screen) + screen:pushAll({ strike("player"), strike("enemy") }) + screen:advanceQueue() + runs[1].bg.picSize.enemy = 1 + screen:revealVanished() + eq(screen.picHidden.player, true, + "the foe's pic size in the user's script reveals nothing") + runs[1].bg.picSize.player = 1 + screen:revealVanished() + eq(screen.picHidden.player, false, "the user's own ENTER_MON reveals it") + eq(screen.picHidden.enemy, true, "and the foe stays down") + check(screen.vanishReveal.enemy ~= nil, "with its latch intact") +end + +T.finish("gen2 vanish reveal both sides bug 2101") diff --git a/tests/engine/gen2_yesno_typing_bug2085.lua b/tests/engine/gen2_yesno_typing_bug2085.lua new file mode 100644 index 00000000..ec7106ce --- /dev/null +++ b/tests/engine/gen2_yesno_typing_bug2085.lua @@ -0,0 +1,126 @@ +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +-- ../pokecrystal/engine/events/daycare.asm:107 +-- ../pokecrystal/engine/items/mart.asm:517 MartConfirmPurchase + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq, check = T.eq, T.check +love = love or require("tests.love_stub") + +local Save = require("src.core.gen2.Save") +local SaveMenu = require("src.ui.gen2.SaveMenu") +local DayCareMenu = require("src.ui.gen2.DayCareMenu") +local MartMenu = require("src.ui.gen2.MartMenu") +local Typer = require("src.ui.gen2.Typer") + +local function newInput() + local input = { pressed = {}, held = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown(button) return self.held[button] == true end + return input +end + +local input = newInput() +local save = Save.newGame({ playerName = "GOLD", trainerId = 1 }) +save.options = save.options or {} +save.options.textSpeed = "MID" +local game = { input = input, save = save } + +local menu = SaveMenu.new(game, { + save = save, existed = true, + writer = function() return true end, +}) + +check(not menu:yesNoVisible(), "no YES/NO before the prompt starts typing") +menu:update(0) +check(Typer.typing(menu), "the confirm question types letter by letter") +check(not menu:yesNoVisible(), "YES/NO stays down while it types") + +local frames = 0 +while Typer.typing(menu) and frames < 600 do + menu:update(0) + frames = frames + 1 +end +check(frames > 0 and frames < 600, "the confirm question finishes typing") +check(menu:yesNoVisible(), "YES/NO goes up once the question has printed") + +input:press("a") +menu:update(0) +eq(menu.phase, "overwrite", "YES on an existing file asks to overwrite") +check(Typer.typing(menu), "the overwrite question types letter by letter") +check(not menu:yesNoVisible(), "its YES/NO waits for its own prompt") + +frames = 0 +while Typer.typing(menu) and frames < 600 do + menu:update(0) + frames = frames + 1 +end +check(frames > 0 and frames < 600, "the overwrite question finishes typing") +check(menu:yesNoVisible(), "then the overwrite YES/NO goes up") + +input:press("a") +menu:update(0) +eq(menu.phase, "saving", "YES starts the save") +check(not menu:yesNoVisible(), "no YES/NO on the SAVING page") + +local dc = setmetatable({ game = game }, DayCareMenu) +dc.confirm = { + pages = { + { "I'm the DAY-CARE", "MAN. Want me to" }, + { "raise a #MON", "for you?" }, + }, + page = 1, choice = 1, +} +Typer.begin(dc, dc.confirm) +check(not dc:yesNoVisible(), "Day-Care YES/NO stays down on page one") + +frames = 0 +while Typer.typing(dc) and frames < 600 do + Typer.step(dc) + frames = frames + 1 +end +check(frames > 0 and frames < 600, "page one finishes typing") +check(not dc:yesNoVisible(), "and it stays down while the page turn waits") + +Typer.turn(dc, dc.confirm) +check(Typer.typing(dc), "the last page types letter by letter") +check(not dc:yesNoVisible(), "and the YES/NO stays down while it does") + +frames = 0 +while Typer.typing(dc) and frames < 600 do + Typer.step(dc) + frames = frames + 1 +end +check(frames > 0 and frames < 600, "the last page finishes typing") +check(dc:yesNoVisible(), "then the Day-Care YES/NO goes up") + +local mm = setmetatable({ game = game }, MartMenu) +mm.confirm = { + pages = { { "POTION?", "That will be ¥300." } }, + page = 1, choice = 1, +} +Typer.begin(mm, mm.confirm) +check(not mm:yesNoVisible(), "mart YES/NO stays down while the price types") + +frames = 0 +while Typer.typing(mm) and frames < 600 do + Typer.step(mm) + frames = frames + 1 +end +check(frames > 0 and frames < 600, "the price line finishes typing") +check(mm:yesNoVisible(), "then the mart YES/NO goes up") + +local instant = setmetatable({ + confirm = { pages = { { "Sure thing." } }, page = 1, choice = 1 }, +}, MartMenu) +check(instant:yesNoVisible(), "a pre-typed prompt shows its YES/NO at once") + +T.finish("gen2 yesno typing bug 2085") diff --git a/tests/engine/textbox_arrow_bug2084.lua b/tests/engine/textbox_arrow_bug2084.lua new file mode 100644 index 00000000..8c21611f --- /dev/null +++ b/tests/engine/textbox_arrow_bug2084.lua @@ -0,0 +1,73 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") +local TextBox = require("src.render.TextBox") + +local ARROW = Theme.moreArrow or 0xEE + +local function newGame(gen) + return { + save = { player = {}, options = { textSpeed = "FAST" }, generation = gen }, + data = { text = {} }, + input = { + wasPressed = function() return false end, + isDown = function() return false end, + }, + } +end + +local captured = {} +local realDraw = Font.drawCode +Font.drawCode = function(code, x, y) + captured[#captured + 1] = { code = code, x = x, y = y } +end + +local function arrowAt(box, blink) + box.blink = blink + captured = {} + box:draw() + for _, g in ipairs(captured) do + if g.code == ARROW then return g end + end + return nil +end + +local red = TextBox.new(newGame(1), "A") +red.blink = 479 +red:update(1 / 60) +eq(red.blink, 0, "the blink counter wraps at 480") +red.waiting = true +red.shown = {} + +local g = arrowAt(red, 0) +check(g, "gen1 arrow draws on the on phase") +eq(g.x, 144, "gen1 arrow x") +eq(g.y, 132, "gen1 arrow keeps its straddled row") +check(arrowAt(red, 29), "gen1 on through frame 29") +eq(arrowAt(red, 30), nil, "gen1 off from frame 30") +eq(arrowAt(red, 59), nil, "gen1 off through frame 59") +check(arrowAt(red, 60), "gen1 back on as the 60-frame cycle repeats") + +local gold = TextBox.new(newGame(2), "A") +gold.waiting = true +gold.shown = {} + +-- pokegold home/text.asm:549 +g = arrowAt(gold, 0) +check(g, "gold arrow draws on the on phase") +eq(g.x, 144, "gold arrow x is tile column 18") +eq(g.y, 136, "gold arrow sits on the border row, tile row 17") +-- pokegold home/joypad.asm:430 +check(arrowAt(gold, 15), "gold on through frame 15") +eq(arrowAt(gold, 16), nil, "gold off from frame 16") +eq(arrowAt(gold, 31), nil, "gold off through frame 31") +check(arrowAt(gold, 32), "gold back on at frame 32") +check(arrowAt(gold, 47), "gold cadence holds later in the wrap") + +Font.drawCode = realDraw +T.finish() diff --git a/tests/gen2_arrow_blink_test.lua b/tests/gen2_arrow_blink_test.lua new file mode 100644 index 00000000..62cdb6d5 --- /dev/null +++ b/tests/gen2_arrow_blink_test.lua @@ -0,0 +1,26 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local Typer = require("src.ui.gen2.Typer") + +-- pokegold home/joypad.asm:430 +local screen = { game = { save = { options = {} } } } +eq(Typer.arrowOn(screen), true, "phase 0 shows the cursor") +for _ = 1, 15 do Typer.step(screen) end +eq(Typer.arrowOn(screen), true, "still on through frame 15") +Typer.step(screen) +eq(Typer.arrowOn(screen), false, "off from frame 16") +for _ = 1, 15 do Typer.step(screen) end +eq(Typer.arrowOn(screen), false, "off through frame 31") +Typer.step(screen) +eq(Typer.arrowOn(screen), true, "back on at frame 32") +eq(screen.arrowBlink, 0, "the counter wraps at 32") + +Typer.say(screen, { "HELLO", "WORLD" }) +Typer.step(screen) +eq(screen.arrowBlink, 1, "step keeps counting while a typer runs") + +T.finish() diff --git a/tests/gen2_battle_anims_test.lua b/tests/gen2_battle_anims_test.lua index 0fd1625b..bb63259a 100644 --- a/tests/gen2_battle_anims_test.lua +++ b/tests/gen2_battle_anims_test.lua @@ -268,20 +268,26 @@ eq(overrideSide("minimize", 0), nil, "anim_minimize ($e9) is a cart dummy") -- battle screen, and the branch-heavy ones (Metronome, Bide, the multi-hit -- loops) are exactly where that would happen. local longest, longestName, stuck = 0, nil, 0 +local DroppedBg = require("src.battle.gen2.BgEffects").DROPPED +eq(#DroppedBg, 0, "no BG effect id was dropped before the sweep") for move, key in pairs(data.moves) do for _, turn in ipairs({ 0, 1 }) do - local r = runner(key, { animId = move, turn = turn, param = 3 }) - local frames = 0 - local ok = pcall(function() - while r:step() and frames < 1500 do frames = frames + 1 end - end) - if not ok or frames >= 1500 then stuck = stuck + 1 end - if frames > longest then longest, longestName = frames, move end + for _, param in ipairs({ 0, 1, 2, 3 }) do + local r = runner(key, { animId = move, turn = turn, param = param }) + local frames = 0 + local ok = pcall(function() + while r:step() and frames < 1500 do frames = frames + 1 end + end) + if not ok or frames >= 1500 then stuck = stuck + 1 end + if frames > longest then longest, longestName = frames, move end + end end end -eq(stuck, 0, "all 251 move animations terminate on both turns") +eq(stuck, 0, "all 251 move animations terminate on both turns at params 0-3") check(longest > 0 and longest < 1500, ("the longest is %s at %d frames"):format(tostring(longestName), longest)) +eq(#DroppedBg, 0, "no BG effect id was dropped for want of an E entry (" + .. table.concat(DroppedBg, ", ") .. ")") -- BattleAnimObjects rows are six bytes and the struct's TILEID comes from the -- tile dict, not the row -- so an object queued before its sheet is loaded diff --git a/tests/gen2_battle_return_fade_test.lua b/tests/gen2_battle_return_fade_test.lua new file mode 100644 index 00000000..f113b944 --- /dev/null +++ b/tests/gen2_battle_return_fade_test.lua @@ -0,0 +1,64 @@ +-- engine/tilesets/timeofday_pals.asm:65-91 +-- data/maps/setup_scripts.asm:124-139 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle return fade") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") + +local function rowEq(row, a, b, c, d, label) + check(row ~= nil, label .. " (row exists)") + if not row then return end + check(row[1] == a and row[2] == b and row[3] == c and row[4] == d, + label .. " (" .. row[1] .. row[2] .. row[3] .. row[4] .. ")") +end + +rowEq(World.fadeRampRow("white", 0.25), 3, 2, 1, 0, + "the first white step is the identity row") +rowEq(World.fadeRampRow("white", 0.5), 2, 1, 0, 0, + "the second collapses one gradation") +rowEq(World.fadeRampRow("white", 0.75), 1, 0, 0, 0, + "the third another") +rowEq(World.fadeRampRow("white", 1), 0, 0, 0, 0, + "the fourth is solid white") +rowEq(World.fadeRampRow("black", 1), 3, 3, 3, 3, + "the black ramp ends solid black") +rowEq(World.fadeRampRow("white", 0), 3, 2, 1, 0, + "a floor of one keeps level 0 on the identity row") +rowEq(World.fadeRampRow("white", 2), 0, 0, 0, 0, + "and a cap of four keeps an overshoot on white") +eq(World.fadeRampRow("outWhite", 1), nil, "an unknown kind quantizes to nothing") +eq(World.fadeRampRow(nil, 1), nil, "and so does no fade at all") + +eq(World.fadeRampByte({ 3, 2, 1, 0 }), 0xe4, "identity packs to $e4") +eq(World.fadeRampByte({ 0, 0, 0, 0 }), 0x00, "white packs to $00") +eq(World.fadeRampByte({ 3, 3, 3, 3 }), 0xff, "black packs to $ff") + +local w = {} +World.battleReturnFade(w) +check(w.mapSetup ~= nil, "the battle exit arms a map setup chain") +eq(w.mapSetup.phase, "in", "the chain is the fade-in half only") +eq(w.mapSetup.step, 4, "four steps up") +eq(w.fade, "white", "under a full white sheet") +eq(w.fadeLevel, 1, "at full strength") +check(World.busy(w), "and the world is busy for its whole run") + +local armed = { mapSetup = { phase = "out", step = 2, wait = 1 } } +World.battleReturnFade(armed) +eq(armed.mapSetup.phase, "out", + "a chain already running is left alone") +eq(armed.fade, nil, "and its sheet untouched") + +local ticks = 0 +while w.mapSetup and ticks < 32 do + World.updateMapSetup(w) + ticks = ticks + 1 +end +eq(ticks, 8, "the fade in runs four steps of two frames") +eq(w.mapSetup, nil, "then the chain ends") +eq(w.fade, nil, "with nothing left over the world") +check(not World.busy(w), "and control back") + +S.finish() diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 631615ae..ffaaa6aa 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -280,11 +280,16 @@ do check("the slot now holds the newcomer", learner.moves[1].id, "SPORE") check("the other three are untouched", learner.moves[2].id, "EMBER") local ev = b:takeEvents() - check("it queues a forgot line then a learned line", #ev, 2) + check("it queues count, forgot, learned lines", #ev, 3) + check("the count line leads", ev[1].text, "1, 2 and…") + -- ../pokecrystal/home/text.asm:887-896 + check("and is a text_pause, not a prompt", ev[1].textPause, true) check("forgot text names the dropped move", - ev[1].text:find("forgot TACKLE") ~= nil, true) + ev[2].text:find("forgot TACKLE") ~= nil, true) + -- engine/pokemon/learn.asm:225-229 + check("the poof rides the forgot line", ev[2].sfx, "Sfx_SwitchPokemon") check("learned text names the new move", - ev[2].text:find("learned SPORE") ~= nil, true) + ev[3].text:find("learned SPORE") ~= nil, true) -- Decline keeps the moveset and says so. b:declineForget(1, "SPORE") @@ -1286,8 +1291,9 @@ do magWild.hp = 200 magWild.maxHp = 200 local magEvents = magBattle:takeTurn({ kind = "move", move = "MAGNITUDE" }) - local announced, announceEvent, usedEvent - for _, ev in ipairs(magEvents) do + local announced, announceEvent, usedEvent, animEvent + local announceIndex, animIndex + for i, ev in ipairs(magEvents) do if ev.kind == "move" and ev.move == "MAGNITUDE" and not usedEvent then usedEvent = ev end @@ -1295,6 +1301,12 @@ do and ev.text:match("^Magnitude %d+") and not announced then announced = ev.text announceEvent = ev + announceIndex = i + end + if ev.kind == "message" and ev.moveAnim == "MAGNITUDE" + and not animEvent then + animEvent = ev + animIndex = i end end check("magnitude announces rolled number", announced, "Magnitude 8!") @@ -1304,12 +1316,52 @@ do usedEvent and usedEvent.animDelay, true) check("magnitude used-move line owns no animation", usedEvent and usedEvent.moveAnim, nil) - check("magnitude line carries the animation", - announceEvent and announceEvent.moveAnim, "MAGNITUDE") - check("magnitude line carries the attacking side", - announceEvent and announceEvent.side, "player") + -- data/text/battle.asm:1017-1021 + check("magnitude number line holds without the animation", + announceEvent and announceEvent.moveAnim, nil) + -- data/moves/effects.asm:1705-1711 + check("magnitude animation rides its own later event", + animIndex ~= nil and announceIndex ~= nil and announceIndex < animIndex, + true) + check("magnitude anim event carries no text", animEvent and animEvent.text, + nil) + check("magnitude anim event carries the attacking side", + animEvent and animEvent.side, "player") check("magnitude deals more than power-1 would", magWild.hp < 200, true) + + local missBattle, missPlayer = newBattle({ + random = function(n) + if n == 256 then return 200 end + if n == 100 then return 99 end + return 0 + end, + }) + missPlayer.moves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } } + missBattle.stages.enemy.evasion = 6 + local missEvents = missBattle:takeTurn({ kind = "move", move = "MAGNITUDE" }) + local missAnnounce, missAnim, missLine + for _, ev in ipairs(missEvents) do + if ev.kind == "message" and type(ev.text) == "string" then + if ev.text:match("^Magnitude %d+") and not missAnnounce then + missAnnounce = ev + end + if ev.text:find("attack missed", 1, true) and not missLine then + missLine = ev + end + end + if ev.kind == "message" and ev.moveAnim == "MAGNITUDE" + and not missAnim then + missAnim = ev + end + end + check("missed magnitude still announces the number", + missAnnounce and missAnnounce.text, "Magnitude 8!") + check("missed magnitude leaves the number line unmarked", + missAnnounce and missAnnounce.missed, nil) + check("missed magnitude marks the anim event", + missAnim and missAnim.missed, true) + check("missed magnitude prints the miss line", missLine ~= nil, true) end -- --------------------------------------------------------------- held items diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 013ae357..81a5b494 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -1762,6 +1762,19 @@ do eq(screen.phase, "choose-forget", "YES opens the picker") tap("a") -- slot 1 eq(lead.moves[1].id, "EMBER", "the move is learned") + eq(screen.message, "1, 2 and…", "the count line prints first") + -- ../pokecrystal/home/text.asm:887-896 + eq(screen.messageTimer, 0, "and text_pause holds it for no button") + local poofed = false + for _ = 1, 400 do + Input:step() + screen:update(1 / 60) + if screen.message and screen.message:find("forgot", 1, true) then + poofed = true + break + end + end + check(poofed, "the forgot line follows the pause with no press between") check(screen.message and screen.message:find("forgot", 1, true) ~= nil, "and its line prints ahead of the send-out that was already queued") end diff --git a/tests/gen2_contest_test.lua b/tests/gen2_contest_test.lua index b1a302c6..ed496bad 100644 --- a/tests/gen2_contest_test.lua +++ b/tests/gen2_contest_test.lua @@ -1227,4 +1227,95 @@ do end end +eq(BugContest.score(mon({ hp = 16 })), BugContest.score(mon()) - 3, + "the remaining HP term feeds the score") + +do + local GameVersion = require("src.core.GameVersion") + local prior = GameVersion.current + GameVersion.set("gold") + local stock, this = ContestMenu.labels() + eq(stock, " STOCK ", "gold's STOCK label") + eq(this, " THIS ", "gold's THIS label, one trailing space") + GameVersion.set("crystal") + stock, this = ContestMenu.labels() + eq(stock, " STOCK ", + "crystal's STOCK expands to the same ten tiles") + eq(this, " THIS ", + "crystal's THIS keeps its second trailing space, ten tiles not nine") + GameVersion.set(prior) +end + +do + local menuSource = sourceOf("src/ui/gen2/ContestMenu.lua") + check(menuSource ~= nil, "ContestMenu's source is readable") + check(menuSource:find("Font.useBattleExtra(true)", 1, true) ~= nil, + "the switch screen loads the battle-extra font page for its tile") +end + +do + local StartMenu = require("src.ui.gen2.StartMenu") + local record = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, + events = {}, player = { name = "GOLD" } } + BugContest.start(record, START) + BugContest.catch(record, mon({ species = "CATERPIE", level = 9 })) + local warped = 0 + local game = { save = record, world = { + bugContestResults = function() warped = warped + 1; return true end } } + + local menu = StartMenu.new(game, { save = record }) + local function ids(m) + local out = {} + for _, item in ipairs(m.items) do out[#out + 1] = item.value end + return table.concat(out, ",") + end + eq(ids(menu), "pokemon,status,quitContest,option", + "mid-contest the menu swaps SAVE for QUIT and drops PACK and the title row") + check(menu.contest, "and knows the contest is on") + eq(menu.list.y, 4, "the list sits two rows lower, per .ContestMenuHeader") + + local quitIndex + for index, item in ipairs(menu.items) do + if item.value == "quitContest" then quitIndex = index end + end + check(quitIndex ~= nil, "the QUIT row is findable") + eq(menu.items[quitIndex].label, "QUIT", "and is labeled QUIT") + + menu.list.index = quitIndex + game.input = fakeInput("a") + menu:update(0) + eq(menu.phase, "confirmContest", "QUIT asks before ending the Contest") + eq(menu.confirmChoice, 1, "with the YesNoBox open on YES") + eq(warped, 0, "and nothing has ended yet") + + game.input = fakeInput("b") + menu:update(0) + eq(menu.phase, nil, "B backs out") + eq(warped, 0, "without ending the contest") + check(BugContest.isActive(record), "which is still running") + + menu.list.index = quitIndex + game.input = fakeInput("a") + menu:update(0) + game.input = fakeInput("a") + menu:update(0) + eq(warped, 1, "YES hands control to BugContestResultsWarpScript") + eq(menu.phase, nil, "and the confirmation is gone") + + game.input = fakeInput("a") + local down = StartMenu.new(game, { save = record }) + menu.list.index = quitIndex + game.input = fakeInput("start") + down.phase = "confirmContest" + down:update(0) + eq(down.phase, nil, "START backs out of the confirmation too") + + BugContest.stop(record) + local after = StartMenu.new(game, { save = record }) + eq(ids(after), "pokemon,pack,status,save,option,quit", + "stopping the contest restores SAVE, PACK and the title QUIT") + check(not after.contest, "and the contest flag clears") + eq(after.list.y, 2, "with the list back at menu_coords 10, 0") +end + S.finish() diff --git a/tests/gen2_dig_anim_2101_test.lua b/tests/gen2_dig_anim_2101_test.lua new file mode 100644 index 00000000..6a5f6aad --- /dev/null +++ b/tests/gen2_dig_anim_2101_test.lua @@ -0,0 +1,96 @@ +-- engine/battle/effect_commands.asm:5432, :5456-5459 (#2101) +-- +-- luajit tests/gen2_dig_anim_2101_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 dig anim events") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, +} + +local MOVES = { + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + DIGGER = { + id = "DIGGER", index = 50, name = "DIGGER", + baseStats = { hp = 40, attack = 55, defense = 40, speed = 60, + specialAttack = 40, specialDefense = 40 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 50, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "DIG" } }, + evolutions = {}, + }, + TARGET = { + id = "TARGET", index = 51, name = "TARGET", + baseStats = { hp = 60, attack = 40, defense = 45, speed = 10, + specialAttack = 40, specialDefense = 40 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 50, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, + evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local function zeroRandom() return 0 end + +local dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 } +local player = Mon.new(DATA, "DIGGER", 20, { dvs = dvs }) +player.moves = { { id = "DIG", pp = 10, maxPp = 10 } } +local wild = Mon.new(DATA, "TARGET", 10, { dvs = dvs }) +wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + +local battle = Battle.new({ + data = DATA, party = { player }, wild = wild, random = zeroRandom, +}) + +local function playerMove(events) + for _, ev in ipairs(events or {}) do + if ev.kind == "move" and ev.side == "player" then return ev end + end + return nil +end + +local turn1 = battle:takeTurn({ kind = "move", move = "DIG" }) +local mv = playerMove(turn1) +check(mv ~= nil, "turn 1 emits the player's move event") +eq(mv and mv.animParam, 1, "the charge turn runs the anim's param-1 branch") +eq(mv and mv.noAfterAnim, true, "and zeroes the after-anim") +check(mv and not mv.wasVanished, "with no reveal flag on the way down") +eq(battle:volatile(player).vanished, true, "the user is underground") +eq(battle:volatile(player).chargeMove, "DIG", "with the attack stored") + +local turn2 = battle:takeTurn({ kind = "move", move = "DIG" }) +local mv2 = playerMove(turn2) +check(mv2 ~= nil, "turn 2 emits the stored attack's move event") +eq(mv2 and mv2.wasVanished, true, "flagged as the reveal turn") +check(mv2 and mv2.animParam == nil, "on the anim's hit branch") +check(mv2 and mv2.noAfterAnim == nil, "with the hit shake intact") +check(battle:volatile(player).vanished == nil, "and the substatus is gone") +check(battle:volatile(player).chargeMove == nil, "with nothing left stored") + +S.finish() diff --git a/tests/gen2_move_effects_test.lua b/tests/gen2_move_effects_test.lua index 444a872b..5d1e37f1 100644 --- a/tests/gen2_move_effects_test.lua +++ b/tests/gen2_move_effects_test.lua @@ -72,6 +72,8 @@ local MOVES = { effect = "EFFECT_DREAM_EATER" }, TRANSFORM = { id = "TRANSFORM", name = "TRANSFORM", power = 0, type = "NORMAL", accuracy = 0, pp = 10, effect = "EFFECT_TRANSFORM" }, + SWORDS_DANCE = { id = "SWORDS_DANCE", name = "SWORDS DANCE", power = 0, + type = "NORMAL", accuracy = 0, pp = 30, effect = "EFFECT_ATTACK_UP_2" }, -- STRUGGLE is in the move table like any other move and in nobody's move -- list, which is the whole of what makes Battle.STRUGGLE work. STRUGGLE = { id = "STRUGGLE", name = "STRUGGLE", power = 50, type = "NORMAL", @@ -244,6 +246,48 @@ do "an already-cursed target refuses") end +-- pokegold data/moves/effects.asm:1488 +do + local battle, player, wild = newBattle({ + playerMoves = { { id = "CURSE", pp = 10, maxPp = 10 } }, + random = rolls({}, 0) }) + battle:volatile(wild).vanished = true + battle:useMove(player, wild, "CURSE") + eq(battle.stages.player.speed, -1, + "non-Ghost Curse ignores a dug-in target: Speed falls") + eq(battle.stages.player.attack, 1, "Attack rises") + eq(battle.stages.player.defense, 1, "Defense rises") + check(not findText(battle:takeEvents(), "MACHOP's attack missed!"), + "a chain without checkhit cannot miss") +end + +-- pokegold engine/battle/move_effects/curse.asm:58 +do + local battle, player, wild = newBattle({ playerSpecies = "GASTLY", + playerMoves = { { id = "CURSE", pp = 10, maxPp = 10 } }, + random = rolls({}, 0) }) + battle:volatile(wild).vanished = true + local hpBefore = player.hp + battle:useMove(player, wild, "CURSE") + check(findText(battle:takeEvents(), "But it failed!"), + "CheckHiddenOpponent fails the Ghost arm") + eq(battle:volatile(wild).cursed, nil, "no curse lands") + eq(player.hp, hpBefore, "no HP cut on the failed arm") +end + +-- pokegold data/moves/effects.asm:272 +do + local battle, player, wild = newBattle({ + playerMoves = { { id = "SWORDS_DANCE", pp = 30, maxPp = 30 } }, + random = rolls({}, 0) }) + battle:volatile(wild).vanished = true + battle:useMove(player, wild, "SWORDS_DANCE") + eq(battle.stages.player.attack, 2, + "AttackUp2 has no checkhit and reaches its stat change") + check(not findText(battle:takeEvents(), "MACHOP's attack missed!"), + "no invented miss against a dug-in target") +end + -- ---- Reflect and Light Screen --------------------------------------------- do -- Reflect doubles Defense against a physical hit: with the seeded diff --git a/tests/gen2_sleep_counter_test.lua b/tests/gen2_sleep_counter_test.lua index 20cc97d6..cd31d92b 100644 --- a/tests/gen2_sleep_counter_test.lua +++ b/tests/gen2_sleep_counter_test.lua @@ -167,6 +167,32 @@ do check(found(events, "woke up!"), "the next round wakes it") eq(wild.status, nil, "and clears the status byte") eq(wild.statusTurns, nil, "and the counter with it") + -- effect_commands.asm:175-181 + for _, event in ipairs(events) do + if event.text and event.text:find("woke up!", 1, true) then + eq(event.kind, "status", "the wake line rides a status event") + eq(event.status, nil, "carrying the cleared byte") + eq(event.side, "enemy", "on the sleeper's side") + end + end +end + +-- effect_commands.asm:6289-6290 +do + local battle, _, wild = newBattle() + wild.status = "freeze" + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + eq(wild.status, nil, "a zero roll thaws the target") + local seen = false + for _, event in ipairs(events) do + if event.text and event.text:find("thawed out!", 1, true) then + seen = true + eq(event.kind, "status", "the thaw line rides a status event") + eq(event.status, nil, "carrying the cleared byte") + eq(event.side, "enemy", "on the thawed side") + end + end + check(seen, "the thaw line was emitted") end -- Hypnosis shares EFFECT_SLEEP, so it shares the window. diff --git a/tests/gen2_text_flow_test.lua b/tests/gen2_text_flow_test.lua index 9ae3ffa8..955abae6 100644 --- a/tests/gen2_text_flow_test.lua +++ b/tests/gen2_text_flow_test.lua @@ -430,4 +430,96 @@ do end end +-- pokecrystal data/text/common_3.asm:1464, pokegold data/text/common_1.asm:1562 +do + local function pageIsPlayable(page, conts) + if #page <= 2 then return true end + for i = 3, #page do + if not (conts and conts[i]) then return false end + end + return true + end + + local function assertPlayable(label, text) + local pages = TextBox.paginate(text, 18) + for i, page in ipairs(pages) do + check(pageIsPlayable(page, pages.contBefore and pages.contBefore[i]), + ("%s: page %d never runs past the box unattended"):format(label, i)) + end + return pages + end + + local function drive(handler, specials, answers) + local texts, stays = {}, {} + local ai = 0 + local vm = Vm.new({}, {}, Events.new(), { specials = specials }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS[handler](vm) end) + local ok, req = coroutine.resume(vm.co) + while true do + if not ok then error(req) end + local answer = nil + if req and req.kind == "text" then + texts[#texts + 1] = req.text + stays[#texts] = req.stay + elseif req and req.kind == "yesorno" then + ai = ai + 1 + answer = answers[ai] + end + if coroutine.status(vm.co) == "dead" then break end + ok, req = coroutine.resume(vm.co, answer) + end + return texts, stays + end + + -- pokecrystal engine/events/move_deleter.asm:2-4 + local texts, stays = drive("MoveDeletion", {}, { true }) + eq(#texts, 3, "the deleter's intro, the mon prompt, then the decline") + check(stays[1] == true, "the intro's last page holds under the YES/NO") + local intro = assertPlayable("_DeleterIntroText", texts[1]) + eq(#intro, 3, "the intro is three pages, one per cart `para` (#2095)") + for i, page in ipairs(intro) do + check(#page <= 2, + ("intro page %d holds at most the box's two lines"):format(i)) + end + eq(intro[3][2], "#MON forget?", "the question is the last page") + assertPlayable("_DeleterWhichMonText", texts[2]) + assertPlayable("_DeleterDeclinedText", texts[3]) + + -- pokecrystal engine/events/name_rater.asm:3-5 + local rate = { + save = function() return { player = { name = "GOLD", id = 1 } } end, + } + rate.selectPartyMon = function(_, done) + done(1, { nickname = "SPOT", ot = "SOMEONE", otId = 2 }) + end + texts, stays = drive("NameRater", rate, { true }) + eq(#texts, 3, "hello, the pick prompt, then the traded-mon verdict") + check(stays[1] == true, "hello's last page holds under the YES/NO") + local hello = assertPlayable("_NameRaterHelloText", texts[1]) + eq(#hello, 3, "hello is three pages, one per cart `para` (#2095)") + local which = assertPlayable("_NameRaterWhichMonText", texts[2]) + eq(#which, 1, "the pick prompt is one page") + eq(#which[1], 3, "of three lines") + check(which.contBefore[1][3] == true, + "whose third scrolls in on a button press, not on its own") + assertPlayable("_NameRaterPerfectNameText", texts[3]) + + rate.selectPartyMon = function(_, done) done(1, { nickname = "SPOT" }) end + rate.renameMon = function(_, done) done("REX") end + texts, stays = drive("NameRater", rate, { true, true }) + eq(#texts, 6, "hello, pick, offer, keyboard prompt, named, finished") + check(stays[3] == true, "the rename offer holds under its YES/NO") + assertPlayable("_NameRaterBetterNameText", texts[3]) + assertPlayable("_NameRaterWhatNameText", texts[4]) + local named = assertPlayable("_NameRaterNamedText", texts[5]) + check(named.contBefore[1][3] == true, "the new name waits for a press") + assertPlayable("_NameRaterFinishedText", texts[6]) + + rate.renameMon = function(_, done) done("SPOT") end + texts = drive("NameRater", rate, { true, true }) + eq(#texts, 6, "a re-typed name still runs the full conversation") + assertPlayable("_NameRaterSameNameText", texts[6]) +end + S.finish() diff --git a/tests/gen2_transition_stale_test.lua b/tests/gen2_transition_stale_test.lua new file mode 100644 index 00000000..74e57f2d --- /dev/null +++ b/tests/gen2_transition_stale_test.lua @@ -0,0 +1,80 @@ +-- ../pokegold/engine/battle/battle_transition.asm:164 +-- ../pokecrystal/engine/battle/start_battle.asm:15-35 +-- ../pokegold/engine/battle/core.asm:7782-7783 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 transition stale levels") +local eq = S.eq + +local World = require("src.world.gen2.World") +local Transition = require("src.ui.gen2.BattleTransition") + +local function world(version, party) + local game = { + data = { audio = { sfxOrder = {} } }, + save = { version = version, party = party or {}, player = {} }, + } + return World.new(game) +end + +local w = world("crystal") +eq(w.staleEnemyMonLevel, 0, "a fresh session has no leftover enemy level") +eq(w.staleBattleMonLevel, 0, "nor a leftover player level") +eq(w:transitionBattleMonLevel(), 0, "an empty crystal party reads 0") +eq(Transition.pick({ environment = "ROUTE", + playerLevel = w:transitionBattleMonLevel(), + enemyLevel = w.staleEnemyMonLevel }), + "spin", "the session's first battle spins outdoors") +eq(Transition.pick({ environment = "CAVE", + playerLevel = w:transitionBattleMonLevel(), + enemyLevel = w.staleEnemyMonLevel }), + "sine", "and sine-waves in a cave") + +eq(World.transitionStatusByte(nil), 0, "a healthy mon's status byte is 0") +eq(World.transitionStatusByte("sleep", 3), 3, "sleep reads its turn counter") +eq(World.transitionStatusByte("sleep", nil), 1, "sleep with no counter is 1") +eq(World.transitionStatusByte("poison"), 8, "poison is bit 3") +eq(World.transitionStatusByte("toxic"), 8, "toxic wears the poison bit") +eq(World.transitionStatusByte("burn"), 16, "burn is bit 4") +eq(World.transitionStatusByte("freeze"), 32, "freeze is bit 5") +eq(World.transitionStatusByte("paralyze"), 64, "paralysis is bit 6") + +local wc = world("crystal", { + { hp = 0, status = "paralyze" }, + { hp = 20, status = "poison" }, +}) +eq(wc:transitionBattleMonLevel(), 8, + "crystal reads the first ALIVE mon's status byte") +wc.game.save.party[2].status = nil +eq(wc:transitionBattleMonLevel(), 0, "a healthy survivor reads 0") + +wc:recordStaleBattleLevels({ enemy = { level = 10 }, player = { level = 40 } }) +eq(wc.staleEnemyMonLevel, 10, "the battle banks the enemy's level") +eq(wc.staleBattleMonLevel, 40, "and the player's") +eq(Transition.pick({ environment = "ROUTE", + playerLevel = wc:transitionBattleMonLevel(), + enemyLevel = wc.staleEnemyMonLevel }), + "speckle", "crystal's second battle speckles outdoors") +eq(Transition.pick({ environment = "CAVE", + playerLevel = wc:transitionBattleMonLevel(), + enemyLevel = wc.staleEnemyMonLevel }), + "zoom", "and zooms in a cave") + +local wg = world("gold", { { hp = 20, status = "poison" } }) +wg:recordStaleBattleLevels({ enemy = { level = 10 }, player = { level = 12 } }) +eq(wg:transitionBattleMonLevel(), 12, + "gold reads the stale player level, not the status byte") +eq(Transition.pick({ environment = "ROUTE", + playerLevel = wg:transitionBattleMonLevel(), + enemyLevel = wg.staleEnemyMonLevel }), + "spin", "so gold's next battle still spins") +eq(Transition.pick({ environment = "ROUTE", + playerLevel = wg:transitionBattleMonLevel(), + enemyLevel = 16 }), + "speckle", "unless the leftovers really were four levels up") + +wg:recordStaleBattleLevels({ enemy = { level = 5 } }) +eq(wg.staleBattleMonLevel, 12, "a battle with no player mon keeps the old level") +eq(wg.staleEnemyMonLevel, 5, "while the enemy side still updates") + +S.finish() diff --git a/tools/audit_effect_chains.py b/tools/audit_effect_chains.py new file mode 100644 index 00000000..3c2830bf --- /dev/null +++ b/tools/audit_effect_chains.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# data/moves/effects.asm, data/moves/effects_pointers.asm, +# constants/move_effect_constants.asm + +import argparse +import os +import re +import sys + +CONST_RE = re.compile(r"^\s*const\s+(EFFECT_[A-Z0-9_]+)") +POINTER_RE = re.compile(r"^\s*dw\s+([A-Za-z_][A-Za-z0-9_]*)") +LABEL_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):") +CMD_RE = re.compile(r"^\s+([a-z][a-z0-9_]*)\b") + + +def read_lines(path): + with open(path, encoding="utf-8") as handle: + return handle.read().splitlines() + + +def effect_constants(pret): + names = [] + for line in read_lines(os.path.join(pret, "constants", "move_effect_constants.asm")): + match = CONST_RE.match(line) + if match: + names.append(match.group(1)) + return names + + +def effect_pointers(pret): + labels = [] + for line in read_lines(os.path.join(pret, "data", "moves", "effects_pointers.asm")): + match = POINTER_RE.match(line) + if match: + labels.append(match.group(1)) + return labels + + +def effect_chains(pret): + chains = {} + lines = {} + current = None + for number, raw in enumerate(read_lines(os.path.join(pret, "data", "moves", "effects.asm")), 1): + line = raw.split(";", 1)[0].rstrip() + if not line.strip(): + continue + label = LABEL_RE.match(line) + if label: + current = label.group(1) + chains.setdefault(current, []) + lines.setdefault(current, number) + continue + cmd = CMD_RE.match(line) + if cmd and current is not None: + chains[current].append(cmd.group(1)) + return chains, lines + + +def audit(pret): + consts = effect_constants(pret) + pointers = effect_pointers(pret) + chains, lines = effect_chains(pret) + if len(consts) != len(pointers): + sys.exit("%s: %d constants vs %d pointers" % (pret, len(consts), len(pointers))) + rows = [] + for effect, label in zip(consts, pointers): + commands = chains.get(label) + if commands is None: + sys.exit("%s: no chain for %s" % (pret, label)) + rows.append((effect, label, commands, lines[label])) + return rows + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("pret", nargs="*", default=["../pokecrystal", "../pokegold"]) + parser.add_argument("--lua", action="store_true") + parser.add_argument("--all", action="store_true") + args = parser.parse_args() + + per_tree = {} + for pret in args.pret: + per_tree[pret] = audit(pret) + + trees = list(per_tree) + first = per_tree[trees[0]] + no_checkhit = [row for row in first if "checkhit" not in row[2]] + + for pret in trees[1:]: + other = {row[0]: row[2] for row in per_tree[pret]} + for effect, _, commands, _ in first: + if effect not in other: + print("%s lacks %s" % (pret, effect), file=sys.stderr) + elif ("checkhit" in commands) != ("checkhit" in other[effect]): + print("%s disagrees on checkhit for %s" % (pret, effect), file=sys.stderr) + + if args.lua: + print("{") + for effect, _, _, line in no_checkhit: + print(" %s = true, -- data/moves/effects.asm:%d" % (effect, line)) + print("}") + return + + rows = first if args.all else no_checkhit + for effect, label, commands, line in rows: + gate = "checkhit" if "checkhit" in commands else "NO_CHECKHIT" + print("%-32s %-28s %5d %s" % (effect, label, line, gate)) + print("%d of %d effect chains have no checkhit" % (len(no_checkhit), len(first)), + file=sys.stderr) + + +if __name__ == "__main__": + main()