From 8e6de04675727a7120e1a8683d9b030965fb3a17 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:06:22 +0300 Subject: [PATCH 01/21] docs: bring the feature lists and the launcher picture up to date --- README.md | 7 ++++++- docs/images/launcher.png | Bin 28960 -> 26717 bytes 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e1e4005..ee69944 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,18 @@ disc revision; the redistributable OpenBIOS backend is used for booting. the screen too, movies stay at 4:3, and the HUD retains its proportions. A HUD size setting shrinks it toward the screen edges. - Optional geometry and perspective-texture correction. +- Optional gap filling, a depth-sorted world, and experimental ambient + occlusion on top of geometry correction. - Optional 60 FPS gameplay and in-between frames. - Keyboard/controller input and optional modern WASD/mouse controls. +- Optional smooth mouse turning between the game's 256 turn steps. - Experimental vertical mouse look and weapon aim. - In-game settings for controls, display, fullscreen, VSync, volume, and mute. +- Diagnostic views and a screen recorder for reports of a fault in the picture. - Memory-card saves and twelve save-state slots. - Windows launcher with disc-image browsing, automatic copy/CUE setup, - full-disc SHA-256 verification, the game's settings, and game launch. + full-disc SHA-256 verification, the game's settings with a hint for each, + and game launch. - Optional French, German, and Japanese game content from an additional disc image you own; the supported USA disc remains required. - Optional intro skipping and a key to skip a logo or movie. diff --git a/docs/images/launcher.png b/docs/images/launcher.png index a8d0b6a6d2995b5dd99edffaa6a518ab2f677767..42ce75fe8bc830753d926c5f1f4fbaff0a0b1d8d 100644 GIT binary patch literal 26717 zcma&N2UJtt+ASO#2#6q}CNg@?^Y@rtyL#vLUQuhMq@Tr)@YU-U3+jFzeCEIS#XFRa z%*0KqxE%yzxnal@_wowggNM=gHi>`BT7Fx*BC$vb<1z0zw{Zg=Feow(t{@AKXb7aj zZX3=DpI2wS6~~kX={XJ06PhVt@X90cBI^D1H8G4ZVYy&y?3p`A9Zt>7D`2?KJ-V&( z?6*ztg2%%yivD-aOwdC4LANJRbB7tHBfu> zc|lT<2yPofI9?Mc)Gd@S_#6#(`kjwE?Z>Ai*j^NF%sv;!mM|=aXzuT+0^z_xVf-9G#*$Cn^a16pHZZ4hs`MKb!67 z-8}w`5=#ka*<70XS{&6dM!y0j;MRoQP`E$A z5W}1{Zb&y zG~)26KUGl>X))NH(*QqoOM-G_dpner2|w&M_vhm#e4kMkzOc1uL-eeB^+(|BES%7~ z1f48xPH$VVFLdBP-kv;6)5tY^X+MuUJtWD4irgrjVj#?T0k>fAY1?1e(PNy$veZ-E zg3cmSDkA7;qOkQQke^y5(c*(c!y;z6pxh zqPfDM5(&7WPu0p3$M)s3UXA6ScWM02^x*Fwt1N%LiqZvT_2EZzee>rNdG6NOLcLd; zy`UcRv!0zZh?gi1zajaim~HPPlC?1&9sS{w^C@G$iqap@A=i1ZuQTVtU{M#{oR3EO z0S#fzO|1ToM;?~WEXX3PvT5Ly96as>9~W}moT~x;2eeESRq<;bM+2+yoB0Vam!G}G znF3G7qQ%eFwU!a;HwC;W4=Wqzg`bam5|;Iztl}=dLe+HxAqS@V{>dD(2nKBqKS07C z@o;M!`qnLE>P1Iu5MaA1QN5T;egr{Z$#hv5;+uJapJ&Z+V)PsPgk|t8srw?*19-in zA_~!8*xd>e!kt6=&+oBv*D$i4Vu!z*&=k|y@TFPv%K4i1{0;Bgp;PJwi z$+-B%Sj2eYfEmV>5YU|mHi&aWPVJlOP-RJm-EEsw#iZ$wFB3-n5_smoc>hk1fvfT2qc=0csEJXvp*$hJ?sCB*?FdE%uyK@Q75*i;L zzuxmT9^JUTb%w{_^M#-6qf5QZ^_N;Ot7=6x%0!@z8PXhgw1hZ!ORYK+e<&rT$9-Axh>qpV}z&L(m*DC6` z&wHiDPnO}3_!HKzS8ELX$Ha2!A`&f!Vo)=^?e%=MrwYCy;#=F}LeFy`=#s)O`SdEd zjuP>w2y;Di8i|;!c=VQ_$k9NKO6H)&i~92Nayx_k*1ab#6OB7T{cbU0Rqzmq)Ik5awqqmd^i+@_97jytSekGh7W#Z5wuP9^ZnbZdwsQcK@O#rxi= z6zs9WH`E!!PfgTQ+gg7uR;9CoCMn_+kCBe0d7&M;(Dc5ZQxLFJ+E4(PiE6Loy~CEm#T#2n5_7 z1iC5?0zEvr1Ohb!SFZblK(F_%fIy!aK_E&T2=r;5_^&_zKbrlk$^YwaT1qZsNE?Ba zKCL-MGuaGPiY|LN-zzBb=DXa8*h{Y$h~chfe5zpZ+~nk(xT)`*6k59E1ixekpV%-1 z7)H*=$HzQ>=F&|$&{cw!l8Ooi`KfmVcS;d#m={8;D|6dHm4JhnFaE)_*pfh0@4pW&&uk2eRfe$q4;XdVa9LWUJqMyDpa}1yzYb4b+vT@{tRnNTGp$aBd|H6-e0rL zEy^>2566n8ewwiPu89}UDyi&h3WL=- z*fnUSze0$2VdMJ)n=iX-^&@u$C+0UYIs$LSo+y*H3*=qgbj-QqPmMuQ1nT%>7hB>z ztmZa)Fb)wtwB569GUcq1RyHYYRDl(Czk21zvL~)SG7@EMCPSb1uBm`JjjcD$b4%2B z##VNdyFzU*ch|JDNEvQYzf)c-dt(?8TdBqx&pXH4+MM!2v#fEa-c;RXeyQiVNOk#C z{QcvT=eG6l$c^e3K&n}yc$+MGx&gN}Pg=)D*C=={Q-LSX zjtg)*J#lrAq78LHx#S4YCuTDIg!It5&p{h(zWp26OOC^D1(HnnsnT>cJ=^!^@!h74 znYY_B#F>|!$_@I1o5QAlRGv!O&ahD#Rk96rk1A@{Xxe$zV_U0cnCJ6KtGNG zz=>Ga8F<@vA{?#aMWT2bbJ|aZzx~{!$-}`F{Em^i1V!d`7;A`G=uoCceWw9Y`|YWq zPx*R$cJv5DJgl%lA1E9xxI(;8;bHJ`r{2FTq^n~Py2>T7a^+f{EIX+S(y5L!sH=RV z+{5h}=&lVgcXCnT+fG(ixVXD>j=vd z@nfO1Fny-;Rm&T zd3Pj9L1iR0lhJsm7S3Z^yQA@d&cxn|YFvp&f2-8JiBYsoe`3ny^#xAJMbO&(00Z5Xm! zb`Y;1Ge;e3S8*Gz&N@o7&+3a-HbdI2G<+*O=SVR~YS%tCn{a*ti=1qwIRUYwc=q<) zMbd8)OxRfrl+K2}zj*Xh7Dvwz#a31s*{!;6)REQ-EUJv|$de(F^ho&`!KP$ir1GL* zUh?=G0*RFA>=ZrlqSuvKZQt3Xj2*?hX2Rpwx3$Ei7UZWw>G9pF?EAMODn9NRvF8~C zbq+5;MQ#(Cgs+wE+}}}%K3#jvXDMV}^QP59gg!PSOjhVBXgOFphDDcYc-IhfBKZp2 z;aAFe>2=(WAO^^o|DGiO%6{`jK>l3c|F8V{uf+Jjr`Ug{*?%>A&7`Yx8w65s@WPt1VuHBE8Wyp9k_Iqe(sKggt(9@%E0T_P!?C*CnLT2jY zU3KX0+__T&nY-IKO$@4y+IAI=C^b_~+ogGO0SvA(t^sd$)vsU4Wy&T^qf(qJV3eLk zBkk{u-oM?Iv{^Zd39*#_G>>Rqk(c$$u)G(S)f`+7voEpjU7nXTf_;-2wa94lWgMiTb79-R9Z#ChuEC$=I3D{TH zeyDol82seamB!BWk4(d=OS^lYfJMCYaXK^TPS#u#FvyPmh9N2r3 zF^I}gA|LLIBS)N2cQ>5#v>c`KWppp@zP$mqqh>I}&;@0+LQk*gKP^z}kJd5#VO zm)QSg;Dg^-mK)2}-Pk-cFPnvIlUhyCbH3NoGs_>=niC%dGty0sCd*>q3r1Cc0G>Yf zk|oEdSck5&Cz9Vm;&gvzLJVr*GnRz+Wfh36XwYj=z7BuIq3h+T%hmQno+N2nBb$>6 zxB6=j(*?I!-}egc$Wdbb8vF)r*m-JGV7}_9^IbW7hT&AW(oeV{YUJ!J=V?T18$N@a zy=`}{%|LwgevKYF=yBpVdtHOIsSt@fA{OJ@H=hnXY{!SgGqR06kNN^{=~g9XM`#3N zx9H55@%HOokp~kLW4bb5Ea%i5WMV>G`+gmEoe!SjODnKHUraJZJ!8Mprc1UZRFl_3 z0fz2pA$-;=P$@P$9r@`y$1V}+KS%gu`_@CbfU&9;M&S!`qaXE%ULU7=qTe7=_VLq<|A!AXcGM3A_1_ zl(PxP3uQkjC7%8vi@KMlqe?otLdEP}Dz7AEdc|-B^?Rov>5EL};OW?)BdxAZP4#XF z{iwmrqM=$smi*u^mw}-b)$X!sKIX$j_zfNm30EpN)uQ^K=1S;pYj6pIC4L@2Gh(dJ z%|qp_01KgZNyrk_4TuF_zT{V;ftite$~Q3M;ynECVboMNfMq08p#A#FtK`(#zol{I(C*@Cf9 zxF^d-P{?xV0cF71lZcyx&n#b+(dh~WM~aE-7ND-2@AH)Veq_lDti707N$($wKj)$Q zjIhN>@?N%I4xQ(BQYKHYalvU=Wvq4Ur}Z!Bj{)Es-Ste%;|R?d|@UrtH2(Q>unGS)pb1bebaiGp8IY zr47&ilhMa?o;V4w0{BN&{PmA5B3cc&epFa2y%lE6%$hp3=aZXc6wbyV&ASYF-^-kf zyP`8ksunKx${}1q&Vt(`BYgA2%j6#qjLP1Jvj!Kx?bP~NJZ=Az*KeMCF8ZEb32paq zRhd^w@SB+g(>p!EkJsOFB6WIM5GQI^8@YBuXO2#?R2nIMIgcxWNrH+;D&2!>vVB~| zUWy}U7d6A^E|ww5V3z2h_Ac+w%}0TJOc`0fN<7x|yLP*6n`7xu^DN#BR_VUC;ZYbC z-fg^XAbfkLtmX_EWC4}?{_C98quAnw&>(8xJ#J2^l57U@?$B4Ia3Bk8&8dL@xYKyp zFYGas;jJwm*p6B0uZ*mB#PCaM7(ds7(Y&U{(7dSku998e4|JR`d{~1x*-@^*pI$0i z(E0ru2v8Q>5XHXAGu64O-bW%0`XpeAc?MfeGt5n;N(#aF1#O}U7WqYX&LW;GV!C&F zP!YT!-aS;q?%3Ga&`oPIGqcsa-1Hvs>6WFDn$_IpuJNiXKw3?p`I~b86A#4EH_R|Z z$XJF{l@Tc`1EgHt0}vw;6BB_e4u`9KTzb_&(@)sdP`31w`2dQ9#KaBET0;rEvhoOz z-4#EcF#e1Hum~!%!EM(N?(1V|8QRqaMQ@Emkkv)``BtsLYTLc0g4fLMiDz-|Kypiik4@-b4Jg=Hv$ z{SMGbv@flBJZ;XZII9T2QWI04ID;SI zygTyNTYhJ4p*mmzgCe0^qr>&hcsrNS_O~updizJi@_KRl$;CHAtp|h_Ew_>e8XY9j zWuzTFDrLfdw3F*SE`6Hk;pQUDJDrUXnyVgugVQ}^vJSYas(5y{A_VA zw9KIA%WsoT)y%v1X&SuRE#=RpZG0;nox_w|^|w(Pj2yM~)~t`^VhYQEQZ;V=@Z*0zD!K z`Q=xwHuL6e?0eycYx`x98I+v9<#vDjufIAfpeK$^(%>1%lK0_PZ5OPAF}bcxgf=3KkT+z#WRBClg{e*5|>W|-7o254#36?o`JwjIW82V!h)ZV)TVXtu$gEUyeQ}`ov4gQ zkc=05R*cE{SQ`a7{B^ImNrE0>^=CZAN#4F+JzQ)oXe1r9Wr$DtBN-f)O z=Wv|Nln4Gbp$-khO-9fX#}L|-Q8X74UwIC$J!(KHvsI8HE_nk&52EC8-Wm& zm78_q=NCf2C>|j80#&wmVH3~fpAG_FNuIMW^q2IE8a$VGi(9?+5GZAWKu7;iS@EAS z?tey^hbI+{k9U`%#PO2|-@8EgBaP#2n85?WI%Kl^@lLAS?yS#l6UJ^1)+b*^RBbbP zFku{JJ5kD%nLQyJ)aZ8AFRf(Hpw4lnn+@tc?@w$&8dnNb_y7WgnyTWo{rLxh(0+IE zDb(w{XX0B_Sm=GS5V8v=z>quD>D=3y60^cUEc!_qAtUb-*GSCZ{##*puOosEP@D0*EQHp=_C^N?Rj#;LYOJ9t<=|0Km zED<4&)I_nrzF}_uyVwc{u6M6dOzjW{R9;b7b)TizDHWTsXLw5*=d!2bGPQ|qeNn!0 zeKf17t1vX^w;i_mgT&|}=NkPN8RAB3*Ym!g2SLcjR|w+ExxC5jDs0m4!MDg(vL2sA zBPAQ%#=YP1t2sS>2P%X6Rk|3~ee%E^mi3%YUxV^E#Pf8m-f0o$-G%P-Ev!6UyOv|W zJKNOW-oCsM78VASA2=s6f4WsS`M8FsWHLwFPc9T!y9fB?59e&A4w2tbLXUSUho{uQ z;9z=@fhgv6Ujg>9!mCVDI`7|qydig$LZI(#jmKGYvv^)N6iA@s~LZ&??8DrK#a{n5|tr;koIjlsA`_1!v=OiykWimx5{8Bc2| zCu||KcZbE?^j%_6*xNmVMc)1|Akz898gwCaCO{DAf8DaKOGfC*s9+<%KHtCHdXA2G?(4A%@E7(& zqdo;dsXVn}`#bC+<}m9Nk>p-CZfjk?C9-}zsw9;;xbD5R?z{6a^%(tic+ldV{Btz) z_D-v^D^^GG-RXO=RhrAjRuDmfXCL#H*TLVR{6+D5)s-qyZ&~z3QrZF?or>G7Bt*&c z4y7^=@sl|-{y{y(WP_our)GY?IiW$y6+K0MI3M`?)3Y8Lg+ouhsoYWjV4XC8uuP3A zVZKSs%$~NQs4-zO@q!$@i~=?j14yLT>Yw{uSROFUd-YG!?5tr(t6FW{+`IRaBOK1e zCHQqAE}4*FWx+{};KO^V)OX#uV~DMTkrcgbCdtENCnEf?@O$a;tSh&1lTNM_uEHyf z0KRSegDw@ya>_y87j(!+u*HQ+yljYaHqkG9PoiNg$>66FKq>JOAd;Do*|}xlJ@4?N z6k3HR|Ap3Td*eI(bQxVX#hbWxB+|rP1N0~PfAl|a8!rTNyyBG5&H+GQoW7idsItuA zhzCc*O|}^o_9i!5hTY#%*iwOa)>^RQgP`HY*}GM%WQP@-{DngX&(rp%-@u#V4I!pq zvC;uObkyH@=mhOX%E5}~5p;s2YF%~E(vqdfVqhulFr%G^9}SzN2gbH6_6H@e*ky;o zz^PihX+vq%9cy{rrYx>nM4fTMWEBS_cukkntx=}aj*J(FV<`LLa-9mQ93O5EvqZ?5 z@2Q4R#%_OozV7toj&kc5L>2&!d8=}@Tzh}v`4q92r5#nh#-;`v z^_9R3svhQ#e-8>BOukzpHz----fOCJTD5u5Y3`Si3**Mba<7>c*_!fXrFZUSbshQY z?uGl!?uPE(GI5)tHIx~t;Cm@BJ-gU9W9mOob0&)_9@H{bGECl%bNrv+zC!n$pBPM-!_X{DTZMlp)};@p{XVU*2cbCq~U zF?z2%ErnG@+d-9wEKq7|H$5#rjt1^F@ugl=C9sCr%~x=RsF>=_-pkF9Sy}Pq^3J(| zp=Uc=!-j<6v;ATYM5TxjMCY^J&-Ww_!(@xU#CB*pAcYr8-07rvOyY;z%K+r9CC(9v z4>d@(xD4K9+h2nC>o2SfXPk$ZIxkiQv&UKo ze4Q3^lOC{Ahe*xx->}R)1w(BW@QJ!j8P_Ec)W#9dT%jw~m z|t zH1gVjrWMMa`Ywl*B;1RW8NS~0Q{K$)VRo& z(K^XOaDvQXSot_^L4=4+dZ=Go#(b*zLv?P^sc9Q>NKCXynA6GrJ-a)gK7Rd?yny!{ zAAL0Rq1ERplN-Qm@(T*2o8htzUhtP`bNQ_UtuJZH&#J z0#HA3ROKy&jku&N{}aU03U`*1;locc?`oUHNAtYiz>WFBU}u|(^J&TK@dyJj+;U(F%8Ux|SBp`BRh$ognSLa44eiTmb9J5Q_vF02m zGQa2s(@EH>g4PkjL0GI-SpS!Y9Wjs-Bd2ll%Ub`9Uz8HFX>H$C)K$O+1Q~S-~72Dg}3)GbTk!j6P z(hnEUy7d_AAO_0bLd`Ucmw>^#x{Ho+kgf5OxoTTIP0i6@a=HoG#}#fDBJ4x=j(^l? z|JPdWzX7T!egJm@Yp_>*LMYeb8@8P6YxYW2rm3xhKuRr9U;IqMY>7eL@m61@&w=N1 z*6i^?Q~50%23ud;J=_evLLLzFVnS7+hA+rolcOMkCvdgLSsTz2Z9~h&@1n+wpRP&D zAzdpBz_19(y*b2yxbB^pcd=F4l9i;Y?5Q&}@fsD9?zn>_&oZQZ>!*3y>aw|)OB5SC zkH`b2igL$B2Tb!a#d9<$);xPAM$fl}hjH@-}V&X=*qL+dY=v!F{`Lx~xvL1!gaYI!O3N zRVRF#%S_S3GC4e%Vb198{??t7U1VQh6p18_vO_9yCR9&GjNLo%lIk?sCde05)F!;3 zD}b!ka%qHVkJIWk$X~owi;Y7_tb?hM^|N$~$~g^2gZmF+Svx;|m3ceyUMuner?R;YacpD@jRbkW3fTGi35Z*BS5VN zDp?Hg%p;YRbQDtVH4t_VNo=_;BvT?y6&(fmmx_$$CsM6H_T4QuNpq1_OQgBKbk3g9 zWbz9&AWqC9k1-ekI*~)q-|H(TM~v1uT|~rzi8zq}T)=l)y3LnV<6q2njLXEjD?Aom zk1F+lEa--_vF>0hg?rDQS+2ZPYN)T2eVaq9wAs?1jd;- zpd0U~>0wqJ67-i0uh%|$Arxv>if#QV6-6gDhgXY+IR$3;E}v{HL{7EIhQ`)cm`<(5 zK(6V-Hf$hVMm((iH%&`ZU4@yI3=>{Cj>hvoNbtP1rByK+e5cnc0(jLbF(_~N`SzGm zcG^lqX6>x{V#PV{3I;%tP2NYMuZ*W&h$|{2?$D$NW(E@|- za;*UZS@*i%&I^cY@-eK^na8SJ+HO4s)0@j*O{+TW34=+Ikx4SKhvH#XI+o@ur0i^d zCuiYrP;@m>?Ppo(T+S}K4Oi)k+wF~lv7!~mj^y>=x~cd&VcX#saqm-fz>vEssf}{3 z!~ssSJ4d6c>sGdk0Tvb;-|NIoz2nSl4PIFtHeU2B27FI!iAF3@t^P%&vl8~q(G__g zC2m#M$yl?Q*H1meryXy8X2t$7dT-?2OC$pRe$GleUiSWXfvFcoMw3O)rU{KD$6fRD z_X&Rnsp&rYaIF^j8hYjZdNu}!SZv%AI>TAm4&8a?0<8Kd-|7?kqIbXTxh&awu%aQI zS?DERwwigzl)?k2lviT5v9vvf%H=vG3xh`cl?r}=-*-xa6C#r`WzF;TCB6hF=z=p# z1T;~4lf~YZ9>tNwpBZzYcao_irjQL0&o^5Of}6V|GdeVV>}oSQdE%axu1Q`^VYkRh)sWdvJ@yeA zY1}K5FyPA~Z*UH{ZBh5B3pcBEJpBj|3ER^-F9u76&fr$WRHu>8jpJlW2EWnoTzQv+ zy_!ufW+c{_Z^q&K3R8lW_UZ@F$I)Edo?t%f+r3-LP2FQ}3yz2bPVrV2d1+dVn+^FQ zEgCDjwqClk$F;Y`4?CW`0aCQw$1gG1T9b2hdZXXEr|nn__6(@qMQR0|D0vQ&V#XVa z85SDSUPmzwGTVrs9{~<3dw@;05EMS7^i_Xs4F)twrxzd)X&X?{{K|6zkWMU+ z9ZU4I*8@m7IXML(vn+sgaCD+A9h23f;=|MiIKWus&sKQ+x(6S|5J@mcUmwl+c>lwd z>v*b4k3%zBrUlqetcuu^db1}uD;mqzRo6oj-%p8H)i0tIM!49%An*>et&RZWzgd%d zKZiVslx$X_nY?|yg7XrHePEt~!Et@V80p1F^=iWu4g#sFi{k5Hbzh7ct8;~h4Sg5f z3#^B=;Z48&-%TkO6`d6oE2q@Ya%>9dW6zu-0e$51Zg-bEvg=tLvIN*-5oBF%*P10G zMS)_G%CyvQ#?=|Vg+EY|#?dPIk~nU?x_VL`Z)|}s*5uY>4A%`U8Xv?}yBSiE*v|Zw z(_g)^=iM$qC6yJ6sWuQ2z8{I$GkaDXPGVt9Nc4;h)rgj3p-S3Hv3#qvZ$Cf%*7U*0 zM%%1YRaNejWIpY^X}KAgOzx+<2paJxB$k0Pbu%UwIf=F!YWiHUm35%EFoR^}LCqiq9J$#8DHl$Y zsibiKayJ&I8aEep&2>3>EZ@7_H+Xe7LHFcXTTs+Z z0k<5_ldssZne>L|q^Nk!4D7|0pcu~sJ5d^P?W~gCv|2Y)P2}U~EQO@LU-Mx)33f+i zC#I+PaB1bkT4~2UMt2gwecTmLe(dCwc`#br%A8S0rT1{<+1@CGr%kk?3v)|z1R(|b z?8h}ypGHCfGHlYa70BTUv_};xrc#fkUwN4SeN`1oHizGIs&EwCP1Dvth(8P~8EJF?exSE2 zf{|%RU_UbzwDmlk3~b?gI*Qg>37dnXJ1*7f-FKJyQd6IP8^V6BxOL?>q{(CRH>XUK z+D!GdXXa`=PX{+HuOK630n4M6!y((S-}Q6K3gyMg*@Orc{7kk%rCrck>b~@jh%N;f zqMLA5!&mzWlw@;ue%Y%Lfvdn~NE$`HH_p={{Cc}SD$3nFO`PANt9V%s0Ui4nEr0FP}QljlzobP)VqKkPQ%Fi%=`OJkS z*i6~(x-*hFoutuWekA1yQn`)Xfx@~h?q{mzJK+Q@iE^B;9tYTa6zb(S#EpDB?<*|^DQicj-VyE)pJFR;Wu^~t)5w_Zwq zd28q&$E2SIn?9r7N)RqL|EU{)j@sRVR-563;!u4xYj3lYYC1G6UD1R^PVsK$=Y>3I zrTH<=arpOzUpCnV9h=oAJ~Ju?Z?#6pR$%eVf>S&mQxndz1}hhndhpe~b(y_zdOur} z;ngdVpL{|Gm*TYdz?*qmL@NadR7zN?iswmj(+{wZ>s;!YE7)MJ->F9xuQaFMvUc7C zR6?^yt{4Gs;L+x(=;#N4$#M(v+XVt^a(LJ`-R&PeLuWY2beKvlVAy?sM)9HR!qMk3xe-+-53H|a%G9yv?zrCa6Rapgq@d~E!@IMd z)=a)%T_(A*5eHEHng_UxAs=z7pFM7=fuASnhPdu{96IIR6iFE+?kr4pVVL`?CMSxHE*MV_f+?nj^}8nnBGn z${W-;%{F#MemS~%c{8%d0F z@LkDi#-^s?E>_U%*~zoCY$Q+0toPWD!{qJ`Gry5;$i%gDH6q-o_I3EtRHUHb<07Gk zM{J3*?^tEUK4Bk6`7s{?$}XVQGADwMylmE{Ps7EcX+)MS81l9hYeo(Z94GNUN7TOi zVPI|+_AwXc z+Ggm=HPDak!d#M1KM#P}e_nYRNT`6VmI2;1AM}Ve!z4L-ufSln6UpOus`!@sw-OAj z!g>-!$(#!n1IV5tKo$R$Z~wz@^dAMd|5>BEj+diQ6|kGBud1x1{-=r#^T8e~rw9yE zUlmtHB_t#OmiBHy;Qo~Jnxw&Lt$%TmxtRwL?*Fg!{plmu|L{0nq)?zv_Ga%}s{{M@ z4Q8*)3s}P)tRZn+)cl)<^A7`Oe;Tfkc@!xb_7ba-HZzJW4-CuZU`$vXhk6YqPi5-G zq-icUEaB-zEX40ow+K^4+_X;yvF9`R8O-TRJu^gVq*?MXvMU2dg^fWH{tck~9VDwy zVp%6Husyl*GhWpKV97PLwcka2931v%o4hJ1nxWo#!uRh(=w7k9VO@+@%N#D97C&_F zDq{nr=Fk)ip|~GtG zeJ*l0e8m?KqV4n_{zI&;M(*qf5SjyACsDNl&jk$VAX5&VTHZ62ZtM>tf&eHW||IR26cc=Sd zIG@PHAQjEtTF-@2nadiXi&1&>QBus&bw1`4l;>S>)`_ypXY&&_)*9N1*lpbB3$q8J zr;gElYMk>eV6h23UdYkW5(LEgivt6my5Tp&CIYV5o=HU@e0bUCEZMj8zSA%;Q3yU& z3jb=r%_Oz=1F8M|R)kFEAy7~>cDok5l4%2!Trz5EFn^Ms>mCF0@hR|+rlzKwApyrs zCq*eq6l|goUyeRztEt!;&GNctJ}$pf)6!6*Y&hLeW4o-cdY!4|@VRY?>nm~wp)ONT zOm%2IEV0QUENp&DR6;Kgvs|mIJMC;fUSP}Y>Xt#hXfw@5WpzDEb8=)eI#%qZfc{c3 zb+LgXEss+8+wt1lPGEOjZUe^(h^EKUM=V2$`e*r;eS@d{X08`8Yus9Sv@@Pso!63#Ncj;FSo=?^3Zl^7NX&<)c_ zymT9T{C0?Fl0I@Zdg4P%SKSZFh3<+fuclYOzPk#?@EQmPs&b8I)UQ#6FqX^vJ`*Z? zcz4wtsn=NM7gDTLQ==miNNz_No`dM;u~-3mJXYykXC2)6d6O%I@8^61F?t5JQMnSZ zy({XpdLvI|FPG-#13F8^x2C(@0LZ&f(-%z9+d9tOE(_UHMNj%{at18fGIO6+AkN$T ziEXpHuTaVFa>?|&y?S*yTJwOIO~!gTwd5~b#kJ00t~J;YT1D8PA5w=KhzI0kps0Z8 zCyv*~pVyey1 ziaGAr^~sn6#KQ|ccfy)>Ol7X5X?Q$qM$TPt%G}G#+^sGUuqDxgV@p_%Q*_iedWZul z7yXGD=1Y{RaLg<2wV7wzuvY=;Wyd|i?s z+q1o5`upA0@Lka-ezUv|*|V`uKF|9cO@X~b&YK!HN44DK`$e;9N*o~c2aoZ>GmBS% zQVOfbC*MNTpP~YyLTZ`e(-#|~s^s!s^@v4#gR6(s_T5%t-a)bHxV<-^ii1nk*Hc*J zJvh$(P8>!%u;aCf{yG~XL_o-cH9$jq&8`Y)cqPTfIrORrtE;Q7+Y@hupYrek2Tk5z z-ye{q*w=#V^a@`8%XpFaeLmyF{2B-pSC7svXPS+@1-aWR_15vmzigzxX)BOBZm8)D zdete^be(Yx(vrAYRs7Qq63E=g$Y{AhAbLB!e4weRS$UOJTq-#dlVgO82Pwx~&W~siwZ>k^l}@v%DYO2;XrdUdtDYedtccy@3%AdO_is(CYck;Tf7Kx~ zeXjfRW7rU1eR$xFkk#)nMFx>&)h@|P|Ce2%JKiMKcb0w<4Y|4F@FMDqqUdP%5^25B zO1ANq%LCFL)2+k^x>sNNQqUHczi}U9qN>l3=yUJ70Py5!_6<=Fy(vpJ>g)0`Z&P17 zoF15-w*i41`qIJ3)HDc)XfAbg>&^nG+vaS>(Nj-59`V_loU0?AIdD*gN-<7fI!o@H_UsQaV%vNs;54!l|)~8BbXG%3FsWyl)znM;#x8*g-83+r!2&-w6 z8)zYov4>iPl>NoAn+Rab61jXauyHA%oQ++weI%0qsS91<1Jo8aiWSaZ?7lww%qQ*B zB|b(){}7|b`(H7#Z8}QD7vQ@ER)*zMI`_z()M1$ee=6tmGxX-B)zqcO z5IZ}&&Pe*o)`NqC)xMM1AjOr9APH)2czL)QOw5KPs1+ZCH8>}Ek6 zR}FY3CU#t>-tdl4d;kpS|6bOR)zrMR1BgGADFU`Y#V3;#3}&Jhvqyy@pr5N+-b|PDhF=L4;9Xf@a^r=pTWR09@0j{oKc$Y zX=Cq=G5@c&k4ax{|z$tNrp35 ze$4pt$_ayhbW+6m2fg0qE&9al@SolWAg18680>jVI~VM`mSz`zGsqr(sU6WMiUkeuQ9=h{HcI39gbNx)%i%kA|;i~Jxm?@5 zS5J23ozkIxy}iq5i>|RIOxe!S>kRmsJpf&kiOIUn_EsO}{a$<@@FTFjO$96x89)cF z{zJ+!`AfSQm~;{2n`PZRTa9zro6p!xpW43&A4s?3PYKGK>T3 z--~YoS30b3Fj(DPaye{k*zRr%*hV_h@+mn8!`PoOHJoC{8lzSyO}8^w zUQ0uuhd_8KAQW#4?a;trKPbuk&tb`-ml$qkl-m4%pt{>5=(J5oQl>^PWZNHaql2r6 zh!J$t_8p1q=1XD@u6z&Rah6OMf#pWWW4ckMQQF%I*I~1dDzh8eI*~H{rbseHwRf8x z@&=r_<6BDOH_hn6uvnWp(J9aR0(J4C@~uk0YC+2NN5^VE{wvfJ24BrzDO=x6SG@l; zhy0`PgT6-v2ALR>() z!(+9plPW+IC}5yc4bC@ygw--+_ys5;26l~H#MVdY;Q)Ge7%UVeQ~9#i*48IACY@cN zdFI-c?-4ENKl}07vY_|9tKYFE?>0~~WZT-m4{ybSx)IXq@Or!VS0|)WW~c9DvjV%ij9G8|D|B!qo1d*xiO1}2dsZnk$c$?~ zv>QH}IgXRgJ|q2+rQ3(@>xwTCV@tI}GWD(o>1^fVdn(Q^%rs(xO}?NflPvN*8iRrW zHvaYNN2vd3t?#*~iwpnGrx-y&!5ZVvn^PY>JUjp^s>GKN2G1{mSp1(SNr4JwP>)^B zT%A*h>uXDZVn)u`<7iAqZi0|WXU z&}$$p{2i+1HKsf<8>(4=U}!Pch3~$}s z&J$~Za5JYMU#T2qKF|P`wh&`{-|phm*SBf`-`e7$(ZLbmt^$tr42oU#b3EXz;|Zj^ zV0k_7%(WA>zsxgrroVnEz+1{jnLwa<*Fki&bh%lw=ffMDTR+y_0at%KBj-xRYjLq+ z+@rs^=|7qTTr`RP#+M=_T~lbMK4cVJFfjVKI=tG~wv^dU1gxFy;R*r#OB(4?&cbfg6V zQIM)g6{9YC3%y8H5EKNIBGM5SL~1~KS5T0y^cFgzkWeBJNFnD5u64HVv-kP-%$e_x z<2W(AN#5MgeN_O^dQdR0J^@zy{>oDC(5xwz+kb&ofKF{L;9DOm_kg{d!Z6|rQb{p$ zM!+l-u)4D}LqY7qJc}YMY{u~8d4Y{{p3+Of3Toecykyy?xJ5hnu z*R}JSdtB04lbd~7M#TrM(sK7qnKby`cD@Uf+~)etG-Z8y^Km9qPAhuuMg@=$rlzK( zYyVnv^zk7fYjQ2d-?=r?KKO%z0F#SwJ`YSZC>jeU?ogJec>JMeGi>u-oeC~A!m|$tQ0NfGvml2g zwRA^6o&YvT%ErctqYj-ssLJ@Add;g3r*4v`Tz`Anj*K>irA*?gK4i>`9|~pqF-(Dg z6UfH05$VaPxQ?YQ;7r4^(cmlDjJ1GQaN`*aK#kdX3g{Dz3%2^xIM6{nY`sYYI_)FR ze&T953lzE6SzqmKb>1>~+TR@zCu6g&^6W!PheG!R^rk3oS(Hol1hC2t!?&vjkJBSo z*8HizlFD_lcL}db6W!#swBx zKe8phV=dK+OF(49CuA{Sag?gLb zxii(^SI#i^I)Zaf*k3n{s=b09PnE6HJc^KOdg<~jbcrvi>s5A8fdY#{Go`$;>f!R`O^LiV>ygxj~49uk6gH@%as=Qggqjm=$K?lyORC%lhBuV*cFT-SF zKODxlw^#qHlwI27O_YbA{ReJ#13n8bH3)X@D^@ZZ zVpyV1>Mi;4LJUmiOKe@Fj`Hla1Y*K4!5wC~RXd}F9I(rl3(!S7F{;rl>A8uCw8T^Q zt}1%nxi|=OA8W2I-W|275mqS86zo5f!aaZGZ+6^vLR>WyzprM_8cbPWg4H68H?$u;4#+z?-lr|rtkaEOT6 znp)5TS*Y|q0lZ}x#5 z9hW>{>HpnazJ$Ey&8lYvn~%{rw}e8T@v`Hl|_}MUImiMnR80eKc264 z*0b|E`#16-H?E$W&-T;jPUE9IAImrD5Vccm?}og#==dGpbGPX$EZHV_0>Le(9k%{{ zx(4Z@;SXki${vEx*w9;Ow0t=l_OS>xsM&gO@RTGd615OVV4{ zhkt=o&+oqBf*5F!f=F*v^2I6cEP;45|JGr35~$MHUts_y`dOs$yFUIe7m|NZ>;DI8 z`)?2V_ZIu#9`@fq@c-Qd@_)Mg{gvxqiV6QDUOQIC4xsJxBKTr#q1L{>xXq*@#ih3!e)-3 z?AwIxr;hh{G$NkQ^0o#vW>6)!%gdaX+~j}epX5LK;1&8zl}{nUMW z#S_WI<1+xK<;}O@M`N20=dadUU2I-G@Nf+E)Wg+;rqJYKTZk>q2Sm+h|_am21Z09tok z#CZID=pNGoIGpS?_RvkW(mwpNj!DNI?PQfdKIn1G&|<4|6t|>}Kn|^L9b22KO5U9z zKD(@*yj3h}EaE&eS(=tmOa~EYoqGF8RS6fS~{4V+$LZ%5oe= z1%n&MSNVNFV}}fLaue~Q2YR$y&u<5?d9?`HvU6@{XgK?iNQoHhKrQ51j$2Hqt`zs+ zjzLt%8YY*}3Qz#$is+D0Zm1}iGC8UOAT~>_N;-(Ant)XS5qwq5_l=(h+2#G~EQS{U z8`V_{MH>{t`@#td;k}CP_0XoX#d{V?q-H1yylA*HkYf9SSeY~!f$vTC%636W)gA-{ zrS{=oOf;R1Y0)c}4Zt|rJ5vo*%0jrAf2EP0xD$om{hU$7Wu zuIa~e0eRElpCRABo-Lo=J$SK%oISe2Ncnh_C>_joze3}s?w22gkB6L0Ki#&Cc%K|=!U zma%bvoa1mD#MUf1t$eZ`#RWPNLGho3J+;)&h)>>yk+&BP^uCq6WP5av(B@J-KqJW7 zj?d9BmoyPQ>iROV_0n~a-Q?bp2QTv%`w?T_zFO)kBgG>2Rcb4Fg-y>%lKvS z#f<(RAY_xjM!%Fp3}CdFj3^SiV(v?w{pA2W(!Iaepdk8X9SXwOYgX0if1fmm&+M98 zPFmw^Zs!6am1Db)*Zd?EGJ!ma(Y=4J;1!(1D`_v#u{WmQuM(BbT(?ue#*MTc19D%_ z$Nb~r_;l1&g}kfN#|u%L`Fr?L0acLkwKw9HHT`@kS0^%46j*CP{Bb}fNg?X}xkb;b zsHM|;yQ9$#)+3LBlz3-XO!n_ok4xqJYC@&O2I^smf-(y(v>_{K=eiH!PBsGB5l+ja z)~ON7^sL9euBN1txq?u+pz_?M%1`WufXviOZwbX_ZXNxHqnC2il{d;o4|z|dhJOU9 zZ%?{TyA4$Re3Z7&mQ<4sYe|AF(Je3V>c_KMabn(zkAIEbbiR3KWSjFU!77xlGhLPF z1k%j?RuM|s7f0{bTr*qT8!bgt=1Xp%Q<_fok2jd4=`DX2JLE0bBP2Hm!jYx3y>ZNG z0Il7h`y~&EU`&v&M6Tf%F`IEfZAM5KzJ0a=I$M%<#N;D2dqU2Gt9Gl zIqHLOJ$Xr`*UZwjew1goIbYN) zMj87P5%KM%p+M!-MWPiZUF22y`xxrAkZ4wesK|N`@vqN(`04L~tJW-jp@e9v6mFc- z-u3SJI|vWN$-8a{$d!PX7-mGJhOENjO=htd?y^#ax0OB)iGTcVreo6L9I)5Dcua~sP8B=Qxa_*7;w$C zCcDErXF-a`YZG?V{imIEX4aY zBv)#>JF+#Im>vuj2+`utT~Q;}2Zkd;08cHr7(|cTBNGhi45gzY39pkK!>tO{208NLplnR*6`Cgn~C8%X3@qd14 zp*}rM`+i1Ejj-f^fE?~s%xd&?@;&xp%eEw}zn2eN4t{`+bMB$wgMCaz*p@ zqb7=1H1-Tj&+zAY=4A`gk*EYz^kzeFl@^;9(`hCbr$(wW>o)TuujxkRpx4J!G0er7 z+^tQC`4@FM=ISHks}hF!cMhbUy0h7513tDhBxMKa$8U_Er(!AKHPc8kMOPVjs&7<; zNlv&EFEemA_i;C$)~Sfuaeo}j$I;P|b~P0;X>x+OTR2}zN=jdO%gC_G>S5U^Wu`s6 zE%hU?x5Re(@})2wA#Z}eq2drEVN)i4ZJ9aXMIQF8V~@sefW9~+0V5O7*(|VD?F42} z$^l5{w-l}Y1HfmCw;Ap24+b$uw<(_xGQwSA_$8W1`Bru%KYP^d6OH zZQHLymV$Brm$T%z!%3BfXE-V#sQL$R-gG4Et8?Q)=O2sO=+f#Gx) zD7MA0=8Q%fNXXVE5l9!2k~=2G#=}S$ynJjl3IZ`DO9lFo2!2Yy@T0o!{@a+}J1D)M z!B{E~uvjuGvFW;hT5|j)yNhq{b&K+rsEToj0THBE?4ty;3avFqR>LoAu{wPka$0B? z3L~skwhAW6nb{E3f{9G?z2+o|m-a{5hg8j|$wr7C?+T&w`FxJZ@KJhG-Q{{siyE3zBMk^GB8H zg3^akQkWZdhevQQIdqkmUdKo26Ho0kuZyCxRQS?gjiz--wZ(R?R9`tSn(D^UFa2mE zJayAjcVg1PzV2|MIm=L|Ra6{p?8^DPRfz}YnYZW#Y<+w}bE$YOqrANNR%v-DQGkIQ zc=$j?L89}QO4a5suM>0P&+W1dAU_nnkW$f}7qTz^90wVWNst4UCvZ@24DcxqmfVPY z-mFgQv5H{eh+o$4JQdHO_Km*_`aS6QuCk_e1d-D@ou~P#Xs?r(`N?o87MCuoi1%R3i z{1{qB@UNZ7an`98J^VG}d-yUJz4 z*FLR1Ws{LP+O;5DG(uxY4us!hZi*|J76{|;bZARa_1b^|kaJ&k%?sk`_Vs$XGH_9VqjtG- z_2XOVF7uMmd%P@gU*km!p&spGonbpuUoiYYvVs8=%sE=zXyy{+(s?~>Xi~&;lZ(Zy z%Cu!p42Jo0?|tHT**w%sk=TAG{Yb{bj9Sj#0mEZi?3o+DXs&*hj*C~%YkBH(B#Mrv z3u#7)#%hh+ou^HHJY;R032x#zP!{v*V`10VE*k^tR?Um!>*#6;OM7Dn>pIQ(mc@#S z=LBx_5j%@n<*WB|TA8C4$J#t6;)V=UNLnxM54FkNtCiP2knqR4A_$2*?n|t* zWv7G+ra$jz15@A1w;CZX$DYS5i;T42T1@WU<~?TrQr8Upylc&~$xeURz{d=IfczF=vkm=eYuws`^Cko})MH@+t?_+P!!gJ4U6fp~;FOjN z%kf>?gtKktgPcz{mIw3GZzd03^+$|>W|qzcUP5@6%}vUiF|}UvQPS?mX0BO&Vh3vv zE!-}jgH;G%&yJh&&+z97FPqLRA>sE>;v*#8<1?Q>??-`^N z-5TzfpO;g3WD=ZUGZY8Gw!Um`CgDI=snbMNG_;GhtPUB-;`lrOWK{TKVcX8acG|7}12_iOxbqxV02*#8G=!qGl(FS2UP2}=5|4<+yR4RnI} z?FpE9VB=)6UI&ElhBz534&B@NWBVKg!USxcxj8w!u_&;R*jWRQOW(iLrk}*&*~z#j zobos`Bt#?t;|LROnqgK$k5@*egF`{H+bXkWUGe z?m3FG{vY71?2x8feVQ`gA5#y>JLNumtG!pNbEJJ{9mgui$mnT*>};QD8k9A93aIk~ z`cO+CKyPibpJ-R7R|e{)edMjWU+U%(9ItVL*S=WR&N%^&SwilU?MDLd;Hwr5erwfx z0kgSyXG?GltGxTB_L}gmxAS{76d%7P3R^9xh$DTm+XUx5n=I%TWS32iJ~DVM&J)Bx zz@10AIngld<-T7^7-Y`FfDWZXTyMoq2Tx%LlNH3))6l3do3G-d3;C(c}*rF5J5y9Oh|*JJ$gHVnXTOM@r*=Rvz98DHd0DUGH1Sw$unJgvmq;zund;e3@N?6( z=36at*5XR|10AtLh$jCEFBPHGX_?HW1$xV5ig{&~duJ_^5_=!WIZd0#d(fSldo||E z!nUD>oewudDsR4k^=?*whdj%(o}3jYyFdmPqpdPBY7yDsI@2|g5DX_^lPs4>s&m|` z-=O60`CJhRUk;Eyg~%cmQjl~!7aL$DO2xW~63^FOO6)PN;2qWzH? zP1!~0M3DSO#j)+(Fmi=tC9t#lM`kZj(_y;-R-WC!`Oe$Y#LyL<76uhT{UI8D`1Mfh zd6*Lc3%v=azO7jJ0ZXm@0E$oiDh`iIWx;=;e=KR}+utSgjXvJQ6hc+7O-ISL2v)Kg*WP{j&JEq;kD_?BH-~%ev zmI!-!-TLpG2UaZn>zPcawRzM#jVD8t>NviK6}u=Z*%pfJ67aok{9HBO;{+0 zY6O$|Rr=)*(u>H=H9{cHA>{_B_hy&e*`){7_=I$>y)E#bMht0uaCLVVT=iF_)KAO5RA%X{$S33-%crT3enPB&@eXPh9XMiT{b-sP{G| z5hI{CIB^kbTy6Rf1^N(8b*{3$iz*xEE>6}%y-!{XEU5GLtEfKM-M2{^_kX8b_b_ab znMv&AnAU{LMls4|vV(W$I(8Iv%~?y^HQPCla7oLww(^kz*WOWZC8rvzqhUQw9`LZV zgb;_h*0%U>Un7sn!FD>aK0|IKw^{F_vk&mXYG;eNm~Y*(Klb@0xmTu4mu41k3q#i7 zK^D7lPf}%iafyI^)AT%(m|H_rp|8Ipc2E;hu+2)DAVKd^@j9_UOci!W+2ZeW4!Ada z>c!TS-hc}yxkRFVcU9mXNFQQN@nl(MW#bEx+fh7i=Gour!$ExwF0u7XoPq-yc_%T( z1d)y*;ijj-oE=GGRKb7C=?h%-s8AoC3dEtt$IZv@^>lZ4D}inR>r&VwYD{DOSK7_m zrymSn5cnkMI} z+n0y8dLR%1bXP;t|8S~M){_u-n!RHBacEt+nlN)x?5ajFr}E11;udXIBu8}l1Cak` zjRy5?tq*RN!;ak(XS7=n-WcJO-{5UxuKbj?M^bm_8?7V%wjB?CP z4~d7FHu#O;G=0%1+~F~U^(vK`y{P7m_59%3AnYvNaO|MI%5GRtFjk$R9@PX=l=Ub* z$0xVw({BIliX(Ip&6m*Bx- zFa7?`JLkN2&$)lxd&jtp!N?kW?_@8T>oe#4%+HEYQ;~c0;MoHJ0Pslu!+Q+?0JjwT z-+T`jd!^5SO$GpP*^z($R?8;~wnFS;G#5CJc*r$UM0gsM-T9cS zvBXWG+%hbt&h``dGhY^z&id&CnatoFDbC9gRJ*tL@k(d=yrlP4faFH)&-$wz|D#r5 z&`y^BrBiQq`$G){b=y`p8tzu4qcDdwT2Sl#=z(yh&8pj|8G4X_M>uzyK%{*Oa<(}r zzLTO&()>~8kEBKflVib)emT}~Inicjl^m5GnokGUZlhNzT2;Ds)A}nVizsqiA&rD^ zM9_M(c2&t|8$@YjWJ7&Xr8$)lF2Mm?l-4VO=lNe6huur#uMiz>JHl(JC?Q(*Y@fw!zpf_4l~h~h z;I24pu_g;o(tmoY*yh6(ORaK8Xh!a4RNbYB=6G%>I+bg4lZi=#Q*N48s9d9!-&n7( zPMn0@q1@1vw|8e-?9~K!LC2AWAmw|RQ|`tFW|fG@ZLxj_KcwaBZSHP{0rA@R$uJ?H;x~=T=_x+IQTa*m> zh%p$7V(z+;SVvEas0$*VjA(W>1!MXp(G!P<7&nc4)qp>w;G0ZS{n{^kxGX?R7j9Pm z_7;8f&TtJjd#31~nhIKu{Wri?x(pk1UDG2Zy^O?dxaswmEje=XGj((?CL@XsA8~^6 zK3euY&I=dZEc?dj1svG7+C2;O=oxq`3*WeI5aYZHGTDOESXD!p%Cgz{P?()G3ONYA7{*E8aS&Sxtky-C~x5 zp5Fj5osuh!_SW6+syn?-uh80a)XrTwke|n=71#8bb6DP7F`Lf&EsSqIc}`LB*e!as z*poisB=41cN=gbh=3*!DRiIsrCs8g&2{r~qLubH}@G@2KDYOa%#7tF7w)&oznC?@v z9L=2Hj7_80x(BvLaueMgt+sbep(`FTP-wK@q*IqPnSG?Y6MNx2aUA}~o9vG3`hK@M zll@@EegZCC<=th$-jx8$cHfTQtu)@*K{t?APQ&I3gGRRJsDe0&wjh|F1Rn4Y7ZP!d zvR$foEK$kj?MKgXG?r>CDiBK;qARXx)KIF@WG=ND`xiYS?vKaRzD$ zGYz`gP*3RvWi_<%k59!xoybJxG5HPYTCLUD`11z$Azj zpVnNh_;^CFYC2R0i3gEr^%Xj{KπCWm5fCA}UdiLh-KJ8Vtw0^T z*3Z!z7+yCLV!yDTC6*=!6G#4j_amxqTGR3&SDbkbY-%k#x9u16)n2D*RB%oB^~?*S zT+~u7_{#e@&;ya<;a&R&PuLhy7lX_lg3|{e-wIC*YTw3TPARReHWM&AA;#R8owtqk zUD)xiJUf;|-6q-j0WrB1h8wVB3@IGAAK3@pk35+3nZc9^H;5y~R0A>Bw*srVF(|FX z!jdj@;qBgzq$a2;s1TzXdx06m-kZk5lcYwJCDf~nS&(ly3p9@bi)TCTpL@ma_UZ`P zktrn>LW#Y~SnYK9_#`n%YrC1>HaTEuE~r1}ohuz2zaq;>KCoO=)ETlCYr0I!k@tY? z3h{TCU}R}7+X+Z_qcf6N;_@dduCfa}*?!Ga%^Sk~%%AgeR*vqf660}ZkQxM=qeC4} zO>HLwZw@8F1!DKEqv$*Gi;)Y!Ycv*__fI}ETBI~Ndtev!pbTm&?s+TO6j*TH+5XV< zLh=;xt7;@u61BP#c9k6dP?Z&SUp}!b@Pv4^z5Rrlm4Lcy7x>QjH0G3A9C!gEQw&0^ z(Ic_;?e9$~VXR#wNRo4Vn`C+$!wmvSmXMibFP~t4NwWlFaZC@Cb0k2bRD(2(WgV?c zqhLPfB^ikUvO8jzX$RyUY`_M7kYZN`2(lJ<4?BX~pSq;_* ztO~N2mrUEZtYe&y01$?b!6qNt;tQPj7#liQZ_U&aVbCwEF-X;N79 z&h7m%#QBr!lv3_+r1olWTxB92kpG!I8`mlrc)Z%NS@For=>&tGbPia^I-`{IvE1$c z#xtE;7HajrPBoCtiT_0-))+UB(e))Wbl_zAJY8Vn<#kMO@F!o`MH-9uDsRFT4x|E+ z7S8i!STGY*v24*AHi!glt5QW<$YyhV${9%$o8vAC2EQ}VYq^RO(m@r3mt%e{W{hdi z25kqog@yU|;h}A`3=9k+IXD+8Xm@Tt&cy{?>ke96X9@xLM_2F*?45j&bM%-&Q;Z%8 zF^2BBin)-)oY(t(1z#0Guie64iC@lJ>`&uhjp=L#dOP(pa33mni`is1MRBr{#))61 zzmnexM|WSB@RXk9Ucd})_a$#QHzvBWxlw&-Gd!4ve6E(^WmoOA9?M7J%-_#7tEN>V zG}x;_e=VRfC`RaFKMq~!EJa(TOX;JZD-z%21N7O2qK+i}MNS&4(}k{NFr>Lv z5!QaJfW)ks7g6g-9x!Y`5ZpF-c%_ZARv;Og|ZwK~SE4L)peVScMHi z!;2NfO@RzLU%~BM)Zo^-7WAN=Cai^0;%uPrV6Fn%dp7Wr+PxNEuKjV0Eb!RWlW}ZY z(*gs&-9GNTfmVI0g++=E4cC2oSurZ)%47?cXL;i+BD)Q@X-3A_#j>W$&ojpQdDuwFiRdhf zI^Mn!jSchNQJUor*S9)#Yw>QaD2+T@%r+nRfmFZA77=#ZUA(*oBQs5XF{rDU-JEV_ z47y~tN;%!3jCKBplK$ma(l&usKWMY!*V9K%>~Z%P$Jzpxj(>$WCRR zICa$Ougd11Vg$O7TcgG-<-=^RcGM-c*ML&bQ)27HAkhywnw2eI%$})-Ln<P>Q!G>UPAT>HR}{OIX^Gh|j<8a3))^d-k%81-F{ zz}m`)&hPV}+=^ii?WoXZ&N2RGi(5ri!Ea@<%a-KO(oG`Uu2BMpbrrd3Q>M1y>F@FR ziT3kE@U(_^v9T!!O$w(}Z?#T9`h?_Xx)GeW8C3zRxgfc_N8exV-YseoRUcFyYV9{& zk2U$o>IC9fF`@kNF0%VX{18h%uLuGF;y?c+27H#f zhXerbh5p}a{_YJ1Hn_`$nacrN<}Gm!RR&zZsaa#Bo(+7MB>=;>fV)8(R+eV{v9#IQ zC{)3vckM(Tsl~pjMM$+vJ>-fcf%rpt501(Qz*e4~IJ&mBmX!I+uj2j4NZRbw)8g8q zo@(@#bKs=z?%91g35K)vA9A+gq)()LS(Mg}pGNE#e|i}h=%`6<~tAqyIQbfbNY8Qp3ZpaLoAu zI=etaIAj0OU3fT!Qz1>fl z%#loi*mc+K&6zp27gN!lqAj)@9a5A;c2k;irrb;+V*uyXtfZhIQ?QULMze^b8=OIaGBU6I}_iblz`6OXUYC zPVyaYJ}E|1NX@&YiEu`y|LV#FPo?UiYCBR0HEGj_iHmwgMlJO`<)aLBsR5rjGbC%( z1Ew~+rXSuSilgPN`|bcV&5it?oLe1IYSu>uI;p=oAl7scPW5#Nn>Se?dpk1CU(4kg zG-|_pOVv5(N}V3sZX+A5F$F5{ zI_1T+GA-@Y+;D}6Mwy>(ldsiynd*(%yHw55wqc!M$q}{)L%!A{(@pqFWhOuS$X;pr!iKQklb9ed*-r>QZ!Q&&IyG1a1yUebH#};A*!vZbVXEOZ*0r;##sq2DH+N)$ zY*}EZ{s|FseFAHVvh&AJ2hj-lLWhH(OgwMQ+j)7kaqxVoSNq2VGION~?o-IgeP@mV z|7G2Gm|ul|(iL`h!^zAckjii^_7-bsy>K$oCq2(?eNfqhI{-$?>mFWsQQ?g8_FT#< zU^e@REar~c`^BvD&M|xhUe$4J-A{BL`m;s6eAR66C2W8S(q;|KrXPyN zzQ5Qjt)oMo{w%S!>@y%9ZvrWg_^N%7*7wFgI8H`KklU#iIFNB0Q!CFd>YHab)Qr{L=C@5KrB(DJW`Bv-PpODEU&!7Zuy)+sgM)9GD{6eb0RQp5Jo( zLqd0w_O+{iVE5MP@rvuOE^7}Kz7(^WLF-8Pec!>SM1ap*<=y-4^_3(**ztu(yaEr_ea(h1duW!N73u3)`U*~CH;+qL+i;bA39%dH+_NhJaDpNEHs)j@Djd3ia~@k4VcoFl>(9YZa&+V0I=oyl;52betZ5t~*w^AXDlP%Zp>f#ET(B$m!()mfv5 zRg~`1a8C9a$7H-^Vvays%k;*gU97A}ZA$8h061F$C!niWAvdy9K4Qv@X?9`5}qh@WctlpsCCX{GTs^v^5pE}4ARdOefWh(XbeX4x#)#83or z_U_XNS#H{sh#+Mg7sWUzRET^gdYS;Vx0U8F5{k&BbI4Yp)$2xymJ=AlQ5of(R7)i; zn7q^2*wZ|I|L-pd`nNpdrCWeKS7UZ<3V7XGwRQX`>YhRDH|14J9^&Q$();~n!AgQYX@h6URQ`F_B4O6HoRkA^Q zyCH4p+e{kXctL_z{=k)ef|!*+G^v~aZ(emfv8raz0M(}3erL9#W~FF>xLc8EAcBad zg7|W=$>h&nE_2N5yJUajod4v}!=gniwuckl$K570ovlnNrOZMveF;SgipwrFfPK*U zNgly#h3T7EE;bf$7hKADUxWt|aLLGt zO!M4z{5)T3rnw`ijb6{(D0Z>yzxo6st7x*RV0FdF=OTT(O+-e1@h}T38PkKH<_(XU zrk9u(t1Q2lhN9{T{){f{FSew!P<7dblq1KLPJaIl652*M4YKF~!wPX%^F&;Z8gbgyzp( z)lHpf+W@0SBdHO!Ml(Br`>G;;NaaL9TQ#|Aid~#=SqIhG`FvgU?s!CO$k~t^o)oq3 zHxL(dxC#3=Z6a3!V+kG_OMEhIxFGma*Cy9`N6O}7i7gp}An!+o1xjAd>S7h*LoH1U z_QR>vL`hOto4k`?$>%}*ba8YYT+qT1;@0fn>1P{-R=nEA2i|p@2#GSb=WNA~IuvxL z(wjg24rES%@`fuhZZW6^@ZD8?VqUvgTB{TtlPSrW9doS1%*2MruY+*;{{G)~FBkgz44DiI7IT=kn5KC29A4A;_gx5PBx1 zh!}j%Sgfk55M=A(V*vSN*hq7kS!8B;^I=m@YLKU)nyJNDKGT7g%+@CFN3iMlFAmEq zg!{_A?-S*UvU~IDHd?HBE$1{S~Kj&i8nY-OU)L<<`Uig|QYlc24?(%N`aIg(W zue9?4O`@g2S2FUCcGG%{UlHB|r{WTY%#3WBw<`Q)i1|Wf8GSu}2&3I5F_cD#7C7~L zEScEzP=A|Y*)l9qfTALyeO(}=(t4An`s}I~O_C^Aa{;^?`^X*#?3W2NcQdf(X;7Hk z^#46AP;mMqfP(ay$lmdR<j2q|!k2?Y*#v$TNq7A3XkSy_H5mM`ht(Pj+o?pdgyL zXqmP}f5EX=Y1LHXHL+fT`c2H6&t~aSiyTQEoG%>)NEcPox9cFm#v^78Q`7Gzn6udN z#%f=aW!Dk!2ny>B)DT9HkFPNArF#bT>RI_Y~|?$t1{;`_W%{xq6=Et#K4|swZU0u+6Rs zQW-oxF7G+`lZR0>&h`h?1`DA$%d$)M zpO+cQG1r%U_ZcD$U`#71(wTZ`2sDMo&%$Yrr{=V}GbBK!Ipj>hu-9f(CB{L}V8xfT zepgNT?{Y#)XCfdCD8l zK#nGoz>U-huV|hC2ufRF84D>^zXKg-!*YN+7UHSG>0F@5yi}5$(}Fv93>Hb-_r8XD z2(;9kSP&2JtcoP^(_RDP-RRLm9s5N#buw~c66N2DkKYsz? zrl8A%Gv%OcpXJ!^ab9ECweDd+=sZOznCAggNgtT~S5bp!y{* zxxK_^ufWc%vaJAsWz~ykF%<2D-NGliPBgx7V4=x2G-%}tCaxXEMgou`BZO1_`X1Fh z$u)(+41bV$y{LcsJE2A^QC5_ljV;wUKsar5B8z;eml~ge1s6!77kk3Yd9J<;?vcwW z9k+e{Vv51BIe?V_k$#epLrVK{VplcyoYOw;S)5!!KXnn3kFxF~H9i0r3=yk8=ZTs= zNnpmt2POICWsXT6L`h1>oKWWZ^wQ1!W*g0I%E{ zcX2B4 zcioeU^N^)xea-Eh7H6B15UWVw2M>QRL(mk*Qt+rU1e;1{V@I9Av?-Q9u}fup5}J_J zrsZZX)2pV~jf?GKmg9!21$WTxRH%5}pfYy|(&nd1hFb>6yBwG=FDbRX_;exYhX}>b zDcacIF?|MHIx`Oe3k)ZeQ@3%z!waFNl89TsfE+v8iY6EyB1g1}pr$XC$ktrL&_)jP zoh*1Hu}gs?7fa%Z-nLW2nFiKe%cne~v_L}MovJ0?%z z4c>&S+r$iCBS(HZ`n@+ig>q;mp>pkS%5YpM!F$Nw3u{W?>70-g28lsx43%QZ7=`M_ zMLXOU+cQ&5I=|4N9}N|^KN%tTO>=tq4GxMaR_;_*nN26c>Ya|^#*T$f8E?#tI|1?d zxWEL|La%868fxTrhA)|N+{%X%H8=h+VpC#!2L14_pXzgSS!QdpG%ZigguGd52pfwi zvrmgY$x@?+5HCTTMF5|_{xrRFAc?Nkf-X;`@^ossHcavV%(c30LpBPsHa{~ok3&px zzh1PRcsN$5Wh<1X-bA&T+Iro2WHUj3*GS}tD%0yCYtBG8?q~Nq=ofEJz=6ClUQ9Q# zv#ZYue$yq=!WmP#GTV0)B%eu9h0#}6KaHNYo4PN^dCaoX5Q_ZK#%i0<=8NO&mU(-l z7Pb1&TdRCnC{1FiMO?w~+H!sZ?D#>HjTEJM%P;O*zV1~Sx_v`PNHX=p5R6CY^w@F#0{#*e>YG z6qd*7jqQp){|--rnBkRlkGPPJbgZ{2Hf~HaYr0 zkC}39s>)s-xATcXGVrMGow+LGr4HZo{TWbHt?Lh|?VTNKE2}2QrBuIK?Dq!;2V)}1 z*`J0CV~d(a-q64+EYwz&mATRxzmav45Yb=bW&iTw)w^lA1;b9i#w0pqrD3z6_RakB=+)`Ng@9STENXJuy1f;wjdDUiSUP{|MKqFB`@-7AW(H)al+K#ION=c8MWZ zj|@&89>&J{Z_*{vtR}nJ9`+ojT+C2R;KimcUS)F{_K+s(eJs9PmnHL+et4L-GkjZ2 z;<3xJx{Pld%lo81O(9$P{l}@A9o9>q-V1f07PV?2uat?!_~u2*R(>J_W%s@|6WAL; zA#v;->>%A(;-o-Qap8{aEwLW5BPdvsk1r`pf{)J~gAN?bE++6}T3llOY7}(#F3n}h z6=7TwN+xy^=cThNA{SFgiz+~g{&}*Ix-tQ+CZnK>mS^l=oANvQN0^IhKPnr!CM}}w-z-tAGGd|kTOO*Q z{Gb>1%Oq-1s7_0#wn7Bby=0Z{K4Eh)knmEs%7};Aq}6Q;+g4OmOk`IkCRV6iS*eVO zd8z5BDk87itP~mb!i@a3CKlgdu_!`*i4XeA)6SFnzP?dP>P=cEE&vV43+7T zE=zC-`7j$2;*sXY^GLJ(qk9C zP*qi#PgUr;*MB#h)G22qQDgj@Z8~3GR_cZ2i-f_hi0y2=DQ#oQmpw2$3)4YczmFv} zZJdiJuba$=$Eo`%2TJrd-%`r_GNCP*R7G*HE

^S~KFzOQu@f z1N>=~a8J8H>ic@eDS|g?DYyjKcyxmxU_Up8lXmi2nREXZG0L`A`+H*bMH!|vs?eScH_2MQWz5aV8b3DE7Fdd*j_XD1;NyD1tu=k32+xb^>L$OTWT&euDw1Cb^`k;9 zXuZ7#t$!qotPBtG-lq)f33-^pdsyNlD^k#$J!#xDJ6AU1`0ArkO5XEIOS3efKI?@V zm}kI5eImJzlv`c{MFHjgYXBU2m0U)&G)a*aMv>gfaR)_CjrSGl(f^pvd_-0AGVwMD{ zI|+cV0mJ8HeR&C2(nrTWZi1(v{M7@~0Y#3;;MkWQKuj$@;rB!Y@zWQOD;E#l;pL{#Mj*5MwxSfVrL669Yb_UDSRtFI(N7%S|uISxt2N zy{uznI`R>e6E3`6lw7w_O4v?CDZl(_`@<)_#QuY#iup%;uin>rG>^7)x0m|O_$wJL zH?LZTCoI*S=o8LxC}O!NZ!)LYTZM4v;Q&$kDCMvkkR!9)a~SU+rObMUr6qQonj`tT z4!H~umOe;zvN1C&H8)s7DU7OZ>Ti2c;To6M)P^E`Upo6PBn#+GjSuKG<#ssTUo$Rl zJx)8q)i$=ieRV(uIwLKm8J$VQYnrlj6pfw26Se=6C`e}2nQxi&Rz=qkcG~+5_R+?) z-qQbF)g6?4<_ItAgX3bXO*E(y!ZS94qIv2EjLP@aY!-|6-Hfic^eW?*m8iO6R))7E z3VEiffp_iL=Zi9h^_kZ5KS;Ekk{WNIKbKW){qk+Z$zA#G45Yr_(2#rR%jBiqwmZbO z^3$2HoSk%+x*hR~bKSN~z%ve8iPD1lB()u>MNPz|(a(au_qo*!&N2~{owkVKjcZky zYZlkbkMu2{Y~M$jCA+m9(0}q4x;KzgaOshgV32)zP1=sE4R=%^5NaiNP)bjcY`%R` ze|E=okg@A2y30|z&*{K^KTL;tjYbpvF1;pt#aW1Lm@b{xyaAeP$&!#nI2<5fC zwEx-j3H&|v<*nLr$lAqA!p7fy4u_MDvB_J!6qGWPzhq=}Gk8@6L6BLr;1e5LdTKu7 z6yX-m784o%XDQ3v%ix0B+h4ntI@ZYyHW-cVet9X0l5lxt$^VoDe6O@#-#mLS^a> zDmH=Rb{{hK*5&MJt5*54&h@y2YCSURCg1iwPd~Wz-yiPVnAkmivGZ9B>oi-+*61U~|>H1BGMue+C5>XLd_ zrBSO}&(}wdjg446*N}}&Y(i$7O7Xu+yw?8;f&V^%S)cYHLL^ zw6(Wx*VPNo**vlvGTzITqj8}JhQioy-bG0_YO1e%K*7uM$?r;G zb9TkWqJ`%-h-r5CBn~}1bP}5u#ZHVCpLVgHI~Ja{OkD7{J(FeEwylk28+6Oq%iUK? zk??p--NgHdX%`z5=u-~wE-mvH?J-7J+SjsLK1T#EOyjF`&fWJX@zSb!<94acTv+?D zvG&6siY?&54J+{YgApT4C+nz!m3fNpH=7C~T$r;ph$fZUx3=X;U$uQB`N8kdjjHrX ziiV@7)@~{W+2Qg}gsg+!kmov{O?c~hst7q6Di+h$v1S%T3FWEWPGvo%SW@nUjBWd3te$crq^GUoER+oc3ty7D+S&IjTOd{+*FrIfSZ!D ztPFZCRJT+eea|y!Z@X)Mhc-_Cexn%}WWjz0tu2a?d@{#d$t%FiYgun^`!LI?dgHQt zKvMhHq)z(`#a3s+)CGpgadcg0^01;cXX8?7%mixZa3BcZs1ds#)tjv{8y4Z%D>AX) zQXZ=5E?l1jTV z7u3oiq`3_Hf3+wyfat@w=Y)QzlLp3<_Le!d0ocW zEGlKI>+N&8I3q8+G-94cT7Big95QlRUIk|t4*Sd18!~Q{ZBKia!R6ce`Uf3f7X*%Z zs6uFh!A}DW_etp)%VU5kyljjhqFBpT{HfoC#iUdf*8FlD0)Gi$lkC?tsea@;sr7TI z_z*kn00<63-VX8}S~rs`-cKoB^|%<|?nkBeT@-oh(FaIOTwTy>1QxbxiCB9&HO_pH`gQT2lZk-s3G<2ovi>D0bHQ^ zfb@_?%_3VHf0IWvzQ6nIA>kFh+ww--(uIw6q?5j9B~Xc~im&FE(rL=Sj!?P~cG8pNFC+#58dN}Uv$VUF?_KBM>ZQ|l5HVR0rZpkkXe|Kr&wwfGo&%m*&BP@zQHJmkPh#& z2c>`yLX>}P)-Q97S#oGrod1H|L(04$K8eH5+2KmDD{4LEY+M(^(C%JcYv`oILM6u8 zC~|x%M3=iyJtrtWow&@&XVoC}Wht}rE=4`!h83tOe);GH;- zv2|f|c5SFP@NcRnyk>{-p0g1(|M_HH@Djd=lL%fR@vmZlLi#F>zUlnky%ciFM{F}h zRMt5OcD`f|PyVh!R?^;R&taihGGr|9Ce3juvJOPr{}1?Zfi9RsP^WedW%N_@`w-$Z zqyqKNu4godX&ZrY?I)F+=m3}2NE?2GLta3u;9~2BmG>&x!#KTivrDWX`;7gSk(SFR zrhh#7+qY2h5SkzTalHH@{8g$?xo#?xoqXfz&+y7>`H2UDwXpepeqO(+pRA~|CQ?*( z7iJ;+FBgQ>IsF#})Bi6Y`d>W2ex3#wJ?^M)Y1u`aVnC&(dX087Ln*!$*z**;bxtcH ztdeI>VHA{<^nSZ}HOB@dsFNPzDC5UM zM4=oU93L2aM|!Os?ti9!-BqdI?&atj7rOMmV1iiU`p0HD_MQ%;Z~n>WKWxEt!jbv; z`LBR=3nRT=?hAl#A^)M7`i<3_Hb4(n=!ZN~jPcexQtkmh^Va3-O|1N;X~L7Eq=>y6 zsm_8OyIKVuQ2KZVf8^%&s7=ZL=IGRJE~7fYxoy7a@jDXG;`{~3*nZZd(;%4_s{9Q4&*l{d=} z)jjfyzSJ^}y*sMF<95<93DHx9vSdoY5dVoGb*|E3j?hKUk;cpB>VvStrZ3e8rI%$i zCo?m6?Mo`gY9g5R!P6$ni?RBA4-L~9kv7+C+0HBKgPx49gcppCxV~t~IG01DYj8;G5Z<3jvF*;UAPpyE#o#XyD7)XXUrsLU0uq4sc7`2V=r1+*<>k zo8R=26?dtNrMVSV#jlpJ55{cS(fNO)4u4IX#tI6XB7Xuf8Wax(cQJ9XU}w3*rC`)a z$&~2akorkdcb_}pI7ib@!eEm)E(-+hJk$>Xo>v|#FdwgWBF^eda(UmWaGW}KdA^s= zLa(wLHXPNAB+CB5F2D;p^`Z@ArJeBBOx0MRJp!W8t;yX?P14(Olp#fR+q)T%ga|NmGGo3H+3l^$MsjSgD#5O-q`bta6X-5Q9BrE# z03T?6#i?F?yEs>#Tyq<92e3UZJw^rRLn0fV1^3zH7%@u)PX#- zwRkV?$a8@|xmh1=-v1})9eEKnt=u{2V1dw!0$@~mjn!k{vT&>V#$j=m)mW6Qbi>*Z#2-;H3s z-T~IG!JeA>>>+47ZMatnB2n8%C0A9aSFrbQ-E_a@lhy}-OPM)78nQC*=#*K9KW^{n4NMoW3^#zYn*hqT`M3rd@Ki%;@oZ*@pZca-Q ziMpSNC+l_opx#h1r#R^92r{WTO0TKwc=N^hO3=)7vQI_S_IxmZzMAf47Me~ick50S zZaI)24_K3xxsTj`+Qr4SOAu>^iRC1{@|~I})UqHqowM%?H}=c#3-Vw97lW@>lEu;0 z;lg!zPrOlNK+MNS*?YjoPmv#cDeno;*TL~8^p8%GNuBB1E8E@kU`Ot(ru|Qbs zvBKgaY*Mq#A8AVMv(ZkfTGlzPTgZkAKGpoLLh0R+YVzjX8!!2I+ww$g13S$z8tkG} zY9%y;EK|$V%rvA>JG$EVIT|m)%dzUjO*bFE$3il6a~hsD z4Sur5`6+XDEt=Qtw6fUZYH@V-5Ozhk>L+lMCzauOnPs=D+&sIyoFxkU)!DKN#Go`P zD1;~xs4FOzQsMAo1tfp=Jeb{L-Ffl=>HB_-yY-$yelNB-#^Sd|`LPr$s@eTdvQ-_5 zc7W3<8gWe7e$PoOf9B7&Iq-Ttyq60XpbNR;V8jJiXfZv<1q>}MFB^3U{rcape+3m1 zxHPtVACHcX%DzZ2r@S-pGOoYgcB)vwepU^FFSSNsARkAw;-N0BD2h)5^dE2)yNtXxth3Nv>FH?|J(bNxV)Je%K3_)dV?3df1e zc4fBaSb6$k{4Pm@Bz$@=INP9HZ1-{VyCLNBVjF(H&4C1V-KuobYHZGnZDUFLKPiev z>n=fTc2Vx5HGv zt~^fiHkWcU=Ok{|CBK|AHk0G9mF=0C*Im{|)H(6}OcxZv!l(w1Bdv;M&lR}Buj(W5 zD~>T#LfO7!koaWyS0;nZc7sJ`rj&S5apAl^)EkRmbbCs#o=`bSF3?2HjF1K!ht^iHM06*LqVjEyQ9Qk*GBTH*XJe%b3FJ|JwN3v{QK{hvv}GaE9RFnOX>M z!)uR8=v-yi*k2U}=qDHOUgdvqrW{_aceL65|GqaC^w;-BzxAs2j$6f_dkOT}G(sIo z5k0WtmwPh#D6Kuk+`rRvC9Qse?oNr^{N7;1WvbL66ietP14Ydyq-qp&al2A67bk0% zsa4OmetntUREx&9pMHzVSkqfXKIeaVNeYjuy7S-pNqRqN#K9KVP925Vi5*&l8$Z9z%$5_s=>^M z1H=^X43ebR=lU)zDI1--9MO-B|KT++;876Rzxlz*ul=0Y|Lz7=r5C~d<&_^dedm34 zx#ZBN54kO=uD0ZHS8faZresq*+|7m|2YXUFn;*pH3&btw=z6X$l-^{IbB_@dUOpqe z%uvmkg6~|owb89?*+8fG@;=eqEYLX+*(t|fS4?gZ59qL$M*VTKUK8r;=~NIBZ?$$% z`kp2^XMH7b;nex#(N^5mv$Bkz=beeE2lbOC1^wIv8@HMzb+BUyR5%{&oZd3=Q{6Wx z&mjF_yD}c(nHYdG+ZQzuOJmSKU2>R;QE9%d#a0xoJU<7OTF(32rT;6U(45>_@a+Qu za5ZNYhR}aj)WU|@VwQmivz1tJp#uJs#bfafxSe3gP9Eya zA#nBK9q%^Zb1P$G<61?W&6OFDZnML}JDIVOkxJusoy0-{`T(_3I^e1GW2_W2HIxW> zhKF}vk3CO4w4zX^gvF;%G+imWn4OY006`}*F#pN^en2Gf{uPD_#7n#MWpd#hB)j#b9Cg?HhOv|=;zFAh5pod`~|KZV1Pmz z_3tMpu!0UF*wIQmR&SB;p>Q4_=WJl()M2r~b|gb!+6@P*{M60N&R*&8f=K*LYC1H;{#Z3J1X8v^EBO^9Xiz z^z0rtaOLMjSXe=D7R93v-gl4AeJ;Z)G_?PU{iFd!WY;}-;r&aCeca;-cN3L~FR~gG za+U>Y)Crx5XJ0ix{A}I=#GtVfP`BIR;b9cwDoXvuL@WPExZJ3`e=acS_}a`p&4})f zTA4JCLbK@Kv_l;trMhDI{Z|zV;q7~smB{5i#~8<+UZ*`5>!_=*GrH8_v^?84J|E|D zPFFz+`w!c5fsX3|nbQoICG&KXvCBrTTr#?r8f|@hvwwgdA>GB*?x~!q_r?@z+@A#8 zWIfE?E0=Fov)TRR|8OjK?E#iD-QU|2_P?wkLHu z-Tc;*=?B=_()9rSo3@kgn6}?MfuzvcyBp4ySbkzIaN$Em^{J&c-GHbOYSB9t)b~42Z2Z%4ukT+pr=ZQSU z^vnSpbtK*?u0AnhCb@x6iT=qI#ts7vGm1zB6D&3HYF?@Ci7r%J?q#M(!kCT;Vjnff z8E59xtutXyP(#(n=eW79@PjO~Bq$mwt1bm!MW%1WHUaqiRofe)W`wb););a{vsL!{y=vpMos`9)K@Eys5I_5rT=TsldryJA1 zVH?9}y0X!ovQEB(Vl(XsAd|c{iBer4buY^7H>Tk$nV!{QggI z>tFn6$I1o{4;@}TVf`Cmp81Gj@ynpz>QSQ+;4flXSzBX!J$#(;qXUa`W5dIbuOEiw zVQ;)hxTj8U=?veWA+#qa{GTe+ALXG|jq%LV9A5m3-LN-{)WZrm@?Fi=h+(SaXG3o| zzI$`Jc$lr*{FrC_;vaVDD~5=i;=iM|X!P_Nr;tZ{+&xj@UBrKZGnFp>#Bca(AnZv5 zINf;d9sVlvY3&Emt}epzW)~^S_Jg+a-=8Y(X~dQ?Z(6m=xI5_>);!I7?R^xZobJu( z^s$GHJrkuc|1GerA!~9vWM(vf_uueF2;DeU@BVS|I@1-kUSQ@=u=g= zIXM239@fBtIrw7tjbK^cRk@&lz@vcpHLx!ACeeuIQ$+48@usz(`%khF@OU_nRci#K*7@k@Mv z>id_Qxsa${-VRXqFLHmme%JrP)=CZlpZg=TjVsT$L?~0jdLF^NW>^7 zXzvfn8?%`*tSRaedx=JwKY)P}dV*4fm5ETK;&^zSp@A)0mX<{%h?r7SeSIu@dw*yv zSd!Dy!3YaD!XY!!&mF48es&FW-u=s%NXzqkem}4eU^RD(!0=D(HH`=66|9<&y_|aZ56qYz2N)7Hv8Z*x!qvG@Ln_k3el>sJ3S`cx(UKiz$2 zR8#BPZ3Gb&0TqEw5l}!n2na|=1(7bjcM(Dr6p#`SK~Rb)p@=k*-g^xYdPjN-T?oDR z62kXJ_c&*td(S!FJ$HQL{y6>B`RtELGASztoq5gv zu#Z9oFv@QjyOo~(EHH0>gB0l?eUUa8XcqPG6|mgkcOS?%XM3Te59m(XcA(pv?$zD$ z9{kH&`gXlvsCW(%2HjryB(+P6IbVTQX)AhFQ*ivyN~_gNAt+S9wMm|95@Rv^8C*rI%ODND5@z#>;DRcI#=! zyMjWgv2&jylwt&{vBxV!L_`Dh?O%pha&mIGT-c-^;D9S=U5JT^L7{rH{a2{`|ET1g zGX+J&g+y^d@KJZoCy#dgZ#VF~t&pzXt#{bO@;kB=3@%=6*sWC#wWs10l*DVf{n^~d zEDBEwN`S3hFIA`fX>XZGl!kI4oJ%)$wFgw|;9w5i9wW0`s|_-;sG43T5+ImjVPMJps-I$_gn7A%gZ3qCdmBJb zUTu1pV)gS6>D!-Y_TgQ6i*a-#qhh^j!IX+M>4pRaO+Yr`?l(lnw<)OC>0Vm?YiAL>60L*{7Bgg$3IxTe=DxsdY?Kx?Yo|mDOGFFtNuMaKk~+X!EA>P zd&5T$1IOre3ix4>c`_%q3>Q678V5^%&bBeym0+OvNU$H zRhUh%Dv#idKme0v=qA1m8sb44((|?h!&lc;1?!BL1c$AX7TxC(=;+2L!?xK+0Akuv zWXg`9orM#8lrgH(f^U8^xarp-8^EAT_)AUHoL)qI6YDK*FaCnmOSlw?m$S+Jo zSNG^90qhiq=(HKn6nLU%y{wQ+M_z}`X#bpL7EldmDUIaG4$x_Y6Fkt+lSj9{LRgg& zVW|rz04`(3G9x7Mxz@-YRYQN)h*3#-f0Vp=Sva_?UGJFjsHbKgz6PbH+{!FF8l<{7 zpQBG9*Cys0s~V=o5omZB2M{lH^|4STOZrx{9|jxR;ck)j6LxcnEzBkv%AXI`Ek&p~+Ifrf&C5!YUc(RE`m zo;YYajKNg3%qB(>B^3u8`xKS|8gSUy8)j;n_zMrN>j=pr{n}K?5AWcZT;r{oB||WCQ!OK;b;3_1$JIciut$kkJmQ);mI%w%L+napP+A z7!#dV{||)#!g~4oA|a!z{L6@d^tiv!F%6IF~z*Tshu>g2O2I|K4>H%ZRv~OnQBSgWQfZ{W7`D;65S$Ed$c%&H<8Z-EPlrbn?|ORQbhGnb zt+{1qr&c)1Zg^2)=Cft@n)vBgrc6F!%=WFRiPDA5C*>RxdI9?!c6D#MbW^y00&=0a zP|wQS(Dn4_-7sEzdXE%YBE5{7rl!n9(yL^*m&W#f4hFeXIaAXxfxSLrgf!Qrf<#45 z{@$y_lm-tKF&s-wJH9|EPoi=s~L?)8$0rhb;HHl zB0^!-G7za5Kb97n(&q1F4mJ&k&qnxE*GH=v)t$_0$7g@=9x3DSIskw5Bo1XezaDsQLWDd7+=VzYD({&#B9kfDcmRp`6$3>IdWg{$o9HGy9PGt zoLlzv0D*Jq88KU~DZ~5gG2;s>?rRw04Z1vR&(xuOss!iM&6RZhA9bN!OHpY{G1M0l zPvt8=`-Z&)P+8F2rgMc5LP+AJOR^?@wy`!BYJ}+u7*KgxH|$*LsvS3+HBmU|QDgC) zQ_MsWO)56!p`$BZlSA~!A;4IwF%hh76_KaS-V0Y=xc{OuEWuY zDUaYV`}4V{x_8NtT~rEek2L6wg^2~uH@0@ba0n485Y^HvukCY zutz}U*TX5)8yFXsU0nwUIwsC~-l?~dN5qDy)~#x(d{#L|U4Vq!Oqb1$p6+<2svPG{p)iXVN%SJ4@K1Z>4XvV2&R6N6;3tSm zc5ao3fl{F8Z8RlOz{56+lFk-4yB#A?TbMUH)HmmZjhZ(rUrpZYu;Yq{=vy1sAPu^aY_W9JozKXKZ*GI zOTQp4E~ch`_HQG6EE*%LN-or?wY+J{e$%hz_DwDj{*xN9c;H3 zcp!zdDXg!{NM7oo)7BM^dtH@{{6-M3F_v}ksXVD-vuJmCL%XDaJ`pZ;ipajZ|m z!kKM7*vI^HYk^dtXCF6%2`n{N%{7{CL^oYuTSA#U&C&3^=&R_~mXzYoedE^R$|Hwc z>s~;cbaIjk7SdOlDwidcf`1%z)#y@xw7R2DXj9I?oq%*u%j;`x$_;xR(Xm_0@0$a+ z@Lmbq=)w7cQu?UL;+NE@0ftO$yHooVxlvp9v7k=7-dzY*>@911gCQHk=%v9RsM8~y z$=t+q=PYZPl{)gyCj25E-bZJI;cH_3yP>*l2W8ms+e2<3@wmb+1|U5GW5LqL)e!O z7>At;bJrWm*=nA)S>%gGh3zZwq_ZKyD>4+=*M{MJI{A-{`3Kvy2KRpMo8-MnGif6# zE><9eql8VI<6B579Xn?!6(m$T`Sf55KfqLN;xv0BI+DgY@4MJ6JrP~lkH<;93WT0c zRk4n*T7#atDvufy z->6V8Eh0iUyl&kfaEb@`hnP)U`sY%)bpa2?H=essf-X!E9R>I=eAP8ou6)Iku%GbJSfYEV>JO|UkV!MB0K`%asoo%Ar{y&5(BeDf6Dvg zrpOF1@rg_3JD$a3q{D#_+WH#r_{k&`<)7%okv={z>FR#bwD z(TTsV7BL-=aO36g(DYN)ESfkjF0v(lR{Vy*e4vw&^z(79mT1 zDy~&?Bxj||1JSJba}U@k_GdL6cG9>X4HdR=8Q$ze^AspR3%m0KIPJOvYYSR~hQp=% zFNW^GEu*v2EZm6o>@}{#oQ&INT+OLqyT(JW4X_LE2cC%?mKk(%x{*6-I4G2VEqTkwhZ@b^wim>zs;7JL3_tRf_O zA+m;~D@EqgCrkRKuC*dDj=7Az&#n2-#9Z1}=XB?nGe!-4+i+L8cPO$T#hi1*W>_vS zcQ4d1K_2l1&Hhl8e&TCxD)3M2_;r1<9p{9Y+iO@EH&3`8=^DDA$rURD`RLyRr;Nx< z&oig0PH~eC#c<0L9y-y~D~By~#W6*84wpu9<4e%<&23tOcrC#nS~9vXS4bw^GcOW# zi>#85p6@JwlOta*y8xlmXSt`JeLFHUH)y+x z(_BY`+NCF7v*v0_4*$T<>=U|AHikC^Jsw+g9nc|xw#?u6b+ktKlGFfjw;Xq=O2)cV z*4J$Xu57bde*fVVMtBr)&bvBeabrv{Iqp7L0Z)KVlvtXFJfv4ytEC2g7%vDbJ#s8Y8C& zaBB+;8@=iZAK2R3>h{_%V%CK>35e(KP%SB5XoyaP#mC#hnYSa~qSvhBm4B@T(;m-j zRH|?>0?aaW=jd$cUE~I2_cJSh6t8EGYR`VXV^Cu?)yrPJf4RNk)zT#YCCo#(CEg#T z8K{5`35|;nv5|!u*ts^1CT@IbGWH;kJ3hu%?KT-fh8J(>PU|EyIT%UJ5VZe{*TuBK zKx&W)FjUWMy}li+j(TY%JixT=#}S^DZJ;8C=j|xAS&RSjq~zI6U1`LDScmL zF&&*{=O`iztu>|%mBbzbsV={ekZj)V2Ta$abve++f#1yCb`p<)uTH7%e z)#c?t3`Wn*#f5etgFwQgLnV$EXX(!lzY5sM^K9ajVnFOW4dle*=RrWhJkGESCVKLG zn><`xz)w^7{rhe27;tJjvkfZv@{p)@{(r7w#yemSI_!f4 zQUkR-Q|VXSD(&W_X1A<%UnglC7hY683bK4SSVfbWRSuG+R;dn7eP$p=FQj1$;j z-vSMh`Y%6k|LduN6nqKB6kvJLzI3qhlAVaZ5%;iuDD+hx48$rZoqi=ym0w8AH;WMH{N=`cA4Z6o(E;Ye4e z+%OjoW!Ir_IF!_kJngh0Hqt!TD7PTpPe1y9H&u8HVH;8dJe2F8}fQ?CbK5jY1nVRY37f13M5Y zlsB5lLkk?L3QEEJg9}2Xx6f|sqYfDzx%%1F85z|D5s{^}OZy`BJi!=s^3GK2Sq@%s zn3Jv0V{f?N!V3bTI4kFI^#ntzSg@&S zpuF;Z9pxXiESiMhz5tPiE9h`BXycJu`XG+uIHky|7F|LpKPzi0%qog(9+B7pd_Rgt z9GXPU&v_B8aqfBQ8%=yc*XVmNFj(8I`^KU+Bo8CK?@MyThPUuF9ciiAiX;WLFIuIF zYpW-u|LFFH0MRLqxhfrq!8%Td^$KaQ;fED2PwPbL-@RbTF?UUQVY#f4PJ+qi&qd_* zy*>6HT$XuUM3I9OFhY_cLf^jkgR}*!rDkl2ea#N-_yfQW%i=#Li~op4M31^ZYRJ^B zk6X)dMFn;PxGp4civJ3cjMcf#r+BQI1YXU+@+j%p>f&Ee!DAMGVS2>Ma?iwO{N=f{ zfs5hKf4;gWa$A5Z)UiMJXAj?jNQH^69q+ieRaKICRofl9X9d3uC{4lw+W`(A^&4R; zxRW~&q-03XkC*dzn+MpTTI*oq`KOteX%At9KlbJ7tg{yTK(Z9YYL~7M)3|r5W2mHk ze^MMAi{?X=-z z7}emsT@}!(jcimDE(yxiP4q?nqnqx6|HPFcqLX)J8;t7Y5uH_m4LSEmM;D(|yS$lmaBYm| zXytX_6kgZI9j6YhKn0nx=(AY2$C+r=e7Vlea^Mn66(uc#Z$HJmsv?8+I@tgzE~ELT z_>vv^Pm|(A?OiPQzBI!0+hA^wMbX+%%G0MIpKQInu={Ic$bB2+9W-IyDi=r6quCsl z<92h76n#3#3x0RZRYIU@VrG~U$40odQ@%)Oo>ir@!M}R*F9M}sGTBCAA4!Dv`_`!^ z+|4$!M92xn6&CNjXL5**cNwvs$d`ALs;3F=L*x~2;Ukyg(Eh6q)7vq!L0cEX%ea(jbYqPWFC|Qf#RZmS z^HG>!e$etw^j`xSGJNApj@ZV6!7|Jpc3v-xjlx8m@Df0 zw&lzEu}l;-$^?}(+IaPP%#6IFYc!(k+u5fargskVUUDZ}@!A(sP|H_V)Q#1s+#QQI z2DkILtjq;VPuB9CI6<-@0%PWV^4zy7xvI44yG2|7s?$AoB$cCB(RRPOc2349i)V&I?ZMqRU;9&ej+T50^1JTop3Z z?c2KYqT~0@;waeb^R5h)(T=F`$hH8}UdFIfp>aj#%YX>;3Y~f*9YKcXw-4lw#Onr_ zRS=?sC0=ZjmvzS=PeE6a04eX3djqUODxa*6zIZPWp)l_%8 z7{L!RrwnO06bN$dAgo8VJZDk}Z0$YE`qKbq9ke708hrj)!NjuO*%!W{_%G?=?xA+~ z+hS5SM|99>agj)_XEjI z*xf;^>&9hJJ%!P>%F?6n3=l_rK65i6%{#=QsM5oE=rHe(-R9@sUFW_Os%+`kQ?2l^t`kp#fZMesw zKF+eNK|$!DjDIBgcc7J|?+iX*9?q^Pw-#{zh|)|#uOFUR2e8M^IUr)7u99WocdTga z-ew0@F-7&JPaeOj2EycijPX1H!*Ta^Wm$`{)Gsfz8`bu^=dY{`&YI6bzI|yV#-v6a zvl^$RmL;f!v>5h`5kh{^Hd()Eo5$n7cpK^eHN^72^<@3eZ&1kRt*fn}fx#?Do(-f) zL?D%bu=6>{ZqWo%lrSHliQDcz!*p+qSHOS&3kbD_dwV}4F*v#(>jY#Fx%K~|H^X&o zVJ&$(r<(zioyPN^4J^P#U8YbTGRvV#?evXy2qE7v1_&1}?`ZyC88v_L7yP$xwIbIM z0T75(Dz4#g<~Rp=)Av`g)?cQ3|McxAOihJKSaS5(LBeS_^7ZV-{1k$+?VvQ@lWS~f zU;OAmP;!?yphNcdXJ9$Ng?)i8YXBJyByB(fB2Gs-Sg81yOweoVx7>>Vo_IqWbzS7L zz30oR&b5<-v;L(87u!Wtf$6FBW6JUM>Z26?Jmi6_WGtdj6V0>_c7gM#>y@;x>}hFe z+(-^ixkVkq&CUIzyW3$T-)96#3-$zu!)eGlv|f<5Mg3zk&UNSe-1he`a}#5X&ybVH zAzkzvsI8}1L7&)@272r^3;%)Fc#ZH{7=4c@Qll2)Fl4~(B3q0I|bO`NC)>^R;KeE}JgG? zF&Brnx3ZH>#OOkIKHT`V;p%4RmH%VJ@jf5e|GI(Z4A}Ujwv3q#@h$XW8!O4L?kC$q-kMgNSAHy3y z1Id&Sk}t9sf-UxXJmI3UwtgK9TzcHhQ}k ziR11~um(H32&$y}Ui=K=WaLh=Z{W-o!I6twS4x;TucEX;I8OE}DLq1}Pd$ngP2O4U`)wqqO$G{{h=h<7M(z!_+&AA1@^XLu!b8E6!>FXXdHHQoJG<}CHUip?XJzdp{sBU% z2^$B#D^RD=!xuk4)$w?5!<;8J&gP{s$sbOz~29=~fo-`{l9H|l0235Vz zX`V50tcNjHG?~E6XDDW#U5>IZ7msnznw*bnEO?mD&uGf2Om!u&YwtrG@3M>o%>mcg zp`fmXnwbCE6;jFV);*fGqN7x7-@U1gl@ciX&>y6C#lDlAa+vK9GJMO-<%f9*6sJ{P zQdDmIS+kcyeLppuHHDs2x!i#%zztD{Z+~sn(Z~TPVOI4FEuMU;-8g*kW-AmZr5c~+ zIgdxtpAmS3jEYdios?(yz3(8dBsLz`^*AL=jdlmumF3pj8&_`^UW-tqyUiN-!s=^| zX9EL>Ew-y_RtaqP^%W~rsxHOi!d=Loz%sNPc4&(5IN7pRRKB85{4*YW-fphvx-AM_ zvE$4wB_ov;CX?7CtUO_|ySGS{F*u+^T5K!n)|ZN;zkjEjKsDf#zuqqQTRSZ%tIA37 z_?K@q>ZfC8$27)YPkbbDy^S(6B9oU{YM7u&dkZP30def|l;F@AmRK$tvN^Urj`aJ0 zz53*vKCCA1!@_T^}@*i@z|I(ox5o1>9H>-q@;loM{3zIRS_YIty0?FNA z=QJ{soFcntx1?_?t32U^?_|!G bKKEzzWcAuh_870xx>CE$;XRc`$V|pwKAWzfD z)b!RFGrJ~O2!UaM-Gxnl44+i&u8`#pSyyYxpAj#=mm7kp3)p_-&SLEdLYBK)0f}S+ zhp-zUi9yq!zRulfQ&NsJE_&Ye^XJd{dOX9i^I+EE<%Lrm0|};^utxoB%vQ^k+d^DL zh4AsI8~F!muqIAaJS8H?%r#$3Q6U+e<$G9IxxC_BjDmfRUHk+U)(UD t2D#;LJnm-wUr}%WRFUwhaqA5K?&^;dsu%Si!IKXmax%)&xera={uemOaJ>Kk From 8b9bddcd0fab63582285b41f4ad664e93bd81f66 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:34:08 +0300 Subject: [PATCH 02/21] refactor: sort the tests into unit, contracts and support --- CMakeLists.txt | 144 +++++++++--------- build.sh | 2 +- src/launcher/CMakeLists.txt | 8 +- .../test_ambient_occlusion_contract.py | 2 +- .../test_backdrop_tiles_contract.py | 4 +- .../test_billboard_aspect_contract.py | 4 +- .../test_borderless_contract.py | 4 +- .../{ => contracts}/test_capsule_contract.py | 8 +- tests/{ => contracts}/test_cheats_contract.py | 2 +- .../test_debug_tab_contract.py | 4 +- .../test_depth24_sync_contract.py | 4 +- .../{ => contracts}/test_dev_menu_contract.py | 4 +- .../test_far_rendering_contract.py | 4 +- .../test_frame_kind_contract.py | 8 +- .../test_frame_rate_contract.py | 4 +- .../test_framework_overlay_new_file.py | 2 +- .../test_gap_filling_contract.py | 2 +- .../test_geometry_provenance_contract.py | 2 +- .../test_grown_quad_contract.py | 4 +- .../test_hud_scale_contract.py | 2 +- .../test_hud_widget_contract.py | 6 +- .../test_intro_skip_contract.py | 4 +- .../{ => contracts}/test_language_contract.py | 4 +- .../test_language_disc_contract.py | 6 +- .../{ => contracts}/test_launcher_contract.py | 2 +- .../test_loaded_state_contract.py | 2 +- .../test_menu_wide_contract.py | 4 +- .../test_name_entry_contract.py | 4 +- .../test_perspective_texture_contract.py | 2 +- .../{ => contracts}/test_phase_a_contract.py | 2 +- .../test_pinned_corner_contract.py | 6 +- .../test_save_point_contract.py | 2 +- .../test_smooth_turn_contract.py | 4 +- .../test_sprite_range_contract.py | 4 +- .../test_temporal_presenter_contract.py | 4 +- .../test_vertical_camera_contract.py | 2 +- .../test_world_depth_contract.py | 4 +- .../{ => support}/disruptor_language_discs.h | 2 +- .../present_rate_fakes/host_ui.h | 0 .../{ => support}/present_rate_fakes/imgui.h | 0 tests/{ => support}/stubs/psx_sdl.h | 0 tests/{ => unit}/disc_import_test.cpp | 0 .../disruptor_backdrop_tiles_test.cpp | 2 +- .../disruptor_billboard_aspect_test.cpp | 2 +- tests/{ => unit}/disruptor_capsule_test.cpp | 2 +- tests/{ => unit}/disruptor_cheats_test.cpp | 2 +- .../disruptor_far_rendering_test.cpp | 2 +- .../{ => unit}/disruptor_frame_rate_test.cpp | 2 +- .../{ => unit}/disruptor_grown_quad_test.cpp | 2 +- .../{ => unit}/disruptor_intro_skip_test.cpp | 2 +- .../disruptor_language_disc_test.cpp | 2 +- .../disruptor_language_hints_test.cpp | 2 +- .../disruptor_language_overlay_test.cpp | 6 +- tests/{ => unit}/disruptor_language_test.cpp | 4 +- tests/{ => unit}/disruptor_mouse_aim_test.cpp | 4 +- .../{ => unit}/disruptor_name_entry_test.cpp | 2 +- .../disruptor_present_rate_test.cpp | 4 +- .../disruptor_render_arena_test.cpp | 2 +- tests/{ => unit}/disruptor_restart_test.cpp | 2 +- .../disruptor_sprite_depth_test.cpp | 2 +- .../disruptor_vertical_camera_test.cpp | 2 +- tests/{ => unit}/gpu_pinned_corner_test.cpp | 0 tests/{ => unit}/gpu_temporal_match_test.cpp | 0 tests/{ => unit}/gpu_temporal_mesh_test.cpp | 0 tests/{ => unit}/gpu_temporal_sprite_test.cpp | 0 .../gpu_turned_perspective_test.cpp | 0 tests/{ => unit}/gpu_world_depth_test.cpp | 0 tests/{ => unit}/gpu_ws_frame_kind_test.cpp | 0 tests/{ => unit}/gpu_ws_hud_scale_test.cpp | 0 tests/{ => unit}/gpu_ws_hud_widget_test.cpp | 0 tests/{ => unit}/gpu_ws_menu_test.cpp | 0 tests/{ => unit}/gpu_ws_tag_match_test.cpp | 0 .../gte_precision_store_pc_test.cpp | 0 tests/{ => unit}/host_borderless_test.cpp | 2 +- tests/{ => unit}/host_pick_slot_test.cpp | 2 +- tests/{ => unit}/launcher_flow_test.cpp | 0 tests/{ => unit}/launcher_settings_test.cpp | 0 .../{ => unit}/presentation_yaw_math_test.cpp | 0 tests/{ => unit}/region_disc_test.cpp | 2 +- tests/{ => unit}/save_point_test.cpp | 0 .../user_settings_persistence_test.cpp | 0 81 files changed, 168 insertions(+), 168 deletions(-) rename tests/{ => contracts}/test_ambient_occlusion_contract.py (99%) rename tests/{ => contracts}/test_backdrop_tiles_contract.py (97%) rename tests/{ => contracts}/test_billboard_aspect_contract.py (97%) rename tests/{ => contracts}/test_borderless_contract.py (96%) rename tests/{ => contracts}/test_capsule_contract.py (97%) rename tests/{ => contracts}/test_cheats_contract.py (98%) rename tests/{ => contracts}/test_debug_tab_contract.py (97%) rename tests/{ => contracts}/test_depth24_sync_contract.py (95%) rename tests/{ => contracts}/test_dev_menu_contract.py (99%) rename tests/{ => contracts}/test_far_rendering_contract.py (98%) rename tests/{ => contracts}/test_frame_kind_contract.py (97%) rename tests/{ => contracts}/test_frame_rate_contract.py (98%) rename tests/{ => contracts}/test_framework_overlay_new_file.py (98%) rename tests/{ => contracts}/test_gap_filling_contract.py (99%) rename tests/{ => contracts}/test_geometry_provenance_contract.py (99%) rename tests/{ => contracts}/test_grown_quad_contract.py (96%) rename tests/{ => contracts}/test_hud_scale_contract.py (99%) rename tests/{ => contracts}/test_hud_widget_contract.py (97%) rename tests/{ => contracts}/test_intro_skip_contract.py (98%) rename tests/{ => contracts}/test_language_contract.py (99%) rename tests/{ => contracts}/test_language_disc_contract.py (99%) rename tests/{ => contracts}/test_launcher_contract.py (99%) rename tests/{ => contracts}/test_loaded_state_contract.py (99%) rename tests/{ => contracts}/test_menu_wide_contract.py (99%) rename tests/{ => contracts}/test_name_entry_contract.py (98%) rename tests/{ => contracts}/test_perspective_texture_contract.py (99%) rename tests/{ => contracts}/test_phase_a_contract.py (99%) rename tests/{ => contracts}/test_pinned_corner_contract.py (98%) rename tests/{ => contracts}/test_save_point_contract.py (99%) rename tests/{ => contracts}/test_smooth_turn_contract.py (98%) rename tests/{ => contracts}/test_sprite_range_contract.py (99%) rename tests/{ => contracts}/test_temporal_presenter_contract.py (99%) rename tests/{ => contracts}/test_vertical_camera_contract.py (98%) rename tests/{ => contracts}/test_world_depth_contract.py (98%) rename tests/{ => support}/disruptor_language_discs.h (99%) rename tests/{ => support}/present_rate_fakes/host_ui.h (100%) rename tests/{ => support}/present_rate_fakes/imgui.h (100%) rename tests/{ => support}/stubs/psx_sdl.h (100%) rename tests/{ => unit}/disc_import_test.cpp (100%) rename tests/{ => unit}/disruptor_backdrop_tiles_test.cpp (99%) rename tests/{ => unit}/disruptor_billboard_aspect_test.cpp (99%) rename tests/{ => unit}/disruptor_capsule_test.cpp (99%) rename tests/{ => unit}/disruptor_cheats_test.cpp (99%) rename tests/{ => unit}/disruptor_far_rendering_test.cpp (99%) rename tests/{ => unit}/disruptor_frame_rate_test.cpp (99%) rename tests/{ => unit}/disruptor_grown_quad_test.cpp (99%) rename tests/{ => unit}/disruptor_intro_skip_test.cpp (99%) rename tests/{ => unit}/disruptor_language_disc_test.cpp (99%) rename tests/{ => unit}/disruptor_language_hints_test.cpp (99%) rename tests/{ => unit}/disruptor_language_overlay_test.cpp (98%) rename tests/{ => unit}/disruptor_language_test.cpp (99%) rename tests/{ => unit}/disruptor_mouse_aim_test.cpp (99%) rename tests/{ => unit}/disruptor_name_entry_test.cpp (99%) rename tests/{ => unit}/disruptor_present_rate_test.cpp (99%) rename tests/{ => unit}/disruptor_render_arena_test.cpp (99%) rename tests/{ => unit}/disruptor_restart_test.cpp (99%) rename tests/{ => unit}/disruptor_sprite_depth_test.cpp (99%) rename tests/{ => unit}/disruptor_vertical_camera_test.cpp (99%) rename tests/{ => unit}/gpu_pinned_corner_test.cpp (100%) rename tests/{ => unit}/gpu_temporal_match_test.cpp (100%) rename tests/{ => unit}/gpu_temporal_mesh_test.cpp (100%) rename tests/{ => unit}/gpu_temporal_sprite_test.cpp (100%) rename tests/{ => unit}/gpu_turned_perspective_test.cpp (100%) rename tests/{ => unit}/gpu_world_depth_test.cpp (100%) rename tests/{ => unit}/gpu_ws_frame_kind_test.cpp (100%) rename tests/{ => unit}/gpu_ws_hud_scale_test.cpp (100%) rename tests/{ => unit}/gpu_ws_hud_widget_test.cpp (100%) rename tests/{ => unit}/gpu_ws_menu_test.cpp (100%) rename tests/{ => unit}/gpu_ws_tag_match_test.cpp (100%) rename tests/{ => unit}/gte_precision_store_pc_test.cpp (100%) rename tests/{ => unit}/host_borderless_test.cpp (97%) rename tests/{ => unit}/host_pick_slot_test.cpp (96%) rename tests/{ => unit}/launcher_flow_test.cpp (100%) rename tests/{ => unit}/launcher_settings_test.cpp (100%) rename tests/{ => unit}/presentation_yaw_math_test.cpp (100%) rename tests/{ => unit}/region_disc_test.cpp (98%) rename tests/{ => unit}/save_point_test.cpp (100%) rename tests/{ => unit}/user_settings_persistence_test.cpp (100%) diff --git a/CMakeLists.txt b/CMakeLists.txt index a477231..e1cb989 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -181,147 +181,147 @@ if(BUILD_TESTING) add_test(NAME disruptor_backdrop_tiles_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_backdrop_tiles_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_backdrop_tiles_contract.py" --require-artifacts) add_test(NAME disruptor_depth24_sync_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_depth24_sync_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py") add_test(NAME disruptor_geometry_provenance_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_geometry_provenance_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_geometry_provenance_contract.py") add_test(NAME disruptor_phase_a_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_phase_a_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_phase_a_contract.py") add_test(NAME disruptor_perspective_texture_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_perspective_texture_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_perspective_texture_contract.py") add_test(NAME disruptor_pinned_corner_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pinned_corner_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py") add_test(NAME disruptor_dev_menu_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_dev_menu_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_dev_menu_contract.py") add_test(NAME disruptor_loaded_state_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_loaded_state_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_loaded_state_contract.py") add_test(NAME disruptor_save_point_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_save_point_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_save_point_contract.py") add_test(NAME disruptor_cheats_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cheats_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_cheats_contract.py") add_test(NAME disruptor_vertical_camera_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_vertical_camera_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_vertical_camera_contract.py") add_test(NAME disruptor_far_rendering_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_far_rendering_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_far_rendering_contract.py") add_test(NAME disruptor_frame_kind_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_kind_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py") add_test(NAME disruptor_temporal_presenter_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_temporal_presenter_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_temporal_presenter_contract.py") add_test(NAME disruptor_menu_wide_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_wide_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_menu_wide_contract.py") add_test(NAME disruptor_hud_scale_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_scale_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_scale_contract.py") add_test(NAME disruptor_hud_widget_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_widget_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py") add_test(NAME disruptor_name_entry_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_name_entry_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_name_entry_contract.py" --require-artifacts) set_tests_properties(disruptor_name_entry_contract PROPERTIES LABELS retail) add_test(NAME disruptor_grown_quad_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_grown_quad_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_grown_quad_contract.py" --require-artifacts) add_test(NAME disruptor_sprite_range_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sprite_range_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_sprite_range_contract.py" --require-artifacts) set_tests_properties(disruptor_sprite_range_contract PROPERTIES LABELS retail) add_test(NAME disruptor_intro_skip_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_intro_skip_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_intro_skip_contract.py" --require-artifacts) add_test(NAME disruptor_language_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_language_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_contract.py" --require-artifacts) add_test(NAME disruptor_language_disc_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_language_disc_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_disc_contract.py" --require-artifacts) add_test(NAME disruptor_frame_rate_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_rate_contract.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_rate_contract.py" --require-artifacts) add_test(NAME disruptor_billboard_aspect_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_billboard_aspect_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_billboard_aspect_contract.py") add_test(NAME disruptor_launcher_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_launcher_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_launcher_contract.py") add_test(NAME disruptor_capsule_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_capsule_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_capsule_contract.py") add_test(NAME disruptor_gap_filling_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gap_filling_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_gap_filling_contract.py") add_test(NAME disruptor_world_depth_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_world_depth_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_world_depth_contract.py") add_test(NAME disruptor_smooth_turn_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_smooth_turn_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_smooth_turn_contract.py") add_test(NAME disruptor_ambient_occlusion_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ambient_occlusion_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_ambient_occlusion_contract.py") add_test(NAME disruptor_debug_tab_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_debug_tab_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_debug_tab_contract.py") add_test(NAME disruptor_framework_overlay_new_file COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_framework_overlay_new_file.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_overlay_new_file.py") # These need the retail image or the translated code. A machine without # the game leaves them out with `ctest -LE retail`. @@ -334,7 +334,7 @@ endif() if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_executable(disruptor-user-settings-test - tests/user_settings_persistence_test.cpp + tests/unit/user_settings_persistence_test.cpp "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") target_include_directories(disruptor-user-settings-test PRIVATE @@ -351,46 +351,46 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-user-settings-test) add_executable(disruptor-mouse-aim-test - tests/disruptor_mouse_aim_test.cpp) + tests/unit/disruptor_mouse_aim_test.cpp) target_include_directories(disruptor-mouse-aim-test PRIVATE - tests/stubs) + tests/support/stubs) target_compile_definitions(disruptor-mouse-aim-test PRIVATE PSX_SDL3=1) target_compile_features(disruptor-mouse-aim-test PRIVATE cxx_std_20) add_executable(disruptor-cheats-test - tests/disruptor_cheats_test.cpp) + tests/unit/disruptor_cheats_test.cpp) target_include_directories(disruptor-cheats-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-cheats-test PRIVATE cxx_std_20) add_executable(disruptor-vertical-camera-test - tests/disruptor_vertical_camera_test.cpp) + tests/unit/disruptor_vertical_camera_test.cpp) target_include_directories(disruptor-vertical-camera-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-vertical-camera-test PRIVATE cxx_std_20) add_executable(disruptor-far-rendering-test - tests/disruptor_far_rendering_test.cpp) + tests/unit/disruptor_far_rendering_test.cpp) target_include_directories(disruptor-far-rendering-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-far-rendering-test PRIVATE cxx_std_20) add_executable(disruptor-render-arena-test - tests/disruptor_render_arena_test.cpp) + tests/unit/disruptor_render_arena_test.cpp) target_include_directories(disruptor-render-arena-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-render-arena-test PRIVATE cxx_std_20) add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test) add_executable(disruptor-name-entry-test - tests/disruptor_name_entry_test.cpp) + tests/unit/disruptor_name_entry_test.cpp) target_include_directories(disruptor-name-entry-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-name-entry-test PRIVATE cxx_std_20) add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test) add_executable(disruptor-backdrop-tiles-test - tests/disruptor_backdrop_tiles_test.cpp) + tests/unit/disruptor_backdrop_tiles_test.cpp) target_include_directories(disruptor-backdrop-tiles-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-backdrop-tiles-test PRIVATE cxx_std_20) @@ -398,7 +398,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-backdrop-tiles-test) add_executable(disruptor-gpu-ws-frame-kind-test - tests/gpu_ws_frame_kind_test.cpp) + tests/unit/gpu_ws_frame_kind_test.cpp) target_include_directories(disruptor-gpu-ws-frame-kind-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-frame-kind-test PRIVATE cxx_std_20) @@ -406,7 +406,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-frame-kind-test) add_executable(disruptor-gpu-ws-menu-test - tests/gpu_ws_menu_test.cpp) + tests/unit/gpu_ws_menu_test.cpp) target_include_directories(disruptor-gpu-ws-menu-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-menu-test PRIVATE cxx_std_20) @@ -414,7 +414,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-menu-test) add_executable(disruptor-gpu-ws-hud-scale-test - tests/gpu_ws_hud_scale_test.cpp) + tests/unit/gpu_ws_hud_scale_test.cpp) target_include_directories(disruptor-gpu-ws-hud-scale-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-hud-scale-test PRIVATE cxx_std_20) @@ -422,7 +422,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-hud-scale-test) add_executable(disruptor-gpu-ws-hud-widget-test - tests/gpu_ws_hud_widget_test.cpp) + tests/unit/gpu_ws_hud_widget_test.cpp) target_include_directories(disruptor-gpu-ws-hud-widget-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-hud-widget-test PRIVATE cxx_std_20) @@ -430,7 +430,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-ws-hud-widget-test) add_executable(disruptor-intro-skip-test - tests/disruptor_intro_skip_test.cpp) + tests/unit/disruptor_intro_skip_test.cpp) target_include_directories(disruptor-intro-skip-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-intro-skip-test PRIVATE cxx_std_20) @@ -438,7 +438,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-intro-skip-test) add_executable(disruptor-frame-rate-test - tests/disruptor_frame_rate_test.cpp) + tests/unit/disruptor_frame_rate_test.cpp) target_include_directories(disruptor-frame-rate-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-frame-rate-test PRIVATE cxx_std_20) @@ -446,7 +446,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-frame-rate-test) add_executable(disruptor-language-test - tests/disruptor_language_test.cpp) + tests/unit/disruptor_language_test.cpp) target_include_directories(disruptor-language-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-language-test PRIVATE cxx_std_20) @@ -454,7 +454,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-language-test) add_executable(disruptor-language-hints-test - tests/disruptor_language_hints_test.cpp) + tests/unit/disruptor_language_hints_test.cpp) target_include_directories(disruptor-language-hints-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-language-hints-test PRIVATE cxx_std_20) @@ -462,13 +462,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-language-hints-test) add_executable(disruptor-language-disc-test - tests/disruptor_language_disc_test.cpp) + tests/unit/disruptor_language_disc_test.cpp) target_compile_features(disruptor-language-disc-test PRIVATE cxx_std_20) add_test(NAME disruptor_language_disc COMMAND disruptor-language-disc-test) add_executable(disruptor-language-overlay-test - tests/disruptor_language_overlay_test.cpp) + tests/unit/disruptor_language_overlay_test.cpp) target_include_directories(disruptor-language-overlay-test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" "${PSXRECOMP_ROOT}/runtime/include" @@ -480,29 +480,29 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-language-overlay-test tall) add_executable(disruptor-capsule-test - tests/disruptor_capsule_test.cpp) + tests/unit/disruptor_capsule_test.cpp) target_compile_features(disruptor-capsule-test PRIVATE cxx_std_20) target_include_directories(disruptor-capsule-test PRIVATE src) add_test(NAME disruptor_capsule COMMAND disruptor-capsule-test) add_executable(host-pick-slot-test - tests/host_pick_slot_test.cpp) + tests/unit/host_pick_slot_test.cpp) target_compile_features(host-pick-slot-test PRIVATE cxx_std_20) add_test(NAME host_pick_slot COMMAND host-pick-slot-test) add_executable(host-borderless-test - tests/host_borderless_test.cpp) + tests/unit/host_borderless_test.cpp) target_compile_features(host-borderless-test PRIVATE cxx_std_20) add_test(NAME host_borderless COMMAND host-borderless-test) add_test(NAME host_borderless_contract COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_borderless_contract.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") add_executable(disruptor-restart-test - tests/disruptor_restart_test.cpp) + tests/unit/disruptor_restart_test.cpp) target_include_directories(disruptor-restart-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-restart-test PRIVATE cxx_std_20) @@ -510,15 +510,15 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-restart-test run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") add_executable(disruptor-present-rate-test - tests/disruptor_present_rate_test.cpp) + tests/unit/disruptor_present_rate_test.cpp) target_include_directories(disruptor-present-rate-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/tests/present_rate_fakes") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") target_compile_features(disruptor-present-rate-test PRIVATE cxx_std_20) add_test(NAME disruptor_present_rate COMMAND disruptor-present-rate-test) add_executable(disruptor-grown-quad-test - tests/disruptor_grown_quad_test.cpp) + tests/unit/disruptor_grown_quad_test.cpp) target_include_directories(disruptor-grown-quad-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-grown-quad-test PRIVATE cxx_std_20) @@ -526,7 +526,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-grown-quad-test) add_executable(disruptor-sprite-depth-test - tests/disruptor_sprite_depth_test.cpp) + tests/unit/disruptor_sprite_depth_test.cpp) target_include_directories(disruptor-sprite-depth-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-sprite-depth-test PRIVATE cxx_std_20) @@ -534,13 +534,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-sprite-depth-test) add_executable(disruptor-billboard-aspect-test - tests/disruptor_billboard_aspect_test.cpp) + tests/unit/disruptor_billboard_aspect_test.cpp) target_include_directories(disruptor-billboard-aspect-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-billboard-aspect-test PRIVATE cxx_std_20) add_executable(disruptor-gpu-temporal-sprite-test - tests/gpu_temporal_sprite_test.cpp) + tests/unit/gpu_temporal_sprite_test.cpp) target_include_directories(disruptor-gpu-temporal-sprite-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-temporal-sprite-test PRIVATE cxx_std_20) @@ -548,13 +548,13 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-temporal-sprite-test) add_executable(disruptor-gpu-ws-tag-match-test - tests/gpu_ws_tag_match_test.cpp) + tests/unit/gpu_ws_tag_match_test.cpp) target_include_directories(disruptor-gpu-ws-tag-match-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-ws-tag-match-test PRIVATE cxx_std_20) add_executable(disruptor-gpu-temporal-match-test - tests/gpu_temporal_match_test.cpp) + tests/unit/gpu_temporal_match_test.cpp) target_include_directories(disruptor-gpu-temporal-match-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-temporal-match-test PRIVATE cxx_std_20) @@ -562,7 +562,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-temporal-match-test) add_executable(disruptor-gpu-temporal-mesh-test - tests/gpu_temporal_mesh_test.cpp) + tests/unit/gpu_temporal_mesh_test.cpp) target_include_directories(disruptor-gpu-temporal-mesh-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-temporal-mesh-test PRIVATE cxx_std_20) @@ -570,7 +570,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-temporal-mesh-test) add_executable(disruptor-gpu-pinned-corner-test - tests/gpu_pinned_corner_test.cpp) + tests/unit/gpu_pinned_corner_test.cpp) target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20) @@ -578,7 +578,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-pinned-corner-test) add_executable(disruptor-gpu-turned-perspective-test - tests/gpu_turned_perspective_test.cpp) + tests/unit/gpu_turned_perspective_test.cpp) target_include_directories(disruptor-gpu-turned-perspective-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-turned-perspective-test PRIVATE cxx_std_20) @@ -586,7 +586,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-turned-perspective-test) add_executable(disruptor-gpu-world-depth-test - tests/gpu_world_depth_test.cpp) + tests/unit/gpu_world_depth_test.cpp) target_include_directories(disruptor-gpu-world-depth-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-gpu-world-depth-test PRIVATE cxx_std_20) @@ -594,7 +594,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-gpu-world-depth-test) add_executable(disruptor-save-point-test - tests/save_point_test.cpp) + tests/unit/save_point_test.cpp) target_include_directories(disruptor-save-point-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") target_compile_features(disruptor-save-point-test PRIVATE cxx_std_20) @@ -602,11 +602,11 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-save-point-test) add_executable(disruptor-presentation-yaw-math-test - tests/presentation_yaw_math_test.cpp) + tests/unit/presentation_yaw_math_test.cpp) target_compile_features(disruptor-presentation-yaw-math-test PRIVATE cxx_std_20) add_executable(disruptor-gte-precision-store-pc-test - tests/gte_precision_store_pc_test.cpp + tests/unit/gte_precision_store_pc_test.cpp "${PSXRECOMP_ROOT}/runtime/src/gte.cpp") target_include_directories(disruptor-gte-precision-store-pc-test PRIVATE "${PSXRECOMP_ROOT}/runtime/include") diff --git a/build.sh b/build.sh index 01cddf6..4f911f2 100755 --- a/build.sh +++ b/build.sh @@ -86,7 +86,7 @@ if [ -n "$WITHOUT_GAME" ]; then printf '%s\n' $OBJECTS $TESTS | xargs -n 100 ninja -C "$BUILD" ctest --test-dir "$BUILD" --output-on-failure --no-tests=error -LE retail # The retail contracts still check the sources when the image is not asked for. - for contract in tests/test_*_contract.py; do + for contract in tests/contracts/test_*_contract.py; do python3 -B "$contract" done echo "Built and tested without the game: the runtime was compiled and not linked." diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 10696dc..d52c9eb 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -70,7 +70,7 @@ set_property(TARGET disruptor-launcher APPEND PROPERTY LINK_DEPENDS "${PROJECT_SOURCE_DIR}/release/windows/settings.toml") if(BUILD_TESTING) - add_executable(disruptor-disc-import-test "${PROJECT_SOURCE_DIR}/tests/disc_import_test.cpp") + add_executable(disruptor-disc-import-test "${PROJECT_SOURCE_DIR}/tests/unit/disc_import_test.cpp") set_target_properties(disruptor-disc-import-test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") target_link_libraries(disruptor-disc-import-test PRIVATE disruptor-disc-import) @@ -81,18 +81,18 @@ if(BUILD_TESTING) endif() add_test(NAME disruptor_disc_import COMMAND disruptor-disc-import-test) - add_executable(disruptor-launcher-settings-test "${PROJECT_SOURCE_DIR}/tests/launcher_settings_test.cpp") + add_executable(disruptor-launcher-settings-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_settings_test.cpp") target_link_libraries(disruptor-launcher-settings-test PRIVATE disruptor-launcher-model) target_compile_definitions(disruptor-launcher-settings-test PRIVATE "DISRUPTOR_SOURCE_DIR=LR\"(${PROJECT_SOURCE_DIR})\"") add_test(NAME disruptor_launcher_settings COMMAND disruptor-launcher-settings-test) - add_executable(disruptor-launcher-flow-test "${PROJECT_SOURCE_DIR}/tests/launcher_flow_test.cpp") + add_executable(disruptor-launcher-flow-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_flow_test.cpp") target_link_libraries(disruptor-launcher-flow-test PRIVATE disruptor-launcher-model) add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test) # The synthetic discs include the layout code whole, so this test takes region_disc.cpp and not the library. add_executable(disruptor-region-disc-test - "${PROJECT_SOURCE_DIR}/tests/region_disc_test.cpp" region_disc.cpp) + "${PROJECT_SOURCE_DIR}/tests/unit/region_disc_test.cpp" region_disc.cpp) target_include_directories(disruptor-region-disc-test PRIVATE "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-region-disc-test PRIVATE disruptor-disc-import) add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test) diff --git a/tests/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py similarity index 99% rename from tests/test_ambient_occlusion_contract.py rename to tests/contracts/test_ambient_occlusion_contract.py index c5374f7..3224d81 100644 --- a/tests/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py similarity index 97% rename from tests/test_backdrop_tiles_contract.py rename to tests/contracts/test_backdrop_tiles_contract.py index b60ad84..08752e6 100644 --- a/tests/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 BACKDROP_BEGIN = 0x8003AF68 @@ -67,7 +67,7 @@ def require(condition: bool, message: str) -> None: "backdrop tile source, its unit test and its contract test must remain registered", ) require( - 'tests/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, + 'tests/contracts/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py similarity index 97% rename from tests/test_billboard_aspect_contract.py rename to tests/contracts/test_billboard_aspect_contract.py index 7d37aee..c3c3d91 100644 --- a/tests/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -70,7 +70,7 @@ def require(condition: bool, message: str) -> None: "dirty-interpreter parity must be project-gated and post-instruction") require("PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1" in cmake and "src/disruptor_billboard_aspect.cpp" in cmake and - "tests/disruptor_billboard_aspect_test.cpp" in cmake, + "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, "runtime source, dirty seam, and focused behavior test must be enabled") require("gpu_ws_tag_primitive" in gpu_h and diff --git a/tests/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py similarity index 96% rename from tests/test_borderless_contract.py rename to tests/contracts/test_borderless_contract.py index 0be89d4..ef8bed5 100644 --- a/tests/test_borderless_contract.py +++ b/tests/contracts/test_borderless_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -61,7 +61,7 @@ def require(condition: bool, message: str) -> None: require( "+runtime/include/host_borderless.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() and " add_test(NAME host_borderless\n COMMAND host-borderless-test)\n" in cmake - and 'tests/test_borderless_contract.py")' in cmake, + and 'tests/contracts/test_borderless_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py similarity index 97% rename from tests/test_capsule_contract.py rename to tests/contracts/test_capsule_contract.py index 696a2d6..a909dff 100644 --- a/tests/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -130,10 +130,10 @@ def require(condition: bool, message: str) -> None: require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake - and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../src/disruptor_capsule.cpp"\n' - in read("tests/disruptor_capsule_test.cpp") + and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/disruptor_capsule.cpp"\n' + in read("tests/unit/disruptor_capsule_test.cpp") and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake - and 'tests/test_capsule_contract.py")' in cmake, + and 'tests/contracts/test_capsule_contract.py")' in cmake, "the capsule and its tests must remain built and registered", ) require( diff --git a/tests/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py similarity index 98% rename from tests/test_cheats_contract.py rename to tests/contracts/test_cheats_contract.py index da5c441..135d4db 100644 --- a/tests/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -8,7 +8,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 diff --git a/tests/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py similarity index 97% rename from tests/test_debug_tab_contract.py rename to tests/contracts/test_debug_tab_contract.py index 4f138a0..0165e16 100644 --- a/tests/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -88,7 +88,7 @@ def between(source: str, opening: str, closing: str) -> str: and """ if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\\n"; """ in loader - and '"disruptor.diagnostic_view",' in read("tests/test_launcher_contract.py"), + and '"disruptor.diagnostic_view",' in read("tests/contracts/test_launcher_contract.py"), "the file names a view by its word, a word it does not know is no view, and the launcher is decided not to offer it", ) diff --git a/tests/test_depth24_sync_contract.py b/tests/contracts/test_depth24_sync_contract.py similarity index 95% rename from tests/test_depth24_sync_contract.py rename to tests/contracts/test_depth24_sync_contract.py index 3f40ac4..f33e4a5 100644 --- a/tests/test_depth24_sync_contract.py +++ b/tests/contracts/test_depth24_sync_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -40,7 +40,7 @@ def enclosing(source: str, block: str) -> list[str]: REGISTRATION = ( ' add_test(NAME disruptor_depth24_sync_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_depth24_sync_contract.py")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py")\n' ) require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], diff --git a/tests/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py similarity index 99% rename from tests/test_dev_menu_contract.py rename to tests/contracts/test_dev_menu_contract.py index 92d22df..b0be712 100644 --- a/tests/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -7,7 +7,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -80,7 +80,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "target_link_libraries(psx-runtime PRIVATE disruptor-imgui)" in cmake, "menu implementation must be linked only into the game runtime") require("src/disruptor_cheats.cpp" in cmake and - "tests/disruptor_cheats_test.cpp" in cmake, + "tests/unit/disruptor_cheats_test.cpp" in cmake, "version-pinned cheats and their behavioral test must be built") require("+runtime/include/host_ui.h" in manifest, "new host UI ABI header must remain reviewed in the overlay manifest") diff --git a/tests/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py similarity index 98% rename from tests/test_far_rendering_contract.py rename to tests/contracts/test_far_rendering_contract.py index 821ab31..e56bd87 100644 --- a/tests/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -29,7 +29,7 @@ def require(condition: bool, message: str) -> None: configs = read("game.toml") + read("game-widescreen.toml") require("src/disruptor_far_rendering.cpp" in cmake and - "tests/disruptor_far_rendering_test.cpp" in cmake and + "tests/unit/disruptor_far_rendering_test.cpp" in cmake and "add_test(NAME disruptor_far_rendering" in cmake, "runtime source and focused behavior test must remain enabled") require("PSX_HAS_DISRUPTOR_FAR_RENDERING=1" in cmake, diff --git a/tests/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py similarity index 97% rename from tests/test_frame_kind_contract.py rename to tests/contracts/test_frame_kind_contract.py index 0965026..1f06686 100644 --- a/tests/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -49,10 +49,10 @@ def enclosing(source: str, block: str) -> list[str]: REGISTERED = { ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp"\n': [], ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_kind_contract.py")\n': CONTRACTS, - " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py")\n': CONTRACTS, + " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/unit/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, " add_test(NAME disruptor_gpu_ws_frame_kind\n COMMAND disruptor-gpu-ws-frame-kind-test)\n": UNIT_TESTS, - " add_executable(disruptor-render-arena-test\n tests/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, + " add_executable(disruptor-render-arena-test\n tests/unit/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, " add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test)\n": UNIT_TESTS, } for block, conditions in REGISTERED.items(): diff --git a/tests/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py similarity index 98% rename from tests/test_frame_rate_contract.py rename to tests/contracts/test_frame_rate_contract.py index c74cd30..fb9d846 100644 --- a/tests/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -51,7 +51,7 @@ def require(condition: bool, message: str) -> None: cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") require( - 'tests/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, + 'tests/contracts/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/test_framework_overlay_new_file.py b/tests/contracts/test_framework_overlay_new_file.py similarity index 98% rename from tests/test_framework_overlay_new_file.py rename to tests/contracts/test_framework_overlay_new_file.py index 6617bab..660600a 100644 --- a/tests/test_framework_overlay_new_file.py +++ b/tests/contracts/test_framework_overlay_new_file.py @@ -9,7 +9,7 @@ import tempfile -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] APPLIER = ROOT / "tools" / "apply_framework_overlay.py" PROJECT_OVERLAY = ROOT / "psxrecomp-overlay" PROJECT_MANIFEST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" diff --git a/tests/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py similarity index 99% rename from tests/test_gap_filling_contract.py rename to tests/contracts/test_gap_filling_contract.py index 4a29417..5f985fa 100644 --- a/tests/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py similarity index 99% rename from tests/test_geometry_provenance_contract.py rename to tests/contracts/test_geometry_provenance_contract.py index a61795c..722b26e 100644 --- a/tests/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -8,7 +8,7 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" MEMORY = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "memory.c" diff --git a/tests/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py similarity index 96% rename from tests/test_grown_quad_contract.py rename to tests/contracts/test_grown_quad_contract.py index e6a9051..73077dc 100644 --- a/tests/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -43,7 +43,7 @@ def require(condition: bool, message: str) -> None: '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake and "add_test(NAME disruptor_grown_quad\n" in cmake - and 'tests/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, + and 'tests/contracts/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, "the grown quad source, its renderer switch, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py similarity index 99% rename from tests/test_hud_scale_contract.py rename to tests/contracts/test_hud_scale_contract.py index 7722d05..2165a3d 100644 --- a/tests/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py similarity index 97% rename from tests/test_hud_widget_contract.py rename to tests/contracts/test_hud_widget_contract.py index 74a7776..dee123f 100644 --- a/tests/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -31,10 +31,10 @@ def body(source: str, signature: str) -> str: "overlay manifest must list the HUD widget arithmetic", ) require( - " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/gpu_ws_hud_widget_test.cpp)\n" in cmake + " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/unit/gpu_ws_hud_widget_test.cpp)\n" in cmake and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hud_widget_contract.py")\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py")\n' in cmake and '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) diff --git a/tests/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py similarity index 98% rename from tests/test_intro_skip_contract.py rename to tests/contracts/test_intro_skip_contract.py index 71af171..53d2fa9 100644 --- a/tests/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -62,7 +62,7 @@ def require(condition: bool, message: str) -> None: ) require( - 'tests/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, + 'tests/contracts/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, "the build gate must run this contract with its inputs required", ) for address, word in SEAMS.items(): diff --git a/tests/test_language_contract.py b/tests/contracts/test_language_contract.py similarity index 99% rename from tests/test_language_contract.py rename to tests/contracts/test_language_contract.py index 551b8ab..a81fc48 100644 --- a/tests/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -95,7 +95,7 @@ def require(condition: bool, message: str) -> None: require( '/src/disruptor_language.cpp"' in cmake and "add_test(NAME disruptor_language\n" in cmake - and 'tests/test_language_contract.py"\n --require-artifacts)' in cmake, + and 'tests/contracts/test_language_contract.py"\n --require-artifacts)' in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", ) require( diff --git a/tests/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py similarity index 99% rename from tests/test_language_disc_contract.py rename to tests/contracts/test_language_disc_contract.py index bc7dd88..27ceebe 100644 --- a/tests/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -85,8 +85,8 @@ def require(condition: bool, message: str) -> None: and "add_test(NAME disruptor_language_disc\n" in cmake and "add_test(NAME disruptor_language_overlay\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake - and " tests/disruptor_language_overlay_test.cpp)" in cmake - and 'tests/test_language_disc_contract.py"\n --require-artifacts)' in cmake, + and " tests/unit/disruptor_language_overlay_test.cpp)" in cmake + and 'tests/contracts/test_language_disc_contract.py"\n --require-artifacts)' in cmake, "the language disc sources and their three tests must remain registered, the contract with its inputs required", ) require( diff --git a/tests/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py similarity index 99% rename from tests/test_launcher_contract.py rename to tests/contracts/test_launcher_contract.py index a797db2..b5bf35d 100644 --- a/tests/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # Keys the game saves and the launcher leaves to the in-game menu, the command line or a hand edit. NOT_OFFERED = { diff --git a/tests/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py similarity index 99% rename from tests/test_loaded_state_contract.py rename to tests/contracts/test_loaded_state_contract.py index 103b84f..baef1e6 100644 --- a/tests/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -7,7 +7,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_menu_wide_contract.py b/tests/contracts/test_menu_wide_contract.py similarity index 99% rename from tests/test_menu_wide_contract.py rename to tests/contracts/test_menu_wide_contract.py index fc55076..31aa9ed 100644 --- a/tests/test_menu_wide_contract.py +++ b/tests/contracts/test_menu_wide_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -195,7 +195,7 @@ def require(condition: bool, message: str) -> None: require( "+runtime/include/gpu_ws_menu.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() and " add_test(NAME disruptor_gpu_ws_menu\n COMMAND disruptor-gpu-ws-menu-test)\n" in cmake - and 'tests/test_menu_wide_contract.py")' in cmake, + and 'tests/contracts/test_menu_wide_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py similarity index 98% rename from tests/test_name_entry_contract.py rename to tests/contracts/test_name_entry_contract.py index c8f7a76..3de0d7c 100644 --- a/tests/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 ROUTINE = 0x8001C484 @@ -100,7 +100,7 @@ def require(condition: bool, message: str) -> None: ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake - and 'tests/test_name_entry_contract.py"\n --require-artifacts)\n' + and 'tests/contracts/test_name_entry_contract.py"\n --require-artifacts)\n' " set_tests_properties(disruptor_name_entry_contract\n PROPERTIES LABELS retail)\n" in cmake, "the module, its switch for the host, its unit test and this contract with its input must stay registered", diff --git a/tests/test_perspective_texture_contract.py b/tests/contracts/test_perspective_texture_contract.py similarity index 99% rename from tests/test_perspective_texture_contract.py rename to tests/contracts/test_perspective_texture_contract.py index 8d5ca25..81af699 100644 --- a/tests/test_perspective_texture_contract.py +++ b/tests/contracts/test_perspective_texture_contract.py @@ -13,7 +13,7 @@ from math import isclose -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] OVERLAY = ROOT / "psxrecomp-overlay" / "runtime" MAIN = OVERLAY / "src" / "main.cpp" GPU = OVERLAY / "src" / "gpu.c" diff --git a/tests/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py similarity index 99% rename from tests/test_phase_a_contract.py rename to tests/contracts/test_phase_a_contract.py index e9411e4..77f68f8 100644 --- a/tests/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -8,7 +8,7 @@ import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" GL = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu_gl_renderer.c" diff --git a/tests/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py similarity index 98% rename from tests/test_pinned_corner_contract.py rename to tests/contracts/test_pinned_corner_contract.py index e4b6dcd..0fa9432 100644 --- a/tests/test_pinned_corner_contract.py +++ b/tests/contracts/test_pinned_corner_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -52,10 +52,10 @@ def enclosing(source: str, block: str) -> list[str]: REGISTRATION = ( ' add_test(NAME disruptor_pinned_corner_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pinned_corner_contract.py")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py")\n' ) UNIT_TEST = ( - " add_executable(disruptor-gpu-pinned-corner-test\n tests/gpu_pinned_corner_test.cpp)\n" + " add_executable(disruptor-gpu-pinned-corner-test\n tests/unit/gpu_pinned_corner_test.cpp)\n" " target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE\n" ' "${PSXRECOMP_ROOT}/runtime/include")\n' " target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20)\n" diff --git a/tests/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py similarity index 99% rename from tests/test_save_point_contract.py rename to tests/contracts/test_save_point_contract.py index aa87912..fdaff40 100644 --- a/tests/test_save_point_contract.py +++ b/tests/contracts/test_save_point_contract.py @@ -8,7 +8,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] POLL = "savestate_poll(cpu, s_compiled_interrupt_resume_pc);" diff --git a/tests/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py similarity index 98% rename from tests/test_smooth_turn_contract.py rename to tests/contracts/test_smooth_turn_contract.py index 0f37728..481c421 100644 --- a/tests/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -3,7 +3,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -31,7 +31,7 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 and '#include "gpu_turned_perspective.h"\n' in renderer - and " add_executable(disruptor-gpu-turned-perspective-test\n tests/gpu_turned_perspective_test.cpp)\n" in cmake + and " add_executable(disruptor-gpu-turned-perspective-test\n tests/unit/gpu_turned_perspective_test.cpp)\n" in cmake and " add_test(NAME disruptor_gpu_turned_perspective\n COMMAND disruptor-gpu-turned-perspective-test)\n" in cmake and " add_test(NAME disruptor_smooth_turn_contract\n" in cmake, "the turned perspective's header is applied to the framework and its test and this contract are registered", diff --git a/tests/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py similarity index 99% rename from tests/test_sprite_range_contract.py rename to tests/contracts/test_sprite_range_contract.py index 2ce1ece..84e7b74 100644 --- a/tests/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -187,7 +187,7 @@ def require(condition: bool, message: str) -> None: gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( - 'tests/test_sprite_range_contract.py"\n --require-artifacts)' in cmake + 'tests/contracts/test_sprite_range_contract.py"\n --require-artifacts)' in cmake and " set_tests_properties(disruptor_sprite_range_contract\n PROPERTIES LABELS retail)\n" in cmake and " add_test(NAME disruptor_sprite_depth\n" in cmake, "this contract with its input and the unit test of the range must stay registered", diff --git a/tests/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py similarity index 99% rename from tests/test_temporal_presenter_contract.py rename to tests/contracts/test_temporal_presenter_contract.py index 975af03..2d4eb27 100644 --- a/tests/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -229,7 +229,7 @@ def shader(source: str, name: str) -> str: " squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides);" in sides and '#include "gpu_temporal_sprite.h"' in gpu and "static inline void gpu_temporal_sprite_sides(const GpuTemporalSprite *sprite, float left, float top, int w, int h," in sprite_header - and "tests/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") + and "tests/unit/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") and "+runtime/include/gpu_temporal_sprite.h" in read("PSXRECOMP_OVERLAY_FILES.txt") and " if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return;" in sizer and " t->placed = t->sized = t->shadow = t->whole = 0;" in gpu, diff --git a/tests/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py similarity index 98% rename from tests/test_vertical_camera_contract.py rename to tests/contracts/test_vertical_camera_contract.py index 4695e11..b8f827d 100644 --- a/tests/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -6,7 +6,7 @@ import sys -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: diff --git a/tests/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py similarity index 98% rename from tests/test_world_depth_contract.py rename to tests/contracts/test_world_depth_contract.py index 9934c50..d7776ce 100644 --- a/tests/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -4,7 +4,7 @@ import re from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] def read(path: str) -> str: @@ -33,7 +33,7 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 and '#include "gpu_world_depth.h"\n' in renderer - and " add_executable(disruptor-gpu-world-depth-test\n tests/gpu_world_depth_test.cpp)\n" in cmake + and " add_executable(disruptor-gpu-world-depth-test\n tests/unit/gpu_world_depth_test.cpp)\n" in cmake and " add_test(NAME disruptor_gpu_world_depth\n COMMAND disruptor-gpu-world-depth-test)\n" in cmake and " add_test(NAME disruptor_world_depth_contract\n" in cmake, "the rule's header is applied to the framework and its test and this contract are registered", diff --git a/tests/disruptor_language_discs.h b/tests/support/disruptor_language_discs.h similarity index 99% rename from tests/disruptor_language_discs.h rename to tests/support/disruptor_language_discs.h index e386c53..cb7b297 100644 --- a/tests/disruptor_language_discs.h +++ b/tests/support/disruptor_language_discs.h @@ -3,7 +3,7 @@ /* Synthetic discs for the language tests. The disc code is included whole, so that a test reaches its internals. */ -#include "../src/disruptor_language_disc.cpp" +#include "../../src/disruptor_language_disc.cpp" namespace { diff --git a/tests/present_rate_fakes/host_ui.h b/tests/support/present_rate_fakes/host_ui.h similarity index 100% rename from tests/present_rate_fakes/host_ui.h rename to tests/support/present_rate_fakes/host_ui.h diff --git a/tests/present_rate_fakes/imgui.h b/tests/support/present_rate_fakes/imgui.h similarity index 100% rename from tests/present_rate_fakes/imgui.h rename to tests/support/present_rate_fakes/imgui.h diff --git a/tests/stubs/psx_sdl.h b/tests/support/stubs/psx_sdl.h similarity index 100% rename from tests/stubs/psx_sdl.h rename to tests/support/stubs/psx_sdl.h diff --git a/tests/disc_import_test.cpp b/tests/unit/disc_import_test.cpp similarity index 100% rename from tests/disc_import_test.cpp rename to tests/unit/disc_import_test.cpp diff --git a/tests/disruptor_backdrop_tiles_test.cpp b/tests/unit/disruptor_backdrop_tiles_test.cpp similarity index 99% rename from tests/disruptor_backdrop_tiles_test.cpp rename to tests/unit/disruptor_backdrop_tiles_test.cpp index 3a7aba1..99f37a3 100644 --- a/tests/disruptor_backdrop_tiles_test.cpp +++ b/tests/unit/disruptor_backdrop_tiles_test.cpp @@ -85,7 +85,7 @@ void gpu_ws_tag_screen_tile(CPUState *cpu, std::uint32_t primitive_addr) { } // extern "C" -#include "../src/disruptor_backdrop_tiles.cpp" +#include "../../src/disruptor_backdrop_tiles.cpp" namespace { diff --git a/tests/disruptor_billboard_aspect_test.cpp b/tests/unit/disruptor_billboard_aspect_test.cpp similarity index 99% rename from tests/disruptor_billboard_aspect_test.cpp rename to tests/unit/disruptor_billboard_aspect_test.cpp index eaf570b..6f8bac7 100644 --- a/tests/disruptor_billboard_aspect_test.cpp +++ b/tests/unit/disruptor_billboard_aspect_test.cpp @@ -66,7 +66,7 @@ void disruptor_sprite_depth_packet(CPUState *, std::uint32_t, std::uint32_t) {} } // extern "C" // Include the implementation to exercise its exact private packet guards. -#include "../src/disruptor_billboard_aspect.cpp" +#include "../../src/disruptor_billboard_aspect.cpp" namespace { diff --git a/tests/disruptor_capsule_test.cpp b/tests/unit/disruptor_capsule_test.cpp similarity index 99% rename from tests/disruptor_capsule_test.cpp rename to tests/unit/disruptor_capsule_test.cpp index 641675a..efd03e5 100644 --- a/tests/disruptor_capsule_test.cpp +++ b/tests/unit/disruptor_capsule_test.cpp @@ -1,5 +1,5 @@ #define DISRUPTOR_CAPSULE_NO_HOST 1 -#include "../src/disruptor_capsule.cpp" +#include "../../src/disruptor_capsule.cpp" #include #include diff --git a/tests/disruptor_cheats_test.cpp b/tests/unit/disruptor_cheats_test.cpp similarity index 99% rename from tests/disruptor_cheats_test.cpp rename to tests/unit/disruptor_cheats_test.cpp index 88d4bbc..f5c70db 100644 --- a/tests/disruptor_cheats_test.cpp +++ b/tests/unit/disruptor_cheats_test.cpp @@ -104,7 +104,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" // Include the implementation to exercise its version-pinned internal guards. -#include "../src/disruptor_cheats.cpp" +#include "../../src/disruptor_cheats.cpp" namespace { diff --git a/tests/disruptor_far_rendering_test.cpp b/tests/unit/disruptor_far_rendering_test.cpp similarity index 99% rename from tests/disruptor_far_rendering_test.cpp rename to tests/unit/disruptor_far_rendering_test.cpp index 41b3682..4c2dd18 100644 --- a/tests/disruptor_far_rendering_test.cpp +++ b/tests/unit/disruptor_far_rendering_test.cpp @@ -109,7 +109,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" -#include "../src/disruptor_far_rendering.cpp" +#include "../../src/disruptor_far_rendering.cpp" namespace { diff --git a/tests/disruptor_frame_rate_test.cpp b/tests/unit/disruptor_frame_rate_test.cpp similarity index 99% rename from tests/disruptor_frame_rate_test.cpp rename to tests/unit/disruptor_frame_rate_test.cpp index 2e3e847..ae020d7 100644 --- a/tests/disruptor_frame_rate_test.cpp +++ b/tests/unit/disruptor_frame_rate_test.cpp @@ -71,7 +71,7 @@ int psx_mod_register_function_entry_plugin( uint32_t interrupts_cycles_to_vblank(void) { return g_cycles_to_vblank; } -#include "../src/disruptor_frame_rate.cpp" +#include "../../src/disruptor_frame_rate.cpp" namespace { diff --git a/tests/disruptor_grown_quad_test.cpp b/tests/unit/disruptor_grown_quad_test.cpp similarity index 99% rename from tests/disruptor_grown_quad_test.cpp rename to tests/unit/disruptor_grown_quad_test.cpp index 6b0c94c..b82d98a 100644 --- a/tests/disruptor_grown_quad_test.cpp +++ b/tests/unit/disruptor_grown_quad_test.cpp @@ -21,7 +21,7 @@ extern "C" std::uint32_t psx_mod_read_word(std::uint32_t address) { return found == g_memory.end() ? 0xDEADBEEFu : found->second; } -#include "../src/disruptor_grown_quad.cpp" +#include "../../src/disruptor_grown_quad.cpp" namespace { diff --git a/tests/disruptor_intro_skip_test.cpp b/tests/unit/disruptor_intro_skip_test.cpp similarity index 99% rename from tests/disruptor_intro_skip_test.cpp rename to tests/unit/disruptor_intro_skip_test.cpp index 00a5707..48d7de6 100644 --- a/tests/disruptor_intro_skip_test.cpp +++ b/tests/unit/disruptor_intro_skip_test.cpp @@ -43,7 +43,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { g_registered_query = query; } -#include "../src/disruptor_intro_skip.cpp" +#include "../../src/disruptor_intro_skip.cpp" namespace { diff --git a/tests/disruptor_language_disc_test.cpp b/tests/unit/disruptor_language_disc_test.cpp similarity index 99% rename from tests/disruptor_language_disc_test.cpp rename to tests/unit/disruptor_language_disc_test.cpp index 48e29b2..5996c3e 100644 --- a/tests/disruptor_language_disc_test.cpp +++ b/tests/unit/disruptor_language_disc_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include diff --git a/tests/disruptor_language_hints_test.cpp b/tests/unit/disruptor_language_hints_test.cpp similarity index 99% rename from tests/disruptor_language_hints_test.cpp rename to tests/unit/disruptor_language_hints_test.cpp index 06d5382..4fd1a06 100644 --- a/tests/disruptor_language_hints_test.cpp +++ b/tests/unit/disruptor_language_hints_test.cpp @@ -60,7 +60,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" int disruptor_language_disc_tall_hints(void) { return g_tall; } -#include "../src/disruptor_language_hints.cpp" +#include "../../src/disruptor_language_hints.cpp" namespace { diff --git a/tests/disruptor_language_overlay_test.cpp b/tests/unit/disruptor_language_overlay_test.cpp similarity index 98% rename from tests/disruptor_language_overlay_test.cpp rename to tests/unit/disruptor_language_overlay_test.cpp index 700d78e..67bfa1a 100644 --- a/tests/disruptor_language_overlay_test.cpp +++ b/tests/unit/disruptor_language_overlay_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include "iso_reader.h" #include "mod_runtime.h" @@ -67,8 +67,8 @@ extern "C" void savestate_get_integrity(uint32_t *bios_checksum, uint32_t *entry *entry_pc = g_states_entry; } -#include "../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" -#include "../src/disruptor_language_source.cpp" +#include "../../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" +#include "../../src/disruptor_language_source.cpp" namespace { diff --git a/tests/disruptor_language_test.cpp b/tests/unit/disruptor_language_test.cpp similarity index 99% rename from tests/disruptor_language_test.cpp rename to tests/unit/disruptor_language_test.cpp index e883afa..18aafcb 100644 --- a/tests/disruptor_language_test.cpp +++ b/tests/unit/disruptor_language_test.cpp @@ -63,8 +63,8 @@ extern "C" int disruptor_language_disc_pack(const uint8_t **data, uint32_t *size return 1; } -#include "../src/disruptor_language.cpp" -#include "../src/disruptor_language_disc.cpp" +#include "../../src/disruptor_language.cpp" +#include "../../src/disruptor_language_disc.cpp" namespace { diff --git a/tests/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp similarity index 99% rename from tests/disruptor_mouse_aim_test.cpp rename to tests/unit/disruptor_mouse_aim_test.cpp index c37d6b2..fb0051e 100644 --- a/tests/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -1,5 +1,5 @@ #include "psx_sdl.h" -#include "../psxrecomp-overlay/runtime/include/gpu.h" +#include "../../psxrecomp-overlay/runtime/include/gpu.h" #include #include @@ -184,7 +184,7 @@ const char *SDL_GetError() { // Include the implementation so its internal pure helpers and state can be // exercised against the mocks above without exposing a test-only production ABI. -#include "../src/disruptor_mouse_aim.cpp" +#include "../../src/disruptor_mouse_aim.cpp" namespace { diff --git a/tests/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp similarity index 99% rename from tests/disruptor_name_entry_test.cpp rename to tests/unit/disruptor_name_entry_test.cpp index eb75d51..e3bef9b 100644 --- a/tests/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -62,7 +62,7 @@ int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) } } -#include "../src/disruptor_name_entry.cpp" +#include "../../src/disruptor_name_entry.cpp" namespace { diff --git a/tests/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp similarity index 99% rename from tests/disruptor_present_rate_test.cpp rename to tests/unit/disruptor_present_rate_test.cpp index e52247b..5bc4941 100644 --- a/tests/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -1,4 +1,4 @@ -#include "../src/disruptor_present_rate.h" +#include "../../src/disruptor_present_rate.h" #include #include @@ -97,7 +97,7 @@ bool SliderInt(const char *, int *value, int lowest, int highest, const char *, void TextDisabled(const char *text, ...) { g_slider.text = text; } } // namespace ImGui -#include "../src/disruptor_present_rate.cpp" +#include "../../src/disruptor_present_rate.cpp" namespace { diff --git a/tests/disruptor_render_arena_test.cpp b/tests/unit/disruptor_render_arena_test.cpp similarity index 99% rename from tests/disruptor_render_arena_test.cpp rename to tests/unit/disruptor_render_arena_test.cpp index 60b6fbf..b909f8a 100644 --- a/tests/disruptor_render_arena_test.cpp +++ b/tests/unit/disruptor_render_arena_test.cpp @@ -52,7 +52,7 @@ int psx_mod_register_function_entry_plugin( const char *, uint32_t, PSXModFunctionEntryCallback) { return 1; } } -#include "../src/disruptor_render_arena.cpp" +#include "../../src/disruptor_render_arena.cpp" int main() { CPUState cpu{}; diff --git a/tests/disruptor_restart_test.cpp b/tests/unit/disruptor_restart_test.cpp similarity index 99% rename from tests/disruptor_restart_test.cpp rename to tests/unit/disruptor_restart_test.cpp index 6e817f9..e782e35 100644 --- a/tests/disruptor_restart_test.cpp +++ b/tests/unit/disruptor_restart_test.cpp @@ -1,4 +1,4 @@ -#include "../src/disruptor_restart.cpp" +#include "../../src/disruptor_restart.cpp" #include #include diff --git a/tests/disruptor_sprite_depth_test.cpp b/tests/unit/disruptor_sprite_depth_test.cpp similarity index 99% rename from tests/disruptor_sprite_depth_test.cpp rename to tests/unit/disruptor_sprite_depth_test.cpp index 3370a99..0ad0f84 100644 --- a/tests/disruptor_sprite_depth_test.cpp +++ b/tests/unit/disruptor_sprite_depth_test.cpp @@ -124,7 +124,7 @@ extern "C" void gte_ws_x_squash(std::int32_t *num, std::int32_t *den) { *den = g_range_den; } -#include "../src/disruptor_sprite_depth.cpp" +#include "../../src/disruptor_sprite_depth.cpp" namespace { diff --git a/tests/disruptor_vertical_camera_test.cpp b/tests/unit/disruptor_vertical_camera_test.cpp similarity index 99% rename from tests/disruptor_vertical_camera_test.cpp rename to tests/unit/disruptor_vertical_camera_test.cpp index 228a1db..3cd3778 100644 --- a/tests/disruptor_vertical_camera_test.cpp +++ b/tests/unit/disruptor_vertical_camera_test.cpp @@ -132,7 +132,7 @@ void gpu_geometry_camera_projection_center_y_set(double center_y) { } // extern "C" // Include the implementation to exercise the version-pinned internal guards. -#include "../src/disruptor_vertical_camera.cpp" +#include "../../src/disruptor_vertical_camera.cpp" namespace { diff --git a/tests/gpu_pinned_corner_test.cpp b/tests/unit/gpu_pinned_corner_test.cpp similarity index 100% rename from tests/gpu_pinned_corner_test.cpp rename to tests/unit/gpu_pinned_corner_test.cpp diff --git a/tests/gpu_temporal_match_test.cpp b/tests/unit/gpu_temporal_match_test.cpp similarity index 100% rename from tests/gpu_temporal_match_test.cpp rename to tests/unit/gpu_temporal_match_test.cpp diff --git a/tests/gpu_temporal_mesh_test.cpp b/tests/unit/gpu_temporal_mesh_test.cpp similarity index 100% rename from tests/gpu_temporal_mesh_test.cpp rename to tests/unit/gpu_temporal_mesh_test.cpp diff --git a/tests/gpu_temporal_sprite_test.cpp b/tests/unit/gpu_temporal_sprite_test.cpp similarity index 100% rename from tests/gpu_temporal_sprite_test.cpp rename to tests/unit/gpu_temporal_sprite_test.cpp diff --git a/tests/gpu_turned_perspective_test.cpp b/tests/unit/gpu_turned_perspective_test.cpp similarity index 100% rename from tests/gpu_turned_perspective_test.cpp rename to tests/unit/gpu_turned_perspective_test.cpp diff --git a/tests/gpu_world_depth_test.cpp b/tests/unit/gpu_world_depth_test.cpp similarity index 100% rename from tests/gpu_world_depth_test.cpp rename to tests/unit/gpu_world_depth_test.cpp diff --git a/tests/gpu_ws_frame_kind_test.cpp b/tests/unit/gpu_ws_frame_kind_test.cpp similarity index 100% rename from tests/gpu_ws_frame_kind_test.cpp rename to tests/unit/gpu_ws_frame_kind_test.cpp diff --git a/tests/gpu_ws_hud_scale_test.cpp b/tests/unit/gpu_ws_hud_scale_test.cpp similarity index 100% rename from tests/gpu_ws_hud_scale_test.cpp rename to tests/unit/gpu_ws_hud_scale_test.cpp diff --git a/tests/gpu_ws_hud_widget_test.cpp b/tests/unit/gpu_ws_hud_widget_test.cpp similarity index 100% rename from tests/gpu_ws_hud_widget_test.cpp rename to tests/unit/gpu_ws_hud_widget_test.cpp diff --git a/tests/gpu_ws_menu_test.cpp b/tests/unit/gpu_ws_menu_test.cpp similarity index 100% rename from tests/gpu_ws_menu_test.cpp rename to tests/unit/gpu_ws_menu_test.cpp diff --git a/tests/gpu_ws_tag_match_test.cpp b/tests/unit/gpu_ws_tag_match_test.cpp similarity index 100% rename from tests/gpu_ws_tag_match_test.cpp rename to tests/unit/gpu_ws_tag_match_test.cpp diff --git a/tests/gte_precision_store_pc_test.cpp b/tests/unit/gte_precision_store_pc_test.cpp similarity index 100% rename from tests/gte_precision_store_pc_test.cpp rename to tests/unit/gte_precision_store_pc_test.cpp diff --git a/tests/host_borderless_test.cpp b/tests/unit/host_borderless_test.cpp similarity index 97% rename from tests/host_borderless_test.cpp rename to tests/unit/host_borderless_test.cpp index 181ecc3..65dd4e7 100644 --- a/tests/host_borderless_test.cpp +++ b/tests/unit/host_borderless_test.cpp @@ -1,4 +1,4 @@ -#include "../psxrecomp-overlay/runtime/include/host_borderless.h" +#include "../../psxrecomp-overlay/runtime/include/host_borderless.h" #include diff --git a/tests/host_pick_slot_test.cpp b/tests/unit/host_pick_slot_test.cpp similarity index 96% rename from tests/host_pick_slot_test.cpp rename to tests/unit/host_pick_slot_test.cpp index c692c4c..af43edd 100644 --- a/tests/host_pick_slot_test.cpp +++ b/tests/unit/host_pick_slot_test.cpp @@ -1,4 +1,4 @@ -#include "../psxrecomp-overlay/runtime/include/host_pick_slot.h" +#include "../../psxrecomp-overlay/runtime/include/host_pick_slot.h" #include #include diff --git a/tests/launcher_flow_test.cpp b/tests/unit/launcher_flow_test.cpp similarity index 100% rename from tests/launcher_flow_test.cpp rename to tests/unit/launcher_flow_test.cpp diff --git a/tests/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp similarity index 100% rename from tests/launcher_settings_test.cpp rename to tests/unit/launcher_settings_test.cpp diff --git a/tests/presentation_yaw_math_test.cpp b/tests/unit/presentation_yaw_math_test.cpp similarity index 100% rename from tests/presentation_yaw_math_test.cpp rename to tests/unit/presentation_yaw_math_test.cpp diff --git a/tests/region_disc_test.cpp b/tests/unit/region_disc_test.cpp similarity index 98% rename from tests/region_disc_test.cpp rename to tests/unit/region_disc_test.cpp index 0954636..e3bc7ce 100644 --- a/tests/region_disc_test.cpp +++ b/tests/unit/region_disc_test.cpp @@ -1,4 +1,4 @@ -#include "disruptor_language_discs.h" +#include "../support/disruptor_language_discs.h" #include "region_disc.h" diff --git a/tests/save_point_test.cpp b/tests/unit/save_point_test.cpp similarity index 100% rename from tests/save_point_test.cpp rename to tests/unit/save_point_test.cpp diff --git a/tests/user_settings_persistence_test.cpp b/tests/unit/user_settings_persistence_test.cpp similarity index 100% rename from tests/user_settings_persistence_test.cpp rename to tests/unit/user_settings_persistence_test.cpp From fdb8807232484a45738ea67608646f3c0e4bf814 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:36:41 +0300 Subject: [PATCH 03/21] refactor: move the status and disc notes into docs, the key presets into config --- CONTRIBUTING.md | 2 +- README.md | 6 +++--- {tools => cmake}/stage_launcher.cmake | 2 +- keybinds-modern.ini => config/keybinds-modern.ini | 0 keybinds-original.ini => config/keybinds-original.ini | 0 docs/BUILD.md | 2 +- DISC.md => docs/DISC.md | 0 STATUS.md => docs/STATUS.md | 2 +- input/README.md | 2 +- run.ps1 | 4 ++-- run.sh | 4 ++-- src/launcher/CMakeLists.txt | 6 +++--- tools/package_release.py | 2 +- 13 files changed, 16 insertions(+), 16 deletions(-) rename {tools => cmake}/stage_launcher.cmake (92%) rename keybinds-modern.ini => config/keybinds-modern.ini (100%) rename keybinds-original.ini => config/keybinds-original.ini (100%) rename DISC.md => docs/DISC.md (100%) rename STATUS.md => docs/STATUS.md (97%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34b4a63..bd661e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Before submitting a change: - Run the relevant tests and review both the working diff and staged paths. - Keep experimental visual features optional. -- Update [STATUS.md](STATUS.md) when behavior, verified coverage, or a known +- Update [STATUS.md](docs/STATUS.md) when behavior, verified coverage, or a known limitation changes. Keep it focused on the current build. For bug reports, include the level, steps to reproduce, render scale/aspect, diff --git a/README.md b/README.md index ee69944..cca7682 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Place these files from your own supported disc in `input/`: - `SLUS_002.24` - `Disruptor (USA).cue` and the `.bin` track it references -Check the required hashes and disc format in [DISC.md](DISC.md). Then run from +Check the required hashes and disc format in [DISC.md](docs/DISC.md). Then run from an x64 developer PowerShell: ```powershell @@ -105,8 +105,8 @@ retest and save-state restart check passed after fixing an ultrawide rendering buffer overflow. Full campaign and release validation remain incomplete; minor geometry and texture artifacts can remain. -- [Current status and known limits](STATUS.md) +- [Current status and known limits](docs/STATUS.md) - [Build details](docs/BUILD.md) -- [Supported disc revision](DISC.md) +- [Supported disc revision](docs/DISC.md) - [Contributing](CONTRIBUTING.md) - [Third-party notices](THIRD_PARTY_NOTICES.md) diff --git a/tools/stage_launcher.cmake b/cmake/stage_launcher.cmake similarity index 92% rename from tools/stage_launcher.cmake rename to cmake/stage_launcher.cmake index e92c3dc..ed5bf93 100644 --- a/tools/stage_launcher.cmake +++ b/cmake/stage_launcher.cmake @@ -3,7 +3,7 @@ foreach(pair IN ITEMS "game.toml|game.toml" "mouse-aim.ini|mouse-aim.ini" "docs/PLAYING.md|GETTING_STARTED.md" "release/windows/Play Disruptor.cmd|Play Disruptor.cmd" - "keybinds-modern.ini|keybinds.ini" + "config/keybinds-modern.ini|keybinds.ini" "release/windows/settings.toml|settings.toml") string(REPLACE "|" ";" paths "${pair}") list(GET paths 0 source) diff --git a/keybinds-modern.ini b/config/keybinds-modern.ini similarity index 100% rename from keybinds-modern.ini rename to config/keybinds-modern.ini diff --git a/keybinds-original.ini b/config/keybinds-original.ini similarity index 100% rename from keybinds-original.ini rename to config/keybinds-original.ini diff --git a/docs/BUILD.md b/docs/BUILD.md index f900920..81f216f 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -3,7 +3,7 @@ ## Requirements - The verified Disruptor USA executable and BIN/CUE files in `input/`; see - [DISC.md](../DISC.md). + [DISC.md](DISC.md). - Git, Python 3, CMake 3.20+, and a C++20 toolchain. - Windows x64: Visual Studio with **Desktop development with C++**, or MSYS2 MinGW-w64. Ninja is recommended. diff --git a/DISC.md b/docs/DISC.md similarity index 100% rename from DISC.md rename to docs/DISC.md diff --git a/STATUS.md b/docs/STATUS.md similarity index 97% rename from STATUS.md rename to docs/STATUS.md index a3602da..8b3e26e 100644 --- a/STATUS.md +++ b/docs/STATUS.md @@ -36,5 +36,5 @@ validation is incomplete. individually exercised live. - Live volume adjustment and its relaunch persistence need user validation. -See [build details](docs/BUILD.md) and [disc requirements](DISC.md) before +See [build details](BUILD.md) and [disc requirements](DISC.md) before building or reporting a problem. diff --git a/input/README.md b/input/README.md index 5523d20..75ef03d 100644 --- a/input/README.md +++ b/input/README.md @@ -5,7 +5,7 @@ Copy these files from your own **Disruptor (USA, SLUS-00224)** disc here: - `SLUS_002.24` - `Disruptor (USA).cue` and the `.bin` track named in its `FILE` entry -Check the hashes and MODE2/2352 format in [DISC.md](../DISC.md). Build and run +Check the hashes and MODE2/2352 format in [DISC.md](../docs/DISC.md). Build and run instructions are in [docs/BUILD.md](../docs/BUILD.md). This directory's game files and the generated `SLUS_002.24.code` analysis image diff --git a/run.ps1 b/run.ps1 index b3f70cf..2462f11 100644 --- a/run.ps1 +++ b/run.ps1 @@ -36,9 +36,9 @@ if ($MouseAim -or $VerticalLook -or $ModernControls) { $RuntimeDirectory = Split-Path -Parent $Runtime $KeybindPresetName = if ($ModernControls) { - 'keybinds-modern.ini' + 'config/keybinds-modern.ini' } else { - 'keybinds-original.ini' + 'config/keybinds-original.ini' } $KeybindPreset = Join-Path $Root $KeybindPresetName Copy-Item -LiteralPath $KeybindPreset -Destination (Join-Path $RuntimeDirectory 'keybinds.ini') -Force diff --git a/run.sh b/run.sh index 79bdcd1..97473ae 100755 --- a/run.sh +++ b/run.sh @@ -57,10 +57,10 @@ if [ "$PERSPECTIVE_TEXTURES" -eq 1 ]; then fi if [ "$MODERN_CONTROLS" -eq 1 ]; then - cp "$ROOT/keybinds-modern.ini" "$BUILD/keybinds.ini" + cp "$ROOT/config/keybinds-modern.ini" "$BUILD/keybinds.ini" echo "Modern controls enabled: WASD, LMB fire, RMB psionic, Space jump, E use." else - cp "$ROOT/keybinds-original.ini" "$BUILD/keybinds.ini" + cp "$ROOT/config/keybinds-original.ini" "$BUILD/keybinds.ini" fi GAME_CONFIG=game.toml diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index d52c9eb..2a76c88 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -58,15 +58,15 @@ add_custom_command(TARGET disruptor-launcher POST_BUILD COMMAND "${CMAKE_COMMAND}" "-DSOURCE=${PROJECT_SOURCE_DIR}" "-DDESTINATION=$" - -P "${PROJECT_SOURCE_DIR}/tools/stage_launcher.cmake" + -P "${PROJECT_SOURCE_DIR}/cmake/stage_launcher.cmake" VERBATIM) set_property(TARGET disruptor-launcher APPEND PROPERTY LINK_DEPENDS - "${PROJECT_SOURCE_DIR}/tools/stage_launcher.cmake" + "${PROJECT_SOURCE_DIR}/cmake/stage_launcher.cmake" "${PROJECT_SOURCE_DIR}/game.toml" "${PROJECT_SOURCE_DIR}/mouse-aim.ini" "${PROJECT_SOURCE_DIR}/docs/PLAYING.md" "${PROJECT_SOURCE_DIR}/release/windows/Play Disruptor.cmd" - "${PROJECT_SOURCE_DIR}/keybinds-modern.ini" + "${PROJECT_SOURCE_DIR}/config/keybinds-modern.ini" "${PROJECT_SOURCE_DIR}/release/windows/settings.toml") if(BUILD_TESTING) diff --git a/tools/package_release.py b/tools/package_release.py index 6b21bfd..0f68c15 100644 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -37,7 +37,7 @@ def main() -> None: "DisruptorLauncher.exe": build / "DisruptorLauncher.exe", "game.toml": ROOT / "game.toml", "mouse-aim.ini": ROOT / "mouse-aim.ini", - "keybinds.ini": ROOT / "keybinds-modern.ini", + "keybinds.ini": ROOT / "config/keybinds-modern.ini", "settings.toml": ROOT / "release/windows/settings.toml", "Play Disruptor.cmd": ROOT / "release/windows/Play Disruptor.cmd", "GETTING_STARTED.md": ROOT / "docs/PLAYING.md", From 22bac488b89843055d39f3a39b8faba1eb5c0315 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:38:25 +0300 Subject: [PATCH 04/21] refactor: sort the game's sources into modules under src/game --- CMakeLists.txt | 49 ++++++++++--------- src/{ => game}/disruptor.manifest | 0 src/{ => game}/disruptor.rc | 0 .../frame/frame_rate.cpp} | 2 +- .../frame/frame_rate.h} | 0 .../frame/present_rate.cpp} | 2 +- .../frame/present_rate.h} | 0 .../input/control_probe.cpp} | 0 .../input/mouse_aim.cpp} | 8 +-- .../input/mouse_aim.h} | 0 .../input/name_entry.cpp} | 0 .../input/vertical_camera.cpp} | 2 +- .../input/vertical_camera.h} | 0 .../language/language.cpp} | 8 +-- .../language/language.h} | 0 .../language/language_disc.cpp} | 4 +- .../language/language_disc.h} | 0 .../language/language_hints.cpp} | 4 +- .../language/language_pack.h} | 0 .../language/language_source.cpp} | 6 +-- .../render/backdrop_tiles.cpp} | 0 .../render/billboard_aspect.cpp} | 2 +- .../render/billboard_aspect.h} | 0 .../render/far_rendering.cpp} | 2 +- .../render/far_rendering.h} | 0 .../render/grown_quad.cpp} | 0 .../render/hud_widgets.cpp} | 0 .../render/render_arena.cpp} | 0 .../render/sprite_depth.cpp} | 0 .../session/capsule.cpp} | 2 +- .../session/capsule.h} | 0 .../session/cheats.cpp} | 2 +- .../session/cheats.h} | 0 .../session/intro_skip.cpp} | 2 +- .../session/intro_skip.h} | 0 .../session/restart.cpp} | 2 +- .../session/restart.h} | 0 .../ui/dev_menu.cpp} | 18 +++---- src/launcher/CMakeLists.txt | 2 +- src/launcher/launcher_settings.cpp | 2 +- src/launcher/region_disc.cpp | 2 +- .../test_ambient_occlusion_contract.py | 2 +- .../contracts/test_backdrop_tiles_contract.py | 4 +- .../test_billboard_aspect_contract.py | 6 +-- tests/contracts/test_capsule_contract.py | 10 ++-- tests/contracts/test_cheats_contract.py | 4 +- tests/contracts/test_debug_tab_contract.py | 2 +- tests/contracts/test_dev_menu_contract.py | 14 +++--- .../contracts/test_far_rendering_contract.py | 6 +-- tests/contracts/test_frame_kind_contract.py | 2 +- tests/contracts/test_frame_rate_contract.py | 14 +++--- tests/contracts/test_gap_filling_contract.py | 2 +- .../test_geometry_provenance_contract.py | 2 +- tests/contracts/test_grown_quad_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 2 +- tests/contracts/test_hud_widget_contract.py | 4 +- tests/contracts/test_intro_skip_contract.py | 8 +-- tests/contracts/test_language_contract.py | 12 ++--- .../contracts/test_language_disc_contract.py | 18 +++---- tests/contracts/test_launcher_contract.py | 14 +++--- tests/contracts/test_loaded_state_contract.py | 4 +- tests/contracts/test_name_entry_contract.py | 4 +- tests/contracts/test_phase_a_contract.py | 2 +- tests/contracts/test_smooth_turn_contract.py | 4 +- tests/contracts/test_sprite_range_contract.py | 2 +- .../test_temporal_presenter_contract.py | 2 +- .../test_vertical_camera_contract.py | 6 +-- tests/contracts/test_world_depth_contract.py | 2 +- tests/support/disruptor_language_discs.h | 2 +- tests/unit/disruptor_backdrop_tiles_test.cpp | 2 +- .../unit/disruptor_billboard_aspect_test.cpp | 2 +- tests/unit/disruptor_capsule_test.cpp | 2 +- tests/unit/disruptor_cheats_test.cpp | 2 +- tests/unit/disruptor_far_rendering_test.cpp | 2 +- tests/unit/disruptor_frame_rate_test.cpp | 2 +- tests/unit/disruptor_grown_quad_test.cpp | 2 +- tests/unit/disruptor_intro_skip_test.cpp | 2 +- tests/unit/disruptor_language_hints_test.cpp | 2 +- .../unit/disruptor_language_overlay_test.cpp | 2 +- tests/unit/disruptor_language_test.cpp | 4 +- tests/unit/disruptor_mouse_aim_test.cpp | 2 +- tests/unit/disruptor_name_entry_test.cpp | 2 +- tests/unit/disruptor_present_rate_test.cpp | 4 +- tests/unit/disruptor_render_arena_test.cpp | 2 +- tests/unit/disruptor_restart_test.cpp | 2 +- tests/unit/disruptor_sprite_depth_test.cpp | 2 +- tests/unit/disruptor_vertical_camera_test.cpp | 2 +- 87 files changed, 160 insertions(+), 159 deletions(-) rename src/{ => game}/disruptor.manifest (100%) rename src/{ => game}/disruptor.rc (100%) rename src/{disruptor_frame_rate.cpp => game/frame/frame_rate.cpp} (99%) rename src/{disruptor_frame_rate.h => game/frame/frame_rate.h} (100%) rename src/{disruptor_present_rate.cpp => game/frame/present_rate.cpp} (98%) rename src/{disruptor_present_rate.h => game/frame/present_rate.h} (100%) rename src/{disruptor_control_probe.cpp => game/input/control_probe.cpp} (100%) rename src/{disruptor_mouse_aim.cpp => game/input/mouse_aim.cpp} (99%) rename src/{disruptor_mouse_aim.h => game/input/mouse_aim.h} (100%) rename src/{disruptor_name_entry.cpp => game/input/name_entry.cpp} (100%) rename src/{disruptor_vertical_camera.cpp => game/input/vertical_camera.cpp} (99%) rename src/{disruptor_vertical_camera.h => game/input/vertical_camera.h} (100%) rename src/{disruptor_language.cpp => game/language/language.cpp} (98%) rename src/{disruptor_language.h => game/language/language.h} (100%) rename src/{disruptor_language_disc.cpp => game/language/language_disc.cpp} (99%) rename src/{disruptor_language_disc.h => game/language/language_disc.h} (100%) rename src/{disruptor_language_hints.cpp => game/language/language_hints.cpp} (98%) rename src/{disruptor_language_pack.h => game/language/language_pack.h} (100%) rename src/{disruptor_language_source.cpp => game/language/language_source.cpp} (96%) rename src/{disruptor_backdrop_tiles.cpp => game/render/backdrop_tiles.cpp} (100%) rename src/{disruptor_billboard_aspect.cpp => game/render/billboard_aspect.cpp} (99%) rename src/{disruptor_billboard_aspect.h => game/render/billboard_aspect.h} (100%) rename src/{disruptor_far_rendering.cpp => game/render/far_rendering.cpp} (99%) rename src/{disruptor_far_rendering.h => game/render/far_rendering.h} (100%) rename src/{disruptor_grown_quad.cpp => game/render/grown_quad.cpp} (100%) rename src/{disruptor_hud_widgets.cpp => game/render/hud_widgets.cpp} (100%) rename src/{disruptor_render_arena.cpp => game/render/render_arena.cpp} (100%) rename src/{disruptor_sprite_depth.cpp => game/render/sprite_depth.cpp} (100%) rename src/{disruptor_capsule.cpp => game/session/capsule.cpp} (99%) rename src/{disruptor_capsule.h => game/session/capsule.h} (100%) rename src/{disruptor_cheats.cpp => game/session/cheats.cpp} (99%) rename src/{disruptor_cheats.h => game/session/cheats.h} (100%) rename src/{disruptor_intro_skip.cpp => game/session/intro_skip.cpp} (99%) rename src/{disruptor_intro_skip.h => game/session/intro_skip.h} (100%) rename src/{disruptor_restart.cpp => game/session/restart.cpp} (98%) rename src/{disruptor_restart.h => game/session/restart.h} (100%) rename src/{disruptor_dev_menu.cpp => game/ui/dev_menu.cpp} (99%) diff --git a/CMakeLists.txt b/CMakeLists.txt index e1cb989..57c6e7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,31 +25,32 @@ option(DISRUPTOR_FRAME_INTERPOLATION ON) set(DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_backdrop_tiles.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_billboard_aspect.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_cheats.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_control_probe.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_far_rendering.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_frame_rate.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_intro_skip.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_disc.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_hints.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_language_source.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_restart.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_mouse_aim.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_sprite_depth.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_vertical_camera.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/backdrop_tiles.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/billboard_aspect.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/cheats.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/control_probe.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/far_rendering.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/frame_rate.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/intro_skip.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_disc.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_hints.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_source.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/restart.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/mouse_aim.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/sprite_depth.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/vertical_camera.cpp") # The project deliberately uses the redistributable OpenBIOS backend only. set(PSXRECOMP_BIOS_STEMS "OpenBIOS" CACHE STRING "" FORCE) set(PSX_RECOMP_UI OFF CACHE BOOL "" FORCE) +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src") include("${PSXRECOMP_ROOT}/runtime/runtime.cmake") if(DISRUPTOR_DEV_MENU) @@ -88,10 +89,10 @@ if(DISRUPTOR_DEV_MENU) endif() list(APPEND DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_dev_menu.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp") if(DISRUPTOR_FRAME_INTERPOLATION) list(APPEND DISRUPTOR_EXTRA_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_present_rate.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp") endif() endif() @@ -166,9 +167,9 @@ endif() if(WIN32) # UTF-8 as the game's code page: a path in settings.toml may hold letters the system's own page lacks. if(MINGW) - target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.rc") + target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.rc") else() - target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.manifest") + target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.manifest") endif() endif() diff --git a/src/disruptor.manifest b/src/game/disruptor.manifest similarity index 100% rename from src/disruptor.manifest rename to src/game/disruptor.manifest diff --git a/src/disruptor.rc b/src/game/disruptor.rc similarity index 100% rename from src/disruptor.rc rename to src/game/disruptor.rc diff --git a/src/disruptor_frame_rate.cpp b/src/game/frame/frame_rate.cpp similarity index 99% rename from src/disruptor_frame_rate.cpp rename to src/game/frame/frame_rate.cpp index 87b7d4e..521f022 100644 --- a/src/disruptor_frame_rate.cpp +++ b/src/game/frame/frame_rate.cpp @@ -12,7 +12,7 @@ * wait counts loop iterations for its timeout and always runs at stock cost. */ -#include "disruptor_frame_rate.h" +#include "game/frame/frame_rate.h" #include "cpu_state.h" #include "interrupts.h" diff --git a/src/disruptor_frame_rate.h b/src/game/frame/frame_rate.h similarity index 100% rename from src/disruptor_frame_rate.h rename to src/game/frame/frame_rate.h diff --git a/src/disruptor_present_rate.cpp b/src/game/frame/present_rate.cpp similarity index 98% rename from src/disruptor_present_rate.cpp rename to src/game/frame/present_rate.cpp index aba9174..e5e7740 100644 --- a/src/disruptor_present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -3,7 +3,7 @@ * Built only together with the frame interpolator. */ -#include "disruptor_present_rate.h" +#include "game/frame/present_rate.h" #include "host_ui.h" #include "imgui.h" diff --git a/src/disruptor_present_rate.h b/src/game/frame/present_rate.h similarity index 100% rename from src/disruptor_present_rate.h rename to src/game/frame/present_rate.h diff --git a/src/disruptor_control_probe.cpp b/src/game/input/control_probe.cpp similarity index 100% rename from src/disruptor_control_probe.cpp rename to src/game/input/control_probe.cpp diff --git a/src/disruptor_mouse_aim.cpp b/src/game/input/mouse_aim.cpp similarity index 99% rename from src/disruptor_mouse_aim.cpp rename to src/game/input/mouse_aim.cpp index 290cc73..87b3343 100644 --- a/src/disruptor_mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -18,10 +18,10 @@ */ #include "psx_sdl.h" -#include "disruptor_mouse_aim.h" -#include "disruptor_capsule.h" -#include "disruptor_vertical_camera.h" -#include "../psxrecomp-overlay/runtime/include/gpu.h" +#include "game/input/mouse_aim.h" +#include "game/session/capsule.h" +#include "game/input/vertical_camera.h" +#include "../../../psxrecomp-overlay/runtime/include/gpu.h" #include #include diff --git a/src/disruptor_mouse_aim.h b/src/game/input/mouse_aim.h similarity index 100% rename from src/disruptor_mouse_aim.h rename to src/game/input/mouse_aim.h diff --git a/src/disruptor_name_entry.cpp b/src/game/input/name_entry.cpp similarity index 100% rename from src/disruptor_name_entry.cpp rename to src/game/input/name_entry.cpp diff --git a/src/disruptor_vertical_camera.cpp b/src/game/input/vertical_camera.cpp similarity index 99% rename from src/disruptor_vertical_camera.cpp rename to src/game/input/vertical_camera.cpp index 5217d64..203a2e8 100644 --- a/src/disruptor_vertical_camera.cpp +++ b/src/game/input/vertical_camera.cpp @@ -14,7 +14,7 @@ * results and memory are left byte-for-byte untouched. */ -#include "disruptor_vertical_camera.h" +#include "game/input/vertical_camera.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_vertical_camera.h b/src/game/input/vertical_camera.h similarity index 100% rename from src/disruptor_vertical_camera.h rename to src/game/input/vertical_camera.h diff --git a/src/disruptor_language.cpp b/src/game/language/language.cpp similarity index 98% rename from src/disruptor_language.cpp rename to src/game/language/language.cpp index 7ea1891..193efe6 100644 --- a/src/disruptor_language.cpp +++ b/src/game/language/language.cpp @@ -23,14 +23,14 @@ * which characters to give that routine for the letters the other disc * writes. That swap is made for every string func_80044BDC draws. * - * The pack comes from the language disc (disruptor_language_disc.cpp) and - * holds what disruptor_language_pack.h says, by the rules written there. + * The pack comes from the language disc (language_disc.cpp) and + * holds what language_pack.h says, by the rules written there. */ -#include "disruptor_language.h" +#include "game/language/language.h" #include "cpu_state.h" -#include "disruptor_language_pack.h" +#include "game/language/language_pack.h" #include "lockstep.h" #include "mod_plugins.h" #include "psx_netplay.h" diff --git a/src/disruptor_language.h b/src/game/language/language.h similarity index 100% rename from src/disruptor_language.h rename to src/game/language/language.h diff --git a/src/disruptor_language_disc.cpp b/src/game/language/language_disc.cpp similarity index 99% rename from src/disruptor_language_disc.cpp rename to src/game/language/language_disc.cpp index 5550797..21dcc0d 100644 --- a/src/disruptor_language_disc.cpp +++ b/src/game/language/language_disc.cpp @@ -29,9 +29,9 @@ * header says, so it takes the other disc's header and that second part. */ -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" -#include "disruptor_language_pack.h" +#include "game/language/language_pack.h" #include #include diff --git a/src/disruptor_language_disc.h b/src/game/language/language_disc.h similarity index 100% rename from src/disruptor_language_disc.h rename to src/game/language/language_disc.h diff --git a/src/disruptor_language_hints.cpp b/src/game/language/language_hints.cpp similarity index 98% rename from src/disruptor_language_hints.cpp rename to src/game/language/language_hints.cpp index 0a89bb7..3d97a7f 100644 --- a/src/disruptor_language_hints.cpp +++ b/src/game/language/language_hints.cpp @@ -21,10 +21,10 @@ * code then shows the upper half. */ -#include "disruptor_language.h" +#include "game/language/language.h" #include "cpu_state.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include "lockstep.h" #include "mod_plugins.h" #include "psx_netplay.h" diff --git a/src/disruptor_language_pack.h b/src/game/language/language_pack.h similarity index 100% rename from src/disruptor_language_pack.h rename to src/game/language/language_pack.h diff --git a/src/disruptor_language_source.cpp b/src/game/language/language_source.cpp similarity index 96% rename from src/disruptor_language_source.cpp rename to src/game/language/language_source.cpp index 66cf7f2..19220d0 100644 --- a/src/disruptor_language_source.cpp +++ b/src/game/language/language_source.cpp @@ -3,15 +3,15 @@ * * The player names an image of another region's disc. When the runtime sets * its save states up, or at the latest the first time the drive reads the - * game disc, the two are laid out as one (see disruptor_language_disc.cpp), + * game disc, the two are laid out as one (see language_disc.cpp), * and from then on every sector comes from that layout. A disc that is not a * known version, or does not fit, is refused whole and the game disc is read * as it is. */ -#include "disruptor_language.h" +#include "game/language/language.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include "iso_overlay.h" #include "mod_plugins.h" #include "savestate.h" diff --git a/src/disruptor_backdrop_tiles.cpp b/src/game/render/backdrop_tiles.cpp similarity index 100% rename from src/disruptor_backdrop_tiles.cpp rename to src/game/render/backdrop_tiles.cpp diff --git a/src/disruptor_billboard_aspect.cpp b/src/game/render/billboard_aspect.cpp similarity index 99% rename from src/disruptor_billboard_aspect.cpp rename to src/game/render/billboard_aspect.cpp index 160f736..d9b5859 100644 --- a/src/disruptor_billboard_aspect.cpp +++ b/src/game/render/billboard_aspect.cpp @@ -14,7 +14,7 @@ * Guest RAM, Y, UVs, HUD SPRTs, and the first-person weapon path are untouched. */ -#include "disruptor_billboard_aspect.h" +#include "game/render/billboard_aspect.h" #include "cpu_state.h" #include "gpu.h" diff --git a/src/disruptor_billboard_aspect.h b/src/game/render/billboard_aspect.h similarity index 100% rename from src/disruptor_billboard_aspect.h rename to src/game/render/billboard_aspect.h diff --git a/src/disruptor_far_rendering.cpp b/src/game/render/far_rendering.cpp similarity index 99% rename from src/disruptor_far_rendering.cpp rename to src/game/render/far_rendering.cpp index a37633d..6e52bee 100644 --- a/src/disruptor_far_rendering.cpp +++ b/src/game/render/far_rendering.cpp @@ -14,7 +14,7 @@ * the new invocation is rejected, so a missed seam fails closed. */ -#include "disruptor_far_rendering.h" +#include "game/render/far_rendering.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_far_rendering.h b/src/game/render/far_rendering.h similarity index 100% rename from src/disruptor_far_rendering.h rename to src/game/render/far_rendering.h diff --git a/src/disruptor_grown_quad.cpp b/src/game/render/grown_quad.cpp similarity index 100% rename from src/disruptor_grown_quad.cpp rename to src/game/render/grown_quad.cpp diff --git a/src/disruptor_hud_widgets.cpp b/src/game/render/hud_widgets.cpp similarity index 100% rename from src/disruptor_hud_widgets.cpp rename to src/game/render/hud_widgets.cpp diff --git a/src/disruptor_render_arena.cpp b/src/game/render/render_arena.cpp similarity index 100% rename from src/disruptor_render_arena.cpp rename to src/game/render/render_arena.cpp diff --git a/src/disruptor_sprite_depth.cpp b/src/game/render/sprite_depth.cpp similarity index 100% rename from src/disruptor_sprite_depth.cpp rename to src/game/render/sprite_depth.cpp diff --git a/src/disruptor_capsule.cpp b/src/game/session/capsule.cpp similarity index 99% rename from src/disruptor_capsule.cpp rename to src/game/session/capsule.cpp index bfef518..a4001b5 100644 --- a/src/disruptor_capsule.cpp +++ b/src/game/session/capsule.cpp @@ -2,7 +2,7 @@ /* PSX_DISRUPTOR_CAPSULE_FRAMES= writes each replayed frame as a picture, which is whole only without a window. */ /* PSX_DISRUPTOR_CAPSULE_RECORD= is the key for a script: two seconds in, it records that many frames and ends the run. */ /* Measured: a loaded state takes its interrupts a few instructions apart, so one frame in four differs in a few bytes, nine in a row at most. */ -#include "disruptor_capsule.h" +#include "game/session/capsule.h" #include #include diff --git a/src/disruptor_capsule.h b/src/game/session/capsule.h similarity index 100% rename from src/disruptor_capsule.h rename to src/game/session/capsule.h diff --git a/src/disruptor_cheats.cpp b/src/game/session/cheats.cpp similarity index 99% rename from src/disruptor_cheats.cpp rename to src/game/session/cheats.cpp index a06a6bf..1be308f 100644 --- a/src/disruptor_cheats.cpp +++ b/src/game/session/cheats.cpp @@ -10,7 +10,7 @@ * below are guarded by source/codegen contract tests for this exact executable. */ -#include "disruptor_cheats.h" +#include "game/session/cheats.h" #include "cpu_state.h" #include "mod_plugins.h" diff --git a/src/disruptor_cheats.h b/src/game/session/cheats.h similarity index 100% rename from src/disruptor_cheats.h rename to src/game/session/cheats.h diff --git a/src/disruptor_intro_skip.cpp b/src/game/session/intro_skip.cpp similarity index 99% rename from src/disruptor_intro_skip.cpp rename to src/game/session/intro_skip.cpp index 486a83e..e166ec2 100644 --- a/src/disruptor_intro_skip.cpp +++ b/src/game/session/intro_skip.cpp @@ -19,7 +19,7 @@ * after launch instead of 14. */ -#include "disruptor_intro_skip.h" +#include "game/session/intro_skip.h" #include "cpu_state.h" #include "lockstep.h" diff --git a/src/disruptor_intro_skip.h b/src/game/session/intro_skip.h similarity index 100% rename from src/disruptor_intro_skip.h rename to src/game/session/intro_skip.h diff --git a/src/disruptor_restart.cpp b/src/game/session/restart.cpp similarity index 98% rename from src/disruptor_restart.cpp rename to src/game/session/restart.cpp index 93529d4..ccb3432 100644 --- a/src/disruptor_restart.cpp +++ b/src/game/session/restart.cpp @@ -9,7 +9,7 @@ * ended by then, the new one ends itself and the player starts the game. */ -#include "disruptor_restart.h" +#include "game/session/restart.h" #include "mod_plugins.h" diff --git a/src/disruptor_restart.h b/src/game/session/restart.h similarity index 100% rename from src/disruptor_restart.h rename to src/game/session/restart.h diff --git a/src/disruptor_dev_menu.cpp b/src/game/ui/dev_menu.cpp similarity index 99% rename from src/disruptor_dev_menu.cpp rename to src/game/ui/dev_menu.cpp index 6a4ffa5..439c393 100644 --- a/src/disruptor_dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -7,15 +7,15 @@ * gameplay APIs whose save consequences are stated in the UI. */ -#include "disruptor_cheats.h" -#include "disruptor_billboard_aspect.h" -#include "disruptor_far_rendering.h" -#include "disruptor_frame_rate.h" -#include "disruptor_capsule.h" -#include "disruptor_intro_skip.h" -#include "disruptor_mouse_aim.h" -#include "disruptor_present_rate.h" -#include "disruptor_restart.h" +#include "game/session/cheats.h" +#include "game/render/billboard_aspect.h" +#include "game/render/far_rendering.h" +#include "game/frame/frame_rate.h" +#include "game/session/capsule.h" +#include "game/session/intro_skip.h" +#include "game/input/mouse_aim.h" +#include "game/frame/present_rate.h" +#include "game/session/restart.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 2a76c88..57a6daf 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -24,7 +24,7 @@ add_library(disruptor-launcher-model STATIC launcher_settings.cpp region_disc.cpp startup_log.cpp - "${PROJECT_SOURCE_DIR}/src/disruptor_language_disc.cpp") + "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp") target_include_directories(disruptor-launcher-model PUBLIC "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-launcher-model PUBLIC disruptor-disc-import disruptor-launcher-config) diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index cefd0fb..4c174c7 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -1,6 +1,6 @@ #include "launcher_settings.h" -#include "disruptor_present_rate.h" +#include "game/frame/present_rate.h" #include diff --git a/src/launcher/region_disc.cpp b/src/launcher/region_disc.cpp index a28a567..2d21910 100644 --- a/src/launcher/region_disc.cpp +++ b/src/launcher/region_disc.cpp @@ -1,7 +1,7 @@ #include "region_disc.h" #include "disc_import.h" -#include "disruptor_language_disc.h" +#include "game/language/language_disc.h" #include #include diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 3224d81..ca1999b 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -22,7 +22,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") diff --git a/tests/contracts/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py index 08752e6..565765d 100644 --- a/tests/contracts/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -53,7 +53,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_backdrop_tiles.cpp") +source = read("src/game/render/backdrop_tiles.cpp") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") gpu_header = read("psxrecomp-overlay/runtime/include/gpu.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") @@ -61,7 +61,7 @@ def require(condition: bool, message: str) -> None: audit = read("tools/audit_codegen.py") require( - "src/disruptor_backdrop_tiles.cpp" in cmake + "src/game/render/backdrop_tiles.cpp" in cmake and "disruptor_backdrop_tiles_contract" in cmake and "add_test(NAME disruptor_backdrop_tiles\n" in cmake, "backdrop tile source, its unit test and its contract test must remain registered", diff --git a/tests/contracts/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py index c3c3d91..bfe1997 100644 --- a/tests/contracts/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -19,8 +19,8 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_billboard_aspect.cpp") -header = read("src/disruptor_billboard_aspect.h") +source = read("src/game/render/billboard_aspect.cpp") +header = read("src/game/render/billboard_aspect.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") @@ -69,7 +69,7 @@ def require(condition: bool, message: str) -> None: "disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1)" in dirty, "dirty-interpreter parity must be project-gated and post-instruction") require("PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1" in cmake and - "src/disruptor_billboard_aspect.cpp" in cmake and + "src/game/render/billboard_aspect.cpp" in cmake and "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, "runtime source, dirty seam, and focused behavior test must be enabled") diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index a909dff..73d28ed 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -16,9 +16,9 @@ def require(condition: bool, message: str) -> None: host = read("psxrecomp-overlay/runtime/src/main.cpp") -capsule = read("src/disruptor_capsule.cpp") -mouse = read("src/disruptor_mouse_aim.cpp") -menu = read("src/disruptor_dev_menu.cpp") +capsule = read("src/game/session/capsule.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = read("src/game/ui/dev_menu.cpp") cmake = read("CMakeLists.txt") require( @@ -128,9 +128,9 @@ def require(condition: bool, message: str) -> None: "Ctrl+F12 must start and end a recording and must not reach the save state keys", ) require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_capsule.cpp"\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake - and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/disruptor_capsule.cpp"\n' + and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' in read("tests/unit/disruptor_capsule_test.cpp") and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake and 'tests/contracts/test_capsule_contract.py")' in cmake, diff --git a/tests/contracts/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py index 135d4db..f7019b4 100644 --- a/tests/contracts/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -23,8 +23,8 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_cheats.cpp") -header = read("src/disruptor_cheats.h") +source = read("src/game/session/cheats.cpp") +header = read("src/game/session/cheats.h") game = read("game.toml") wide = read("game-widescreen.toml") diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 0165e16..521a958 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -22,7 +22,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index b0be712..87bd76d 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -58,11 +58,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: main_cpp = read("psxrecomp-overlay/runtime/src/main.cpp") gl = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -menu = read("src/disruptor_dev_menu.cpp") -mouse = read("src/disruptor_mouse_aim.cpp") -cheats = read("src/disruptor_cheats.cpp") -cheats_h = read("src/disruptor_cheats.h") -far_rendering_h = read("src/disruptor_far_rendering.h") +menu = read("src/game/ui/dev_menu.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +cheats = read("src/game/session/cheats.cpp") +cheats_h = read("src/game/session/cheats.h") +far_rendering_h = read("src/game/render/far_rendering.h") config_h = read("psxrecomp-overlay/recompiler/src/config_loader.h") config_cpp = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") run_ps1 = read("run.ps1") @@ -76,10 +76,10 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "5b17c01f69545bde732b14936d89ce0f508adb83e8b56fa82448371845172bc3" in cmake.lower(), "Dear ImGui source must remain versioned and integrity-pinned") -require("src/disruptor_dev_menu.cpp" in cmake and +require("src/game/ui/dev_menu.cpp" in cmake and "target_link_libraries(psx-runtime PRIVATE disruptor-imgui)" in cmake, "menu implementation must be linked only into the game runtime") -require("src/disruptor_cheats.cpp" in cmake and +require("src/game/session/cheats.cpp" in cmake and "tests/unit/disruptor_cheats_test.cpp" in cmake, "version-pinned cheats and their behavioral test must be built") require("+runtime/include/host_ui.h" in manifest, diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index e56bd87..013e9a4 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -19,8 +19,8 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -source = read("src/disruptor_far_rendering.cpp") -header = read("src/disruptor_far_rendering.h") +source = read("src/game/render/far_rendering.cpp") +header = read("src/game/render/far_rendering.h") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") main = read("psxrecomp-overlay/runtime/src/main.cpp") @@ -28,7 +28,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") -require("src/disruptor_far_rendering.cpp" in cmake and +require("src/game/render/far_rendering.cpp" in cmake and "tests/unit/disruptor_far_rendering_test.cpp" in cmake and "add_test(NAME disruptor_far_rendering" in cmake, "runtime source and focused behavior test must remain enabled") diff --git a/tests/contracts/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py index 1f06686..c7fd7e1 100644 --- a/tests/contracts/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -47,7 +47,7 @@ def enclosing(source: str, block: str) -> list[str]: CONTRACTS = ["if(BUILD_TESTING)"] UNIT_TESTS = ["if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)"] REGISTERED = { - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_render_arena.cpp"\n': [], + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp"\n': [], ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py")\n': CONTRACTS, " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/unit/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index fb9d846..fe4af39 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -43,9 +43,9 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_frame_rate.cpp") -header = read("src/disruptor_frame_rate.h") -menu = read("src/disruptor_dev_menu.cpp") +source = read("src/game/frame/frame_rate.cpp") +header = read("src/game/frame/frame_rate.h") +menu = read("src/game/ui/dev_menu.cpp") manifest = read("PSXRECOMP_OVERLAY_FILES.txt") cycle_header = read("psxrecomp-overlay/runtime/include/psx_cyc.h") cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") @@ -55,7 +55,7 @@ def require(condition: bool, message: str) -> None: "the build gate must run this contract with its inputs required", ) require( - "src/disruptor_frame_rate.cpp" in cmake + "src/game/frame/frame_rate.cpp" in cmake and "disruptor_frame_rate_contract" in cmake and "add_test(NAME disruptor_frame_rate\n" in cmake, "frame-rate source, its unit test and its contract test must remain registered", @@ -161,8 +161,8 @@ def require(condition: bool, message: str) -> None: "the divisor must charge whole cycles and carry the remainder", ) -present_rate = read("src/disruptor_present_rate.h") -present_control = read("src/disruptor_present_rate.cpp") +present_rate = read("src/game/frame/present_rate.h") +present_control = read("src/game/frame/present_rate.cpp") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( @@ -174,7 +174,7 @@ def require(condition: bool, message: str) -> None: require( " if(DISRUPTOR_FRAME_INTERPOLATION)\n" " list(APPEND DISRUPTOR_EXTRA_SOURCES\n" - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_present_rate.cpp")\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp")\n' in cmake and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 5f985fa..b4e4aff 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -22,7 +22,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") diff --git a/tests/contracts/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py index 722b26e..6486009 100644 --- a/tests/contracts/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -18,7 +18,7 @@ GTE_HEADER = ( ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" MAIN = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "main.cpp" CODEGEN = ( ROOT / "psxrecomp-overlay" / "recompiler" / "src" / "code_generator.cpp" diff --git a/tests/contracts/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py index 73077dc..2c9fb38 100644 --- a/tests/contracts/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -36,11 +36,11 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_grown_quad.cpp") +source = read("src/game/render/grown_quad.cpp") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") require( - '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_grown_quad.cpp"' in cmake + '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake and "add_test(NAME disruptor_grown_quad\n" in cmake and 'tests/contracts/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 2165a3d..2dbc67c 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -20,7 +20,7 @@ def require(condition: bool, message: str) -> None: scale_header = read("psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( diff --git a/tests/contracts/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py index dee123f..178a42e 100644 --- a/tests/contracts/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -24,7 +24,7 @@ def body(source: str, signature: str) -> str: manifest = read("PSXRECOMP_OVERLAY_FILES.txt") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") -module = read("src/disruptor_hud_widgets.cpp") +module = read("src/game/render/hud_widgets.cpp") require( "+runtime/include/gpu_ws_hud_widget.h" in manifest.splitlines(), @@ -35,7 +35,7 @@ def body(source: str, signature: str) -> str: and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py")\n' in cmake - and '"${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_hud_widgets.cpp"' in cmake, + and '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 53d2fa9..f1b839a 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -45,16 +45,16 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_intro_skip.cpp") -header = read("src/disruptor_intro_skip.h") -menu = read("src/disruptor_dev_menu.cpp") +source = read("src/game/session/intro_skip.cpp") +header = read("src/game/session/intro_skip.h") +menu = read("src/game/ui/dev_menu.cpp") generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( - "src/disruptor_intro_skip.cpp" in cmake + "src/game/session/intro_skip.cpp" in cmake and "PSX_HAS_DISRUPTOR_INTRO_SKIP=1" in cmake and "disruptor_intro_skip_contract" in cmake and "add_test(NAME disruptor_intro_skip\n" in cmake, diff --git a/tests/contracts/test_language_contract.py b/tests/contracts/test_language_contract.py index a81fc48..a99d38a 100644 --- a/tests/contracts/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -87,13 +87,13 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_language.cpp") -rules = read("src/disruptor_language_pack.h") -header = read("src/disruptor_language.h") -hints = read("src/disruptor_language_hints.cpp") +source = read("src/game/language/language.cpp") +rules = read("src/game/language/language_pack.h") +header = read("src/game/language/language.h") +hints = read("src/game/language/language_hints.cpp") require( - '/src/disruptor_language.cpp"' in cmake + '/src/game/language/language.cpp"' in cmake and "add_test(NAME disruptor_language\n" in cmake and 'tests/contracts/test_language_contract.py"\n --require-artifacts)' in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", @@ -192,7 +192,7 @@ def require(condition: bool, message: str) -> None: "by the rules the disc code writes it by", ) require( - '/src/disruptor_language_hints.cpp"' in cmake + '/src/game/language/language_hints.cpp"' in cmake and "add_test(NAME disruptor_language_hints\n" in cmake and "constexpr uint32_t kRead = 0x80011888u, kLoadImage = 0x8004D348u, kMoveImage = 0x8004D410u;" in hints and "constexpr uint32_t kReadReturn = 0x80020928u, kStageReturn = 0x80020954u;" in hints diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index 27ceebe..ea70e19 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -65,23 +65,23 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") overlay_files = read("PSXRECOMP_OVERLAY_FILES.txt") -disc = read("src/disruptor_language_disc.cpp") -disc_header = read("src/disruptor_language_disc.h") -rules = read("src/disruptor_language_pack.h") -source = read("src/disruptor_language_source.cpp") +disc = read("src/game/language/language_disc.cpp") +disc_header = read("src/game/language/language_disc.h") +rules = read("src/game/language/language_pack.h") +source = read("src/game/language/language_source.cpp") header = read("psxrecomp-overlay/runtime/include/iso_overlay.h") host = read("psxrecomp-overlay/runtime/src/main.cpp") host_header = read("psxrecomp-overlay/runtime/include/host_ui.h") pick_slot = read("psxrecomp-overlay/runtime/include/host_pick_slot.h") -restart = read("src/disruptor_restart.cpp") +restart = read("src/game/session/restart.cpp") settings_header = read("psxrecomp-overlay/recompiler/src/config_loader.h") settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") reader = read("psxrecomp-overlay/runtime/src/iso_reader_c.cpp") require( - '/src/disruptor_language_disc.cpp"' in cmake - and '/src/disruptor_language_source.cpp"' in cmake + '/src/game/language/language_disc.cpp"' in cmake + and '/src/game/language/language_source.cpp"' in cmake and "add_test(NAME disruptor_language_disc\n" in cmake and "add_test(NAME disruptor_language_overlay\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake @@ -353,7 +353,7 @@ def require(condition: bool, message: str) -> None: and " if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked);" in menu and ' if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) {' in menu and " quit.type = SDL_QUIT;\n SDL_PushEvent(&quit);" in menu - and '/src/disruptor_restart.cpp"' in cmake + and '/src/game/session/restart.cpp"' in cmake and "add_test(NAME disruptor_restart\n" in cmake and " if (!before) return GetLastError() == ERROR_INVALID_PARAMETER;" in restart and " return waited == WAIT_OBJECT_0;" in restart diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index b5bf35d..c9d187d 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -43,7 +43,7 @@ def plain_values(text: str) -> dict[str, str]: launcher_cmake = read("src/launcher/CMakeLists.txt") cmake = read("CMakeLists.txt") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -menu = read("src/disruptor_dev_menu.cpp") + read("src/disruptor_present_rate.cpp") +menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") rows = re.findall(r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", Page::(\w+), L"([^"]+)", (-?\d+)', table) keys = [key for _, key, _, _, _ in rows] @@ -76,7 +76,7 @@ def plain_values(text: str) -> dict[str, str]: shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") -mouse = read("src/disruptor_mouse_aim.cpp") +mouse = read("src/game/input/mouse_aim.cpp") require( "constexpr double kDefaultHorizontalSensitivity = 0.080;" in mouse and "constexpr double kDefaultVerticalSensitivity = 0.080;" in mouse @@ -91,7 +91,7 @@ def plain_values(text: str) -> dict[str, str]: require( '"${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp"' in launcher_cmake - and '"${PROJECT_SOURCE_DIR}/src/disruptor_language_disc.cpp"' in launcher_cmake + and '"${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp"' in launcher_cmake and " Settings fresh = PSXRecompV4::load_user_settings(path_);" in table and " if (!PSXRecompV4::save_user_settings(path_, fresh)) return false;" in table and " if (!readable_) return false;\n alter(fresh);" in table, @@ -145,10 +145,10 @@ def plain_values(text: str) -> dict[str, str]: "drag and drop, the per-monitor layout and the log tail must stay", ) require( - UTF8_PAGE in read("src/disruptor.manifest") and UTF8_PAGE in read("src/launcher/launcher.manifest") - and '1 RT_MANIFEST "disruptor.manifest"' in read("src/disruptor.rc") - and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.manifest")' in cmake - and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor.rc")' in cmake, + UTF8_PAGE in read("src/game/disruptor.manifest") and UTF8_PAGE in read("src/launcher/launcher.manifest") + and '1 RT_MANIFEST "disruptor.manifest"' in read("src/game/disruptor.rc") + and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.manifest")' in cmake + and 'target_sources(psx-runtime PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/game/disruptor.rc")' in cmake, "the game and the launcher must both run with UTF-8 as their code page, or a path outside it cannot be named", ) require( diff --git a/tests/contracts/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py index baef1e6..742b49e 100644 --- a/tests/contracts/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -24,8 +24,8 @@ def require(condition: bool, message: str) -> None: state = read("psxrecomp-overlay/runtime/src/boot_state.c") cycles = read("psxrecomp-overlay/runtime/include/psx_cyc.h") latch = read("psxrecomp/runtime/src/fntrace.c") -menu = read("src/disruptor_dev_menu.cpp") -rate = read("src/disruptor_frame_rate.cpp") +menu = read("src/game/ui/dev_menu.cpp") +rate = read("src/game/frame/frame_rate.cpp") require( "static void engage_widescreen_at_game_entry(void) {\n" diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index 3de0d7c..3f68dc6 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -93,11 +93,11 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -module = read("src/disruptor_name_entry.cpp") +module = read("src/game/input/name_entry.cpp") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/disruptor_name_entry.cpp"\n' in cmake + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake and 'tests/contracts/test_name_entry_contract.py"\n --require-artifacts)\n' diff --git a/tests/contracts/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py index 77f68f8..94de2be 100644 --- a/tests/contracts/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -16,7 +16,7 @@ GTE_HEADER = ( ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" ) -MOUSE = ROOT / "src" / "disruptor_mouse_aim.cpp" +MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" OVERLAY_LIST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 481c421..08106be 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -24,8 +24,8 @@ def between(source: str, opening: str, closing: str) -> str: overlay = read("PSXRECOMP_OVERLAY_FILES.txt") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -mouse = read("src/disruptor_mouse_aim.cpp") -menu = read("src/disruptor_dev_menu.cpp") +mouse = read("src/game/input/mouse_aim.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") require( diff --git a/tests/contracts/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py index 84e7b74..2fe9737 100644 --- a/tests/contracts/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -183,7 +183,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -source = read("src/disruptor_sprite_depth.cpp") +source = read("src/game/render/sprite_depth.cpp") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 2d4eb27..db27fa2 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -39,7 +39,7 @@ def shader(source: str, name: str) -> str: temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -billboards = read("src/disruptor_billboard_aspect.cpp") +billboards = read("src/game/render/billboard_aspect.cpp") require( "disruptor_temporal_presenter_contract" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, diff --git a/tests/contracts/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py index b8f827d..c5b8f0c 100644 --- a/tests/contracts/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -19,9 +19,9 @@ def require(condition: bool, message: str) -> None: raise SystemExit(1) -camera = read("src/disruptor_vertical_camera.cpp") -camera_h = read("src/disruptor_vertical_camera.h") -mouse = read("src/disruptor_mouse_aim.cpp") +camera = read("src/game/input/vertical_camera.cpp") +camera_h = read("src/game/input/vertical_camera.h") +mouse = read("src/game/input/mouse_aim.cpp") codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp-overlay/runtime/src/gpu.c") diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index d7776ce..ab6359c 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -26,7 +26,7 @@ def between(source: str, opening: str, closing: str) -> str: header = read("psxrecomp-overlay/runtime/include/gpu_world_depth.h") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") presenter = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") -menu = read("src/disruptor_dev_menu.cpp") +menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") diff --git a/tests/support/disruptor_language_discs.h b/tests/support/disruptor_language_discs.h index cb7b297..4f715e4 100644 --- a/tests/support/disruptor_language_discs.h +++ b/tests/support/disruptor_language_discs.h @@ -3,7 +3,7 @@ /* Synthetic discs for the language tests. The disc code is included whole, so that a test reaches its internals. */ -#include "../../src/disruptor_language_disc.cpp" +#include "../../src/game/language/language_disc.cpp" namespace { diff --git a/tests/unit/disruptor_backdrop_tiles_test.cpp b/tests/unit/disruptor_backdrop_tiles_test.cpp index 99f37a3..309df41 100644 --- a/tests/unit/disruptor_backdrop_tiles_test.cpp +++ b/tests/unit/disruptor_backdrop_tiles_test.cpp @@ -85,7 +85,7 @@ void gpu_ws_tag_screen_tile(CPUState *cpu, std::uint32_t primitive_addr) { } // extern "C" -#include "../../src/disruptor_backdrop_tiles.cpp" +#include "../../src/game/render/backdrop_tiles.cpp" namespace { diff --git a/tests/unit/disruptor_billboard_aspect_test.cpp b/tests/unit/disruptor_billboard_aspect_test.cpp index 6f8bac7..f432096 100644 --- a/tests/unit/disruptor_billboard_aspect_test.cpp +++ b/tests/unit/disruptor_billboard_aspect_test.cpp @@ -66,7 +66,7 @@ void disruptor_sprite_depth_packet(CPUState *, std::uint32_t, std::uint32_t) {} } // extern "C" // Include the implementation to exercise its exact private packet guards. -#include "../../src/disruptor_billboard_aspect.cpp" +#include "../../src/game/render/billboard_aspect.cpp" namespace { diff --git a/tests/unit/disruptor_capsule_test.cpp b/tests/unit/disruptor_capsule_test.cpp index efd03e5..933d541 100644 --- a/tests/unit/disruptor_capsule_test.cpp +++ b/tests/unit/disruptor_capsule_test.cpp @@ -1,5 +1,5 @@ #define DISRUPTOR_CAPSULE_NO_HOST 1 -#include "../../src/disruptor_capsule.cpp" +#include "../../src/game/session/capsule.cpp" #include #include diff --git a/tests/unit/disruptor_cheats_test.cpp b/tests/unit/disruptor_cheats_test.cpp index f5c70db..cdfbe04 100644 --- a/tests/unit/disruptor_cheats_test.cpp +++ b/tests/unit/disruptor_cheats_test.cpp @@ -104,7 +104,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" // Include the implementation to exercise its version-pinned internal guards. -#include "../../src/disruptor_cheats.cpp" +#include "../../src/game/session/cheats.cpp" namespace { diff --git a/tests/unit/disruptor_far_rendering_test.cpp b/tests/unit/disruptor_far_rendering_test.cpp index 4c2dd18..e0a2bc1 100644 --- a/tests/unit/disruptor_far_rendering_test.cpp +++ b/tests/unit/disruptor_far_rendering_test.cpp @@ -109,7 +109,7 @@ void psx_mod_write_word(std::uint32_t address, std::uint32_t value) { } // extern "C" -#include "../../src/disruptor_far_rendering.cpp" +#include "../../src/game/render/far_rendering.cpp" namespace { diff --git a/tests/unit/disruptor_frame_rate_test.cpp b/tests/unit/disruptor_frame_rate_test.cpp index ae020d7..3d18c22 100644 --- a/tests/unit/disruptor_frame_rate_test.cpp +++ b/tests/unit/disruptor_frame_rate_test.cpp @@ -71,7 +71,7 @@ int psx_mod_register_function_entry_plugin( uint32_t interrupts_cycles_to_vblank(void) { return g_cycles_to_vblank; } -#include "../../src/disruptor_frame_rate.cpp" +#include "../../src/game/frame/frame_rate.cpp" namespace { diff --git a/tests/unit/disruptor_grown_quad_test.cpp b/tests/unit/disruptor_grown_quad_test.cpp index b82d98a..a1fd507 100644 --- a/tests/unit/disruptor_grown_quad_test.cpp +++ b/tests/unit/disruptor_grown_quad_test.cpp @@ -21,7 +21,7 @@ extern "C" std::uint32_t psx_mod_read_word(std::uint32_t address) { return found == g_memory.end() ? 0xDEADBEEFu : found->second; } -#include "../../src/disruptor_grown_quad.cpp" +#include "../../src/game/render/grown_quad.cpp" namespace { diff --git a/tests/unit/disruptor_intro_skip_test.cpp b/tests/unit/disruptor_intro_skip_test.cpp index 48d7de6..3de0806 100644 --- a/tests/unit/disruptor_intro_skip_test.cpp +++ b/tests/unit/disruptor_intro_skip_test.cpp @@ -43,7 +43,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { g_registered_query = query; } -#include "../../src/disruptor_intro_skip.cpp" +#include "../../src/game/session/intro_skip.cpp" namespace { diff --git a/tests/unit/disruptor_language_hints_test.cpp b/tests/unit/disruptor_language_hints_test.cpp index 4fd1a06..f5cad85 100644 --- a/tests/unit/disruptor_language_hints_test.cpp +++ b/tests/unit/disruptor_language_hints_test.cpp @@ -60,7 +60,7 @@ int psx_mod_register_function_entry_plugin(const char *, uint32_t address, PSXMo } extern "C" int disruptor_language_disc_tall_hints(void) { return g_tall; } -#include "../../src/disruptor_language_hints.cpp" +#include "../../src/game/language/language_hints.cpp" namespace { diff --git a/tests/unit/disruptor_language_overlay_test.cpp b/tests/unit/disruptor_language_overlay_test.cpp index 67bfa1a..c5252d8 100644 --- a/tests/unit/disruptor_language_overlay_test.cpp +++ b/tests/unit/disruptor_language_overlay_test.cpp @@ -68,7 +68,7 @@ extern "C" void savestate_get_integrity(uint32_t *bios_checksum, uint32_t *entry } #include "../../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" -#include "../../src/disruptor_language_source.cpp" +#include "../../src/game/language/language_source.cpp" namespace { diff --git a/tests/unit/disruptor_language_test.cpp b/tests/unit/disruptor_language_test.cpp index 18aafcb..a962e92 100644 --- a/tests/unit/disruptor_language_test.cpp +++ b/tests/unit/disruptor_language_test.cpp @@ -63,8 +63,8 @@ extern "C" int disruptor_language_disc_pack(const uint8_t **data, uint32_t *size return 1; } -#include "../../src/disruptor_language.cpp" -#include "../../src/disruptor_language_disc.cpp" +#include "../../src/game/language/language.cpp" +#include "../../src/game/language/language_disc.cpp" namespace { diff --git a/tests/unit/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp index fb0051e..5a3e40f 100644 --- a/tests/unit/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -184,7 +184,7 @@ const char *SDL_GetError() { // Include the implementation so its internal pure helpers and state can be // exercised against the mocks above without exposing a test-only production ABI. -#include "../../src/disruptor_mouse_aim.cpp" +#include "../../src/game/input/mouse_aim.cpp" namespace { diff --git a/tests/unit/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp index e3bef9b..be1e3b1 100644 --- a/tests/unit/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -62,7 +62,7 @@ int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) } } -#include "../../src/disruptor_name_entry.cpp" +#include "../../src/game/input/name_entry.cpp" namespace { diff --git a/tests/unit/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp index 5bc4941..58540ba 100644 --- a/tests/unit/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -1,4 +1,4 @@ -#include "../../src/disruptor_present_rate.h" +#include "../../src/game/frame/present_rate.h" #include #include @@ -97,7 +97,7 @@ bool SliderInt(const char *, int *value, int lowest, int highest, const char *, void TextDisabled(const char *text, ...) { g_slider.text = text; } } // namespace ImGui -#include "../../src/disruptor_present_rate.cpp" +#include "../../src/game/frame/present_rate.cpp" namespace { diff --git a/tests/unit/disruptor_render_arena_test.cpp b/tests/unit/disruptor_render_arena_test.cpp index b909f8a..4cc9f56 100644 --- a/tests/unit/disruptor_render_arena_test.cpp +++ b/tests/unit/disruptor_render_arena_test.cpp @@ -52,7 +52,7 @@ int psx_mod_register_function_entry_plugin( const char *, uint32_t, PSXModFunctionEntryCallback) { return 1; } } -#include "../../src/disruptor_render_arena.cpp" +#include "../../src/game/render/render_arena.cpp" int main() { CPUState cpu{}; diff --git a/tests/unit/disruptor_restart_test.cpp b/tests/unit/disruptor_restart_test.cpp index e782e35..42322bc 100644 --- a/tests/unit/disruptor_restart_test.cpp +++ b/tests/unit/disruptor_restart_test.cpp @@ -1,4 +1,4 @@ -#include "../../src/disruptor_restart.cpp" +#include "../../src/game/session/restart.cpp" #include #include diff --git a/tests/unit/disruptor_sprite_depth_test.cpp b/tests/unit/disruptor_sprite_depth_test.cpp index 0ad0f84..d416903 100644 --- a/tests/unit/disruptor_sprite_depth_test.cpp +++ b/tests/unit/disruptor_sprite_depth_test.cpp @@ -124,7 +124,7 @@ extern "C" void gte_ws_x_squash(std::int32_t *num, std::int32_t *den) { *den = g_range_den; } -#include "../../src/disruptor_sprite_depth.cpp" +#include "../../src/game/render/sprite_depth.cpp" namespace { diff --git a/tests/unit/disruptor_vertical_camera_test.cpp b/tests/unit/disruptor_vertical_camera_test.cpp index 3cd3778..b10789d 100644 --- a/tests/unit/disruptor_vertical_camera_test.cpp +++ b/tests/unit/disruptor_vertical_camera_test.cpp @@ -132,7 +132,7 @@ void gpu_geometry_camera_projection_center_y_set(double center_y) { } // extern "C" // Include the implementation to exercise the version-pinned internal guards. -#include "../../src/disruptor_vertical_camera.cpp" +#include "../../src/game/input/vertical_camera.cpp" namespace { From d11bef4bfff6e84aac6a76040e3e1c1fa3d02356 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:45:19 +0300 Subject: [PATCH 05/21] refactor: register the tests through two helper functions --- CMakeLists.txt | 553 ++++-------------- cmake/DisruptorTests.cmake | 56 ++ .../test_ambient_occlusion_contract.py | 2 +- .../contracts/test_backdrop_tiles_contract.py | 6 +- tests/contracts/test_borderless_contract.py | 5 +- tests/contracts/test_capsule_contract.py | 4 +- tests/contracts/test_cheats_contract.py | 2 +- tests/contracts/test_debug_tab_contract.py | 2 +- tests/contracts/test_depth24_sync_contract.py | 5 +- .../contracts/test_far_rendering_contract.py | 2 +- tests/contracts/test_frame_kind_contract.py | 9 +- tests/contracts/test_frame_rate_contract.py | 8 +- tests/contracts/test_gap_filling_contract.py | 2 +- tests/contracts/test_grown_quad_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 2 +- tests/contracts/test_hud_widget_contract.py | 6 +- tests/contracts/test_intro_skip_contract.py | 6 +- tests/contracts/test_language_contract.py | 6 +- .../contracts/test_language_disc_contract.py | 11 +- tests/contracts/test_launcher_contract.py | 2 +- tests/contracts/test_menu_wide_contract.py | 4 +- tests/contracts/test_name_entry_contract.py | 6 +- .../contracts/test_pinned_corner_contract.py | 13 +- tests/contracts/test_save_point_contract.py | 4 +- tests/contracts/test_smooth_turn_contract.py | 6 +- tests/contracts/test_sprite_range_contract.py | 5 +- .../test_temporal_presenter_contract.py | 2 +- .../test_test_registration_contract.py | 164 ++++++ .../test_vertical_camera_contract.py | 2 +- tests/contracts/test_world_depth_contract.py | 5 +- 30 files changed, 378 insertions(+), 526 deletions(-) create mode 100644 cmake/DisruptorTests.cmake create mode 100644 tests/contracts/test_test_registration_contract.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 57c6e7e..2c51d93 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,466 +179,69 @@ if(WIN32) endif() if(BUILD_TESTING) find_package(Python3 REQUIRED COMPONENTS Interpreter) - - add_test(NAME disruptor_backdrop_tiles_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_backdrop_tiles_contract.py" - --require-artifacts) - - add_test(NAME disruptor_depth24_sync_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py") - - add_test(NAME disruptor_geometry_provenance_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_geometry_provenance_contract.py") - - add_test(NAME disruptor_phase_a_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_phase_a_contract.py") - - add_test(NAME disruptor_perspective_texture_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_perspective_texture_contract.py") - - add_test(NAME disruptor_pinned_corner_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py") - - add_test(NAME disruptor_dev_menu_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_dev_menu_contract.py") - - add_test(NAME disruptor_loaded_state_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_loaded_state_contract.py") - - add_test(NAME disruptor_save_point_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_save_point_contract.py") - - add_test(NAME disruptor_cheats_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_cheats_contract.py") - - add_test(NAME disruptor_vertical_camera_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_vertical_camera_contract.py") - - add_test(NAME disruptor_far_rendering_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_far_rendering_contract.py") - - add_test(NAME disruptor_frame_kind_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py") - - add_test(NAME disruptor_temporal_presenter_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_temporal_presenter_contract.py") - - add_test(NAME disruptor_menu_wide_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_menu_wide_contract.py") - - add_test(NAME disruptor_hud_scale_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_scale_contract.py") - - add_test(NAME disruptor_hud_widget_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py") - - add_test(NAME disruptor_name_entry_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_name_entry_contract.py" - --require-artifacts) - set_tests_properties(disruptor_name_entry_contract - PROPERTIES LABELS retail) - - add_test(NAME disruptor_grown_quad_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_grown_quad_contract.py" - --require-artifacts) - - add_test(NAME disruptor_sprite_range_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_sprite_range_contract.py" - --require-artifacts) - set_tests_properties(disruptor_sprite_range_contract - PROPERTIES LABELS retail) - - add_test(NAME disruptor_intro_skip_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_intro_skip_contract.py" - --require-artifacts) - - add_test(NAME disruptor_language_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_contract.py" - --require-artifacts) - - add_test(NAME disruptor_language_disc_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_language_disc_contract.py" - --require-artifacts) - - add_test(NAME disruptor_frame_rate_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_rate_contract.py" - --require-artifacts) - - add_test(NAME disruptor_billboard_aspect_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_billboard_aspect_contract.py") - - add_test(NAME disruptor_launcher_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_launcher_contract.py") - - add_test(NAME disruptor_capsule_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_capsule_contract.py") - - add_test(NAME disruptor_gap_filling_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_gap_filling_contract.py") - - add_test(NAME disruptor_world_depth_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_world_depth_contract.py") - - add_test(NAME disruptor_smooth_turn_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_smooth_turn_contract.py") - - add_test(NAME disruptor_ambient_occlusion_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_ambient_occlusion_contract.py") - - add_test(NAME disruptor_debug_tab_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_debug_tab_contract.py") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake") + + disruptor_contract(backdrop_tiles RETAIL) + disruptor_contract(depth24_sync) + disruptor_contract(geometry_provenance) + disruptor_contract(phase_a) + disruptor_contract(perspective_texture) + disruptor_contract(pinned_corner) + disruptor_contract(dev_menu) + disruptor_contract(loaded_state) + disruptor_contract(save_point) + disruptor_contract(cheats) + disruptor_contract(vertical_camera) + disruptor_contract(far_rendering) + disruptor_contract(frame_kind) + disruptor_contract(temporal_presenter) + disruptor_contract(menu_wide) + disruptor_contract(hud_scale) + disruptor_contract(hud_widget) + disruptor_contract(name_entry RETAIL) + disruptor_contract(grown_quad RETAIL) + disruptor_contract(sprite_range RETAIL) + disruptor_contract(intro_skip RETAIL) + disruptor_contract(language RETAIL) + disruptor_contract(language_disc RETAIL) + disruptor_contract(frame_rate RETAIL) + disruptor_contract(billboard_aspect) + disruptor_contract(launcher) + disruptor_contract(capsule) + disruptor_contract(gap_filling) + disruptor_contract(world_depth) + disruptor_contract(smooth_turn) + disruptor_contract(ambient_occlusion) + disruptor_contract(debug_tab) + disruptor_contract(test_registration) add_test(NAME disruptor_framework_overlay_new_file COMMAND "${Python3_EXECUTABLE}" -B "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_overlay_new_file.py") - - # These need the retail image or the translated code. A machine without - # the game leaves them out with `ctest -LE retail`. - set_tests_properties( - disruptor_backdrop_tiles_contract disruptor_grown_quad_contract - disruptor_intro_skip_contract disruptor_language_contract - disruptor_language_disc_contract disruptor_frame_rate_contract - PROPERTIES LABELS retail) endif() if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) - add_executable(disruptor-user-settings-test - tests/unit/user_settings_persistence_test.cpp - "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" - "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") - target_include_directories(disruptor-user-settings-test PRIVATE - "${PSXRECOMP_ROOT}/recompiler/src" - "${PSXRECOMP_ROOT}/recompiler/include" - "${PSXRECOMP_ROOT}/recompiler/lib/fmt/include" - "${PSXRECOMP_ROOT}/recompiler/lib/toml11") - target_compile_definitions(disruptor-user-settings-test PRIVATE - FMT_HEADER_ONLY=1 - "$<$:NOMINMAX>" - "$<$:WIN32_LEAN_AND_MEAN>") - target_compile_features(disruptor-user-settings-test PRIVATE cxx_std_20) - add_test(NAME disruptor_user_settings_persistence - COMMAND disruptor-user-settings-test) - - add_executable(disruptor-mouse-aim-test - tests/unit/disruptor_mouse_aim_test.cpp) - target_include_directories(disruptor-mouse-aim-test PRIVATE - tests/support/stubs) - target_compile_definitions(disruptor-mouse-aim-test PRIVATE PSX_SDL3=1) - target_compile_features(disruptor-mouse-aim-test PRIVATE cxx_std_20) - - add_executable(disruptor-cheats-test - tests/unit/disruptor_cheats_test.cpp) - target_include_directories(disruptor-cheats-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-cheats-test PRIVATE cxx_std_20) - - add_executable(disruptor-vertical-camera-test - tests/unit/disruptor_vertical_camera_test.cpp) - target_include_directories(disruptor-vertical-camera-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-vertical-camera-test PRIVATE cxx_std_20) - - add_executable(disruptor-far-rendering-test - tests/unit/disruptor_far_rendering_test.cpp) - target_include_directories(disruptor-far-rendering-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-far-rendering-test PRIVATE cxx_std_20) - - add_executable(disruptor-render-arena-test - tests/unit/disruptor_render_arena_test.cpp) - target_include_directories(disruptor-render-arena-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-render-arena-test PRIVATE cxx_std_20) - add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test) - - add_executable(disruptor-name-entry-test - tests/unit/disruptor_name_entry_test.cpp) - target_include_directories(disruptor-name-entry-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-name-entry-test PRIVATE cxx_std_20) - add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test) - - add_executable(disruptor-backdrop-tiles-test - tests/unit/disruptor_backdrop_tiles_test.cpp) - target_include_directories(disruptor-backdrop-tiles-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-backdrop-tiles-test PRIVATE cxx_std_20) - add_test(NAME disruptor_backdrop_tiles - COMMAND disruptor-backdrop-tiles-test) - - add_executable(disruptor-gpu-ws-frame-kind-test - tests/unit/gpu_ws_frame_kind_test.cpp) - target_include_directories(disruptor-gpu-ws-frame-kind-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-frame-kind-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_frame_kind - COMMAND disruptor-gpu-ws-frame-kind-test) - - add_executable(disruptor-gpu-ws-menu-test - tests/unit/gpu_ws_menu_test.cpp) - target_include_directories(disruptor-gpu-ws-menu-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-menu-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_menu - COMMAND disruptor-gpu-ws-menu-test) - - add_executable(disruptor-gpu-ws-hud-scale-test - tests/unit/gpu_ws_hud_scale_test.cpp) - target_include_directories(disruptor-gpu-ws-hud-scale-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-hud-scale-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_hud_scale - COMMAND disruptor-gpu-ws-hud-scale-test) - - add_executable(disruptor-gpu-ws-hud-widget-test - tests/unit/gpu_ws_hud_widget_test.cpp) - target_include_directories(disruptor-gpu-ws-hud-widget-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-hud-widget-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_ws_hud_widget - COMMAND disruptor-gpu-ws-hud-widget-test) - - add_executable(disruptor-intro-skip-test - tests/unit/disruptor_intro_skip_test.cpp) - target_include_directories(disruptor-intro-skip-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-intro-skip-test PRIVATE cxx_std_20) - add_test(NAME disruptor_intro_skip - COMMAND disruptor-intro-skip-test) - - add_executable(disruptor-frame-rate-test - tests/unit/disruptor_frame_rate_test.cpp) - target_include_directories(disruptor-frame-rate-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-frame-rate-test PRIVATE cxx_std_20) - add_test(NAME disruptor_frame_rate - COMMAND disruptor-frame-rate-test) - - add_executable(disruptor-language-test - tests/unit/disruptor_language_test.cpp) - target_include_directories(disruptor-language-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-language-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language - COMMAND disruptor-language-test) - - add_executable(disruptor-language-hints-test - tests/unit/disruptor_language_hints_test.cpp) - target_include_directories(disruptor-language-hints-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-language-hints-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_hints - COMMAND disruptor-language-hints-test) - - add_executable(disruptor-language-disc-test - tests/unit/disruptor_language_disc_test.cpp) - target_compile_features(disruptor-language-disc-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_disc - COMMAND disruptor-language-disc-test) - - add_executable(disruptor-language-overlay-test - tests/unit/disruptor_language_overlay_test.cpp) - target_include_directories(disruptor-language-overlay-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" - "${PSXRECOMP_ROOT}/runtime/include" - "${CMAKE_CURRENT_SOURCE_DIR}/src") - target_compile_features(disruptor-language-overlay-test PRIVATE cxx_std_20) - add_test(NAME disruptor_language_overlay - COMMAND disruptor-language-overlay-test) - add_test(NAME disruptor_language_overlay_tall - COMMAND disruptor-language-overlay-test tall) - - add_executable(disruptor-capsule-test - tests/unit/disruptor_capsule_test.cpp) - target_compile_features(disruptor-capsule-test PRIVATE cxx_std_20) - target_include_directories(disruptor-capsule-test PRIVATE src) - add_test(NAME disruptor_capsule - COMMAND disruptor-capsule-test) - - add_executable(host-pick-slot-test - tests/unit/host_pick_slot_test.cpp) - target_compile_features(host-pick-slot-test PRIVATE cxx_std_20) - add_test(NAME host_pick_slot - COMMAND host-pick-slot-test) - - add_executable(host-borderless-test - tests/unit/host_borderless_test.cpp) - target_compile_features(host-borderless-test PRIVATE cxx_std_20) - add_test(NAME host_borderless - COMMAND host-borderless-test) - add_test(NAME host_borderless_contract - COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") - - add_executable(disruptor-restart-test - tests/unit/disruptor_restart_test.cpp) - target_include_directories(disruptor-restart-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-restart-test PRIVATE cxx_std_20) - add_test(NAME disruptor_restart - COMMAND disruptor-restart-test run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") - - add_executable(disruptor-present-rate-test - tests/unit/disruptor_present_rate_test.cpp) - target_include_directories(disruptor-present-rate-test PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") - target_compile_features(disruptor-present-rate-test PRIVATE cxx_std_20) - add_test(NAME disruptor_present_rate - COMMAND disruptor-present-rate-test) - - add_executable(disruptor-grown-quad-test - tests/unit/disruptor_grown_quad_test.cpp) - target_include_directories(disruptor-grown-quad-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-grown-quad-test PRIVATE cxx_std_20) - add_test(NAME disruptor_grown_quad - COMMAND disruptor-grown-quad-test) - - add_executable(disruptor-sprite-depth-test - tests/unit/disruptor_sprite_depth_test.cpp) - target_include_directories(disruptor-sprite-depth-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-sprite-depth-test PRIVATE cxx_std_20) - add_test(NAME disruptor_sprite_depth - COMMAND disruptor-sprite-depth-test) - - add_executable(disruptor-billboard-aspect-test - tests/unit/disruptor_billboard_aspect_test.cpp) - target_include_directories(disruptor-billboard-aspect-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-billboard-aspect-test PRIVATE cxx_std_20) - - add_executable(disruptor-gpu-temporal-sprite-test - tests/unit/gpu_temporal_sprite_test.cpp) - target_include_directories(disruptor-gpu-temporal-sprite-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-sprite-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_sprite - COMMAND disruptor-gpu-temporal-sprite-test) - - add_executable(disruptor-gpu-ws-tag-match-test - tests/unit/gpu_ws_tag_match_test.cpp) - target_include_directories(disruptor-gpu-ws-tag-match-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-ws-tag-match-test PRIVATE cxx_std_20) - - add_executable(disruptor-gpu-temporal-match-test - tests/unit/gpu_temporal_match_test.cpp) - target_include_directories(disruptor-gpu-temporal-match-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-match-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_match - COMMAND disruptor-gpu-temporal-match-test) - - add_executable(disruptor-gpu-temporal-mesh-test - tests/unit/gpu_temporal_mesh_test.cpp) - target_include_directories(disruptor-gpu-temporal-mesh-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-temporal-mesh-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_temporal_mesh - COMMAND disruptor-gpu-temporal-mesh-test) - - add_executable(disruptor-gpu-pinned-corner-test - tests/unit/gpu_pinned_corner_test.cpp) - target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_pinned_corner - COMMAND disruptor-gpu-pinned-corner-test) - - add_executable(disruptor-gpu-turned-perspective-test - tests/unit/gpu_turned_perspective_test.cpp) - target_include_directories(disruptor-gpu-turned-perspective-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-turned-perspective-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_turned_perspective - COMMAND disruptor-gpu-turned-perspective-test) - - add_executable(disruptor-gpu-world-depth-test - tests/unit/gpu_world_depth_test.cpp) - target_include_directories(disruptor-gpu-world-depth-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-gpu-world-depth-test PRIVATE cxx_std_20) - add_test(NAME disruptor_gpu_world_depth - COMMAND disruptor-gpu-world-depth-test) - - add_executable(disruptor-save-point-test - tests/unit/save_point_test.cpp) - target_include_directories(disruptor-save-point-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_features(disruptor-save-point-test PRIVATE cxx_std_20) - add_test(NAME disruptor_save_point - COMMAND disruptor-save-point-test) - - add_executable(disruptor-presentation-yaw-math-test - tests/unit/presentation_yaw_math_test.cpp) - target_compile_features(disruptor-presentation-yaw-math-test PRIVATE cxx_std_20) - - add_executable(disruptor-gte-precision-store-pc-test - tests/unit/gte_precision_store_pc_test.cpp - "${PSXRECOMP_ROOT}/runtime/src/gte.cpp") - target_include_directories(disruptor-gte-precision-store-pc-test PRIVATE - "${PSXRECOMP_ROOT}/runtime/include") - target_compile_definitions(disruptor-gte-precision-store-pc-test PRIVATE - PSX_NO_DEBUG_TOOLS=1 - PSX_GTE_REGISTER_TEST=1 - PSX_ENABLE_BLOCK_CYCLES=1) - target_compile_features(disruptor-gte-precision-store-pc-test PRIVATE - cxx_std_20) - - add_test(NAME disruptor_mouse_aim - COMMAND disruptor-mouse-aim-test) - add_test(NAME disruptor_cheats - COMMAND disruptor-cheats-test) - add_test(NAME disruptor_vertical_camera - COMMAND disruptor-vertical-camera-test) - add_test(NAME disruptor_far_rendering - COMMAND disruptor-far-rendering-test) - add_test(NAME disruptor_billboard_aspect - COMMAND disruptor-billboard-aspect-test) - add_test(NAME disruptor_gpu_ws_tag_match - COMMAND disruptor-gpu-ws-tag-match-test) + disruptor_unit_test(disruptor_user_settings_persistence tests/unit/user_settings_persistence_test.cpp + TARGET disruptor-user-settings-test + SOURCES + "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" + "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp" + INCLUDES + "${PSXRECOMP_ROOT}/recompiler/src" + "${PSXRECOMP_ROOT}/recompiler/include" + "${PSXRECOMP_ROOT}/recompiler/lib/fmt/include" + "${PSXRECOMP_ROOT}/recompiler/lib/toml11" + DEFINITIONS + FMT_HEADER_ONLY=1 + "$<$:NOMINMAX>" + "$<$:WIN32_LEAN_AND_MEAN>") + + disruptor_unit_test(disruptor_mouse_aim tests/unit/disruptor_mouse_aim_test.cpp + INCLUDES tests/support/stubs + DEFINITIONS PSX_SDL3=1) set_tests_properties(disruptor_mouse_aim PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=") - add_test(NAME disruptor_presentation_yaw_math - COMMAND disruptor-presentation-yaw-math-test) - - add_test(NAME disruptor_gte_precision_store_pc - COMMAND disruptor-gte-precision-store-pc-test) - add_test(NAME disruptor_mouse_aim_registration COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_mouse_aim_registration PROPERTIES @@ -658,4 +261,54 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_high_precision_camera_registration PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=1") + + disruptor_unit_test(disruptor_cheats tests/unit/disruptor_cheats_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_vertical_camera tests/unit/disruptor_vertical_camera_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_far_rendering tests/unit/disruptor_far_rendering_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_render_arena tests/unit/disruptor_render_arena_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_name_entry tests/unit/disruptor_name_entry_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_backdrop_tiles tests/unit/disruptor_backdrop_tiles_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_intro_skip tests/unit/disruptor_intro_skip_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_frame_rate tests/unit/disruptor_frame_rate_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language tests/unit/disruptor_language_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language_hints tests/unit/disruptor_language_hints_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_language_disc tests/unit/disruptor_language_disc_test.cpp) + disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp + INCLUDES + "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" + "${PSXRECOMP_ROOT}/runtime/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME disruptor_language_overlay_tall + COMMAND disruptor-language-overlay-test tall) + disruptor_unit_test(disruptor_capsule tests/unit/disruptor_capsule_test.cpp INCLUDES src) + disruptor_unit_test(disruptor_restart tests/unit/disruptor_restart_test.cpp RUNTIME_HEADERS + ARGUMENTS run "${CMAKE_CURRENT_BINARY_DIR}/disruptor_restart_test.txt") + disruptor_unit_test(disruptor_present_rate tests/unit/disruptor_present_rate_test.cpp + INCLUDES "${CMAKE_CURRENT_SOURCE_DIR}/tests/support/present_rate_fakes") + disruptor_unit_test(disruptor_grown_quad tests/unit/disruptor_grown_quad_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_sprite_depth tests/unit/disruptor_sprite_depth_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_billboard_aspect tests/unit/disruptor_billboard_aspect_test.cpp RUNTIME_HEADERS) + + disruptor_unit_test(disruptor_gpu_ws_frame_kind tests/unit/gpu_ws_frame_kind_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_menu tests/unit/gpu_ws_menu_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_hud_scale tests/unit/gpu_ws_hud_scale_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_hud_widget tests/unit/gpu_ws_hud_widget_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_ws_tag_match tests/unit/gpu_ws_tag_match_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_sprite tests/unit/gpu_temporal_sprite_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_match tests/unit/gpu_temporal_match_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_temporal_mesh tests/unit/gpu_temporal_mesh_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_turned_perspective tests/unit/gpu_turned_perspective_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_gpu_world_depth tests/unit/gpu_world_depth_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_save_point tests/unit/save_point_test.cpp RUNTIME_HEADERS) + disruptor_unit_test(disruptor_presentation_yaw_math tests/unit/presentation_yaw_math_test.cpp) + disruptor_unit_test(disruptor_gte_precision_store_pc tests/unit/gte_precision_store_pc_test.cpp RUNTIME_HEADERS + SOURCES "${PSXRECOMP_ROOT}/runtime/src/gte.cpp" + DEFINITIONS PSX_NO_DEBUG_TOOLS=1 PSX_GTE_REGISTER_TEST=1 PSX_ENABLE_BLOCK_CYCLES=1) + + disruptor_unit_test(host_pick_slot tests/unit/host_pick_slot_test.cpp) + disruptor_unit_test(host_borderless tests/unit/host_borderless_test.cpp) + add_test(NAME host_borderless_contract + COMMAND "${Python3_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py") endif() diff --git a/cmake/DisruptorTests.cmake b/cmake/DisruptorTests.cmake new file mode 100644 index 0000000..bd5391a --- /dev/null +++ b/cmake/DisruptorTests.cmake @@ -0,0 +1,56 @@ +# disruptor_contract( [RETAIL]) +# +# Registers tests/contracts/test__contract.py as the test disruptor__contract. +# RETAIL: the contract needs the retail image or the translated code. It is passed +# --require-artifacts and labelled so that `ctest -LE retail` leaves it out. +function(disruptor_contract name) + cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") + if(CONTRACT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_contract(${name}): unknown arguments ${CONTRACT_UNPARSED_ARGUMENTS}") + endif() + set(script "${PROJECT_SOURCE_DIR}/tests/contracts/test_${name}_contract.py") + if(NOT EXISTS "${script}") + message(FATAL_ERROR "disruptor_contract(${name}): ${script} does not exist") + endif() + set(arguments "") + if(CONTRACT_RETAIL) + set(arguments --require-artifacts) + endif() + add_test(NAME disruptor_${name}_contract + COMMAND "${Python3_EXECUTABLE}" -B "${script}" ${arguments}) + if(CONTRACT_RETAIL) + set_tests_properties(disruptor_${name}_contract PROPERTIES LABELS retail) + endif() +endfunction() + +# disruptor_unit_test( [TARGET ] [RUNTIME_HEADERS] +# [SOURCES ...] [INCLUDES ...] [DEFINITIONS ...] +# [ARGUMENTS ...]) +# +# Builds one C++20 executable from , a path from the project's root, and registers +# it as the test . The executable is named after the test, disruptor_far_rendering +# giving disruptor-far-rendering-test, unless TARGET names it. +# RUNTIME_HEADERS puts the framework's runtime headers on its include path. +function(disruptor_unit_test test source) + cmake_parse_arguments(PARSE_ARGV 2 UNIT "RUNTIME_HEADERS" "TARGET" + "SOURCES;INCLUDES;DEFINITIONS;ARGUMENTS") + if(UNIT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_unit_test(${test}): unknown arguments ${UNIT_UNPARSED_ARGUMENTS}") + endif() + set(target "${UNIT_TARGET}") + if(NOT target) + string(REPLACE "_" "-" target "${test}-test") + endif() + add_executable(${target} "${PROJECT_SOURCE_DIR}/${source}" ${UNIT_SOURCES}) + if(UNIT_RUNTIME_HEADERS) + target_include_directories(${target} PRIVATE "${PSXRECOMP_ROOT}/runtime/include") + endif() + if(UNIT_INCLUDES) + target_include_directories(${target} PRIVATE ${UNIT_INCLUDES}) + endif() + if(UNIT_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${UNIT_DEFINITIONS}) + endif() + target_compile_features(${target} PRIVATE cxx_std_20) + add_test(NAME ${test} COMMAND ${target} ${UNIT_ARGUMENTS}) +endfunction() diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index ca1999b..1dcbe82 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -27,7 +27,7 @@ def between(source: str, opening: str, closing: str) -> str: loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") -require(" add_test(NAME disruptor_ambient_occlusion_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(ambient_occlusion)\n" in cmake, "this contract is registered") require( '" v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\\n"' in renderer diff --git a/tests/contracts/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py index 565765d..a011091 100644 --- a/tests/contracts/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -62,12 +62,12 @@ def require(condition: bool, message: str) -> None: require( "src/game/render/backdrop_tiles.cpp" in cmake - and "disruptor_backdrop_tiles_contract" in cmake - and "add_test(NAME disruptor_backdrop_tiles\n" in cmake, + and " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_backdrop_tiles " in cmake, "backdrop tile source, its unit test and its contract test must remain registered", ) require( - 'tests/contracts/test_backdrop_tiles_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) require( diff --git a/tests/contracts/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py index ef8bed5..e2202fa 100644 --- a/tests/contracts/test_borderless_contract.py +++ b/tests/contracts/test_borderless_contract.py @@ -60,8 +60,9 @@ def require(condition: bool, message: str) -> None: ) require( "+runtime/include/host_borderless.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() - and " add_test(NAME host_borderless\n COMMAND host-borderless-test)\n" in cmake - and 'tests/contracts/test_borderless_contract.py")' in cmake, + and " disruptor_unit_test(host_borderless " in cmake + and " add_test(NAME host_borderless_contract\n" in cmake + and '/tests/contracts/test_borderless_contract.py")' in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index 73d28ed..63e2b09 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -132,8 +132,8 @@ def require(condition: bool, message: str) -> None: and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' in read("tests/unit/disruptor_capsule_test.cpp") - and " add_test(NAME disruptor_capsule\n COMMAND disruptor-capsule-test)\n" in cmake - and 'tests/contracts/test_capsule_contract.py")' in cmake, + and " disruptor_unit_test(disruptor_capsule " in cmake + and " disruptor_contract(capsule)\n" in cmake, "the capsule and its tests must remain built and registered", ) require( diff --git a/tests/contracts/test_cheats_contract.py b/tests/contracts/test_cheats_contract.py index f7019b4..7723aff 100644 --- a/tests/contracts/test_cheats_contract.py +++ b/tests/contracts/test_cheats_contract.py @@ -29,7 +29,7 @@ def require(condition: bool, message: str) -> None: wide = read("game-widescreen.toml") require("disruptor_cheats_test.cpp" in cmake and - "add_test(NAME disruptor_cheats" in cmake, + " disruptor_unit_test(disruptor_cheats " in cmake, "behavioral cheat test must remain registered") for config, name in ((game, "game.toml"), (wide, "game-widescreen.toml")): entry_match = re.search( diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 521a958..d097fe5 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -26,7 +26,7 @@ def between(source: str, opening: str, closing: str) -> str: loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") -require(" add_test(NAME disruptor_debug_tab_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(debug_tab)\n" in cmake, "this contract is registered") debug = between(menu, "void draw_debug_tab() {", "void draw_cheats_tab() {") require( diff --git a/tests/contracts/test_depth24_sync_contract.py b/tests/contracts/test_depth24_sync_contract.py index f33e4a5..69d0d20 100644 --- a/tests/contracts/test_depth24_sync_contract.py +++ b/tests/contracts/test_depth24_sync_contract.py @@ -38,10 +38,7 @@ def enclosing(source: str, block: str) -> list[str]: gpu = read("psxrecomp-overlay/runtime/src/gpu.c") renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -REGISTRATION = ( - ' add_test(NAME disruptor_depth24_sync_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_depth24_sync_contract.py")\n' -) +REGISTRATION = " disruptor_contract(depth24_sync)\n" require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], "this contract must stay registered as written, under the condition every contract of the repository is under", diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index 013e9a4..78dae0a 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -30,7 +30,7 @@ def require(condition: bool, message: str) -> None: require("src/game/render/far_rendering.cpp" in cmake and "tests/unit/disruptor_far_rendering_test.cpp" in cmake and - "add_test(NAME disruptor_far_rendering" in cmake, + " disruptor_unit_test(disruptor_far_rendering " in cmake, "runtime source and focused behavior test must remain enabled") require("PSX_HAS_DISRUPTOR_FAR_RENDERING=1" in cmake, "the Disruptor target must opt into dirty-interpreter far hooks") diff --git a/tests/contracts/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py index c7fd7e1..01475e3 100644 --- a/tests/contracts/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -48,12 +48,9 @@ def enclosing(source: str, block: str) -> list[str]: UNIT_TESTS = ["if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)"] REGISTERED = { ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp"\n': [], - ' add_test(NAME disruptor_frame_kind_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_frame_kind_contract.py")\n': CONTRACTS, - " add_executable(disruptor-gpu-ws-frame-kind-test\n tests/unit/gpu_ws_frame_kind_test.cpp)\n": UNIT_TESTS, - " add_test(NAME disruptor_gpu_ws_frame_kind\n COMMAND disruptor-gpu-ws-frame-kind-test)\n": UNIT_TESTS, - " add_executable(disruptor-render-arena-test\n tests/unit/disruptor_render_arena_test.cpp)\n": UNIT_TESTS, - " add_test(NAME disruptor_render_arena COMMAND disruptor-render-arena-test)\n": UNIT_TESTS, + " disruptor_contract(frame_kind)\n": CONTRACTS, + " disruptor_unit_test(disruptor_gpu_ws_frame_kind tests/unit/gpu_ws_frame_kind_test.cpp RUNTIME_HEADERS)\n": UNIT_TESTS, + " disruptor_unit_test(disruptor_render_arena tests/unit/disruptor_render_arena_test.cpp RUNTIME_HEADERS)\n": UNIT_TESTS, } for block, conditions in REGISTERED.items(): require( diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index fe4af39..1d63952 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -51,13 +51,13 @@ def require(condition: bool, message: str) -> None: cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") require( - 'tests/contracts/test_frame_rate_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(frame_rate RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) require( "src/game/frame/frame_rate.cpp" in cmake - and "disruptor_frame_rate_contract" in cmake - and "add_test(NAME disruptor_frame_rate\n" in cmake, + and " disruptor_contract(frame_rate RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_frame_rate " in cmake, "frame-rate source, its unit test and its contract test must remain registered", ) @@ -166,7 +166,7 @@ def require(condition: bool, message: str) -> None: renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") host = read("psxrecomp-overlay/runtime/src/main.cpp") require( - "add_test(NAME disruptor_present_rate\n" in cmake + " disruptor_unit_test(disruptor_present_rate " in cmake and "constexpr int kDisruptorPresentRateLowest = 30;" in present_rate and "constexpr int kDisruptorPresentRateLeastTop = 120;" in present_rate, "the in-between frame rate control must run from 30 FPS to at least 120 and keep its unit test", diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index b4e4aff..5d61efb 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -26,7 +26,7 @@ def between(source: str, opening: str, closing: str) -> str: launcher = read("src/launcher/launcher_settings.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -require(" add_test(NAME disruptor_gap_filling_contract\n" in cmake, "this contract is registered") +require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") require( " const int filler = s_grown_under && exact && s_next_world == 2;\n" in renderer diff --git a/tests/contracts/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py index 2c9fb38..d2f59d8 100644 --- a/tests/contracts/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -42,8 +42,8 @@ def require(condition: bool, message: str) -> None: require( '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake - and "add_test(NAME disruptor_grown_quad\n" in cmake - and 'tests/contracts/test_grown_quad_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_unit_test(disruptor_grown_quad " in cmake + and " disruptor_contract(grown_quad RETAIL)\n" in cmake, "the grown quad source, its renderer switch, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 2dbc67c..ab2d45c 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -28,7 +28,7 @@ def require(condition: bool, message: str) -> None: "overlay manifest must list the HUD scale arithmetic", ) require( - "add_test(NAME disruptor_gpu_ws_hud_scale\n" in cmake and "disruptor_hud_scale_contract" in cmake, + " disruptor_unit_test(disruptor_gpu_ws_hud_scale " in cmake and " disruptor_contract(hud_scale)\n" in cmake, "the HUD scale unit test and its contract must remain registered", ) require( diff --git a/tests/contracts/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py index 178a42e..9bacf9e 100644 --- a/tests/contracts/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -31,10 +31,8 @@ def body(source: str, signature: str) -> str: "overlay manifest must list the HUD widget arithmetic", ) require( - " add_executable(disruptor-gpu-ws-hud-widget-test\n tests/unit/gpu_ws_hud_widget_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_ws_hud_widget\n COMMAND disruptor-gpu-ws-hud-widget-test)\n" in cmake - and ' add_test(NAME disruptor_hud_widget_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_hud_widget_contract.py")\n' in cmake + " disruptor_unit_test(disruptor_gpu_ws_hud_widget tests/unit/gpu_ws_hud_widget_test.cpp RUNTIME_HEADERS)\n" in cmake + and " disruptor_contract(hud_widget)\n" in cmake and '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp"' in cmake, "the widget unit test, its contract and the game's widget list must remain in the build", ) diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index f1b839a..d66bca2 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -56,13 +56,13 @@ def require(condition: bool, message: str) -> None: require( "src/game/session/intro_skip.cpp" in cmake and "PSX_HAS_DISRUPTOR_INTRO_SKIP=1" in cmake - and "disruptor_intro_skip_contract" in cmake - and "add_test(NAME disruptor_intro_skip\n" in cmake, + and " disruptor_contract(intro_skip RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_intro_skip " in cmake, "intro-skip source, its interpreter switch and both tests must remain registered", ) require( - 'tests/contracts/test_intro_skip_contract.py"\n --require-artifacts)' in cmake, + " disruptor_contract(intro_skip RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) for address, word in SEAMS.items(): diff --git a/tests/contracts/test_language_contract.py b/tests/contracts/test_language_contract.py index a99d38a..0baa532 100644 --- a/tests/contracts/test_language_contract.py +++ b/tests/contracts/test_language_contract.py @@ -94,8 +94,8 @@ def require(condition: bool, message: str) -> None: require( '/src/game/language/language.cpp"' in cmake - and "add_test(NAME disruptor_language\n" in cmake - and 'tests/contracts/test_language_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_unit_test(disruptor_language " in cmake + and " disruptor_contract(language RETAIL)\n" in cmake, "the language source and both of its tests must remain registered, the contract with its inputs required", ) require( @@ -193,7 +193,7 @@ def require(condition: bool, message: str) -> None: ) require( '/src/game/language/language_hints.cpp"' in cmake - and "add_test(NAME disruptor_language_hints\n" in cmake + and " disruptor_unit_test(disruptor_language_hints " in cmake and "constexpr uint32_t kRead = 0x80011888u, kLoadImage = 0x8004D348u, kMoveImage = 0x8004D410u;" in hints and "constexpr uint32_t kReadReturn = 0x80020928u, kStageReturn = 0x80020954u;" in hints and "constexpr std::array kMoves = {{{0x80043508u, 0xA0u}, {0x80043518u, 0x190u}}};" in hints diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index ea70e19..581ff28 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -82,11 +82,10 @@ def require(condition: bool, message: str) -> None: require( '/src/game/language/language_disc.cpp"' in cmake and '/src/game/language/language_source.cpp"' in cmake - and "add_test(NAME disruptor_language_disc\n" in cmake - and "add_test(NAME disruptor_language_overlay\n" in cmake + and " disruptor_unit_test(disruptor_language_disc " in cmake + and " disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp\n" in cmake and "add_test(NAME disruptor_language_overlay_tall\n COMMAND disruptor-language-overlay-test tall)" in cmake - and " tests/unit/disruptor_language_overlay_test.cpp)" in cmake - and 'tests/contracts/test_language_disc_contract.py"\n --require-artifacts)' in cmake, + and " disruptor_contract(language_disc RETAIL)\n" in cmake, "the language disc sources and their three tests must remain registered, the contract with its inputs required", ) require( @@ -343,7 +342,7 @@ def require(condition: bool, message: str) -> None: and " return s_disc_pick.take(out, size);" in host and " if (state_ != State::kNone) return false;\n state_ = State::kOpen;" in pick_slot and " if (state_ != State::kClosed) return 0;\n state_ = State::kNone;" in pick_slot - and "add_test(NAME host_pick_slot\n" in cmake + and " disruptor_unit_test(host_pick_slot " in cmake and "CLSID_FileOpenDialog" in host and "FOS_NOCHANGEDIR" in host and "GetOpenFileName" not in host.replace("IFileOpenDialog, not GetOpenFileName:", "") @@ -354,7 +353,7 @@ def require(condition: bool, message: str) -> None: and ' if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) {' in menu and " quit.type = SDL_QUIT;\n SDL_PushEvent(&quit);" in menu and '/src/game/session/restart.cpp"' in cmake - and "add_test(NAME disruptor_restart\n" in cmake + and " disruptor_unit_test(disruptor_restart " in cmake and " if (!before) return GetLastError() == ERROR_INVALID_PARAMETER;" in restart and " return waited == WAIT_OBJECT_0;" in restart and " if (!earlier_game_ended(std::strtoul(earlier, nullptr, 10), kLongestWait)) {" in restart diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index c9d187d..6f2fc11 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -155,7 +155,7 @@ def plain_values(text: str) -> dict[str, str]: "add_test(NAME disruptor_launcher_settings COMMAND disruptor-launcher-settings-test)" in launcher_cmake and "add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test)" in launcher_cmake and "add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test)" in launcher_cmake - and "add_test(NAME disruptor_launcher_contract" in cmake, + and " disruptor_contract(launcher)\n" in cmake, "the launcher's tests must remain registered", ) diff --git a/tests/contracts/test_menu_wide_contract.py b/tests/contracts/test_menu_wide_contract.py index 31aa9ed..33bd4dd 100644 --- a/tests/contracts/test_menu_wide_contract.py +++ b/tests/contracts/test_menu_wide_contract.py @@ -194,8 +194,8 @@ def require(condition: bool, message: str) -> None: ) require( "+runtime/include/gpu_ws_menu.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() - and " add_test(NAME disruptor_gpu_ws_menu\n COMMAND disruptor-gpu-ws-menu-test)\n" in cmake - and 'tests/contracts/test_menu_wide_contract.py")' in cmake, + and " disruptor_unit_test(disruptor_gpu_ws_menu " in cmake + and " disruptor_contract(menu_wide)\n" in cmake, "the header is part of the overlay and its tests remain registered", ) diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index 3f68dc6..ccb38e7 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -99,10 +99,8 @@ def require(condition: bool, message: str) -> None: require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake - and " add_test(NAME disruptor_name_entry COMMAND disruptor-name-entry-test)\n" in cmake - and 'tests/contracts/test_name_entry_contract.py"\n --require-artifacts)\n' - " set_tests_properties(disruptor_name_entry_contract\n PROPERTIES LABELS retail)\n" - in cmake, + and " disruptor_unit_test(disruptor_name_entry " in cmake + and " disruptor_contract(name_entry RETAIL)\n" in cmake, "the module, its switch for the host, its unit test and this contract with its input must stay registered", ) require( diff --git a/tests/contracts/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py index 0fa9432..8defb02 100644 --- a/tests/contracts/test_pinned_corner_contract.py +++ b/tests/contracts/test_pinned_corner_contract.py @@ -50,17 +50,8 @@ def enclosing(source: str, block: str) -> list[str]: temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") mesh = read("psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h") -REGISTRATION = ( - ' add_test(NAME disruptor_pinned_corner_contract\n COMMAND "${Python3_EXECUTABLE}" -B\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_pinned_corner_contract.py")\n' -) -UNIT_TEST = ( - " add_executable(disruptor-gpu-pinned-corner-test\n tests/unit/gpu_pinned_corner_test.cpp)\n" - " target_include_directories(disruptor-gpu-pinned-corner-test PRIVATE\n" - ' "${PSXRECOMP_ROOT}/runtime/include")\n' - " target_compile_features(disruptor-gpu-pinned-corner-test PRIVATE cxx_std_20)\n" - " add_test(NAME disruptor_gpu_pinned_corner\n COMMAND disruptor-gpu-pinned-corner-test)\n" -) +REGISTRATION = " disruptor_contract(pinned_corner)\n" +UNIT_TEST = " disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS)\n" require( cmake.count(REGISTRATION) == 1 and enclosing(cmake, REGISTRATION) == ["if(BUILD_TESTING)"], "this contract must stay registered as written, under the condition every contract of the repository is under", diff --git a/tests/contracts/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py index fdaff40..a7cc45d 100644 --- a/tests/contracts/test_save_point_contract.py +++ b/tests/contracts/test_save_point_contract.py @@ -37,8 +37,8 @@ def polls_are_the_four(source: str) -> bool: rule = read("psxrecomp-overlay/runtime/include/save_point.h") require( - " add_test(NAME disruptor_save_point_contract\n" in cmake - and " add_test(NAME disruptor_save_point\n COMMAND disruptor-save-point-test)\n" in cmake, + " disruptor_contract(save_point)\n" in cmake + and " disruptor_unit_test(disruptor_save_point " in cmake, "this contract and the rule's unit test are registered", ) require( diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 08106be..fcae32e 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -31,9 +31,9 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 and '#include "gpu_turned_perspective.h"\n' in renderer - and " add_executable(disruptor-gpu-turned-perspective-test\n tests/unit/gpu_turned_perspective_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_turned_perspective\n COMMAND disruptor-gpu-turned-perspective-test)\n" in cmake - and " add_test(NAME disruptor_smooth_turn_contract\n" in cmake, + and " disruptor_unit_test(disruptor_gpu_turned_perspective tests/unit/gpu_turned_perspective_test.cpp RUNTIME_HEADERS)\n" + in cmake + and " disruptor_contract(smooth_turn)\n" in cmake, "the turned perspective's header is applied to the framework and its test and this contract are registered", ) diff --git a/tests/contracts/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py index 2fe9737..997114e 100644 --- a/tests/contracts/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -187,9 +187,8 @@ def require(condition: bool, message: str) -> None: gte = read("psxrecomp-overlay/runtime/src/gte.cpp") require( - 'tests/contracts/test_sprite_range_contract.py"\n --require-artifacts)' in cmake - and " set_tests_properties(disruptor_sprite_range_contract\n PROPERTIES LABELS retail)\n" in cmake - and " add_test(NAME disruptor_sprite_depth\n" in cmake, + " disruptor_contract(sprite_range RETAIL)\n" in cmake + and " disruptor_unit_test(disruptor_sprite_depth " in cmake, "this contract with its input and the unit test of the range must stay registered", ) require( diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index db27fa2..9f77a83 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -42,7 +42,7 @@ def shader(source: str, name: str) -> str: billboards = read("src/game/render/billboard_aspect.cpp") require( - "disruptor_temporal_presenter_contract" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, + " disruptor_contract(temporal_presenter)\n" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, "the presenter contract and the sprite-depth interpreter switch must remain registered", ) diff --git a/tests/contracts/test_test_registration_contract.py b/tests/contracts/test_test_registration_contract.py new file mode 100644 index 0000000..3687686 --- /dev/null +++ b/tests/contracts/test_test_registration_contract.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Source contract: every contract and every unit test in the tree is registered once, through the two helper functions. + +A contract or a test file that nothing registers is never run, and nothing else would say so. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +ARTIFACTS = "--require-" + "artifacts" +EXPLICIT = { + "test_borderless_contract.py": "host_borderless_contract", + "test_framework_overlay_new_file.py": "disruptor_framework_overlay_new_file", +} +CONTRACT_HELPER = """function(disruptor_contract name) + cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") + if(CONTRACT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_contract(${name}): unknown arguments ${CONTRACT_UNPARSED_ARGUMENTS}") + endif() + set(script "${PROJECT_SOURCE_DIR}/tests/contracts/test_${name}_contract.py") + if(NOT EXISTS "${script}") + message(FATAL_ERROR "disruptor_contract(${name}): ${script} does not exist") + endif() + set(arguments "") + if(CONTRACT_RETAIL) + set(arguments --require-artifacts) + endif() + add_test(NAME disruptor_${name}_contract + COMMAND "${Python3_EXECUTABLE}" -B "${script}" ${arguments}) + if(CONTRACT_RETAIL) + set_tests_properties(disruptor_${name}_contract PROPERTIES LABELS retail) + endif() +endfunction() +""" +UNIT_HELPER = """function(disruptor_unit_test test source) + cmake_parse_arguments(PARSE_ARGV 2 UNIT "RUNTIME_HEADERS" "TARGET" + "SOURCES;INCLUDES;DEFINITIONS;ARGUMENTS") + if(UNIT_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "disruptor_unit_test(${test}): unknown arguments ${UNIT_UNPARSED_ARGUMENTS}") + endif() + set(target "${UNIT_TARGET}") + if(NOT target) + string(REPLACE "_" "-" target "${test}-test") + endif() + add_executable(${target} "${PROJECT_SOURCE_DIR}/${source}" ${UNIT_SOURCES}) + if(UNIT_RUNTIME_HEADERS) + target_include_directories(${target} PRIVATE "${PSXRECOMP_ROOT}/runtime/include") + endif() + if(UNIT_INCLUDES) + target_include_directories(${target} PRIVATE ${UNIT_INCLUDES}) + endif() + if(UNIT_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${UNIT_DEFINITIONS}) + endif() + target_compile_features(${target} PRIVATE cxx_std_20) + add_test(NAME ${test} COMMAND ${target} ${UNIT_ARGUMENTS}) +endfunction() +""" + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def contract_faults(cmake: str, scripts: dict[str, bool]) -> list[str]: + """What is wrong with how the build file registers these contract files, each given with whether it takes artifacts.""" + faults = [] + for script, retail in sorted(scripts.items()): + if script in EXPLICIT: + names = cmake.count(f" add_test(NAME {EXPLICIT[script]}\n") + runs = cmake.count(f"/tests/contracts/{script}") + if names != 1 or runs != 1: + faults.append(f"{script} is named {names} times and run {runs} times, not once each") + continue + name = re.fullmatch(r"test_(\w+)_contract\.py", script) + if name is None: + faults.append(f"{script} is not named as a contract") + continue + plain = cmake.count(f" disruptor_contract({name.group(1)})\n") + marked = cmake.count(f" disruptor_contract({name.group(1)} RETAIL)\n") + if (plain, marked) != ((0, 1) if retail else (1, 0)): + faults.append(f"{script} is registered {plain} times plainly and {marked} times as retail") + if f"/tests/contracts/{script}" in cmake: + faults.append(f"{script} is also run by name") + registered = re.findall(r"^\s*disruptor_contract\((\w+)", cmake, re.MULTILINE) + faults += [ + f"{name} is registered and has no file" for name in registered if f"test_{name}_contract.py" not in scripts + ] + return faults + + +def unit_faults(builds: str, cmake: str, sources: list[str]) -> list[str]: + """What is wrong with how the build files register these unit test files.""" + faults = [f"{source} is built {builds.count(source)} times" for source in sources if builds.count(source) != 1] + tail = cmake.split("include(CTest)")[1] + units = re.findall(r"^\s*disruptor_unit_test\((\w+) (tests/unit/\w+\.cpp)", tail, re.MULTILINE) + tests = [test for test, _ in units] + faults += [f"{test} is registered twice" for test in sorted(set(tests)) if tests.count(test) > 1] + faults += [f"{source} has no file" for _, source in units if source not in sources] + if "add_executable(" in tail: + faults.append("an executable is built past the helper") + return faults + + +cmake = read("CMakeLists.txt") +launcher_cmake = read("src/launcher/CMakeLists.txt") +helpers = read("cmake/DisruptorTests.cmake") + +require( + ' include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake")\n' in cmake + and cmake.index("find_package(Python3 REQUIRED COMPONENTS Interpreter)") < cmake.index("cmake/DisruptorTests.cmake") + and cmake.index("cmake/DisruptorTests.cmake") < cmake.index("disruptor_contract("), + "the helpers are loaded after Python is found and before the first registration", +) +require( + helpers.count(CONTRACT_HELPER) == 1 and helpers.count("\nfunction(") == 2, + "a contract runs its own file, and a retail one is told to require its artifacts and is labelled retail", +) +require( + helpers.count(UNIT_HELPER) == 1, + "a unit test is one executable named after its test, built with what it was given and always run under that name", +) + +scripts = { + path.name: ARTIFACTS in path.read_text(encoding="utf-8") + for path in (ROOT / "tests/contracts").glob("test_*.py") + if path.name != Path(__file__).name +} +scripts[Path(__file__).name] = False +require(contract_faults(cmake, scripts) == [], "; ".join(contract_faults(cmake, scripts))) +require(sum(scripts.values()) == 8, "eight contracts take the retail artifacts") + +sources = sorted(f"tests/unit/{path.name}" for path in (ROOT / "tests/unit").glob("*.cpp")) +require( + unit_faults(cmake + launcher_cmake, cmake, sources) == [], + "; ".join(unit_faults(cmake + launcher_cmake, cmake, sources)), +) + +plain_line, retail_line = " disruptor_contract(depth24_sync)\n", " disruptor_contract(name_entry RETAIL)\n" +again = ' add_test(NAME host_borderless_again\n COMMAND x "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_borderless_contract.py")\n' +unit_line = " disruptor_unit_test(host_pick_slot tests/unit/host_pick_slot_test.cpp)\n" +require( + cmake.count(plain_line) == cmake.count(retail_line) == cmake.count(unit_line) == 1 + and contract_faults(cmake.replace(plain_line, ""), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line * 2), scripts) != [] + and contract_faults(cmake.replace(plain_line, " disruptor_contract(depth24_sync RETAIL)\n"), scripts) != [] + and contract_faults(cmake.replace(retail_line, " disruptor_contract(name_entry)\n"), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line + " disruptor_contract(no_such)\n"), scripts) != [] + and contract_faults(cmake.replace(plain_line, plain_line + again), scripts) != [] + and contract_faults(cmake, scripts | {"test_new_contract.py": False}) != [] + and unit_faults((cmake + launcher_cmake).replace(unit_line, ""), cmake.replace(unit_line, ""), sources) != [] + and unit_faults(cmake + launcher_cmake + unit_line, cmake + unit_line, sources) != [] + and unit_faults(cmake + launcher_cmake, cmake, [*sources, "tests/unit/new_test.cpp"]) != [] + and unit_faults(cmake + launcher_cmake, cmake + " add_executable(extra x.cpp)\n", sources) != [], + "a missing, doubled, mislabelled or fileless registration is seen, in the contracts and in the unit tests", +) + +print("Disruptor test registration source contract: PASS") diff --git a/tests/contracts/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py index c5b8f0c..66a3e8a 100644 --- a/tests/contracts/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -84,7 +84,7 @@ def require(condition: bool, message: str) -> None: require("gpu_geometry_camera_projection_center_y_set" in camera and "gpu_geometry_camera_projection_center_y_set" in gpu, "guest and presentation horizons must share one renderer setter") -require("disruptor-vertical-camera-test" in cmake and +require(" disruptor_unit_test(disruptor_vertical_camera " in cmake and "disruptor_vertical_look_registration" in cmake, "behavior and vertical-only registration tests must remain enabled") require("disruptor_vertical_camera_instruction_hook" in camera_h, diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index ab6359c..5dd1bf0 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -33,9 +33,8 @@ def between(source: str, opening: str, closing: str) -> str: require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 and '#include "gpu_world_depth.h"\n' in renderer - and " add_executable(disruptor-gpu-world-depth-test\n tests/unit/gpu_world_depth_test.cpp)\n" in cmake - and " add_test(NAME disruptor_gpu_world_depth\n COMMAND disruptor-gpu-world-depth-test)\n" in cmake - and " add_test(NAME disruptor_world_depth_contract\n" in cmake, + and " disruptor_unit_test(disruptor_gpu_world_depth tests/unit/gpu_world_depth_test.cpp RUNTIME_HEADERS)\n" in cmake + and " disruptor_contract(world_depth)\n" in cmake, "the rule's header is applied to the framework and its test and this contract are registered", ) From 3f862d3390c663c1f18fdd973d94cc903cca26c3 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 01:46:25 +0300 Subject: [PATCH 06/21] docs: describe the layout in the build guide --- docs/BUILD.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/BUILD.md b/docs/BUILD.md index 81f216f..b1211b1 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -129,6 +129,27 @@ files are listed in `PSXRECOMP_OVERLAY_FILES.txt`. Runtime preferences live in `settings.toml` beside the executable; game files, generated code, and build directories are ignored by Git. +## Layout + +| Folder | Holds | +| --- | --- | +| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `ui` | +| `src/launcher/` | The Windows launcher | +| `psxrecomp-overlay/` | The framework files this project replaces or adds | +| `tests/unit/` | Unit tests, one executable each | +| `tests/contracts/` | Source contracts, run by Python | +| `tests/support/` | Stubs and fakes the unit tests build against | +| `cmake/` | Build helpers: test registration, launcher staging | +| `config/` | Key presets | +| `tools/` | Regeneration, the overlay, packaging, capsule replay | + +`game.toml`, `game-widescreen.toml` and `mouse-aim.ini` stay at the root: the +game is started from there and reads them by those names. + +A unit test is registered in `CMakeLists.txt` with +`disruptor_unit_test( tests/unit/)` and a contract with +`disruptor_contract()` for `tests/contracts/test__contract.py`. + ## Replay capsules Ctrl+F12 in the game starts a recording and Ctrl+F12 again ends it. The game From 8bd7de76233c66385a94d6aa7be80aeb9f9b63b8 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 02:10:51 +0300 Subject: [PATCH 07/21] refactor: describe the settings once, in a table the launcher and the game can share --- docs/BUILD.md | 2 +- src/game/settings/options.cpp | 210 ++++++++++++++++ src/game/settings/options.h | 35 +++ src/launcher/CMakeLists.txt | 3 +- src/launcher/launcher_settings.cpp | 235 +++--------------- src/launcher/launcher_settings.h | 37 +-- src/launcher/main.cpp | 30 ++- src/launcher/startup_log.cpp | 12 +- .../test_ambient_occlusion_contract.py | 12 +- tests/contracts/test_gap_filling_contract.py | 7 +- tests/contracts/test_launcher_contract.py | 25 +- tests/contracts/test_smooth_turn_contract.py | 8 +- tests/contracts/test_world_depth_contract.py | 7 +- tests/unit/launcher_settings_test.cpp | 16 +- 14 files changed, 376 insertions(+), 263 deletions(-) create mode 100644 src/game/settings/options.cpp create mode 100644 src/game/settings/options.h diff --git a/docs/BUILD.md b/docs/BUILD.md index b1211b1..b4f5782 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -133,7 +133,7 @@ directories are ignored by Git. | Folder | Holds | | --- | --- | -| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `ui` | +| `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `settings`, `ui` | | `src/launcher/` | The Windows launcher | | `psxrecomp-overlay/` | The framework files this project replaces or adds | | `tests/unit/` | Unit tests, one executable each | diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp new file mode 100644 index 0000000..c9dd02d --- /dev/null +++ b/src/game/settings/options.cpp @@ -0,0 +1,210 @@ +#include "game/settings/options.h" + +#include "game/frame/present_rate.h" + +#include +#include +#include +#include + +namespace disruptor::settings { +namespace { +using Saved = bool (*)(const Settings&); +using Get = int (*)(const Settings&); +using Set = void (*)(Settings&, int); + +template bool saved(const Settings& settings) { return settings.*Has; } +template int read(const Settings& settings) { return static_cast(settings.*Value); } +template void write(Settings& settings, int value) { + settings.*Has = true; + settings.*Value = static_cast>(value); +} + +constexpr int aspect_widths[] = {4, 16, 21, 32}; +constexpr int aspect_heights[] = {3, 9, 9, 9}; +constexpr int widest_aspect = 3, window_aspect = 4; + +bool aspect_saved(const Settings& settings) { return settings.has_aspect_ratio || settings.has_adaptive_view; } + +int aspect(const Settings& settings) { + if (settings.has_adaptive_view && settings.adaptive_view) return window_aspect; + for (int index = 0; index <= widest_aspect; ++index) + if (settings.aspect_num * aspect_heights[index] == settings.aspect_den * aspect_widths[index]) return index; + return -1; +} + +void set_aspect(Settings& settings, int index) { + const int fixed = index == window_aspect ? widest_aspect : index; + settings.has_aspect_ratio = settings.has_adaptive_view = true; + settings.adaptive_view = index == window_aspect; + settings.aspect_num = aspect_widths[fixed]; + settings.aspect_den = aspect_heights[fixed]; +} + +int scale(const Settings& settings) { return settings.supersampling - 1; } +void set_scale(Settings& settings, int index) { settings.has_supersampling = true; settings.supersampling = index + 1; } +int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); } +void set_vsync(Settings& settings, int index) { settings.has_vsync = true; settings.vsync = index - 1; } + +template int thousandths(const Settings& settings) { + return static_cast(std::lround(settings.*Value * 1000.0)); +} +template void set_thousandths(Settings& settings, int value) { + settings.*Has = true; + settings.*Value = value / 1000.0; +} + +constexpr const char* display_modes[] = {"Windowed", "Borderless fullscreen", "Exclusive fullscreen"}; +constexpr const char* aspects[] = {"4:3", "16:9", "21:9", "32:9", "Match window (up to 32:9)"}; +constexpr const char* scales[] = {"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; +constexpr const char* vsyncs[] = {"Adaptive", "Immediate", "Synchronised"}; +constexpr const char* shadows[] = {"Vanilla", "Improved"}; + +constexpr const char* geometry_key = "disruptor.geometry_correction"; +constexpr const char* frames_key = "video.frame_interpolation"; +constexpr const char* rate_key = "video.frame_interpolation_fps"; +constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; +constexpr const char* aim_key = "disruptor.mouse_aim"; + +template +constexpr Option toggle(const char* key, const char* label, int initial, const char* hint, + const char* needs = nullptr) { + return {key, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, + Unit::none, hint}; +} + +constexpr Option choice(const char* key, const char* label, int initial, const char* hint, + std::span choices, Saved saved, Get get, Set set) { + return {key, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, + choices, nullptr, Unit::none, hint}; +} + +constexpr Option slider(const char* key, const char* label, int initial, const char* hint, + int lowest, int highest, Unit unit, Saved saved, Get get, Set set, + const char* needs = nullptr) { + return {key, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; +} + +constexpr Option option_list[] = { + choice("video.fullscreen", "Display mode", 0, + "A window, a borderless window the size of the screen, or exclusive fullscreen. Alt+Enter " + "switches between a window and fullscreen while playing.", display_modes, + saved<&Settings::has_fullscreen>, read<&Settings::fullscreen>, + write<&Settings::has_fullscreen, &Settings::fullscreen>), + choice("video.aspect_ratio", "Aspect ratio", 0, + "4:3 is the original picture. The wider choices show more of the world at the sides. Match " + "window follows the window's shape, up to 32:9. Movies stay 4:3.", aspects, + aspect_saved, aspect, set_aspect), + choice("video.supersampling", "Internal resolution scale", 3, + "Draws the picture at this multiple of the original resolution. Higher is sharper. What it " + "costs the graphics card grows roughly with the square of the value.", scales, + saved<&Settings::has_supersampling>, scale, set_scale), + choice("video.vsync", "VSync", 2, + "Synchronised waits for the display, so the picture never tears. Immediate does not wait. " + "Adaptive waits only while the game keeps up with the display.", + vsyncs, saved<&Settings::has_vsync>, vsync, set_vsync), + slider("disruptor.hud_scale", "HUD size", 100, + "Widescreen only. Shrinks the health and ammunition displays toward their screen edges. " + "The weapon keeps its size.", 50, 100, Unit::percent, + saved<&Settings::has_hud_scale>, read<&Settings::hud_scale>, + write<&Settings::has_hud_scale, &Settings::hud_scale>), + + toggle<&Settings::has_geometry_correction, &Settings::geometry_correction>( + "disruptor.geometry_correction", "Exact-provenance geometry", 1, + "Places polygons from the game's own arithmetic before it is rounded to whole pixels, which " + "steadies the PlayStation's wobbling edges. Other options need it."), + toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( + "disruptor.perspective_textures", "Perspective-correct world textures", 1, + "Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " + "the viewer.", geometry_key), + toggle<&Settings::has_gap_filling, &Settings::gap_filling>( + "disruptor.gap_filling", "Gap filling", 1, + "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), + toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( + "disruptor.depth_sorted_world", "Depth-sorted world", 0, + "Draws world polygons by their depth where the game's own order puts a far one over a near " + "one.", geometry_key), + choice("disruptor.improved_shadows", "Shadows", 0, + "Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " + "game makes it and as tall as the floor's perspective gives. Improved needs exact " + "geometry.", shadows, + saved<&Settings::has_improved_shadows>, read<&Settings::improved_shadows>, + write<&Settings::has_improved_shadows, &Settings::improved_shadows>), + toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>( + "disruptor.frame_unlock", "60 FPS gameplay", 0, + "Lets the game run at up to 60 frames a second in place of its original rate."), + toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( + "video.frame_interpolation", "In-between frames", 0, + "Shows extra pictures between the game's frames, built from the game's own geometry, for " + "smoother motion.", geometry_key), + slider("video.frame_interpolation_fps", "In-between frame rate", 0, + "Pictures shown per second while in-between frames are on.", + kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, + saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, + write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), + toggle<&Settings::has_skip_intro, &Settings::skip_intro>( + "disruptor.skip_intro", "Skip the logos and the title movie", 0, + "Starts at the main menu without the logos and the title movie. Escape skips a logo or a " + "movie either way."), + + toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( + "disruptor.mouse_aim", "Horizontal mouse aim", 1, + "Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " + "click or Escape releases it."), + toggle<&Settings::has_modern_controls, &Settings::modern_controls>( + "disruptor.modern_controls", "Modern keyboard and mouse controls", 1, + "WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " + "interacts."), + slider("disruptor.horizontal_sensitivity", "Horizontal sensitivity", 80, + "How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths, + saved<&Settings::has_horizontal_sensitivity>, thousandths<&Settings::horizontal_sensitivity>, + set_thousandths<&Settings::has_horizontal_sensitivity, &Settings::horizontal_sensitivity>), + toggle<&Settings::has_invert_horizontal, &Settings::invert_horizontal>( + "disruptor.invert_horizontal", "Invert horizontal mouse", 0, + "Turns the view left when the mouse moves right."), + toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( + "disruptor.high_precision_camera", "Smooth mouse turning", 0, + "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " + "a step in between. Needs exact geometry.", aim_key), + toggle<&Settings::has_vertical_look, &Settings::vertical_look>( + "disruptor.vertical_look", "Vertical mouse look", 0, + "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " + "such view. Experimental."), + slider("disruptor.vertical_sensitivity", "Vertical sensitivity", 80, + "How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths, + saved<&Settings::has_vertical_sensitivity>, thousandths<&Settings::vertical_sensitivity>, + set_thousandths<&Settings::has_vertical_sensitivity, &Settings::vertical_sensitivity>), + toggle<&Settings::has_invert_vertical, &Settings::invert_vertical>( + "disruptor.invert_vertical", "Invert vertical mouse", 0, + "Looks down when the mouse moves forward."), + + slider("audio.master_volume", "Master volume", 100, + "The loudness of everything the game plays.", 0, 100, Unit::percent, + saved<&Settings::has_master_volume>, read<&Settings::master_volume>, + write<&Settings::has_master_volume, &Settings::master_volume>), + toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", "Mute all audio", 0, + "Silences the game and keeps the volume setting."), + + toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( + "disruptor.ambient_occlusion", "Ambient occlusion", 0, + "Shades corners and the floor under things. Unfinished.", geometry_key), + slider("disruptor.occlusion_reach", "Occlusion reach", 160, + "How far from a corner or an object the shade spreads.", 50, 800, Unit::none, + saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, + write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), + slider("disruptor.occlusion_strength", "Occlusion strength", 80, + "How dark the shade is.", 0, 300, Unit::percent, + saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, + write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), +}; +} // namespace + +std::span options() { return option_list; } + +const Option* find_option(const char* key) { + for (const auto& option : option_list) + if (std::string_view(option.key) == key) return &option; + return nullptr; +} + +} // namespace disruptor::settings diff --git a/src/game/settings/options.h b/src/game/settings/options.h new file mode 100644 index 0000000..b6e5dd9 --- /dev/null +++ b/src/game/settings/options.h @@ -0,0 +1,35 @@ +#pragma once + +#include "config_loader.h" + +#include + +// The settings a player can change, said once for the launcher and for the in-game menu. +namespace disruptor::settings { + +using Settings = PSXRecompV4::UserSettings; + +enum class Kind { toggle, choice, slider }; +enum class Unit { none, percent, frames, thousandths }; + +// A value is 0 or 1 for a toggle, an index into `choices` for a choice and a whole number of `unit` for a slider. +struct Option { + const char* key; // "section.key" in settings.toml + Kind kind; + const char* label; // UTF-8, as is `hint` and every choice + bool (*saved)(const Settings&); + int (*get)(const Settings&); + void (*set)(Settings&, int); + int initial; // what a fresh installation runs with + int lowest; + int highest; + std::span choices; + const char* needs; // the toggle that must be on for this option to apply + Unit unit; + const char* hint; +}; + +std::span options(); +const Option* find_option(const char* key); + +} // namespace disruptor::settings diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 57a6daf..d5ccca3 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -24,7 +24,8 @@ add_library(disruptor-launcher-model STATIC launcher_settings.cpp region_disc.cpp startup_log.cpp - "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp") + "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp" + "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp") target_include_directories(disruptor-launcher-model PUBLIC "${PROJECT_SOURCE_DIR}/src") target_link_libraries(disruptor-launcher-model PUBLIC disruptor-disc-import disruptor-launcher-config) diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index 4c174c7..98a4e72 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -5,203 +5,44 @@ #include #include -#include #include #include -#include #include namespace fs = std::filesystem; namespace disruptor::launcher { namespace { -using Saved = bool (*)(const Settings&); -using Get = int (*)(const Settings&); -using Set = void (*)(Settings&, int); - -template bool saved(const Settings& settings) { return settings.*Has; } -template int read(const Settings& settings) { return static_cast(settings.*Value); } -template void write(Settings& settings, int value) { - settings.*Has = true; - settings.*Value = static_cast>(value); -} - -constexpr int aspect_widths[] = {4, 16, 21, 32}; -constexpr int aspect_heights[] = {3, 9, 9, 9}; -constexpr int widest_aspect = 3, window_aspect = 4; - -bool aspect_saved(const Settings& settings) { return settings.has_aspect_ratio || settings.has_adaptive_view; } - -int aspect(const Settings& settings) { - if (settings.has_adaptive_view && settings.adaptive_view) return window_aspect; - for (int index = 0; index <= widest_aspect; ++index) - if (settings.aspect_num * aspect_heights[index] == settings.aspect_den * aspect_widths[index]) return index; - return -1; -} - -void set_aspect(Settings& settings, int index) { - const int fixed = index == window_aspect ? widest_aspect : index; - settings.has_aspect_ratio = settings.has_adaptive_view = true; - settings.adaptive_view = index == window_aspect; - settings.aspect_num = aspect_widths[fixed]; - settings.aspect_den = aspect_heights[fixed]; -} - -int scale(const Settings& settings) { return settings.supersampling - 1; } -void set_scale(Settings& settings, int index) { settings.has_supersampling = true; settings.supersampling = index + 1; } -int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); } -void set_vsync(Settings& settings, int index) { settings.has_vsync = true; settings.vsync = index - 1; } - -template int thousandths(const Settings& settings) { - return static_cast(std::lround(settings.*Value * 1000.0)); -} -template void set_thousandths(Settings& settings, int value) { - settings.*Has = true; - settings.*Value = value / 1000.0; -} - -constexpr const wchar_t* display_modes[] = {L"Windowed", L"Borderless fullscreen", L"Exclusive fullscreen"}; -constexpr const wchar_t* aspects[] = {L"4:3", L"16:9", L"21:9", L"32:9", L"Match window (up to 32:9)"}; -constexpr const wchar_t* scales[] = {L"1x (native)", L"2x", L"3x", L"4x", L"5x", L"6x", L"7x", L"8x"}; -constexpr const wchar_t* vsyncs[] = {L"Adaptive", L"Immediate", L"Synchronised"}; -constexpr const wchar_t* shadows[] = {L"Vanilla", L"Improved"}; constexpr const wchar_t* pages[] = {L"Display", L"Enhancements", L"Controls", L"Audio", L"Experimental"}; - -constexpr const char* geometry_key = "disruptor.geometry_correction"; -constexpr const char* frames_key = "video.frame_interpolation"; constexpr const char* rate_key = "video.frame_interpolation_fps"; -constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; -constexpr const char* aim_key = "disruptor.mouse_aim"; - -template -constexpr Option toggle(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - const char* needs = nullptr) { - return {key, page, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, - Unit::none, hint}; -} - -constexpr Option choice(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - std::span choices, Saved saved, Get get, Set set) { - return {key, page, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, - choices, nullptr, Unit::none, hint}; -} - -constexpr Option slider(const char* key, Page page, const wchar_t* label, int initial, const wchar_t* hint, - int lowest, int highest, Unit unit, Saved saved, Get get, Set set, - const char* needs = nullptr) { - return {key, page, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; -} - -constexpr Option option_list[] = { - choice("video.fullscreen", Page::display, L"Display mode", 0, - L"A window, a borderless window the size of the screen, or exclusive fullscreen. Alt+Enter " - L"switches between a window and fullscreen while playing.", display_modes, - saved<&Settings::has_fullscreen>, read<&Settings::fullscreen>, - write<&Settings::has_fullscreen, &Settings::fullscreen>), - choice("video.aspect_ratio", Page::display, L"Aspect ratio", 0, - L"4:3 is the original picture. The wider choices show more of the world at the sides. Match " - L"window follows the window's shape, up to 32:9. Movies stay 4:3.", aspects, - aspect_saved, aspect, set_aspect), - choice("video.supersampling", Page::display, L"Internal resolution scale", 3, - L"Draws the picture at this multiple of the original resolution. Higher is sharper. What it " - L"costs the graphics card grows roughly with the square of the value.", scales, - saved<&Settings::has_supersampling>, scale, set_scale), - choice("video.vsync", Page::display, L"VSync", 2, - L"Synchronised waits for the display, so the picture never tears. Immediate does not wait. " - L"Adaptive waits only while the game keeps up with the display.", - vsyncs, saved<&Settings::has_vsync>, vsync, set_vsync), - slider("disruptor.hud_scale", Page::display, L"HUD size", 100, - L"Widescreen only. Shrinks the health and ammunition displays toward their screen edges. " - L"The weapon keeps its size.", 50, 100, Unit::percent, - saved<&Settings::has_hud_scale>, read<&Settings::hud_scale>, - write<&Settings::has_hud_scale, &Settings::hud_scale>), - - toggle<&Settings::has_geometry_correction, &Settings::geometry_correction>( - "disruptor.geometry_correction", Page::enhancements, L"Exact-provenance geometry", 1, - L"Places polygons from the game's own arithmetic before it is rounded to whole pixels, which " - L"steadies the PlayStation's wobbling edges. Other options need it."), - toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( - "disruptor.perspective_textures", Page::enhancements, L"Perspective-correct world textures", 1, - L"Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " - L"the viewer.", geometry_key), - toggle<&Settings::has_gap_filling, &Settings::gap_filling>( - "disruptor.gap_filling", Page::enhancements, L"Gap filling", 1, - L"Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), - toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( - "disruptor.depth_sorted_world", Page::enhancements, L"Depth-sorted world", 0, - L"Draws world polygons by their depth where the game's own order puts a far one over a near " - L"one.", geometry_key), - choice("disruptor.improved_shadows", Page::enhancements, L"Shadows", 0, - L"Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " - L"game makes it and as tall as the floor's perspective gives. Improved needs exact " - L"geometry.", shadows, - saved<&Settings::has_improved_shadows>, read<&Settings::improved_shadows>, - write<&Settings::has_improved_shadows, &Settings::improved_shadows>), - toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>( - "disruptor.frame_unlock", Page::enhancements, L"60 FPS gameplay", 0, - L"Lets the game run at up to 60 frames a second in place of its original rate."), - toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( - "video.frame_interpolation", Page::enhancements, L"In-between frames", 0, - L"Shows extra pictures between the game's frames, built from the game's own geometry, for " - L"smoother motion.", geometry_key), - slider("video.frame_interpolation_fps", Page::enhancements, L"In-between frame rate", 0, - L"Pictures shown per second while in-between frames are on.", - kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, - saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, - write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), - toggle<&Settings::has_skip_intro, &Settings::skip_intro>( - "disruptor.skip_intro", Page::enhancements, L"Skip the logos and the title movie", 0, - L"Starts at the main menu without the logos and the title movie. Escape skips a logo or a " - L"movie either way."), - - toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( - "disruptor.mouse_aim", Page::controls, L"Horizontal mouse aim", 1, - L"Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " - L"click or Escape releases it."), - toggle<&Settings::has_modern_controls, &Settings::modern_controls>( - "disruptor.modern_controls", Page::controls, L"Modern keyboard and mouse controls", 1, - L"WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " - L"interacts."), - slider("disruptor.horizontal_sensitivity", Page::controls, L"Horizontal sensitivity", 80, - L"How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths, - saved<&Settings::has_horizontal_sensitivity>, thousandths<&Settings::horizontal_sensitivity>, - set_thousandths<&Settings::has_horizontal_sensitivity, &Settings::horizontal_sensitivity>), - toggle<&Settings::has_invert_horizontal, &Settings::invert_horizontal>( - "disruptor.invert_horizontal", Page::controls, L"Invert horizontal mouse", 0, - L"Turns the view left when the mouse moves right."), - toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( - "disruptor.high_precision_camera", Page::controls, L"Smooth mouse turning", 0, - L"The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - L"a step in between. Needs exact geometry.", aim_key), - toggle<&Settings::has_vertical_look, &Settings::vertical_look>( - "disruptor.vertical_look", Page::controls, L"Vertical mouse look", 0, - L"Looks up and down with the mouse, to about 31 degrees each way. The original game has no " - L"such view. Experimental."), - slider("disruptor.vertical_sensitivity", Page::controls, L"Vertical sensitivity", 80, - L"How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths, - saved<&Settings::has_vertical_sensitivity>, thousandths<&Settings::vertical_sensitivity>, - set_thousandths<&Settings::has_vertical_sensitivity, &Settings::vertical_sensitivity>), - toggle<&Settings::has_invert_vertical, &Settings::invert_vertical>( - "disruptor.invert_vertical", Page::controls, L"Invert vertical mouse", 0, - L"Looks down when the mouse moves forward."), - - slider("audio.master_volume", Page::audio, L"Master volume", 100, - L"The loudness of everything the game plays.", 0, 100, Unit::percent, - saved<&Settings::has_master_volume>, read<&Settings::master_volume>, - write<&Settings::has_master_volume, &Settings::master_volume>), - toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", Page::audio, L"Mute all audio", 0, - L"Silences the game and keeps the volume setting."), - toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( - "disruptor.ambient_occlusion", Page::experimental, L"Ambient occlusion", 0, - L"Shades corners and the floor under things. Unfinished.", geometry_key), - slider("disruptor.occlusion_reach", Page::experimental, L"Occlusion reach", 160, - L"How far from a corner or an object the shade spreads.", 50, 800, Unit::none, - saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, - write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), - slider("disruptor.occlusion_strength", Page::experimental, L"Occlusion strength", 80, - L"How dark the shade is.", 0, 300, Unit::percent, - saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, - write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), +constexpr Placement placed[] = { + {"video.fullscreen", Page::display}, + {"video.aspect_ratio", Page::display}, + {"video.supersampling", Page::display}, + {"video.vsync", Page::display}, + {"disruptor.hud_scale", Page::display}, + {"disruptor.geometry_correction", Page::enhancements}, + {"disruptor.perspective_textures", Page::enhancements}, + {"disruptor.gap_filling", Page::enhancements}, + {"disruptor.depth_sorted_world", Page::enhancements}, + {"disruptor.improved_shadows", Page::enhancements}, + {"disruptor.frame_unlock", Page::enhancements}, + {"video.frame_interpolation", Page::enhancements}, + {"video.frame_interpolation_fps", Page::enhancements}, + {"disruptor.skip_intro", Page::enhancements}, + {"disruptor.mouse_aim", Page::controls}, + {"disruptor.modern_controls", Page::controls}, + {"disruptor.horizontal_sensitivity", Page::controls}, + {"disruptor.invert_horizontal", Page::controls}, + {"disruptor.high_precision_camera", Page::controls}, + {"disruptor.vertical_look", Page::controls}, + {"disruptor.vertical_sensitivity", Page::controls}, + {"disruptor.invert_vertical", Page::controls}, + {"audio.master_volume", Page::audio}, + {"audio.muted", Page::audio}, + {"disruptor.ambient_occlusion", Page::experimental}, + {"disruptor.occlusion_reach", Page::experimental}, + {"disruptor.occlusion_strength", Page::experimental}, }; constexpr PresetValue original_values[] = { @@ -226,14 +67,16 @@ bool listed(const std::vector& discs, const std::string& path) { } } // namespace -std::span options() { return option_list; } +std::span layout() { return placed; } std::span presets() { return preset_list; } std::span page_labels() { return pages; } -const Option* find_option(const char* key) { - for (const auto& option : option_list) - if (std::string_view(option.key) == key) return &option; - return nullptr; +std::wstring wide(std::string_view utf8_text) { + const int bytes = static_cast(utf8_text.size()); + const int size = MultiByteToWideChar(CP_UTF8, 0, utf8_text.data(), bytes, nullptr, 0); + std::wstring text(static_cast(std::max(size, 0)), L'\0'); + if (size > 0) MultiByteToWideChar(CP_UTF8, 0, utf8_text.data(), bytes, text.data(), size); + return text; } std::string utf8(const fs::path& path) { @@ -285,7 +128,7 @@ bool SettingsFile::applies(const Option& option) const { void SettingsFile::put(Settings& settings, const Option& option, int value) { option.set(settings, value); if (option.kind != Kind::toggle || value != 0) return; - for (const auto& other : option_list) { + for (const auto& other : options()) { if (other.kind == Kind::toggle && other.needs && std::string_view(other.needs) == option.key && stored(settings, other) != 0) put(settings, other, 0); @@ -314,7 +157,7 @@ bool SettingsFile::apply(const Preset& preset) { bool SettingsFile::reset() { return change([](Settings& settings) { - for (const auto& option : option_list) option.set(settings, option.initial); + for (const auto& option : options()) option.set(settings, option.initial); }); } @@ -326,9 +169,9 @@ bool shipped(const Option& option) { bool SettingsFile::complete() { if (!load()) return false; const auto whole = [this](const Option& option) { return !shipped(option) || option.saved(settings_); }; - if (std::all_of(std::begin(option_list), std::end(option_list), whole)) return true; + if (std::all_of(options().begin(), options().end(), whole)) return true; return change([](Settings& settings) { - for (const auto& option : option_list) + for (const auto& option : options()) if (shipped(option) && !option.saved(settings)) option.set(settings, option.initial); }); } diff --git a/src/launcher/launcher_settings.h b/src/launcher/launcher_settings.h index dc7da7d..4b29f9f 100644 --- a/src/launcher/launcher_settings.h +++ b/src/launcher/launcher_settings.h @@ -1,48 +1,37 @@ #pragma once -#include "config_loader.h" +#include "game/settings/options.h" #include #include #include +#include #include namespace disruptor::launcher { -using Settings = PSXRecompV4::UserSettings; +using settings::find_option; +using settings::Kind; +using settings::Option; +using settings::options; +using settings::Settings; +using settings::Unit; enum class Page { display, enhancements, controls, audio, experimental }; -enum class Kind { toggle, choice, slider }; -enum class Unit { none, percent, frames, thousandths }; - -// A value is 0 or 1 for a toggle, an index into `choices` for a choice and a whole number of `unit` for a slider. -struct Option { - const char* key; // "section.key" in settings.toml - Page page; - Kind kind; - const wchar_t* label; - bool (*saved)(const Settings&); - int (*get)(const Settings&); - void (*set)(Settings&, int); - int initial; // what a fresh installation runs with - int lowest; - int highest; - std::span choices; - const char* needs; // the toggle that must be on for this row to apply - Unit unit; - const wchar_t* hint; // shown when the pointer rests on the row -}; +// A row of the settings pages. The rows of a page are shown in the order they are listed. +struct Placement { const char* key; Page page; }; struct PresetValue { const char* key; int value; }; struct Preset { const wchar_t* label; std::span values; }; -std::span options(); +std::span layout(); std::span presets(); -const Option* find_option(const char* key); // Whether a release's settings.toml names the row: for these the game's own value is not the one a release runs with. bool shipped(const Option& option); std::span page_labels(); +// Bytes that are not UTF-8 come out as U+FFFD. +std::wstring wide(std::string_view utf8_text); std::string utf8(const std::filesystem::path& path); std::filesystem::path from_utf8(const std::string& text); // The PSX_ variables of this process, which the game it starts lets replace settings for that run. diff --git a/src/launcher/main.cpp b/src/launcher/main.cpp index 83e76bb..0ab83b7 100644 --- a/src/launcher/main.cpp +++ b/src/launcher/main.cpp @@ -114,7 +114,13 @@ std::optional pick_image(HWND owner, const wchar_t* title) { struct Update { std::wstring status; unsigned percent; }; struct Placed { HWND window; int x, y, width, height; bool heading; }; -struct Row { const Option* option; int order = 0; HWND label = nullptr, control = nullptr, value = nullptr; }; +struct Row { + const Option* option; + Page page; + int order = 0; + std::wstring hint; + HWND label = nullptr, control = nullptr, value = nullptr; +}; struct App { fs::path root; @@ -273,10 +279,10 @@ struct App { } const bool on = editable() && settings.applies(option); const int slot = row.order - first; - const bool here = static_cast(option.page) == page && slot >= 0 && slot < page_slots; + const bool here = static_cast(row.page) == page && slot >= 0 && slot < page_slots; if (here) put_row(row, slot); const int shown = here ? SW_SHOW : SW_HIDE; - if (here) tip_of(slot, option.hint); + if (here) tip_of(slot, row.hint.c_str()); for (HWND part : {row.label, row.control, row.value}) { if (!part) continue; EnableWindow(part, on); @@ -413,20 +419,22 @@ void create_controls(App& app) { } std::vector rows_on_page(page_labels().size(), 0); index = 0; - for (const auto& option : options()) { - const int order = rows_on_page[static_cast(option.page)]++, y = 38 + 28 * std::min(order, page_slots - 1); + for (const auto& placement : layout()) { + const Option& option = *find_option(placement.key); + const int order = rows_on_page[static_cast(placement.page)]++, y = 38 + 28 * std::min(order, page_slots - 1); + const std::wstring label = wide(option.label); const int id = option_base + index++; - Row row{&option, order}; + Row row{&option, placement.page, order, wide(option.hint)}; if (option.kind == Kind::toggle) { - row.control = app.add(app.tabs, L"BUTTON", option.label, BS_AUTOCHECKBOX | WS_TABSTOP, 0, + row.control = app.add(app.tabs, L"BUTTON", label.c_str(), BS_AUTOCHECKBOX | WS_TABSTOP, 0, 18, y, 560, 24, id); } else { - row.label = app.add(app.tabs, L"STATIC", option.label, SS_LEFT, 0, 18, y + 4, 250, 20); + row.label = app.add(app.tabs, L"STATIC", label.c_str(), SS_LEFT, 0, 18, y + 4, 250, 20); if (option.kind == Kind::choice) { row.control = app.add(app.tabs, WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP, 0, 280, y, 250, 240, id); - for (const wchar_t* label : option.choices) - SendMessageW(row.control, CB_ADDSTRING, 0, reinterpret_cast(label)); + for (const char* choice : option.choices) + SendMessageW(row.control, CB_ADDSTRING, 0, reinterpret_cast(wide(choice).c_str())); } else { row.control = app.add(app.tabs, TRACKBAR_CLASSW, L"", TBS_HORZ | TBS_NOTICKS | WS_TABSTOP, 0, 274, y, 256, 26, id); @@ -443,7 +451,7 @@ void create_controls(App& app) { for (const auto& row : app.rows) { TTTOOLINFOW tool{sizeof(tool), TTF_IDISHWND | TTF_SUBCLASS, app.tabs}; tool.uId = reinterpret_cast(row.control); - tool.lpszText = const_cast(row.option->hint); + tool.lpszText = const_cast(row.hint.c_str()); SendMessageW(app.tips, TTM_ADDTOOLW, 0, reinterpret_cast(&tool)); } for (int slot = 0; slot < page_slots; ++slot) { diff --git a/src/launcher/startup_log.cpp b/src/launcher/startup_log.cpp index e4851ae..9ff3956 100644 --- a/src/launcher/startup_log.cpp +++ b/src/launcher/startup_log.cpp @@ -1,6 +1,6 @@ #include "startup_log.h" -#include +#include "launcher_settings.h" #include #include @@ -10,14 +10,6 @@ namespace fs = std::filesystem; namespace disruptor::launcher { namespace { constexpr std::streamoff most_bytes = 16384; - -// Bytes that are not UTF-8 come out as U+FFFD: a log in another encoding is still shown. -std::wstring decode(const std::string& text) { - const int size = MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0); - std::wstring result(static_cast(std::max(size, 0)), L'\0'); - if (size > 0) MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), result.data(), size); - return result; -} } // namespace std::wstring log_tail(const fs::path& log, size_t lines) { @@ -43,7 +35,7 @@ std::wstring log_tail(const fs::path& log, size_t lines) { } std::string joined; for (auto line = kept.rbegin(); line != kept.rend(); ++line) joined += *line + "\r\n"; - return decode(joined); + return wide(joined); } } // namespace disruptor::launcher diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 1dcbe82..cd732f9 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -24,6 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") @@ -155,16 +156,19 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the switch, off until made, and a reach and a strength only inside the sliders' bounds", ) -switch = between(launcher, '"disruptor.ambient_occlusion", Page::experimental, L"Ambient occlusion", 0,', "),\n") -reach = between(launcher, 'slider("disruptor.occlusion_reach", Page::experimental, L"Occlusion reach", 160,', "),\n") -strength = between(launcher, 'slider("disruptor.occlusion_strength", Page::experimental, L"Occlusion strength", 80,', "),\n") +switch = between(options, '"disruptor.ambient_occlusion", "Ambient occlusion", 0,', "),\n") +reach = between(options, 'slider("disruptor.occlusion_reach", "Occlusion reach", 160,', "),\n") +strength = between(options, 'slider("disruptor.occlusion_strength", "Occlusion strength", 80,', "),\n") require( switch.endswith("geometry_key") and "50, 800, Unit::none," in reach and reach.endswith("occlusion_key") and "0, 300, Unit::percent," in strength and strength.endswith("occlusion_key") - and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in launcher + and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in options + and ' {"disruptor.ambient_occlusion", Page::experimental},\n' + ' {"disruptor.occlusion_reach", Page::experimental},\n' + ' {"disruptor.occlusion_strength", Page::experimental},\n' in launcher and launcher.count("Page::experimental") == 3, "the launcher's Experimental page holds these three rows: the switch needs geometry correction, the sliders the switch, " "with the menu's own bounds", diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 5d61efb..7f6c559 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -24,6 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") @@ -177,9 +178,11 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the choice, which is on until changed", ) -row = between(launcher, '"disruptor.gap_filling", Page::enhancements, L"Gap filling", 1,', "),\n") +row = between(options, '"disruptor.gap_filling", "Gap filling", 1,', "),\n") require( - row.endswith("geometry_key") and launcher.count('"disruptor.gap_filling"') == 1, + row.endswith("geometry_key") + and options.count('"disruptor.gap_filling"') == launcher.count('"disruptor.gap_filling"') == 1 + and ' {"disruptor.gap_filling", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, on at first and only with geometry correction", ) diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 6f2fc11..9c61bd7 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -38,6 +38,7 @@ def plain_values(text: str) -> dict[str, str]: table = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") window = read("src/launcher/main.cpp") region = read("src/launcher/region_disc.cpp") launcher_cmake = read("src/launcher/CMakeLists.txt") @@ -45,10 +46,20 @@ def plain_values(text: str) -> dict[str, str]: loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") -rows = re.findall(r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", Page::(\w+), L"([^"]+)", (-?\d+)', table) -keys = [key for _, key, _, _, _ in rows] -initial = {key: int(value) for _, key, _, _, value in rows} -require(len(rows) == 27 and len(set(keys)) == 27, "the launcher's table must hold its 27 rows, each key once") +ROW = r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", "([^"]+)", (-?\d+)' +rows = re.findall(ROW, options) +keys = [key for _, key, _, _ in rows] +initial = {key: int(value) for _, key, _, value in rows} +require(len(rows) == 27 and len(set(keys)) == 27, "the shared table must hold its 27 options, each key once") +placed = re.findall(r'^ \{"([\w.]+)", Page::\w+\},$', table, re.MULTILINE) +require( + sorted(placed) == sorted(keys) + and not re.findall(ROW, table) + and ' "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp")\n' in launcher_cmake + and "std::span layout() { return placed; }\n" in table + and " for (const auto& placement : layout()) {\n const Option& option = *find_option(placement.key);\n" in window, + "the launcher puts each shared option on one page and describes none itself", +) writer = loader[loader.index("bool save_user_settings(") :] saved, section = set(), "" @@ -65,14 +76,14 @@ def plain_values(text: str) -> dict[str, str]: f"keys listed here are no longer saved: {sorted((NOT_OFFERED | COVERED) - saved)}", ) -for kind, key, _, label, _ in rows: +for kind, key, label, _ in rows: require(f'"{label}"' in menu, f"the row {key} must be named as the in-game menu names it: {label}") for label in ( "Windowed", "Borderless fullscreen", "Exclusive fullscreen", "16:9", "21:9", "32:9", "Match window (up to 32:9)", "1x (native)", "8x", "Adaptive", "Immediate", "Synchronised", ): - require(f'L"{label}"' in table and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") -require('L"Vanilla", L"Improved"' in table and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") + require(f'"{label}"' in options and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") +require('"Vanilla", "Improved"' in options and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index fcae32e..9e2a286 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -27,6 +27,7 @@ def between(source: str, opening: str, closing: str) -> str: mouse = read("src/game/input/mouse_aim.cpp") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 @@ -117,11 +118,12 @@ def between(source: str, opening: str, closing: str) -> str: "into the file's settings, applied at the next start with the environment first, and applied when another run saved it", ) -row = between(launcher, '"disruptor.high_precision_camera", Page::controls, L"Smooth mouse turning", 0,', "),\n") +row = between(options, '"disruptor.high_precision_camera", "Smooth mouse turning", 0,', "),\n") require( row.endswith("aim_key") - and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in launcher - and launcher.count('"disruptor.high_precision_camera"') == 1, + and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in options + and options.count('"disruptor.high_precision_camera"') == launcher.count('"disruptor.high_precision_camera"') == 1 + and ' {"disruptor.high_precision_camera", Page::controls},\n' in launcher, "the launcher offers it on the Controls page, off at first and only with mouse aim", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 5dd1bf0..9cfdec0 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -28,6 +28,7 @@ def between(source: str, opening: str, closing: str) -> str: presenter = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") +options = read("src/game/settings/options.cpp") loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -160,9 +161,11 @@ def between(source: str, opening: str, closing: str) -> str: "the settings file keeps the choice, which is off until made", ) -row = between(launcher, '"disruptor.depth_sorted_world", Page::enhancements, L"Depth-sorted world", 0,', "),\n") +row = between(options, '"disruptor.depth_sorted_world", "Depth-sorted world", 0,', "),\n") require( - row.endswith("geometry_key") and launcher.count('"disruptor.depth_sorted_world"') == 1, + row.endswith("geometry_key") + and options.count('"disruptor.depth_sorted_world"') == launcher.count('"disruptor.depth_sorted_world"') == 1 + and ' {"disruptor.depth_sorted_world", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, off at first and only with geometry correction", ) diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 1bf44c1..9dac975 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -3,6 +3,7 @@ #include +#include #include #include @@ -54,8 +55,7 @@ void table() { one.highest == static_cast(one.choices.size()) - 1, "a choice starts outside its list"); if (one.kind == Kind::toggle) require(one.initial == 0 || one.initial == 1, "a toggle starts as neither"); if (one.needs) require(option(one.needs).kind == Kind::toggle && &option(one.needs) != &one, "a row needs no toggle"); - require(static_cast(one.page) < page_labels().size(), "a row is on no page"); - require(one.hint && std::wcslen(one.hint) >= 20 && std::wcslen(one.hint) <= 240, "a row has no hint to show"); + require(one.hint && std::strlen(one.hint) >= 20 && std::strlen(one.hint) <= 240, "a row has no hint to show"); } require(presets().size() == 2, "the two presets are expected"); for (const auto& preset : presets()) @@ -64,6 +64,17 @@ void table() { "a preset sets a value outside its option"); } +void placements() { + std::set placed; + for (const auto& row : layout()) { + require(find_option(row.key) != nullptr, "a row names no option"); + require(placed.insert(row.key).second, "an option is on two rows"); + require(static_cast(row.page) < page_labels().size(), "a row is on no page"); + } + require(placed.size() == options().size(), "an option is on no page"); + require(wide("caf\xC3\xA9") == L"café" && wide("").empty(), "an option's text is read as UTF-8"); +} + void absent(const fs::path& folder) { SettingsFile settings(folder / "none" / "settings.toml"); require(settings.load() && settings.readable(), "a missing file must read as defaults"); @@ -416,6 +427,7 @@ int main() { fs::remove_all(folder); fs::create_directories(folder); table(); + placements(); absent(folder); one_change(folder); round_trip(folder); From 4317047d8c7f8b8f2a4452aef61cca826b46f01f Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 02:15:34 +0300 Subject: [PATCH 08/21] fix: remove the stray space in the mouse aim hint's middle-click --- src/game/settings/options.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index c9dd02d..046131c 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -149,8 +149,8 @@ constexpr Option option_list[] = { toggle<&Settings::has_mouse_aim, &Settings::mouse_aim>( "disruptor.mouse_aim", "Horizontal mouse aim", 1, - "Turns the view with the mouse. In the game a middle-click captures the mouse, and a middle- " - "click or Escape releases it."), + "Turns the view with the mouse. In the game a middle-click captures the mouse, and a " + "middle-click or Escape releases it."), toggle<&Settings::has_modern_controls, &Settings::modern_controls>( "disruptor.modern_controls", "Modern keyboard and mouse controls", 1, "WASD moves, the left mouse button fires, the right one uses psionics, Space jumps and E " From 5f18d0270fada2fd33ffda7c410445529ce3ca07 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 02:28:45 +0300 Subject: [PATCH 09/21] refactor: stage and save the menu's settings through the shared table --- CMakeLists.txt | 1 + src/game/ui/dev_menu.cpp | 381 +++++------------- .../test_ambient_occlusion_contract.py | 45 ++- tests/contracts/test_debug_tab_contract.py | 6 +- tests/contracts/test_dev_menu_contract.py | 100 +++-- tests/contracts/test_frame_rate_contract.py | 35 +- tests/contracts/test_gap_filling_contract.py | 30 +- tests/contracts/test_hud_scale_contract.py | 19 +- tests/contracts/test_intro_skip_contract.py | 19 +- .../contracts/test_language_disc_contract.py | 4 +- tests/contracts/test_smooth_turn_contract.py | 30 +- tests/contracts/test_world_depth_contract.py | 34 +- tests/unit/launcher_settings_test.cpp | 41 ++ 13 files changed, 341 insertions(+), 404 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c51d93..0bae398 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,7 @@ if(DISRUPTOR_DEV_MENU) endif() list(APPEND DISRUPTOR_EXTRA_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp") if(DISRUPTOR_FRAME_INTERPOLATION) list(APPEND DISRUPTOR_EXTRA_SOURCES diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index 439c393..ed6262c 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -16,6 +16,7 @@ #include "game/input/mouse_aim.h" #include "game/frame/present_rate.h" #include "game/session/restart.h" +#include "game/settings/options.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" @@ -38,6 +39,7 @@ #include #include #include +#include #include #include @@ -82,45 +84,27 @@ struct DevMenuState { DevMenuState g_menu; namespace fs = std::filesystem; - -enum PreferenceDirty : uint32_t { - PREF_MOUSE_AIM = 1u << 0, - PREF_MODERN_CONTROLS = 1u << 1, - PREF_SENSITIVITY = 1u << 2, - PREF_INVERT_X = 1u << 3, - PREF_GEOMETRY = 1u << 5, - PREF_TEXTURES = 1u << 6, - PREF_VSYNC = 1u << 7, - PREF_VERTICAL_LOOK = 1u << 10, - PREF_VERTICAL_SENS = 1u << 11, - PREF_INVERT_Y = 1u << 12, - PREF_ASPECT = 1u << 13, - PREF_SUPERSAMPLING = 1u << 14, - PREF_FULLSCREEN = 1u << 15, - PREF_MASTER_VOLUME = 1u << 16, - PREF_AUDIO_MUTED = 1u << 17, - PREF_HUD_SCALE = 1u << 18, - PREF_FRAME_UNLOCK = 1u << 19, - PREF_SHADOW_SHAPE = 1u << 20, - PREF_PRESENT_RATE = 1u << 21, - PREF_SKIP_INTRO = 1u << 22, - PREF_PRESENT_ENABLED = 1u << 23, - PREF_LANGUAGE_DISC = 1u << 24, - PREF_OCCLUSION = 1u << 25, - PREF_OCCLUSION_SHAPE = 1u << 26, - PREF_GAP_FILLING = 1u << 27, - PREF_WORLD_DEPTH = 1u << 28, - PREF_DIAGNOSTIC_VIEW = 1u << 29, - PREF_SMOOTH_TURN = 1u << 30, -}; +using disruptor::settings::find_option; +using disruptor::settings::Option; struct PreferenceState { fs::path path; PSXRecompV4::UserSettings pending; - uint32_t dirty = 0; + std::set changed; + bool language_disc_changed = false; + bool diagnostic_view_changed = false; bool loaded = false; bool save_failed = false; std::string status; + + bool dirty() const { + return !changed.empty() || language_disc_changed || + diagnostic_view_changed; + } + void clean() { + changed.clear(); + language_disc_changed = diagnostic_view_changed = false; + } }; PreferenceState g_preferences; @@ -203,65 +187,45 @@ void set_modern_controls_live(bool enabled) { disruptor_modern_controls_set_enabled(enabled ? 1 : 0); } -void mark_mouse_aim(bool value) { - g_preferences.pending.has_mouse_aim = true; - g_preferences.pending.mouse_aim = value; - g_preferences.dirty |= PREF_MOUSE_AIM; +void mark(const char *key, int value) { + const Option *option = find_option(key); + option->set(g_preferences.pending, value); + g_preferences.changed.insert(option); } -void mark_modern_controls(bool value) { - g_preferences.pending.has_modern_controls = true; - g_preferences.pending.modern_controls = value; - g_preferences.dirty |= PREF_MODERN_CONTROLS; +bool changed(const char *key) { + return g_preferences.changed.contains(find_option(key)); } +void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } + +void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } + void mark_sensitivity(double value) { g_preferences.pending.has_horizontal_sensitivity = true; g_preferences.pending.horizontal_sensitivity = value; - g_preferences.dirty |= PREF_SENSITIVITY; + g_preferences.changed.insert( + find_option("disruptor.horizontal_sensitivity")); } -void mark_invert_x(bool value) { - g_preferences.pending.has_invert_horizontal = true; - g_preferences.pending.invert_horizontal = value; - g_preferences.dirty |= PREF_INVERT_X; -} +void mark_invert_x(bool value) { mark("disruptor.invert_horizontal", value); } -void mark_vertical_look(bool value) { - g_preferences.pending.has_vertical_look = true; - g_preferences.pending.vertical_look = value; - g_preferences.dirty |= PREF_VERTICAL_LOOK; -} +void mark_vertical_look(bool value) { mark("disruptor.vertical_look", value); } void mark_vertical_sensitivity(double value) { g_preferences.pending.has_vertical_sensitivity = true; g_preferences.pending.vertical_sensitivity = value; - g_preferences.dirty |= PREF_VERTICAL_SENS; + g_preferences.changed.insert( + find_option("disruptor.vertical_sensitivity")); } -void mark_invert_y(bool value) { - g_preferences.pending.has_invert_vertical = true; - g_preferences.pending.invert_vertical = value; - g_preferences.dirty |= PREF_INVERT_Y; -} +void mark_invert_y(bool value) { mark("disruptor.invert_vertical", value); } -void mark_geometry(bool value) { - g_preferences.pending.has_geometry_correction = true; - g_preferences.pending.geometry_correction = value; - g_preferences.dirty |= PREF_GEOMETRY; -} +void mark_geometry(bool value) { mark("disruptor.geometry_correction", value); } -void mark_textures(bool value) { - g_preferences.pending.has_perspective_textures = true; - g_preferences.pending.perspective_textures = value; - g_preferences.dirty |= PREF_TEXTURES; -} +void mark_textures(bool value) { mark("disruptor.perspective_textures", value); } -void mark_vsync(int value) { - g_preferences.pending.has_vsync = true; - g_preferences.pending.vsync = value; - g_preferences.dirty |= PREF_VSYNC; -} +void mark_vsync(int value) { mark("video.vsync", value + 1); } void mark_aspect(int numerator, int denominator, bool adaptive) { g_preferences.pending.has_aspect_ratio = true; @@ -271,88 +235,42 @@ void mark_aspect(int numerator, int denominator, bool adaptive) { * settings update as the fallback/initial aspect. */ g_preferences.pending.has_adaptive_view = true; g_preferences.pending.adaptive_view = adaptive; - g_preferences.dirty |= PREF_ASPECT; + g_preferences.changed.insert(find_option("video.aspect_ratio")); } -void mark_supersampling(int value) { - g_preferences.pending.has_supersampling = true; - g_preferences.pending.supersampling = value; - g_preferences.dirty |= PREF_SUPERSAMPLING; -} +void mark_supersampling(int value) { mark("video.supersampling", value - 1); } -void mark_fullscreen(int value) { - g_preferences.pending.has_fullscreen = true; - g_preferences.pending.fullscreen = value; - g_preferences.dirty |= PREF_FULLSCREEN; -} +void mark_fullscreen(int value) { mark("video.fullscreen", value); } -void mark_master_volume(int value) { - g_preferences.pending.has_master_volume = true; - g_preferences.pending.master_volume = value; - g_preferences.dirty |= PREF_MASTER_VOLUME; -} +void mark_master_volume(int value) { mark("audio.master_volume", value); } -void mark_audio_muted(bool value) { - g_preferences.pending.has_audio_muted = true; - g_preferences.pending.audio_muted = value; - g_preferences.dirty |= PREF_AUDIO_MUTED; -} +void mark_audio_muted(bool value) { mark("audio.muted", value); } -void mark_skip_intro(bool value) { - g_preferences.pending.has_skip_intro = true; - g_preferences.pending.skip_intro = value; - g_preferences.dirty |= PREF_SKIP_INTRO; -} +void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); } void mark_language_disc(const std::string &path) { g_language_disc = path; g_preferences.pending.has_language_disc = true; g_preferences.pending.language_disc = path; - g_preferences.dirty |= PREF_LANGUAGE_DISC; + g_preferences.language_disc_changed = true; } -void mark_hud_scale(int value) { - g_preferences.pending.has_hud_scale = true; - g_preferences.pending.hud_scale = value; - g_preferences.dirty |= PREF_HUD_SCALE; -} +void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); } -void mark_frame_unlock(bool value) { - g_preferences.pending.has_frame_unlock = true; - g_preferences.pending.frame_unlock = value; - g_preferences.dirty |= PREF_FRAME_UNLOCK; -} +void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); } -void mark_shadow_shape(bool improved) { - g_preferences.pending.has_improved_shadows = true; - g_preferences.pending.improved_shadows = improved; - g_preferences.dirty |= PREF_SHADOW_SHAPE; -} +void mark_shadow_shape(bool improved) { mark("disruptor.improved_shadows", improved); } -void mark_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} +void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } void mark_occlusion_shape(int reach, int strength) { - g_preferences.pending.has_occlusion_reach = g_preferences.pending.has_occlusion_strength = true; - g_preferences.pending.occlusion_reach = reach; - g_preferences.pending.occlusion_strength = strength; - g_preferences.dirty |= PREF_OCCLUSION_SHAPE; + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); } -void mark_gap_filling(bool value) { - g_preferences.pending.has_gap_filling = true; - g_preferences.pending.gap_filling = value; - g_preferences.dirty |= PREF_GAP_FILLING; -} +void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } -void mark_world_depth(bool value) { - g_preferences.pending.has_depth_sorted_world = true; - g_preferences.pending.depth_sorted_world = value; - g_preferences.dirty |= PREF_WORLD_DEPTH; -} +void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); } // Views 1 to 4 are layers of the occlusion pass, 5 is the mesh drawn over in-between frames. void apply_diagnostic_view(int view) { @@ -363,7 +281,7 @@ void apply_diagnostic_view(int view) { void mark_diagnostic_view(int value) { g_preferences.pending.has_diagnostic_view = true; g_preferences.pending.diagnostic_view = value; - g_preferences.dirty |= PREF_DIAGNOSTIC_VIEW; + g_preferences.diagnostic_view_changed = true; } // With the vertical divided too a renderer turn of one step misses the game's next step in a median of 8604 pixels of 307200, without in 24274. @@ -372,146 +290,28 @@ void apply_smooth_turn(bool enabled) { if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); } -void mark_smooth_turn(bool value) { - g_preferences.pending.has_high_precision_camera = true; - g_preferences.pending.high_precision_camera = value; - g_preferences.dirty |= PREF_SMOOTH_TURN; -} +void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); } -void mark_present_rate(int value) { - g_preferences.pending.has_frame_interpolation_fps = true; - g_preferences.pending.frame_interpolation_fps = value; - g_preferences.dirty |= PREF_PRESENT_RATE; -} +void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); } -void mark_present_enabled(bool value) { - g_preferences.pending.has_frame_interpolation = true; - g_preferences.pending.frame_interpolation = value; - g_preferences.dirty |= PREF_PRESENT_ENABLED; -} +void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); } void merge_dirty_preferences(PSXRecompV4::UserSettings &settings) { const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) { - settings.has_mouse_aim = true; - settings.mouse_aim = pending.mouse_aim; - } - if (g_preferences.dirty & PREF_MODERN_CONTROLS) { - settings.has_modern_controls = true; - settings.modern_controls = pending.modern_controls; - } - if (g_preferences.dirty & PREF_SENSITIVITY) { - settings.has_horizontal_sensitivity = true; - settings.horizontal_sensitivity = pending.horizontal_sensitivity; - } - if (g_preferences.dirty & PREF_INVERT_X) { - settings.has_invert_horizontal = true; - settings.invert_horizontal = pending.invert_horizontal; - } - if (g_preferences.dirty & PREF_VERTICAL_LOOK) { - settings.has_vertical_look = true; - settings.vertical_look = pending.vertical_look; - } - if (g_preferences.dirty & PREF_VERTICAL_SENS) { - settings.has_vertical_sensitivity = true; - settings.vertical_sensitivity = pending.vertical_sensitivity; - } - if (g_preferences.dirty & PREF_INVERT_Y) { - settings.has_invert_vertical = true; - settings.invert_vertical = pending.invert_vertical; - } - if (g_preferences.dirty & PREF_GEOMETRY) { - settings.has_geometry_correction = true; - settings.geometry_correction = pending.geometry_correction; - } - if (g_preferences.dirty & PREF_TEXTURES) { - settings.has_perspective_textures = true; - settings.perspective_textures = pending.perspective_textures; - } - if (g_preferences.dirty & PREF_VSYNC) { - settings.has_vsync = true; - settings.vsync = pending.vsync; - } - if (g_preferences.dirty & PREF_ASPECT) { - settings.has_aspect_ratio = true; - settings.aspect_num = pending.aspect_num; - settings.aspect_den = pending.aspect_den; - settings.has_adaptive_view = true; - settings.adaptive_view = pending.adaptive_view; - } - if (g_preferences.dirty & PREF_SUPERSAMPLING) { - settings.has_supersampling = true; - settings.supersampling = pending.supersampling; - } - if (g_preferences.dirty & PREF_FULLSCREEN) { - settings.has_fullscreen = true; - settings.fullscreen = pending.fullscreen; - } - if (g_preferences.dirty & PREF_MASTER_VOLUME) { - settings.has_master_volume = true; - settings.master_volume = pending.master_volume; - } - if (g_preferences.dirty & PREF_AUDIO_MUTED) { - settings.has_audio_muted = true; - settings.audio_muted = pending.audio_muted; - } - if (g_preferences.dirty & PREF_SKIP_INTRO) { - settings.has_skip_intro = true; - settings.skip_intro = pending.skip_intro; - } - if (g_preferences.dirty & PREF_LANGUAGE_DISC) { + for (const Option *option : g_preferences.changed) + option->set(settings, option->get(pending)); + if (g_preferences.language_disc_changed) { settings.has_language_disc = true; settings.language_disc = pending.language_disc; } - if (g_preferences.dirty & PREF_HUD_SCALE) { - settings.has_hud_scale = true; - settings.hud_scale = pending.hud_scale; - } - if (g_preferences.dirty & PREF_FRAME_UNLOCK) { - settings.has_frame_unlock = true; - settings.frame_unlock = pending.frame_unlock; - } - if (g_preferences.dirty & PREF_SHADOW_SHAPE) { - settings.has_improved_shadows = true; - settings.improved_shadows = pending.improved_shadows; - } - if (g_preferences.dirty & PREF_OCCLUSION) { - settings.has_ambient_occlusion = true; - settings.ambient_occlusion = pending.ambient_occlusion; - } - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) { - settings.has_occlusion_reach = settings.has_occlusion_strength = true; - settings.occlusion_reach = pending.occlusion_reach; - settings.occlusion_strength = pending.occlusion_strength; - } - if (g_preferences.dirty & PREF_GAP_FILLING) { - settings.has_gap_filling = true; - settings.gap_filling = pending.gap_filling; - } - if (g_preferences.dirty & PREF_WORLD_DEPTH) { - settings.has_depth_sorted_world = true; - settings.depth_sorted_world = pending.depth_sorted_world; - } - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) { + if (g_preferences.diagnostic_view_changed) { settings.has_diagnostic_view = true; settings.diagnostic_view = pending.diagnostic_view; } - if (g_preferences.dirty & PREF_SMOOTH_TURN) { - settings.has_high_precision_camera = true; - settings.high_precision_camera = pending.high_precision_camera; - } - if (g_preferences.dirty & PREF_PRESENT_RATE) { - settings.has_frame_interpolation_fps = true; - settings.frame_interpolation_fps = pending.frame_interpolation_fps; - } - if (g_preferences.dirty & PREF_PRESENT_ENABLED) { - settings.has_frame_interpolation = true; - settings.frame_interpolation = pending.frame_interpolation; - } } bool flush_preferences() { - if (g_preferences.dirty == 0) return true; + if (!g_preferences.dirty()) return true; if (g_preferences.path.empty()) { g_preferences.save_failed = true; g_preferences.status = "Runtime settings path is unavailable."; @@ -534,7 +334,7 @@ bool flush_preferences() { return false; } - g_preferences.dirty = 0; + g_preferences.clean(); g_preferences.save_failed = false; g_preferences.status = "Settings saved."; std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", @@ -638,74 +438,75 @@ void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { void apply_pending_preferences() { const auto &pending = g_preferences.pending; - if (g_preferences.dirty & PREF_MOUSE_AIM) + if (changed("disruptor.mouse_aim")) disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); - if (g_preferences.dirty & PREF_MODERN_CONTROLS) + if (changed("disruptor.modern_controls")) set_modern_controls_live(pending.modern_controls); - if (g_preferences.dirty & PREF_SENSITIVITY) + if (changed("disruptor.horizontal_sensitivity")) (void)disruptor_mouse_set_horizontal_sensitivity( pending.horizontal_sensitivity); - if (g_preferences.dirty & PREF_INVERT_X) + if (changed("disruptor.invert_horizontal")) disruptor_mouse_set_invert_horizontal( pending.invert_horizontal ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_LOOK) + if (changed("disruptor.vertical_look")) disruptor_mouse_set_vertical_look_enabled( pending.vertical_look ? 1 : 0); - if (g_preferences.dirty & PREF_VERTICAL_SENS) + if (changed("disruptor.vertical_sensitivity")) (void)disruptor_mouse_set_vertical_sensitivity( pending.vertical_sensitivity); - if (g_preferences.dirty & PREF_INVERT_Y) + if (changed("disruptor.invert_vertical")) disruptor_mouse_set_invert_vertical( pending.invert_vertical ? 1 : 0); - if (g_preferences.dirty & PREF_GEOMETRY) + if (changed("disruptor.geometry_correction")) gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); - if (g_preferences.dirty & PREF_TEXTURES) + if (changed("disruptor.perspective_textures")) gpu_texture_correction_set( pending.perspective_textures && gpu_geometry_correction_enabled() ? 1 : 0); - if (g_preferences.dirty & PREF_VSYNC) + if (changed("video.vsync")) (void)psx_host_video_set_vsync(pending.vsync); - if (g_preferences.dirty & PREF_ASPECT) { + if (changed("video.aspect_ratio")) { if (pending.adaptive_view) (void)psx_host_video_set_adaptive_view(1); else (void)psx_host_video_set_display_aspect( pending.aspect_num, pending.aspect_den); } - if (g_preferences.dirty & PREF_SUPERSAMPLING) + if (changed("video.supersampling")) (void)psx_host_video_set_internal_scale(pending.supersampling); - if (g_preferences.dirty & PREF_FULLSCREEN) + if (changed("video.fullscreen")) g_menu.fullscreen_apply_failed = psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; - if (g_preferences.dirty & PREF_MASTER_VOLUME) + if (changed("audio.master_volume")) (void)psx_host_audio_set_master_volume(pending.master_volume); - if (g_preferences.dirty & PREF_AUDIO_MUTED) + if (changed("audio.muted")) (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); - if (g_preferences.dirty & PREF_HUD_SCALE) + if (changed("disruptor.hud_scale")) gpu_ws_set_hud_scale(pending.hud_scale); - if (g_preferences.dirty & PREF_FRAME_UNLOCK) + if (changed("disruptor.frame_unlock")) disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); - if (g_preferences.dirty & PREF_SKIP_INTRO) + if (changed("disruptor.skip_intro")) disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); - if (g_preferences.dirty & PREF_SHADOW_SHAPE) + if (changed("disruptor.improved_shadows")) gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION) + if (changed("disruptor.ambient_occlusion")) gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); - if (g_preferences.dirty & PREF_GAP_FILLING) + if (changed("disruptor.gap_filling")) gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); - if (g_preferences.dirty & PREF_WORLD_DEPTH) + if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); - if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) + if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); - if (g_preferences.dirty & PREF_SMOOTH_TURN) + if (changed("disruptor.high_precision_camera")) apply_smooth_turn(pending.high_precision_camera); #ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (g_preferences.dirty & PREF_PRESENT_ENABLED) + if (changed("video.frame_interpolation")) (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); - if (g_preferences.dirty & PREF_PRESENT_RATE) + if (changed("video.frame_interpolation_fps")) (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); #endif } @@ -1403,14 +1204,14 @@ void draw_system_tab() { if (g_preferences.save_failed) ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), "Not saved"); - else if (g_preferences.dirty) + else if (g_preferences.dirty()) ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), "Unsaved changes"); else ImGui::TextColored(ImVec4(0.35f, 0.90f, 0.45f, 1.0f), "Saved"); if (!g_preferences.status.empty()) ImGui::TextWrapped("%s", g_preferences.status.c_str()); - if (g_preferences.dirty && ImGui::Button("Save now")) + if (g_preferences.dirty() && ImGui::Button("Save now")) (void)flush_preferences(); ImGui::TextDisabled( "Current vertical pitch, menu layout, " diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index cd732f9..78b9796 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -25,6 +25,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") @@ -96,29 +107,18 @@ def between(source: str, opening: str, closing: str) -> str: ) require( - """void mark_occlusion(bool value) { - g_preferences.pending.has_ambient_occlusion = true; - g_preferences.pending.ambient_occlusion = value; - g_preferences.dirty |= PREF_OCCLUSION; -} + """void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } void mark_occlusion_shape(int reach, int strength) { - g_preferences.pending.has_occlusion_reach = g_preferences.pending.has_occlusion_strength = true; - g_preferences.pending.occlusion_reach = reach; - g_preferences.pending.occlusion_strength = strength; - g_preferences.dirty |= PREF_OCCLUSION_SHAPE; + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); } """ in menu - and """ if (g_preferences.dirty & PREF_OCCLUSION) { - settings.has_ambient_occlusion = true; - settings.ambient_occlusion = pending.ambient_occlusion; - } - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) { - settings.has_occlusion_reach = settings.has_occlusion_strength = true; - settings.occlusion_reach = pending.occlusion_reach; - settings.occlusion_strength = pending.occlusion_strength; - } -""" in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>(\n "disruptor.ambient_occlusion",' in options + and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key)" in options + and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key)" in options and """ if (!env_override_present("PSX_GL_SSAO")) { if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); """ in menu @@ -128,12 +128,13 @@ def between(source: str, opening: str, closing: str) -> str: settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") ? settings.occlusion_strength / 100.0f : -1.0f); """ in menu - and """ if (g_preferences.dirty & PREF_OCCLUSION) + and """ if (changed("disruptor.ambient_occlusion")) gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (g_preferences.dirty & PREF_OCCLUSION_SHAPE) + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); """ in menu, - "the switch and the shape are each staged under a bit of their own, merged into the file's settings, applied at the " + "the switch and the shape are each staged as options of the shared table, merged into the file's settings, applied at the " "next start unless the environment decides that one, and applied when another run saved them", ) diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index d097fe5..060c45e 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -61,10 +61,10 @@ def between(source: str, opening: str, closing: str) -> str: """void mark_diagnostic_view(int value) { g_preferences.pending.has_diagnostic_view = true; g_preferences.pending.diagnostic_view = value; - g_preferences.dirty |= PREF_DIAGNOSTIC_VIEW; + g_preferences.diagnostic_view_changed = true; } """ in menu - and """ if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) { + and """ if (g_preferences.diagnostic_view_changed) { settings.has_diagnostic_view = true; settings.diagnostic_view = pending.diagnostic_view; } @@ -72,7 +72,7 @@ def between(source: str, opening: str, closing: str) -> str: and """ apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") ? settings.diagnostic_view : 0); """ in menu - and """ if (g_preferences.dirty & PREF_DIAGNOSTIC_VIEW) + and """ if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); """ in menu, "a view is written to the file so that a capsule carries it, and at a start only a capsule's replay takes it from there", diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 87bd76d..3d1f014 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -59,6 +59,18 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: gl = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") gte = read("psxrecomp-overlay/runtime/src/gte.cpp") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) mouse = read("src/game/input/mouse_aim.cpp") cheats = read("src/game/session/cheats.cpp") cheats_h = read("src/game/session/cheats.h") @@ -542,12 +554,13 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ): require(token in scale_controls, f"live internal-resolution controls are missing {token}") -require("PREF_SUPERSAMPLING" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_supersampling = true" in - function_body(menu, "merge_dirty_preferences"), +require('void mark_supersampling(int value) { mark("video.supersampling", value - 1); }\n' in menu and + MERGED in function_body(menu, "merge_dirty_preferences") and + "void set_scale(Settings& settings, int index) { settings.has_supersampling = true; " + "settings.supersampling = index + 1; }\n" in options and + "saved<&Settings::has_supersampling>, scale, set_scale)" in options, "internal scale must be merged into the user settings") -require("PREF_SUPERSAMPLING" in +require('changed("video.supersampling")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_internal_scale" in function_body(menu, "apply_pending_preferences"), @@ -571,14 +584,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ): require(token in fullscreen_controls, f"live fullscreen controls are missing {token}") -require("PREF_FULLSCREEN" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_fullscreen = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.fullscreen = pending.fullscreen" in - function_body(menu, "merge_dirty_preferences"), +require('void mark_fullscreen(int value) { mark("video.fullscreen", value); }\n' in menu and + MERGED in function_body(menu, "merge_dirty_preferences") and + "write<&Settings::has_fullscreen, &Settings::fullscreen>)" in options, "explicit fullscreen selections must be merged into user settings") -require("PREF_FULLSCREEN" in +require('changed("video.fullscreen")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_fullscreen_mode" in function_body(menu, "apply_pending_preferences"), @@ -608,17 +618,16 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require(token in audio_controls, f"live host-audio controls are missing {token}") merge_preferences = function_body(menu, "merge_dirty_preferences") -require("PREF_MASTER_VOLUME" in merge_preferences and - "settings.has_master_volume = true" in merge_preferences and - "settings.master_volume = pending.master_volume" in merge_preferences and - "PREF_AUDIO_MUTED" in merge_preferences and - "settings.has_audio_muted = true" in merge_preferences and - "settings.audio_muted = pending.audio_muted" in merge_preferences, +require('void mark_master_volume(int value) { mark("audio.master_volume", value); }\n' in menu and + 'void mark_audio_muted(bool value) { mark("audio.muted", value); }\n' in menu and + MERGED in merge_preferences and + "write<&Settings::has_master_volume, &Settings::master_volume>)" in options and + ' toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted",' in options, "host-audio selections must be merged into user settings") pending_preferences = function_body(menu, "apply_pending_preferences") -require("PREF_MASTER_VOLUME" in pending_preferences and +require('changed("audio.master_volume")' in pending_preferences and "psx_host_audio_set_master_volume" in pending_preferences and - "PREF_AUDIO_MUTED" in pending_preferences and + 'changed("audio.muted")' in pending_preferences and "psx_host_audio_set_muted" in pending_preferences, "pending host-audio settings must survive a soft runtime session") saved_preferences = function_body(menu, "apply_saved_preferences") @@ -671,15 +680,14 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require("disruptor_billboard_aspect_all_site_mask" in session_body and "disruptor_billboard_aspect_set_site_mask" in session_body, f"{function_name} must restore every billboard repair path") -require("PREF_ASPECT" in function_body(menu, "merge_dirty_preferences") and - "settings.has_aspect_ratio = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.has_adaptive_view = true" in - function_body(menu, "merge_dirty_preferences") and - "settings.adaptive_view = pending.adaptive_view" in - function_body(menu, "merge_dirty_preferences"), +require('g_preferences.changed.insert(find_option("video.aspect_ratio"));' in + function_body(menu, "mark_aspect") and + MERGED in function_body(menu, "merge_dirty_preferences") and + " settings.has_aspect_ratio = settings.has_adaptive_view = true;\n" + " settings.adaptive_view = index == window_aspect;\n" in options and + "aspect_saved, aspect, set_aspect)" in options, "fixed and adaptive aspect choices must persist atomically") -require("PREF_ASPECT" in function_body(menu, "apply_pending_preferences") and +require('changed("video.aspect_ratio")' in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_display_aspect" in function_body(menu, "apply_pending_preferences") and "psx_host_video_set_adaptive_view" in @@ -729,4 +737,40 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "export PSX_VIDEO_ASPECT=16:9" in run_sh, "explicit widescreen launch switches must override a saved aspect") +shared_keys = set(re.findall(r'\b(?:toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)"', options)) +staged_keys = set(re.findall(r'\bmark\("([\w.]+)"', menu)) | set( + re.findall(r'g_preferences\.changed\.insert\(\s*find_option\("([\w.]+)"\)\)', menu)) +applied_keys = set(re.findall(r'\bchanged\("([\w.]+)"\)', function_body(menu, "apply_pending_preferences"))) +require(len(shared_keys) == 27 and staged_keys == shared_keys and applied_keys == shared_keys and + set(re.findall(r'\bfind_option\("([\w.]+)"', menu)) <= shared_keys, + "the menu must stage every shared option, apply every one it staged and name no key the table lacks") +require('void mark_vsync(int value) { mark("video.vsync", value + 1); }\n' in menu and + " g_preferences.pending.horizontal_sensitivity = value;\n" + " g_preferences.changed.insert(\n" + ' find_option("disruptor.horizontal_sensitivity"));\n' in function_body(menu, "mark_sensitivity") and + " g_preferences.pending.vertical_sensitivity = value;\n" + " g_preferences.changed.insert(\n" + ' find_option("disruptor.vertical_sensitivity"));\n' in function_body(menu, "mark_vertical_sensitivity") and + "(void)disruptor_mouse_set_horizontal_sensitivity(\n pending.horizontal_sensitivity);" in + function_body(menu, "apply_pending_preferences") and + "int vsync(const Settings& settings) { return std::clamp(settings.vsync + 1, 0, 2); }\n" in options and + " return static_cast(std::lround(settings.*Value * 1000.0));\n" in options and + " settings.*Value = value / 1000.0;\n" in options, + "a value the menu holds in its own unit must be staged in the shared table's, and a sensitivity kept whole " + "for the running game and rounded only where it is written") +require(STAGED in menu and + " return g_preferences.changed.contains(find_option(key));\n" in menu and + " std::set changed;\n" in menu and + " return !changed.empty() || language_disc_changed ||\n" + " diagnostic_view_changed;\n" in menu and + " changed.clear();\n" + " language_disc_changed = diagnostic_view_changed = false;\n" in menu and + " if (!g_preferences.dirty()) return true;\n" in function_body(menu, "flush_preferences") and + " g_preferences.clean();\n g_preferences.save_failed = false;\n" in function_body(menu, "flush_preferences") and + "PreferenceDirty" not in menu and "PREF_" not in menu and + ' list(APPEND DISRUPTOR_EXTRA_SOURCES\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp"\n' + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp")\n' in cmake, + "a staged change must be an option of the shared table, and a save must write and then forget every one") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 1d63952..9e2dca3 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -46,6 +46,18 @@ def require(condition: bool, message: str) -> None: source = read("src/game/frame/frame_rate.cpp") header = read("src/game/frame/frame_rate.h") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) manifest = read("PSXRECOMP_OVERLAY_FILES.txt") cycle_header = read("psxrecomp-overlay/runtime/include/psx_cyc.h") cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") @@ -125,9 +137,10 @@ def require(condition: bool, message: str) -> None: "the settings menu must apply the switch live and stage it for saving", ) require( - "if (g_preferences.dirty & PREF_FRAME_UNLOCK) {\n" - " settings.has_frame_unlock = true;\n" - " settings.frame_unlock = pending.frame_unlock;" in menu, + 'void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_frame_unlock, &Settings::frame_unlock>(\n "disruptor.frame_unlock",' in options, "a changed switch must reach settings.toml", ) require( @@ -185,9 +198,9 @@ def require(condition: bool, message: str) -> None: "#endif\n" in menu and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" - " if (g_preferences.dirty & PREF_PRESENT_ENABLED)\n" + ' if (changed("video.frame_interpolation"))\n' " (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0);\n" - " if (g_preferences.dirty & PREF_PRESENT_RATE)\n" + ' if (changed("video.frame_interpolation_fps"))\n' " (void)disruptor_present_rate_apply(pending.frame_interpolation_fps);\n" "#endif\n" in menu, @@ -221,9 +234,9 @@ def require(condition: bool, message: str) -> None: "the switch must keep the rate and the blend mode and report only a state the host accepted", ) require( - " if (g_preferences.dirty & PREF_PRESENT_ENABLED) {\n" - " settings.has_frame_interpolation = true;\n" - " settings.frame_interpolation = pending.frame_interpolation;\n" in menu, + 'void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); }\n' in menu + and MERGED in menu + and ' toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>(\n "video.frame_interpolation",' in options, "a changed switch must reach settings.toml", ) require( @@ -244,9 +257,9 @@ def require(condition: bool, message: str) -> None: "the host must restore the saved switch, and geometry in-between frames not without exact geometry", ) require( - " if (g_preferences.dirty & PREF_PRESENT_RATE) {\n" - " settings.has_frame_interpolation_fps = true;\n" - " settings.frame_interpolation_fps = pending.frame_interpolation_fps;\n" in menu, + 'void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); }\n' in menu + and MERGED in menu + and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key)" in options, "a changed in-between frame rate must reach settings.toml", ) require( diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 7f6c559..f6996d3 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -25,6 +25,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") @@ -144,24 +155,17 @@ def between(source: str, opening: str, closing: str) -> str: ) require( - """void mark_gap_filling(bool value) { - g_preferences.pending.has_gap_filling = true; - g_preferences.pending.gap_filling = value; - g_preferences.dirty |= PREF_GAP_FILLING; -} -""" in menu - and """ if (g_preferences.dirty & PREF_GAP_FILLING) { - settings.has_gap_filling = true; - settings.gap_filling = pending.gap_filling; - } -""" in menu + 'void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_gap_filling, &Settings::gap_filling>(\n "disruptor.gap_filling",' in options and """ if (settings.has_gap_filling && !env_override_present("PSX_GL_CRACK_FILL") && !env_override_present("PSX_GL_GROWN_UNDER")) gl_renderer_set_gap_fill(settings.gap_filling ? 1 : 0); """ in menu - and """ if (g_preferences.dirty & PREF_GAP_FILLING) + and """ if (changed("disruptor.gap_filling")) gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); """ in menu, - "a change is staged under a bit of its own, merged into the file's settings, applied at the next start unless the " + "a change is staged as an option of the shared table, merged into the file's settings, applied at the next start unless the " "environment decides either filler, and applied when another run saved it", ) diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index ab2d45c..11a1b12 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -21,6 +21,18 @@ def require(condition: bool, message: str) -> None: gpu = read("psxrecomp-overlay/runtime/src/gpu.c") header = read("psxrecomp-overlay/runtime/include/gpu.h") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -104,9 +116,10 @@ def require(condition: bool, message: str) -> None: "the settings menu must apply the size live and stage it for saving", ) require( - " if (g_preferences.dirty & PREF_HUD_SCALE) {\n" - " settings.has_hud_scale = true;\n" - " settings.hud_scale = pending.hud_scale;" in menu, + 'void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and "write<&Settings::has_hud_scale, &Settings::hud_scale>)" in options, "a changed size must reach settings.toml", ) require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index d66bca2..58973bd 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -48,6 +48,18 @@ def require(condition: bool, message: str) -> None: source = read("src/game/session/intro_skip.cpp") header = read("src/game/session/intro_skip.h") menu = read("src/game/ui/dev_menu.cpp") +options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") @@ -165,9 +177,10 @@ def require(condition: bool, message: str) -> None: "the settings menu must apply the switch live and stage it for saving", ) require( - "if (g_preferences.dirty & PREF_SKIP_INTRO) {\n" - " settings.has_skip_intro = true;\n" - " settings.skip_intro = pending.skip_intro;" in menu, + 'void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_skip_intro, &Settings::skip_intro>(\n "disruptor.skip_intro",' in options, "a changed switch must reach settings.toml", ) require( diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index 581ff28..cd55795 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -361,8 +361,8 @@ def require(condition: bool, message: str) -> None: and " if (program.empty() || !CreateProcessW(program.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup, &started)) {" in restart and " program.resize(GetModuleFileNameW(nullptr, program.data(), static_cast(program.size())));" in restart and ' if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string());' in menu - and " g_preferences.pending.has_language_disc = true;\n g_preferences.pending.language_disc = path;\n g_preferences.dirty |= PREF_LANGUAGE_DISC;" in menu - and " if (g_preferences.dirty & PREF_LANGUAGE_DISC) {\n settings.has_language_disc = true;\n settings.language_disc = pending.language_disc;" in menu + and " g_preferences.pending.has_language_disc = true;\n g_preferences.pending.language_disc = path;\n g_preferences.language_disc_changed = true;" in menu + and " if (g_preferences.language_disc_changed) {\n settings.has_language_disc = true;\n settings.language_disc = pending.language_disc;" in menu and " if (settings.has_language_disc) g_language_disc = settings.language_disc;" in menu, "the menu must show the saved disc, take a new one from a file dialog that neither stops the game nor moves its " "working directory, or none, stage it for saving, and offer a restart that saves first and is refused under netplay", diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 9e2a286..2d531bd 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -28,6 +28,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) require( overlay.count("+runtime/include/gpu_turned_perspective.h\n") == 1 @@ -95,26 +106,19 @@ def between(source: str, opening: str, closing: str) -> str: if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); } """ in menu - and """void mark_smooth_turn(bool value) { - g_preferences.pending.has_high_precision_camera = true; - g_preferences.pending.high_precision_camera = value; - g_preferences.dirty |= PREF_SMOOTH_TURN; -} -""" in menu - and """ if (g_preferences.dirty & PREF_SMOOTH_TURN) { - settings.has_high_precision_camera = true; - settings.high_precision_camera = pending.high_precision_camera; - } -""" in menu + and 'void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>(\n "disruptor.high_precision_camera",' in options and """ if (const char *forced = std::getenv("PSX_DISRUPTOR_HIGH_PRECISION_CAMERA")) apply_smooth_turn(forced[0] != '\\0' && forced[0] != '0'); else if (settings.has_high_precision_camera) apply_smooth_turn(settings.high_precision_camera); """ in menu - and """ if (g_preferences.dirty & PREF_SMOOTH_TURN) + and """ if (changed("disruptor.high_precision_camera")) apply_smooth_turn(pending.high_precision_camera); """ in menu, - "a change switches the mouse module and the renderer's full turn together, is staged under a bit of its own, merged " + "a change switches the mouse module and the renderer's full turn together, is staged as an option of the shared table, merged " "into the file's settings, applied at the next start with the environment first, and applied when another run saved it", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 9cfdec0..99b00c9 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """Source contract for the depth-sorted world: its rule's header, the renderer's two passes and the setting.""" -import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -29,6 +28,17 @@ def between(source: str, opening: str, closing: str) -> str: menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") +STAGED = ( + "void mark(const char *key, int value) {\n" + " const Option *option = find_option(key);\n" + " option->set(g_preferences.pending, value);\n" + " g_preferences.changed.insert(option);\n" + "}\n" +) +MERGED = ( + " for (const Option *option : g_preferences.changed)\n" + " option->set(settings, option->get(pending));\n" +) loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") require( @@ -123,27 +133,19 @@ def between(source: str, opening: str, closing: str) -> str: "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) -bits = re.findall(r"PREF_(\w+)\s*= 1u << (\d+),", menu) require( - len({number for _, number in bits}) == len(bits) - and """void mark_world_depth(bool value) { - g_preferences.pending.has_depth_sorted_world = true; - g_preferences.pending.depth_sorted_world = value; - g_preferences.dirty |= PREF_WORLD_DEPTH; -} -""" in menu - and """ if (g_preferences.dirty & PREF_WORLD_DEPTH) { - settings.has_depth_sorted_world = true; - settings.depth_sorted_world = pending.depth_sorted_world; - } -""" in menu + " std::set changed;\n" in menu + and 'void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); }\n' in menu + and STAGED in menu + and MERGED in menu + and ' toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>(\n "disruptor.depth_sorted_world",' in options and """ if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); """ in menu - and """ if (g_preferences.dirty & PREF_WORLD_DEPTH) + and """ if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); """ in menu, - "a change is staged under a bit of its own, merged into the file's settings, applied at the next start unless the " + "a change is staged as an option of the shared table, merged into the file's settings, applied at the next start unless the " "environment decides, and applied when another run saved it", ) diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 9dac975..865e281 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -64,6 +64,46 @@ void table() { "a preset sets a value outside its option"); } +// The in-game menu saves a staged value by get and set from one settings to another. +void carried() { + for (const auto& one : options()) { + std::set values{one.initial}; + for (int value = one.lowest; value <= one.highest; ++value) values.insert(value); + for (const int value : values) { + Settings staged{}, file{}; + one.set(staged, value); + one.set(file, one.get(staged)); + require(one.saved(file) && one.get(file) == value, "a staged value is not carried whole"); + } + } + const Option& aspect = option("video.aspect_ratio"); + const int shapes[][3] = {{4, 3, 0}, {16, 9, 0}, {21, 9, 0}, {32, 9, 0}, {32, 9, 1}}; + for (const auto& shape : shapes) { + Settings staged{}, file{}; + staged.has_aspect_ratio = staged.has_adaptive_view = true; + staged.aspect_num = shape[0]; + staged.aspect_den = shape[1]; + staged.adaptive_view = shape[2] != 0; + aspect.set(file, aspect.get(staged)); + require(file.has_aspect_ratio && file.has_adaptive_view && file.aspect_num == shape[0] && + file.aspect_den == shape[1] && file.adaptive_view == staged.adaptive_view, + "an aspect the menu stages is not carried whole"); + } + Settings probe{}; + option("video.vsync").set(probe, 0); + option("video.supersampling").set(probe, 3); + option("disruptor.horizontal_sensitivity").set(probe, 83); + require(probe.vsync == -1 && probe.supersampling == 4 && probe.horizontal_sensitivity == 0.083, + "the menu's own values are not the table's"); + const Option& turn = option("disruptor.horizontal_sensitivity"); + Settings typed{}, written{}; + typed.has_horizontal_sensitivity = true; + typed.horizontal_sensitivity = 0.0834; + turn.set(written, turn.get(typed)); + require(written.has_horizontal_sensitivity && written.horizontal_sensitivity == 0.083, + "a sensitivity is not written in whole thousandths"); +} + void placements() { std::set placed; for (const auto& row : layout()) { @@ -427,6 +467,7 @@ int main() { fs::remove_all(folder); fs::create_directories(folder); table(); + carried(); placements(); absent(folder); one_change(folder); From f12c2b80e1cedb62f6e44e79a13110a97662ae7f Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:10:55 +0300 Subject: [PATCH 10/21] refactor: draw the menu's settings from the shared table --- src/game/frame/present_rate.cpp | 8 +- src/game/frame/present_rate.h | 4 +- src/game/ui/dev_menu.cpp | 101 ++++++++++-------- .../test_ambient_occlusion_contract.py | 11 +- tests/contracts/test_dev_menu_contract.py | 64 ++++++++--- tests/contracts/test_frame_rate_contract.py | 19 +++- tests/contracts/test_gap_filling_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 7 +- tests/contracts/test_intro_skip_contract.py | 3 +- tests/contracts/test_launcher_contract.py | 11 +- tests/contracts/test_smooth_turn_contract.py | 4 +- tests/contracts/test_world_depth_contract.py | 4 +- tests/unit/disruptor_present_rate_test.cpp | 43 ++++---- 13 files changed, 177 insertions(+), 106 deletions(-) diff --git a/src/game/frame/present_rate.cpp b/src/game/frame/present_rate.cpp index e5e7740..4a6acf4 100644 --- a/src/game/frame/present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -20,13 +20,13 @@ int disruptor_present_rate_enable(int enabled) { return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0; } -int disruptor_present_rate_switch(int available, int *enabled) { +int disruptor_present_rate_switch(const char *label, int available, int *enabled) { int on = 0, changed = 0; psx_host_video_get_interpolation(&on, nullptr, nullptr); bool checked = on != 0; const bool locked = !available && !checked; if (locked) ImGui::BeginDisabled(); - if (ImGui::Checkbox("In-between frames", &checked) && + if (ImGui::Checkbox(label, &checked) && disruptor_present_rate_enable(checked ? 1 : 0)) { if (enabled) *enabled = checked ? 1 : 0; changed = 1; @@ -43,7 +43,7 @@ int disruptor_present_rate_apply(int frames_per_second) { return enabled && psx_host_video_set_interpolation(1, frames_per_second, blend) != 0; } -int disruptor_present_rate_control(int *accepted) { +int disruptor_present_rate_control(const char *label, int *accepted) { int enabled = 0, target = 0, changed = 0; double display_hz = 0.0; psx_host_video_get_interpolation(&enabled, &target, nullptr); @@ -52,7 +52,7 @@ int disruptor_present_rate_control(int *accepted) { int shown = disruptor_present_rate_shown(target, top); if (!enabled) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("In-between frame rate", &shown, kDisruptorPresentRateLowest, top, "%d FPS", + if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", ImGuiSliderFlags_AlwaysClamp) && disruptor_present_rate_apply(shown)) { if (accepted) *accepted = shown; diff --git a/src/game/frame/present_rate.h b/src/game/frame/present_rate.h index 601811c..2ab5f3c 100644 --- a/src/game/frame/present_rate.h +++ b/src/game/frame/present_rate.h @@ -28,10 +28,10 @@ constexpr int disruptor_present_rate_shown(int target_fps, int top) { /* Switches in-between frames on or off at the rate and blend the host has. Returns 0 when the host refused. */ int disruptor_present_rate_enable(int enabled); /* Draws the switch: frames that are off cannot be switched on while `available` is 0. Returns 1 and the state in `enabled` when the user changed it and the host took it. */ -int disruptor_present_rate_switch(int available, int *enabled); +int disruptor_present_rate_switch(const char *label, int available, int *enabled); /* Sets the rate of in-between frames that are on. Returns 0 when they are off or the host refused. */ int disruptor_present_rate_apply(int frames_per_second); /* Draws the control. Returns 1 and the rate in `accepted` when the user changed it and the host took it. */ -int disruptor_present_rate_control(int *accepted); +int disruptor_present_rate_control(const char *label, int *accepted); #endif diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index ed6262c..72e998e 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -86,6 +86,7 @@ DevMenuState g_menu; namespace fs = std::filesystem; using disruptor::settings::find_option; using disruptor::settings::Option; +using disruptor::settings::Unit; struct PreferenceState { fs::path path; @@ -197,6 +198,32 @@ bool changed(const char *key) { return g_preferences.changed.contains(find_option(key)); } +bool option_toggle(const char *key, bool *value) { + return ImGui::Checkbox(find_option(key)->label, value); +} + +bool option_slider(const char *key, int *value) { + const Option &option = *find_option(key); + const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; + return ImGui::SliderInt(option.label, value, option.lowest, + option.highest, format, + ImGuiSliderFlags_AlwaysClamp); +} + +bool option_slider(const char *key, float *value) { + const Option &option = *find_option(key); + return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f, + option.highest / 1000.0f, "%.3f", + ImGuiSliderFlags_Logarithmic); +} + +bool option_list(const char *key, int *index, size_t skipped = 0) { + const Option &option = *find_option(key); + const auto shown = option.choices.subspan(skipped); + return ImGui::Combo(option.label, index, shown.data(), + static_cast(shown.size())); +} + void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } @@ -680,14 +707,14 @@ void draw_status_badge(const char *label, const ImVec4 &colour) { void draw_controls_tab() { bool mouse_aim = disruptor_mouse_aim_enabled() != 0; - if (ImGui::Checkbox("Horizontal mouse aim", &mouse_aim)) { + if (option_toggle("disruptor.mouse_aim", &mouse_aim)) { disruptor_mouse_aim_set_enabled(mouse_aim ? 1 : 0); mark_mouse_aim(mouse_aim); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool modern = disruptor_modern_controls_enabled() != 0; - if (ImGui::Checkbox("Modern keyboard and mouse controls", &modern)) { + if (option_toggle("disruptor.modern_controls", &modern)) { set_modern_controls_live(modern); mark_modern_controls(modern); } @@ -695,16 +722,14 @@ void draw_controls_tab() { float sensitivity = static_cast(disruptor_mouse_horizontal_sensitivity()); - if (ImGui::SliderFloat("Horizontal sensitivity", &sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { + if (option_slider("disruptor.horizontal_sensitivity", &sensitivity)) { const double applied = disruptor_mouse_set_horizontal_sensitivity(sensitivity); mark_sensitivity(applied); } bool invert_x = disruptor_mouse_invert_horizontal() != 0; - if (ImGui::Checkbox("Invert horizontal mouse", &invert_x)) { + if (option_toggle("disruptor.invert_horizontal", &invert_x)) { disruptor_mouse_set_invert_horizontal(invert_x ? 1 : 0); mark_invert_x(invert_x); } @@ -712,7 +737,7 @@ void draw_controls_tab() { const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &smooth)) { + if (option_toggle("disruptor.high_precision_camera", &smooth)) { apply_smooth_turn(smooth); mark_smooth_turn(smooth); } @@ -725,7 +750,7 @@ void draw_controls_tab() { ImGui::SeparatorText("Vertical look"); bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; - if (ImGui::Checkbox("Vertical mouse look", &vertical_look)) { + if (option_toggle("disruptor.vertical_look", &vertical_look)) { disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); mark_vertical_look(vertical_look); } @@ -733,16 +758,14 @@ void draw_controls_tab() { float vertical_sensitivity = static_cast(disruptor_mouse_vertical_sensitivity()); - if (ImGui::SliderFloat("Vertical sensitivity", &vertical_sensitivity, - 0.005f, 2.0f, "%.3f", - ImGuiSliderFlags_Logarithmic)) { + if (option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)) { const double applied = disruptor_mouse_set_vertical_sensitivity(vertical_sensitivity); mark_vertical_sensitivity(applied); } bool invert_y = disruptor_mouse_invert_vertical() != 0; - if (ImGui::Checkbox("Invert vertical mouse", &invert_y)) { + if (option_toggle("disruptor.invert_vertical", &invert_y)) { disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); mark_invert_y(invert_y); } @@ -783,8 +806,6 @@ void apply_geometry_enabled(bool enabled) { void draw_aspect_controls() { static constexpr int kAspectNumerators[] = {16, 21, 32, 32}; static constexpr int kAspectDenominators[] = {9, 9, 9, 9}; - static const char *kAspectLabels[] = { - "16:9", "21:9", "32:9", "Match window (up to 32:9)"}; static constexpr int kFixedWideAspectCount = 3; static constexpr int kAdaptiveAspectIndex = 3; static constexpr int kAspectCount = 4; @@ -831,8 +852,7 @@ void draw_aspect_controls() { draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); int selected = g_menu.selected_wide_aspect; - if (ImGui::Combo("Aspect ratio", &selected, kAspectLabels, - kAspectCount)) { + if (option_list("video.aspect_ratio", &selected, 1)) { g_menu.selected_wide_aspect = selected; if (widescreen) { if (selected == kAdaptiveAspectIndex) { @@ -864,7 +884,7 @@ void draw_aspect_controls() { if (gpu_sprite_placement_available()) { int shadow_shape = gpu_shadow_shape(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::Combo("Shadows", &shadow_shape, "Vanilla\0Improved\0")) { + if (option_list("disruptor.improved_shadows", &shadow_shape)) { gpu_set_shadow_shape(shadow_shape); mark_shadow_shape(shadow_shape != 0); } @@ -876,8 +896,7 @@ void draw_aspect_controls() { int hud_scale = gpu_ws_hud_scale(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("HUD size", &hud_scale, 50, 100, "%d%%", - ImGuiSliderFlags_AlwaysClamp)) { + if (option_slider("disruptor.hud_scale", &hud_scale)) { gpu_ws_set_hud_scale(hud_scale); mark_hud_scale(hud_scale); } @@ -888,15 +907,12 @@ void draw_aspect_controls() { } void draw_internal_resolution_controls() { - static const char *kScaleLabels[] = { - "1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; static constexpr int kScaleCount = 8; int selected = std::clamp( psx_host_video_get_internal_scale(), 1, kScaleCount) - 1; ImGui::SeparatorText("Rendering resolution"); - if (ImGui::Combo("Internal resolution scale", &selected, - kScaleLabels, kScaleCount)) { + if (option_list("video.supersampling", &selected)) { const int requested = selected + 1; g_menu.internal_scale_apply_failed = psx_host_video_set_internal_scale(requested) == 0; @@ -920,13 +936,13 @@ void draw_enhancements_tab() { draw_internal_resolution_controls(); bool geometry = gpu_geometry_correction_enabled() != 0; - if (ImGui::Checkbox("Exact-provenance geometry", &geometry)) + if (option_toggle("disruptor.geometry_correction", &geometry)) apply_geometry_enabled(geometry); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool textures = gpu_texture_correction_enabled() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Perspective-correct world textures", &textures)) { + if (option_toggle("disruptor.perspective_textures", &textures)) { gpu_texture_correction_set(textures ? 1 : 0); mark_textures(textures); } @@ -937,7 +953,7 @@ void draw_enhancements_tab() { bool gaps = gl_renderer_gap_fill() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Gap filling", &gaps)) { + if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } @@ -949,7 +965,7 @@ void draw_enhancements_tab() { bool by_depth = gl_renderer_world_depth() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Depth-sorted world", &by_depth)) { + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } @@ -962,15 +978,13 @@ void draw_enhancements_tab() { ImGui::SeparatorText("Synchronisation"); int vsync = psx_host_video_get_vsync(); int vsync_index = std::clamp(vsync + 1, 0, 2); - static const char *kVsyncLabels[] = { - "Adaptive", "Immediate", "Synchronised"}; - if (ImGui::Combo("VSync", &vsync_index, kVsyncLabels, 3)) { + if (option_list("video.vsync", &vsync_index)) { const int requested = vsync_index - 1; if (psx_host_video_set_vsync(requested)) mark_vsync(requested); } bool unlocked = disruptor_frame_rate_unlocked() != 0; - if (ImGui::Checkbox("60 FPS gameplay", &unlocked)) { + if (option_toggle("disruptor.frame_unlock", &unlocked)) { disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); mark_frame_unlock(unlocked); } @@ -988,15 +1002,19 @@ void draw_enhancements_tab() { #ifndef PSX_DISABLE_FRAME_INTERPOLATION int present_enabled = 0; - if (disruptor_present_rate_switch(geometry ? 1 : 0, &present_enabled)) + if (disruptor_present_rate_switch( + find_option("video.frame_interpolation")->label, geometry ? 1 : 0, + &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; - if (disruptor_present_rate_control(&present_rate)) mark_present_rate(present_rate); + if (disruptor_present_rate_control( + find_option("video.frame_interpolation_fps")->label, &present_rate)) + mark_present_rate(present_rate); #endif ImGui::SeparatorText("Startup"); bool skip_intro = disruptor_intro_skip_enabled() != 0; - if (ImGui::Checkbox("Skip the logos and the title movie", &skip_intro)) { + if (option_toggle("disruptor.skip_intro", &skip_intro)) { disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0); mark_skip_intro(skip_intro); } @@ -1028,7 +1046,7 @@ void draw_experimental_tab() { const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Ambient occlusion", &occlusion)) { + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } @@ -1038,10 +1056,10 @@ void draw_experimental_tab() { int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); if (!geometry) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); ImGui::SetNextItemWidth(260.0f); const bool strength_moved = - ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); + option_slider("disruptor.occlusion_strength", &strength); if (!geometry) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); @@ -1145,12 +1163,10 @@ void draw_cheats_tab() { } void draw_fullscreen_controls() { - static const char *kFullscreenLabels[] = { - "Windowed", "Borderless fullscreen", "Exclusive fullscreen"}; int mode = std::clamp(psx_host_video_get_fullscreen_mode(), 0, 2); ImGui::SeparatorText("Window presentation"); - if (ImGui::Combo("Display mode", &mode, kFullscreenLabels, 3)) { + if (option_list("video.fullscreen", &mode)) { g_menu.fullscreen_apply_failed = psx_host_video_set_fullscreen_mode(mode) == 0; if (!g_menu.fullscreen_apply_failed) @@ -1172,15 +1188,14 @@ void draw_audio_controls() { ImGui::SeparatorText("Audio output"); int volume = psx_host_audio_get_master_volume(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt("Master volume", &volume, 0, 100, "%d%%", - ImGuiSliderFlags_AlwaysClamp) && + if (option_slider("audio.master_volume", &volume) && psx_host_audio_set_master_volume(volume)) { mark_master_volume(volume); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool muted = psx_host_audio_get_muted() != 0; - if (ImGui::Checkbox("Mute all audio", &muted) && + if (option_toggle("audio.muted", &muted) && psx_host_audio_set_muted(muted ? 1 : 0)) { mark_audio_muted(muted); } diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 78b9796..b2e7acb 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -84,24 +84,25 @@ def between(source: str, opening: str, closing: str) -> str: """ const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Ambient occlusion", &occlusion)) { + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } if (!geometry) ImGui::EndDisabled(); """ in experimental - and """ const bool reach_moved = ImGui::SliderInt("Occlusion reach", &reach, 50, 800, "%d", ImGuiSliderFlags_AlwaysClamp); + and """ const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); """ in experimental - and """ ImGui::SliderInt("Occlusion strength", &strength, 0, 300, "%d%%", ImGuiSliderFlags_AlwaysClamp); + and """ option_slider("disruptor.occlusion_strength", &strength); if (!geometry) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); } """ in experimental - and menu.count('"Ambient occlusion"') == 1 + and menu.count('option_toggle("disruptor.ambient_occlusion"') == 1 and experimental.count("ImGui::BeginDisabled()") == experimental.count("ImGui::EndDisabled()") == 2 - and experimental.count("ImGui::Checkbox(") == 1 + and experimental.count("option_toggle(") == 1 + and "ImGui::Checkbox(" not in experimental and ' if (ImGui::BeginTabItem("Experimental")) {\n draw_experimental_tab();\n' in menu, "the Experimental tab holds the occlusion alone: a switch and two bounded sliders, greyed without geometry correction", ) diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 3d1f014..7d664e3 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -462,9 +462,9 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: function_body(menu, "apply_saved_preferences"), "saved or environment-enabled modern controls must install conflict-free binds") for token in ( - "Vertical mouse look", - "Vertical sensitivity", - "Invert vertical mouse", + 'option_toggle("disruptor.vertical_look", &vertical_look)', + 'option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)', + 'option_toggle("disruptor.invert_vertical", &invert_y)', "Recenter vertical view", "disruptor_mouse_set_vertical_look_enabled", "disruptor_mouse_set_vertical_sensitivity", @@ -528,14 +528,12 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: aspect_controls = function_body(menu, "draw_aspect_controls") for token in ( 'Checkbox("Widescreen"', - 'Combo("Aspect ratio"', - '"16:9", "21:9", "32:9"', + 'option_list("video.aspect_ratio", &selected, 1)', "psx_host_video_get_display_aspect", "psx_host_video_set_display_aspect", "psx_host_video_get_adaptive_view", "psx_host_video_set_adaptive_view", "mark_aspect", - '"Match window (up to 32:9)"', 'draw_status_badge("LIVE"', ): require(token in aspect_controls, @@ -544,8 +542,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: scale_controls = function_body(menu, "draw_internal_resolution_controls") for token in ( 'SeparatorText("Rendering resolution")', - 'Combo("Internal resolution scale"', - '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"', + 'option_list("video.supersampling", &selected)', "kScaleCount = 8", "psx_host_video_get_internal_scale", "psx_host_video_set_internal_scale", @@ -571,8 +568,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: fullscreen_controls = function_body(menu, "draw_fullscreen_controls") for token in ( 'SeparatorText("Window presentation")', - 'Combo("Display mode"', - '"Windowed", "Borderless fullscreen", "Exclusive fullscreen"', + 'option_list("video.fullscreen", &mode)', "psx_host_video_get_fullscreen_mode", "psx_host_video_set_fullscreen_mode", "mark_fullscreen", @@ -604,8 +600,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: audio_controls = function_body(menu, "draw_audio_controls") for token in ( 'SeparatorText("Audio output")', - 'SliderInt("Master volume"', - 'Checkbox("Mute all audio"', + 'option_slider("audio.master_volume", &volume)', + 'option_toggle("audio.muted", &muted)', "psx_host_audio_get_master_volume", "psx_host_audio_set_master_volume", "psx_host_audio_get_muted", @@ -773,4 +769,48 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp")\n' in cmake, "a staged change must be an option of the shared table, and a save must write and then forget every one") +HELPERS = ( + "bool option_toggle(const char *key, bool *value) {\n" + " return ImGui::Checkbox(find_option(key)->label, value);\n" + "}\n" + "\n" + "bool option_slider(const char *key, int *value) {\n" + " const Option &option = *find_option(key);\n" + ' const char *format = option.unit == Unit::percent ? "%d%%" : "%d";\n' + " return ImGui::SliderInt(option.label, value, option.lowest,\n" + " option.highest, format,\n" + " ImGuiSliderFlags_AlwaysClamp);\n" + "}\n" + "\n" + "bool option_slider(const char *key, float *value) {\n" + " const Option &option = *find_option(key);\n" + " return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f,\n" + ' option.highest / 1000.0f, "%.3f",\n' + " ImGuiSliderFlags_Logarithmic);\n" + "}\n" + "\n" + "bool option_list(const char *key, int *index, size_t skipped = 0) {\n" + " const Option &option = *find_option(key);\n" + " const auto shown = option.choices.subspan(skipped);\n" + " return ImGui::Combo(option.label, index, shown.data(),\n" + " static_cast(shown.size()));\n" + "}\n" +) +shared_labels = re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"([^"]+)"', options) +shared_choices = re.findall(r'"([^"]+)"', "".join(re.findall(r"constexpr const char\* \w+\[\] = \{([^}]*)\};", options))) +drawn_keys = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( + re.findall(r'find_option\("([\w.]+)"\)->label', menu)) +present = read("src/game/frame/present_rate.cpp") +require(HELPERS in menu and len(shared_labels) == 27 and drawn_keys == shared_keys and + not [text for text in [*shared_labels, *shared_choices] if f'"{text}"' in menu + present] and + " static constexpr int kScaleCount = 8;\n" in menu and + '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"' in options and + '{"4:3", "16:9", "21:9", "32:9", "Match window (up to 32:9)"}' in options and + '{"Windowed", "Borderless fullscreen", "Exclusive fullscreen"}' in options and + '{"Adaptive", "Immediate", "Synchronised"}' in options and '{"Vanilla", "Improved"}' in options and + '"How far the view turns for a movement of the mouse.", 5, 2000, Unit::thousandths,' in options and + '"How far the view tilts for a movement of the mouse.", 5, 2000, Unit::thousandths,' in options and + '"The loudness of everything the game plays.", 0, 100, Unit::percent,' in options, + "the menu must draw every shared option by its key, with the table's label, bounds and choices and none of its own") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 9e2dca3..0b72189 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -133,7 +133,8 @@ def require(condition: bool, message: str) -> None: require( "disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0);\n mark_frame_unlock(unlocked);" in menu - and '"60 FPS gameplay"' in menu, + and ' if (option_toggle("disruptor.frame_unlock", &unlocked)) {\n' in menu + and '"disruptor.frame_unlock", "60 FPS gameplay", 0,' in options, "the settings menu must apply the switch live and stage it for saving", ) require( @@ -191,10 +192,14 @@ def require(condition: bool, message: str) -> None: in cmake and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" - " if (disruptor_present_rate_switch(geometry ? 1 : 0, &present_enabled))\n" + " if (disruptor_present_rate_switch(\n" + ' find_option("video.frame_interpolation")->label, geometry ? 1 : 0,\n' + " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" - " if (disruptor_present_rate_control(&present_rate)) mark_present_rate(present_rate);\n" + " if (disruptor_present_rate_control(\n" + ' find_option("video.frame_interpolation_fps")->label, &present_rate))\n' + " mark_present_rate(present_rate);\n" "#endif\n" in menu and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" @@ -207,7 +212,7 @@ def require(condition: bool, message: str) -> None: "a build without the frame interpolator must have no rate control, in the menu or in the sources", ) require( - 'if (ImGui::SliderInt("In-between frame rate", &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' + 'if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' " ImGuiSliderFlags_AlwaysClamp) &&\n" " disruptor_present_rate_apply(shown)) {\n" " if (accepted) *accepted = shown;\n" @@ -227,7 +232,11 @@ def require(condition: bool, message: str) -> None: ) require( " return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0;\n" in present_control - and 'if (ImGui::Checkbox("In-between frames", &checked) &&\n' + and '"video.frame_interpolation", "In-between frames", 0,' in options + and 'slider("video.frame_interpolation_fps", "In-between frame rate", 0,' in options + and "int disruptor_present_rate_switch(const char *label, int available, int *enabled) {\n" in present_control + and "int disruptor_present_rate_control(const char *label, int *accepted) {\n" in present_control + and "if (ImGui::Checkbox(label, &checked) &&\n" " disruptor_present_rate_enable(checked ? 1 : 0)) {\n" " if (enabled) *enabled = checked ? 1 : 0;\n" in present_control, diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index f6996d3..3e2efe6 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -143,13 +143,13 @@ def between(source: str, opening: str, closing: str) -> str: require( """ bool gaps = gl_renderer_gap_fill() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Gap filling", &gaps)) { + if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } if (!geometry) ImGui::EndDisabled(); """ in enhancements - and menu.count('"Gap filling"') == 1 + and menu.count('option_toggle("disruptor.gap_filling"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 11a1b12..393c175 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -109,10 +109,11 @@ def require(condition: bool, message: str) -> None: ) require( - ' if (ImGui::SliderInt("HUD size", &hud_scale, 50, 100, "%d%%",\n' - " ImGuiSliderFlags_AlwaysClamp)) {\n" + ' if (option_slider("disruptor.hud_scale", &hud_scale)) {\n' " gpu_ws_set_hud_scale(hud_scale);\n" - " mark_hud_scale(hud_scale);" in menu, + " mark_hud_scale(hud_scale);" in menu + and 'slider("disruptor.hud_scale", "HUD size", 100,' in options + and '"The weapon keeps its size.", 50, 100, Unit::percent,' in options, "the settings menu must apply the size live and stage it for saving", ) require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 58973bd..7399fae 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -173,7 +173,8 @@ def require(condition: bool, message: str) -> None: require( "disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0);\n mark_skip_intro(skip_intro);" in menu - and '"Skip the logos and the title movie"' in menu, + and ' if (option_toggle("disruptor.skip_intro", &skip_intro)) {\n' in menu + and '"disruptor.skip_intro", "Skip the logos and the title movie", 0,' in options, "the settings menu must apply the switch live and stage it for saving", ) require( diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 9c61bd7..a5bbcda 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -76,14 +76,11 @@ def plain_values(text: str) -> dict[str, str]: f"keys listed here are no longer saved: {sorted((NOT_OFFERED | COVERED) - saved)}", ) +drawn = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( + re.findall(r'find_option\("([\w.]+)"\)->label', menu)) +require(drawn == set(keys), f"the in-game menu must draw every row by its key: {sorted(set(keys) ^ drawn)}") for kind, key, label, _ in rows: - require(f'"{label}"' in menu, f"the row {key} must be named as the in-game menu names it: {label}") -for label in ( - "Windowed", "Borderless fullscreen", "Exclusive fullscreen", "16:9", "21:9", "32:9", "Match window (up to 32:9)", - "1x (native)", "8x", "Adaptive", "Immediate", "Synchronised", -): - require(f'"{label}"' in options and f'"{label}"' in menu, f"the choice {label} must read as in the in-game menu") -require('"Vanilla", "Improved"' in options and '"Vanilla\\0Improved\\0"' in menu, "the shadow choices must match") + require(f'"{label}"' not in menu, f"the in-game menu must take the name of {key} from the shared table") shipped = plain_values(read("release/windows/settings.toml")) require(not set(shipped) - set(keys), f"the shipped settings.toml sets keys the launcher does not show: {sorted(set(shipped) - set(keys))}") diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index 2d531bd..f6339bd 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -89,13 +89,13 @@ def between(source: str, opening: str, closing: str) -> str: """ const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Smooth mouse turning", &smooth)) { + if (option_toggle("disruptor.high_precision_camera", &smooth)) { apply_smooth_turn(smooth); mark_smooth_turn(smooth); } if (!turn_applies) ImGui::EndDisabled(); """ in controls - and menu.count('"Smooth mouse turning"') == 1 + and menu.count('option_toggle("disruptor.high_precision_camera"') == 1 and controls.count("ImGui::BeginDisabled()") == controls.count("ImGui::EndDisabled()"), "the setting is on the Controls tab alone, greyed without mouse aim or geometry correction, applied and staged when changed", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 99b00c9..4226f36 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -122,13 +122,13 @@ def between(source: str, opening: str, closing: str) -> str: require( """ bool by_depth = gl_renderer_world_depth() != 0; if (!geometry) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Depth-sorted world", &by_depth)) { + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } if (!geometry) ImGui::EndDisabled(); """ in enhancements - and menu.count('"Depth-sorted world"') == 1 + and menu.count('option_toggle("disruptor.depth_sorted_world"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), "the setting is on the Enhancements tab alone, greyed without geometry correction, applied and staged when changed", ) diff --git a/tests/unit/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp index 58540ba..95cee36 100644 --- a/tests/unit/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -28,6 +28,7 @@ struct Host { double display_hz = 143.86; }; struct Slider { + std::string label; bool moved = false; int moved_to = 0; int shown = -1; @@ -38,6 +39,7 @@ struct Slider { std::string text; }; struct Switch { + std::string label; bool clicked = false; bool shown = false; int disabled_when_drawn = -1; @@ -77,7 +79,8 @@ extern "C" void gl_renderer_interpolation_diag(int *, int *, int *, double *host namespace ImGui { void BeginDisabled() { ++g_slider.disabled; } -bool Checkbox(const char *, bool *value) { +bool Checkbox(const char *label, bool *value) { + g_switch.label = label; g_switch.shown = *value; g_switch.disabled_when_drawn = g_slider.disabled; const bool taken = g_switch.clicked && g_slider.disabled == 0; @@ -86,7 +89,8 @@ bool Checkbox(const char *, bool *value) { } void EndDisabled() { --g_slider.disabled; } void SetNextItemWidth(float) {} -bool SliderInt(const char *, int *value, int lowest, int highest, const char *, int) { +bool SliderInt(const char *label, int *value, int lowest, int highest, const char *, int) { + g_slider.label = label; g_slider.shown = *value; g_slider.disabled_when_drawn = g_slider.disabled; g_slider.lowest = lowest; @@ -119,8 +123,9 @@ void test_the_control_hands_over_what_the_user_chose() { launcher_override(""); g_host = Host{}; g_slider = Slider{}; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 0, + expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0, "a control nobody touched changes nothing"); + expect(g_slider.label == "Rate", "the control is drawn under the label it was given"); expect(g_slider.shown == 60 && g_slider.lowest == 30 && g_slider.highest == 144 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 0, "it shows the host's target between 30 and the display's rounded rate"); @@ -128,19 +133,19 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.text.find("launcher") == std::string::npos, "and says where its top comes from"); launcher_override("90"); - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.text.find("PSX_FRAME_INTERPOLATION_FPS of the launcher replaces it") != std::string::npos && g_slider.text.find("not known") == std::string::npos, "with the launcher's override set it says the override wins at the next start"); for (const char *refused : {"29", "1001", "-5"}) { launcher_override(refused); - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.text.find("launcher") == std::string::npos, "a launcher rate the start refuses is not announced as replacing the saved one"); } launcher_override(""); g_host.display_hz = 1000.6; - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.highest == 120 && g_slider.text.find("outside the host's range") != std::string::npos, "a refresh rate past the host's range gives 120, as the sentence says"); g_host.display_hz = 143.86; @@ -148,21 +153,21 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider = Slider{}; g_slider.moved = true; g_slider.moved_to = 30; - expect(disruptor_present_rate_control(&accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, + expect(disruptor_present_rate_control("Rate", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, "a moved control sets the chosen rate and reports it"); g_host = Host{}; g_host.accepts = 0; accepted = -7; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 1, + expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 1, "a rate the host refuses is not reported as chosen"); g_host = Host{}; - expect(disruptor_present_rate_control(nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); + expect(disruptor_present_rate_control("Rate", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); g_host = Host{}; g_host.display_hz = 59.94; g_host.target = 0; g_slider = Slider{}; - (void)disruptor_present_rate_control(&accepted); + (void)disruptor_present_rate_control("Rate", &accepted); expect(g_slider.highest == 120 && g_slider.shown == 120, "on a 60 Hz display the top is 120 and a target of 0 is shown as the top"); g_host = Host{}; @@ -171,7 +176,7 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.moved = true; g_slider.moved_to = 45; accepted = -7; - expect(disruptor_present_rate_control(&accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 1, "with in-between frames off the control is drawn disabled and sets nothing"); expect(g_slider.text.find("In-between frames are off") != std::string::npos, "and says why"); @@ -196,38 +201,40 @@ void test_the_switch_turns_the_frames_on_and_off() { g_host.enabled = 0; g_slider = Slider{}; g_switch = Switch{}; - expect(disruptor_present_rate_switch(1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && + expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && g_switch.label == "Switch", + "the switch is drawn under the label it was given"); + expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && g_switch.disabled_when_drawn == 0, "a switch nobody touched changes nothing and shows the host's state"); g_switch.clicked = true; - expect(disruptor_present_rate_switch(1, &state) == 1 && state == 1 && g_host.set_enabled == 1, + expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, "a click on a switch that is off turns the frames on and reports it"); g_host = Host{}; state = -7; - expect(disruptor_present_rate_switch(1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, + expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, "a click on a switch that is on turns them off and reports it"); g_host = Host{}; g_host.enabled = 0; g_host.accepts = 0; state = -7; - expect(disruptor_present_rate_switch(1, &state) == 0 && state == -7 && g_host.sets == 1, + expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 1, "a switch the host refuses is not reported as changed"); g_host = Host{}; g_host.enabled = 0; - expect(disruptor_present_rate_switch(1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); + expect(disruptor_present_rate_switch("Switch", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); g_host = Host{}; g_host.enabled = 0; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch(0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_switch("Switch", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_switch.disabled_when_drawn == 1, "without exact geometry the switch is drawn disabled and sets nothing"); expect(g_slider.text.find("Enable exact geometry") != std::string::npos, "and says what it waits for"); g_host = Host{}; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch(0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && + expect(disruptor_present_rate_switch("Switch", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.disabled_when_drawn == 0 && g_slider.text.empty(), "frames that are on can be switched off without exact geometry too"); } From bc1173be17781b6689f3012be36f9401e9dbd9d4 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:18:13 +0300 Subject: [PATCH 11/21] fix: grey a setting by one rule of what it needs, in the launcher and the menu --- src/game/settings/options.cpp | 35 ++++++---- src/game/settings/options.h | 5 +- src/game/ui/dev_menu.cpp | 47 +++++++++---- src/launcher/launcher_settings.cpp | 6 +- .../test_ambient_occlusion_contract.py | 29 ++++---- tests/contracts/test_dev_menu_contract.py | 68 +++++++++++++++++++ tests/contracts/test_frame_rate_contract.py | 5 +- tests/contracts/test_gap_filling_contract.py | 8 ++- tests/contracts/test_launcher_contract.py | 5 ++ tests/contracts/test_smooth_turn_contract.py | 7 +- tests/contracts/test_world_depth_contract.py | 8 ++- tests/unit/launcher_settings_test.cpp | 34 ++++++++-- 12 files changed, 199 insertions(+), 58 deletions(-) diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index 046131c..59d9788 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -65,10 +65,14 @@ constexpr const char* frames_key = "video.frame_interpolation"; constexpr const char* rate_key = "video.frame_interpolation_fps"; constexpr const char* occlusion_key = "disruptor.ambient_occlusion"; constexpr const char* aim_key = "disruptor.mouse_aim"; +constexpr const char* needs_geometry[] = {geometry_key}; +constexpr const char* needs_frames[] = {frames_key}; +constexpr const char* needs_occlusion[] = {occlusion_key}; +constexpr const char* needs_aim_and_geometry[] = {aim_key, geometry_key}; template constexpr Option toggle(const char* key, const char* label, int initial, const char* hint, - const char* needs = nullptr) { + std::span needs = {}) { return {key, Kind::toggle, label, saved, read, write, initial, 0, 1, {}, needs, Unit::none, hint}; } @@ -76,12 +80,12 @@ constexpr Option toggle(const char* key, const char* label, int initial, const c constexpr Option choice(const char* key, const char* label, int initial, const char* hint, std::span choices, Saved saved, Get get, Set set) { return {key, Kind::choice, label, saved, get, set, initial, 0, static_cast(choices.size()) - 1, - choices, nullptr, Unit::none, hint}; + choices, {}, Unit::none, hint}; } constexpr Option slider(const char* key, const char* label, int initial, const char* hint, int lowest, int highest, Unit unit, Saved saved, Get get, Set set, - const char* needs = nullptr) { + std::span needs = {}) { return {key, Kind::slider, label, saved, get, set, initial, lowest, highest, {}, needs, unit, hint}; } @@ -116,14 +120,14 @@ constexpr Option option_list[] = { toggle<&Settings::has_perspective_textures, &Settings::perspective_textures>( "disruptor.perspective_textures", "Perspective-correct world textures", 1, "Draws wall and floor textures in true perspective. The PlayStation's own way bends them near " - "the viewer.", geometry_key), + "the viewer.", needs_geometry), toggle<&Settings::has_gap_filling, &Settings::gap_filling>( "disruptor.gap_filling", "Gap filling", 1, - "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", geometry_key), + "Closes hairline gaps between polygons and keeps the game's own patches off their neighbours.", needs_geometry), toggle<&Settings::has_depth_sorted_world, &Settings::depth_sorted_world>( "disruptor.depth_sorted_world", "Depth-sorted world", 0, "Draws world polygons by their depth where the game's own order puts a far one over a near " - "one.", geometry_key), + "one.", needs_geometry), choice("disruptor.improved_shadows", "Shadows", 0, "Vanilla is the game's own shape. Improved lays a shadow flat on the floor: as wide as the " "game makes it and as tall as the floor's perspective gives. Improved needs exact " @@ -136,12 +140,12 @@ constexpr Option option_list[] = { toggle<&Settings::has_frame_interpolation, &Settings::frame_interpolation>( "video.frame_interpolation", "In-between frames", 0, "Shows extra pictures between the game's frames, built from the game's own geometry, for " - "smoother motion.", geometry_key), + "smoother motion.", needs_geometry), slider("video.frame_interpolation_fps", "In-between frame rate", 0, "Pictures shown per second while in-between frames are on.", kDisruptorPresentRateLowest, kDisruptorPresentRateHighest, Unit::frames, saved<&Settings::has_frame_interpolation_fps>, read<&Settings::frame_interpolation_fps>, - write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key), + write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, needs_frames), toggle<&Settings::has_skip_intro, &Settings::skip_intro>( "disruptor.skip_intro", "Skip the logos and the title movie", 0, "Starts at the main menu without the logos and the title movie. Escape skips a logo or a " @@ -165,7 +169,7 @@ constexpr Option option_list[] = { toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( "disruptor.high_precision_camera", "Smooth mouse turning", 0, "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - "a step in between. Needs exact geometry.", aim_key), + "a step in between. Needs exact geometry.", needs_aim_and_geometry), toggle<&Settings::has_vertical_look, &Settings::vertical_look>( "disruptor.vertical_look", "Vertical mouse look", 0, "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " @@ -187,15 +191,15 @@ constexpr Option option_list[] = { toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( "disruptor.ambient_occlusion", "Ambient occlusion", 0, - "Shades corners and the floor under things. Unfinished.", geometry_key), + "Shades corners and the floor under things. Unfinished.", needs_geometry), slider("disruptor.occlusion_reach", "Occlusion reach", 160, "How far from a corner or an object the shade spreads.", 50, 800, Unit::none, saved<&Settings::has_occlusion_reach>, read<&Settings::occlusion_reach>, - write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key), + write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, needs_occlusion), slider("disruptor.occlusion_strength", "Occlusion strength", 80, "How dark the shade is.", 0, 300, Unit::percent, saved<&Settings::has_occlusion_strength>, read<&Settings::occlusion_strength>, - write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key), + write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, needs_occlusion), }; } // namespace @@ -207,4 +211,11 @@ const Option* find_option(const char* key) { return nullptr; } +bool applies(const Option& option, const std::function& on) { + return std::all_of(option.needs.begin(), option.needs.end(), [&](const char* key) { + const Option* needed = find_option(key); + return needed && on(*needed) && applies(*needed, on); + }); +} + } // namespace disruptor::settings diff --git a/src/game/settings/options.h b/src/game/settings/options.h index b6e5dd9..c939a5a 100644 --- a/src/game/settings/options.h +++ b/src/game/settings/options.h @@ -2,6 +2,7 @@ #include "config_loader.h" +#include #include // The settings a player can change, said once for the launcher and for the in-game menu. @@ -24,12 +25,14 @@ struct Option { int lowest; int highest; std::span choices; - const char* needs; // the toggle that must be on for this option to apply + std::span needs; // the toggles that must be on for this option to apply Unit unit; const char* hint; }; std::span options(); const Option* find_option(const char* key); +// Whether every toggle the option needs is on, as `on` tells, and every toggle those need in turn. +bool applies(const Option& option, const std::function& on); } // namespace disruptor::settings diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index 72e998e..e568fa7 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -198,6 +198,19 @@ bool changed(const char *key) { return g_preferences.changed.contains(find_option(key)); } +bool prerequisite_on(const Option &option) { + const std::string_view key(option.key); + if (key == "disruptor.geometry_correction") + return gpu_geometry_correction_enabled() != 0; + if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0; + if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0; + return false; +} + +bool applies(const char *key) { + return disruptor::settings::applies(*find_option(key), prerequisite_on); +} + bool option_toggle(const char *key, bool *value) { return ImGui::Checkbox(find_option(key)->label, value); } @@ -734,7 +747,7 @@ void draw_controls_tab() { mark_invert_x(invert_x); } - const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; + const bool turn_applies = applies("disruptor.high_precision_camera"); bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.high_precision_camera", &smooth)) { @@ -941,35 +954,38 @@ void draw_enhancements_tab() { draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); bool textures = gpu_texture_correction_enabled() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool textures_apply = applies("disruptor.perspective_textures"); + if (!textures_apply) ImGui::BeginDisabled(); if (option_toggle("disruptor.perspective_textures", &textures)) { gpu_texture_correction_set(textures ? 1 : 0); mark_textures(textures); } - if (!geometry) ImGui::EndDisabled(); + if (!textures_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (!geometry) + if (!textures_apply) ImGui::TextDisabled("Enable exact geometry before perspective textures."); bool gaps = gl_renderer_gap_fill() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } - if (!geometry) ImGui::EndDisabled(); + if (!gaps_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); ImGui::TextDisabled( "Closes hairline gaps between polygons and keeps the game's own " "patches off their neighbours."); bool by_depth = gl_renderer_world_depth() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } - if (!geometry) ImGui::EndDisabled(); + if (!depth_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); ImGui::TextDisabled( "Draws world polygons by their depth where the game's own order puts a " @@ -1003,7 +1019,8 @@ void draw_enhancements_tab() { #ifndef PSX_DISABLE_FRAME_INTERPOLATION int present_enabled = 0; if (disruptor_present_rate_switch( - find_option("video.frame_interpolation")->label, geometry ? 1 : 0, + find_option("video.frame_interpolation")->label, + applies("video.frame_interpolation") ? 1 : 0, &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; @@ -1043,24 +1060,26 @@ void draw_experimental_tab() { "correction on."); ImGui::Spacing(); - const bool geometry = gpu_geometry_correction_enabled() != 0; bool occlusion = gl_renderer_ssao() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } - if (!geometry) ImGui::EndDisabled(); + if (!occlusion_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); int reach = static_cast(std::lround(gl_renderer_ssao_radius())); int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); - if (!geometry) ImGui::BeginDisabled(); + const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); ImGui::SetNextItemWidth(260.0f); const bool strength_moved = option_slider("disruptor.occlusion_strength", &strength); - if (!geometry) ImGui::EndDisabled(); + if (!shape_applies) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index 98a4e72..9b84723 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -121,15 +121,15 @@ int SettingsFile::shown(const Option& option) const { } bool SettingsFile::applies(const Option& option) const { - const Option* needed = option.needs ? find_option(option.needs) : nullptr; - return !needed || (shown(*needed) != 0 && applies(*needed)); + return settings::applies(option, [this](const Option& needed) { return shown(needed) != 0; }); } void SettingsFile::put(Settings& settings, const Option& option, int value) { option.set(settings, value); if (option.kind != Kind::toggle || value != 0) return; + const auto named = [&](const char* key) { return std::string_view(key) == option.key; }; for (const auto& other : options()) { - if (other.kind == Kind::toggle && other.needs && std::string_view(other.needs) == option.key && + if (other.kind == Kind::toggle && std::any_of(other.needs.begin(), other.needs.end(), named) && stored(settings, other) != 0) put(settings, other, 0); } diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index b2e7acb..95fdf3b 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -81,19 +81,23 @@ def between(source: str, opening: str, closing: str) -> str: experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") require( - """ const bool geometry = gpu_geometry_correction_enabled() != 0; - bool occlusion = gl_renderer_ssao() != 0; - if (!geometry) ImGui::BeginDisabled(); + """ bool occlusion = gl_renderer_ssao() != 0; + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { gl_renderer_set_ssao(occlusion ? 1 : 0); mark_occlusion(occlusion); } - if (!geometry) ImGui::EndDisabled(); + if (!occlusion_applies) ImGui::EndDisabled(); """ in experimental - and """ const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); + and """ const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); + ImGui::SetNextItemWidth(260.0f); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); """ in experimental and """ option_slider("disruptor.occlusion_strength", &strength); - if (!geometry) ImGui::EndDisabled(); + if (!shape_applies) ImGui::EndDisabled(); if (reach_moved || strength_moved) { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); @@ -104,7 +108,7 @@ def between(source: str, opening: str, closing: str) -> str: and experimental.count("option_toggle(") == 1 and "ImGui::Checkbox(" not in experimental and ' if (ImGui::BeginTabItem("Experimental")) {\n draw_experimental_tab();\n' in menu, - "the Experimental tab holds the occlusion alone: a switch and two bounded sliders, greyed without geometry correction", + "the Experimental tab holds the occlusion alone: a switch greyed without geometry correction and two bounded sliders greyed without the switch", ) require( @@ -118,8 +122,8 @@ def between(source: str, opening: str, closing: str) -> str: and STAGED in menu and MERGED in menu and ' toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>(\n "disruptor.ambient_occlusion",' in options - and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, occlusion_key)" in options - and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, occlusion_key)" in options + and "write<&Settings::has_occlusion_reach, &Settings::occlusion_reach>, needs_occlusion)" in options + and "write<&Settings::has_occlusion_strength, &Settings::occlusion_strength>, needs_occlusion)" in options and """ if (!env_override_present("PSX_GL_SSAO")) { if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); """ in menu @@ -162,11 +166,12 @@ def between(source: str, opening: str, closing: str) -> str: reach = between(options, 'slider("disruptor.occlusion_reach", "Occlusion reach", 160,', "),\n") strength = between(options, 'slider("disruptor.occlusion_strength", "Occlusion strength", 80,', "),\n") require( - switch.endswith("geometry_key") + switch.endswith("needs_geometry") and "50, 800, Unit::none," in reach - and reach.endswith("occlusion_key") + and reach.endswith("needs_occlusion") and "0, 300, Unit::percent," in strength - and strength.endswith("occlusion_key") + and strength.endswith("needs_occlusion") + and "constexpr const char* needs_occlusion[] = {occlusion_key};\n" in options and 'constexpr const char* occlusion_key = "disruptor.ambient_occlusion";\n' in options and ' {"disruptor.ambient_occlusion", Page::experimental},\n' ' {"disruptor.occlusion_reach", Page::experimental},\n' diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 7d664e3..4924809 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -813,4 +813,72 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: '"The loudness of everything the game plays.", 0, 100, Unit::percent,' in options, "the menu must draw every shared option by its key, with the table's label, bounds and choices and none of its own") +RULE = ( + "bool applies(const Option& option, const std::function& on) {\n" + " return std::all_of(option.needs.begin(), option.needs.end(), [&](const char* key) {\n" + " const Option* needed = find_option(key);\n" + " return needed && on(*needed) && applies(*needed, on);\n" + " });\n" + "}\n" +) +LIVE = ( + "bool prerequisite_on(const Option &option) {\n" + " const std::string_view key(option.key);\n" + ' if (key == "disruptor.geometry_correction")\n' + " return gpu_geometry_correction_enabled() != 0;\n" + ' if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0;\n' + ' if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0;\n' + " return false;\n" + "}\n" + "\n" + "bool applies(const char *key) {\n" + " return disruptor::settings::applies(*find_option(key), prerequisite_on);\n" + "}\n" +) +key_names = dict(re.findall(r'constexpr const char\* (\w+_key) = "([\w.]+)";', options)) +needs_lists = { + name: [key_names[word.strip()] for word in words.split(",")] + for name, words in re.findall(r"constexpr const char\* (needs_\w+)\[\] = \{([^}]*)\};", options) +} +table_rows = re.split(r"\n (?=toggle<|choice\(|slider\()", options[options.index("constexpr Option option_list[]") :])[1:] +needing = {} +for row in table_rows: + row = row.split("\n};")[0] + ending = re.search(r"\b(needs_\w+)\),?\s*$", row) + if ending: + needing[re.search(r'"([\w.]+)"', row)[1]] = needs_lists[ending[1]] +asked = set(re.findall(r'\bapplies\("([\w.]+)"\)', menu)) +told = set(re.findall(r'key == "([\w.]+)"', function_body(menu, "prerequisite_on"))) +require(RULE in options and LIVE in menu and len(needing) == 9 and + needing["disruptor.high_precision_camera"] == ["disruptor.mouse_aim", "disruptor.geometry_correction"] and + asked == set(needing) - {"video.frame_interpolation_fps"} and + told == {key for keys in needing.values() for key in keys} - {"video.frame_interpolation"} and + " if (!enabled) ImGui::BeginDisabled();\n" in function_body(present, "disruptor_present_rate_control") and + "if (!geometry)" not in menu and "turn_applies = mouse_aim" not in menu, + "the menu must grey a control by the shared table's rule alone, asked of the running game, and the " + "rate control must grey itself while in-between frames are off") + + +def greyed_by_own_rule(source: str, key: str) -> bool: + rule = re.search(r'const bool (\w+) =[^;]*\bapplies\("' + re.escape(key) + r'"\)[^;]*;\n', source) + if rule is None: + return False + begin, end = f" if (!{rule[1]}) ImGui::BeginDisabled();\n", f" if (!{rule[1]}) ImGui::EndDisabled();\n" + control = re.search(r'\boption_(?:toggle|slider|list)\("' + re.escape(key) + '"', source) + if control is None or source.count(begin) != 1 or source.count(end) != 1: + return False + opened, closed = source.index(begin), source.index(end) + return (rule.end() <= opened < control.start() < closed and + "Disabled()" not in source[opened + len(begin):control.start()] and + "BeginDisabled()" not in source[control.start():closed]) + + +greyed = asked - {"video.frame_interpolation"} +require(len(greyed) == 7 and all(greyed_by_own_rule(menu, key) for key in greyed) and + not greyed_by_own_rule(menu.replace(" if (!textures_apply) ImGui::BeginDisabled();\n", + " if (false) ImGui::BeginDisabled();\n"), "disruptor.perspective_textures") and + not greyed_by_own_rule(menu.replace('applies("disruptor.gap_filling")', 'applies("disruptor.vsync")'), + "disruptor.gap_filling"), + "each control that needs something must be drawn inside a disabled block opened by its own rule") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 0b72189..1f54eb8 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -193,7 +193,8 @@ def require(condition: bool, message: str) -> None: and "#ifndef PSX_DISABLE_FRAME_INTERPOLATION\n" " int present_enabled = 0;\n" " if (disruptor_present_rate_switch(\n" - ' find_option("video.frame_interpolation")->label, geometry ? 1 : 0,\n' + ' find_option("video.frame_interpolation")->label,\n' + ' applies("video.frame_interpolation") ? 1 : 0,\n' " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" @@ -268,7 +269,7 @@ def require(condition: bool, message: str) -> None: require( 'void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); }\n' in menu and MERGED in menu - and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, frames_key)" in options, + and "write<&Settings::has_frame_interpolation_fps, &Settings::frame_interpolation_fps>, needs_frames)" in options, "a changed in-between frame rate must reach settings.toml", ) require( diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 3e2efe6..292cb94 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -142,12 +142,13 @@ def between(source: str, opening: str, closing: str) -> str: enhancements = between(menu, "void draw_enhancements_tab() {", "const char *cheat_result_message(") require( """ bool gaps = gl_renderer_gap_fill() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); if (option_toggle("disruptor.gap_filling", &gaps)) { gl_renderer_set_gap_fill(gaps ? 1 : 0); mark_gap_filling(gaps); } - if (!geometry) ImGui::EndDisabled(); + if (!gaps_apply) ImGui::EndDisabled(); """ in enhancements and menu.count('option_toggle("disruptor.gap_filling"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), @@ -184,7 +185,8 @@ def between(source: str, opening: str, closing: str) -> str: row = between(options, '"disruptor.gap_filling", "Gap filling", 1,', "),\n") require( - row.endswith("geometry_key") + row.endswith("needs_geometry") + and "constexpr const char* needs_geometry[] = {geometry_key};\n" in options and options.count('"disruptor.gap_filling"') == launcher.count('"disruptor.gap_filling"') == 1 and ' {"disruptor.gap_filling", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, on at first and only with geometry correction", diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index a5bbcda..0108a2d 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -60,6 +60,11 @@ def plain_values(text: str) -> dict[str, str]: and " for (const auto& placement : layout()) {\n const Option& option = *find_option(placement.key);\n" in window, "the launcher puts each shared option on one page and describes none itself", ) +require( + " return settings::applies(option, [this](const Option& needed) { return shown(needed) != 0; });\n" in table + and "std::any_of(other.needs.begin(), other.needs.end(), named)" in table, + "the launcher greys a row by the shared rule asked of its file, and a toggle switched off takes with it the toggles that need it", +) writer = loader[loader.index("bool save_user_settings(") :] saved, section = set(), "" diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index f6339bd..f182d04 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -86,7 +86,7 @@ def between(source: str, opening: str, closing: str) -> str: controls = between(menu, "void draw_controls_tab() {", "void apply_geometry_enabled(") require( - """ const bool turn_applies = mouse_aim && gpu_geometry_correction_enabled() != 0; + """ const bool turn_applies = applies("disruptor.high_precision_camera"); bool smooth = disruptor_high_precision_camera_enabled() != 0; if (!turn_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.high_precision_camera", &smooth)) { @@ -124,11 +124,12 @@ def between(source: str, opening: str, closing: str) -> str: row = between(options, '"disruptor.high_precision_camera", "Smooth mouse turning", 0,', "),\n") require( - row.endswith("aim_key") + row.endswith("needs_aim_and_geometry") + and "constexpr const char* needs_aim_and_geometry[] = {aim_key, geometry_key};\n" in options and 'constexpr const char* aim_key = "disruptor.mouse_aim";\n' in options and options.count('"disruptor.high_precision_camera"') == launcher.count('"disruptor.high_precision_camera"') == 1 and ' {"disruptor.high_precision_camera", Page::controls},\n' in launcher, - "the launcher offers it on the Controls page, off at first and only with mouse aim", + "the launcher offers it on the Controls page, off at first and only with mouse aim and geometry correction", ) print("Disruptor smooth mouse turning source contract: PASS") diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 4226f36..58adbfc 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -121,12 +121,13 @@ def between(source: str, opening: str, closing: str) -> str: enhancements = between(menu, "void draw_enhancements_tab() {", "const char *cheat_result_message(") require( """ bool by_depth = gl_renderer_world_depth() != 0; - if (!geometry) ImGui::BeginDisabled(); + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { gl_renderer_set_world_depth(by_depth ? 1 : 0); mark_world_depth(by_depth); } - if (!geometry) ImGui::EndDisabled(); + if (!depth_applies) ImGui::EndDisabled(); """ in enhancements and menu.count('option_toggle("disruptor.depth_sorted_world"') == 1 and enhancements.count("ImGui::BeginDisabled()") == enhancements.count("ImGui::EndDisabled()"), @@ -165,7 +166,8 @@ def between(source: str, opening: str, closing: str) -> str: row = between(options, '"disruptor.depth_sorted_world", "Depth-sorted world", 0,', "),\n") require( - row.endswith("geometry_key") + row.endswith("needs_geometry") + and "constexpr const char* needs_geometry[] = {geometry_key};\n" in options and options.count('"disruptor.depth_sorted_world"') == launcher.count('"disruptor.depth_sorted_world"') == 1 and ' {"disruptor.depth_sorted_world", Page::enhancements},\n' in launcher, "the launcher offers it on the Enhancements page, off at first and only with geometry correction", diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 865e281..36ba231 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace fs = std::filesystem; using namespace disruptor::launcher; @@ -54,7 +55,8 @@ void table() { require(!one.choices.empty() && one.initial >= 0 && one.initial < static_cast(one.choices.size()) && one.highest == static_cast(one.choices.size()) - 1, "a choice starts outside its list"); if (one.kind == Kind::toggle) require(one.initial == 0 || one.initial == 1, "a toggle starts as neither"); - if (one.needs) require(option(one.needs).kind == Kind::toggle && &option(one.needs) != &one, "a row needs no toggle"); + for (const char* need : one.needs) + require(option(need).kind == Kind::toggle && &option(need) != &one, "a row needs no toggle"); require(one.hint && std::strlen(one.hint) >= 20 && std::strlen(one.hint) <= 240, "a row has no hint to show"); } require(presets().size() == 2, "the two presets are expected"); @@ -104,6 +106,24 @@ void carried() { "a sensitivity is not written in whole thousandths"); } +// The rule both front ends ask: every toggle an option needs is on, and so is what those need. +void needed() { + const auto on = [](std::set keys) { + return [keys = std::move(keys)](const Option& toggle) { return keys.contains(toggle.key); }; + }; + const Option& smooth = option("disruptor.high_precision_camera"); + const Option& reach = option("disruptor.occlusion_reach"); + require(disruptor::settings::applies(option("video.vsync"), on({})), "an option that needs nothing always applies"); + require(disruptor::settings::applies(smooth, on({"disruptor.mouse_aim", "disruptor.geometry_correction"})) && + !disruptor::settings::applies(smooth, on({"disruptor.mouse_aim"})) && + !disruptor::settings::applies(smooth, on({"disruptor.geometry_correction"})), + "an option applies only with every toggle it needs"); + require(disruptor::settings::applies(reach, on({"disruptor.ambient_occlusion", "disruptor.geometry_correction"})) && + !disruptor::settings::applies(reach, on({"disruptor.ambient_occlusion"})) && + !disruptor::settings::applies(reach, on({"disruptor.geometry_correction"})), + "and only when what it needs applies itself"); +} + void placements() { std::set placed; for (const auto& row : layout()) { @@ -218,10 +238,13 @@ void dependants(const fs::path& folder) { const Option& by_depth = option("disruptor.depth_sorted_world"); const Option& occlusion = option("disruptor.ambient_occlusion"); const Option& reach = option("disruptor.occlusion_reach"); - require(settings.set(aim, 1) && settings.set(smooth, 1) && settings.applies(smooth) && settings.set(geometry, 0) && - settings.shown(smooth) == 1 && settings.applies(smooth), "smooth turning needs mouse aim, not geometry"); - require(settings.set(aim, 0) && settings.shown(smooth) == 0 && !settings.applies(smooth) && - has(file, "high_precision_camera = false"), "and goes off with mouse aim"); + require(settings.set(aim, 1) && settings.set(smooth, 1) && settings.applies(smooth) && settings.set(aim, 0) && + settings.shown(smooth) == 0 && !settings.applies(smooth) && has(file, "high_precision_camera = false"), + "smooth turning needs mouse aim and goes off with it"); + require(settings.set(aim, 1) && settings.applies(smooth) && settings.set(smooth, 1) && + has(file, "high_precision_camera = true") && settings.set(geometry, 0) && settings.shown(smooth) == 0 && + !settings.applies(smooth) && has(file, "high_precision_camera = false") && settings.set(aim, 0), + "and geometry, and goes off with that too"); require(settings.shown(gaps) == 0 && has(file, "gap_filling = false"), "gap filling, on in a fresh installation, went off with geometry above and was written so"); require(settings.set(geometry, 1) && settings.set(gaps, 1) && settings.set(by_depth, 1) && settings.set(occlusion, 1) && @@ -468,6 +491,7 @@ int main() { fs::create_directories(folder); table(); carried(); + needed(); placements(); absent(folder); one_change(folder); From fac502d3bd3cf78e16a6f452047192d210bc0df0 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:24:18 +0300 Subject: [PATCH 12/21] feat: show a setting's description under the pointer in the in-game menu --- docs/PLAYING.md | 3 ++ src/game/frame/present_rate.cpp | 18 ++++--- src/game/frame/present_rate.h | 4 +- src/game/settings/options.cpp | 2 +- src/game/ui/dev_menu.cpp | 50 ++++++++---------- src/game/ui/hint.h | 16 ++++++ .../test_ambient_occlusion_contract.py | 2 +- tests/contracts/test_dev_menu_contract.py | 52 +++++++++++++++---- tests/contracts/test_frame_rate_contract.py | 21 +++++--- tests/support/present_rate_fakes/imgui.h | 6 +++ tests/unit/disruptor_present_rate_test.cpp | 47 ++++++++++------- 11 files changed, 146 insertions(+), 75 deletions(-) create mode 100644 src/game/ui/hint.h diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 38b99e5..37fbc1d 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -77,6 +77,9 @@ are enabled initially. Vertical mouse look is experimental and starts off; enable it in **Settings → Controls** if desired. **Smooth mouse turning** in the same tab turns the picture between the game's 256 heading steps. It requires captured mouse aim and exact geometry. +A setting shows its description under the pointer, the same one as in the +launcher. A setting that needs another one stays greyed until that one is on. +In-between frames that are on can still be switched off. **Settings → Enhancements** offers 1x–8x resolution, widescreen options, HUD size for widescreen, and 60 FPS gameplay and in-between frames. Both diff --git a/src/game/frame/present_rate.cpp b/src/game/frame/present_rate.cpp index 4a6acf4..1b7051b 100644 --- a/src/game/frame/present_rate.cpp +++ b/src/game/frame/present_rate.cpp @@ -5,6 +5,8 @@ #include "game/frame/present_rate.h" +#include "game/ui/hint.h" + #include "host_ui.h" #include "imgui.h" @@ -20,14 +22,15 @@ int disruptor_present_rate_enable(int enabled) { return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0; } -int disruptor_present_rate_switch(const char *label, int available, int *enabled) { +int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled) { int on = 0, changed = 0; psx_host_video_get_interpolation(&on, nullptr, nullptr); bool checked = on != 0; const bool locked = !available && !checked; if (locked) ImGui::BeginDisabled(); - if (ImGui::Checkbox(label, &checked) && - disruptor_present_rate_enable(checked ? 1 : 0)) { + const bool clicked = ImGui::Checkbox(label, &checked); + disruptor::ui::hint(hint); + if (clicked && disruptor_present_rate_enable(checked ? 1 : 0)) { if (enabled) *enabled = checked ? 1 : 0; changed = 1; } @@ -43,7 +46,7 @@ int disruptor_present_rate_apply(int frames_per_second) { return enabled && psx_host_video_set_interpolation(1, frames_per_second, blend) != 0; } -int disruptor_present_rate_control(const char *label, int *accepted) { +int disruptor_present_rate_control(const char *label, const char *hint, int *accepted) { int enabled = 0, target = 0, changed = 0; double display_hz = 0.0; psx_host_video_get_interpolation(&enabled, &target, nullptr); @@ -52,9 +55,10 @@ int disruptor_present_rate_control(const char *label, int *accepted) { int shown = disruptor_present_rate_shown(target, top); if (!enabled) ImGui::BeginDisabled(); ImGui::SetNextItemWidth(260.0f); - if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", - ImGuiSliderFlags_AlwaysClamp) && - disruptor_present_rate_apply(shown)) { + const bool moved = ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS", + ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(hint); + if (moved && disruptor_present_rate_apply(shown)) { if (accepted) *accepted = shown; changed = 1; } diff --git a/src/game/frame/present_rate.h b/src/game/frame/present_rate.h index 2ab5f3c..9832f5f 100644 --- a/src/game/frame/present_rate.h +++ b/src/game/frame/present_rate.h @@ -28,10 +28,10 @@ constexpr int disruptor_present_rate_shown(int target_fps, int top) { /* Switches in-between frames on or off at the rate and blend the host has. Returns 0 when the host refused. */ int disruptor_present_rate_enable(int enabled); /* Draws the switch: frames that are off cannot be switched on while `available` is 0. Returns 1 and the state in `enabled` when the user changed it and the host took it. */ -int disruptor_present_rate_switch(const char *label, int available, int *enabled); +int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled); /* Sets the rate of in-between frames that are on. Returns 0 when they are off or the host refused. */ int disruptor_present_rate_apply(int frames_per_second); /* Draws the control. Returns 1 and the rate in `accepted` when the user changed it and the host took it. */ -int disruptor_present_rate_control(const char *label, int *accepted); +int disruptor_present_rate_control(const char *label, const char *hint, int *accepted); #endif diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index 59d9788..15325e4 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -169,7 +169,7 @@ constexpr Option option_list[] = { toggle<&Settings::has_high_precision_camera, &Settings::high_precision_camera>( "disruptor.high_precision_camera", "Smooth mouse turning", 0, "The game turns in 256 steps of a circle. With this on, the picture also turns by the part of " - "a step in between. Needs exact geometry.", needs_aim_and_geometry), + "a step in between. Needs mouse aim and exact geometry.", needs_aim_and_geometry), toggle<&Settings::has_vertical_look, &Settings::vertical_look>( "disruptor.vertical_look", "Vertical mouse look", 0, "Looks up and down with the mouse, to about 31 degrees each way. The original game has no " diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp index e568fa7..df034e0 100644 --- a/src/game/ui/dev_menu.cpp +++ b/src/game/ui/dev_menu.cpp @@ -17,6 +17,7 @@ #include "game/frame/present_rate.h" #include "game/session/restart.h" #include "game/settings/options.h" +#include "game/ui/hint.h" #include "config_loader.h" #include "gpu.h" #include "host_ui.h" @@ -212,29 +213,38 @@ bool applies(const char *key) { } bool option_toggle(const char *key, bool *value) { - return ImGui::Checkbox(find_option(key)->label, value); + const Option &option = *find_option(key); + const bool moved = ImGui::Checkbox(option.label, value); + disruptor::ui::hint(option.hint); + return moved; } bool option_slider(const char *key, int *value) { const Option &option = *find_option(key); const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; - return ImGui::SliderInt(option.label, value, option.lowest, - option.highest, format, - ImGuiSliderFlags_AlwaysClamp); + const bool moved = + ImGui::SliderInt(option.label, value, option.lowest, option.highest, + format, ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(option.hint); + return moved; } bool option_slider(const char *key, float *value) { const Option &option = *find_option(key); - return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f, - option.highest / 1000.0f, "%.3f", - ImGuiSliderFlags_Logarithmic); + const bool moved = ImGui::SliderFloat( + option.label, value, option.lowest / 1000.0f, + option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic); + disruptor::ui::hint(option.hint); + return moved; } bool option_list(const char *key, int *index, size_t skipped = 0) { const Option &option = *find_option(key); const auto shown = option.choices.subspan(skipped); - return ImGui::Combo(option.label, index, shown.data(), - static_cast(shown.size())); + const bool moved = ImGui::Combo(option.label, index, shown.data(), + static_cast(shown.size())); + disruptor::ui::hint(option.hint); + return moved; } void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } @@ -756,10 +766,6 @@ void draw_controls_tab() { } if (!turn_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "The game turns in 256 steps of a circle. While the mouse is captured " - "the picture is turned by the part of a step in between. Needs mouse aim " - "and exact geometry."); ImGui::SeparatorText("Vertical look"); bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; @@ -902,9 +908,6 @@ void draw_aspect_controls() { mark_shadow_shape(shadow_shape != 0); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Improved lays a shadow flat on the floor: as wide as the game makes it and as tall " - "as the floor's perspective gives. Works with geometry correction on."); } int hud_scale = gpu_ws_hud_scale(); @@ -914,9 +917,6 @@ void draw_aspect_controls() { mark_hud_scale(hud_scale); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Widescreen only. Shrinks the health and ammunition displays toward " - "their screen edges. The weapon keeps its size."); } void draw_internal_resolution_controls() { @@ -974,9 +974,6 @@ void draw_enhancements_tab() { } if (!gaps_apply) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Closes hairline gaps between polygons and keeps the game's own " - "patches off their neighbours."); bool by_depth = gl_renderer_world_depth() != 0; const bool depth_applies = applies("disruptor.depth_sorted_world"); @@ -987,9 +984,6 @@ void draw_enhancements_tab() { } if (!depth_applies) ImGui::EndDisabled(); draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Draws world polygons by their depth where the game's own order puts a " - "far one over a near one."); ImGui::SeparatorText("Synchronisation"); int vsync = psx_host_video_get_vsync(); @@ -1020,12 +1014,14 @@ void draw_enhancements_tab() { int present_enabled = 0; if (disruptor_present_rate_switch( find_option("video.frame_interpolation")->label, + find_option("video.frame_interpolation")->hint, applies("video.frame_interpolation") ? 1 : 0, &present_enabled)) mark_present_enabled(present_enabled != 0); int present_rate = 0; if (disruptor_present_rate_control( - find_option("video.frame_interpolation_fps")->label, &present_rate)) + find_option("video.frame_interpolation_fps")->label, + find_option("video.frame_interpolation_fps")->hint, &present_rate)) mark_present_rate(present_rate); #endif @@ -1036,7 +1032,6 @@ void draw_enhancements_tab() { mark_skip_intro(skip_intro); } draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled("Escape skips the logo or movie on screen."); } const char *cheat_result_message(int result, const char *success) { @@ -1084,7 +1079,6 @@ void draw_experimental_tab() { gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); mark_occlusion_shape(reach, strength); } - ImGui::TextDisabled("Shades corners and the floor under things."); } void draw_debug_tab() { diff --git a/src/game/ui/hint.h b/src/game/ui/hint.h new file mode 100644 index 0000000..267ccb8 --- /dev/null +++ b/src/game/ui/hint.h @@ -0,0 +1,16 @@ +#pragma once + +#include "imgui.h" + +namespace disruptor::ui { + +// Shows `text` while the pointer rests on the control drawn last. +inline void hint(const char *text) { + if (!ImGui::BeginItemTooltip()) return; + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f); + ImGui::TextUnformatted(text); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); +} + +} // namespace disruptor::ui diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 95fdf3b..3710a88 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -79,7 +79,7 @@ def between(source: str, opening: str, closing: str) -> str: "or a strength that is not one changes nothing", ) -experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") +experimental = between(menu, "void draw_experimental_tab() {", "\n}\n") + "\n" require( """ bool occlusion = gl_renderer_ssao() != 0; const bool occlusion_applies = applies("disruptor.ambient_occlusion"); diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 4924809..49a3a9a 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -771,29 +771,38 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: HELPERS = ( "bool option_toggle(const char *key, bool *value) {\n" - " return ImGui::Checkbox(find_option(key)->label, value);\n" + " const Option &option = *find_option(key);\n" + " const bool moved = ImGui::Checkbox(option.label, value);\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" "\n" "bool option_slider(const char *key, int *value) {\n" " const Option &option = *find_option(key);\n" ' const char *format = option.unit == Unit::percent ? "%d%%" : "%d";\n' - " return ImGui::SliderInt(option.label, value, option.lowest,\n" - " option.highest, format,\n" - " ImGuiSliderFlags_AlwaysClamp);\n" + " const bool moved =\n" + " ImGui::SliderInt(option.label, value, option.lowest, option.highest,\n" + " format, ImGuiSliderFlags_AlwaysClamp);\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" "\n" "bool option_slider(const char *key, float *value) {\n" " const Option &option = *find_option(key);\n" - " return ImGui::SliderFloat(option.label, value, option.lowest / 1000.0f,\n" - ' option.highest / 1000.0f, "%.3f",\n' - " ImGuiSliderFlags_Logarithmic);\n" + " const bool moved = ImGui::SliderFloat(\n" + " option.label, value, option.lowest / 1000.0f,\n" + ' option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic);\n' + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" "\n" "bool option_list(const char *key, int *index, size_t skipped = 0) {\n" " const Option &option = *find_option(key);\n" " const auto shown = option.choices.subspan(skipped);\n" - " return ImGui::Combo(option.label, index, shown.data(),\n" - " static_cast(shown.size()));\n" + " const bool moved = ImGui::Combo(option.label, index, shown.data(),\n" + " static_cast(shown.size()));\n" + " disruptor::ui::hint(option.hint);\n" + " return moved;\n" "}\n" ) shared_labels = re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"([^"]+)"', options) @@ -881,4 +890,29 @@ def greyed_by_own_rule(source: str, key: str) -> bool: "disruptor.gap_filling"), "each control that needs something must be drawn inside a disabled block opened by its own rule") +HINT = ( + "inline void hint(const char *text) {\n" + " if (!ImGui::BeginItemTooltip()) return;\n" + " ImGui::PushTextWrapPos(ImGui::GetFontSize() * 30.0f);\n" + " ImGui::TextUnformatted(text);\n" + " ImGui::PopTextWrapPos();\n" + " ImGui::EndTooltip();\n" + "}\n" +) +LITERAL = r'"((?:[^"\\]|\\.)*)"' +shared_hints = [ + "".join(re.findall(LITERAL, pieces)) + for pieces in re.findall( + r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"[^"]+",\s*-?\d+,\s*((?:"(?:[^"\\]|\\.)*"\s*)+)', options) +] +written = "".join(re.findall(LITERAL, menu + present)) +require(HINT in read("src/game/ui/hint.h") and len(shared_hints) == 27 and + all(20 <= len(text) <= 240 for text in shared_hints) and + not [text for text in shared_hints if text[:32] in written] and + "Needs mouse aim and exact geometry." in shared_hints[ + [key for key in re.findall(r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"([\w.]+)"', options)].index( + "disruptor.high_precision_camera")], + "the menu must show an option's description from the shared table while the pointer rests on its control, " + "and write none of the descriptions itself") + print("developer menu contract: PASS") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 1f54eb8..f975773 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -194,12 +194,14 @@ def require(condition: bool, message: str) -> None: " int present_enabled = 0;\n" " if (disruptor_present_rate_switch(\n" ' find_option("video.frame_interpolation")->label,\n' + ' find_option("video.frame_interpolation")->hint,\n' ' applies("video.frame_interpolation") ? 1 : 0,\n' " &present_enabled))\n" " mark_present_enabled(present_enabled != 0);\n" " int present_rate = 0;\n" " if (disruptor_present_rate_control(\n" - ' find_option("video.frame_interpolation_fps")->label, &present_rate))\n' + ' find_option("video.frame_interpolation_fps")->label,\n' + ' find_option("video.frame_interpolation_fps")->hint, &present_rate))\n' " mark_present_rate(present_rate);\n" "#endif\n" in menu @@ -213,9 +215,10 @@ def require(condition: bool, message: str) -> None: "a build without the frame interpolator must have no rate control, in the menu or in the sources", ) require( - 'if (ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' - " ImGuiSliderFlags_AlwaysClamp) &&\n" - " disruptor_present_rate_apply(shown)) {\n" + ' const bool moved = ImGui::SliderInt(label, &shown, kDisruptorPresentRateLowest, top, "%d FPS",\n' + " ImGuiSliderFlags_AlwaysClamp);\n" + " disruptor::ui::hint(hint);\n" + " if (moved && disruptor_present_rate_apply(shown)) {\n" " if (accepted) *accepted = shown;\n" " changed = 1;\n" in present_control @@ -235,10 +238,12 @@ def require(condition: bool, message: str) -> None: " return psx_host_video_set_interpolation(enabled ? 1 : 0, target, blend) != 0;\n" in present_control and '"video.frame_interpolation", "In-between frames", 0,' in options and 'slider("video.frame_interpolation_fps", "In-between frame rate", 0,' in options - and "int disruptor_present_rate_switch(const char *label, int available, int *enabled) {\n" in present_control - and "int disruptor_present_rate_control(const char *label, int *accepted) {\n" in present_control - and "if (ImGui::Checkbox(label, &checked) &&\n" - " disruptor_present_rate_enable(checked ? 1 : 0)) {\n" + and "int disruptor_present_rate_switch(const char *label, const char *hint, int available, int *enabled) {\n" + in present_control + and "int disruptor_present_rate_control(const char *label, const char *hint, int *accepted) {\n" in present_control + and " const bool clicked = ImGui::Checkbox(label, &checked);\n" + " disruptor::ui::hint(hint);\n" + " if (clicked && disruptor_present_rate_enable(checked ? 1 : 0)) {\n" " if (enabled) *enabled = checked ? 1 : 0;\n" in present_control, "the switch must keep the rate and the blend mode and report only a state the host accepted", diff --git a/tests/support/present_rate_fakes/imgui.h b/tests/support/present_rate_fakes/imgui.h index 7c3725b..364c2dc 100644 --- a/tests/support/present_rate_fakes/imgui.h +++ b/tests/support/present_rate_fakes/imgui.h @@ -3,10 +3,16 @@ enum { ImGuiSliderFlags_AlwaysClamp = 16 }; namespace ImGui { void BeginDisabled(); +bool BeginItemTooltip(); bool Checkbox(const char *label, bool *value); void EndDisabled(); +void EndTooltip(); +float GetFontSize(); +void PopTextWrapPos(); +void PushTextWrapPos(float position); void SetNextItemWidth(float width); bool SliderInt(const char *label, int *value, int lowest, int highest, const char *format, int flags); void TextDisabled(const char *text, ...); +void TextUnformatted(const char *text); } // namespace ImGui #endif diff --git a/tests/unit/disruptor_present_rate_test.cpp b/tests/unit/disruptor_present_rate_test.cpp index 95cee36..44782c3 100644 --- a/tests/unit/disruptor_present_rate_test.cpp +++ b/tests/unit/disruptor_present_rate_test.cpp @@ -77,8 +77,11 @@ extern "C" void gl_renderer_interpolation_diag(int *, int *, int *, double *host if (host_hz) *host_hz = g_host.display_hz; } +std::string g_hint; + namespace ImGui { void BeginDisabled() { ++g_slider.disabled; } +bool BeginItemTooltip() { return true; } bool Checkbox(const char *label, bool *value) { g_switch.label = label; g_switch.shown = *value; @@ -88,6 +91,10 @@ bool Checkbox(const char *label, bool *value) { return taken; } void EndDisabled() { --g_slider.disabled; } +void EndTooltip() {} +float GetFontSize() { return 13.0f; } +void PopTextWrapPos() {} +void PushTextWrapPos(float) {} void SetNextItemWidth(float) {} bool SliderInt(const char *label, int *value, int lowest, int highest, const char *, int) { g_slider.label = label; @@ -99,6 +106,7 @@ bool SliderInt(const char *label, int *value, int lowest, int highest, const cha return g_slider.moved && g_slider.disabled == 0; } void TextDisabled(const char *text, ...) { g_slider.text = text; } +void TextUnformatted(const char *text) { g_hint = text; } } // namespace ImGui #include "../../src/game/frame/present_rate.cpp" @@ -123,9 +131,9 @@ void test_the_control_hands_over_what_the_user_chose() { launcher_override(""); g_host = Host{}; g_slider = Slider{}; - expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 0, "a control nobody touched changes nothing"); - expect(g_slider.label == "Rate", "the control is drawn under the label it was given"); + expect(g_slider.label == "Rate" && g_hint == "Rate hint", "the control is drawn under the label and with the hint it was given"); expect(g_slider.shown == 60 && g_slider.lowest == 30 && g_slider.highest == 144 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 0, "it shows the host's target between 30 and the display's rounded rate"); @@ -133,19 +141,19 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.text.find("launcher") == std::string::npos, "and says where its top comes from"); launcher_override("90"); - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.text.find("PSX_FRAME_INTERPOLATION_FPS of the launcher replaces it") != std::string::npos && g_slider.text.find("not known") == std::string::npos, "with the launcher's override set it says the override wins at the next start"); for (const char *refused : {"29", "1001", "-5"}) { launcher_override(refused); - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.text.find("launcher") == std::string::npos, "a launcher rate the start refuses is not announced as replacing the saved one"); } launcher_override(""); g_host.display_hz = 1000.6; - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.highest == 120 && g_slider.text.find("outside the host's range") != std::string::npos, "a refresh rate past the host's range gives 120, as the sentence says"); g_host.display_hz = 143.86; @@ -153,21 +161,21 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider = Slider{}; g_slider.moved = true; g_slider.moved_to = 30; - expect(disruptor_present_rate_control("Rate", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 1 && accepted == 30 && g_host.set_target == 30 && g_host.set_blend == 2, "a moved control sets the chosen rate and reports it"); g_host = Host{}; g_host.accepts = 0; accepted = -7; - expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 1, + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 1, "a rate the host refuses is not reported as chosen"); g_host = Host{}; - expect(disruptor_present_rate_control("Rate", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); + expect(disruptor_present_rate_control("Rate", "Rate hint", nullptr) == 1 && g_host.set_target == 30, "the caller may ask for no report"); g_host = Host{}; g_host.display_hz = 59.94; g_host.target = 0; g_slider = Slider{}; - (void)disruptor_present_rate_control("Rate", &accepted); + (void)disruptor_present_rate_control("Rate", "Rate hint", &accepted); expect(g_slider.highest == 120 && g_slider.shown == 120, "on a 60 Hz display the top is 120 and a target of 0 is shown as the top"); g_host = Host{}; @@ -176,7 +184,7 @@ void test_the_control_hands_over_what_the_user_chose() { g_slider.moved = true; g_slider.moved_to = 45; accepted = -7; - expect(disruptor_present_rate_control("Rate", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_control("Rate", "Rate hint", &accepted) == 0 && accepted == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_slider.disabled_when_drawn == 1, "with in-between frames off the control is drawn disabled and sets nothing"); expect(g_slider.text.find("In-between frames are off") != std::string::npos, "and says why"); @@ -201,40 +209,41 @@ void test_the_switch_turns_the_frames_on_and_off() { g_host.enabled = 0; g_slider = Slider{}; g_switch = Switch{}; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && g_switch.label == "Switch", - "the switch is drawn under the label it was given"); - expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && g_switch.label == "Switch" && + g_hint == "Switch hint", + "the switch is drawn under the label and with the hint it was given"); + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && state == -7 && g_host.sets == 0 && !g_switch.shown && g_switch.disabled_when_drawn == 0, "a switch nobody touched changes nothing and shows the host's state"); g_switch.clicked = true; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 1 && state == 1 && g_host.set_enabled == 1, "a click on a switch that is off turns the frames on and reports it"); g_host = Host{}; state = -7; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.shown, "a click on a switch that is on turns them off and reports it"); g_host = Host{}; g_host.enabled = 0; g_host.accepts = 0; state = -7; - expect(disruptor_present_rate_switch("Switch", 1, &state) == 0 && state == -7 && g_host.sets == 1, + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, &state) == 0 && state == -7 && g_host.sets == 1, "a switch the host refuses is not reported as changed"); g_host = Host{}; g_host.enabled = 0; - expect(disruptor_present_rate_switch("Switch", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); + expect(disruptor_present_rate_switch("Switch", "Switch hint", 1, nullptr) == 1 && g_host.set_enabled == 1, "the caller may ask for no report"); g_host = Host{}; g_host.enabled = 0; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch("Switch", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 0, &state) == 0 && state == -7 && g_host.sets == 0 && g_slider.disabled == 0 && g_switch.disabled_when_drawn == 1, "without exact geometry the switch is drawn disabled and sets nothing"); expect(g_slider.text.find("Enable exact geometry") != std::string::npos, "and says what it waits for"); g_host = Host{}; g_slider = Slider{}; state = -7; - expect(disruptor_present_rate_switch("Switch", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && + expect(disruptor_present_rate_switch("Switch", "Switch hint", 0, &state) == 1 && state == 0 && g_host.set_enabled == 0 && g_switch.disabled_when_drawn == 0 && g_slider.text.empty(), "frames that are on can be switched off without exact geometry too"); } From 427d9c9eabc616d459cd179438f2851774b58193 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 03:54:16 +0300 Subject: [PATCH 13/21] refactor: keep the framework changes as patches against the pinned revision --- .gitattributes | 3 + CMakeLists.txt | 16 +- CONTRIBUTING.md | 7 +- README.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- build.ps1 | 11 +- build.sh | 9 +- docs/BUILD.md | 22 +- .../FILES.txt | 6 +- PSXRECOMP_PIN => framework/PIN | 0 .../runtime/include/gpu_pinned_corner.h | 0 .../runtime/include/gpu_temporal_match.h | 0 .../runtime/include/gpu_temporal_mesh.h | 0 .../runtime/include/gpu_temporal_sprite.h | 0 .../runtime/include/gpu_turned_perspective.h | 0 .../files}/runtime/include/gpu_world_depth.h | 0 .../runtime/include/gpu_ws_frame_kind.h | 0 .../files}/runtime/include/gpu_ws_hud_scale.h | 0 .../runtime/include/gpu_ws_hud_widget.h | 0 .../files}/runtime/include/gpu_ws_menu.h | 0 .../runtime/include/gpu_ws_screen_tile.h | 0 .../files}/runtime/include/gpu_ws_tag_match.h | 0 .../files}/runtime/include/gte_precision.h | 0 .../files}/runtime/include/host_borderless.h | 0 .../files}/runtime/include/host_pick_slot.h | 0 .../files}/runtime/include/host_ui.h | 0 .../files}/runtime/include/iso_overlay.h | 0 .../files}/runtime/include/save_point.h | 0 .../files}/runtime/src/gpu_gl_temporal.c.inc | 0 .../recompiler/include/code_generator.h.patch | 34 + .../seeds/openbios_elf_seeds.json.patch | 25 + .../recompiler/src/code_generator.cpp.patch | 829 ++ .../recompiler/src/config_loader.cpp.patch | 633 ++ .../recompiler/src/config_loader.h.patch | 167 + .../src/full_function_emitter.cpp.patch | 13 + .../patches/recompiler/src/main_psx.cpp.patch | 97 + .../tests/recompiler_patch_test.cpp.patch | 209 + .../test_reachable_discovery_codegen.py.patch | 16 + .../patches/runtime/CMakeLists.txt.patch | 23 + .../runtime/include/dirty_ram_interp.h.patch | 24 + framework/patches/runtime/include/gpu.h.patch | 230 + .../runtime/include/gpu_render.h.patch | 77 + .../runtime/include/gpu_sw_renderer.h.patch | 13 + .../runtime/include/mod_memory.h.patch | 16 + .../overlay_dispatch_preamble.c.inc.patch | 24 + .../patches/runtime/include/psx_cyc.h.patch | 30 + .../include/ws_aspect_cone_math.h.patch | 23 + .../patches/runtime/src/boot_state.c.patch | 81 + .../runtime/src/dirty_ram_interp.c.patch | 358 + framework/patches/runtime/src/gpu.c.patch | 2084 ++++ .../runtime/src/gpu_gl_renderer.c.patch | 2640 +++++ .../patches/runtime/src/gpu_render.c.patch | 68 + framework/patches/runtime/src/gte.cpp.patch | 1159 +++ .../patches/runtime/src/interrupts.c.patch | 99 + .../runtime/src/iso_reader_c.cpp.patch | 102 + framework/patches/runtime/src/main.cpp.patch | 2206 +++++ framework/patches/runtime/src/memory.c.patch | 225 + .../runtime/src/overlay_loader.c.patch | 13 + .../patches/runtime/src/psx_cycles.c.patch | 13 + .../runtime/src/ws_aspect_cone_math.c.patch | 52 + .../tests/test_ws_aspect_cone_math.c.patch | 23 + .../recompiler/include/code_generator.h | 513 - .../recompiler/src/code_generator.cpp | 4112 -------- .../recompiler/src/config_loader.cpp | 2814 ------ .../recompiler/src/config_loader.h | 1185 --- psxrecomp-overlay/recompiler/src/main_psx.cpp | 1636 ---- .../tests/recompiler_patch_test.cpp | 1135 --- .../tests/test_reachable_discovery_codegen.py | 205 - psxrecomp-overlay/runtime/CMakeLists.txt | 270 - .../runtime/include/dirty_ram_interp.h | 348 - psxrecomp-overlay/runtime/include/gpu.h | 624 -- .../runtime/include/gpu_render.h | 237 - .../runtime/include/gpu_sw_renderer.h | 130 - .../runtime/include/mod_memory.h | 56 - .../include/overlay_dispatch_preamble.c.inc | 281 - psxrecomp-overlay/runtime/include/psx_cyc.h | 286 - .../runtime/include/ws_aspect_cone_math.h | 41 - psxrecomp-overlay/runtime/src/boot_state.c | 477 - .../runtime/src/dirty_ram_interp.c | 4039 -------- psxrecomp-overlay/runtime/src/gpu.c | 6336 ------------ .../runtime/src/gpu_gl_renderer.c | 5309 ---------- psxrecomp-overlay/runtime/src/gpu_render.c | 232 - psxrecomp-overlay/runtime/src/gte.cpp | 2841 ------ psxrecomp-overlay/runtime/src/interrupts.c | 1669 ---- .../runtime/src/iso_reader_c.cpp | 119 - psxrecomp-overlay/runtime/src/main.cpp | 8526 ----------------- psxrecomp-overlay/runtime/src/memory.c | 2106 ---- .../runtime/src/overlay_loader.c | 4715 --------- psxrecomp-overlay/runtime/src/psx_cycles.c | 684 -- .../runtime/src/ws_aspect_cone_math.c | 70 - .../runtime/tests/test_ws_aspect_cone_math.c | 81 - src/game/input/mouse_aim.cpp | 2 +- .../test_ambient_occlusion_contract.py | 6 +- .../contracts/test_backdrop_tiles_contract.py | 10 +- .../test_billboard_aspect_contract.py | 10 +- tests/contracts/test_borderless_contract.py | 6 +- tests/contracts/test_capsule_contract.py | 2 +- tests/contracts/test_debug_tab_contract.py | 6 +- tests/contracts/test_depth24_sync_contract.py | 4 +- tests/contracts/test_dev_menu_contract.py | 16 +- .../contracts/test_far_rendering_contract.py | 6 +- tests/contracts/test_frame_kind_contract.py | 8 +- tests/contracts/test_frame_rate_contract.py | 12 +- .../test_framework_overlay_new_file.py | 120 - tests/contracts/test_framework_tool.py | 221 + tests/contracts/test_gap_filling_contract.py | 6 +- .../test_geometry_provenance_contract.py | 14 +- tests/contracts/test_grown_quad_contract.py | 2 +- tests/contracts/test_hud_scale_contract.py | 10 +- tests/contracts/test_hud_widget_contract.py | 6 +- tests/contracts/test_intro_skip_contract.py | 12 +- .../contracts/test_language_disc_contract.py | 16 +- tests/contracts/test_launcher_contract.py | 4 +- tests/contracts/test_loaded_state_contract.py | 8 +- tests/contracts/test_menu_wide_contract.py | 12 +- tests/contracts/test_name_entry_contract.py | 2 +- .../test_perspective_texture_contract.py | 2 +- tests/contracts/test_phase_a_contract.py | 12 +- .../contracts/test_pinned_corner_contract.py | 16 +- tests/contracts/test_save_point_contract.py | 6 +- tests/contracts/test_smooth_turn_contract.py | 6 +- tests/contracts/test_sprite_range_contract.py | 2 +- .../test_temporal_presenter_contract.py | 14 +- .../test_test_registration_contract.py | 4 +- .../test_vertical_camera_contract.py | 6 +- tests/contracts/test_world_depth_contract.py | 12 +- .../unit/disruptor_language_overlay_test.cpp | 2 +- tests/unit/disruptor_mouse_aim_test.cpp | 2 +- tests/unit/host_borderless_test.cpp | 2 +- tests/unit/host_pick_slot_test.cpp | 2 +- tools/apply_framework_overlay.py | 143 - tools/framework.py | 353 + tools/patch_openbios_seeds.py | 149 - 133 files changed, 12367 insertions(+), 51648 deletions(-) rename PSXRECOMP_OVERLAY_FILES.txt => framework/FILES.txt (86%) rename PSXRECOMP_PIN => framework/PIN (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_pinned_corner.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_temporal_match.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_temporal_mesh.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_temporal_sprite.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_turned_perspective.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_world_depth.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_ws_frame_kind.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_ws_hud_scale.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_ws_hud_widget.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_ws_menu.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_ws_screen_tile.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gpu_ws_tag_match.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/gte_precision.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/host_borderless.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/host_pick_slot.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/host_ui.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/iso_overlay.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/include/save_point.h (100%) rename {psxrecomp-overlay => framework/files}/runtime/src/gpu_gl_temporal.c.inc (100%) create mode 100644 framework/patches/recompiler/include/code_generator.h.patch create mode 100644 framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch create mode 100644 framework/patches/recompiler/src/code_generator.cpp.patch create mode 100644 framework/patches/recompiler/src/config_loader.cpp.patch create mode 100644 framework/patches/recompiler/src/config_loader.h.patch create mode 100644 framework/patches/recompiler/src/full_function_emitter.cpp.patch create mode 100644 framework/patches/recompiler/src/main_psx.cpp.patch create mode 100644 framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch create mode 100644 framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch create mode 100644 framework/patches/runtime/CMakeLists.txt.patch create mode 100644 framework/patches/runtime/include/dirty_ram_interp.h.patch create mode 100644 framework/patches/runtime/include/gpu.h.patch create mode 100644 framework/patches/runtime/include/gpu_render.h.patch create mode 100644 framework/patches/runtime/include/gpu_sw_renderer.h.patch create mode 100644 framework/patches/runtime/include/mod_memory.h.patch create mode 100644 framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch create mode 100644 framework/patches/runtime/include/psx_cyc.h.patch create mode 100644 framework/patches/runtime/include/ws_aspect_cone_math.h.patch create mode 100644 framework/patches/runtime/src/boot_state.c.patch create mode 100644 framework/patches/runtime/src/dirty_ram_interp.c.patch create mode 100644 framework/patches/runtime/src/gpu.c.patch create mode 100644 framework/patches/runtime/src/gpu_gl_renderer.c.patch create mode 100644 framework/patches/runtime/src/gpu_render.c.patch create mode 100644 framework/patches/runtime/src/gte.cpp.patch create mode 100644 framework/patches/runtime/src/interrupts.c.patch create mode 100644 framework/patches/runtime/src/iso_reader_c.cpp.patch create mode 100644 framework/patches/runtime/src/main.cpp.patch create mode 100644 framework/patches/runtime/src/memory.c.patch create mode 100644 framework/patches/runtime/src/overlay_loader.c.patch create mode 100644 framework/patches/runtime/src/psx_cycles.c.patch create mode 100644 framework/patches/runtime/src/ws_aspect_cone_math.c.patch create mode 100644 framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch delete mode 100644 psxrecomp-overlay/recompiler/include/code_generator.h delete mode 100644 psxrecomp-overlay/recompiler/src/code_generator.cpp delete mode 100644 psxrecomp-overlay/recompiler/src/config_loader.cpp delete mode 100644 psxrecomp-overlay/recompiler/src/config_loader.h delete mode 100644 psxrecomp-overlay/recompiler/src/main_psx.cpp delete mode 100644 psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp delete mode 100644 psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py delete mode 100644 psxrecomp-overlay/runtime/CMakeLists.txt delete mode 100644 psxrecomp-overlay/runtime/include/dirty_ram_interp.h delete mode 100644 psxrecomp-overlay/runtime/include/gpu.h delete mode 100644 psxrecomp-overlay/runtime/include/gpu_render.h delete mode 100644 psxrecomp-overlay/runtime/include/gpu_sw_renderer.h delete mode 100644 psxrecomp-overlay/runtime/include/mod_memory.h delete mode 100644 psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc delete mode 100644 psxrecomp-overlay/runtime/include/psx_cyc.h delete mode 100644 psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h delete mode 100644 psxrecomp-overlay/runtime/src/boot_state.c delete mode 100644 psxrecomp-overlay/runtime/src/dirty_ram_interp.c delete mode 100644 psxrecomp-overlay/runtime/src/gpu.c delete mode 100644 psxrecomp-overlay/runtime/src/gpu_gl_renderer.c delete mode 100644 psxrecomp-overlay/runtime/src/gpu_render.c delete mode 100644 psxrecomp-overlay/runtime/src/gte.cpp delete mode 100644 psxrecomp-overlay/runtime/src/interrupts.c delete mode 100644 psxrecomp-overlay/runtime/src/iso_reader_c.cpp delete mode 100644 psxrecomp-overlay/runtime/src/main.cpp delete mode 100644 psxrecomp-overlay/runtime/src/memory.c delete mode 100644 psxrecomp-overlay/runtime/src/overlay_loader.c delete mode 100644 psxrecomp-overlay/runtime/src/psx_cycles.c delete mode 100644 psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c delete mode 100644 psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c delete mode 100644 tests/contracts/test_framework_overlay_new_file.py create mode 100644 tests/contracts/test_framework_tool.py delete mode 100755 tools/apply_framework_overlay.py create mode 100644 tools/framework.py delete mode 100644 tools/patch_openbios_seeds.py diff --git a/.gitattributes b/.gitattributes index e0db6f2..2d50abf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,6 @@ *.c text eol=lf *.cpp text eol=lf *.h text eol=lf + +# A patch must reach every machine byte for byte: one framework file has CRLF lines. +framework/patches/** -text diff --git a/CMakeLists.txt b/CMakeLists.txt index 0bae398..4b8c36d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,7 +179,7 @@ if(WIN32) add_subdirectory(src/launcher) endif() if(BUILD_TESTING) - find_package(Python3 REQUIRED COMPONENTS Interpreter) + find_package(Python3 3.10 REQUIRED COMPONENTS Interpreter) include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake") disruptor_contract(backdrop_tiles RETAIL) @@ -216,9 +216,18 @@ if(BUILD_TESTING) disruptor_contract(debug_tab) disruptor_contract(test_registration) - add_test(NAME disruptor_framework_overlay_new_file + add_test(NAME disruptor_framework_tool COMMAND "${Python3_EXECUTABLE}" -B - "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_overlay_new_file.py") + "${CMAKE_CURRENT_SOURCE_DIR}/tests/contracts/test_framework_tool.py") + # The contracts and some unit tests read framework files from the checkout: it must hold what framework/ says. + file(REAL_PATH "${PSXRECOMP_ROOT}" framework_checkout) + file(REAL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp" framework_expected) + if(NOT framework_checkout STREQUAL framework_expected) + message(FATAL_ERROR "The tests read the framework from ${framework_expected}, not ${framework_checkout}") + endif() + add_test(NAME disruptor_framework_in_step + COMMAND "${Python3_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tools/framework.py" check --framework "${PSXRECOMP_ROOT}") endif() if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) @@ -276,7 +285,6 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) disruptor_unit_test(disruptor_language_disc tests/unit/disruptor_language_disc_test.cpp) disruptor_unit_test(disruptor_language_overlay tests/unit/disruptor_language_overlay_test.cpp INCLUDES - "${CMAKE_CURRENT_SOURCE_DIR}/psxrecomp-overlay/runtime/include" "${PSXRECOMP_ROOT}/runtime/include" "${CMAKE_CURRENT_SOURCE_DIR}/src") add_test(NAME disruptor_language_overlay_tall diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd661e6..ae6b777 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,9 +4,10 @@ Keep contributions source-only. Do not commit disc images, extracted game code or assets, generated translations, captured overlays, proprietary BIOS files, binaries, saves, captures, logs, credentials, or machine-specific paths. -Edit project source, configuration, seeds, tests, or `psxrecomp-overlay/`, then -regenerate locally. Every framework-overlay change must be listed in -`PSXRECOMP_OVERLAY_FILES.txt`; do not hand-edit `generated/*.c` or include +Edit project source, configuration, seeds, tests, or the framework checkout, +then regenerate locally. Save a framework change with +`python tools/framework.py refresh`, and list a newly changed file in +`framework/FILES.txt` first. Do not hand-edit `generated/*.c` or include unrelated framework changes. Before submitting a change: diff --git a/README.md b/README.md index cca7682..2de9ef9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ and share feedback. ## Build and run -Install Git, Python 3, CMake 3.20+, and a Windows x64 C++ toolchain. Visual Studio +Install Git, Python 3.10+, CMake 3.20+, and a Windows x64 C++ toolchain. Visual Studio with **Desktop development with C++** is supported; Ninja is recommended. Place these files from your own supported disc in `input/`: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9a69b7a..2f18ba0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -`psxrecomp-overlay/` contains modified files based on PSXRecomp revision +`framework/` contains patches to, and files written for, PSXRecomp revision `854a658372b304c222a0d0cbbae16c66264b785e`. PSXRecomp is copyright Matthew Stan and is provided under the PolyForm Noncommercial License 1.0.0. The upstream license is reproduced in diff --git a/build.ps1 b/build.ps1 index 9472874..453e974 100644 --- a/build.ps1 +++ b/build.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $MyInvocation.MyCommand.Path $Framework = Join-Path $Root 'psxrecomp' -$Pin = (Get-Content (Join-Path $Root 'PSXRECOMP_PIN') -Raw).Trim() +$Pin = (Get-Content (Join-Path $Root 'framework/PIN') -Raw).Trim() $Exe = Join-Path $Root 'input/SLUS_002.24' # Ninja inherits its target architecture from the active compiler environment. @@ -101,6 +101,10 @@ foreach ($Command in @('git', 'cmake', 'ctest', 'python')) { throw "Missing prerequisite: $Command" } } +python -c "import sys; sys.exit(sys.version_info < (3, 10))" +if ($LASTEXITCODE -ne 0) { + throw 'Python 3.10 or newer is required.' +} if (!(Test-Path $Exe)) { throw "Copy the verified SLUS_002.24 into input/ before building." } @@ -114,10 +118,7 @@ if ($LASTEXITCODE -ne 0) { } Invoke-Checked { git -C $Framework checkout --detach $Pin } Invoke-Checked { - python (Join-Path $Root 'tools/apply_framework_overlay.py') --framework $Framework -} -Invoke-Checked { - python (Join-Path $Root 'tools/patch_openbios_seeds.py') --framework $Framework + python (Join-Path $Root 'tools/framework.py') apply --framework $Framework } $Generator = @() diff --git a/build.sh b/build.sh index 4f911f2..bff5102 100755 --- a/build.sh +++ b/build.sh @@ -3,7 +3,7 @@ set -eu ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) FRAMEWORK="$ROOT/psxrecomp" -PIN=$(tr -d '[:space:]' < "$ROOT/PSXRECOMP_PIN") +PIN=$(tr -d '[:space:]' < "$ROOT/framework/PIN") for command in git cmake ctest python3; do command -v "$command" >/dev/null 2>&1 || { @@ -11,6 +11,10 @@ for command in git cmake ctest python3; do exit 1 } done +python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' || { + echo "Python 3.10 or newer is required." >&2 + exit 1 +} # DISRUPTOR_WITHOUT_GAME=1 is for a machine without the game: every source is # compiled, the runtime is not linked, and the tests that read no retail data run. WITHOUT_GAME="${DISRUPTOR_WITHOUT_GAME:-}" @@ -24,8 +28,7 @@ if [ ! -d "$FRAMEWORK/.git" ]; then fi git -C "$FRAMEWORK" fetch --quiet origin "$PIN" git -C "$FRAMEWORK" checkout --detach "$PIN" -python3 "$ROOT/tools/apply_framework_overlay.py" --framework "$FRAMEWORK" -python3 "$ROOT/tools/patch_openbios_seeds.py" --framework "$FRAMEWORK" +python3 "$ROOT/tools/framework.py" apply --framework "$FRAMEWORK" GENERATOR="" if command -v ninja >/dev/null 2>&1; then GENERATOR="-G Ninja"; fi diff --git a/docs/BUILD.md b/docs/BUILD.md index b4f5782..369f26d 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -4,7 +4,7 @@ - The verified Disruptor USA executable and BIN/CUE files in `input/`; see [DISC.md](DISC.md). -- Git, Python 3, CMake 3.20+, and a C++20 toolchain. +- Git, Python 3.10+, CMake 3.20+, and a C++20 toolchain. - Windows x64: Visual Studio with **Desktop development with C++**, or MSYS2 MinGW-w64. Ninja is recommended. - Linux: GCC and the platform development libraries needed to build SDL. @@ -118,14 +118,24 @@ cmake --build build --config Release --parallel ctest --test-dir build -C Release --output-on-failure ``` -Changes to `psxrecomp-overlay/` must be applied before rebuilding: +The project's changes to the framework are patches against the revision in +`framework/PIN`. `framework/FILES.txt` lists every changed file, a `+` marking +one the project adds. To change the framework, edit the file in the `psxrecomp` +checkout, build and test, then save the edit as a patch: ```sh -python tools/apply_framework_overlay.py --framework psxrecomp +python tools/framework.py refresh ``` -The framework revision is pinned in `PSXRECOMP_PIN`, and the reviewed overlay -files are listed in `PSXRECOMP_OVERLAY_FILES.txt`. Runtime preferences live in +After pulling changes to `framework/`, bring the checkout up to date: + +```sh +python tools/framework.py apply +``` + +`apply` refuses to overwrite an edit that `refresh` has not saved, and +`python tools/framework.py check` tells whether the checkout holds exactly what +`framework/` describes. Runtime preferences live in `settings.toml` beside the executable; game files, generated code, and build directories are ignored by Git. @@ -135,7 +145,7 @@ directories are ignored by Git. | --- | --- | | `src/game/` | The game's own module by area: `input`, `render`, `frame`, `language`, `session`, `settings`, `ui` | | `src/launcher/` | The Windows launcher | -| `psxrecomp-overlay/` | The framework files this project replaces or adds | +| `framework/` | The pinned framework revision, the project's patches to it and the files it adds | | `tests/unit/` | Unit tests, one executable each | | `tests/contracts/` | Source contracts, run by Python | | `tests/support/` | Stubs and fakes the unit tests build against | diff --git a/PSXRECOMP_OVERLAY_FILES.txt b/framework/FILES.txt similarity index 86% rename from PSXRECOMP_OVERLAY_FILES.txt rename to framework/FILES.txt index a0f2939..ccc9916 100644 --- a/PSXRECOMP_OVERLAY_FILES.txt +++ b/framework/FILES.txt @@ -1,5 +1,5 @@ -# Reviewed files applied from psxrecomp-overlay/ to the pinned framework. -# Prefix an entry with '+' only when the overlay intentionally creates it. +# Files of the pinned framework that this project changes, each with a patch under patches/. +# '+' marks a file the project adds, kept whole under files/. recompiler/include/code_generator.h recompiler/src/code_generator.cpp recompiler/src/config_loader.cpp @@ -49,3 +49,5 @@ runtime/src/overlay_loader.c runtime/src/psx_cycles.c runtime/src/ws_aspect_cone_math.c runtime/tests/test_ws_aspect_cone_math.c +recompiler/seeds/openbios_elf_seeds.json +recompiler/src/full_function_emitter.cpp diff --git a/PSXRECOMP_PIN b/framework/PIN similarity index 100% rename from PSXRECOMP_PIN rename to framework/PIN diff --git a/psxrecomp-overlay/runtime/include/gpu_pinned_corner.h b/framework/files/runtime/include/gpu_pinned_corner.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_pinned_corner.h rename to framework/files/runtime/include/gpu_pinned_corner.h diff --git a/psxrecomp-overlay/runtime/include/gpu_temporal_match.h b/framework/files/runtime/include/gpu_temporal_match.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_temporal_match.h rename to framework/files/runtime/include/gpu_temporal_match.h diff --git a/psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h b/framework/files/runtime/include/gpu_temporal_mesh.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h rename to framework/files/runtime/include/gpu_temporal_mesh.h diff --git a/psxrecomp-overlay/runtime/include/gpu_temporal_sprite.h b/framework/files/runtime/include/gpu_temporal_sprite.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_temporal_sprite.h rename to framework/files/runtime/include/gpu_temporal_sprite.h diff --git a/psxrecomp-overlay/runtime/include/gpu_turned_perspective.h b/framework/files/runtime/include/gpu_turned_perspective.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_turned_perspective.h rename to framework/files/runtime/include/gpu_turned_perspective.h diff --git a/psxrecomp-overlay/runtime/include/gpu_world_depth.h b/framework/files/runtime/include/gpu_world_depth.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_world_depth.h rename to framework/files/runtime/include/gpu_world_depth.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_frame_kind.h b/framework/files/runtime/include/gpu_ws_frame_kind.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_frame_kind.h rename to framework/files/runtime/include/gpu_ws_frame_kind.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h b/framework/files/runtime/include/gpu_ws_hud_scale.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h rename to framework/files/runtime/include/gpu_ws_hud_scale.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_hud_widget.h b/framework/files/runtime/include/gpu_ws_hud_widget.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_hud_widget.h rename to framework/files/runtime/include/gpu_ws_hud_widget.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_menu.h b/framework/files/runtime/include/gpu_ws_menu.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_menu.h rename to framework/files/runtime/include/gpu_ws_menu.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_screen_tile.h b/framework/files/runtime/include/gpu_ws_screen_tile.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_screen_tile.h rename to framework/files/runtime/include/gpu_ws_screen_tile.h diff --git a/psxrecomp-overlay/runtime/include/gpu_ws_tag_match.h b/framework/files/runtime/include/gpu_ws_tag_match.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gpu_ws_tag_match.h rename to framework/files/runtime/include/gpu_ws_tag_match.h diff --git a/psxrecomp-overlay/runtime/include/gte_precision.h b/framework/files/runtime/include/gte_precision.h similarity index 100% rename from psxrecomp-overlay/runtime/include/gte_precision.h rename to framework/files/runtime/include/gte_precision.h diff --git a/psxrecomp-overlay/runtime/include/host_borderless.h b/framework/files/runtime/include/host_borderless.h similarity index 100% rename from psxrecomp-overlay/runtime/include/host_borderless.h rename to framework/files/runtime/include/host_borderless.h diff --git a/psxrecomp-overlay/runtime/include/host_pick_slot.h b/framework/files/runtime/include/host_pick_slot.h similarity index 100% rename from psxrecomp-overlay/runtime/include/host_pick_slot.h rename to framework/files/runtime/include/host_pick_slot.h diff --git a/psxrecomp-overlay/runtime/include/host_ui.h b/framework/files/runtime/include/host_ui.h similarity index 100% rename from psxrecomp-overlay/runtime/include/host_ui.h rename to framework/files/runtime/include/host_ui.h diff --git a/psxrecomp-overlay/runtime/include/iso_overlay.h b/framework/files/runtime/include/iso_overlay.h similarity index 100% rename from psxrecomp-overlay/runtime/include/iso_overlay.h rename to framework/files/runtime/include/iso_overlay.h diff --git a/psxrecomp-overlay/runtime/include/save_point.h b/framework/files/runtime/include/save_point.h similarity index 100% rename from psxrecomp-overlay/runtime/include/save_point.h rename to framework/files/runtime/include/save_point.h diff --git a/psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc b/framework/files/runtime/src/gpu_gl_temporal.c.inc similarity index 100% rename from psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc rename to framework/files/runtime/src/gpu_gl_temporal.c.inc diff --git a/framework/patches/recompiler/include/code_generator.h.patch b/framework/patches/recompiler/include/code_generator.h.patch new file mode 100644 index 0000000..638deb2 --- /dev/null +++ b/framework/patches/recompiler/include/code_generator.h.patch @@ -0,0 +1,34 @@ +diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h +index db776ad6748afc7f0f31731ef183d9b6c6d76e0e..8e6e02e72c421768b1729489c1e161584027af27 100644 +--- a/recompiler/include/code_generator.h ++++ b/recompiler/include/code_generator.h +@@ -110,6 +110,13 @@ + // Signed lower-bound counterpart: `slti rt,sx,-W` compares against + // `-W-x_margin`, preserving the original result at 4:3. + std::set ws_cull_slti_lower_sites; ++ // Manual screen-window counterparts for zero-bound branches, constant / ++ // zero-store clamps, right-edge constants, and packed [left,right] spans. ++ // Each configured site is instruction-shape checked by codegen. ++ std::set ws_cull_lower_branch_sites; ++ std::set ws_cull_lower_value_sites; ++ std::set ws_cull_upper_value_sites; ++ std::set ws_cull_span_value_sites; + + // `bltz rs, reject` emitted through psx_ws_cull_bltz — the explicit + // LEFT-edge counterpart to ws_cull_slti_sites ([widescreen.cull] +@@ -148,6 +155,15 @@ + // Exact `addi[u] rt,zero,imm` 12-bit angular half-extents. The runtime + // scales tan(angle) by the current horizontal reveal factor. + std::vector ws_cull_angle_sites; ++ ++ // Exact `addi[u] rt,rs,+/-half_fov` wrapping-yaw sites. The runtime widens ++ // only the signed offset in the site's declared angular unit system. ++ std::vector ws_cull_yaw_cone_sites; ++ ++ // Exact signed `slt dst,bound,horizontal` side-plane comparisons. The ++ // horizontal operand is projection-scaled only while widescreen is live. ++ std::vector ++ ws_cull_horizontal_slt_sites; + + // Exact model-participation cosine compares that gain a camera-horizontal + // aspect envelope while preserving the vanilla vertical cone. diff --git a/framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch b/framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch new file mode 100644 index 0000000..6e699e2 --- /dev/null +++ b/framework/patches/recompiler/seeds/openbios_elf_seeds.json.patch @@ -0,0 +1,25 @@ +diff --git a/recompiler/seeds/openbios_elf_seeds.json b/recompiler/seeds/openbios_elf_seeds.json +index 32c77f16e016ecfa8c96e6a295fb5e8892371ffd..cb55a0042829b79c3c5e4d639139d10386d181d1 100644 +--- a/recompiler/seeds/openbios_elf_seeds.json ++++ b/recompiler/seeds/openbios_elf_seeds.json +@@ -1,7 +1,7 @@ + { + "schema": "psxrecomp phase2 seeds", + "source": "gen_bios_seeds_from_elf.py over openbios.elf, shell.elf + 5 empirical seeds from openbios_dispatch_miss.json", +- "seed_count": 646, ++ "seed_count": 647, + "seeds": [ + { + "address": "0xBFC00000", +@@ -1429,6 +1429,11 @@ + "rationale": "ELF STT_FUNC symbol from openbios.elf" + }, + { ++ "address": "0xBFC095F8", ++ "label": "data_after_osDbgPrintf", ++ "rationale": "verified exclusive end of osDbgPrintf; prevents unconditional-branch fallthrough analysis from entering adjacent data" ++ }, ++ { + "address": "0xBFC0A500", + "label": "elf_entry_shell_elf", + "rationale": "ELF entry point (e_entry) of shell.elf" diff --git a/framework/patches/recompiler/src/code_generator.cpp.patch b/framework/patches/recompiler/src/code_generator.cpp.patch new file mode 100644 index 0000000..bb7e319 --- /dev/null +++ b/framework/patches/recompiler/src/code_generator.cpp.patch @@ -0,0 +1,829 @@ +diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp +index a2154f104ac7f6fb7a1f2bb9db772de7a4745099..f54752e2a15f4535694ca6c2fc2e2651490102e0 100644 +--- a/recompiler/src/code_generator.cpp ++++ b/recompiler/src/code_generator.cpp +@@ -18,6 +18,297 @@ + + namespace PSXRecomp { + ++/* Disruptor's resident geometry builders read SXY2 with MFC2 and later use an ++ * ordinary SW. Keep these hooks deliberately narrower than the general code ++ * generator: the runtime also checks the game serial, PC and raw instruction. ++ * A shape mismatch at either source PC is a build error, not a silent widening ++ * of the provenance trust boundary. */ ++static bool disruptor_precision_mfc2_pc(uint32_t address) { ++ return address == 0x8004633Cu || address == 0x8004795Cu; ++} ++ ++static bool disruptor_precision_store_pc(uint32_t address) { ++ switch (address) { ++ case 0x8004641Cu: ++ case 0x800464BCu: ++ case 0x80046524u: ++ case 0x80046540u: ++ case 0x80046594u: ++ case 0x800465D8u: ++ return true; ++ default: ++ return false; ++ } ++} ++ ++static bool disruptor_precision_scratch_store_pc(uint32_t address) { ++ return address == 0x80047A0Cu; ++} ++ ++struct DisruptorPrecisionCopySite { ++ uint32_t pc; ++ uint32_t instruction; ++ uint8_t gpr; ++}; ++ ++static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyLoads[] = { ++ {0x80046C4Cu, 0x8D0C0004u, 12u}, ++ {0x80046C50u, 0x8D2D0004u, 13u}, ++ {0x80046C54u, 0x8D4E0004u, 14u}, ++ {0x80046C60u, 0x8D6F0004u, 15u}, ++ {0x80047A80u, 0x8CA80084u, 8u}, ++ {0x80047A84u, 0x8CA900A4u, 9u}, ++ {0x80047A88u, 0x8CAA00C4u, 10u}, ++ {0x80047A8Cu, 0x8CAB00BCu, 11u}, ++ {0x80047B40u, 0x8CA800A4u, 8u}, ++ {0x80047B44u, 0x8CA9008Cu, 9u}, ++ {0x80047B48u, 0x8CAA00ACu, 10u}, ++ {0x80047B4Cu, 0x8CAB00C4u, 11u}, ++ {0x80047C00u, 0x8CA800C4u, 8u}, ++ {0x80047C04u, 0x8CA900ACu, 9u}, ++ {0x80047C08u, 0x8CAA0094u, 10u}, ++ {0x80047C0Cu, 0x8CAB00B4u, 11u}, ++ {0x80047CC4u, 0x8CA800BCu, 8u}, ++ {0x80047CC8u, 0x8CA900C4u, 9u}, ++ {0x80047CCCu, 0x8CAA00B4u, 10u}, ++ {0x80047CD0u, 0x8CAB009Cu, 11u}, ++ {0x80047D6Cu, 0x8CA80084u, 8u}, ++ {0x80047D70u, 0x8CA9008Cu, 9u}, ++ {0x80047D74u, 0x8CAA0094u, 10u}, ++ {0x80047D78u, 0x8CAB009Cu, 11u}, ++}; ++ ++static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyStores[] = { ++ {0x80046D04u, 0xAF2C0008u, 12u}, ++ {0x80046D08u, 0xAF2D0014u, 13u}, ++ {0x80046D10u, 0xAF2E002Cu, 14u}, ++ {0x80046D0Cu, 0xAF2F0020u, 15u}, ++ {0x80047A90u, 0xAF280008u, 8u}, ++ {0x80047A94u, 0xAF290014u, 9u}, ++ {0x80047A9Cu, 0xAF2A002Cu, 10u}, ++ {0x80047A98u, 0xAF2B0020u, 11u}, ++ {0x80047B50u, 0xAF280008u, 8u}, ++ {0x80047B54u, 0xAF290014u, 9u}, ++ {0x80047B5Cu, 0xAF2A002Cu, 10u}, ++ {0x80047B58u, 0xAF2B0020u, 11u}, ++ {0x80047C10u, 0xAF280008u, 8u}, ++ {0x80047C14u, 0xAF290014u, 9u}, ++ {0x80047C1Cu, 0xAF2A002Cu, 10u}, ++ {0x80047C18u, 0xAF2B0020u, 11u}, ++ {0x80047CD4u, 0xAF280008u, 8u}, ++ {0x80047CD8u, 0xAF290014u, 9u}, ++ {0x80047CE0u, 0xAF2A002Cu, 10u}, ++ {0x80047CDCu, 0xAF2B0020u, 11u}, ++ {0x80047D7Cu, 0xAF280008u, 8u}, ++ {0x80047D80u, 0xAF290014u, 9u}, ++ {0x80047D88u, 0xAF2A002Cu, 10u}, ++ {0x80047D84u, 0xAF2B0020u, 11u}, ++}; ++ ++template ++static const DisruptorPrecisionCopySite *disruptor_precision_copy_site( ++ const DisruptorPrecisionCopySite (&sites)[N], uint32_t address) { ++ for (const DisruptorPrecisionCopySite &site : sites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor has no architectural pitch state. The project implements its ++ * opt-in vertical camera at a deliberately finite set of resident instruction ++ * sites: CPU-projected screen-Y centres, proven vertical participation tests, ++ * the two projectile-construction convergence points, and the common renderer ++ * epilogue. Every complete instruction word is pinned so a different retail ++ * revision fails generation instead of silently modifying unrelated code. ++ * ++ * The generated callback is appended after the vanilla instruction. At zero ++ * pitch it is an exact no-op; the runtime callback decodes the pinned word to ++ * identify the destination register or the required post-instruction action. */ ++struct DisruptorVerticalCameraSite { ++ uint32_t pc; ++ uint32_t instruction; ++}; ++ ++static const DisruptorVerticalCameraSite kDisruptorVerticalCameraSites[] = { ++ /* CPU world projection centres (ORI rt,zero,120). */ ++ {0x8003AD54u, 0x34020078u}, ++ {0x8003ADE4u, 0x34020078u}, ++ {0x8003B140u, 0x34020078u}, ++ {0x8003B1ECu, 0x34020078u}, ++ {0x8003B764u, 0x34020078u}, ++ {0x8003B990u, 0x34020078u}, ++ {0x8003BDA4u, 0x34020078u}, ++ {0x8003C4B4u, 0x34020078u}, ++ {0x8003C99Cu, 0x34020078u}, ++ {0x8003D07Cu, 0x34020078u}, ++ {0x8003D398u, 0x34030078u}, ++ {0x8003F6B0u, 0x34020078u}, ++ /* Proven camera-vertical object participation pairs. */ ++ {0x8003B900u, 0x0205102Au}, ++ {0x8003B90Cu, 0x0202102Au}, ++ {0x8003BD14u, 0x0206102Au}, ++ {0x8003BD20u, 0x0202102Au}, ++ {0x8003CDB8u, 0x0065102Au}, ++ {0x8003CDC4u, 0x0062102Au}, ++ /* Normal-weapon and psionic construction convergence loads. */ ++ {0x8002E4B8u, 0x92640022u}, ++ {0x8002EAF0u, 0x92430020u}, ++ /* Common renderer epilogue: restore the neutral projection horizon. */ ++ {0x8004279Cu, 0x8FBF016Cu}, ++}; ++ ++static const DisruptorVerticalCameraSite *disruptor_vertical_camera_site( ++ uint32_t address) { ++ for (const DisruptorVerticalCameraSite &site : ++ kDisruptorVerticalCameraSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor's experimental far-rendering override is host-only: reviewed ++ * loads of the retail far/fade globals are substituted after the original LW, ++ * leaving guest RAM untouched. Additional reviewed portal, object-decision, ++ * and packet-boundary instructions feed read-only diagnostics through the same ++ * callback. The renderer entry starts a measurement span and the reviewed ++ * common epilogue closes it. Keep this callback independent from the ++ * vertical-camera dispatcher so either enhancement can be built and tested in ++ * isolation. */ ++struct DisruptorFarRenderingSite { ++ uint32_t pc; ++ uint32_t instruction; ++}; ++ ++static const DisruptorFarRenderingSite kDisruptorFarRenderingSites[] = { ++ /* Portal traversal diagnostics: recursion depth, unique-room completion, ++ * and the three candidate-distance values before their branch tests. */ ++ {0x8003A0F8u, 0x93B300C0u}, ++ {0x8003A390u, 0xA082001Fu}, ++ {0x8003A92Cu, 0x00005812u}, ++ {0x8003A964u, 0x00681021u}, ++ {0x8003AA74u, 0x00AC1021u}, ++ /* Far-distance loads from $gp + 0x4A0. */ ++ {0x8003A914u, 0x8F8C04A0u}, ++ {0x8003B8C8u, 0x8F8304A0u}, ++ {0x8003BCE0u, 0x8F8204A0u}, ++ {0x8003C168u, 0x8F8304A0u}, ++ {0x8003CD64u, 0x8F8304A0u}, ++ /* Object distance-decision results. */ ++ {0x8003B8D8u, 0x0072182Au}, ++ {0x8003BCECu, 0x0053102Au}, ++ {0x8003C178u, 0x0077182Au}, ++ {0x8003CD70u, 0x0077182Au}, ++ /* Fade-start loads from $gp + 0x494. */ ++ {0x8003B2CCu, 0x8F820494u}, ++ {0x8003BA30u, 0x8F820494u}, ++ {0x8003BE40u, 0x8F820494u}, ++ {0x8003C628u, 0x8F820494u}, ++ {0x8003C878u, 0x8F820494u}, ++ {0x8003D28Cu, 0x8F820494u}, ++ {0x8003FB0Cu, 0x8F840494u}, ++ /* Billboard post-pass CLUT-row selection. */ ++ {0x80043184u, 0x8F820494u}, ++ /* Main-renderer visible-list and packet boundaries. */ ++ {0x800410E0u, 0x93830310u}, ++ {0x800410F8u, 0x8F820680u}, ++ {0x800411D8u, 0x8F8301D4u}, ++ /* Common renderer epilogue. */ ++ {0x8004279Cu, 0x8FBF016Cu}, ++}; ++ ++static const DisruptorFarRenderingSite *disruptor_far_rendering_site( ++ uint32_t address) { ++ for (const DisruptorFarRenderingSite &site : ++ kDisruptorFarRenderingSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. ++ * Their projected centre goes through the reviewed classic-wide X squash, but ++ * their retail pixel width does not. These are five final packet writes plus ++ * the deferred base-actor submission seam; the host callback tags only a ++ * completed, shape-validated packet for proportion repair. */ ++struct DisruptorBillboardAspectSite { ++ uint32_t pc; ++ uint32_t instruction; ++ uint8_t packet_gpr; ++}; ++ ++static const DisruptorBillboardAspectSite kDisruptorBillboardAspectSites[] = { ++ {0x8003BB88u, 0xA6030016u, 16u}, ++ {0x8003BFB0u, 0xA6030016u, 16u}, ++ {0x8003C848u, 0xA6030016u, 16u}, ++ {0x8003CAD4u, 0xA6020016u, 16u}, ++ {0x8003D488u, 0xA2060025u, 16u}, ++ {0x800433A0u, 0x02A02821u, 5u}, ++}; ++ ++static const DisruptorBillboardAspectSite *disruptor_billboard_aspect_site( ++ uint32_t address) { ++ for (const DisruptorBillboardAspectSite &site : ++ kDisruptorBillboardAspectSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++/* Disruptor's sky backdrop routine lays SPRT tiles over the opening it has ++ * already clamped to the 320-pixel frame. These four seams hand the host the ++ * left edge, the panorama scroll and the right edge before the tile loop reads ++ * them, so classic widescreen can ask for the columns a wider view needs. */ ++struct DisruptorBackdropTileSite { ++ uint32_t pc; ++ uint32_t instruction; ++}; ++ ++static const DisruptorBackdropTileSite kDisruptorBackdropTileSites[] = { ++ {0x8003B328u, 0x8FB80040u}, ++ {0x8003B338u, 0x00621821u}, ++ {0x8003B348u, 0x8FB80028u}, ++ {0x8003B5A8u, 0x8F8205B8u}, ++ {0x8003B638u, 0x8FB80040u}, ++}; ++ ++static const DisruptorBackdropTileSite kDisruptorSpriteDepthSites[] = { ++ {0x8003B98Cu, 0xAFA20010u}, ++ {0x8003BDA0u, 0xAFA20010u}, ++ {0x8003C4B0u, 0xAFA20010u}, ++ {0x8003D078u, 0xAFA20010u}, ++ {0x8003D1B4u, 0xA4379EA0u}, ++}; ++ ++static const DisruptorBackdropTileSite *disruptor_sprite_depth_site( ++ uint32_t address) { ++ for (const DisruptorBackdropTileSite &site : kDisruptorSpriteDepthSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++static const DisruptorBackdropTileSite kDisruptorIntroSkipSites[] = { ++ {0x800147B0u, 0x2A820003u}, ++ {0x80020A80u, 0x2A02005Eu}, ++ {0x80048678u, 0x30420940u}, ++}; ++ ++static const DisruptorBackdropTileSite *disruptor_intro_skip_site( ++ uint32_t address) { ++ for (const DisruptorBackdropTileSite &site : kDisruptorIntroSkipSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ ++static const DisruptorBackdropTileSite *disruptor_backdrop_tile_site( ++ uint32_t address) { ++ for (const DisruptorBackdropTileSite &site : kDisruptorBackdropTileSites) { ++ if (site.pc == address) return &site; ++ } ++ return nullptr; ++} ++ + static bool codegen_cycle_per_insn() { + // DEFAULT ON for the faithful-timing (cycle-audit) branch: each instruction + // charges its cost at its own site, so the running cycle count is correct +@@ -723,6 +1014,36 @@ + uint32_t opcode = (instr >> 26) & 0x3F; + uint32_t rs = get_rs(instr); + uint32_t rt = get_rt(instr); ++ ++ // Explicit manual-screen lower-edge branches. Disruptor-class portal and ++ // wall renderers project in CPU code and use zero-bound branches rather ++ // than the usual SLTI/BLTZ funnel. Move that zero edge to -x_margin while ++ // retaining the original branch sense. At margin 0 these expressions are ++ // exactly the vanilla predicates. ++ if (config_.ws_cull_lower_branch_sites.count(addr)) { ++ if (opcode == 0x01) { ++ const uint32_t regimm_op = (instr >> 16) & 0x1Fu; ++ if (regimm_op == 0x00u) ++ return fmt::format("(int32_t){} < -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ if (regimm_op == 0x01u) ++ return fmt::format("(int32_t){} >= -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ } ++ if (opcode == 0x06u || opcode == 0x16u) ++ return fmt::format("(int32_t){} <= -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ if (opcode == 0x07u || opcode == 0x17u) ++ return fmt::format("(int32_t){} > -psx_ws_x_margin()" ++ " /* ws manual lower edge */", reg_name(rs)); ++ if (!config_.overlay_mode) { ++ fmt::print(stderr, ++ "ERROR: [widescreen.cull] lower_branch site 0x{:08X} " ++ "is not bltz/bgez/blez/bgtz (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ } + + // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just + // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, +@@ -784,6 +1105,78 @@ + + std::string code; + ++ const DisruptorVerticalCameraSite *vertical_camera_site = ++ disruptor_vertical_camera_site(addr); ++ if (vertical_camera_site && ++ instr != vertical_camera_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor vertical-camera site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, vertical_camera_site->instruction, instr)); ++ } ++ vertical_camera_site = nullptr; ++ } ++ ++ const DisruptorFarRenderingSite *far_rendering_site = ++ disruptor_far_rendering_site(addr); ++ if (far_rendering_site && instr != far_rendering_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor far-rendering site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, far_rendering_site->instruction, instr)); ++ } ++ far_rendering_site = nullptr; ++ } ++ ++ const DisruptorBillboardAspectSite *billboard_aspect_site = ++ disruptor_billboard_aspect_site(addr); ++ if (billboard_aspect_site && ++ instr != billboard_aspect_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor billboard-aspect site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, billboard_aspect_site->instruction, instr)); ++ } ++ billboard_aspect_site = nullptr; ++ } ++ ++ const DisruptorBackdropTileSite *backdrop_tile_site = ++ disruptor_backdrop_tile_site(addr); ++ if (backdrop_tile_site && instr != backdrop_tile_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor backdrop-tile site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, backdrop_tile_site->instruction, instr)); ++ } ++ backdrop_tile_site = nullptr; ++ } ++ const DisruptorBackdropTileSite *sprite_depth_site = ++ disruptor_sprite_depth_site(addr); ++ if (sprite_depth_site && instr != sprite_depth_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor sprite-depth site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, sprite_depth_site->instruction, instr)); ++ } ++ sprite_depth_site = nullptr; ++ } ++ const DisruptorBackdropTileSite *intro_skip_site = ++ disruptor_intro_skip_site(addr); ++ if (intro_skip_site && instr != intro_skip_site->instruction) { ++ if (!config_.overlay_mode) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor intro-skip site 0x{:08X} expected word " ++ "0x{:08X}, found 0x{:08X}", ++ addr, intro_skip_site->instruction, instr)); ++ } ++ intro_skip_site = nullptr; ++ } ++ + // Full-word-guarded terrain-frustum half-angle constants. Scaling the + // tangent is the geometric counterpart of widening the horizontal + // projection; adding screen pixels directly to 12-bit angle units is not. +@@ -812,6 +1205,67 @@ + "{} = psx_ws_angle_widen({}u);" + " /* aspect-scaled terrain frustum half-angle */{}", + reg_name(dst), (uint32_t)vanilla, comment); ++ } ++ ++ // Full-word-guarded wrapping-yaw half-FOV offsets. Disruptor, for example, ++ // seeds portal traversal with yaw +/-32 in an eight-bit turn. Widen the ++ // signed offset geometrically while leaving the game's base yaw and wrap ++ // behavior untouched. ++ for (const auto& site : config_.ws_cull_yaw_cone_sites) { ++ if ((site.address & 0x1FFFFFFFu) != ++ (addr & 0x1FFFFFFFu)) continue; ++ if (instr != site.expected) { ++ if (config_.overlay_mode) continue; ++ fmt::print(stderr, ++ "ERROR: yaw cone expected 0x{:08X} at 0x{:08X}, " ++ "found 0x{:08X}\n", ++ site.expected, addr, instr); ++ std::exit(1); ++ } ++ if ((opcode != 0x08u && opcode != 0x09u) || ++ get_rs(instr) == 0u || site.full_turn < 16u) { ++ fmt::print(stderr, ++ "ERROR: yaw cone site 0x{:08X} is not a valid " ++ "ADDI/ADDIU rt,rs,offset (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ const uint32_t dst = get_rt(instr); ++ const uint32_t src = get_rs(instr); ++ const int32_t vanilla = (int16_t)(instr & 0xFFFFu); ++ return fmt::format( ++ "{} = {} + (uint32_t)psx_ws_yaw_cone_offset({}, {}u);" ++ " /* aspect-scaled wrapping yaw half-FOV */{}", ++ reg_name(dst), reg_name(src), vanilla, site.full_turn, comment); ++ } ++ ++ // Symmetric camera-horizontal side-plane rejects. Scaling the horizontal ++ // operand is the exact counterpart of widening the projection; vertical ++ // tests in the same functions remain completely vanilla. ++ for (const auto& site : config_.ws_cull_horizontal_slt_sites) { ++ if ((site.address & 0x1FFFFFFFu) != ++ (addr & 0x1FFFFFFFu)) continue; ++ if (instr != site.expected) { ++ if (config_.overlay_mode) continue; ++ fmt::print(stderr, ++ "ERROR: horizontal SLT expected 0x{:08X} at " ++ "0x{:08X}, found 0x{:08X}\n", ++ site.expected, addr, instr); ++ std::exit(1); ++ } ++ if (opcode != 0u || funct != 0x2Au || ++ ((instr >> 6) & 31u) != 0u) { ++ fmt::print(stderr, ++ "ERROR: horizontal SLT site 0x{:08X} is not signed " ++ "register-register SLT (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ return fmt::format( ++ "{} = psx_ws_horizontal_slt({}, {});" ++ " /* aspect-scaled camera-horizontal side plane */{}", ++ reg_name(get_rd(instr)), reg_name(get_rs(instr)), ++ reg_name(get_rt(instr)), comment); + } + + for (const auto& site : config_.ws_signed_x_bound_sites) { +@@ -971,6 +1425,76 @@ + // same address holds different code across scene variants, so a mismatch is + // expected — apply the transform only where the bytes match, else fall + // through to the vanilla translation (see CodeGenConfig::overlay_mode). ++ // Manual screen-window bound materialization. These are deliberately ++ // exact-site and instruction-shape guarded: a generic 320 constant can be ++ // a centre-relative divider, texture width, or packed span, and those ++ // meanings are not interchangeable. ++ const bool ws_lower_value = config_.ws_cull_lower_value_sites.count(addr); ++ const bool ws_upper_value = config_.ws_cull_upper_value_sites.count(addr); ++ if (ws_lower_value || ws_upper_value) { ++ const char* helper = ws_lower_value ++ ? "psx_ws_cull_lower_value" : "psx_ws_cull_upper_value"; ++ const char* tag = ws_lower_value ? "lower" : "upper"; ++ if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && ++ get_rs(instr) == 0u) { ++ const uint32_t dst = get_rt(instr); ++ const int32_t vanilla = opcode == 0x0Du ++ ? (int32_t)get_imm16_u(instr) : (int32_t)get_imm16(instr); ++ return fmt::format( ++ "{} = (uint32_t){}({});" ++ " /* ws manual {} bound value */{}", ++ reg_name(dst), helper, vanilla, tag, comment); ++ } ++ if (ws_lower_value && opcode == 0x00u && ++ (funct == 0x21u || funct == 0x25u) && ++ get_rs(instr) == 0u && get_rt(instr) == 0u) { ++ return fmt::format( ++ "{} = (uint32_t)psx_ws_cull_lower_value(0);" ++ " /* ws manual lower clamp */{}", ++ reg_name(get_rd(instr)), comment); ++ } ++ if (ws_lower_value && opcode == 0x2Bu && get_rt(instr) == 0u) { ++ const uint32_t base = get_rs(instr); ++ const int16_t offset = get_imm16(instr); ++ const std::string store_addr = offset == 0 ++ ? reg_name(base) ++ : fmt::format("{} + {}", reg_name(base), (int)offset); ++ return fmt::format( ++ "g_debug_last_store_pc = 0x{:08X}u; " ++ "psx_store_cycle_barrier(); cpu->write_word({}, " ++ "(uint32_t)psx_ws_cull_lower_value(0));" ++ " /* ws manual lower clamp store */{}", ++ addr, store_addr, comment); ++ } ++ if (!config_.overlay_mode) { ++ fmt::print(stderr, ++ "ERROR: [widescreen.cull] {}_value site 0x{:08X} " ++ "is not a supported constant/clamp form (0x{:08X})\n", ++ tag, addr, instr); ++ std::exit(1); ++ } ++ } ++ if (config_.ws_cull_span_value_sites.count(addr)) { ++ if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && ++ get_rs(instr) == 0u) { ++ const uint32_t dst = get_rt(instr); ++ const uint32_t vanilla = opcode == 0x0Du ++ ? (uint32_t)get_imm16_u(instr) ++ : (uint32_t)(int32_t)get_imm16(instr); ++ return fmt::format( ++ "{} = psx_ws_cull_span_value(0x{:08X}u);" ++ " /* ws manual packed [left,right] span */{}", ++ reg_name(dst), vanilla, comment); ++ } ++ if (!config_.overlay_mode) { ++ fmt::print(stderr, ++ "ERROR: [widescreen.cull] span_value site 0x{:08X} " ++ "is not ADDI/ADDIU/ORI rt,zero,imm (0x{:08X})\n", ++ addr, instr); ++ std::exit(1); ++ } ++ } ++ + if (config_.ws_cull_bias_sites.count(addr)) { + if (opcode == 0x08 || opcode == 0x09) { // addi / addiu + uint32_t rs = get_rs(instr), rt = get_rt(instr); +@@ -1217,11 +1741,15 @@ + } + // Overlay variant at the same address: leave nonmatching code unchanged. + } +- // Widescreen backdrop screenX squash ([widescreen.backdrop] x_sites). The +- // site is the `sh rt,off(base)` storing a parallax 2D backdrop layer's +- // final screen-X; squash the stored value around the screen centre so the +- // un-GTE'd backdrop tracks the 16:9 FOV (psx_ws_backdrop_x is identity at +- // 4:3). Instruction type is verified; a mismatch is a loud build error. ++ // Widescreen CPU-projected screen-X correction ++ // ([widescreen.backdrop] x_sites). Most sites are `sh rt,off(base)` stores ++ // from parallax layers. Some 3D engines also calculate their portal or ++ // billboard projection wholly on the CPU and materialize the final X with ++ // `addiu rt,rs,centre`; route those exact sites through the same classic ++ // projection squash as the GTE. This keeps the guest's original 0..W clip ++ // invariants intact instead of widening portal spans into negative values. ++ // Both helpers are identities at 4:3. Instruction shape is verified so a ++ // stale game address fails loudly rather than changing unrelated code. + if (config_.ws_backdrop_x_sites.count(addr)) { + if (opcode == 0x29) { // sh + uint32_t rs = get_rs(instr), rt = get_rt(instr); +@@ -1232,9 +1760,16 @@ + reg_name(rs), reg_name(rt), comment); + return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_half({} + {}, (uint16_t)psx_ws_backdrop_x((int16_t){}));{}", + reg_name(rs), offset, reg_name(rt), comment); ++ } else if (opcode == 0x09) { // addiu rt,rs,screen-centre ++ uint32_t rs = get_rs(instr), rt = get_rt(instr); ++ int16_t imm = get_imm16(instr); ++ return fmt::format( ++ "{} = (uint32_t)psx_ws_project_x((int32_t)({} + (uint32_t){}));" ++ " /* ws CPU-projected screen-x */{}", ++ reg_name(rt), reg_name(rs), (int32_t)imm, comment); + } else if (!config_.overlay_mode) { + fmt::print(stderr, "ERROR: [widescreen.backdrop] x site 0x{:08X} is not " +- "sh (opcode 0x{:02X})\n", addr, opcode); ++ "sh or addiu (opcode 0x{:02X})\n", addr, opcode); + std::exit(1); + } + // overlay variant: addr is different code here — fall through to vanilla. +@@ -1486,11 +2021,26 @@ + const std::string gte_read = fmt::format( + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt); + if (cop_op == 0x00) { // MFC2 - move from COP2 data ++ std::string value; + if (PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd))) { +- code = gte_read + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd); ++ value = fmt::format("gte_read_data(cpu, {})", rd); + } else { +- code = gte_read + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); ++ value = fmt::format("cpu->gte_data[{}]", rd); + } ++ if (disruptor_precision_mfc2_pc(addr)) { ++ if (rt != 10u || rd != 14u || instr != 0x480A7000u) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision MFC2 site 0x{:08X} no longer " ++ "matches MFC2 r10,SXY2 (word 0x{:08X})", ++ addr, instr)); ++ } ++ value = fmt::format( ++ "gte_precision_mfc2_pc_read(0x{:08X}u, " ++ "0x{:08X}u, {}, {})", ++ addr, instr, rd, value); ++ } ++ code = gte_read + fmt::format( ++ "{} = {}; /* mfc2 */", reg_name(rt), value); + } else if (cop_op == 0x02) { // CFC2 - move from COP2 control + if (PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd))) { + code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); +@@ -1521,7 +2071,35 @@ + case 0x20: code = translate_lb(instr); break; // lb + case 0x21: code = translate_lh(instr); break; // lh + case 0x22: code = translate_lwl(instr); break; // lwl +- case 0x23: code = translate_lw(instr); break; // lw ++ case 0x23: { // lw ++ const DisruptorPrecisionCopySite *site = ++ disruptor_precision_copy_site( ++ kDisruptorPrecisionCopyLoads, addr); ++ if (!site) { ++ code = translate_lw(instr); ++ break; ++ } ++ const uint32_t rs = get_rs(instr); ++ const uint32_t rt = get_rt(instr); ++ const int16_t offset = get_imm16(instr); ++ if (instr != site->instruction || rt != site->gpr) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision-copy LW site 0x{:08X} no longer " ++ "matches word 0x{:08X}/r{} (found 0x{:08X})", ++ addr, site->instruction, site->gpr, instr)); ++ } ++ const std::string load_addr = offset == 0 ++ ? reg_name(rs) ++ : fmt::format("{} + {}", reg_name(rs), offset); ++ code = fmt::format( ++ "{{ uint32_t _precision_copy_addr = {}; " ++ "{} = psx_cyc_load_word(cpu, _precision_copy_addr, {}, " ++ "0x{:X}u); {} = gte_precision_copy_pc_read(" ++ "0x{:08X}u, 0x{:08X}u, {}, _precision_copy_addr, {}); }}", ++ load_addr, reg_name(rt), rt, 1u << rs, reg_name(rt), ++ addr, instr, rt, reg_name(rt)); ++ break; ++ } + case 0x24: code = translate_lbu(instr); break; // lbu + case 0x25: code = translate_lhu(instr); break; // lhu + case 0x26: code = translate_lwr(instr); break; // lwr +@@ -1533,7 +2111,69 @@ + case 0x28: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sb(instr); break; // sb + case 0x29: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sh(instr); break; // sh + case 0x2A: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swl(instr); break; // swl +- case 0x2B: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sw(instr); break; // sw ++ case 0x2B: { // sw ++ const DisruptorPrecisionCopySite *copy_site = ++ disruptor_precision_copy_site( ++ kDisruptorPrecisionCopyStores, addr); ++ if (copy_site) { ++ const uint32_t rs = get_rs(instr); ++ const uint32_t rt = get_rt(instr); ++ const int16_t offset = get_imm16(instr); ++ if (instr != copy_site->instruction || ++ rt != copy_site->gpr) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision-copy SW site 0x{:08X} no " ++ "longer matches word 0x{:08X}/r{} (found " ++ "0x{:08X})", ++ addr, copy_site->instruction, ++ copy_site->gpr, instr)); ++ } ++ const std::string store_addr = offset == 0 ++ ? reg_name(rs) ++ : fmt::format("{} + {}", reg_name(rs), offset); ++ code = fmt::format( ++ "g_debug_last_store_pc = 0x{:08X}u; " ++ "{{ psx_store_cycle_barrier(); " ++ "uint32_t _precision_copy_addr = {}; " ++ "uint32_t _precision_copy_value = {}; " ++ "cpu->write_word(_precision_copy_addr, " ++ "_precision_copy_value); " ++ "gte_precision_copy_pc_word(0x{:08X}u, " ++ "0x{:08X}u, {}, _precision_copy_addr, " ++ "_precision_copy_value); }}", ++ addr, store_addr, reg_name(rt), addr, instr, rt); ++ break; ++ } ++ code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + ++ translate_sw(instr); ++ if (disruptor_precision_store_pc(addr) || ++ disruptor_precision_scratch_store_pc(addr)) { ++ const uint32_t rs = get_rs(instr); ++ const uint32_t rt = get_rt(instr); ++ const int16_t offset = get_imm16(instr); ++ if (rs != 5u || rt != 10u || offset != 4 || ++ instr != 0xACAA0004u) { ++ throw std::runtime_error(fmt::format( ++ "Disruptor precision SW site 0x{:08X} no longer " ++ "matches SW r10,4(r5) (word 0x{:08X})", ++ addr, instr)); ++ } ++ const std::string store_addr = fmt::format( ++ "{} + {}", reg_name(rs), offset); ++ if (disruptor_precision_scratch_store_pc(addr)) { ++ code += fmt::format( ++ " gte_precision_scratch_store_pc_word(" ++ "0x{:08X}u, 0x{:08X}u, {}, {});", ++ addr, instr, store_addr, reg_name(rt)); ++ } else { ++ code += fmt::format( ++ " gte_precision_store_pc_word(0x{:08X}u, " ++ "0x{:08X}u, {}, {});", ++ addr, instr, store_addr, reg_name(rt)); ++ } ++ } ++ break; ++ } + case 0x2E: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swr(instr); break; // swr + case 0x2F: // CACHE - cache op (no-op for static recomp) + code = "/* cache (no-op: static recompilation has no I/D cache) */"; +@@ -1590,6 +2230,43 @@ + default: + code = fmt::format("/* TODO: opcode=0x{:02X} */", opcode); + } ++ } ++ ++ if (vertical_camera_site) { ++ code += fmt::format( ++ " disruptor_vertical_camera_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ addr, instr); ++ } ++ if (far_rendering_site) { ++ code += fmt::format( ++ "\n{}disruptor_far_rendering_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (billboard_aspect_site) { ++ code += fmt::format( ++ "\n{}disruptor_billboard_aspect_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (backdrop_tile_site) { ++ code += fmt::format( ++ "\n{}disruptor_backdrop_tiles_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (sprite_depth_site) { ++ code += fmt::format( ++ "\n{}disruptor_sprite_depth_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); ++ } ++ if (intro_skip_site) { ++ code += fmt::format( ++ "\n{}disruptor_intro_skip_instruction_hook(cpu, " ++ "0x{:08X}u, 0x{:08X}u, 1);", ++ config_.indent, addr, instr); + } + + return config_.indent + code + comment; +@@ -2984,6 +3661,12 @@ + ss << "#endif\n"; + ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; + ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ++ ss << "extern void disruptor_vertical_camera_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned vertical-camera seam */\n"; ++ ss << "extern void disruptor_far_rendering_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned far-rendering seam */\n"; ++ ss << "extern void disruptor_billboard_aspect_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned world-billboard seam */\n"; ++ ss << "extern void disruptor_backdrop_tiles_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned backdrop-tile seam */\n"; ++ ss << "extern void disruptor_sprite_depth_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned sprite-depth seam */\n"; ++ ss << "extern void disruptor_intro_skip_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned intro-skip seam */\n"; + ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; + ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; + ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; +@@ -2993,6 +3676,9 @@ + ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n"; + ss << "extern int psx_ws_cull_slti(uint32_t sx, uint32_t imm); /* ws cull signed right edge (gpu.c) */\n"; + ss << "extern int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); /* ws cull signed lower edge (gpu.c) */\n"; ++ ss << "extern int32_t psx_ws_cull_lower_value(int32_t vanilla); /* ws manual lower bound (gpu.c) */\n"; ++ ss << "extern int32_t psx_ws_cull_upper_value(int32_t vanilla); /* ws manual upper bound (gpu.c) */\n"; ++ ss << "extern uint32_t psx_ws_cull_span_value(uint32_t vanilla); /* ws packed [left,right] span (gpu.c) */\n"; + ss << "extern int psx_ws_cull_bltz(uint32_t v); /* ws cull signed left edge (gpu.c) */\n"; + ss << "extern int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* ws masked-u16 X window */\n"; + ss << "extern int32_t psx_ws_depth_bound(int32_t imm); /* ws aspect-scaled far bound */\n"; +@@ -3001,7 +3687,10 @@ + ss << "extern uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); /* ws guarded keep compare (gpu.c) */\n"; + ss << "extern uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, uint32_t object, int32_t x, int32_t z, int32_t y); /* aspect-aware participation cone */\n"; + ss << "extern uint32_t psx_ws_angle_widen(uint32_t vanilla); /* aspect-scaled 12-bit terrain-frustum half-angle */\n"; ++ ss << "extern int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); /* aspect-scaled wrapping yaw half-FOV */\n"; ++ ss << "extern uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); /* aspect-scaled signed horizontal side plane */\n"; + ss << "extern int psx_ws_backdrop_x(int x); /* widescreen backdrop screenX squash (gpu.c) */\n"; ++ ss << "extern int psx_ws_project_x(int x); /* classic-wide CPU projection squash (gpu.c) */\n"; + ss << "extern int psx_ws_bg2d_cols(int base); /* ws 2D bg tile-loop widen: col count (gpu.c) */\n"; + ss << "extern int psx_ws_bg2d_startcol(int col, unsigned mask); /* ws 2D bg tile-loop widen: start tile col (gpu.c) */\n"; + ss << "extern int psx_ws_bg2d_startx(int x); /* ws 2D bg tile-loop widen: start screen-x (gpu.c) */\n"; +@@ -3013,6 +3702,11 @@ + ss << "extern int psx_game_option_store(uint32_t addr, int val); /* persisted OPTION restore-at-init (game_options.c) */\n"; + ss << "extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ws backdrop preload (gpu.c) */\n"; + ss << "extern void gte_ws_set_suppress(int on); /* widescreen far-backdrop un-squash (gte.cpp) */\n"; ++ ss << "extern uint32_t gte_precision_mfc2_pc_read(uint32_t pc, uint32_t insn, uint8_t reg, uint32_t packed); /* reviewed MFC2 snapshot */\n"; ++ ss << "extern void gte_precision_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed ordinary SW */\n"; ++ ss << "extern void gte_precision_scratch_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed scratchpad SW */\n"; ++ ss << "extern uint32_t gte_precision_copy_pc_read(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy LW */\n"; ++ ss << "extern void gte_precision_copy_pc_word(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy SW */\n"; + ss << "extern uint32_t g_debug_last_store_pc; /* exact PC of the executing SW/SH/SB — wtrace/readtrace producer attribution (debug_server.c) */\n\n"; + } + diff --git a/framework/patches/recompiler/src/config_loader.cpp.patch b/framework/patches/recompiler/src/config_loader.cpp.patch new file mode 100644 index 0000000..1437c52 --- /dev/null +++ b/framework/patches/recompiler/src/config_loader.cpp.patch @@ -0,0 +1,633 @@ +diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp +index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0b23a24d3 100644 +--- a/recompiler/src/config_loader.cpp ++++ b/recompiler/src/config_loader.cpp +@@ -3,12 +3,26 @@ + #include "config_loader.h" + + #include ++#include + #include ++#include ++#include + #include + #include ++#include ++#include + #include + #include + #include ++ ++#ifdef _WIN32 ++#ifndef NOMINMAX ++#define NOMINMAX ++#endif ++#include ++#else ++#include ++#endif + + #include "bios_rom_alias.h" + #include "fmt/format.h" +@@ -64,6 +78,10 @@ + h.words("cull_screen_x", c.ws_cull_screen_x_sites); + h.words("cull_slti", c.ws_cull_slti_sites); + h.words("cull_slti_lower", c.ws_cull_slti_lower_sites); ++ h.words("cull_lower_branch", c.ws_cull_lower_branch_sites); ++ h.words("cull_lower_value", c.ws_cull_lower_value_sites); ++ h.words("cull_upper_value", c.ws_cull_upper_value_sites); ++ h.words("cull_span_value", c.ws_cull_span_value_sites); + h.words("cull_bltz", c.ws_cull_bltz_sites); + h.words("cull_negsub", c.ws_cull_negsub_sites); + h.words("cull_vxrange", c.ws_cull_vxrange_sites); +@@ -120,6 +138,36 @@ + h.tag("cull_angle"); + h.u32((uint32_t)angle_sites.size()); + for (const auto& site : angle_sites) { ++ h.u32(site.address); ++ h.u32(site.expected); ++ } ++ ++ std::vector yaw_cone_sites = ++ c.ws_cull_yaw_cone_sites; ++ std::sort(yaw_cone_sites.begin(), yaw_cone_sites.end(), ++ [](const auto& a, const auto& b) { ++ if (a.address != b.address) return a.address < b.address; ++ if (a.expected != b.expected) return a.expected < b.expected; ++ return a.full_turn < b.full_turn; ++ }); ++ h.tag("cull_yaw_cone"); ++ h.u32((uint32_t)yaw_cone_sites.size()); ++ for (const auto& site : yaw_cone_sites) { ++ h.u32(site.address); ++ h.u32(site.expected); ++ h.u32(site.full_turn); ++ } ++ ++ std::vector horizontal_slt_sites = ++ c.ws_cull_horizontal_slt_sites; ++ std::sort(horizontal_slt_sites.begin(), horizontal_slt_sites.end(), ++ [](const auto& a, const auto& b) { ++ if (a.address != b.address) return a.address < b.address; ++ return a.expected < b.expected; ++ }); ++ h.tag("cull_horizontal_slt"); ++ h.u32((uint32_t)horizontal_slt_sites.size()); ++ for (const auto& site : horizontal_slt_sites) { + h.u32(site.address); + h.u32(site.expected); + } +@@ -496,9 +544,9 @@ + const toml::value& video = toml::find(cfg, "video"); + if (video.contains("supersampling")) { + const auto n = toml::find(video, "supersampling"); +- if (n < 1 || n > 4) { ++ if (n < 1 || n > 8) { + throw std::runtime_error(fmt::format( +- "[video] supersampling out of range (1..4): {}", n)); ++ "[video] supersampling out of range (1..8): {}", n)); + } + rt.video_supersampling = static_cast(n); + } +@@ -1451,6 +1499,10 @@ + std::vector ws_cull_screen_x_sites; + std::vector ws_cull_slti_sites; + std::vector ws_cull_slti_lower_sites; ++ std::vector ws_cull_lower_branch_sites; ++ std::vector ws_cull_lower_value_sites; ++ std::vector ws_cull_upper_value_sites; ++ std::vector ws_cull_span_value_sites; + std::vector ws_cull_bltz_sites; + std::vector ws_cull_negsub_sites; + std::vector ws_cull_vxrange_sites; +@@ -1459,6 +1511,9 @@ + std::vector ws_cull_xclip_load_sites; + std::vector ws_cull_keep_sites; + std::vector ws_cull_angle_sites; ++ std::vector ws_cull_yaw_cone_sites; ++ std::vector ++ ws_cull_horizontal_slt_sites; + WidescreenAspectConeConfig ws_aspect_cone; + int ws_cull_guard_pixels = 0; + int ws_cull_activation_guard_pixels = 0; +@@ -1484,6 +1539,10 @@ + load_sites("screen_x_sites", ws_cull_screen_x_sites); + load_sites("slti_sites", ws_cull_slti_sites); + load_sites("slti_lower_sites", ws_cull_slti_lower_sites); ++ load_sites("lower_branch_sites", ws_cull_lower_branch_sites); ++ load_sites("lower_value_sites", ws_cull_lower_value_sites); ++ load_sites("upper_value_sites", ws_cull_upper_value_sites); ++ load_sites("span_value_sites", ws_cull_span_value_sites); + load_sites("bltz_sites", ws_cull_bltz_sites); + load_sites("negsub_sites", ws_cull_negsub_sites); + load_sites("vxrange_sites", ws_cull_vxrange_sites); +@@ -1552,6 +1611,85 @@ + "{}: duplicate cull-angle address 0x{:08X}", + config_path.string(), site.address)); + ws_cull_angle_sites.push_back(site); ++ } ++ } ++ if (cull.contains("yaw_cone")) { ++ std::set seen; ++ for (const auto& item : ++ toml::find(cull, "yaw_cone")) { ++ WidescreenYawConeSite site; ++ site.address = parse_hex( ++ toml::find(item, "address"), ++ "widescreen.cull.yaw_cone.address"); ++ site.expected = parse_hex( ++ toml::find(item, "expected"), ++ "widescreen.cull.yaw_cone.expected"); ++ const int64_t full_turn = ++ toml::find(item, "full_turn"); ++ if (full_turn < 16 || full_turn > 65536 || ++ (full_turn & 3) != 0) { ++ throw std::runtime_error(fmt::format( ++ "{}: [[widescreen.cull.yaw_cone]] full_turn " ++ "must be a multiple of four in [16, 65536]", ++ config_path.string())); ++ } ++ site.full_turn = (uint32_t)full_turn; ++ const uint32_t op = site.expected >> 26; ++ const uint32_t rs = (site.expected >> 21) & 31u; ++ const int32_t imm = ++ (int16_t)(site.expected & 0xFFFFu); ++ const int32_t magnitude = imm < 0 ? -imm : imm; ++ if ((op != 0x08u && op != 0x09u) || rs == 0u || ++ imm == 0 || ++ magnitude >= (int32_t)(site.full_turn / 4u)) { ++ throw std::runtime_error(fmt::format( ++ "{}: [[widescreen.cull.yaw_cone]] expected must " ++ "be ADDI/ADDIU rt,rs,+/-half_fov with rs != zero " ++ "and |half_fov| < full_turn/4", ++ config_path.string())); ++ } ++ if ((site.address & 3u) != 0) ++ throw std::runtime_error(fmt::format( ++ "{}: yaw-cone address 0x{:08X} is not " ++ "instruction-aligned", ++ config_path.string(), site.address)); ++ if (!seen.insert(site.address & 0x1FFFFFFFu).second) ++ throw std::runtime_error(fmt::format( ++ "{}: duplicate yaw-cone address 0x{:08X}", ++ config_path.string(), site.address)); ++ ws_cull_yaw_cone_sites.push_back(site); ++ } ++ } ++ if (cull.contains("horizontal_slt")) { ++ std::set seen; ++ for (const auto& item : ++ toml::find(cull, "horizontal_slt")) { ++ WidescreenHorizontalSltSite site; ++ site.address = parse_hex( ++ toml::find(item, "address"), ++ "widescreen.cull.horizontal_slt.address"); ++ site.expected = parse_hex( ++ toml::find(item, "expected"), ++ "widescreen.cull.horizontal_slt.expected"); ++ const uint32_t op = site.expected >> 26; ++ const uint32_t fn = site.expected & 0x3Fu; ++ const uint32_t shamt = (site.expected >> 6) & 31u; ++ if (op != 0u || fn != 0x2Au || shamt != 0u) { ++ throw std::runtime_error(fmt::format( ++ "{}: [[widescreen.cull.horizontal_slt]] expected " ++ "must be a signed register-register SLT", ++ config_path.string())); ++ } ++ if ((site.address & 3u) != 0) ++ throw std::runtime_error(fmt::format( ++ "{}: horizontal-SLT address 0x{:08X} is not " ++ "instruction-aligned", ++ config_path.string(), site.address)); ++ if (!seen.insert(site.address & 0x1FFFFFFFu).second) ++ throw std::runtime_error(fmt::format( ++ "{}: duplicate horizontal-SLT address 0x{:08X}", ++ config_path.string(), site.address)); ++ ws_cull_horizontal_slt_sites.push_back(site); + } + } + if (cull.contains("aspect_cone")) { +@@ -1901,6 +2039,10 @@ + /*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites, + /*ws_cull_slti_sites*/ ws_cull_slti_sites, + /*ws_cull_slti_lower_sites*/ ws_cull_slti_lower_sites, ++ /*ws_cull_lower_branch_sites*/ ws_cull_lower_branch_sites, ++ /*ws_cull_lower_value_sites*/ ws_cull_lower_value_sites, ++ /*ws_cull_upper_value_sites*/ ws_cull_upper_value_sites, ++ /*ws_cull_span_value_sites*/ ws_cull_span_value_sites, + /*ws_cull_bltz_sites*/ ws_cull_bltz_sites, + /*ws_cull_negsub_sites*/ ws_cull_negsub_sites, + /*ws_cull_vxrange_sites*/ ws_cull_vxrange_sites, +@@ -1909,6 +2051,8 @@ + /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, + /*ws_cull_keep_sites*/ ws_cull_keep_sites, + /*ws_cull_angle_sites*/ ws_cull_angle_sites, ++ /*ws_cull_yaw_cone_sites*/ ws_cull_yaw_cone_sites, ++ /*ws_cull_horizontal_slt_sites*/ ws_cull_horizontal_slt_sites, + /*ws_aspect_cone*/ ws_aspect_cone, + /*ws_cull_guard_pixels*/ ws_cull_guard_pixels, + /*ws_cull_activation_guard_pixels*/ ws_cull_activation_guard_pixels, +@@ -1995,7 +2139,7 @@ + return go; + } + +-// ---- UserSettings (settings.toml) — launcher-written override layer ---- ++// ---- UserSettings (settings.toml) — user-interface-written override layer ---- + + UserSettings load_user_settings(const fs::path& path) { + UserSettings s; +@@ -2004,7 +2148,10 @@ + + toml::value doc; + try { +- doc = toml::parse(path.string()); ++ // Opened by its wide name: path.string() is UTF-8 under MinGW, which the narrow open reads as the ANSI page. ++ std::ifstream file(path, std::ios::binary); ++ if (!file) throw std::runtime_error("settings.toml could not be opened"); ++ doc = toml::parse(file, "settings.toml"); + } catch (const std::exception&) { + // Malformed file: fall back to all-defaults rather than refuse to boot, + // but tell the caller so the user hears about it (and so the file can +@@ -2026,7 +2173,7 @@ + }); + if (v.contains("supersampling")) try_get([&]{ + const auto n = toml::find(v, "supersampling"); +- if (n >= 1 && n <= 4) { s.supersampling = (int)n; s.has_supersampling = true; } ++ if (n >= 1 && n <= 8) { s.supersampling = (int)n; s.has_supersampling = true; } + }); + if (v.contains("window_width")) try_get([&]{ + const auto n = toml::find(v, "window_width"); +@@ -2087,8 +2234,16 @@ + }); + if (v.contains("frame_interpolation_fps")) try_get([&]{ + s.frame_interpolation_fps = toml::find(v, "frame_interpolation_fps"); +- if (s.frame_interpolation_fps == 0 || s.frame_interpolation_fps >= 90) ++ if (s.frame_interpolation_fps == 0 || ++ (s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000)) + s.has_frame_interpolation_fps = true; ++ }); ++ if (v.contains("frame_interpolation_blend")) try_get([&]{ ++ const auto n = toml::find(v, "frame_interpolation_blend"); ++ if (n == 0 || n == 1 || n == 2) { ++ s.frame_interpolation_blend = static_cast(n); ++ s.has_frame_interpolation_blend = true; ++ } + }); + if (v.contains("aspect_ratio")) try_get([&]{ + const auto m = toml::find(v, "aspect_ratio"); +@@ -2106,6 +2261,17 @@ + const toml::value& a = toml::find(doc, "audio"); + if (a.contains("spu_hq")) try_get([&]{ + s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; ++ }); ++ if (a.contains("master_volume")) try_get([&]{ ++ const auto n = toml::find(a, "master_volume"); ++ if (n >= 0 && n <= 100) { ++ s.master_volume = static_cast(n); ++ s.has_master_volume = true; ++ } ++ }); ++ if (a.contains("muted")) try_get([&]{ ++ s.audio_muted = toml::find(a, "muted"); ++ s.has_audio_muted = true; + }); + } + if (doc.contains("launcher")) { +@@ -2230,15 +2396,190 @@ + if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } + }); + } ++ if (doc.contains("disruptor")) { ++ const toml::value& d = toml::find(doc, "disruptor"); ++ if (d.contains("mouse_aim")) try_get([&]{ ++ s.mouse_aim = toml::find(d, "mouse_aim"); ++ s.has_mouse_aim = true; ++ }); ++ if (d.contains("modern_controls")) try_get([&]{ ++ s.modern_controls = toml::find(d, "modern_controls"); ++ s.has_modern_controls = true; ++ }); ++ if (d.contains("horizontal_sensitivity")) try_get([&]{ ++ double n = 0.0; ++ try { ++ n = toml::find(d, "horizontal_sensitivity"); ++ } catch (const std::exception&) { ++ n = static_cast( ++ toml::find(d, "horizontal_sensitivity")); ++ } ++ if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { ++ s.horizontal_sensitivity = n; ++ s.has_horizontal_sensitivity = true; ++ } ++ }); ++ if (d.contains("invert_horizontal")) try_get([&]{ ++ s.invert_horizontal = toml::find(d, "invert_horizontal"); ++ s.has_invert_horizontal = true; ++ }); ++ if (d.contains("vertical_look")) try_get([&]{ ++ s.vertical_look = toml::find(d, "vertical_look"); ++ s.has_vertical_look = true; ++ }); ++ if (d.contains("vertical_sensitivity")) try_get([&]{ ++ double n = 0.0; ++ try { ++ n = toml::find(d, "vertical_sensitivity"); ++ } catch (const std::exception&) { ++ n = static_cast( ++ toml::find(d, "vertical_sensitivity")); ++ } ++ if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { ++ s.vertical_sensitivity = n; ++ s.has_vertical_sensitivity = true; ++ } ++ }); ++ if (d.contains("invert_vertical")) try_get([&]{ ++ s.invert_vertical = toml::find(d, "invert_vertical"); ++ s.has_invert_vertical = true; ++ }); ++ if (d.contains("high_precision_camera")) try_get([&]{ ++ s.high_precision_camera = toml::find(d, "high_precision_camera"); ++ s.has_high_precision_camera = true; ++ }); ++ if (d.contains("geometry_correction")) try_get([&]{ ++ s.geometry_correction = toml::find(d, "geometry_correction"); ++ s.has_geometry_correction = true; ++ }); ++ if (d.contains("perspective_textures")) try_get([&]{ ++ s.perspective_textures = toml::find(d, "perspective_textures"); ++ s.has_perspective_textures = true; ++ }); ++ if (d.contains("frame_unlock")) try_get([&]{ ++ s.frame_unlock = toml::find(d, "frame_unlock"); ++ s.has_frame_unlock = true; ++ }); ++ if (d.contains("skip_intro")) try_get([&]{ ++ s.skip_intro = toml::find(d, "skip_intro"); ++ s.has_skip_intro = true; ++ }); ++ if (d.contains("language_disc")) try_get([&]{ ++ s.language_disc = toml::find(d, "language_disc"); ++ s.has_language_disc = true; ++ }); ++ if (d.contains("language_discs")) try_get([&]{ ++ s.language_discs = toml::find>(d, "language_discs"); ++ s.has_language_discs = true; ++ }); ++ if (d.contains("hud_scale")) try_get([&]{ ++ const int percent = toml::find(d, "hud_scale"); ++ if (percent >= 50 && percent <= 100) { ++ s.hud_scale = percent; ++ s.has_hud_scale = true; ++ } ++ }); ++ if (d.contains("improved_shadows")) try_get([&]{ ++ s.improved_shadows = toml::find(d, "improved_shadows"); ++ s.has_improved_shadows = true; ++ }); ++ if (d.contains("ambient_occlusion")) try_get([&]{ ++ s.ambient_occlusion = toml::find(d, "ambient_occlusion"); ++ s.has_ambient_occlusion = true; ++ }); ++ if (d.contains("occlusion_reach")) try_get([&]{ ++ const int reach = toml::find(d, "occlusion_reach"); ++ if (reach >= 50 && reach <= 800) { ++ s.occlusion_reach = reach; ++ s.has_occlusion_reach = true; ++ } ++ }); ++ if (d.contains("occlusion_strength")) try_get([&]{ ++ const int percent = toml::find(d, "occlusion_strength"); ++ if (percent >= 0 && percent <= 300) { ++ s.occlusion_strength = percent; ++ s.has_occlusion_strength = true; ++ } ++ }); ++ if (d.contains("gap_filling")) try_get([&]{ ++ s.gap_filling = toml::find(d, "gap_filling"); ++ s.has_gap_filling = true; ++ }); ++ if (d.contains("depth_sorted_world")) try_get([&]{ ++ s.depth_sorted_world = toml::find(d, "depth_sorted_world"); ++ s.has_depth_sorted_world = true; ++ }); ++ if (d.contains("diagnostic_view")) try_get([&]{ ++ const std::string view = toml::find(d, "diagnostic_view"); ++ for (int index = 0; index < kDisruptorDiagnosticViewCount; ++index) { ++ if (view == kDisruptorDiagnosticViews[index]) { ++ s.diagnostic_view = index; ++ s.has_diagnostic_view = true; ++ } ++ } ++ }); ++ } + return s; + } ++ ++namespace { ++ ++fs::path user_settings_temp_path(const fs::path& destination) { ++ static std::atomic sequence{0}; ++#ifdef _WIN32 ++ const auto process_id = static_cast(::GetCurrentProcessId()); ++#else ++ const auto process_id = static_cast(::getpid()); ++#endif ++ const auto tick = static_cast( ++ std::chrono::steady_clock::now().time_since_epoch().count()); ++ ++ fs::path temporary = destination; ++ temporary += fmt::format(".tmp.{}.{}.{}", process_id, tick, ++ sequence.fetch_add(1, std::memory_order_relaxed)); ++ return temporary; ++} ++ ++bool atomic_replace_user_settings(const fs::path& temporary, ++ const fs::path& destination) { ++#ifdef _WIN32 ++ // A reader or antivirus scanner may briefly deny replacement after a save. ++ // Keep the old file intact and allow up to 500 ms for that handle to close. ++ for (unsigned attempt = 0; ; ++attempt) { ++ if (::MoveFileExW(temporary.c_str(), destination.c_str(), ++ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true; ++ const DWORD error = ::GetLastError(); ++ const bool transient = error == ERROR_ACCESS_DENIED || ++ error == ERROR_SHARING_VIOLATION || ++ error == ERROR_LOCK_VIOLATION; ++ const DWORD attributes = ::GetFileAttributesW(destination.c_str()); ++ const bool blocked = attributes != INVALID_FILE_ATTRIBUTES && ++ (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)); ++ if (!transient || blocked || attempt == 20) { ++ ::SetLastError(error); ++ return false; ++ } ++ ::Sleep(25); ++ } ++#else ++ return std::rename(temporary.c_str(), destination.c_str()) == 0; ++#endif ++} ++ ++void remove_user_settings_temp(const fs::path& temporary) { ++ std::error_code ignored; ++ fs::remove(temporary, ignored); ++} ++ ++} // namespace + + bool save_user_settings(const fs::path& path, const UserSettings& s) { + std::error_code ec; + if (!path.parent_path().empty()) + fs::create_directories(path.parent_path(), ec); // best-effort + +- std::ofstream f(path, std::ios::trunc); ++ const fs::path temporary = user_settings_temp_path(path); ++ std::ofstream f(temporary, std::ios::trunc); + if (!f.is_open()) return false; + + // TOML strings use forward slashes so backslash escaping is never an issue. +@@ -2247,7 +2588,21 @@ + return str; + }; + +- f << "# psxrecomp user settings - written by the launcher. Safe to hand-edit.\n"; ++ // A TOML basic string: a quote, a backslash and a control character are escaped. ++ auto quoted = [](const std::string& text) { ++ std::string out = "\""; ++ for (const char letter : text) { ++ const unsigned code = static_cast(letter); ++ char escape[8]; ++ if (letter == '\\' || letter == '"') std::snprintf(escape, sizeof(escape), "\\%c", letter); ++ else if (code < 0x20 || code == 0x7F) std::snprintf(escape, sizeof(escape), "\\u%04X", code); ++ else std::snprintf(escape, sizeof(escape), "%c", letter); ++ out += escape; ++ } ++ return out + "\""; ++ }; ++ ++ f << "# psxrecomp user settings - written by settings UIs. Safe to hand-edit.\n"; + f << "# Overrides the bundled game.toml; the command line overrides this file.\n\n"; + + f << "[video]\n"; +@@ -2287,6 +2642,11 @@ + f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n"; + if (s.has_frame_interpolation_fps) + f << "frame_interpolation_fps = " << s.frame_interpolation_fps << "\n"; ++ if (s.has_frame_interpolation_blend && ++ (s.frame_interpolation_blend == 0 || ++ s.frame_interpolation_blend == 1 || ++ s.frame_interpolation_blend == 2)) ++ f << "frame_interpolation_blend = " << s.frame_interpolation_blend << "\n"; + if (s.has_aspect_ratio) + f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; + if (s.has_adaptive_view) +@@ -2294,6 +2654,10 @@ + f << "\n[audio]\n"; + if (s.has_spu_hq) + f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; ++ if (s.has_master_volume) ++ f << "master_volume = " << s.master_volume << "\n"; ++ if (s.has_audio_muted) ++ f << "muted = " << (s.audio_muted ? "true" : "false") << "\n"; + if (s.has_skip_launcher) + f << "\n[launcher]\nskip_launcher = " << (s.skip_launcher ? "true" : "false") << "\n"; + if ((s.has_netplay_player_name && !s.netplay_player_name.empty()) || +@@ -2353,7 +2717,98 @@ + f << "extra_late = " << s.parappa_timing_extra_late << "\n"; + } + +- return f.good(); ++ if (s.has_mouse_aim || s.has_modern_controls || ++ s.has_horizontal_sensitivity || s.has_invert_horizontal || ++ s.has_vertical_look || s.has_vertical_sensitivity || ++ s.has_invert_vertical || ++ s.has_high_precision_camera || s.has_geometry_correction || ++ s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || ++ s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || ++ s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || ++ s.has_language_discs || ++ s.has_language_disc || s.has_skip_intro || ++ s.has_hud_scale) { ++ f << "\n[disruptor]\n"; ++ if (s.has_mouse_aim) ++ f << "mouse_aim = " << (s.mouse_aim ? "true" : "false") << "\n"; ++ if (s.has_modern_controls) ++ f << "modern_controls = " << (s.modern_controls ? "true" : "false") << "\n"; ++ if (s.has_horizontal_sensitivity && ++ std::isfinite(s.horizontal_sensitivity) && ++ s.horizontal_sensitivity >= 0.005 && ++ s.horizontal_sensitivity <= 2.0) { ++ f << "horizontal_sensitivity = " ++ << std::setprecision(std::numeric_limits::max_digits10) ++ << s.horizontal_sensitivity << "\n"; ++ } ++ if (s.has_invert_horizontal) ++ f << "invert_horizontal = " << (s.invert_horizontal ? "true" : "false") << "\n"; ++ if (s.has_vertical_look) ++ f << "vertical_look = " << (s.vertical_look ? "true" : "false") << "\n"; ++ if (s.has_vertical_sensitivity && ++ std::isfinite(s.vertical_sensitivity) && ++ s.vertical_sensitivity >= 0.005 && ++ s.vertical_sensitivity <= 2.0) { ++ f << "vertical_sensitivity = " ++ << std::setprecision(std::numeric_limits::max_digits10) ++ << s.vertical_sensitivity << "\n"; ++ } ++ if (s.has_invert_vertical) ++ f << "invert_vertical = " << (s.invert_vertical ? "true" : "false") << "\n"; ++ if (s.has_high_precision_camera) ++ f << "high_precision_camera = " << (s.high_precision_camera ? "true" : "false") << "\n"; ++ if (s.has_geometry_correction) ++ f << "geometry_correction = " << (s.geometry_correction ? "true" : "false") << "\n"; ++ if (s.has_perspective_textures) ++ f << "perspective_textures = " << (s.perspective_textures ? "true" : "false") << "\n"; ++ if (s.has_frame_unlock) ++ f << "frame_unlock = " << (s.frame_unlock ? "true" : "false") << "\n"; ++ if (s.has_skip_intro) ++ f << "skip_intro = " << (s.skip_intro ? "true" : "false") << "\n"; ++ if (s.has_language_disc) ++ f << "language_disc = " << quoted(s.language_disc) << "\n"; ++ if (s.has_language_discs) { ++ f << "language_discs = ["; ++ for (size_t index = 0; index < s.language_discs.size(); ++index) ++ f << (index ? ", " : "") << quoted(s.language_discs[index]); ++ f << "]\n"; ++ } ++ if (s.has_hud_scale && s.hud_scale >= 50 && s.hud_scale <= 100) ++ f << "hud_scale = " << s.hud_scale << "\n"; ++ if (s.has_improved_shadows) ++ f << "improved_shadows = " << (s.improved_shadows ? "true" : "false") << "\n"; ++ if (s.has_ambient_occlusion) ++ f << "ambient_occlusion = " << (s.ambient_occlusion ? "true" : "false") << "\n"; ++ if (s.has_occlusion_reach && s.occlusion_reach >= 50 && s.occlusion_reach <= 800) ++ f << "occlusion_reach = " << s.occlusion_reach << "\n"; ++ if (s.has_occlusion_strength && s.occlusion_strength >= 0 && s.occlusion_strength <= 300) ++ f << "occlusion_strength = " << s.occlusion_strength << "\n"; ++ if (s.has_gap_filling) ++ f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\n"; ++ if (s.has_depth_sorted_world) ++ f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; ++ if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) ++ f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; ++ } ++ ++ f.flush(); ++ const bool write_ok = f.good(); ++ f.close(); ++ if (!write_ok || f.fail()) { ++ remove_user_settings_temp(temporary); ++ return false; ++ } ++ if (!atomic_replace_user_settings(temporary, path)) { ++#ifdef _WIN32 ++ const DWORD error = ::GetLastError(); ++#endif ++ remove_user_settings_temp(temporary); ++#ifdef _WIN32 ++ ::SetLastError(error); ++#endif ++ return false; ++ } ++ return true; + } + + } // namespace PSXRecompV4 diff --git a/framework/patches/recompiler/src/config_loader.h.patch b/framework/patches/recompiler/src/config_loader.h.patch new file mode 100644 index 0000000..18ac7f7 --- /dev/null +++ b/framework/patches/recompiler/src/config_loader.h.patch @@ -0,0 +1,167 @@ +diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h +index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139ea621c6ed 100644 +--- a/recompiler/src/config_loader.h ++++ b/recompiler/src/config_loader.h +@@ -71,6 +71,26 @@ + struct WidescreenAngleSite { + uint32_t address = 0; + uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero ++}; ++ ++// Aspect-scaled wrapping yaw offsets. Unlike WidescreenAngleSite, these are ++// ADDI/ADDIU instructions whose source register already contains the live ++// camera yaw. The signed immediate is a half-FOV in `full_turn` units (for ++// example +/-32 with full_turn=256). Only the offset is widened; the base yaw ++// and its natural integer wrapping remain owned by the game. ++struct WidescreenYawConeSite { ++ uint32_t address = 0; ++ uint32_t expected = 0; // guarded ADDI/ADDIU with rs != zero ++ uint32_t full_turn = 0; ++}; ++ ++// Exact signed SLT side-plane tests of the form `bound < horizontal`. The ++// runtime aspect-scales only the horizontal operand, preserving the original ++// comparison at 4:3 and widening a camera-space horizontal cone without ++// touching its corresponding vertical test. ++struct WidescreenHorizontalSltSite { ++ uint32_t address = 0; ++ uint32_t expected = 0; // guarded signed register-register SLT + }; + + // Aspect-aware horizontal participation cone. The exact compare sites are +@@ -309,7 +329,7 @@ + + // ---- [video] block — visual enhancement options ---- + // supersampling: internal-resolution SSAA factor (per axis). 1 = native +- // (default, behaves exactly as before). 2..4 render geometry/shading into ++ // (default, behaves exactly as before). 2..8 render geometry/shading into + // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid + // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. + int video_supersampling = 1; +@@ -695,6 +715,20 @@ + // Explicit signed lower-bound sites (`slti rt, sx, -W`). The threshold is + // moved left by the live reveal margin. Empty by default; regen required. + std::vector ws_cull_slti_lower_sites; ++ // Manual screen-window code often expresses the left edge as a zero-bound ++ // branch (`bgez`, `bgtz`, `blez`, or `bltz`) rather than an SLTI. At these ++ // exact branch PCs, move that boundary from 0 to -x_margin while preserving ++ // the original branch sense and the exact 4:3 result. ++ std::vector ws_cull_lower_branch_sites; ++ // Exact constant/store sites which materialize the manual window edges. ++ // lower_value_sites accepts li-zero forms and `sw zero,off(base)` and emits ++ // vanilla-x_margin; upper_value_sites accepts li constants and emits ++ // vanilla+x_margin. span_value_sites accepts a packed signed-left/u16-right ++ // constant (high16,low16), widening both halves symmetrically. All are ++ // identity transforms when native-wide is inactive; regen required. ++ std::vector ws_cull_lower_value_sites; ++ std::vector ws_cull_upper_value_sites; ++ std::vector ws_cull_span_value_sites; + // [widescreen.cull] bltz_sites — explicit signed LEFT-edge widen sites + // (`bltz rs, reject` -> psx_ws_cull_bltz), the counterpart to slti_sites. + // detect_cull_bltz_sites only classifies left-edge bltz for functions +@@ -730,6 +764,10 @@ + std::vector ws_cull_keep_sites; + // Exact 12-bit angular half-extents used by terrain-cell frusta. + std::vector ws_cull_angle_sites; ++ // Signed wrapping yaw +/- half-FOV offsets used to seed portal traversal. ++ std::vector ws_cull_yaw_cone_sites; ++ // Signed camera-horizontal side-plane rejects used after traversal. ++ std::vector ws_cull_horizontal_slt_sites; + // Full-word-guarded model-participation cosine compares widened only in + // the camera-horizontal plane. Empty/default is completely inert. + WidescreenAspectConeConfig ws_aspect_cone; +@@ -944,12 +982,12 @@ + uint32_t ws_bg2d_packet_cap = 1000; + }; + +-// UserSettings — the launcher-written, user-editable override layer. ++// UserSettings — the launcher/in-game-UI-written, user-editable override layer. + // + // Lives in a `settings.toml` next to the runtime exe (NOT in the repo). It is + // layered on top of the bundled game.toml at startup: any field present here + // overrides the corresponding game.toml value, and the command line overrides +-// both. Absent fields fall through to game.toml. The launcher seeds this file ++// both. Absent fields fall through to game.toml. A settings UI seeds this file + // from game.toml defaults the first time it writes. + // + // Unlike game.toml, paths here are stored verbatim (the user picked them); they +@@ -963,7 +1001,7 @@ + + // [video] + bool has_renderer = false; int renderer = 0; // 0=software,1=opengl +- bool has_supersampling = false; int supersampling = 1; // 1..4 ++ bool has_supersampling = false; int supersampling = 1; // 1..8 + // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to + // both the launcher and the emulator window so they boot at the same size. + bool has_window_width = false; int window_width = 1280; // -> 1280x960 +@@ -995,6 +1033,10 @@ + bool has_vsync = false; int vsync = 1; + bool has_frame_interpolation = false; bool frame_interpolation = false; + bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; ++ // Frame-interpolation mode: 0 = legacy linear, 1 = legacy adaptive, ++ // 2 = geometry inter-frame redraw. Activation remains session-only in ++ // Disruptor; its runtime migrates saved legacy modes to geometry. ++ bool has_frame_interpolation_blend = false; int frame_interpolation_blend = 0; + // [launcher] — when true, boot straight into the game and skip the GUI + // launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run: + // `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off. +@@ -1008,6 +1050,10 @@ + bool has_adaptive_view = false; bool adaptive_view = false; + // [audio] + bool has_spu_hq = false; bool spu_hq = false; ++ // Host-output controls. These scale the final mixed stereo stream and do ++ // not alter SPU registers, voice state, CD playback state or guest saves. ++ bool has_master_volume = false; int master_volume = 100; // 0..100 ++ bool has_audio_muted = false; bool audio_muted = false; + // [bios] / [disc] / [memcard] + bool has_bios_path = false; std::filesystem::path bios_path; + bool has_disc_path = false; std::filesystem::path disc_path; +@@ -1043,7 +1089,36 @@ + bool has_parappa_timing_mode = false; std::string parappa_timing_mode = "stock"; + bool has_parappa_timing_extra_early = false; int parappa_timing_extra_early = 0; + bool has_parappa_timing_extra_late = false; int parappa_timing_extra_late = 0; +-}; ++ ++ // [disruptor] — Disruptor-specific, live-safe enhancement settings. ++ // Kept optional so a missing key continues to inherit the launch-time ++ // default or an explicit per-run override. ++ bool has_mouse_aim = false; bool mouse_aim = false; ++ bool has_modern_controls = false; bool modern_controls = false; ++ bool has_horizontal_sensitivity = false; double horizontal_sensitivity = 0.1; ++ bool has_invert_horizontal = false; bool invert_horizontal = false; ++ bool has_vertical_look = false; bool vertical_look = false; ++ bool has_vertical_sensitivity = false; double vertical_sensitivity = 0.08; ++ bool has_invert_vertical = false; bool invert_vertical = false; ++ bool has_high_precision_camera = false; bool high_precision_camera = false; ++ bool has_geometry_correction = false; bool geometry_correction = false; ++ bool has_perspective_textures = false; bool perspective_textures = false; ++ bool has_frame_unlock = false; bool frame_unlock = false; ++ bool has_skip_intro = false; bool skip_intro = false; ++ bool has_language_disc = false; std::string language_disc; // image of another region's disc, "" for none ++ bool has_language_discs = false; std::vector language_discs; // the ones named before, for a launcher to offer again ++ bool has_hud_scale = false; int hud_scale = 100; ++ bool has_improved_shadows = false; bool improved_shadows = false; ++ bool has_ambient_occlusion = false; bool ambient_occlusion = false; ++ bool has_occlusion_reach = false; int occlusion_reach = 160; // camera depth units, 50..800 ++ bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 ++ bool has_gap_filling = false; bool gap_filling = true; ++ bool has_depth_sorted_world = false; bool depth_sorted_world = false; ++ // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. ++ bool has_diagnostic_view = false; int diagnostic_view = 0; ++}; ++inline constexpr const char* kDisruptorDiagnosticViews[] = {"off", "shading", "depth", "normals", "fade", "mesh"}; ++inline constexpr int kDisruptorDiagnosticViewCount = 6; + + // GameOptions — the game's OWN native OPTION-screen settings, declared in a + // dedicated, self-contained `game_options.toml` next to game.toml. Kept entirely +@@ -1084,7 +1159,9 @@ + // never throws. + UserSettings load_user_settings(const std::filesystem::path& path); + +-// Write settings.toml deterministically. Returns false on I/O failure. ++// Write settings.toml deterministically through a same-directory temporary ++// file and atomically replace the destination. Returns false on I/O failure; ++// the previous destination is left intact and the temporary file is removed. + bool save_user_settings(const std::filesystem::path& path, const UserSettings& s); + + // Locate the project root by walking upward from `config_path` until a diff --git a/framework/patches/recompiler/src/full_function_emitter.cpp.patch b/framework/patches/recompiler/src/full_function_emitter.cpp.patch new file mode 100644 index 0000000..1b1ac08 --- /dev/null +++ b/framework/patches/recompiler/src/full_function_emitter.cpp.patch @@ -0,0 +1,13 @@ +diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp +index 6d7f80bf721ec298f293ebd8cb39449f52865668..5c5cb38670df0dba8d65860eb134ebfed4e3dd3f 100644 +--- a/recompiler/src/full_function_emitter.cpp ++++ b/recompiler/src/full_function_emitter.cpp +@@ -1792,7 +1792,7 @@ + g_sym_prefix, kb_count); + out += kb; + out += "};\n"; +- out += fmt::format("static const uint32_t {}psx_bios_kernel_body_count = {}u;\n\n", ++ out += fmt::format("enum {{ {}psx_bios_kernel_body_count = {}u }};\n\n", + g_sym_prefix, kb_count); + } + diff --git a/framework/patches/recompiler/src/main_psx.cpp.patch b/framework/patches/recompiler/src/main_psx.cpp.patch new file mode 100644 index 0000000..194a58c --- /dev/null +++ b/framework/patches/recompiler/src/main_psx.cpp.patch @@ -0,0 +1,97 @@ +diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp +index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7bebbb4215a 100644 +--- a/recompiler/src/main_psx.cpp ++++ b/recompiler/src/main_psx.cpp +@@ -169,6 +169,10 @@ + std::set ws_cull_screen_x; // [widescreen.cull] screen_x_sites + std::set ws_cull_slti; // [widescreen.cull] slti_sites + std::set ws_cull_slti_lower; // [widescreen.cull] slti_lower_sites ++ std::set ws_cull_lower_branch; // [widescreen.cull] lower_branch_sites ++ std::set ws_cull_lower_value; // [widescreen.cull] lower_value_sites ++ std::set ws_cull_upper_value; // [widescreen.cull] upper_value_sites ++ std::set ws_cull_span_value; // [widescreen.cull] span_value_sites + std::set ws_cull_bltz; // [widescreen.cull] bltz_sites + std::set ws_cull_negsub; // [widescreen.cull] negsub_sites + std::set ws_cull_vxrange; // [widescreen.cull] vxrange_sites +@@ -177,6 +181,9 @@ + std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites + std::vector ws_cull_keep; + std::vector ws_cull_angle; ++ std::vector ws_cull_yaw_cone; ++ std::vector ++ ws_cull_horizontal_slt; + PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; + int ws_cull_activation_guard_pixels = 0; + std::vector ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms +@@ -226,6 +233,14 @@ + ws_cull_slti.insert(cfg.ws_cull_slti_sites.begin(), cfg.ws_cull_slti_sites.end()); + ws_cull_slti_lower.insert(cfg.ws_cull_slti_lower_sites.begin(), + cfg.ws_cull_slti_lower_sites.end()); ++ ws_cull_lower_branch.insert(cfg.ws_cull_lower_branch_sites.begin(), ++ cfg.ws_cull_lower_branch_sites.end()); ++ ws_cull_lower_value.insert(cfg.ws_cull_lower_value_sites.begin(), ++ cfg.ws_cull_lower_value_sites.end()); ++ ws_cull_upper_value.insert(cfg.ws_cull_upper_value_sites.begin(), ++ cfg.ws_cull_upper_value_sites.end()); ++ ws_cull_span_value.insert(cfg.ws_cull_span_value_sites.begin(), ++ cfg.ws_cull_span_value_sites.end()); + ws_cull_bltz.insert(cfg.ws_cull_bltz_sites.begin(), cfg.ws_cull_bltz_sites.end()); + ws_cull_negsub.insert(cfg.ws_cull_negsub_sites.begin(), cfg.ws_cull_negsub_sites.end()); + ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end()); +@@ -234,6 +249,8 @@ + ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); + ws_cull_keep = cfg.ws_cull_keep_sites; + ws_cull_angle = cfg.ws_cull_angle_sites; ++ ws_cull_yaw_cone = cfg.ws_cull_yaw_cone_sites; ++ ws_cull_horizontal_slt = cfg.ws_cull_horizontal_slt_sites; + ws_aspect_cone = cfg.ws_aspect_cone; + ws_cull_activation_guard_pixels = + cfg.ws_cull_activation_guard_pixels; +@@ -314,6 +331,14 @@ + ws_cull_slti.insert(wscfg.ws_cull_slti_sites.begin(), wscfg.ws_cull_slti_sites.end()); + ws_cull_slti_lower.insert(wscfg.ws_cull_slti_lower_sites.begin(), + wscfg.ws_cull_slti_lower_sites.end()); ++ ws_cull_lower_branch.insert(wscfg.ws_cull_lower_branch_sites.begin(), ++ wscfg.ws_cull_lower_branch_sites.end()); ++ ws_cull_lower_value.insert(wscfg.ws_cull_lower_value_sites.begin(), ++ wscfg.ws_cull_lower_value_sites.end()); ++ ws_cull_upper_value.insert(wscfg.ws_cull_upper_value_sites.begin(), ++ wscfg.ws_cull_upper_value_sites.end()); ++ ws_cull_span_value.insert(wscfg.ws_cull_span_value_sites.begin(), ++ wscfg.ws_cull_span_value_sites.end()); + ws_cull_bltz.insert(wscfg.ws_cull_bltz_sites.begin(), wscfg.ws_cull_bltz_sites.end()); + ws_cull_negsub.insert(wscfg.ws_cull_negsub_sites.begin(), wscfg.ws_cull_negsub_sites.end()); + ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end()); +@@ -322,6 +347,11 @@ + ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); + if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; + if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; ++ if (ws_cull_yaw_cone.empty()) ++ ws_cull_yaw_cone = wscfg.ws_cull_yaw_cone_sites; ++ if (ws_cull_horizontal_slt.empty()) ++ ws_cull_horizontal_slt = ++ wscfg.ws_cull_horizontal_slt_sites; + if (ws_aspect_cone.sites.empty()) + ws_aspect_cone = wscfg.ws_aspect_cone; + ws_cull_activation_guard_pixels = +@@ -1152,6 +1182,10 @@ + ws_cull_activation_guard_pixels; + codegen_config.ws_cull_slti_sites = ws_cull_slti; + codegen_config.ws_cull_slti_lower_sites = ws_cull_slti_lower; ++ codegen_config.ws_cull_lower_branch_sites = ws_cull_lower_branch; ++ codegen_config.ws_cull_lower_value_sites = ws_cull_lower_value; ++ codegen_config.ws_cull_upper_value_sites = ws_cull_upper_value; ++ codegen_config.ws_cull_span_value_sites = ws_cull_span_value; + codegen_config.ws_cull_bltz_sites = ws_cull_bltz; + codegen_config.ws_cull_negsub_sites = ws_cull_negsub; + codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange; +@@ -1160,6 +1194,9 @@ + codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; + codegen_config.ws_cull_keep_sites = ws_cull_keep; + codegen_config.ws_cull_angle_sites = ws_cull_angle; ++ codegen_config.ws_cull_yaw_cone_sites = ws_cull_yaw_cone; ++ codegen_config.ws_cull_horizontal_slt_sites = ++ ws_cull_horizontal_slt; + codegen_config.ws_aspect_cone = ws_aspect_cone; + codegen_config.ws_cull_w_imms = ws_cull_w_imms; + codegen_config.ws_cull_h_imms = ws_cull_h_imms; diff --git a/framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch b/framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch new file mode 100644 index 0000000..57eecf5 --- /dev/null +++ b/framework/patches/recompiler/tests/recompiler_patch_test.cpp.patch @@ -0,0 +1,209 @@ +diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp +index 05f9cd45d963df9fd098b35522822c17191a739d..19125131038d312fe36111cd17e0b6b61bd297d5 100644 +--- a/recompiler/tests/recompiler_patch_test.cpp ++++ b/recompiler/tests/recompiler_patch_test.cpp +@@ -206,6 +206,32 @@ + std::vector{0x80012340u}, + "parser preserves negsub cull sites"); + ++ const auto manual_window = write_config(root, "manual-window", R"toml( ++[widescreen.cull] ++lower_branch_sites = ["0x80012340"] ++lower_value_sites = ["0x80012344"] ++upper_value_sites = ["0x80012348"] ++span_value_sites = ["0x8001234C"] ++)toml"); ++ const auto manual_window_config = ++ PSXRecompV4::load_game_config(manual_window); ++ check(manual_window_config.ws_cull_lower_branch_sites == ++ std::vector{0x80012340u} && ++ manual_window_config.ws_cull_lower_value_sites == ++ std::vector{0x80012344u} && ++ manual_window_config.ws_cull_upper_value_sites == ++ std::vector{0x80012348u} && ++ manual_window_config.ws_cull_span_value_sites == ++ std::vector{0x8001234Cu}, ++ "parser preserves manual screen-window site families"); ++ auto changed_manual_window_config = manual_window_config; ++ changed_manual_window_config.ws_cull_span_value_sites.push_back( ++ 0x80012350u); ++ check(PSXRecompV4::overlay_codegen_config_hash(manual_window_config) != ++ PSXRecompV4::overlay_codegen_config_hash( ++ changed_manual_window_config), ++ "manual screen-window sites change overlay cache identity"); ++ + const auto vxrange = write_config(root, "vxrange", R"toml( + [widescreen.cull] + vxrange_sites = ["0x80012340"] +@@ -324,6 +350,65 @@ + check_throws([&] { (void)PSXRecompV4::load_game_config(bad_angle); }, + "expected must be ADDI/ADDIU rt,zero,imm", + "parser rejects non-constant terrain-angle instructions"); ++ ++ const auto yaw_cone = write_config(root, "yaw-cone", R"toml( ++[[widescreen.cull.yaw_cone]] ++address = "0x80040FE8" ++expected = "0x24710020" ++full_turn = 256 ++ ++[[widescreen.cull.yaw_cone]] ++address = "0x80040FF0" ++expected = "0x2472FFE0" ++full_turn = 256 ++ ++[[widescreen.cull.horizontal_slt]] ++address = "0x8003B8EC" ++expected = "0x0067102A" ++)toml"); ++ const auto yaw_cone_config = PSXRecompV4::load_game_config(yaw_cone); ++ check(yaw_cone_config.ws_cull_yaw_cone_sites.size() == 2 && ++ yaw_cone_config.ws_cull_yaw_cone_sites[0].address == ++ 0x80040FE8u && ++ yaw_cone_config.ws_cull_yaw_cone_sites[0].expected == ++ 0x24710020u && ++ yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn == 256u && ++ yaw_cone_config.ws_cull_yaw_cone_sites[1].expected == ++ 0x2472FFE0u, ++ "parser preserves signed wrapping-yaw cone sites"); ++ check(yaw_cone_config.ws_cull_horizontal_slt_sites.size() == 1 && ++ yaw_cone_config.ws_cull_horizontal_slt_sites[0].address == ++ 0x8003B8ECu && ++ yaw_cone_config.ws_cull_horizontal_slt_sites[0].expected == ++ 0x0067102Au, ++ "parser preserves signed horizontal side-plane sites"); ++ auto changed_yaw_cone_config = yaw_cone_config; ++ changed_yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn = 512u; ++ check(PSXRecompV4::overlay_codegen_config_hash(yaw_cone_config) != ++ PSXRecompV4::overlay_codegen_config_hash( ++ changed_yaw_cone_config), ++ "yaw-cone metadata changes overlay cache identity"); ++ ++ const auto bad_yaw_cone = write_config(root, "yaw-cone-bad", R"toml( ++[[widescreen.cull.yaw_cone]] ++address = "0x80040FE8" ++expected = "0x24110020" ++full_turn = 256 ++)toml"); ++ check_throws([&] { (void)PSXRecompV4::load_game_config(bad_yaw_cone); }, ++ "rs != zero", ++ "parser rejects yaw-cone constants without a live base yaw"); ++ ++ const auto bad_horizontal_slt = write_config( ++ root, "horizontal-slt-bad", R"toml( ++[[widescreen.cull.horizontal_slt]] ++address = "0x8003B8EC" ++expected = "0x0067102B" ++)toml"); ++ check_throws( ++ [&] { (void)PSXRecompV4::load_game_config(bad_horizontal_slt); }, ++ "signed register-register SLT", ++ "parser rejects unsigned horizontal side-plane compares"); + + const auto aspect_cone = write_config(root, "aspect-cone", R"toml( + [widescreen.cull.aspect_cone] +@@ -622,6 +707,67 @@ + check(overlay_mismatch.find("ws cull negsub") == std::string::npos, + "overlay nonmatching negsub variant remains unchanged"); + ++ PSXRecomp::CodeGenConfig cpu_projection_config; ++ cpu_projection_config.ws_backdrop_x_sites.insert(0x80010000u); ++ const std::string cpu_projection = generate_first_instruction( ++ 0x248200A0u, {}, false, cpu_projection_config); // addiu v0,a0,160 ++ check(cpu_projection.find( ++ "psx_ws_project_x((int32_t)(cpu->gpr[4] + (uint32_t)160))") != ++ std::string::npos, ++ "codegen squashes exact CPU-projected screen-X sites"); ++ ++ const std::string cpu_projection_overlay_mismatch = ++ generate_first_instruction( ++ 0x288200A0u, {}, true, cpu_projection_config); // slti at same VA ++ check(cpu_projection_overlay_mismatch.find("psx_ws_project_x") == ++ std::string::npos, ++ "overlay nonmatching CPU-projection variant remains unchanged"); ++ ++ PSXRecomp::CodeGenConfig lower_branch_config; ++ lower_branch_config.ws_cull_lower_branch_sites.insert(0x80010000u); ++ const std::string lower_bgez = generate_first_instruction( ++ 0x04810002u, {}, false, lower_branch_config); // bgez a0,+2 ++ check(lower_bgez.find( ++ "(int32_t)cpu->gpr[4] >= -psx_ws_x_margin()") != ++ std::string::npos, ++ "codegen widens a manual bgez lower screen edge"); ++ const std::string lower_blez = generate_first_instruction( ++ 0x18800002u, {}, false, lower_branch_config); // blez a0,+2 ++ check(lower_blez.find( ++ "(int32_t)cpu->gpr[4] <= -psx_ws_x_margin()") != ++ std::string::npos, ++ "codegen preserves manual lower-edge branch sense"); ++ ++ PSXRecomp::CodeGenConfig lower_value_config; ++ lower_value_config.ws_cull_lower_value_sites.insert(0x80010000u); ++ const std::string lower_zero = generate_first_instruction( ++ 0x00001021u, {}, false, lower_value_config); // addu v0,zero,zero ++ check(lower_zero.find("psx_ws_cull_lower_value(0)") != ++ std::string::npos, ++ "codegen widens a register lower clamp"); ++ const std::string lower_store = generate_first_instruction( ++ 0xAFA0002Cu, {}, false, lower_value_config); // sw zero,44(sp) ++ check(lower_store.find("cpu->write_word(cpu->gpr[29] + 44, " ++ "(uint32_t)psx_ws_cull_lower_value(0))") != ++ std::string::npos, ++ "codegen widens a stored lower clamp"); ++ ++ PSXRecomp::CodeGenConfig upper_value_config; ++ upper_value_config.ws_cull_upper_value_sites.insert(0x80010000u); ++ const std::string upper_value = generate_first_instruction( ++ 0x34020140u, {}, false, upper_value_config); // ori v0,zero,320 ++ check(upper_value.find("psx_ws_cull_upper_value(320)") != ++ std::string::npos, ++ "codegen widens a manual upper bound constant"); ++ ++ PSXRecomp::CodeGenConfig span_value_config; ++ span_value_config.ws_cull_span_value_sites.insert(0x80010000u); ++ const std::string span_value = generate_first_instruction( ++ 0x34020140u, {}, false, span_value_config); // packed [0,320] ++ check(span_value.find("psx_ws_cull_span_value(0x00000140u)") != ++ std::string::npos, ++ "codegen widens both halves of a packed screen span"); ++ + PSXRecomp::CodeGenConfig vxrange_config; + vxrange_config.ws_cull_vxrange_sites.insert(0x80010000u); + const std::string vxrange = generate_first_instruction( +@@ -747,6 +893,37 @@ + check(angle_overlay_mismatch.find("psx_ws_angle_widen") == + std::string::npos, + "overlay full-word mismatch leaves terrain angle unchanged"); ++ ++ PSXRecomp::CodeGenConfig yaw_config; ++ yaw_config.ws_cull_yaw_cone_sites.push_back( ++ {0x80010000u, 0x24710020u, 256u}); ++ const std::string yaw = generate_first_instruction( ++ 0x24710020u, {}, false, yaw_config); ++ check(yaw.find("cpu->gpr[17] = cpu->gpr[3] + (uint32_t)" ++ "psx_ws_yaw_cone_offset(32, 256u)") != ++ std::string::npos, ++ "codegen emits aspect-scaled wrapping-yaw offset"); ++ const std::string yaw_overlay_mismatch = generate_first_instruction( ++ 0x2471001Fu, {}, true, yaw_config); ++ check(yaw_overlay_mismatch.find("psx_ws_yaw_cone_offset") == ++ std::string::npos, ++ "overlay full-word mismatch leaves yaw cone unchanged"); ++ ++ PSXRecomp::CodeGenConfig horizontal_slt_config; ++ horizontal_slt_config.ws_cull_horizontal_slt_sites.push_back( ++ {0x80010000u, 0x0067102Au}); ++ const std::string horizontal_slt = generate_first_instruction( ++ 0x0067102Au, {}, false, horizontal_slt_config); ++ check(horizontal_slt.find( ++ "cpu->gpr[2] = psx_ws_horizontal_slt(cpu->gpr[3], " ++ "cpu->gpr[7])") != std::string::npos, ++ "codegen emits signed camera-horizontal side-plane helper"); ++ const std::string horizontal_slt_overlay_mismatch = ++ generate_first_instruction( ++ 0x0067102Bu, {}, true, horizontal_slt_config); ++ check(horizontal_slt_overlay_mismatch.find("psx_ws_horizontal_slt") == ++ std::string::npos, ++ "overlay full-word mismatch leaves horizontal side plane unchanged"); + + PSXRecomp::CodeGenConfig aspect_config; + aspect_config.ws_aspect_cone.sites.push_back( diff --git a/framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch b/framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch new file mode 100644 index 0000000..5735eb1 --- /dev/null +++ b/framework/patches/recompiler/tests/test_reachable_discovery_codegen.py.patch @@ -0,0 +1,16 @@ +diff --git a/recompiler/tests/test_reachable_discovery_codegen.py b/recompiler/tests/test_reachable_discovery_codegen.py +index db87e2bea0f88e85d6930b3c687472c91f8e1784..26fd31f8575e5ec96dbcbe054a15fcc05a41b558 100644 +--- a/recompiler/tests/test_reachable_discovery_codegen.py ++++ b/recompiler/tests/test_reachable_discovery_codegen.py +@@ -90,6 +90,11 @@ + + failures = [] + with tempfile.TemporaryDirectory() as tmp: ++ # Keep find_project_root() anchored to this fixture. Some CI/sandbox ++ # hosts place a repository marker in the shared temp parent, which ++ # would otherwise make relative test.exe/seeds.txt paths resolve one ++ # directory too high and turn this into an environment-dependent test. ++ pathlib.Path(tmp, ".gitignore").write_text("\n", encoding="utf-8") + exe = os.path.join(tmp, "test.exe") + seeds = os.path.join(tmp, "seeds.txt") + config = os.path.join(tmp, "game.toml") diff --git a/framework/patches/runtime/CMakeLists.txt.patch b/framework/patches/runtime/CMakeLists.txt.patch new file mode 100644 index 0000000..d517dcb --- /dev/null +++ b/framework/patches/runtime/CMakeLists.txt.patch @@ -0,0 +1,23 @@ +diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt +index 6e6df7651c4a9240a4423d977052975f25d7c237..efeecd26a0422c4ee3347bf3113ef59697c9f2e7 100644 +--- a/runtime/CMakeLists.txt ++++ b/runtime/CMakeLists.txt +@@ -16,6 +16,18 @@ + DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" + EXE_NAME "PSXRecomp" + ) ++if(WIN32) ++ target_compile_definitions( ++ psx-runtime PRIVATE "$<$:NOMINMAX>") ++endif() ++if(MSVC) ++ # audio_trace.c is the sole runtime source using C11 atomics. Keep the ++ # generated C in MSVC's extension-compatible C mode: its static tables use ++ # address constants that strict /std:c11 rejects. ++ set_property(SOURCE "${PSXRECOMP_ROOT}/runtime/src/audio_trace.c" ++ APPEND PROPERTY COMPILE_OPTIONS ++ "/std:c11" "/experimental:c11atomics") ++endif() + + if(BUILD_TESTING) + add_executable(launcher_device_roundtrip_test diff --git a/framework/patches/runtime/include/dirty_ram_interp.h.patch b/framework/patches/runtime/include/dirty_ram_interp.h.patch new file mode 100644 index 0000000..9ea8ea7 --- /dev/null +++ b/framework/patches/runtime/include/dirty_ram_interp.h.patch @@ -0,0 +1,24 @@ +diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h +index 883f3eaf789ceca5ca406ba82b9d2a5c18eb4261..7f9b87c3215659923e4e5050ec343b61bbbb894b 100644 +--- a/runtime/include/dirty_ram_interp.h ++++ b/runtime/include/dirty_ram_interp.h +@@ -102,6 +102,19 @@ + int psx_kernel_bless_dispatchable(uint32_t phys); + void psx_kernel_bless_note_range(uint32_t phys, uint32_t len); + void psx_kernel_bless_stats(uint64_t out[6]); ++/* Detail for the most recent kernel-body validation failure. `count` is the ++ * cumulative number of failed body validations; out[] is entry, body_lo, ++ * body_hi, first_mismatch, live_byte, reference_byte. */ ++void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]); ++ ++/* Opt-in, low-overhead interpreter hot-PC sampler used by ++ * PSX_RUNTIME_PERF_DIAG. It samples one retired instruction per `stride` and ++ * returns/resets the three hottest physical PCs since the previous take. */ ++#define DIRTY_RAM_PERF_HOT_COUNT 3u ++void dirty_ram_perf_hot_enable(int enabled); ++void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t *stride); + + /* Capture/candidate window membership. Kernel window and overlay region are + * unconditional; main-EXE text [KERNEL_WINDOW_END, FLOOR) is included only diff --git a/framework/patches/runtime/include/gpu.h.patch b/framework/patches/runtime/include/gpu.h.patch new file mode 100644 index 0000000..25d98c3 --- /dev/null +++ b/framework/patches/runtime/include/gpu.h.patch @@ -0,0 +1,230 @@ +diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h +index 11b29fe121b12ffe7b453fde551c274d99425a1e..fad42ec530bb44fb305f34317c46958e1d126ace 100644 +--- a/runtime/include/gpu.h ++++ b/runtime/include/gpu.h +@@ -1,4 +1,4 @@ +-/* gpu.h — PS1 GPU hardware simulation (Phase 3). ++/* gpu.h — PS1 GPU hardware simulation (Phase 3). + * + * Implements GPUSTAT, GP0, GP1, and 1024x512 16-bit VRAM. + * No rendering to screen — just correct hardware state transitions. +@@ -116,6 +116,135 @@ + * env var, if set, overrides this. Default 0 (raw) is byte-identical. */ + void gpu_set_screen_kind(int kind); + ++/* Presentation-only high-precision geometry. The guest-visible GP0/GTE path ++ * remains integer; supported renderers mirror gameplay polygons into a ++ * separate display surface using retained 16.16 projection coordinates. */ ++void gpu_geometry_correction_set(int enabled); ++int gpu_geometry_correction_enabled(void); ++void gpu_geometry_camera_yaw_residual_set(double yaw_units); ++/* Display-relative projection horizon shared with the optional presentation ++ * yaw homography. Values are rounded to an integer pixel; non-finite or ++ * out-of-range values reset the horizon to the 240-line default of 120. */ ++void gpu_geometry_camera_projection_center_y_set(double center_y); ++int gpu_geometry_camera_projection_center_y_get(void); ++void gpu_geometry_correction_stats(uint64_t *world_triangles, ++ uint64_t *precise_triangles); ++ ++/* Independent, default-off perspective texture interpolation for exact world ++ * triangles on the presentation mirror. Canonical VRAM, sprites, rectangles, ++ * UI, and any polygon without strict projective depth remain affine. */ ++void gpu_texture_correction_set(int enabled); ++int gpu_texture_correction_enabled(void); ++uint32_t gpu_texture_correction_hits(void); ++ ++/* Versioned Test-13 diagnostics. Candidate/accepted primitive arrays count ++ * polygons; triangle and vertex totals make quad coverage unambiguous. The ++ * latest_live counters are the most recent presentation which was eligible to ++ * consume the independent corrected surface, while cumulative includes every ++ * geometry-correction candidate since the feature was enabled. */ ++#define GPU_GEOMETRY_DIAGNOSTICS_VERSION 1u ++#define GPU_GEOMETRY_REJECT_REASON_COUNT 15u ++#define GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT 8u ++#define GPU_GEOMETRY_OPCODE_COUNT 32u ++#define GPU_GEOMETRY_DEPTH_BUCKET_COUNT 6u ++#define GPU_GEOMETRY_SCREEN_BUCKET_COUNT 10u ++#define GPU_GEOMETRY_CORRECTION_BUCKET_COUNT 5u ++ ++typedef enum GpuGeometryRejectReason { ++ GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE = 0, ++ GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW, ++ GPU_GEOMETRY_REJECT_TRACKING_DISABLED, ++ GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP, ++ GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM, ++ GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED, ++ GPU_GEOMETRY_REJECT_STORE_MISS, ++ GPU_GEOMETRY_REJECT_STORE_COLLISION, ++ GPU_GEOMETRY_REJECT_PROJECTION_INVALID, ++ GPU_GEOMETRY_REJECT_PACKED_MISMATCH, ++ GPU_GEOMETRY_REJECT_ZERO_DEPTH, ++ GPU_GEOMETRY_REJECT_SATURATED, ++ GPU_GEOMETRY_REJECT_INTEGER_MISMATCH, ++ GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, ++ GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH ++} GpuGeometryRejectReason; ++ ++typedef enum GpuGeometryPrimitiveClass { ++ GPU_GEOMETRY_PRIMITIVE_FLAT_TRI = 0, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_TRI, ++ GPU_GEOMETRY_PRIMITIVE_GOURAUD_TRI, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_TRI, ++ GPU_GEOMETRY_PRIMITIVE_FLAT_QUAD, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_QUAD, ++ GPU_GEOMETRY_PRIMITIVE_GOURAUD_QUAD, ++ GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_QUAD ++} GpuGeometryPrimitiveClass; ++ ++typedef struct GpuGeometryCorrectionCounters { ++ uint64_t triangle_candidates; ++ uint64_t triangle_accepted; ++ uint64_t polygon_candidates; ++ uint64_t polygon_accepted; ++ uint64_t vertex_candidates; ++ uint64_t vertex_accepted; ++ uint64_t vertex_rejections[GPU_GEOMETRY_REJECT_REASON_COUNT]; ++ uint64_t primitive_candidates[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; ++ uint64_t primitive_accepted[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; ++ /* Polygon opcode 0x20..0x3f, indexed by opcode - 0x20. */ ++ uint64_t opcode_candidates[GPU_GEOMETRY_OPCODE_COUNT]; ++ uint64_t opcode_accepted[GPU_GEOMETRY_OPCODE_COUNT]; ++ /* Accepted vertices: z <256,<1024,<4096,<16384,<32768,>=32768. */ ++ uint64_t depth_vertices[GPU_GEOMETRY_DEPTH_BUCKET_COUNT]; ++ /* Accepted vertices: 3x3 on-screen cells followed by off-screen. */ ++ uint64_t screen_vertices[GPU_GEOMETRY_SCREEN_BUCKET_COUNT]; ++ uint64_t correction_vertices; ++ uint64_t correction_abs_x16_sum; ++ uint64_t correction_abs_y16_sum; ++ uint64_t correction_magnitude16_sum; ++ uint64_t correction_magnitude16_max; ++ uint64_t correction_x16_squared_sum; ++ uint64_t correction_y16_squared_sum; ++ /* Set to one if any correction sum has saturated at UINT64_MAX. */ ++ uint64_t correction_accumulators_saturated; ++ /* max(|dx|,|dy|): zero, <.25px, <.5px, <.75px, <1px. */ ++ uint64_t correction_magnitude_buckets[ ++ GPU_GEOMETRY_CORRECTION_BUCKET_COUNT]; ++ uint64_t partial_polygon_rejections; ++ uint64_t partial_quad_rejections; ++} GpuGeometryCorrectionCounters; ++ ++typedef struct GpuGeometryCorrectionStats { ++ uint32_t version; ++ uint32_t size; ++ uint64_t presentation_sequence; ++ uint64_t corrected_presentations; ++ uint64_t latest_live_presentation; ++ uint32_t latest_live_valid; ++ uint32_t reserved; ++ uint64_t provenance_store_collisions; ++ uint64_t provenance_store_evictions; ++ uint64_t provenance_lookup_collisions; ++ GpuGeometryCorrectionCounters cumulative; ++ GpuGeometryCorrectionCounters latest_live; ++ /* Additive tail field: older size-bounded callers retain the version-1 ++ * prefix while current diagnostics expose rejected uncommitted SWC2s. */ ++ uint64_t provenance_store_uncommitted_rejections; ++ /* Reviewed MFC2 -> ordinary-SW routes. Attempts count committed-site ++ * observations; accepts entered exact provenance, while packed rejections ++ * identify clipped/repacked output that deliberately stayed canonical. */ ++ uint64_t provenance_registered_store_attempts; ++ uint64_t provenance_registered_store_accepts; ++ uint64_t provenance_registered_store_packed_rejections; ++ /* Reviewed projected-vertex-buffer -> GP0 packet copy routes. */ ++ uint64_t provenance_copy_load_attempts; ++ uint64_t provenance_copy_load_accepts; ++ uint64_t provenance_copy_store_attempts; ++ uint64_t provenance_copy_store_accepts; ++ uint64_t provenance_copy_store_packed_rejections; ++} GpuGeometryCorrectionStats; ++ ++void gpu_geometry_correction_stats_detailed( ++ GpuGeometryCorrectionStats *out, uint32_t out_size); ++ + /* Widescreen proportion correction (active only when aspect != 4:3 and the + * game's [widescreen] block opts in — see config_loader.h). Tagged + * character/billboard prims are re-squashed around their projected anchor; +@@ -126,6 +255,39 @@ + * (render the wider FOV into a wider frame, present 1:1; GTE not squashed). */ + void gpu_ws_configure(int aspect_num, int aspect_den, + uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode); ++/* Add an audited world primitive to the existing classic-squash provenance ++ * table. This is host presentation metadata only; it never edits guest RAM. ++ * Calls are ignored outside an active classic-wide gameplay presentation. */ ++/* Camera depth of a CPU-projected POLY_FT4 sprite, for frame interpolation. */ ++void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, ++ int32_t depth); ++/* The unrounded centre column of the sprite just noted and its distance from its row, 16.16. */ ++void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16); ++/* The unrounded row the placed sprite is centred on and its unrounded sides, 16.16. A shadow lies on the floor. ++ * whole: the packet spans the size cut to whole pixels, not one less. */ ++void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, ++ int shadow, int whole); ++/* Shadows as the game shapes them (0) or flattened the way the floor recedes (1). */ ++void gpu_set_shadow_shape(int improved); ++int gpu_shadow_shape(void); ++int gpu_sprite_placement_available(void); ++void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, ++ int32_t anchor_x); ++/* Mark a completed, audited SPRT packet as a backdrop tile laid out edge to ++ * edge in widened coordinates: classic squash maps it about the display centre. */ ++void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr); ++/* Widened counterpart of a frame X for such a tile span. */ ++int32_t gpu_ws_widen_x(int32_t x, int round_up); ++/* Size of the widescreen HUD in percent, 50 to 100. 100 is the authored size. */ ++void gpu_ws_set_hud_scale(int percent); ++int gpu_ws_hud_scale(void); ++/* HUD widgets of a game that links its HUD to `layers` nodes just below its ordering table, whose address ++ * is the guest word at `table_pointer`: six numbers each, the box x0, y0, x1, y1 and the pivot x, y. Once ++ * such a layer has shown a widget, later rectangles inside its box belong to it too, until the game sets ++ * its next drawing area. The game has to set one every frame. */ ++void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count); ++/* A game whose text printer draws a line as one picture `wide` across, the words from its left end. */ ++void gpu_ws_set_hud_text_strip(int wide); + /* [widescreen] full_2d: opt a pure-2D sprite game into the widescreen present + * path (treat every in-game frame as gameplay, since it never tags 3D prims). */ + void gpu_ws_set_full_2d(int on); +@@ -186,6 +348,13 @@ + * full-2D menu/title screen), so the squash is suppressed and content drawn + * pixel-native. The present path uses the same predicate to pillarbox. */ + int gpu_ws_present_native_43(void); ++/* The same for the frame on display, which a double-buffered game drew a frame earlier. */ ++int gpu_ws_displayed_native_43(void); ++/* The frame on display is a front-end screen shown wide: flat, so nothing is put in between its frames. */ ++int gpu_ws_displayed_menu(void); ++/* A game says what the frame it begins to build is, before it culls or projects for it: 1 a full-2D screen, 0 a world. */ ++void gpu_ws_tell_frame_kind(int flat); ++int gpu_ws_displayed_flat(void); + /* Per-side X cull-margin (screen/world units) emitted into the game's draw- + * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ + int psx_ws_x_margin(void); +@@ -240,6 +409,8 @@ + int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, + uint32_t *out); + uint32_t psx_ws_angle_widen(uint32_t vanilla); ++int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); ++uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); + int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out); + uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, + uint32_t object, int32_t x, int32_t z, +@@ -263,6 +434,9 @@ + * widen for the paired `bltz maxSX` reject. Identity at 4:3. */ + int psx_ws_cull_slti(uint32_t sx, uint32_t imm); + int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); ++int32_t psx_ws_cull_lower_value(int32_t vanilla); ++int32_t psx_ws_cull_upper_value(int32_t vanilla); ++uint32_t psx_ws_cull_span_value(uint32_t vanilla); + int psx_ws_cull_bltz(uint32_t v); + int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); + /* True if a run of instruction words carries the screen-extent reject signature +@@ -318,6 +492,10 @@ + * final screenX store to squash it around the screen centre (identity at 4:3). + * Pulls far backdrop pieces in from past the 320px edge to cover the 16:9 FOV. */ + int psx_ws_backdrop_x(int x); ++/* CPU-calculated perspective X counterpart to the GTE widescreen squash. ++ * Exact game sites use this only for final projected screen coordinates. */ ++int psx_ws_project_x(int x); ++int32_t psx_ws_project_x16(int x, int32_t fraction16); + + /* Backdrop PRELOAD ([widescreen.cull] auto_backdrop). psx_ws_backdrop_preload() + * is nonzero only while native-wide widescreen is engaged (0 at 4:3 / boot / diff --git a/framework/patches/runtime/include/gpu_render.h.patch b/framework/patches/runtime/include/gpu_render.h.patch new file mode 100644 index 0000000..0b313bb --- /dev/null +++ b/framework/patches/runtime/include/gpu_render.h.patch @@ -0,0 +1,77 @@ +diff --git a/runtime/include/gpu_render.h b/runtime/include/gpu_render.h +index 4953270aaccd4c606f5320f949befcdc544261e6..6cb1779203723b8aa120fc1109cf32325b244a29 100644 +--- a/runtime/include/gpu_render.h ++++ b/runtime/include/gpu_render.h +@@ -38,6 +38,41 @@ + void gr_set_mask_bits(int set_bit, int check_bit); + void gr_set_texture_window(uint32_t raw); + void gr_set_color_modulation(int r, int g, int b, int raw_texture); ++ ++/* Optional presentation-only geometry correction. The authoritative PSX ++ * framebuffer is always rasterized from the integer GP0 coordinates. A ++ * backend may use the precise 16.16 coordinates and fractional camera yaw ++ * only while mirroring the primitive into a separate display surface. */ ++void gr_set_geometry_correction(int enabled); ++void gr_set_world_triangle(int world); ++void gr_set_precise_triangle(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2); ++/* One-shot presentation positions of a triangle without exact provenance: where its pinned corners project. */ ++void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); ++/* One-shot reciprocal-depth metadata for the next textured triangle. Only a ++ * backend with a separate presentation surface should implement this; the ++ * authoritative PS1 framebuffer must retain affine texture interpolation. */ ++void gr_set_perspective_triangle(int enabled, ++ float q0, float q1, float q2); ++/* One-shot unnormalized GTE camera depth for temporal vertex identity. This ++ * metadata never reaches the shaders and is independent of perspective q, ++ * whose scale can differ between polygons. Zero means unknown depth. */ ++void gr_set_temporal_depth_triangle(int enabled, ++ float z0, float z1, float z2); ++/* One-shot camera depth of the same corners for the display mirror's own depth: a corner the game pinned has one here, ++ * where the temporal depth above says 0. Zero means unknown. */ ++void gr_set_surface_depth_triangle(float z0, float z1, float z2); ++/* One-shot GTE input vertices of the same three corners (zero = unknown). */ ++void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2); ++/* The next textured rectangle is a world sprite at this camera depth (0 = none). */ ++void gr_set_temporal_sprite(float depth, const float sides[4]); ++/* center_y is relative to the active drawing band; a backend which stores ++ * absolute VRAM positions must combine it with its current draw offset. */ ++void gr_set_presentation_yaw(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x); + + /* Primitives */ + void gr_fill_rect(int x, int y, int w, int h, uint16_t color); +@@ -119,6 +154,22 @@ + void (*set_mask_bits)(int set_bit, int check_bit); + void (*set_texture_window)(uint32_t raw); + void (*set_color_modulation)(int r, int g, int b, int raw_texture); ++ void (*set_geometry_correction)(int enabled); ++ void (*set_world_triangle)(int world); ++ void (*set_precise_triangle)(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2); ++ void (*set_perspective_triangle)(int enabled, ++ float q0, float q1, float q2); ++ void (*set_temporal_depth_triangle)(int enabled, ++ float z0, float z1, float z2); ++ void (*set_surface_depth_triangle)(float z0, float z1, float z2); ++ void (*set_temporal_identity_triangle)(uint64_t id0, uint64_t id1, uint64_t id2); ++ void (*set_temporal_sprite)(float depth, const float sides[4]); ++ void (*set_presentation_yaw)(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x); + void (*fill_rect)(int x, int y, int w, int h, uint16_t color); + void (*copy_rect)(int src_x, int src_y, int dst_x, int dst_y, int w, int h); + void (*draw_flat_triangle)(int x0, int y0, int x1, int y1, int x2, int y2, +@@ -176,6 +227,7 @@ + * pixel count, writes width/height to ow/oh. NULL if unsupported. */ + int (*wide_dump_full)(uint32_t *out, int cap_pixels, int *ow, int *oh, + int base_x); ++ void (*set_unpinned_triangle)(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); + } GpuRenderBackend; + + #ifdef __cplusplus diff --git a/framework/patches/runtime/include/gpu_sw_renderer.h.patch b/framework/patches/runtime/include/gpu_sw_renderer.h.patch new file mode 100644 index 0000000..384944a --- /dev/null +++ b/framework/patches/runtime/include/gpu_sw_renderer.h.patch @@ -0,0 +1,13 @@ +diff --git a/runtime/include/gpu_sw_renderer.h b/runtime/include/gpu_sw_renderer.h +index b8cea0e3a63da893772e2beccd97dc2ebec2559b..193ecbdf42fd1f0bd26d2ba36a0f8f17f59208d3 100644 +--- a/runtime/include/gpu_sw_renderer.h ++++ b/runtime/include/gpu_sw_renderer.h +@@ -8,7 +8,7 @@ + #endif + + /* Maximum internal-resolution supersampling factor (linear, per axis). */ +-#define SW_MAX_INTERNAL_SCALE 4 ++#define SW_MAX_INTERNAL_SCALE 8 + + /* Initialize software renderer */ + void sw_renderer_init(uint16_t* vram); diff --git a/framework/patches/runtime/include/mod_memory.h.patch b/framework/patches/runtime/include/mod_memory.h.patch new file mode 100644 index 0000000..dab1ee5 --- /dev/null +++ b/framework/patches/runtime/include/mod_memory.h.patch @@ -0,0 +1,16 @@ +diff --git a/runtime/include/mod_memory.h b/runtime/include/mod_memory.h +index 7406c39a683b0a7a9c8df8d80004c46cb2408d7c..c5a5c3f89c56ca8869bc7fb1fd18cfdc129fe1bb 100644 +--- a/runtime/include/mod_memory.h ++++ b/runtime/include/mod_memory.h +@@ -43,6 +43,11 @@ + + uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment); + uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address); ++/* Snapshot payload is the allocated byte prefix; restoring its length also ++ * restores the allocation boundary needed by CPU and 24-bit GPU accesses. */ ++uint32_t psx_mod_gpu_dma_memory_bytes(void); ++const uint8_t *psx_mod_gpu_dma_memory_data(void); ++int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size); + + #ifdef __cplusplus + } diff --git a/framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch b/framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch new file mode 100644 index 0000000..b13270a --- /dev/null +++ b/framework/patches/runtime/include/overlay_dispatch_preamble.c.inc.patch @@ -0,0 +1,24 @@ +diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc +index 6cd4b10e4d8525813984ce8a136803e71f6ecb5b..ea2e5c7cb9190c84d87f9ac1d984760aab04c015 100644 +--- a/runtime/include/overlay_dispatch_preamble.c.inc ++++ b/runtime/include/overlay_dispatch_preamble.c.inc +@@ -202,6 +202,19 @@ + int psx_ws_cull_slti(uint32_t sx, uint32_t imm) { + return ((int32_t)sx < (int32_t)imm + psx_ws_x_margin()) ? 1 : 0; + } ++int32_t psx_ws_cull_lower_value(int32_t vanilla) { ++ return vanilla - psx_ws_x_margin(); ++} ++int32_t psx_ws_cull_upper_value(int32_t vanilla) { ++ return vanilla + psx_ws_x_margin(); ++} ++uint32_t psx_ws_cull_span_value(uint32_t vanilla) { ++ int32_t margin = psx_ws_x_margin(); ++ int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); ++ uint32_t right = vanilla & 0xFFFFu; ++ return ((uint32_t)(uint16_t)(left - margin) << 16) | ++ (uint32_t)(uint16_t)((int32_t)right + margin); ++} + int psx_ws_cull_bltz(uint32_t v) { + return ((int32_t)v < -psx_ws_x_margin()) ? 1 : 0; + } diff --git a/framework/patches/runtime/include/psx_cyc.h.patch b/framework/patches/runtime/include/psx_cyc.h.patch new file mode 100644 index 0000000..af05140 --- /dev/null +++ b/framework/patches/runtime/include/psx_cyc.h.patch @@ -0,0 +1,30 @@ +diff --git a/runtime/include/psx_cyc.h b/runtime/include/psx_cyc.h +index a7e79f81c443083143768c02fcc5ea201a0fc8c7..30ef6723fcd47aa8c7d74ba0a802e22f4f6f747c 100644 +--- a/runtime/include/psx_cyc.h ++++ b/runtime/include/psx_cyc.h +@@ -66,11 +66,25 @@ + } + #endif + ++#if !defined(PSX_OVERLAY_DLL_BUILD) ++/* Enhancement-only guest CPU overclock: instruction cycles are charged at ++ * 1 / (1 << shift). Zero keeps the faithful cost. */ ++extern uint32_t g_psx_cyc_overclock_shift; ++extern uint32_t g_psx_cyc_overclock_carry; ++#endif ++ + static inline void psx_cyc_charge(uint32_t cycles) { + if (cycles == 0u) return; + #if defined(PSX_OVERLAY_DLL_BUILD) + psx_advance_cycles(cycles); + #else ++ if (g_psx_cyc_overclock_shift) { ++ const uint32_t total = g_psx_cyc_overclock_carry + cycles; ++ g_psx_cyc_overclock_carry = ++ total & ((1u << g_psx_cyc_overclock_shift) - 1u); ++ cycles = total >> g_psx_cyc_overclock_shift; ++ if (cycles == 0u) return; ++ } + #if defined(__GNUC__) || defined(__clang__) + if (__builtin_expect(g_ls_replay_active | g_event_step_conservative, 0)) { + #else diff --git a/framework/patches/runtime/include/ws_aspect_cone_math.h.patch b/framework/patches/runtime/include/ws_aspect_cone_math.h.patch new file mode 100644 index 0000000..ae936e8 --- /dev/null +++ b/framework/patches/runtime/include/ws_aspect_cone_math.h.patch @@ -0,0 +1,23 @@ +diff --git a/runtime/include/ws_aspect_cone_math.h b/runtime/include/ws_aspect_cone_math.h +index abb3adacf76f3836c11201dc7990ae9a6faba856..8355760ad00a0647b88ac750721cae03bb7c3bd1 100644 +--- a/runtime/include/ws_aspect_cone_math.h ++++ b/runtime/include/ws_aspect_cone_math.h +@@ -24,6 +24,18 @@ + * when extent_pixels is zero. Results stay below a 90-degree half-angle. */ + uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels); + ++/* Generic signed counterpart for games whose wrapping yaw uses a different ++ * unit system. `full_turn` is the number of integer units in 360 degrees. ++ * The sign is preserved and the magnitude is widened by the same 320-wide ++ * projection geometry. */ ++int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, ++ int extent_pixels); ++ ++/* Scale a signed camera-horizontal coordinate into the original 320-wide ++ * projection before an unchanged side-plane comparison. Identity at zero ++ * extent; symmetric nearest-integer rounding for negative values. */ ++int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels); ++ + #ifdef __cplusplus + } + #endif diff --git a/framework/patches/runtime/src/boot_state.c.patch b/framework/patches/runtime/src/boot_state.c.patch new file mode 100644 index 0000000..ec603ca --- /dev/null +++ b/framework/patches/runtime/src/boot_state.c.patch @@ -0,0 +1,81 @@ +diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c +index b4acb4175df98cced308fbcc84e64d56a9b98c0c..7ba1437dbe6da8fdb8da74faa40b5e4bc4efaa5b 100644 +--- a/runtime/src/boot_state.c ++++ b/runtime/src/boot_state.c +@@ -1,4 +1,10 @@ + #include "boot_state.h" ++#include "mod_memory.h" ++ ++/* Optional v3 section; old snapshots predate enhancement packet allocations. */ ++#define BS_SEC_GPU_DMA_MEMORY 0x0Fu ++/* Optional: the instruction cost divider of an enhancement and its carry. An old snapshot resumes at the session's. */ ++#define BS_SEC_CPU_COST 0x10u + #include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ + #include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */ + #include "psx_cycles.h" +@@ -22,6 +28,8 @@ + extern uint32_t i_stat; + extern uint32_t i_mask; + extern uint64_t psx_cycle_count; ++extern uint32_t g_psx_cyc_overclock_shift; ++extern uint32_t g_psx_cyc_overclock_carry; + extern void timers_get_snapshot(uint16_t counter[3], uint32_t mode[3], + uint16_t target[3], int32_t irq_line[3], + uint32_t frac[3]); +@@ -178,7 +186,7 @@ + h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; + h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; + h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; +- h.section_count = 14; ++ h.section_count = 16; + + int ok = write_header_le(f, &h); + +@@ -242,6 +250,18 @@ + } + } + ++ if (ok) ok = write_section(f, BS_SEC_GPU_DMA_MEMORY, ++ psx_mod_gpu_dma_memory_data(), ++ psx_mod_gpu_dma_memory_bytes()); ++ if (ok) { ++ uint8_t cost[8]; ++ PstW w; ++ pst_w_init(&w, cost, sizeof cost); ++ ok = pst_w_u32(&w, g_psx_cyc_overclock_shift) && ++ pst_w_u32(&w, g_psx_cyc_overclock_carry) && ++ write_section(f, BS_SEC_CPU_COST, cost, sizeof cost); ++ } ++ + fclose(f); + if (!ok) + remove(path); +@@ -271,6 +291,19 @@ + if (!pst_r_u32(&r, &cpu->gte_ctrl[i])) return 0; + return 1; + } ++ case BS_SEC_GPU_DMA_MEMORY: ++ return psx_mod_gpu_dma_memory_restore(p, len); ++ case BS_SEC_CPU_COST: { ++ PstR r; ++ uint32_t shift, carry; ++ if (len != 8) return 0; ++ pst_r_init(&r, p, len); ++ /* A wait drops the divider and leaves the carry for the next frame, so the carry is not held to the divider. */ ++ if (!pst_r_u32(&r, &shift) || !pst_r_u32(&r, &carry) || shift > 31u || (carry >> 31) != 0u) return 0; ++ g_psx_cyc_overclock_shift = shift; ++ g_psx_cyc_overclock_carry = carry; ++ return 1; ++ } + case BS_SEC_RAM: + if (len != RAM_SIZE) return 0; + memcpy(memory_get_ram_ptr(), p, RAM_SIZE); +@@ -424,6 +457,8 @@ + + if (!ok || (seen & required) != required) + return 0; ++ if (!(seen & (1u << BS_SEC_GPU_DMA_MEMORY))) ++ psx_mod_gpu_dma_memory_restore(NULL, 0u); + return 1; + } + diff --git a/framework/patches/runtime/src/dirty_ram_interp.c.patch b/framework/patches/runtime/src/dirty_ram_interp.c.patch new file mode 100644 index 0000000..c5bb9cf --- /dev/null +++ b/framework/patches/runtime/src/dirty_ram_interp.c.patch @@ -0,0 +1,358 @@ +diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c +index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4ff1ac5452 100644 +--- a/runtime/src/dirty_ram_interp.c ++++ b/runtime/src/dirty_ram_interp.c +@@ -24,6 +24,7 @@ + */ + + #include "dirty_ram_interp.h" ++#include "gte_precision.h" + #include "cpu_state.h" + #include "debug_server.h" + #include "interrupts.h" +@@ -45,6 +46,55 @@ + uint64_t g_dirty_ram_aborts = 0; + uint64_t g_dirty_ram_guard_yields = 0; + uint64_t g_dirty_ram_native_handoffs = 0; ++ ++/* One sample per 256 interpreted instructions identifies stable hot loops ++ * without turning production diagnostics into another per-insn profiler. The ++ * physical-PC direct table has no hash collisions and is touched only while ++ * PSX_RUNTIME_PERF_DIAG is enabled. */ ++#define DIRTY_RAM_PERF_SAMPLE_SHIFT 8u ++#define DIRTY_RAM_PERF_SAMPLE_STRIDE (1u << DIRTY_RAM_PERF_SAMPLE_SHIFT) ++static int s_dirty_ram_perf_hot_enabled = 0; ++static uint32_t s_dirty_ram_perf_hot_samples[DIRTY_RAM_EXEC_WORD_COUNT]; ++ ++void dirty_ram_perf_hot_enable(int enabled) { ++ s_dirty_ram_perf_hot_enabled = enabled ? 1 : 0; ++ memset(s_dirty_ram_perf_hot_samples, 0, ++ sizeof(s_dirty_ram_perf_hot_samples)); ++} ++ ++static inline void dirty_ram_perf_hot_record(uint32_t pc) { ++ if (!s_dirty_ram_perf_hot_enabled || ++ (g_dirty_ram_insns_run & (DIRTY_RAM_PERF_SAMPLE_STRIDE - 1u)) != 0u) ++ return; ++ uint32_t phys = pc & 0x1FFFFFFFu; ++ if (phys >= 2u * 1024u * 1024u || (phys & 3u) != 0u) return; ++ uint32_t *slot = &s_dirty_ram_perf_hot_samples[phys >> 2]; ++ if (*slot != UINT32_MAX) (*slot)++; ++} ++ ++void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], ++ uint32_t *stride) { ++ uint32_t top_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ uint32_t top_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ for (uint32_t word = 0; word < DIRTY_RAM_EXEC_WORD_COUNT; word++) { ++ uint32_t n = s_dirty_ram_perf_hot_samples[word]; ++ if (n == 0u || n <= top_n[DIRTY_RAM_PERF_HOT_COUNT - 1u]) continue; ++ uint32_t at = DIRTY_RAM_PERF_HOT_COUNT - 1u; ++ while (at > 0u && n > top_n[at - 1u]) { ++ top_n[at] = top_n[at - 1u]; ++ top_pc[at] = top_pc[at - 1u]; ++ at--; ++ } ++ top_n[at] = n; ++ top_pc[at] = word << 2; ++ } ++ memset(s_dirty_ram_perf_hot_samples, 0, ++ sizeof(s_dirty_ram_perf_hot_samples)); ++ if (pcs) memcpy(pcs, top_pc, sizeof(top_pc)); ++ if (samples) memcpy(samples, top_n, sizeof(top_n)); ++ if (stride) *stride = DIRTY_RAM_PERF_SAMPLE_STRIDE; ++} + /* Scheduling-contract pump telemetry (see dirty_ram_dispatch). Always-on: + * g_dirty_pump_max_gap_insns is the largest interpreted-insn gap ever seen + * between two interrupt pumps — must stay bounded (~4096 + one block) once the +@@ -1449,6 +1499,7 @@ + uint32_t dummy_next = 0; + (void)exec_one_fetched(cpu, pc, insn, &dummy_next); + g_dirty_ram_insns_run++; ++ dirty_ram_perf_hot_record(pc); + /* CYCLE MODEL: the delay-slot instruction is a real retired R3000A instruction + * and is charged its own per-instruction interlock INSIDE exec_one (top-of-fn + * §1+deps+DO_LDS, or psx_cyc_load_* for a load delay slot) — so a branch+slot +@@ -1461,6 +1512,158 @@ + * so the delay-slot instruction observes the architecturally-correct value. */ + static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, + uint32_t *next_pc_out); ++ ++#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA ++extern void disruptor_vertical_camera_instruction_hook( ++ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); ++ ++static int disruptor_vertical_camera_site(uint32_t pc, uint32_t insn) { ++ switch (pc) { ++ case 0x8003AD54u: case 0x8003ADE4u: case 0x8003B140u: ++ case 0x8003B1ECu: case 0x8003B764u: case 0x8003B990u: ++ case 0x8003BDA4u: case 0x8003C4B4u: case 0x8003C99Cu: ++ case 0x8003D07Cu: case 0x8003F6B0u: ++ return insn == 0x34020078u; ++ case 0x8003D398u: ++ return insn == 0x34030078u; ++ case 0x8003B900u: ++ return insn == 0x0205102Au; ++ case 0x8003B90Cu: ++ return insn == 0x0202102Au; ++ case 0x8003BD14u: ++ return insn == 0x0206102Au; ++ case 0x8003BD20u: ++ return insn == 0x0202102Au; ++ case 0x8003CDB8u: ++ return insn == 0x0065102Au; ++ case 0x8003CDC4u: ++ return insn == 0x0062102Au; ++ case 0x8002E4B8u: ++ return insn == 0x92640022u; ++ case 0x8002EAF0u: ++ return insn == 0x92430020u; ++ case 0x8004279Cu: ++ return insn == 0x8FBF016Cu; ++ default: ++ return 0; ++ } ++} ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING ++extern void disruptor_far_rendering_instruction_hook( ++ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); ++ ++static int disruptor_far_rendering_site(uint32_t pc, uint32_t insn) { ++ switch (pc) { ++ case 0x8003A0F8u: ++ return insn == 0x93B300C0u; ++ case 0x8003A390u: ++ return insn == 0xA082001Fu; ++ case 0x8003A92Cu: ++ return insn == 0x00005812u; ++ case 0x8003A964u: ++ return insn == 0x00681021u; ++ case 0x8003AA74u: ++ return insn == 0x00AC1021u; ++ case 0x8003A914u: ++ return insn == 0x8F8C04A0u; ++ case 0x8003B8C8u: case 0x8003C168u: case 0x8003CD64u: ++ return insn == 0x8F8304A0u; ++ case 0x8003BCE0u: ++ return insn == 0x8F8204A0u; ++ case 0x8003B8D8u: ++ return insn == 0x0072182Au; ++ case 0x8003BCECu: ++ return insn == 0x0053102Au; ++ case 0x8003C178u: case 0x8003CD70u: ++ return insn == 0x0077182Au; ++ case 0x8003B2CCu: case 0x8003BA30u: case 0x8003BE40u: ++ case 0x8003C628u: case 0x8003C878u: case 0x8003D28Cu: ++ case 0x80043184u: ++ return insn == 0x8F820494u; ++ case 0x8003FB0Cu: ++ return insn == 0x8F840494u; ++ case 0x800410E0u: ++ return insn == 0x93830310u; ++ case 0x800410F8u: ++ return insn == 0x8F820680u; ++ case 0x800411D8u: ++ return insn == 0x8F8301D4u; ++ case 0x8004279Cu: ++ return insn == 0x8FBF016Cu; ++ default: ++ return 0; ++ } ++} ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT ++extern void disruptor_billboard_aspect_instruction_hook( ++ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); ++ ++static int disruptor_billboard_aspect_site(uint32_t pc, uint32_t insn) { ++ switch (pc) { ++ case 0x8003BB88u: case 0x8003BFB0u: case 0x8003C848u: ++ return insn == 0xA6030016u; ++ case 0x8003CAD4u: ++ return insn == 0xA6020016u; ++ case 0x8003D488u: ++ return insn == 0xA2060025u; ++ case 0x800433A0u: ++ return insn == 0x02A02821u; ++ default: ++ return 0; ++ } ++} ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP ++extern void disruptor_intro_skip_instruction_hook( ++ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); ++ ++static int disruptor_intro_skip_site(uint32_t pc, uint32_t insn) { ++ return (pc == 0x800147B0u && insn == 0x2A820003u) || ++ (pc == 0x80020A80u && insn == 0x2A02005Eu) || ++ (pc == 0x80048678u && insn == 0x30420940u); ++} ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH ++extern void disruptor_sprite_depth_instruction_hook( ++ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); ++ ++static int disruptor_sprite_depth_site(uint32_t pc, uint32_t insn) { ++ switch (pc) { ++ case 0x8003B98Cu: case 0x8003BDA0u: case 0x8003C4B0u: case 0x8003D078u: ++ return insn == 0xAFA20010u; ++ case 0x8003D1B4u: ++ return insn == 0xA4379EA0u; ++ default: ++ return 0; ++ } ++} ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES ++extern void disruptor_backdrop_tiles_instruction_hook( ++ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); ++ ++static int disruptor_backdrop_tile_site(uint32_t pc, uint32_t insn) { ++ switch (pc) { ++ case 0x8003B328u: case 0x8003B638u: ++ return insn == 0x8FB80040u; ++ case 0x8003B338u: ++ return insn == 0x00621821u; ++ case 0x8003B348u: ++ return insn == 0x8FB80028u; ++ case 0x8003B5A8u: ++ return insn == 0x8F8205B8u; ++ default: ++ return 0; ++ } ++} ++#endif + + static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, + uint32_t *next_pc_out) { +@@ -1516,6 +1719,42 @@ + const uint32_t ld_before = is_ld ? cpu->gpr[ld_rt] : 0u; + + const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out); ++ ++#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA ++ if (disruptor_vertical_camera_site(pc, insn)) { ++ disruptor_vertical_camera_instruction_hook(cpu, pc, insn, 1); ++ } ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING ++ if (disruptor_far_rendering_site(pc, insn)) { ++ disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1); ++ } ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT ++ if (disruptor_billboard_aspect_site(pc, insn)) { ++ disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1); ++ } ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP ++ if (disruptor_intro_skip_site(pc, insn)) { ++ disruptor_intro_skip_instruction_hook(cpu, pc, insn, 1); ++ } ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH ++ if (disruptor_sprite_depth_site(pc, insn)) { ++ disruptor_sprite_depth_instruction_hook(cpu, pc, insn, 1); ++ } ++#endif ++ ++#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES ++ if (disruptor_backdrop_tile_site(pc, insn)) { ++ disruptor_backdrop_tiles_instruction_hook(cpu, pc, insn, 1); ++ } ++#endif + + if (is_ld) { + const uint32_t loaded = cpu->gpr[ld_rt]; +@@ -2114,7 +2353,9 @@ + #ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_read(cpu, rt); + #endif +- cpu->gpr[rt] = gte_read_data(cpu, (uint8_t)rd); ++ cpu->gpr[rt] = gte_precision_mfc2_pc_read( ++ pc, insn, (uint8_t)rd, ++ gte_read_data(cpu, (uint8_t)rd)); + cpu->gpr[0] = 0; + return 0; + } +@@ -2181,6 +2422,8 @@ + cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); + else + cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); ++ cpu->gpr[rt] = gte_precision_copy_pc_read( ++ pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); + cpu->gpr[0] = 0; + return 0; + } +@@ -2236,6 +2479,11 @@ + g_debug_last_store_pc = pc; + parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt], 4u); + cpu->write_word(addr, cpu->gpr[rt]); ++ gte_precision_scratch_store_pc_word( ++ pc, insn, addr, cpu->gpr[rt]); ++ gte_precision_copy_pc_word( ++ pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); ++ gte_precision_store_pc_word(pc, insn, addr, cpu->gpr[rt]); + return 0; + } + case 0x2E: { /* SWR */ +@@ -2509,6 +2757,7 @@ + cosim_exec_one_begin(); + int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */ + g_dirty_ram_insns_run++; ++ dirty_ram_perf_hot_record(pc); + #ifdef PSX_COSIM + if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } + #endif +@@ -2981,6 +3230,7 @@ + OV_FPLOG_RET1(); + } + g_dirty_ram_insns_run++; ++ dirty_ram_perf_hot_record(pc); + insns_executed++; + /* COP0 software-interrupt latency: an MTC0 to CAUSE or SR that makes a + * software interrupt deliverable (CAUSE.IP0/IP1 & SR.IM0/IM1 & IEc) +@@ -3433,6 +3683,7 @@ + uint32_t sav_unsup_pc = g_unsupported_pc; + uint32_t sav_unsup_insn = g_unsupported_insn; + const char *sav_unsup_reason = g_unsupported_reason; ++ int gte_sandbox = gte_replay_side_effects_begin(); + g_ls_replay_active = 1; + g_ls_mode = 2; + uint32_t pc = rep.pc; +@@ -3456,6 +3707,7 @@ + } + g_ls_mode = 0; + g_ls_replay_active = 0; ++ if (gte_sandbox) gte_replay_side_effects_end(); + g_unsupported_seen = sav_unsup_seen; /* undo any replay-side decode-state leak */ + g_unsupported_pc = sav_unsup_pc; + g_unsupported_insn = sav_unsup_insn; +@@ -3550,6 +3802,7 @@ + const char *sav_unsup_reason = g_unsupported_reason; + uint32_t sav_store_pc = g_debug_last_store_pc; + uint32_t sav_func_addr = g_debug_current_func_addr; ++ int gte_sandbox = gte_replay_side_effects_begin(); + + g_ls_replay_active = 1; + g_ls_mode = 2; +@@ -3585,6 +3838,7 @@ + + g_ls_mode = 0; + g_ls_replay_active = 0; ++ if (gte_sandbox) gte_replay_side_effects_end(); + g_unsupported_seen = sav_unsup_seen; + g_unsupported_pc = sav_unsup_pc; + g_unsupported_insn = sav_unsup_insn; diff --git a/framework/patches/runtime/src/gpu.c.patch b/framework/patches/runtime/src/gpu.c.patch new file mode 100644 index 0000000..45ac484 --- /dev/null +++ b/framework/patches/runtime/src/gpu.c.patch @@ -0,0 +1,2084 @@ +diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c +index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867ebb08b0b 100644 +--- a/runtime/src/gpu.c ++++ b/runtime/src/gpu.c +@@ -11,6 +11,7 @@ + */ + + #include "gpu.h" ++#include "gte_precision.h" + #include "mod_memory.h" + #include "gpu_primitive_reject.h" + #include "gpu_sw_renderer.h" +@@ -24,7 +25,16 @@ + #include "mod_runtime.h" + #include "ws_cull_detect.h" + #include "ws_aspect_cone_math.h" ++#include "gpu_pinned_corner.h" ++#include "gpu_ws_hud_scale.h" ++#include "gpu_ws_hud_widget.h" ++#include "gpu_ws_screen_tile.h" ++#include "gpu_ws_tag_match.h" ++#include "gpu_ws_frame_kind.h" ++#include "gpu_ws_menu.h" ++#include "gpu_temporal_sprite.h" + #include "ws_ui_group.h" ++#include + #include + #include + #include +@@ -104,12 +114,36 @@ + * in native-wide mode. */ + static int ws_mode = 0; + static int ws_cfg_num = 4, ws_cfg_den = 3; ++static int ws_geometry_correction = 0; ++static int s_texture_correction_enabled = 0; ++static uint32_t s_texture_correction_triangles = 0; ++static double s_geometry_camera_yaw_residual = 0.0; ++static int s_geometry_camera_projection_center_y = 120; ++static uint64_t ws_geometry_world_triangles = 0; ++static uint64_t ws_geometry_precise_triangles = 0; ++static GpuGeometryCorrectionCounters s_geometry_diag_cumulative; ++static GpuGeometryCorrectionCounters s_geometry_diag_live_current; ++static GpuGeometryCorrectionCounters s_geometry_diag_live_latest; ++static uint64_t s_geometry_diag_presentation_sequence; ++static uint64_t s_geometry_diag_corrected_presentations; ++static uint64_t s_geometry_diag_latest_live_presentation; ++static int s_geometry_diag_latest_live_valid; ++static int s_geometry_diag_live_current_touched; + static void ws_nw_sync_target(void); + + #define WS_TAG_BUCKETS 4096 /* power of two */ + #define WS_TAG_PROBES 8 + #define WS_FMV_HYSTERESIS 30 /* frames a colour MDEC decode pins 4:3 */ +-typedef struct { uint32_t key; uint32_t stamp; int32_t anchor_x; } WsTag; ++typedef struct { ++ uint32_t key; ++ uint32_t stamp; ++ int32_t anchor_x; ++ uint32_t signature; ++ uint8_t content_validated; ++ uint8_t placed, sized, shadow, whole; ++ int32_t centre_x, offset_y; /* 16.16: a sprite's unrounded centre, and how far above or below its row */ ++ int32_t row, width, height; /* 16.16: the unrounded row it is centred on and its unrounded sides */ ++} WsTag; + static WsTag ws_tags[WS_TAG_BUCKETS]; + static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */ + static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */ +@@ -177,6 +211,8 @@ + * intro-cutscene flicker). Only a genuine full-2D screen — no GTE projection for + * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ + #define WS_GTE_GAME_MODE_HYSTERESIS 45u ++static PsxWsFrameKinds ws_frame_kinds; ++static uint32_t display_area_x, display_area_y; + void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } + void gpu_ws_set_gameplay_state_gate(uint32_t addr, + const uint32_t *values, int nvalues) { +@@ -240,6 +276,7 @@ + + void psx_ws_note_gte_project(int nverts) { + uint32_t f = (uint32_t)s_frame_count; ++ psx_ws_frame_kinds_project(&ws_frame_kinds, (uint32_t)nverts); + if (f != ws_gte_frame) { + if (f == ws_gte_frame + 1u) ws_gte_prev_verts = ws_gte_count; + else ws_gte_prev_verts = 0; +@@ -262,14 +299,22 @@ + if (env < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); env = (e && e[0] == '1') ? 1 : 0; } + return ws_full_2d || env; + } +-static int ws_game_mode(void) { ++/* `displayed` asks about the frame on display, which a double-buffered game drew one frame before the one it is ++ * drawing now. A GTE-detected game's frames are told apart per buffer, and the grace below only covers the start. */ ++static int ws_game_mode_of(int displayed) { + int state_match = ws_gameplay_state_matches(); + if (state_match >= 0) return state_match; + if (ws_full_2d_mode()) return 1; +- if (ws_gte_game_mode_cfg && +- (uint32_t)s_frame_count - ws_last_gte_stamp <= WS_GTE_GAME_MODE_HYSTERESIS) return 1; ++ if (ws_gte_game_mode_cfg) { ++ const int flat = displayed ++ ? psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) ++ : psx_ws_frame_kinds_drawing(&ws_frame_kinds, WS_GTE_GAME_MODE_MIN_VERTS); ++ if (flat >= 0) return !flat; ++ if ((uint32_t)s_frame_count - ws_last_gte_stamp <= WS_GTE_GAME_MODE_HYSTERESIS) return 1; ++ } + return (uint32_t)s_frame_count - ws_last_tag_stamp <= 2; + } ++static int ws_game_mode(void) { return ws_game_mode_of(0); } + + /* True when the current frame is presented at native 4:3 (NOT stretched), so + * ALL squash must be suppressed and the content rendered pixel-native: +@@ -304,9 +349,19 @@ + static uint32_t s_ws_fmv_frame_cache = 0xFFFFFFFFu; + static int s_ws_fmv_cached = 0; + +-int gpu_ws_present_native_43(void) { ++/* The front end's backdrop on a wide screen (gpu_ws_menu.h). Squash mode only: a wider frame of its own has nothing to put beside the picture. */ ++static int ws_menu_logo; /* the picture at (320,0) is the front end's, and its logo is kept at (512,0) */ ++static int ws_menu_halves; /* the halves of the backdrop drawn since the logo was last drawn */ ++static int ws_menu_wanted(void) { return ws_mode == 1 && ws_xnum != ws_xden; } ++static int ws_menu_frame(void) { return psx_ws_frame_kinds_menu(&ws_frame_kinds); } ++/* On a front-end frame everything but the backdrop keeps its shape on the stretched screen. Returns the rectangle's width. */ ++static int ws_menu_rect(int32_t *x0, int w) { return ws_menu_frame() ? psx_ws_menu_squash_rect(x0, w, ws_xnum, ws_xden) : w; } ++int gpu_ws_displayed_menu(void) { return psx_ws_frame_kinds_displayed_menu(&ws_frame_kinds, display_area_x, display_area_y); } ++ ++static int ws_native_43(int displayed) { + if (!ws_engaged()) return 0; +- if (!ws_game_mode()) return 1; /* full-2D screen */ ++ if (displayed ? gpu_ws_displayed_menu() : ws_menu_frame()) return 0; ++ if (!ws_game_mode_of(displayed)) return 1; /* full-2D screen */ + if (ws_2d_only_scene()) return 1; /* 2D-only gameplay scene */ + uint32_t f = (uint32_t)s_frame_count; + if (f != s_ws_fmv_frame_cache) { +@@ -315,6 +370,14 @@ + s_ws_fmv_cached = di.depth24 || mdec_recently_active(WS_FMV_HYSTERESIS); + } + return s_ws_fmv_cached; ++} ++int gpu_ws_present_native_43(void) { return ws_native_43(0); } ++int gpu_ws_displayed_native_43(void) { return ws_native_43(1); } ++void gpu_ws_tell_frame_kind(int flat) { psx_ws_frame_kinds_tell(&ws_frame_kinds, flat); } ++/* The frame on display is a full-2D one of a GTE-detected game and not the front end's: uploads and copies make it, and they do not reach the corrected mirror. */ ++int gpu_ws_displayed_flat(void) { ++ return ws_gte_game_mode_cfg && !gpu_ws_displayed_menu() && ++ psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) == 1; + } + + /* Squash applies only when configured AND the frame is being stretched. */ +@@ -599,6 +662,38 @@ + if (widened > ws_angle_max_widened) + ws_angle_max_widened = widened; + return widened; ++} ++ ++/* A diagnostic launcher can isolate the two discovered frustum layers without ++ * rebuilding: "off" keeps both vanilla, "yaw" widens only portal traversal, ++ * and the default "full" also widens the later horizontal side planes. */ ++static int ws_frustum_mode(void) { ++ static int mode = -1; ++ if (mode >= 0) return mode; ++ const char *value = getenv("PSX_WS_FRUSTUM_MODE"); ++ if (value && (strcmp(value, "off") == 0 || strcmp(value, "0") == 0)) ++ mode = 0; ++ else if (value && (strcmp(value, "yaw") == 0 || strcmp(value, "1") == 0)) ++ mode = 1; ++ else ++ mode = 2; ++ printf("psxrecomp: widescreen frustum mode %s\n", ++ mode == 0 ? "off" : (mode == 1 ? "yaw-only" : "full")); ++ return mode; ++} ++ ++int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn) { ++ if (ws_frustum_mode() == 0) return vanilla; ++ return psx_ws_widen_angle_units( ++ vanilla, full_turn, psx_ws_x_margin()); ++} ++ ++uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal) { ++ if (ws_frustum_mode() < 2) ++ return (int32_t)bound < (int32_t)horizontal ? 1u : 0u; ++ const int32_t scaled_horizontal = psx_ws_scale_horizontal_320( ++ (int32_t)horizontal, psx_ws_x_margin()); ++ return (int32_t)bound < scaled_horizontal ? 1u : 0u; + } + + int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out) { +@@ -1303,6 +1398,23 @@ + return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; + } + ++/* Manual CPU-projected screen-window constants. These complement the compare ++ * helpers above for games which clamp and queue signed [left,right] spans in ++ * ordinary MIPS code. All three are exact identities when margin==0. */ ++int32_t psx_ws_cull_lower_value(int32_t vanilla) { ++ return vanilla - psx_ws_x_margin(); ++} ++int32_t psx_ws_cull_upper_value(int32_t vanilla) { ++ return vanilla + psx_ws_x_margin(); ++} ++uint32_t psx_ws_cull_span_value(uint32_t vanilla) { ++ int32_t margin = psx_ws_x_margin(); ++ int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); ++ uint32_t right = vanilla & 0xFFFFu; ++ return ((uint32_t)(uint16_t)(left - margin) << 16) | ++ (uint32_t)(uint16_t)((int32_t)right + margin); ++} ++ + /* Signed left-edge widen for the funnel's `bltz maxSX, reject`: reject only + * when the prim ends left of the REVEALED edge (maxSX < -margin). Returns the + * branch predicate. Identity at margin 0 (4:3). */ +@@ -1403,6 +1515,30 @@ + if (!ws_active()) return (int16_t)x; + int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ + return ws_scale_about((int16_t)x, cx); ++} ++ ++/* Classic-wide counterpart for engines which calculate perspective screen X ++ * in ordinary CPU code instead of the GTE. Exact recompiler sites call this ++ * immediately after adding the projection centre. Squashing here keeps the ++ * game's original non-negative 0..display-width portal spans and clip logic ++ * valid; the classic present stretch restores native proportions afterward. ++ * Native-wide and 4:3 are exact identities. Use 64-bit intermediates because ++ * a near-plane projection may be far outside the eventual screen clamp. */ ++int psx_ws_project_x(int x) { ++ if (ws_mode != 1 || !ws_configured()) return x; ++ int64_t cx = (int64_t)ws_disp_w() / 2; ++ int64_t d = (int64_t)(int32_t)x - cx; ++ int64_t n = d * (int64_t)ws_xnum; ++ n += d >= 0 ? ws_xden / 2 : -ws_xden / 2; ++ return (int32_t)(cx + n / ws_xden); ++} ++ ++/* psx_ws_project_x for a point with a 16.16 fraction, without its rounding. */ ++int32_t psx_ws_project_x16(int x, int32_t fraction16) { ++ const int64_t exact = (int64_t)x * 65536 + fraction16; ++ if (ws_mode != 1 || !ws_configured()) return (int32_t)exact; ++ const int64_t cx = ((int64_t)ws_disp_w() / 2) * 65536; ++ return (int32_t)(cx + (exact - cx) * ws_xnum / ws_xden); + } + + /* Backdrop PRELOAD predicate + value substitution ([widescreen.cull] +@@ -1616,17 +1752,10 @@ + ws_nw_sync_target(); + } + +-/* Called from generated code at the entry of each [widescreen] +- * sprite_tag_funcs function: record prim pointer ($a0) -> projected anchor. +- * Gated on ws_configured (NOT ws_active): ws_active depends on game_mode which +- * THIS function sets, so gating it on ws_active would never let gameplay +- * start (the first character of a frame would be suppressed forever). */ +-void psx_ws_sprite_tag(CPUState* cpu) { +- if (!ws_engaged() || !ws_anchor_addr) return; +- uint32_t key = cpu->gpr[4] & 0x1FFFFCu; ++static void ws_tag_primitive(uint32_t primitive_addr, int32_t anchor_x, ++ uint32_t signature, int content_validated) { ++ uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); + if (!key) return; +- uint32_t sxy = cpu->read_word(ws_anchor_addr); +- int32_t ax = (int32_t)(int16_t)(sxy & 0xFFFFu); + uint32_t now = (uint32_t)s_frame_count; + uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); + uint32_t victim = idx; +@@ -1638,8 +1767,175 @@ + } + ws_tags[victim].key = key; + ws_tags[victim].stamp = now; +- ws_tags[victim].anchor_x = ax; ++ ws_tags[victim].anchor_x = anchor_x; ++ ws_tags[victim].signature = signature; ++ ws_tags[victim].content_validated = content_validated != 0; + ws_last_tag_stamp = now; ++} ++ ++/* Explicit provenance seam for a title-specific, reviewed world-billboard ++ * builder. Unlike sprite_tag_funcs this does not depend on a fixed scratchpad ++ * anchor: the caller has already identified both the completed P_TAG packet and ++ * its projected centre. Restrict it to the classic squash path so a tag cannot ++ * affect native-wide backdrop classification or a native-4:3 presentation. */ ++void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, ++ int32_t anchor_x) { ++ if (!ws_active() || !cpu || !cpu->read_word) return; ++ uint32_t words[9]; ++ for (int i = 0; i < 9; i++) ++ words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); ++ ws_tag_primitive(primitive_addr, anchor_x, ++ psx_ws_ft4_signature_words(words), 1); ++} ++ ++#define TEMPORAL_SPRITE_BUCKETS 4096 ++static WsTag temporal_sprites[TEMPORAL_SPRITE_BUCKETS]; ++ ++static WsTag *temporal_sprite_slot(uint32_t key, int claim) { ++ const uint32_t now = (uint32_t)s_frame_count; ++ const uint32_t first = (key >> 2) & (TEMPORAL_SPRITE_BUCKETS - 1); ++ WsTag *stale = NULL; ++ for (int i = 0; i < WS_TAG_PROBES; i++) { ++ WsTag *t = &temporal_sprites[(first + (uint32_t)i) & (TEMPORAL_SPRITE_BUCKETS - 1)]; ++ if (t->key == key) return t; ++ if (!stale && (t->key == 0 || now - t->stamp > 2)) stale = t; ++ } ++ return claim ? stale : NULL; ++} ++ ++void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, ++ int32_t depth) { ++ if (!cpu || !cpu->read_word || depth <= 0) return; ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); ++ WsTag *t = key ? temporal_sprite_slot(key, 1) : NULL; ++ uint32_t words[9]; ++ if (!t) return; ++ for (int i = 0; i < 9; i++) ++ words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); ++ t->key = key; ++ t->stamp = (uint32_t)s_frame_count; ++ t->anchor_x = depth; ++ t->signature = psx_ws_ft4_signature_words(words); ++ t->content_validated = 1; ++ t->placed = t->sized = t->shadow = t->whole = 0; ++} ++ ++void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16) { ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); ++ WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; ++ if (!t || t->stamp != (uint32_t)s_frame_count) return; ++ t->placed = 1; ++ t->centre_x = centre_x16; ++ t->offset_y = offset_y16; ++} ++ ++void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, ++ int shadow, int whole) { ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); ++ WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; ++ if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return; ++ t->sized = 1; ++ t->shadow = shadow != 0; ++ t->whole = whole != 0; ++ t->row = row16; ++ t->width = width16; ++ t->height = height16; ++} ++ ++static int s_shadow_shape = -1; /* not chosen yet: PSX_DISRUPTOR_IMPROVED_SHADOWS=1 of the launcher decides */ ++void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; } ++int gpu_shadow_shape(void) { ++ if (s_shadow_shape < 0) { ++ const char *asked = getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS"); ++ s_shadow_shape = asked && asked[0] == '1'; ++ } ++ return s_shadow_shape; ++} ++int gpu_sprite_placement_available(void) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ return 1; ++#else ++ return 0; ++#endif ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++static const WsTag *temporal_sprite_tag(void) { ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu) return NULL; ++ const uint32_t key = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u); ++ const WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; ++ if (!t || psx_ws_tag_match_result((uint32_t)s_frame_count, t->stamp, 1, ++ t->signature, gp0_cmd_buf) != PSX_WS_TAG_MATCH) ++ return NULL; ++ return t; ++} ++ ++/* How far the presentation moves the left, top, right and bottom side of a sprite's rectangle (x, y, w, h on screen, ++ * raw_w and raw_h as the packet has them). Returns the sprite's camera depth, 0 without one. */ ++static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4]) { ++ const WsTag *t = temporal_sprite_tag(); ++ sides[0] = sides[1] = sides[2] = sides[3] = 0.0f; ++ if (!t) return 0.0f; ++ const GpuTemporalSprite sprite = gpu_temporal_sprite_from_fixed( ++ t->placed, t->sized, t->shadow, t->whole, t->centre_x, t->offset_y, t->row, t->width, t->height); ++ gpu_temporal_sprite_sides(&sprite, (float)x, (float)y, w, h, raw_w, raw_h, ++ squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides); ++ return (float)t->anchor_x; ++} ++#endif ++ ++/* Backdrop tiles tile the frame exactly, so squashing each one around a HUD ++ * pivot pulls them apart and exposes the clear colour between them. */ ++static PsxWsScreenTiles ws_screen_tiles; ++ ++void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr) { ++ if (!ws_active() || !cpu || !cpu->read_word) return; ++ uint32_t words[PSX_WS_SCREEN_TILE_WORDS]; ++ for (uint32_t i = 0; i < PSX_WS_SCREEN_TILE_WORDS; i++) ++ words[i] = cpu->read_word(primitive_addr + 4u + i * 4u); ++ psx_ws_screen_tile_mark(&ws_screen_tiles, ++ psx_mod_gpu_dma_resolve_address(primitive_addr), ++ words, (uint32_t)s_frame_count); ++} ++ ++int32_t gpu_ws_widen_x(int32_t x, int round_up) { ++ if (!ws_active()) return x; ++ return psx_ws_widen_about(x, ws_disp_w() / 2, ws_xnum, ws_xden, round_up); ++} ++ ++/* A two-frame window was tried instead of one-shot marks and lost them on ++ * 4-VBlank frames. */ ++static int ws_screen_tile_take(void) { ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; ++ return psx_ws_screen_tile_take( ++ &ws_screen_tiles, ++ psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u), ++ gp0_cmd_buf, (uint32_t)s_frame_count); ++} ++ ++/* Called from generated code at the entry of each [widescreen] ++ * sprite_tag_funcs function: record prim pointer ($a0) -> projected anchor. ++ * Gated on ws_engaged (NOT ws_active): ws_active can depend on game_mode which ++ * this legacy tag path establishes for sprite-classified games. */ ++void psx_ws_sprite_tag(CPUState* cpu) { ++ if (!ws_engaged() || !ws_anchor_addr) return; ++ uint32_t sxy = cpu->read_word(ws_anchor_addr); ++ int32_t ax = (int32_t)(int16_t)(sxy & 0xFFFFu); ++ ws_tag_primitive(cpu->gpr[4], ax, 0, 0); ++} ++ ++static int ws_tag_matches_current(WsTag *tag, uint32_t now) { ++ const int result = psx_ws_tag_match_result( ++ now, tag->stamp, tag->content_validated, ++ tag->signature, gp0_cmd_buf); ++ if (result == PSX_WS_TAG_MATCH) return 1; ++ if (result != PSX_WS_TAG_CONTENT_MISMATCH) return 0; ++ ++ /* The packet pool reused this address. Invalidate immediately instead of ++ * letting a stale world-billboard tag flicker onto an unrelated primitive. */ ++ tag->key = 0; ++ tag->content_validated = 0; ++ return 0; + } + + /* Look up the executing GP0 command's prim in the tag table. The command's +@@ -1649,11 +1945,12 @@ + if (!ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; + uint32_t now = (uint32_t)s_frame_count; + for (int variant = 0; variant < 2; variant++) { +- uint32_t key = (gp0_cmd_source_addr - (variant ? 0u : 4u)) & 0x1FFFFCu; ++ uint32_t key = psx_mod_gpu_dma_resolve_address( ++ gp0_cmd_source_addr - (variant ? 0u : 4u)); + uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); + for (int i = 0; i < WS_TAG_PROBES; i++) { + WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; +- if (t->key == key && now - t->stamp <= 2) { ++ if (t->key == key && ws_tag_matches_current(t, now)) { + *out_ax = t->anchor_x; + return 1; + } +@@ -1786,11 +2083,12 @@ + if (!ws_engaged() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; + uint32_t now = (uint32_t)s_frame_count; + for (int variant = 0; variant < 2; variant++) { +- uint32_t key = (gp0_cmd_source_addr - (variant ? 0u : 4u)) & 0x1FFFFCu; ++ uint32_t key = psx_mod_gpu_dma_resolve_address( ++ gp0_cmd_source_addr - (variant ? 0u : 4u)); + uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); + for (int i = 0; i < WS_TAG_PROBES; i++) { + WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; +- if (t->key == key && now - t->stamp <= 2) return 1; ++ if (t->key == key && ws_tag_matches_current(t, now)) return 1; + } + } + return 0; +@@ -1820,6 +2118,12 @@ + GpuDisplayInfo di; + gpu_get_display_info(&di); + return di.height ? (int32_t)di.height : 240; ++} ++ ++/* An upload or a copy into a frame buffer has no drawing area to announce it. */ ++static void ws_frame_pictured(int x, int y, int w, int h) { ++ psx_ws_frame_kinds_pictured(&ws_frame_kinds, (uint32_t)x, (uint32_t)y, (uint32_t)w, (uint32_t)h, ++ (uint32_t)ws_disp_w(), (uint32_t)ws_disp_h()); + } + + /* Full-screen fades and environmental filters are authored as 320x240 TILEs. +@@ -1842,9 +2146,10 @@ + * the middle third to centre. A composite (counter box + digits) sits inside + * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu + * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ ++static int32_t ws_hud_strip_wide(void); + static int32_t ws_hud_pivot(int32_t x, int32_t w) { + int32_t W = ws_disp_w(); +- int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ ++ int32_t cx = psx_ws_hud_thirds_centre(x, w, ws_hud_strip_wide(), W); /* 2*centre, avoids losing the half */ + if (3 * cx < 2 * W) return 0; + if (3 * cx > 4 * W) return W; + return W / 2; +@@ -1861,7 +2166,7 @@ + if (!ws_auto_ui_squash || !ws_active() || + gp0_cmd_source_addr == 0xFFFFFFFFu) + return 0; +- uint32_t src = gp0_cmd_source_addr & 0x1FFFFCu; ++ uint32_t src = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr); + for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { + if (ws_ui_prepass[i].src_addr != src) continue; + if (out_anchor) *out_anchor = ws_ui_prepass[i].group.anchor; +@@ -1946,20 +2251,102 @@ + return 1; + } + ++static int ws_hud_scale_percent = PSX_WS_HUD_SCALE_MAX; ++void gpu_ws_set_hud_scale(int percent) { ++ ws_hud_scale_percent = psx_ws_hud_scale_clamp(percent); ++} ++int gpu_ws_hud_scale(void) { return ws_hud_scale_percent; } ++ ++/* The user's HUD size for an untagged screen-space SPRT. 0 leaves the ++ * stock horizontal squash to the caller. */ ++static int ws_hud_user_scale(int32_t *x, int32_t *y, int *w, int *h) { ++ int32_t scaled_w = *w, scaled_h = *h; ++ if (ws_hud_scale_percent >= PSX_WS_HUD_SCALE_MAX || ++ !psx_ws_hud_scale_rect_strip(x, y, &scaled_w, &scaled_h, ws_disp_w(), ws_disp_h(), ++ ws_xnum, ws_xden, ws_hud_scale_percent, ws_hud_strip_wide())) ++ return 0; ++ *w = (int)scaled_w; ++ *h = (int)scaled_h; ++ return 1; ++} ++ ++static PsxWsHudWidgets ws_hud; ++static uint32_t ws_hud_table_pointer; ++static int ws_hud_strip; /* how wide the game's text printer makes a line, 0 when the game has not said */ ++ ++void gpu_ws_set_hud_text_strip(int wide) { ws_hud_strip = wide > 0 ? wide : 0; } ++ ++/* A line of text is printed as a list of its own, outside the HUD layer: only there is a rectangle of that width a line. */ ++static int32_t ws_hud_strip_wide(void) { return ws_hud.in_hud_layer ? 0 : ws_hud_strip; } ++ ++void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count) { ++ ws_hud_table_pointer = table_pointer; ++ memset(&ws_hud, 0, sizeof(ws_hud)); ++ ws_hud.layers = layers; ++ for (int i = 0; boxes && i < count && i < PSX_WS_HUD_WIDGET_MAX; ++i) { ++ const int32_t *box = boxes + 6 * i; ++ const PsxWsHudWidget widget = { box[0], box[1], box[2], box[3], box[4], box[5] }; ++ ws_hud.widgets[ws_hud.count++] = widget; ++ } ++} ++ ++/* The widget of an untagged widescreen primitive with these bounds. See psx_ws_hud_widgets_take. */ ++static const PsxWsHudWidget *ws_hud_widget(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int rectangle) { ++ if (!ws_active() || psx_ws_prim_is_tagged()) return NULL; ++ return psx_ws_hud_widgets_take(&ws_hud, x0, y0, x1, y1, rectangle); ++} ++ ++/* A widget's rectangle in place. 0 for a rectangle of no widget. */ ++static int ws_hud_widget_rect(int32_t *x, int32_t *y, int32_t *w, int32_t *h) { ++ const PsxWsHudWidget *widget = ws_hud_widget(*x, *y, *x + *w, *y + *h, 1); ++ if (!widget) return 0; ++ psx_ws_hud_widget_rect(widget, x, y, w, h, ws_xnum, ws_xden, ws_hud_scale_percent); ++ return 1; ++} ++ ++/* A widget's axis-aligned quad, every corner through the same mapping. A quad joins in a HUD layer only. */ ++static void ws_hud_widget_quad(int32_t vx[4], int32_t vy[4]) { ++ int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; ++ if (!ws_hud.in_hud_layer || !ws_axis_aligned_quad(vx, vy)) return; ++ for (int i = 1; i < 4; i++) { ++ if (vx[i] < min_x) min_x = vx[i]; ++ if (vx[i] > max_x) max_x = vx[i]; ++ if (vy[i] < min_y) min_y = vy[i]; ++ if (vy[i] > max_y) max_y = vy[i]; ++ } ++ const PsxWsHudWidget *widget = ws_hud_widget(min_x, min_y, max_x, max_y, 0); ++ if (!widget) return; ++ for (int i = 0; i < 4; i++) ++ psx_ws_hud_widget_corner(widget, &vx[i], &vy[i], ws_xnum, ws_xden, ws_hud_scale_percent); ++} ++ + /* Shared transform for fixed-size textured sprites (8x8 / 16x16 / 1x1 dot): + * squash *x0 in place (around the tagged anchor, else the HUD pivot) and +- * return the squashed draw width, or 0 = no change. */ +-static int ws_sprt_fixed_transform(int32_t *x0, int32_t y0, int w) { ++ * return the squashed draw width, or 0 = no change. *out_h is the draw ++ * height when the user's HUD size changed it, else 0. */ ++static int ws_sprt_fixed_transform(int32_t *x0, int32_t *y0, int w, int *out_h) { ++ *out_h = 0; ++ if (ws_menu_frame()) return ws_menu_rect(x0, w); + if (!ws_active()) return 0; + int32_t ax; + if (ws_tagged_anchor(&ax)) { + *x0 = ws_scale_about(*x0, ax); + return (int)ws_scale_len(w); + } ++ int32_t widget_w = w, widget_h = w; ++ if (ws_hud_widget_rect(x0, y0, &widget_w, &widget_h)) { ++ *out_h = (int)widget_h; ++ return (int)widget_w; ++ } + int auto_w = w; +- if (ws_auto_ui_transform_rect(x0, y0, &auto_w, w)) ++ if (ws_auto_ui_transform_rect(x0, *y0, &auto_w, w)) + return auto_w; + if (ws_hud_sprt) { ++ int scaled_w = w, scaled_h = w; ++ if (ws_hud_user_scale(x0, y0, &scaled_w, &scaled_h)) { ++ *out_h = scaled_h; ++ return scaled_w; ++ } + *x0 = ws_scale_about(*x0, ws_hud_pivot(*x0, w)); + return (int)ws_scale_len(w); + } +@@ -2024,6 +2411,10 @@ + * the game's dedicated HUD packet arena reaches here, never world polygons. */ + static void ws_nw_hud_shift_vertices(int32_t *vx, int count) { + if (count <= 0) return; ++ if (ws_menu_frame()) { /* the front end's polygons and lines keep their shape like its text */ ++ psx_ws_menu_squash_corners(vx, count, ws_xnum, ws_xden); ++ return; ++ } + /* Sprite-tag titles: polygon/line prims are the GTE world and the tagged + * character billboards, never HUD — only the rect-family sites (which + * call ws_nw_hud_shift directly, with the untagged filter) re-anchor. */ +@@ -2103,6 +2494,7 @@ + * CPU-visible transfer remains ordered because GP0 accepts no next command + * until this payload is complete. Maximum PS1 transfer = full VRAM (1 MiB). */ + static uint16_t vram_write_pixels[1024 * 512]; ++static uint16_t ws_menu_clut_x = 0, ws_menu_clut_y = 480; /* the backdrop's palette, as its rectangles last named it */ + + /* Depth24 CPU→VRAM upload span (halfwords, exclusive end). See + * gpu_depth24_rgb_limit — declared early so gpu_reset_state can clear it. */ +@@ -2110,6 +2502,58 @@ + static int s_d24_present_hold = 0; /* vblanks to skip Swap after GP1(07h) */ + static uint32_t s_d24_prev_disp_h = 0; /* last GP1(07h) band height */ + static void depth24_note_upload(uint32_t x, uint32_t w); ++ ++/* The wide front end's logo and paint are in the renderer's video memory alone, the one the game reads and a save state holds. */ ++static uint16_t ws_menu_kept[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE], ws_menu_under[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; ++static uint16_t ws_menu_painted[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS], ws_menu_uploaded[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS]; ++static int ws_menu_fresh; /* the CPU copy at (320,0) is a whole picture with the logo, and no state was loaded since it came */ ++static int ws_menu_lent; /* the renderer holds the kept logo at (512,0) */ ++static int ws_menu_rows; /* and the painted rows in the picture */ ++static int ws_menu_due; /* the frame has drawn nothing yet: its first primitive says whether it is the front end's */ ++ ++static const uint16_t *ws_menu_palette(void) { return &vram[ws_menu_clut_y * 1024u + ws_menu_clut_x]; } ++ ++/* Gives a rectangle of the renderer's back word by word: what is still as it was put there becomes what it was before. */ ++static void ws_menu_return(int x, int y, int w, int h, const uint16_t *put, const uint16_t *before) { ++ static uint16_t now[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; ++ gr_vram_transfer_out(x, y, w, h, now); ++ if (psx_ws_menu_return(now, w, w, h, put, before)) gr_vram_transfer_in(x, y, w, h, now); ++} ++ ++/* The front end is left, or the game is about to read or write there: it finds its own words. */ ++static void ws_menu_give_back(void) { ++ if (ws_menu_lent) ws_menu_return(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept, ws_menu_under); ++ if (ws_menu_rows) ws_menu_return(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted, ws_menu_uploaded); ++ ws_menu_lent = ws_menu_rows = ws_menu_logo = 0; ++} ++ ++/* The game is about to read or write this rectangle of video memory, in words. What it writes there the CPU copy does not have. */ ++static void ws_menu_yield(int x, int y, int w, int h, int written) { ++ if (!psx_ws_menu_reaches(x, y, w, h)) return; ++ ws_menu_give_back(); ++ if (written) ws_menu_fresh = 0; ++} ++ ++/* The front end's picture is at (320,0): its logo goes to (512,0) and is painted over in the picture, both in the renderer's video memory. */ ++static void ws_menu_take_picture(void) { ++ static uint8_t mask[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; ++ static uint16_t picture[PSX_WS_MENU_HIGH * PSX_WS_MENU_WORDS]; ++ const uint16_t *palette = ws_menu_palette(); ++ if (!ws_menu_fresh || !ws_menu_wanted()) { ++ ws_menu_give_back(); ++ return; ++ } ++ if (!ws_menu_lent) gr_vram_transfer_out(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_under); ++ for (int row = 0; row < PSX_WS_MENU_HIGH; ++row) memcpy(&picture[row * PSX_WS_MENU_WORDS], &vram[row * 1024 + PSX_WS_MENU_X], PSX_WS_MENU_WORDS * sizeof(uint16_t)); ++ memcpy(ws_menu_uploaded, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_uploaded)); ++ psx_ws_menu_logo_mask(picture, PSX_WS_MENU_WORDS, palette, mask); ++ psx_ws_menu_keep_logo(picture, PSX_WS_MENU_WORDS, palette, mask, ws_menu_kept); ++ gr_vram_transfer_in(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept); ++ psx_ws_menu_paint(picture, PSX_WS_MENU_WORDS, mask); ++ memcpy(ws_menu_painted, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_painted)); ++ gr_vram_transfer_in(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted); ++ ws_menu_logo = ws_menu_lent = ws_menu_rows = 1; ++} + + static void gp0_commit_cpu_to_vram(void) { + for (uint32_t row = 0; row < vram_write_h; row++) +@@ -2120,6 +2564,11 @@ + gr_vram_transfer_in(vram_write_x, vram_write_y, + vram_write_w, vram_write_h, vram_write_pixels); + depth24_note_upload(vram_write_x, vram_write_w); ++ if (psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) { ++ ws_menu_rows = 0; /* the renderer's rows are the game's again */ ++ ws_menu_fresh = psx_ws_menu_has_logo(&vram[PSX_WS_MENU_X], 1024, ws_menu_palette()); ++ ws_menu_take_picture(); ++ } + gp0_state = GP0_IDLE; + vram_write_remaining = 0; + text_xlate_vram_upload(vram_write_x, vram_write_y, +@@ -2221,11 +2670,186 @@ + * when native-wide is active and that buffer is a known display buffer; else + * disable mirroring for this draw. Called when the draw env changes. */ + static void ws_nw_sync_target(void) { +- if (!ws_native_wide_active()) { gr_wide_disable_target(); return; } +- gr_wide_configure((int)ws_disp_w() + ws_nw_extra(), ws_nw_offset()); ++ const int native_wide = ws_native_wide_active(); ++ /* The corrected presentation mirror is also required at authentic 4:3. ++ * main.cpp's game-entry gate deliberately considers that mode engaged even ++ * though this file's ws_engaged() means only "a non-identity wide mode". ++ * Gating visual-only here on ws_mode therefore left 4:3 correction without ++ * a surface and sent OpenGL into its CPU fallback at the wrong SSAA scale. */ ++ const int visual_only = ws_geometry_correction; ++ if (!native_wide && !visual_only) { ++ gr_wide_disable_target(); ++ return; ++ } ++ /* Geometry correction deliberately reuses the renderer's independent wide ++ * surface even in classic squash mode. With zero side offset it becomes a ++ * same-width presentation mirror: canonical VRAM remains integer-exact, ++ * while the mirror may consume subpixel vertices and fractional yaw. */ ++ gr_wide_configure(native_wide ? (int)ws_disp_w() + ws_nw_extra() ++ : (int)ws_disp_w(), ++ native_wide ? ws_nw_offset() : 0); + uint32_t base = draw_area_left; + if (ws_is_fb_base(base)) gr_wide_set_target((int)base); + else gr_wide_disable_target(); ++} ++ ++extern void gte_geometry_correction_set(int enabled); ++extern void gte_precision_tracking_set(int enabled); ++ ++static void geometry_diag_reset(void) { ++ memset(&s_geometry_diag_cumulative, 0, ++ sizeof(s_geometry_diag_cumulative)); ++ memset(&s_geometry_diag_live_current, 0, ++ sizeof(s_geometry_diag_live_current)); ++ memset(&s_geometry_diag_live_latest, 0, ++ sizeof(s_geometry_diag_live_latest)); ++ s_geometry_diag_presentation_sequence = 0; ++ s_geometry_diag_corrected_presentations = 0; ++ s_geometry_diag_latest_live_presentation = 0; ++ s_geometry_diag_latest_live_valid = 0; ++ s_geometry_diag_live_current_touched = 0; ++ gte_precision_diagnostics_reset(); ++} ++ ++static int geometry_diag_display_active(void) { ++ GpuDisplayInfo display; ++ gpu_get_display_info(&display); ++ return !display.disabled && display.width != 0u && display.height != 0u; ++} ++ ++/* Match main.cpp's corrected-present eligibility using state available at GP0 ++ * decode time. A known framebuffer target is additionally required because an ++ * offscreen texture draw cannot reach the independent presentation surface. */ ++static int geometry_diag_presentation_live(void) { ++ return geometry_diag_display_active() && ++ ws_geometry_correction && ++ !gpu_ws_present_native_43() && !(display_depth & 1u) && ++ gr_wide_supported() && ws_is_fb_base(draw_area_left); ++} ++ ++static void geometry_diag_finish_presentation(void) { ++ const int live = geometry_diag_display_active() && ++ (s_geometry_diag_live_current_touched || ++ geometry_diag_presentation_live()); ++ ++s_geometry_diag_presentation_sequence; ++ if (live) { ++ s_geometry_diag_live_latest = s_geometry_diag_live_current; ++ ++s_geometry_diag_corrected_presentations; ++ s_geometry_diag_latest_live_presentation = ++ s_geometry_diag_presentation_sequence; ++ s_geometry_diag_latest_live_valid = 1; ++ } ++ memset(&s_geometry_diag_live_current, 0, ++ sizeof(s_geometry_diag_live_current)); ++ s_geometry_diag_live_current_touched = 0; ++} ++ ++void gpu_geometry_correction_set(int enabled) { ++ ws_geometry_correction = enabled ? 1 : 0; ++ ws_geometry_world_triangles = 0; ++ ws_geometry_precise_triangles = 0; ++ geometry_diag_reset(); ++ gte_geometry_correction_set(ws_geometry_correction); ++ /* Exact committed packet-address provenance is preferred over the fallback ++ * packed-SXY cache whenever the game stores GTE results into its OT. */ ++ gte_precision_tracking_set(ws_geometry_correction || ++ s_texture_correction_enabled); ++ gr_set_geometry_correction(ws_geometry_correction); ++ gpu_geometry_camera_yaw_residual_set( ++ s_geometry_camera_yaw_residual); ++ ws_nw_sync_target(); ++} ++ ++void gpu_geometry_correction_stats(uint64_t *world_triangles, ++ uint64_t *precise_triangles) { ++ if (world_triangles) *world_triangles = ws_geometry_world_triangles; ++ if (precise_triangles) *precise_triangles = ws_geometry_precise_triangles; ++} ++ ++void gpu_geometry_correction_stats_detailed( ++ GpuGeometryCorrectionStats *out, uint32_t out_size) { ++ if (!out || out_size == 0u) return; ++ GtePrecisionDiagnostics provenance = { 0 }; ++ GpuGeometryCorrectionStats snapshot; ++ gte_precision_diagnostics(&provenance); ++ memset(&snapshot, 0, sizeof(snapshot)); ++ snapshot.version = GPU_GEOMETRY_DIAGNOSTICS_VERSION; ++ snapshot.size = (uint32_t)sizeof(snapshot); ++ snapshot.presentation_sequence = s_geometry_diag_presentation_sequence; ++ snapshot.corrected_presentations = s_geometry_diag_corrected_presentations; ++ snapshot.latest_live_presentation = s_geometry_diag_latest_live_presentation; ++ snapshot.latest_live_valid = (uint32_t)s_geometry_diag_latest_live_valid; ++ snapshot.provenance_store_collisions = provenance.store_collisions; ++ snapshot.provenance_store_evictions = provenance.store_evictions; ++ snapshot.provenance_lookup_collisions = provenance.lookup_collisions; ++ snapshot.cumulative = s_geometry_diag_cumulative; ++ snapshot.latest_live = s_geometry_diag_live_latest; ++ snapshot.provenance_store_uncommitted_rejections = ++ provenance.store_uncommitted_rejections; ++ snapshot.provenance_registered_store_attempts = ++ provenance.registered_store_attempts; ++ snapshot.provenance_registered_store_accepts = ++ provenance.registered_store_accepts; ++ snapshot.provenance_registered_store_packed_rejections = ++ provenance.registered_store_packed_rejections; ++ snapshot.provenance_copy_load_attempts = ++ provenance.copy_load_attempts; ++ snapshot.provenance_copy_load_accepts = ++ provenance.copy_load_accepts; ++ snapshot.provenance_copy_store_attempts = ++ provenance.copy_store_attempts; ++ snapshot.provenance_copy_store_accepts = ++ provenance.copy_store_accepts; ++ snapshot.provenance_copy_store_packed_rejections = ++ provenance.copy_store_packed_rejections; ++ { ++ const uint32_t copy_size = out_size < (uint32_t)sizeof(snapshot) ++ ? out_size : (uint32_t)sizeof(snapshot); ++ memcpy(out, &snapshot, copy_size); ++ } ++} ++ ++int gpu_geometry_correction_enabled(void) { ++ return ws_geometry_correction; ++} ++ ++static void geometry_camera_presentation_update(void) { ++ double center = (double)ws_disp_w() * 0.5; ++ /* Keep Y relative to the drawing band. The GL backend adds its current ++ * GP0(E5) draw offset after flushing the prior batch, so a framebuffer ++ * switch after VBlank cannot leave the next frame on the old horizon. */ ++ const double center_y = ++ (double)s_geometry_camera_projection_center_y; ++ double focal = center; ++ if (ws_mode == 1 && ws_xden != 0) ++ focal = focal * (double)ws_xnum / (double)ws_xden; ++ gr_set_presentation_yaw(ws_geometry_correction && ++ isfinite(s_geometry_camera_yaw_residual) ++ ? s_geometry_camera_yaw_residual : 0.0, ++ 256.0, center, center_y, focal); ++} ++ ++void gpu_geometry_camera_yaw_residual_set(double yaw_units) { ++ s_geometry_camera_yaw_residual = yaw_units; ++ geometry_camera_presentation_update(); ++} ++ ++void gpu_geometry_camera_projection_center_y_set(double center_y) { ++ int next = 120; ++ if (isfinite(center_y) && center_y >= 24.0 && center_y <= 216.0) ++ next = (int)floor(center_y + 0.5); ++ if (next == s_geometry_camera_projection_center_y) return; ++ ++ s_geometry_camera_projection_center_y = next; ++ /* Reissuing the current yaw lands renderer batches under the old horizon ++ * before the backend adopts the new one. This call is also intentional ++ * while correction is disabled: it stores the requested shared center but ++ * supplies an identity yaw to the presentation backend. */ ++ geometry_camera_presentation_update(); ++} ++ ++int gpu_geometry_camera_projection_center_y_get(void) { ++ return s_geometry_camera_projection_center_y; + } + + static void ws_clear_all_reveal_margins(void) { +@@ -2239,18 +2863,23 @@ + * stage art. A stale reveal tile is safer than deleting submitted content. */ + void gpu_ws_begin_linked_list(void) { + gp0_ot_rank = 0xFFFFu; ++ psx_ws_hud_widgets_list(&ws_hud, ws_hud.count && ws_active() ? psx_read_word(ws_hud_table_pointer) : 0u); + } + + void gpu_set_gp0_linked_list_node(uint32_t addr, uint32_t word_count) { + (void)addr; ++ psx_ws_frame_kinds_list(&ws_frame_kinds, 1); + if (word_count == 0) { + gp0_ot_rank = gp0_ot_rank == 0xFFFFu ? 0u + : (uint16_t)(gp0_ot_rank + 1u); ++ psx_ws_hud_widgets_node(&ws_hud, addr); + } + } + + void gpu_ws_end_linked_list(void) { ++ psx_ws_frame_kinds_list(&ws_frame_kinds, 0); + gp0_ot_rank = 0xFFFFu; ++ psx_ws_hud_widgets_list_end(&ws_hud); + } + + +@@ -2362,6 +2991,16 @@ + s_d24_upload_x1 = 0; + s_d24_present_hold = 0; + s_d24_prev_disp_h = 0; ++ ++ /* gr_init can retain backend-side draw state across GP1(00h). Keep the ++ * restored canonical clip and the framebuffer-relative yaw horizon in ++ * lockstep with the power-on GP0 defaults. */ ++ gr_set_draw_area((int)draw_area_left, (int)draw_area_top, ++ (int)draw_area_right, (int)draw_area_bottom); ++ gr_set_draw_offset(draw_offset_x, draw_offset_y); ++ s_geometry_camera_yaw_residual = 0.0; ++ s_geometry_camera_projection_center_y = 120; ++ geometry_camera_presentation_update(); + } + + void gpu_init(void) { +@@ -2538,6 +3177,9 @@ + } + g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; + gpustat_poll_count = 0; ++ /* Latch the just-finished frame before the mouse/mod hook samples the ++ * diagnostics below. */ ++ geometry_diag_finish_presentation(); + /* Trusted package-selected plugins run on guest VBlank, independent of + * host presentation, pacing, turbo, or skipped frames. */ + mod_runtime_on_vblank(); +@@ -2757,73 +3399,527 @@ + *y = sign_extend((word >> 16) & 0x7FFu, 11); + } + +-extern int gte_geometry_correction_lookup(uint32_t packed, +- int32_t *x16, int32_t *y16); + extern int gte_geometry_correction_enabled(void); +-static int s_texture_correction_enabled = 0; + extern void gte_precision_tracking_set(int enabled); +-extern int gte_precision_load_word(uint32_t addr, uint32_t packed, +- int32_t *x16, int32_t *y16, uint16_t *z); + + void gpu_texture_correction_set(int enabled) { + s_texture_correction_enabled = enabled ? 1 : 0; +- gte_precision_tracking_set(enabled); ++ s_texture_correction_triangles = 0; ++ gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); ++ gte_precision_tracking_set(s_texture_correction_enabled || ++ ws_geometry_correction); ++} ++ ++int gpu_texture_correction_enabled(void) { ++ return s_texture_correction_enabled; + } + + uint32_t gpu_texture_correction_hits(void) { +- return sw_perspective_triangle_count(); +-} +- +-/* Match all three GP0 positions to recent GTE projections. Requiring a full +- * triangle match prevents screen-space HUD/sprites that happen to share one +- * coordinate from receiving world-geometry correction. The integer delta +- * folds in draw offsets and any widescreen adjustment already applied. */ +-static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, +- const int32_t vx[3], const int32_t vy[3]) { +- uint32_t words[3] = { w0, w1, w2 }; ++ return s_texture_correction_triangles; ++} ++ ++static int gp0_source_word_address(int word_index, uint32_t *out) { ++ const uint32_t delta = (uint32_t)word_index * 4u; ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu || ++ gp0_cmd_source_addr > UINT32_MAX - delta) ++ return 0; ++ *out = gp0_cmd_source_addr + delta; ++ return 1; ++} ++ ++static int32_t fixed16_integer_floor(int32_t value) { ++ const int64_t wide = value; ++ if (wide >= 0) return (int32_t)(wide / 65536); ++ return (int32_t)(-(((-wide) + 65535) / 65536)); ++} ++ ++static uint64_t abs_i64_u64(int64_t value) { ++ return (uint64_t)(value < 0 ? -value : value); ++} ++ ++static uint32_t geometry_primitive_class(uint8_t opcode) { ++ const uint32_t quad = (opcode & 0x08u) ? 4u : 0u; ++ const uint32_t gouraud = (opcode & 0x10u) ? 2u : 0u; ++ const uint32_t textured = (opcode & 0x04u) ? 1u : 0u; ++ return quad | gouraud | textured; ++} ++ ++static GpuGeometryRejectReason geometry_reject_from_lookup( ++ GtePrecisionLookupResult result) { ++ switch (result) { ++ case GTE_PRECISION_LOOKUP_TRACKING_DISABLED: ++ return GPU_GEOMETRY_REJECT_TRACKING_DISABLED; ++ case GTE_PRECISION_LOOKUP_SPECULATIVE: ++ return GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP; ++ case GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM: ++ return GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM; ++ case GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED: ++ return GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED; ++ case GTE_PRECISION_LOOKUP_STORE_MISS: ++ return GPU_GEOMETRY_REJECT_STORE_MISS; ++ case GTE_PRECISION_LOOKUP_STORE_COLLISION: ++ return GPU_GEOMETRY_REJECT_STORE_COLLISION; ++ case GTE_PRECISION_LOOKUP_PROJECTION_INVALID: ++ return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; ++ case GTE_PRECISION_LOOKUP_PACKED_MISMATCH: ++ return GPU_GEOMETRY_REJECT_PACKED_MISMATCH; ++ case GTE_PRECISION_LOOKUP_ZERO_DEPTH: ++ return GPU_GEOMETRY_REJECT_ZERO_DEPTH; ++ case GTE_PRECISION_LOOKUP_SATURATED: ++ return GPU_GEOMETRY_REJECT_SATURATED; ++ case GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID: ++ return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; ++ case GTE_PRECISION_LOOKUP_ACCEPTED: ++ case GTE_PRECISION_LOOKUP_RESULT_COUNT: ++ default: ++ return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; ++ } ++} ++ ++static void geometry_diag_note_candidate( ++ GpuGeometryCorrectionCounters *stats, int count, int triangle_count, ++ uint32_t primitive_class, uint32_t opcode_index) { ++ if (!stats) return; ++ stats->triangle_candidates += (uint64_t)triangle_count; ++ ++stats->polygon_candidates; ++ stats->vertex_candidates += (uint64_t)count; ++ ++stats->primitive_candidates[primitive_class]; ++ ++stats->opcode_candidates[opcode_index]; ++} ++ ++static void geometry_diag_note_rejection( ++ GpuGeometryCorrectionCounters *stats, ++ GpuGeometryRejectReason reason, uint64_t count) { ++ if (stats) stats->vertex_rejections[(uint32_t)reason] += count; ++} ++ ++/* Axis-aligned textured quads use a rectangle renderer and never consume the ++ * per-triangle precision metadata. Keep them visible in the coverage ++ * denominator as an explicit, safe canonical fallback. */ ++static void geometry_diag_note_rectangle_fast_path(void) { ++ if (!gte_geometry_correction_enabled()) return; ++ const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); ++ const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; ++ const uint32_t primitive_class = geometry_primitive_class(opcode); ++ GpuGeometryCorrectionCounters *live = NULL; ++ if (geometry_diag_presentation_live()) { ++ live = &s_geometry_diag_live_current; ++ s_geometry_diag_live_current_touched = 1; ++ } ++ geometry_diag_note_candidate(&s_geometry_diag_cumulative, 4, 2, ++ primitive_class, opcode_index); ++ geometry_diag_note_candidate(live, 4, 2, ++ primitive_class, opcode_index); ++ geometry_diag_note_rejection( ++ &s_geometry_diag_cumulative, ++ GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); ++ geometry_diag_note_rejection( ++ live, GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); ++} ++ ++static uint32_t geometry_depth_bucket(uint16_t z) { ++ if (z < 256u) return 0; ++ if (z < 1024u) return 1; ++ if (z < 4096u) return 2; ++ if (z < 16384u) return 3; ++ if (z < 32768u) return 4; ++ return 5; ++} ++ ++static uint32_t geometry_screen_bucket(int32_t x, int32_t y) { ++ const int32_t width = ws_disp_w(); ++ const int32_t height = ws_disp_h(); ++ if (x < 0 || y < 0 || x >= width || y >= height) ++ return 9; ++ int32_t column = width > 0 ? (x * 3) / width : 0; ++ int32_t row = height > 0 ? (y * 3) / height : 0; ++ if (column > 2) column = 2; ++ if (row > 2) row = 2; ++ return (uint32_t)(row * 3 + column); ++} ++ ++static uint32_t geometry_correction_bucket(uint64_t magnitude16) { ++ if (magnitude16 == 0) return 0; ++ if (magnitude16 < 16384u) return 1; ++ if (magnitude16 < 32768u) return 2; ++ if (magnitude16 < 49152u) return 3; ++ return 4; ++} ++ ++static void geometry_diag_saturating_add( ++ GpuGeometryCorrectionCounters *stats, ++ uint64_t *destination, uint64_t value) { ++ if (UINT64_MAX - *destination < value) { ++ *destination = UINT64_MAX; ++ stats->correction_accumulators_saturated = 1; ++ } else { ++ *destination += value; ++ } ++} ++ ++static void geometry_diag_note_accepted( ++ GpuGeometryCorrectionCounters *stats, int count, int triangle_count, ++ uint32_t primitive_class, uint32_t opcode_index, ++ const int32_t *precise_x, const int32_t *precise_y, ++ const int32_t *raw_x, const int32_t *raw_y, ++ const int32_t *screen_x, const int32_t *screen_y, ++ const uint16_t *z) { ++ if (!stats) return; ++ stats->triangle_accepted += (uint64_t)triangle_count; ++ ++stats->polygon_accepted; ++ stats->vertex_accepted += (uint64_t)count; ++ ++stats->primitive_accepted[primitive_class]; ++ ++stats->opcode_accepted[opcode_index]; ++ for (int i = 0; i < count; ++i) { ++ const int64_t dx = (int64_t)precise_x[i] - ++ (int64_t)raw_x[i] * 65536; ++ const int64_t dy = (int64_t)precise_y[i] - ++ (int64_t)raw_y[i] * 65536; ++ const uint64_t ax = abs_i64_u64(dx); ++ const uint64_t ay = abs_i64_u64(dy); ++ const uint64_t magnitude = ax > ay ? ax : ay; ++ ++stats->depth_vertices[geometry_depth_bucket(z[i])]; ++ ++stats->screen_vertices[ ++ geometry_screen_bucket(screen_x[i], screen_y[i])]; ++ ++stats->correction_vertices; ++ geometry_diag_saturating_add( ++ stats, &stats->correction_abs_x16_sum, ax); ++ geometry_diag_saturating_add( ++ stats, &stats->correction_abs_y16_sum, ay); ++ geometry_diag_saturating_add( ++ stats, &stats->correction_magnitude16_sum, magnitude); ++ if (magnitude > stats->correction_magnitude16_max) ++ stats->correction_magnitude16_max = magnitude; ++ geometry_diag_saturating_add( ++ stats, &stats->correction_x16_squared_sum, ax * ax); ++ geometry_diag_saturating_add( ++ stats, &stats->correction_y16_squared_sum, ay * ay); ++ ++stats->correction_magnitude_buckets[ ++ geometry_correction_bucket(magnitude)]; ++ } ++} ++ ++/* Resolve vertices only through the exact RAM address at which a reviewed GTE ++ * projection store committed that result. A rounded packed SXY value is not a ++ * unique identity: many ++ * unrelated 3D vertices can land on the same PS1 pixel. Test 11's packed-SXY ++ * fallback therefore paired polygons with unrelated projections and opened ++ * cracks between adjacent primitives. Missing provenance is deliberately a ++ * canonical-render fallback for the whole polygon. The integer delta folds in ++ * draw offsets and any widescreen adjustment already applied. */ ++/* Valid only for the polygon most recently resolved below. Both halves of a ++ * quad use their original indices; shader q remains a separate attribute. */ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++static uint16_t s_precise_vertex_depth[4]; ++static uint16_t s_precise_vertex_surface[4]; /* the same with a pinned corner's depth kept: for the mirror's depth */ ++static uint64_t s_precise_vertex_identity[4]; ++static int s_precise_vertex_partial; ++#endif ++/* A polygon that falls back still draws the corners the game pinned where they project: its neighbours do. */ ++static PsxPinnedPlaces s_pinned_places; ++/* The polygon resolved last is the game's grown second copy of the one before it. */ ++static int s_precise_grown; ++#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) ++/* A grown corner lies off its source corner, on the same plane: its depth is the plane's there, 1/z being linear on screen. */ ++static void grown_corner_depths(const int32_t *source_x, const int32_t *source_y, const int32_t *raw_x, const int32_t *raw_y, ++ const int *indices) { ++ static const int beside[4][2] = {{1, 2}, {0, 3}, {0, 3}, {1, 2}}; ++ uint16_t moved[4]; ++ for (int i = 0; i < 4; ++i) { ++ const int j = beside[i][0], k = beside[i][1]; ++ const double zi = s_precise_vertex_depth[i], zj = s_precise_vertex_depth[j], zk = s_precise_vertex_depth[k]; ++ const double ax = ((double)source_x[j] - source_x[i]) / 65536.0, ay = ((double)source_y[j] - source_y[i]) / 65536.0; ++ const double bx = ((double)source_x[k] - source_x[i]) / 65536.0, by = ((double)source_y[k] - source_y[i]) / 65536.0; ++ const double det = ax * by - bx * ay; ++ int32_t grown_x, grown_y; ++ moved[i] = s_precise_vertex_depth[i]; ++ if (zi <= 0.0 || zj <= 0.0 || zk <= 0.0 || (det < 1.0e-6 && det > -1.0e-6)) continue; ++ parse_vertex(gp0_cmd_buf[indices[i]], &grown_x, &grown_y); ++ { ++ const double ga = 1.0 / zj - 1.0 / zi, gb = 1.0 / zk - 1.0 / zi; ++ const double px = (double)grown_x - raw_x[i], py = (double)grown_y - raw_y[i]; ++ const double g = 1.0 / zi + ((ga * by - gb * ay) * px + (ax * gb - bx * ga) * py) / det; ++ const double z = g > 1.0 / 65535.0 ? 1.0 / g : 65535.0; ++ if (z >= 17.0) moved[i] = (uint16_t)(z + 0.5); /* 16 and nearer reads as no depth: the corner keeps its source's */ ++ } ++ } ++ memcpy(s_precise_vertex_depth, moved, sizeof(moved)); ++} ++#endif ++#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD ++extern int disruptor_grown_quad_sources( ++ uint32_t command_address, const uint32_t *packet, ++ uint32_t source_address[4], uint32_t source_word[4]); ++#endif ++static int resolve_precise_vertices(const int *indices, int count, ++ int triangle_count, ++ const int32_t *vx, const int32_t *vy, ++ int32_t *fx, int32_t *fy) { ++ s_precise_grown = 0; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ memset(s_precise_vertex_depth, 0, sizeof(s_precise_vertex_depth)); ++ memset(s_precise_vertex_surface, 0, sizeof(s_precise_vertex_surface)); ++ memset(s_precise_vertex_identity, 0, sizeof(s_precise_vertex_identity)); ++ s_precise_vertex_partial = 0; ++#endif ++ s_pinned_places.unpinned = 0; ++ if (!gte_geometry_correction_enabled()) ++ return 0; ++ ++ const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); ++ const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; ++ const uint32_t primitive_class = geometry_primitive_class(opcode); ++ GpuGeometryCorrectionCounters *live = NULL; ++ if (geometry_diag_presentation_live()) { ++ live = &s_geometry_diag_live_current; ++ s_geometry_diag_live_current_touched = 1; ++ } ++ geometry_diag_note_candidate(&s_geometry_diag_cumulative, count, ++ triangle_count, ++ primitive_class, opcode_index); ++ geometry_diag_note_candidate(live, count, triangle_count, ++ primitive_class, opcode_index); ++ ++ if (gp0_cmd_source_addr == 0xFFFFFFFFu) { ++ geometry_diag_note_rejection(&s_geometry_diag_cumulative, ++ GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, ++ (uint64_t)count); ++ geometry_diag_note_rejection(live, ++ GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, ++ (uint64_t)count); ++ return 0; ++ } ++ ++ int32_t precise_x[4], precise_y[4], raw_x[4], raw_y[4]; ++ uint16_t z[4]; ++ int resolved = 0; ++ int pinned[4] = {0}; ++ const int unpin = gte_precision_unpin_enabled(); ++ uint32_t grown_addr[4] = {0}, grown_word[4] = {0}; ++#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD ++ /* Disruptor's second, grown copy of a world quad stands on the first packet's corners. */ ++ const int grown = count == 4 && disruptor_grown_quad_sources( ++ gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word); ++#else ++ const int grown = 0; ++#endif ++ ++ for (int i = 0; i < count; ++i) { ++ const uint32_t word = grown ? grown_word[i] : gp0_cmd_buf[indices[i]]; ++ uint32_t addr = grown_addr[i]; ++ GpuGeometryRejectReason reject; ++ int accepted = 0; ++ if (!grown && !gp0_source_word_address(indices[i], &addr)) { ++ reject = GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW; ++ } else { ++ const GtePrecisionLookupResult result = ++ gte_precision_load_word_ex(addr, word, ++ &precise_x[i], &precise_y[i], &z[i]); ++ if (result != GTE_PRECISION_LOOKUP_ACCEPTED) { ++ reject = geometry_reject_from_lookup(result); ++ } else { ++ parse_vertex(word, &raw_x[i], &raw_y[i]); ++ /* This test happens on the retained raw projection. Draw ++ * offsets and widescreen transforms in vx/vy must not hide a ++ * saturated or otherwise non-subpixel discrepancy. */ ++ /* A corner the game pinned to its guard band is drawn where it projects. */ ++ pinned[i] = gte_precision_word_clamped(addr, word); ++ if ((fixed16_integer_floor(precise_x[i]) != raw_x[i] || ++ fixed16_integer_floor(precise_y[i]) != raw_y[i]) && ++ !pinned[i]) { ++ reject = GPU_GEOMETRY_REJECT_INTEGER_MISMATCH; ++ } else { ++ accepted = 1; ++ ++resolved; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); ++ s_precise_vertex_surface[i] = z[i]; ++#endif ++ } ++ } ++ } ++ if (!accepted) { ++ geometry_diag_note_rejection(&s_geometry_diag_cumulative, ++ reject, 1); ++ geometry_diag_note_rejection(live, reject, 1); ++ } ++ } ++ ++ if (resolved != count) { ++ if (resolved != 0) { ++ geometry_diag_note_rejection( ++ &s_geometry_diag_cumulative, ++ GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, (uint64_t)resolved); ++ geometry_diag_note_rejection( ++ live, GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, ++ (uint64_t)resolved); ++ ++s_geometry_diag_cumulative.partial_polygon_rejections; ++ if (live) ++live->partial_polygon_rejections; ++ if (count == 4) { ++ ++s_geometry_diag_cumulative.partial_quad_rejections; ++ if (live) ++live->partial_quad_rejections; ++ } ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ s_precise_vertex_partial = 1; ++#endif ++ psx_pinned_places(count, unpin, pinned, precise_x, precise_y, raw_x, raw_y, vx, vy, &s_pinned_places); ++ } ++ return 0; ++ } ++ ++ geometry_diag_note_accepted(&s_geometry_diag_cumulative, count, ++ triangle_count, ++ primitive_class, opcode_index, ++ precise_x, precise_y, raw_x, raw_y, ++ raw_x, raw_y, z); ++ geometry_diag_note_accepted(live, count, triangle_count, ++ primitive_class, opcode_index, ++ precise_x, precise_y, raw_x, raw_y, ++ raw_x, raw_y, z); ++ for (int i = 0; i < count; ++i) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); ++ s_precise_vertex_surface[i] = z[i]; ++ { ++ uint32_t identity_addr; ++ /* A grown corner is off its model point: it gets the depth and no identity. */ ++ if (!grown && gp0_source_word_address(indices[i], &identity_addr)) ++ (void)gte_precision_load_identity(identity_addr, gp0_cmd_buf[indices[i]], ++ &s_precise_vertex_identity[i]); ++ } ++#endif ++ fx[i] = (int32_t)((int64_t)precise_x[i] + ++ (int64_t)(vx[i] - raw_x[i]) * 65536); ++ fy[i] = (int32_t)((int64_t)precise_y[i] + ++ (int64_t)(vy[i] - raw_y[i]) * 65536); ++ } ++ s_precise_grown = grown; ++#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) ++ if (grown) { ++ grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices); ++ memcpy(s_precise_vertex_surface, s_precise_vertex_depth, sizeof(s_precise_vertex_surface)); ++ } ++#endif ++ return 1; ++} ++ ++/* Queue one triangle's one-shot presentation metadata. Fractional yaw uses the ++ * same exact-provenance gate as subpixel positions, so CPU-built 2D polygons ++ * and incomplete world packets remain completely canonical. */ ++static void queue_precise_triangle(int exact, ++ const int32_t *fx, const int32_t *fy, ++ int a, int b, int c) { ++ const int geometry_enabled = gte_geometry_correction_enabled(); ++ gr_set_world_triangle(0); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ gr_set_temporal_depth_triangle(0, 0.0f, 0.0f, 0.0f); ++ gr_set_temporal_identity_triangle(0u, 0u, 0u); ++ gr_set_surface_depth_triangle(0.0f, 0.0f, 0.0f); ++#endif ++ gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); ++ if (geometry_enabled) ++ws_geometry_world_triangles; ++ if (!geometry_enabled || !exact) { ++ gr_set_precise_triangle(0, 0,0, 0,0, 0,0); ++ if (geometry_enabled && s_pinned_places.unpinned) ++ gr_set_unpinned_triangle(s_pinned_places.x16[a], s_pinned_places.y16[a], s_pinned_places.x16[b], s_pinned_places.y16[b], ++ s_pinned_places.x16[c], s_pinned_places.y16[c]); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ /* The corners that did resolve keep their depth: an in-between frame moves them with the camera. */ ++ if (geometry_enabled && s_precise_vertex_partial) ++ gr_set_temporal_depth_triangle(2, ++ (float)s_precise_vertex_depth[a], ++ (float)s_precise_vertex_depth[b], ++ (float)s_precise_vertex_depth[c]); ++ if (geometry_enabled && s_precise_vertex_partial) ++ gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], ++ (float)s_precise_vertex_surface[c]); ++#endif ++ return; ++ } ++ ++ws_geometry_precise_triangles; ++ gr_set_world_triangle(1); ++ if (s_precise_grown) gr_set_world_triangle(2); /* the game's grown second copy */ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ gr_set_temporal_depth_triangle(psx_pinned_depth_mode(s_precise_vertex_depth[a], s_precise_vertex_depth[b], s_precise_vertex_depth[c]), ++ (float)s_precise_vertex_depth[a], ++ (float)s_precise_vertex_depth[b], ++ (float)s_precise_vertex_depth[c]); ++ gr_set_temporal_identity_triangle(s_precise_vertex_identity[a], ++ s_precise_vertex_identity[b], ++ s_precise_vertex_identity[c]); ++ gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], ++ (float)s_precise_vertex_surface[c]); ++#endif ++ gr_set_precise_triangle(1, ++ fx[a], fy[a], fx[b], fy[b], fx[c], fy[c]); ++} ++ ++static int prepare_precise_triangle(int i0, int i1, int i2, ++ const int32_t vx[3], const int32_t vy[3]) { ++ const int indices[3] = { i0, i1, i2 }; + int32_t fx[3], fy[3]; +- const int geometry_enabled = gte_geometry_correction_enabled(); +- sw_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); +- if (!geometry_enabled) { +- sw_set_precise_triangle(0, 0,0, 0,0, 0,0); ++ const int exact = resolve_precise_vertices( ++ indices, 3, 1, vx, vy, fx, fy); ++ queue_precise_triangle(exact, fx, fy, 0, 1, 2); ++ return exact; ++} ++ ++/* Resolve a GP0 quad as one unit before either of its raster triangles is ++ * submitted. This prevents a partially tracked quad from correcting only one ++ * side of its shared diagonal. */ ++static int prepare_precise_quad_vertices(const int indices[4], ++ int triangle_count, ++ const int32_t vx[4], ++ const int32_t vy[4], ++ int32_t fx[4], int32_t fy[4]) { ++ return resolve_precise_vertices( ++ indices, 4, triangle_count, vx, vy, fx, fy); ++} ++ ++/* Resolve projective depth as one polygon-wide transaction. Geometry's exact ++ * verdict is a prerequisite: this keeps both halves of a quad affine whenever ++ * any one of its four vertices falls back, and excludes CPU-built UI. */ ++static int resolve_texture_vertices(int exact, const int *indices, int count, ++ float *q) { ++ if (!exact || !s_texture_correction_enabled || ++ gp0_cmd_source_addr == 0xFFFFFFFFu || count < 3 || count > 4) ++ return 0; ++ uint16_t z[4]; ++ for (int i = 0; i < count; i++) { ++ uint32_t addr; ++ if (!gp0_source_word_address(indices[i], &addr) || ++ gte_precision_load_perspective_word_ex( ++ addr, gp0_cmd_buf[indices[i]], &z[i]) != ++ GTE_PRECISION_LOOKUP_ACCEPTED) ++ return 0; ++ } ++ for (int i = 0; i < count; ++i) q[i] = 1.0f / (float)z[i]; ++ float qmax = q[0]; ++ for (int i = 1; i < count; ++i) ++ if (q[i] > qmax) qmax = q[i]; ++ if (qmax <= 0.0f) return 0; ++ for (int i = 0; i < count; ++i) q[i] /= qmax; ++ return 1; ++} ++ ++static void queue_texture_triangle(int exact, const float *q, ++ int a, int b, int c) { ++ if (!exact) { ++ gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); + return; + } +- for (int i = 0; i < 3; i++) { +- int32_t raw_x, raw_y; +- parse_vertex(words[i], &raw_x, &raw_y); +- if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { +- sw_set_precise_triangle(0, 0,0, 0,0, 0,0); +- return; +- } +- fx[i] = (int32_t)((int64_t)fx[i] + +- (int64_t)(vx[i] - raw_x) * 65536); +- fy[i] = (int32_t)((int64_t)fy[i] + +- (int64_t)(vy[i] - raw_y) * 65536); +- } +- sw_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); +-} +- +-/* Enable perspective UVs only when every position word came from an exact +- * SWC2 projection store at that same DMA packet address. This preserves the +- * association through ordering-table reordering and rejects CPU-built UI. */ +-static void prepare_texture_triangle(int i0, int i1, int i2) { +- sw_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); +- if (!s_texture_correction_enabled || gp0_cmd_source_addr == 0xFFFFFFFFu) +- return; +- int indices[3] = { i0, i1, i2 }; +- uint16_t z[3]; +- for (int i = 0; i < 3; i++) { +- uint32_t addr = (gp0_cmd_source_addr + (uint32_t)indices[i] * 4u) & 0x1FFFFCu; +- if (!gte_precision_load_word(addr, gp0_cmd_buf[indices[i]], NULL, NULL, &z[i]) || +- z[i] == 0) +- return; +- } +- float q[3] = { 1.0f / (float)z[0], 1.0f / (float)z[1], 1.0f / (float)z[2] }; +- float qmax = q[0]; +- if (q[1] > qmax) qmax = q[1]; +- if (q[2] > qmax) qmax = q[2]; +- if (qmax <= 0.0f) return; +- sw_set_perspective_triangle(1, q[0] / qmax, q[1] / qmax, q[2] / qmax); ++ gr_set_perspective_triangle(1, q[a], q[b], q[c]); ++ if (s_texture_correction_triangles != UINT32_MAX) ++ ++s_texture_correction_triangles; ++} ++ ++/* Triangle wrapper; quads preflight all four vertices once and queue their two ++ * reciprocal-depth orderings explicitly below. */ ++static void prepare_texture_triangle(int exact, int i0, int i1, int i2) { ++ const int indices[3] = { i0, i1, i2 }; ++ float q[3]; ++ const int perspective = resolve_texture_vertices(exact, indices, 3, q); ++ queue_texture_triangle(perspective, q, 0, 1, 2); + } + + /* Write a single pixel to VRAM with draw area clipping and mask bit handling */ +@@ -3009,8 +4105,7 @@ + } + if (draw_area_out_bbox(vx, vy, 3)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], +- vx, vy); ++ prepare_precise_triangle(1, 2, 3, vx, vy); + gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); + } + +@@ -3046,6 +4141,7 @@ + gr_draw_flat_rect(x, y, w, h, color); + return; + } ++ ws_hud_widget_quad(vx, vy); + ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (no-op else) */ + ws_nw_hud_shift_vertices(vx, 4); + for (int i = 0; i < 4; i++) { +@@ -3054,18 +4150,17 @@ + } + if (draw_area_out_bbox(vx, vy, 4)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); ++ const int precise_indices[4] = { 1, 2, 3, 4 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], +- gp0_cmd_buf[3], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); + gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[3], gp0_cmd_buf[2], +- gp0_cmd_buf[4], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); + gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); + } + } +@@ -3088,8 +4183,7 @@ + } + if (draw_area_out_bbox(vx, vy, 3)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], +- vx, vy); ++ prepare_precise_triangle(1, 3, 5, vx, vy); + gr_draw_gouraud_triangle(vx[0], vy[0], c[0], + vx[1], vy[1], c[1], + vx[2], vy[2], c[2]); +@@ -3131,20 +4225,19 @@ + } + } + gr_set_semi_transparency(semi_trans, (int)semi_transparency); ++ const int precise_indices[4] = { 1, 3, 5, 7 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], +- gp0_cmd_buf[5], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); + gr_draw_gouraud_triangle(vx[0], vy[0], c[0], + vx[1], vy[1], c[1], + vx[2], vy[2], c[2]); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], +- gp0_cmd_buf[7], tx, ty); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); + gr_draw_gouraud_triangle(vx[2], vy[2], c[2], + vx[1], vy[1], c[1], + vx[3], vy[3], c[3]); +@@ -3215,9 +4308,8 @@ + if (draw_area_out_bbox(vx, vy, 3)) return; + + setup_textured_draw(color24, semi_trans, raw_texture); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], +- vx, vy); +- prepare_texture_triangle(1, 3, 5); ++ const int precise = prepare_precise_triangle(1, 3, 5, vx, vy); ++ prepare_texture_triangle(precise, 1, 3, 5); + gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], + vx[1], vy[1], u[1], v[1], + vx[2], vy[2], u[2], v[2], +@@ -3252,11 +4344,16 @@ + + /* Widescreen: tagged billboard quads carry CPU-computed pixel offsets the + * GTE squash never saw — re-squash every X around the prim's anchor. */ ++ const int raw_w = abs(vx[1] - vx[0]) + 1, raw_h = abs(vy[2] - vy[0]) + 1; ++ int squashed = 0; + { + int32_t ws_ax; +- if (ws_tagged_anchor(&ws_ax)) ++ if (ws_tagged_anchor(&ws_ax)) { ++ squashed = 1; + for (int i = 0; i < 4; i++) vx[i] = ws_scale_about(vx[i], ws_ax); +- } ++ } ++ } ++ ws_hud_widget_quad(vx, vy); + ws_auto_ui_transform_quad(vx, vy); + ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop image stretch (no-op else) */ + ws_nw_hud_shift_vertices(vx, 4); +@@ -3282,6 +4379,13 @@ + int top_v = vy[0] < vy[2] ? v[0] : v[2]; + int bot_v = vy[0] < vy[2] ? v[2] : v[0]; + if (w > 0 && h > 0) { ++ geometry_diag_note_rectangle_fast_path(); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ float sides[4]; ++ const float sprite_depth = temporal_sprite_sides( ++ x - draw_offset_x, y - draw_offset_y, w, h, raw_w, raw_h, squashed, sides); ++ gr_set_temporal_sprite(sprite_depth, sides); ++#endif + if (right_u - left_u == w && bot_v - top_v == h) { + gr_draw_textured_rect(x, y, w, h, left_u, top_v, + clut_x, clut_y, tpage); +@@ -3294,23 +4398,25 @@ + } + } + ++ const int precise_indices[4] = { 1, 3, 5, 7 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); ++ float texture_q[4]; ++ const int perspective_quad = resolve_texture_vertices( ++ precise_quad, precise_indices, 4, texture_q); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], +- gp0_cmd_buf[5], tx, ty); +- prepare_texture_triangle(1, 3, 5); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); ++ queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); + gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], + vx[1], vy[1], u[1], v[1], + vx[2], vy[2], u[2], v[2], + clut_x, clut_y, tpage); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], +- gp0_cmd_buf[7], tx, ty); +- prepare_texture_triangle(5, 3, 7); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); ++ queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); + gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], + vx[1], vy[1], u[1], v[1], + vx[3], vy[3], u[3], v[3], +@@ -3351,9 +4457,8 @@ + if (draw_area_out_bbox(vx, vy, 3)) return; + + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], +- vx, vy); +- prepare_texture_triangle(1, 4, 7); ++ const int precise = prepare_precise_triangle(1, 4, 7, vx, vy); ++ prepare_texture_triangle(precise, 1, 4, 7); + gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], + vx[1], vy[1], u[1], v[1], c[1], + vx[2], vy[2], u[2], v[2], c[2], +@@ -3399,23 +4504,25 @@ + if (draw_area_out_bbox(vx, vy, 4)) return; + + gr_set_semi_transparency(semi_trans, (int)semi_transparency); ++ const int precise_indices[4] = { 1, 4, 7, 10 }; ++ int32_t precise_x[4], precise_y[4]; ++ const int precise_quad = prepare_precise_quad_vertices( ++ precise_indices, (!rej_a) + (!rej_b), ++ vx, vy, precise_x, precise_y); ++ float texture_q[4]; ++ const int perspective_quad = resolve_texture_vertices( ++ precise_quad, precise_indices, 4, texture_q); + if (!rej_a) { +- int32_t tx[3] = { vx[0], vx[1], vx[2] }; +- int32_t ty[3] = { vy[0], vy[1], vy[2] }; +- prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], +- gp0_cmd_buf[7], tx, ty); +- prepare_texture_triangle(1, 4, 7); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); ++ queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); + gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], + vx[1], vy[1], u[1], v[1], c[1], + vx[2], vy[2], u[2], v[2], c[2], + clut_x, clut_y, tpage, raw_texture); + } + if (!rej_b) { +- int32_t tx[3] = { vx[2], vx[1], vx[3] }; +- int32_t ty[3] = { vy[2], vy[1], vy[3] }; +- prepare_precise_triangle(gp0_cmd_buf[7], gp0_cmd_buf[4], +- gp0_cmd_buf[10], tx, ty); +- prepare_texture_triangle(7, 4, 10); ++ queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); ++ queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); + gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], + vx[1], vy[1], u[1], v[1], c[1], + vx[3], vy[3], u[3], v[3], c[3], +@@ -3477,6 +4584,7 @@ + if (w > 1023) w = 1023; + if (h > 511) h = 511; + ws_expand_fullscreen_rect(&x0, y0, &w, h); ++ w = ws_menu_rect(&x0, w); + x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ + x0 += draw_offset_x; y0 += draw_offset_y; + if (draw_area_out_rect(x0, y0, w, h)) return; +@@ -3504,35 +4612,76 @@ + /* Widescreen: tagged sprite parts squash around their projected anchor; + * untagged SPRTs are screen-space 2D (HUD/menus) and squash around the + * display centre. Texels keep full coverage via the scaled-rect path. */ +- int ws_w = 0; +- if (ws_active() && w > 0) { ++ int ws_w = 0, ws_h = 0; ++ const int menu_page = texpage_y != 0 || texpage_colors != 1 || clut_x > 1024 - 256 ? 0 : texpage_x == PSX_WS_MENU_X / 64 ? 1 : texpage_x == (PSX_WS_MENU_X + 128) / 64 ? 2 : 0; /* a palette that runs off its row is not the picture's */ ++ const int menu_shape = psx_ws_menu_backdrop_half(x0, y0, w, h) & menu_page; ++ if (ws_menu_due) { /* the front end's frame begins with its backdrop */ ++ ws_menu_due = 0; ++ if (!menu_shape) ws_menu_give_back(); ++ } ++ if (menu_shape) { ++ ws_menu_clut_x = clut_x; ++ ws_menu_clut_y = clut_y; ++ if (!ws_menu_logo) ws_menu_take_picture(); /* a save state or a read took it away, or the screen has become wide */ ++ } ++ const int menu_half = ws_menu_logo ? menu_shape : 0; ++ if (menu_half && ws_menu_wanted()) psx_ws_frame_kinds_menu_begin(&ws_frame_kinds); ++ if (ws_menu_frame()) { ++ if (!menu_half) ws_w = ws_menu_rect(&x0, w); /* the backdrop itself is stretched */ ++ } else if (ws_active() && w > 0) { + int32_t ws_ax; +- if (ws_tagged_anchor(&ws_ax)) { ++ if (ws_screen_tile_take()) { ++ /* Both edges go through one mapping, so neighbours keep touching. */ ++ const int32_t centre = ws_disp_w() / 2; ++ const int32_t right = ++ psx_ws_squash_about(x0 + w, centre, ws_xnum, ws_xden); ++ x0 = psx_ws_squash_about(x0, centre, ws_xnum, ws_xden); ++ if (right <= x0) return; ++ ws_w = (int)(right - x0); ++ } else if (ws_tagged_anchor(&ws_ax)) { + x0 = ws_scale_about(x0, ws_ax); + ws_w = (int)ws_scale_len(w); + } else { + int corrected_w = w; +- if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) ++ int32_t widget_w = w, widget_h = h; ++ if (ws_hud_widget_rect(&x0, &y0, &widget_w, &widget_h)) { ++ ws_w = (int)widget_w; ++ ws_h = (int)widget_h; ++ } else if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) + ws_w = corrected_w; + else if (ws_hud_sprt) { +- x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); +- ws_w = (int)ws_scale_len(w); ++ int scaled_w = w, scaled_h = h; ++ if (ws_hud_user_scale(&x0, &y0, &scaled_w, &scaled_h)) { ++ ws_w = scaled_w; ++ ws_h = scaled_h; ++ } else { ++ x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); ++ ws_w = (int)ws_scale_len(w); ++ } + } + } + } + x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ + + x0 += draw_offset_x; y0 += draw_offset_y; +- { +- int dw = (ws_w && ws_w != w) ? ws_w : w; +- if (draw_area_out_rect(x0, y0, dw, h)) return; +- } ++ const int dw = ws_w ? ws_w : w, dh = ws_h ? ws_h : h; ++ if (draw_area_out_rect(x0, y0, dw, dh)) return; + setup_textured_draw(color24, semi_trans, raw_texture); +- if (ws_w && ws_w != w) +- gr_draw_textured_rect_scaled(x0, y0, ws_w, h, u0, v0, u0 + w, v0 + h, ++ if (dw != w || dh != h) ++ gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + w, v0 + h, + clut_x, clut_y, current_texpage()); + else + gr_draw_textured_rect(x0, y0, w, h, u0, v0, clut_x, clut_y, current_texpage()); ++ if (!menu_half) return; ++ if (ws_menu_halves & menu_half) ws_menu_halves = 0; ++ ws_menu_halves |= menu_half; ++ if (ws_menu_halves != 3) return; ++ ws_menu_halves = 0; ++ int32_t logo_left = PSX_WS_MENU_LOGO_LEFT, logo_right = PSX_WS_MENU_LOGO_RIGHT; /* where it was when the screen is not wide after all */ ++ if (ws_menu_frame()) psx_ws_menu_logo_span(PSX_WS_MENU_WIDE / 2, ws_xnum, ws_xden, &logo_left, &logo_right); ++ gr_draw_textured_rect_scaled(logo_left + draw_offset_x, PSX_WS_MENU_LOGO_TOP + draw_offset_y, logo_right - logo_left, PSX_WS_MENU_LOGO_ROWS, ++ 0, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ++ clut_x, clut_y, PSX_WS_MENU_KEPT_PAGE); + } + + /* Execute 1x1 dot (GP0 0x68-0x6B) */ +@@ -3541,6 +4690,7 @@ + uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); + int32_t x, y; + parse_vertex(gp0_cmd_buf[1], &x, &y); ++ (void)ws_menu_rect(&x, 1); + x += ws_nw_hud_shift(x, 1); + x += draw_offset_x; y += draw_offset_y; + if (draw_area_out_point(x, y)) return; +@@ -3555,13 +4705,12 @@ + int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); +- int ws_w = ws_sprt_fixed_transform(&x0, y0, 8); ++ int ws_h = 0; ++ int ws_w = ws_sprt_fixed_transform(&x0, &y0, 8, &ws_h); + x0 += ws_nw_hud_shift(x0, 8); /* native-wide HUD corner re-anchor (no-op else) */ + x0 += draw_offset_x; y0 += draw_offset_y; +- { +- int dw = (ws_w && ws_w != 8) ? ws_w : 8; +- if (draw_area_out_rect(x0, y0, dw, 8)) return; +- } ++ const int dw = ws_w ? ws_w : 8, dh = ws_h ? ws_h : 8; ++ if (draw_area_out_rect(x0, y0, dw, dh)) return; + int u0 = gp0_cmd_buf[2] & 0xFF; + int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; + uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); +@@ -3569,8 +4718,8 @@ + uint16_t clut_y = (clut >> 6) & 0x1FF; + + setup_textured_draw(color24, semi_trans, raw_texture); +- if (ws_w && ws_w != 8) +- gr_draw_textured_rect_scaled(x0, y0, ws_w, 8, u0, v0, u0 + 8, v0 + 8, ++ if (dw != 8 || dh != 8) ++ gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 8, v0 + 8, + clut_x, clut_y, current_texpage()); + else + gr_draw_textured_rect(x0, y0, 8, 8, u0, v0, clut_x, clut_y, current_texpage()); +@@ -3582,11 +4731,12 @@ + uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); ++ const int w = ws_menu_rect(&x0, 8); + x0 += ws_nw_hud_shift(x0, 8); + x0 += draw_offset_x; y0 += draw_offset_y; +- if (draw_area_out_rect(x0, y0, 8, 8)) return; ++ if (draw_area_out_rect(x0, y0, w, 8)) return; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- gr_draw_flat_rect(x0, y0, 8, 8, color); ++ gr_draw_flat_rect(x0, y0, w, 8, color); + } + + /* Execute 16x16 textured sprite (GP0 0x7C-0x7F) */ +@@ -3599,7 +4749,8 @@ + /* Never suppress MMX6 BG packets at a guessed finite-map boundary. The + * classifier cannot distinguish an authored layer entering the reveal from + * a stale ring slot; suppressing here caused the stage-start black flicker. */ +- int ws_w = ws_sprt_fixed_transform(&x0, y0, 16); ++ int ws_h = 0; ++ int ws_w = ws_sprt_fixed_transform(&x0, &y0, 16, &ws_h); + x0 += ws_nw_hud_shift(x0, 16); /* native-wide HUD corner re-anchor (no-op else) */ + x0 += draw_offset_x; y0 += draw_offset_y; + int u0 = gp0_cmd_buf[2] & 0xFF; +@@ -3608,13 +4759,11 @@ + uint16_t clut_x = (clut & 0x3F) * 16; + uint16_t clut_y = (clut >> 6) & 0x1FF; + +- { +- int dw = (ws_w && ws_w != 16) ? ws_w : 16; +- if (draw_area_out_rect(x0, y0, dw, 16)) return; +- } ++ const int dw = ws_w ? ws_w : 16, dh = ws_h ? ws_h : 16; ++ if (draw_area_out_rect(x0, y0, dw, dh)) return; + setup_textured_draw(color24, semi_trans, raw_texture); +- if (ws_w && ws_w != 16) +- gr_draw_textured_rect_scaled(x0, y0, ws_w, 16, u0, v0, u0 + 16, v0 + 16, ++ if (dw != 16 || dh != 16) ++ gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 16, v0 + 16, + clut_x, clut_y, current_texpage()); + else + gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage()); +@@ -3642,12 +4791,15 @@ + /* Fill ignores draw area, mask bits, and draw offset — writes directly to + * VRAM. Routed through the renderer so it also fills the hi-res + * supersampling mirror (no-op cost when supersampling is off). */ ++ ws_menu_yield((int)dst_x, (int)dst_y, (int)width, (int)height, 1); + gr_fill_rect((int)dst_x, (int)dst_y, (int)width, (int)height, color16); + +- /* Native-wide: when the game clears a display buffer, clear the full width +- * of that buffer's wide surface over the same rows — refreshing the centred +- * content region and keeping the revealed margins clean. */ +- if (ws_native_wide_active() && ws_is_fb_base(dst_x)) { ++ /* Keep every active presentation mirror in lockstep with the canonical ++ * framebuffer clear. The exact-geometry mirror is also used at authentic ++ * 4:3, where this file's ws_engaged() is deliberately false; gating it on ++ * wide mode left prior frames resident and produced persistent trails. */ ++ if ((ws_native_wide_active() || ws_geometry_correction) && ++ ws_is_fb_base(dst_x)) { + gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); + } + } +@@ -3682,6 +4834,11 @@ + gr_set_texture_window(texture_window_value); + } + ++/* The game may draw anywhere in its drawing area. */ ++static void ws_menu_yield_drawing_area(void) { ++ ws_menu_yield((int)draw_area_left, (int)draw_area_top, (int)draw_area_right - (int)draw_area_left + 1, (int)draw_area_bottom - (int)draw_area_top + 1, 1); ++} ++ + static void gp0_exec_draw_area_tl(void) { + /* 0xE3 — Set Drawing Area Top-Left + * Bits 0-9: X +@@ -3691,7 +4848,12 @@ + draw_area_top = (param >> 10) & 0x3FF; + gr_set_draw_area((int)draw_area_left, (int)draw_area_top, + (int)draw_area_right, (int)draw_area_bottom); ++ psx_ws_hud_widgets_frame(&ws_hud); + ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ ++ ws_menu_due = 1; ++ ws_menu_halves = 0; ++ ws_menu_yield_drawing_area(); ++ psx_ws_frame_kinds_area(&ws_frame_kinds, draw_area_left, draw_area_top, WS_GTE_GAME_MODE_MIN_VERTS); + } + + static void gp0_exec_draw_area_br(void) { +@@ -3703,6 +4865,7 @@ + draw_area_bottom = (param >> 10) & 0x3FF; + gr_set_draw_area((int)draw_area_left, (int)draw_area_top, + (int)draw_area_right, (int)draw_area_bottom); ++ ws_menu_yield_drawing_area(); + ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ + } + +@@ -3799,7 +4962,10 @@ + /* 0 means max dimension */ + vram_write_w = (w == 0) ? 0x400 : (uint16_t)w; + vram_write_h = (h == 0) ? 0x200 : (uint16_t)h; +- ++ if (!psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) /* the whole picture is dealt with once it has arrived */ ++ ws_menu_yield(vram_write_x, vram_write_y, vram_write_w, vram_write_h, 1); /* before its words land */ ++ ++ ws_frame_pictured(vram_write_x, vram_write_y, vram_write_w, vram_write_h); + /* Record for debug */ + if (a0_history_count < A0_HISTORY_CAP) { + int slot = a0_history_count++; +@@ -3879,6 +5045,7 @@ + uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; + vram_read_w = (w == 0) ? 0x400 : (uint16_t)w; + vram_read_h = (h == 0) ? 0x200 : (uint16_t)h; ++ ws_menu_yield(vram_read_x, vram_read_y, vram_read_w, vram_read_h, 0); + + /* Record for debug */ + if (c0_history_count < C0_HISTORY_CAP) { +@@ -4047,7 +5214,7 @@ + item->group.x = min_x; + item->group.width = width; + item->group.anchor = 0; +- item->src_addr = source_addr & 0x1FFFFCu; ++ item->src_addr = psx_mod_gpu_dma_resolve_address(source_addr); + item->ot_rank = rank; + } + +@@ -4397,6 +5564,10 @@ + extern void ws_bg_phase_note(uint32_t op); + ws_bg_phase_note(opcode); /* native-wide 2D-backdrop stretch: background-phase latch */ + ++ if (ws_menu_due && opcode >= 0x20 && opcode <= 0x7F && (opcode & 0xFC) != 0x64) { /* a frame that begins with anything but a textured rectangle is not the front end's */ ++ ws_menu_due = 0; ++ ws_menu_give_back(); ++ } + /* Draw-census: capture every drawing primitive's first vertex + camera. */ + if (opcode >= 0x20 && opcode <= 0x7F) { + int32_t cvx, cvy; +@@ -4499,7 +5670,8 @@ + int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); +- (void)ws_sprt_fixed_transform(&x0, y0, 1); /* position only; 1px stays 1px */ ++ int dot_h; ++ (void)ws_sprt_fixed_transform(&x0, &y0, 1, &dot_h); /* position only; 1px stays 1px */ + x0 += draw_offset_x; y0 += draw_offset_y; + int u0 = gp0_cmd_buf[2] & 0xFF; + int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; +@@ -4522,9 +5694,10 @@ + uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); + int32_t x0, y0; + parse_vertex(gp0_cmd_buf[1], &x0, &y0); ++ const int w = ws_menu_rect(&x0, 16); + x0 += draw_offset_x; y0 += draw_offset_y; + gr_set_semi_transparency(semi_trans, (int)semi_transparency); +- gr_draw_flat_rect(x0, y0, 16, 16, color); ++ gr_draw_flat_rect(x0, y0, w, 16, color); + break; + } + case 0x7C: case 0x7D: case 0x7E: case 0x7F: +@@ -4549,7 +5722,10 @@ + int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; + if (w == 0) w = 0x400; + if (h == 0) h = 0x200; ++ ws_menu_yield(src_x, src_y, w, h, 0); ++ ws_menu_yield(dst_x, dst_y, w, h, 1); + gr_copy_rect(src_x, src_y, dst_x, dst_y, w, h); ++ ws_frame_pictured(dst_x, dst_y, w, h); + break; + } + +@@ -4901,7 +6077,11 @@ + hres1 = val & 3; + vres = (val >> 2) & 1; + video_mode = (val >> 3) & 1; +- display_depth = (val >> 4) & 1; ++ const uint32_t depth = (val >> 4) & 1; ++ /* A 24-bit scanout reads the CPU copy of VRAM, and a GPU backend leaves that copy without what it drew. Disruptor ++ * blacks out its movie's area with a rectangle a few VBlanks before it switches, so sync once, at the switch. */ ++ if (depth && !display_depth) (void)gr_vram_read(0, 0); ++ display_depth = depth; + vertical_interlace = (val >> 5) & 1; + /* GPUSTAT.13 holds the legacy constant 0 in progressive (see the vblank + * field flip); clear it on the switch so a title that toggles interlace +@@ -5087,11 +6267,16 @@ + } + void gpu_snapshot_write(uint8_t *p) { + PstW w; uint32_t n = gpu_snapshot_bytes(); ++ ws_menu_give_back(); /* a save state reads video memory from the renderer after this, and holds the game's alone */ + pst_w_init(&w, p, n); + (void)gpu_snap_emit(&w); + } + uint32_t gpu_cosim_snapshot_bytes(void) { return gpu_snapshot_bytes(); } +-void gpu_cosim_snapshot_write(uint8_t *p) { gpu_snapshot_write(p); } ++void gpu_cosim_snapshot_write(uint8_t *p) { /* a hash of the registers between blocks, not a save state: nothing is given back for it */ ++ PstW w; ++ pst_w_init(&w, p, gpu_snapshot_bytes()); ++ (void)gpu_snap_emit(&w); ++} + void gpu_cosim_dump(char *out, int cap) { + if (!out || cap <= 0) return; + char *p = out; +@@ -5136,10 +6321,14 @@ + if (len != gpu_snapshot_bytes()) return 0; + pst_r_init(&r, p, len); + if (!gpu_snap_parse(&r)) return 0; ++ ws_menu_lent = ws_menu_rows = ws_menu_logo = ws_menu_fresh = 0; /* the loaded video memory holds nothing of the wide menu's, and the CPU copy is not it */ + /* Sync renderer clip/scissor to restored GP0(E3/E4); vars alone leave GL +- * on a stale draw area after savestate load. */ ++ * on stale drawing state after savestate load. The offset also selects the ++ * correct framebuffer-relative horizon for optional full-Y yaw. */ + gr_set_draw_area((int)draw_area_left, (int)draw_area_top, + (int)draw_area_right, (int)draw_area_bottom); ++ gr_set_draw_offset(draw_offset_x, draw_offset_y); ++ geometry_camera_presentation_update(); + ws_nw_sync_target(); + return 1; + } diff --git a/framework/patches/runtime/src/gpu_gl_renderer.c.patch b/framework/patches/runtime/src/gpu_gl_renderer.c.patch new file mode 100644 index 0000000..df656f7 --- /dev/null +++ b/framework/patches/runtime/src/gpu_gl_renderer.c.patch @@ -0,0 +1,2640 @@ +diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c +index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4fbc378aee 100644 +--- a/runtime/src/gpu_gl_renderer.c ++++ b/runtime/src/gpu_gl_renderer.c +@@ -67,6 +67,9 @@ + #include "gpu_render.h" + #include "gpu_sw_renderer.h" + #include "gpu_gl_renderer.h" ++#include "gpu_turned_perspective.h" ++#include "gpu_world_depth.h" ++#include "host_ui.h" + #include "latency_ring.h" + + #include "psx_sdl.h" +@@ -76,6 +79,7 @@ + #include + #endif + #include ++#include + #include + #include + #include +@@ -121,7 +125,7 @@ + + #define VRAM_W 1024 + #define VRAM_H 512 +-#define GL_MAX_INTERNAL_SCALE 4 ++#define GL_MAX_INTERNAL_SCALE SW_MAX_INTERNAL_SCALE + + /* ---- Loaded modern-GL entry points ------------------------------------- */ + typedef GLuint (APIENTRY *PFN_glCreateShader)(GLenum); +@@ -245,6 +249,7 @@ + static int s_ws_ablate = 0; + static void flush_tex_batch(void); /* textured-prim batch — defined below, flushed from coherency points */ + static void flush_flat_batch(void); /* flat/gouraud GEO batch (MotK starfield 0x68 dots) */ ++static int resize_gpu_raster(int scale); /* live internal-resolution rebuild */ + static PFN_glGenRenderbuffers p_glGenRenderbuffers; + static PFN_glDeleteRenderbuffers p_glDeleteRenderbuffers; + static PFN_glBindRenderbuffer p_glBindRenderbuffer; +@@ -273,9 +278,11 @@ + LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); + LOAD(p_glEnableVertexAttribArray, "glEnableVertexAttribArray"); + LOAD(p_glBindFragDataLocationIndexed, "glBindFragDataLocationIndexed"); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + LOAD(p_glFenceSync, "glFenceSync"); + LOAD(p_glWaitSync, "glWaitSync"); + LOAD(p_glDeleteSync, "glDeleteSync"); ++#endif + LOAD(p_glGenFramebuffers, "glGenFramebuffers"); LOAD(p_glBindFramebuffer, "glBindFramebuffer"); + LOAD(p_glDeleteFramebuffers, "glDeleteFramebuffers"); + LOAD(p_glFramebufferTexture2D, "glFramebufferTexture2D"); +@@ -312,6 +319,18 @@ + static int s_present_w = 0, s_present_h = 0; + static GLuint s_present_prog = 0, s_present_vao = 0; + static GLint s_present_uTex = -1, s_present_uUvRect = -1; ++/* Experimental ambient occlusion: the display mirror keeps the depth the painter's order leaves, the present pass shades by it. */ ++static int s_ssao = 0; ++/* What s_ssao is made of: the player's switch, and a layer shown in the picture's place (1 shading, 2 depth, 3 normals, 4 fade). */ ++static int s_ssao_on = 0, s_ssao_view = 0; ++static float s_ssao_radius = 160.0f, s_ssao_strength = 0.8f; ++static float s_ssao_tilt = 0.35f; /* the sine of the angle under which a joint between two faces is no corner: 20 degrees */ ++static GLuint s_ssao_prog = 0, s_ssao_mix_prog = 0; ++static GLint s_ssao_uDepth = -1, s_ssao_uTex = -1, s_ssao_uUvRect = -1, s_ssao_uTexel = -1, s_ssao_uProj = -1, s_ssao_uAo = -1, ++ s_ssao_uFade = -1; ++static float s_ssao_fade_from = 0.0f, s_ssao_fade_to = 0.0f; ++static GLint s_ssao_mix_uTex = -1, s_ssao_mix_uShade = -1, s_ssao_mix_uUvRect = -1, s_ssao_mix_uSpan = -1; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static GLuint s_interp_prog = 0, s_interp_tex[3]; + static GLsync s_interp_fence[3]; + static GLsync s_interp_draw_fence = NULL; +@@ -320,9 +339,11 @@ + static GLint s_interp_uBlendMode = -1; + static int s_interp_enabled = 0, s_interp_valid = 0; + static int s_interp_suspended = 0; ++static int s_interp_content_suspended = 0; ++static int s_interp_host_ui_suspended = 0; + static int s_interp_blend_mode = 0; + static int s_interp_prev = 0, s_interp_cur = 0; +-static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0; ++static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0, s_interp_scale = 1; + static int s_interp_force_4_3 = 0, s_interp_source_path = -1; + static uint64_t s_interp_start = 0, s_interp_duration = 1; + static uint64_t s_interp_last_capture = 0, s_interp_swaps = 0; +@@ -335,12 +356,62 @@ + static SDL_mutex *s_interp_mutex = NULL; + static SDL_atomic_t s_interp_thread_run; + static GLuint s_interp_thread_vao = 0; ++static void interp_reset_history_unlocked(void); + static void interp_reset_history(void); + static int interp_thread_main(void *opaque); + static int interp_present(void); +-static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); ++static void shown_capture(int kind, float alpha); ++int gl_renderer_record_shown(const char *folder, int skip, int count, int width); ++static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); ++static uint64_t s_temporal_raw_generation = 1; ++static void temporal_reset(void); ++static void temporal_release(void); ++static void temporal_begin(GLuint fbo, int y, int h, uint16_t color); ++static void temporal_invalidate_surface(GLuint fbo); ++static void temporal_draw(const float *vertices, const uint64_t *identities, int n, ++ int textured, GLenum mode, int semi, int mask, int filter, ++ const int *twin, int gate, int direct); ++#define temporal_note_raw_change() (++s_temporal_raw_generation) ++#else ++#define s_interp_enabled 0 ++#define interp_reset_history() ((void)0) ++#define temporal_note_raw_change() ((void)0) ++#define temporal_begin(...) ((void)0) ++#define temporal_invalidate_surface(...) ((void)0) ++#define temporal_draw(...) ((void)0) ++#define interp_capture(...) 0 ++#endif + + static int s_raster_ok = 0; /* full GPU pipeline available */ ++ ++/* Presentation-only geometry correction state. Precise vertices are queued by ++ * gpu.c immediately before a polygon draw. Every GL vertex carries the exact ++ * integer GP0 position separately from its optional precise position; the ++ * canonical pass always consumes the former, while only the independent ++ * display mirror may consume the latter and the fractional camera yaw. */ ++static int s_geometry_correction = 0; ++static int s_next_world = 0; ++static int s_next_precise = 0; ++static int32_t s_next_x16[3], s_next_y16[3]; ++static int s_next_unpinned = 0; ++static int32_t s_next_unpinned_x16[3], s_next_unpinned_y16[3]; ++static int s_next_perspective = 0; ++static float s_next_q[3] = { 0.0f, 0.0f, 0.0f }; ++static int s_next_temporal_depth = 0; ++static float s_next_z[3] = { 0.0f, 0.0f, 0.0f }; ++static float s_next_surface_z[3] = { 0.0f, 0.0f, 0.0f }; /* see gr_set_surface_depth_triangle */ ++static uint64_t s_next_identity[3] = { 0u, 0u, 0u }; ++#define TEMPORAL_SPRITE_IDENTITY UINT64_C(0x4000000000000000) ++#define TEMPORAL_PARTIAL_IDENTITY UINT64_C(0x2000000000000000) /* a world polygon with a corner off its projection */ ++static float s_next_sprite_depth = 0.0f, s_sprite_depth = 0.0f; ++static float s_next_sprite_sides[4], s_sprite_sides[4]; /* left, top, right, bottom */ ++static int s_sprite_x, s_sprite_y; ++static float s_yaw_sin = 0.0f, s_yaw_cos = 1.0f; ++static float s_yaw_center_x = 160.0f; ++static float s_yaw_center_y_relative = 120.0f; ++static float s_yaw_focal_x = 160.0f; ++static int s_geometry_full_yaw = 0; ++static int s_geometry_coverage_tint = 0; + + /* Authoritative VRAM: hr color texture + stencil (mask bit) FBO. */ + static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0; +@@ -354,9 +425,10 @@ + /* Programs. */ + static GLuint s_geo_prog = 0, s_geo_vao = 0, s_geo_vbo = 0; + static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; +-/* Textured vertex: pos(2) uv(2) col(4) tpage(2) clut(2) depth(1) raw(1) limits(4) +- * — per-prim texture state in flat attributes so prims batch (see flush_tex_batch). */ +-#define TEXV 19 ++/* Vertex formats retain both canonical GP0 and presentation-only positions. ++ * Per-primitive texture state stays in flat attributes so prims can batch. */ ++#define GEOV 10 ++#define TEXV 24 + static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; + static GLuint s_pack_prog = 0, s_stencil_prog = 0, s_empty_vao = 0; + +@@ -379,6 +451,12 @@ + * prim each frame). 1.0 / 0.0 => no-op, so the canonical pass stays identical. */ + static GLint s_geo_uXscale = -1, s_geo_uXcenter = -1; + static GLint s_tex_uXscale = -1, s_tex_uXcenter = -1; ++/* Presentation-mirror-only subpixel/yaw uniforms. The canonical pass keeps ++ * u_visual=0 and reads the explicit GP0-position attribute. World/exact ++ * classification is carried per vertex so sprites and HUD rectangles remain ++ * fixed and the optional exact-coverage tint cannot affect canonical output. */ ++static GLint s_geo_uVisual = -1, s_geo_uCamera = -1, s_geo_uVisualOptions = -1; ++static GLint s_tex_uVisual = -1, s_tex_uCamera = -1, s_tex_uVisualOptions = -1; + /* Runtime controls (ws_backdrop_stretch debug command). */ + int g_ws_bd_stretch_on = 1; /* feature on (gated by native-wide + per-prim gate) */ + int g_ws_bd_stretch_pct = 0; /* 0 = auto (g_wide_w/native_w); else pct/100 */ +@@ -475,6 +553,7 @@ + #define WIDE_MAX_SURF 4 + static GLuint s_wide_tex[WIDE_MAX_SURF]; /* color tex per surface (0 = free) */ + static GLuint s_wide_fbo[WIDE_MAX_SURF]; /* FBO per surface */ ++static uint32_t s_wide_depth_shown[WIDE_MAX_SURF] = {~0u, ~0u, ~0u, ~0u}; /* rows shown since their depth was last cleared */ + static GLuint s_wide_rb[WIDE_MAX_SURF]; /* depth-stencil RB per surface (mask + * mirror, same as hr). PERF-CRITICAL: + * a stencil-less wide FBO made every +@@ -495,6 +574,95 @@ + /* X-translation (native px) from canonical VRAM space into the active wide + * surface: local_x = vram_x - base_x + OFFSET. Same as SW wide_dx(). */ + static inline int wide_dx(void) { return g_wide_off - g_wide_cur_base; } ++ ++/* A filler closes gaps between polygons of a textured batch. It is drawn before its batch, colour only: the batch's opaque ++ * fragments replace it and its blended ones blend over it. Over earlier batches it lands only where their depth is farther: ++ * the game's grown second copy of a near polygon (visual flag 4) by its own depth, and the outline of every exact world ++ * polygon (s_crack_fill native pixels wide, 0 for none) only where no depth was left at all. */ ++static float s_crack_fill = 0.5f, s_crack_width = 0.5f; ++static float s_line_width_max = 1.0f; /* the widest line this context draws */ ++static int s_mesh_view = 0; /* diagnostic: in-between frames draw their triangles' edges over the picture */ ++static int s_grown_under = 1; ++static int s_tb_fillers = 0; /* grown copies in the open batch */ ++static int s_draw_filler = 0; /* the batch being recorded for the in-between presenter holds grown copies */ ++/* What the mirror draws of a batch: 0 its polygons, between 0 and 1 its grown copies pushed back by that share, 2 the outlines, ++ * -1 all of it as it comes. */ ++static float s_fill_pass = 0.0f; ++#define GROWN_COPY_MARGIN 0.03f ++ ++/* Experimental: exact world polygons are drawn by depth, not by the game's order alone. A batch of them first writes the ++ * depth of its opaque fragments, then draws its colours in the game's order where they lie within s_world_margin of the ++ * nearest depth: polygons in one plane, whose depths differ by rounding, keep the game's order, and a polygon behind ++ * another by more is not drawn over it. Measured between a coarse wall quad and the pieces the game draws over it: up to ++ * 0.1 percent. The in-between presenter gives a departed face the same clearance (TEMP_ORDER_CLEARANCE). */ ++static int s_world_depth = 0; ++static float s_world_margin = 0.03f; ++static int s_tb_world = 0; /* the open batch holds only such polygons */ ++static int s_draw_world = 0; /* the batch being recorded for the in-between presenter is such a batch */ ++static GLint s_tex_uPull = -1; ++ ++static int mirror_depth(void) { return s_geometry_correction && (s_ssao || s_grown_under || s_crack_fill > 0.0f || s_world_depth); } ++ ++static void geometry_visual_uniforms(GLint visual, GLint camera, GLint options, ++ int enabled) { ++ const float on = enabled && s_geometry_correction ? 1.0f : 0.0f; ++ p_glUniform1f(visual, on); ++ p_glUniform4f(camera, s_yaw_sin, s_yaw_cos, ++ (float)g_wide_cur_base + s_yaw_center_x, ++ (float)s_off_y + s_yaw_center_y_relative); ++ p_glUniform4f(options, s_yaw_focal_x, ++ s_geometry_full_yaw ? 1.0f : 0.0f, ++ on && s_geometry_coverage_tint ? 0.55f : 0.0f, on > 0.0f ? s_fill_pass : 0.0f); ++} ++ ++/* Capture the one-shot metadata queued by gpu.c for the next polygon. `xs/ys` ++ * remain the exact GP0 coordinates used for canonical dirty tracking and ++ * guest-visible rasterization. The returned floats are consumed only by the ++ * presentation mirror when the shader's u_visual switch is set. The returned ++ * flags are bit 0 = world, bit 1 = exact precise provenance and bit 2 = the game's grown copy, a filler. */ ++static int take_visual_triangle(const int *xs, const int *ys, ++ float px[3], float py[3], float pq[3], ++ float pz[3], uint64_t pid[3]) { ++ const int world = s_geometry_correction && s_next_world; ++ const int exact = world && s_next_precise; ++ const int perspective = exact && s_next_perspective; ++ const int partial = !exact && s_next_temporal_depth == 2; ++ const int unpinned = !exact && s_geometry_correction && s_next_unpinned; ++ const int sprite = !world && s_geometry_correction && s_sprite_depth > 0.0f; ++ for (int i = 0; i < 3; ++i) { ++ if (exact) { ++ px[i] = (float)((double)s_next_x16[i] / 65536.0); ++ py[i] = (float)((double)s_next_y16[i] / 65536.0); ++ } else if (unpinned) { ++ px[i] = (float)((double)s_next_unpinned_x16[i] / 65536.0); ++ py[i] = (float)((double)s_next_unpinned_y16[i] / 65536.0); ++ } else { ++ px[i] = (float)xs[i] + (sprite ? s_sprite_sides[xs[i] == s_sprite_x ? 0 : 2] : 0.0f); ++ py[i] = (float)ys[i] + (sprite ? s_sprite_sides[ys[i] == s_sprite_y ? 1 : 3] : 0.0f); ++ } ++ if (pq) pq[i] = perspective ? s_next_q[i] : 0.0f; ++ if (pz) pz[i] = (exact && s_next_temporal_depth) || partial ? s_next_z[i] : s_sprite_depth; ++ /* A corner the presenter has no depth for, the mirror has: negative, so that only the mirror's shaders take it. */ ++ if (pz && (exact || partial) && !(pz[i] > 0.0f) && s_next_surface_z[i] > 0.0f) pz[i] = -s_next_surface_z[i]; ++ s_next_surface_z[i] = 0.0f; ++ if (pid) pid[i] = exact && s_next_temporal_depth ? s_next_identity[i] ++ : partial ? TEMPORAL_PARTIAL_IDENTITY ++ : s_sprite_depth > 0.0f ? TEMPORAL_SPRITE_IDENTITY : 0u; ++ s_next_identity[i] = 0u; ++ } ++ const int filler = s_grown_under && exact && s_next_world == 2; ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ return (world || sprite || unpinned ? 1 : 0) | (exact ? 2 : 0) | (filler ? 4 : 0); ++} ++ ++static void turned_perspective(const float px[3], float pq[3], int visual_flags) { ++ if (visual_flags & 2) ++ psx_turned_perspective(px, pq, s_yaw_sin, s_yaw_cos, (float)g_wide_cur_base + s_yaw_center_x, s_yaw_focal_x); ++} + + /* ---- dirty-rect helpers ------------------------------------------------- */ + static void rect_clear(DirtyRect *r) { r->set = 0; } +@@ -742,6 +910,94 @@ + "#version 330\n" + "in vec2 v_uv; uniform sampler2D u_tex; out vec4 frag;\n" + "void main(){ frag = texture(u_tex, v_uv); }\n"; ++/* Scalable ambient obscurance over the mirror's depth, one value a screen pixel. A pixel without camera depth (HUD, backdrop, 2D) ++ * is neither shaded nor an occluder. Red is the light left, green a coarse code of the depth for the blur, 0 for none. */ ++static const char *SSAO_FS = ++ "#version 330\n" ++ "in vec2 v_uv; out vec4 frag;\n" ++ "uniform sampler2D u_depth, u_tex;\n" ++ "uniform vec4 u_span; /* one texel of the surface in x and y, the view asked for */\n" ++ "#define u_texel u_span.xy\n" ++ "uniform vec4 u_proj; /* focal x, focal y, centre x, centre y, in texels */\n" ++ "uniform vec4 u_ao; /* radius in depth units, strength, widest reach in texels, one native pixel in texels */\n" ++ "uniform vec4 u_fade; /* the depths between which the game fades the world into its far colour (0 0 for none), s_ssao_tilt */\n" ++ "float cam(vec2 uv){ float z = texture(u_depth, uv).r; return (z < 0.00002 || z > 0.99998) ? 0.0 : 16.0/(1.0-z); }\n" ++ "/* The depth of a surface to shade by: none where a blended fragment or one without a whole depth is marked. */\n" ++ "float surf(vec2 uv){\n" ++ " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" ++ " return (mark > 0.2 && mark < 0.3) ? 0.0 : cam(uv); }\n" ++ "/* The same for a pixel's normal: a sprite beside it, or a sliver of a polygon drawn on the game's own pixels, is not its surface. */\n" ++ "float flat_surf(vec2 uv){\n" ++ " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" ++ " return (mark > 0.2 && mark < 0.6) ? 0.0 : cam(uv); }\n" ++ "vec3 at(vec2 uv, float d){ return vec3((uv/u_texel - u_proj.zw)/u_proj.xy*d, d); }\n" ++ "vec3 edge(vec2 step, vec3 p, float d){\n" ++ " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" ++ " if (a == 0.0 && b == 0.0) return vec3(0.0);\n" ++ " return (b == 0.0 || (a != 0.0 && abs(a-d) < abs(b-d))) ? p - at(v_uv - step, a) : at(v_uv + step, b) - p; }\n" ++ "/* A hairline between two polygons shows what lies behind them, or a polygon an in-between frame holds in place under them:\n" ++ " * where the depths half a pixel or a pixel to both sides agree and this one does not, it is theirs. */\n" ++ "float mend(float d){\n" ++ " if (d == 0.0) return d;\n" ++ " for (int i = 0; i < 4; ++i) {\n" ++ " vec2 step = (i < 2 ? vec2(u_texel.x, 0.0) : vec2(0.0, u_texel.y))*u_ao.w*((i & 1) == 0 ? 0.5 : 1.0);\n" ++ " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" ++ " if (a != 0.0 && b != 0.0 && abs(a - b) < 0.03*min(a, b) + 1.0 && abs(d - 0.5*(a + b)) > 0.04*d + 4.0) return 0.5*(a + b);\n" ++ " }\n" ++ " return d; }\n" ++ "void main(){\n" ++ " float d = mend(cam(v_uv));\n" ++ " frag = vec4(1.0, 0.0, 0.0, 1.0);\n" ++ " if (d == 0.0) return;\n" ++ " frag.g = clamp(log2(d)/16.0, 1.0/255.0, 1.0);\n" ++ " float clear = u_fade.y > u_fade.x ? 1.0 - clamp((d - u_fade.x)/(u_fade.y - u_fade.x), 0.0, 1.0) : 1.0;\n" ++ " if (u_span.z > 4.5) { frag.rb = vec2(clear); return; }\n" ++ " if (clear <= 0.0) return;\n" ++ " if (u_span.z > 2.5 && u_span.z < 3.5) { frag.rb = vec2(fract(d/256.0), d/4096.0); return; }\n" ++ " vec3 p = at(v_uv, d);\n" ++ " vec3 n = cross(edge(vec2(0.0, u_ao.w*u_texel.y), p, d), edge(vec2(u_ao.w*u_texel.x, 0.0), p, d));\n" ++ " if (dot(n, n) < 1e-12) return;\n" ++ " n = normalize(n); if (dot(n, p) > 0.0) n = -n;\n" ++ " float mark = texelFetch(u_tex, ivec2(v_uv*vec2(textureSize(u_tex, 0))), 0).a;\n" ++ " if (mark > 0.2 && mark < 0.6 && u_span.z < 3.5) return; /* a sprite stands in the floor it is drawn over, and under a blended fragment the depth is not its own */\n" ++ " if (u_span.z > 3.5) { frag = vec4(n*0.5 + 0.5, 1.0); return; }\n" ++ " float reach = min(u_ao.x*u_proj.x/d, u_ao.z);\n" ++ " if (reach < u_ao.w) return;\n" ++ " float turn = 6.2831853*fract(52.9829189*fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n" ++ " float r2 = u_ao.x*u_ao.x, sum = 0.0;\n" ++ " for (int i = 0; i < 16; ++i) {\n" ++ " float f = (float(i)+0.5)/16.0, angle = turn + f*6.2831853*7.0;\n" ++ " vec2 uv = v_uv + vec2(cos(angle), sin(angle))*sqrt(f)*reach*u_texel;\n" ++ " float ds = surf(uv);\n" ++ " if (ds == 0.0) continue;\n" ++ " vec3 v = at(uv, ds) - p;\n" ++ " float vv = dot(v, v), fall = max(r2 - vv, 0.0);\n" ++ " sum += fall*fall*fall*max(((dot(v, n) - u_fade.z*sqrt(vv))/(1.0 - u_fade.z) - 0.012*u_ao.x - 0.0015*d)/(vv + 0.0001*r2), 0.0);\n" ++ " }\n" ++ " frag.r = mix(1.0, max(0.4, 1.0 - u_ao.y*5.0*u_ao.x*sum/(r2*r2*r2*16.0)), clear); }\n"; ++/* The frame with the shading of the pass above, averaged over the 4x4 pixels around that lie at the pixel's own depth. */ ++static const char *SSAO_MIX_FS = ++ "#version 330\n" ++ "in vec2 v_uv; out vec4 frag;\n" ++ "uniform sampler2D u_tex, u_shade;\n" ++ "uniform vec4 u_span; /* x, y of the picture's corner on screen, the view asked for */\n" ++ "void main(){\n" ++ " ivec2 at = ivec2(gl_FragCoord.xy - u_span.xy), most = textureSize(u_shade, 0) - 1;\n" ++ " vec4 here = texelFetch(u_shade, clamp(at, ivec2(0), most), 0);\n" ++ " vec3 c = texture(u_tex, v_uv).rgb;\n" ++ " if (u_span.z > 4.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : vec3(here.r), 1.0); return; }\n" ++ " if (u_span.z > 2.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : here.rgb, 1.0); return; }\n" ++ " frag = vec4(u_span.z > 1.5 ? vec3(0.0, 0.0, 0.25) : c, 1.0);\n" ++ " if (here.g == 0.0) return;\n" ++ " float lit = 0.0, count = 0.0;\n" ++ " for (int y = -2; y < 2; ++y) for (int x = -2; x < 2; ++x) {\n" ++ " vec4 near = texelFetch(u_shade, clamp(at + ivec2(x, y), ivec2(0), most), 0);\n" ++ " if (near.g == 0.0 || abs(near.g - here.g) > 2.5/255.0) continue;\n" ++ " lit += near.r; count += 1.0;\n" ++ " }\n" ++ " lit = count > 0.0 ? lit/count : here.r;\n" ++ " frag = vec4(u_span.z > 1.5 ? vec3(lit) : c*lit, 1.0); }\n"; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static const char *INTERP_FS = + "#version 330\n" + "in vec2 v_uv; uniform sampler2D u_prev; uniform sampler2D u_curr;\n" +@@ -757,6 +1013,7 @@ + " }\n" + " frag=mix(prev,curr,alpha);\n" + "}\n"; ++#endif + + /* Geometry: position in VRAM pixels (draw offset already applied by gpu.c), + * color rgb in 0..1, color a = mask bit (0/1). The clip transform is in +@@ -775,24 +1032,41 @@ + "#version 330\n" + "layout(location=0) in vec2 a_pos;\n" + "layout(location=1) in vec4 a_col;\n" ++ "layout(location=2) in float a_visual_flags;\n" ++ "layout(location=3) in vec2 a_visual_pos;\n" + "uniform float u_shift;\n" + "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" + "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" + "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" + "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" +- "noperspective out vec4 v_col;\n" ++ "uniform float u_visual; /* 1 only for the independent display mirror */\n" ++ "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" ++ "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" ++ "noperspective out vec4 v_col; noperspective out float v_tint;\n" + "void main(){ v_col = a_col;\n" +- " float xb = a_pos.x;\n" ++ " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" ++ " vec2 p = corrected ? a_visual_pos : a_pos;\n" ++ " float xb = p.x, yb = p.y;\n" ++ " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" ++ " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" ++ " float ux = (xb-u_camera.z)/u_visual_options.x;\n" ++ " float den = u_camera.y-ux*u_camera.x;\n" ++ " if (abs(den) > 0.01) {\n" ++ " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" ++ " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" ++ " }\n" ++ " }\n" + " if (u_xscale < 0.0) {\n" + " float s = -u_xscale; float h = u_xhalf / s;\n" + " float l = u_xcenter - h, r = u_xcenter + h;\n" + " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" + " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" +- " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; ++ " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; + static const char *GEO_FS = + "#version 330\n" +- "noperspective in vec4 v_col; out vec4 frag;\n" +- "void main(){ frag = v_col; }\n"; ++ "noperspective in vec4 v_col; noperspective in float v_tint; out vec4 frag;\n" ++ "void main(){ frag = v_col;\n" ++ " if (v_tint > 0.0) frag.rgb = mix(frag.rgb, vec3(1.0,0.15,0.85),v_tint); }\n"; + + /* Textured prims: sample raw 1555 VRAM (integer), CLUT decode per depth, + * texture window, optional bilinear, texel-0 discard, STP-split discard, +@@ -816,12 +1090,20 @@ + "layout(location=6) in float a_raw;\n" + "layout(location=7) in vec4 a_limits;\n" + "layout(location=8) in float a_semi;\n" ++ "layout(location=9) in float a_visual_flags;\n" ++ "layout(location=10) in vec2 a_visual_pos;\n" ++ "layout(location=11) in float a_q;\n" + "uniform float u_shift;\n" + "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" + "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" + "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" + "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" +- "noperspective out vec2 v_uv; noperspective out vec4 v_col;\n" ++ "uniform float u_visual; /* 1 only for the independent display mirror */\n" ++ "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" ++ "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" ++ "noperspective out vec2 v_uv; noperspective out vec3 v_uvq;\n" ++ "noperspective out vec4 v_col; flat out int v_perspective;\n" ++ "noperspective out float v_tint;\n" + "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" + "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" + "void main(){ v_uv = a_uv; v_col = a_col;\n" +@@ -831,16 +1113,32 @@ + " v_limits = ivec4(floor(a_limits + 0.5));\n" + " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" + " * GEO_VS) so interpolated uv at a fragment equals the PS1 DDA value. */\n" +- " float xb = a_pos.x;\n" ++ " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" ++ " vec2 p = corrected ? a_visual_pos : a_pos;\n" ++ " float xb = p.x, yb = p.y;\n" ++ " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" ++ " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" ++ " float ux = (xb-u_camera.z)/u_visual_options.x;\n" ++ " float den = u_camera.y-ux*u_camera.x;\n" ++ " if (abs(den) > 0.01) {\n" ++ " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" ++ " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" ++ " }\n" ++ " }\n" ++ " /* Under a fractional yaw a_q comes divided by the turn's denominator, or 0 for the whole triangle. */\n" ++ " float q = (u_visual > 0.5 && a_visual_flags > 1.5 && a_q > 0.0) ? a_q : 0.0;\n" ++ " v_uvq = vec3(a_uv*q, q); v_perspective = q > 0.0 ? 1 : 0;\n" + " if (u_xscale < 0.0) {\n" + " float s = -u_xscale; float h = u_xhalf / s;\n" + " float l = u_xcenter - h, r = u_xcenter + h;\n" + " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" + " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" +- " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; ++ " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; + static const char *TEX_FS = + "#version 330\n" +- "noperspective in vec2 v_uv; noperspective in vec4 v_col;\n" ++ "noperspective in vec2 v_uv; noperspective in vec3 v_uvq;\n" ++ "noperspective in vec4 v_col; flat in int v_perspective;\n" ++ "noperspective in float v_tint;\n" + "out vec4 frag; out vec4 blend_factor;\n" + "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" + "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" +@@ -879,9 +1177,11 @@ + " return vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31)) / 31.0;\n" + "}\n" + "void main(){\n" ++ " vec2 sample_uv = v_uv;\n" ++ " if (v_perspective != 0 && v_uvq.z > 1.0e-12) sample_uv = v_uvq.xy/v_uvq.z;\n" + " int stp; vec3 rgb;\n" + " if (u_filter == 0) {\n" +- " int raw = fetch_texel(int(floor(v_uv.x)), int(floor(v_uv.y)));\n" ++ " int raw = fetch_texel(int(floor(sample_uv.x)), int(floor(sample_uv.y)));\n" + " if (raw == 0) discard;\n" + " rgb = col5(raw);\n" + " stp = (raw >> 15) & 1;\n" +@@ -892,8 +1192,8 @@ + " * its opacity with the colour renormalised — so prim edges and\n" + " * cutout borders keep their colour instead of dissolving into the\n" + " * transparent (black) neighbour and discarding whole edge columns. */\n" +- " int iu = int(floor(v_uv.x)), iv = int(floor(v_uv.y));\n" +- " float fx = v_uv.x - float(iu) - 0.5, fy = v_uv.y - float(iv) - 0.5;\n" ++ " int iu = int(floor(sample_uv.x)), iv = int(floor(sample_uv.y));\n" ++ " float fx = sample_uv.x - float(iu) - 0.5, fy = sample_uv.y - float(iv) - 0.5;\n" + " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" + " fx = abs(fx); fy = abs(fy);\n" + " int c00 = fetch_texel(iu, iv);\n" +@@ -914,6 +1214,7 @@ + " if (u_semipass == 1 && stp == 1) discard;\n" + " if (u_semipass == 2 && stp == 0) discard;\n" + " if (v_raw == 0) rgb = clamp(rgb * v_col.rgb * 2.0, 0.0, 1.0);\n" ++ " if (v_tint > 0.0) rgb = mix(rgb, vec3(1.0,0.15,0.85),v_tint);\n" + " float dst_factor = 0.0;\n" + " if (u_semimode == 4 && v_semi != 0 && stp != 0) {\n" + " dst_factor = v_semi == 1 ? 0.5 : 1.0;\n" +@@ -1006,6 +1307,70 @@ + fprintf(stdout, "psxrecomp: GL program link failed: %s\n", log); return 0; } + return p; + } ++/* The drawing programs' own copy of a vertex shader: on the mirror the camera depth of slot 9 or 23 becomes the fragment's depth, ++ * and v_bill says what is drawn: 0 off the mirror, 1 on it, 2 there with a depth that is not a world polygon's exact one ++ * (a sprite's one depth, or a clipped polygon's partial one: flags of 1, an exact polygon has 3). ++ * The source strings stay as they are: the in-between presenter makes its own copies of them. */ ++/* A copy of a shader with a line after its first one and another end. NULL when it does not end as expected. */ ++static char *shader_variant(const char *source, const char *declared, const char *end, const char *ending) { ++ const size_t length = strlen(source), tail = strlen(end), room = length + strlen(declared) + strlen(ending) + 8; ++ const char *body = strchr(source, '\n'); ++ char *out; ++ int written; ++ if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; ++ out = (char *)malloc(room); ++ if (!out) return NULL; ++ written = snprintf(out, room, "%.*s%s", (int)(body + 1 - source), source, declared); ++ memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); ++ strcpy(out + written + (source + length - tail - (body + 1)), ending); ++ return out; ++} ++ ++static char *depth_writing_shader(const char *source, int location) { ++ static const char flat[] = "0.0, 1.0); }\n"; ++ static const char deep[] = "u_visual_options.w >= 1.0 ? 0.99999 : ((u_visual > 0.5 && abs(a_camz) > 16.0) ? max(1.0 - 32.0*(1.0 + u_pull)/(abs(a_camz)*(1.0 + max(u_visual_options.w, 0.0))), -1.0) : 0.999999), 1.0);\n" ++ " bool grown = a_visual_flags > 5.5;\n" ++ " if (u_visual > 0.5 && (u_visual_options.w == 0.0 ? grown : u_visual_options.w >= 1.0 ? (grown || a_visual_flags < 1.5) : (u_visual_options.w > 0.0 && !grown)))\n" ++ " gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n" ++ " v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\n" ++ " v_bill = u_visual > 0.5 ? ((abs(a_camz) > 16.0 && a_visual_flags < 1.5) ? 2.0 : 1.0) : 0.0; }\n"; ++ const size_t length = strlen(source), tail = sizeof(flat) - 1, room = length + sizeof(deep) + 160; ++ const char *body = strchr(source, '\n'); ++ char *out; ++ int written; ++ if (!body || length < tail || strcmp(source + length - tail, flat)) return NULL; ++ out = (char *)malloc(room); ++ if (!out) return NULL; ++ written = snprintf(out, room, "%.*slayout(location=%d) in float a_camz; flat out float v_bill; noperspective out float v_deep; uniform float u_pull;\n", (int)(body + 1 - source), source, location); ++ memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); ++ strcpy(out + written + (source + length - tail - (body + 1)), deep); ++ return out; ++} ++ ++/* The textured program's own copy of its fragment shader. Pass 3 and 4 write depth alone, 4 for a batch whose STP texels are blended: ++ * a blended fragment leaves the depth under it. On the mirror, whose alpha nothing reads back, a blended fragment marks it 0.25 ++ * and an opaque one of a sprite 0.5. */ ++static char *sprite_marking_shader(const char *source) { ++ static const char end[] = "}\n"; ++ static const char marked[] = ++ " bool blended = stp != 0 && (u_semimode == 4 ? v_semi != 0 : (u_semipass == 2 || u_semipass == 4));\n" ++ " if (u_semipass == 4 && blended) discard;\n" ++ " if (v_bill > 0.5 && blended) frag.a = 0.25; else if (v_bill > 1.5) frag.a = 0.5;\n" ++ " if (v_bill > 0.5 && v_deep > -999.5 && v_deep < 0.999) frag.a = 0.25; /* some corner has no depth (it weighs -1000): its depth is no surface */\n" ++ "}\n"; ++ const size_t length = strlen(source), tail = sizeof(end) - 1, room = length + sizeof(marked) + 64; ++ const char *body = strchr(source, '\n'); ++ char *out; ++ int written; ++ if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; ++ out = (char *)malloc(room); ++ if (!out) return NULL; ++ written = snprintf(out, room, "%.*sflat in float v_bill; noperspective in float v_deep;\n", (int)(body + 1 - source), source); ++ memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); ++ strcpy(out + written + (source + length - tail - (body + 1)), marked); ++ return out; ++} ++ + static GLuint build_program(const char *vs, const char *fs) { + return build_program_ex(vs, fs, 0); + } +@@ -1025,6 +1390,14 @@ + * stored value is known to be 0 when the test passed) and a 0 + * via KEEP. */ + static void mask_stencil(int write_val) { ++ if (s_fill_pass > 0.0f) { /* a filler obeys the mask and leaves it as it is */ ++ if (!s_mask_check) { glDisable(GL_STENCIL_TEST); return; } ++ glEnable(GL_STENCIL_TEST); ++ glStencilMask(0x00); ++ glStencilFunc(GL_EQUAL, 0, 0x01); ++ glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); ++ return; ++ } + glEnable(GL_STENCIL_TEST); + glStencilMask(0x01); + if (s_mask_check) { +@@ -1135,6 +1508,7 @@ + PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, + s_vram + (size_t)y * VRAM_W + x); + glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); ++ temporal_note_raw_change(); + if (diag) s_rt_up_diag[4] += SDL_GetPerformanceCounter() - t0; + } + +@@ -1223,6 +1597,7 @@ + int h = s_pack_dirty.y1 - s_pack_dirty.y0 + 1; + rect_clear(&s_pack_dirty); + coh_record(GL_COH_PACK, x, y, x + w - 1, y + h - 1); ++ temporal_note_raw_change(); + + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); + glViewport(0, 0, VRAM_W, VRAM_H); +@@ -1327,9 +1702,43 @@ + * boundary (MMX6: draw area alternates y=0/y=240, both bands in ONE wide + * surface) bleed into the OTHER band's rows — presented one frame later as + * top/bottom edge flicker (16:9 GL only). */ ++/* Rows y..y+h-1 of a surface as bits, one for every 16 rows. */ ++static uint32_t wide_depth_rows(int y, int h) { ++ uint32_t rows = 0; ++ for (int block = y < 0 ? 0 : y / 16; block < 32 && block * 16 < y + h; block++) rows |= 1u << block; ++ return rows; ++} ++ ++/* The depth of a frame buffer is one frame's: drawing into rows that have been shown starts from none. The game's frame buffers ++ * share a surface, one above the other, so the rows on screen keep theirs. Polygons the game blends into its far colour write ++ * no depth: without this the last frame's would stay under them. */ ++static void wide_depth_shown(GLuint fbo, int y, int h) { ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); ++} ++ ++static void wide_depth_begin(void) { ++ int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1; ++ if (!mirror_depth()) return; ++ if (sy < 0) { sh += sy; sy = 0; } ++ if (sy + sh > VRAM_H) sh = VRAM_H - sy; ++ if (sh <= 0) return; ++ for (int i = 0; i < WIDE_MAX_SURF; i++) { ++ const uint32_t rows = wide_depth_rows(sy, sh); ++ if (s_wide_fbo[i] != g_wide_cur || !(s_wide_depth_shown[i] & rows)) continue; ++ s_wide_depth_shown[i] &= ~rows; ++ glEnable(GL_SCISSOR_TEST); ++ glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); ++ glDepthMask(GL_TRUE); ++ glClearDepth(1.0); ++ glClear(GL_DEPTH_BUFFER_BIT); ++ } ++} ++ + static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) { + if (s_ws_ablate != 3) /* ablate 3: no FBO rebind (draws land in hr — perf probe) */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); ++ wide_depth_begin(); + glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); + glEnable(GL_SCISSOR_TEST); + { +@@ -1339,10 +1748,12 @@ + if (sh < 0) sh = 0; + glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); + } ++ if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(GL_FALSE); } + p_glUniform1f(uXoff, (float)dx); + p_glUniform1f(uXhalf, (float)g_wide_w / 2.0f); + } + static void wide_target_end(GLint uXoff, GLint uXhalf) { ++ if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } + p_glUniform1f(uXoff, 0.0f); + p_glUniform1f(uXhalf, 512.0f); + if (s_ws_ablate != 3) +@@ -1397,13 +1808,13 @@ + /* True if [lo,hi] (canonical draw-x) lies strictly inside the 4:3 frame, so the + * prim adds nothing to either reveal margin and its mirror can be skipped. */ + static int mirror_x_center_only(int lo, int hi) { +- if (!s_wide_fast) return 0; ++ if (!s_wide_fast || s_geometry_correction) return 0; + int base = g_wide_cur_base, native_w = g_wide_w - 2 * g_wide_off; + if (native_w <= 0) return 0; + return (lo >= base) && (hi < base + native_w); + } + static int mirror_geo_center_only(const int *xs, int n) { +- if (!s_wide_fast) return 0; ++ if (!s_wide_fast || s_geometry_correction) return 0; + int lo = xs[0], hi = xs[0]; + for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } + return mirror_x_center_only(lo, hi); +@@ -1449,6 +1860,7 @@ + * flush_tex_batch never re-enters those helpers. */ + #define TEXBATCH_MAXV 8190 /* multiple of 3; ~2730 tris */ + static float s_tb[TEXBATCH_MAXV * TEXV]; ++static uint64_t s_tb_identity[TEXBATCH_MAXV]; + static int s_tb_n = 0; /* verts queued */ + static int s_tb_semi = -2; + static int s_tb_mask = 0, s_tb_filter = 0; +@@ -1506,7 +1918,7 @@ + if (s_tb_mask) mask_stencil(1); else glDisable(GL_STENCIL_TEST); + p_glUniform1i(s_uSemipass, 0); + glDrawArrays(GL_TRIANGLES, 0, nverts); +- if (!s_tb_mask) s_stencil_valid = 0; ++ if (!s_tb_mask && s_fill_pass <= 0.0f) s_stencil_valid = 0; + } else { + glDisable(GL_BLEND); /* Pass 1: STP=0 texels (opaque) */ + mask_stencil(s_tb_mask); +@@ -1540,7 +1952,7 @@ + /* Textured-batch variant of mirror_x_center_only: scan the queued verts' x + * (attr 0, stride TEXV). Defined here so s_tb / TEXV are in scope. */ + static int mirror_batch_center_only(int nverts) { +- if (!s_wide_fast || nverts <= 0) return 0; ++ if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; + int lo = (int)s_tb[0], hi = (int)s_tb[0]; + for (int i = 1; i < nverts; i++) { + int x = (int)s_tb[i * TEXV]; +@@ -1549,15 +1961,74 @@ + return mirror_x_center_only(lo, hi); + } + ++static float filler_line_width(int scale) { ++ const float width = s_crack_fill * (float)scale; ++ return width < 1.0f ? 1.0f : width > s_line_width_max ? s_line_width_max : width; ++} ++ ++/* What a filler pass of the mirror leaves as it found it: the mask bit in alpha and stencil, and the depth. */ ++static void filler_pass_begin(float pass) { ++ s_fill_pass = pass; ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); ++ glDepthFunc(GL_LESS); ++ glDepthMask(GL_FALSE); ++} ++static void filler_pass_end(void) { ++ s_fill_pass = 0.0f; ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ glStencilMask(0x01); ++ glDepthFunc(GL_ALWAYS); ++} ++ ++/* Under a batch, the edges of its exact world polygons: exact corners open the hairline gaps that whole pixels closed. */ ++static void tex_batch_outline(int nverts, int semi) { ++ int exact = 0; ++ for (int i = 0; i < nverts && !exact; i += 3) exact = s_tb[i * TEXV + 19] > 1.5f; ++ if (!exact) return; ++ filler_pass_begin(2.0f); ++ glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); ++ glLineWidth(filler_line_width(s_scale)); ++ glDisable(GL_BLEND); ++ mask_stencil(s_tb_mask); ++ p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); ++ p_glUniform1i(s_uSemipass, semi < 0 ? 0 : 4); ++ glDrawArrays(GL_TRIANGLES, 0, nverts); ++ glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); ++ filler_pass_end(); ++} ++ ++/* A batch of exact world polygons by depth, see s_world_depth. */ ++static void tex_batch_by_depth(int nverts, int semi) { ++ glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glDepthFunc(GL_LEQUAL); ++ glDepthMask(GL_TRUE); ++ p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); ++ p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); ++ glDrawArrays(GL_TRIANGLES, 0, nverts); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ glDepthMask(GL_FALSE); ++ p_glUniform1f(s_tex_uPull, s_world_margin); ++ tex_batch_draw_passes(nverts, semi); ++ p_glUniform1f(s_tex_uPull, 0.0f); ++ glDepthFunc(GL_ALWAYS); ++} ++ + static void flush_tex_batch(void) { + if (s_tb_n == 0) return; + int nverts = s_tb_n, semi = s_tb_semi; ++ const int fillers = s_tb_fillers; ++ const int world = s_tb_world; + s_tb_n = 0; /* clear first: re-entrancy safe */ + double cw_t0 = cw_ms(); + s_cw_batches++; s_batch_total++; s_cw_flush_depth++; + + hr_begin(1); + p_glUseProgram(s_tex_prog); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_raw_tex); + p_glUniform1i(s_uVram, 0); +@@ -1580,9 +2051,34 @@ + s_bd_gate = s_tb_gate; /* this batch is uniform-gate (flushed on change) */ + gl_perf_mirror_begin(); + wide_target_begin(dx, s_tex_uXoff, s_tex_uXhalf); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); + wide_set_bd_scale(s_tex_uXscale, s_tex_uXcenter); +- if (s_ws_ablate != 2) tex_batch_draw_passes(nverts, semi); ++ s_draw_filler = mirror_depth() && fillers; ++ s_draw_world = mirror_depth() && world; ++ temporal_draw(s_tb, s_tb_identity, nverts, 1, GL_TRIANGLES, semi, s_tb_mask, ++ s_tb_filter, s_tb_twin, s_tb_gate, 0); ++ s_draw_filler = s_draw_world = 0; ++ if (s_ws_ablate != 2) { ++ if (mirror_depth() && s_crack_fill > 0.0f) tex_batch_outline(nverts, semi); ++ if (mirror_depth() && fillers) { /* in sequence each copy would break the batch: 135 batches a frame against 2 */ ++ filler_pass_begin(GROWN_COPY_MARGIN); ++ tex_batch_draw_passes(nverts, semi); ++ filler_pass_end(); ++ } ++ if (world && mirror_depth()) tex_batch_by_depth(nverts, semi); ++ else tex_batch_draw_passes(nverts, semi); ++ } ++ if (mirror_depth() && !world && s_ws_ablate != 2) { /* the depth of the opaque fragments alone */ ++ glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glDepthMask(GL_TRUE); ++ p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); ++ glDrawArrays(GL_TRIANGLES, 0, nverts); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ } + wide_clear_bd_scale(s_tex_uXscale, s_tex_uXcenter); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); + wide_target_end(s_tex_uXoff, s_tex_uXhalf); + gl_perf_mirror_end(); + } +@@ -1594,16 +2090,17 @@ + * GP0(68h) 1x1 dots; each was two immediate gpu_triangle draws (BufferData + + * DrawArrays each). Coalesce opaque/semi-uniform tris into one draw. */ + #define FLATBATCH_MAXV 8190 /* multiple of 3 */ +-static float s_fb[FLATBATCH_MAXV * 6]; ++static float s_fb[FLATBATCH_MAXV * GEOV]; ++static uint64_t s_fb_identity[FLATBATCH_MAXV]; + static int s_fb_n = 0; + static int s_fb_semi = -2; + static int s_fb_mask = -1; + + static int mirror_flat_batch_center_only(int nverts) { +- if (!s_wide_fast || nverts <= 0) return 0; ++ if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; + int lo = (int)s_fb[0], hi = (int)s_fb[0]; + for (int i = 1; i < nverts; i++) { +- int x = (int)s_fb[i * 6]; ++ int x = (int)s_fb[i * GEOV]; + if (x < lo) lo = x; if (x > hi) hi = x; + } + return mirror_x_center_only(lo, hi); +@@ -1618,9 +2115,10 @@ + if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); + mask_stencil(mask); + p_glUseProgram(s_geo_prog); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glBindVertexArray(s_geo_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); +- p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * 6 * sizeof(float)), ++ p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * GEOV * sizeof(float)), + s_fb, PSXGL_STREAM_DRAW); + glDrawArrays(GL_TRIANGLES, 0, nverts); + +@@ -1631,9 +2129,13 @@ + s_bd_gate = 0; + gl_perf_mirror_begin(); + wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); + wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ temporal_draw(s_fb, s_fb_identity, nverts, 0, GL_TRIANGLES, semi, mask, 0, NULL, 0, 0); ++ if (mirror_depth()) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); + if (s_ws_ablate != 2) glDrawArrays(GL_TRIANGLES, 0, nverts); + wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + wide_target_end(s_geo_uXoff, s_geo_uXhalf); + gl_perf_mirror_end(); + } +@@ -1641,9 +2143,23 @@ + } + + /* Flat / gouraud triangles and lines share the GEO program. mode: GL_TRIANGLES +- * or GL_LINES; verts are (x, y, r, g, b, a) tuples with colors as 1555. */ ++ * or GL_LINES; vertices carry canonical x/y, color, visual flags, and separate ++ * presentation x/y. Bit 0 of the flags gates the fractional-yaw transform; ++ * bit 1 identifies exact provenance for the optional coverage tint. */ + static void gpu_geometry(GLenum mode, const int *xs, const int *ys, + const uint16_t *cs, int n, int semi) { ++ float px[3] = {0}, py[3] = {0}, pz[3] = {0}; ++ uint64_t pid[3] = {0}; ++ int visual_flags = 0; ++ if (mode == GL_TRIANGLES && n == 3) ++ visual_flags = take_visual_triangle(xs, ys, px, py, NULL, pz, pid) & 3; ++ else { ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ } + flush_tex_batch(); /* flat prim: drain textured draws first (order + program) */ + flush_cpu_upload(); /* also drains flat batch if an upload was pending */ + mark_prim_dirty(xs, ys, n, 0 /* flat */); +@@ -1651,24 +2167,29 @@ + /* Lines stay immediate (rare); tris batch for MotK 0x68 starfields. */ + if (mode != GL_TRIANGLES || n < 3) { + flush_flat_batch(); +- float verts[3 * 6]; ++ float verts[3 * GEOV]; + float mask_a = s_mask_set ? 1.0f : 0.0f; + for (int i = 0; i < n; i++) { +- verts[i*6+0] = (float)xs[i]; +- verts[i*6+1] = (float)ys[i]; +- verts[i*6+2] = ((cs[i] & 0x1F) << 3) / 255.0f; +- verts[i*6+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; +- verts[i*6+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; +- verts[i*6+5] = mask_a; ++ verts[i*GEOV+0] = (float)xs[i]; ++ verts[i*GEOV+1] = (float)ys[i]; ++ verts[i*GEOV+2] = ((cs[i] & 0x1F) << 3) / 255.0f; ++ verts[i*GEOV+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; ++ verts[i*GEOV+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; ++ verts[i*GEOV+5] = mask_a; ++ verts[i*GEOV+6] = 0.0f; ++ verts[i*GEOV+7] = (float)xs[i]; ++ verts[i*GEOV+8] = (float)ys[i]; ++ verts[i*GEOV+9] = 0.0f; + } + hr_begin(1); + if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); + mask_stencil(s_mask_set); + if (mode == GL_LINES) glLineWidth((float)s_scale); + p_glUseProgram(s_geo_prog); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glBindVertexArray(s_geo_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); +- p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * 6 * sizeof(float)), ++ p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * GEOV * sizeof(float)), + verts, PSXGL_STREAM_DRAW); + glDrawArrays(mode, 0, n); + if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && +@@ -1677,9 +2198,13 @@ + s_bd_gate = bd_prim_gate(xs, n, 0); + gl_perf_mirror_begin(); + wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); + wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ temporal_draw(verts, NULL, n, 0, mode, semi, s_mask_set, 0, NULL, s_bd_gate, 0); ++ if (s_ssao) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); + if (s_ws_ablate != 2) glDrawArrays(mode, 0, n); + wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + wide_target_end(s_geo_uXoff, s_geo_uXhalf); + gl_perf_mirror_end(); + } +@@ -1696,13 +2221,18 @@ + + float mask_a = s_mask_set ? 1.0f : 0.0f; + for (int i = 0; i < n; i++) { +- float *v = &s_fb[s_fb_n * 6]; ++ float *v = &s_fb[s_fb_n * GEOV]; + v[0] = (float)xs[i]; + v[1] = (float)ys[i]; + v[2] = ((cs[i] & 0x1F) << 3) / 255.0f; + v[3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; + v[4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; + v[5] = mask_a; ++ v[6] = (float)visual_flags; ++ v[7] = visual_flags ? px[i] : (float)xs[i]; ++ v[8] = visual_flags ? py[i] : (float)ys[i]; ++ v[9] = pz[i]; /* temporal identity only; no shader attribute */ ++ s_fb_identity[s_fb_n] = pid[i]; + s_fb_n++; + } + } +@@ -1733,6 +2263,10 @@ + const float *col, uint16_t texpage, + uint16_t clut_x, uint16_t clut_y, int rawtex, + int semi, const int *lim) { ++ float px[3], py[3], pq[3], pz[3]; ++ uint64_t pid[3]; ++ const int visual_flags = take_visual_triangle(xs, ys, px, py, pq, pz, pid); ++ turned_perspective(px, pq, visual_flags); + int lim_buf[4]; + int uv_buf[6]; + if (!lim) { +@@ -1762,6 +2296,8 @@ + flush_flat_batch(); /* painter order: flat GEO before textured */ + int twx = s_tw_mask_x, twy = s_tw_mask_y, tox = s_tw_off_x, toy = s_tw_off_y; + int gate = bd_prim_gate(xs, 3, 1); /* backdrop-stretch gate is also a batch key */ ++ const int world = psx_world_depth_class(s_world_depth, visual_flags, pz, s_world_margin); ++ if (s_tb_n > 0 && world != s_tb_world) flush_tex_batch(); + /* With mask checking off, opaque and mode-0 semi primitives use the + * same dual-source blend state. The per-vertex a_semi flag selects + * replace vs half-blend without breaking painter order. */ +@@ -1800,11 +2336,14 @@ + if (s_tb_n + 3 > TEXBATCH_MAXV) { s_batch_reason[6]++; flush_tex_batch(); } + if (s_tb_n == 0) { /* opening a batch: capture its keyed state */ + s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = s_tex_filter; s_tb_gate = gate; ++ s_tb_fillers = 0; ++ s_tb_world = world; + s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy; + } + float *vp = &s_tb[s_tb_n * TEXV]; + for (int i = 0; i < 3; i++, vp += TEXV) { +- vp[0] = (float)xs[i]; vp[1] = (float)ys[i]; ++ vp[0] = (float)xs[i]; ++ vp[1] = (float)ys[i]; + vp[2] = (float)us[i]; vp[3] = (float)vs[i]; + vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; + vp[8] = (float)base_x; vp[9] = (float)base_y; /* a_tpage */ +@@ -1813,7 +2352,14 @@ + vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ + vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; + vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ ++ vp[19] = (float)visual_flags; /* a_visual_flags */ ++ vp[20] = visual_flags ? px[i] : (float)xs[i]; /* a_visual_pos */ ++ vp[21] = visual_flags ? py[i] : (float)ys[i]; ++ vp[22] = pq[i]; /* a_q (0 = affine) */ ++ vp[23] = pz[i]; /* temporal identity only; no shader attribute */ ++ s_tb_identity[s_tb_n + i] = pid[i]; + } ++ if (visual_flags & 4) s_tb_fillers++; + s_tb_n += 3; + if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ + } +@@ -1831,31 +2377,44 @@ + float b = (((c >> 10) & 0x1F) << 3) / 255.0f; + float a = s_mask_set ? 1.0f : 0.0f; + float fx0 = (float)wx, fy0 = (float)y, fx1 = (float)(wx + ww), fy1 = (float)(y + h); +- float verts[6 * 6] = { +- fx0,fy0,r,g,b,a, fx1,fy0,r,g,b,a, fx0,fy1,r,g,b,a, +- fx1,fy0,r,g,b,a, fx0,fy1,r,g,b,a, fx1,fy1,r,g,b,a, ++ float verts[6 * GEOV] = { ++ fx0,fy0,r,g,b,a,0,fx0,fy0,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, ++ fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, ++ fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy1,r,g,b,a,0,fx1,fy1,0, + }; + /* Wide target: positions already wide-space so u_xoff = 0; full-width + * scissor; u_xhalf = g_wide_w/2. */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); ++ wide_depth_begin(); + glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); + glEnable(GL_SCISSOR_TEST); + glScissor(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); /* full surface (rt_wide) */ + if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); + mask_stencil(s_mask_set); + p_glUseProgram(s_geo_prog); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glUniform1f(s_geo_uXoff, 0.0f); + p_glUniform1f(s_geo_uXhalf, (float)g_wide_w / 2.0f); + p_glBindVertexArray(s_geo_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); + p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); ++ temporal_draw(verts, NULL, 6, 0, GL_TRIANGLES, semi, s_mask_set, 0, NULL, 0, 1); ++ if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); } + glDrawArrays(GL_TRIANGLES, 0, 6); ++ if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } + p_glUniform1f(s_geo_uXoff, 0.0f); + p_glUniform1f(s_geo_uXhalf, 512.0f); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); + } + + static void gpu_flat_rect(int x,int y,int w,int h,uint16_t c,int semi) { ++ /* Rectangles are 2D UI/sprite primitives. Never let one-shot metadata ++ * prepared for a rejected polygon leak into the first triangle here. */ ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; + if (w <= 0 || h <= 0) return; + /* Full-screen 2D overlay (pause gray-filter / load fade): a flat rect + * spanning the whole 4:3 framebuffer must cover the whole wide surface too, +@@ -1877,6 +2436,7 @@ + gpu_triangle(x, y, c, x+w, y, c, x, y+h, c, semi); + gpu_triangle(x+w, y, c, x, y+h, c, x+w, y+h, c, semi); + if (overlay) { ++ flush_flat_batch(); + s_wide_suppress = 0; + /* The two canonical triangles drew into the hr FBO already (each + * gpu_triangle ran its own hr_begin/hr_end). Re-open the bracket just +@@ -1895,7 +2455,19 @@ + static void gpu_textured_rect(int x,int y,int w,int h, + int u0,int v0,int u1,int v1, + uint16_t clut_x,uint16_t clut_y,uint16_t tp,int semi) { +- if (w <= 0 || h <= 0) return; ++ /* As above, a textured rectangle is never a GTE-projected world ++ * triangle, even though it is decomposed into two triangles below. */ ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ s_sprite_depth = s_next_sprite_depth; ++ s_next_sprite_depth = 0.0f; ++ memcpy(s_sprite_sides, s_next_sprite_sides, sizeof(s_sprite_sides)); ++ memset(s_next_sprite_sides, 0, sizeof(s_next_sprite_sides)); ++ s_sprite_x = x; s_sprite_y = y; ++ if (w <= 0 || h <= 0) { s_sprite_depth = 0.0f; return; } + float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; + float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; + /* gpu.c routes axis-aligned MIRRORED quads (X/Y-flipped 2D sprites, +@@ -1911,6 +2483,7 @@ + int xs2[3]={x+w, x, x+w}, ys2[3]={y, y+h, y+h}; + int us2[3]={u1,u0,u1}, vs2[3]={v0,v1,v1}; + gpu_textured_triangle(xs2,ys2,us2,vs2,col,tp,clut_x,clut_y,s_mod_raw,semi,lim); ++ s_sprite_depth = 0.0f; + } + + /* GP0(02h) fill: writes color with bit15=0, ignoring draw area, mask and +@@ -2020,6 +2593,8 @@ + if (s < 1) s = 1; + if (s > GL_MAX_INTERNAL_SCALE) s = GL_MAX_INTERNAL_SCALE; + s_req_scale = s; ++ if (s_raster_ok && s != s_scale && !resize_gpu_raster(s)) ++ s_req_scale = s_scale; + sw_renderer_set_scale(1); + } + static int glb_scale(void) { return s_scale; } /* real internal SSAA scale (was a stub 1; the +@@ -2028,6 +2603,131 @@ + unaffected since it never reads gr_scale()) */ + static void glb_set_texture_filter(int b) { s_tex_filter = b ? 1 : 0; sw_set_texture_filter(b); } + static int glb_texture_filter(void) { return s_tex_filter; } ++ ++static void glb_set_geometry_correction(int enabled) { ++ if ((enabled ? 1 : 0) != s_geometry_correction) { ++ interp_reset_history(); ++ memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ } ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ s_geometry_correction = enabled ? 1 : 0; ++ if (!s_geometry_correction) { ++ s_next_world = 0; ++ s_next_precise = 0; ++ s_next_unpinned = 0; ++ s_next_perspective = 0; ++ s_next_temporal_depth = 0; ++ s_yaw_sin = 0.0f; ++ s_yaw_cos = 1.0f; ++ } ++} ++ ++static void glb_set_world_triangle(int world) { ++ if (!s_raster_ok) return; ++ s_next_world = world; /* 2: the game's grown second copy */ ++ if (!world) s_next_temporal_depth = 0; ++} ++ ++static void glb_set_precise_triangle(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2) { ++ if (!s_raster_ok) { ++ sw_set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); ++ return; ++ } ++ s_next_precise = enabled ? 1 : 0; ++ if (!enabled) s_next_temporal_depth = 0; ++ s_next_x16[0] = x0; s_next_y16[0] = y0; ++ s_next_x16[1] = x1; s_next_y16[1] = y1; ++ s_next_x16[2] = x2; s_next_y16[2] = y2; ++} ++ ++static void glb_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { ++ if (!s_raster_ok) return; ++ s_next_unpinned = 1; ++ s_next_unpinned_x16[0] = x0; s_next_unpinned_y16[0] = y0; ++ s_next_unpinned_x16[1] = x1; s_next_unpinned_y16[1] = y1; ++ s_next_unpinned_x16[2] = x2; s_next_unpinned_y16[2] = y2; ++} ++ ++static void glb_set_perspective_triangle(int enabled, ++ float q0, float q1, float q2) { ++ /* Pre-context fallback draws canonical software VRAM, where perspective ++ * correction is deliberately forbidden. Drop rather than forward it. */ ++ s_next_perspective = 0; ++ if (!s_raster_ok || !enabled || ++ !isfinite(q0) || !isfinite(q1) || !isfinite(q2) || ++ q0 <= 0.0f || q1 <= 0.0f || q2 <= 0.0f) ++ return; ++ s_next_q[0] = q0; ++ s_next_q[1] = q1; ++ s_next_q[2] = q2; ++ s_next_perspective = 1; ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++static void glb_set_temporal_depth_triangle(int enabled, ++ float z0, float z1, float z2) { ++ s_next_temporal_depth = 0; ++ if (!s_raster_ok || !enabled || ++ !isfinite(z0) || !isfinite(z1) || !isfinite(z2)) ++ return; ++ /* enabled == 2: a polygon some of whose corners have no depth, which stay 0. */ ++ if (enabled == 2 ? z0 < 0.0f || z1 < 0.0f || z2 < 0.0f || z0 + z1 + z2 <= 0.0f ++ : z0 <= 0.0f || z1 <= 0.0f || z2 <= 0.0f) ++ return; ++ s_next_z[0] = z0; s_next_z[1] = z1; s_next_z[2] = z2; ++ s_next_temporal_depth = enabled == 2 ? 2 : 1; ++} ++static void glb_set_surface_depth_triangle(float z0, float z1, float z2) { ++ const int usable = s_raster_ok && isfinite(z0) && isfinite(z1) && isfinite(z2) && z0 >= 0.0f && z1 >= 0.0f && z2 >= 0.0f; ++ s_next_surface_z[0] = usable ? z0 : 0.0f; ++ s_next_surface_z[1] = usable ? z1 : 0.0f; ++ s_next_surface_z[2] = usable ? z2 : 0.0f; ++} ++static void glb_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { ++ s_next_identity[0] = id0; s_next_identity[1] = id1; s_next_identity[2] = id2; ++} ++static void glb_set_temporal_sprite(float depth, const float sides[4]) { ++ s_next_sprite_depth = s_raster_ok && isfinite(depth) && depth > 0.0f ? depth : 0.0f; ++ int placed = s_next_sprite_depth > 0.0f && sides != NULL; ++ for (int side = 0; placed && side < 4; ++side) placed = isfinite(sides[side]) && fabsf(sides[side]) < 64.0f; ++#ifndef PSX_NO_DEBUG_TOOLS ++ /* PSX_GL_SPRITE_WHOLE_PIXELS=1 puts sprites back on whole pixels, to measure against. */ ++ static int whole = -1; ++ if (whole < 0) { const char *asked = getenv("PSX_GL_SPRITE_WHOLE_PIXELS"); whole = asked && asked[0] == '1'; } ++ if (whole) placed = 0; ++#endif ++ for (int side = 0; side < 4; ++side) s_next_sprite_sides[side] = placed ? sides[side] : 0.0f; ++} ++#endif ++ ++static void glb_set_presentation_yaw(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x) { ++ /* A frame may still have batched polygons when the vblank mouse hook ++ * advances the residual. Land those with the OLD residual first so the ++ * presentation transform stays paired with the guest yaw that built them. */ ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ if (!isfinite(yaw_units)) yaw_units = 0.0; ++ if (yaw_units > 4.0) yaw_units = 4.0; ++ if (yaw_units < -4.0) yaw_units = -4.0; ++ if (!isfinite(full_turn) || full_turn <= 0.0) { ++ s_yaw_sin = 0.0f; ++ s_yaw_cos = 1.0f; ++ } else { ++ const double radians = ++ yaw_units * (6.28318530717958647692 / full_turn); ++ s_yaw_sin = (float)sin(radians); ++ s_yaw_cos = (float)cos(radians); ++ } ++ s_yaw_center_x = isfinite(center_x) ? (float)center_x : 160.0f; ++ s_yaw_center_y_relative = isfinite(center_y) ++ ? (float)center_y : 120.0f; ++ s_yaw_focal_x = isfinite(focal_x) && focal_x > 1.0 ++ ? (float)focal_x : 160.0f; ++} + + static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } + static void glb_set_mask_bits(int s, int c) { +@@ -2152,6 +2852,7 @@ + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + if (s_raw_fbo) { ++ temporal_note_raw_change(); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); + glViewport(0, 0, VRAM_W, VRAM_H); + glScissor(x0, y0, rw, rh); +@@ -2266,11 +2967,176 @@ + return 1; + } + ++static void delete_scale_target(GLuint *fbo, GLuint *tex, GLuint *rb) { ++ if (*fbo) { p_glDeleteFramebuffers(1, fbo); *fbo = 0; } ++ if (*tex) { glDeleteTextures(1, tex); *tex = 0; } ++ if (rb && *rb) { p_glDeleteRenderbuffers(1, rb); *rb = 0; } ++} ++ ++/* Rebuild only the scale-dependent GL targets while keeping the current VRAM ++ * image alive. All replacement targets are validated before any live handle ++ * changes; a failed allocation therefore leaves the old renderer untouched. ++ * Colour is nearest-blitted between equivalent logical grids, then stencil is ++ * reconstructed from the copied bit-15 alpha. Native-wide surfaces are ++ * migrated too, avoiding a black/stale transition when the game does not ++ * repaint every framebuffer pixel on the following frame. */ ++static int resize_gpu_raster(int scale) { ++ if (!s_raster_ok || scale == s_scale) return 1; ++ if (scale < 1 || scale > GL_MAX_INTERNAL_SCALE) return 0; ++ ++ flush_flat_batch(); ++ flush_tex_batch(); ++ flush_cpu_upload(); ++ ++ const int old_scale = s_scale; ++ const int new_hw = VRAM_W * scale; ++ const int new_hh = VRAM_H * scale; ++ GLuint new_hr_tex = 0, new_hr_fbo = 0, new_hr_rb = 0; ++ GLuint new_scratch_tex = 0, new_scratch_fbo = 0; ++ GLuint new_wide_tex[WIDE_MAX_SURF] = {0}; ++ GLuint new_wide_fbo[WIDE_MAX_SURF] = {0}; ++ GLuint new_wide_rb[WIDE_MAX_SURF] = {0}; ++ int current_wide_index = -1; ++ ++ new_hr_tex = make_tex(GL_RGBA8, new_hw, new_hh, ++ GL_RGBA, GL_UNSIGNED_BYTE); ++ new_scratch_tex = make_tex(GL_RGBA8, new_hw, new_hh, ++ GL_RGBA, GL_UNSIGNED_BYTE); ++ p_glGenRenderbuffers(1, &new_hr_rb); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_hr_rb); ++ p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, ++ new_hw, new_hh); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); ++ if (!new_hr_tex || !new_scratch_tex || !new_hr_rb || ++ !make_fbo(&new_hr_fbo, new_hr_tex, new_hr_rb) || ++ !make_fbo(&new_scratch_fbo, new_scratch_tex, 0)) { ++ goto fail; ++ } ++ ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ if (!s_wide_fbo[i]) continue; ++ if (g_wide_cur == s_wide_fbo[i]) current_wide_index = i; ++ const int wide_w = g_wide_w * scale; ++ new_wide_tex[i] = make_tex(GL_RGBA8, wide_w, new_hh, ++ GL_RGBA, GL_UNSIGNED_BYTE); ++ p_glGenRenderbuffers(1, &new_wide_rb[i]); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_wide_rb[i]); ++ p_glRenderbufferStorage(PSXGL_RENDERBUFFER, ++ PSXGL_DEPTH24_STENCIL8, wide_w, new_hh); ++ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); ++ if (!new_wide_tex[i] || !new_wide_rb[i] || ++ !make_fbo(&new_wide_fbo[i], new_wide_tex[i], ++ new_wide_rb[i])) { ++ goto fail; ++ } ++ } ++ ++ glDisable(GL_SCISSOR_TEST); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_hr_fbo); ++ p_glBlitFramebuffer(0, 0, VRAM_W * old_scale, VRAM_H * old_scale, ++ 0, 0, new_hw, new_hh, ++ GL_COLOR_BUFFER_BIT, GL_NEAREST); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ if (!new_wide_fbo[i]) continue; ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_wide_fbo[i]); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_wide_fbo[i]); ++ p_glBlitFramebuffer(0, 0, g_wide_w * old_scale, ++ VRAM_H * old_scale, ++ 0, 0, g_wide_w * scale, new_hh, ++ GL_COLOR_BUFFER_BIT, GL_NEAREST); ++ } ++ ++ { ++ GLuint old_hr_tex = s_hr_tex, old_hr_fbo = s_hr_fbo, ++ old_hr_rb = s_hr_rb; ++ GLuint old_scratch_tex = s_scratch_tex, ++ old_scratch_fbo = s_scratch_fbo; ++ GLuint old_wide_tex[WIDE_MAX_SURF]; ++ GLuint old_wide_fbo[WIDE_MAX_SURF]; ++ GLuint old_wide_rb[WIDE_MAX_SURF]; ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ old_wide_tex[i] = s_wide_tex[i]; ++ old_wide_fbo[i] = s_wide_fbo[i]; ++ old_wide_rb[i] = s_wide_rb[i]; ++ s_wide_tex[i] = new_wide_tex[i]; new_wide_tex[i] = 0; ++ s_wide_fbo[i] = new_wide_fbo[i]; new_wide_fbo[i] = 0; ++ s_wide_rb[i] = new_wide_rb[i]; new_wide_rb[i] = 0; ++ } ++ s_hr_tex = new_hr_tex; new_hr_tex = 0; ++ s_hr_fbo = new_hr_fbo; new_hr_fbo = 0; ++ s_hr_rb = new_hr_rb; new_hr_rb = 0; ++ s_scratch_tex = new_scratch_tex; new_scratch_tex = 0; ++ s_scratch_fbo = new_scratch_fbo; new_scratch_fbo = 0; ++ s_scale = scale; ++ s_req_scale = scale; ++ g_wide_cur = current_wide_index >= 0 ++ ? s_wide_fbo[current_wide_index] : 0; ++ ++ const float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; ++ p_glUseProgram(s_geo_prog); ++ p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); ++ p_glUseProgram(s_tex_prog); ++ p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); ++ p_glUseProgram(s_blit_prog); ++ p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), shift); ++ p_glUseProgram(0); ++ ++ rebuild_target_stencil(s_hr_fbo, new_hw, new_hh); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) { ++ if (s_wide_fbo[i]) ++ rebuild_target_stencil(s_wide_fbo[i], ++ g_wide_w * s_scale, new_hh); ++ } ++ s_stencil_valid = 1; ++ ++ delete_scale_target(&old_hr_fbo, &old_hr_tex, &old_hr_rb); ++ delete_scale_target(&old_scratch_fbo, &old_scratch_tex, NULL); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) ++ delete_scale_target(&old_wide_fbo[i], &old_wide_tex[i], ++ &old_wide_rb[i]); ++ } ++ ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ gl_renderer_invalidate_present(); ++ fprintf(stdout, "psxrecomp: live internal scale = %dx\n", s_scale); ++ return 1; ++ ++fail: ++ delete_scale_target(&new_hr_fbo, &new_hr_tex, &new_hr_rb); ++ delete_scale_target(&new_scratch_fbo, &new_scratch_tex, NULL); ++ for (int i = 0; i < WIDE_MAX_SURF; ++i) ++ delete_scale_target(&new_wide_fbo[i], &new_wide_tex[i], ++ &new_wide_rb[i]); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ fprintf(stderr, ++ "psxrecomp: live internal-scale allocation failed; keeping %dx\n", ++ s_scale); ++ return 0; ++} ++ + static int init_gpu_raster(void) { + s_scale = s_req_scale; +- +- s_geo_prog = build_program(GEO_VS, GEO_FS); +- s_tex_prog = build_program_ex(TEX_VS, TEX_FS, 1); ++ { ++ const char *full_yaw = getenv("PSX_GEOMETRY_FULL_YAW"); ++ const char *coverage_tint = getenv("PSX_GEOMETRY_COVERAGE_TINT"); ++ s_geometry_full_yaw = full_yaw && full_yaw[0] && full_yaw[0] != '0'; ++ s_geometry_coverage_tint = coverage_tint && coverage_tint[0] && ++ coverage_tint[0] != '0'; ++ } ++ ++ { ++ char *geo_vs = depth_writing_shader(GEO_VS, 4), *tex_vs = depth_writing_shader(TEX_VS, 12); ++ char *tex_fs = tex_vs ? sprite_marking_shader(TEX_FS) : NULL; ++ s_geo_prog = build_program(geo_vs ? geo_vs : GEO_VS, GEO_FS); ++ s_tex_prog = build_program_ex(tex_fs ? tex_vs : TEX_VS, tex_fs ? tex_fs : TEX_FS, 1); ++ free(geo_vs); ++ free(tex_vs); ++ free(tex_fs); ++ } + s_blit_prog = build_program(BLIT_VS, BLIT_FS); + s_pack_prog = build_program(PACK_VS, PACK_FS); + s_stencil_prog = build_program(PACK_VS, STENCIL_FS); +@@ -2335,12 +3201,21 @@ + s_geo_uXcenter = p_glGetUniformLocation(s_geo_prog, "u_xcenter"); + s_tex_uXscale = p_glGetUniformLocation(s_tex_prog, "u_xscale"); + s_tex_uXcenter = p_glGetUniformLocation(s_tex_prog, "u_xcenter"); ++ s_geo_uVisual = p_glGetUniformLocation(s_geo_prog, "u_visual"); ++ s_geo_uCamera = p_glGetUniformLocation(s_geo_prog, "u_camera"); ++ s_geo_uVisualOptions = p_glGetUniformLocation(s_geo_prog, "u_visual_options"); ++ s_tex_uVisual = p_glGetUniformLocation(s_tex_prog, "u_visual"); ++ s_tex_uCamera = p_glGetUniformLocation(s_tex_prog, "u_camera"); ++ s_tex_uVisualOptions = p_glGetUniformLocation(s_tex_prog, "u_visual_options"); ++ s_tex_uPull = p_glGetUniformLocation(s_tex_prog, "u_pull"); + /* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL would + * otherwise zero them, collapsing all x to 0. */ + p_glUseProgram(s_geo_prog); + p_glUniform1f(s_geo_uXscale, 1.0f); p_glUniform1f(s_geo_uXcenter, 0.0f); ++ geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); + p_glUseProgram(s_tex_prog); + p_glUniform1f(s_tex_uXscale, 1.0f); p_glUniform1f(s_tex_uXcenter, 0.0f); ++ geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); + + /* Sample-grid alignment shift: half an HR pixel, set once (S is fixed + * for the lifetime of the pipeline). Backed off by 1/64 native px so +@@ -2372,10 +3247,16 @@ + p_glBindVertexArray(s_geo_vao); + p_glGenBuffers(1, &s_geo_vbo); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); +- p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)0); ++ p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)0); + p_glEnableVertexAttribArray(0); +- p_glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void *)(2 * sizeof(float))); ++ p_glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(2 * sizeof(float))); + p_glEnableVertexAttribArray(1); ++ p_glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(6 * sizeof(float))); ++ p_glEnableVertexAttribArray(2); ++ p_glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(7 * sizeof(float))); ++ p_glEnableVertexAttribArray(3); ++ p_glVertexAttribPointer(4, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(9 * sizeof(float))); ++ p_glEnableVertexAttribArray(4); + + p_glGenVertexArrays(1, &s_tex_vao); + p_glBindVertexArray(s_tex_vao); +@@ -2392,6 +3273,10 @@ + p_glVertexAttribPointer(6, 1, GL_FLOAT, GL_FALSE, st, (void*)(13*sizeof(float))); p_glEnableVertexAttribArray(6); /* raw */ + p_glVertexAttribPointer(7, 4, GL_FLOAT, GL_FALSE, st, (void*)(14*sizeof(float))); p_glEnableVertexAttribArray(7); /* limits */ + p_glVertexAttribPointer(8, 1, GL_FLOAT, GL_FALSE, st, (void*)(18*sizeof(float))); p_glEnableVertexAttribArray(8); /* semi */ ++ p_glVertexAttribPointer(9, 1, GL_FLOAT, GL_FALSE, st, (void*)(19*sizeof(float))); p_glEnableVertexAttribArray(9); /* visual flags */ ++ p_glVertexAttribPointer(10, 2, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* visual pos */ ++ p_glVertexAttribPointer(11, 1, GL_FLOAT, GL_FALSE, st, (void*)(22*sizeof(float))); p_glEnableVertexAttribArray(11); /* reciprocal depth */ ++ p_glVertexAttribPointer(12, 1, GL_FLOAT, GL_FALSE, st, (void*)(23*sizeof(float))); p_glEnableVertexAttribArray(12); /* camera depth */ + } + + p_glGenVertexArrays(1, &s_blit_vao); +@@ -2433,6 +3318,12 @@ + gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ + fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx, " + "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale); ++ if (s_geometry_full_yaw || s_geometry_coverage_tint) { ++ fprintf(stdout, "psxrecomp: geometry diagnostics: full X/Y yaw %s, " ++ "exact-coverage tint %s (presentation mirror only)\n", ++ s_geometry_full_yaw ? "enabled" : "disabled", ++ s_geometry_coverage_tint ? "enabled" : "disabled"); ++ } + return 1; + } + +@@ -2467,11 +3358,60 @@ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + s_present_prog = build_program(PRESENT_VS, PRESENT_FS); ++ s_ssao_prog = build_program(PRESENT_VS, SSAO_FS); ++ s_ssao_mix_prog = build_program(PRESENT_VS, SSAO_MIX_FS); ++ { ++ const char *fill = getenv("PSX_GL_CRACK_FILL"), *under = getenv("PSX_GL_GROWN_UNDER"); ++ GLfloat widths[2] = {1.0f, 1.0f}; ++ glGetFloatv(0x846E /* GL_ALIASED_LINE_WIDTH_RANGE */, widths); ++ while (glGetError() != GL_NO_ERROR) {} ++ glLineWidth(2.0f); /* a forward compatible context refuses any width but 1 */ ++ s_line_width_max = glGetError() == GL_NO_ERROR && widths[1] > 1.0f ? widths[1] : 1.0f; ++ glLineWidth(1.0f); ++ if (fill) s_crack_fill = s_crack_width = (float)atof(fill); ++ if (under) s_grown_under = atoi(under) != 0; ++ if (getenv("PSX_GL_WORLD_DEPTH")) s_world_depth = atoi(getenv("PSX_GL_WORLD_DEPTH")) != 0; ++ if (getenv("PSX_GL_SHOWN_DUMP")) ++ gl_renderer_record_shown(getenv("PSX_GL_SHOWN_DUMP"), ++ getenv("PSX_GL_SHOWN_DUMP_SKIP") ? atoi(getenv("PSX_GL_SHOWN_DUMP_SKIP")) : 0, ++ getenv("PSX_GL_SHOWN_DUMP_COUNT") ? atoi(getenv("PSX_GL_SHOWN_DUMP_COUNT")) : 240, ++ getenv("PSX_GL_SHOWN_DUMP_WIDTH") ? atoi(getenv("PSX_GL_SHOWN_DUMP_WIDTH")) : 640); ++ if (getenv("PSX_GL_WORLD_DEPTH_MARGIN") && atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")) > 0.0) ++ s_world_margin = (float)atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")); ++ } ++ if (s_ssao_prog && s_ssao_mix_prog) { ++ const char *on = getenv("PSX_GL_SSAO"), *radius = getenv("PSX_GL_SSAO_RADIUS"), *strength = getenv("PSX_GL_SSAO_STRENGTH"); ++ s_ssao_mix_uTex = p_glGetUniformLocation(s_ssao_mix_prog, "u_tex"); ++ s_ssao_mix_uShade = p_glGetUniformLocation(s_ssao_mix_prog, "u_shade"); ++ s_ssao_mix_uUvRect = p_glGetUniformLocation(s_ssao_mix_prog, "u_uv_rect"); ++ s_ssao_mix_uSpan = p_glGetUniformLocation(s_ssao_mix_prog, "u_span"); ++ s_ssao_uDepth = p_glGetUniformLocation(s_ssao_prog, "u_depth"); ++ s_ssao_uTex = p_glGetUniformLocation(s_ssao_prog, "u_tex"); ++ s_ssao_uFade = p_glGetUniformLocation(s_ssao_prog, "u_fade"); ++ s_ssao_uUvRect = p_glGetUniformLocation(s_ssao_prog, "u_uv_rect"); ++ s_ssao_uTexel = p_glGetUniformLocation(s_ssao_prog, "u_span"); ++ s_ssao_uProj = p_glGetUniformLocation(s_ssao_prog, "u_proj"); ++ s_ssao_uAo = p_glGetUniformLocation(s_ssao_prog, "u_ao"); ++ if (on) { ++ s_ssao = atoi(on); ++ s_ssao_on = s_ssao == 1; ++ s_ssao_view = s_ssao > 1 && s_ssao <= 5 ? s_ssao - 1 : 0; ++ } ++ if (radius && atof(radius) > 0.0) s_ssao_radius = (float)atof(radius); ++ if (strength && atof(strength) > 0.0) s_ssao_strength = (float)atof(strength); ++ if (getenv("PSX_GL_SSAO_TILT") && atof(getenv("PSX_GL_SSAO_TILT")) >= 0.0 && atof(getenv("PSX_GL_SSAO_TILT")) < 0.95) ++ s_ssao_tilt = (float)atof(getenv("PSX_GL_SSAO_TILT")); ++ } ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + s_interp_prog = build_program(PRESENT_VS, INTERP_FS); + if (s_present_prog && s_interp_prog) { ++#else ++ if (s_present_prog) { ++#endif + p_glGenVertexArrays(1, &s_present_vao); + s_present_uTex = p_glGetUniformLocation(s_present_prog, "u_tex"); + s_present_uUvRect = p_glGetUniformLocation(s_present_prog, "u_uv_rect"); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); + s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); + s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); +@@ -2484,6 +3424,7 @@ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } ++#endif + } else ok = 0; + } + if (ok) ok = init_gpu_raster(); +@@ -2496,20 +3437,28 @@ + return 1; + } + +-/* Set the GL swap interval (vsync mode): 1=vsync, 0=immediate, -1=adaptive. +- * Safe to call before or after context creation; applies live when a context +- * exists. Adaptive falls back to vsync if unsupported. */ +-void gl_renderer_set_swap_interval(int interval) { +- s_swap_interval = interval; +- if (s_ctx) { +- if (SDL_GL_SetSwapInterval(interval) != 0 && interval < 0) { +- SDL_GL_SetSwapInterval(1); +- s_swap_interval = 1; +- } +- } +-} +- ++static void apply_swap_interval(void) { ++ if (!s_ctx) return; ++ /* The interpolation thread owns host-refresh presentation and requires ++ * immediate swaps. Preserve the user's requested interval separately so ++ * disabling interpolation can restore it without a renderer restart. */ ++ int effective = s_interp_enabled ? 0 : s_swap_interval; ++ if (SDL_GL_SetSwapInterval(effective) != 0 && effective < 0) { ++ SDL_GL_SetSwapInterval(1); ++ s_swap_interval = 1; ++ } ++} ++ ++/* Set the requested GL swap interval (1=vsync, 0=immediate, -1=adaptive). ++ * Safe before or after context creation. While interpolation is active the ++ * request is remembered and physical swapping stays immediate. */ ++void gl_renderer_set_swap_interval(int interval) { ++ s_swap_interval = interval; ++ apply_swap_interval(); ++} ++ + void gl_renderer_shutdown(void) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (s_interp_thread) { + SDL_AtomicSet(&s_interp_thread_run, 0); + SDL_WaitThread(s_interp_thread, NULL); +@@ -2532,12 +3481,25 @@ + } + } + ensure_cpu(); ++ temporal_release(); + SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; + } + if (s_interp_mutex) { + SDL_DestroyMutex(s_interp_mutex); + s_interp_mutex = NULL; + } ++ s_interp_enabled = 0; ++ s_interp_suspended = 0; ++ s_interp_content_suspended = 0; ++ s_interp_host_ui_suspended = 0; ++ interp_reset_history_unlocked(); ++#else ++ if (s_ctx) { ++ SDL_GL_MakeCurrent(s_win, s_ctx); ++ ensure_cpu(); ++ SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; ++ } ++#endif + free(s_conv); s_conv = NULL; + s_raster_ok = 0; + /* New context regenerates s_present_tex empty; a stale size makes +@@ -2549,16 +3511,36 @@ + rect_clear(&s_d24_skip_fb); + } + ++/* Host UI callbacks are allowed to change live renderer settings. Those ++ * setters can flush queued geometry and consequently bind an internal FBO and ++ * its VRAM-sized viewport while ImGui is already building the current frame. ++ * Reassert the window target after the callback so the state cannot leak into ++ * this swap (or into ImGui's next-frame state backup). */ ++static void render_host_ui_for_swap(int drawable_w, int drawable_h) { ++ if (!psx_host_ui_gl_visible()) return; ++ psx_host_ui_render_gl(drawable_w, drawable_h); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ glViewport(0, 0, drawable_w, drawable_h); ++} ++ + /* CPU-readout present (24-bit FMV frames and the PSX_GL_FORCE_CPU_PRESENT + * diagnostic): full-window clear, then a quad into the letterbox rect. + * force_4_3 pins the rect to native 4:3 regardless of the display aspect — + * FMVs are authored 4:3 and have no GTE squash to compensate a stretch, so + * widescreen presents them pillarboxed instead of distorted. */ ++/* Rows of the window above the screen (host_borderless.h): the picture goes in the rest, which starts at the bottom row as OpenGL counts. */ ++static int s_hidden_rows; ++void gl_renderer_set_hidden_rows(int rows) { s_hidden_rows = rows > 0 ? rows : 0; } ++static void gl_visible_size(int *w, int *h) { ++ SDL_GL_GetDrawableSize(s_win, w, h); ++ if (*h > s_hidden_rows) *h -= s_hidden_rows; ++} ++ + void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linear, + int force_4_3, int content_w) { + if (!s_ctx) return; + interp_reset_history(); +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); + glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); +@@ -2608,6 +3590,8 @@ + p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); + p_glBindVertexArray(0); p_glUseProgram(0); + pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -2618,10 +3602,12 @@ + void gl_renderer_present_blank(void) { + if (!s_ctx) return; + interp_reset_history(); +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); + pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -2749,6 +3735,7 @@ + * ------------------------------------------------------------------------- */ + + static void wide_free_all(void) { ++ interp_reset_history(); + for (int i = 0; i < WIDE_MAX_SURF; i++) { + if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } + if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } +@@ -2802,9 +3789,11 @@ + static void glb_wide_configure(int wide_w, int offset) { + if (!s_raster_ok) return; + double t0 = cw_ms(); s_cw_wide_cfgs++; ++ flush_flat_batch(); + flush_tex_batch(); /* a queued batch's wide mirror targets the CURRENT surfaces */ + if (wide_w <= 0) { wide_free_all(); g_wide_w = 0; g_wide_off = 0; s_cw_wide_ms += cw_ms() - t0; return; } + if (wide_w != g_wide_w) wide_free_all(); ++ if (offset != g_wide_off) interp_reset_history(); + g_wide_w = wide_w; + g_wide_off = offset; + s_cw_wide_ms += cw_ms() - t0; +@@ -2814,6 +3803,7 @@ + static void glb_wide_set_target(int base_x) { + if (!s_raster_ok) { g_wide_cur = 0; return; } + double t0 = cw_ms(); s_cw_wide_sets++; ++ flush_flat_batch(); + flush_tex_batch(); /* drain into the OLD target before switching */ + g_wide_cur = wide_fbo_for(base_x); + g_wide_cur_base = base_x; +@@ -2821,7 +3811,7 @@ + } + + /* Stop mirroring (offscreen draws that don't target a framebuffer). */ +-static void glb_wide_disable_target(void) { flush_tex_batch(); g_wide_cur = 0; } ++static void glb_wide_disable_target(void) { flush_flat_batch(); flush_tex_batch(); g_wide_cur = 0; } + + /* Mirror a framebuffer clear: fill the full wide width over [y, y+h) of the + * surface for base_x, so the revealed margins are clean. Mirrors sw_wide_clear: +@@ -2829,9 +3819,11 @@ + static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { + if (!s_raster_ok || s_ws_ablate == 1) return; + double t0 = cw_ms(); s_cw_wide_clears++; ++ flush_flat_batch(); + flush_tex_batch(); + GLuint fbo = wide_fbo_for(base_x); + if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } ++ temporal_begin(fbo, y, h, color); + gl_perf_mirror_begin(); + int H = VRAM_H * s_scale; + int y0 = y * s_scale, y1 = (y + h) * s_scale; +@@ -2849,6 +3841,13 @@ + glClearColor(r, g, b, a); + glClearStencil((color >> 15) & 1); /* stencil mirrors bit15, like the color alpha */ + glStencilMask(0xFF); ++ if (s_world_depth && mirror_depth()) { /* under the depth test a frame never shown must not leave its depth to the next */ ++ glDepthMask(GL_TRUE); ++ glClearDepth(1.0); ++ glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] &= ~wide_depth_rows(y, h); ++ } else + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); +@@ -2861,9 +3860,11 @@ + static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { + if (!s_raster_ok || s_ws_ablate == 1 || g_wide_off <= 0) return; + double t0 = cw_ms(); s_cw_wide_clears++; ++ flush_flat_batch(); + flush_tex_batch(); + GLuint fbo = wide_fbo_for(base_x); + if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } ++ temporal_invalidate_surface(fbo); + gl_perf_mirror_begin(); + int H = VRAM_H * s_scale; + int W = g_wide_w * s_scale; +@@ -3236,10 +4237,153 @@ + } + + /* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ +-void gl_renderer_set_ws_ablate(int mode) { s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } ++void gl_renderer_set_ws_ablate(int mode) { interp_reset_history(); s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } + int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } + ++extern void gte_ws_x_squash(int32_t *num, int32_t *den); ++ ++/* What the shading draws through. Frame buffer objects belong to one GL context: the frame loop's and the in-between presenter's. */ ++typedef struct { ++ GLuint depth_fbo, depth, shade_fbo, shade; ++ int w, h, shade_w, shade_h; ++} SsaoTargets; ++static SsaoTargets s_ssao_main, s_ssao_thread; ++ ++static GLuint ssao_depth_texture(int w, int h) { ++ GLuint made = 0; ++ glGenTextures(1, &made); ++ glBindTexture(GL_TEXTURE_2D, made); ++ glTexImage2D(GL_TEXTURE_2D, 0, PSXGL_DEPTH24_STENCIL8, w, h, 0, 0x84F9 /* GL_DEPTH_STENCIL */, 0x84FA /* GL_UNSIGNED_INT_24_8 */, NULL); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); ++ return made; ++} ++ ++static int ssao_targets(SsaoTargets *t, int w, int h, int lw, int lh, int copies) { ++ if (copies && (!t->depth_fbo || t->w != w || t->h != h)) { ++ if (t->depth_fbo) p_glDeleteFramebuffers(1, &t->depth_fbo); ++ if (t->depth) glDeleteTextures(1, &t->depth); ++ t->depth_fbo = 0; ++ t->depth = ssao_depth_texture(w, h); ++ p_glGenFramebuffers(1, &t->depth_fbo); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->depth_fbo); ++ p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, t->depth, 0); ++ glDrawBuffer(GL_NONE); ++ glReadBuffer(GL_NONE); ++ if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) return 0; ++ t->w = w; ++ t->h = h; ++ } ++ if (!t->shade_fbo || t->shade_w != lw || t->shade_h != lh) { ++ if (t->shade_fbo) p_glDeleteFramebuffers(1, &t->shade_fbo); ++ if (t->shade) glDeleteTextures(1, &t->shade); ++ t->shade_fbo = 0; ++ t->shade = make_tex(GL_RGBA8, lw, lh, GL_RGBA, GL_UNSIGNED_BYTE); ++ if (!t->shade || !make_fbo(&t->shade_fbo, t->shade, 0)) return 0; ++ t->shade_w = lw; ++ t->shade_h = lh; ++ } ++ return 1; ++} ++ ++/* Shades `color` by a depth of the same size and puts it on screen. The depth is `depth`, or with 0 is first copied from the ++ * frame buffer `from`. uv is the part shown. 0: nothing drawn, the caller presents plainly. */ ++static int ssao_draw(SsaoTargets *t, GLuint vao, GLuint from, GLuint depth, GLuint color, int w, int h, ++ float u0, float v0, float u1, float v1, int scale, int linear, int lx, int ly, int lw, int lh) { ++ int32_t num = 1, den = 1; ++ float focal_x, focal_y; ++ if (!s_ssao || !s_geometry_correction || !s_ssao_prog || !s_ssao_mix_prog || lw <= 0 || lh <= 0 || w <= 0 || h <= 0) return 0; ++ if (!ssao_targets(t, w, h, lw, lh, depth == 0)) { ++ fprintf(stdout, "psxrecomp: GL ambient occlusion has no targets, switched off\n"); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ s_ssao = s_ssao_on = s_ssao_view = 0; ++ return 0; ++ } ++ glDisable(GL_SCISSOR_TEST); ++ if (!depth) { ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, from); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, t->depth_fbo); ++ p_glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, GL_DEPTH_BUFFER_BIT, GL_NEAREST); ++ depth = t->depth; ++ } ++ gte_ws_x_squash(&num, &den); ++ focal_x = s_yaw_focal_x * (float)scale; ++ focal_y = num > 0 && den > 0 ? focal_x * (float)den / (float)num : focal_x; ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->shade_fbo); ++ glViewport(0, 0, lw, lh); ++ glDisable(GL_BLEND); ++ glDisable(GL_STENCIL_TEST); ++ glDisable(GL_DEPTH_TEST); ++ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); ++ p_glActiveTexture(PSXGL_TEXTURE0 + 1); ++ glBindTexture(GL_TEXTURE_2D, color); ++ p_glActiveTexture(PSXGL_TEXTURE0); ++ glBindTexture(GL_TEXTURE_2D, depth); ++ p_glUseProgram(s_ssao_prog); ++ p_glUniform1i(s_ssao_uDepth, 0); ++ p_glUniform1i(s_ssao_uTex, 1); ++ p_glUniform4f(s_ssao_uUvRect, u0, v0, u1, v1); ++ p_glUniform4f(s_ssao_uTexel, 1.0f / (float)w, 1.0f / (float)h, (float)s_ssao, 0.0f); ++ p_glUniform4f(s_ssao_uProj, focal_x, focal_y, (u0 + u1) * 0.5f * (float)w, (v0 + v1) * 0.5f * (float)h); ++ p_glUniform4f(s_ssao_uAo, s_ssao_radius, s_ssao_strength, 24.0f * (float)scale, (float)scale); ++ p_glUniform4f(s_ssao_uFade, s_ssao_fade_from, s_ssao_fade_to, s_ssao_tilt, 0.0f); ++ p_glBindVertexArray(vao); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ glViewport(lx, ly, lw, lh); ++ p_glActiveTexture(PSXGL_TEXTURE0 + 1); ++ glBindTexture(GL_TEXTURE_2D, t->shade); ++ p_glActiveTexture(PSXGL_TEXTURE0); ++ glBindTexture(GL_TEXTURE_2D, color); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); ++ p_glUseProgram(s_ssao_mix_prog); ++ p_glUniform1i(s_ssao_mix_uTex, 0); ++ p_glUniform1i(s_ssao_mix_uShade, 1); ++ p_glUniform4f(s_ssao_mix_uUvRect, u0, v0, u1, v1); ++ p_glUniform4f(s_ssao_mix_uSpan, (float)lx, (float)ly, (float)s_ssao, 0.0f); ++ glDrawArrays(GL_TRIANGLES, 0, 3); ++ p_glBindVertexArray(0); ++ p_glUseProgram(0); ++ return 1; ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++/* The depth of each captured real frame, for the presenter's thread to shade it by. */ ++static GLuint s_interp_depth[3], s_interp_depth_fbo; ++static int s_interp_depth_w, s_interp_depth_h; ++static int s_temp_scene_depth; /* the replay is drawing its scene, whose opaque fragments alone leave depth while the shading is on */ ++ ++static void interp_capture_depth(GLuint fbo, int x, int y, int h, int pw, int ph, int dst) { ++ if (!s_ssao || !s_geometry_correction) return; ++ if (!s_interp_depth[0] || s_interp_depth_w != pw || s_interp_depth_h != ph) { ++ for (int i = 0; i < 3; i++) { ++ if (s_interp_depth[i]) glDeleteTextures(1, &s_interp_depth[i]); ++ s_interp_depth[i] = ssao_depth_texture(pw, ph); ++ } ++ s_interp_depth_w = pw; ++ s_interp_depth_h = ph; ++ } ++ if (!s_interp_depth_fbo) p_glGenFramebuffers(1, &s_interp_depth_fbo); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_interp_depth_fbo); ++ p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, s_interp_depth[dst], 0); ++ glDrawBuffer(GL_NONE); ++ glDisable(GL_SCISSOR_TEST); ++ p_glBlitFramebuffer(x * s_scale, y * s_scale, x * s_scale + pw, y * s_scale + ph, 0, 0, pw, ph, GL_DEPTH_BUFFER_BIT, GL_NEAREST); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); ++} ++#endif ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++#include "gpu_gl_temporal.c.inc" ++ + static void interp_reset_history_unlocked(void) { ++ temporal_reset(); + s_interp_valid = 0; + s_interp_w = s_interp_h = 0; + s_interp_start = s_interp_last_capture = 0; +@@ -3257,9 +4401,10 @@ + int blend_mode) { + double effective_hz = target_hz < 0.0 + ? -1.0 +- : (target_hz >= 60.0 ? target_hz : host_hz); ++ : (target_hz >= 30.0 ? target_hz : host_hz); + int active = (enabled && +- (effective_hz < 0.0 || effective_hz >= 50.0)) ? 1 : 0; ++ (blend_mode != 2 || s_geometry_correction) && ++ (effective_hz < 0.0 || effective_hz >= 30.0)) ? 1 : 0; + const char *diag = getenv("PSX_GL_INTERP_DIAG"); + s_interp_diag = diag && diag[0] && diag[0] != '0'; + if (active && !s_interp_ctx && s_ctx) { +@@ -3273,6 +4418,7 @@ + * FMV temporarily suspends interpolation and the main context presents. */ + SDL_GL_SetSwapInterval(0); + if (s_interp_ctx && s_interp_mutex) { ++ SDL_AtomicSet(&s_temp_worker_ready, 0); + SDL_AtomicSet(&s_interp_thread_run, 1); + s_interp_thread = SDL_CreateThread(interp_thread_main, + "psx-gl-interp", NULL); +@@ -3284,31 +4430,52 @@ + active = 0; + } + } ++ if (active && s_interp_thread) { ++ while (!SDL_AtomicGet(&s_temp_worker_ready)) SDL_Delay(1); ++ if (blend_mode == 2 && SDL_AtomicGet(&s_temp_worker_ready) < 0) active = 0; ++ } + if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); +- if (active != s_interp_enabled) interp_reset_history_unlocked(); ++ if (active != s_interp_enabled || blend_mode != s_interp_blend_mode) interp_reset_history_unlocked(); + s_interp_enabled = active; + s_interp_host_hz = host_hz; + s_interp_target_hz = active ? effective_hz : 0.0; +- s_interp_blend_mode = blend_mode == 1 ? 1 : 0; ++ s_interp_blend_mode = blend_mode == 2 ? 2 : blend_mode == 1 ? 1 : 0; + if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); ++ /* Enabling interpolation forces immediate swaps; disabling restores the ++ * last user-requested interval kept in s_swap_interval. */ ++ apply_swap_interval(); + if (active && effective_hz < 0.0) + fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " +- "target on %.1f Hz display (%s blend)\n", host_hz, +- s_interp_blend_mode ? "motion-adaptive" : "linear"); ++ "target on %.1f Hz display (%s)\n", host_hz, ++ s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); + else if (active) + fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " +- "target on %.1f Hz display (%s blend)\n", effective_hz, host_hz, +- s_interp_blend_mode ? "motion-adaptive" : "linear"); ++ "target on %.1f Hz display (%s)\n", effective_hz, host_hz, ++ s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); + else + fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); + } + +-void gl_renderer_set_interpolation_suspended(int suspended) { ++static void interp_set_suspended(int *reason, int suspended) { + suspended = suspended ? 1 : 0; + if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); +- if (suspended != s_interp_suspended) interp_reset_history_unlocked(); +- s_interp_suspended = suspended; ++ const int was_suspended = s_interp_suspended; ++ *reason = suspended; ++ s_interp_suspended = ++ s_interp_content_suspended || s_interp_host_ui_suspended; ++ if (was_suspended != s_interp_suspended) interp_reset_history_unlocked(); + if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); ++} ++ ++void gl_renderer_set_interpolation_suspended(int suspended) { ++ interp_set_suspended(&s_interp_content_suspended, suspended); ++} ++ ++/* Host UI suspension is independent of content/FMV suspension. Keeping two ++ * reasons prevents closing a menu during an FMV from briefly waking the ++ * interpolation thread on the shared window. */ ++void gl_renderer_set_host_ui_suspended(int suspended) { ++ interp_set_suspended(&s_interp_host_ui_suspended, suspended); + } + + void gl_renderer_interpolation_diag(int *enabled, int *suspended, +@@ -3325,12 +4492,69 @@ + if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); + } + ++void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { ++ if (!out) return; ++ if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); ++ out[0] = s_temp_published; ++ out[1] = s_temp_replays; ++ out[2] = s_temp_fallbacks; ++ out[3] = (uint64_t)s_temp_last_matched; ++ out[4] = (uint64_t)s_temp_last_world; ++ out[5] = (uint64_t)s_temp_last_ready; ++ if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); ++} ++ ++/* PSX_GL_INTERP_DIAG: spacing of presents in ms, and of the phase they were ++ * drawn at (captures plus the alpha drawn) in game frames per present. A step ++ * near 0 drew one phase twice, a step near 1 skipped most of an interval. ++ * Pictures are not compared: a replay that moves nothing still advances. */ ++typedef struct { double sum, squares, low, high; int count; } InterpSpread; ++static InterpSpread s_interp_gap, s_interp_step, s_interp_hold, s_interp_work; ++static InterpSpread s_interp_wake, s_interp_wait, s_interp_draw; ++static int s_interp_holds, s_interp_jumps, s_interp_late, s_interp_settle; ++static uint64_t s_interp_last_swap; ++static double s_interp_last_phase; ++static void interp_spread_add(InterpSpread *s, double value) { ++ if (!s->count || value < s->low) s->low = value; ++ if (!s->count || value > s->high) s->high = value; ++ s->sum += value; s->squares += value * value; s->count++; ++} ++static double interp_spread_deviation(const InterpSpread *s) { ++ const double mean = s->count ? s->sum / s->count : 0.0; ++ const double variance = s->count ? s->squares / s->count - mean * mean : 0.0; ++ return variance > 0.0 ? sqrt(variance) : 0.0; ++} ++ + /* Copy a stable display image out of the mutable VRAM/wide render target. + * Returns true once both previous and current images are available. */ + static int interp_capture(GLuint fbo, int x, int y, int w, int h, + int linear, int force_4_3, int source_path) { + if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; ++ uint64_t began = 0; ++ int under_lock = 0; ++#ifndef PSX_NO_DEBUG_TOOLS ++ { /* A/B: PSX_GL_TEMPORAL_MATCH_LOCKED=1 matches under the lock as before. */ ++ static int cached = -1; ++ if (cached < 0) { const char *e = getenv("PSX_GL_TEMPORAL_MATCH_LOCKED"); cached = e && e[0] == '1'; } ++ under_lock = cached; ++ } ++#endif ++ if (s_interp_blend_mode == 2 && !under_lock) { ++ began = SDL_GetPerformanceCounter(); ++ temporal_prepare(fbo, y, w, h); ++ if (s_interp_diag) ++ began = SDL_GetPerformanceCounter() - began; ++ } + SDL_LockMutex(s_interp_mutex); ++ const uint64_t locked = SDL_GetPerformanceCounter(); ++ if (s_interp_blend_mode == 2 && under_lock) { ++ began = SDL_GetPerformanceCounter(); ++ temporal_prepare(fbo, y, w, h); ++ began = SDL_GetPerformanceCounter() - began; ++ } ++ if (s_interp_diag && s_interp_blend_mode == 2) ++ interp_spread_add(&s_interp_work, (double)began * 1000.0 / ++ (double)SDL_GetPerformanceFrequency()); + /* The presentation context may still have a draw queued which samples one + * of the shared history textures. Order this context's next allocation or + * copy after that draw before recycling a texture. glWaitSync keeps the +@@ -3343,6 +4567,7 @@ + int pw = w * s_scale, ph = h * s_scale; + if (pw != s_interp_w || ph != s_interp_h || + source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { ++ temporal_reset(); + s_interp_valid = 0; + s_interp_w = pw; s_interp_h = ph; + s_interp_prev = s_interp_cur = 0; +@@ -3365,6 +4590,22 @@ + glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); + glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, + x * s_scale, y * s_scale, pw, ph); ++ wide_depth_shown(fbo, y, h); ++ if (s_interp_blend_mode == 2) interp_capture_depth(fbo, x, y, h, pw, ph, dst); ++ if (s_interp_blend_mode == 2) temporal_publish(fbo, y, h); ++#ifndef PSX_NO_DEBUG_TOOLS ++ if (s_interp_blend_mode == 2 && s_temp_curr.active) ++ temporal_dump_frame(x * s_scale, y * s_scale, pw, ph, -1.0f, ++ s_temp_curr.mesh_stats.departed_faces); ++ if (s_interp_blend_mode == 2 && temporal_edge_diag() && s_temp_curr.active) { ++ const int left = temporal_edge_gap((x + 1) * s_scale, y * s_scale, ph, s_temp_curr.clear); ++ const int right = temporal_edge_gap((x + w - 2) * s_scale, y * s_scale, ph, s_temp_curr.clear); ++ fprintf(stdout, "psxrecomp: edge real %llu %d %d %d\n", ++ (unsigned long long)s_temp_published, left, right, ph); ++ if ((left > right ? left : right) * 33 > ph) ++ temporal_dump_edge(x * s_scale, y * s_scale, pw, ph); ++ } ++#endif + s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); +@@ -3388,21 +4629,41 @@ + s_interp_cur = dst; + s_interp_valid = 2; + } ++ s_interp_scale = s_scale; + s_interp_linear = linear; + s_interp_force_4_3 = force_4_3; + s_interp_source_path = source_path; + s_interp_captures++; + int ready = s_interp_valid >= 2; ++ if (s_interp_diag) ++ interp_spread_add(&s_interp_hold, (double)(SDL_GetPerformanceCounter() - locked) * 1000.0 / ++ (double)SDL_GetPerformanceFrequency()); + SDL_UnlockMutex(s_interp_mutex); + return ready; + } + +-static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { ++/* Returns the alpha of the picture it drew, which is 1 when the replay fell back. */ ++static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { ++ if (s_interp_blend_mode == 2) { ++ temporal_verify_endpoint(lx, ly, lw, lh); ++#ifndef PSX_NO_DEBUG_TOOLS ++ alpha = temporal_dump_alpha(alpha); ++#endif ++ if (alpha < 1.0f && temporal_present(alpha, lx, ly, lw, lh)) return alpha; ++ alpha = 1.0f; /* Unsafe/incomplete geometry: current frame, never a crossfade. */ ++ } + int prev = s_interp_prev, curr = s_interp_cur; + if (s_interp_fence[prev]) + p_glWaitSync(s_interp_fence[prev], 0, PSXGL_TIMEOUT_IGNORED); + if (s_interp_fence[curr]) + p_glWaitSync(s_interp_fence[curr], 0, PSXGL_TIMEOUT_IGNORED); ++ if (s_interp_blend_mode == 2 && alpha >= 1.0f && s_interp_depth[curr] && ++ s_interp_depth_w == s_interp_w && s_interp_depth_h == s_interp_h) { ++ const float ix = 0.5f * s_interp_scale / s_interp_w, iy = 0.5f * s_interp_scale / s_interp_h; ++ if (ssao_draw(&s_ssao_thread, s_interp_thread_vao, 0, s_interp_depth[curr], s_interp_tex[curr], s_interp_w, s_interp_h, ++ ix, iy, 1 - ix, 1 - iy, s_interp_scale, s_interp_linear, lx, ly, lw, lh)) ++ return alpha; ++ } + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + glViewport(lx, ly, lw, lh); + p_glActiveTexture(PSXGL_TEXTURE0); +@@ -3418,12 +4679,135 @@ + p_glUniform1i(s_interp_uCurr, 1); + p_glUniform1f(s_interp_uAlpha, alpha); + p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); +- p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); ++ if (s_interp_blend_mode == 2) { ++ const float ix=0.5f*s_interp_scale/s_interp_w, iy=0.5f*s_interp_scale/s_interp_h; ++ p_glUniform4f(s_interp_uUvRect,ix,iy,1-ix,1-iy); ++ } else p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); + p_glBindVertexArray(s_interp_thread_vao); + glDrawArrays(GL_TRIANGLES, 0, 3); + p_glBindVertexArray(0); + p_glUseProgram(0); + p_glActiveTexture(PSXGL_TEXTURE0); ++ return alpha; ++} ++ ++/* What reaches the screen, for a report: each swapped picture is read back at the asked width (the window's at most) with ++ * its time and kept in memory, then written as PPM files and a list when the count is reached. Returns 0 when refused. */ ++static char s_shown_dir[512]; ++static int s_shown_skip = 0, s_shown_left = 0, s_shown_count = 0, s_shown_w = 0, s_shown_h = 0, s_shown_width = 640; ++static uint8_t *s_shown_pixels = NULL; ++static struct ShownMeta { uint64_t tick; float alpha; int kind; int placed[7]; } *s_shown_meta = NULL; ++ ++int gl_renderer_record_shown(const char *folder, int skip, int count, int width) { ++ if (!folder || !folder[0] || strlen(folder) >= sizeof s_shown_dir || count <= 0 || count > 2000 || width < 160 || ++ width > 3840 || s_shown_left > 0) return 0; ++ snprintf(s_shown_dir, sizeof s_shown_dir, "%s", folder); ++ s_shown_width = width; ++ s_shown_skip = skip > 0 ? skip : 0; ++ s_shown_count = 0; ++ s_shown_w = s_shown_h = 0; ++ s_shown_left = count; ++ return 1; ++} ++int gl_renderer_recording_shown(void) { return s_shown_left > 0; } ++ ++static void shown_write(void) { ++ char path[640]; ++ FILE *list; ++ snprintf(path, sizeof path, "%s/shown.csv", s_shown_dir); ++ list = fopen(path, "wb"); ++ if (list) fprintf(list, "index,kind,alpha,microseconds,faces,camera_samples,camera_inliers,carried,held,conflicting,departed\n"); ++ for (int i = 0; i < s_shown_count; ++i) { ++ FILE *file; ++ snprintf(path, sizeof path, "%s/shown_%04d.ppm", s_shown_dir, i); ++ file = fopen(path, "wb"); ++ if (file) { ++ fprintf(file, "P6\n%d %d\n255\n", s_shown_w, s_shown_h); ++ for (int y = s_shown_h - 1; y >= 0; --y) ++ fwrite(s_shown_pixels + ((size_t)i * s_shown_h + y) * s_shown_w * 3, 1, (size_t)s_shown_w * 3, file); ++ fclose(file); ++ } ++ if (list) ++ fprintf(list, "%d,%s,%.3f,%.0f,%d,%d,%d,%d,%d,%d,%d\n", i, s_shown_meta[i].kind ? "between" : "frame", s_shown_meta[i].alpha, ++ (double)(s_shown_meta[i].tick - s_shown_meta[0].tick) * 1e6 / (double)SDL_GetPerformanceFrequency(), ++ s_shown_meta[i].placed[0], s_shown_meta[i].placed[1], s_shown_meta[i].placed[2], s_shown_meta[i].placed[3], ++ s_shown_meta[i].placed[4], s_shown_meta[i].placed[5], s_shown_meta[i].placed[6]); ++ } ++ if (list) fclose(list); ++ free(s_shown_pixels); ++ free(s_shown_meta); ++ s_shown_pixels = NULL; ++ s_shown_meta = NULL; ++} ++ ++/* Called with the finished picture in the back buffer, right before its swap. kind: 0 a game frame's own swap, 1 the ++ * in-between presenter's, whose alpha is the picture's place between the last two game frames (1 is the later one). */ ++static void shown_capture(int kind, float alpha) { ++ int ww = 0, wh = 0; ++ GLint bound = 0; ++ GLuint fbo = 0, texture = 0; ++ const GLboolean scissor = glIsEnabled(GL_SCISSOR_TEST); ++ if (s_shown_left <= 0) return; ++ if (s_shown_skip > 0) { --s_shown_skip; return; } ++ gl_visible_size(&ww, &wh); ++ if (ww <= 0 || wh <= 0) return; ++ if (!s_shown_pixels) { ++ s_shown_w = s_shown_width < ww ? s_shown_width : ww; ++ s_shown_h = s_shown_w * wh / ww > 0 ? s_shown_w * wh / ww : 1; ++ if (s_shown_h > 0) { ++ s_shown_pixels = (uint8_t *)malloc((size_t)s_shown_left * s_shown_w * s_shown_h * 3); ++ s_shown_meta = (struct ShownMeta *)malloc((size_t)s_shown_left * sizeof *s_shown_meta); ++ } ++ if (!s_shown_pixels || !s_shown_meta) { ++ free(s_shown_pixels); ++ free(s_shown_meta); ++ s_shown_pixels = NULL; ++ s_shown_meta = NULL; ++ s_shown_left = 0; ++ return; ++ } ++ } ++ glGetIntegerv(GL_TEXTURE_BINDING_2D, &bound); ++ glGenTextures(1, &texture); ++ glBindTexture(GL_TEXTURE_2D, texture); ++ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s_shown_w, s_shown_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); ++ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); ++ p_glGenFramebuffers(1, &fbo); ++ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, fbo); ++ p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); ++ glReadBuffer(GL_BACK); ++ glDisable(GL_SCISSOR_TEST); ++ p_glBlitFramebuffer(0, 0, ww, wh, 0, 0, s_shown_w, s_shown_h, GL_COLOR_BUFFER_BIT, GL_LINEAR); ++ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); ++ glPixelStorei(GL_PACK_ALIGNMENT, 1); ++ glReadPixels(0, 0, s_shown_w, s_shown_h, GL_RGB, GL_UNSIGNED_BYTE, ++ s_shown_pixels + (size_t)s_shown_count * s_shown_w * s_shown_h * 3); ++ glPixelStorei(GL_PACK_ALIGNMENT, 4); ++ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); ++ p_glDeleteFramebuffers(1, &fbo); ++ glBindTexture(GL_TEXTURE_2D, (GLuint)bound); ++ glDeleteTextures(1, &texture); ++ if (scissor) glEnable(GL_SCISSOR_TEST); ++ s_shown_meta[s_shown_count].tick = SDL_GetPerformanceCounter(); ++ s_shown_meta[s_shown_count].alpha = alpha; ++ s_shown_meta[s_shown_count].kind = kind; ++ memset(s_shown_meta[s_shown_count].placed, 0, sizeof s_shown_meta[s_shown_count].placed); ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ if (kind) { ++ int *placed = s_shown_meta[s_shown_count].placed; ++ placed[0] = s_temp_shown_faces; ++ placed[1] = s_temp_shown_stats.camera_samples; ++ placed[2] = s_temp_shown_stats.camera_inliers; ++ placed[3] = s_temp_shown_stats.reprojected_vertices; ++ placed[4] = s_temp_shown_stats.held_vertices; ++ placed[5] = s_temp_shown_stats.conflicting_vertices; ++ placed[6] = s_temp_shown_stats.departed_faces; ++ } ++#endif ++ ++s_shown_count; ++ if (--s_shown_left == 0) shown_write(); + } + + static int interp_present(void) { +@@ -3438,7 +4822,7 @@ + if (a > 1.0) a = 1.0; + if (a < 0.0) a = 0.0; + +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + int lx, ly, lw, lh; + if (s_interp_force_4_3) + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); +@@ -3450,22 +4834,44 @@ + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + } +- interp_draw_quad((float)a, lx, ly, lw, lh); ++ const float shown = interp_draw_quad((float)a, lx, ly, lw, lh); + if (s_interp_draw_fence) p_glDeleteSync(s_interp_draw_fence); + s_interp_draw_fence = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, + lx, ly, lw, lh); ++ shown_capture(1, shown); + SDL_GL_SwapWindow(s_win); + s_interp_swaps++; ++ if (s_interp_diag) { ++ const uint64_t swapped = SDL_GetPerformanceCounter(); ++ const double phase = (double)s_interp_captures + shown; ++ if (s_interp_settle > 0) --s_interp_settle; ++ else if (s_interp_last_swap) { ++ const double step = phase - s_interp_last_phase; ++ interp_spread_add(&s_interp_gap, (double)(swapped - s_interp_last_swap) * 1000.0 / ++ (double)SDL_GetPerformanceFrequency()); ++ interp_spread_add(&s_interp_step, step); ++ s_interp_late += (double)(swapped - s_interp_last_swap) * s_interp_target_hz > ++ 1.25 * (double)SDL_GetPerformanceFrequency(); ++ s_interp_holds += step < 0.05; ++ s_interp_jumps += step > 0.9; ++ } ++ s_interp_last_swap = swapped; ++ s_interp_last_phase = phase; ++ } + return 1; + } + + static int interp_thread_main(void *opaque) { + (void)opaque; +- if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) return -1; ++ if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) { ++ SDL_AtomicSet(&s_temp_worker_ready, -1); ++ return -1; ++ } + SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ + p_glGenVertexArrays(1, &s_interp_thread_vao); ++ SDL_AtomicSet(&s_temp_worker_ready, temporal_worker_init() ? 1 : -1); + uint64_t freq = SDL_GetPerformanceFrequency(); + uint64_t deadline = SDL_GetPerformanceCounter(); + uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; +@@ -3477,7 +4883,7 @@ + int uncapped = hz < 0.0; + uint64_t now = SDL_GetPerformanceCounter(); + if (!uncapped) { +- if (hz < 50.0) hz = 60.0; ++ if (hz < 30.0) hz = 60.0; + uint64_t period = (uint64_t)((double)freq / hz); + if (!period) period = 1; + deadline += period; +@@ -3492,20 +4898,80 @@ + } + while (SDL_GetPerformanceCounter() < deadline) {} + now = SDL_GetPerformanceCounter(); ++ if (s_interp_diag && !s_interp_settle) ++ interp_spread_add(&s_interp_wake, (double)(now - deadline) * 1000.0 / (double)freq); + } else { + deadline = now; + } + + SDL_LockMutex(s_interp_mutex); + int presented = 0; ++ const uint64_t entered = SDL_GetPerformanceCounter(); + if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) + presented = interp_present(); ++ if (s_interp_diag) { ++ interp_spread_add(&s_interp_wait, (double)(entered - now) * 1000.0 / (double)freq); ++ interp_spread_add(&s_interp_draw, (double)(SDL_GetPerformanceCounter() - entered) * 1000.0 / ++ (double)freq); ++ } + if (s_interp_diag && now - diag_start >= freq * 5u) { + double seconds = (double)(now - diag_start) / (double)freq; + fprintf(stdout, "psxrecomp: GL interpolation cadence: " + "%.2f captures/s, %.2f presents/s\n", + (double)(s_interp_captures - diag_captures) / seconds, + (double)(s_interp_swaps - diag_swaps) / seconds); ++ if (s_interp_gap.count) { ++ fprintf(stdout, "psxrecomp: GL interpolation pacing: present gap %.3f ms " ++ "(deviation %.3f, %.3f to %.3f), phase step %.3f frames " ++ "(deviation %.3f), %d repeated and %d skipped phases, %d late in %d presents\n", ++ s_interp_gap.sum / s_interp_gap.count, interp_spread_deviation(&s_interp_gap), ++ s_interp_gap.low, s_interp_gap.high, ++ s_interp_step.sum / s_interp_step.count, interp_spread_deviation(&s_interp_step), ++ s_interp_holds, s_interp_jumps, s_interp_late, s_interp_gap.count); ++ if (s_interp_hold.count) ++ fprintf(stdout, "psxrecomp: GL interpolation lock: a capture holds it %.3f ms " ++ "(up to %.3f), matching takes %.3f ms (up to %.3f)\n", ++ s_interp_hold.sum / s_interp_hold.count, s_interp_hold.high, ++ s_interp_work.count ? s_interp_work.sum / s_interp_work.count : 0.0, ++ s_interp_work.high); ++ fprintf(stdout, "psxrecomp: GL interpolation presenter: wakes %.3f ms late (up to %.3f), " ++ "waits %.3f ms for the lock (up to %.3f), draws and swaps in %.3f ms (up to %.3f)\n", ++ s_interp_wake.count ? s_interp_wake.sum / s_interp_wake.count : 0.0, s_interp_wake.high, ++ s_interp_wait.count ? s_interp_wait.sum / s_interp_wait.count : 0.0, s_interp_wait.high, ++ s_interp_draw.count ? s_interp_draw.sum / s_interp_draw.count : 0.0, s_interp_draw.high); ++ memset(&s_interp_wake, 0, sizeof(s_interp_wake)); ++ memset(&s_interp_wait, 0, sizeof(s_interp_wait)); ++ memset(&s_interp_draw, 0, sizeof(s_interp_draw)); ++ memset(&s_interp_hold, 0, sizeof(s_interp_hold)); ++ memset(&s_interp_work, 0, sizeof(s_interp_work)); ++ memset(&s_interp_gap, 0, sizeof(s_interp_gap)); ++ memset(&s_interp_step, 0, sizeof(s_interp_step)); ++ s_interp_holds = s_interp_jumps = s_interp_late = 0; ++ s_interp_settle = 8; /* the report itself delays the next presents */ ++ } ++ if (s_interp_blend_mode == 2) { ++ fprintf(stdout, "psxrecomp: GL geometry interpolation: %llu scenes, " ++ "%llu redraws, %llu fallback captures, %d/%d matched world triangles\n", ++ (unsigned long long)s_temp_published, (unsigned long long)s_temp_replays, ++ (unsigned long long)s_temp_fallbacks, s_temp_curr.matched, s_temp_curr.world); ++ fprintf(stdout, "psxrecomp: GL geometry mesh: %d moving triangles, " ++ "%d seeded / %d shared vertices, %d propagated corners, " ++ "%d conflicts, %d held vertices, %d anchored junctions, " ++ "camera %d of %d corners, %d reprojected vertices, %d departed faces, " ++ "%d welded corners, %d twin faces\n", ++ s_temp_curr.moving, s_temp_curr.mesh_stats.seeded_vertices, ++ s_temp_curr.mesh_stats.shared_vertices, ++ s_temp_curr.mesh_stats.propagated_vertices, ++ s_temp_curr.mesh_stats.conflicting_vertices, ++ s_temp_curr.mesh_stats.held_vertices, ++ s_temp_curr.mesh_stats.anchored_junctions, ++ s_temp_curr.mesh_stats.camera_inliers, ++ s_temp_curr.mesh_stats.camera_samples, ++ s_temp_curr.mesh_stats.reprojected_vertices, ++ s_temp_curr.mesh_stats.departed_faces, ++ s_temp_curr.mesh_stats.welded_corners, ++ s_temp_curr.mesh_stats.twin_faces); ++ } + fflush(stdout); + diag_start = now; + diag_captures = s_interp_captures; +@@ -3518,6 +4984,33 @@ + SDL_GL_MakeCurrent(s_win, NULL); + return 0; + } ++ ++#else ++/* Retain the framework ABI while the experimental presenter is compiled out. ++ * No history, capture, shared context, worker or diagnostic output is created. */ ++void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, ++ int blend_mode) { ++ (void)enabled; (void)target_hz; (void)blend_mode; ++ fprintf(stdout, "psxrecomp: GL frame interpolation disabled (build; host %.1f Hz)\n", ++ host_hz); ++} ++void gl_renderer_set_interpolation_suspended(int suspended) { (void)suspended; } ++void gl_renderer_set_hidden_rows(int rows) { (void)rows; } ++void gl_renderer_set_host_ui_suspended(int suspended) { (void)suspended; } ++void gl_renderer_interpolation_diag(int *enabled, int *suspended, ++ int *history_frames, double *host_hz, ++ double *target_hz, uint64_t *swaps) { ++ if (enabled) *enabled = 0; ++ if (suspended) *suspended = 0; ++ if (history_frames) *history_frames = 0; ++ if (host_hz) *host_hz = 0.0; ++ if (target_hz) *target_hz = 0.0; ++ if (swaps) *swaps = 0; ++} ++void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { ++ if (out) memset(out, 0, 6 * sizeof(*out)); ++} ++#endif + + static void present_target_quad(GLuint tex, int tex_w, int tex_h, + int x, int y, int w, int h, int linear, +@@ -3553,13 +5046,14 @@ + s_last_present_path == GL_PRES_VRAM && + s_last_dx == disp_x && s_last_dy == disp_y && + s_last_dw == w && s_last_dh == h && +- !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1)) { ++ !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && ++ !psx_host_ui_gl_visible()) { + gl_perf_present_enter(); + gl_perf_present_exit(0); + return; + } + gl_perf_present_enter(); /* per-frame backdrop-phase reset + dbg snapshot live in here */ +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + int lx, ly, lw, lh; + if (force_4_3) + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); +@@ -3588,6 +5082,8 @@ + present_target_quad(s_hr_tex, VRAM_W, VRAM_H, + disp_x, disp_y, w, h, linear, lx, ly, lw, lh); + pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -3607,7 +5103,9 @@ + * x-translated by the reveal offset. No-op when s_wide_fast is off (then the + * mirror drew the full surface, as before). Shared by both present paths. */ + static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { +- if (!s_wide_fast || g_wide_w <= 0) return; ++ /* The correction surface is already a complete full mirror. Copying the ++ * canonical centre here would erase its subpixel/yaw-corrected gameplay. */ ++ if (!s_wide_fast || s_geometry_correction || g_wide_w <= 0) return; + int native_w = g_wide_w - 2 * g_wide_off; + if (native_w <= 0) return; + int S = s_scale; +@@ -3638,6 +5136,71 @@ + * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y + * bottom-origin → window top). Returns 0 if there's no wide surface for base_x + * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ ++/* The wide surface shaded by its own depth. 0: nothing drawn, the plain present follows. */ ++static int ssao_present(GLuint fbo, GLuint tex, int y, int h, int linear, int lx, int ly, int lw, int lh) { ++ if (!ssao_draw(&s_ssao_main, s_present_vao, fbo, 0, tex, g_wide_w * s_scale, VRAM_H * s_scale, ++ 0.5f / (float)g_wide_w, ((float)y + 0.5f) / (float)VRAM_H, ++ ((float)g_wide_w - 0.5f) / (float)g_wide_w, ((float)(y + h) - 0.5f) / (float)VRAM_H, ++ s_scale, linear, lx, ly, lw, lh)) ++ return 0; ++ for (int i = 0; i < WIDE_MAX_SURF; i++) ++ if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); ++ return 1; ++} ++ ++void gl_renderer_set_mesh_view(int enabled) { s_mesh_view = enabled != 0; } ++int gl_renderer_mesh_view(void) { return s_mesh_view; } ++void gl_renderer_set_full_yaw(int enabled) { ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ if ((enabled != 0) != s_geometry_full_yaw) interp_reset_history(); ++ s_geometry_full_yaw = enabled != 0; ++} ++void gl_renderer_set_gap_fill(int enabled) { ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ const float width = enabled ? s_crack_width : 0.0f; ++ if ((enabled != 0) != s_grown_under || width != s_crack_fill) interp_reset_history(); ++ if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ s_grown_under = enabled != 0; ++ s_crack_fill = width; ++} ++int gl_renderer_gap_fill(void) { return s_grown_under || s_crack_fill > 0.0f; } ++ ++void gl_renderer_set_world_depth(int enabled) { ++ if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } ++ if ((enabled != 0) != s_world_depth) interp_reset_history(); ++ if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ s_world_depth = enabled != 0; ++} ++int gl_renderer_world_depth(void) { return s_world_depth; } ++ ++static void ssao_mode(void) { ++ const int mode = s_ssao_view ? s_ssao_view + 1 : s_ssao_on; ++ if (mode && !s_ssao) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); ++ s_ssao = mode; ++} ++void gl_renderer_set_ssao(int enabled) { ++ s_ssao_on = enabled != 0; ++ ssao_mode(); ++} ++void gl_renderer_set_ssao_view(int view) { ++ s_ssao_view = view >= 1 && view <= 4 ? view : 0; ++ ssao_mode(); ++} ++int gl_renderer_ssao_view(void) { return s_ssao_view; } ++float gl_renderer_ssao_tilt(void) { return s_ssao_tilt; } ++void gl_renderer_set_ssao_tilt(float tilt) { ++ if (tilt >= 0.0f && tilt < 0.95f) s_ssao_tilt = tilt; ++} ++void gl_renderer_set_ssao_shape(float radius, float strength) { ++ if (radius > 0.0f) s_ssao_radius = radius; ++ if (strength >= 0.0f) s_ssao_strength = strength; ++} ++float gl_renderer_ssao_radius(void) { return s_ssao_radius; } ++/* The camera depths between which the game fades its world out: the shading goes with it. */ ++void gl_renderer_set_ssao_fade(float from, float to) { s_ssao_fade_from = from; s_ssao_fade_to = to; } ++float gl_renderer_ssao_strength(void) { return s_ssao_strength; } ++int gl_renderer_ssao(void) { return s_ssao_on; } ++ + int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { + if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; + GLuint fbo = 0, tex = 0; +@@ -3653,13 +5216,14 @@ + s_last_present_path == GL_PRES_WIDE && + s_last_dx == disp_x && s_last_dy == disp_y && + s_last_dw == g_wide_w && s_last_dh == disp_h && +- !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1)) { ++ !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && ++ !psx_host_ui_gl_visible()) { + gl_perf_present_enter(); + gl_perf_present_exit(1); + return 1; + } + gl_perf_present_enter(); +- int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); ++ int ww = 0, wh = 0; gl_visible_size(&ww, &wh); + int lx, ly, lw, lh; + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ +@@ -3681,9 +5245,13 @@ + s_last_dw = g_wide_w; s_last_dh = disp_h; + return 1; + } +- present_target_quad(tex, g_wide_w, VRAM_H, +- 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); ++ wide_depth_shown(fbo, disp_y, disp_h); ++ if (!ssao_present(fbo, tex, disp_y, disp_h, linear, lx, ly, lw, lh)) ++ present_target_quad(tex, g_wide_w, VRAM_H, ++ 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); + pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); ++ render_host_ui_for_swap(ww, wh); ++ shown_capture(0, 1.0f); + latency_ring_mark(LAT_SWAP_BEGIN); + SDL_GL_SwapWindow(s_win); + latency_ring_mark(LAT_SWAP_END); +@@ -3702,6 +5270,21 @@ + .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, + .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, + .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, ++ .set_geometry_correction = glb_set_geometry_correction, ++ .set_world_triangle = glb_set_world_triangle, ++ .set_precise_triangle = glb_set_precise_triangle, ++ .set_unpinned_triangle = glb_set_unpinned_triangle, ++ .set_perspective_triangle = glb_set_perspective_triangle, ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION ++ .set_temporal_depth_triangle = glb_set_temporal_depth_triangle, ++ .set_surface_depth_triangle = glb_set_surface_depth_triangle, ++ .set_temporal_identity_triangle = glb_set_temporal_identity_triangle, ++ .set_temporal_sprite = glb_set_temporal_sprite, ++#else ++ .set_temporal_depth_triangle = NULL, ++ .set_temporal_identity_triangle = NULL, ++#endif ++ .set_presentation_yaw = glb_set_presentation_yaw, + .fill_rect = glb_fill_rect, .copy_rect = glb_copy_rect, + .draw_flat_triangle = glb_draw_flat_triangle, .draw_gouraud_triangle = glb_draw_gouraud_triangle, + .draw_textured_triangle = glb_draw_textured_triangle, diff --git a/framework/patches/runtime/src/gpu_render.c.patch b/framework/patches/runtime/src/gpu_render.c.patch new file mode 100644 index 0000000..28a9bd5 --- /dev/null +++ b/framework/patches/runtime/src/gpu_render.c.patch @@ -0,0 +1,68 @@ +diff --git a/runtime/src/gpu_render.c b/runtime/src/gpu_render.c +index cae379538501938c7236686556174d7d34a4fbe8..a6b96fe12d38080895e4a1bf398ca3322446b8be 100644 +--- a/runtime/src/gpu_render.c ++++ b/runtime/src/gpu_render.c +@@ -25,6 +25,13 @@ + .set_mask_bits = sw_set_mask_bits, + .set_texture_window = sw_set_texture_window, + .set_color_modulation = sw_set_color_modulation, ++ .set_geometry_correction = NULL, ++ .set_world_triangle = NULL, ++ .set_precise_triangle = sw_set_precise_triangle, ++ .set_perspective_triangle = NULL, ++ .set_temporal_depth_triangle = NULL, ++ .set_temporal_identity_triangle = NULL, ++ .set_presentation_yaw = NULL, + .fill_rect = sw_fill_rect, + .copy_rect = sw_copy_rect, + .draw_flat_triangle = sw_draw_flat_triangle, +@@ -101,6 +108,49 @@ + void gr_set_mask_bits(int s, int c) { g_b->set_mask_bits(s, c); } + void gr_set_texture_window(uint32_t raw) { g_b->set_texture_window(raw); } + void gr_set_color_modulation(int r, int g, int b, int raw) { g_b->set_color_modulation(r, g, b, raw); } ++void gr_set_geometry_correction(int enabled) { ++ if (g_b->set_geometry_correction) g_b->set_geometry_correction(enabled); ++} ++void gr_set_world_triangle(int world) { ++ if (g_b->set_world_triangle) g_b->set_world_triangle(world); ++} ++void gr_set_precise_triangle(int enabled, ++ int32_t x0, int32_t y0, ++ int32_t x1, int32_t y1, ++ int32_t x2, int32_t y2) { ++ if (g_b->set_precise_triangle) ++ g_b->set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); ++} ++void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { ++ if (g_b->set_unpinned_triangle) g_b->set_unpinned_triangle(x0, y0, x1, y1, x2, y2); ++} ++void gr_set_perspective_triangle(int enabled, ++ float q0, float q1, float q2) { ++ if (g_b->set_perspective_triangle) ++ g_b->set_perspective_triangle(enabled, q0, q1, q2); ++} ++void gr_set_temporal_depth_triangle(int enabled, ++ float z0, float z1, float z2) { ++ if (g_b->set_temporal_depth_triangle) ++ g_b->set_temporal_depth_triangle(enabled, z0, z1, z2); ++} ++void gr_set_surface_depth_triangle(float z0, float z1, float z2) { ++ if (g_b->set_surface_depth_triangle) g_b->set_surface_depth_triangle(z0, z1, z2); ++} ++void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { ++ if (g_b->set_temporal_identity_triangle) ++ g_b->set_temporal_identity_triangle(id0, id1, id2); ++} ++void gr_set_temporal_sprite(float depth, const float sides[4]) { ++ if (g_b->set_temporal_sprite) g_b->set_temporal_sprite(depth, sides); ++} ++void gr_set_presentation_yaw(double yaw_units, double full_turn, ++ double center_x, double center_y, ++ double focal_x) { ++ if (g_b->set_presentation_yaw) ++ g_b->set_presentation_yaw(yaw_units, full_turn, ++ center_x, center_y, focal_x); ++} + void gr_fill_rect(int x, int y, int w, int h, uint16_t c) { g_b->fill_rect(x, y, w, h, c); } + void gr_copy_rect(int sx, int sy, int dx, int dy, int w, int h) { g_b->copy_rect(sx, sy, dx, dy, w, h); } + void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, uint16_t c) { diff --git a/framework/patches/runtime/src/gte.cpp.patch b/framework/patches/runtime/src/gte.cpp.patch new file mode 100644 index 0000000..553feb7 --- /dev/null +++ b/framework/patches/runtime/src/gte.cpp.patch @@ -0,0 +1,1159 @@ +diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp +index e7a1083a316b72fc30b9fa34038b773911bced51..163280ce64fa04e8c5a5f21e87af541c5daeaeb2 100644 +--- a/runtime/src/gte.cpp ++++ b/runtime/src/gte.cpp +@@ -1,5 +1,7 @@ + #include "gte.h" + #include "cpu_state.h" ++#include "gte_precision.h" ++#include "mod_memory.h" + #include + #include + #include +@@ -184,35 +186,75 @@ + // --------------------------------------------------------------------------- + static int32_t s_ws_xnum = 1, s_ws_xden = 1; + extern "C" int gpu_ws_present_native_43(void); /* gpu.c — suppress on 4:3 frames */ ++static bool ws_x_squashed() { return s_ws_xnum != s_ws_xden && !gpu_ws_present_native_43(); } ++/* The ratio RTPS scales a projected X by on this frame, 1/1 when it leaves X alone. */ ++extern "C" void gte_ws_x_squash(int32_t* num, int32_t* den) { ++ const bool squashed = ws_x_squashed(); ++ *num = squashed ? s_ws_xnum : 1; ++ *den = squashed ? s_ws_xden : 1; ++} + extern "C" void psx_ws_note_gte_project(int nverts); /* gpu.c — gte_game_mode stamp */ + static int s_gte_replay_sandbox = 0; + +-/* Optional visual-only geometry precision cache. The PS1-visible SXY FIFO +- * remains integer and fully faithful; this side cache retains the discarded +- * 16.16 projection fraction so the high-resolution software mirror can match +- * a later GP0 polygon and place its vertices between native pixels. */ +-#define GEOM_CACHE_SIZE 8192u +-struct GeomVertex { ++/* Geometry presentation is enabled here, but vertex recovery is deliberately ++ * owned by the exact address-keyed precision store below. Test 11 also kept a ++ * cache keyed only by rounded SXY; that key is ambiguous and is permanently ++ * disabled. Keep a generation counter solely for state/test ABI compatibility ++ * with checkpoints that predate the removal of that cache. */ ++static uint32_t s_geom_generation = 1; ++static int s_geom_enabled = 0; ++ ++/* The pinned framework's register test predates Test 12 and explicitly seeds ++ * the removed cache to verify generation wrap. Preserve that fixture only in ++ * the test binary; no production build contains or writes this storage. */ ++#ifdef PSX_GTE_REGISTER_TEST ++#define GEOM_TEST_CACHE_SIZE 8192u ++struct GeomTestVertex { + uint32_t packed; + int32_t x16, y16; + uint32_t generation; + }; +-static GeomVertex s_geom_cache[GEOM_CACHE_SIZE]; +-static uint32_t s_geom_generation = 1; +-static int s_geom_enabled = 0; +-static uint32_t s_geom_hits = 0; +- +-/* Exact GTE projection provenance for perspective texture correction. The +- * recompiler/interpreters call gte_precision_store after SWC2 writes an SXY +- * register into guest RAM. GPU DMA later supplies that exact RAM address, so +- * depth lookup follows the packet rather than guessing from rounded X/Y. */ ++static GeomTestVertex s_geom_test_cache[GEOM_TEST_CACHE_SIZE]; ++static uint32_t s_geom_test_hits = 0; ++ ++static inline uint32_t geom_test_hash(uint32_t packed) { ++ packed ^= packed >> 16; ++ return (packed * 2654435761u) & (GEOM_TEST_CACHE_SIZE - 1u); ++} ++#endif ++ ++/* Exact GTE projection provenance for perspective texture correction. SWC2 ++ * stores are observed directly; reviewed game-specific MFC2 + ordinary-SW ++ * routes use the same committed-word gate. GPU DMA later supplies that exact ++ * RAM address, so depth lookup follows the packet rather than guessing from ++ * rounded X/Y. */ + struct PreciseProjection { + uint32_t packed; + int32_t x16, y16; + uint16_t z; + uint8_t valid; ++ uint8_t saturated; ++ uint8_t perspective_valid; ++ uint8_t clamped; /* packed is what the game pinned the projection to */ ++ uint8_t unbounded; /* the GTE's divide or screen stopped short, and exact_x16 and exact_y16 hold what an exact divide gives */ ++ int32_t exact_x16, exact_y16; ++ uint64_t identity; + }; + static PreciseProjection s_precise_sxy[4] = {}; ++/* PSX_GEOMETRY_UNPIN=0 in a build with debug tools leaves pinned corners as the game put them, for comparison. */ ++static int s_precision_unpin = -1; ++static int precision_unpin() { ++ if (s_precision_unpin < 0) { ++ s_precision_unpin = 1; ++#ifndef PSX_NO_DEBUG_TOOLS ++ const char *value = getenv("PSX_GEOMETRY_UNPIN"); ++ if (value && value[0] == '0') s_precision_unpin = 0; ++#endif ++ } ++ return s_precision_unpin; ++} ++/* Half of what a 16.16 coordinate holds: an exact place stays under it with the screen centre in, the drawing offset comes later. */ ++constexpr int64_t kUnboundedLimit16 = INT64_C(1) << 30; + + #define PRECISION_STORE_SIZE 65536u + struct PrecisionStoreEntry { +@@ -223,9 +265,73 @@ + static PrecisionStoreEntry s_precision_store[PRECISION_STORE_SIZE]; + static uint32_t s_precision_generation = 1; + static int s_precision_tracking = 0; ++static GtePrecisionDiagnostics s_precision_diagnostics = {}; ++struct PrecisionWordCommitToken { ++ uint32_t physical; ++ uint8_t domain; ++ uint8_t pending; ++}; ++static PrecisionWordCommitToken s_precision_word_commit = {}; ++enum PrecisionWordDomain : uint8_t { ++ PRECISION_WORD_DOMAIN_NONE = 0, ++ PRECISION_WORD_DOMAIN_MAIN_RAM = 1, ++ PRECISION_WORD_DOMAIN_SCRATCHPAD = 2, ++ PRECISION_WORD_DOMAIN_GPU_DMA = 3, ++}; ++#define PRECISION_STORE_PC_ROUTE_MAX 32u ++struct PrecisionStorePcRoute { ++ uint32_t physical_pc; ++ uint32_t instruction; ++ uint8_t gte_reg; ++}; ++static PrecisionStorePcRoute ++ s_precision_store_pc_routes[PRECISION_STORE_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_store_pc_route_count = 0; ++#define PRECISION_SCRATCH_STORE_PC_ROUTE_MAX 8u ++struct PrecisionScratchStorePcRoute { ++ uint32_t physical_pc; ++ uint32_t instruction; ++ uint32_t scratch_first; ++ uint32_t scratch_stride; ++ uint32_t scratch_count; ++ uint8_t gte_reg; ++ uint8_t clamps; ++ int16_t low_x, high_x, low_y, high_y; ++}; ++static PrecisionScratchStorePcRoute ++ s_precision_scratch_store_pc_routes[ ++ PRECISION_SCRATCH_STORE_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_scratch_store_pc_route_count = 0; ++#define PRECISION_MFC2_PC_ROUTE_MAX 8u ++struct PrecisionMfc2PcRoute { ++ uint32_t physical_pc; ++ uint32_t instruction; ++ uint8_t gte_reg; ++}; ++static PrecisionMfc2PcRoute ++ s_precision_mfc2_pc_routes[PRECISION_MFC2_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_mfc2_pc_route_count = 0; ++static PreciseProjection s_precision_mfc2_capture[4] = {}; ++#define PRECISION_COPY_PC_ROUTE_MAX 32u ++struct PrecisionCopyPcRoute { ++ uint32_t load_physical_pc; ++ uint32_t load_instruction; ++ uint32_t store_physical_pc; ++ uint32_t store_instruction; ++ uint8_t gpr; ++ PreciseProjection capture; ++}; ++static PrecisionCopyPcRoute ++ s_precision_copy_pc_routes[PRECISION_COPY_PC_ROUTE_MAX] = {}; ++static uint32_t s_precision_copy_pc_route_count = 0; + static PreciseProjection s_speculative_saved_sxy[4]; + static uint32_t s_speculative_depth = 0; + static int s_speculative_timeline_invalidated = 0; ++ ++static void precision_copy_captures_clear(void) { ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) ++ s_precision_copy_pc_routes[i].capture.valid = 0; ++} + + static void gte_precision_generation_advance(void) { + if (++s_precision_generation == 0) { +@@ -236,13 +342,20 @@ + + static void gte_geom_generation_advance(void) { + if (++s_geom_generation == 0) { +- std::memset(s_geom_cache, 0, sizeof(s_geom_cache)); ++#ifdef PSX_GTE_REGISTER_TEST ++ std::memset(s_geom_test_cache, 0, sizeof(s_geom_test_cache)); ++#endif + s_geom_generation = 1; + } + } + + extern "C" void gte_precision_timeline_invalidate(void) { + for (int i = 0; i < 4; ++i) s_precise_sxy[i].valid = 0; ++ std::memset(s_precision_mfc2_capture, 0, ++ sizeof(s_precision_mfc2_capture)); ++ precision_copy_captures_clear(); ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; + /* Raw machine-state restore is authoritative even if host polling reached + * it during a speculative validation pass. Defer the generation advance + * until the outer transaction ends so old provenance cannot be restored. */ +@@ -255,6 +368,8 @@ + } + + extern "C" void gte_precision_speculative_begin(void) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; + if (s_speculative_depth++ == 0) { + std::memcpy(s_speculative_saved_sxy, s_precise_sxy, + sizeof(s_precise_sxy)); +@@ -273,6 +388,8 @@ + std::memcpy(s_precise_sxy, s_speculative_saved_sxy, + sizeof(s_precise_sxy)); + } ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; + } + } + +@@ -283,64 +400,676 @@ + } + + static inline int precision_ram_address(uint32_t addr, uint32_t *physical) { ++ /* Match memory.c: KSEG2/KSEG3 are not translated through the ordinary ++ * 0x1fffffff mask. Apart from cache control, those accesses are unmapped ++ * and must never alias a low-RAM provenance entry. */ ++ if (addr >= 0xC0000000u) return 0; + uint32_t mapped = addr & 0x1FFFFFFFu; +- if (mapped >= 0x00800000u) return 0; ++ if (mapped >= 0x00800000u) { ++ if (mapped < PSX_MOD_GPU_DMA_APERTURE_BASE || ++ mapped > PSX_MOD_GPU_DMA_APERTURE_BASE + ++ PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u) return 0; ++ *physical = mapped & ~3u; ++ return 1; ++ } + *physical = mapped & 0x1FFFFCu; + return 1; + } + +-extern "C" void gte_precision_tracking_set(int enabled) { +- s_precision_tracking = enabled ? 1 : 0; +- gte_precision_generation_advance(); +-} +- +-extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { +- if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || +- reg < 12 || reg > 15) return; +- int index = reg == 15 ? 2 : (int)reg - 12; +- const PreciseProjection &projection = s_precise_sxy[index]; +- if (!projection.valid) return; +- uint32_t physical; +- if (!precision_ram_address(addr, &physical)) return; ++static inline int precision_scratch_address(uint32_t addr, ++ uint32_t *physical) { ++ /* Use the same KUSEG/KSEG0/KSEG1 translation as memory.c, while keeping ++ * KSEG2/KSEG3 from aliasing a reviewed scratchpad slot. */ ++ if (addr >= 0xC0000000u) return 0; ++ const uint32_t mapped = addr & 0x1FFFFFFFu; ++ if (mapped < 0x1F800000u || mapped > 0x1F8003FFu) return 0; ++ *physical = mapped & ~3u; ++ return 1; ++} ++ ++static inline int precision_copy_source_address(uint32_t addr, ++ uint32_t *physical) { ++ return precision_ram_address(addr, physical) || ++ precision_scratch_address(addr, physical); ++} ++ ++static inline int precision_projection_index(uint8_t reg) { ++ if (reg < 12 || reg > 15) return -1; ++ return reg == 15 ? 2 : (int)reg - 12; ++} ++ ++static void precision_store_projection( ++ uint32_t physical, const PreciseProjection &projection) { + PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; ++ if (entry.generation != 0 && entry.addr != physical) { ++ ++s_precision_diagnostics.store_collisions; ++ if (entry.generation == s_precision_generation) ++ ++s_precision_diagnostics.store_evictions; ++ } + entry.addr = physical; + entry.projection = projection; + entry.generation = s_precision_generation; + } + ++static const PrecisionStorePcRoute *precision_store_pc_route( ++ uint32_t store_pc, uint32_t instruction) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { ++ if (s_precision_store_pc_routes[i].physical_pc == physical_pc && ++ s_precision_store_pc_routes[i].instruction == instruction) ++ return &s_precision_store_pc_routes[i]; ++ } ++ return nullptr; ++} ++ ++static const PrecisionScratchStorePcRoute *precision_scratch_store_pc_route( ++ uint32_t store_pc, uint32_t instruction) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; ++ i < s_precision_scratch_store_pc_route_count; ++i) { ++ const PrecisionScratchStorePcRoute &route = ++ s_precision_scratch_store_pc_routes[i]; ++ if (route.physical_pc == physical_pc && ++ route.instruction == instruction) ++ return &route; ++ } ++ return nullptr; ++} ++ ++static inline int precision_scratch_route_contains( ++ const PrecisionScratchStorePcRoute &route, uint32_t physical) { ++ if (physical < route.scratch_first) return 0; ++ const uint32_t delta = physical - route.scratch_first; ++ if ((delta % route.scratch_stride) != 0u) return 0; ++ return (delta / route.scratch_stride) < route.scratch_count; ++} ++ ++static const PrecisionMfc2PcRoute *precision_mfc2_pc_route( ++ uint32_t mfc2_pc, uint32_t instruction) { ++ const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { ++ if (s_precision_mfc2_pc_routes[i].physical_pc == physical_pc && ++ s_precision_mfc2_pc_routes[i].instruction == instruction) ++ return &s_precision_mfc2_pc_routes[i]; ++ } ++ return nullptr; ++} ++ ++static PrecisionCopyPcRoute *precision_copy_load_pc_route( ++ uint32_t load_pc, uint32_t instruction, uint8_t gpr) { ++ const uint32_t physical_pc = load_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { ++ PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; ++ if (route.load_physical_pc == physical_pc && ++ route.load_instruction == instruction && route.gpr == gpr) ++ return &route; ++ } ++ return nullptr; ++} ++ ++static PrecisionCopyPcRoute *precision_copy_store_pc_route( ++ uint32_t store_pc, uint32_t instruction, uint8_t gpr) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { ++ PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; ++ if (route.store_physical_pc == physical_pc && ++ route.store_instruction == instruction && route.gpr == gpr) ++ return &route; ++ } ++ return nullptr; ++} ++ ++extern "C" void gte_precision_word_write_begin(void) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++} ++ ++extern "C" void gte_precision_main_ram_word_committed(uint32_t physical) { ++ /* memory.c passes its already-folded main-RAM address only after all four ++ * bytes have been written. Do not turn an unaligned host write into an ++ * adjacent aligned provenance token. */ ++ if (physical >= 0x00200000u || (physical & 3u) != 0u) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ return; ++ } ++ s_precision_word_commit.physical = physical; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_MAIN_RAM; ++ s_precision_word_commit.pending = 1; ++} ++ ++/* Only memory.c arms this domain, after an allocated aperture word commits. ++ * A renderer lookup cannot alias this address with a folded main-RAM word. */ ++extern "C" void gte_precision_gpu_dma_word_committed(uint32_t physical) { ++ if (physical < PSX_MOD_GPU_DMA_APERTURE_BASE || ++ physical > PSX_MOD_GPU_DMA_APERTURE_BASE + ++ PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u || (physical & 3u)) { ++ gte_precision_word_write_begin(); ++ return; ++ } ++ s_precision_word_commit.physical = physical; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_GPU_DMA; ++ s_precision_word_commit.pending = 1; ++} ++ ++static int precision_memory_commit_matches(uint8_t domain, uint32_t physical) { ++ return domain == (physical < 0x00200000u ? ++ PRECISION_WORD_DOMAIN_MAIN_RAM : PRECISION_WORD_DOMAIN_GPU_DMA); ++} ++ ++extern "C" void gte_precision_scratch_word_committed(uint32_t physical) { ++ /* memory.c supplies an already-translated physical address after all four ++ * scratchpad bytes commit. Slot eligibility is checked independently by ++ * the reviewed post-write route. */ ++ if (physical < 0x1F800000u || physical > 0x1F8003FCu || ++ (physical & 3u) != 0u) { ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ return; ++ } ++ s_precision_word_commit.physical = physical; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_SCRATCHPAD; ++ s_precision_word_commit.pending = 1; ++} ++ ++extern "C" void gte_precision_tracking_set(int enabled) { ++ s_precision_tracking = enabled ? 1 : 0; ++ std::memset(s_precision_mfc2_capture, 0, ++ sizeof(s_precision_mfc2_capture)); ++ precision_copy_captures_clear(); ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ gte_precision_generation_advance(); ++} ++ ++extern "C" void gte_precision_store_pc_routes_reset(void) { ++ s_precision_store_pc_route_count = 0; ++ s_precision_scratch_store_pc_route_count = 0; ++ s_precision_mfc2_pc_route_count = 0; ++ s_precision_copy_pc_route_count = 0; ++ std::memset(s_precision_store_pc_routes, 0, ++ sizeof(s_precision_store_pc_routes)); ++ std::memset(s_precision_scratch_store_pc_routes, 0, ++ sizeof(s_precision_scratch_store_pc_routes)); ++ std::memset(s_precision_mfc2_pc_routes, 0, ++ sizeof(s_precision_mfc2_pc_routes)); ++ std::memset(s_precision_copy_pc_routes, 0, ++ sizeof(s_precision_copy_pc_routes)); ++ std::memset(s_precision_mfc2_capture, 0, ++ sizeof(s_precision_mfc2_capture)); ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++} ++ ++extern "C" int gte_precision_mfc2_pc_route_add( ++ uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg) { ++ if ((mfc2_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || ++ ((instruction >> 26) & 0x3Fu) != 0x12u || ++ ((instruction >> 21) & 0x1Fu) != 0u || ++ ((instruction >> 11) & 0x1Fu) != gte_reg) ++ return 0; ++ const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { ++ PrecisionMfc2PcRoute &route = s_precision_mfc2_pc_routes[i]; ++ if (route.physical_pc != physical_pc) continue; ++ return route.instruction == instruction && route.gte_reg == gte_reg; ++ } ++ if (s_precision_mfc2_pc_route_count >= PRECISION_MFC2_PC_ROUTE_MAX) ++ return 0; ++ PrecisionMfc2PcRoute &route = ++ s_precision_mfc2_pc_routes[s_precision_mfc2_pc_route_count++]; ++ route.physical_pc = physical_pc; ++ route.instruction = instruction; ++ route.gte_reg = gte_reg; ++ return 1; ++} ++ ++extern "C" int gte_precision_store_pc_route_add( ++ uint32_t store_pc, uint32_t instruction, uint8_t gte_reg) { ++ if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || ++ ((instruction >> 26) & 0x3Fu) != 0x2Bu) ++ return 0; ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { ++ PrecisionStorePcRoute &route = s_precision_store_pc_routes[i]; ++ if (route.physical_pc != physical_pc) continue; ++ return route.instruction == instruction && route.gte_reg == gte_reg; ++ } ++ if (s_precision_store_pc_route_count >= PRECISION_STORE_PC_ROUTE_MAX) ++ return 0; ++ PrecisionStorePcRoute &route = ++ s_precision_store_pc_routes[s_precision_store_pc_route_count++]; ++ route.physical_pc = physical_pc; ++ route.instruction = instruction; ++ route.gte_reg = gte_reg; ++ return 1; ++} ++ ++extern "C" int gte_precision_scratch_store_pc_route_add( ++ uint32_t store_pc, uint32_t instruction, uint8_t gte_reg, ++ uint32_t scratch_first, uint32_t scratch_stride, ++ uint32_t scratch_count) { ++ if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || ++ ((instruction >> 26) & 0x3Fu) != 0x2Bu || ++ scratch_count == 0u || scratch_stride == 0u || ++ (scratch_first & 3u) != 0u || (scratch_stride & 3u) != 0u || ++ scratch_first < 0x1F800000u || scratch_first > 0x1F8003FCu) ++ return 0; ++ ++ /* Prove the final registered slot without overflow, and keep every slot ++ * wholly within the physical 1 KiB scratchpad. */ ++ const uint64_t scratch_last = ++ (uint64_t)scratch_first + ++ (uint64_t)scratch_stride * (uint64_t)(scratch_count - 1u); ++ if (scratch_last > 0x1F8003FCull) return 0; ++ ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; ++ i < s_precision_scratch_store_pc_route_count; ++i) { ++ PrecisionScratchStorePcRoute &route = ++ s_precision_scratch_store_pc_routes[i]; ++ if (route.physical_pc != physical_pc) continue; ++ return route.instruction == instruction && ++ route.gte_reg == gte_reg && ++ route.scratch_first == scratch_first && ++ route.scratch_stride == scratch_stride && ++ route.scratch_count == scratch_count; ++ } ++ if (s_precision_scratch_store_pc_route_count >= ++ PRECISION_SCRATCH_STORE_PC_ROUTE_MAX) ++ return 0; ++ PrecisionScratchStorePcRoute &route = ++ s_precision_scratch_store_pc_routes[ ++ s_precision_scratch_store_pc_route_count++]; ++ route.physical_pc = physical_pc; ++ route.instruction = instruction; ++ route.scratch_first = scratch_first; ++ route.scratch_stride = scratch_stride; ++ route.scratch_count = scratch_count; ++ route.gte_reg = gte_reg; ++ return 1; ++} ++ ++extern "C" int gte_precision_scratch_store_pc_route_clamp( ++ uint32_t store_pc, uint32_t instruction, ++ int16_t low_x, int16_t high_x, int16_t low_y, int16_t high_y) { ++ const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; ++ if (low_x > high_x || low_y > high_y) return 0; ++ for (uint32_t i = 0; i < s_precision_scratch_store_pc_route_count; ++i) { ++ PrecisionScratchStorePcRoute &route = s_precision_scratch_store_pc_routes[i]; ++ if (route.physical_pc != physical_pc || route.instruction != instruction) continue; ++ route.clamps = 1; ++ route.low_x = low_x; route.high_x = high_x; ++ route.low_y = low_y; route.high_y = high_y; ++ return 1; ++ } ++ return 0; ++} ++ ++static uint32_t precision_scratch_route_clamp( ++ const PrecisionScratchStorePcRoute &route, uint32_t packed) { ++ int32_t x = (int16_t)(packed & 0xFFFFu), y = (int16_t)(packed >> 16); ++ if (x < route.low_x) x = route.low_x; ++ if (x > route.high_x) x = route.high_x; ++ if (y < route.low_y) y = route.low_y; ++ if (y > route.high_y) y = route.high_y; ++ return ((uint32_t)x & 0xFFFFu) | ((uint32_t)y << 16); ++} ++ ++extern "C" int gte_precision_copy_pc_route_add( ++ uint32_t load_pc, uint32_t load_instruction, ++ uint32_t store_pc, uint32_t store_instruction, uint8_t gpr) { ++ if ((load_pc & 3u) != 0u || (store_pc & 3u) != 0u || gpr == 0u || ++ gpr >= 32u || ((load_instruction >> 26) & 0x3Fu) != 0x23u || ++ ((store_instruction >> 26) & 0x3Fu) != 0x2Bu || ++ ((load_instruction >> 16) & 0x1Fu) != gpr || ++ ((store_instruction >> 16) & 0x1Fu) != gpr) ++ return 0; ++ const uint32_t load_physical_pc = load_pc & 0x1FFFFFFFu; ++ const uint32_t store_physical_pc = store_pc & 0x1FFFFFFFu; ++ for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { ++ PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; ++ if (route.load_physical_pc != load_physical_pc && ++ route.store_physical_pc != store_physical_pc) ++ continue; ++ return route.load_physical_pc == load_physical_pc && ++ route.load_instruction == load_instruction && ++ route.store_physical_pc == store_physical_pc && ++ route.store_instruction == store_instruction && ++ route.gpr == gpr; ++ } ++ if (s_precision_copy_pc_route_count >= PRECISION_COPY_PC_ROUTE_MAX) ++ return 0; ++ PrecisionCopyPcRoute &route = ++ s_precision_copy_pc_routes[s_precision_copy_pc_route_count++]; ++ route.load_physical_pc = load_physical_pc; ++ route.load_instruction = load_instruction; ++ route.store_physical_pc = store_physical_pc; ++ route.store_instruction = store_instruction; ++ route.gpr = gpr; ++ route.capture.valid = 0; ++ return 1; ++} ++ ++extern "C" uint32_t gte_precision_mfc2_pc_read( ++ uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg, ++ uint32_t packed) { ++ const PrecisionMfc2PcRoute *route = ++ precision_mfc2_pc_route(mfc2_pc, instruction); ++ if (!route || route->gte_reg != gte_reg) ++ return packed; ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking) ++ return packed; ++ ++ const int index = precision_projection_index(gte_reg); ++ if (index < 0) return packed; ++ PreciseProjection &capture = s_precision_mfc2_capture[index]; ++ capture.valid = 0; ++ const PreciseProjection &projection = s_precise_sxy[index]; ++ if (projection.valid && projection.packed == packed) ++ capture = projection; ++ return packed; ++} ++ ++extern "C" uint32_t gte_precision_copy_pc_read( ++ uint32_t load_pc, uint32_t instruction, uint8_t gpr, ++ uint32_t addr, uint32_t packed) { ++ PrecisionCopyPcRoute *route = ++ precision_copy_load_pc_route(load_pc, instruction, gpr); ++ if (!route) return packed; ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking) ++ return packed; ++ ++s_precision_diagnostics.copy_load_attempts; ++ ++ /* Every authoritative execution replaces the prior capture, including a ++ * source miss. An older equal packed value must never survive this load. */ ++ route->capture.valid = 0; ++ if ((addr & 3u) != 0u) return packed; ++ uint32_t physical; ++ if (!precision_copy_source_address(addr, &physical)) return packed; ++ const PrecisionStoreEntry &entry = ++ s_precision_store[precision_hash(physical)]; ++ if (entry.generation != s_precision_generation || entry.addr != physical || ++ !entry.projection.valid || entry.projection.packed != packed) ++ return packed; ++ route->capture = entry.projection; ++ ++s_precision_diagnostics.copy_load_accepts; ++ return packed; ++} ++ ++extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { ++ /* Consume before every early return. Speculative validation, disabled ++ * tracking and ineligible GTE registers must not leave a real or replayed ++ * write token available to a later SWC2 callback. */ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || ++ reg < 12 || reg > 15 || (addr & 3u) != 0u) return; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) return; ++ int commit_matches = committed && ++ precision_memory_commit_matches(committed_domain, physical) && ++ committed_physical == physical; ++#ifdef PSX_GTE_REGISTER_TEST ++ /* The pinned standalone register fixture links gte.cpp without memory.c ++ * and directly seeds then stores precision entries. Preserve that ++ * historical test seam only when no producer token exists; an explicitly ++ * armed mismatched token still exercises the production rejection. */ ++ if (!committed) commit_matches = 1; ++#endif ++ if (!commit_matches) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ int index = precision_projection_index(reg); ++ const PreciseProjection &projection = s_precise_sxy[index]; ++ if (!projection.valid) return; ++ precision_store_projection(physical, projection); ++} ++ ++extern "C" void gte_precision_store_pc_word( ++ uint32_t store_pc, uint32_t instruction, uint32_t addr, ++ uint32_t packed) { ++ /* Most ordinary SWs are not reviewed projection stores and must leave the ++ * token available for an immediately-following SWC2 callback. */ ++ const PrecisionStorePcRoute *route = ++ precision_store_pc_route(store_pc, instruction); ++ if (!route) return; ++ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking || (addr & 3u) != 0u) ++ return; ++ ++s_precision_diagnostics.registered_store_attempts; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) return; ++ if (!committed || !precision_memory_commit_matches(committed_domain, physical) || ++ committed_physical != physical) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ ++ const int index = precision_projection_index(route->gte_reg); ++ if (index < 0) return; ++ const PreciseProjection &projection = s_precision_mfc2_capture[index]; ++ if (!projection.valid) return; ++ if (projection.packed != packed) { ++ ++s_precision_diagnostics.registered_store_packed_rejections; ++ return; ++ } ++ precision_store_projection(physical, projection); ++ ++s_precision_diagnostics.registered_store_accepts; ++} ++ ++extern "C" void gte_precision_scratch_store_pc_word( ++ uint32_t store_pc, uint32_t instruction, ++ uint32_t addr, uint32_t packed) { ++ /* An unrelated SW callback must not steal the token from the matching ++ * reviewed helper that immediately follows it. */ ++ const PrecisionScratchStorePcRoute *route = ++ precision_scratch_store_pc_route(store_pc, instruction); ++ if (!route) return; ++ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking || (addr & 3u) != 0u) ++ return; ++ ++s_precision_diagnostics.registered_store_attempts; ++ ++ uint32_t physical; ++ if (!precision_scratch_address(addr, &physical) || ++ !precision_scratch_route_contains(*route, physical)) ++ return; ++ if (!committed || ++ committed_domain != PRECISION_WORD_DOMAIN_SCRATCHPAD || ++ committed_physical != physical) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ ++ const int index = precision_projection_index(route->gte_reg); ++ if (index < 0) return; ++ const PreciseProjection &projection = s_precision_mfc2_capture[index]; ++ if (!projection.valid) return; ++ if (projection.packed != packed) { ++ /* A corner the game pinned to its guard band keeps the projection it was pinned from. */ ++ if (!route->clamps || precision_scratch_route_clamp(*route, projection.packed) != packed) { ++ ++s_precision_diagnostics.registered_store_packed_rejections; ++ return; ++ } ++ PreciseProjection pinned = projection; ++ pinned.packed = packed; ++ pinned.clamped = 1; ++ precision_store_projection(physical, pinned); ++ ++s_precision_diagnostics.registered_store_accepts; ++ return; ++ } ++ precision_store_projection(physical, projection); ++ ++s_precision_diagnostics.registered_store_accepts; ++} ++ ++extern "C" void gte_precision_copy_pc_word( ++ uint32_t store_pc, uint32_t instruction, uint8_t gpr, ++ uint32_t addr, uint32_t packed) { ++ PrecisionCopyPcRoute *route = ++ precision_copy_store_pc_route(store_pc, instruction, gpr); ++ if (!route) return; ++ ++ const uint32_t committed_physical = s_precision_word_commit.physical; ++ const uint8_t committed_domain = s_precision_word_commit.domain; ++ const int committed = s_precision_word_commit.pending != 0; ++ s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; ++ s_precision_word_commit.pending = 0; ++ ++ /* Replay/speculative execution may consume only its transient memory token; ++ * it must neither replace nor erase an authoritative load capture. */ ++ if (s_speculative_depth != 0 || s_gte_replay_sandbox || ++ !s_precision_tracking) ++ return; ++ ++s_precision_diagnostics.copy_store_attempts; ++ ++ const PreciseProjection projection = route->capture; ++ route->capture.valid = 0; ++ if ((addr & 3u) != 0u) return; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) return; ++ if (!committed || !precision_memory_commit_matches(committed_domain, physical) || ++ committed_physical != physical) { ++ ++s_precision_diagnostics.store_uncommitted_rejections; ++ return; ++ } ++ if (!projection.valid) return; ++ if (projection.packed != packed) { ++ ++s_precision_diagnostics.copy_store_packed_rejections; ++ return; ++ } ++ precision_store_projection(physical, projection); ++ ++s_precision_diagnostics.copy_store_accepts; ++} ++ + extern "C" void gte_precision_invalidate_word(uint32_t addr) { + if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking) return; + uint32_t physical; +- if (!precision_ram_address(addr, &physical)) return; ++ if (!precision_copy_source_address(addr, &physical)) return; + PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; + if (entry.generation == s_precision_generation && entry.addr == physical) + entry.generation = 0; + } + ++static GtePrecisionLookupResult precision_lookup_projection( ++ uint32_t addr, uint32_t packed, ++ const PreciseProjection **projection) { ++ if (projection) *projection = nullptr; ++ if (s_speculative_depth != 0) ++ return GTE_PRECISION_LOOKUP_SPECULATIVE; ++ if (!s_precision_tracking) ++ return GTE_PRECISION_LOOKUP_TRACKING_DISABLED; ++ if ((addr & 3u) != 0u) ++ return GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED; ++ uint32_t physical; ++ if (!precision_ram_address(addr, &physical)) ++ return GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM; ++ const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; ++ if (entry.generation != s_precision_generation) ++ return GTE_PRECISION_LOOKUP_STORE_MISS; ++ if (entry.addr != physical) { ++ ++s_precision_diagnostics.lookup_collisions; ++ return GTE_PRECISION_LOOKUP_STORE_COLLISION; ++ } ++ if (!entry.projection.valid) ++ return GTE_PRECISION_LOOKUP_PROJECTION_INVALID; ++ if (entry.projection.packed != packed) ++ return GTE_PRECISION_LOOKUP_PACKED_MISMATCH; ++ if (entry.projection.z == 0) ++ return GTE_PRECISION_LOOKUP_ZERO_DEPTH; ++ /* A corner the GTE saturated and the game then pinned to its guard band is drawn where it projects. */ ++ if (entry.projection.saturated && !(entry.projection.clamped && entry.projection.unbounded && precision_unpin())) ++ return GTE_PRECISION_LOOKUP_SATURATED; ++ if (projection) *projection = &entry.projection; ++ return GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" GtePrecisionLookupResult gte_precision_load_word_ex( ++ uint32_t addr, uint32_t packed, ++ int32_t *x16, int32_t *y16, uint16_t *z) { ++ const PreciseProjection *projection = nullptr; ++ const GtePrecisionLookupResult result = ++ precision_lookup_projection(addr, packed, &projection); ++ if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; ++ /* Only a corner the game pinned leaves its packed pixel: it is drawn where an exact divide puts it. */ ++ const bool exact = projection->clamped && projection->unbounded && precision_unpin(); ++ if (x16) *x16 = exact ? projection->exact_x16 : projection->x16; ++ if (y16) *y16 = exact ? projection->exact_y16 : projection->y16; ++ if (z) *z = projection->z; ++ return GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" GtePrecisionLookupResult gte_precision_load_perspective_word_ex( ++ uint32_t addr, uint32_t packed, uint16_t *z) { ++ const PreciseProjection *projection = nullptr; ++ const GtePrecisionLookupResult result = ++ precision_lookup_projection(addr, packed, &projection); ++ if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; ++ if (!projection->perspective_valid) ++ return GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID; ++ if (z) *z = projection->z; ++ return GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" GtePrecisionLookupResult gte_precision_load_identity( ++ uint32_t addr, uint32_t packed, uint64_t *identity) { ++ const PreciseProjection *projection = nullptr; ++ const GtePrecisionLookupResult result = ++ precision_lookup_projection(addr, packed, &projection); ++ if (identity) ++ *identity = result == GTE_PRECISION_LOOKUP_ACCEPTED ? projection->identity : 0u; ++ return result; ++} ++ ++extern "C" int gte_precision_unpin_enabled(void) { return precision_unpin(); } ++extern "C" void gte_precision_unpin_set(int enabled) { s_precision_unpin = enabled ? 1 : 0; } ++ ++extern "C" int gte_precision_word_clamped(uint32_t addr, uint32_t packed) { ++ const PreciseProjection *projection = nullptr; ++ return precision_lookup_projection(addr, packed, &projection) == GTE_PRECISION_LOOKUP_ACCEPTED && ++ projection->clamped; ++} ++ + extern "C" int gte_precision_load_word(uint32_t addr, uint32_t packed, + int32_t *x16, int32_t *y16, + uint16_t *z) { +- if (s_speculative_depth != 0 || !s_precision_tracking) return 0; +- uint32_t physical; +- if (!precision_ram_address(addr, &physical)) return 0; +- const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; +- if (entry.generation != s_precision_generation || entry.addr != physical || +- !entry.projection.valid || entry.projection.packed != packed) +- return 0; +- if (x16) *x16 = entry.projection.x16; +- if (y16) *y16 = entry.projection.y16; +- if (z) *z = entry.projection.z; +- return entry.projection.z != 0; +-} +- +-static inline uint32_t geom_hash(uint32_t packed) { +- packed ^= packed >> 16; +- return (packed * 2654435761u) & (GEOM_CACHE_SIZE - 1u); ++ return gte_precision_load_word_ex(addr, packed, x16, y16, z) == ++ GTE_PRECISION_LOOKUP_ACCEPTED; ++} ++ ++extern "C" void gte_precision_diagnostics(GtePrecisionDiagnostics *out) { ++ if (out) *out = s_precision_diagnostics; ++} ++ ++extern "C" void gte_precision_diagnostics_reset(void) { ++ s_precision_diagnostics = {}; + } + + extern "C" void gte_geometry_correction_set(int enabled) { + s_geom_enabled = enabled ? 1 : 0; +- s_geom_hits = 0; ++#ifdef PSX_GTE_REGISTER_TEST ++ s_geom_test_hits = 0; ++#endif + gte_geom_generation_advance(); + } + +@@ -349,50 +1078,59 @@ + } + + extern "C" uint32_t gte_geometry_correction_hits(void) { +- return s_geom_hits; +-} +- ++#ifdef PSX_GTE_REGISTER_TEST ++ return s_geom_test_hits; ++#else ++ return 0; ++#endif ++} ++ ++/* Retained as a hard-disabled ABI stub for older generated/native test code. ++ * Geometry must never recover provenance from a rounded coordinate alone. */ + extern "C" int gte_geometry_correction_lookup(uint32_t packed, + int32_t *x16, int32_t *y16) { ++#ifdef PSX_GTE_REGISTER_TEST + if (s_speculative_depth != 0 || !s_geom_enabled) return 0; +- const GeomVertex &entry = s_geom_cache[geom_hash(packed)]; +- if (entry.generation != s_geom_generation || entry.packed != packed) return 0; ++ const GeomTestVertex &entry = s_geom_test_cache[geom_test_hash(packed)]; ++ if (entry.generation != s_geom_generation || entry.packed != packed) ++ return 0; + if (x16) *x16 = entry.x16; + if (y16) *y16 = entry.y16; +- s_geom_hits++; ++ s_geom_test_hits++; + return 1; +-} +- +-static inline void geom_note(uint32_t packed, int64_t x16, int64_t y16) { +- if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_geom_enabled) return; +- /* Saturated off-screen projections are unsuitable for subpixel recovery. */ +- int32_t x = (int16_t)(packed & 0xFFFFu); +- int32_t y = (int16_t)(packed >> 16); +- if (x <= -0x400 || x >= 0x3FF || y <= -0x400 || y >= 0x3FF) return; +- GeomVertex &entry = s_geom_cache[geom_hash(packed)]; +- entry.packed = packed; +- entry.x16 = (int32_t)x16; +- entry.y16 = (int32_t)y16; +- entry.generation = s_geom_generation; +-} +- +-// Per-draw suppression of the X-squash (8C far-backdrop). The far backdrop +-// (ocean/cloud/distant mountain) is a parallax layer that is conceptually at +-// infinity: squashing it toward centre leaves its geometry short of the +-// revealed 16:9 edges (blue void). Drawing it UN-squashed lets its 4:3-extent +-// fill the stretched frame (skybox treatment) while the near 3D world keeps the +-// wider FOV. The recompiler brackets the backdrop driver (FUN_8004db3c) with +-// gte_ws_set_suppress(1)/(0) ([widescreen] backdrop_unsquash_funcs). +-// The driver (FUN_8004db3c) actually draws a MIX: the far backdrop (which we +-// want un-squashed) AND nearer props (doors/mansion models) that must stay +-// squashed or they drift relative to the rest of the world. So suppression is +-// DEPTH-GATED: only vertices with SZ (projected Z) >= s_ws_far_threshold get +-// un-squashed while suppress is active; nearer props keep the squash. Threshold +-// tuned live via the ws_far_threshold TCP cmd; SZ stats below characterize the +-// driver's depth range so the split is set from data, not a guess. +-static int s_ws_suppress = 0; ++#else ++ (void)packed; ++ (void)x16; ++ (void)y16; ++ return 0; ++#endif ++} ++ ++// Per-scope diagnostic suppression of the classic widescreen X squash for ++// candidate finite far backdrops. The recompiler brackets each reviewed ++// [widescreen.backdrop].unsquash_funcs driver with ++// gte_ws_set_suppress(1)/(0). Those drivers may draw a mixture of backdrop and ++// ordinary room geometry, so the scope remains DEPTH-GATED: only vertices with ++// SZ >= s_ws_far_threshold retain their authored 4:3 projection before the ++// final wide stretch. The counter makes nested/recursive tagged renderers safe: ++// an inner return must not clear its caller's active scope. Live testing has ++// not linked this path to the skyline plane, so it must remain opt-in. ++static unsigned s_ws_suppress_depth = 0; ++static int s_ws_backdrop_repair_enabled = 0; + static int32_t s_ws_far_threshold = 900; /* SZ split: near props squashed, far backdrop un-squashed (8C, tunable via ws_far_threshold) */ +-extern "C" void gte_ws_set_suppress(int on) { s_ws_suppress = on ? 1 : 0; } ++extern "C" void gte_ws_set_suppress(int on) { ++ if (on) { ++ if (s_ws_suppress_depth != 0xFFFFFFFFu) ++s_ws_suppress_depth; ++ } else if (s_ws_suppress_depth != 0) { ++ --s_ws_suppress_depth; ++ } ++} ++extern "C" void gte_ws_set_backdrop_repair_enabled(int on) { ++ s_ws_backdrop_repair_enabled = on ? 1 : 0; ++} ++extern "C" int gte_ws_get_backdrop_repair_enabled(void) { ++ return s_ws_backdrop_repair_enabled; ++} + extern "C" void gte_ws_set_far_threshold(int t) { s_ws_far_threshold = t; } + extern "C" int gte_ws_get_far_threshold(void) { return (int)s_ws_far_threshold; } + /* SZ stats observed while suppress is active (one frame window, reset on read). */ +@@ -814,13 +1552,13 @@ + // this frame is being stretched — never on a 4:3-presented frame (FMV / + // full-2D screen), so content and present stay locked. + int64_t xterm = (int64_t)gte->IR1 * h_div_sz; +- bool do_squash = (s_ws_xnum != s_ws_xden) && !gpu_ws_present_native_43(); ++ bool do_squash = ws_x_squashed(); + const bool dome_call = ws_dome_call_matches(); + // Curved backdrops are authored to cover the original 4:3 projection. + // In classic widescreen, leave that projection intact and let the normal + // final-frame stretch cover the wide output instead of shrinking it first. + if (dome_call) do_squash = false; +- if (do_squash && s_ws_suppress) { ++ if (do_squash && s_ws_suppress_depth != 0) { + /* Depth-gated: un-squash only FAR geometry (the backdrop); keep near + * props squashed so they stay aligned. Record SZ stats for tuning. */ + int32_t sz = gte->SZ[3]; +@@ -829,7 +1567,7 @@ + if (sz > s_ws_sz_max) s_ws_sz_max = sz; + s_ws_sz_n++; + } +- if (sz >= s_ws_far_threshold) { ++ if (s_ws_backdrop_repair_enabled && sz >= s_ws_far_threshold) { + do_squash = false; + if (!s_gte_replay_sandbox) s_ws_sz_far++; + } +@@ -863,11 +1601,46 @@ + s_precise_sxy[2].x16 = (int32_t)sx16; + s_precise_sxy[2].y16 = (int32_t)sy16; + s_precise_sxy[2].z = gte->SZ[3]; +- s_precise_sxy[2].valid = gte->SZ[3] != 0; ++ s_precise_sxy[2].valid = 1; ++ s_precise_sxy[2].identity = UINT64_C(0x8000000000000000) | ++ static_cast(static_cast(V[0])) | ++ static_cast(static_cast(V[1])) << 16 | ++ static_cast(static_cast(V[2])) << 32; ++ s_precise_sxy[2].saturated = ++ static_cast(gte->SXY[2] & 0xFFFF) != sx || ++ static_cast((uint32_t)gte->SXY[2] >> 16) != sy; ++ /* The GTE's divide stops at twice the focal length and its screen at 1023: an exact divide does not. */ ++ const bool stopped = s_precise_sxy[2].saturated || (uint32_t)gte->SZ[3] * 2 <= (uint32_t)gte->H; ++ s_precise_sxy[2].unbounded = 0; ++ if (stopped && gte->SZ[3] != 0 && (do_squash || xterm == (int64_t)gte->IR1 * h_div_sz)) { ++ const int64_t exact = ((int64_t)gte->H << 16) / gte->SZ[3]; ++ int64_t exact_x = (int64_t)gte->IR1 * exact; ++ if (do_squash) exact_x = exact_x * s_ws_xnum / s_ws_xden; ++ int64_t exact_y = (int64_t)gte->IR2 * exact; ++ /* Too far for a 16.16 coordinate once the screen centre is added: nearer along the same ray from that centre. */ ++ const int64_t room_x = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFX); ++ const int64_t room_y = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFY); ++ double nearer = 1.0; ++ if (std::abs(exact_x) > room_x) nearer = (double)room_x / (double)std::abs(exact_x); ++ if (std::abs(exact_y) > room_y) nearer = std::min(nearer, (double)room_y / (double)std::abs(exact_y)); ++ if (room_x > 0 && room_y > 0) { ++ if (nearer < 1.0) { ++ exact_x = (int64_t)((double)exact_x * nearer); ++ exact_y = (int64_t)((double)exact_y * nearer); ++ } ++ s_precise_sxy[2].exact_x16 = (int32_t)(gte->OFX + exact_x); ++ s_precise_sxy[2].exact_y16 = (int32_t)(gte->OFY + exact_y); ++ s_precise_sxy[2].unbounded = 1; ++ } ++ } ++ /* Perspective textures need the original camera depth, not a value ++ * clamped into SZ3, and must reject the GTE's near divide-overflow ++ * region. Geometry can still use the retained X/Y when this bit is 0. */ ++ s_precise_sxy[2].perspective_valid = ++ gte->H != 0 && gte->MAC3 > 0 && gte->MAC3 <= 0xFFFF && ++ (uint32_t)gte->SZ[3] * 2u > (uint32_t)gte->H; + s_precise_sxy[3] = s_precise_sxy[2]; + } +- geom_note((uint32_t)gte->SXY[2], sx16, sy16); +- + // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS + if (setMac0) { + int64_t mac0 = (int64_t)gte->DQA * h_div_sz + gte->DQB; +@@ -1494,6 +2267,7 @@ + extern "C" int gte_replay_side_effects_begin(void) { + using namespace PSXRecomp::GTE; + if (s_gte_replay_sandbox) return 0; ++ gte_precision_word_write_begin(); + s_gte_replay_saved_caller_ra = s_gte_caller_ra; + s_gte_replay_sandbox = 1; + return 1; +@@ -1503,6 +2277,7 @@ + if (!s_gte_replay_sandbox) return; + s_gte_caller_ra = s_gte_replay_saved_caller_ra; + s_gte_replay_sandbox = 0; ++ gte_precision_word_write_begin(); + } + + static uint32_t gte_cpu_lzcr(uint32_t value); +@@ -2010,6 +2785,33 @@ + p.y16 = y16; + p.z = z; + p.valid = 1; ++ p.saturated = 0; ++ p.unbounded = 0; ++ p.perspective_valid = 1; ++} ++ ++extern "C" void gte_test_set_precise_exact(uint32_t index, int saturated, int unbounded, int32_t exact_x16, int32_t exact_y16) { ++ if (index >= 4) return; ++ auto &p = PSXRecomp::GTE::s_precise_sxy[index]; ++ p.saturated = saturated ? 1 : 0; ++ p.unbounded = unbounded ? 1 : 0; ++ p.exact_x16 = exact_x16; ++ p.exact_y16 = exact_y16; ++} ++ ++extern "C" void gte_test_get_precise_exact(uint32_t index, int *saturated, int *unbounded, int32_t *exact_x16, int32_t *exact_y16) { ++ if (index >= 4) return; ++ const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; ++ if (saturated) *saturated = p.saturated; ++ if (unbounded) *unbounded = p.unbounded; ++ if (exact_x16) *exact_x16 = p.exact_x16; ++ if (exact_y16) *exact_y16 = p.exact_y16; ++} ++ ++extern "C" void gte_test_set_precise_perspective_valid(uint32_t index, ++ int valid) { ++ if (index >= 4) return; ++ PSXRecomp::GTE::s_precise_sxy[index].perspective_valid = valid ? 1 : 0; + } + + extern "C" void gte_test_get_precise_projection(uint32_t index, +@@ -2029,8 +2831,8 @@ + + extern "C" void gte_test_seed_geometry(uint32_t packed, int32_t x16, + int32_t y16) { +- auto &entry = PSXRecomp::GTE::s_geom_cache[ +- PSXRecomp::GTE::geom_hash(packed)]; ++ auto &entry = PSXRecomp::GTE::s_geom_test_cache[ ++ PSXRecomp::GTE::geom_test_hash(packed)]; + entry.packed = packed; + entry.x16 = x16; + entry.y16 = y16; diff --git a/framework/patches/runtime/src/interrupts.c.patch b/framework/patches/runtime/src/interrupts.c.patch new file mode 100644 index 0000000..0ee52f1 --- /dev/null +++ b/framework/patches/runtime/src/interrupts.c.patch @@ -0,0 +1,99 @@ +diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c +index c267a87da5a361b8d34d15a00622279e231c314f..f105a564bdfa0c97aa46db56bda7acc5db2ba626 100644 +--- a/runtime/src/interrupts.c ++++ b/runtime/src/interrupts.c +@@ -37,6 +37,7 @@ + #include "lockstep.h" + #include "psx_cycles.h" + #include "psx_scheduler.h" ++#include "save_point.h" + #include + #include + #include +@@ -447,6 +448,10 @@ + * the synchronous host window, dirty_ram_dispatch can resume at this guest PC + * instead of treating the sentinel as pc=0 termination. */ + static uint32_t s_compiled_interrupt_resume_pc = 0; ++/* Set by psx_check_interrupts_at for the one call it makes: in any other call the latch above is an earlier call's. */ ++static int s_resume_pc_given = 0; ++static int s_save_poll_owed = 0; ++extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); + static uint32_t s_last_interrupt_check_pc = 0; + static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; + +@@ -731,6 +736,8 @@ + } + + void psx_check_interrupts(CPUState* cpu) { ++ const int save_point = s_resume_pc_given; ++ s_resume_pc_given = 0; + psx_cyc_batch_flush(); + extern int g_ls_suppress_record; + #define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0) +@@ -749,11 +756,10 @@ + if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { + uint32_t sr = cpu->cop0[COP0_SR]; + if (!(sr & 0x01u) || !(sr & (1u << 10))) { +- if ((++s_fast_maintenance & 0x3FFFu) == 0) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); ++ const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; ++ if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); +- debug_server_poll(); +- } ++ if (periodic) debug_server_poll(); + return; + } + } +@@ -795,11 +801,10 @@ + psx_idle_note_check(cpu, check_pc); + } + if ((i_stat & i_mask) == 0 && sw_pending == 0) { +- if ((++s_fast_maintenance & 0x3FFFu) == 0) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); ++ const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; ++ if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) + savestate_poll(cpu, s_compiled_interrupt_resume_pc); +- debug_server_poll(); +- } ++ if (periodic) debug_server_poll(); + return; + } + /* Idle skip advanced time and a device raised I_STAT — deliver below. */ +@@ -817,11 +822,10 @@ + s_last_interrupt_check_cycle = psx_get_cycle_count(); + g_ls_suppress_record++; + total_checks++; +- if ((total_checks & 0x3FFFu) == 0) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); +- savestate_poll(cpu, check_pc); +- debug_server_poll(); +- } ++ const int periodic = (total_checks & 0x3FFFu) == 0; ++ if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) ++ savestate_poll(cpu, s_compiled_interrupt_resume_pc); ++ if (periodic) debug_server_poll(); + goto irq_deliver_eval; + } + +@@ -862,10 +866,8 @@ + * only act outside the exception handler so a restore's stack-unwind can't + * strand a half-finished handler. Near-free when nothing is staged. A load + * longjmps to the scheduler and never returns here. */ +- if (!in_exception) { +- extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); +- savestate_poll(cpu, s_last_interrupt_check_pc); +- } ++ if (!in_exception && psx_save_poll_due(1, save_point, &s_save_poll_owed)) ++ savestate_poll(cpu, s_compiled_interrupt_resume_pc); + + /* Deferred cooperative thread switch: honor at the next real thread-save + * boundary (see s_defer_switch_* above). A +@@ -1649,6 +1651,7 @@ + void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) { + uint32_t prev = s_compiled_interrupt_resume_pc; + s_compiled_interrupt_resume_pc = resume_pc; ++ s_resume_pc_given = 1; + psx_check_interrupts(cpu); /* flushes load-charge batch on entry */ + s_compiled_interrupt_resume_pc = prev; + } diff --git a/framework/patches/runtime/src/iso_reader_c.cpp.patch b/framework/patches/runtime/src/iso_reader_c.cpp.patch new file mode 100644 index 0000000..cc31b27 --- /dev/null +++ b/framework/patches/runtime/src/iso_reader_c.cpp.patch @@ -0,0 +1,102 @@ +diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp +index 13c0beb274a76941b174bd8083f89c92266cc403..a3fd420284513192ff70b4446bcb50e9b1adf02c 100644 +--- a/runtime/src/iso_reader_c.cpp ++++ b/runtime/src/iso_reader_c.cpp +@@ -4,9 +4,25 @@ + * Provides iso_open() / iso_read_sector() / iso_close() for use by cdrom.c + */ + ++#include "iso_overlay.h" + #include "iso_reader.h" + #include "mod_runtime.h" ++#include + #include ++#include ++#include ++ ++namespace { ++ ++const PsxIsoOverlay* g_overlay = nullptr; ++std::vector g_plain; ++void* g_game_disc = nullptr; ++ ++bool overlaid(void* handle) { ++ return g_overlay && std::find(g_plain.begin(), g_plain.end(), handle) == g_plain.end(); ++} ++ ++} // namespace + + extern "C" { + +@@ -16,14 +32,39 @@ + delete reader; + return nullptr; + } ++ g_game_disc = reader; + return reader; ++} ++ ++void iso_set_overlay(const PsxIsoOverlay* overlay) { g_overlay = overlay; } ++ ++void* iso_open_plain(const char* path) { ++ void* const game = g_game_disc; ++ void* handle = iso_open(path); ++ g_game_disc = game; ++ if (handle) g_plain.push_back(handle); ++ return handle; ++} ++ ++void* iso_game_disc(void) { return g_game_disc; } ++ ++int iso_read_raw_sector_plain(void* handle, uint32_t lba, uint8_t* buffer) { ++ return handle && static_cast(handle)->ReadRawSector(lba, buffer) ? 1 : 0; ++} ++ ++uint32_t iso_sector_count_plain(void* handle) { ++ return handle ? static_cast(handle)->GetSectorCount() : 0; + } + + int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { + if (!handle) return 0; + auto* reader = static_cast(handle); + (void)size; /* ReadSector always reads 2048 bytes */ +- if (!reader->ReadSector(lba, buffer)) return 0; ++ uint8_t raw[2352]; ++ const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, raw) : 0; ++ if (stood_in < 0) return 0; ++ if (stood_in > 0) std::memcpy(buffer, raw + 24, 2048); ++ else if (!reader->ReadSector(lba, buffer)) return 0; + mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); + return 1; + } +@@ -31,15 +72,17 @@ + int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { + if (!handle || size < 2352) return 0; + auto* reader = static_cast(handle); +- if (!reader->ReadRawSector(lba, buffer)) return 0; ++ const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, buffer) : 0; ++ if (stood_in < 0) return 0; ++ if (stood_in == 0 && !reader->ReadRawSector(lba, buffer)) return 0; + mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); + return 1; + } + + uint32_t iso_sector_count(void* handle) { + if (!handle) return 0; +- auto* reader = static_cast(handle); +- return reader->GetSectorCount(); ++ const uint32_t stood_in = overlaid(handle) && g_overlay->sectors ? g_overlay->sectors(handle) : 0; ++ return stood_in ? stood_in : iso_sector_count_plain(handle); + } + + /* CD-track TOC accessors (multi-track / CD-DA support). track is 1-based. */ +@@ -65,6 +108,9 @@ + + void iso_close(void* handle) { + if (!handle) return; ++ if (overlaid(handle) && g_overlay->closed) g_overlay->closed(handle); ++ if (handle == g_game_disc) g_game_disc = nullptr; ++ g_plain.erase(std::remove(g_plain.begin(), g_plain.end(), handle), g_plain.end()); + auto* reader = static_cast(handle); + reader->Close(); + delete reader; diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch new file mode 100644 index 0000000..af20c10 --- /dev/null +++ b/framework/patches/runtime/src/main.cpp.patch @@ -0,0 +1,2206 @@ +diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp +index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1fa9a3548 100644 +--- a/runtime/src/main.cpp ++++ b/runtime/src/main.cpp +@@ -1,9 +1,16 @@ +-/* main.cpp — Phase 3 runtime entry point. ++/* main.cpp — Phase 3 runtime entry point. + * + * Loads BIOS ROM, initializes CPU state + SDL display, calls into + * the recompiled reset vector. BIOS drives execution; SDL presents + * VRAM at each vblank via callback from gpu_vblank_tick(). + */ ++ ++/* Keep the Windows min/max macros out of this C++ translation unit. SDL may ++ * include windows.h before the explicit platform includes below, so this must ++ * be defined before any runtime header is included. */ ++#if defined(_WIN32) && !defined(NOMINMAX) ++#define NOMINMAX ++#endif + + #include "cpu_state.h" + #include "psx_bios_image.h" /* the active BIOS's self-description */ +@@ -35,6 +42,11 @@ + #include "gpu_render.h" + #include "gpu_gl_renderer.h" + #include "gpu_vk_renderer.h" ++#include "host_borderless.h" ++#include "host_pick_slot.h" ++#include "host_ui.h" ++extern "C" void gl_renderer_geometry_interpolation_diag(uint64_t out[6]); ++#include "gte_precision.h" + #include "frame_pacing.h" + #include "latency_ring.h" + #include "sio.h" +@@ -59,6 +71,21 @@ + #include "game_options.h" + #include "mod_plugins.h" + #include "mod_runtime.h" ++#ifdef PSX_HAS_DISRUPTOR_CAPSULE ++extern "C" void disruptor_capsule_before_input(void); ++extern "C" void disruptor_capsule_after_input(void); ++extern "C" void disruptor_capsule_state_loaded(void); ++#endif ++#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING ++extern "C" void disruptor_far_rendering_abandon_metrics(void); ++#ifdef PSX_HAS_DISRUPTOR_LANGUAGE ++extern "C" void disruptor_language_set_disc(const char* path); ++extern "C" void disruptor_language_settle(void); ++#endif ++#endif ++#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY ++extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); ++#endif + #include "dirty_ram_interp.h" + #include "crc32.h" + #include "disc_identity.h" +@@ -87,6 +114,9 @@ + #endif + #include + #include ++#include ++#include ++#include + #include + #include + #include +@@ -100,6 +130,11 @@ + #include + #include + ++/* Internal GL seam: host-menu suspension is a separate reason from FMV/ ++ * content suspension, so either owner can release independently. */ ++extern "C" void gl_renderer_set_host_ui_suspended(int suspended); ++extern "C" void gl_renderer_set_hidden_rows(int rows); ++ + #ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN +@@ -108,7 +143,7 @@ + #include + #include + #include +-#include ++#include + #else + #include + #include +@@ -132,7 +167,131 @@ + #error DEFAULT_DEBUG_PORT must be defined by the runtime target. + #endif + ++/* SDL_RenderSetVSync was added in SDL 2.0.18. Keep the explicitly supported ++ * SDL2 fallback buildable against older distro/toolchain copies; its OpenGL ++ * renderer can still release the swap interval through the public GL API. ++ * Use a project-local name because SDL3's psx_sdl.h already defines its own ++ * psx_sdl_render_set_vsync compatibility helper. */ ++static int disruptor_sdl_render_set_vsync_compat(SDL_Renderer *renderer, int enabled) { ++#if defined(PSX_SDL3) || SDL_VERSION_ATLEAST(2, 0, 18) ++ return SDL_RenderSetVSync(renderer, enabled); ++#else ++ (void)renderer; ++ return SDL_GL_SetSwapInterval(enabled ? 1 : 0); ++#endif ++} ++ + extern "C" uint64_t gte_get_exec_count(void); ++ ++/* Disruptor does not use SWC2 for its resident world-projection loops. These ++ * two reviewed MFC2 sites snapshot SXY2. Six ordinary SW sites publish into ++ * main RAM and one publishes into nine exact scratchpad slots, each only after ++ * an exact instruction and committed-word check. Twenty-four reviewed LW/SW ++ * pairs then carry those proven words into GP0 packets. Keeping every route ++ * behind the game serial prevents another title at the same PCs from ++ * inheriting it. */ ++static int configure_disruptor_precision_store_routes( ++ const std::string& game_id) { ++ gte_precision_store_pc_routes_reset(); ++ if (game_id != "SLUS-00224") return 1; ++ static const uint32_t mfc2_pcs[] = { ++ 0x8004633Cu, ++ 0x8004795Cu, ++ }; ++ static const uint32_t store_pcs[] = { ++ 0x8004641Cu, ++ 0x800464BCu, ++ 0x80046524u, ++ 0x80046540u, ++ 0x80046594u, ++ 0x800465D8u, ++ }; ++ struct PrecisionCopyRoute { ++ uint32_t load_pc; ++ uint32_t load_instruction; ++ uint32_t store_pc; ++ uint32_t store_instruction; ++ uint8_t gpr; ++ }; ++ static const PrecisionCopyRoute copy_routes[] = { ++ {0x80046C4Cu, 0x8D0C0004u, 0x80046D04u, 0xAF2C0008u, 12u}, ++ {0x80046C50u, 0x8D2D0004u, 0x80046D08u, 0xAF2D0014u, 13u}, ++ {0x80046C54u, 0x8D4E0004u, 0x80046D10u, 0xAF2E002Cu, 14u}, ++ {0x80046C60u, 0x8D6F0004u, 0x80046D0Cu, 0xAF2F0020u, 15u}, ++ {0x80047A80u, 0x8CA80084u, 0x80047A90u, 0xAF280008u, 8u}, ++ {0x80047A84u, 0x8CA900A4u, 0x80047A94u, 0xAF290014u, 9u}, ++ {0x80047A88u, 0x8CAA00C4u, 0x80047A9Cu, 0xAF2A002Cu, 10u}, ++ {0x80047A8Cu, 0x8CAB00BCu, 0x80047A98u, 0xAF2B0020u, 11u}, ++ {0x80047B40u, 0x8CA800A4u, 0x80047B50u, 0xAF280008u, 8u}, ++ {0x80047B44u, 0x8CA9008Cu, 0x80047B54u, 0xAF290014u, 9u}, ++ {0x80047B48u, 0x8CAA00ACu, 0x80047B5Cu, 0xAF2A002Cu, 10u}, ++ {0x80047B4Cu, 0x8CAB00C4u, 0x80047B58u, 0xAF2B0020u, 11u}, ++ {0x80047C00u, 0x8CA800C4u, 0x80047C10u, 0xAF280008u, 8u}, ++ {0x80047C04u, 0x8CA900ACu, 0x80047C14u, 0xAF290014u, 9u}, ++ {0x80047C08u, 0x8CAA0094u, 0x80047C1Cu, 0xAF2A002Cu, 10u}, ++ {0x80047C0Cu, 0x8CAB00B4u, 0x80047C18u, 0xAF2B0020u, 11u}, ++ {0x80047CC4u, 0x8CA800BCu, 0x80047CD4u, 0xAF280008u, 8u}, ++ {0x80047CC8u, 0x8CA900C4u, 0x80047CD8u, 0xAF290014u, 9u}, ++ {0x80047CCCu, 0x8CAA00B4u, 0x80047CE0u, 0xAF2A002Cu, 10u}, ++ {0x80047CD0u, 0x8CAB009Cu, 0x80047CDCu, 0xAF2B0020u, 11u}, ++ {0x80047D6Cu, 0x8CA80084u, 0x80047D7Cu, 0xAF280008u, 8u}, ++ {0x80047D70u, 0x8CA9008Cu, 0x80047D80u, 0xAF290014u, 9u}, ++ {0x80047D74u, 0x8CAA0094u, 0x80047D88u, 0xAF2A002Cu, 10u}, ++ {0x80047D78u, 0x8CAB009Cu, 0x80047D84u, 0xAF2B0020u, 11u}, ++ }; ++ for (uint32_t pc : mfc2_pcs) { ++ if (!gte_precision_mfc2_pc_route_add( ++ pc, 0x480A7000u, 14u)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to register Disruptor SXY2 MFC2 PC " ++ "0x%08X\n", pc); ++ return 0; ++ } ++ } ++ for (uint32_t pc : store_pcs) { ++ if (!gte_precision_store_pc_route_add( ++ pc, 0xACAA0004u, 14u)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to register Disruptor SXY2 store PC " ++ "0x%08X\n", pc); ++ return 0; ++ } ++ } ++ if (!gte_precision_scratch_store_pc_route_add( ++ 0x80047A0Cu, 0xACAA0004u, 14u, ++ 0x1F800084u, 8u, 9u)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to register Disruptor scratchpad SXY2 " ++ "store route 0x80047A0C\n"); ++ return 0; ++ } ++ /* 0x80047980..0x800479E0 pin x to -256..576 and y to -256..496 before that store. */ ++ if (!gte_precision_scratch_store_pc_route_clamp( ++ 0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to register the guard band of Disruptor's " ++ "scratchpad SXY2 store route 0x80047A0C\n"); ++ return 0; ++ } ++ for (const PrecisionCopyRoute& route : copy_routes) { ++ if (!gte_precision_copy_pc_route_add( ++ route.load_pc, route.load_instruction, ++ route.store_pc, route.store_instruction, route.gpr)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to register Disruptor projection-copy " ++ "route 0x%08X -> 0x%08X\n", ++ route.load_pc, route.store_pc); ++ return 0; ++ } ++ } ++ std::fprintf(stdout, ++ "psxrecomp: registered %zu MFC2 captures, %zu main-RAM projection " ++ "stores, 1 scratchpad projection store and %zu packet-copy routes\n", ++ sizeof(mfc2_pcs) / sizeof(mfc2_pcs[0]), ++ sizeof(store_pcs) / sizeof(store_pcs[0]), ++ sizeof(copy_routes) / sizeof(copy_routes[0])); ++ return 1; ++} + + /* Cross-language globals defined in C translation units. Declared extern "C" at + * file scope so MSVC gives them C linkage (matching the C definitions); without +@@ -164,11 +323,12 @@ + * every install must arm it — not just repo checkouts that happen to carry a + * loose EXE copy next to game.toml. Source order: + * 1. The local EXE file from game.toml (dev checkouts; recompiler input). +- * 2. The boot EXE extracted from the mounted disc image (end-user installs +- * — the disc is the same bytes the BIOS loads, i.e. the true reference). ++ * 2. The configured native range sliced from the boot EXE on the mounted ++ * disc (end-user installs may intentionally omit a derived code-only EXE). + * Registration passes ownership of the malloc'd buffer to memory.c. */ + static void arm_text_image_guard(const std::string &exe_path, + uint32_t load_address, ++ uint32_t text_size, + const std::string &disc_path) { + const uint32_t phys_lo = load_address & 0x1FFFFFFFu; + /* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */ +@@ -177,9 +337,12 @@ + if (ef) { + std::streamsize sz = ef.tellg(); + if (sz > 2048) { +- uint32_t img_len = (uint32_t)(sz - 2048); +- uint8_t *img = (uint8_t *)std::malloc(img_len); +- if (img) { ++ uint32_t available = (uint32_t)(sz - 2048); ++ uint32_t img_len = text_size ? text_size : available; ++ if (img_len > available) img_len = 0; ++ uint8_t *img = img_len ++ ? (uint8_t *)std::malloc(img_len) : nullptr; ++ if (img && img_len) { + ef.seekg(2048, std::ios::beg); + if (ef.read((char *)img, img_len)) { + dirty_ram_register_text_image(phys_lo, img, img_len); +@@ -193,7 +356,10 @@ + } + } + } +- /* 2. Extract the boot EXE from the disc image. */ ++ /* 2. Extract the boot EXE from the disc image. Its declared load address ++ * can precede the code-only image consumed by the recompiler, so slice by ++ * the configured physical load and text size rather than blindly copying ++ * from payload byte zero. */ + if (!disc_path.empty()) { + PS1::ISOReader iso; + if (iso.Open(disc_path)) { +@@ -239,17 +405,38 @@ + uint8_t *file = (uint8_t *)std::malloc(ent.size); + if (file) { + size_t got = iso.ReadFile(boot_name, file, ent.size); +- if (got > 2048) { +- uint32_t img_len = (uint32_t)(got - 2048); +- uint8_t *img = (uint8_t *)std::malloc(img_len); +- if (img) { +- memcpy(img, file + 2048, img_len); ++ if (got > 2048 && got >= 0x20 && ++ std::memcmp(file, "PS-X EXE", 8) == 0) { ++ const auto read_le32 = [](const uint8_t *p) { ++ return (uint32_t)p[0] | ++ ((uint32_t)p[1] << 8) | ++ ((uint32_t)p[2] << 16) | ++ ((uint32_t)p[3] << 24); ++ }; ++ uint32_t disc_load = ++ read_le32(file + 0x18) & 0x1FFFFFFFu; ++ uint32_t declared_len = read_le32(file + 0x1C); ++ uint32_t payload_len = (uint32_t)(got - 2048); ++ if (declared_len < payload_len) payload_len = declared_len; ++ uint32_t payload_off = phys_lo >= disc_load ++ ? phys_lo - disc_load : UINT32_MAX; ++ uint32_t img_len = text_size ? text_size ++ : (payload_off <= payload_len ++ ? payload_len - payload_off : 0u); ++ if (payload_off > payload_len || ++ img_len > payload_len - payload_off) ++ img_len = 0; ++ uint8_t *img = img_len ++ ? (uint8_t *)std::malloc(img_len) : nullptr; ++ if (img && img_len) { ++ memcpy(img, file + 2048 + payload_off, img_len); + std::free(file); + dirty_ram_register_text_image(phys_lo, img, img_len); + std::fprintf(stdout, +- "psxrecomp: text image guard armed (0x%08X..0x%08X, disc %s)\n", ++ "psxrecomp: text image guard armed (0x%08X..0x%08X, " ++ "disc %s payload +0x%X)\n", + load_address, load_address + img_len, +- boot_name.c_str()); ++ boot_name.c_str(), payload_off); + return; + } + } +@@ -317,6 +504,10 @@ + * 640*scale x 512*scale. Allocated once the supersampling scale is known + * (sized for the native 640x512 when supersampling is off). */ + static uint32_t* sdl_pixel_buf = nullptr; ++/* Live OpenGL internal-scale changes may grow the CPU fallback/readback path. ++ * Never shrink this allocation during a session: if a GL target rebuild fails, ++ * the previous (larger) renderer scale must remain safe to present. */ ++static int sdl_pixel_buf_scale_capacity = 0; + + typedef void (*ModFrameHook)(void); + +@@ -337,8 +528,118 @@ + for (size_t i = 0; i < count; i++) hooks[i](); + } + ++/* Optional game-owned in-game UI. Keep the registration state here rather ++ * than in a renderer so input routing and renderer lifetime remain coordinated ++ * and a build with no provider has no extra source or link dependency. */ ++struct HostUiState { ++ PsxHostUiHooks hooks{}; ++ bool registered = false; ++ bool session_live = false; ++ SDL_Window *window = nullptr; ++ int backend = PSX_HOST_UI_BACKEND_NONE; ++}; ++ ++static HostUiState& host_ui_state() { ++ static HostUiState state; ++ return state; ++} ++ ++static std::string g_host_user_settings_path_utf8; ++ ++extern "C" const char *psx_host_user_settings_path(void) { ++ return g_host_user_settings_path_utf8.empty() ++ ? nullptr : g_host_user_settings_path_utf8.c_str(); ++} ++ ++extern "C" int psx_host_ui_register(const PsxHostUiHooks *hooks) { ++ if (!hooks || hooks->abi_version != PSX_HOST_UI_ABI_VERSION || ++ hooks->struct_size < sizeof(PsxHostUiHooks)) ++ return 0; ++ ++ HostUiState& state = host_ui_state(); ++ if (state.registered) return 0; ++ state.hooks = *hooks; ++ state.registered = true; ++ if (state.session_live && state.hooks.on_runtime_ready) ++ state.hooks.on_runtime_ready(state.hooks.userdata, state.window, ++ state.backend); ++ return 1; ++} ++ ++static void host_ui_runtime_ready(SDL_Window *window, int backend) { ++ HostUiState& state = host_ui_state(); ++ /* A lobby soft-return may create another game session in the same process. ++ * Close the prior provider lifetime defensively before opening a new one. */ ++ if (state.session_live && state.registered && ++ state.hooks.on_runtime_shutdown) ++ state.hooks.on_runtime_shutdown(state.hooks.userdata); ++ state.window = window; ++ state.backend = backend; ++ state.session_live = true; ++ if (state.registered && state.hooks.on_runtime_ready) ++ state.hooks.on_runtime_ready(state.hooks.userdata, window, backend); ++} ++ ++static void host_ui_runtime_shutdown() { ++ HostUiState& state = host_ui_state(); ++ if (!state.session_live) return; ++ if (state.registered && state.hooks.on_runtime_shutdown) ++ state.hooks.on_runtime_shutdown(state.hooks.userdata); ++ state.session_live = false; ++ state.window = nullptr; ++ state.backend = PSX_HOST_UI_BACKEND_NONE; ++} ++ ++extern "C" int psx_host_ui_handle_event(const SDL_Event *event) { ++ HostUiState& state = host_ui_state(); ++ if (!event || !state.session_live || !state.registered || ++ !state.hooks.on_sdl_event) ++ return 0; ++ return state.hooks.on_sdl_event(state.hooks.userdata, event) ? 1 : 0; ++} ++ ++extern "C" uint32_t psx_host_ui_capture_flags(void) { ++ HostUiState& state = host_ui_state(); ++ if (!state.session_live || !state.registered || !state.hooks.flags) ++ return 0; ++ return state.hooks.flags(state.hooks.userdata); ++} ++ ++static int (*g_fast_forward_query)(void) = nullptr; ++ ++extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { ++ g_fast_forward_query = query; ++} ++ ++extern "C" int psx_host_ui_game_input_captured(void) { ++ const uint32_t input = PSX_HOST_UI_CAPTURE_KEYBOARD | ++ PSX_HOST_UI_CAPTURE_MOUSE | ++ PSX_HOST_UI_CAPTURE_GAMEPAD; ++ return (psx_host_ui_capture_flags() & input) != 0; ++} ++ ++extern "C" int psx_host_ui_gl_visible(void) { ++ HostUiState& state = host_ui_state(); ++ return state.session_live && state.registered && ++ state.backend == PSX_HOST_UI_BACKEND_OPENGL && ++ state.hooks.render_gl && ++ (psx_host_ui_capture_flags() & PSX_HOST_UI_VISIBLE) != 0; ++} ++ ++extern "C" void psx_host_ui_render_gl(int drawable_width, ++ int drawable_height) { ++ HostUiState& state = host_ui_state(); ++ if (!state.session_live || !state.registered || ++ state.backend != PSX_HOST_UI_BACKEND_OPENGL || ++ !state.hooks.render_gl) ++ return; ++ state.hooks.render_gl(state.hooks.userdata, drawable_width, ++ drawable_height); ++} ++ + /* Presentation-only interpolation for software-rendered content that repeats + * each guest image for two vblanks. This never changes guest or audio timing. */ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static std::atomic g_smooth_60fps{0}; + + struct Smooth60State { +@@ -352,6 +653,7 @@ + }; + + static Smooth60State g_smooth_60_state; ++#endif + + /* Present-path session state — must reset on rematch (function-local statics + * survive soft-return and poison FMV/FPS after session_reboot). */ +@@ -379,12 +681,14 @@ + } + + static void smooth_60_reset(void) { ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + g_smooth_60_state.previous_source.clear(); + g_smooth_60_state.source_hash = 0; + g_smooth_60_state.width = 0; + g_smooth_60_state.height = 0; + g_smooth_60_state.have_source = false; + g_smooth_60_state.previous_was_duplicate = false; ++#endif + } + + static void present_session_reset(void) { +@@ -402,10 +706,21 @@ + smooth_60_reset(); + } + ++extern "C" int dirty_ram_text_image_registered(void); ++extern "C" int psx_game_text_native_ok(uint32_t addr); ++extern "C" void memory_keep_low_boot_scratch(void); ++static void take_loaded_state_for_started(void); ++ + /* Called from savestate_poll after a successful restore (before scheduler + * longjmp). Clears present latches and forces the next vblank to show the + * restored VRAM — including a blank if display was disabled in the snapshot. */ + extern "C" void psx_frontend_on_savestate_loaded(void) { ++#ifdef PSX_HAS_DISRUPTOR_CAPSULE ++ disruptor_capsule_state_loaded(); ++#endif ++#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING ++ disruptor_far_rendering_abandon_metrics(); ++#endif + s_disabled_frame_presented = false; + s_force_present_after_load = true; + smooth_60_reset(); +@@ -419,8 +734,10 @@ + * tiles + force several swaps so the window actually updates. Safe no-op + * when the GL pipeline was never brought up. */ + gl_renderer_invalidate_present(); +-} +- ++ take_loaded_state_for_started(); ++} ++ ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { + uint64_t hash = 1469598103934665603ull; + const size_t step = std::max(1, count / 4096u); +@@ -504,6 +821,13 @@ + extern "C" void psx_smooth_60fps_set(int enabled) { + g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release); + } ++#else ++static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height, ++ bool eligible) { ++ (void)pixels; (void)width; (void)height; (void)eligible; ++} ++extern "C" void psx_smooth_60fps_set(int enabled) { (void)enabled; } ++#endif + #if defined(PSX_WEB) + extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { + psx_smooth_60fps_set(enabled); +@@ -515,6 +839,8 @@ + static bool g_video_aa = true; /* linear present filtering */ + static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ + static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; ++static bool g_geometry_correction = false; /* visual-only subpixel mirror */ ++static bool g_texture_correction = false; /* visual-only perspective UVs */ + static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) + * fullscreen, 2 exclusive fullscreen */ + static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ +@@ -599,10 +925,17 @@ + static int g_video_vsync = 1; + static int g_frame_interpolation = 0; + static int g_frame_interpolation_fps = 0; ++#if defined(DISRUPTOR_DEV_MENU) ++static int g_frame_interpolation_blend = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++static int g_frame_interpolation_blend_default = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++#else + static int g_frame_interpolation_blend = + PSX_MOD_FRAME_INTERPOLATION_LINEAR; + static int g_frame_interpolation_blend_default = + PSX_MOD_FRAME_INTERPOLATION_LINEAR; ++#endif + static int g_mod_controller_mode_override[2] = { -1, -1 }; + static_assert((int)PSX_MOD_CONTROLLER_HYBRID == + (int)PSXRecompV4::PAD_MODE_HYBRID); +@@ -630,6 +963,111 @@ + return 0; /* windowed */ + } + ++/* SDL2 selects borderless vs exclusive through SDL_SetWindowFullscreen's ++ * flag. SDL3 moved that distinction into the window's optional fullscreen ++ * display mode: NULL means borderless desktop and a concrete display mode ++ * means exclusive. Keep that difference behind one helper so startup, the ++ * host UI, and the fullscreen hotkey all agree. */ ++static int psx_window_fullscreen_mode(SDL_Window *window) { ++ if (!window) return g_fullscreen; ++ const Uint32 flags = (Uint32)SDL_GetWindowFlags(window); ++ if ((flags & SDL_WINDOW_FULLSCREEN) == 0) return 0; ++#if defined(PSX_SDL3) ++ return SDL_GetWindowFullscreenMode(window) ? 2 : 1; ++#else ++ return ((flags & SDL_WINDOW_FULLSCREEN_DESKTOP) == ++ SDL_WINDOW_FULLSCREEN_DESKTOP) ? 1 : 2; ++#endif ++} ++ ++#if defined(_WIN32) && defined(PSX_SDL3) ++/* Keeps a borderless OpenGL window from covering its screen exactly (host_borderless.h). SDL fits it to the screen again when it is restored, so this is asked every frame. */ ++static void psx_borderless_keep_windowed(void) { ++ int hidden = 0; ++ if (sdl_window && g_video_renderer == 1 && psx_window_fullscreen_mode(sdl_window) == 1) { ++ const HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(sdl_window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, nullptr); ++ MONITORINFO info = {}; ++ info.cbSize = sizeof(info); ++ RECT now = {}; ++ if (handle && GetMonitorInfoW(MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST), &info) && GetWindowRect(handle, &now)) { ++ const PsxHostRect screen = {info.rcMonitor.left, info.rcMonitor.top, info.rcMonitor.right, info.rcMonitor.bottom}; ++ PsxHostRect window = {now.left, now.top, now.right, now.bottom}, wanted = {}; ++ if (psx_borderless_wanted(window, screen, &wanted) && ++ SetWindowPos(handle, nullptr, wanted.left, wanted.top, wanted.right - wanted.left, wanted.bottom - wanted.top, ++ SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER)) { ++ window = wanted; ++ std::fprintf(stderr, "psxrecomp: borderless window kept one row taller than its screen\n"); ++ } ++ int pixels_wide = 0, pixels_high = 0; ++ SDL_GetWindowSizeInPixels(sdl_window, &pixels_wide, &pixels_high); ++ hidden = psx_borderless_hidden_rows(window, screen, pixels_high); ++ } ++ } ++ gl_renderer_set_hidden_rows(hidden); ++} ++#else ++static void psx_borderless_keep_windowed(void) {} ++#endif ++ ++static int psx_apply_window_fullscreen_mode(SDL_Window *window, int mode) { ++ if (!window || mode < 0 || mode > 2) return 0; ++#if defined(PSX_SDL3) ++ if (mode == 0) { ++ if (!SDL_SetWindowFullscreen(window, false)) return 0; ++ } else if (mode == 1) { ++ if (!SDL_SetWindowFullscreenMode(window, nullptr)) return 0; ++ if (!SDL_SetWindowFullscreen(window, true)) return 0; ++ } else { ++ const SDL_DisplayID display = SDL_GetDisplayForWindow(window); ++ const SDL_DisplayMode *desktop = ++ display ? SDL_GetDesktopDisplayMode(display) : nullptr; ++ if (!display || !desktop) return 0; ++ ++ int width = 0; ++ int height = 0; ++ if (!SDL_GetWindowSizeInPixels(window, &width, &height) || ++ width <= 0 || height <= 0) { ++ width = desktop->w; ++ height = desktop->h; ++ } ++ SDL_DisplayMode closest = {}; ++ if (!SDL_GetClosestFullscreenDisplayMode( ++ display, width, height, desktop->refresh_rate, true, ++ &closest)) { ++ return 0; ++ } ++ if (!SDL_SetWindowFullscreenMode(window, &closest)) return 0; ++ if (!SDL_SetWindowFullscreen(window, true)) return 0; ++ } ++ /* Fullscreen transitions may be asynchronous. Settle the request before ++ * reporting success so the live getter and menu cannot disagree. */ ++ if (!SDL_SyncWindow(window)) return 0; ++#else ++ if (SDL_SetWindowFullscreen( ++ window, psx_fullscreen_flag_for_mode(mode)) != 0) { ++ return 0; ++ } ++#endif ++ psx_borderless_keep_windowed(); /* at once: a present may come before the next vblank */ ++ return psx_window_fullscreen_mode(window) == mode ? 1 : 0; ++} ++ ++extern "C" int psx_host_video_get_fullscreen_mode(void) { ++ return sdl_window ? psx_window_fullscreen_mode(sdl_window) : g_fullscreen; ++} ++ ++extern "C" int psx_host_video_set_fullscreen_mode(int mode) { ++ if (mode < 0 || mode > 2) return 0; ++ if (sdl_window && !psx_apply_window_fullscreen_mode(sdl_window, mode)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to apply fullscreen mode %d: %s\n", ++ mode, SDL_GetError()); ++ return 0; ++ } ++ g_fullscreen = mode; ++ return 1; ++} ++ + /* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ + extern "C" int cdrom_xa_stream_active(void); + extern "C" uint32_t mdec_get_decode_count(void); +@@ -655,12 +1093,105 @@ + * in config_loader.h). */ + static int g_video_aspect_num = 4; + static int g_video_aspect_den = 3; ++/* Host-UI aspect requests may arrive while the current frame is being ++ * composed. Keep the renderer/GTE/GPU transition at the next presentation ++ * boundary instead of changing projection state halfway through a frame. ++ * Fixed requests are packed as numerator:denominator; the two reserved values ++ * select adaptive mode. Zero means no pending request. A single atomic makes ++ * the most recent fixed/adaptive selection win without a mixed transition. */ ++static std::atomic g_pending_video_aspect{0}; ++static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_ON = 0xFFFFFFFFu; ++static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_OFF = 0xFFFFFFFEu; + /* Resize-driven widescreen. The user's fixed aspect is still used to shape the + * initial window; after the game window exists these values follow its live + * aspect, clamped to 4:3..the widest mode offered by the title. */ + static bool g_ws_adaptive_view = false; + static int g_ws_adaptive_max_num = 16; + static int g_ws_adaptive_max_den = 9; ++ ++static uint32_t pack_display_aspect(int numerator, int denominator) { ++ return ((uint32_t)numerator << 16) | (uint32_t)denominator; ++} ++ ++static bool is_host_fixed_display_aspect(int numerator, int denominator) { ++ return (numerator == 4 && denominator == 3) || ++ (numerator == 16 && denominator == 9) || ++ (numerator == 21 && denominator == 9) || ++ (numerator == 32 && denominator == 9); ++} ++ ++extern "C" void psx_host_video_get_display_aspect( ++ int *numerator, int *denominator) { ++ /* Report an accepted pending selection immediately so a host UI does not ++ * visibly snap back during the one-frame handoff to the present thread. */ ++ uint32_t packed = g_pending_video_aspect.load(std::memory_order_acquire); ++ const bool fixed_pending = ++ packed != 0 && ++ packed != PENDING_VIDEO_ASPECT_ADAPTIVE_ON && ++ packed != PENDING_VIDEO_ASPECT_ADAPTIVE_OFF; ++ int num = fixed_pending ? (int)(packed >> 16) : g_video_aspect_num; ++ int den = fixed_pending ? (int)(packed & 0xFFFFu) : g_video_aspect_den; ++ if (numerator) *numerator = num; ++ if (denominator) *denominator = den; ++} ++ ++extern "C" int psx_host_video_set_display_aspect( ++ int numerator, int denominator) { ++ if (!is_host_fixed_display_aspect(numerator, denominator)) return 0; ++ g_pending_video_aspect.store( ++ pack_display_aspect(numerator, denominator), ++ std::memory_order_release); ++ return 1; ++} ++ ++extern "C" int psx_host_video_get_adaptive_view(void) { ++ uint32_t pending = ++ g_pending_video_aspect.load(std::memory_order_acquire); ++ if (pending == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) return 1; ++ if (pending != 0) return 0; /* Explicit off or a pending fixed aspect. */ ++ return g_ws_adaptive_view ? 1 : 0; ++} ++ ++extern "C" int psx_host_video_set_adaptive_view(int enabled) { ++ if (enabled != 0 && enabled != 1) return 0; ++ g_pending_video_aspect.store( ++ enabled ? PENDING_VIDEO_ASPECT_ADAPTIVE_ON ++ : PENDING_VIDEO_ASPECT_ADAPTIVE_OFF, ++ std::memory_order_release); ++ return 1; ++} ++ ++extern "C" int psx_host_video_get_internal_scale(void) { ++ return g_video_scale; ++} ++ ++extern "C" int psx_host_video_set_internal_scale(int scale) { ++ if (scale < 1 || scale > SW_MAX_INTERNAL_SCALE) return 0; ++ /* The developer UI itself is OpenGL-backed. Software and Vulkan keep ++ * startup-only allocation rules until their presentation resources gain ++ * an equivalent transactional rebuild. */ ++ if (!host_ui_state().session_live || ++ host_ui_state().backend != PSX_HOST_UI_BACKEND_OPENGL) { ++ return 0; ++ } ++ if (scale == g_video_scale) return 1; ++ ++ if (scale > sdl_pixel_buf_scale_capacity) { ++ uint32_t *grown = static_cast(std::realloc( ++ sdl_pixel_buf, ++ (size_t)640 * scale * 512 * scale * sizeof(uint32_t))); ++ if (!grown) return 0; ++ sdl_pixel_buf = grown; ++ sdl_pixel_buf_scale_capacity = scale; ++ } ++ ++ gr_set_scale(scale); ++ const int applied = gr_scale(); ++ if (applied != scale) return 0; ++ g_video_scale = applied; ++ gl_renderer_invalidate_present(); ++ return 1; ++} + + extern "C" int psx_mod_set_fixed_display_aspect( + uint32_t numerator, uint32_t denominator) { +@@ -738,6 +1269,10 @@ + + extern "C" int psx_mod_set_frame_interpolation( + uint32_t frames_per_second) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ (void)frames_per_second; ++ return 0; ++#else + if (frames_per_second != 0 && + (frames_per_second < 60 || frames_per_second > 1000)) { + std::fprintf(stderr, +@@ -768,10 +1303,15 @@ + "interpolation; guest timing remains stock\n"); + } + return 1; ++#endif + } + + extern "C" int psx_mod_set_frame_interpolation_blend( + uint32_t blend_mode) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ (void)blend_mode; ++ return 0; ++#else + if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && + blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) { + std::fprintf(stderr, +@@ -784,6 +1324,7 @@ + blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE + ? "motion-adaptive clarity" : "linear crossfade"); + return 1; ++#endif + } + + extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { +@@ -867,6 +1408,8 @@ + * 4:3, wider for wide aspects. Height is always 480*scale. Set at window + * creation alongside SDL_RenderSetLogicalSize. */ + static int g_logical_w = 640; ++static bool g_gl_active = false; /* GL context live -> GL present path */ ++static bool g_vk_active = false; /* Vulkan context live -> VK present path */ + + /* Clamp a requested window width to the primary display's usable area so an + * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps +@@ -886,6 +1429,103 @@ + static int aspect_gcd(int a, int b) { + while (b) { int t = a % b; a = b; b = t; } + return a > 0 ? a : 1; ++} ++ ++/* Resize only an ordinary window. Fullscreen owns the display mode/desktop ++ * extent; its compositor instead follows the logical/present aspect below. */ ++static void reshape_window_for_fixed_aspect(int num, int den) { ++ if (!sdl_window || ++ (SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN) != 0) ++ return; ++ ++ int width = 0, height = 0; ++ SDL_GetWindowSize(sdl_window, &width, &height); ++ if (width <= 0) return; ++ height = width * den / num; ++ clamp_window_aspect(&width, &height, num, den); ++ SDL_SetWindowSize(sdl_window, width, height); ++} ++ ++/* One transition funnel for adaptive resizing and explicit host-UI choices. ++ * It runs only from sdl_vblank_present, after game hooks have completed, so a ++ * frame cannot mix old projection state with a new presentation shape. */ ++static void apply_live_display_aspect(int num, int den, ++ bool reshape_fixed_window) { ++ const bool changed = ++ num != g_video_aspect_num || den != g_video_aspect_den; ++ g_video_aspect_num = num; ++ g_video_aspect_den = den; ++ ++ gl_renderer_set_display_aspect(num, den); ++ if (sdl_renderer) { ++ g_logical_w = 480 * num * g_video_scale / den; ++ SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, ++ 480 * g_video_scale); ++ } ++ if (reshape_fixed_window) reshape_window_for_fixed_aspect(num, den); ++ ++ const bool wide = num * 3 != den * 4; ++ const bool game_started = fntrace_is_game_started() != 0; ++ if (game_started) { ++ g_ws_engaged = true; ++ const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; ++ gte_set_display_aspect(mode == 1 ? num : 4, ++ mode == 1 ? den : 3); ++ gpu_ws_configure(num, den, g_ws_anchor_addr, ++ g_ws_hud_sprt ? 1 : 0, mode); ++ } else { ++ /* Preserve authentic 4:3 boot rendering even when a host UI selects a ++ * wide output before game entry. The normal engagement path will pick ++ * up the already-updated num:den once gameplay begins. */ ++ g_ws_engaged = !wide; ++ gte_set_display_aspect(4, 3); ++ gpu_ws_configure(4, 3, g_ws_anchor_addr, ++ g_ws_hud_sprt ? 1 : 0, 0); ++ } ++ ++ if (changed && g_gl_active) { ++ /* Do not crossfade or reuse a stale destination rectangle across an ++ * aspect transition. This also forces an otherwise-identical next ++ * frame through the GL present path. */ ++ gl_renderer_invalidate_present(); ++ } ++} ++ ++static void update_adaptive_widescreen(); ++ ++static void apply_pending_display_aspect() { ++ uint32_t packed = ++ g_pending_video_aspect.exchange(0, std::memory_order_acq_rel); ++ if (!packed) return; ++ ++ if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) { ++ g_ws_adaptive_view = true; ++ /* The developer-facing match-window mode supports every intermediate ++ * window ratio, but never exposes more than the audited 32:9 view. */ ++ g_ws_adaptive_max_num = 32; ++ g_ws_adaptive_max_den = 9; ++ update_adaptive_widescreen(); ++ std::fprintf(stdout, ++ "psxrecomp: live adaptive display aspect enabled " ++ "(match window, maximum 32:9)\n"); ++ return; ++ } ++ if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_OFF) { ++ /* Leave the current effective aspect in place. A subsequent fixed ++ * selection can choose and reshape a canonical fixed output. */ ++ g_ws_adaptive_view = false; ++ std::fprintf(stdout, ++ "psxrecomp: live adaptive display aspect disabled\n"); ++ return; ++ } ++ ++ int num = (int)(packed >> 16); ++ int den = (int)(packed & 0xFFFFu); ++ g_ws_adaptive_view = false; ++ apply_live_display_aspect(num, den, true); ++ std::fprintf(stdout, ++ "psxrecomp: live fixed display aspect = %d:%d (adaptive view off)\n", ++ num, den); + } + + /* Follow the host window without feeding its absolute pixel size into guest +@@ -911,23 +1551,7 @@ + den /= divisor; + } + if (num == g_video_aspect_num && den == g_video_aspect_den) return; +- +- g_video_aspect_num = num; +- g_video_aspect_den = den; +- gl_renderer_set_display_aspect(num, den); +- if (sdl_renderer) { +- g_logical_w = 480 * num * g_video_scale / den; +- SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); +- } +- +- if (g_ws_engaged) { +- const bool wide = num * 3 != den * 4; +- const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; +- gte_set_display_aspect(mode == 1 ? num : 4, +- mode == 1 ? den : 3); +- gpu_ws_configure(num, den, g_ws_anchor_addr, +- g_ws_hud_sprt ? 1 : 0, mode); +- } ++ apply_live_display_aspect(num, den, false); + } + + /* SDL GL attributes are global inputs to the next context creation. Set the +@@ -955,8 +1579,107 @@ + } + extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } + +-static bool g_gl_active = false; /* GL context live -> GL present path */ +-static bool g_vk_active = false; /* Vulkan context live -> VK present path */ ++extern "C" int psx_host_video_get_vsync(void) { ++ return g_video_vsync; ++} ++ ++extern "C" int psx_host_video_set_vsync(int mode) { ++ if (mode < -1 || mode > 1) return 0; ++ g_video_vsync = mode; ++#ifndef PSX_SDL_NO_RENDER ++ /* gl_renderer_set_swap_interval remembers this request but keeps the ++ * physical interval at zero while interpolation owns presentation. */ ++ if (g_gl_active) gl_renderer_set_swap_interval(mode); ++ if (g_vk_active) vk_renderer_set_present_mode(mode); ++#endif ++ latency_ring_set_present_mode(mode); ++ return 1; ++} ++ ++extern "C" void psx_host_video_get_interpolation(int *enabled, ++ int *target_fps, ++ int *blend_mode) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ if (enabled) *enabled = 0; ++ if (target_fps) *target_fps = 0; ++ if (blend_mode) *blend_mode = PSX_HOST_FRAME_INTERPOLATION_LINEAR; ++#else ++ int actual_enabled = g_frame_interpolation ? 1 : 0; ++ if (g_gl_active) ++ gl_renderer_interpolation_diag(&actual_enabled, nullptr, nullptr, ++ nullptr, nullptr, nullptr); ++ if (enabled) *enabled = actual_enabled; ++ if (target_fps) *target_fps = g_frame_interpolation_fps; ++ if (blend_mode) *blend_mode = g_frame_interpolation_blend; ++#endif ++} ++ ++extern "C" void psx_host_video_get_geometry_interpolation_stats( ++ uint64_t out[6]) { ++ if (!out) return; ++ for (int i = 0; i < 6; ++i) out[i] = 0; ++ if (g_gl_active) gl_renderer_geometry_interpolation_diag(out); ++} ++ ++extern "C" int psx_host_video_set_interpolation(int enabled, ++ int target_fps, ++ int blend_mode) { ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ (void)target_fps; (void)blend_mode; ++ /* A disable request is harmless; activation is unavailable in this build. */ ++ return enabled ? 0 : 1; ++#else ++ enabled = enabled ? 1 : 0; ++ if (target_fps != -1 && target_fps != 0 && ++ (target_fps < 30 || target_fps > 1000)) ++ return 0; ++ if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && ++ blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE && ++ blend_mode != PSX_HOST_FRAME_INTERPOLATION_GEOMETRY) ++ return 0; ++ /* Interpolation is implemented only by the GL backend and renderer changes ++ * are restart-owned. Do not let an in-game control advertise success on a ++ * software/Vulkan session. */ ++ if (enabled && !g_gl_active) return 0; ++ if (enabled && blend_mode == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && ++ !gpu_geometry_correction_enabled()) ++ return 0; ++ ++ if (g_gl_active) { ++ int previous_actual = 0; ++ gl_renderer_interpolation_diag(&previous_actual, nullptr, nullptr, ++ nullptr, nullptr, nullptr); ++ gl_renderer_set_interpolation(enabled, g_host_refresh_hz, ++ (double)target_fps, blend_mode); ++ int actual = 0; ++ gl_renderer_interpolation_diag(&actual, nullptr, nullptr, ++ nullptr, nullptr, nullptr); ++ if (actual != enabled) { ++ /* Context/thread creation and display-rate validation live in the ++ * GL backend. Do not advertise a requested state which the ++ * renderer could not actually enter. Restore a previously-live ++ * interpolator when a target change was rejected. */ ++ if (actual != previous_actual) { ++ gl_renderer_set_interpolation( ++ previous_actual, g_host_refresh_hz, ++ (double)g_frame_interpolation_fps, ++ g_frame_interpolation_blend); ++ } ++ g_frame_interpolation = previous_actual; ++ gl_renderer_set_swap_interval(g_video_vsync); ++ return 0; ++ } ++ /* Re-assert the requested user interval. The GL backend defers it ++ * while interpolation is enabled and restores it on disable. */ ++ gl_renderer_set_swap_interval(g_video_vsync); ++ } ++ g_frame_interpolation = enabled; ++ g_frame_interpolation_fps = target_fps; ++ g_frame_interpolation_blend = blend_mode; ++ return 1; ++#endif ++} ++ + /* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 + * to force the software readout path instead — a diagnostic/fallback that also + * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ +@@ -994,6 +1717,16 @@ + static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; + static SDL_AudioDeviceID sdl_audio_device; + static int16_t sdl_audio_buf[2048 * 2]; ++static std::atomic g_audio_master_volume_percent{100}; ++static std::atomic g_audio_master_muted{0}; ++/* Legacy queued audio has two producer sites (ordinary pump and turbo-mute ++ * tail); both share the same applied gain so either path transitions smoothly. */ ++static float g_audio_legacy_applied_master_gain = -1.0f; ++/* Callback-owned; reset only after the audio device has stopped. */ ++static float g_audio_drc_applied_master_gain = -1.0f; ++ ++static void sdl_audio_apply_master_gain(int16_t* buf, int frames, ++ float* applied_gain); + + /* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under + * SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio +@@ -1026,10 +1759,36 @@ + int g_audio_host_rate = 44100; + } + ++extern "C" int psx_host_audio_get_master_volume(void) { ++ return g_audio_master_volume_percent.load(std::memory_order_relaxed); ++} ++ ++extern "C" int psx_host_audio_set_master_volume(int percent) { ++ if (percent < 0 || percent > 100) return 0; ++ g_audio_master_volume_percent.store(percent, std::memory_order_relaxed); ++ return 1; ++} ++ ++extern "C" int psx_host_audio_get_muted(void) { ++ return g_audio_master_muted.load(std::memory_order_relaxed); ++} ++ ++extern "C" int psx_host_audio_set_muted(int muted) { ++ if (muted != 0 && muted != 1) return 0; ++ g_audio_master_muted.store(muted, std::memory_order_relaxed); ++ return 1; ++} ++ + static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) { + if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; } + int frames = len / (int)(2 * sizeof(int16_t)); /* stereo S16 */ + rab_pull(&s_drc, reinterpret_cast(stream), frames); ++ /* Apply user gain at the final pull boundary so the default bridge path ++ * responds on the next device callback instead of waiting for the DRC ++ * ring's buffered audio to drain. */ ++ sdl_audio_apply_master_gain( ++ reinterpret_cast(stream), frames, ++ &g_audio_drc_applied_master_gain); + /* T3 tap in bridge mode: the exact device-rate bytes the host consumes. + * This callback is the tap's single writer while the bridge is active. */ + audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast(stream), +@@ -1174,22 +1933,40 @@ + return false; + } + #ifdef _WIN32 +- char path_buf[4096]; +- std::memset(path_buf, 0, sizeof(path_buf)); +- +- OPENFILENAMEA ofn; +- std::memset(&ofn, 0, sizeof(ofn)); +- ofn.lStructSize = sizeof(ofn); +- ofn.hwndOwner = NULL; +- ofn.lpstrFilter = filter; +- ofn.lpstrFile = path_buf; +- ofn.nMaxFile = (DWORD)sizeof(path_buf); +- ofn.lpstrTitle = title; +- ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY; +- +- if (!GetOpenFileNameA(&ofn)) return false; +- out = path_buf; +- return true; ++ /* IFileOpenDialog, not GetOpenFileName: that one moves the process's working directory into every folder the ++ * player opens (measured, OFN_NOCHANGEDIR or not), and the freeze heartbeat writes its files there. */ ++ const auto wide = [](const char* text) { ++ std::wstring out(static_cast(MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0)), L'\0'); ++ MultiByteToWideChar(CP_UTF8, 0, text, -1, out.data(), static_cast(out.size())); ++ return out; ++ }; ++ std::vector texts; ++ for (const char* at = filter; at && *at; at += std::strlen(at) + 1) texts.push_back(wide(at)); ++ std::vector kinds; ++ for (size_t pair = 0; pair + 1 < texts.size(); pair += 2) kinds.push_back({texts[pair].c_str(), texts[pair + 1].c_str()}); ++ ++ const HRESULT initialised = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); ++ IFileOpenDialog* dialog = nullptr; ++ bool chosen = false; ++ if (SUCCEEDED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) { ++ DWORD options = 0; ++ dialog->GetOptions(&options); ++ dialog->SetOptions(options | FOS_FILEMUSTEXIST | FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR); ++ if (!kinds.empty()) dialog->SetFileTypes(static_cast(kinds.size()), kinds.data()); ++ dialog->SetTitle(wide(title).c_str()); ++ IShellItem* item = nullptr; ++ wchar_t* path = nullptr; ++ if (SUCCEEDED(dialog->Show(nullptr)) && SUCCEEDED(dialog->GetResult(&item)) && ++ SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) { ++ out = path; ++ chosen = true; ++ } ++ CoTaskMemFree(path); ++ if (item) item->Release(); ++ dialog->Release(); ++ } ++ if (SUCCEEDED(initialised)) CoUninitialize(); ++ return chosen; + #else + (void)filter; + (void)out; +@@ -1205,6 +1982,46 @@ + title, cli_flag); + return false; + #endif ++} ++ ++/* The dialog runs on a thread of its own: on the game's thread it would stop the frames, and the freeze watchdog ++ * takes two seconds without a frame for a hang and writes a dump. */ ++/* Never destroyed: the dialog's thread may still be in the dialog when the program ends. */ ++static PsxHostPickSlot& s_disc_pick = *new PsxHostPickSlot; ++static std::thread& s_disc_worker = *new std::thread; ++ ++extern "C" int psx_host_pick_disc_image_begin(void) { ++#ifdef _WIN32 ++ if (g_headless || !s_disc_pick.open()) return 0; ++ try { ++ /* The worker before this one closed its pick as its last act, and no pick opens before that one is taken. */ ++ if (s_disc_worker.joinable()) s_disc_worker.join(); ++ s_disc_worker = std::thread([] { ++ std::string path; ++ try { ++ std::filesystem::path picked; ++ if (pick_runtime_file("Select a disc image", ++ "Disc image (*.cue;*.bin;*.chd;*.iso)\0*.cue;*.bin;*.chd;*.iso\0All files\0*.*\0", ++ picked, "--disc")) ++ path = picked.string(); ++ } catch (...) { ++ std::fprintf(stderr, "psxrecomp: the chosen disc image could not be taken, its path may have letters this system's code page lacks\n"); ++ path.clear(); ++ } ++ s_disc_pick.close(std::move(path)); ++ }); ++ } catch (const std::system_error&) { ++ s_disc_pick.abandon(); ++ return 0; ++ } ++ return 1; ++#else ++ return 0; ++#endif ++} ++ ++extern "C" int psx_host_pick_disc_image_poll(char* out, int size) { ++ return s_disc_pick.take(out, size); + } + + static std::string uppercase_ascii(std::string s) { +@@ -1590,6 +2407,9 @@ + } + + static void shutdown_runtime(void) { ++ /* UI teardown may destroy GL resources; it must run while the game window ++ * and main renderer context are still alive. The helper is idempotent. */ ++ host_ui_runtime_shutdown(); + /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the + * off-thread JIT worker here; the worker no longer exists.) */ + psx_netplay_shutdown(); +@@ -1607,6 +2427,8 @@ + psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ + sdl_audio_device = 0; + } ++ g_audio_drc_applied_master_gain = -1.0f; ++ g_audio_legacy_applied_master_gain = -1.0f; + if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } + close_controller(); + debug_server_shutdown(); +@@ -1615,6 +2437,7 @@ + /* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL + * subsystems and the lobby WebSocket intact for the next launcher session. */ + static void teardown_game_session_keep_lobby(void) { ++ host_ui_runtime_shutdown(); + netplay_host_present_restore(); + psx_netplay_shutdown(); + memcard_flush_all(); +@@ -1623,6 +2446,8 @@ + psx_sdl_audio_close(sdl_audio_device); + sdl_audio_device = 0; + } ++ g_audio_drc_applied_master_gain = -1.0f; ++ g_audio_legacy_applied_master_gain = -1.0f; + if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } + close_controller(); + if (g_vk_active) { +@@ -1637,6 +2462,7 @@ + if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } + if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } + if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } ++ sdl_pixel_buf_scale_capacity = 0; + psx_lobby_set_ready(0); + psx_lobby_clear_launch_pending(); + psx_clear_return_to_lobby(); +@@ -1653,6 +2479,24 @@ + } + } + ++/* Smooth live master-volume changes over one produced/device block. The DRC ++ * path invokes this on its final audio callback; legacy queued audio invokes ++ * it immediately before queueing. Separate per-path applied gains are owned by ++ * their respective threads, while the requested controls are atomic. */ ++static void sdl_audio_apply_master_gain(int16_t* buf, int frames, ++ float* applied_gain) { ++ if (!buf || frames <= 0 || !applied_gain) return; ++ const int percent = ++ g_audio_master_volume_percent.load(std::memory_order_relaxed); ++ const bool muted = ++ g_audio_master_muted.load(std::memory_order_relaxed) != 0; ++ const float target_gain = muted ? 0.0f : (float)percent / 100.0f; ++ if (*applied_gain < 0.0f) *applied_gain = target_gain; ++ if (*applied_gain == 1.0f && target_gain == 1.0f) return; ++ sdl_audio_gain_ramp(buf, frames, *applied_gain, target_gain); ++ *applied_gain = target_gain; ++} ++ + /* Fade-in state: samples of rising ramp still to apply after an unmute. + * Consumed by sdl_audio_pump across however many pump calls it spans. + * MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many +@@ -1661,7 +2505,7 @@ + static int sdl_audio_fadein_left = 0; + + static void sdl_audio_pump(bool discard_output = false) { +- if (!sdl_audio_device) return; ++ if (!sdl_audio_device) discard_output = true; /* a game reads the SPU back: it runs on with nothing to play it */ + + const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; + const bool legacy = audio_legacy_mode(); +@@ -1751,6 +2595,8 @@ + sdl_audio_fadein_left -= ramp; + } + if (legacy) { ++ sdl_audio_apply_master_gain( ++ sdl_audio_buf, frames, &g_audio_legacy_applied_master_gain); + /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ + audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); + psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, +@@ -1883,6 +2729,7 @@ + + g_runtime_perf.frequency = SDL_GetPerformanceFrequency(); + if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1; ++ dirty_ram_perf_hot_enable(1); + if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) { + char *tail = nullptr; + long requested = std::strtol(interval, &tail, 10); +@@ -2026,12 +2873,32 @@ + if (end >= start) *total += end - start; + } + ++extern "C" uint8_t *memory_get_ram_ptr(void); ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, ++ uint64_t *, uint64_t *); ++extern "C" void overlay_loader_static_match_stats(uint64_t *, uint64_t *, ++ uint64_t *); ++#endif ++static uint32_t runtime_perf_live_word(uint32_t phys, uint32_t samples) { ++ if (samples == 0u || phys > 2u * 1024u * 1024u - 4u) return 0u; ++ const uint8_t *ram = memory_get_ram_ptr(); ++ if (!ram) return 0u; ++ return (uint32_t)ram[phys] | ++ ((uint32_t)ram[phys + 1u] << 8) | ++ ((uint32_t)ram[phys + 2u] << 16) | ++ ((uint32_t)ram[phys + 3u] << 24); ++} ++ + static void runtime_perf_diag_tick() { + static bool have_last = false; + static RuntimePerfSnapshot last; + static uint64_t last_spu = 0, last_underruns = 0, last_overflows = 0; + static uint64_t last_up[6] = {0}; + static uint64_t last_overlay_load_us = 0; ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ static uint64_t last_static[7] = {0}; ++#endif + if (!g_runtime_perf.enabled) return; + + uint64_t now = SDL_GetPerformanceCounter(); +@@ -2052,12 +2919,31 @@ + uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; + overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us, + &overlay_load_last_us); ++ uint32_t dirty_hot_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ uint32_t dirty_hot_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; ++ uint32_t dirty_hot_stride = 0; ++ dirty_ram_perf_take_hot(dirty_hot_pc, dirty_hot_n, &dirty_hot_stride); ++ uint64_t kbless[6] = {0}; ++ uint64_t kbless_mismatch_count = 0; ++ uint32_t kbless_last[6] = {0}; ++ psx_kernel_bless_stats(kbless); ++ psx_kernel_bless_mismatch_detail(&kbless_mismatch_count, kbless_last); ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ uint64_t static_now[7] = {0}; ++ psx_overlay_static_get_stats(&static_now[0], &static_now[1], ++ &static_now[2], &static_now[3]); ++ overlay_loader_static_match_stats(&static_now[4], &static_now[5], ++ &static_now[6]); ++#endif + if (!have_last) { + last = current; + last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; + last_underruns = underruns; + last_overflows = overflows; + last_overlay_load_us = overlay_load_us; ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; ++#endif + for (int i = 0; i < 6; i++) last_up[i] = up[i]; + have_last = true; + return; +@@ -2072,6 +2958,14 @@ + "cpu=%.1f tex=%.1f draw=%.1f ms/s; " + "work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, " + "dirty=%.0f insn/s %.0f dispatch/s; " ++ "dirty_hot=0x%08X:%08X/%u,0x%08X:%08X/%u," ++ "0x%08X:%08X/%u sample=1/%u; " ++ "kbless clean=%llu mismatch=%llu hits=%llu last=0x%08X " ++ "body=0x%08X-0x%08X diff=0x%08X:%02X/%02X; " ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ "static checks=+%llu hits=+%llu variant_miss=+%llu " ++ "address_miss=+%llu rehash=+%llu crc_miss=+%llu fast=+%llu; " ++#endif + "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " + "shadow=+%llu div=+%llu first_div=0x%08X " + "loads=+%u revalidations=+%u " +@@ -2096,6 +2990,26 @@ + runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt, + (double)(current.dirty_insns - last.dirty_insns) / dt, + (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, ++ dirty_hot_pc[0], runtime_perf_live_word(dirty_hot_pc[0], dirty_hot_n[0]), ++ dirty_hot_n[0], ++ dirty_hot_pc[1], runtime_perf_live_word(dirty_hot_pc[1], dirty_hot_n[1]), ++ dirty_hot_n[1], ++ dirty_hot_pc[2], runtime_perf_live_word(dirty_hot_pc[2], dirty_hot_n[2]), ++ dirty_hot_n[2], dirty_hot_stride, ++ (unsigned long long)kbless[1], ++ (unsigned long long)kbless_mismatch_count, ++ (unsigned long long)kbless[3], ++ kbless_last[0], kbless_last[1], kbless_last[2], kbless_last[3], ++ kbless_last[4] & 0xFFu, kbless_last[5] & 0xFFu, ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ (unsigned long long)(static_now[0] - last_static[0]), ++ (unsigned long long)(static_now[1] - last_static[1]), ++ (unsigned long long)(static_now[2] - last_static[2]), ++ (unsigned long long)(static_now[3] - last_static[3]), ++ (unsigned long long)(static_now[4] - last_static[4]), ++ (unsigned long long)(static_now[5] - last_static[5]), ++ (unsigned long long)(static_now[6] - last_static[6]), ++#endif + (unsigned long long)(current.overlay_native - last.overlay_native), + (unsigned long long)(current.overlay_interp - last.overlay_interp), + current.overlay_hot_native_pc, +@@ -2118,11 +3032,13 @@ + last_underruns = underruns; + last_overflows = overflows; + last_overlay_load_us = overlay_load_us; ++#ifdef PSX_HAS_OVERLAY_DISPATCH ++ for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; ++#endif + for (int i = 0; i < 6; i++) last_up[i] = up[i]; + } + + static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { +- if (!sdl_audio_device) return; + { /* Tag audio events with the vblank frame counter. */ + extern uint64_t s_frame_count; + audio_trace_note_frame((uint32_t)s_frame_count); +@@ -2145,11 +3061,14 @@ + spu_render(sdl_audio_buf, tail); + sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); + audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); +- if (audio_legacy_mode()) { ++ if (sdl_audio_device && audio_legacy_mode()) { ++ sdl_audio_apply_master_gain( ++ sdl_audio_buf, tail, ++ &g_audio_legacy_applied_master_gain); + audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail); + psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, + (uint32_t)tail * sizeof(int16_t) * 2u); +- } else if (s_drc_ready) { ++ } else if (sdl_audio_device && s_drc_ready) { + psx_sdl_audio_lock(sdl_audio_device); + rab_push(&s_drc, sdl_audio_buf, tail); + psx_sdl_audio_unlock(sdl_audio_device); +@@ -2506,6 +3425,13 @@ + static void load_input_config(const char* argv0) { + set_default_controller_mapping(); + ++ /* Keyboard bindings are independent of the gamepad input.ini. Load them ++ * before the first-run input.ini creation branch: that branch returns ++ * after writing the default controller file, and previously left the ++ * compiled legacy keyboard defaults active for the entire first session ++ * even when a packaged keybinds.ini was already present. */ ++ psx_keybinds_init(argv0); ++ + namespace fs = std::filesystem; + fs::path config_path = exe_dir_from_argv(argv0) / "input.ini"; + std::error_code ec; +@@ -2557,11 +3483,6 @@ + for (auto& entry : controller_map) entry.sources.clear(); + } + +- /* Configurable KEYBOARD keybinds (keybinds.ini, next to the exe) — separate +- * from input.ini's gamepad map. Loads the user's map (or writes defaults on +- * first run). The launcher may have just edited+saved this file; we re-read +- * it here so the runtime always reflects the current bindings. */ +- psx_keybinds_init(argv0); + } + + static void close_player(PlayerInput& p) { +@@ -3095,6 +4016,16 @@ + * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive + * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ + static void capture_local_human_pad(PsxNetPad* out) { ++ const uint32_t ui_flags = psx_host_ui_capture_flags(); ++ if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | ++ PSX_HOST_UI_CAPTURE_GAMEPAD)) { ++ out->buttons = 0xFFFFu; ++ out->lx = out->ly = out->rx = out->ry = 0x80u; ++ out->analog = 1; ++ out->connected = 1; ++ psx_netplay_normalize_pad(out); ++ return; ++ } + int idx = psx_netplay_input_player(); + if (idx < 0 || idx > 1) idx = 0; + if (!capture_pad_slot_exclusive(idx, out)) { +@@ -3228,11 +4159,12 @@ + if (!g_headless) { + SDL_Event ev; + while (SDL_PollEvent(&ev)) { ++ const int ui_consumed = psx_host_ui_handle_event(&ev); + if (ev.type == SDL_QUIT) { + netplay_soft_exit("sdl_window_close"); + if (psx_return_to_lobby_requested()) return; + } +- if (ev.type == SDL_KEYDOWN && ++ if (!ui_consumed && ev.type == SDL_KEYDOWN && + #if defined(PSX_SDL3) + ev.key.key == SDLK_ESCAPE) { + #else +@@ -3261,6 +4193,24 @@ + static void sample_pad_into_sio(int override) { + if (override >= 0) { + apply_input_override_to_sio(override); ++ return; ++ } ++ const uint32_t ui_flags = psx_host_ui_capture_flags(); ++ if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | ++ PSX_HOST_UI_CAPTURE_GAMEPAD)) { ++ /* Poll-based input bypasses SDL event consumption. Publish a fully ++ * neutral sample (buttons and sticks) while preserving each port's ++ * current digital/analog presentation mode. */ ++ for (int s = 0; s < 2; s++) { ++ PsxNetPad pad{}; ++ pad.buttons = 0xFFFFu; ++ pad.lx = pad.ly = pad.rx = pad.ry = 0x80u; ++ pad.analog = (g_players[s].mode == PSXRecompV4::PAD_MODE_ANALOG || ++ (g_players[s].mode == PSXRecompV4::PAD_MODE_HYBRID && ++ g_players[s].hybrid_analog)) ? 1u : 0u; ++ pad.connected = g_players[s].kind != 0 ? 1u : 0u; ++ apply_pad_slot_to_sio(s, pad); ++ } + return; + } + for (int s = 0; s < 2; s++) { +@@ -3482,8 +4432,51 @@ + } + } + ++static void engage_widescreen(void) { ++ g_ws_engaged = true; ++ const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; ++ int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; ++ /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio ++ * (identity squash). Squash mode: feed the real wide aspect. */ ++ gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, ++ mode == 1 ? g_video_aspect_den : 3); ++ gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, ++ g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); ++} ++ ++/* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ ++static void engage_widescreen_at_game_entry(void) { ++ extern int fntrace_is_game_started(void); ++ if (!g_ws_engaged && fntrace_is_game_started()) engage_widescreen(); ++} ++ ++/* A state of a started game is no game entry. Told by the game's code in memory, as the entry latch tells it. */ ++static void take_loaded_state_for_started(void) { ++ uint32_t bios = 0, entry = 0; ++ savestate_get_integrity(&bios, &entry); ++ if (!dirty_ram_text_image_registered() || !psx_game_text_native_ok(entry)) return; ++ memory_keep_low_boot_scratch(); /* the latch of a fresh process would clear what the game has written there since */ ++ if (!g_ws_engaged) engage_widescreen(); /* at the next VBlank the frame the state draws first would be a 4:3 one */ ++} ++ + /* Called from gpu_vblank_tick() at each simulated vblank. */ + static void sdl_vblank_present(void) { ++ psx_borderless_keep_windowed(); ++ /* A host UI normally submits its request from the GL render callback near ++ * the end of this function. Apply on scope exit, after the old-aspect frame ++ * has been presented, so the next guest frame and its eventual present use ++ * one coherent aspect. The scope also covers every skipped/early-return ++ * vblank path. */ ++ struct PendingAspectBoundary { ++ ~PendingAspectBoundary() { ++ apply_pending_display_aspect(); ++ /* SDL resize events were consumed during this vblank. Commit ++ * their ratio only after the old-aspect frame has presented so ++ * projection, packet correction and destination sizing advance ++ * together for the next guest frame. */ ++ update_adaptive_widescreen(); ++ } ++ } pending_aspect_boundary; + #ifndef PSX_NO_DEBUG_TOOLS + debug_server_set_fmv_quiet(mdec_recently_active(2)); + /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ +@@ -3582,6 +4575,7 @@ + /* Pump SDL events to prevent window freeze. */ + SDL_Event ev; + while (SDL_PollEvent(&ev)) { ++ const int ui_consumed = psx_host_ui_handle_event(&ev); + if (ev.type == SDL_QUIT) { + if (psx_netplay_active()) { + netplay_soft_exit("sdl_window_close"); +@@ -3603,18 +4597,34 @@ + close_controller(); + refresh_player_devices(); + } +- } else if (ev.type == SDL_KEYDOWN) { ++#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY ++ } else if (ev.type == SDL_KEYUP) { ++#if defined(PSX_SDL3) ++ (void)disruptor_name_entry_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1); ++#else ++ (void)disruptor_name_entry_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1); ++#endif ++#endif ++ } else if (!ui_consumed && ev.type == SDL_KEYDOWN) { + #if defined(PSX_SDL3) + const SDL_Keymod mod = ev.key.mod; + const SDL_Keycode key = ev.key.key; ++ const SDL_Scancode scancode = ev.key.scancode; + #else + const Uint16 mod = ev.key.keysym.mod; + const SDL_Keycode key = ev.key.keysym.sym; ++ const SDL_Scancode scancode = ev.key.keysym.scancode; + #endif + if (key == SDLK_ESCAPE && psx_netplay_active()) { + netplay_soft_exit("netplay_escape"); + return; + } ++#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY ++ if (disruptor_name_entry_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI)))) ++ continue; /* the key is a letter of a save's name */ ++#else ++ (void)scancode; ++#endif + /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. + * (F11 is a save slot per the user's spec, so fullscreen is + * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ +@@ -3649,21 +4659,34 @@ + * detects "currently fullscreen, either mode". */ + else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || + (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { +- Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & +- SDL_WINDOW_FULLSCREEN; +- if (is_fs) { +- SDL_SetWindowFullscreen(sdl_window, 0); +- } else { +- /* If the configured mode is "off", the hotkey still +- * needs a mode to switch INTO — default to borderless, +- * matching the historical (pre-tri-state) behaviour. */ +- Uint32 target = psx_fullscreen_flag_for_mode(g_fullscreen); +- if (target == 0) target = SDL_WINDOW_FULLSCREEN_DESKTOP; +- SDL_SetWindowFullscreen(sdl_window, target); ++ const int current = ++ psx_window_fullscreen_mode(sdl_window); ++ /* If the configured mode is "off", the hotkey still ++ * needs a mode to switch INTO — default to borderless, ++ * matching the historical (pre-tri-state) behaviour. ++ * Deliberately do not update g_fullscreen here: hotkey ++ * state is temporary and is not an implicit preference. */ ++ const int target = current ? 0 : ++ (g_fullscreen ? g_fullscreen : 1); ++ if (!psx_apply_window_fullscreen_mode( ++ sdl_window, target)) { ++ std::fprintf(stderr, ++ "psxrecomp: fullscreen hotkey failed: %s\n", ++ SDL_GetError()); + } + } + } + } ++ } ++ ++ if (g_gl_active) { ++ const uint32_t ui_flags = psx_host_ui_capture_flags(); ++ /* Visible UI must stay on the main GL context even if a provider omits ++ * the explicit suspension flag. The interpolation thread never calls ++ * the host render hook. */ ++ gl_renderer_set_host_ui_suspended( ++ (ui_flags & (PSX_HOST_UI_VISIBLE | ++ PSX_HOST_UI_SUSPEND_INTERPOLATION)) != 0); + } + + /* Sample each player's device and feed the matching SIO pad slot. +@@ -3697,6 +4720,9 @@ + } + } netplay_tail(override); + ++#ifdef PSX_HAS_DISRUPTOR_CAPSULE ++ if (!psx_netplay_active()) disruptor_capsule_before_input(); ++#endif + if (psx_netplay_active()) { + psx_netplay_finish_frame(); + } else if (g_headless) { +@@ -3704,6 +4730,9 @@ + } else { + sample_pad_into_sio(override); + } ++#ifdef PSX_HAS_DISRUPTOR_CAPSULE ++ if (!psx_netplay_active()) disruptor_capsule_after_input(); ++#endif + + /* Latency ring: open this present cycle's slot, stamping when input was + * sampled into SIO. Always-on; queried via the debug server "latency". */ +@@ -3789,16 +4818,21 @@ + } + } + ++ const bool module_fast_forward = g_fast_forward_query && ++ !psx_netplay_active() && g_fast_forward_query() != 0; ++ + #ifndef PSX_SDL_NO_AUDIO + /* Optional turbo host sink advances the canonical SPU on the exact guest + * sample budget but drops accelerated output before SDL. This is distinct + * from the old mute/freeze model: voice and CD state never pause. FMV skip + * retains the hard mute because it deliberately fast-forwards a movie. */ + sdl_audio_update(fmv_skip_active, +- turbo_loads_active && g_turbo_audio_sink_enabled); +-#endif +- +- if (g_headless) { ++ module_fast_forward || ++ (turbo_loads_active && g_turbo_audio_sink_enabled)); ++#endif ++ ++ if (g_headless) engage_widescreen_at_game_entry(); /* a run without a window is the same game */ ++ if (g_headless || module_fast_forward) { + netplay_tail.skip_pace(); + return; + } +@@ -3813,7 +4847,7 @@ + } + #endif + +- /* Turbo mode: while TAB is held, skip both VRAM->ARGB conversion and ++ /* Turbo mode: while ] is held, skip both VRAM->ARGB conversion and + * SDL_RenderPresent. The recompiled BIOS still advances simulated + * cycles every vblank, so the BIOS proceeds at whatever rate the host + * CPU sustains without graphics-driver vsync overhead. Present once +@@ -3822,7 +4856,8 @@ + const Uint8* keys = SDL_GetKeyboardState(NULL); + static int turbo_skip = 0; + const int TURBO_PRESENT_EVERY = 30; +- if (keys[SDL_SCANCODE_TAB]) { ++ if (!(psx_host_ui_capture_flags() & PSX_HOST_UI_CAPTURE_KEYBOARD) && ++ keys[SDL_SCANCODE_RIGHTBRACKET]) { + turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; + if (turbo_skip != 0) { + netplay_tail.skip_pace(); +@@ -3916,30 +4951,12 @@ + /* Mod hooks. Run after all normal input sampling. */ + mod_call_frame_hooks(); + +- /* Resize-driven mode updates the pending aspect during BIOS boot too, but +- * the actual wide compositor remains disengaged until game entry. */ +- update_adaptive_widescreen(); +- + /* Depth24 GP1(07h) retarget (MotK intro→crawl): keep the prior Swap for a + * few vblanks so stale trailing VRAM never flashes on the right edge. */ + if (gpu_depth24_present_hold_tick()) + return; + +- /* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ +- if (!g_ws_engaged) { +- extern int fntrace_is_game_started(void); +- if (fntrace_is_game_started()) { +- g_ws_engaged = true; +- const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; +- int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; +- /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio +- * (identity squash). Squash mode: feed the real wide aspect. */ +- gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, +- mode == 1 ? g_video_aspect_den : 3); +- gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, +- g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); +- } +- } ++ engage_widescreen_at_game_entry(); + + /* ---- Display from our VRAM ---- */ + uint32_t w = 0, h = 0; +@@ -3963,7 +4980,8 @@ + /* After savestate load, always restage once even if we blanked + * earlier in the session — otherwise the window keeps a stale + * pre-load frame while vblanks (and FPS) keep advancing. */ +- if (!s_disabled_frame_presented || s_force_present_after_load) { ++ if (!s_disabled_frame_presented || s_force_present_after_load || ++ psx_host_ui_gl_visible()) { + s_disabled_frame_presented = true; + s_force_present_after_load = false; + if (g_gl_active) { +@@ -3988,7 +5006,7 @@ + * of truth, shared with the GTE/GPU squash so content and present stay + * locked: we squash IFF we stretch. depth24 always classifies as FMV + * even if the ws layer is not engaged yet (4:3 titles). */ +- fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_present_native_43() != 0; ++ fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_displayed_native_43() != 0; + /* MDEC movies are already decoded at their authored cadence and are + * CPU/upload heavy. High-refresh crossfades only contend with decoding + * and can starve audio, so present native-4:3/MDEC phases directly. +@@ -3996,19 +5014,25 @@ + * decode; the activity stamp also covers authentic 4:3 configurations. */ + if (g_gl_active) + gl_renderer_set_interpolation_suspended( +- fmv_frame || mdec_recently_active(2)); ++ fmv_frame || mdec_recently_active(2) || gpu_ws_displayed_menu() != 0); + + /* Canonical present width. Native-wide does NOT widen the canonical read + * (that bled across adjacent framebuffers); it composites into a separate + * wide surface and presents from there instead (see gpu wide compositor). */ + present_w = w; + +- /* Native-wide present: on a game frame, if the active backend has the +- * wide compositor, present the wider surface (canonical width + EXTRA) +- * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ ++ /* A native-wide frame and the same-width high-precision experiment both ++ * present from an independent compositor surface. Native-wide adds ++ * reveal columns; classic squash mode keeps `present_w == w` and uses ++ * the second surface solely to isolate visual correction from VRAM. */ ++ const bool corrected_present = ++ (!fmv_frame && !di.depth24 && g_ws_engaged && ++ gpu_geometry_correction_enabled() && gr_wide_supported()); + bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && +- ws_native_wide_active() && gr_wide_supported()); +- if (wide_present) present_w = w + (uint32_t)ws_nw_extra(); ++ gr_wide_supported() && !gpu_ws_displayed_flat() && ++ (ws_native_wide_active() || corrected_present)); ++ if (wide_present && ws_native_wide_active()) ++ present_w = w + (uint32_t)ws_nw_extra(); + + /* Native-wide invariant: canonical (320-wide) content is NEVER + * stretched across the wide window — a game frame that cannot present +@@ -4045,6 +5069,22 @@ + if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, + (int)h, g_video_aa ? 1 : 0)) + return; ++ /* A correction/native-wide surface can be unavailable for its ++ * first frame (allocation failure, an as-yet unknown display ++ * base, or a live mode transition). The GL renderer's CPU ++ * mirror is intentionally native-scale even when the FBO uses ++ * SSAA, so falling through to the generic CPU-hires path would ++ * upload a 1x image as though it were Nx and show a tiny box. ++ * Present the authoritative canonical FBO for this frame; the ++ * compositor can retry on the next one. */ ++ pres_entry->path = PRES_PATH_CANONICAL; ++ pres_entry->wide_fellback = 1; ++ pres_entry->present_w = (uint16_t)w; ++ gl_renderer_present_vram((int)di.display_x, (int)di.display_y, ++ (int)w, (int)h, ++ g_video_aa ? 1 : 0, ++ nw_pin ? 1 : 0); ++ return; + } else { + gl_renderer_present_vram((int)di.display_x, (int)di.display_y, + (int)present_w, (int)h, g_video_aa ? 1 : 0, +@@ -4142,6 +5182,7 @@ + h * (uint32_t)active_scale, + !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); + ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + /* Frame blending (CRT-persistence masker for 30fps double-buffered + * content). Some games (e.g. Crash Bash menus/characters) leave a + * dynamic object in only one of the two display buffers per 30fps cycle, +@@ -4183,6 +5224,7 @@ + } + } + } ++#endif + } + + /* Update only the active display rectangle. The backing texture is sized +@@ -4245,7 +5287,7 @@ + if (!g_present_vsync_disabled && present_ms > 250) { + g_present_slow_count++; + if (g_present_slow_count >= 3 && +- SDL_RenderSetVSync(sdl_renderer, 0) == 0) { ++ disruptor_sdl_render_set_vsync_compat(sdl_renderer, 0) == 0) { + g_present_vsync_disabled = 1; + } + } +@@ -4867,9 +5909,11 @@ + #endif + + int main(int argc, char** argv) { +- /* Force line-buffered output so messages appear even if killed. */ +- std::setvbuf(stdout, nullptr, _IOLBF, 0); +- std::setvbuf(stderr, nullptr, _IOLBF, 0); ++ /* Keep startup diagnostics visible even if the process is killed. MSVC's ++ * CRT rejects a zero-sized _IOLBF buffer via its invalid-parameter handler, ++ * so use the portable zero-sized _IONBF form for both standard streams. */ ++ std::setvbuf(stdout, nullptr, _IONBF, 0); ++ std::setvbuf(stderr, nullptr, _IONBF, 0); + std::fprintf(stderr, "psxrecomp: main() entered\n"); + std::fflush(stderr); + +@@ -5047,7 +6091,11 @@ + bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ + bool ws_ultrawide_offered = false; + bool ws_adaptive_view_supported = false; ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ bool frame_interpolation_offered = false; ++#else + bool frame_interpolation_offered = true; ++#endif + bool skip_fmv_offered = true; + bool turbo_loads_offered = true; + bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ +@@ -5084,6 +6132,7 @@ + * path is resolved (arm_text_image_guard). */ + std::string text_guard_exe_path; + uint32_t text_guard_load_addr = 0; ++ uint32_t text_guard_text_size = 0; + + if (game_config_path) { + try { +@@ -5143,8 +6192,10 @@ + g_video_aspect_den = gc.runtime.video_aspect_den; + g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; + g_video_vsync = gc.runtime.video_vsync; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; + g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; ++#endif + g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; + g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; + if (gc.runtime.video_fmv_skip_end_total) +@@ -5308,8 +6359,10 @@ + ws_offered = gc.ws_offered; + ws_ultrawide_offered = gc.ws_ultrawide_offered; + ws_adaptive_view_supported = gc.ws_adaptive_view; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + frame_interpolation_offered = + gc.runtime.video_offer_frame_interpolation; ++#endif + skip_fmv_offered = gc.runtime.video_offer_skip_fmv; + turbo_loads_offered = gc.runtime.offer_turbo_loads; + vulkan_offered = gc.vulkan_offered; +@@ -5363,6 +6416,7 @@ + * user's disc image instead. */ + text_guard_exe_path = gc.exe_path.string(); + text_guard_load_addr = gc.load_address; ++ text_guard_text_size = gc.text_size; + /* HLE-tier scheduler subsystem replacement default (env + * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ + psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); +@@ -5567,6 +6621,8 @@ + } + } + ++ if (!configure_disruptor_precision_store_routes(game_id)) return 1; ++ + if (!game_name.empty()) s_picker_game_name = game_name; + + /* Layer the launcher-written settings.toml (next to the exe) over the +@@ -5580,6 +6636,9 @@ + { + std::filesystem::path settings_path = + exe_dir_from_argv(argv[0]) / "settings.toml"; ++ const auto settings_utf8 = settings_path.u8string(); ++ g_host_user_settings_path_utf8.assign(settings_utf8.begin(), ++ settings_utf8.end()); + #if defined(RECOMP_LAUNCHER) + g_lnch_settings_path = settings_path; + #endif +@@ -5640,6 +6699,17 @@ + * Config + env own it until a launcher toggle exists; if one is + * added, restore this line together with the UI. The seed write + * below still persists the effective value for inspection. */ ++#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP ++ /* Disruptor's intro switch has a control and covers the BIOS animation too. */ ++ if (us.has_skip_intro && us.skip_intro && ++ !std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) ++ fast_boot = true; ++#endif ++#ifdef PSX_HAS_DISRUPTOR_LANGUAGE ++ /* Named before the drive first reads the game disc. */ ++ if (us.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) ++ disruptor_language_set_disc(us.language_disc.c_str()); ++#endif + if (us.has_bios_hle) bios_hle_requested = us.bios_hle; + if (us.has_fullscreen) g_fullscreen = us.fullscreen; + if (us.has_aspect_ratio) { +@@ -5648,6 +6718,10 @@ + } + if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; + if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; ++ if (us.has_master_volume) ++ (void)psx_host_audio_set_master_volume(us.master_volume); ++ if (us.has_audio_muted) ++ (void)psx_host_audio_set_muted(us.audio_muted ? 1 : 0); + /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- + * as-hide treatment as lock_mode / ws_offered below) so a stale + * settings.toml or launcher-less build can never point a bundled +@@ -5682,10 +6756,30 @@ + if (us.has_deadzone) resolved_deadzone = us.deadzone; + if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; + if (us.has_vsync) g_video_vsync = us.vsync; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (us.has_frame_interpolation) + g_frame_interpolation = us.frame_interpolation ? 1 : 0; + if (us.has_frame_interpolation_fps) + g_frame_interpolation_fps = us.frame_interpolation_fps; ++#if defined(DISRUPTOR_DEV_MENU) ++ /* Migrate legacy image-blend preferences to geometry at startup. ++ * Explicit environment selection below can still opt into legacy ++ * modes for one-run comparisons. */ ++ g_frame_interpolation_blend_default = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++ g_frame_interpolation_blend = g_frame_interpolation_blend_default; ++#else ++ if (us.has_frame_interpolation_blend) { ++ g_frame_interpolation_blend_default = ++ us.frame_interpolation_blend; ++ g_frame_interpolation_blend = us.frame_interpolation_blend; ++ } ++#endif ++#endif ++ if (us.has_geometry_correction) ++ g_geometry_correction = us.geometry_correction; ++ if (us.has_perspective_textures) ++ g_texture_correction = us.perspective_textures; + if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || + us.has_parappa_timing_extra_late) { + parappa_apply_timing_setting( +@@ -5782,25 +6876,65 @@ + g_video_aspect_den = ws_offered ? 9 : 3; + } + if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; +- g_ws_adaptive_max_num = ws_ultrawide_offered ? 21 : 16; ++ /* A title that explicitly opts into resize-driven presentation has ++ * accepted the runtime's full validated aspect range. Launcher-only ++ * ultrawide support remains capped at its historical 21:9 choice. */ ++ g_ws_adaptive_max_num = ws_adaptive_view_supported ++ ? 32 : (ws_ultrawide_offered ? 21 : 16); + g_ws_adaptive_max_den = 9; + +- /* Latency knobs: env overrides win over config (for A/B measurement). ++ /* Explicit process overrides win over saved/configured presentation. ++ * PSX_VIDEO_ASPECT=4:3|16:9|21:9|32:9; + * PSX_LOW_LATENCY_INPUT=0/1 ; PSX_VSYNC=1(vsync)/0(immediate)/-1(adaptive); + * PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|60+; +- * PSX_FRAME_INTERPOLATION_BLEND=linear|adaptive. */ ++ * PSX_FRAME_INTERPOLATION_BLEND=geometry|linear|adaptive. */ ++ if (const char *e = std::getenv("PSX_VIDEO_ASPECT")) { ++ int numerator = 0; ++ int denominator = 0; ++ if (std::strcmp(e, "4:3") == 0) { ++ numerator = 4; denominator = 3; ++ } else if (std::strcmp(e, "16:9") == 0) { ++ numerator = 16; denominator = 9; ++ } else if (std::strcmp(e, "21:9") == 0) { ++ numerator = 21; denominator = 9; ++ } else if (std::strcmp(e, "32:9") == 0) { ++ numerator = 32; denominator = 9; ++ } ++ const bool parsed = numerator != 0; ++ const bool wide = numerator * 3 != denominator * 4; ++ const bool ultrawide = numerator * 9 > denominator * 16; ++ const bool offered = ++ !wide || (ws_offered && (!ultrawide || ws_ultrawide_offered)); ++ if (parsed && is_host_fixed_display_aspect(numerator, denominator) && ++ offered) { ++ g_video_aspect_num = numerator; ++ g_video_aspect_den = denominator; ++ g_ws_adaptive_view = false; ++ } else { ++ std::fprintf(stderr, ++ "psxrecomp: ignoring unsupported PSX_VIDEO_ASPECT=%s\n", e); ++ } ++ } + if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0; + if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e); + if (const char *e = std::getenv("PSX_SMOOTH_60FPS")) + psx_smooth_60fps_set(atoi(e) ? 1 : 0); ++ if (const char *e = std::getenv("PSX_GEOMETRY_CORRECTION")) ++ g_geometry_correction = atoi(e) ? true : false; ++ if (const char *e = std::getenv("PSX_TEXTURE_CORRECTION")) ++ g_texture_correction = atoi(e) ? true : false; ++#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION")) + g_frame_interpolation = atoi(e) ? 1 : 0; + if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_FPS")) { + int fps = atoi(e); +- if (fps == 0 || fps >= 60) g_frame_interpolation_fps = fps; ++ if (fps == 0 || (fps >= 30 && fps <= 1000)) g_frame_interpolation_fps = fps; + } + if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_BLEND")) { +- if (strcmp(e, "adaptive") == 0 || strcmp(e, "clarity") == 0 || ++ if (strcmp(e, "geometry") == 0 || strcmp(e, "2") == 0) { ++ g_frame_interpolation_blend_default = ++ PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; ++ } else if (strcmp(e, "adaptive") == 0 || strcmp(e, "clarity") == 0 || + strcmp(e, "1") == 0) { + g_frame_interpolation_blend_default = + PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; +@@ -5810,6 +6944,11 @@ + } + g_frame_interpolation_blend = g_frame_interpolation_blend_default; + } ++ /* Geometry in-between frames need exact geometry, as the live switch insists. */ ++ if (g_frame_interpolation_blend == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && ++ !g_geometry_correction) ++ g_frame_interpolation = 0; ++#endif + { + const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); + const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); +@@ -5916,7 +7055,14 @@ + if (want_launcher) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { + int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ +- PSXRecompV4::UserSettings seed; ++ /* Merge launcher edits into the canonical file instead of ++ * constructing a fresh snapshot. Game-owned sections (including ++ * [disruptor]) and settings not exposed by recomp-ui must survive ++ * a later launcher save. */ ++ PSXRecompV4::UserSettings seed = ++ PSXRecompV4::load_user_settings( ++ exe_dir_from_argv(argv[0]) / "settings.toml"); ++ if (seed.parse_error) seed = PSXRecompV4::UserSettings{}; + seed.renderer = g_video_renderer; seed.has_renderer = true; + seed.supersampling = g_video_scale; seed.has_supersampling = true; + seed.antialiasing = g_video_aa; seed.has_antialiasing = true; +@@ -6415,6 +7561,12 @@ + g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; + if (!turbo_loads_offered) + g_turbo_loads_enabled = 0; ++#ifdef PSX_DISABLE_FRAME_INTERPOLATION ++ /* Clamp each runtime session after configuration and launcher selection. ++ * Legacy settings and environment variables cannot revive this feature. */ ++ g_frame_interpolation = 0; ++ g_frame_interpolation_fps = 0; ++#endif + g_frame_interpolation_blend = g_frame_interpolation_blend_default; + mod_runtime_activate_plugins(); + if (g_mod_controller_mode_override[0] >= 0) +@@ -6527,6 +7679,22 @@ + gr_set_scale(g_video_scale); + g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ + gr_set_texture_filter(g_video_texfilter); ++ gpu_geometry_correction_set(g_geometry_correction ? 1 : 0); ++ if (g_geometry_correction) ++ std::fprintf(stdout, ++ "psxrecomp: exact-provenance presentation geometry enabled " ++ "(ambiguous SXY fallback disabled; guest GP0/GTE coordinates preserved)\n"); ++ if (g_texture_correction && !g_geometry_correction) { ++ std::fprintf(stdout, ++ "psxrecomp: perspective texture correction requested but exact " ++ "geometry is disabled; keeping affine textures\n"); ++ g_texture_correction = false; ++ } ++ gpu_texture_correction_set(g_texture_correction ? 1 : 0); ++ if (g_texture_correction) ++ std::fprintf(stdout, ++ "psxrecomp: exact-depth perspective textures enabled on the " ++ "presentation mirror (canonical VRAM remains affine)\n"); + /* Display aspect. Identity at the default 4:3. The present letterbox uses + * this aspect; native-wide fills it with a genuinely wider frame (no + * stretch), squash mode stretches the 4:3 frame into it. */ +@@ -6633,6 +7801,7 @@ + * the local EXE file (dev checkouts) and the disc image (every install). */ + if (game_config_path) + arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, ++ text_guard_text_size, + disc_path_str); + mod_runtime_enable_disc_patches(); + { +@@ -6691,6 +7860,7 @@ + + if (g_headless) { + std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); ++ host_ui_runtime_ready(nullptr, PSX_HOST_UI_BACKEND_NONE); /* a game module sets its session up there, saved settings included */ + } else { + /* ---- SDL init ---- */ + /* Scale quality governs SDL's logical-size -> window scaling. Linear when +@@ -6780,6 +7950,17 @@ + std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); + return 1; + } ++#if defined(PSX_SDL3) ++ /* SDL3's creation flag can request fullscreen but cannot select its ++ * exclusive display mode. Complete a configured exclusive startup now, ++ * before renderer/context creation, so mode 2 is not silently borderless. */ ++ if (g_fullscreen == 2 && ++ !psx_apply_window_fullscreen_mode(sdl_window, g_fullscreen)) { ++ std::fprintf(stderr, ++ "psxrecomp: failed to enter configured exclusive fullscreen: %s\n", ++ SDL_GetError()); ++ } ++#endif + + /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock + * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank +@@ -6909,6 +8090,7 @@ + std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); + return 1; + } ++ sdl_pixel_buf_scale_capacity = g_video_scale; + + if (!g_gl_active && !g_vk_active) { + sdl_texture = SDL_CreateTexture( +@@ -6924,6 +8106,12 @@ + SDL_SetTextureScaleMode(sdl_texture, + g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); + } ++ ++ host_ui_runtime_ready( ++ sdl_window, ++ g_gl_active ? PSX_HOST_UI_BACKEND_OPENGL : ++ g_vk_active ? PSX_HOST_UI_BACKEND_VULKAN : ++ PSX_HOST_UI_BACKEND_SOFTWARE); + } + + /* Register vblank presentation callback. */ +@@ -7012,6 +8200,10 @@ + bios_hle_requested = (e[0] && e[0] != '0'); + if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) + bios_hle_keep_intro = (e[0] && e[0] != '0'); ++#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP ++ if (const char* e = std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) ++ if (e[0] && e[0] != '0') fast_boot = true; ++#endif + + PsxBiosHleRequest req; + req.bios_hle = bios_hle_requested ? 1 : 0; +@@ -7062,6 +8254,10 @@ + if (game_entry_pc != 0) { + savestate_configure(memcard_dir.string().c_str(), + memory_get_bios_checksum(), game_entry_pc); ++#ifdef PSX_HAS_DISRUPTOR_LANGUAGE ++ /* Before any state can be asked for: a language disc's states have a folder of their own. */ ++ disruptor_language_settle(); ++#endif + /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) + * at the next safe block boundary after boot. */ + if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { diff --git a/framework/patches/runtime/src/memory.c.patch b/framework/patches/runtime/src/memory.c.patch new file mode 100644 index 0000000..d123c3d --- /dev/null +++ b/framework/patches/runtime/src/memory.c.patch @@ -0,0 +1,225 @@ +diff --git a/runtime/src/memory.c b/runtime/src/memory.c +index d96d6d74422c5b80e3ae283d3a2d872686cab632..86e933d5c6a0e4426fc33ac30c09c68cc148754a 100644 +--- a/runtime/src/memory.c ++++ b/runtime/src/memory.c +@@ -14,6 +14,7 @@ + #include "dma.h" + #include "fntrace.h" + #include "gpu.h" ++#include "gte_precision.h" + #include "mdec.h" + #include "mod_memory.h" + #include "sio.h" +@@ -82,6 +83,16 @@ + return PSX_MOD_GPU_DMA_GUEST_BASE + start; + } + ++uint32_t psx_mod_gpu_dma_memory_bytes(void) { return mod_gpu_dma_memory_used; } ++const uint8_t *psx_mod_gpu_dma_memory_data(void) { return mod_gpu_dma_memory; } ++int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size) { ++ if (size > PSX_MOD_GPU_DMA_APERTURE_SIZE || (size && !data)) return 0; ++ if (size) memcpy(mod_gpu_dma_memory, data, size); ++ memset(mod_gpu_dma_memory + size, 0, PSX_MOD_GPU_DMA_APERTURE_SIZE - size); ++ mod_gpu_dma_memory_used = size; ++ return 1; ++} ++ + static int mod_gpu_dma_memory_offset(uint32_t phys, uint32_t width, + uint32_t *offset) { + return psx_mod_gpu_dma_aperture_offset_for( +@@ -115,7 +126,11 @@ + uint8_t *memory_get_ram_ptr(void) { return ram; } + uint8_t *memory_get_scratchpad_ptr(void) { return scratchpad; } + ++static int s_low_scratch_is_the_games; /* a loaded state of a started game: what is there is no longer the boot's */ ++void memory_keep_low_boot_scratch(void) { s_low_scratch_is_the_games = 1; } ++ + void memory_clear_low_boot_scratch(void) { ++ if (s_low_scratch_is_the_games) return; + memset(ram, 0, 0x10u); + } + +@@ -207,6 +222,7 @@ + static uint64_t kbless_verifies = 0; + static uint64_t kbless_mismatches = 0; + static uint64_t kbless_invalidations = 0; ++static uint32_t kbless_last_mismatch[6] = {0}; + + static int kbless_on(void) { + if (kbless_enabled < 0) { +@@ -264,6 +280,20 @@ + } + kbless_state[i] = KBLESS_MISMATCH; + kbless_mismatches++; ++ kbless_last_mismatch[0] = phys; ++ kbless_last_mismatch[1] = b->body_lo; ++ kbless_last_mismatch[2] = b->body_hi; ++ { ++ const uint8_t *live = ram + b->body_lo; ++ const uint8_t *ref = bios_rom + s_kb_rom_off + ++ (b->body_lo - s_kb_lo); ++ uint32_t len = b->body_hi - b->body_lo; ++ uint32_t off = 0; ++ while (off < len && live[off] == ref[off]) off++; ++ kbless_last_mismatch[3] = b->body_lo + off; ++ kbless_last_mismatch[4] = off < len ? live[off] : 0u; ++ kbless_last_mismatch[5] = off < len ? ref[off] : 0u; ++ } + return 0; + } + +@@ -322,6 +352,11 @@ + out[3] = kbless_native_hits; + out[4] = kbless_verifies; + out[5] = kbless_invalidations; ++} ++ ++void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]) { ++ if (count) *count = kbless_mismatches; ++ if (out) memcpy(out, kbless_last_mismatch, sizeof(kbless_last_mismatch)); + } + + static inline void dirty_ram_mark_kernel_write(uint32_t phys) { +@@ -881,6 +916,8 @@ + uint32_t memory_get_bios_checksum(void) { return s_bios_checksum; } + + void memory_init(const char* bios_path) { ++ gte_precision_word_write_begin(); ++ s_low_scratch_is_the_games = 0; + memset(ram, 0, sizeof(ram)); + memset(scratchpad, 0, sizeof(scratchpad)); + /* Rematch re-enters without process exit — wipe sticky I/O regs that +@@ -1458,10 +1495,13 @@ + } + + static void psx_write_word_raw(uint32_t addr, uint32_t val); +-extern void gte_precision_invalidate_word(uint32_t addr); + void psx_write_word(uint32_t addr, uint32_t val) { + extern void (*g_overlay_flush_pending_cycles)(void); + if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); ++ /* The immediately following provenance callback may trust only a commit ++ * from this write attempt. Clear after the cycle flush because flushing ++ * can itself perform guest writes. */ ++ gte_precision_word_write_begin(); + if (g_ls_mode == 2) { ls_write_hook(addr, 4, val); return; } + if (g_ds_recording) { + if (g_dma_exec_depth > 0) ds_note_dma_write(); +@@ -1475,7 +1515,6 @@ + } + static void psx_write_word_raw(uint32_t addr, uint32_t val) { + g_guest_store_count++; +- gte_precision_invalidate_word(addr); + /* KSEG2 cache control — before physical translation. */ + if (addr == 0xFFFE0130u) { cache_ctrl = val; return; } + /* KSEG2 guard — see psx_read_word_raw. */ +@@ -1540,6 +1579,9 @@ + return; + } + } ++ /* All discard/suppression paths are now behind us. Only a write that ++ * actually changes main RAM may invalidate prior provenance. */ ++ gte_precision_invalidate_word(addr); + if (phys == D44_PHYS) d44_note(phys, read_ram_word(phys), val); + debug_server_trace_write_check(phys, read_ram_word(phys), val, 4); + parity_trace_note_write(phys, 4, effective_store_pc()); +@@ -1554,12 +1596,18 @@ + ram[phys + 1] = (uint8_t)(val >> 8); + ram[phys + 2] = (uint8_t)(val >> 16); + ram[phys + 3] = (uint8_t)(val >> 24); ++ gte_precision_main_ram_word_committed(phys); ++ /* The matching emitted/interpreted guest-store callback consumes this ++ * token after write_word returns. DMA, HLE and mod writes deliberately ++ * have no such callback, so a sticky debug PC cannot grant provenance. */ + return; + } + { + uint32_t off; + if (mod_gpu_dma_memory_offset(phys, 4u, &off)) { ++ gte_precision_invalidate_word(addr); + memcpy(mod_gpu_dma_memory + off, &val, sizeof(val)); ++ gte_precision_gpu_dma_word_committed(phys); + return; + } + } +@@ -1574,6 +1622,9 @@ + if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { + uint32_t off = phys - 0x1F800000u; ++ /* All discard/suppression paths are behind us. Invalidate any ++ * reviewed scratch provenance before the replacement bytes land. */ ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, + (uint32_t)scratchpad[off] + | ((uint32_t)scratchpad[off + 1] << 8) +@@ -1584,6 +1635,7 @@ + scratchpad[off + 1] = (uint8_t)(val >> 8); + scratchpad[off + 2] = (uint8_t)(val >> 16); + scratchpad[off + 3] = (uint8_t)(val >> 24); ++ gte_precision_scratch_word_committed(phys); + return; + } + if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { +@@ -1662,7 +1714,6 @@ + } + static void psx_write_half_raw(uint32_t addr, uint16_t val) { + g_guest_store_count++; +- gte_precision_invalidate_word(addr); + if (sr_ptr && (*sr_ptr & 0x10000u)) return; + + /* KSEG2 guard — see psx_read_word_raw. */ +@@ -1670,6 +1721,7 @@ + uint32_t phys = psx_phys_addr(addr); + + if (phys < RAM_SIZE) { ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, (uint32_t)read_ram_half(phys), (uint32_t)val, 2); + parity_trace_note_write(phys, 2, effective_store_pc()); + card_data_writes_check(phys, (uint32_t)val, 2); +@@ -1686,6 +1738,7 @@ + { + uint32_t off; + if (mod_gpu_dma_memory_offset(phys, 2u, &off)) { ++ gte_precision_invalidate_word(addr); + mod_gpu_dma_memory[off] = (uint8_t)val; + mod_gpu_dma_memory[off + 1u] = (uint8_t)(val >> 8); + return; +@@ -1702,6 +1755,7 @@ + if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { + uint32_t off = phys - 0x1F800000u; ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, + (uint32_t)scratchpad[off] | ((uint32_t)scratchpad[off + 1] << 8), + (uint32_t)val, 2); +@@ -1998,7 +2052,6 @@ + } + static void psx_write_byte_raw(uint32_t addr, uint8_t val) { + g_guest_store_count++; +- gte_precision_invalidate_word(addr); + if (sr_ptr && (*sr_ptr & 0x10000u)) return; + + /* KSEG2 guard — see psx_read_word_raw. */ +@@ -2006,6 +2059,7 @@ + uint32_t phys = psx_phys_addr(addr); + + if (phys < RAM_SIZE) { ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, (uint32_t)ram[phys], (uint32_t)val, 1); + parity_trace_note_write(phys, 1, effective_store_pc()); + card_data_writes_check(phys, (uint32_t)val, 1); +@@ -2021,6 +2075,7 @@ + { + uint32_t off; + if (mod_gpu_dma_memory_offset(phys, 1u, &off)) { ++ gte_precision_invalidate_word(addr); + mod_gpu_dma_memory[off] = val; + return; + } +@@ -2034,6 +2089,7 @@ + } + if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { ++ gte_precision_invalidate_word(addr); + debug_server_trace_write_check(phys, (uint32_t)scratchpad[phys - 0x1F800000u], + (uint32_t)val, 1); + scratchpad[phys - 0x1F800000u] = val; diff --git a/framework/patches/runtime/src/overlay_loader.c.patch b/framework/patches/runtime/src/overlay_loader.c.patch new file mode 100644 index 0000000..731dba9 --- /dev/null +++ b/framework/patches/runtime/src/overlay_loader.c.patch @@ -0,0 +1,13 @@ +diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c +index d5d1f381cea9019bcba1db168908f599ce6ce650..f05e6918965ea0ede9ddae3e2b6dabbb5b590909 100644 +--- a/runtime/src/overlay_loader.c ++++ b/runtime/src/overlay_loader.c +@@ -3415,7 +3415,7 @@ + return 0; + } + int lazy_loaded = 0; +-retry_candidates: ++retry_candidates: ; + int head = idx_head(phys); + int loaded_range_ci = -1; + int lazy_exact = 0; diff --git a/framework/patches/runtime/src/psx_cycles.c.patch b/framework/patches/runtime/src/psx_cycles.c.patch new file mode 100644 index 0000000..671f00d --- /dev/null +++ b/framework/patches/runtime/src/psx_cycles.c.patch @@ -0,0 +1,13 @@ +diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c +index 72cbe9b496991cbb84b10afb8813c5282c7dd0cf..dfd3b33f2d8383089645c67847200a7559cd784b 100644 +--- a/runtime/src/psx_cycles.c ++++ b/runtime/src/psx_cycles.c +@@ -20,6 +20,8 @@ + uint32_t g_psx_cyc_batch = 0; + uint32_t g_psx_cyc_batch_limit = 0; + int g_psx_cyc_bb_defer = 0; ++uint32_t g_psx_cyc_overclock_shift = 0; ++uint32_t g_psx_cyc_overclock_carry = 0; + static int s_cycle_replay_active = 0; + static uint64_t s_cycle_replay_live = 0; + diff --git a/framework/patches/runtime/src/ws_aspect_cone_math.c.patch b/framework/patches/runtime/src/ws_aspect_cone_math.c.patch new file mode 100644 index 0000000..0e01b45 --- /dev/null +++ b/framework/patches/runtime/src/ws_aspect_cone_math.c.patch @@ -0,0 +1,52 @@ +diff --git a/runtime/src/ws_aspect_cone_math.c b/runtime/src/ws_aspect_cone_math.c +index aca28999b02c32c1c7b16ad7c820472b7e9fd17b..a97019ed2c40bd20f69652731e7f2cfe1644c494 100644 +--- a/runtime/src/ws_aspect_cone_math.c ++++ b/runtime/src/ws_aspect_cone_math.c +@@ -2,19 +2,39 @@ + + #include + +-uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { +- if (extent_pixels <= 0) return vanilla; +- if (vanilla == 0) return 0; +- if (vanilla >= 1024u) return 1023u; ++int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, ++ int extent_pixels) { ++ if (extent_pixels <= 0 || vanilla == 0) return vanilla; ++ if (full_turn < 4u || (full_turn & 3u) != 0u) return vanilla; ++ ++ const int sign = vanilla < 0 ? -1 : 1; ++ int64_t magnitude = vanilla; ++ if (magnitude < 0) magnitude = -magnitude; ++ const int64_t maximum = (int64_t)full_turn / 4 - 1; ++ if (magnitude > maximum) magnitude = maximum; + + const double tau = 6.283185307179586476925286766559; +- const double angle = (double)vanilla * tau / 4096.0; ++ const double angle = (double)magnitude * tau / (double)full_turn; + const double scale = (160.0 + (double)extent_pixels) / 160.0; + const double widened = atan(tan(angle) * scale); +- long result = lround(widened * 4096.0 / tau); ++ long result = lround(widened * (double)full_turn / tau); + if (result < 1) result = 1; +- if (result > 1023) result = 1023; +- return (uint32_t)result; ++ if ((int64_t)result > maximum) result = (long)maximum; ++ return (int32_t)(sign * result); ++} ++ ++uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { ++ if (vanilla >= 1024u) vanilla = 1023u; ++ return (uint32_t)psx_ws_widen_angle_units( ++ (int32_t)vanilla, 4096u, extent_pixels); ++} ++ ++int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels) { ++ if (extent_pixels <= 0 || value == 0) return value; ++ const int64_t denominator = 160 + (int64_t)extent_pixels; ++ int64_t numerator = (int64_t)value * 160; ++ numerator += numerator >= 0 ? denominator / 2 : -denominator / 2; ++ return (int32_t)(numerator / denominator); + } + + int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, diff --git a/framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch b/framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch new file mode 100644 index 0000000..a062cfe --- /dev/null +++ b/framework/patches/runtime/tests/test_ws_aspect_cone_math.c.patch @@ -0,0 +1,23 @@ +diff --git a/runtime/tests/test_ws_aspect_cone_math.c b/runtime/tests/test_ws_aspect_cone_math.c +index 30ca90a49e62012c1115db3cd4675d0929f8a3e8..5575a2d22c88cd40f19fc8f69d0ccd53b53181a5 100644 +--- a/runtime/tests/test_ws_aspect_cone_math.c ++++ b/runtime/tests/test_ws_aspect_cone_math.c +@@ -64,5 +64,18 @@ + check(psx_ws_widen_angle_q12(1023, 256) <= 1023, + "terrain angle remains below a quarter turn"); + ++ check(psx_ws_widen_angle_units(32, 256, 0) == 32 && ++ psx_ws_widen_angle_units(-32, 256, 0) == -32, ++ "wrapping yaw offsets are exact at 4:3"); ++ check(psx_ws_widen_angle_units(32, 256, 53) == 38 && ++ psx_ws_widen_angle_units(-32, 256, 53) == -38, ++ "eight-bit yaw cone widens symmetrically for 16:9"); ++ check(psx_ws_scale_horizontal_320(213, 53) == 160 && ++ psx_ws_scale_horizontal_320(-213, 53) == -160, ++ "16:9 horizontal edges map to the original side plane"); ++ check(psx_ws_scale_horizontal_320(123, 0) == 123 && ++ psx_ws_scale_horizontal_320(-123, 0) == -123, ++ "horizontal side-plane scaling is exact at 4:3"); ++ + return failures ? 1 : 0; + } diff --git a/psxrecomp-overlay/recompiler/include/code_generator.h b/psxrecomp-overlay/recompiler/include/code_generator.h deleted file mode 100644 index 8e6e02e..0000000 --- a/psxrecomp-overlay/recompiler/include/code_generator.h +++ /dev/null @@ -1,513 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include "ps1_exe_parser.h" -#include "function_analysis.h" -#include "control_flow.h" -#include "../src/annotations.hpp" -#include "../src/config_loader.h" - -namespace PSXRecompV4 { class BiosAddressModel; } - -namespace PSXRecomp { - -// CPU state structure (registers + memory access) -struct CPUState { - // General purpose registers (32 total) - uint32_t gpr[32]; // $0-$31 - uint32_t pc; // Program counter - uint32_t hi, lo; // Multiply/divide results - - // Memory access functions (to be implemented in runtime) - uint32_t (*read_word)(uint32_t addr); - void (*write_word)(uint32_t addr, uint32_t value); - uint16_t (*read_half)(uint32_t addr); - void (*write_half)(uint32_t addr, uint16_t value); - uint8_t (*read_byte)(uint32_t addr); - void (*write_byte)(uint32_t addr, uint8_t value); -}; - -// Code generation configuration -struct CodeGenConfig { - bool emit_comments; // Include MIPS instruction comments - bool emit_line_numbers; // Include original address as comments - bool optimize_zero_reg; // Optimize away $zero assignments - bool use_switch_for_blocks; // Use switch instead of goto labels - bool split_mid_function_targets; // Split branch targets into funcs for legacy main-EXE analysis - // True when compiling an overlay image (psxrecomp-game --overlay). Overlay - // regions hold different scene VARIANTS at the same addresses (the same addr - // is backdrop code in one scene, unrelated code/data in another), so an - // explicit per-address widescreen site (x_sites / cull_* sites) authored for - // one variant won't match the bytes in another. In overlay mode such a shape - // mismatch is EXPECTED (apply the transform only where the bytes match, skip - // it elsewhere) rather than a hard config error. - bool overlay_mode = false; - std::vector ws_signed_x_bound_sites; - std::string indent; // Indentation string (default: " ") - // Widescreen sprite-tag hooks ([widescreen] sprite_tag_funcs): functions - // that get a psx_ws_sprite_tag(cpu) callback emitted at entry, so the - // runtime can record the per-prim pointer ($a0) + projected anchor for - // proportion correction at GP0 submission. Empty = no hooks (default). - std::set ws_sprite_tag_funcs; - - // Data-shard hooks ([data_shards] funcs): memoized pure-function replay. - // Entry gets `if (psx_datashard_enter(cpu, KEY)) return;` (replay path), - // every jr-$ra return gets `psx_datashard_ret(cpu)` (capture finalize). - // See docs/DATA_SHARDS.md. Empty = no hooks (default). - std::set data_shard_funcs; - - // Trusted game-mod entry hooks ([recompiler] mod_function_entry_funcs). - // Only explicitly listed guest functions call the runtime dispatcher. - std::set mod_function_entry_funcs; - - // [recompiler] hot_funcs: emit __attribute__((hot)) on these guest - // addresses (MotK VLC leaves, etc.). Host locality hint only. - std::set hot_funcs; - - // [load_accel.vsync_query] verified PsyQ VSync functions whose mode=-1 - // path may bypass its unused GPUSTAT/Timer1 reads. The map value is the - // guest RAM VBlank counter returned by that query path. Empty = inert. - // value = {VBlank counter, GPUSTAT-pointer global, Timer1-pointer global, - // cached Timer1 global} - std::map> vsync_query_hle_funcs; - - // MMX6-class 2D tile-ring stage initializer. Emit a reveal-invalidation - // callback at entry so host-only wide pixels cannot survive a stage swap. - uint32_t ws_bg2d_init_func = 0; - - // Widescreen far-backdrop un-squash ([widescreen] backdrop_unsquash_funcs): - // functions whose body is bracketed by gte_ws_set_suppress(1)/(0) so the - // GTE X-squash is OFF for their (far-backdrop) draws — the backdrop fills - // the stretched 16:9 frame instead of leaving edge void (8C). Suppress is - // set at entry and cleared before every jr-$ra return. Empty = none. - std::set ws_backdrop_unsquash_funcs; - - // Widescreen cull-margin widening ([widescreen] cull_* sites). At these - // addresses the immediate is emitted with a runtime margin term - // psx_ws_x_margin() so the world-space draw cull widens with the aspect - // (0 at 4:3). bias = addiu (+margin); range = sltiu (+2*margin); a1 = a - // nop replaced with `a1 += margin`, or move rD,a1 replaced with - // `rD = a1 + margin`. See config_loader.h. Empty = default. - std::set ws_cull_bias_sites; - std::set ws_cull_range_sites; - std::set ws_cull_a1_sites; - std::set ws_cull_screen_x_sites; - // Extra lead for bias/range activation windows only. Render/terrain - // helpers continue to use psx_ws_x_margin() without this term. - int ws_cull_activation_guard_pixels = 0; - - // Explicit signed right-edge widen sites ([widescreen.cull] slti_sites): - // `slti rt, sx, W` emitted through psx_ws_cull_slti for funnel functions - // the auto-detector cannot qualify (X-only test, no height compare). - std::set ws_cull_slti_sites; - // Signed lower-bound counterpart: `slti rt,sx,-W` compares against - // `-W-x_margin`, preserving the original result at 4:3. - std::set ws_cull_slti_lower_sites; - // Manual screen-window counterparts for zero-bound branches, constant / - // zero-store clamps, right-edge constants, and packed [left,right] spans. - // Each configured site is instruction-shape checked by codegen. - std::set ws_cull_lower_branch_sites; - std::set ws_cull_lower_value_sites; - std::set ws_cull_upper_value_sites; - std::set ws_cull_span_value_sites; - - // `bltz rs, reject` emitted through psx_ws_cull_bltz — the explicit - // LEFT-edge counterpart to ws_cull_slti_sites ([widescreen.cull] - // bltz_sites), for X-only funnels the auto-detector cannot qualify. - std::set ws_cull_bltz_sites; - - // Explicit horizontal low-edge widen sites ([widescreen.cull] - // negsub_sites): `subu rd, zero, rs` becomes `-rs - x_margin`. - // Identity at 4:3; empty by default. - std::set ws_cull_negsub_sites; - - // Explicit masked-16-bit screen-X window sites ([widescreen.cull] - // vxrange_sites). The configured sltiu is routed through the shared - // runtime helper so native and interpreted overlays use identical math. - std::set ws_cull_vxrange_sites; - - // Explicit aspect-scaled far-bound sites ([widescreen.cull] - // depth_sites). Configured slti/sltiu instructions route through the - // runtime helper; empty by default. - std::set ws_cull_depth_sites; - - // Side frustum-plane normal-X load sites ([widescreen.cull] - // plane_nx_sites). The configured lw routes through the runtime helper - // (inverse-aspect scale while revealed, identity at 4:3). - std::set ws_cull_plane_nx_sites; - - // Per-primitive X-reject bound load sites ([widescreen.cull] - // xclip_load_sites). The configured lw routes through the runtime helper - // (INT32_MAX while revealed, vanilla at 4:3); empty by default. - std::set ws_cull_xclip_load_sites; - - // Exact, full-word-guarded comparison sites whose result is forced only - // while widescreen reveals extra world. 4:3 evaluates the original compare. - std::vector ws_cull_keep_sites; - - // Exact `addi[u] rt,zero,imm` 12-bit angular half-extents. The runtime - // scales tan(angle) by the current horizontal reveal factor. - std::vector ws_cull_angle_sites; - - // Exact `addi[u] rt,rs,+/-half_fov` wrapping-yaw sites. The runtime widens - // only the signed offset in the site's declared angular unit system. - std::vector ws_cull_yaw_cone_sites; - - // Exact signed `slt dst,bound,horizontal` side-plane comparisons. The - // horizontal operand is projection-scaled only while widescreen is live. - std::vector - ws_cull_horizontal_slt_sites; - - // Exact model-participation cosine compares that gain a camera-horizontal - // aspect envelope while preserving the vanilla vertical cone. - PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; - - // Screen-extent signature immediates ([widescreen.cull] screen_w_imms / - // screen_h_imms) — per-game display-width-derived bounds. Defaults are the - // Tomba signature; Ape Escape uses 0x181 (+ 0xF1 height). - std::vector ws_cull_w_imms = { 0x140, 0x141 }; - std::vector ws_cull_h_imms = { 0xE0, 0xF1 }; - - // Widescreen automatic horizontal-FOV cull widening ([widescreen.cull] - // auto_screen_x). When true, any function carrying the GTE screen-extent - // reject signature — at least one `sltiu …,0x140/0x141` (right/width) AND - // one `sltiu …,0xE0/0xF1` (bottom/height) — has every width compare emitted - // with + 2*psx_ws_x_margin() (0 at 4:3). This routes the whole render-funnel - // (≈100 sites in Tomba) through one helper with no per-address list, and is - // generic to any PSX GTE title. The vertical (0xE0) bound is never touched. - bool ws_auto_screen_x_cull = false; - - // Widescreen automatic far-backdrop column PRELOAD ([widescreen.cull] - // auto_backdrop). When true, every scrolling-backdrop column-window generator - // is auto-detected by its invariant (see ws_backdrop_detect.h) and its window - // START/END finalize instructions are emitted through psx_ws_backdrop_value() - // so the whole finite tile row preloads in 16:9 (0 at 4:3 => byte-identical). - // Generators are overlay-resident, so this must reach the overlay compile. - bool ws_auto_backdrop_preload = false; - - // Widescreen backdrop screenX squash ([widescreen.backdrop] x_sites). Each - // address is a `sh rt,off(base)` storing a parallax 2D backdrop layer's - // final screen-X; emitted as write_half(base+off, psx_ws_backdrop_x(rt)) - // so the un-GTE'd backdrop is squashed around screen centre for the 16:9 - // FOV (identity at 4:3). Overlay-resident; see config_loader.h. Empty=default. - std::set ws_backdrop_x_sites; - - // Widescreen pure-2D background tile-loop widen ([widescreen.bg2d]). Three - // instruction addresses in a 2D game's per-layer BG renderer (MMX6's - // FUN_800270d0): the column-count load/compare, the start tile-column mask, - // and the start screen-x calculation (MMX6: li count + sra start; MMX4/MMX5: - // inline slti[u] count, sra or subu-zero start), rewritten through the gpu.c - // psx_ws_bg2d_* helpers so the loop draws the 16:9 reveal columns on both - // sides (identity at 4:3 / 512 hi-res). 0 = unset. Main-EXE addresses; - // verified by opcode at gen time. - uint32_t ws_bg2d_count_site = 0; - uint32_t ws_bg2d_startcol_site = 0; - uint32_t ws_bg2d_startx_site = 0; - uint32_t ws_bg2d_stream_left_site = 0; // tile-ring streamer left leading-edge addiu - uint32_t ws_bg2d_stream_right_site = 0; // tile-ring streamer right leading-edge addiu - uint32_t ws_bg2d_bufbase_site = 0; // driver addu: BG packet-buffer address (relocate) - uint32_t ws_bg2d_cap_site = 0; // renderer slti: per-frame BG tile cap (raise) - - // Persistent game-option init-store hooks ([persist_options] in - // game_options.toml). Each entry is the PC of a boot-init sb/sh that writes a - // config global's DEFAULT value; the store is rewritten to route the value - // through psx_game_option_store(addr,val), so a value persisted from a prior - // session overrides the default at initialization (issue #5). Identity when - // nothing is persisted, so a fresh install is byte-identical. Empty=default. - std::set persist_init_store_sites; - - CodeGenConfig() - : emit_comments(true) - , emit_line_numbers(true) - , optimize_zero_reg(true) - , use_switch_for_blocks(false) - , split_mid_function_targets(true) - , indent(" ") {} -}; - -// Result of code generation for a function -struct GeneratedFunction { - std::string function_name; // e.g., "func_80016940" - std::string signature; // e.g., "void func_80016940(CPUState* cpu)" - std::string body; // C code body - std::string full_code; // signature + body - int line_count; // Number of lines generated - // False for fail-closed data/untranslatable stubs whose only operation is - // psx_unknown_dispatch. They remain emitted for direct diagnostics, but - // must not advertise themselves as executable native game entries. - bool dispatchable = true; -}; - -class CodeGenerator { -public: - explicit CodeGenerator(const PS1Executable& exe, const CodeGenConfig& config = CodeGenConfig()); - - // Set the BIOS address model the game codegen folds shell/kernel RAM - // aliases through (jump tables inside relocated BIOS code windows). - // Resolved from the game's [recompiler] bios_config profile (default: - // the SCPH1001 profile) in main_psx.cpp; there is no built-in window. - // Must outlive code generation. - static void set_bios_address_model(const PSXRecompV4::BiosAddressModel* m); - - // Generate C code for a single function. - // fallthrough_name: if non-empty and the last block has no explicit control - // flow (ControlFlowType::None), emit a tail call to that function before '}'. - // This handles MIPS functions that fall through into the next function without - // an explicit jr/j instruction. - GeneratedFunction generate_function( - const Function& func, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name = ""); - - // Generate C code for all functions - std::vector generate_all_functions( - const std::vector& functions, - const std::map& cfgs); - - // Generate a group of overlapping-alias entries that share one host - // function: ONE static body holding the host-range blocks (restricted to - // the union of blocks reachable from the entries) with an entry switch, - // plus a dispatchable wrapper per entry. Returns one GeneratedFunction per - // entry; the first carries the shared body. - std::vector generate_alias_group( - const std::vector& aliases, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name); - - // Generate complete C file with all functions - std::string generate_file( - const std::vector& functions, - const std::map& cfgs); - - // The generated functions from the most recent generate_file() call - // (post mid-function-split pass). Consumed by main_psx to write the - // split full.c shards + shared decls header. Not populated until - // generate_file() has run. - const std::vector& last_gen_funcs() const { return last_gen_funcs_; } - - // Build the shared-declarations header shared by every full.c shard: - // the runtime extern prologue, the unaligned-access helpers (as static - // inline, since this header is included by multiple translation units), - // and forward declarations for every recompiled function plus every - // overlapping-alias shared body. Byte-for-byte the same declaration set - // the monolith emits inline, just hoisted into a header. - std::string build_shared_decls_header(const std::vector& gen_funcs) const; - - // Generate the per-function code-range manifest consumed by the overlay - // loader's per-entry validity hash (design §8). For each function, emits its - // compiled code byte-ranges (union of basic-block extents), coalesced; - // interleaved jump tables / constant pools fall in the gaps and are - // excluded — which is what makes the runtime hash stable across reloads. - // F one per function (virtual entry address) - // R one per coalesced code range (virtual addr) - std::string generate_ranges_manifest( - const std::vector& functions, - const std::map& cfgs); - - // Exact manifest for the final function/CFG set emitted by the most recent - // generate_file() call, including functions synthesized by its split pass. - const std::string& last_ranges_manifest() const { return last_ranges_manifest_; } - - // Set known functions for this compilation unit (for linking) - void set_known_functions(const std::set& functions) { - known_functions_ = functions; - } - - // Set annotation table (optional — no-op if not called) - void set_annotations(const AnnotationTable* at) { annotations_ = at; } - -private: - const PS1Executable& exe_; - CodeGenConfig config_; - std::set known_functions_; // Addresses of functions in this compilation unit - std::string last_ranges_manifest_; - std::set extra_labels_; // Mid-block addresses that need inline labels (jump table targets) - const AnnotationTable* annotations_ = nullptr; - - // Forward declarations for every psx_alias_body_XXXXXXXX() emitted by - // generate_alias_group() during the most recent generate_all_functions() - // pass. These bodies are externally linked (split-TU build) so every - // shard/header that calls into another shard's alias body needs the - // prototype. Cleared at the top of generate_all_functions(). - std::vector alias_body_decls_; - - // The functions emitted by the most recent generate_file() call, stashed - // for main_psx's split-shard writer. See last_gen_funcs(). - std::vector last_gen_funcs_; - - // Emit the runtime-hook extern prologue (debug_server_log_call_entry ... - // g_debug_last_store_pc). Shared verbatim between the monolith path - // (generate_file) and the split-shard shared header - // (build_shared_decls_header). - void emit_runtime_externs(std::ostream& ss) const; - - // Emit the LWL/LWR/SWL/SWR unaligned-access reference helpers. - // as_inline selects `static inline` (shared header, included by many - // TUs) vs `static` (monolith, single TU) linkage for the 4 helper - // function definitions; everything else is identical. - void emit_unaligned_helpers(std::ostream& ss, bool as_inline) const; - - // RECURSION_BUG.md §25 — continuation-passing call/return (the universal fix - // for the idle-freeze host-stack leak). When set (gen-time env PSX_CPS), - // guest calls (jal/jalr/jr-table/external) are emitted as TAIL-TRANSFERS - // (set $ra, cpu->pc = target, return) and each call's return point is - // registered as a dispatchable continuation; jr $ra publishes cpu->pc. - // The unified flat trampoline (psx_dispatch_impl) drives the whole - // call/return via the guest's own $ra/$sp — so the host stack can never - // mirror unbounded guest re-entrancy. The §8 leaking chain (func_8001A954 - // ...) lives in GAME code, so the game emitter MUST honor CPS too. - bool cps_enabled_ = false; - // Continuations (return points) collected during the CURRENT function's - // block translation; consumed by generate_function to emit the entry-switch. - std::vector cps_cur_continuations_; - // Global map: continuation address -> owning function entry, for the game - // dispatch table (psx_dispatch_game_compiled) to route a returned-to - // continuation into its function's entry-switch. - std::map cps_continuation_owner_; - - // Cycle accounting for legal entries that land inside an already-emitted - // block instead of at its leader. Block leaders charge the whole block; an - // entry-switch or jump-table target that bypasses the leader must charge the - // remaining instructions from the interior label to the end of that block. - uint32_t partial_block_cycle_count(uint32_t addr, const ControlFlowGraph& cfg) const; - std::string emit_mid_block_cycle_charge(uint32_t addr, const ControlFlowGraph& cfg, - const std::string& indent) const; - std::string emit_interrupt_check(uint32_t resume_pc, const std::string& indent) const; - std::string emit_interrupt_check_expr(const std::string& resume_pc_expr, - const std::string& indent) const; - -public: - bool cps_enabled() const { return cps_enabled_; } - // continuation_addr -> owning function entry (CPS dispatch routing). - const std::map& cps_continuations() const { - return cps_continuation_owner_; - } -private: - - // Set per-function by generate_function when config_.ws_auto_screen_x_cull is - // on AND the function carries the GTE screen-extent reject signature; read by - // translate_instruction to widen that function's width compares. See - // func_has_screen_extent_cull(). - bool ws_auto_cull_func_ = false; - // LEFT-edge funnel bltz addresses in the current function (auto_screen_x, - // signed min/max + center±halfwidth idioms — ws_cull_detect.h). Populated - // per function alongside ws_auto_cull_func_; generate_branch_condition - // emits a listed bltz through psx_ws_cull_bltz. Cleared per function. - std::set ws_cull_bltz_pcs_; - - // True iff the function's instruction stream contains both a screen-width - // reject (`sltiu …,0x140` or `…,0x141`) and a screen-height reject - // (`sltiu …,0xE0` or `…,0xF1`) — the uniform GTE per-vertex trivial-reject. - bool func_has_screen_extent_cull(const ControlFlowGraph& cfg) const; - - // Backdrop-preload rewrite sites for the CURRENT function ([widescreen.cull] - // auto_backdrop). Maps each detected window-bound instruction address to - // {kind (WS_BD_START / WS_BD_END), window_cols (|addiu offset|, the widen - // margin scale)}. Repopulated per function by detect_backdrop_windows() - // (gated on config_.ws_auto_backdrop_preload) and consulted by - // translate_instruction; cleared every function so it never leaks. - std::map> ws_backdrop_sites_; - - // Populate ws_backdrop_sites_ from the shared psx_ws_find_backdrop_windows() - // detector over this function's instruction words; returns the window count. - int detect_backdrop_windows(const ControlFlowGraph& cfg); - // Populate ws_cull_bltz_pcs_ (LEFT-edge funnel bltz addresses) for the - // current function; no-op unless ws_auto_cull_func_ is set. - int detect_cull_bltz_sites(const ControlFlowGraph& cfg); - - // Instruction translation - std::string translate_instruction(uint32_t addr, uint32_t instr); - - // Register name mapping - static std::string reg_name(int reg_num); - - // Basic block translation - std::string translate_basic_block( - const BasicBlock& block, - const ControlFlowGraph& cfg); - - // Detect jr jump tables across all blocks of a CFG. Fills extra_labels_ - // (mid-block table targets needing inline labels) and out_edges - // (jr block -> in-range rom targets), used for alias reachability. - void scan_jr_tables( - const ControlFlowGraph& cfg, - std::map>& out_edges); - - // Control flow translation - std::string generate_branch_condition(uint32_t instr, uint32_t addr); - std::string translate_branch(const ControlFlowInstr& cf, uint32_t fall_through); - std::string translate_jump(const ControlFlowInstr& cf); - - // Arithmetic instructions - std::string translate_add(uint32_t instr); - std::string translate_addu(uint32_t instr); - std::string translate_sub(uint32_t instr); - std::string translate_subu(uint32_t instr); - std::string translate_mult(uint32_t instr); - std::string translate_multu(uint32_t instr); - std::string translate_div(uint32_t instr); - std::string translate_divu(uint32_t instr); - std::string translate_mfhi(uint32_t instr); - std::string translate_mflo(uint32_t instr); - std::string translate_mthi(uint32_t instr); - std::string translate_mtlo(uint32_t instr); - - // Logical instructions - std::string translate_and(uint32_t instr); - std::string translate_or(uint32_t instr); - std::string translate_xor(uint32_t instr); - std::string translate_nor(uint32_t instr); - - // Shift instructions - std::string translate_sll(uint32_t instr); - std::string translate_srl(uint32_t instr); - std::string translate_sra(uint32_t instr); - std::string translate_sllv(uint32_t instr); - std::string translate_srlv(uint32_t instr); - std::string translate_srav(uint32_t instr); - - // Load/store instructions - std::string translate_lw(uint32_t instr); - std::string translate_sw(uint32_t instr); - std::string translate_lb(uint32_t instr); - std::string translate_lbu(uint32_t instr); - std::string translate_lh(uint32_t instr); - std::string translate_lhu(uint32_t instr); - std::string translate_lwl(uint32_t instr); - std::string translate_lwr(uint32_t instr); - std::string translate_swl(uint32_t instr); - std::string translate_swr(uint32_t instr); - std::string translate_sb(uint32_t instr); - std::string translate_sh(uint32_t instr); - - // Immediate instructions - std::string translate_addiu(uint32_t instr); - std::string translate_andi(uint32_t instr); - std::string translate_ori(uint32_t instr); - std::string translate_xori(uint32_t instr); - std::string translate_lui(uint32_t instr); - - // Set instructions - std::string translate_slt(uint32_t instr); - std::string translate_sltu(uint32_t instr); - std::string translate_slti(uint32_t instr); - std::string translate_sltiu(uint32_t instr); - - // Helper: Extract register fields - static uint32_t get_rs(uint32_t instr) { return (instr >> 21) & 0x1F; } - static uint32_t get_rt(uint32_t instr) { return (instr >> 16) & 0x1F; } - static uint32_t get_rd(uint32_t instr) { return (instr >> 11) & 0x1F; } - static uint32_t get_shamt(uint32_t instr) { return (instr >> 6) & 0x1F; } - static uint32_t get_funct(uint32_t instr) { return instr & 0x3F; } - static int16_t get_imm16(uint32_t instr) { return (int16_t)(instr & 0xFFFF); } - static uint16_t get_imm16_u(uint32_t instr) { return (uint16_t)(instr & 0xFFFF); } -}; - -} // namespace PSXRecomp diff --git a/psxrecomp-overlay/recompiler/src/code_generator.cpp b/psxrecomp-overlay/recompiler/src/code_generator.cpp deleted file mode 100644 index f54752e..0000000 --- a/psxrecomp-overlay/recompiler/src/code_generator.cpp +++ /dev/null @@ -1,4112 +0,0 @@ -#include "code_generator.h" -#include "control_flow.h" -#include "gte_register_classification.h" -#include "../src/bios_address_model.h" -// Shared widescreen backdrop-window detector (single source of truth across the -// recompiler and the interpreter). Self-contained C header; -// included via relative path to avoid an include-dir collision (recompiler and -// runtime both ship a gte.h). -#include "../../runtime/include/ws_backdrop_detect.h" -#include "../../runtime/include/ws_cull_detect.h" -#include "../../runtime/include/psx_instr_cost.h" /* single-source CPU cycle cost (shared with interp) */ -#include -#include -#include -#include -#include -#include - -namespace PSXRecomp { - -/* Disruptor's resident geometry builders read SXY2 with MFC2 and later use an - * ordinary SW. Keep these hooks deliberately narrower than the general code - * generator: the runtime also checks the game serial, PC and raw instruction. - * A shape mismatch at either source PC is a build error, not a silent widening - * of the provenance trust boundary. */ -static bool disruptor_precision_mfc2_pc(uint32_t address) { - return address == 0x8004633Cu || address == 0x8004795Cu; -} - -static bool disruptor_precision_store_pc(uint32_t address) { - switch (address) { - case 0x8004641Cu: - case 0x800464BCu: - case 0x80046524u: - case 0x80046540u: - case 0x80046594u: - case 0x800465D8u: - return true; - default: - return false; - } -} - -static bool disruptor_precision_scratch_store_pc(uint32_t address) { - return address == 0x80047A0Cu; -} - -struct DisruptorPrecisionCopySite { - uint32_t pc; - uint32_t instruction; - uint8_t gpr; -}; - -static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyLoads[] = { - {0x80046C4Cu, 0x8D0C0004u, 12u}, - {0x80046C50u, 0x8D2D0004u, 13u}, - {0x80046C54u, 0x8D4E0004u, 14u}, - {0x80046C60u, 0x8D6F0004u, 15u}, - {0x80047A80u, 0x8CA80084u, 8u}, - {0x80047A84u, 0x8CA900A4u, 9u}, - {0x80047A88u, 0x8CAA00C4u, 10u}, - {0x80047A8Cu, 0x8CAB00BCu, 11u}, - {0x80047B40u, 0x8CA800A4u, 8u}, - {0x80047B44u, 0x8CA9008Cu, 9u}, - {0x80047B48u, 0x8CAA00ACu, 10u}, - {0x80047B4Cu, 0x8CAB00C4u, 11u}, - {0x80047C00u, 0x8CA800C4u, 8u}, - {0x80047C04u, 0x8CA900ACu, 9u}, - {0x80047C08u, 0x8CAA0094u, 10u}, - {0x80047C0Cu, 0x8CAB00B4u, 11u}, - {0x80047CC4u, 0x8CA800BCu, 8u}, - {0x80047CC8u, 0x8CA900C4u, 9u}, - {0x80047CCCu, 0x8CAA00B4u, 10u}, - {0x80047CD0u, 0x8CAB009Cu, 11u}, - {0x80047D6Cu, 0x8CA80084u, 8u}, - {0x80047D70u, 0x8CA9008Cu, 9u}, - {0x80047D74u, 0x8CAA0094u, 10u}, - {0x80047D78u, 0x8CAB009Cu, 11u}, -}; - -static const DisruptorPrecisionCopySite kDisruptorPrecisionCopyStores[] = { - {0x80046D04u, 0xAF2C0008u, 12u}, - {0x80046D08u, 0xAF2D0014u, 13u}, - {0x80046D10u, 0xAF2E002Cu, 14u}, - {0x80046D0Cu, 0xAF2F0020u, 15u}, - {0x80047A90u, 0xAF280008u, 8u}, - {0x80047A94u, 0xAF290014u, 9u}, - {0x80047A9Cu, 0xAF2A002Cu, 10u}, - {0x80047A98u, 0xAF2B0020u, 11u}, - {0x80047B50u, 0xAF280008u, 8u}, - {0x80047B54u, 0xAF290014u, 9u}, - {0x80047B5Cu, 0xAF2A002Cu, 10u}, - {0x80047B58u, 0xAF2B0020u, 11u}, - {0x80047C10u, 0xAF280008u, 8u}, - {0x80047C14u, 0xAF290014u, 9u}, - {0x80047C1Cu, 0xAF2A002Cu, 10u}, - {0x80047C18u, 0xAF2B0020u, 11u}, - {0x80047CD4u, 0xAF280008u, 8u}, - {0x80047CD8u, 0xAF290014u, 9u}, - {0x80047CE0u, 0xAF2A002Cu, 10u}, - {0x80047CDCu, 0xAF2B0020u, 11u}, - {0x80047D7Cu, 0xAF280008u, 8u}, - {0x80047D80u, 0xAF290014u, 9u}, - {0x80047D88u, 0xAF2A002Cu, 10u}, - {0x80047D84u, 0xAF2B0020u, 11u}, -}; - -template -static const DisruptorPrecisionCopySite *disruptor_precision_copy_site( - const DisruptorPrecisionCopySite (&sites)[N], uint32_t address) { - for (const DisruptorPrecisionCopySite &site : sites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor has no architectural pitch state. The project implements its - * opt-in vertical camera at a deliberately finite set of resident instruction - * sites: CPU-projected screen-Y centres, proven vertical participation tests, - * the two projectile-construction convergence points, and the common renderer - * epilogue. Every complete instruction word is pinned so a different retail - * revision fails generation instead of silently modifying unrelated code. - * - * The generated callback is appended after the vanilla instruction. At zero - * pitch it is an exact no-op; the runtime callback decodes the pinned word to - * identify the destination register or the required post-instruction action. */ -struct DisruptorVerticalCameraSite { - uint32_t pc; - uint32_t instruction; -}; - -static const DisruptorVerticalCameraSite kDisruptorVerticalCameraSites[] = { - /* CPU world projection centres (ORI rt,zero,120). */ - {0x8003AD54u, 0x34020078u}, - {0x8003ADE4u, 0x34020078u}, - {0x8003B140u, 0x34020078u}, - {0x8003B1ECu, 0x34020078u}, - {0x8003B764u, 0x34020078u}, - {0x8003B990u, 0x34020078u}, - {0x8003BDA4u, 0x34020078u}, - {0x8003C4B4u, 0x34020078u}, - {0x8003C99Cu, 0x34020078u}, - {0x8003D07Cu, 0x34020078u}, - {0x8003D398u, 0x34030078u}, - {0x8003F6B0u, 0x34020078u}, - /* Proven camera-vertical object participation pairs. */ - {0x8003B900u, 0x0205102Au}, - {0x8003B90Cu, 0x0202102Au}, - {0x8003BD14u, 0x0206102Au}, - {0x8003BD20u, 0x0202102Au}, - {0x8003CDB8u, 0x0065102Au}, - {0x8003CDC4u, 0x0062102Au}, - /* Normal-weapon and psionic construction convergence loads. */ - {0x8002E4B8u, 0x92640022u}, - {0x8002EAF0u, 0x92430020u}, - /* Common renderer epilogue: restore the neutral projection horizon. */ - {0x8004279Cu, 0x8FBF016Cu}, -}; - -static const DisruptorVerticalCameraSite *disruptor_vertical_camera_site( - uint32_t address) { - for (const DisruptorVerticalCameraSite &site : - kDisruptorVerticalCameraSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor's experimental far-rendering override is host-only: reviewed - * loads of the retail far/fade globals are substituted after the original LW, - * leaving guest RAM untouched. Additional reviewed portal, object-decision, - * and packet-boundary instructions feed read-only diagnostics through the same - * callback. The renderer entry starts a measurement span and the reviewed - * common epilogue closes it. Keep this callback independent from the - * vertical-camera dispatcher so either enhancement can be built and tested in - * isolation. */ -struct DisruptorFarRenderingSite { - uint32_t pc; - uint32_t instruction; -}; - -static const DisruptorFarRenderingSite kDisruptorFarRenderingSites[] = { - /* Portal traversal diagnostics: recursion depth, unique-room completion, - * and the three candidate-distance values before their branch tests. */ - {0x8003A0F8u, 0x93B300C0u}, - {0x8003A390u, 0xA082001Fu}, - {0x8003A92Cu, 0x00005812u}, - {0x8003A964u, 0x00681021u}, - {0x8003AA74u, 0x00AC1021u}, - /* Far-distance loads from $gp + 0x4A0. */ - {0x8003A914u, 0x8F8C04A0u}, - {0x8003B8C8u, 0x8F8304A0u}, - {0x8003BCE0u, 0x8F8204A0u}, - {0x8003C168u, 0x8F8304A0u}, - {0x8003CD64u, 0x8F8304A0u}, - /* Object distance-decision results. */ - {0x8003B8D8u, 0x0072182Au}, - {0x8003BCECu, 0x0053102Au}, - {0x8003C178u, 0x0077182Au}, - {0x8003CD70u, 0x0077182Au}, - /* Fade-start loads from $gp + 0x494. */ - {0x8003B2CCu, 0x8F820494u}, - {0x8003BA30u, 0x8F820494u}, - {0x8003BE40u, 0x8F820494u}, - {0x8003C628u, 0x8F820494u}, - {0x8003C878u, 0x8F820494u}, - {0x8003D28Cu, 0x8F820494u}, - {0x8003FB0Cu, 0x8F840494u}, - /* Billboard post-pass CLUT-row selection. */ - {0x80043184u, 0x8F820494u}, - /* Main-renderer visible-list and packet boundaries. */ - {0x800410E0u, 0x93830310u}, - {0x800410F8u, 0x8F820680u}, - {0x800411D8u, 0x8F8301D4u}, - /* Common renderer epilogue. */ - {0x8004279Cu, 0x8FBF016Cu}, -}; - -static const DisruptorFarRenderingSite *disruptor_far_rendering_site( - uint32_t address) { - for (const DisruptorFarRenderingSite &site : - kDisruptorFarRenderingSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. - * Their projected centre goes through the reviewed classic-wide X squash, but - * their retail pixel width does not. These are five final packet writes plus - * the deferred base-actor submission seam; the host callback tags only a - * completed, shape-validated packet for proportion repair. */ -struct DisruptorBillboardAspectSite { - uint32_t pc; - uint32_t instruction; - uint8_t packet_gpr; -}; - -static const DisruptorBillboardAspectSite kDisruptorBillboardAspectSites[] = { - {0x8003BB88u, 0xA6030016u, 16u}, - {0x8003BFB0u, 0xA6030016u, 16u}, - {0x8003C848u, 0xA6030016u, 16u}, - {0x8003CAD4u, 0xA6020016u, 16u}, - {0x8003D488u, 0xA2060025u, 16u}, - {0x800433A0u, 0x02A02821u, 5u}, -}; - -static const DisruptorBillboardAspectSite *disruptor_billboard_aspect_site( - uint32_t address) { - for (const DisruptorBillboardAspectSite &site : - kDisruptorBillboardAspectSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -/* Disruptor's sky backdrop routine lays SPRT tiles over the opening it has - * already clamped to the 320-pixel frame. These four seams hand the host the - * left edge, the panorama scroll and the right edge before the tile loop reads - * them, so classic widescreen can ask for the columns a wider view needs. */ -struct DisruptorBackdropTileSite { - uint32_t pc; - uint32_t instruction; -}; - -static const DisruptorBackdropTileSite kDisruptorBackdropTileSites[] = { - {0x8003B328u, 0x8FB80040u}, - {0x8003B338u, 0x00621821u}, - {0x8003B348u, 0x8FB80028u}, - {0x8003B5A8u, 0x8F8205B8u}, - {0x8003B638u, 0x8FB80040u}, -}; - -static const DisruptorBackdropTileSite kDisruptorSpriteDepthSites[] = { - {0x8003B98Cu, 0xAFA20010u}, - {0x8003BDA0u, 0xAFA20010u}, - {0x8003C4B0u, 0xAFA20010u}, - {0x8003D078u, 0xAFA20010u}, - {0x8003D1B4u, 0xA4379EA0u}, -}; - -static const DisruptorBackdropTileSite *disruptor_sprite_depth_site( - uint32_t address) { - for (const DisruptorBackdropTileSite &site : kDisruptorSpriteDepthSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -static const DisruptorBackdropTileSite kDisruptorIntroSkipSites[] = { - {0x800147B0u, 0x2A820003u}, - {0x80020A80u, 0x2A02005Eu}, - {0x80048678u, 0x30420940u}, -}; - -static const DisruptorBackdropTileSite *disruptor_intro_skip_site( - uint32_t address) { - for (const DisruptorBackdropTileSite &site : kDisruptorIntroSkipSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -static const DisruptorBackdropTileSite *disruptor_backdrop_tile_site( - uint32_t address) { - for (const DisruptorBackdropTileSite &site : kDisruptorBackdropTileSites) { - if (site.pc == address) return &site; - } - return nullptr; -} - -static bool codegen_cycle_per_insn() { - // DEFAULT ON for the faithful-timing (cycle-audit) branch: each instruction - // charges its cost at its own site, so the running cycle count is correct - // mid-block. This is REQUIRED for stateful timing — mult/div completion-stall - // (mflo/mfhi wait for muldiv_ts_done) and later GTE stalls — to absorb - // correctly, exactly like Beetle's per-instruction timestamp. Block-up-front - // charging loses the in-between timing and can only charge full latency. - // Set PSX_CODEGEN_CYCLE_PER_INSN=0 to force the old block-up-front mode. - const char* e = std::getenv("PSX_CODEGEN_CYCLE_PER_INSN"); - if (e != nullptr && e[0] != '\0') return e[0] != '0'; - return true; -} - -/* The BIOS address model of the profile this game is built against - * (main_psx.cpp resolves [recompiler] bios_config, default the SCPH1001 - * profile, and sets it before generation). A game artifact depends on a - * BIOS property here — jump tables inside relocated BIOS code windows — - * so the window comes from the profile, never from a constant. */ -static const ::PSXRecompV4::BiosAddressModel* g_game_bios_model = nullptr; - -void CodeGenerator::set_bios_address_model(const PSXRecompV4::BiosAddressModel* m) { - g_game_bios_model = m; -} - -/* - * Translate a runtime RAM address to an address that exe_.read_word() can - * resolve. - * - * Game EXEs are read directly at their load address. The BIOS copies code - * into RAM at boot (SCPH1001: the shell to 0x80030000); jump tables within - * such code store RAM-space target addresses, but at recompile time we can - * only read the ROM. This helper maps: - * - * active EXE load range -> active EXE load range - * BIOS-copy RAM windows -> ROM kseg1 (per the BIOS profile's model) - * - * Other addresses pass through unchanged. - */ -static uint32_t ram_to_rom(uint32_t addr, const PS1Executable& exe) { - uint32_t phys = addr & 0x1FFFFFFFu; - - /* - * Game EXEs can legitimately occupy the BIOS-copy RAM windows (the - * shell window especially), so prefer the active EXE's load range - * before applying any BIOS remap. - */ - uint32_t exe_phys = exe.load_address() & 0x1FFFFFFFu; - uint32_t exe_size = exe.code_size(); - if (exe_size != 0 && phys >= exe_phys && phys < exe_phys + exe_size) { - return exe.load_address() + (phys - exe_phys); - } - - if (!g_game_bios_model) { - throw std::runtime_error( - "CodeGenerator: no BIOS address model set " - "(CodeGenerator::set_bios_address_model must run before generation)"); - } - return g_game_bios_model->ram_alias_to_rom(addr); -} - -CodeGenerator::CodeGenerator(const PS1Executable& exe, const CodeGenConfig& config) - : exe_(exe), config_(config) { - // RECURSION_BUG.md §25 — continuation-passing call/return. Gen-time opt-in - // via PSX_CPS so legacy codegen stays byte-identical when unset. - // CPS is the DEFAULT (RECURSION_BUG.md §25). Opt out (legacy) with PSX_CPS=0. - { const char* e = std::getenv("PSX_CPS"); cps_enabled_ = (e == nullptr || e[0] != '0'); } -} - -uint32_t CodeGenerator::partial_block_cycle_count(uint32_t addr, - const ControlFlowGraph& cfg) const { - if (cfg.blocks.count(addr)) { - return 0; - } - - for (const auto& [block_addr, block] : cfg.blocks) { - (void)block_addr; - if (addr <= block.start_addr || addr > block.end_addr) { - continue; - } - if (((addr - block.start_addr) & 3u) != 0) { - continue; - } - return ((block.end_addr - addr) / 4u) + 1u; - } - - return 0; -} - -std::string CodeGenerator::emit_mid_block_cycle_charge(uint32_t addr, - const ControlFlowGraph& cfg, - const std::string& indent) const { - if (codegen_cycle_per_insn()) { - return ""; - } - - uint32_t cycles = partial_block_cycle_count(addr, cfg); - if (cycles == 0) { - return ""; - } - - std::stringstream ss; - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_advance_cycles({}u);\n", cycles); - ss << "#endif\n"; - return ss.str(); -} - -std::string CodeGenerator::emit_interrupt_check(uint32_t resume_pc, - const std::string& indent) const { - return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + - "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc); -} - -std::string CodeGenerator::emit_interrupt_check_expr(const std::string& resume_pc_expr, - const std::string& indent) const { - return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + - "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); -} - -std::string CodeGenerator::reg_name(int reg_num) { - if (reg_num >= 0 && reg_num < 32) { - return fmt::format("cpu->gpr[{}]", reg_num); - } - return fmt::format("cpu->gpr[{}]", reg_num); -} - -std::string CodeGenerator::translate_addiu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t imm = get_imm16(instr); - - // Optimize: loading immediate into register (from $zero) - if (rs == 0) { - return fmt::format("{} = {};", reg_name(rt), imm); - } - - // Optimize: don't assign to $zero - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} + {};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_lui(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = 0x{:04X} << 16; /* 0x{:08X} */", - reg_name(rt), imm, ((uint32_t)imm) << 16); -} - -std::string CodeGenerator::translate_lw(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; /* GPR_DEP rs (load: dest rt armed via LDWhich) */ - - if (config_.optimize_zero_reg && rt == 0) { - /* load to $zero: no GPR write, but the data access + R3000A interlock still run */ - return fmt::format("(void)psx_cyc_load_word(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = psx_cyc_load_word(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_sw(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (offset == 0) { - return fmt::format("psx_store_cycle_barrier(); cpu->write_word({}, {});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_store_cycle_barrier(); cpu->write_word({} + {}, {});", - reg_name(rs), offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_or(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: move instruction (or $rd, $rs, $zero) - if (rt == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rs)); - } - if (rs == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = {} | {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_and(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} & {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sll(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - uint32_t shamt = get_shamt(instr); - - // nop instruction (sll $zero, $zero, 0) - if (rd == 0 && rt == 0 && shamt == 0) { - return "/* nop */"; - } - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - if (shamt == 0) { - return fmt::format("{} = {}; /* no shift */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = {} << {};", reg_name(rd), reg_name(rt), shamt); -} - -std::string CodeGenerator::translate_srl(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - uint32_t shamt = get_shamt(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (uint32_t){} >> {};", reg_name(rd), reg_name(rt), shamt); -} - -std::string CodeGenerator::translate_slt(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ((int32_t){} < (int32_t){}) ? 1 : 0;", - reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sltu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ({} < {}) ? 1 : 0;", - reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_addu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: move instruction (addu $rd, $rs, $zero or addu $rd, $zero, $rt) - if (rt == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rs)); - } - if (rs == 0) { - return fmt::format("{} = {}; /* move */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = {} + {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_add(uint32_t instr) { - // add and addu are functionally identical in practice (trap on overflow not used) - return translate_addu(instr); -} - -std::string CodeGenerator::translate_subu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} - {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sub(uint32_t instr) { - // sub and subu are functionally identical in practice (trap on overflow not used) - return translate_subu(instr); -} - -std::string CodeGenerator::translate_lb(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = (int32_t)(int8_t)psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lbu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lh(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_half(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = (int32_t)(int16_t)psx_cyc_load_half(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lhu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - uint32_t mask = 1u << rs; - - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_cyc_load_half(cpu, {}, 0, 0x{:X}u);", addr, mask); - } - return fmt::format("{} = psx_cyc_load_half(cpu, {}, {}, 0x{:X}u);", - reg_name(rt), addr, rt, mask); -} - -std::string CodeGenerator::translate_lwl(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: load to $zero */"; - } - - // LWL: Load Word Left - merges high bytes from the aligned word into rt. - // psx_lwl runs the full R3000A load interlock on the aligned address (GPR_DEP rs, - // arm LDWhich=rt) and returns the merged value. - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int32_t)offset); - uint32_t mask = 1u << rs; - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_lwl(cpu, {}, {}, 0, 0x{:X}u);", addr, reg_name(rt), mask); - } - return fmt::format("{} = psx_lwl(cpu, {}, {}, {}, 0x{:X}u);", - reg_name(rt), addr, reg_name(rt), rt, mask); -} - -std::string CodeGenerator::translate_lwr(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: load to $zero */"; - } - - // LWR: Load Word Right - merges low bytes from the aligned word into rt. - std::string addr = (offset == 0) ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int32_t)offset); - uint32_t mask = 1u << rs; - if (config_.optimize_zero_reg && rt == 0) { - return fmt::format("(void)psx_lwr(cpu, {}, {}, 0, 0x{:X}u);", addr, reg_name(rt), mask); - } - return fmt::format("{} = psx_lwr(cpu, {}, {}, {}, 0x{:X}u);", - reg_name(rt), addr, reg_name(rt), rt, mask); -} - -std::string CodeGenerator::translate_swl(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - // SWL: Store Word Left - stores rt into memory using addr, MSB side - // Generate a helper call: psx_swl(cpu, addr, cpu->rt) - if (offset == 0) { - return fmt::format("psx_swl(cpu, {}, {});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_swl(cpu, {} + {}, {});", - reg_name(rs), (int32_t)offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_swr(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - // SWR: Store Word Right - stores rt into memory using addr, LSB side - // Generate a helper call: psx_swr(cpu, addr, cpu->rt) - if (offset == 0) { - return fmt::format("psx_swr(cpu, {}, {});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_swr(cpu, {} + {}, {});", - reg_name(rs), (int32_t)offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_sb(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (offset == 0) { - return fmt::format("psx_store_cycle_barrier(); cpu->write_byte({}, (uint8_t){});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_store_cycle_barrier(); cpu->write_byte({} + {}, (uint8_t){});", - reg_name(rs), offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_sh(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - - if (offset == 0) { - return fmt::format("psx_store_cycle_barrier(); cpu->write_half({}, (uint16_t){});", reg_name(rs), reg_name(rt)); - } else { - return fmt::format("psx_store_cycle_barrier(); cpu->write_half({} + {}, (uint16_t){});", - reg_name(rs), offset, reg_name(rt)); - } -} - -std::string CodeGenerator::translate_sra(uint32_t instr) { - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - uint32_t shamt = get_shamt(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (int32_t){} >> {};", reg_name(rd), reg_name(rt), shamt); -} - -std::string CodeGenerator::translate_andi(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} & 0x{:04X};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_ori(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: loading immediate into register (from $zero) - if (rs == 0) { - return fmt::format("{} = 0x{:04X};", reg_name(rt), imm); - } - - return fmt::format("{} = {} | 0x{:04X};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_xori(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} ^ 0x{:04X};", reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_xor(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} ^ {};", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_nor(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - // Optimize: not instruction (nor $rd, $rs, $zero) - if (rt == 0) { - return fmt::format("{} = ~{}; /* not */", reg_name(rd), reg_name(rs)); - } - if (rs == 0) { - return fmt::format("{} = ~{}; /* not */", reg_name(rd), reg_name(rt)); - } - - return fmt::format("{} = ~({} | {});", reg_name(rd), reg_name(rs), reg_name(rt)); -} - -std::string CodeGenerator::translate_sllv(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = {} << ({} & 0x1F);", reg_name(rd), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_srlv(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (uint32_t){} >> ({} & 0x1F);", reg_name(rd), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_srav(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - - if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = (int32_t){} >> ({} & 0x1F);", reg_name(rd), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_slti(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t imm = get_imm16(instr); - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ((int32_t){} < {}) ? 1 : 0;", - reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_sltiu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t imm = get_imm16(instr); // Sign-extended for comparison - - if (config_.optimize_zero_reg && rt == 0) { - return "/* nop: write to $zero */"; - } - - return fmt::format("{} = ({} < (uint32_t){}) ? 1 : 0;", - reg_name(rt), reg_name(rs), imm); -} - -std::string CodeGenerator::translate_mult(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - return fmt::format("{{ int64_t result = (int64_t)(int32_t){} * (int64_t)(int32_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, psx_mult_latency_s({}));\n#endif", - reg_name(rs), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_multu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - return fmt::format("{{ uint64_t result = (uint64_t){} * (uint64_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, psx_mult_latency_u({}));\n#endif", - reg_name(rs), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_div(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - // MIPS division: quotient in LO, remainder in HI - // Handle division by zero (MIPS behavior: result is unpredictable but doesn't trap) - return fmt::format("if ({} != 0) {{ cpu->lo = (uint32_t)((int32_t){} / (int32_t){}); cpu->hi = (uint32_t)((int32_t){} % (int32_t){}); }} else {{ cpu->lo = ((int32_t){} >= 0) ? 0xFFFFFFFF : 1; cpu->hi = (uint32_t){}; }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, 37u);\n#endif", - reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rs)); -} - -std::string CodeGenerator::translate_divu(uint32_t instr) { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - // MIPS division: quotient in LO, remainder in HI - // Handle division by zero (MIPS behavior: result is unpredictable but doesn't trap) - return fmt::format("if ({} != 0) {{ cpu->lo = {} / {}; cpu->hi = {} % {}; }} else {{ cpu->lo = 0xFFFFFFFF; cpu->hi = {}; }}" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, 37u);\n#endif", - reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs)); -} - -std::string CodeGenerator::translate_mfhi(uint32_t instr) { - uint32_t rd = get_rd(instr); - - // Stall until the mult/div completion deadline (faithful R3000A) — happens - // even when rd==$zero (the read still stalls on HW). - const char* stall = "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_stall(cpu);\n#endif"; - if (config_.optimize_zero_reg && rd == 0) { - return fmt::format("/* nop: write to $zero */{}", stall); - } - - return fmt::format("{} = cpu->hi;{}", reg_name(rd), stall); -} - -std::string CodeGenerator::translate_mflo(uint32_t instr) { - uint32_t rd = get_rd(instr); - - const char* stall = "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_stall(cpu);\n#endif"; - if (config_.optimize_zero_reg && rd == 0) { - return fmt::format("/* nop: write to $zero */{}", stall); - } - - return fmt::format("{} = cpu->lo;{}", reg_name(rd), stall); -} - -std::string CodeGenerator::translate_mthi(uint32_t instr) { - uint32_t rs = get_rs(instr); - - return fmt::format("cpu->hi = {};", reg_name(rs)); -} - -std::string CodeGenerator::translate_mtlo(uint32_t instr) { - uint32_t rs = get_rs(instr); - - return fmt::format("cpu->lo = {};", reg_name(rs)); -} - -std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t addr) { - uint32_t opcode = (instr >> 26) & 0x3F; - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - - // Explicit manual-screen lower-edge branches. Disruptor-class portal and - // wall renderers project in CPU code and use zero-bound branches rather - // than the usual SLTI/BLTZ funnel. Move that zero edge to -x_margin while - // retaining the original branch sense. At margin 0 these expressions are - // exactly the vanilla predicates. - if (config_.ws_cull_lower_branch_sites.count(addr)) { - if (opcode == 0x01) { - const uint32_t regimm_op = (instr >> 16) & 0x1Fu; - if (regimm_op == 0x00u) - return fmt::format("(int32_t){} < -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - if (regimm_op == 0x01u) - return fmt::format("(int32_t){} >= -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - } - if (opcode == 0x06u || opcode == 0x16u) - return fmt::format("(int32_t){} <= -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - if (opcode == 0x07u || opcode == 0x17u) - return fmt::format("(int32_t){} > -psx_ws_x_margin()" - " /* ws manual lower edge */", reg_name(rs)); - if (!config_.overlay_mode) { - fmt::print(stderr, - "ERROR: [widescreen.cull] lower_branch site 0x{:08X} " - "is not bltz/bgez/blez/bgtz (0x{:08X})\n", - addr, instr); - std::exit(1); - } - } - - // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just - // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, - // 1 = bgez) and the link register is written iff (rt & 0x1E) == 0x10 — - // Beetle cpu.cpp op_BCOND: result = (int32)(rs ^ (rt<<31)) < 0, - // link = ((rt & 0x1E) == 0x10). Undefined rt values appear when discovery - // sweeps data-as-code into a function; emitting the hardware decode keeps - // the regen alive AND matches the oracle if the word is ever executed. - if (opcode == 0x01) { - uint32_t regimm_op = (instr >> 16) & 0x1F; - if ((regimm_op & 0x01u) == 0x00u) { // bltz family (incl. bltzal + undefined mirrors) - // LEFT-edge funnel bltz: reject only past the revealed margin. - // Identity at 4:3 (margin 0). Two sources: (a) auto_screen_x's - // detect_cull_bltz_sites classification (ws_cull_bltz_pcs_), and - // (b) explicit [widescreen.cull] bltz_sites — the left-edge - // counterpart to slti_sites, for X-only funnels auto_screen_x can't - // qualify (whose bltz would otherwise never be widened). - if (regimm_op == 0x00 && - (ws_cull_bltz_pcs_.count(addr) || config_.ws_cull_bltz_sites.count(addr))) - return fmt::format("psx_ws_cull_bltz({}) /* ws cull (left edge) */", - reg_name(rs)); - return fmt::format("(int32_t){} < 0", reg_name(rs)); - } else { // bgez family (incl. bgezal + undefined mirrors) - return fmt::format("(int32_t){} >= 0", reg_name(rs)); - } - } - - // Standard branches - switch (opcode) { - case 0x04: // beq - case 0x14: // beql - return fmt::format("{} == {}", reg_name(rs), reg_name(rt)); - - case 0x05: // bne - case 0x15: // bnel - return fmt::format("{} != {}", reg_name(rs), reg_name(rt)); - - case 0x06: // blez - case 0x16: // blezl - return fmt::format("(int32_t){} <= 0", reg_name(rs)); - - case 0x07: // bgtz - case 0x17: // bgtzl - return fmt::format("(int32_t){} > 0", reg_name(rs)); - } - - return "0 /* unknown branch condition: defaults to not-taken */"; -} - -std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { - uint32_t opcode = (instr >> 26) & 0x3F; - uint32_t funct = instr & 0x3F; - - // Instruction comment (if enabled) - std::string comment; - if (config_.emit_comments) { - comment = fmt::format(" /* 0x{:08X}: 0x{:08X} */", addr, instr); - } - - std::string code; - - const DisruptorVerticalCameraSite *vertical_camera_site = - disruptor_vertical_camera_site(addr); - if (vertical_camera_site && - instr != vertical_camera_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor vertical-camera site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, vertical_camera_site->instruction, instr)); - } - vertical_camera_site = nullptr; - } - - const DisruptorFarRenderingSite *far_rendering_site = - disruptor_far_rendering_site(addr); - if (far_rendering_site && instr != far_rendering_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor far-rendering site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, far_rendering_site->instruction, instr)); - } - far_rendering_site = nullptr; - } - - const DisruptorBillboardAspectSite *billboard_aspect_site = - disruptor_billboard_aspect_site(addr); - if (billboard_aspect_site && - instr != billboard_aspect_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor billboard-aspect site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, billboard_aspect_site->instruction, instr)); - } - billboard_aspect_site = nullptr; - } - - const DisruptorBackdropTileSite *backdrop_tile_site = - disruptor_backdrop_tile_site(addr); - if (backdrop_tile_site && instr != backdrop_tile_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor backdrop-tile site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, backdrop_tile_site->instruction, instr)); - } - backdrop_tile_site = nullptr; - } - const DisruptorBackdropTileSite *sprite_depth_site = - disruptor_sprite_depth_site(addr); - if (sprite_depth_site && instr != sprite_depth_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor sprite-depth site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, sprite_depth_site->instruction, instr)); - } - sprite_depth_site = nullptr; - } - const DisruptorBackdropTileSite *intro_skip_site = - disruptor_intro_skip_site(addr); - if (intro_skip_site && instr != intro_skip_site->instruction) { - if (!config_.overlay_mode) { - throw std::runtime_error(fmt::format( - "Disruptor intro-skip site 0x{:08X} expected word " - "0x{:08X}, found 0x{:08X}", - addr, intro_skip_site->instruction, instr)); - } - intro_skip_site = nullptr; - } - - // Full-word-guarded terrain-frustum half-angle constants. Scaling the - // tangent is the geometric counterpart of widening the horizontal - // projection; adding screen pixels directly to 12-bit angle units is not. - for (const auto& site : config_.ws_cull_angle_sites) { - if ((site.address & 0x1FFFFFFFu) != - (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: cull angle expected 0x{:08X} at 0x{:08X}, " - "found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - if ((opcode != 0x08u && opcode != 0x09u) || - get_rs(instr) != 0u) { - fmt::print(stderr, - "ERROR: cull angle site 0x{:08X} is not " - "ADDI/ADDIU rt,zero,imm (0x{:08X})\n", - addr, instr); - std::exit(1); - } - const uint32_t dst = get_rt(instr); - const int32_t vanilla = (int16_t)(instr & 0xFFFFu); - return fmt::format( - "{} = psx_ws_angle_widen({}u);" - " /* aspect-scaled terrain frustum half-angle */{}", - reg_name(dst), (uint32_t)vanilla, comment); - } - - // Full-word-guarded wrapping-yaw half-FOV offsets. Disruptor, for example, - // seeds portal traversal with yaw +/-32 in an eight-bit turn. Widen the - // signed offset geometrically while leaving the game's base yaw and wrap - // behavior untouched. - for (const auto& site : config_.ws_cull_yaw_cone_sites) { - if ((site.address & 0x1FFFFFFFu) != - (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: yaw cone expected 0x{:08X} at 0x{:08X}, " - "found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - if ((opcode != 0x08u && opcode != 0x09u) || - get_rs(instr) == 0u || site.full_turn < 16u) { - fmt::print(stderr, - "ERROR: yaw cone site 0x{:08X} is not a valid " - "ADDI/ADDIU rt,rs,offset (0x{:08X})\n", - addr, instr); - std::exit(1); - } - const uint32_t dst = get_rt(instr); - const uint32_t src = get_rs(instr); - const int32_t vanilla = (int16_t)(instr & 0xFFFFu); - return fmt::format( - "{} = {} + (uint32_t)psx_ws_yaw_cone_offset({}, {}u);" - " /* aspect-scaled wrapping yaw half-FOV */{}", - reg_name(dst), reg_name(src), vanilla, site.full_turn, comment); - } - - // Symmetric camera-horizontal side-plane rejects. Scaling the horizontal - // operand is the exact counterpart of widening the projection; vertical - // tests in the same functions remain completely vanilla. - for (const auto& site : config_.ws_cull_horizontal_slt_sites) { - if ((site.address & 0x1FFFFFFFu) != - (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: horizontal SLT expected 0x{:08X} at " - "0x{:08X}, found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - if (opcode != 0u || funct != 0x2Au || - ((instr >> 6) & 31u) != 0u) { - fmt::print(stderr, - "ERROR: horizontal SLT site 0x{:08X} is not signed " - "register-register SLT (0x{:08X})\n", - addr, instr); - std::exit(1); - } - return fmt::format( - "{} = psx_ws_horizontal_slt({}, {});" - " /* aspect-scaled camera-horizontal side plane */{}", - reg_name(get_rd(instr)), reg_name(get_rs(instr)), - reg_name(get_rt(instr)), comment); - } - - for (const auto& site : config_.ws_signed_x_bound_sites) { - if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: signed_x_bound expected 0x{:08X} at 0x{:08X}, found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - const int32_t vanilla = (int32_t)((instr & 0xFFFFu) << 16); - return fmt::format("{} = (uint32_t)psx_ws_player_x_bound((int32_t)0x{:08X});" - " /* typed native-wide signed X bound */{}", - reg_name(get_rt(instr)), (uint32_t)vanilla, comment); - } - - // Full-word-guarded, camera-horizontal model-participation cones. The - // runtime helper preserves the original compare at 4:3 and every vanilla - // keep verdict, adding only the aspect-derived horizontal fringe. - for (const auto& site : config_.ws_aspect_cone.sites) { - if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: aspect cone expected 0x{:08X} at 0x{:08X}, " - "found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - const bool is_slti = opcode == 0x0A; - const bool is_slt = - opcode == 0x00 && (instr & 0x3Fu) == 0x2Au && - ((instr >> 6) & 0x1Fu) == 0u; - if (!is_slti && !is_slt) { - fmt::print(stderr, - "ERROR: aspect cone site 0x{:08X} is not signed " - "SLTI/SLT " - "(0x{:08X})\n", addr, instr); - std::exit(1); - } - const uint32_t dst = is_slti ? get_rt(instr) : get_rd(instr); - const std::string vanilla = is_slti - ? fmt::format("((int32_t){} < (int32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), - (int32_t)(int16_t)(instr & 0xFFFFu)) - : fmt::format("((int32_t){} < (int32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), reg_name(get_rt(instr))); - const auto effective_reg = [](uint32_t site_reg, - uint32_t default_reg) { - return site_reg == 0xFFFFFFFFu ? default_reg : site_reg; - }; - return fmt::format( - "{} = psx_ws_aspect_cone_result(0x{:08X}u, ({}), {}, " - "(int32_t)(int16_t){}, (int32_t)(int16_t){}, " - "(int32_t)(int16_t){});" - " /* aspect-aware horizontal model participation */{}", - reg_name(dst), addr, vanilla, - reg_name(effective_reg( - site.object_reg, config_.ws_aspect_cone.object_reg)), - reg_name(effective_reg( - site.x_reg, config_.ws_aspect_cone.x_reg)), - reg_name(effective_reg( - site.z_reg, config_.ws_aspect_cone.z_reg)), - reg_name(effective_reg( - site.y_reg, config_.ws_aspect_cone.y_reg)), comment); - } - - // Full-word-guarded object/model participation compares. At 4:3 the - // helper returns the comparison's vanilla value; when widescreen reveals - // extra world it returns the configured keep verdict. Overlay variants - // with another instruction at the same VA are deliberately untouched. - for (const auto& site : config_.ws_cull_keep_sites) { - if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; - if (instr != site.expected) { - if (config_.overlay_mode) continue; - fmt::print(stderr, - "ERROR: cull keep expected 0x{:08X} at 0x{:08X}, found 0x{:08X}\n", - site.expected, addr, instr); - std::exit(1); - } - uint32_t dst = 0; - std::string vanilla; - if (opcode == 0x0A) { // SLTI - dst = get_rt(instr); - vanilla = fmt::format("((int32_t){} < {}) ? 1u : 0u", - reg_name(get_rs(instr)), (int)get_imm16(instr)); - } else if (opcode == 0x0B) { // SLTIU (sign-extended immediate) - dst = get_rt(instr); - vanilla = fmt::format("({} < (uint32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), (int)get_imm16(instr)); - } else if (opcode == 0x00 && funct == 0x2A) { // SLT - dst = get_rd(instr); - vanilla = fmt::format("((int32_t){} < (int32_t){}) ? 1u : 0u", - reg_name(get_rs(instr)), reg_name(get_rt(instr))); - } else if (opcode == 0x00 && funct == 0x2B) { // SLTU - dst = get_rd(instr); - vanilla = fmt::format("({} < {}) ? 1u : 0u", - reg_name(get_rs(instr)), reg_name(get_rt(instr))); - } else { - fmt::print(stderr, - "ERROR: cull keep site 0x{:08X} is not a compare (0x{:08X})\n", - addr, instr); - std::exit(1); - } - return fmt::format( - "{} = psx_ws_cull_keep_result(({}), {}u);" - " /* ws maximal object/model participation */{}", - reg_name(dst), vanilla, site.result, comment); - } - - // Widescreen automatic far-backdrop column PRELOAD ([widescreen.cull] - // auto_backdrop). At a detected window's START/END finalize, route the value - // through psx_ws_backdrop_value(orig, is_end): identity at 4:3, but in - // native-wide it forces START->0 / END->0x100, so the generator's own low/ - // high clamps preload the whole finite tile row (filling the revealed 16:9 - // margin). ws_backdrop_sites_ is populated per function by - // detect_backdrop_windows() only when config_.ws_auto_backdrop_preload is - // set — inert and byte-identical when the feature is off. The instruction - // shape is verified; a mismatch is a loud build error (detector/codegen drift). - if (!ws_backdrop_sites_.empty()) { - auto bd = ws_backdrop_sites_.find(addr); - if (bd != ws_backdrop_sites_.end()) { - int is_end = (bd->second.first == WS_BD_END) ? 1 : 0; - int wcols = bd->second.second; - const char* tag = is_end ? "END" : "START"; - if (opcode == 0x00 && (funct == 0x21 || funct == 0x25)) { // addu/or (move) - uint32_t rd = get_rd(instr), rs = get_rs(instr), rt = get_rt(instr); - uint32_t src = (rt == 0) ? rs : rt; - return fmt::format("{} = psx_ws_backdrop_value({}, {}, {});" - " /* ws backdrop preload {} */{}", - reg_name(rd), reg_name(src), is_end, wcols, tag, comment); - } - if (opcode == 0x09 || opcode == 0x08) { // addiu / addi - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - std::string orig = (rs == 0) - ? fmt::format("(uint32_t){}", (int)imm) - : fmt::format("(uint32_t)({} + {})", reg_name(rs), (int)imm); - return fmt::format("{} = psx_ws_backdrop_value({}, {}, {});" - " /* ws backdrop preload {} */{}", - reg_name(rt), orig, is_end, wcols, tag, comment); - } - fmt::print(stderr, "ERROR: [widescreen.cull] auto_backdrop site 0x{:08X} " - "is not addu/or/addiu (opcode 0x{:02X} funct 0x{:02X})\n", - addr, opcode, funct); - std::exit(1); - } - } - - // Widescreen cull-margin widening ([widescreen.cull] sites). Emit the - // window immediate with a runtime margin term psx_ws_x_margin() — 0 at - // 4:3/boot/menu/FMV, ~half-the-extra-width when stretching — so the world- - // space draw cull tracks the aspect and one build serves both. Each site's - // instruction type is verified; a mismatch is a loud build error in main-EXE - // mode (a bad address would silently mis-emit otherwise). In OVERLAY mode the - // same address holds different code across scene variants, so a mismatch is - // expected — apply the transform only where the bytes match, else fall - // through to the vanilla translation (see CodeGenConfig::overlay_mode). - // Manual screen-window bound materialization. These are deliberately - // exact-site and instruction-shape guarded: a generic 320 constant can be - // a centre-relative divider, texture width, or packed span, and those - // meanings are not interchangeable. - const bool ws_lower_value = config_.ws_cull_lower_value_sites.count(addr); - const bool ws_upper_value = config_.ws_cull_upper_value_sites.count(addr); - if (ws_lower_value || ws_upper_value) { - const char* helper = ws_lower_value - ? "psx_ws_cull_lower_value" : "psx_ws_cull_upper_value"; - const char* tag = ws_lower_value ? "lower" : "upper"; - if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && - get_rs(instr) == 0u) { - const uint32_t dst = get_rt(instr); - const int32_t vanilla = opcode == 0x0Du - ? (int32_t)get_imm16_u(instr) : (int32_t)get_imm16(instr); - return fmt::format( - "{} = (uint32_t){}({});" - " /* ws manual {} bound value */{}", - reg_name(dst), helper, vanilla, tag, comment); - } - if (ws_lower_value && opcode == 0x00u && - (funct == 0x21u || funct == 0x25u) && - get_rs(instr) == 0u && get_rt(instr) == 0u) { - return fmt::format( - "{} = (uint32_t)psx_ws_cull_lower_value(0);" - " /* ws manual lower clamp */{}", - reg_name(get_rd(instr)), comment); - } - if (ws_lower_value && opcode == 0x2Bu && get_rt(instr) == 0u) { - const uint32_t base = get_rs(instr); - const int16_t offset = get_imm16(instr); - const std::string store_addr = offset == 0 - ? reg_name(base) - : fmt::format("{} + {}", reg_name(base), (int)offset); - return fmt::format( - "g_debug_last_store_pc = 0x{:08X}u; " - "psx_store_cycle_barrier(); cpu->write_word({}, " - "(uint32_t)psx_ws_cull_lower_value(0));" - " /* ws manual lower clamp store */{}", - addr, store_addr, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, - "ERROR: [widescreen.cull] {}_value site 0x{:08X} " - "is not a supported constant/clamp form (0x{:08X})\n", - tag, addr, instr); - std::exit(1); - } - } - if (config_.ws_cull_span_value_sites.count(addr)) { - if ((opcode == 0x08u || opcode == 0x09u || opcode == 0x0Du) && - get_rs(instr) == 0u) { - const uint32_t dst = get_rt(instr); - const uint32_t vanilla = opcode == 0x0Du - ? (uint32_t)get_imm16_u(instr) - : (uint32_t)(int32_t)get_imm16(instr); - return fmt::format( - "{} = psx_ws_cull_span_value(0x{:08X}u);" - " /* ws manual packed [left,right] span */{}", - reg_name(dst), vanilla, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, - "ERROR: [widescreen.cull] span_value site 0x{:08X} " - "is not ADDI/ADDIU/ORI rt,zero,imm (0x{:08X})\n", - addr, instr); - std::exit(1); - } - } - - if (config_.ws_cull_bias_sites.count(addr)) { - if (opcode == 0x08 || opcode == 0x09) { // addi / addiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - const std::string margin = - config_.ws_cull_activation_guard_pixels > 0 - ? fmt::format( - "(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + {} : 0)", - config_.ws_cull_activation_guard_pixels) - : "psx_ws_x_margin()"; - return fmt::format("{} = {} + ((int32_t){} + {});{}", - reg_name(rt), reg_name(rs), imm, margin, - comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] bias site 0x{:08X} is not " - "addi/addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - if (config_.ws_cull_vxrange_sites.count(addr)) { - if (opcode == 0x0B) { // sltiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = psx_ws_cull_vxrange({}, {});" - " /* ws cull masked-u16 X window */{}", - reg_name(rt), reg_name(rs), (unsigned)imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] vxrange site 0x{:08X} is not " - "sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - if (config_.ws_cull_depth_sites.count(addr)) { - if (opcode == 0x0A) { // slti - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = ((int32_t){} < psx_ws_depth_bound({})) ? 1 : 0;" - " /* ws cull depth */{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - } - if (opcode == 0x0B) { // sltiu: MIPS sign-extends its immediate - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = ((uint32_t){} < (uint32_t)psx_ws_depth_bound({})) ? 1 : 0;" - " /* ws cull depth unsigned */{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] depth site 0x{:08X} is not " - "slti/sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - // Side frustum-plane normal-X load: `lw rt,off(rs)` reads the nx of a - // plane sign-tested per corner (dot = nx*px + nz*pz). The helper scales - // nx by the inverse aspect factor (4*den)/(3*num) while revealed, which - // widens the cone by exactly atan((3*num)/(4*den)*tan(theta)); identity - // at 4:3. - if (config_.ws_cull_plane_nx_sites.count(addr)) { - if (opcode == 0x23) { // lw - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string laddr = (offset == 0) - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int)offset); - uint32_t mask = 1u << rs; - return fmt::format("{} = (uint32_t)psx_ws_plane_nx((int32_t)psx_cyc_load_word(cpu, {}, {}, 0x{:X}u));" - " /* ws cull plane nx load */{}", - reg_name(rt), laddr, rt, mask, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] plane_nx site 0x{:08X} is not " - "lw (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - - // Per-primitive X-reject bound load: `lw rt,off(rs)` reads the bound a - // masked-u16 screen X is sltu-compared against. While the margins are - // revealed the helper yields INT32_MAX (reject disabled; the wide-surface - // scissor clips the overflow); identity at 4:3. - if (config_.ws_cull_xclip_load_sites.count(addr)) { - if (opcode == 0x23) { // lw - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string laddr = (offset == 0) - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), (int)offset); - uint32_t mask = 1u << rs; - return fmt::format("{} = psx_ws_xclip_bound(psx_cyc_load_word(cpu, {}, {}, 0x{:X}u));" - " /* ws cull xclip bound load */{}", - reg_name(rt), laddr, rt, mask, comment); - } - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] xclip_load site 0x{:08X} is not " - "lw (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant at the same address: leave nonmatching code unchanged. - } - if (config_.ws_cull_range_sites.count(addr)) { - if (opcode == 0x0B) { // sltiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - const std::string margin = - config_.ws_cull_activation_guard_pixels > 0 - ? fmt::format( - "(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + {} : 0)", - config_.ws_cull_activation_guard_pixels) - : "psx_ws_x_margin()"; - return fmt::format( - "{} = ({} < (uint32_t)((int32_t){} + 2*{})) ? 1 : 0;{}", - reg_name(rt), reg_name(rs), imm, margin, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] range site 0x{:08X} is not " - "sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - if (config_.ws_cull_a1_sites.count(addr)) { - if (instr == 0x00000000u) { // must be a nop we can safely repurpose - // a1 = $5: widen the caller-supplied margin (param-margin classifiers). - return fmt::format("cpu->gpr[5] = cpu->gpr[5] + psx_ws_x_margin();" - " /* ws cull a1 bias */{}", comment); - } else if (opcode == 0x00 && get_funct(instr) == 0x21) { - // move rD,a1 (addu rD,a1,zero or addu rD,zero,a1): some Capcom - // classifiers copy the caller-supplied horizontal margin - // immediately, leaving no delay-slot nop to repurpose. Widen the - // copied value; a1 itself and the vertical margin stay unchanged. - uint32_t rs = get_rs(instr), rt = get_rt(instr), rd = get_rd(instr); - if ((rs == 5 && rt == 0) || (rs == 0 && rt == 5)) - return fmt::format("{} = cpu->gpr[5] + psx_ws_x_margin();" - " /* ws cull copied a1 bias */{}", - reg_name(rd), comment); - if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] a1 site 0x{:08X} " - "is addu but not move rD,a1 (0x{:08X})\n", addr, instr); - std::exit(1); - } - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] a1 site 0x{:08X} is not a " - "nop or move rD,a1 (0x{:08X})\n", addr, instr); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - if (config_.ws_cull_screen_x_sites.count(addr)) { - if (opcode == 0x0B) { // sltiu rt,rs,imm - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = psx_ws_cull_sltiu({}, {});" - " /* ws explicit screen-x cull */{}", - reg_name(rt), reg_name(rs), imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.cull] screen_x site 0x{:08X} " - "is not sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // Overlay variant: the configured main-EXE PC holds different code. - } - // Widescreen automatic horizontal-FOV cull widening ([widescreen.cull] - // auto_screen_x). ws_auto_cull_func_ is set when this function carries the - // GTE screen-extent reject signature, so a per-vertex width compare here is - // `sltiu rt, SX, 0x140` (or inclusive 0x141). Emit it widened by - // +2*psx_ws_x_margin() (0 at 4:3 ⇒ byte-identical; = the wide-surface extra - // when 16:9), so geometry out to the wide frame edge is submitted rather - // than culled at the 320 boundary. Same shape as an explicit range_site but - // applied by signature — no per-address list. (Reached only when the addr is - // not already an explicit cull site, which returns above.) - if (ws_auto_cull_func_ && (opcode == 0x0B || opcode == 0x0A)) { // sltiu / slti - uint16_t uimm = get_imm16_u(instr); - if (psx_ws_cull_imm_in(uimm, config_.ws_cull_w_imms.data(), - (int)config_.ws_cull_w_imms.size())) { - uint32_t rs = get_rs(instr), rt = get_rt(instr); - if (opcode == 0x0B) { - // Route through the shared runtime helper psx_ws_cull_sltiu so the - // gcc emit and the interpreter both widen identically. - // It sign-extends SX and shifts by +margin (wide window - // -margin <= SX < imm+margin, both edges); at margin=0 it reduces - // bit-for-bit to the vanilla `SX write_half({}, (uint16_t)psx_ws_backdrop_x((int16_t){}));{}", - reg_name(rs), reg_name(rt), comment); - return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_half({} + {}, (uint16_t)psx_ws_backdrop_x((int16_t){}));{}", - reg_name(rs), offset, reg_name(rt), comment); - } else if (opcode == 0x09) { // addiu rt,rs,screen-centre - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format( - "{} = (uint32_t)psx_ws_project_x((int32_t)({} + (uint32_t){}));" - " /* ws CPU-projected screen-x */{}", - reg_name(rt), reg_name(rs), (int32_t)imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.backdrop] x site 0x{:08X} is not " - "sh or addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - // overlay variant: addr is different code here — fall through to vanilla. - } - - // Widescreen pure-2D background tile-loop widen ([widescreen.bg2d]). The - // per-layer BG renderer draws `count` 16px tile columns from a start column / - // start screen-x derived from the camera scroll. Rewrite those three values - // (via the gpu.c psx_ws_bg2d_* helpers — identity at 4:3 / 512 hi-res) so - // the loop draws the 16:9 reveal columns on both sides. Each site's opcode is - // verified; a mismatch is a loud build error (main-EXE addresses). - if (config_.ws_bg2d_count_site && addr == config_.ws_bg2d_count_site) { - if (opcode == 0x09 || opcode == 0x0D) { // addiu / ori (li rt,imm) - uint32_t rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_cols({});{}", - reg_name(rt), (int)imm, comment); - } else if (opcode == 0x0A || opcode == 0x0B) { // slti/sltiu rt,rs,imm (loop bound compare) - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - if (opcode == 0x0A) // signed variant - return fmt::format("{} = ((int32_t){} < psx_ws_bg2d_cols({})) ? 1u : 0u;{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - return fmt::format("{} = ({} < (uint32_t)psx_ws_bg2d_cols({})) ? 1u : 0u;{}", - reg_name(rt), reg_name(rs), (int)imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] count_site 0x{:08X} is not " - "addiu/ori/slti/sltiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - if (config_.ws_bg2d_startcol_site && addr == config_.ws_bg2d_startcol_site) { - if (opcode == 0x0C) { // andi rt,rs,imm (start tile-column mask) - uint32_t rs = get_rs(instr), rt = get_rt(instr); - uint16_t imm = get_imm16_u(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_startcol((int)({} & 0x{:X}u), 0x{:X}u);{}", - reg_name(rt), reg_name(rs), imm, imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] startcol_site 0x{:08X} is not " - "andi (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - if (config_.ws_bg2d_startx_site && addr == config_.ws_bg2d_startx_site) { - if (opcode == 0x00 && (instr & 0x3F) == 0x03) { // sra rd,rt,sa (start screen-x) - uint32_t rt = get_rt(instr), rd = get_rd(instr), sh = get_shamt(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_startx((int32_t){} >> {});{}", - reg_name(rd), reg_name(rt), sh, comment); - } else if (opcode == 0x00 && - (instr & 0x3F) == 0x23 && get_rs(instr) == 0) { - // subu rd,zero,rt (MMX4's start screen-x = -(scrollX & 15)) - uint32_t rt = get_rt(instr), rd = get_rd(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_startx(-(int32_t){});{}", - reg_name(rd), reg_name(rt), comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] startx_site 0x{:08X} is not " - "sra or subu-zero (instr 0x{:08X})\n", addr, instr); - std::exit(1); - } - } - // Tile-ring streamer leading-edge widen: two addiu computing the left - // (scrollX-16) and right (scrollX+16) stream world-X; push them out by - // LEFT*16 so the ring is populated across the widened column window. - if (config_.ws_bg2d_stream_left_site && addr == config_.ws_bg2d_stream_left_site) { - if (opcode == 0x08 || opcode == 0x09) { // addi / addiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_stream_left((int32_t){} + ({}));{}", - reg_name(rt), reg_name(rs), imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] stream_left_site 0x{:08X} is not " - "addi/addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - if (config_.ws_bg2d_stream_right_site && addr == config_.ws_bg2d_stream_right_site) { - if (opcode == 0x08 || opcode == 0x09) { // addi / addiu - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t imm = get_imm16(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_stream_right((int32_t){} + ({}));{}", - reg_name(rt), reg_name(rs), imm, comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] stream_right_site 0x{:08X} is not " - "addi/addiu (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - // BG packet-buffer RELOCATION (cure for the 1024-slot/1000-cap overflow that the - // widen causes in dense stages). bufbase_site = the driver addu computing the buffer - // address (base + bufidx*0x4000); cap_site = the renderer's per-frame tile-cap slti. - // Helpers relocate to a larger free buffer + raise the cap when the widen is active, - // identity otherwise (4:3 / other games byte-identical). - if (config_.ws_bg2d_bufbase_site && addr == config_.ws_bg2d_bufbase_site) { - if (opcode == 0x00 && (instr & 0x3F) == 0x21) { // addu rd,rs,rt (BG buffer address) - uint32_t rs = get_rs(instr), rt = get_rt(instr), rd = get_rd(instr); - return fmt::format("{} = (uint32_t)psx_ws_mmx6_bg_bufbase((int)({} + {}));{}", - reg_name(rd), reg_name(rs), reg_name(rt), comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] bufbase_site 0x{:08X} is not " - "addu (instr 0x{:08X})\n", addr, instr); - std::exit(1); - } - } - if (config_.ws_bg2d_cap_site && addr == config_.ws_bg2d_cap_site) { - if (opcode == 0x0A) { // slti rt,rs,imm (BG per-frame tile cap compare) - uint32_t rs = get_rs(instr), rt = get_rt(instr); - return fmt::format("{} = (uint32_t)psx_ws_bg2d_undercap((int32_t){}, {});{}", - reg_name(rt), reg_name(rs), (int16_t)(instr & 0xFFFFu), comment); - } else if (!config_.overlay_mode) { - fmt::print(stderr, "ERROR: [widescreen.bg2d] cap_site 0x{:08X} is not " - "slti (opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - } - - // Persistent game-option init store ([persist_options] in game_options.toml). - // The site is the boot-init sb/sh that writes a config global's DEFAULT value - // (Tomba MESSAGE / SOUND / VIBRATION / ADJUST SCREEN). Route the stored value - // through psx_game_option_store(addr, val): it returns a value persisted from a - // prior session for that address, else `val` unchanged — so the saved setting - // overrides the default exactly once, at initialization, and a fresh install - // (no saved file) is byte-identical. The in-OPTION write is a different - // instruction (option overlay), untouched, so the player can still change it. - // Verified sb/sh; a mismatch is a loud build error. - if (config_.persist_init_store_sites.count(addr)) { - uint32_t rs = get_rs(instr), rt = get_rt(instr); - int16_t offset = get_imm16(instr); - std::string aexpr = (offset != 0) - ? fmt::format("({} + {})", reg_name(rs), offset) - : std::string(reg_name(rs)); - std::string store_pc = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr); - if (opcode == 0x28) // sb - return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_byte({0}, (uint8_t)psx_game_option_store({0}, (int){1}));{2}", - aexpr, reg_name(rt), comment); - if (opcode == 0x29) // sh - return store_pc + fmt::format("psx_store_cycle_barrier(); cpu->write_half({0}, (uint16_t)psx_game_option_store({0}, (int){1}));{2}", - aexpr, reg_name(rt), comment); - fmt::print(stderr, "ERROR: [persist_options] init site 0x{:08X} is not sb/sh " - "(opcode 0x{:02X})\n", addr, opcode); - std::exit(1); - } - - // SPECIAL opcode (0x00) - if (opcode == 0x00) { - switch (funct) { - case 0x00: code = translate_sll(instr); break; // sll - case 0x02: code = translate_srl(instr); break; // srl - case 0x03: code = translate_sra(instr); break; // sra - case 0x04: code = translate_sllv(instr); break; // sllv - case 0x06: code = translate_srlv(instr); break; // srlv - case 0x07: code = translate_srav(instr); break; // srav - case 0x08: code = "return; /* jr $ra */"; break; // jr (assume return) - case 0x09: // jalr - { - uint32_t rs = get_rs(instr); - uint32_t rd = get_rd(instr); - code = fmt::format("{{ uint32_t _jt_{0:08X} = {1}; {2} = 0x{3:08X}; " - "cpu->pc = _jt_{0:08X}; }} /* jalr */", - addr, reg_name(rs), reg_name(rd), addr + 8); - } - break; - case 0x0C: // syscall - { - uint32_t syscall_code = (instr >> 6) & 0xFFFFF; - code = fmt::format("psx_syscall(cpu, {}); /* syscall {} */", syscall_code, syscall_code); - } - break; - case 0x0D: // break - { - uint32_t break_code = (instr >> 6) & 0xFFFFF; - code = fmt::format("/* break({}) — trap, no-op in recompiler */", break_code); - } - break; - case 0x10: code = translate_mfhi(instr); break; // mfhi - case 0x11: code = translate_mthi(instr); break; // mthi - case 0x12: code = translate_mflo(instr); break; // mflo - case 0x13: code = translate_mtlo(instr); break; // mtlo - case 0x18: code = translate_mult(instr); break; // mult - case 0x19: code = translate_multu(instr); break; // multu - case 0x1A: code = translate_div(instr); break; // div - case 0x1B: code = translate_divu(instr); break; // divu - case 0x20: code = translate_add(instr); break; // add - case 0x21: code = translate_addu(instr); break; // addu - case 0x22: code = translate_sub(instr); break; // sub - case 0x23: code = translate_subu(instr); break; // subu - case 0x24: code = translate_and(instr); break; // and - case 0x25: code = translate_or(instr); break; // or - case 0x26: code = translate_xor(instr); break; // xor - case 0x27: code = translate_nor(instr); break; // nor - case 0x2A: code = translate_slt(instr); break; // slt - case 0x2B: code = translate_sltu(instr); break; // sltu - default: - code = fmt::format("/* TODO: SPECIAL funct=0x{:02X} */", funct); - } - } - // Immediate and load/store instructions - else { - switch (opcode) { - case 0x08: code = translate_addiu(instr); break; // addi (same as addiu) - case 0x09: code = translate_addiu(instr); break; // addiu - case 0x0A: code = translate_slti(instr); break; // slti - case 0x0B: code = translate_sltiu(instr); break; // sltiu - case 0x0C: code = translate_andi(instr); break; // andi - case 0x0D: code = translate_ori(instr); break; // ori - case 0x0E: code = translate_xori(instr); break; // xori - case 0x0F: code = translate_lui(instr); break; // lui - case 0x10: // COP0 - { - uint32_t cop_op = (instr >> 21) & 0x1F; - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - if (cop_op == 0x00) { // MFC0 - move from COP0 - // MFC0 is a delayed load (Beetle: LDAbsorb=0, LDWhich=rt) — no - // give-back cycles, but it sets ReadFudge=rt so a load in the next - // slot gets no fudge. §1+DO_LDS ran in the block's psx_cyc_step. - code = fmt::format( - "{} = cpu->cop0[{}];" - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n cpu->ld_absorb = 0u; cpu->ld_which_t = {}u;\n#endif" - " /* mfc0 (delayed load) */", - reg_name(rt), rd, rt); - } else if (cop_op == 0x04) { // MTC0 - move to COP0 - code = fmt::format("cpu->cop0[{}] = {}; /* mtc0 */", rd, reg_name(rt)); - } else if (cop_op == 0x10 && (instr & 0x3F) == 0x10) { // RFE - // Restore interrupt enable bits: shift bits 5:2 → 3:0. - // Fix B: arm the host exception-return escape if this RFE runs - // inside the synchronous exception handler (see psx_runtime.h). - code = "{ uint32_t sr = cpu->cop0[12]; " - "cpu->cop0[12] = (sr & 0xFFFFFFF0u) | ((sr >> 2) & 0x0Fu); } " - "psx_rfe_mark_escape(); /* rfe */"; - } else { - code = fmt::format("/* cop0: 0x{:08X} */", instr); - } - } - break; - case 0x12: // COP2 (GTE) - { - uint32_t cop_op = (instr >> 21) & 0x1F; - uint32_t rt = get_rt(instr); - uint32_t rd = get_rd(instr); - // Faithful GTE: any COP2 register access stalls to the pending - // command completion deadline (gte_execute arms it). Emitted - // before the access; cost-free unless an op is still in flight. - const char* gte_stall = - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; - // MFC2/CFC2 (GPR-dest reads): stall to the GTE deadline AND hand the - // stall amount to the next instruction(s) as a load-delay give-back - // (Beetle MFC2/CFC2: LDAbsorb=gte_ts_done-ts, LDWhich=rt). §1+DO_LDS - // ran in the block's psx_cyc_step (COP2 is non-load). - const std::string gte_read = fmt::format( - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt); - if (cop_op == 0x00) { // MFC2 - move from COP2 data - std::string value; - if (PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd))) { - value = fmt::format("gte_read_data(cpu, {})", rd); - } else { - value = fmt::format("cpu->gte_data[{}]", rd); - } - if (disruptor_precision_mfc2_pc(addr)) { - if (rt != 10u || rd != 14u || instr != 0x480A7000u) { - throw std::runtime_error(fmt::format( - "Disruptor precision MFC2 site 0x{:08X} no longer " - "matches MFC2 r10,SXY2 (word 0x{:08X})", - addr, instr)); - } - value = fmt::format( - "gte_precision_mfc2_pc_read(0x{:08X}u, " - "0x{:08X}u, {}, {})", - addr, instr, rd, value); - } - code = gte_read + fmt::format( - "{} = {}; /* mfc2 */", reg_name(rt), value); - } else if (cop_op == 0x02) { // CFC2 - move from COP2 control - if (PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd))) { - code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); - } else { - code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); - } - } else if (cop_op == 0x04) { // MTC2 - move to COP2 data - if (PSXRecompGTERegisters::data_write_needs_helper(static_cast(rd))) { - code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); - } else { - code = gte_stall + fmt::format("cpu->gte_data[{}] = {}; /* mtc2 */", rd, reg_name(rt)); - } - } else if (cop_op == 0x06) { // CTC2 - move to COP2 control - if (PSXRecompGTERegisters::ctrl_write_needs_helper(static_cast(rd))) { - code = gte_stall + fmt::format("gte_write_ctrl(cpu, {}, {}); /* ctc2 */", rd, reg_name(rt)); - } else { - code = gte_stall + fmt::format("cpu->gte_ctrl[{}] = {}; /* ctc2 */", rd, reg_name(rt)); - } - } else if ((cop_op & 0x10) != 0) { // GTE command (bit 25 set) - uint32_t gte_cmd = instr & 0x1FFFFFF; - // Route ALL GTE commands through gte_execute() for correct behavior - code = fmt::format("gte_execute(cpu, 0x{:07X}); /* gte cmd 0x{:02X} */", gte_cmd, gte_cmd & 0x3F); - } else { - code = fmt::format("/* cop2: 0x{:08X} */", instr); - } - } - break; - case 0x20: code = translate_lb(instr); break; // lb - case 0x21: code = translate_lh(instr); break; // lh - case 0x22: code = translate_lwl(instr); break; // lwl - case 0x23: { // lw - const DisruptorPrecisionCopySite *site = - disruptor_precision_copy_site( - kDisruptorPrecisionCopyLoads, addr); - if (!site) { - code = translate_lw(instr); - break; - } - const uint32_t rs = get_rs(instr); - const uint32_t rt = get_rt(instr); - const int16_t offset = get_imm16(instr); - if (instr != site->instruction || rt != site->gpr) { - throw std::runtime_error(fmt::format( - "Disruptor precision-copy LW site 0x{:08X} no longer " - "matches word 0x{:08X}/r{} (found 0x{:08X})", - addr, site->instruction, site->gpr, instr)); - } - const std::string load_addr = offset == 0 - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - code = fmt::format( - "{{ uint32_t _precision_copy_addr = {}; " - "{} = psx_cyc_load_word(cpu, _precision_copy_addr, {}, " - "0x{:X}u); {} = gte_precision_copy_pc_read(" - "0x{:08X}u, 0x{:08X}u, {}, _precision_copy_addr, {}); }}", - load_addr, reg_name(rt), rt, 1u << rs, reg_name(rt), - addr, instr, rt, reg_name(rt)); - break; - } - case 0x24: code = translate_lbu(instr); break; // lbu - case 0x25: code = translate_lhu(instr); break; // lhu - case 0x26: code = translate_lwr(instr); break; // lwr - // Stores: stamp the EXACT executing store PC before the write so - // wtrace/readtrace producer attribution is correct for game code. - // (The BIOS emitter does the same in strict_translator.cpp; the game - // emitter previously left g_debug_last_store_pc holding a STALE pc — - // the last BIOS store — which mis-attributed game writes to BIOS.) - case 0x28: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sb(instr); break; // sb - case 0x29: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_sh(instr); break; // sh - case 0x2A: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swl(instr); break; // swl - case 0x2B: { // sw - const DisruptorPrecisionCopySite *copy_site = - disruptor_precision_copy_site( - kDisruptorPrecisionCopyStores, addr); - if (copy_site) { - const uint32_t rs = get_rs(instr); - const uint32_t rt = get_rt(instr); - const int16_t offset = get_imm16(instr); - if (instr != copy_site->instruction || - rt != copy_site->gpr) { - throw std::runtime_error(fmt::format( - "Disruptor precision-copy SW site 0x{:08X} no " - "longer matches word 0x{:08X}/r{} (found " - "0x{:08X})", - addr, copy_site->instruction, - copy_site->gpr, instr)); - } - const std::string store_addr = offset == 0 - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - code = fmt::format( - "g_debug_last_store_pc = 0x{:08X}u; " - "{{ psx_store_cycle_barrier(); " - "uint32_t _precision_copy_addr = {}; " - "uint32_t _precision_copy_value = {}; " - "cpu->write_word(_precision_copy_addr, " - "_precision_copy_value); " - "gte_precision_copy_pc_word(0x{:08X}u, " - "0x{:08X}u, {}, _precision_copy_addr, " - "_precision_copy_value); }}", - addr, store_addr, reg_name(rt), addr, instr, rt); - break; - } - code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + - translate_sw(instr); - if (disruptor_precision_store_pc(addr) || - disruptor_precision_scratch_store_pc(addr)) { - const uint32_t rs = get_rs(instr); - const uint32_t rt = get_rt(instr); - const int16_t offset = get_imm16(instr); - if (rs != 5u || rt != 10u || offset != 4 || - instr != 0xACAA0004u) { - throw std::runtime_error(fmt::format( - "Disruptor precision SW site 0x{:08X} no longer " - "matches SW r10,4(r5) (word 0x{:08X})", - addr, instr)); - } - const std::string store_addr = fmt::format( - "{} + {}", reg_name(rs), offset); - if (disruptor_precision_scratch_store_pc(addr)) { - code += fmt::format( - " gte_precision_scratch_store_pc_word(" - "0x{:08X}u, 0x{:08X}u, {}, {});", - addr, instr, store_addr, reg_name(rt)); - } else { - code += fmt::format( - " gte_precision_store_pc_word(0x{:08X}u, " - "0x{:08X}u, {}, {});", - addr, instr, store_addr, reg_name(rt)); - } - } - break; - } - case 0x2E: code = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr) + translate_swr(instr); break; // swr - case 0x2F: // CACHE - cache op (no-op for static recomp) - code = "/* cache (no-op: static recompilation has no I/D cache) */"; - break; - case 0x32: // LWC2 - load word to COP2 - { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - // Faithful GTE: COP2 reg write stalls to the command deadline. - const char* gte_stall = - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; - // LWC2 load timing: §1+DO_LDS via the block's psx_cyc_step(cpu,0) - // (op 0x32 is non-load), the GTE deadline stall via psx_gte_stall, - // then psx_cyc_lwc2_read does the ReadMemory timing (completion +1, - // no LDWhich arm — the dest is a GTE register). - std::string addr = (offset == 0) - ? reg_name(rs) - : fmt::format("{} + {}", reg_name(rs), offset); - bool special = PSXRecompGTERegisters::data_write_needs_helper(static_cast(rt)); - if (special) { - code = gte_stall + fmt::format("gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {})); /* lwc2 gte[{}] */", - rt, addr, rt); - } else { - code = gte_stall + fmt::format("cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {}); /* lwc2 gte[{}], ({}) */", - rt, addr, rt, addr); - } - } - break; - case 0x3A: // SWC2 - store word from COP2 - { - uint32_t rs = get_rs(instr); - uint32_t rt = get_rt(instr); - int16_t offset = get_imm16(instr); - // Faithful GTE: COP2 reg read stalls to the command deadline. - const char* gte_stall = - "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; - std::string value = PSXRecompGTERegisters::data_read_needs_helper(static_cast(rt)) - ? fmt::format("gte_read_data(cpu, {})", rt) - : fmt::format("cpu->gte_data[{}]", rt); - std::string swc2_store_pc = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr); - if (offset == 0) { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({}, {}); gte_precision_store_word({}, {}); /* swc2 gte[{}], ({}) */", - reg_name(rs), value, reg_name(rs), rt, rt, reg_name(rs)); - } else { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({} + {}, {}); gte_precision_store_word({} + {}, {}); /* swc2 gte[{}], {}({}) */", - reg_name(rs), offset, value, reg_name(rs), offset, rt, - rt, offset, reg_name(rs)); - } - } - break; - default: - code = fmt::format("/* TODO: opcode=0x{:02X} */", opcode); - } - } - - if (vertical_camera_site) { - code += fmt::format( - " disruptor_vertical_camera_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - addr, instr); - } - if (far_rendering_site) { - code += fmt::format( - "\n{}disruptor_far_rendering_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (billboard_aspect_site) { - code += fmt::format( - "\n{}disruptor_billboard_aspect_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (backdrop_tile_site) { - code += fmt::format( - "\n{}disruptor_backdrop_tiles_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (sprite_depth_site) { - code += fmt::format( - "\n{}disruptor_sprite_depth_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - if (intro_skip_site) { - code += fmt::format( - "\n{}disruptor_intro_skip_instruction_hook(cpu, " - "0x{:08X}u, 0x{:08X}u, 1);", - config_.indent, addr, instr); - } - - return config_.indent + code + comment; -} - -std::string CodeGenerator::translate_basic_block( - const BasicBlock& block, - const ControlFlowGraph& cfg) { - - std::stringstream ss; - - // Block label - ss << fmt::format("block_{:08X}:\n", block.start_addr); - - // Per-block-leader cycle observe (cyc_watch ruler — universal with the BIOS - // emitter). Sampled at the block leader BEFORE any cycle is charged, matching - // Beetle's before-instruction sample and the cycle_compare.py anchor - // semantics, so ANY block-leader PC is anchorable on both backends (not just - // function entries). Debug-only: prod (PSX_NO_DEBUG_TOOLS) emits nothing. - ss << "#ifndef PSX_NO_DEBUG_TOOLS\n"; - ss << config_.indent - << fmt::format("debug_server_cyc_observe(0x{:08X}u);\n", block.start_addr); - ss << "#endif\n"; - // First-divergence co-sim oracle (COSIM_ORACLE.md): lean per-block-leader hook, - // present only in the clean PSX_COSIM build (independent of the debug tools). - ss << "#ifdef PSX_COSIM\n"; - ss << config_.indent - << fmt::format("cosim_block(0x{:08X}u);\n", block.start_addr); - ss << "#endif\n"; - - // Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). At the block - // leader — before any cycle is charged or the body runs — divert to the - // per-instruction interpreter when an interrupt could be taken inside this - // block, so the IRQ lands at its exact architectural instruction instead of - // the coarse block edge. psx_slice_block returns nonzero iff it sliced (it - // interpreted the block — and possibly more — and left cpu->pc at a - // dispatchable resume point), in which case this function returns so its - // compiled body does not re-execute the same instructions. - // side_effects=1 marks blocks that change interrupt visibility on the CPU - // (mtc0 Status/Cause, rfe), which can make a pending+masked IRQ deliverable - // mid-block with no external event deadline. - // Faithful cycle accounting (FAITHFUL_TIMING_PLAN.md P2): the exit branch/jump - // ALWAYS executes its delay-slot instruction (emitted as a clone before the - // transfer, below). When the delay slot lives INSIDE this block - // (block.exit_instr.address < end_addr) it is already part of - // instruction_count. But when the delay slot is a SEPARATE block leader (a - // branch target, or a split-function edge) the branch sits AT end_addr and the - // delay slot at end_addr+4 is outside [start,end]; instruction_count then - // EXCLUDES it, yet the clone still executes — so the block undercharges by one - // cycle vs the per-instruction interpreter on BOTH paths. (This is the measured - // -8 drift in the Tomba 2 logo init subtree: ~8 such sites on the boot path, - // native runs behind interp, the elapsed-Timer1 logo-delay loop exits early.) - // Charge the always-executed clone here so every dynamically executed - // instruction is charged exactly once. (R3000 is MIPS-I: no branch-likely, so - // the delay slot is unconditional.) cycle_per_insn mode already charges the - // clone per-instruction at its emit site, so this adjustment is block-mode only. - const bool exit_delay_slot_outside = - block.exit_instr.has_delay_slot && - block.exit_instr.type != ControlFlowType::None && - block.exit_instr.address == block.end_addr; - // Block cycle charge = sum of the single-source per-instruction cost - // (psx_instr_cost.h) over every instruction the block executes, INCLUDING - // the always-executed delay-slot clone when the exit branch sits at end_addr - // (delay slot outside [start,end]). The interpreter charges the same cost - // per instruction through the same function, so the two backends cannot - // disagree. Stage 1 cost is identity (1/insn) => this equals - // instruction_count(+1 outside delay) exactly => byte-identical regen; Stage 2 - // edits ONLY psx_instr_base_cycles and both backends update together. - uint32_t block_exec_cycles = 0; - for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) { - auto w = exe_.read_word(a); - if (!w.has_value()) break; - block_exec_cycles += psx_instr_base_cycles(*w); - } - if (exit_delay_slot_outside) { - auto dw = exe_.read_word(block.end_addr + 4u); // the delay-slot clone - if (dw.has_value()) block_exec_cycles += psx_instr_base_cycles(*dw); - } - - { - uint32_t slice_bcyc = block_exec_cycles; - if (slice_bcyc == 0) slice_bcyc = 1; - int slice_side_effects = 0; - for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) { - auto w = exe_.read_word(a); - if (!w.has_value()) break; - uint32_t insn = *w; - if ((insn >> 26) == 0x10u) { // COP0 - uint32_t rs = (insn >> 21) & 0x1Fu; - uint32_t co = (insn >> 25) & 0x1u; - uint32_t funct = insn & 0x3Fu; - if (rs == 0x04u) slice_side_effects = 1; // MTC0 (writes Status/Cause) - if (co && funct == 0x10u) slice_side_effects = 1; // RFE - } - } - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << config_.indent - << fmt::format("if (psx_slice_block(cpu, 0x{:08X}u, {}u, {})) return;\n", - block.start_addr, slice_bcyc, slice_side_effects); - ss << "#endif\n"; - } - - const bool cycle_per_insn = codegen_cycle_per_insn(); - // Per-instruction R3000A load-delay interlock (cycle_per_insn mode): §1 base + - // GPR_DEPRES + DO_LDS, emitted BEFORE the instruction body so §1 precedes any - // muldiv/GTE deadline stall the body applies (Beetle order). CPU loads (op - // 0x20-0x26) are SKIPPED here — psx_cyc_load_* runs their full interlock inside - // the body (and arms LDWhich=rt). The dep/res mask is a gen-time literal. This - // replaces the old flat per-instruction psx_advance_cycles(1u). - auto emit_pre_timing = [&](uint32_t in, const std::string& indent) { - uint32_t op = in >> 26; - if (op >= 0x20u && op <= 0x26u) return; // CPU load: interlock inside psx_cyc_load_* - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_cyc_step(cpu, 0x{:X}u);\n", - psx_cyc_dep_res_mask(in)); - ss << "#endif\n"; - }; - // I-cache FETCH cost (faithful R3000A), emitted BEFORE the per-instruction - // interlock/load — exactly like Beetle ReadInstruction precedes the base, and so a - // fetch MISS clears any pending load give-back before the next load arms one. Only - // emitted at cache-line LEADERS: a block leader / mid-block jump-table target (any - // address reachable other than by fall-through, i.e. a possibly-cold cache entry) OR - // a 16-byte-line start (addr&0xC==0, a sequential line crossing). Intra-line - // followers reached by fall-through are guaranteed hits — the leader's fetch - // refilled the line to its end — so they need no call (+0). Extra fetch points are - // harmless (a hit is +0); only UNDER-counting a cold entry would diverge, which the - // leader set prevents. The game runs at its KSEG0 load address, so `insn_addr` is - // already the runtime guest PC (matching the dirty-RAM interp's cpu->pc and Beetle). - auto emit_pre_icache = [&](uint32_t insn_addr, const std::string& indent) { - if (!(insn_addr == block.start_addr || (insn_addr & 0xCu) == 0 || - extra_labels_.count(insn_addr))) return; - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_icache_fetch(cpu, 0x{:08X}u);\n", insn_addr); - ss << "#endif\n"; - }; - auto emit_cosim_instr = [&](uint32_t insn_addr, const std::string& indent) { - ss << "#ifdef PSX_COSIM\n"; - ss << indent << fmt::format("cosim_instr(0x{:08X}u);\n", insn_addr); - ss << "#endif\n"; - }; - if (!cycle_per_insn && block_exec_cycles > 0) { - ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << config_.indent << fmt::format("psx_advance_cycles({}u);\n", - block_exec_cycles); - ss << "#endif\n"; - } - - // Translate each instruction in the block - uint32_t addr = block.start_addr; - - // Determine if the exit instruction uses a delay slot pattern. - // Normal case: branch at end_addr-4, delay slot at end_addr. - // Split-function edge case: exit instruction IS at end_addr (delay slot - // is outside the function), so exit_branch_addr = end_addr not end_addr-4. - bool exit_has_delay = (block.exit_instr.has_delay_slot && - block.exit_instr.type != ControlFlowType::None); - bool delay_slot_in_block = (exit_has_delay && - block.exit_instr.address < block.end_addr); - bool exit_uses_delay_slot = delay_slot_in_block; - uint32_t exit_branch_addr; - if (delay_slot_in_block) { - exit_branch_addr = block.end_addr - 4; // branch at end-4, delay slot at end - } else if (exit_has_delay) { - exit_branch_addr = block.end_addr; // branch at end, delay slot outside - } else { - exit_branch_addr = block.end_addr; - } - - while (addr <= block.end_addr) { - auto instr_opt = exe_.read_word(addr); - if (!instr_opt.has_value()) { - break; - } - - uint32_t instr = *instr_opt; - - // Skip the delay slot at end_addr - it's emitted as part of the branch handling below - if (exit_uses_delay_slot && addr == block.end_addr) { - addr += 4; - break; - } - - // Don't emit control flow instructions here (handled separately) - bool is_cf = ControlFlowAnalyzer::is_control_flow(instr); - - // Emit inline label for mid-block jump table targets - if (addr != block.start_addr && extra_labels_.count(addr)) { - ss << fmt::format("block_{:08X}:;\n", addr); - } - - // Instruction-level annotation (skipped at function start — already shown above signature) - if (annotations_ && addr != cfg.function_start) { - const std::string& inote = annotations_->lookup(addr); - if (!inote.empty()) - ss << config_.indent << fmt::format("/* [NOTE] {} */\n", inote); - } - - if (!is_cf) { - if (cycle_per_insn) emit_pre_icache(addr, config_.indent); - if (cycle_per_insn) emit_pre_timing(instr, config_.indent); - ss << translate_instruction(addr, instr) << "\n"; - emit_cosim_instr(addr, config_.indent); - } else { - // Control flow is handled at block exit - if (addr == exit_branch_addr) { - std::string delay_saved_cond; // branch condition captured before delay - std::string delay_saved_target; // JR/JALR target captured before delay - - const uint32_t branch_opcode = - (block.exit_instr.instruction >> 26) & 0x3Fu; - if (branch_opcode >= 0x14u && branch_opcode <= 0x17u) { - /* Opcodes 0x14-0x17 (the MIPS-II branch-likely family) are - * RESERVED on the R3000A: real hardware raises the Reserved - * Instruction exception at this PC (Beetle cpu.cpp opcode - * table row 0x10: op_ILL). Discovery can sweep data-as-code - * into a function body; aborting the WHOLE regen here (the - * old throw) broke "every title regenerates at tip" over one - * bogus word, and emitting the word as a real branch would - * be unfaithful. Emit the architectural RI raise inline — - * ABI-neutral: the dispatch trampoline (and the CPS exit - * contract in overlay mode) re-dispatches cpu->pc after the - * return, exactly like any other guest control transfer. If - * the word is data (never executed) nothing happens; if the - * guest really reaches it, the kernel handler sees the same - * exception hardware would deliver. */ - fmt::print(" WARNING: reserved opcode 0x{:08X} at 0x{:08X} " - "— emitting guest RI exception raise\n", - block.exit_instr.instruction, addr); - ss << config_.indent - << fmt::format("{{ /* reserved opcode 0x{:08X}: architectural RI exception */\n", - block.exit_instr.instruction); - ss << config_.indent << " uint32_t psx_ri_sr = cpu->cop0[12];\n"; - ss << config_.indent - << fmt::format(" cpu->cop0[14] = 0x{:08X}u; /* EPC = faulting PC, BD=0 */\n", addr); - ss << config_.indent - << " cpu->cop0[13] = (cpu->cop0[13] & ~0x8000007Cu) | (10u << 2); /* Cause: RI */\n"; - ss << config_.indent - << " cpu->cop0[12] = (psx_ri_sr & ~0x3Fu) | ((psx_ri_sr & 0x0Fu) << 2); /* SR push */\n"; - ss << config_.indent - << " cpu->pc = (psx_ri_sr & 0x00400000u) ? 0xBFC00180u : 0x80000080u;\n"; - ss << config_.indent << " return; }\n"; - break; /* block ends at the raise; nothing after is reachable */ - } - - // Per-instruction interlock ORDER (Beetle): the branch's §1+deps+DO_LDS - // runs at the branch PC, THEN the delay slot's at PC+4. Emit the branch - // step FIRST (it is pure timing — does not touch GPR values, so it is - // safe before the branch-condition capture below). - if (cycle_per_insn) - emit_pre_icache(exit_branch_addr, config_.indent); - if (cycle_per_insn) - emit_pre_timing(block.exit_instr.instruction, config_.indent); - - // Register-indirect targets are resolved at the jump instruction. - // The delay slot may overwrite the source register, so all JR/JALR - // paths below consume this pre-delay snapshot. - if (block.exit_instr.type == ControlFlowType::Return || - block.exit_instr.type == ControlFlowType::JumpRegister || - block.exit_instr.type == ControlFlowType::JumpLinkReg) { - const uint32_t target_rs = get_rs(block.exit_instr.instruction); - delay_saved_target = fmt::format("_jt_{:08X}", addr); - ss << config_.indent - << fmt::format("uint32_t {} = {}; /* latch indirect target before delay slot */\n", - delay_saved_target, reg_name(target_rs)); - } - - if (block.exit_instr.type == ControlFlowType::JumpLink) { - ss << config_.indent - << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* jal link before delay slot */\n", - addr + 8); - } else if (block.exit_instr.type == ControlFlowType::JumpLinkReg) { - uint32_t rd = get_rd(block.exit_instr.instruction); - if (rd != 0) { - ss << config_.indent - << fmt::format("{} = 0x{:08X}u; /* jalr link before delay slot */\n", - reg_name(rd), addr + 8); - } - } else if (block.exit_instr.type == ControlFlowType::Branch) { - uint32_t branch_instr = block.exit_instr.instruction; - uint32_t b_opcode = (branch_instr >> 26) & 0x3F; - uint32_t regimm_op = (branch_instr >> 16) & 0x1F; - if (b_opcode == 0x01 && (regimm_op == 0x10 || regimm_op == 0x11)) { - ss << config_.indent - << fmt::format("cpu->gpr[31] = 0x{:08X}u; /* branch-and-link before delay slot */\n", - addr + 8); - } - } - - // MIPS delay slot handling: emit delay slot instruction BEFORE branch/jump - if (block.exit_instr.has_delay_slot) { - uint32_t delay_slot_addr = addr + 4; - auto delay_instr_opt = exe_.read_word(delay_slot_addr); - - /* A native control transfer without its architectural delay - * slot is never a legal partial shard. In particular, a - * page-scoped overlay capture can end with the branch at - * ...FFC while the slot lives at ...000 in the next page. - * The old code silently skipped the unavailable word and - * still emitted the transfer, corrupting guest state while - * looking like a successful native call. Fail generation - * closed; the runtime interpreter remains authoritative - * until a capture containing the guard word is available. */ - if (!delay_instr_opt.has_value()) { - throw std::runtime_error(fmt::format( - "cannot emit control flow at 0x{:08X}: mandatory " - "delay slot 0x{:08X} is outside the input image", - addr, delay_slot_addr)); - } - { - uint32_t delay_instr = *delay_instr_opt; - - // For branch-likely variants, delay slot is conditional - if (block.exit_instr.is_likely) { - ss << config_.indent << "/* delay slot (likely) - conditional execution */\n"; - ss << config_.indent << "if (" << generate_branch_condition(block.exit_instr.instruction, block.exit_instr.address) << ") {\n"; - ss << config_.indent << translate_instruction(delay_slot_addr, delay_instr) << "\n"; - emit_cosim_instr(delay_slot_addr, config_.indent + config_.indent); - ss << config_.indent << "}\n"; - } else { - // Normal delay slot - always executes. - // In MIPS, the branch condition is evaluated at the branch - // instruction (before the delay slot). If the delay slot - // modifies a condition register we must save the result first. - if (block.exit_instr.type == ControlFlowType::Branch) { - std::string cond = generate_branch_condition(block.exit_instr.instruction, block.exit_instr.address); - delay_saved_cond = fmt::format("_bc_{:08X}", addr); - ss << config_.indent - << fmt::format("int {} = ({}); /* save branch cond before delay slot */\n", - delay_saved_cond, cond); - } - ss << config_.indent << "/* delay slot (always executes) */\n"; - // Delay slot's own fetch + §1+deps+DO_LDS, before its body (skipped if a load). - if (cycle_per_insn) emit_pre_icache(delay_slot_addr, config_.indent); - if (cycle_per_insn) emit_pre_timing(delay_instr, config_.indent); - ss << translate_instruction(delay_slot_addr, delay_instr) << "\n"; - emit_cosim_instr(delay_slot_addr, config_.indent); - } - } - } - - // Now emit the branch/jump - if (block.exit_instr.type == ControlFlowType::Branch) { - // Conditional branch - use pre-captured condition if available - std::string condition = delay_saved_cond.empty() - ? generate_branch_condition(block.exit_instr.instruction, block.exit_instr.address) - : delay_saved_cond; - if (block.successors.size() == 2) { - ss << config_.indent << "if (" << condition << ") {\n"; - ss << emit_interrupt_check(block.successors[0], config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* taken */\n", block.successors[0]); - ss << config_.indent << "} else {\n"; - ss << emit_interrupt_check(block.successors[1], config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* not taken */\n", block.successors[1]); - ss << config_.indent << "}\n"; - } else { - // Split-function: one or both branch targets are outside - // this function piece. Emit function calls for out-of-function - // targets to reconnect the split pieces. - uint32_t branch_target = block.exit_instr.target; - uint32_t fall_through_addr = block.exit_instr.address + 8; - bool target_in = cfg.blocks.count(branch_target) > 0; - bool fallthru_in = cfg.blocks.count(fall_through_addr) > 0; - - ss << config_.indent << "if (" << condition << ") {\n"; - if (target_in) { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* taken */\n", branch_target); - } else if (cps_enabled_) { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS taken: split */\n", branch_target); - } else if (known_functions_.count(branch_target)) { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* taken: split piece */\n", branch_target); - } else { - ss << emit_interrupt_check(branch_target, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* taken: split (mid-func) */\n", branch_target); - } - ss << config_.indent << "} else {\n"; - if (fallthru_in) { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("goto block_{:08X}; /* not taken */\n", fall_through_addr); - } else if (cps_enabled_) { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS not taken: split */\n", fall_through_addr); - } else if (known_functions_.count(fall_through_addr)) { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* not taken: split piece */\n", fall_through_addr); - } else { - ss << emit_interrupt_check(fall_through_addr, config_.indent + config_.indent); - ss << config_.indent << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* not taken: split (mid-func) */\n", fall_through_addr); - } - ss << config_.indent << "}\n"; - } - } else if (block.exit_instr.type == ControlFlowType::Jump) { - // Unconditional jump - if (!block.successors.empty()) { - ss << emit_interrupt_check(block.successors[0], config_.indent); - ss << config_.indent - << fmt::format("goto block_{:08X}; /* j */\n", block.successors[0]); - } else if (cps_enabled_ && block.exit_instr.target != 0) { - // CPS: out-of-function jump target — tail-transfer (the - // flat trampoline dispatches the split piece / mid-func). - ss << emit_interrupt_check(block.exit_instr.target, config_.indent); - ss << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS j: split */\n", - block.exit_instr.target); - } else if (block.exit_instr.target != 0 && known_functions_.count(block.exit_instr.target)) { - // Jump target is out-of-function and is a known function start - ss << emit_interrupt_check(block.exit_instr.target, config_.indent); - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* j to split piece */\n", - block.exit_instr.target); - } else if (block.exit_instr.target != 0) { - // Jump target is a mid-function address — dispatch dynamically - ss << emit_interrupt_check(block.exit_instr.target, config_.indent); - ss << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* j to split (mid-func) */\n", - block.exit_instr.target); - } - } else if (block.exit_instr.type == ControlFlowType::Return) { - /* Return instruction. - * - * Detect "longjmp-return" pattern: if $ra was loaded from a - * non-$sp source anywhere in this function (e.g. RestoreState - * loads $ra from a save buffer via $a0), the jr $ra must set - * cpu->pc so the dispatch loop tail-calls to the restored - * address — the C `return` alone would go back to the wrong - * caller. Normal functions that save/restore $ra on the $sp - * stack get the plain `return;`. */ - bool ra_loaded_from_non_sp = false; - for (const auto& [baddr, blk] : cfg.blocks) { - for (uint32_t ia = blk.start_addr; ia < blk.end_addr; ia += 4) { - auto wopt = exe_.read_word(ia); - if (!wopt) continue; - uint32_t w = *wopt; - uint32_t i_op = (w >> 26) & 0x3F; - uint32_t i_rt = (w >> 16) & 0x1F; - uint32_t i_rs = (w >> 21) & 0x1F; - if (i_op == 0x23 && i_rt == 31 && i_rs == 4) { - /* lw $ra, offset($a0) — RestoreState/longjmp pattern */ - ra_loaded_from_non_sp = true; - break; - } - } - if (ra_loaded_from_non_sp) break; - } - if (config_.ws_backdrop_unsquash_funcs.count(cfg.function_start)) { - ss << config_.indent - << "gte_ws_set_suppress(0); /* widescreen: end far-backdrop un-squash (8C) */\n"; - } - if (config_.data_shard_funcs.count(cfg.function_start)) { - // Capture finalize: fires only when this is the armed - // function's own return (sp/ra checked runtime-side). - ss << config_.indent - << "psx_datashard_ret(cpu); /* data-shard: finalize capture */\n"; - } - if (ra_loaded_from_non_sp) { - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent - << "cpu->pc = " << delay_saved_target << "; psx_restore_state_escape(); return;" - << " /* jr $ra — longjmp-return (ra loaded from non-sp) */\n"; - } else if (cps_enabled_) { - // CPS: publish $ra so the flat trampoline dispatches the - // caller's continuation (no host C-return to nest). - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent - << "cpu->pc = " << delay_saved_target << "; return; /* CPS: jr $ra */\n"; - } else { - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << "return; /* jr $ra */\n"; - } - } else if (block.exit_instr.type == ControlFlowType::JumpRegister) { - // jr $rs — may be: BIOS call (jr $t2, t2=0xA0/0xB0/0xC0) - // or: jump table (jr $v0, v0 loaded from LW+ADDU+LUI) - uint32_t jr_rs = get_rs(block.exit_instr.instruction); - - // Detect MIPS jump-table pattern by scanning backwards from jr. - // Handles two common patterns: - // P1: lui $R,hi; addiu $R,$R,lo; addu $D,$idx,$R; lw $D,0($D) - // P2: lui $R,hi; addu $D,$R,$idx; lw $D,off($D) - // table_base = lui_val + addiu_val + lw_offset - ExactJumpTable exact_table; - bool have_exact_table = resolve_exact_bounded_jump_table( - exe_, cfg.function_start, cfg.function_end, - block.exit_instr.address, jr_rs, exact_table, - ram_to_rom, cfg.producer_lo, cfg.producer_hi); - uint32_t table_base = have_exact_table - ? exact_table.table_base : 0u; - uint32_t table_count = have_exact_table - ? exact_table.table_count : 0u; - // If we found a valid table, read entries and emit switch. - // table_base may be a RAM address (BIOS shell code copies - // ROM 0xBFC18000+ to RAM 0x80030000+). Translate to ROM - // so exe_.read_word() can read the data, and translate each - // entry so it matches cfg.blocks / extra_labels_ (ROM space). - bool emitted_switch = false; - if (table_base != 0 && table_count > 0) { - uint32_t rom_table_base = ram_to_rom(table_base, exe_); - std::set seen; - std::vector> targets; // {runtime_addr, rom_addr} - for (const auto& [runtime_addr, rom_addr] : - exact_table.targets) { - if (seen.insert(rom_addr).second && - (cfg.blocks.count(rom_addr) || extra_labels_.count(rom_addr))) { - targets.push_back({runtime_addr, rom_addr}); - } - } - if (!targets.empty()) { - ss << config_.indent << fmt::format("/* jump table 0x{:08X} (rom 0x{:08X}), {} entries */\n", - table_base, rom_table_base, table_count); - ss << config_.indent << fmt::format("switch ({}) {{\n", delay_saved_target); - for (auto& [rt, rom] : targets) { - if (partial_block_cycle_count(rom, cfg) != 0) { - ss << config_.indent << fmt::format(" case 0x{:08X}u:\n", rt); - ss << emit_interrupt_check(rom, config_.indent + " "); - ss << emit_mid_block_cycle_charge(rom, cfg, config_.indent + " "); - ss << config_.indent << fmt::format(" goto block_{:08X};\n", rom); - } else { - ss << config_.indent << fmt::format(" case 0x{:08X}u:\n", rt); - ss << emit_interrupt_check(rom, config_.indent + " "); - ss << config_.indent << fmt::format(" goto block_{:08X};\n", rom); - } - } - if (cps_enabled_) { - ss << config_.indent << " default:\n"; - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent + " "); - ss << config_.indent << " cpu->pc = " << delay_saved_target - << "; return; /* CPS: jr table miss — tail-transfer */\n"; - } else { - ss << config_.indent << " default:\n"; - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent + " "); - ss << config_.indent << " call_by_address(cpu, " << delay_saved_target << "); return;\n"; - } - ss << config_.indent << "}\n"; - emitted_switch = true; - } - } - if (!emitted_switch) { - if (cps_enabled_) { - // CPS: indirect jump / BIOS-call gate — tail-transfer - // to the target (the flat trampoline dispatches it). - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << fmt::format("cpu->pc = {}; return; /* CPS: jr {} */\n", - delay_saved_target, reg_name(jr_rs)); - } else { - // BIOS call or unrecognised indirect jump - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jr {} */\n", - delay_saved_target, reg_name(jr_rs)); - ss << config_.indent << "return;\n"; - } - } - } else if (block.exit_instr.type == ControlFlowType::JumpLink) { - uint32_t target = block.exit_instr.target; - uint32_t cont_addr = block.exit_instr.address + 8; - if (cps_enabled_) { - // CPS: tail-transfer to the callee. Set $ra to the - // return point and register it as a dispatchable - // continuation; the callee's jr $ra publishes cpu->pc = - // $ra and the flat trampoline dispatches back into this - // function's entry-switch. No host nesting. - // Only register a LOCAL switch case when the return PC - // lives in this piece (has a block label). When the - // delay/return was split out, successors is empty — the - // mid-function pre-pass promotes addr+8 to its own - // dispatch entry (do not emit goto block_). - if (!block.successors.empty()) { - cps_cur_continuations_.push_back(cont_addr); - } - ss << emit_interrupt_check(target, config_.indent); - ss << config_.indent - << fmt::format("cpu->pc = 0x{:08X}u; return; /* CPS jal -> 0x{:08X} */\n", - target, target); - } else { - // Function call (jal). The call contract (Bug D family) - // guards the continuation: it may only run if the guest - // actually returned here with the caller's $sp. - ss << config_.indent << "{ uint32_t _csp = cpu->gpr[29];\n"; - ss << emit_interrupt_check(target, config_.indent); - if (known_functions_.count(target) > 0) { - ss << config_.indent << fmt::format("func_{:08X}(cpu); /* jal */\n", target); - ss << config_.indent << fmt::format("if (psx_call_contract(cpu, 0x{:08X}u, _csp)) return; }}\n", addr + 8); - } else { - ss << config_.indent << fmt::format("call_by_address(cpu, 0x{:08X}u); /* external jal */\n", target); - /* psx_dispatch_call validated the (ra, sp) contract; - * only propagate an active bail unwind here. */ - ss << config_.indent << "if (g_psx_call_bail) return; (void)_csp; }\n"; - } - if (!block.successors.empty()) { - ss << config_.indent - << fmt::format("goto block_{:08X}; /* continue after call */\n", block.successors[0]); - } else { - // Split-function: JAL continuation is outside this function piece. - // Tail-call to the continuation piece (at exit_addr + 8, past delay slot). - if (known_functions_.count(cont_addr)) { - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* jal cont: split piece */\n", cont_addr); - } else { - ss << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* jal cont: split */\n", cont_addr); - } - } - } - } else if (block.exit_instr.type == ControlFlowType::JumpLinkReg) { - // Register indirect call (jalr $rs, $rd) — call function at rs, return to rd - uint32_t rs = get_rs(block.exit_instr.instruction); - uint32_t cont_addr = block.exit_instr.address + 8; - - if (cps_enabled_) { - // The target and link were committed before the delay - // slot above. Register a local continuation only when - // the return PC is in this piece (see jal). - if (!block.successors.empty()) { - cps_cur_continuations_.push_back(cont_addr); - } - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << "cpu->pc = " << delay_saved_target - << "; return; /* CPS jalr */\n"; - } else { - // Dispatch the pre-delay latched indirect target. - // Dispatch indirect call — target is a runtime register value - ss << emit_interrupt_check_expr(delay_saved_target, config_.indent); - ss << config_.indent << fmt::format("call_by_address(cpu, {}); /* jalr {} */\n", - delay_saved_target, reg_name(rs)); - /* psx_dispatch_call validated the (ra, sp) contract; - * only propagate an active bail unwind here. */ - ss << config_.indent << "if (g_psx_call_bail) return;\n"; - - // Continue to successor block (instruction at return address) - if (!block.successors.empty()) { - ss << config_.indent - << fmt::format("goto block_{:08X}; /* continue after jalr */\n", - block.successors[0]); - } else { - // Split-function: JALR continuation is outside this function piece. - if (known_functions_.count(cont_addr)) { - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* jalr cont: split piece */\n", cont_addr); - } else { - ss << config_.indent - << fmt::format("call_by_address(cpu, 0x{:08X}u); return; /* jalr cont: split */\n", cont_addr); - } - } - } - } - } - } - - addr += 4; - } - - // If no explicit control flow, fall through to the next block. When a - // CFG split or mid-function seed split placed the next instruction behind - // a C label/function boundary, preserve the physical fall-through without - // consuming an interrupt/cooldown check at the artificial boundary. - if (block.exit_instr.type == ControlFlowType::None && !block.successors.empty()) { - ss << config_.indent << fmt::format("/* fall through to block_{:08X} */\n", - block.successors[0]); - } else if (block.exit_instr.type == ControlFlowType::None) { - uint32_t next_addr = block.end_addr + 4; - if (known_functions_.count(next_addr) > 0) { - ss << config_.indent - << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", - next_addr); - } else { - ss << emit_interrupt_check(next_addr, config_.indent); - } - } - - return ss.str(); -} - -bool CodeGenerator::func_has_screen_extent_cull(const ControlFlowGraph& cfg) const { - // The GTE trivial-reject pairs a width compare (slti/sltiu, immediate from - // the per-game W set) with a height compare (immediate from the H set) in - // the same function. Presence of both is the signature of a screen-extent - // render funnel; a lone width compare elsewhere (rare) is left alone. - // Shared scan (ws_cull_detect.h) so every backend derives one verdict. - bool has_x = false, has_y = false; - for (uint32_t block_addr : cfg.block_order) { - const BasicBlock& block = cfg.blocks.at(block_addr); - for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) { - auto io = exe_.read_word(a); - if (!io.has_value()) continue; - uint32_t in = *io; - uint32_t op = in >> 26; - if (op != 0x0Au && op != 0x0Bu) continue; // not slti/sltiu - uint32_t im = in & 0xFFFFu; - if (psx_ws_cull_imm_in(im, config_.ws_cull_w_imms.data(), - (int)config_.ws_cull_w_imms.size())) has_x = true; - else if (psx_ws_cull_imm_in(im, config_.ws_cull_h_imms.data(), - (int)config_.ws_cull_h_imms.size())) has_y = true; - if (has_x && has_y) return true; - } - } - return false; -} - -int CodeGenerator::detect_cull_bltz_sites(const ControlFlowGraph& cfg) { - // Populate ws_cull_bltz_pcs_ with the function's LEFT-edge funnel bltz - // addresses (ws_cull_detect.h idioms 2/3). Only meaningful when the - // function already qualified (ws_auto_cull_func_). Build a contiguous word - // image over the function range (same approach as detect_backdrop_windows) - // so the structural classifier sees cross-block context. - ws_cull_bltz_pcs_.clear(); - if (!ws_auto_cull_func_) return 0; - uint32_t lo = cfg.function_start, hi = cfg.function_end; - if (hi <= lo || (hi - lo) > 0x40000u) return 0; // sanity bound - std::vector words; - words.reserve((hi - lo) / 4u); - for (uint32_t a = lo; a < hi; a += 4) { - auto io = exe_.read_word(a); - words.push_back(io.has_value() ? *io : 0u); - } - int n = (int)words.size(); - for (int i = 0; i < n; i++) - if (psx_ws_cull_bltz_here(words.data(), n, i, - config_.ws_cull_w_imms.data(), - (int)config_.ws_cull_w_imms.size())) - ws_cull_bltz_pcs_.insert(lo + (uint32_t)i * 4u); - return (int)ws_cull_bltz_pcs_.size(); -} - -int CodeGenerator::detect_backdrop_windows(const ControlFlowGraph& cfg) { - // Always clear so a stale map from the previous function can never leak. - ws_backdrop_sites_.clear(); - if (!config_.ws_auto_backdrop_preload) return 0; - - // Build a contiguous word image of the whole function range so the shared - // detector's absolute-PC math (base_pc + i*4) lands on real addresses; any - // non-code gap (jump table / constant pool) simply never matches the - // invariant and is harmless. base_pc = function_start. - uint32_t lo = cfg.function_start, hi = cfg.function_end; - if (hi <= lo || (hi - lo) > 0x40000u) return 0; // sanity bound - std::vector words; - words.reserve((hi - lo) / 4u); - for (uint32_t a = lo; a < hi; a += 4) { - auto io = exe_.read_word(a); - words.push_back(io.has_value() ? *io : 0u); - } - - WsBackdropSite sites[64]; - int ns = psx_ws_find_backdrop_windows(words.data(), (int)words.size(), lo, - sites, 64); - for (int i = 0; i < ns; i++) - ws_backdrop_sites_[sites[i].pc] = { sites[i].kind, sites[i].window_cols }; - return ns; -} - -GeneratedFunction CodeGenerator::generate_function( - const Function& func, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name) { - - GeneratedFunction result; - result.function_name = func.name; - { - std::string sig; - if (config_.hot_funcs.count(func.start_addr)) { - sig += "#if defined(__GNUC__) || defined(__clang__)\n" - "__attribute__((hot))\n" - "#endif\n"; - } - sig += fmt::format("void {}(CPUState* cpu)", func.name); - result.signature = std::move(sig); - } - - // Widescreen auto cull (gated): detect the screen-extent reject signature so - // translate_instruction widens this function's width compares. Cleared for - // every function so it never leaks across functions. The LEFT-edge funnel - // bltz sites (signed idioms) are classified alongside. - ws_auto_cull_func_ = config_.ws_auto_screen_x_cull && - func_has_screen_extent_cull(cfg); - detect_cull_bltz_sites(cfg); - - // Widescreen backdrop preload (gated): detect each scrolling-backdrop column - // window so translate_instruction rewrites its START/END finalize. Cleared + - // repopulated per function so sites never leak across functions. - { - int bd_windows = detect_backdrop_windows(cfg); - if (bd_windows > 0) - fmt::print(" [ws backdrop] {} site(s) in {} (0x{:08X})\n", - bd_windows, func.name, func.start_addr); - } - - // Pre-scan: find jump table targets that land mid-block (not a block boundary). - // These need inline labels so goto statements in the switch can reach them. - // Must run BEFORE translating blocks (translate_basic_block reads extra_labels_). - extra_labels_.clear(); - std::map> jr_table_edges; - scan_jr_tables(cfg, jr_table_edges); - - // CPS (§25): collect this function's continuations (call return points) as - // blocks are translated, so the entry-switch below can route a dispatched - // continuation address into the right block. - cps_cur_continuations_.clear(); - - // Translate blocks into a temp buffer first: the CPS entry-switch is emitted - // at the top (before the entry hooks) and must know every continuation. - std::stringstream blocks_ss; - for (uint32_t block_addr : cfg.block_order) { - const BasicBlock& block = cfg.blocks.at(block_addr); - blocks_ss << "\n" << translate_basic_block(block, cfg); - } - - // FAITHFUL RE-ENTRY — every basic-block leader is a dispatchable continuation - // (MMX6 boot-wedge class B, resume PC 0x8005B07C). psx_check_interrupts_at is - // emitted at EVERY block leader, so ANY leader can be published as an async-RFE - // / interp-handoff interrupt-resume PC. The compiled top-level trampoline can - // only re-enter a function at a registered continuation; a leader that is only - // an interrupt-resume point (not a CPS jal-return) was NOT registered, so a - // mid-function resume PC missed dispatch and the top loop read pc=0 = - // "execution completed" abnormal exit. Game functions keep ALL guest state - // canonical in cpu->gpr[]/RAM (no cross-block C-locals), so entering at - // block_LEADER from a fresh call reconstructs identical state to normal flow. - // Register every leader (the jal/jalr call-returns pushed during translation - // above are a subset, deduped by `seen`/the owner map). Entry block runs from - // the top (cpu->pc==0) and is dispatched as a function entry, so skip it. - if (cps_enabled_) { - for (uint32_t block_addr : cfg.block_order) { - if (block_addr == func.start_addr) continue; - cps_cur_continuations_.push_back(block_addr); - } - } - - std::stringstream body_ss; - body_ss << "{\n"; - body_ss << "#if defined(PSX_ENABLE_BLOCK_CYCLES) && " - "(defined(__GNUC__) || defined(__clang__))\n" - << config_.indent - << "__attribute__((cleanup(psx_cyc_bb_defer_cleanup))) " - "int _psx_cyc_bb_guard = 1;\n" - << config_.indent << "psx_cyc_bb_defer_begin();\n" - << "#endif\n"; - - // CPS entry-switch: when the unified flat trampoline dispatches a - // continuation address (a callee published cpu->pc = $ra back to us), route - // into the owning block. Emitted BEFORE the entry hooks so a continuation - // re-entry bypasses debug_server_log_call_entry / widescreen entry hooks - // (those must run only on a true function entry, cpu->pc == 0). - // Overlay mode must emit the cpu->pc guard even when the continuation set - // is EMPTY: a range re-entry can still request an interior PC of a - // single-block function, and without the guard the body runs from its top - // regardless of the requested PC — the same corrupting-fall-through this - // switch's fail-closed default exists to prevent. - if (cps_enabled_ && (!cps_cur_continuations_.empty() || config_.overlay_mode)) { - std::set seen; - body_ss << config_.indent << "if (cpu->pc != 0u) {\n"; - body_ss << config_.indent << " uint32_t _cont = cpu->pc; cpu->pc = 0;\n"; - body_ss << config_.indent << " switch (_cont) {\n"; - for (uint32_t c : cps_cur_continuations_) { - if (!seen.insert(c).second) continue; - if (partial_block_cycle_count(c, cfg) != 0) { - body_ss << config_.indent << fmt::format(" case 0x{:08X}u:\n", c); - body_ss << emit_mid_block_cycle_charge(c, cfg, config_.indent + " "); - body_ss << config_.indent << fmt::format(" goto block_{:08X};\n", c); - } else { - body_ss << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", c, c); - } - cps_continuation_owner_[c] = func.start_addr; - } - if (config_.overlay_mode) { - // Overlay functions: FAIL CLOSED on a foreign interior entry (the - // Tomba/Tomba2 native-overlay "blue screen" wedge). Entry at the true - // prologue (cpu->pc == start_addr) runs from the top. Any OTHER non-zero - // _cont is a foreign interior entry (a range-ownership mismatch routed a - // PC this function does not own into it). The old `default: break` ran - // the function FROM ITS TOP -- corrupting shared CPU/RAM state and - // wedging. Instead restore the requested PC and signal the overlay - // dispatch to route it to the sanctioned dirty-RAM interpreter - // (psx_native_bad_entry). NEVER run from the top. - if (seen.insert(func.start_addr).second) { - body_ss << config_.indent - << fmt::format(" case 0x{:08X}u: break; /* entry at prologue */\n", - func.start_addr); - } - body_ss << config_.indent - << fmt::format(" default: cpu->pc = _cont; " - "psx_native_bad_entry(cpu, 0x{:08X}u, _cont); return;\n", - func.start_addr); - } else { - // Static (BIOS/boot-EXE) functions keep the legacy fall-through: the - // generated static trampoline does not consume the bad-entry signal, - // and static ranges are discovered once with no overlay multi-variant - // ownership ambiguity. (The fail-closed default is overlay-scoped.) - body_ss << config_.indent << " default: break;\n"; - } - body_ss << config_.indent << " }\n"; - body_ss << config_.indent << "}\n"; - } - - body_ss << config_.indent - << fmt::format("debug_server_log_call_entry(0x{:08X}u);\n", - func.start_addr); - { - const auto vq = config_.vsync_query_hle_funcs.find(func.start_addr); - if (vq != config_.vsync_query_hle_funcs.end()) { - body_ss << config_.indent - << fmt::format( - "if (psx_vsync_query_hle_enter(cpu, 0x{:08X}u, 0x{:08X}u, " - "0x{:08X}u, 0x{:08X}u, 0x{:08X}u)) return;" - " /* load accel: cycle-faithful VSync(-1) query */\n", - func.start_addr, vq->second[0], vq->second[1], - vq->second[2], vq->second[3]); - } - } - if (config_.data_shard_funcs.count(func.start_addr)) { - // Data-shard hook (docs/DATA_SHARDS.md): on a verified shard hit the - // runtime applies the recorded effect, credits the recorded cycles, - // publishes pc=$ra and we return without executing the body. Otherwise - // it arms the capture recorder and the body runs natively. - body_ss << config_.indent - << fmt::format("if (psx_datashard_enter(cpu, 0x{:08X}u)) return;" - " /* data-shard: replay or arm capture */\n", - func.start_addr); - } - if (config_.mod_function_entry_funcs.count(func.start_addr)) { - body_ss << config_.indent - << fmt::format( - "psx_mod_function_entry(cpu, 0x{:08X}u);" - " /* trusted opt-in game-mod hook */\n", - func.start_addr); - } - if (config_.ws_sprite_tag_funcs.count(func.start_addr)) { - body_ss << config_.indent - << "psx_ws_sprite_tag(cpu); /* widescreen: record prim ($a0) + anchor */\n"; - } - if (config_.ws_bg2d_init_func == func.start_addr) { - body_ss << config_.indent - << "psx_ws_mmx6_bg_stage_init(); /* widescreen: invalidate stale reveal at stage BG init */\n"; - } - if (config_.ws_backdrop_unsquash_funcs.count(func.start_addr)) { - body_ss << config_.indent - << "gte_ws_set_suppress(1); /* widescreen: un-squash far backdrop (8C) */\n"; - } - - // Add function comment - if (config_.emit_comments) { - body_ss << config_.indent - << fmt::format("/* Address: 0x{:08X}, Size: {} bytes, Blocks: {} */\n", - func.start_addr, func.size, cfg.blocks.size()); - } - - body_ss << blocks_ss.str(); - - // Emit fallthrough call if the function ends without explicit control flow. - // A MIPS function truly falls through when: - // 1. It has NO jr/jr $ra block anywhere (no explicit return or indirect jump) - // 2. Its last basic block (by address) ends with no control flow (ControlFlowType::None) - // OR it ends with a branch/jump whose targets are all outside this function - // (indicating a split-function where the branch targets are in the next piece) - // Functions that have dead code after a jr $ra (e.g., padding) must NOT get a - // fallthrough call — condition 1 guards against that. - if (!fallthrough_name.empty() && !cfg.block_order.empty()) { - const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); - bool needs_fallthrough = false; - - if (last_block.exit_instr.type == ControlFlowType::None) { - needs_fallthrough = true; - } else if ((last_block.exit_instr.type == ControlFlowType::Branch || - last_block.exit_instr.type == ControlFlowType::Jump) && - last_block.successors.empty()) { - // Branch/jump with no in-function successors — likely a split-function bug. - // All targets are in the next function piece. Emit fallthrough as a safety net. - fmt::print(" WARNING: func_{:08X} has out-of-function {} at block_{:08X}, " - "emitting fallthrough to {}\n", - func.start_addr, - last_block.exit_instr.type == ControlFlowType::Branch ? "branch" : "jump", - last_block.start_addr, fallthrough_name); - needs_fallthrough = true; - } - - if (needs_fallthrough) { - // Emit fallthrough if the last block is reachable (has predecessors - // or is the entry block). Dead code after a return (e.g., padding - // nops) has no predecessors and should NOT get a fallthrough call. - const BasicBlock& last = cfg.blocks.at(cfg.block_order.back()); - bool is_reachable = last.is_entry || !last.predecessors.empty(); - if (is_reachable) { - body_ss << fmt::format(" {}(cpu); /* fallthrough to next function */\n", - fallthrough_name); - } - } - } - body_ss << " ; /* label compatibility: C requires a statement after the last label */\n"; - body_ss << "}\n"; - - result.body = body_ss.str(); - - // Prepend annotation comment if one exists for this function's address - std::string annotation_prefix; - if (annotations_) { - const std::string& note = annotations_->lookup(func.start_addr); - if (!note.empty()) - annotation_prefix = fmt::format("/* [NOTE] {} */\n", note); - } - - result.full_code = annotation_prefix + result.signature + "\n" + result.body; - result.line_count = std::count(result.full_code.begin(), result.full_code.end(), '\n'); - - return result; -} - -void CodeGenerator::scan_jr_tables( - const ControlFlowGraph& cfg, - std::map>& out_edges) { - - for (const auto& [baddr, blk] : cfg.blocks) { - if (blk.exit_instr.type != ControlFlowType::JumpRegister) continue; - uint32_t jr_r = get_rs(blk.exit_instr.instruction); - ExactJumpTable exact_table; - if (!resolve_exact_bounded_jump_table( - exe_, cfg.function_start, cfg.function_end, - blk.exit_instr.address, jr_r, exact_table, ram_to_rom, - cfg.producer_lo, cfg.producer_hi)) { - continue; - } - uint32_t tb = exact_table.table_base; - uint32_t tc = exact_table.table_count; - if (tb==0 || tc==0) continue; - for (const auto& target_pair : exact_table.targets) { - uint32_t t = target_pair.second; - if (t >= cfg.function_start && t < cfg.function_end) { - out_edges[baddr].push_back(t); - if (!cfg.blocks.count(t)) - extra_labels_.insert(t); - } - } - } -} - -std::vector CodeGenerator::generate_alias_group( - const std::vector& aliases, - const ControlFlowGraph& cfg, - const std::string& fallthrough_name) { - - std::vector results; - if (aliases.empty()) return results; - uint32_t host = aliases[0]->alias_walk_lo; - - // Widescreen auto cull (gated): set per-group so a stale value from the last - // generate_function() call can't leak into this body. (See generate_function.) - ws_auto_cull_func_ = config_.ws_auto_screen_x_cull && - func_has_screen_extent_cull(cfg); - detect_cull_bltz_sites(cfg); - - // Backdrop preload sites for this alias group's shared CFG (see - // generate_function). Set per-group so a stale map can't leak in. - { - int bd_windows = detect_backdrop_windows(cfg); - if (bd_windows > 0) - fmt::print(" [ws backdrop] {} site(s) in alias host 0x{:08X}\n", - bd_windows, host); - } - - // Jump-table edges + mid-block labels for this host's CFG. - extra_labels_.clear(); - std::map> jr_table_edges; - scan_jr_tables(cfg, jr_table_edges); - - // CPS (§25): collect this alias group's continuations during block - // translation (also prevents a stale list from leaking into the next - // generate_function call). - cps_cur_continuations_.clear(); - - // Union of blocks reachable from any alias entry. Edges are - // BasicBlock::successors plus jump-table targets (mapped to their - // containing block — table targets may be mid-block labels). - auto containing_block = [&](uint32_t a) -> uint32_t { - auto it = cfg.blocks.upper_bound(a); - if (it == cfg.blocks.begin()) return 0; - --it; - return (a >= it->second.start_addr && a <= it->second.end_addr) - ? it->first : 0; - }; - std::set live_blocks; - std::vector work; - for (const Function* a : aliases) { - uint32_t eb = containing_block(a->start_addr); - if (eb != 0) work.push_back(eb); - } - while (!work.empty()) { - uint32_t b = work.back(); - work.pop_back(); - if (!live_blocks.insert(b).second) continue; - const BasicBlock& blk = cfg.blocks.at(b); - for (uint32_t s : blk.successors) { - if (cfg.blocks.count(s)) work.push_back(s); - } - auto je = jr_table_edges.find(b); - if (je != jr_table_edges.end()) { - for (uint32_t t : je->second) { - uint32_t cb = containing_block(t); - if (cb != 0) work.push_back(cb); - } - } - } - - // Translate live blocks first (populates cps_cur_continuations_ for the CPS - // continuation switch below). - std::stringstream blocks_buf; - for (uint32_t block_addr : cfg.block_order) { - if (!live_blocks.count(block_addr)) continue; - const BasicBlock& block = cfg.blocks.at(block_addr); - blocks_buf << "\n" << translate_basic_block(block, cfg); - } - - // FAITHFUL RE-ENTRY — every live block leader is a dispatchable continuation - // (see generate_function for the full rationale: psx_check_interrupts_at is at - // every leader, so any leader can be an async-RFE/interp-handoff resume PC and - // must be re-enterable, not just CPS jal-returns). Alias-set blocks live behind - // an entry switch; the leaders re-enter via the continuation switch below. - // Owner = aliases[0]->start_addr (same as the call-return continuations). Skip - // the alias entry PCs (they are dispatched as function entries with cpu->pc==0). - if (cps_enabled_) { - std::set alias_entries; - for (const Function* a : aliases) alias_entries.insert(a->start_addr); - for (uint32_t block_addr : cfg.block_order) { - if (!live_blocks.count(block_addr)) continue; - if (alias_entries.count(block_addr)) continue; - cps_cur_continuations_.push_back(block_addr); - } - } - - // Shared body: host-range blocks (live subset) behind an entry switch. - std::stringstream body; - body << fmt::format("/* Overlapping-alias body for host func_{:08X}: {} entries */\n", - host, aliases.size()); - body << fmt::format("void psx_alias_body_{:08X}(CPUState* cpu, uint32_t entry)\n{{\n", - host); - alias_body_decls_.push_back(fmt::format("void psx_alias_body_{:08X}(CPUState* cpu, uint32_t entry)", host)); - // CPS (§25): a dispatched continuation (callee published cpu->pc = $ra) is - // routed into its block here, BEFORE the entry switch — cpu->pc overrides - // the `entry` arg. The dispatch routes a continuation by calling the first - // alias wrapper with cpu->pc set (entry is then ignored). Owner = - // aliases[0]->start_addr. - if (cps_enabled_ && (!cps_cur_continuations_.empty() || config_.overlay_mode)) { - std::set seen; - body << config_.indent << "if (cpu->pc != 0u) {\n"; - body << config_.indent << " uint32_t _cont = cpu->pc; cpu->pc = 0;\n"; - body << config_.indent << " switch (_cont) {\n"; - for (uint32_t c : cps_cur_continuations_) { - if (!seen.insert(c).second) continue; - body << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", c, c); - cps_continuation_owner_[c] = aliases[0]->start_addr; - } - if (config_.overlay_mode) { - // Alias entry PCs are excluded from cps_cur_continuations_ (they - // dispatch as function entries with cpu->pc == 0), but a RANGE - // re-entry can still request one with cpu->pc set — route it to - // its block, exactly as the entry switch below would. - for (const Function* a : aliases) { - if (!seen.insert(a->start_addr).second) continue; - body << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", - a->start_addr, a->start_addr); - } - // FAIL CLOSED on any other interior PC (same contract as - // generate_function's overlay entry switch, PR #46). The old - // `default: break` fell through to `switch (entry)` and ran the - // ALIAS'S OWN block for a PC it does not own — the Tomba 2 - // splash→title reload death: dispatch(0x80089788) resolved to the - // alias func_80089770, which re-ran the crt0 shim tail (reload - // $ra, call main-init) in an infinite loop, leaking 0x40 of guest - // stack per iteration until the frames overwrote the freshly - // loaded module code itself. Restore the requested PC and signal - // the overlay dispatch to route it to the dirty-RAM interpreter. - body << config_.indent - << fmt::format(" default: cpu->pc = _cont; " - "psx_native_bad_entry(cpu, 0x{:08X}u, _cont); return;\n", - aliases[0]->start_addr); - } else { - body << config_.indent << " default: break;\n"; - } - body << config_.indent << " }\n"; - body << config_.indent << "}\n"; - } - body << config_.indent << "switch (entry) {\n"; - for (const Function* a : aliases) { - body << config_.indent - << fmt::format(" case 0x{:08X}u: goto block_{:08X};\n", - a->start_addr, a->start_addr); - } - if (config_.overlay_mode) { - // A dispatched entry outside the alias set must not silently no-op: - // the loader would count it as executed. Fail closed to the interp. - body << config_.indent - << fmt::format(" default: cpu->pc = entry; " - "psx_native_bad_entry(cpu, 0x{:08X}u, entry); return;\n", - aliases[0]->start_addr); - } else { - body << config_.indent << " default: return;\n"; - } - body << config_.indent << "}\n"; - - body << blocks_buf.str(); - - // Host-end fallthrough (mirrors generate_function): only relevant when the - // host's final block is live and ends without explicit control flow. - if (!fallthrough_name.empty() && !cfg.block_order.empty() && - live_blocks.count(cfg.block_order.back())) { - const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); - bool needs_fallthrough = - (last_block.exit_instr.type == ControlFlowType::None) || - ((last_block.exit_instr.type == ControlFlowType::Branch || - last_block.exit_instr.type == ControlFlowType::Jump) && - last_block.successors.empty()); - if (needs_fallthrough) { - body << fmt::format(" {}(cpu); /* fallthrough to next function */\n", - fallthrough_name); - } - } - body << " ; /* label compatibility */\n"; - body << "}\n"; - - // One dispatchable wrapper per alias entry; the first carries the body. - for (size_t i = 0; i < aliases.size(); ++i) { - const Function* a = aliases[i]; - GeneratedFunction gf; - gf.function_name = a->name; - gf.signature = fmt::format("void {}(CPUState* cpu)", a->name); - gf.body = fmt::format( - "{{\n" - " debug_server_log_call_entry(0x{:08X}u);\n" - " psx_alias_body_{:08X}(cpu, 0x{:08X}u); /* alias entry into host func_{:08X} */\n" - "}}\n", - a->start_addr, host, a->start_addr, host); - gf.full_code = (i == 0 ? body.str() + "\n" : std::string()) + - gf.signature + "\n" + gf.body; - gf.line_count = std::count(gf.full_code.begin(), gf.full_code.end(), '\n'); - results.push_back(std::move(gf)); - } - return results; -} - -std::vector CodeGenerator::generate_all_functions( - const std::vector& functions, - const std::map& cfgs) { - - std::vector results; - results.reserve(functions.size()); - - // CPS (§25): rebuild the continuation->owner map for this full pass so the - // game dispatch table (psx_dispatch_game_compiled) reflects exactly the - // functions emitted here (the final generate_file pass is authoritative). - cps_continuation_owner_.clear(); - alias_body_decls_.clear(); - - fmt::print("\n=== C Code Generation ===\n\n"); - fmt::print("Generating C code for {} functions...\n", functions.size()); - - // Build address -> function name map for fallthrough detection. - // When a function ends without explicit control flow (ControlFlowType::None) - // and the very next address is another function's start, it's a MIPS - // fall-through: the recompiler must emit a tail call to the continuation. - std::map func_name_by_addr; - for (const Function& f : functions) { - func_name_by_addr[f.start_addr] = f.name; - } - - // Group overlapping-alias entries by host: each group is emitted as one - // shared static body plus per-entry wrappers (see generate_alias_group), - // not as per-alias duplicates of the host's blocks. - std::map> alias_groups; - for (const Function& f : functions) { - if (f.alias_walk_lo != 0) alias_groups[f.alias_walk_lo].push_back(&f); - } - std::set alias_groups_emitted; - - int total_lines = 0; - - for (const Function& func : functions) { - if (func.alias_walk_lo != 0) { - if (!alias_groups_emitted.insert(func.alias_walk_lo).second) continue; - const auto& group = alias_groups.at(func.alias_walk_lo); - if (cfgs.count(func.start_addr) == 0) continue; - const ControlFlowGraph& cfg = cfgs.at(func.start_addr); - - // Host-end fallthrough target (all group members share end_addr). - std::string fallthrough_name; - auto ft_it = func_name_by_addr.find(func.end_addr); - if (ft_it != func_name_by_addr.end()) fallthrough_name = ft_it->second; - - auto group_funcs = generate_alias_group(group, cfg, fallthrough_name); - for (auto& gf : group_funcs) { - total_lines += gf.line_count; - results.push_back(std::move(gf)); - } - continue; - } - if (func.is_data_section) { - GeneratedFunction stub; - stub.function_name = func.name; - stub.signature = fmt::format("void {}(CPUState* cpu)", func.name); - stub.body = fmt::format( - "{{\n psx_unknown_dispatch(cpu, 0x{:08X}u, 0x{:08X}u);\n}}\n", - func.start_addr, func.start_addr & 0x1FFFFFFFu); - stub.full_code = stub.signature + "\n" + stub.body; - stub.line_count = 4; - stub.dispatchable = false; - results.push_back(stub); - continue; - } - - if (cfgs.count(func.start_addr) == 0) { - continue; // Skip if no CFG - } - - const ControlFlowGraph& cfg = cfgs.at(func.start_addr); - - // Detect fallthrough: is there a function immediately after this one? - std::string fallthrough_name; - uint32_t next_addr = func.start_addr + func.size; - auto ft_it = func_name_by_addr.find(next_addr); - if (ft_it != func_name_by_addr.end()) { - fallthrough_name = ft_it->second; - } - - GeneratedFunction gen_func = generate_function(func, cfg, fallthrough_name); - - // PSX_EMIT_PROD (DEFAULT ON): a "function" whose body is overwhelmingly - // untranslatable (TODO-opcode) words is a data region that discovery's - // primary-opcode-only validity check let slip through as code (valid - // primary opcode, garbage sub-field). Re-emit it as the same compact, - // fail-closed data stub used for is_data_section — if it is ever - // dispatched (it should not be: real reachable code never hits a TODO) - // psx_unknown_dispatch fatals LOUDLY rather than silently no-op'ing. - // This is strictly more faithful than the baseline it replaces (which - // emitted a SILENT no-op for the same words). Smoke-validated on Tomba, - // Tomba2, Ape Escape, MMX6. Set PSX_EMIT_PROD=0 to restore the full-scan - // (oracle) emission. Proper productionization moves this judgment UPSTREAM - // into discovery's validity check (sub-field aware) in function_analysis. - static int s_emit_prod = -1; - if (s_emit_prod < 0) { - const char* e = std::getenv("PSX_EMIT_PROD"); - s_emit_prod = (e && *e == '0') ? 0 : 1; // default ON; =0 restores full-scan baseline - } - if (s_emit_prod) { - size_t todo = 0, pos = 0; - while ((pos = gen_func.body.find("TODO: opcode", pos)) != std::string::npos) { todo++; pos += 12; } - pos = 0; - while ((pos = gen_func.body.find("TODO: SPECIAL", pos)) != std::string::npos) { todo++; pos += 13; } - uint32_t instrs = func.size / 4u; - if (instrs > 0 && todo * 2u >= instrs) { // >= 50% untranslatable => data - GeneratedFunction data_stub; - data_stub.function_name = func.name; - data_stub.signature = fmt::format("void {}(CPUState* cpu)", func.name); - data_stub.body = fmt::format( - "{{\n psx_unknown_dispatch(cpu, 0x{:08X}u, 0x{:08X}u);\n}}\n", - func.start_addr, func.start_addr & 0x1FFFFFFFu); - data_stub.full_code = data_stub.signature + "\n" + data_stub.body; - data_stub.line_count = 4; - data_stub.dispatchable = false; - total_lines += data_stub.line_count; - results.push_back(std::move(data_stub)); - continue; - } - } - - total_lines += gen_func.line_count; - results.push_back(gen_func); - } - - fmt::print("✓ Generated {} functions\n", results.size()); - fmt::print("✓ Total C code: {} lines\n\n", total_lines); - - return results; -} - -void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { - /* Release inlines debug_server_log_call_entry via debug_server.h - * (included from psx_runtime.h). Debug builds need the extern. */ - ss << "#ifndef PSX_NO_DEBUG_TOOLS\n"; - ss << "extern void debug_server_log_call_entry(uint32_t func_addr);\n"; - ss << "extern void debug_server_cyc_observe(uint32_t block_leader_phys);\n"; - ss << "#endif\n"; - ss << "#ifdef PSX_COSIM\n"; - ss << "extern void cosim_block(uint32_t block_leader_phys);\n"; - ss << "extern void cosim_instr(uint32_t pc);\n"; - ss << "#endif\n"; - ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; - ss << "extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; - ss << "extern void disruptor_vertical_camera_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned vertical-camera seam */\n"; - ss << "extern void disruptor_far_rendering_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned far-rendering seam */\n"; - ss << "extern void disruptor_billboard_aspect_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned world-billboard seam */\n"; - ss << "extern void disruptor_backdrop_tiles_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned backdrop-tile seam */\n"; - ss << "extern void disruptor_sprite_depth_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned sprite-depth seam */\n"; - ss << "extern void disruptor_intro_skip_instruction_hook(CPUState* cpu, uint32_t address, uint32_t instruction, int phase); /* version-pinned intro-skip seam */\n"; - ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; - ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; - ss << "extern void psx_ws_sprite_tag(CPUState* cpu); /* widescreen prim tag (gpu.c) */\n"; - ss << "extern void psx_ws_mmx6_bg_stage_init(void); /* ws 2D stage reveal invalidation (gpu.c) */\n"; - ss << "extern int psx_ws_x_margin(void); /* widescreen cull-margin term (gpu.c) */\n"; - ss << "extern int32_t psx_ws_player_x_bound(int32_t vanilla); /* typed gameplay X bound */\n"; - ss << "extern int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); /* ws auto screen-x cull (gpu.c) */\n"; - ss << "extern int psx_ws_cull_slti(uint32_t sx, uint32_t imm); /* ws cull signed right edge (gpu.c) */\n"; - ss << "extern int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); /* ws cull signed lower edge (gpu.c) */\n"; - ss << "extern int32_t psx_ws_cull_lower_value(int32_t vanilla); /* ws manual lower bound (gpu.c) */\n"; - ss << "extern int32_t psx_ws_cull_upper_value(int32_t vanilla); /* ws manual upper bound (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_cull_span_value(uint32_t vanilla); /* ws packed [left,right] span (gpu.c) */\n"; - ss << "extern int psx_ws_cull_bltz(uint32_t v); /* ws cull signed left edge (gpu.c) */\n"; - ss << "extern int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* ws masked-u16 X window */\n"; - ss << "extern int32_t psx_ws_depth_bound(int32_t imm); /* ws aspect-scaled far bound */\n"; - ss << "extern int32_t psx_ws_plane_nx(int32_t nx); /* ws side-plane normal-X scale (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_xclip_bound(uint32_t vanilla); /* ws per-prim X reject bound load (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); /* ws guarded keep compare (gpu.c) */\n"; - ss << "extern uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, uint32_t object, int32_t x, int32_t z, int32_t y); /* aspect-aware participation cone */\n"; - ss << "extern uint32_t psx_ws_angle_widen(uint32_t vanilla); /* aspect-scaled 12-bit terrain-frustum half-angle */\n"; - ss << "extern int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); /* aspect-scaled wrapping yaw half-FOV */\n"; - ss << "extern uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); /* aspect-scaled signed horizontal side plane */\n"; - ss << "extern int psx_ws_backdrop_x(int x); /* widescreen backdrop screenX squash (gpu.c) */\n"; - ss << "extern int psx_ws_project_x(int x); /* classic-wide CPU projection squash (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_cols(int base); /* ws 2D bg tile-loop widen: col count (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_startcol(int col, unsigned mask); /* ws 2D bg tile-loop widen: start tile col (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_startx(int x); /* ws 2D bg tile-loop widen: start screen-x (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_stream_left(int x); /* ws 2D bg tile-ring streamer: left edge (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_stream_right(int x); /* ws 2D bg tile-ring streamer: right edge (gpu.c) */\n"; - ss << "extern int psx_ws_bg2d_undercap(int counter, int native_cap); /* ws 2D BG packet cap (gpu.c) */\n"; - ss << "extern int psx_ws_mmx6_bg_bufbase(int addr); /* ws 2D bg packet-buffer relocation (gpu.c) */\n"; - ss << "extern int psx_ws_mmx6_bg_undercap(int counter);/* ws 2D bg per-frame tile cap (gpu.c) */\n"; - ss << "extern int psx_game_option_store(uint32_t addr, int val); /* persisted OPTION restore-at-init (game_options.c) */\n"; - ss << "extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ws backdrop preload (gpu.c) */\n"; - ss << "extern void gte_ws_set_suppress(int on); /* widescreen far-backdrop un-squash (gte.cpp) */\n"; - ss << "extern uint32_t gte_precision_mfc2_pc_read(uint32_t pc, uint32_t insn, uint8_t reg, uint32_t packed); /* reviewed MFC2 snapshot */\n"; - ss << "extern void gte_precision_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed ordinary SW */\n"; - ss << "extern void gte_precision_scratch_store_pc_word(uint32_t pc, uint32_t insn, uint32_t addr, uint32_t packed); /* reviewed scratchpad SW */\n"; - ss << "extern uint32_t gte_precision_copy_pc_read(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy LW */\n"; - ss << "extern void gte_precision_copy_pc_word(uint32_t pc, uint32_t insn, uint8_t gpr, uint32_t addr, uint32_t packed); /* reviewed packet-copy SW */\n"; - ss << "extern uint32_t g_debug_last_store_pc; /* exact PC of the executing SW/SH/SB — wtrace/readtrace producer attribution (debug_server.c) */\n\n"; -} - -void CodeGenerator::emit_unaligned_helpers(std::ostream& ss, bool as_inline) const { - const char* kw = as_inline ? "static inline " : "static "; - - // Emit reference implementations for unaligned memory helpers. - // These implement the MIPS lwl/lwr/swl/swr semantics. - // The runtime may override these by setting the corresponding function pointers - // in CPUState, but these standalone functions provide correct reference behavior. - ss << "/* --- Unaligned memory access helper implementations --- */\n"; - ss << "\n"; - ss << "/* LWL/LWR/SWL/SWR implement little-endian R3000A unaligned word\n"; - ss << " * semantics. Keep these switch tables in sync with psx_interpreter.c\n"; - ss << " * and dirty_ram_interp.c.\n"; - ss << " */\n"; - ss << "\n"; - ss << "/* LWL: Load Word Left - merges high bytes from aligned word into rt.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the current value of the destination register.\n"; - ss << " */\n"; - ss << kw << "uint32_t psx_lwl(CPUState* cpu, uint32_t addr, uint32_t rt_value, uint32_t rt, uint32_t reg_mask) {\n"; - ss << " uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, reg_mask); /* full load interlock */\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: return (rt_value & 0x00FFFFFFu) | (word << 24);\n"; - ss << " case 1: return (rt_value & 0x0000FFFFu) | (word << 16);\n"; - ss << " case 2: return (rt_value & 0x000000FFu) | (word << 8);\n"; - ss << " default: return word;\n"; - ss << " }\n"; - ss << "}\n"; - ss << "\n"; - ss << "/* LWR: Load Word Right - merges low bytes from aligned word into rt.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the current value of the destination register.\n"; - ss << " */\n"; - ss << kw << "uint32_t psx_lwr(CPUState* cpu, uint32_t addr, uint32_t rt_value, uint32_t rt, uint32_t reg_mask) {\n"; - ss << " uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, reg_mask); /* full load interlock */\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: return word;\n"; - ss << " case 1: return (rt_value & 0xFF000000u) | (word >> 8);\n"; - ss << " case 2: return (rt_value & 0xFFFF0000u) | (word >> 16);\n"; - ss << " default: return (rt_value & 0xFFFFFF00u) | (word >> 24);\n"; - ss << " }\n"; - ss << "}\n"; - ss << "\n"; - ss << "/* SWL: Store Word Left - stores the high bytes of rt_value into memory.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the value of the source register.\n"; - ss << " */\n"; - ss << kw << "void psx_swl(CPUState* cpu, uint32_t addr, uint32_t rt_value) {\n"; - ss << " psx_store_cycle_barrier();\n"; - ss << " uint32_t aligned_addr = addr & ~3u;\n"; - ss << " uint32_t word = cpu->read_word(aligned_addr);\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: word = (word & 0xFFFFFF00u) | (rt_value >> 24); break;\n"; - ss << " case 1: word = (word & 0xFFFF0000u) | (rt_value >> 16); break;\n"; - ss << " case 2: word = (word & 0xFF000000u) | (rt_value >> 8); break;\n"; - ss << " default: word = rt_value; break;\n"; - ss << " }\n"; - ss << " cpu->write_word(aligned_addr, word);\n"; - ss << "}\n"; - ss << "\n"; - ss << "/* SWR: Store Word Right - stores the low bytes of rt_value into memory.\n"; - ss << " * addr is the effective (possibly unaligned) address.\n"; - ss << " * rt_value is the value of the source register.\n"; - ss << " */\n"; - ss << kw << "void psx_swr(CPUState* cpu, uint32_t addr, uint32_t rt_value) {\n"; - ss << " psx_store_cycle_barrier();\n"; - ss << " uint32_t aligned_addr = addr & ~3u;\n"; - ss << " uint32_t word = cpu->read_word(aligned_addr);\n"; - ss << " switch (addr & 3u) {\n"; - ss << " case 0: word = rt_value; break;\n"; - ss << " case 1: word = (word & 0x000000FFu) | (rt_value << 8); break;\n"; - ss << " case 2: word = (word & 0x0000FFFFu) | (rt_value << 16); break;\n"; - ss << " default: word = (word & 0x00FFFFFFu) | (rt_value << 24); break;\n"; - ss << " }\n"; - ss << " cpu->write_word(aligned_addr, word);\n"; - ss << "}\n"; - ss << "\n"; -} - -std::string CodeGenerator::build_shared_decls_header(const std::vector& gen_funcs) const { - std::stringstream h; - h << "/* Generated by PSXRecomp - shared declarations for split full.c shards. DO NOT EDIT. */\n"; - h << "#pragma once\n"; - h << "#include \"psx_runtime.h\"\n\n"; - emit_runtime_externs(h); - emit_unaligned_helpers(h, /*as_inline=*/true); - if (!gen_funcs.empty()) { - h << "/* Forward declarations for all recompiled functions */\n"; - for (const auto& gf : gen_funcs) h << gf.signature << ";\n"; - for (const auto& decl : alias_body_decls_) h << decl << ";\n"; - h << "\n"; - } - return h.str(); -} - -std::string CodeGenerator::generate_file( - const std::vector& functions, - const std::map& cfgs) { - - std::stringstream ss; - - // File header - ss << "/* Generated by PSXRecomp - PlayStation 1 Static Recompiler */\n\n"; - - // Include the generic PSX runtime header. - // This provides CPUState, GTE/trap declarations, and call_by_address(). - ss << "#include \"psx_runtime.h\"\n\n"; - emit_runtime_externs(ss); - emit_unaligned_helpers(ss, /*as_inline=*/false); - - // Make mutable copies for potential function splitting - std::vector functions_mut(functions); - std::map cfgs_mut(cfgs); - - // Build initial set of known functions - std::set known_addrs; - for (const auto& func : functions_mut) { - known_addrs.insert(func.start_addr); - } - - // ---- Pre-pass: find mid-function branch/jump targets and split ---- - // Scan all CFGs for branch/jump targets that fall within a function's - // range but are NOT at any function start. Split containing functions - // at discovered targets. Iterate until convergence (no new mid-targets - // found), since splitting creates new pieces whose branches may target - // other mid-piece addresses. Each subsequent pass only scans CFGs that - // were rebuilt in the prior pass. - // - // Previously hard-capped at 3 iterations; Tomba's `func_800905DC` had - // 2 mid-func targets (0x800905E4, 0x80090600) that needed a 4th pass. - // The unconverged stragglers fell through to the - // `call_by_address(cpu, 0xX); return;` emit path (code_generator.cpp - // lines 970/981/998), which at runtime missed the dispatch table and - // hit `psx_unknown_dispatch`. Surfaced by Phase B2 audit. The 16-pass - // cap is a safety limit; in practice each pass strictly reduces the - // remaining target set so convergence is fast. - // - // Overlay exact mode disables this: overlay branch targets are basic-block - // labels, not callable function entries, so splitting them into standalone - // functions produces broken dispatch — the mid-function-seed failure mode. - if (config_.split_mid_function_targets) { - std::set cfgs_to_scan; // Which CFGs to scan (empty = all) - uint32_t exe_start = exe_.header.load_address; - uint32_t exe_end = exe_.end_address(); - int total_new = 0; - // Safety cap only — the loop must run to convergence. Unconverged - // targets emit `call_by_address(mid-func); return;` which misses the - // dispatch table at runtime. 16 proved too low once data-scan - // promotions and alias entries widened the function set. - const int MAX_PASSES = 256; - bool converged = false; - - for (int pass = 0; pass < MAX_PASSES; pass++) { - std::set mid_targets; - - // Scan either all CFGs (pass 0) or only rebuilt CFGs (pass 1+) - auto scan_cfg = [&](const ControlFlowGraph& cfg) { - for (const auto& [baddr, block] : cfg.blocks) { - auto check_target = [&](uint32_t target) { - if (target == 0) return; - if (cfg.blocks.count(target)) return; - if (known_addrs.count(target)) return; - if (target < exe_start || target >= exe_end) return; - if (target & 3) return; - mid_targets.insert(target); - }; - - if (block.exit_instr.type == ControlFlowType::Branch) { - check_target(block.exit_instr.target); - check_target(block.exit_instr.address + 8); - } else if (block.exit_instr.type == ControlFlowType::Jump) { - check_target(block.exit_instr.target); - } else if (block.exit_instr.type == ControlFlowType::JumpLink || - block.exit_instr.type == ControlFlowType::JumpLinkReg) { - // CPS jal/jalr publishes $ra = addr+8. When the delay slot - // was split into another piece, that return PC is often a - // mid-block address (not a function start / block leader) and - // was never registered — MotK 0x8001E7F0 fell to dirty_ram. - check_target(block.exit_instr.address + 8); - } - } - }; - - if (cfgs_to_scan.empty()) { - for (const auto& [cfg_addr, cfg] : cfgs_mut) scan_cfg(cfg); - } else { - for (uint32_t addr : cfgs_to_scan) { - auto it = cfgs_mut.find(addr); - if (it != cfgs_mut.end()) scan_cfg(it->second); - } - } - - if (mid_targets.empty()) { converged = true; break; } - - fmt::print("Pre-pass {}: {} mid-function branch targets\n", pass + 1, mid_targets.size()); - - // Sort functions for binary search. Alias entries overlap their - // host's range and must never be treated as the containing - // function of a mid-target (that would split/truncate the alias - // instead of the host) — exclude them from containment. - std::sort(functions_mut.begin(), functions_mut.end(), - [](const Function& a, const Function& b) { return a.start_addr < b.start_addr; }); - std::vector func_starts; - for (const auto& f : functions_mut) { - if (f.alias_walk_lo == 0) func_starts.push_back(f.start_addr); - } - - // Group targets by containing function. Some real compiler layouts - // branch past an early jr-ra return into the bytes before the next - // discovered function. Those targets are not "mid-function" by the - // return-scanner's range, but they are executable and must still be - // dispatchable CPS pieces. - std::map> splits_by_func; - std::set gap_targets; - for (uint32_t target : mid_targets) { - auto it = std::upper_bound(func_starts.begin(), func_starts.end(), target); - if (it == func_starts.begin()) { - gap_targets.insert(target); - continue; - } - --it; - auto fit = std::find_if(functions_mut.begin(), functions_mut.end(), - [&](const Function& f) { return f.start_addr == *it; }); - if (fit != functions_mut.end() && target > fit->start_addr && target < fit->end_addr) { - splits_by_func[*it].push_back(target); - } else { - gap_targets.insert(target); - } - } - - // Split each affected function - std::vector new_funcs; - std::set affected; - - for (uint32_t target : gap_targets) { - auto next_it = std::upper_bound(func_starts.begin(), func_starts.end(), target); - uint32_t gap_end = (next_it != func_starts.end()) ? *next_it : exe_end; - if (target >= gap_end) continue; - - Function nf; - nf.start_addr = target; - nf.end_addr = gap_end; - nf.size = nf.end_addr - nf.start_addr; - nf.name = fmt::format("func_{:08X}", nf.start_addr); - nf.has_prologue = false; - nf.has_epilogue = false; - nf.stack_frame_size = 0; - nf.is_data_section = false; - new_funcs.push_back(nf); - known_addrs.insert(nf.start_addr); - affected.insert(nf.start_addr); - } - - for (auto& [func_start, targets] : splits_by_func) { - std::sort(targets.begin(), targets.end()); - - for (auto& f : functions_mut) { - if (f.start_addr != func_start) continue; - if (targets.back() >= f.end_addr) break; - - uint32_t orig_end = f.end_addr; - bool orig_data = f.is_data_section; - - f.end_addr = targets[0]; - f.size = f.end_addr - f.start_addr; - affected.insert(f.start_addr); - - for (size_t ti = 0; ti < targets.size(); ti++) { - Function nf; - nf.start_addr = targets[ti]; - nf.end_addr = (ti + 1 < targets.size()) ? targets[ti + 1] : orig_end; - nf.size = nf.end_addr - nf.start_addr; - nf.name = fmt::format("func_{:08X}", nf.start_addr); - nf.has_prologue = false; - nf.has_epilogue = false; - nf.stack_frame_size = 0; - nf.is_data_section = orig_data; - new_funcs.push_back(nf); - known_addrs.insert(nf.start_addr); - affected.insert(nf.start_addr); - } - break; - } - } - - functions_mut.insert(functions_mut.end(), new_funcs.begin(), new_funcs.end()); - std::sort(functions_mut.begin(), functions_mut.end(), - [](const Function& a, const Function& b) { return a.start_addr < b.start_addr; }); - - // Rebuild CFGs for affected functions - ControlFlowAnalyzer cfg_analyzer(exe_); - cfgs_to_scan.clear(); - for (auto& f : functions_mut) { - if (affected.count(f.start_addr)) { - cfgs_mut[f.start_addr] = cfg_analyzer.analyze_function(f); - cfgs_to_scan.insert(f.start_addr); - } - } - - total_new += (int)new_funcs.size(); - fmt::print(" Created {} new entry points\n", new_funcs.size()); - } - - if (total_new > 0) { - fmt::print("Pre-pass total: {} new entry points, {} functions now\n", - total_new, functions_mut.size()); - } - if (!converged) { - fmt::print("WARNING: mid-function split pre-pass did NOT converge after {} passes; " - "remaining targets will dispatch-miss at runtime\n", MAX_PASSES); - } - } - - set_known_functions(known_addrs); - - // Generate all functions first (to collect names) - auto gen_funcs = generate_all_functions(functions_mut, cfgs_mut); - - // Forward declarations for all functions - if (!gen_funcs.empty()) { - ss << "/* Forward declarations */\n"; - for (const auto& gen_func : gen_funcs) { - ss << gen_func.signature << ";\n"; - } - for (const auto& decl : alias_body_decls_) ss << decl << ";\n"; - ss << "\n"; - } - - // Function definitions - for (const auto& gen_func : gen_funcs) { - ss << gen_func.full_code << "\n"; - } - - // Stash for the split-shard writer (main_psx); copy is intentional so the - // caller can consume it after generate_file() returns the monolith text. - last_gen_funcs_ = gen_funcs; - - // Preserve the exact post-split CFG inventory used by the emitted C. The - // dispatch generator consumes this so synthesized fallthrough functions - // receive the same byte-precise native-validity ranges as ordinary entries. - last_ranges_manifest_ = generate_ranges_manifest(functions_mut, cfgs_mut); - - return ss.str(); -} - -std::string CodeGenerator::generate_ranges_manifest( - const std::vector& functions, - const std::map& cfgs) { - - std::stringstream ss; - ss << "# psxrecomp overlay code-range manifest v1\n"; - ss << "# F one per function (virtual entry address)\n"; - ss << "# R one per coalesced code range (hex, virtual)\n"; - - for (const auto& func : functions) { - auto it = cfgs.find(func.start_addr); - if (it == cfgs.end()) continue; - const ControlFlowGraph& cfg = it->second; - - // Byte intervals [start_addr, end_addr+4) per block (end_addr is the - // last instruction, inclusive). - std::vector> iv; - for (const auto& [baddr, blk] : cfg.blocks) { - (void)baddr; - uint32_t lo = blk.start_addr; - uint32_t hi = blk.end_addr + 4u; - /* translate_basic_block clones an out-of-block delay slot when the - * control-flow instruction itself is the block's last word. That - * cloned instruction is compiled code too: include it in the exact - * validity range so its bytes participate in the candidate CRC and - * page-generation watch. Otherwise a later write to the slot could - * leave stale native semantics callable. */ - if (blk.exit_instr.has_delay_slot && - blk.exit_instr.type != ControlFlowType::None && - blk.exit_instr.address == blk.end_addr && hi <= UINT32_MAX - 4u) { - hi += 4u; - } - if (hi > lo) iv.emplace_back(lo, hi); - } - if (iv.empty()) continue; - - // Merge overlapping/adjacent intervals. A gap > 0 between blocks is - // non-code (e.g. a jump table) and is intentionally left out. - std::sort(iv.begin(), iv.end()); - std::vector> merged; - for (const auto& r : iv) { - if (!merged.empty() && r.first <= merged.back().second) { - if (r.second > merged.back().second) merged.back().second = r.second; - } else { - merged.push_back(r); - } - } - - ss << fmt::format("F {:08X}\n", func.start_addr); - for (const auto& r : merged) - ss << fmt::format("R {:08X} {:X}\n", r.first, r.second - r.first); - } - return ss.str(); -} - -} // namespace PSXRecomp diff --git a/psxrecomp-overlay/recompiler/src/config_loader.cpp b/psxrecomp-overlay/recompiler/src/config_loader.cpp deleted file mode 100644 index 5f99a33..0000000 --- a/psxrecomp-overlay/recompiler/src/config_loader.cpp +++ /dev/null @@ -1,2814 +0,0 @@ -// config_loader.cpp — see config_loader.h for the contract. - -#include "config_loader.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _WIN32 -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#else -#include -#endif - -#include "bios_rom_alias.h" -#include "fmt/format.h" -#include "ps1_exe_parser.h" - -// toml11 is header-only. -#define TOML11_USE_UNRELEASED_TOML_FEATURES -#include "toml.hpp" - -namespace PSXRecompV4 { - -namespace fs = std::filesystem; - -namespace { - -struct ConfigHash { - uint32_t value = 2166136261u; // FNV-1a 32 - - void byte(uint8_t v) { - value ^= v; - value *= 16777619u; - } - void u32(uint32_t v) { - byte((uint8_t)(v >> 0)); - byte((uint8_t)(v >> 8)); - byte((uint8_t)(v >> 16)); - byte((uint8_t)(v >> 24)); - } - void tag(const char *s) { - while (*s) byte((uint8_t)*s++); - byte(0); - } - void words(const char *name, std::vector values) { - tag(name); - std::sort(values.begin(), values.end()); - values.erase(std::unique(values.begin(), values.end()), values.end()); - u32((uint32_t)values.size()); - for (uint32_t value : values) u32(value); - } -}; - -} // namespace - -uint32_t overlay_codegen_config_hash(const GameConfig& c) { - ConfigHash h; - h.tag("psxrecomp-overlay-config-v1"); - - h.words("sprite_tag_funcs", c.ws_sprite_tag_funcs); - h.words("mod_function_entry_funcs", c.mod_function_entry_funcs); - h.words("cull_bias", c.ws_cull_bias_sites); - h.words("cull_range", c.ws_cull_range_sites); - h.words("cull_a1", c.ws_cull_a1_sites); - h.words("cull_screen_x", c.ws_cull_screen_x_sites); - h.words("cull_slti", c.ws_cull_slti_sites); - h.words("cull_slti_lower", c.ws_cull_slti_lower_sites); - h.words("cull_lower_branch", c.ws_cull_lower_branch_sites); - h.words("cull_lower_value", c.ws_cull_lower_value_sites); - h.words("cull_upper_value", c.ws_cull_upper_value_sites); - h.words("cull_span_value", c.ws_cull_span_value_sites); - h.words("cull_bltz", c.ws_cull_bltz_sites); - h.words("cull_negsub", c.ws_cull_negsub_sites); - h.words("cull_vxrange", c.ws_cull_vxrange_sites); - h.words("cull_depth", c.ws_cull_depth_sites); - h.words("cull_plane_nx", c.ws_cull_plane_nx_sites); - h.words("cull_xclip_load", c.ws_cull_xclip_load_sites); - h.words("cull_w_imms", c.ws_cull_w_imms); - h.words("cull_h_imms", c.ws_cull_h_imms); - h.words("backdrop_x", c.ws_backdrop_x_sites); - h.words("backdrop_unsquash", c.ws_backdrop_unsquash_funcs); - - h.tag("flags"); - h.u32(c.ws_auto_screen_x_cull ? 1u : 0u); - h.u32(c.ws_auto_backdrop_preload ? 1u : 0u); - h.u32(c.ws_bg2d_init_func); - h.u32((uint32_t)c.ws_cull_guard_pixels); - h.u32((uint32_t)c.ws_cull_activation_guard_pixels); - - std::vector signed_sites = - c.ws_signed_x_bound_sites; - std::sort(signed_sites.begin(), signed_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("signed_x_bounds"); - h.u32((uint32_t)signed_sites.size()); - for (const auto& site : signed_sites) { - h.u32(site.address); - h.u32(site.expected); - } - - std::vector keep_sites = c.ws_cull_keep_sites; - std::sort(keep_sites.begin(), keep_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - if (a.expected != b.expected) return a.expected < b.expected; - return a.result < b.result; - }); - h.tag("cull_keep"); - h.u32((uint32_t)keep_sites.size()); - for (const auto& site : keep_sites) { - h.u32(site.address); - h.u32(site.expected); - h.u32(site.result); - } - - std::vector angle_sites = c.ws_cull_angle_sites; - std::sort(angle_sites.begin(), angle_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("cull_angle"); - h.u32((uint32_t)angle_sites.size()); - for (const auto& site : angle_sites) { - h.u32(site.address); - h.u32(site.expected); - } - - std::vector yaw_cone_sites = - c.ws_cull_yaw_cone_sites; - std::sort(yaw_cone_sites.begin(), yaw_cone_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - if (a.expected != b.expected) return a.expected < b.expected; - return a.full_turn < b.full_turn; - }); - h.tag("cull_yaw_cone"); - h.u32((uint32_t)yaw_cone_sites.size()); - for (const auto& site : yaw_cone_sites) { - h.u32(site.address); - h.u32(site.expected); - h.u32(site.full_turn); - } - - std::vector horizontal_slt_sites = - c.ws_cull_horizontal_slt_sites; - std::sort(horizontal_slt_sites.begin(), horizontal_slt_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("cull_horizontal_slt"); - h.u32((uint32_t)horizontal_slt_sites.size()); - for (const auto& site : horizontal_slt_sites) { - h.u32(site.address); - h.u32(site.expected); - } - - std::vector cone_sites = - c.ws_aspect_cone.sites; - std::sort(cone_sites.begin(), cone_sites.end(), - [](const auto& a, const auto& b) { - if (a.address != b.address) return a.address < b.address; - return a.expected < b.expected; - }); - h.tag("aspect_cone"); - h.u32((uint32_t)cone_sites.size()); - for (const auto& site : cone_sites) { - h.u32(site.address); - h.u32(site.expected); - h.u32(site.cosine_threshold); - h.u32(site.object_reg); - h.u32(site.x_reg); - h.u32(site.z_reg); - h.u32(site.y_reg); - h.u32(site.queue_guard ? 1u : 0u); - } - h.u32(c.ws_aspect_cone.forward_addr); - h.u32(c.ws_aspect_cone.object_type_offset); - h.u32(c.ws_aspect_cone.object_reg); - h.u32(c.ws_aspect_cone.x_reg); - h.u32(c.ws_aspect_cone.z_reg); - h.u32(c.ws_aspect_cone.y_reg); - h.u32(c.ws_aspect_cone.hysteresis_pixels); - h.u32(c.ws_aspect_cone.queue_reserve); - for (uint32_t value : c.ws_aspect_cone.queue_count_addrs) h.u32(value); - for (uint32_t value : c.ws_aspect_cone.queue_capacities) h.u32(value); - for (uint32_t value : c.ws_aspect_cone.queue_type_masks) h.u32(value); - - std::vector patches = c.recompiler_patches; - std::sort(patches.begin(), patches.end(), - [](const auto& a, const auto& b) { - const uint32_t aa = recompiler_patch_address_key(a.address); - const uint32_t ba = recompiler_patch_address_key(b.address); - if (aa != ba) return aa < ba; - if (a.expected != b.expected) return a.expected < b.expected; - return a.replacement < b.replacement; - }); - h.tag("instruction_patches"); - h.u32((uint32_t)patches.size()); - for (const auto& patch : patches) { - h.u32(recompiler_patch_address_key(patch.address)); - h.u32(patch.expected); - h.u32(patch.replacement); - } - return h.value; -} - -// Pad mode <-> string. Accepts "hybrid"/"analog"/"digital" (case-insensitive); -// returns `fallback` for anything unrecognised so a typo never silently flips -// the pad type. -int pad_mode_from_string(const std::string& s, int fallback) { - std::string l; - l.reserve(s.size()); - for (char c : s) l.push_back((char)std::tolower((unsigned char)c)); - if (l == "hybrid") return PAD_MODE_HYBRID; - if (l == "analog") return PAD_MODE_ANALOG; - if (l == "digital") return PAD_MODE_DIGITAL; - return fallback; -} - -const char* pad_mode_to_string(int mode) { - switch (mode) { - case PAD_MODE_ANALOG: return "analog"; - case PAD_MODE_DIGITAL: return "digital"; - default: return "hybrid"; - } -} - -// Parse a hex string ("0x...") to uint32_t. Throws on malformed input. -static uint32_t parse_hex(const std::string& s, const std::string& field) { - try { - return static_cast(std::stoul(s, nullptr, 16)); - } catch (const std::exception& ex) { - throw std::runtime_error( - fmt::format("config field '{}': expected hex string, got '{}' ({})", - field, s, ex.what())); - } -} - -// Parse a display aspect "W:H" string (e.g. "4:3", "16:9", "21:9"). Accepts -// only aspects between native 4:3 and 32:9 — narrower than 4:3 has no -// widescreen meaning and ultra-extreme values would squash the GTE X axis -// into unusability. Returns false on malformed input or out-of-range aspect. -static bool parse_aspect_ratio(const std::string& s, int* num, int* den) { - const size_t colon = s.find(':'); - if (colon == std::string::npos || colon == 0 || colon + 1 >= s.size()) - return false; - int n = 0, d = 0; - try { - size_t used = 0; - n = std::stoi(s.substr(0, colon), &used); - if (used != colon) return false; - const std::string rest = s.substr(colon + 1); - d = std::stoi(rest, &used); - if (used != rest.size()) return false; - } catch (const std::exception&) { - return false; - } - if (n <= 0 || d <= 0 || n > 99 || d > 99) return false; - if (3 * n < 4 * d) return false; // narrower than 4:3 - if (9 * n > 32 * d) return false; // wider than 32:9 - *num = n; *den = d; - return true; -} - -// Reject a config path that is not confined to the project directory. -// -// A game.toml is checked in and read on every host, so a path in it must mean -// the same thing everywhere; std::filesystem::path answers by the *host* -// grammar, so a Windows-authored escape passes on POSIX. Scan the raw bytes -// under both separator conventions instead. NUL is rejected outright because -// the runtime hands this value on as a C string — anything past a NUL would -// silently vanish between what is validated and what is used. -static void validate_project_relative(const std::string& value, - const char* field) { - if (value.find('\0') != std::string::npos) { - throw std::runtime_error( - fmt::format("{} must not contain a NUL byte", field)); - } - // Rooted ("/x", "\x"), UNC ("\\server\share"), drive-qualified ("C:/x", - // "C:\x") and drive-relative ("C:x") alike. - const bool rooted = !value.empty() && (value[0] == '/' || value[0] == '\\'); - const bool drive = value.size() >= 2 && value[1] == ':' && - std::isalpha(static_cast(value[0])); - if (rooted || drive) { - throw std::runtime_error( - fmt::format("{} must be project-relative", field)); - } - for (size_t start = 0;;) { - const size_t sep = value.find_first_of("/\\", start); - const size_t end = (sep == std::string::npos) ? value.size() : sep; - if (value.compare(start, end - start, "..") == 0) { - throw std::runtime_error( - fmt::format("{} must stay inside the project", field)); - } - if (sep == std::string::npos) break; - start = sep + 1; - } -} - -// Parse the optional [runtime] block. All fields optional; absent fields -// leave has_* = false on the returned struct. Paths are resolved relative -// to `root` (project root). -static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& root) { - RuntimeConfig rt; - // [localization] language = "en" (top-level; independent of [runtime]). - if (cfg.contains("localization")) { - const toml::value& loc = toml::find(cfg, "localization"); - if (loc.contains("language")) - rt.language = toml::find(loc, "language"); - // `default` is the launcher-facing alias for the pre-selected language. - if (loc.contains("default")) - rt.language = toml::find(loc, "default"); - // Optional launcher dropdown options: languages = [ {code, label}, ... ]. - if (loc.contains("languages") && toml::find(loc, "languages").is_array()) { - for (const auto& e : toml::find(loc, "languages").as_array()) { - if (!e.contains("code")) continue; - RuntimeConfig::LanguageOption lo; - lo.code = toml::find(e, "code"); - lo.label = e.contains("label") ? toml::find(e, "label") - : lo.code; - if (!lo.code.empty()) rt.languages.push_back(std::move(lo)); - } - } - } - if (!cfg.contains("runtime")) return rt; - const toml::value& runtime = toml::find(cfg, "runtime"); - if (runtime.contains("language")) // [runtime].language convenience alias - rt.language = toml::find(runtime, "language"); - - if (runtime.contains("debug_port")) { - const auto port = toml::find(runtime, "debug_port"); - if (port < 0 || port > 65535) { - throw std::runtime_error( - fmt::format("[runtime] debug_port out of range (0..65535): {}", port)); - } - rt.debug_port = static_cast(port); - rt.has_debug_port = true; - } - if (runtime.contains("window_title")) { - rt.window_title = toml::find(runtime, "window_title"); - rt.has_window_title = true; - } - if (runtime.contains("controller")) { - rt.controller = toml::find(runtime, "controller"); - for (char& c : rt.controller) { - c = static_cast(std::tolower(static_cast(c))); - } - if (rt.controller != "digital" && rt.controller != "dualshock") { - throw std::runtime_error( - fmt::format("[runtime] controller must be 'digital' or 'dualshock', got '{}'", - rt.controller)); - } - rt.has_controller = true; - } - if (runtime.contains("memcard_dir")) { - const auto rel = toml::find(runtime, "memcard_dir"); - rt.memcard_dir = fs::absolute(root / rel); - rt.has_memcard_dir = true; - } - if (runtime.contains("disc_speed")) { - rt.disc_speed = toml::find(runtime, "disc_speed"); - rt.has_disc_speed = true; - } - if (runtime.contains("instant_max_per_frame")) { - const auto n = toml::find(runtime, "instant_max_per_frame"); - if (n < 1 || n > 4096) { - throw std::runtime_error(fmt::format( - "[runtime] instant_max_per_frame out of range (1..4096): {}", n)); - } - rt.instant_max_per_frame = static_cast(n); - rt.has_instant_max_per_frame = true; - } - auto parse_warm_cd_route = [&](const toml::value& route, - const std::string& label) { - if (!route.contains("arm_lba") || !route.contains("lbas")) { - throw std::runtime_error( - label + " arm_lba and lbas are required"); - } - const auto arm = toml::find(route, "arm_lba"); - if (arm < 0 || arm > 0x7FFFFFFFll) { - throw std::runtime_error(fmt::format( - "{} arm_lba out of range: {}", label, arm)); - } - RuntimeConfig::WarmCdRoute parsed; - parsed.arm_lba = static_cast(arm); - const auto& lbas = toml::find>(route, "lbas"); - if (lbas.empty() || lbas.size() > 64) { - throw std::runtime_error( - label + " lbas must contain 1..64 entries"); - } - for (const auto lba : lbas) { - if (lba < 0 || lba > 0x7FFFFFFFll) { - throw std::runtime_error(fmt::format( - "{} LBA out of range: {}", label, lba)); - } - parsed.lbas.push_back(static_cast(lba)); - } - if (route.contains("instant_max_per_frame")) { - const auto n = toml::find(route, "instant_max_per_frame"); - if (n < 1 || n > 4096) { - throw std::runtime_error(fmt::format( - "{} instant_max_per_frame out of range (1..4096): {}", - label, n)); - } - parsed.instant_max_per_frame = static_cast(n); - } - for (const auto& existing : rt.warm_cd_routes) { - if (existing.arm_lba == parsed.arm_lba && - existing.lbas.front() == parsed.lbas.front()) { - throw std::runtime_error(fmt::format( - "{} duplicates route arm {} / first LBA {}", label, - parsed.arm_lba, parsed.lbas.front())); - } - } - rt.warm_cd_routes.push_back(std::move(parsed)); - }; - if (runtime.contains("warm_cd_route")) { - std::fprintf(stderr, - "psxrecomp: warning: [runtime.warm_cd_route] is deprecated; " - "use [[runtime.warm_cd_routes]]\n"); - parse_warm_cd_route(toml::find(runtime, "warm_cd_route"), - "[runtime.warm_cd_route]"); - } - if (runtime.contains("warm_cd_routes")) { - const auto& routes = toml::find(runtime, "warm_cd_routes"); - if (!routes.is_array() || routes.as_array().empty() || - routes.as_array().size() > 16) { - throw std::runtime_error( - "[[runtime.warm_cd_routes]] must contain 1..16 routes"); - } - size_t route_index = 0; - for (const auto& route : routes.as_array()) { - if (!route.is_table()) { - throw std::runtime_error( - "[[runtime.warm_cd_routes]] entries must be tables"); - } - parse_warm_cd_route(route, fmt::format( - "[[runtime.warm_cd_routes]] entry {}", route_index++)); - } - } - if (runtime.contains("fast_boot")) { - rt.fast_boot = toml::find(runtime, "fast_boot"); - } - if (runtime.contains("openbios")) { - rt.openbios = toml::find(runtime, "openbios"); - } - if (runtime.contains("bios_hle")) { - rt.bios_hle = toml::find(runtime, "bios_hle"); - } - if (runtime.contains("bios_hle_keep_intro")) { - rt.bios_hle_keep_intro = toml::find(runtime, "bios_hle_keep_intro"); - } - if (runtime.contains("hle_scheduler")) { - rt.hle_scheduler = toml::find(runtime, "hle_scheduler"); - } - if (runtime.contains("overlay_cache")) { - rt.overlay_cache = toml::find(runtime, "overlay_cache"); - } - if (runtime.contains("overlay_capture_history")) { - rt.overlay_capture_history = - toml::find(runtime, "overlay_capture_history"); - } - if (runtime.contains("overlay_capture_persist_dir")) { - rt.overlay_capture_persist_dir = - toml::find(runtime, "overlay_capture_persist_dir"); - validate_project_relative(rt.overlay_capture_persist_dir, - "runtime.overlay_capture_persist_dir"); - } - if (runtime.contains("turbo_loads")) { - rt.turbo_loads = toml::find(runtime, "turbo_loads"); - } - if (runtime.contains("offer_turbo_loads")) { - rt.offer_turbo_loads = - toml::find(runtime, "offer_turbo_loads"); - } - if (runtime.contains("turbo_audio_sink")) { - rt.turbo_audio_sink = toml::find(runtime, "turbo_audio_sink"); - } - if (runtime.contains("idle_skip")) { - rt.idle_skip = toml::find(runtime, "idle_skip"); - } - if (runtime.contains("overlay_autocompile_cmd")) { - rt.overlay_autocompile_cmd = - toml::find(runtime, "overlay_autocompile_cmd"); - rt.has_overlay_autocompile_cmd = !rt.overlay_autocompile_cmd.empty(); - } - if (runtime.contains("overlay_autocompile_cmd_tcc")) { - rt.overlay_autocompile_cmd_tcc = - toml::find(runtime, "overlay_autocompile_cmd_tcc"); - rt.has_overlay_autocompile_cmd_tcc = !rt.overlay_autocompile_cmd_tcc.empty(); - } - if (runtime.contains("overlay_backend")) { - rt.overlay_backend = toml::find(runtime, "overlay_backend"); - } - if (runtime.contains("overlay_native_block")) { - for (const auto& a : toml::find>(runtime, "overlay_native_block")) { - rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); - } - } - - if (cfg.contains("parappa_timing")) { - const toml::value& timing = toml::find(cfg, "parappa_timing"); - rt.has_parappa_timing = true; - if (timing.contains("mode")) { - rt.parappa_timing_mode = toml::find(timing, "mode"); - for (char& c : rt.parappa_timing_mode) - c = (char)std::tolower((unsigned char)c); - } - auto parse_window = [&](const char *key) -> int { - const auto n = toml::find(timing, key); - if (n < 0 || n > 60) { - throw std::runtime_error(fmt::format( - "[parappa_timing] {} out of range (0..60): {}", key, n)); - } - return (int)n; - }; - if (timing.contains("extra_early")) - rt.parappa_timing_extra_early = parse_window("extra_early"); - if (timing.contains("extra_late")) - rt.parappa_timing_extra_late = parse_window("extra_late"); - } - - // Optional [video] block — visual enhancement options. Kept on the same - // RuntimeConfig so main.cpp consumes them alongside the other knobs. - if (cfg.contains("video")) { - const toml::value& video = toml::find(cfg, "video"); - if (video.contains("supersampling")) { - const auto n = toml::find(video, "supersampling"); - if (n < 1 || n > 8) { - throw std::runtime_error(fmt::format( - "[video] supersampling out of range (1..8): {}", n)); - } - rt.video_supersampling = static_cast(n); - } - if (video.contains("antialiasing")) { - rt.video_antialiasing = toml::find(video, "antialiasing"); - } - if (video.contains("texture_filtering")) { - const auto mode = toml::find(video, "texture_filtering"); - if (mode == "nearest") rt.video_texture_filter = 0; - else if (mode == "bilinear") rt.video_texture_filter = 1; - else throw std::runtime_error(fmt::format( - "[video] texture_filtering must be \"nearest\" or \"bilinear\": {}", mode)); - } - if (video.contains("renderer")) { - const auto mode = toml::find(video, "renderer"); - if (mode == "software") rt.video_renderer = 0; - else if (mode == "opengl") rt.video_renderer = 1; - else if (mode == "vulkan") rt.video_renderer = 2; - else throw std::runtime_error(fmt::format( - "[video] renderer must be \"software\", \"opengl\" or \"vulkan\": {}", mode)); - } - if (video.contains("offer_vulkan")) { - rt.video_offer_vulkan = toml::find(video, "offer_vulkan"); - } - if (video.contains("crt_filter")) { - const auto mode = toml::find(video, "crt_filter"); - if (mode == "raw") rt.video_screen_kind = 0; - else if (mode == "crt") rt.video_screen_kind = 1; - else if (mode == "composite") rt.video_screen_kind = 2; - else if (mode == "trinitron") rt.video_screen_kind = 3; - else throw std::runtime_error(fmt::format( - "[video] crt_filter must be \"raw\"|\"crt\"|\"composite\"|\"trinitron\": {}", - mode)); - } - if (video.contains("auto_skip_fmv")) { - rt.video_auto_skip_fmv = toml::find(video, "auto_skip_fmv"); - } - if (video.contains("offer_skip_fmv")) { - rt.video_offer_skip_fmv = toml::find(video, "offer_skip_fmv"); - } - if (video.contains("fmv_skip_total_table")) { - rt.video_fmv_skip_total_table = - (uint32_t)toml::find(video, "fmv_skip_total_table"); - } - if (video.contains("fmv_skip_movie_id")) { - rt.video_fmv_skip_movie_id = - (uint32_t)toml::find(video, "fmv_skip_movie_id"); - } - if (video.contains("fmv_skip_end_total")) { - rt.video_fmv_skip_end_total = - (int)toml::find(video, "fmv_skip_end_total"); - } - if (video.contains("fmv_skip_no_xa")) { - rt.video_fmv_skip_no_xa = toml::find(video, "fmv_skip_no_xa"); - } - if (video.contains("fmv_skip_no_xa_hold")) { - rt.video_fmv_skip_no_xa_hold = - (int)toml::find(video, "fmv_skip_no_xa_hold"); - if (rt.video_fmv_skip_no_xa_hold < 4 || rt.video_fmv_skip_no_xa_hold > 3600) - throw std::runtime_error( - "[video] fmv_skip_no_xa_hold must be between 4 and 3600 vblanks"); - } - if (video.contains("low_latency_input")) { - rt.video_low_latency_input = toml::find(video, "low_latency_input"); - } - if (video.contains("vsync")) { - const auto mode = toml::find(video, "vsync"); - if (mode == "on" || mode == "vsync") rt.video_vsync = 1; - else if (mode == "off" || mode == "immediate") rt.video_vsync = 0; - else if (mode == "adaptive") rt.video_vsync = -1; - else throw std::runtime_error(fmt::format( - "[video] vsync must be \"on\"|\"off\"|\"immediate\"|\"adaptive\": {}", mode)); - } - if (video.contains("frame_interpolation")) { - rt.video_frame_interpolation = - toml::find(video, "frame_interpolation"); - } - if (video.contains("frame_interpolation_fps")) { - rt.video_frame_interpolation_fps = - toml::find(video, "frame_interpolation_fps"); - if (rt.video_frame_interpolation_fps != 0 && - rt.video_frame_interpolation_fps < 90) - throw std::runtime_error( - "[video] frame_interpolation_fps must be 0 (display) or >= 90"); - } - if (video.contains("offer_frame_interpolation")) { - rt.video_offer_frame_interpolation = - toml::find(video, "offer_frame_interpolation"); - } - if (video.contains("aspect_ratio")) { - const auto mode = toml::find(video, "aspect_ratio"); - int n = 0, d = 0; - if (!parse_aspect_ratio(mode, &n, &d)) - throw std::runtime_error(fmt::format( - "[video] aspect_ratio must be \"W:H\", no narrower than 4:3 " - "and no wider than 32:9 (e.g. \"4:3\", \"16:9\"): {}", mode)); - rt.video_aspect_num = n; - rt.video_aspect_den = d; - } - } - - // Optional [audio] block. - if (cfg.contains("audio")) { - const toml::value& audio = toml::find(cfg, "audio"); - if (audio.contains("buffer_ms")) { - const auto n = toml::find(audio, "buffer_ms"); - if (n < 30 || n > 500) { - throw std::runtime_error(fmt::format( - "[audio] buffer_ms out of range (30..500): {}", n)); - } - rt.audio_buffer_ms = static_cast(n); - } - if (audio.contains("spu_hq")) { - rt.audio_spu_hq = toml::find(audio, "spu_hq"); - } - } - - // Optional [controller] block — game-declared input defaults. - if (cfg.contains("controller")) { - const toml::value& ct = toml::find(cfg, "controller"); - // Legacy boolean form (true->analog, false->digital), read first so the - // new string `*_mode` keys win when both are present. - if (ct.contains("default_analog")) { - const int m = toml::find(ct, "default_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - rt.default_p1_mode = rt.default_p2_mode = m; - rt.has_default_mode = true; - } - if (ct.contains("p1_analog")) { - rt.default_p1_mode = toml::find(ct, "p1_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - rt.has_default_mode = true; - } - if (ct.contains("p2_analog")) { - rt.default_p2_mode = toml::find(ct, "p2_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - rt.has_default_mode = true; - } - // Preferred string form. - if (ct.contains("default_mode")) { - const int m = pad_mode_from_string( - toml::find(ct, "default_mode"), PAD_MODE_HYBRID); - rt.default_p1_mode = rt.default_p2_mode = m; - rt.has_default_mode = true; - } - if (ct.contains("p1_mode")) { - rt.default_p1_mode = pad_mode_from_string( - toml::find(ct, "p1_mode"), PAD_MODE_HYBRID); - rt.has_default_mode = true; - } - if (ct.contains("p2_mode")) { - rt.default_p2_mode = pad_mode_from_string( - toml::find(ct, "p2_mode"), PAD_MODE_HYBRID); - rt.has_default_mode = true; - } - if (ct.contains("allow_hybrid")) { - rt.controller_allow_hybrid = toml::find(ct, "allow_hybrid"); - } - if (ct.contains("lock_mode")) { - rt.controller_lock_mode = toml::find(ct, "lock_mode"); - } - if (ct.contains("lock_device")) { - rt.controller_lock_device = toml::find(ct, "lock_device"); - } - if (ct.contains("deadzone")) { - const auto n = toml::find(ct, "deadzone"); - if (n < 0 || n > 32767) - throw std::runtime_error(fmt::format( - "[controller] deadzone out of range (0..32767): {}", n)); - rt.deadzone = static_cast(n); - rt.has_deadzone = true; - } - if (ct.contains("anti_deadzone")) { - const auto n = toml::find(ct, "anti_deadzone"); - if (n < 0 || n > 32767) - throw std::runtime_error(fmt::format( - "[controller] anti_deadzone out of range (0..32767): {}", n)); - rt.anti_deadzone = static_cast(n); - rt.has_anti_deadzone = true; - } - } - - return rt; -} - -fs::path find_project_root(const fs::path& config_path) { - fs::path cur = fs::absolute(config_path).parent_path(); - const fs::path fallback = cur; - for (int i = 0; i < 8; ++i) { - for (const char* marker : { ".gitignore", ".git", "CMakeLists.txt" }) { - if (fs::exists(cur / marker)) { - return cur; - } - } - const fs::path parent = cur.parent_path(); - if (parent == cur) break; - cur = parent; - } - return fallback; -} - -// Derive the output filename stem from a rom basename. Mirrors the Python -// audit_config.py logic: strip a trailing .BIN/.EXE (case-insensitive) but -// preserve dotted names like "SCUS_942.36" unchanged. -static std::string derive_out_stem(const std::string& rom_basename) { - auto ends_with_ci = [](const std::string& s, const std::string& suffix) { - if (s.size() < suffix.size()) return false; - for (size_t i = 0; i < suffix.size(); ++i) { - char a = s[s.size() - suffix.size() + i]; - char b = suffix[i]; - if (std::tolower(static_cast(a)) != - std::tolower(static_cast(b))) return false; - } - return true; - }; - if (ends_with_ci(rom_basename, ".bin") || ends_with_ci(rom_basename, ".exe")) { - return rom_basename.substr(0, rom_basename.size() - 4); - } - return rom_basename; -} - -BiosConfig load_bios_config(const fs::path& config_path_in) { - const fs::path config_path = fs::absolute(config_path_in); - if (!fs::exists(config_path)) { - throw std::runtime_error( - fmt::format("config file not found: {}", config_path.string())); - } - - const fs::path root = find_project_root(config_path); - - toml::value cfg; - try { - cfg = toml::parse(config_path); - } catch (const toml::syntax_error& ex) { - throw std::runtime_error( - fmt::format("TOML syntax error in {}: {}", config_path.string(), ex.what())); - } - - // A BIOS profile describes an IMAGE — facts about bytes — never a runtime - // preference. This block used to be parsed into BiosConfig::runtime and - // read by nothing, which let bios/OpenBIOS.toml carry a `bios_hle = false` - // that looked authoritative and was inert. Fail loud instead of ignoring: - // a preference put here would never take effect, and believing it had is - // how the OpenBIOS boot-skip regression got explained away. - if (cfg.contains("runtime")) { - throw std::runtime_error(fmt::format( - "{}: a BIOS profile has no [runtime] block — it describes an image, " - "not a preference. Runtime options belong in game.toml / " - "settings.toml. (The BIOS-dependent HLE gating is expressed by " - "[recompiler.runtime_exports]: shell_entry_phys enables the boot-" - "skip, deliver_event_ret enables the kernel-call HLE tier.)", - config_path.string())); - } - - // [program] — bios.toml uses this; some legacy files use [game] - const toml::value* prog_ptr = nullptr; - if (cfg.contains("program")) { - prog_ptr = &toml::find(cfg, "program"); - } else if (cfg.contains("game")) { - prog_ptr = &toml::find(cfg, "game"); - } else { - throw std::runtime_error( - fmt::format("{}: missing [program] (or [game]) block", - config_path.string())); - } - const toml::value& prog = *prog_ptr; - - const std::string name = toml::find(prog, "name"); - const std::string id = prog.contains("id") - ? toml::find(prog, "id") - : std::string{}; - - // BIOS uses `rom`; game files use `exe`. Either is accepted here, but - // BIOS callers should pass a bios config that has `rom`. - std::string rom_field; - if (prog.contains("rom")) { - rom_field = toml::find(prog, "rom"); - } else if (prog.contains("exe")) { - rom_field = toml::find(prog, "exe"); - } else { - throw std::runtime_error( - fmt::format("{}: [program] missing 'rom' or 'exe' field", - config_path.string())); - } - // Tolerate either BIOS filename convention (bare "SCPH1001.BIN" vs - // region-qualified "US-PSX-SCPH1001.BIN") — see bios_rom_alias.h. A no-op - // for game [program].exe fields, which never match a BIOS model token. - const fs::path rom_path = resolve_bios_rom(fs::absolute(root / rom_field)); - - const uint32_t load_address = - parse_hex(toml::find(prog, "load_address"), "program.load_address"); - const uint32_t entry_pc = - prog.contains("entry_pc") - ? parse_hex(toml::find(prog, "entry_pc"), "program.entry_pc") - : load_address; - const uint32_t text_size = - parse_hex(toml::find(prog, "text_size"), "program.text_size"); - - // [program.image] — declared identity (optional) - std::string image_sha256; - bool image_redistributable = false; - if (prog.contains("image")) { - const toml::value& img = toml::find(prog, "image"); - if (img.contains("sha256")) - image_sha256 = toml::find(img, "sha256"); - if (img.contains("redistributable")) - image_redistributable = toml::find(img, "redistributable"); - } - - // [recompiler] - if (!cfg.contains("recompiler")) { - throw std::runtime_error( - fmt::format("{}: missing [recompiler] block", config_path.string())); - } - const toml::value& recomp = toml::find(cfg, "recompiler"); - - if (!recomp.contains("seeds")) { - throw std::runtime_error( - fmt::format("{}: [recompiler] missing 'seeds' field", config_path.string())); - } - const std::string seeds_field = toml::find(recomp, "seeds"); - const fs::path seeds_path = fs::absolute(root / seeds_field); - - const std::string out_dir_field = - recomp.contains("out_dir") - ? toml::find(recomp, "out_dir") - : std::string{"generated"}; - const fs::path out_dir = fs::absolute(root / out_dir_field); - - const bool strict = recomp.contains("strict") - ? toml::find(recomp, "strict") - : true; - - std::string out_stem; - if (recomp.contains("out_stem")) { - out_stem = toml::find(recomp, "out_stem"); - } else { - out_stem = derive_out_stem(fs::path(rom_field).filename().string()); - } - - // [[recompiler.bios_vectors]] — optional array of vector dispatch tables - std::vector bios_vectors; - if (recomp.contains("bios_vectors")) { - const auto& arr = recomp.at("bios_vectors").as_array(); - for (const auto& v : arr) { - BiosVectorTable bvt; - bvt.ram_addr = parse_hex( - toml::find(v, "ram_addr"), "bios_vectors.ram_addr"); - bvt.index_reg = static_cast( - toml::find(v, "index_reg")); - bvt.table_rom_addr = parse_hex( - toml::find(v, "table_rom_addr"), "bios_vectors.table_rom_addr"); - bvt.table_count = static_cast( - toml::find(v, "table_count")); - bvt.table_ram_addr = v.contains("table_ram_addr") - ? parse_hex(toml::find(v, "table_ram_addr"), - "bios_vectors.table_ram_addr") - : 0u; - bios_vectors.push_back(bvt); - } - } - - // [[recompiler.bios_aliases]] — fixed-target RAM trampolines - std::vector bios_aliases; - if (recomp.contains("bios_aliases")) { - const auto& arr = recomp.at("bios_aliases").as_array(); - for (const auto& v : arr) { - BiosAlias ba; - ba.ram_addr = parse_hex(toml::find(v, "ram_addr"), - "bios_aliases.ram_addr"); - ba.target_key = parse_hex(toml::find(v, "target_key"), - "bios_aliases.target_key"); - bios_aliases.push_back(ba); - } - } - - // [recompiler.address_model] — the BIOS's boot-time ROM->RAM code copies. - // Optional: absent (or an empty copy list) means the BIOS runs entirely - // from ROM and normalization degenerates to the KSEG mask. Semantic - // invariants (window overlap, bless uniqueness, alignment) are enforced - // in BiosAddressModel::from_config, not here. - std::vector address_copies; - if (recomp.contains("address_model")) { - const toml::value& am = toml::find(recomp, "address_model"); - if (am.contains("normalize_mask")) { - const uint32_t mask = parse_hex( - toml::find(am, "normalize_mask"), - "address_model.normalize_mask"); - // Only the KSEG strip is implemented; refuse anything else rather - // than silently mis-normalizing (RELOCATION_MANIFEST_FORMAT.md - // echo-and-check). - if (mask != 0x1FFFFFFFu) { - throw std::runtime_error(fmt::format( - "{}: address_model.normalize_mask must be 0x1FFFFFFF", - config_path.string())); - } - } - if (am.contains("copy")) { - for (const auto& v : am.at("copy").as_array()) { - BiosAddrCopy c; - c.name = toml::find(v, "name"); - c.rom_lo = parse_hex(toml::find(v, "rom_lo"), - "address_model.copy.rom_lo"); - c.rom_hi = parse_hex(toml::find(v, "rom_hi"), - "address_model.copy.rom_hi"); - c.ram_lo = parse_hex(toml::find(v, "ram_lo"), - "address_model.copy.ram_lo"); - c.runtime_base = parse_hex( - toml::find(v, "runtime_base"), - "address_model.copy.runtime_base"); - const std::string key = - toml::find(v, "dispatch_key"); - if (key == "ram") c.key_is_ram = true; - else if (key == "rom") c.key_is_ram = false; - else { - throw std::runtime_error(fmt::format( - "{}: address_model.copy '{}': dispatch_key must be " - "\"ram\" or \"rom\", got '{}'", - config_path.string(), c.name, key)); - } - if (v.contains("kernel_bless")) - c.kernel_bless = toml::find(v, "kernel_bless"); - address_copies.push_back(std::move(c)); - } - } - } - - // [[recompiler.install_slots]] — kernel-RAM PCs the BIOS overwrites with - // dispatch stubs at runtime. - std::vector install_slots; - if (recomp.contains("install_slots")) { - for (const auto& v : recomp.at("install_slots").as_array()) { - install_slots.push_back(parse_hex( - toml::find(v, "ram_addr"), - "install_slots.ram_addr")); - } - } - - // [recompiler.runtime_exports] — per-image HLE anchors couriered into the - // generated C. Absent keys stay 0 = structurally unavailable. - uint32_t shell_entry_phys = 0, deliver_event_ret = 0; - if (recomp.contains("runtime_exports")) { - const toml::value& rx = toml::find(recomp, "runtime_exports"); - if (rx.contains("shell_entry_phys")) - shell_entry_phys = parse_hex( - toml::find(rx, "shell_entry_phys"), - "runtime_exports.shell_entry_phys"); - if (rx.contains("deliver_event_ret")) - deliver_event_ret = parse_hex( - toml::find(rx, "deliver_event_ret"), - "runtime_exports.deliver_event_ret"); - } - - return BiosConfig{ - /*config_path*/ config_path, - /*project_root*/ root, - /*name*/ name, - /*id*/ id, - /*rom_path*/ rom_path, - /*load_address*/ load_address, - /*entry_pc*/ entry_pc, - /*text_size*/ text_size, - /*image_sha256*/ image_sha256, - /*image_redistributable*/ image_redistributable, - /*seeds_path*/ seeds_path, - /*out_dir*/ out_dir, - /*strict*/ strict, - /*out_stem*/ out_stem, - /*bios_vectors*/ std::move(bios_vectors), - /*bios_aliases*/ std::move(bios_aliases), - /*address_copies*/ std::move(address_copies), - /*install_slots*/ std::move(install_slots), - /*shell_entry_phys*/ shell_entry_phys, - /*deliver_event_ret*/ deliver_event_ret, - }; -} - -GameConfig load_game_config(const fs::path& config_path_in) { - const fs::path config_path = fs::absolute(config_path_in); - if (!fs::exists(config_path)) { - throw std::runtime_error( - fmt::format("game config not found: {}", config_path.string())); - } - const fs::path root = find_project_root(config_path); - - toml::value cfg; - try { - cfg = toml::parse(config_path); - } catch (const toml::syntax_error& ex) { - throw std::runtime_error( - fmt::format("TOML syntax error in {}: {}", config_path.string(), ex.what())); - } - - // [game] (preferred for game configs) or [program] - const toml::value* prog_ptr = nullptr; - if (cfg.contains("game")) { - prog_ptr = &toml::find(cfg, "game"); - } else if (cfg.contains("program")) { - prog_ptr = &toml::find(cfg, "program"); - } else { - throw std::runtime_error( - fmt::format("{}: missing [game] (or [program]) block", - config_path.string())); - } - const toml::value& game = *prog_ptr; - - const std::string name = toml::find(game, "name"); - const std::string id = game.contains("id") - ? toml::find(game, "id") - : std::string{}; - const std::string region = game.contains("region") - ? toml::find(game, "region") - : std::string{}; - const int players = game.contains("players") - ? (int)toml::find(game, "players") - : 1; - - std::string exe_field; - if (game.contains("exe")) { - exe_field = toml::find(game, "exe"); - } else if (game.contains("rom")) { - exe_field = toml::find(game, "rom"); - } else { - throw std::runtime_error( - fmt::format("{}: [game] missing 'exe' or 'rom' field", config_path.string())); - } - const fs::path exe_path = fs::absolute(root / exe_field); - - // Auto-detect EXE header values for any field not explicitly set in TOML. - // Parses the PS-X EXE header once and fills in load_address, entry_pc, - // text_size, and stack_base from the binary. If the EXE cannot be read - // (malformed header, file not found) and a required field is missing - // from TOML, the error surfaces when the EXE parse itself fails. - struct ExeHeaderAuto { - bool ok = false; - uint32_t load_address = 0; - uint32_t entry_pc = 0; - uint32_t text_size = 0; - uint32_t stack_base = 0; - }; - ExeHeaderAuto auto_hdr; - // We only parse the EXE header if at least one field is missing from TOML. - const bool need_auto = - !game.contains("load_address") || !game.contains("entry_pc") || - !game.contains("text_size") || !game.contains("stack_base"); - if (need_auto) { - std::string err; - if (auto exe = PSXRecomp::PS1ExeParser::parse_file(exe_path, err)) { - auto_hdr.ok = true; - auto_hdr.load_address = exe->load_address(); - auto_hdr.entry_pc = exe->entry_point(); - auto_hdr.text_size = exe->code_size(); - auto_hdr.stack_base = exe->header.stack_base; - // Round text_size up to next 4K page if it's not already - // page-aligned (the runtime's overlay region floor uses this - // as a guard, and the text segment in RAM is always fully - // page-reserved even if the EXE doesn't fill the last page). - if (auto_hdr.text_size & 0xFFF) { - auto_hdr.text_size = (auto_hdr.text_size + 0xFFF) & ~0xFFFu; - } - } - } - - const uint32_t load_address = - game.contains("load_address") - ? parse_hex(toml::find(game, "load_address"), "game.load_address") - : (auto_hdr.ok ? auto_hdr.load_address : []() -> uint32_t { - throw std::runtime_error( - "game.toml: missing 'load_address' in [game] and could not " - "auto-detect from EXE header"); - }()); - const uint32_t entry_pc = - game.contains("entry_pc") - ? parse_hex(toml::find(game, "entry_pc"), "game.entry_pc") - : (auto_hdr.ok ? auto_hdr.entry_pc : load_address); - const uint32_t text_size = - game.contains("text_size") - ? parse_hex(toml::find(game, "text_size"), "game.text_size") - : (auto_hdr.ok ? auto_hdr.text_size : []() -> uint32_t { - throw std::runtime_error( - "game.toml: missing 'text_size' in [game] and could not " - "auto-detect from EXE header"); - }()); - const uint32_t stack_base = - game.contains("stack_base") - ? parse_hex(toml::find(game, "stack_base"), "game.stack_base") - : (auto_hdr.ok && auto_hdr.stack_base != 0 - ? auto_hdr.stack_base - : 0x801FFFF0u); - - // Disc paths: accept either single `disc` or array `discs`. - std::vector discs; - if (game.contains("discs")) { - const auto& arr = toml::find>(game, "discs"); - for (const auto& d : arr) discs.push_back(fs::absolute(root / d)); - } else if (game.contains("disc")) { - const auto& d = toml::find(game, "disc"); - discs.push_back(fs::absolute(root / d)); - } - - // Optional expected disc identity (launcher verification badge). - bool has_disc_crc = false; - uint32_t disc_crc = 0; - std::string disc_sha1; - if (game.contains("disc_crc")) { - disc_crc = parse_hex(toml::find(game, "disc_crc"), "game.disc_crc"); - has_disc_crc = true; - } - if (game.contains("disc_sha1")) { - disc_sha1 = toml::find(game, "disc_sha1"); - // normalize to lowercase hex - for (char& c : disc_sha1) c = (char)std::tolower((unsigned char)c); - } - - // [recompiler] - if (!cfg.contains("recompiler")) { - throw std::runtime_error( - fmt::format("{}: missing [recompiler] block", config_path.string())); - } - const toml::value& recomp = toml::find(cfg, "recompiler"); - - if (!recomp.contains("seeds")) { - throw std::runtime_error( - fmt::format("{}: [recompiler] missing 'seeds' field", config_path.string())); - } - const fs::path seeds_path = - fs::absolute(root / toml::find(recomp, "seeds")); - - fs::path bios_thunks_path; - if (recomp.contains("bios_thunks")) { - bios_thunks_path = - fs::absolute(root / toml::find(recomp, "bios_thunks")); - } - - // [recompiler] bios_config — the BIOS profile this game is built - // against. Optional: main_psx falls back to the SCPH1001 profile - // (bios/SCPH1001.toml, in-repo or under the psxrecomp/ submodule). - fs::path bios_config_path; - if (recomp.contains("bios_config")) { - bios_config_path = - fs::absolute(root / toml::find(recomp, "bios_config")); - } - - const std::string out_dir_field = - recomp.contains("out_dir") - ? toml::find(recomp, "out_dir") - : std::string{"generated"}; - const fs::path out_dir = fs::absolute(root / out_dir_field); - - const bool strict = recomp.contains("strict") - ? toml::find(recomp, "strict") - : true; - - const std::string discovery = - recomp.contains("discovery") - ? toml::find(recomp, "discovery") - : std::string{"whole-image"}; - if (discovery != "whole-image" && discovery != "reachable") { - throw std::runtime_error(fmt::format( - "{}: [recompiler].discovery must be 'whole-image' or 'reachable'", - config_path.string())); - } - - std::string out_stem; - if (recomp.contains("out_stem")) { - out_stem = toml::find(recomp, "out_stem"); - } else { - out_stem = derive_out_stem(fs::path(exe_field).filename().string()); - } - - std::vector recompiler_patches; - if (recomp.contains("patch")) { - const auto& patches = toml::find(recomp, "patch"); - std::set seen_ids; - std::set seen_addresses; - for (const auto& item : patches) { - RecompilerPatch patch; - patch.id = toml::find(item, "id"); - patch.address = parse_hex(toml::find(item, "address"), - "recompiler.patch.address"); - patch.expected = parse_hex(toml::find(item, "expected"), - "recompiler.patch.expected"); - patch.replacement = parse_hex( - toml::find(item, "replacement"), - "recompiler.patch.replacement"); - if (item.contains("note")) { - patch.note = toml::find(item, "note"); - } - - if (patch.id.empty()) { - throw std::runtime_error( - "recompiler.patch.id must not be empty"); - } - if ((patch.address & 3u) != 0u) { - throw std::runtime_error(fmt::format( - "{}: recompiler patch '{}' address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), patch.id, patch.address)); - } - if (!seen_ids.insert(patch.id).second) { - throw std::runtime_error(fmt::format( - "{}: duplicate [[recompiler.patch]] id '{}'", - config_path.string(), patch.id)); - } - const uint32_t address_key = - recompiler_patch_address_key(patch.address); - if (!seen_addresses.insert(address_key).second) { - throw std::runtime_error(fmt::format( - "{}: duplicate [[recompiler.patch]] physical address " - "0x{:08X} (KUSEG/KSEG aliases are one site)", - config_path.string(), address_key)); - } - recompiler_patches.push_back(std::move(patch)); - } - } - - // Optional [widescreen] block — per-game hooks for the widescreen hack. - std::vector ws_sprite_tag_funcs; - uint32_t ws_sprite_anchor_addr = 0; - bool ws_hud_sprt_squash = false; - bool ws_auto_ui_squash = false; - bool ws_full_2d = false; - bool ws_gte_game_mode = false; - uint32_t ws_gameplay_state_addr = 0; - std::vector ws_gameplay_state_values; - bool ws_native_wide = true; - bool ws_nw_hud_corners = false; - uint32_t ws_nw_left_hud_packet_lo = 0; - uint32_t ws_nw_left_hud_packet_hi = 0; - bool ws_nw_backdrop = false; - bool ws_clear_reveal = false; - bool ws_nw_flat_backdrop = false; - bool ws_nw_phase_backdrop = false; - bool ws_nw_textured_edges = false; - int ws_nw_textured_edge_scale = 0; - bool ws_nw_full_mirror = false; - std::vector ws_signed_x_bound_sites; - bool ws_offered = true; - bool vulkan_offered = false; - bool ws_adaptive_view = false; - bool ws_ultrawide_offered = false; - if (cfg.contains("video")) { - const toml::value& video = toml::find(cfg, "video"); - if (video.contains("offer_vulkan")) - vulkan_offered = toml::find(video, "offer_vulkan"); - } - // Optional [data_shards] block — memoized pure-function replay hooks. - std::vector data_shard_funcs; - if (cfg.contains("data_shards")) { - const toml::value& dsv = toml::find(cfg, "data_shards"); - if (dsv.contains("funcs")) { - const auto& arr = toml::find>(dsv, "funcs"); - for (const auto& a : arr) - data_shard_funcs.push_back(parse_hex(a, "data_shards.funcs")); - } - } - // Optional [recompiler] hot_funcs — __attribute__((hot)) on emitted C. - // Optional trusted, statically linked game-mod entry hooks. - std::vector mod_function_entry_funcs; - if (recomp.contains("mod_function_entry_funcs")) { - const auto& arr = toml::find>( - recomp, "mod_function_entry_funcs"); - for (const auto& a : arr) - mod_function_entry_funcs.push_back( - parse_hex(a, "recompiler.mod_function_entry_funcs")); - } - std::vector hot_funcs; - if (recomp.contains("hot_funcs")) { - const auto& arr = toml::find>(recomp, "hot_funcs"); - for (const auto& a : arr) - hot_funcs.push_back(parse_hex(a, "recompiler.hot_funcs")); - } - uint32_t vsync_query_func = 0; - uint32_t vsync_counter_addr = 0; - uint32_t vsync_gpustat_ptr_addr = 0; - uint32_t vsync_timer1_ptr_addr = 0; - uint32_t vsync_timer1_cache_addr = 0; - std::vector vsync_event_horizon_sites; - std::vector vsync_event_horizon_extra_sites; - bool vsync_event_horizon_any = false; - if (cfg.contains("load_accel")) { - const toml::value& lav = toml::find(cfg, "load_accel"); - if (lav.contains("vsync_query")) { - const toml::value& vq = toml::find(lav, "vsync_query"); - const bool has_func = vq.contains("func"); - const bool has_counter = vq.contains("counter_addr"); - const bool has_gpu_ptr = vq.contains("gpustat_ptr_addr"); - const bool has_timer_ptr = vq.contains("timer1_ptr_addr"); - const bool has_timer_cache = vq.contains("timer1_cache_addr"); - const int present = (int)has_func + (int)has_counter + (int)has_gpu_ptr + - (int)has_timer_ptr + (int)has_timer_cache; - if (present != 0 && present != 5) - throw std::runtime_error(fmt::format( - "{}: [load_accel.vsync_query] func, counter_addr, gpustat_ptr_addr, " - "timer1_ptr_addr, and timer1_cache_addr must be set together", - config_path.string())); - if (has_func) { - vsync_query_func = parse_hex( - toml::find(vq, "func"), - "load_accel.vsync_query.func"); - vsync_counter_addr = parse_hex( - toml::find(vq, "counter_addr"), - "load_accel.vsync_query.counter_addr"); - vsync_gpustat_ptr_addr = parse_hex( - toml::find(vq, "gpustat_ptr_addr"), - "load_accel.vsync_query.gpustat_ptr_addr"); - vsync_timer1_ptr_addr = parse_hex( - toml::find(vq, "timer1_ptr_addr"), - "load_accel.vsync_query.timer1_ptr_addr"); - vsync_timer1_cache_addr = parse_hex( - toml::find(vq, "timer1_cache_addr"), - "load_accel.vsync_query.timer1_cache_addr"); - if (vq.contains("event_horizon_sites")) { - const auto& arr = toml::find>( - vq, "event_horizon_sites"); - for (const auto& a : arr) - vsync_event_horizon_sites.push_back(parse_hex( - a, "load_accel.vsync_query.event_horizon_sites")); - } - if (vq.contains("event_horizon_extra_sites")) { - const auto& arr = toml::find>( - vq, "event_horizon_extra_sites"); - for (const auto& a : arr) - vsync_event_horizon_extra_sites.push_back(parse_hex( - a, "load_accel.vsync_query.event_horizon_extra_sites")); - } - if (vq.contains("event_horizon_any")) - vsync_event_horizon_any = toml::find(vq, "event_horizon_any"); - } - } - } - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("sprite_tag_funcs")) { - const auto& arr = toml::find>(ws, "sprite_tag_funcs"); - for (const auto& a : arr) - ws_sprite_tag_funcs.push_back( - parse_hex(a, "widescreen.sprite_tag_funcs")); - } - if (ws.contains("sprite_anchor_addr")) { - ws_sprite_anchor_addr = parse_hex( - toml::find(ws, "sprite_anchor_addr"), - "widescreen.sprite_anchor_addr"); - } - if (!ws_sprite_tag_funcs.empty() && ws_sprite_anchor_addr == 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen] sprite_tag_funcs requires sprite_anchor_addr", - config_path.string())); - if (ws.contains("hud_sprt_squash")) - ws_hud_sprt_squash = toml::find(ws, "hud_sprt_squash"); - if (ws.contains("auto_ui_squash")) - ws_auto_ui_squash = toml::find(ws, "auto_ui_squash"); - if (ws.contains("full_2d")) - ws_full_2d = toml::find(ws, "full_2d"); - if (ws.contains("gte_game_mode")) - ws_gte_game_mode = toml::find(ws, "gte_game_mode"); - const bool has_gameplay_state_addr = ws.contains("gameplay_state_addr"); - const bool has_gameplay_state_values = ws.contains("gameplay_state_values"); - if (has_gameplay_state_addr != has_gameplay_state_values) - throw std::runtime_error(fmt::format( - "{}: [widescreen] gameplay_state_addr and " - "gameplay_state_values must be set together", - config_path.string())); - if (has_gameplay_state_addr) { - ws_gameplay_state_addr = parse_hex( - toml::find(ws, "gameplay_state_addr"), - "widescreen.gameplay_state_addr"); - const auto& arr = toml::find>( - ws, "gameplay_state_values"); - for (const auto& value : arr) - ws_gameplay_state_values.push_back(parse_hex( - value, "widescreen.gameplay_state_values")); - if (ws_gameplay_state_values.empty()) - throw std::runtime_error(fmt::format( - "{}: [widescreen] gameplay_state_values must not be empty", - config_path.string())); - } - if (ws.contains("native_wide")) - ws_native_wide = toml::find(ws, "native_wide"); - if (ws.contains("nw_hud_corners")) - ws_nw_hud_corners = toml::find(ws, "nw_hud_corners"); - const bool has_nw_left_hud_lo = ws.contains("nw_left_hud_packet_lo"); - const bool has_nw_left_hud_hi = ws.contains("nw_left_hud_packet_hi"); - if (has_nw_left_hud_lo != has_nw_left_hud_hi) - throw std::runtime_error(fmt::format( - "{}: [widescreen] nw_left_hud_packet_lo and " - "nw_left_hud_packet_hi must be set together", - config_path.string())); - if (has_nw_left_hud_lo) { - ws_nw_left_hud_packet_lo = parse_hex( - toml::find(ws, "nw_left_hud_packet_lo"), - "widescreen.nw_left_hud_packet_lo"); - ws_nw_left_hud_packet_hi = parse_hex( - toml::find(ws, "nw_left_hud_packet_hi"), - "widescreen.nw_left_hud_packet_hi"); - if (ws_nw_left_hud_packet_lo >= ws_nw_left_hud_packet_hi) - throw std::runtime_error(fmt::format( - "{}: [widescreen] nw_left_hud_packet range is empty or reversed", - config_path.string())); - } - if (ws.contains("nw_backdrop")) - ws_nw_backdrop = toml::find(ws, "nw_backdrop"); - if (ws.contains("clear_reveal")) - ws_clear_reveal = toml::find(ws, "clear_reveal"); - if (ws.contains("nw_flat_backdrop")) - ws_nw_flat_backdrop = toml::find(ws, "nw_flat_backdrop"); - if (ws.contains("nw_phase_backdrop")) - ws_nw_phase_backdrop = toml::find(ws, "nw_phase_backdrop"); - if (ws.contains("nw_textured_edges")) - ws_nw_textured_edges = toml::find(ws, "nw_textured_edges"); - if (ws.contains("nw_textured_edge_scale")) { - ws_nw_textured_edge_scale = toml::find(ws, "nw_textured_edge_scale"); - if (ws_nw_textured_edge_scale != 0 && - (ws_nw_textured_edge_scale < 100 || ws_nw_textured_edge_scale > 400)) - throw std::runtime_error(fmt::format( - "{}: [widescreen] nw_textured_edge_scale must be 0 or in [100, 400]", - config_path.string())); - } - if (ws.contains("nw_full_mirror")) - ws_nw_full_mirror = toml::find(ws, "nw_full_mirror"); - if (ws.contains("signed_x_bound")) { - std::set seen; - for (const auto& item : toml::find(ws, "signed_x_bound")) { - WidescreenSignedBoundSite site; - site.address = parse_hex(toml::find(item, "address"), - "widescreen.signed_x_bound.address"); - site.expected = parse_hex(toml::find(item, "expected"), - "widescreen.signed_x_bound.expected"); - if ((site.expected >> 26) != 0x0Fu) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.signed_x_bound]] expected must be LUI", - config_path.string())); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate [[widescreen.signed_x_bound]] address 0x{:08X}", - config_path.string(), site.address)); - ws_signed_x_bound_sites.push_back(site); - } - } - if (ws.contains("offer")) - ws_offered = toml::find(ws, "offer"); - if (ws.contains("offer_ultrawide")) - ws_ultrawide_offered = toml::find(ws, "offer_ultrawide"); - if (ws.contains("adaptive_view")) - ws_adaptive_view = toml::find(ws, "adaptive_view"); - } - - // Optional [widescreen.cull] block — world-space draw-cull widening. - std::vector ws_cull_bias_sites, ws_cull_range_sites, ws_cull_a1_sites; - std::vector ws_cull_screen_x_sites; - std::vector ws_cull_slti_sites; - std::vector ws_cull_slti_lower_sites; - std::vector ws_cull_lower_branch_sites; - std::vector ws_cull_lower_value_sites; - std::vector ws_cull_upper_value_sites; - std::vector ws_cull_span_value_sites; - std::vector ws_cull_bltz_sites; - std::vector ws_cull_negsub_sites; - std::vector ws_cull_vxrange_sites; - std::vector ws_cull_depth_sites; - std::vector ws_cull_plane_nx_sites; - std::vector ws_cull_xclip_load_sites; - std::vector ws_cull_keep_sites; - std::vector ws_cull_angle_sites; - std::vector ws_cull_yaw_cone_sites; - std::vector - ws_cull_horizontal_slt_sites; - WidescreenAspectConeConfig ws_aspect_cone; - int ws_cull_guard_pixels = 0; - int ws_cull_activation_guard_pixels = 0; - // Cull-signature immediates (screen_w_imms / screen_h_imms). Defaults are - // the original Tomba signature (320-display: 0x140/0x141 + 0xE0/0xF1); a - // game with a different display width overrides them (Ape Escape: 0x181). - std::vector ws_cull_w_imms = { 0x140, 0x141 }; - std::vector ws_cull_h_imms = { 0xE0, 0xF1 }; - bool ws_auto_screen_x_cull = false; - bool ws_auto_backdrop_preload = false; - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("cull")) { - const toml::value& cull = toml::find(ws, "cull"); - auto load_sites = [&](const char* key, std::vector& out) { - if (!cull.contains(key)) return; - for (const auto& a : toml::find>(cull, key)) - out.push_back(parse_hex(a, fmt::format("widescreen.cull.{}", key))); - }; - load_sites("bias_sites", ws_cull_bias_sites); - load_sites("range_sites", ws_cull_range_sites); - load_sites("a1_sites", ws_cull_a1_sites); - load_sites("screen_x_sites", ws_cull_screen_x_sites); - load_sites("slti_sites", ws_cull_slti_sites); - load_sites("slti_lower_sites", ws_cull_slti_lower_sites); - load_sites("lower_branch_sites", ws_cull_lower_branch_sites); - load_sites("lower_value_sites", ws_cull_lower_value_sites); - load_sites("upper_value_sites", ws_cull_upper_value_sites); - load_sites("span_value_sites", ws_cull_span_value_sites); - load_sites("bltz_sites", ws_cull_bltz_sites); - load_sites("negsub_sites", ws_cull_negsub_sites); - load_sites("vxrange_sites", ws_cull_vxrange_sites); - load_sites("depth_sites", ws_cull_depth_sites); - load_sites("plane_nx_sites", ws_cull_plane_nx_sites); - load_sites("xclip_load_sites", ws_cull_xclip_load_sites); - if (cull.contains("keep")) { - std::set seen; - for (const auto& item : toml::find(cull, "keep")) { - WidescreenCullKeepSite site; - site.address = parse_hex(toml::find(item, "address"), - "widescreen.cull.keep.address"); - site.expected = parse_hex(toml::find(item, "expected"), - "widescreen.cull.keep.expected"); - site.result = (uint32_t)toml::find(item, "result"); - const uint32_t op = site.expected >> 26; - const uint32_t fn = site.expected & 0x3Fu; - if (!((op == 0x0Au) || (op == 0x0Bu) || - (op == 0u && (fn == 0x2Au || fn == 0x2Bu)))) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.keep]] expected must be " - "SLT/SLTU/SLTI/SLTIU", - config_path.string())); - } - if (site.result > 1u) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.keep]] result must be 0 or 1", - config_path.string())); - } - if (!seen.insert(site.address & 0x1FFFFFFFu).second) { - throw std::runtime_error(fmt::format( - "{}: duplicate [[widescreen.cull.keep]] address 0x{:08X}", - config_path.string(), site.address)); - } - ws_cull_keep_sites.push_back(site); - } - } - if (cull.contains("angle")) { - std::set seen; - for (const auto& item : - toml::find(cull, "angle")) { - WidescreenAngleSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.angle.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.angle.expected"); - const uint32_t op = site.expected >> 26; - const uint32_t rs = (site.expected >> 21) & 31u; - const int32_t imm = (int16_t)(site.expected & 0xFFFFu); - if ((op != 0x08u && op != 0x09u) || rs != 0u || - imm <= 0 || imm >= 1024) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.angle]] expected must be " - "ADDI/ADDIU rt,zero,imm with imm in [1, 1023]", - config_path.string())); - } - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: cull-angle address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate cull-angle address 0x{:08X}", - config_path.string(), site.address)); - ws_cull_angle_sites.push_back(site); - } - } - if (cull.contains("yaw_cone")) { - std::set seen; - for (const auto& item : - toml::find(cull, "yaw_cone")) { - WidescreenYawConeSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.yaw_cone.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.yaw_cone.expected"); - const int64_t full_turn = - toml::find(item, "full_turn"); - if (full_turn < 16 || full_turn > 65536 || - (full_turn & 3) != 0) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.yaw_cone]] full_turn " - "must be a multiple of four in [16, 65536]", - config_path.string())); - } - site.full_turn = (uint32_t)full_turn; - const uint32_t op = site.expected >> 26; - const uint32_t rs = (site.expected >> 21) & 31u; - const int32_t imm = - (int16_t)(site.expected & 0xFFFFu); - const int32_t magnitude = imm < 0 ? -imm : imm; - if ((op != 0x08u && op != 0x09u) || rs == 0u || - imm == 0 || - magnitude >= (int32_t)(site.full_turn / 4u)) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.yaw_cone]] expected must " - "be ADDI/ADDIU rt,rs,+/-half_fov with rs != zero " - "and |half_fov| < full_turn/4", - config_path.string())); - } - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: yaw-cone address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate yaw-cone address 0x{:08X}", - config_path.string(), site.address)); - ws_cull_yaw_cone_sites.push_back(site); - } - } - if (cull.contains("horizontal_slt")) { - std::set seen; - for (const auto& item : - toml::find(cull, "horizontal_slt")) { - WidescreenHorizontalSltSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.horizontal_slt.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.horizontal_slt.expected"); - const uint32_t op = site.expected >> 26; - const uint32_t fn = site.expected & 0x3Fu; - const uint32_t shamt = (site.expected >> 6) & 31u; - if (op != 0u || fn != 0x2Au || shamt != 0u) { - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.horizontal_slt]] expected " - "must be a signed register-register SLT", - config_path.string())); - } - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: horizontal-SLT address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate horizontal-SLT address 0x{:08X}", - config_path.string(), site.address)); - ws_cull_horizontal_slt_sites.push_back(site); - } - } - if (cull.contains("aspect_cone")) { - const toml::value& cone = toml::find(cull, "aspect_cone"); - auto cone_hex = [&](const char *key) { - return parse_hex(toml::find(cone, key), - fmt::format("widescreen.cull.aspect_cone.{}", key)); - }; - ws_aspect_cone.forward_addr = cone_hex("forward_addr"); - const int object_type_offset = - toml::find(cone, "object_type_offset"); - const int object_reg = toml::find(cone, "object_reg"); - const int x_reg = toml::find(cone, "x_reg"); - const int z_reg = toml::find(cone, "z_reg"); - const int y_reg = toml::find(cone, "y_reg"); - const int hysteresis_pixels = - toml::find_or(cone, "hysteresis_pixels", 0); - const int queue_reserve = - toml::find_or(cone, "queue_reserve", 0); - if (object_type_offset < 0 || object_type_offset > 0xFFFF) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "object_type_offset must be in [0, 65535]", - config_path.string())); - } - if (object_reg < 0 || object_reg > 31 || - x_reg < 0 || x_reg > 31 || - z_reg < 0 || z_reg > 31 || - y_reg < 0 || y_reg > 31) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] register indices " - "must be in [0, 31]", config_path.string())); - } - if (hysteresis_pixels < 0 || hysteresis_pixels > 256 || - queue_reserve < 0 || queue_reserve > 256) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] hysteresis_pixels " - "and queue_reserve must be in [0, 256]", - config_path.string())); - } - if ((ws_aspect_cone.forward_addr & 1u) != 0) { - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] forward_addr " - "must be halfword-aligned", config_path.string())); - } - ws_aspect_cone.object_type_offset = - (uint32_t)object_type_offset; - ws_aspect_cone.object_reg = (uint32_t)object_reg; - ws_aspect_cone.x_reg = (uint32_t)x_reg; - ws_aspect_cone.z_reg = (uint32_t)z_reg; - ws_aspect_cone.y_reg = (uint32_t)y_reg; - ws_aspect_cone.hysteresis_pixels = - (uint32_t)hysteresis_pixels; - ws_aspect_cone.queue_reserve = (uint32_t)queue_reserve; - auto load_cone_hex_array = - [&](const char *key, std::array& out) { - const auto values = - toml::find>(cone, key); - if (values.size() != out.size()) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] {} must " - "contain exactly three values", - config_path.string(), key)); - for (size_t i = 0; i < out.size(); i++) - out[i] = parse_hex( - values[i], - fmt::format("widescreen.cull.aspect_cone.{}", - key)); - }; - auto load_cone_int_array = - [&](const char *key, std::array& out) { - const auto values = - toml::find>(cone, key); - if (values.size() != out.size()) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] {} must " - "contain exactly three values", - config_path.string(), key)); - for (size_t i = 0; i < out.size(); i++) { - if (values[i] < 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] {} " - "values must be non-negative", - config_path.string(), key)); - out[i] = (uint32_t)values[i]; - } - }; - load_cone_hex_array("queue_count_addrs", - ws_aspect_cone.queue_count_addrs); - load_cone_int_array("queue_capacities", - ws_aspect_cone.queue_capacities); - load_cone_hex_array("queue_type_masks", - ws_aspect_cone.queue_type_masks); - uint32_t used_type_mask = 0; - for (size_t i = 0; i < 3; i++) { - if ((ws_aspect_cone.queue_count_addrs[i] & 1u) != 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_count_addrs values must be " - "halfword-aligned", config_path.string())); - if (ws_aspect_cone.queue_capacities[i] == 0 || - ws_aspect_cone.queue_capacities[i] > 0x7FFFu) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_capacities values must be in [1, 32767]", - config_path.string())); - if (ws_aspect_cone.queue_reserve >= - ws_aspect_cone.queue_capacities[i]) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_reserve must be smaller than every " - "queue capacity", config_path.string())); - if ((used_type_mask & - ws_aspect_cone.queue_type_masks[i]) != 0) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] " - "queue_type_masks must not overlap", - config_path.string())); - used_type_mask |= ws_aspect_cone.queue_type_masks[i]; - } - if (!cone.contains("sites")) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull.aspect_cone] requires at least " - "one [[...sites]] entry", config_path.string())); - std::set seen; - for (const auto& item : - toml::find(cone, "sites")) { - WidescreenAspectConeSite site; - site.address = parse_hex( - toml::find(item, "address"), - "widescreen.cull.aspect_cone.sites.address"); - site.expected = parse_hex( - toml::find(item, "expected"), - "widescreen.cull.aspect_cone.sites.expected"); - const uint32_t opcode = site.expected >> 26; - const bool is_slti = opcode == 0x0Au; - const bool is_slt = - opcode == 0u && - (site.expected & 0x3Fu) == 0x2Au && - ((site.expected >> 6) & 0x1Fu) == 0u; - if (!is_slti && !is_slt) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.aspect_cone.sites]] " - "expected must be signed SLTI or SLT", - config_path.string())); - const int threshold = toml::find_or( - item, "cosine_threshold", - is_slti ? (int)(site.expected & 0xFFFFu) : -1); - if (threshold < 1 || threshold > 1023) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.aspect_cone.sites]] " - "cosine_threshold must be in [1, 1023] " - "(and is required for SLT)", - config_path.string())); - site.cosine_threshold = (uint32_t)threshold; - auto site_reg = [&](const char *key, int fallback) { - const int value = item.contains(key) - ? toml::find(item, key) : fallback; - if (value < 0 || value > 31) - throw std::runtime_error(fmt::format( - "{}: [[widescreen.cull.aspect_cone.sites]] " - "{} must be in [0, 31]", - config_path.string(), key)); - return (uint32_t)value; - }; - site.object_reg = site_reg("object_reg", object_reg); - site.x_reg = site_reg("x_reg", x_reg); - site.z_reg = site_reg("z_reg", z_reg); - site.y_reg = site_reg("y_reg", y_reg); - site.queue_guard = - toml::find_or(item, "queue_guard", true); - if ((site.address & 3u) != 0) - throw std::runtime_error(fmt::format( - "{}: aspect-cone address 0x{:08X} is not " - "instruction-aligned", - config_path.string(), site.address)); - if (!seen.insert(site.address & 0x1FFFFFFFu).second) - throw std::runtime_error(fmt::format( - "{}: duplicate aspect-cone address 0x{:08X}", - config_path.string(), site.address)); - ws_aspect_cone.sites.push_back(site); - } - } - if (cull.contains("guard_pixels")) { - ws_cull_guard_pixels = toml::find(cull, "guard_pixels"); - if (ws_cull_guard_pixels < 0 || ws_cull_guard_pixels > 256) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull] guard_pixels must be in [0, 256]", - config_path.string())); - } - if (cull.contains("activation_guard_pixels")) { - ws_cull_activation_guard_pixels = - toml::find(cull, "activation_guard_pixels"); - if (ws_cull_activation_guard_pixels < 0 || - ws_cull_activation_guard_pixels > 256) - throw std::runtime_error(fmt::format( - "{}: [widescreen.cull] activation_guard_pixels must " - "be in [0, 256]", - config_path.string())); - } - if (cull.contains("screen_w_imms")) { - ws_cull_w_imms.clear(); - load_sites("screen_w_imms", ws_cull_w_imms); - } - if (cull.contains("screen_h_imms")) { - ws_cull_h_imms.clear(); - load_sites("screen_h_imms", ws_cull_h_imms); - } - if (cull.contains("auto_screen_x")) - ws_auto_screen_x_cull = toml::find(cull, "auto_screen_x"); - if (cull.contains("auto_backdrop")) - ws_auto_backdrop_preload = toml::find(cull, "auto_backdrop"); - } - } - - // Optional [widescreen.bg2d] block — pure-2D background tile-loop widen. - uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0, ws_bg2d_startx_site = 0; - uint32_t ws_bg2d_stream_left_site = 0, ws_bg2d_stream_right_site = 0; - uint32_t ws_bg2d_bufbase_site = 0, ws_bg2d_cap_site = 0; - uint32_t ws_bg2d_layer_base = 0x800971F8u, ws_bg2d_ring_base = 0x800A21B8u; - uint32_t ws_bg2d_map_size_addr = 0x800CD338u, ws_bg2d_layer_stride_addr = 0x8008EC10u; - uint32_t ws_bg2d_ring_cols = 64, ws_bg2d_layer_count = 3; - uint32_t ws_bg2d_layer_struct_stride = 0x54; - uint32_t ws_bg2d_init_func = 0; - uint32_t ws_bg2d_packet_cap = 1000; - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("bg2d")) { - const toml::value& bg = toml::find(ws, "bg2d"); - auto load1 = [&](const char* key) -> uint32_t { - return bg.contains(key) - ? parse_hex(toml::find(bg, key), - fmt::format("widescreen.bg2d.{}", key)) - : 0u; - }; - ws_bg2d_count_site = load1("count_site"); - ws_bg2d_startcol_site = load1("startcol_site"); - ws_bg2d_startx_site = load1("startx_site"); - ws_bg2d_stream_left_site = load1("stream_left_site"); - ws_bg2d_stream_right_site = load1("stream_right_site"); - ws_bg2d_bufbase_site = load1("bufbase_site"); - ws_bg2d_cap_site = load1("cap_site"); - if (bg.contains("layer_base")) - ws_bg2d_layer_base = load1("layer_base"); - if (bg.contains("ring_base")) - ws_bg2d_ring_base = load1("ring_base"); - if (bg.contains("map_size_addr")) - ws_bg2d_map_size_addr = load1("map_size_addr"); - if (bg.contains("layer_stride_addr")) - ws_bg2d_layer_stride_addr = load1("layer_stride_addr"); - auto load_positive = [&](const char* key, uint32_t current) -> uint32_t { - if (!bg.contains(key)) return current; - const int64_t value = toml::find(bg, key); - if (value <= 0 || value > UINT32_MAX) - throw std::runtime_error(fmt::format( - "widescreen.bg2d.{} must be a positive 32-bit integer", key)); - return static_cast(value); - }; - ws_bg2d_ring_cols = load_positive("ring_cols", ws_bg2d_ring_cols); - ws_bg2d_layer_count = load_positive("layer_count", ws_bg2d_layer_count); - ws_bg2d_layer_struct_stride = load_positive( - "layer_struct_stride", ws_bg2d_layer_struct_stride); - ws_bg2d_packet_cap = load_positive("packet_cap", ws_bg2d_packet_cap); - if ((ws_bg2d_ring_cols & (ws_bg2d_ring_cols - 1u)) != 0u) - throw std::runtime_error( - "widescreen.bg2d.ring_cols must be a power of two"); - ws_bg2d_init_func = load1("init_func"); - } - } - - // Optional [widescreen.backdrop] block — parallax 2D backdrop screenX squash - // (x_sites) + far-backdrop GTE un-squash (unsquash_funcs). - std::vector ws_backdrop_x_sites; - std::vector ws_backdrop_unsquash_funcs; - std::vector ws_dome_call_sites; - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("backdrop")) { - const toml::value& bd = toml::find(ws, "backdrop"); - if (bd.contains("x_sites")) - for (const auto& a : toml::find>(bd, "x_sites")) - ws_backdrop_x_sites.push_back( - parse_hex(a, "widescreen.backdrop.x_sites")); - if (bd.contains("unsquash_funcs")) - for (const auto& a : toml::find>(bd, "unsquash_funcs")) - ws_backdrop_unsquash_funcs.push_back( - parse_hex(a, "widescreen.backdrop.unsquash_funcs")); - } - } - - // Optional [widescreen.dome] block: exact projection calls belonging to - // finite curved background meshes that retain authored 4:3 coverage. - if (cfg.contains("widescreen")) { - const toml::value& ws = toml::find(cfg, "widescreen"); - if (ws.contains("dome")) { - const toml::value& dome = toml::find(ws, "dome"); - if (dome.contains("call_sites")) - for (const auto& a : toml::find>(dome, "call_sites")) - ws_dome_call_sites.push_back( - parse_hex(a, "widescreen.dome.call_sites")); - } - } - - return GameConfig{ - /*config_path*/ config_path, - /*project_root*/ root, - /*name*/ name, - /*id*/ id, - /*region*/ region, - /*players*/ players, - /*exe_path*/ exe_path, - /*load_address*/ load_address, - /*entry_pc*/ entry_pc, - /*text_size*/ text_size, - /*stack_base*/ stack_base, - /*discs*/ discs, - /*has_disc_crc*/ has_disc_crc, - /*disc_crc*/ disc_crc, - /*disc_sha1*/ disc_sha1, - /*seeds_path*/ seeds_path, - /*bios_thunks_path*/ bios_thunks_path, - /*bios_config_path*/ bios_config_path, - /*out_dir*/ out_dir, - /*strict*/ strict, - /*discovery*/ discovery, - /*out_stem*/ out_stem, - /*recompiler_patches*/ recompiler_patches, - /*runtime*/ parse_runtime_block(cfg, root), - /*ws_sprite_tag_funcs*/ ws_sprite_tag_funcs, - /*ws_sprite_anchor_addr*/ ws_sprite_anchor_addr, - /*ws_hud_sprt_squash*/ ws_hud_sprt_squash, - /*ws_auto_ui_squash*/ ws_auto_ui_squash, - /*data_shard_funcs*/ data_shard_funcs, - /*mod_function_entry_funcs*/ mod_function_entry_funcs, - /*hot_funcs*/ hot_funcs, - /*vsync_query_func*/ vsync_query_func, - /*vsync_counter_addr*/ vsync_counter_addr, - /*vsync_gpustat_ptr_addr*/ vsync_gpustat_ptr_addr, - /*vsync_timer1_ptr_addr*/ vsync_timer1_ptr_addr, - /*vsync_timer1_cache_addr*/ vsync_timer1_cache_addr, - /*vsync_event_horizon_sites*/ vsync_event_horizon_sites, - /*vsync_event_horizon_extra_sites*/ vsync_event_horizon_extra_sites, - /*vsync_event_horizon_any*/ vsync_event_horizon_any, - /*ws_cull_bias_sites*/ ws_cull_bias_sites, - /*ws_cull_range_sites*/ ws_cull_range_sites, - /*ws_cull_a1_sites*/ ws_cull_a1_sites, - /*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites, - /*ws_cull_slti_sites*/ ws_cull_slti_sites, - /*ws_cull_slti_lower_sites*/ ws_cull_slti_lower_sites, - /*ws_cull_lower_branch_sites*/ ws_cull_lower_branch_sites, - /*ws_cull_lower_value_sites*/ ws_cull_lower_value_sites, - /*ws_cull_upper_value_sites*/ ws_cull_upper_value_sites, - /*ws_cull_span_value_sites*/ ws_cull_span_value_sites, - /*ws_cull_bltz_sites*/ ws_cull_bltz_sites, - /*ws_cull_negsub_sites*/ ws_cull_negsub_sites, - /*ws_cull_vxrange_sites*/ ws_cull_vxrange_sites, - /*ws_cull_depth_sites*/ ws_cull_depth_sites, - /*ws_cull_plane_nx_sites*/ ws_cull_plane_nx_sites, - /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, - /*ws_cull_keep_sites*/ ws_cull_keep_sites, - /*ws_cull_angle_sites*/ ws_cull_angle_sites, - /*ws_cull_yaw_cone_sites*/ ws_cull_yaw_cone_sites, - /*ws_cull_horizontal_slt_sites*/ ws_cull_horizontal_slt_sites, - /*ws_aspect_cone*/ ws_aspect_cone, - /*ws_cull_guard_pixels*/ ws_cull_guard_pixels, - /*ws_cull_activation_guard_pixels*/ ws_cull_activation_guard_pixels, - /*ws_cull_w_imms*/ ws_cull_w_imms, - /*ws_cull_h_imms*/ ws_cull_h_imms, - /*ws_backdrop_x_sites*/ ws_backdrop_x_sites, - /*ws_backdrop_unsquash_funcs*/ ws_backdrop_unsquash_funcs, - /*ws_dome_call_sites*/ ws_dome_call_sites, - /*ws_auto_screen_x_cull*/ ws_auto_screen_x_cull, - /*ws_auto_backdrop_preload*/ ws_auto_backdrop_preload, - /*ws_full_2d*/ ws_full_2d, - /*ws_gte_game_mode*/ ws_gte_game_mode, - /*ws_gameplay_state_addr*/ ws_gameplay_state_addr, - /*ws_gameplay_state_values*/ ws_gameplay_state_values, - /*ws_native_wide*/ ws_native_wide, - /*ws_nw_hud_corners*/ ws_nw_hud_corners, - /*ws_nw_left_hud_packet_lo*/ ws_nw_left_hud_packet_lo, - /*ws_nw_left_hud_packet_hi*/ ws_nw_left_hud_packet_hi, - /*ws_nw_backdrop*/ ws_nw_backdrop, - /*ws_clear_reveal*/ ws_clear_reveal, - /*ws_nw_flat_backdrop*/ ws_nw_flat_backdrop, - /*ws_nw_phase_backdrop*/ ws_nw_phase_backdrop, - /*ws_nw_textured_edges*/ ws_nw_textured_edges, - /*ws_nw_textured_edge_scale*/ ws_nw_textured_edge_scale, - /*ws_nw_full_mirror*/ ws_nw_full_mirror, - /*ws_signed_x_bound_sites*/ ws_signed_x_bound_sites, - /*ws_offered*/ ws_offered, - /*vulkan_offered*/ vulkan_offered, - /*ws_adaptive_view*/ ws_adaptive_view, - /*ws_ultrawide_offered*/ ws_ultrawide_offered, - /*ws_bg2d_count_site*/ ws_bg2d_count_site, - /*ws_bg2d_startcol_site*/ ws_bg2d_startcol_site, - /*ws_bg2d_startx_site*/ ws_bg2d_startx_site, - /*ws_bg2d_stream_left_site*/ ws_bg2d_stream_left_site, - /*ws_bg2d_stream_right_site*/ ws_bg2d_stream_right_site, - /*ws_bg2d_bufbase_site*/ ws_bg2d_bufbase_site, - /*ws_bg2d_cap_site*/ ws_bg2d_cap_site, - /*ws_bg2d_layer_base*/ ws_bg2d_layer_base, - /*ws_bg2d_ring_base*/ ws_bg2d_ring_base, - /*ws_bg2d_map_size_addr*/ ws_bg2d_map_size_addr, - /*ws_bg2d_layer_stride_addr*/ ws_bg2d_layer_stride_addr, - /*ws_bg2d_ring_cols*/ ws_bg2d_ring_cols, - /*ws_bg2d_layer_count*/ ws_bg2d_layer_count, - /*ws_bg2d_layer_struct_stride*/ ws_bg2d_layer_struct_stride, - /*ws_bg2d_init_func*/ ws_bg2d_init_func, - /*ws_bg2d_packet_cap*/ ws_bg2d_packet_cap, - }; -} - -// ---- GameOptions (game_options.toml) — the game's own native settings ---- - -GameOptions load_game_options(const fs::path& path) { - GameOptions go; - std::error_code ec; - if (path.empty() || !fs::exists(path, ec)) return go; - - const toml::value doc = toml::parse(path.string()); - if (!doc.contains("option")) return go; - const auto& arr = toml::find(doc, "option"); - for (const auto& item : arr) { - GameOption o; - o.name = toml::find(item, "name"); - o.addr = parse_hex(toml::find(item, "addr"), - fmt::format("game_options [[option]] '{}' addr", o.name)); - o.size = static_cast(toml::find(item, "size")); - if (o.size != 1 && o.size != 2) - throw std::runtime_error(fmt::format( - "game_options [[option]] '{}' size must be 1 or 2: {}", o.name, o.size)); - if (item.contains("init_store_pc")) - o.init_store_pc = parse_hex(toml::find(item, "init_store_pc"), - fmt::format("game_options '{}' init_store_pc", o.name)); - // Optional restore-time value range. `max` (with optional `min`, default 0) - // marks the field validated; a saved value outside [min,max] is dropped. - if (item.contains("max")) { - o.vmax = toml::find(item, "max"); - o.vmin = item.contains("min") ? toml::find(item, "min") : 0; - if (o.vmax < o.vmin) - throw std::runtime_error(fmt::format( - "game_options '{}' max ({}) < min ({})", o.name, o.vmax, o.vmin)); - o.has_range = true; - } - go.options.push_back(o); - } - return go; -} - -// ---- UserSettings (settings.toml) — user-interface-written override layer ---- - -UserSettings load_user_settings(const fs::path& path) { - UserSettings s; - std::error_code ec; - if (path.empty() || !fs::exists(path, ec)) return s; - - toml::value doc; - try { - // Opened by its wide name: path.string() is UTF-8 under MinGW, which the narrow open reads as the ANSI page. - std::ifstream file(path, std::ios::binary); - if (!file) throw std::runtime_error("settings.toml could not be opened"); - doc = toml::parse(file, "settings.toml"); - } catch (const std::exception&) { - // Malformed file: fall back to all-defaults rather than refuse to boot, - // but tell the caller so the user hears about it (and so the file can - // be preserved before any launcher save overwrites it with defaults). - s.parse_error = true; - return s; - } - - // Each field guarded independently so one bad value can't blank the rest. - auto try_get = [](auto&& fn) { try { fn(); } catch (const std::exception&) {} }; - - if (doc.contains("video")) { - const toml::value& v = toml::find(doc, "video"); - if (v.contains("renderer")) try_get([&]{ - const auto m = toml::find(v, "renderer"); - if (m == "software") { s.renderer = 0; s.has_renderer = true; } - else if (m == "opengl") { s.renderer = 1; s.has_renderer = true; } - else if (m == "vulkan") { s.renderer = 2; s.has_renderer = true; } - }); - if (v.contains("supersampling")) try_get([&]{ - const auto n = toml::find(v, "supersampling"); - if (n >= 1 && n <= 8) { s.supersampling = (int)n; s.has_supersampling = true; } - }); - if (v.contains("window_width")) try_get([&]{ - const auto n = toml::find(v, "window_width"); - if (n >= 640 && n <= 3840) { s.window_width = (int)n; s.has_window_width = true; } - }); - if (v.contains("antialiasing")) try_get([&]{ - s.antialiasing = toml::find(v, "antialiasing"); s.has_antialiasing = true; - }); - if (v.contains("texture_filtering")) try_get([&]{ - const auto m = toml::find(v, "texture_filtering"); - if (m == "nearest") { s.texture_filter = 0; s.has_texture_filter = true; } - else if (m == "bilinear") { s.texture_filter = 1; s.has_texture_filter = true; } - }); - if (v.contains("crt_filter")) try_get([&]{ - const auto m = toml::find(v, "crt_filter"); - if (m == "raw") { s.screen_kind = 0; s.has_screen_kind = true; } - else if (m == "crt") { s.screen_kind = 1; s.has_screen_kind = true; } - else if (m == "composite") { s.screen_kind = 2; s.has_screen_kind = true; } - else if (m == "trinitron") { s.screen_kind = 3; s.has_screen_kind = true; } - }); - if (v.contains("auto_skip_fmv")) try_get([&]{ - s.auto_skip_fmv = toml::find(v, "auto_skip_fmv"); s.has_auto_skip_fmv = true; - }); - if (v.contains("turbo_loads")) try_get([&]{ - s.turbo_loads = toml::find(v, "turbo_loads"); s.has_turbo_loads = true; - }); - if (v.contains("fast_boot")) try_get([&]{ - s.fast_boot = toml::find(v, "fast_boot"); s.has_fast_boot = true; - }); - if (v.contains("bios_hle")) try_get([&]{ - s.bios_hle = toml::find(v, "bios_hle"); s.has_bios_hle = true; - }); - if (v.contains("fullscreen")) try_get([&]{ - // Tri-state (0 off / 1 borderless / 2 exclusive). Back-compat: a - // settings.toml written before the tri-state migration stores this - // as a bool (true meant borderless desktop fullscreen). - try { - s.fullscreen = toml::find(v, "fullscreen"); - } catch (const std::exception&) { - s.fullscreen = toml::find(v, "fullscreen") ? 1 : 0; - } - if (s.fullscreen < 0 || s.fullscreen > 2) s.fullscreen = 0; - s.has_fullscreen = true; - }); - if (v.contains("low_latency_input")) try_get([&]{ - s.low_latency_input = toml::find(v, "low_latency_input"); - s.has_low_latency_input = true; - }); - if (v.contains("vsync")) try_get([&]{ - const auto m = toml::find(v, "vsync"); - if (m == "on" || m == "vsync") { s.vsync = 1; s.has_vsync = true; } - else if (m == "off" || m == "immediate"){ s.vsync = 0; s.has_vsync = true; } - else if (m == "adaptive") { s.vsync = -1; s.has_vsync = true; } - }); - if (v.contains("frame_interpolation")) try_get([&]{ - s.frame_interpolation = toml::find(v, "frame_interpolation"); - s.has_frame_interpolation = true; - }); - if (v.contains("frame_interpolation_fps")) try_get([&]{ - s.frame_interpolation_fps = toml::find(v, "frame_interpolation_fps"); - if (s.frame_interpolation_fps == 0 || - (s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000)) - s.has_frame_interpolation_fps = true; - }); - if (v.contains("frame_interpolation_blend")) try_get([&]{ - const auto n = toml::find(v, "frame_interpolation_blend"); - if (n == 0 || n == 1 || n == 2) { - s.frame_interpolation_blend = static_cast(n); - s.has_frame_interpolation_blend = true; - } - }); - if (v.contains("aspect_ratio")) try_get([&]{ - const auto m = toml::find(v, "aspect_ratio"); - int n = 0, d = 0; - if (parse_aspect_ratio(m, &n, &d)) { - s.aspect_num = n; s.aspect_den = d; s.has_aspect_ratio = true; - } - }); - if (v.contains("adaptive_view")) try_get([&]{ - s.adaptive_view = toml::find(v, "adaptive_view"); - s.has_adaptive_view = true; - }); - } - if (doc.contains("audio")) { - const toml::value& a = toml::find(doc, "audio"); - if (a.contains("spu_hq")) try_get([&]{ - s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; - }); - if (a.contains("master_volume")) try_get([&]{ - const auto n = toml::find(a, "master_volume"); - if (n >= 0 && n <= 100) { - s.master_volume = static_cast(n); - s.has_master_volume = true; - } - }); - if (a.contains("muted")) try_get([&]{ - s.audio_muted = toml::find(a, "muted"); - s.has_audio_muted = true; - }); - } - if (doc.contains("launcher")) { - const toml::value& l = toml::find(doc, "launcher"); - if (l.contains("skip_launcher")) try_get([&]{ - s.skip_launcher = toml::find(l, "skip_launcher"); s.has_skip_launcher = true; - }); - } - if (doc.contains("netplay")) { - const toml::value& n = toml::find(doc, "netplay"); - if (n.contains("player_name")) try_get([&]{ - const auto v = toml::find(n, "player_name"); - if (!v.empty()) { s.netplay_player_name = v; s.has_netplay_player_name = true; } - }); - if (n.contains("lobby_url")) try_get([&]{ - const auto v = toml::find(n, "lobby_url"); - if (!v.empty()) { s.netplay_lobby_url = v; s.has_netplay_lobby_url = true; } - }); - } - if (doc.contains("bios")) { - const toml::value& b = toml::find(doc, "bios"); - if (b.contains("path")) try_get([&]{ - const auto p = toml::find(b, "path"); - if (!p.empty()) { s.bios_path = fs::path(p); s.has_bios_path = true; } - }); - } - if (doc.contains("disc")) { - const toml::value& d = toml::find(doc, "disc"); - if (d.contains("path")) try_get([&]{ - const auto p = toml::find(d, "path"); - if (!p.empty()) { s.disc_path = fs::path(p); s.has_disc_path = true; } - }); - } - if (doc.contains("memcard")) { - const toml::value& m = toml::find(doc, "memcard"); - if (m.contains("dir")) try_get([&]{ - const auto p = toml::find(m, "dir"); - if (!p.empty()) { s.memcard_dir = fs::path(p); s.has_memcard_dir = true; } - }); - if (m.contains("card1")) try_get([&]{ - const auto p = toml::find(m, "card1"); - if (!p.empty()) { s.memcard1_path = fs::path(p); s.has_memcard1_path = true; } - }); - if (m.contains("card2")) try_get([&]{ - const auto p = toml::find(m, "card2"); - if (!p.empty()) { s.memcard2_path = fs::path(p); s.has_memcard2_path = true; } - }); - if (m.contains("enable1")) try_get([&]{ - s.memcard1_enabled = toml::find(m, "enable1"); s.has_memcard1_enabled = true; - }); - if (m.contains("enable2")) try_get([&]{ - s.memcard2_enabled = toml::find(m, "enable2"); s.has_memcard2_enabled = true; - }); - } - if (doc.contains("localization")) { - const toml::value& lc = toml::find(doc, "localization"); - if (lc.contains("language")) try_get([&]{ - const auto v = toml::find(lc, "language"); - if (!v.empty()) { s.language = v; s.has_language = true; } - }); - } - if (doc.contains("parappa_timing")) { - const toml::value& timing = toml::find(doc, "parappa_timing"); - if (timing.contains("mode")) try_get([&]{ - auto mode = toml::find(timing, "mode"); - for (char& c : mode) c = (char)std::tolower((unsigned char)c); - if (mode == "stock" || mode == "off" || mode == "medium" || - mode == "permissive" || mode == "easy" || mode == "custom") { - s.parappa_timing_mode = mode; - s.has_parappa_timing_mode = true; - } - }); - if (timing.contains("extra_early")) try_get([&]{ - const auto n = toml::find(timing, "extra_early"); - if (n >= 0 && n <= 60) { - s.parappa_timing_extra_early = (int)n; - s.has_parappa_timing_extra_early = true; - } - }); - if (timing.contains("extra_late")) try_get([&]{ - const auto n = toml::find(timing, "extra_late"); - if (n >= 0 && n <= 60) { - s.parappa_timing_extra_late = (int)n; - s.has_parappa_timing_extra_late = true; - } - }); - } - if (doc.contains("controller")) { - const toml::value& ct = toml::find(doc, "controller"); - if (ct.contains("p1_device")) try_get([&]{ - const auto v = toml::find(ct, "p1_device"); - if (!v.empty()) { s.p1_device = v; s.has_p1_device = true; } - }); - if (ct.contains("p2_device")) try_get([&]{ - const auto v = toml::find(ct, "p2_device"); - if (!v.empty()) { s.p2_device = v; s.has_p2_device = true; } - }); - // Legacy boolean form first (true->analog, false->digital); the new - // string `*_mode` keys override when present. - if (ct.contains("p1_analog")) try_get([&]{ - s.p1_mode = toml::find(ct, "p1_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - s.has_p1_mode = true; - }); - if (ct.contains("p2_analog")) try_get([&]{ - s.p2_mode = toml::find(ct, "p2_analog") - ? PAD_MODE_ANALOG : PAD_MODE_DIGITAL; - s.has_p2_mode = true; - }); - if (ct.contains("p1_mode")) try_get([&]{ - s.p1_mode = pad_mode_from_string( - toml::find(ct, "p1_mode"), PAD_MODE_HYBRID); - s.has_p1_mode = true; - }); - if (ct.contains("p2_mode")) try_get([&]{ - s.p2_mode = pad_mode_from_string( - toml::find(ct, "p2_mode"), PAD_MODE_HYBRID); - s.has_p2_mode = true; - }); - if (ct.contains("deadzone")) try_get([&]{ - const auto n = toml::find(ct, "deadzone"); - if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } - }); - } - if (doc.contains("disruptor")) { - const toml::value& d = toml::find(doc, "disruptor"); - if (d.contains("mouse_aim")) try_get([&]{ - s.mouse_aim = toml::find(d, "mouse_aim"); - s.has_mouse_aim = true; - }); - if (d.contains("modern_controls")) try_get([&]{ - s.modern_controls = toml::find(d, "modern_controls"); - s.has_modern_controls = true; - }); - if (d.contains("horizontal_sensitivity")) try_get([&]{ - double n = 0.0; - try { - n = toml::find(d, "horizontal_sensitivity"); - } catch (const std::exception&) { - n = static_cast( - toml::find(d, "horizontal_sensitivity")); - } - if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { - s.horizontal_sensitivity = n; - s.has_horizontal_sensitivity = true; - } - }); - if (d.contains("invert_horizontal")) try_get([&]{ - s.invert_horizontal = toml::find(d, "invert_horizontal"); - s.has_invert_horizontal = true; - }); - if (d.contains("vertical_look")) try_get([&]{ - s.vertical_look = toml::find(d, "vertical_look"); - s.has_vertical_look = true; - }); - if (d.contains("vertical_sensitivity")) try_get([&]{ - double n = 0.0; - try { - n = toml::find(d, "vertical_sensitivity"); - } catch (const std::exception&) { - n = static_cast( - toml::find(d, "vertical_sensitivity")); - } - if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { - s.vertical_sensitivity = n; - s.has_vertical_sensitivity = true; - } - }); - if (d.contains("invert_vertical")) try_get([&]{ - s.invert_vertical = toml::find(d, "invert_vertical"); - s.has_invert_vertical = true; - }); - if (d.contains("high_precision_camera")) try_get([&]{ - s.high_precision_camera = toml::find(d, "high_precision_camera"); - s.has_high_precision_camera = true; - }); - if (d.contains("geometry_correction")) try_get([&]{ - s.geometry_correction = toml::find(d, "geometry_correction"); - s.has_geometry_correction = true; - }); - if (d.contains("perspective_textures")) try_get([&]{ - s.perspective_textures = toml::find(d, "perspective_textures"); - s.has_perspective_textures = true; - }); - if (d.contains("frame_unlock")) try_get([&]{ - s.frame_unlock = toml::find(d, "frame_unlock"); - s.has_frame_unlock = true; - }); - if (d.contains("skip_intro")) try_get([&]{ - s.skip_intro = toml::find(d, "skip_intro"); - s.has_skip_intro = true; - }); - if (d.contains("language_disc")) try_get([&]{ - s.language_disc = toml::find(d, "language_disc"); - s.has_language_disc = true; - }); - if (d.contains("language_discs")) try_get([&]{ - s.language_discs = toml::find>(d, "language_discs"); - s.has_language_discs = true; - }); - if (d.contains("hud_scale")) try_get([&]{ - const int percent = toml::find(d, "hud_scale"); - if (percent >= 50 && percent <= 100) { - s.hud_scale = percent; - s.has_hud_scale = true; - } - }); - if (d.contains("improved_shadows")) try_get([&]{ - s.improved_shadows = toml::find(d, "improved_shadows"); - s.has_improved_shadows = true; - }); - if (d.contains("ambient_occlusion")) try_get([&]{ - s.ambient_occlusion = toml::find(d, "ambient_occlusion"); - s.has_ambient_occlusion = true; - }); - if (d.contains("occlusion_reach")) try_get([&]{ - const int reach = toml::find(d, "occlusion_reach"); - if (reach >= 50 && reach <= 800) { - s.occlusion_reach = reach; - s.has_occlusion_reach = true; - } - }); - if (d.contains("occlusion_strength")) try_get([&]{ - const int percent = toml::find(d, "occlusion_strength"); - if (percent >= 0 && percent <= 300) { - s.occlusion_strength = percent; - s.has_occlusion_strength = true; - } - }); - if (d.contains("gap_filling")) try_get([&]{ - s.gap_filling = toml::find(d, "gap_filling"); - s.has_gap_filling = true; - }); - if (d.contains("depth_sorted_world")) try_get([&]{ - s.depth_sorted_world = toml::find(d, "depth_sorted_world"); - s.has_depth_sorted_world = true; - }); - if (d.contains("diagnostic_view")) try_get([&]{ - const std::string view = toml::find(d, "diagnostic_view"); - for (int index = 0; index < kDisruptorDiagnosticViewCount; ++index) { - if (view == kDisruptorDiagnosticViews[index]) { - s.diagnostic_view = index; - s.has_diagnostic_view = true; - } - } - }); - } - return s; -} - -namespace { - -fs::path user_settings_temp_path(const fs::path& destination) { - static std::atomic sequence{0}; -#ifdef _WIN32 - const auto process_id = static_cast(::GetCurrentProcessId()); -#else - const auto process_id = static_cast(::getpid()); -#endif - const auto tick = static_cast( - std::chrono::steady_clock::now().time_since_epoch().count()); - - fs::path temporary = destination; - temporary += fmt::format(".tmp.{}.{}.{}", process_id, tick, - sequence.fetch_add(1, std::memory_order_relaxed)); - return temporary; -} - -bool atomic_replace_user_settings(const fs::path& temporary, - const fs::path& destination) { -#ifdef _WIN32 - // A reader or antivirus scanner may briefly deny replacement after a save. - // Keep the old file intact and allow up to 500 ms for that handle to close. - for (unsigned attempt = 0; ; ++attempt) { - if (::MoveFileExW(temporary.c_str(), destination.c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true; - const DWORD error = ::GetLastError(); - const bool transient = error == ERROR_ACCESS_DENIED || - error == ERROR_SHARING_VIOLATION || - error == ERROR_LOCK_VIOLATION; - const DWORD attributes = ::GetFileAttributesW(destination.c_str()); - const bool blocked = attributes != INVALID_FILE_ATTRIBUTES && - (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)); - if (!transient || blocked || attempt == 20) { - ::SetLastError(error); - return false; - } - ::Sleep(25); - } -#else - return std::rename(temporary.c_str(), destination.c_str()) == 0; -#endif -} - -void remove_user_settings_temp(const fs::path& temporary) { - std::error_code ignored; - fs::remove(temporary, ignored); -} - -} // namespace - -bool save_user_settings(const fs::path& path, const UserSettings& s) { - std::error_code ec; - if (!path.parent_path().empty()) - fs::create_directories(path.parent_path(), ec); // best-effort - - const fs::path temporary = user_settings_temp_path(path); - std::ofstream f(temporary, std::ios::trunc); - if (!f.is_open()) return false; - - // TOML strings use forward slashes so backslash escaping is never an issue. - auto fwd = [](const fs::path& p) { - std::string str = p.generic_string(); - return str; - }; - - // A TOML basic string: a quote, a backslash and a control character are escaped. - auto quoted = [](const std::string& text) { - std::string out = "\""; - for (const char letter : text) { - const unsigned code = static_cast(letter); - char escape[8]; - if (letter == '\\' || letter == '"') std::snprintf(escape, sizeof(escape), "\\%c", letter); - else if (code < 0x20 || code == 0x7F) std::snprintf(escape, sizeof(escape), "\\u%04X", code); - else std::snprintf(escape, sizeof(escape), "%c", letter); - out += escape; - } - return out + "\""; - }; - - f << "# psxrecomp user settings - written by settings UIs. Safe to hand-edit.\n"; - f << "# Overrides the bundled game.toml; the command line overrides this file.\n\n"; - - f << "[video]\n"; - if (s.has_renderer) - f << "renderer = \"" - << (s.renderer == 2 ? "vulkan" : s.renderer == 1 ? "opengl" : "software") - << "\"\n"; - if (s.has_supersampling) - f << "supersampling = " << s.supersampling << "\n"; - if (s.has_window_width) - f << "window_width = " << s.window_width << "\n"; - if (s.has_antialiasing) - f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n"; - if (s.has_texture_filter) - f << "texture_filtering = \"" << (s.texture_filter ? "bilinear" : "nearest") << "\"\n"; - if (s.has_screen_kind) { - const char* k = s.screen_kind == 1 ? "crt" - : s.screen_kind == 2 ? "composite" - : s.screen_kind == 3 ? "trinitron" : "raw"; - f << "crt_filter = \"" << k << "\"\n"; - } - if (s.has_auto_skip_fmv) - f << "auto_skip_fmv = " << (s.auto_skip_fmv ? "true" : "false") << "\n"; - if (s.has_turbo_loads) - f << "turbo_loads = " << (s.turbo_loads ? "true" : "false") << "\n"; - if (s.has_fast_boot) - f << "fast_boot = " << (s.fast_boot ? "true" : "false") << "\n"; - if (s.has_bios_hle) - f << "bios_hle = " << (s.bios_hle ? "true" : "false") << "\n"; - if (s.has_fullscreen) - f << "fullscreen = " << s.fullscreen << "\n"; - if (s.has_low_latency_input) - f << "low_latency_input = " << (s.low_latency_input ? "true" : "false") << "\n"; - if (s.has_vsync) - f << "vsync = \"" << (s.vsync == 0 ? "immediate" : s.vsync < 0 ? "adaptive" : "on") << "\"\n"; - if (s.has_frame_interpolation) - f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n"; - if (s.has_frame_interpolation_fps) - f << "frame_interpolation_fps = " << s.frame_interpolation_fps << "\n"; - if (s.has_frame_interpolation_blend && - (s.frame_interpolation_blend == 0 || - s.frame_interpolation_blend == 1 || - s.frame_interpolation_blend == 2)) - f << "frame_interpolation_blend = " << s.frame_interpolation_blend << "\n"; - if (s.has_aspect_ratio) - f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; - if (s.has_adaptive_view) - f << "adaptive_view = " << (s.adaptive_view ? "true" : "false") << "\n"; - f << "\n[audio]\n"; - if (s.has_spu_hq) - f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; - if (s.has_master_volume) - f << "master_volume = " << s.master_volume << "\n"; - if (s.has_audio_muted) - f << "muted = " << (s.audio_muted ? "true" : "false") << "\n"; - if (s.has_skip_launcher) - f << "\n[launcher]\nskip_launcher = " << (s.skip_launcher ? "true" : "false") << "\n"; - if ((s.has_netplay_player_name && !s.netplay_player_name.empty()) || - (s.has_netplay_lobby_url && !s.netplay_lobby_url.empty())) { - f << "\n[netplay]\n"; - if (s.has_netplay_player_name && !s.netplay_player_name.empty()) - f << "player_name = \"" << s.netplay_player_name << "\"\n"; - if (s.has_netplay_lobby_url && !s.netplay_lobby_url.empty()) - f << "lobby_url = \"" << s.netplay_lobby_url << "\"\n"; - } - if (s.has_bios_path) - f << "\n[bios]\npath = \"" << fwd(s.bios_path) << "\"\n"; - if (s.has_disc_path) - f << "\n[disc]\npath = \"" << fwd(s.disc_path) << "\"\n"; - if (s.has_memcard_dir || s.has_memcard1_path || s.has_memcard2_path || - s.has_memcard1_enabled || s.has_memcard2_enabled) { - f << "\n[memcard]\n"; - if (s.has_memcard_dir) - f << "dir = \"" << fwd(s.memcard_dir) << "\"\n"; - if (s.has_memcard1_path) - f << "card1 = \"" << fwd(s.memcard1_path) << "\"\n"; - if (s.has_memcard2_path) - f << "card2 = \"" << fwd(s.memcard2_path) << "\"\n"; - if (s.has_memcard1_enabled) - f << "enable1 = " << (s.memcard1_enabled ? "true" : "false") << "\n"; - if (s.has_memcard2_enabled) - f << "enable2 = " << (s.memcard2_enabled ? "true" : "false") << "\n"; - } - - if (s.has_p1_device || s.has_p2_device || s.has_p1_mode || s.has_p2_mode || - s.has_deadzone) { - f << "\n[controller]\n"; - if (s.has_p1_device) - f << "p1_device = \"" << s.p1_device << "\"\n"; - if (s.has_p1_mode) - f << "p1_mode = \"" << pad_mode_to_string(s.p1_mode) << "\"\n"; - if (s.has_p2_device) - f << "p2_device = \"" << s.p2_device << "\"\n"; - if (s.has_p2_mode) - f << "p2_mode = \"" << pad_mode_to_string(s.p2_mode) << "\"\n"; - if (s.has_deadzone) - f << "deadzone = " << s.deadzone << "\n"; - } - - if (s.has_language) { - f << "\n[localization]\n"; - f << "language = \"" << s.language << "\"\n"; - } - if (s.has_parappa_timing_mode || s.has_parappa_timing_extra_early || - s.has_parappa_timing_extra_late) { - f << "\n[parappa_timing]\n"; - if (s.has_parappa_timing_mode) - f << "mode = \"" << s.parappa_timing_mode << "\"\n"; - if (s.has_parappa_timing_extra_early) - f << "extra_early = " << s.parappa_timing_extra_early << "\n"; - if (s.has_parappa_timing_extra_late) - f << "extra_late = " << s.parappa_timing_extra_late << "\n"; - } - - if (s.has_mouse_aim || s.has_modern_controls || - s.has_horizontal_sensitivity || s.has_invert_horizontal || - s.has_vertical_look || s.has_vertical_sensitivity || - s.has_invert_vertical || - s.has_high_precision_camera || s.has_geometry_correction || - s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || - s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || - s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || - s.has_language_discs || - s.has_language_disc || s.has_skip_intro || - s.has_hud_scale) { - f << "\n[disruptor]\n"; - if (s.has_mouse_aim) - f << "mouse_aim = " << (s.mouse_aim ? "true" : "false") << "\n"; - if (s.has_modern_controls) - f << "modern_controls = " << (s.modern_controls ? "true" : "false") << "\n"; - if (s.has_horizontal_sensitivity && - std::isfinite(s.horizontal_sensitivity) && - s.horizontal_sensitivity >= 0.005 && - s.horizontal_sensitivity <= 2.0) { - f << "horizontal_sensitivity = " - << std::setprecision(std::numeric_limits::max_digits10) - << s.horizontal_sensitivity << "\n"; - } - if (s.has_invert_horizontal) - f << "invert_horizontal = " << (s.invert_horizontal ? "true" : "false") << "\n"; - if (s.has_vertical_look) - f << "vertical_look = " << (s.vertical_look ? "true" : "false") << "\n"; - if (s.has_vertical_sensitivity && - std::isfinite(s.vertical_sensitivity) && - s.vertical_sensitivity >= 0.005 && - s.vertical_sensitivity <= 2.0) { - f << "vertical_sensitivity = " - << std::setprecision(std::numeric_limits::max_digits10) - << s.vertical_sensitivity << "\n"; - } - if (s.has_invert_vertical) - f << "invert_vertical = " << (s.invert_vertical ? "true" : "false") << "\n"; - if (s.has_high_precision_camera) - f << "high_precision_camera = " << (s.high_precision_camera ? "true" : "false") << "\n"; - if (s.has_geometry_correction) - f << "geometry_correction = " << (s.geometry_correction ? "true" : "false") << "\n"; - if (s.has_perspective_textures) - f << "perspective_textures = " << (s.perspective_textures ? "true" : "false") << "\n"; - if (s.has_frame_unlock) - f << "frame_unlock = " << (s.frame_unlock ? "true" : "false") << "\n"; - if (s.has_skip_intro) - f << "skip_intro = " << (s.skip_intro ? "true" : "false") << "\n"; - if (s.has_language_disc) - f << "language_disc = " << quoted(s.language_disc) << "\n"; - if (s.has_language_discs) { - f << "language_discs = ["; - for (size_t index = 0; index < s.language_discs.size(); ++index) - f << (index ? ", " : "") << quoted(s.language_discs[index]); - f << "]\n"; - } - if (s.has_hud_scale && s.hud_scale >= 50 && s.hud_scale <= 100) - f << "hud_scale = " << s.hud_scale << "\n"; - if (s.has_improved_shadows) - f << "improved_shadows = " << (s.improved_shadows ? "true" : "false") << "\n"; - if (s.has_ambient_occlusion) - f << "ambient_occlusion = " << (s.ambient_occlusion ? "true" : "false") << "\n"; - if (s.has_occlusion_reach && s.occlusion_reach >= 50 && s.occlusion_reach <= 800) - f << "occlusion_reach = " << s.occlusion_reach << "\n"; - if (s.has_occlusion_strength && s.occlusion_strength >= 0 && s.occlusion_strength <= 300) - f << "occlusion_strength = " << s.occlusion_strength << "\n"; - if (s.has_gap_filling) - f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\n"; - if (s.has_depth_sorted_world) - f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; - if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) - f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; - } - - f.flush(); - const bool write_ok = f.good(); - f.close(); - if (!write_ok || f.fail()) { - remove_user_settings_temp(temporary); - return false; - } - if (!atomic_replace_user_settings(temporary, path)) { -#ifdef _WIN32 - const DWORD error = ::GetLastError(); -#endif - remove_user_settings_temp(temporary); -#ifdef _WIN32 - ::SetLastError(error); -#endif - return false; - } - return true; -} - -} // namespace PSXRecompV4 diff --git a/psxrecomp-overlay/recompiler/src/config_loader.h b/psxrecomp-overlay/recompiler/src/config_loader.h deleted file mode 100644 index dbe869f..0000000 --- a/psxrecomp-overlay/recompiler/src/config_loader.h +++ /dev/null @@ -1,1185 +0,0 @@ -// config_loader.h — shared TOML config loader for psxrecomp-{bios,game}. -// -// Mirrors the schema accepted by tools/audit_config.py (Python side). See -// docs/config_schema.md for the field reference. -// -// Two entry points: -// load_bios_config(path) reads bios/SCPH1001.toml — describes the BIOS -// load_game_config(path) reads /game.toml — describes a game EXE -// -// A runtime/process that needs both calls both; the BIOS one is the -// always-loaded base, the game one is layered on top (merge semantics are -// the caller's responsibility for now — recompiler tools consume one or -// the other independently). -// -// Paths inside the TOML are resolved relative to the detected project -// root (the nearest ancestor of the config file that has .gitignore, -// .git, or CMakeLists.txt). - -#pragma once - -#include -#include -#include -#include -#include - -#include "bios_address_model.h" // BiosAddrCopy (BiosConfig::address_copies) -#include "recompiler_patch.h" - -namespace PSXRecompV4 { - -// Pad input mode (per player). Replaces the old analog on/off boolean. -// hybrid — auto-switch DualShock(analog)/digital per the most-recent input: -// nudge the stick -> report DualShock (0x73, variable sticks); -// press the D-pad -> report a digital pad (0x41) so the game runs -// its OWN d-pad path at true digital sensitivity. Mirrors a -// DualShock's analog LED toggling on/off (and Tomba Special -// Edition's auto-detect). Default. -// analog — always present a DualShock/analog pad (id 0x73). The D-pad is -// folded onto the stick at full deflection so it still moves you. -// digital — always present a digital pad (id 0x41); sticks disabled. -enum PadMode { PAD_MODE_HYBRID = 0, PAD_MODE_ANALOG = 1, PAD_MODE_DIGITAL = 2 }; - -// Renderer IDs shared by game.toml/settings parsing and runtime startup. -// OpenGL is the default because the Windows software/SDL_Renderer path is slow -// enough to mask interpreter/AOT performance work and can present as a false -// 30 FPS regression. Software remains an explicit opt-in fallback. -inline constexpr int VIDEO_RENDERER_SOFTWARE = 0; -inline constexpr int VIDEO_RENDERER_OPENGL = 1; -inline constexpr int VIDEO_RENDERER_VULKAN = 2; -inline constexpr int DEFAULT_VIDEO_RENDERER = VIDEO_RENDERER_OPENGL; - -struct WidescreenSignedBoundSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded LUI instruction -}; - -// One exact compare whose verdict is forced while a widescreen reveal is -// active. The full instruction word is part of the identity because overlay -// variants routinely place unrelated code at the same virtual address. -struct WidescreenCullKeepSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded SLT/SLTU/SLTI/SLTIU instruction - uint32_t result = 0; // forced comparison result (0 or 1) -}; - -// Aspect-scaled 12-bit angular half-extent. These sites load a positive angle -// constant with `addi[u] rt,zero,imm`; the runtime widens tan(angle) by the -// live horizontal reveal factor. Full-word guards prevent overlay-address -// aliases from changing unrelated immediates. -struct WidescreenAngleSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero -}; - -// Aspect-scaled wrapping yaw offsets. Unlike WidescreenAngleSite, these are -// ADDI/ADDIU instructions whose source register already contains the live -// camera yaw. The signed immediate is a half-FOV in `full_turn` units (for -// example +/-32 with full_turn=256). Only the offset is widened; the base yaw -// and its natural integer wrapping remain owned by the game. -struct WidescreenYawConeSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded ADDI/ADDIU with rs != zero - uint32_t full_turn = 0; -}; - -// Exact signed SLT side-plane tests of the form `bound < horizontal`. The -// runtime aspect-scales only the horizontal operand, preserving the original -// comparison at 4:3 and widening a camera-space horizontal cone without -// touching its corresponding vertical test. -struct WidescreenHorizontalSltSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded signed register-register SLT -}; - -// Aspect-aware horizontal participation cone. The exact compare sites are -// full-word guarded because overlay variants can reuse a virtual address for -// unrelated code. Registers are MIPS GPR indices captured at each comparison. -// Queue metadata is optional; when present it lets guard/hysteresis candidates -// leave configured headroom without changing the game's fixed capacities. -struct WidescreenAspectConeSite { - uint32_t address = 0; - uint32_t expected = 0; // guarded signed SLTI or SLT reject comparison - // Q10 cosine threshold. Zero derives the threshold from an SLTI - // immediate; register-register SLT sites must provide it explicitly. - uint32_t cosine_threshold = 0; - // Per-site register overrides. UINT32_MAX inherits the enclosing - // aspect-cone defaults. - uint32_t object_reg = 0xFFFFFFFFu; - uint32_t x_reg = 0xFFFFFFFFu; - uint32_t z_reg = 0xFFFFFFFFu; - uint32_t y_reg = 0xFFFFFFFFu; - // False for lower-level model/child predicates that do not append to the - // configured fixed-capacity queues. - bool queue_guard = true; -}; - -struct WidescreenAspectConeConfig { - std::vector sites; - uint32_t forward_addr = 0; // three signed Q12 halfwords: X,Z,Y - uint32_t object_type_offset = 0; - uint32_t object_reg = 0; - uint32_t x_reg = 0; - uint32_t z_reg = 0; - uint32_t y_reg = 0; - uint32_t hysteresis_pixels = 0; - uint32_t queue_reserve = 0; - std::array queue_count_addrs{}; - std::array queue_capacities{}; - std::array queue_type_masks{}; -}; -// Parse/format a pad mode. pad_mode_from_string accepts "hybrid"/"analog"/ -// "digital" (case-insensitive) and returns `fallback` for anything else. -int pad_mode_from_string(const std::string& s, int fallback); -const char* pad_mode_to_string(int mode); - -// [runtime] block — consumed by runtime/src/main.cpp. All fields optional; -// callers that need them check has_* flags or use the supplied defaults. -struct RuntimeConfig { - bool has_debug_port = false; - uint16_t debug_port = 0; - - // Localization / on-the-fly string translation (docs/STRING_TRANSLATION.md). - // language selects the translations/*.toml column; "jp"/"off"/"" disables - // APPLY (capture still runs). From [localization].language or [runtime]. - // language; env PSX_LANG overrides at runtime. Also the launcher default. - std::string language = "en"; - - // Optional launcher-facing language menu. When a game declares - // [localization].languages, the launcher shows a "Localization" dropdown of - // these {code,label} options (code feeds `language`; "off"/"jp"/"" = the - // untranslated native game). Empty => no dropdown (the general default). - struct LanguageOption { std::string code; std::string label; }; - std::vector languages; - - bool has_window_title = false; - std::string window_title; - - // controller: "digital" (default) | "dualshock" - bool has_controller = false; - std::string controller; - - bool has_memcard_dir = false; - std::filesystem::path memcard_dir; // absolute path (resolved against project root) - - // disc_speed: "1x" (default) | "2x" | "4x" | "instant" - // Controls how quickly CD-ROM timing delays fire. "instant" collapses all - // seek/read delays to 1 cycle — correct INT sequence, no artificial wait. - bool has_disc_speed = false; - std::string disc_speed; // raw string; main.cpp converts to divisor - - // instant_max_per_frame: per-frame sector-IRQ budget while disc_speed = - // "instant" (cdrom.c floors the per-sector period to VBLANK/N). Absent = - // cdrom.c built-in default. Runtime-tunable via the cdrom_instant_rate - // TCP command; the turbo-through-loads predicate drives the same knob. - bool has_instant_max_per_frame = false; - int instant_max_per_frame = 0; - - // Optional, game-specific warm-load route accelerators. A matching SetLoc - // at arm_lba followed by the exact ordered lbas sequence switches only - // those data reads to the bounded instant cadence. Any sequence mismatch - // fails closed to the configured disc_speed. The parser accepts the legacy - // [runtime.warm_cd_route] table and the reusable - // [[runtime.warm_cd_routes]] array-of-tables. - struct WarmCdRoute { - int arm_lba = -1; - std::vector lbas; - int instant_max_per_frame = 32; - }; - std::vector warm_cd_routes; - - // fast_boot: DEPRECATED alias for the BIOS boot shell-skip (see bios_hle - // below). The old mechanism (snapshot BIOS state at first handoff, restore - // on later launches) is gone; fast_boot=true now skips only the BIOS shell - // (the boot animation) via the HLE tier's one-shot shell intercept, with - // kernel init + game EXE load still executed by the real recompiled BIOS - // at host speed. It is boot-only: it never enables the kernel-call HLE - // tier, and it works on any BIOS image that exports a shell entry. - // Kept so existing game.toml/settings.toml keep working. - bool fast_boot = false; - - // bios_hle: High-Level Emulation tier for BIOS kernel services - // (CLAUDE.md §0 amendment 2026-07-02, the gbarecomp model). DEFAULT ON as of - // 2026-07-06 (user-directed player default: instant boot-skip for every - // game). Opt OUT with [runtime] bios_hle = false or env PSX_BIOS_HLE=0 to run - // pure LLE (the recompiled BIOS), which REMAINS the reference implementation - // and the oracle — this only flips the default, LLE is still fully linked and - // selectable. When on, implemented kernel services are computed in-runtime - // against the real guest kernel structures and every other call falls through - // to LLE. Implies the BIOS boot shell-skip unless bios_hle_keep_intro. - // PSX_BIOS_HLE / PSX_BIOS_HLE_KEEP_INTRO env override at launch. - // Runtime: runtime/src/bios_hle.c. - // - // The flag drives TWO axes with different per-image requirements, resolved - // in runtime/src/bios_hle_plan.c: - // * boot shell-skip — needs only the image's shell_entry_phys anchor and - // works under pure LLE, so it fires the SAME WAY on every linked BIOS - // (retail SCPH-1001 and the bundled OpenBIOS alike). "Skip the BIOS and - // go straight to the game" is one behaviour, not a per-BIOS lottery. - // * kernel-call HLE — needs the image's deliver_event_ret anchor; an - // image without it (OpenBIOS, until its B0 semantics are validated) - // refuses this axis and says so at startup, WITHOUT cancelling the - // boot-skip. Collapsing the two is exactly the bug fixed 2026-07-27. - // openbios: may this title run on the bundled, redistributable OpenBIOS? - // Default true — a player who chooses no BIOS gets OpenBIOS and never has - // to find one (docs/BIOS_SELECTION.md). Set false ONLY for a title with a - // verified OpenBIOS incompatibility; a retail image is then required and - // the player is prompted for it. - // - // Deliberately NOT overridable by a player's settings.toml: this records a - // developer's compatibility finding, not a preference. - bool openbios = true; - - bool bios_hle = true; - bool bios_hle_keep_intro = false; - - // hle_scheduler: the HLE tier's standing SUBSYSTEM REPLACEMENT for guest - // thread switching (deterministic TCB scheduler vs the legacy - // non-deterministic host-fiber bridge). Default ON under BOTH BIOS - // backends (CLAUDE.md §0 amendment 2026-06-29 carve-out); PSX_HLE_SCHEDULER - // env wins over this key. Runtime: traps.c psx_hle_scheduler_enabled(). - bool hle_scheduler = true; - - // overlay_cache: enable the overlay DLL cache + capture (Layer A). Off by - // default. When true the runtime scans cache// for precompiled - // overlay DLLs (loaded ahead of the dirty-RAM interpreter) and records - // overlay bytes to overlay_captures.json for offline compilation. - bool overlay_cache = false; - - // overlay_capture_history: opt-in durable capture history. The runtime - // keeps overlay_captures.json as an atomic latest snapshot for the live - // compiler and additionally appends every changed coherent snapshot to - // overlay_captures.addendum.jsonl beside the executable. A malformed tail - // from a hard kill cannot destroy earlier records. - bool overlay_capture_history = false; - - // overlay_capture_persist_dir: optional DEV-only, project-relative safe - // directory for one immutable JSON file per changed snapshot. Rooted, - // UNC, drive-qualified and drive-relative paths are rejected, as is any - // `..` component or an embedded NUL, under both `/` and `\` separators. - // Production normally leaves this unset and retains only the addendum - // beside the executable. - std::string overlay_capture_persist_dir; - - // turbo_loads: OPT-IN per game. While the game is loading (CD data - // stream active, XA/FMV excluded, post-BIOS-handoff only) the frontend - // skips wall-clock pacing so the guest runs at host speed — compressing - // load wall-time. Streaming titles (e.g. Crash) must leave this off. - bool turbo_loads = false; - - // offer_turbo_loads: expose the generic Turbo loads switch through - // recomp-ui Settings. Defaults true for compatibility. A game migrating - // load acceleration into its mod catalog sets this false; stale persisted - // Settings values are then ignored and a trusted activation plugin owns - // the launch policy. - bool offer_turbo_loads = true; - - // turbo_audio_sink: while turbo_loads is actively running unpaced, keep - // rendering the exact guest-time SPU sample budget (so voice/CD state - // advances) but discard those samples before the host playback queue. - // Opt-in while the experiment is under live audio QA. - bool turbo_audio_sink = false; - - // idle_skip: proof-gated fast-forward through repeated CPU polling - // loops with no stores/MMIO and stable register state. Guest time and - // device events still advance exactly. Opt-in per game; the idle_skip - // debug command and PSX_IDLE_SKIP environment variable support live A/B. - bool idle_skip = false; - - // overlay_autocompile_cmd: variant-capture automation (step 2.8). A - // shell command (run via cmd.exe /C, cwd = project root) that compiles - // overlay_captures.json into the cache — normally the project's - // compile_overlays.py invocation. When set (and overlay_cache is on), - // the runtime auto-captures on sustained capture-window interp pressure - // and spawns this command in the background; on success the loader - // rescans the cache and the new variant goes native in-session. - bool has_overlay_autocompile_cmd = false; - std::string overlay_autocompile_cmd; - - // overlay_autocompile_cmd_tcc: the SAME pipeline as overlay_autocompile_cmd - // but invoking the bundled, toolchain-free TinyCC compiler instead of gcc. - // The runtime spawns THIS command instead of the gcc one when the resolved - // backend is tcc (a machine without a gcc toolchain, or overlay_backend = - // "tcc"/"auto-no-gcc"). Lets gcc stay the dev/production path while tcc is - // the user fallback. When unset, a tcc-resolved backend has no compiler and - // overlay gaps fall to the interpreter. - bool has_overlay_autocompile_cmd_tcc = false; - std::string overlay_autocompile_cmd_tcc; - - // overlay_backend: overlay tier-selection (overlay_backend.h). - // "auto" (default, empty == auto) | "gcc" | "tcc" | "auto-no-gcc". ("sljit" - // was removed 2026-07-15; a stale value degrades to auto.) auto resolves to - // gcc when a gcc TOOLCHAIN is present (a dev / - // production box), else tcc (bundled, toolchain-free). "auto-no-gcc" forces - // the tcc branch even with gcc present (simulate a toolchain-less user box; - // gcc shards still load). The env var PSX_OVERLAY_BACKEND overrides at runtime. - std::string overlay_backend; - - // overlay_native_block: per-game overlay function entries that must stay on - // the dirty-RAM interpreter even when a matching native DLL exists. Intended - // for small timing-sensitive setup/task routines while the rest of the - // overlay runs native. - std::vector overlay_native_block; - - bool has_parappa_timing = false; - std::string parappa_timing_mode = "stock"; - int parappa_timing_extra_early = 0; - int parappa_timing_extra_late = 0; - - // ---- [video] block — visual enhancement options ---- - // supersampling: internal-resolution SSAA factor (per axis). 1 = native - // (default, behaves exactly as before). 2..8 render geometry/shading into - // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid - // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. - int video_supersampling = 1; - - // antialiasing: when true the present path uses linear filtering when - // scaling the framebuffer to the window (smooths the supersample - // downscale and any window resize). false = nearest (sharp pixels). - // Defaults to true. - bool video_antialiasing = true; - - // texture_filtering: "nearest" (default, native PSX look) | "bilinear" - // (smooths textures and 2D backgrounds). Stored as 0/1. - int video_texture_filter = 0; - - // renderer: "software" | "opengl" (default) | "vulkan". Selects the - // rasterizer/present backend. The software rasterizer remains the explicit - // fallback. Stored as VIDEO_RENDERER_*. - int video_renderer = DEFAULT_VIDEO_RENDERER; - - // offer_vulkan: expose the experimental Vulkan renderer in the launcher. - // Defaults false even for Vulkan-enabled builds; developers must opt in per - // game once visuals are validated. - bool video_offer_vulkan = false; - - // low_latency_input: re-sample the pad after the wall-clock pacer (just - // before present) so the next CPU frame reads near-fresh input instead of - // input ~one frame stale. Default on. vsync: present/swap mode — - // 1=on (tear-free, default), 0=immediate (lowest display latency, may - // tear), -1=adaptive. The wall-clock pacer holds 59.94Hz regardless. - bool video_low_latency_input = true; - int video_vsync = 1; - bool video_frame_interpolation = false; - int video_frame_interpolation_fps = 0; // 0 = display refresh - // offer_frame_interpolation: expose the generic interpolation controls - // through recomp-ui Settings. Defaults true for compatibility. A game - // migrating interpolation into its mod catalog sets this false; stale - // persisted Settings values are then ignored and a trusted activation - // plugin owns the runtime switch. - bool video_offer_frame_interpolation = true; - - // crt_filter: present-time screen-colour model (verified-enhancement LUT). - // "raw" (default, byte-identical 5->8 passthrough) | "crt" | "composite" | - // "trinitron". Stored 0..3 to match ScreenKind in runtime/color_lut.h. The - // PSX_SCREEN env var overrides this at runtime (debug path). - int video_screen_kind = 0; - - // auto_skip_fmv: when true, full-motion videos (streaming XA audio + MDEC - // video) are skipped the instant they're detected — presentation + pacing are - // suppressed and audio muted for the duration, so an FMV ends in a fraction of - // a second with nothing shown, landing on the next screen with side effects - // intact. The skip is driven the GAME's own way (see fmv_skip_* below); with - // no per-game config it falls back to holding the skip button. - bool video_auto_skip_fmv = false; - - // offer_skip_fmv: expose auto_skip_fmv through recomp-ui Settings. - // Defaults true for compatibility. A game migrating the feature into its - // mod catalog sets this false; stale persisted Settings values are then - // ignored and a trusted activation plugin owns the runtime switch. - bool video_offer_skip_fmv = true; - - // fmv_skip_*: per-game FMV instant-skip via the game's own end-of-movie path. - // Some players (Tomba) end a movie when the streamed frame number reaches that - // movie's per-movie frame total minus a small offset. When auto_skip_fmv is on - // and fmv_skip_total_table is set, the runtime writes the CURRENT movie's total - // (at fmv_skip_total_table + movie_id*2, a u16 table indexed by the movie-id - // byte at fmv_skip_movie_id) down to fmv_skip_end_total, so the player tears the - // movie down on its next frame — a natural end that reaches EVERY movie (incl. - // ones whose caller never polls the skip button). Only the active movie's entry - // is touched. Addresses are game-specific (RE of the player loop); leave the - // table 0 to fall back to button injection. Tomba: table 0x80077728, - // movie_id 0x1F8001CD, end_total 3 (the player's "total - 3" offset). - uint32_t video_fmv_skip_total_table = 0; - uint32_t video_fmv_skip_movie_id = 0; - int video_fmv_skip_end_total = 0; // 0 => runtime default (3) - - // fmv_skip_no_xa: broaden FMV detection to MDEC-decode activity ALONE (no - // XA-stream requirement). Some movies are silent and fully preloaded into - // RAM (Tomba2's Whoopee Camp logo: no CD sectors, no XA during playback), - // so the default MDEC+XA detector never fires and neither skip mechanism is - // even attempted. With this on, such movies enter the same skip path: - // pacing/presents suppressed (fast-forward at host speed), audio muted, - // skip button held. The movie still executes every frame — presentation- - // side only, guest timeline untouched. Per-game opt-in because MDEC use - // outside movies (loading-screen stills) would briefly trigger it. - bool video_fmv_skip_no_xa = false; - // fmv_skip_no_xa_hold: presentation-side fast-forward latch, in guest - // vblanks, after the most recent silent MDEC decode. Silent preloaded - // logos can retain an authored post-decode wait; a title may opt into a - // longer latch so auto-skip covers that wait too. Default preserves the - // generic four-frame inter-decode hysteresis. - int video_fmv_skip_no_xa_hold = 4; - - // aspect_ratio: display aspect "W:H" (default "4:3" = native). A wider - // aspect (e.g. "16:9") enables the widescreen hack: the GTE squashes - // screen-X by (4*H)/(3*W) around the game's projection centre and the - // present path stretches the 4:3 frame to W:H — net effect is a wider - // field of view for GTE-projected geometry. Screen-space 2D (HUD, FMV, - // sprite widths) stretches; world geometry keeps correct proportions. - int video_aspect_num = 4; - int video_aspect_den = 3; - - // ---- [audio] block ---- - // buffer_ms: steady-state host playback cushion. The ecosystem default - // remains 180 ms because it survives long streamed-stage production gaps; - // games with smoother production cadence may opt into a lower value to - // reduce controller-to-sound latency. This is a game-developer setting, - // not a player preference. - int audio_buffer_ms = 180; - - // spu_hq: enable the SPU float-shadow re-render (Catmull-Rom resample, float - // headroom). Verified-enhancement, default OFF — spu_render output is - // byte-identical to the canon hardware mix when off. The PSX_AUDIO_SHADOW - // env var overrides this at runtime (debug path). - bool audio_spu_hq = false; - - // ---- [controller] block — game-declared input defaults ---- - // default_mode: the pad input mode this game ships with (see PadMode): - // "hybrid" (default) auto-switches DualShock/digital from the player's - // input, "analog" pins DualShock (0x73), "digital" pins a digital pad - // (0x41). A stick-capable title (e.g. Tomba) ships "hybrid" so the stick - // gives variable run speed yet the D-pad keeps its classic digital feel, - // with no launcher toggling. Per-install settings.toml [controller] - // p1_mode/p2_mode still override. `default_mode` sets both ports; - // `p1_mode`/`p2_mode` set one. Legacy `default_analog`/`p1_analog`/ - // `p2_analog` booleans are still accepted (true->analog, false->digital). - bool has_default_mode = false; - int default_p1_mode = PAD_MODE_HYBRID; - int default_p2_mode = PAD_MODE_HYBRID; - - // allow_hybrid: whether the launcher offers the "Hybrid" pad mode at all. - // Default true (Hybrid | Analog | D-Pad). A game that needs an explicit - // analog/digital choice (e.g. one that hard-requires a DualShock) can set - // [controller] allow_hybrid = false to drop Hybrid from the selector. - bool controller_allow_hybrid = true; - - // lock_mode: when true the launcher HIDES the whole pad-mode selector - // (Hybrid | Analog | D-Pad) and forces every port to default_p1_mode. For a - // game that supports exactly one pad type — e.g. Tomba 2, whose driver only - // works as a plain digital pad because the DualShock config-mode handshake - // is unhandled — so the player can't pick a broken mode. Supersedes - // allow_hybrid (which only hides the Hybrid segment). Default false. - bool controller_lock_mode = false; - - // lock_device: when true the launcher HIDES the Player 1/2 controller cards - // entirely (no device picker, no config) — the game's controller type is - // fixed and auto-bound. For a title that presents exactly one hardcoded pad - // type (e.g. Ape Escape = DualShock analog) where exposing a device/mode - // choice is pointless. Distinct from lock_mode (which only hides the pad-mode - // segment but keeps the device dropdown). Default false. - bool controller_lock_device = false; - - // deadzone: default analog-stick deadzone in raw SDL axis units (0..32767). - // Applied both to the stick->d-pad press threshold and the analog-axis centre - // dead-band. Absent => runtime default (12000). Overridden per-install by - // settings.toml [controller] deadzone and by input.ini. - bool has_deadzone = false; - int deadzone = 0; - - // anti_deadzone: minimum radial analog output after leaving deadzone, in - // raw SDL axis units (0..32767). This is a game-owned response setting used - // to compensate a title's own internal stick deadzone. Absent => 0. - bool has_anti_deadzone = false; - int anti_deadzone = 0; - -}; - -// One entry from [[recompiler.bios_vectors]]. -// Describes a BIOS vector dispatch stub (A0/B0/C0) that the BIOS installs -// into low RAM at boot. The recompiler reads the function pointer table from -// the ROM binary at build time and emits a static C switch handler so these -// addresses are resolved as binary-search hits at runtime rather than falling -// through to dirty_ram_interp. -struct BiosVectorTable { - uint32_t ram_addr; // RAM address of the installed stub (e.g. 0xA0) - int index_reg; // CPU register that holds the function index ($t1 = 9) - uint32_t table_rom_addr; // ROM virtual address of the function pointer table - uint32_t table_count; // number of entries to read from the table - // Runtime RAM address of the live function table (used as fallback for - // Shell-patched entries not present in ROM). 0 = no runtime fallback. - uint32_t table_ram_addr; -}; - -// One entry from [[recompiler.bios_aliases]]. -// A RAM address the BIOS installs a simple fixed-target trampoline at -// (e.g. the SIO handler at 0x0CF0 which just jalrs to 0x641C). Emitted as -// a one-liner wrapper in the dispatch table — no table lookup, no switch. -struct BiosAlias { - uint32_t ram_addr; // the installed stub address (e.g. 0x0CF0) - uint32_t target_key; // normalized dispatch key of the target (e.g. 0x641C) -}; - -struct BiosConfig { - std::filesystem::path config_path; // the toml file itself - std::filesystem::path project_root; // resolved via .gitignore/.git/CMakeLists.txt walk - - // [program] block - std::string name; // display name, e.g. "SCPH1001 BIOS" - std::string id; // canonical id, e.g. "SCPH-1001" - std::filesystem::path rom_path; // absolute path to BIOS ROM - uint32_t load_address; - uint32_t entry_pc; - uint32_t text_size; - - // [program.image] block (optional): declared image identity. When - // sha256 is present the recompiler REFUSES to emit from a ROM whose - // computed sha doesn't match — regenerating from the wrong image is a - // build defect, not a warning. redistributable=true marks a BIOS that - // ships WITH the game (OpenBIOS): the runtime then hides the whole - // BIOS-selection surface (couriered via psx_bios_image.image_bundled). - std::string image_sha256; // empty = unchecked - bool image_redistributable = false; - - // [recompiler] block - std::filesystem::path seeds_path; // absolute path to seeds JSON - std::filesystem::path out_dir; // absolute path to output dir - bool strict; // currently always true - std::string out_stem; // derived if not explicit - std::vector bios_vectors; // optional vector dispatch tables - std::vector bios_aliases; // optional fixed-target trampolines - - // [recompiler.address_model] block: the BIOS's boot-time ROM->RAM code - // copies, semantic validation and consumption in BiosAddressModel - // (bios_address_model.h). Empty = the BIOS runs entirely from ROM. - std::vector address_copies; - // [[recompiler.install_slots]]: kernel-RAM PCs the BIOS overwrites with - // dispatch stubs at runtime (see docs/dynamic_handler_install.md). - std::vector install_slots; - - // [recompiler.runtime_exports]: per-image anchors the emitter couriers - // into the generated C (psx_bios_image, runtime/include/psx_bios_image.h) - // for the runtime's HLE tier. 0 = this BIOS has no such anchor — the - // consumer treats the feature as structurally unavailable. - // shell_entry_phys gates the BIOS boot-skip and NOTHING else; it works - // under pure LLE, so every image that exports it skips the boot the same - // way. deliver_event_ret gates the kernel-call HLE tier, separately. The - // runtime decides the two axes in psx_bios_hle_plan() - // (runtime/include/bios_hle_plan.h) — read that header before touching - // either, it records why collapsing them broke OpenBIOS boot-skip. - uint32_t shell_entry_phys = 0; // BIOS boot-skip trigger (bios_hle.c) - uint32_t deliver_event_ret = 0; // $ra after the kernel DeliverEvent jalr - - // NOTE: a BIOS profile has NO [runtime] block. It describes an IMAGE — - // facts about bytes — never a preference; runtime options belong to - // game.toml/settings.toml, where the player and the title can both be - // heard. There used to be a RuntimeConfig here, parsed and never read by - // anything, and bios/OpenBIOS.toml carried a `bios_hle = false` in it that - // looked like the reason HLE was off on OpenBIOS. It was not (the real gate - // is the absent deliver_event_ret anchor), and reading it as one is how the - // boot-skip regression got rationalized instead of fixed. load_bios_config - // now REJECTS a [runtime] block rather than silently ignoring it. -}; - -struct GameConfig { - std::filesystem::path config_path; - std::filesystem::path project_root; - - // [game] block - std::string name; // e.g. "Tomba!" - std::string id; // e.g. "SCUS-94236" - // Optional region label shown by the launcher (e.g. "(USA)", "(Europe)", - // "(Japan)"). Empty = derive from the `id` serial prefix at the call site - // (SCUS/SLUS/LSP -> USA, SCES/SLES -> Europe, SCPS/SLPS/SLPM -> Japan); - // an unrecognized/empty serial yields no region badge. - std::string region; - // How many players the game supports (1 or 2). Defaults to 1; most PSX - // titles configs never set this explicitly. - int players = 1; - std::filesystem::path exe_path; // absolute path to PS-X EXE - uint32_t load_address; - uint32_t entry_pc; - uint32_t text_size; - uint32_t stack_base; // initial $sp - // disc paths (Phase D will properly support multi-disc; for now we - // accept either a single `disc = "..."` or `discs = [...]` and store - // the union here). - std::vector discs; - - // Optional expected disc identity, for the launcher's "Disc verified" badge. - // disc_crc: full-file CRC32 (IEEE) of the data track. disc_sha1: lowercase - // hex SHA-1. Either may be absent (has_disc_crc / disc_sha1.empty()). - bool has_disc_crc = false; - uint32_t disc_crc = 0; - std::string disc_sha1; - - // [recompiler] block - std::filesystem::path seeds_path; // absolute path to seeds (text or json) - std::filesystem::path bios_thunks_path; // optional; empty if not set - // [recompiler] bios_config — BIOS profile this game builds against - // (empty = main_psx resolves the SCPH1001 profile default). - std::filesystem::path bios_config_path; - std::filesystem::path out_dir; - bool strict; - std::string discovery; // "whole-image" (default) or "reachable" - std::string out_stem; // derived if not explicit - // Game-owned, exact MIPS word replacements. IDs and physical instruction - // addresses are unique within a config; see docs/config_schema.md. - std::vector recompiler_patches; - - // [runtime] block (optional) - RuntimeConfig runtime; - - // [widescreen] block (optional) — per-game knobs for the widescreen hack - // ([video] aspect_ratio != 4:3). All default to inert; a game with no - // [widescreen] block gets the plain GTE squash + stretched present only. - // - // sprite_tag_funcs: guest addresses of functions called once per - // character/billboard prim with the prim pointer in $a0 (the recompiler - // emits a psx_ws_sprite_tag(cpu) callback at their entry). Tagged prims - // get their X coords re-squashed around the prim's projected anchor at - // GP0 submission, undoing the present stretch so sprites keep correct - // proportions. - // sprite_anchor_addr: scratchpad address holding the prim's projected - // anchor SXY (written by the game's RTPS preamble) at tag time. - // hud_sprt_squash: center-squash every UNtagged textured-rect (SPRT) - // prim — pure screen-space 2D (HUD, menus) — so it presents at native - // proportions. Untextured TILEs (fades) are never touched. - std::vector ws_sprite_tag_funcs; - uint32_t ws_sprite_anchor_addr = 0; - bool ws_hud_sprt_squash = false; - // auto_ui_squash: proportion-correct textured screen-space primitives in - // the final ordering-table layer. Repeated glyph/icon rows share an anchor - // so centred text and edge counters cannot split at thirds boundaries. - bool ws_auto_ui_squash = false; - - // [data_shards] funcs: functions that get the memoized pure-function - // replay entry/return hooks (psx_datashard_enter/psx_datashard_ret). - // Enhancement-phase load-time work; see docs/DATA_SHARDS.md. Capture is - // self-proving (byte-verified read-set), so listing a function that turns - // out to be impure only costs a poisoned capture, never a wrong replay. - std::vector data_shard_funcs; - - // [recompiler] mod_function_entry_funcs: narrowly selected guest function - // entries that dispatch trusted, statically linked mod callbacks. Empty by - // default, so projects that do not opt in emit no callback overhead. - std::vector mod_function_entry_funcs; - - // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) - // on their generated C bodies (profile/host locality; no guest semantics). - std::vector hot_funcs; - - // [load_accel.vsync_query] opt-in for a byte-verified PsyQ VSync(mode) - // implementation. mode=-1 returns vsync_counter_addr while bypassing two - // unused MMIO reads; every other mode executes the original function. - uint32_t vsync_query_func = 0; - uint32_t vsync_counter_addr = 0; - uint32_t vsync_gpustat_ptr_addr = 0; - uint32_t vsync_timer1_ptr_addr = 0; - uint32_t vsync_timer1_cache_addr = 0; - // Return addresses of verified CD wait-loop VSync(-1) calls. At these - // sites only, the runtime may advance guest time to the next deliverable - // device event while a sustained CD load is active. - std::vector vsync_event_horizon_sites; - // Separately togglable second tier for additional verified loops, allowing - // per-feature A/B without disabling the accepted base site set. - std::vector vsync_event_horizon_extra_sites; - // When true, any VSync(-1) may event-horizon while CD load/read is busy - // (not only listed return PCs). For titles whose FMV polls many sites. - bool vsync_event_horizon_any = false; - - // Cull-margin widening. The game's per-object draw classifier compares - // (objX - camX + BIAS) against a RANGE derived from the 4:3 screen width; - // the GTE squash shows ~33% more world, so the fixed margin collapses and - // objects pop in/out near the wide-screen edges. We widen the window by - // emitting a runtime margin term psx_ws_x_margin() (0 at 4:3/boot/menu/FMV, - // ~the half-extra-width when stretching) into the relevant immediates: - // cull_bias_sites: an addiu rT,rS,imm → rT = rS + (imm + margin) - // cull_range_sites: an sltiu rT,rS,imm → rT = rS ws_cull_bias_sites; - std::vector ws_cull_range_sites; - std::vector ws_cull_a1_sites; - // Explicit `sltiu rt,sx,W` render rejects for cases where codegen function - // splitting separates the paired vertical test from auto_screen_x. - std::vector ws_cull_screen_x_sites; - - // [widescreen.cull] slti_sites — explicit signed right-edge widen sites - // (`slti rt, sx, W` → psx_ws_cull_slti) for funnel functions the - // auto-detector cannot qualify (e.g. an X-only test with no height compare - // in the same function — Ape Escape 0x8004AB64). Empty by default; regen. - std::vector ws_cull_slti_sites; - // Explicit signed lower-bound sites (`slti rt, sx, -W`). The threshold is - // moved left by the live reveal margin. Empty by default; regen required. - std::vector ws_cull_slti_lower_sites; - // Manual screen-window code often expresses the left edge as a zero-bound - // branch (`bgez`, `bgtz`, `blez`, or `bltz`) rather than an SLTI. At these - // exact branch PCs, move that boundary from 0 to -x_margin while preserving - // the original branch sense and the exact 4:3 result. - std::vector ws_cull_lower_branch_sites; - // Exact constant/store sites which materialize the manual window edges. - // lower_value_sites accepts li-zero forms and `sw zero,off(base)` and emits - // vanilla-x_margin; upper_value_sites accepts li constants and emits - // vanilla+x_margin. span_value_sites accepts a packed signed-left/u16-right - // constant (high16,low16), widening both halves symmetrically. All are - // identity transforms when native-wide is inactive; regen required. - std::vector ws_cull_lower_value_sites; - std::vector ws_cull_upper_value_sites; - std::vector ws_cull_span_value_sites; - // [widescreen.cull] bltz_sites — explicit signed LEFT-edge widen sites - // (`bltz rs, reject` -> psx_ws_cull_bltz), the counterpart to slti_sites. - // detect_cull_bltz_sites only classifies left-edge bltz for functions - // auto_screen_x qualified, so an X-only funnel wired through explicit - // slti_sites has no left-edge widen without this. Empty by default; - // identity at 4:3; regen required. - std::vector ws_cull_bltz_sites; - // Horizontal low-edge form `subu rd,zero,rs` -> `-rs-x_margin`. - // Empty by default; configured sites require regenerated native code. - std::vector ws_cull_negsub_sites; - // `sltiu rt,rs,imm` where rs is an ANDI-masked 16-bit screen X. - // Widens both edges in 16-bit space; empty by default; regen required. - std::vector ws_cull_vxrange_sites; - // Aspect-scaled slti/sltiu far-bound sites. Empty by default; use only for - // pure visibility gates. Configured sites require regenerated native code. - std::vector ws_cull_depth_sites; - // `lw rt,off(rs)` sites loading the X component of a side frustum-plane - // normal feeding a sign test (dot = nx*px + nz*pz). Scaled by the inverse - // aspect factor (4*den)/(3*num) while revealed, which widens the plane - // cone by exactly atan((3*num)/(4*den)*tan(theta)); identity at 4:3. - std::vector ws_cull_plane_nx_sites; - - // `lw rt,off(rs)` sites loading a per-primitive X-reject bound that is - // compared (sltu) against ANDI-masked u16 screen X. While the margins are - // revealed the load yields INT32_MAX (reject disabled; the wide-surface - // scissor clips the overflow and wrapped off-left coords pass); the - // vanilla loaded value at 4:3. Empty by default; regen required. - std::vector ws_cull_xclip_load_sites; - // Exact comparison sites whose result is forced only while widescreen - // reveals extra world. Used for proven object/model participation gates - // where maximal overdraw is preferable to range guessing. Each entry is - // guarded by the complete MIPS word; 4:3 executes the vanilla comparison. - std::vector ws_cull_keep_sites; - // Exact 12-bit angular half-extents used by terrain-cell frusta. - std::vector ws_cull_angle_sites; - // Signed wrapping yaw +/- half-FOV offsets used to seed portal traversal. - std::vector ws_cull_yaw_cone_sites; - // Signed camera-horizontal side-plane rejects used after traversal. - std::vector ws_cull_horizontal_slt_sites; - // Full-word-guarded model-participation cosine compares widened only in - // the camera-horizontal plane. Empty/default is completely inert. - WidescreenAspectConeConfig ws_aspect_cone; - // Extra per-side render/terrain participation beyond the visible edge. - int ws_cull_guard_pixels = 0; - // Additional per-side lead used only by the explicit bias_sites and - // range_sites world-space activation windows. This lets a game activate - // already-resident objects well before the visible edge without widening - // terrain producers or fixed-capacity render cones by the same amount. - int ws_cull_activation_guard_pixels = 0; - - // [widescreen.cull] screen_w_imms / screen_h_imms — the width/height - // immediates of the GTE screen-extent reject signature, per game (the - // display width varies: Tomba 320 → 0x140/0x141; Ape Escape 368 → 0x181). - // Consumed by the auto_screen_x detector + emit on every backend (the - // runtime mirrors get them via gpu_ws_set_cull_imms). Defaults preserve - // the original Tomba signature. - std::vector ws_cull_w_imms; - std::vector ws_cull_h_imms; - - // Backdrop screen-X squash ([widescreen.backdrop] x_sites). The parallax - // 2D backdrop layer (ocean/cloud/mountain/grass — overlay actor handlers) - // computes screenX = (worldX - camX) >> parallax in pure integer math and - // stores it to the object's screen-X field WITHOUT the GTE, so the GTE - // X-squash that gives 3D the wider 16:9 FOV never reaches it; far pieces - // sit past the 320px edge and are clipped (the edge "void"/pop-in). Each - // x_site is a `sh rt,off(base)` storing the FINAL screenX; we emit it as - // `write_half(base+off, psx_ws_backdrop_x(rt))` so the value is squashed - // around screen centre (identity at 4:3). These addresses live in OVERLAY - // code, so the overlay compile must see this config (--ws-config). A regen - // is required; empty by default. - std::vector ws_backdrop_x_sites; - - // [widescreen.backdrop] unsquash_funcs — far-backdrop driver functions whose - // body is bracketed with gte_ws_set_suppress(1)/(0) so the GTE X-squash is - // OFF for their (far, parallax) draws: the backdrop fills the stretched 16:9 - // frame instead of leaving edge void (8C). Main-EXE addresses; regen-class. - std::vector ws_backdrop_unsquash_funcs; - - // [widescreen.dome] call_sites: exact guest JAL addresses whose GTE - // projections belong to a finite curved backdrop mesh authored for 4:3. - std::vector ws_dome_call_sites; - - // [widescreen.cull] auto_screen_x — automatic horizontal-FOV cull widening. - // GTE-projected render funnels reject a primitive when ALL its vertices fall - // off the 4:3 frame: a per-vertex `sltiu vN, SX, 0x140` (right edge) paired - // with `sltiu vN, SY, 0xE0` (bottom edge) in the same function. When this is - // true the recompiler auto-detects that signature and emits every width - // compare (0x140 / inclusive 0x141) with + 2*psx_ws_x_margin(), so the - // wider 16:9 field of view is submitted instead of culled at 320 — no - // per-site address list needed. 0 at 4:3 ⇒ byte-identical. Off by default; - // a regen is required. (Vertical 0xE0 bound is left untouched.) - bool ws_auto_screen_x_cull = false; - - // [widescreen.cull] auto_backdrop — automatic far-backdrop column PRELOAD. - // Scrolling 2D backdrop layers (sky/ground/cloud/flower-field tile rows) - // generate only a camera-windowed ~4:3 range of tile columns, so the 16:9 - // revealed margin shows void until the camera moves. When true the recompiler - // auto-detects each column-window generator by its invariant (the /96 magic - // 0x66666667 dividing the 0x176 camera-X, the sra-by-5 divide tail, and the - // move/addiu loop-bound finalize — see ws_backdrop_detect.h) and rewrites the - // window START to 0 and END high via psx_ws_backdrop_value(); the generator's - // own low/high clamps then pin the loop to the whole finite row. Generators - // are overlay-resident, so the overlay compile must see this (--ws-config). - // 0 at 4:3 (native-wide inactive) => byte-identical. Off by default; regen. - bool ws_auto_backdrop_preload = false; - - // [widescreen] full_2d — pure-2D sprite game (e.g. Mega Man X6) that never - // emits the sprite_tag hook the 3D detector relies on. When true, every - // in-game frame is treated as "gameplay" so native-wide engages and the 2D - // scene is presented widescreen (the framework's normal full-2D screens - // pillarbox 4:3, which is wrong for a game that IS 2D end-to-end). Runtime- - // only (read at startup; no codegen impact) — no regen required. The wider - // field of view itself is supplied by [widescreen.cull]/engine hooks; this - // flag only opts the title into the 2D widescreen present path. Off by - // default; the env var PSX_WS_FORCE_2D=1 forces it on for testing. - bool ws_full_2d = false; - - // [widescreen] gte_game_mode — GTE-activity gameplay detector for a fully - // 3D title with no sprite-tag helper (e.g. Ape Escape). When true, a frame - // that projects enough vertices through RTPS/RTPT is stamped as gameplay - // (native-wide engages); genuine full-2D screens (save/options) still - // pillarbox 4:3. Runtime-only — no regen required. Off by default. - bool ws_gte_game_mode = false; - // Optional authoritative game-state gate for titles whose menus also - // render enough 3D geometry to fool gte_game_mode. When configured, - // native-wide is active only while the guest word matches one listed - // value. Runtime-only; both fields must be supplied together. - uint32_t ws_gameplay_state_addr = 0; - std::vector ws_gameplay_state_values; - - // [widescreen] native_wide — select the newer wide render-target path. - // Defaults on for compatibility. Titles can keep the original GTE-squash - // + stretched-present path when native-wide is not regression-free. - bool ws_native_wide = true; - - // [widescreen] nw_hud_corners — in native-wide, push outer-third screen- - // space HUD sprites out to the true wide-frame corners (they otherwise sit - // inset by the reveal offset). Runtime-only — no regen. Off by default. - bool ws_nw_hud_corners = false; - - // [widescreen] nw_left_hud_packet_lo / nw_left_hud_packet_hi — optional - // half-open physical-RAM source range for a specifically identified HUD - // pool. In native-wide, primitives from this pool anchor by screen third - // (left/right), without moving similarly placed scenery from other pools - // (essential for pure-2D games whose world is also screen-space prims). - // Both values must be present together. Runtime-only; no regen required. - uint32_t ws_nw_left_hud_packet_lo = 0; - uint32_t ws_nw_left_hud_packet_hi = 0; - - // [widescreen] nw_backdrop — in native-wide, stretch a screen-space quad - // that covers the whole 4:3 framebuffer (sky gradient / backdrop image) to - // fill the wide frame, so it stops pillarboxing at the reveal margins. - // Runtime-only — no regen. Off by default. - bool ws_nw_backdrop = false; - - // [widescreen] clear_reveal — opt a title into synthetic native-wide margin - // cleanup. A game-specific stage/map boundary can clear only proven-void - // sides while preserving the canonical 4:3 surface and guest VRAM. Runtime- - // only gate; any game-specific init hook remains regen-class. Off by default. - bool ws_clear_reveal = false; - - // [widescreen] nw_flat_backdrop — in the native-wide mirror only, stretch - // untextured flat primitives across the wider output. This is for games - // whose authored 4:3 sky/backdrop is emitted as flat polygons rather than - // a recognizable full-frame textured quad. Runtime-only; off by default. - bool ws_nw_flat_backdrop = false; - - // [widescreen] nw_phase_backdrop — stretch textured primitives emitted - // before the frame's first shaded 3D primitive. This isolates an authored - // 2D sky/backdrop phase from the later textured foreground. Runtime-only; - // off by default because draw ordering is title-specific. - bool ws_nw_phase_backdrop = false; - - // Expand only textured polygon vertices that already lie beyond the - // canonical 4:3 boundary. Useful for finite arena/background meshes while - // leaving actors, HUD, sprites, and centre geometry untouched. - bool ws_nw_textured_edges = false; - int ws_nw_textured_edge_scale = 0; // percent; 0 = aspect-derived - - // Render the complete wide mirror instead of splicing its centre from - // canonical VRAM. Required when edge-crossing polygon interpolation is - // transformed in the mirror. - bool ws_nw_full_mirror = false; - - // [[widescreen.signed_x_bound]] guarded LUI sites whose signed Q16 - // constants scale with the active native-wide field and remain identity in - // 4:3/menus/FMV. Shared by static codegen, overlay JIT, and interpreter. - std::vector ws_signed_x_bound_sites; - // [widescreen] offer — whether the launcher OFFERS its EXPERIMENTAL - // Widescreen toggle for this title. Default true. Set false while a - // title's widescreen is unported/unvalidated (e.g. MMX4 at bring-up): - // the toggle is hidden in the launcher UI AND the runtime clamps the - // display aspect to 4:3 after all config sources, so a stale persisted - // 16:9 in settings.toml can never engage the hack. Runtime-only (read at - // startup; no codegen impact) — no regen required. - bool ws_offered = true; - bool vulkan_offered = false; - - // [widescreen] adaptive_view — let the user opt into a live, resize-driven - // aspect instead of selecting only fixed 4:3/16:9/21:9 modes. The fixed - // aspect remains the initial window shape; the live view is clamped to the - // widest aspect this title offers. - bool ws_adaptive_view = false; - - // [widescreen] offer_ultrawide — expose a separate experimental 21:9 - // launcher choice for titles that have explicitly tested it. Default off; - // ordinary widescreen offer remains the independent 16:9 choice. - bool ws_ultrawide_offered = false; - - // [widescreen.bg2d] — pure-2D background tile-loop widen (e.g. MMX6's - // FUN_800270d0). Three instruction addresses in the per-layer BG renderer - // whose column count and loop start are rewritten so the loop draws the - // 16:9 reveal columns on both sides of the 320 view (see gpu.c - // psx_ws_bg2d_* helpers — identity at 4:3 / 512 hi-res mode). Regen-class. - // count_site: either the `li rt,21` column-count load (addiu/ori), or - // the loop-closing inline `slti[u] rt,index,21` bound - // compare (MMX4/MMX5). - // startcol_site: the `andi rt,rs,0x3f` start tile-column mask. - // startx_site: the `sra rd,rt,sa` or `subu rd,zero,rt` start screen-x. - // 0 = unset (feature off). Verified by opcode at gen time. - uint32_t ws_bg2d_count_site = 0; - uint32_t ws_bg2d_startcol_site = 0; - uint32_t ws_bg2d_startx_site = 0; - // stream_left_site / stream_right_site: the addiu instructions in the tile- - // RING STREAMER that compute the left (scrollX-16) and right (scrollX+16, - // before +width) leading-edge world-X. Pushed out by LEFT*16 so the ring is - // populated across the widened column window (else the extra columns show - // empty/stale tiles). 0 = unset. - uint32_t ws_bg2d_stream_left_site = 0; - uint32_t ws_bg2d_stream_right_site = 0; - // bufbase_site: the driver addu computing the BG packet-buffer address - // (base 0x800B91C0 + bufidx*0x4000); relocated to a larger free buffer when the - // widen is active. cap_site: the renderer's per-frame tile-cap slti (counter<1000); - // raised to match the bigger buffer. Together they cure the dense-stage overflow. - uint32_t ws_bg2d_bufbase_site = 0; - uint32_t ws_bg2d_cap_site = 0; - // Tile-ring layout used by the once-per-frame freshness refill. Defaults - // describe MMX6; sibling engines such as MMX5 override the shifted RAM - // addresses and smaller ring width in game.toml. - uint32_t ws_bg2d_layer_base = 0x800971F8u; - uint32_t ws_bg2d_ring_base = 0x800A21B8u; - uint32_t ws_bg2d_map_size_addr = 0x800CD338u; - uint32_t ws_bg2d_layer_stride_addr = 0x8008EC10u; - uint32_t ws_bg2d_ring_cols = 64; - uint32_t ws_bg2d_layer_count = 3; - uint32_t ws_bg2d_layer_struct_stride = 0x54; - // init_func: full tile-ring initializer called only when an independent - // layer's stage data is dirty. A callback at entry invalidates stale host - // reveal pixels once before the new stage background is submitted. - uint32_t ws_bg2d_init_func = 0; - uint32_t ws_bg2d_packet_cap = 1000; -}; - -// UserSettings — the launcher/in-game-UI-written, user-editable override layer. -// -// Lives in a `settings.toml` next to the runtime exe (NOT in the repo). It is -// layered on top of the bundled game.toml at startup: any field present here -// overrides the corresponding game.toml value, and the command line overrides -// both. Absent fields fall through to game.toml. A settings UI seeds this file -// from game.toml defaults the first time it writes. -// -// Unlike game.toml, paths here are stored verbatim (the user picked them); they -// are NOT resolved against a project root. -struct UserSettings { - // Set when the file existed but failed to parse as TOML: every field below - // is defaults and the caller must warn loudly — silently dropping a user's - // hand-edited settings looks like the settings "don't work", and a later - // launcher save would overwrite their file with defaults. - bool parse_error = false; - - // [video] - bool has_renderer = false; int renderer = 0; // 0=software,1=opengl - bool has_supersampling = false; int supersampling = 1; // 1..8 - // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to - // both the launcher and the emulator window so they boot at the same size. - bool has_window_width = false; int window_width = 1280; // -> 1280x960 - bool has_antialiasing = false; bool antialiasing = true; - bool has_texture_filter = false; int texture_filter = 0; // 0=nearest,1=bilinear - bool has_screen_kind = false; int screen_kind = 0; // 0..3 (ScreenKind) - bool has_auto_skip_fmv = false; bool auto_skip_fmv = false; // skip FMVs - // Turbo through in-game load screens: while the CD data stream is active, run - // the guest unpaced (host speed) to compress load wall-time. All guest timing - // (VBlanks/callbacks/sectors) is preserved and audio plays through. Default ON - // (the per-game game.toml value seeds the launcher toggle; user choice in - // settings.toml overrides it). - bool has_turbo_loads = false; bool turbo_loads = true; - bool has_fast_boot = false; bool fast_boot = false; - // HLE BIOS tier toggle (see RuntimeConfig::bios_hle). Overrides game.toml. - bool has_bios_hle = false; bool bios_hle = false; - // [video] fullscreen: universal tri-state (matches every recomp-ui console's - // launcher control). 0 = windowed (default), 1 = borderless desktop - // fullscreen, 2 = exclusive fullscreen (real display-mode change). The - // in-game Alt+Enter / Cmd+Ctrl+F hotkey toggles live between windowed and - // whichever of these is configured. - bool has_fullscreen = false; int fullscreen = 0; - // Low-latency present knobs. low_latency_input re-samples the pad after the - // wall-clock pacer (just before present) so the next CPU frame reads fresh - // input instead of input ~one frame stale (the dominant input->photon cost - // on a vsync-light box). vsync: 1=on (tear-free), 0=immediate (lowest - // display latency, may tear), -1=adaptive. - bool has_low_latency_input = false; bool low_latency_input = true; - bool has_vsync = false; int vsync = 1; - bool has_frame_interpolation = false; bool frame_interpolation = false; - bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; - // Frame-interpolation mode: 0 = legacy linear, 1 = legacy adaptive, - // 2 = geometry inter-frame redraw. Activation remains session-only in - // Disruptor; its runtime migrates saved legacy modes to geometry. - bool has_frame_interpolation_blend = false; int frame_interpolation_blend = 0; - // [launcher] — when true, boot straight into the game and skip the GUI - // launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run: - // `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off. - bool has_skip_launcher = false; bool skip_launcher = false; - // [netplay] — display name for lobby browser / room. Prompted once on first - // Netplay Lobbies visit; Change Player Name updates it. - bool has_netplay_player_name = false; std::string netplay_player_name; - bool has_netplay_lobby_url = false; std::string netplay_lobby_url; - bool has_aspect_ratio = false; int aspect_num = 4; // display aspect W:H - int aspect_den = 3; // (4:3 = native) - bool has_adaptive_view = false; bool adaptive_view = false; - // [audio] - bool has_spu_hq = false; bool spu_hq = false; - // Host-output controls. These scale the final mixed stereo stream and do - // not alter SPU registers, voice state, CD playback state or guest saves. - bool has_master_volume = false; int master_volume = 100; // 0..100 - bool has_audio_muted = false; bool audio_muted = false; - // [bios] / [disc] / [memcard] - bool has_bios_path = false; std::filesystem::path bios_path; - bool has_disc_path = false; std::filesystem::path disc_path; - bool has_memcard_dir = false; std::filesystem::path memcard_dir; - // Per-slot memory-card overrides. An explicit card path overrides the - //

/card.mcd default; the enable flag inserts/removes the card. - bool has_memcard1_path = false; std::filesystem::path memcard1_path; - bool has_memcard2_path = false; std::filesystem::path memcard2_path; - bool has_memcard1_enabled = false; bool memcard1_enabled = true; - bool has_memcard2_enabled = false; bool memcard2_enabled = true; - - // [controller] — per-player input device + pad type. device is one of: - // "none" — no pad in this port (port not connected) - // "keyboard" — driven by the keyboard map (input.ini) - // "" — an SDL game-controller GUID (SDL_JoystickGetGUIDString) - // p1_mode/p2_mode select the emulated pad behaviour (see PadMode): - // hybrid (default) / analog / digital. Defaults: P1 keyboard, P2 none. - bool has_p1_device = false; std::string p1_device = "keyboard"; - bool has_p2_device = false; std::string p2_device = "none"; - // Pad input mode per player (see PadMode): hybrid (default) / analog / - // digital. Persisted as p1_mode/p2_mode strings. Legacy p1_analog/p2_analog - // booleans are still read for back-compat (true->analog, false->digital). - bool has_p1_mode = false; int p1_mode = PAD_MODE_HYBRID; - bool has_p2_mode = false; int p2_mode = PAD_MODE_HYBRID; - // Analog-stick deadzone, raw SDL axis units (0..32767). The launcher edits - // this as 0-100% (raw = pct*32767/100), mirroring snesrecomp's GamepadDeadzone. - bool has_deadzone = false; int deadzone = 12000; - // Localization: the launcher's chosen language code (feeds RuntimeConfig - // .language / g_lang). "off"/"jp"/"" = untranslated native game. Persisted to - // settings.toml [localization].language. - bool has_language = false; std::string language = "en"; - - bool has_parappa_timing_mode = false; std::string parappa_timing_mode = "stock"; - bool has_parappa_timing_extra_early = false; int parappa_timing_extra_early = 0; - bool has_parappa_timing_extra_late = false; int parappa_timing_extra_late = 0; - - // [disruptor] — Disruptor-specific, live-safe enhancement settings. - // Kept optional so a missing key continues to inherit the launch-time - // default or an explicit per-run override. - bool has_mouse_aim = false; bool mouse_aim = false; - bool has_modern_controls = false; bool modern_controls = false; - bool has_horizontal_sensitivity = false; double horizontal_sensitivity = 0.1; - bool has_invert_horizontal = false; bool invert_horizontal = false; - bool has_vertical_look = false; bool vertical_look = false; - bool has_vertical_sensitivity = false; double vertical_sensitivity = 0.08; - bool has_invert_vertical = false; bool invert_vertical = false; - bool has_high_precision_camera = false; bool high_precision_camera = false; - bool has_geometry_correction = false; bool geometry_correction = false; - bool has_perspective_textures = false; bool perspective_textures = false; - bool has_frame_unlock = false; bool frame_unlock = false; - bool has_skip_intro = false; bool skip_intro = false; - bool has_language_disc = false; std::string language_disc; // image of another region's disc, "" for none - bool has_language_discs = false; std::vector language_discs; // the ones named before, for a launcher to offer again - bool has_hud_scale = false; int hud_scale = 100; - bool has_improved_shadows = false; bool improved_shadows = false; - bool has_ambient_occlusion = false; bool ambient_occlusion = false; - bool has_occlusion_reach = false; int occlusion_reach = 160; // camera depth units, 50..800 - bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 - bool has_gap_filling = false; bool gap_filling = true; - bool has_depth_sorted_world = false; bool depth_sorted_world = false; - // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. - bool has_diagnostic_view = false; int diagnostic_view = 0; -}; -inline constexpr const char* kDisruptorDiagnosticViews[] = {"off", "shading", "depth", "normals", "fade", "mesh"}; -inline constexpr int kDisruptorDiagnosticViewCount = 6; - -// GameOptions — the game's OWN native OPTION-screen settings, declared in a -// dedicated, self-contained `game_options.toml` next to game.toml. Kept entirely -// separate from GameConfig (recompiler/aftermarket config) and UserSettings -// (launcher overrides): these describe the GAME's settings, persisted across -// launches because some titles keep them only in a per-boot RAM global with no -// memory-card config block (issue #5). See game_options.{h,c} for the runtime. -struct GameOption { - std::string name; // key written to the saved-values state file - uint32_t addr = 0; // guest RAM address of the canonical config global - int size = 1; // 1 or 2 bytes - uint32_t init_store_pc = 0; // PC of the boot-init sb/sh that writes this - // global's default; the recompiler rewrites it - // to apply the persisted value (restore-at-init). - // Optional valid-value range for RESTORE-time validation. When has_range is - // true (game_options.toml declared `max`, and optionally `min`), a persisted - // value outside [vmin, vmax] is rejected at load — the game's own default is - // used instead — so a corrupt/stale options file can never inject an out-of- - // range value (e.g. an enum used as a jump-table index -> wild jump). Absent - // => no validation (e.g. signed screen offsets). Only the runtime consumes - // this; the recompiler ignores it (no codegen impact, no regen needed). - bool has_range = false; - int64_t vmin = 0; - int64_t vmax = 0; -}; -struct GameOptions { - std::vector options; // empty => feature off for this game -}; - -// Load game_options.toml ([[option]] array of {name, addr, size}). Returns an -// empty GameOptions if the file is missing/unreadable; throws on a malformed -// declared entry (bad addr / size) so a typo is surfaced, not silently dropped. -GameOptions load_game_options(const std::filesystem::path& path); - -// Load settings.toml. Returns an all-defaults (all has_*=false) struct if the -// file is missing or unreadable. Malformed values are skipped (best-effort: -// the launcher must still be able to start so the user can fix them), so this -// never throws. -UserSettings load_user_settings(const std::filesystem::path& path); - -// Write settings.toml deterministically through a same-directory temporary -// file and atomically replace the destination. Returns false on I/O failure; -// the previous destination is left intact and the temporary file is removed. -bool save_user_settings(const std::filesystem::path& path, const UserSettings& s); - -// Locate the project root by walking upward from `config_path` until a -// directory containing `.gitignore`, `.git`, or `CMakeLists.txt` is found. -// Throws std::runtime_error if not found within 8 levels. -std::filesystem::path find_project_root(const std::filesystem::path& config_path); - -// Load a BIOS config TOML. Throws std::runtime_error on schema violations. -BiosConfig load_bios_config(const std::filesystem::path& config_path); - -// Load a game config TOML. Throws std::runtime_error on schema violations. -GameConfig load_game_config(const std::filesystem::path& config_path); - -// Hash exactly the per-game settings that can change generated overlay code. -// The runtime and compile_overlays.py (via psxrecomp-game's -// --overlay-config-hash query) use this as part of the cache namespace, so a -// widescreen/patch config edit can never reuse shards emitted under the old -// rules. Runtime-only settings and comments deliberately do not participate. -uint32_t overlay_codegen_config_hash(const GameConfig& config); - -} // namespace PSXRecompV4 diff --git a/psxrecomp-overlay/recompiler/src/main_psx.cpp b/psxrecomp-overlay/recompiler/src/main_psx.cpp deleted file mode 100644 index b508c97..0000000 --- a/psxrecomp-overlay/recompiler/src/main_psx.cpp +++ /dev/null @@ -1,1636 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "ps1_exe_parser.h" -#include "gte.h" -#include "function_analysis.h" -#include "control_flow.h" -#include "code_generator.h" -#include "annotations.hpp" -#include "config_loader.h" -#include "rabbitizer.hpp" -#include "fmt/format.h" - -/* Baked emitter-source hash (recompiler/CMakeLists.txt custom command; same - * hash_codegen.cmake + canonical source list as the runtime's overlay cache - * tag). Exposed via --codegen-hash so compile_overlays.py can verify THIS - * binary was built from the same emitter sources as the cg tag it stamps — - * the stale-recompiler-binary guard. 0 when built without the bake (guard - * consumers treat that as "cannot verify" and fail loudly). */ -#if defined(__has_include) -# if __has_include("psxrecomp_baked_codegen_hash.h") -# include "psxrecomp_baked_codegen_hash.h" -# endif -#endif -#ifndef PSX_OVERLAY_CODEGEN_HASH -# define PSX_OVERLAY_CODEGEN_HASH 0u -#endif - -namespace { - -struct AliasEntry { uint32_t addr, host_start, host_end; }; - -// Materialize alias entries as overlapping functions, grouped by host. Each -// group shares one emitted body (entry switch + host-range blocks); every -// member lists its siblings so all group CFGs carry identical block leaders. -// Host functions are emitted unchanged — aliases never cap or truncate them. -void materialize_alias_groups(PSXRecomp::FunctionAnalysisResult& result, - const std::vector& alias_entries) { - std::map> by_host; - for (const auto& ae : alias_entries) { - by_host[ae.host_start].push_back(&ae); - } - for (const auto& [host_start, group] : by_host) { - uint32_t producer_lo = 0; - uint32_t producer_hi = 0; - for (const auto& function : result.functions) { - if (function.start_addr == host_start) { - producer_lo = function.producer_lo; - producer_hi = function.producer_hi; - break; - } - } - std::vector entries; - for (const AliasEntry* ae : group) entries.push_back(ae->addr); - for (const AliasEntry* ae : group) { - PSXRecomp::Function af; - af.start_addr = ae->addr; - af.end_addr = ae->host_end; - af.size = af.end_addr - af.start_addr; - af.has_prologue = false; - af.has_epilogue = false; - af.stack_frame_size = 0; - af.name = fmt::format("func_{:08X}", ae->addr); - af.is_data_section = false; - af.alias_walk_lo = ae->host_start; - af.alias_group_entries = entries; - af.producer_lo = producer_lo; - af.producer_hi = producer_hi; - result.functions.push_back(af); - } - } -} - -} // namespace - -int main(int argc, char** argv) { - const auto print_usage = [&]() { - fmt::print("Usage: {} --config \n", argv[0]); - fmt::print(" {} [--seeds ] [--out-dir ] [--strict] [--inspect]\n", argv[0]); - fmt::print("Example: {} SCUS_942.36 --seeds seeds/functions.txt --out-dir generated --strict\n", argv[0]); - }; - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - if (arg == "-h" || arg == "--help") { - print_usage(); - return 0; - } - } - if (argc < 2) { - print_usage(); - return 0; - } - - /* --codegen-hash: print the baked emitter-source hash and exit. Handled - * before the banner so the output is machine-parseable (one hex line). - * compile_overlays.py compares this to the hash from --runtime-include and - * refuses to build shards with a stale recompiler binary. */ - for (int i = 1; i < argc; ++i) { - if (std::string(argv[i]) == "--codegen-hash") { - fmt::print("{:08x}\n", (unsigned)PSX_OVERLAY_CODEGEN_HASH); - return 0; - } - if (std::string(argv[i]) == "--overlay-config-hash" && i + 1 < argc) { - try { - const auto cfg = PSXRecompV4::load_game_config(argv[i + 1]); - fmt::print("{:08x}\n", - (unsigned)PSXRecompV4::overlay_codegen_config_hash(cfg)); - return 0; - } catch (const std::exception& e) { - fmt::print(stderr, "overlay config hash failed: {}\n", e.what()); - return 2; - } - } - } - - fmt::print("PSXRecomp - PlayStation 1 Static Recompiler\n"); - fmt::print("============================================\n\n"); - - // ── --config short-form ──────────────────────────────────── - // If --config is provided, all paths come from the TOML and other - // CLI flags are ignored. Positional form below stays for back-compat. - std::filesystem::path config_path; - // --ws-config : load ONLY the [widescreen] site lists from this TOML - // without treating it as the full game config. Used by compile_overlays.py - // so overlay compilation gets the widescreen emits (sprite-tag / cull / - // backdrop) whose addresses live in overlay code — the overlay path is - // positional (no --config), so this is the channel for those sites. - std::filesystem::path ws_config_path; - for (int i = 1; i < argc; ++i) { - std::string a = argv[i]; - if (a == "--config" && i + 1 < argc) { - config_path = argv[i + 1]; - break; - } - if (a.rfind("--config=", 0) == 0) { - config_path = a.substr(std::string("--config=").size()); - break; - } - } - for (int i = 1; i < argc; ++i) { - std::string a = argv[i]; - if (a == "--ws-config" && i + 1 < argc) { ws_config_path = argv[i + 1]; break; } - if (a.rfind("--ws-config=", 0) == 0) { - ws_config_path = a.substr(std::string("--ws-config=").size()); - break; - } - } - - std::filesystem::path exe_path; - std::string extra_funcs_storage; // lifetime anchor for the .c_str() below - const char* extra_funcs_path = nullptr; - bool inspect_mode = false; - bool overlay_mode = false; - bool reachable_discovery = false; - std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs - std::set ds_funcs; // [data_shards] funcs - std::set mod_entry_funcs; // trusted game-mod entry hooks - std::set hot_funcs; // [recompiler] hot_funcs - std::map> vsync_query_hle_funcs; - std::set ws_cull_bias, ws_cull_range, ws_cull_a1; // [widescreen.cull] - std::set ws_cull_screen_x; // [widescreen.cull] screen_x_sites - std::set ws_cull_slti; // [widescreen.cull] slti_sites - std::set ws_cull_slti_lower; // [widescreen.cull] slti_lower_sites - std::set ws_cull_lower_branch; // [widescreen.cull] lower_branch_sites - std::set ws_cull_lower_value; // [widescreen.cull] lower_value_sites - std::set ws_cull_upper_value; // [widescreen.cull] upper_value_sites - std::set ws_cull_span_value; // [widescreen.cull] span_value_sites - std::set ws_cull_bltz; // [widescreen.cull] bltz_sites - std::set ws_cull_negsub; // [widescreen.cull] negsub_sites - std::set ws_cull_vxrange; // [widescreen.cull] vxrange_sites - std::set ws_cull_depth; // [widescreen.cull] depth_sites - std::set ws_cull_plane_nx; // [widescreen.cull] plane_nx_sites - std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites - std::vector ws_cull_keep; - std::vector ws_cull_angle; - std::vector ws_cull_yaw_cone; - std::vector - ws_cull_horizontal_slt; - PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; - int ws_cull_activation_guard_pixels = 0; - std::vector ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms - std::vector ws_cull_h_imms = { 0xE0, 0xF1 }; // [widescreen.cull] screen_h_imms - std::set ws_backdrop_x; // [widescreen.backdrop] x_sites - std::set ws_backdrop_unsquash; // [widescreen.backdrop] unsquash_funcs - bool ws_auto_screen_x_cull = false; // [widescreen.cull] auto_screen_x - std::set persist_init_sites; // [persist_options] init-store hooks (game_options.toml) - std::vector instruction_patches; - std::vector ws_signed_x_bound_sites; - bool ws_auto_backdrop_preload = false; // [widescreen.cull] auto_backdrop - uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0, - ws_bg2d_startx_site = 0, - ws_bg2d_stream_left_site = 0, - ws_bg2d_stream_right_site = 0, - ws_bg2d_bufbase_site = 0, - ws_bg2d_cap_site = 0, - ws_bg2d_init_func = 0; // [widescreen.bg2d] - std::filesystem::path out_dir = "generated"; - uint32_t configured_text_size = 0; - std::filesystem::path bios_profile_path; // [recompiler] bios_config - - if (!config_path.empty()) { - const auto cfg = PSXRecompV4::load_game_config(config_path); - exe_path = cfg.exe_path; - configured_text_size = cfg.text_size; - reachable_discovery = cfg.discovery == "reachable"; - extra_funcs_storage = cfg.seeds_path.string(); - extra_funcs_path = extra_funcs_storage.c_str(); - bios_profile_path = cfg.bios_config_path; - out_dir = cfg.out_dir; - instruction_patches = cfg.recompiler_patches; - ws_tag_funcs.insert(cfg.ws_sprite_tag_funcs.begin(), - cfg.ws_sprite_tag_funcs.end()); - ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); - mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), - cfg.mod_function_entry_funcs.end()); - hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); - if (cfg.vsync_query_func) - vsync_query_hle_funcs[cfg.vsync_query_func] = { - cfg.vsync_counter_addr, cfg.vsync_gpustat_ptr_addr, - cfg.vsync_timer1_ptr_addr, cfg.vsync_timer1_cache_addr }; - ws_cull_bias.insert(cfg.ws_cull_bias_sites.begin(), cfg.ws_cull_bias_sites.end()); - ws_cull_range.insert(cfg.ws_cull_range_sites.begin(), cfg.ws_cull_range_sites.end()); - ws_cull_a1.insert(cfg.ws_cull_a1_sites.begin(), cfg.ws_cull_a1_sites.end()); - ws_cull_screen_x.insert(cfg.ws_cull_screen_x_sites.begin(), cfg.ws_cull_screen_x_sites.end()); - ws_cull_slti.insert(cfg.ws_cull_slti_sites.begin(), cfg.ws_cull_slti_sites.end()); - ws_cull_slti_lower.insert(cfg.ws_cull_slti_lower_sites.begin(), - cfg.ws_cull_slti_lower_sites.end()); - ws_cull_lower_branch.insert(cfg.ws_cull_lower_branch_sites.begin(), - cfg.ws_cull_lower_branch_sites.end()); - ws_cull_lower_value.insert(cfg.ws_cull_lower_value_sites.begin(), - cfg.ws_cull_lower_value_sites.end()); - ws_cull_upper_value.insert(cfg.ws_cull_upper_value_sites.begin(), - cfg.ws_cull_upper_value_sites.end()); - ws_cull_span_value.insert(cfg.ws_cull_span_value_sites.begin(), - cfg.ws_cull_span_value_sites.end()); - ws_cull_bltz.insert(cfg.ws_cull_bltz_sites.begin(), cfg.ws_cull_bltz_sites.end()); - ws_cull_negsub.insert(cfg.ws_cull_negsub_sites.begin(), cfg.ws_cull_negsub_sites.end()); - ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end()); - ws_cull_depth.insert(cfg.ws_cull_depth_sites.begin(), cfg.ws_cull_depth_sites.end()); - ws_cull_plane_nx.insert(cfg.ws_cull_plane_nx_sites.begin(), cfg.ws_cull_plane_nx_sites.end()); - ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); - ws_cull_keep = cfg.ws_cull_keep_sites; - ws_cull_angle = cfg.ws_cull_angle_sites; - ws_cull_yaw_cone = cfg.ws_cull_yaw_cone_sites; - ws_cull_horizontal_slt = cfg.ws_cull_horizontal_slt_sites; - ws_aspect_cone = cfg.ws_aspect_cone; - ws_cull_activation_guard_pixels = - cfg.ws_cull_activation_guard_pixels; - ws_cull_w_imms = cfg.ws_cull_w_imms; - ws_cull_h_imms = cfg.ws_cull_h_imms; - ws_backdrop_x.insert(cfg.ws_backdrop_x_sites.begin(), cfg.ws_backdrop_x_sites.end()); - ws_backdrop_unsquash.insert(cfg.ws_backdrop_unsquash_funcs.begin(), cfg.ws_backdrop_unsquash_funcs.end()); - ws_auto_screen_x_cull = ws_auto_screen_x_cull || cfg.ws_auto_screen_x_cull; - ws_auto_backdrop_preload = ws_auto_backdrop_preload || cfg.ws_auto_backdrop_preload; - if (cfg.ws_bg2d_count_site) ws_bg2d_count_site = cfg.ws_bg2d_count_site; - if (cfg.ws_bg2d_startcol_site) ws_bg2d_startcol_site = cfg.ws_bg2d_startcol_site; - if (cfg.ws_bg2d_startx_site) ws_bg2d_startx_site = cfg.ws_bg2d_startx_site; - if (cfg.ws_bg2d_stream_left_site) ws_bg2d_stream_left_site = cfg.ws_bg2d_stream_left_site; - if (cfg.ws_bg2d_stream_right_site) ws_bg2d_stream_right_site = cfg.ws_bg2d_stream_right_site; - if (cfg.ws_bg2d_bufbase_site) ws_bg2d_bufbase_site = cfg.ws_bg2d_bufbase_site; - if (cfg.ws_bg2d_cap_site) ws_bg2d_cap_site = cfg.ws_bg2d_cap_site; - if (cfg.ws_bg2d_init_func) ws_bg2d_init_func = cfg.ws_bg2d_init_func; - ws_signed_x_bound_sites = cfg.ws_signed_x_bound_sites; - // [persist_options] init-store hook sites live in a dedicated - // game_options.toml next to game.toml (the game's own native OPTION - // settings, kept separate from game.toml/settings.toml). Best-effort: - // a missing/bad file just means no persistence hooks this build. - try { - std::filesystem::path go_path = - std::filesystem::path(config_path).parent_path() / "game_options.toml"; - const auto goc = PSXRecompV4::load_game_options(go_path); - for (const auto& o : goc.options) - if (o.init_store_pc) persist_init_sites.insert(o.init_store_pc); - } catch (const std::exception& e) { - fmt::print(stderr, " game_options.toml ignored: {}\n", e.what()); - } - fmt::print("config: {}\n", config_path.string()); - fmt::print(" exe = {}\n", exe_path.string()); - fmt::print(" seeds = {}\n", extra_funcs_storage); - fmt::print(" out_dir = {}\n", out_dir.string()); - if (!ws_tag_funcs.empty()) - fmt::print(" ws_tag_funcs= {}\n", ws_tag_funcs.size()); - fmt::print("\n"); - } else { - exe_path = argv[1]; - for (int i = 2; i < argc; i++) { - std::string arg = argv[i]; - if ((arg == "--extra-funcs" || arg == "--seeds") && i + 1 < argc) { - extra_funcs_path = argv[++i]; - } else if (arg == "--out-dir" && i + 1 < argc) { - out_dir = argv[++i]; - } else if (arg == "--strict") { - /* The PS-X EXE path is fail-loud by default; accepted for parity - * with psxrecomp-bios and project scripts. */ - } else if (arg == "--inspect") { - inspect_mode = true; - } else if (arg == "--overlay") { - /* Overlay-compilation contract: this input is a runtime-captured - * overlay with execution evidence, so discovery is evidence-scoped - * (no whole-byte sweep) and branch/jump-table targets stay as - * in-parent labels. Set unconditionally by compile_overlays.py for - * every overlay input — never a human toggle. */ - overlay_mode = true; - } - } - } - - // --ws-config: merge the [widescreen] site lists from a side config WITHOUT - // adopting its exe/paths. The overlay path is positional (no --config), so - // this is how overlay compilation receives the widescreen emits whose - // addresses live in overlay code (backdrop screenX, and any sprite-tag / - // cull sites that resolve there). No-op when --config already provided the - // sites (same TOML) — std::set insert dedupes. - if (!ws_config_path.empty()) { - const auto wscfg = PSXRecompV4::load_game_config(ws_config_path); - ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end()); - mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), - wscfg.mod_function_entry_funcs.end()); - ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); - ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); - ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end()); - ws_cull_screen_x.insert(wscfg.ws_cull_screen_x_sites.begin(), wscfg.ws_cull_screen_x_sites.end()); - ws_cull_slti.insert(wscfg.ws_cull_slti_sites.begin(), wscfg.ws_cull_slti_sites.end()); - ws_cull_slti_lower.insert(wscfg.ws_cull_slti_lower_sites.begin(), - wscfg.ws_cull_slti_lower_sites.end()); - ws_cull_lower_branch.insert(wscfg.ws_cull_lower_branch_sites.begin(), - wscfg.ws_cull_lower_branch_sites.end()); - ws_cull_lower_value.insert(wscfg.ws_cull_lower_value_sites.begin(), - wscfg.ws_cull_lower_value_sites.end()); - ws_cull_upper_value.insert(wscfg.ws_cull_upper_value_sites.begin(), - wscfg.ws_cull_upper_value_sites.end()); - ws_cull_span_value.insert(wscfg.ws_cull_span_value_sites.begin(), - wscfg.ws_cull_span_value_sites.end()); - ws_cull_bltz.insert(wscfg.ws_cull_bltz_sites.begin(), wscfg.ws_cull_bltz_sites.end()); - ws_cull_negsub.insert(wscfg.ws_cull_negsub_sites.begin(), wscfg.ws_cull_negsub_sites.end()); - ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end()); - ws_cull_depth.insert(wscfg.ws_cull_depth_sites.begin(), wscfg.ws_cull_depth_sites.end()); - ws_cull_plane_nx.insert(wscfg.ws_cull_plane_nx_sites.begin(), wscfg.ws_cull_plane_nx_sites.end()); - ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); - if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; - if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; - if (ws_cull_yaw_cone.empty()) - ws_cull_yaw_cone = wscfg.ws_cull_yaw_cone_sites; - if (ws_cull_horizontal_slt.empty()) - ws_cull_horizontal_slt = - wscfg.ws_cull_horizontal_slt_sites; - if (ws_aspect_cone.sites.empty()) - ws_aspect_cone = wscfg.ws_aspect_cone; - ws_cull_activation_guard_pixels = - wscfg.ws_cull_activation_guard_pixels; - ws_cull_w_imms = wscfg.ws_cull_w_imms; - ws_cull_h_imms = wscfg.ws_cull_h_imms; - ws_backdrop_x.insert(wscfg.ws_backdrop_x_sites.begin(), wscfg.ws_backdrop_x_sites.end()); - ws_backdrop_unsquash.insert(wscfg.ws_backdrop_unsquash_funcs.begin(), wscfg.ws_backdrop_unsquash_funcs.end()); - ws_auto_screen_x_cull = ws_auto_screen_x_cull || wscfg.ws_auto_screen_x_cull; - ws_auto_backdrop_preload = ws_auto_backdrop_preload || wscfg.ws_auto_backdrop_preload; - PSXRecompV4::merge_recompiler_patches( - instruction_patches, wscfg.recompiler_patches); - ws_signed_x_bound_sites.insert(ws_signed_x_bound_sites.end(), - wscfg.ws_signed_x_bound_sites.begin(), - wscfg.ws_signed_x_bound_sites.end()); - if (wscfg.ws_bg2d_init_func) ws_bg2d_init_func = wscfg.ws_bg2d_init_func; - fmt::print("ws-config: {} (backdrop_x sites={}, unsquash funcs={})\n", - ws_config_path.string(), ws_backdrop_x.size(), ws_backdrop_unsquash.size()); - } - - /* Resolve the BIOS profile this game builds against and activate its - * address model for codegen (jump tables inside relocated BIOS code - * windows fold through it — see code_generator.cpp ram_to_rom). Order: - * explicit [recompiler] bios_config; else the SCPH1001 profile in-repo - * (framework checkout) or under the psxrecomp/ submodule (game repo). - * No profile -> fatal: there are no built-in windows. */ - static PSXRecompV4::BiosAddressModel s_bios_model; - { - std::filesystem::path prof = bios_profile_path; - /* An explicit bios_config that does not resolve is a different failure - * from having none at all — say which, or the message sends you looking - * for an unset key that is in fact set. */ - if (!prof.empty() && !std::filesystem::exists(prof)) { - fmt::print(stderr, - "psxrecomp-game: FATAL: [recompiler] bios_config = '{}' does " - "not exist (path is relative to the game project root)\n", - prof.string()); - return 1; - } - if (prof.empty()) { - if (std::filesystem::exists("bios/SCPH1001.toml")) { - prof = "bios/SCPH1001.toml"; /* framework checkout */ - } else { - /* Game repo vendoring the framework. Do NOT assume the - * directory is named "psxrecomp": ApeEscapeRecomp vendors it as - * "psxrecomp-v4", and hardcoding the name made regeneration - * fail outright there. Probe one level for any vendored - * framework, sorted so the pick is deterministic. */ - std::vector found; - std::error_code ec; - for (const auto& de : - std::filesystem::directory_iterator(".", ec)) { - if (ec) break; - if (!de.is_directory(ec) || ec) continue; - auto cand = de.path() / "bios" / "SCPH1001.toml"; - if (std::filesystem::exists(cand)) found.push_back(cand); - } - std::sort(found.begin(), found.end()); - if (!found.empty()) prof = found.front(); - } - } - if (prof.empty()) { - fmt::print(stderr, - "psxrecomp-game: FATAL: no BIOS profile found (set " - "[recompiler] bios_config in game.toml, or run from a root " - "containing bios/SCPH1001.toml or /bios/" - "SCPH1001.toml)\n"); - return 1; - } - const auto bios_cfg = PSXRecompV4::load_bios_config(prof); - s_bios_model = PSXRecompV4::BiosAddressModel::from_config(bios_cfg); - PSXRecomp::CodeGenerator::set_bios_address_model(&s_bios_model); - } - - // Parse the PS1-EXE file - std::string error_msg; - fmt::print("Parsing PS1-EXE: {}\n", exe_path.string()); - - auto exe = PSXRecomp::PS1ExeParser::parse_file(exe_path, error_msg); - - if (!exe.has_value()) { - fmt::print(stderr, "Failed to parse PS1-EXE: {}\n", error_msg); - return 1; - } - - // [game].text_size is the title-owned static-analysis bound. It may trim a - // verified data/padding tail, but it may never widen the parsed payload or - // exclude the executable entry. Runtime disc loading remains unchanged. - if (!config_path.empty()) { - const uint32_t parsed_size = exe->header.file_size; - std::string bound_error; - if (!PSXRecomp::apply_static_analysis_bound( - *exe, configured_text_size, bound_error)) { - fmt::print(stderr, "Invalid static analysis bound: {}\n", bound_error); - return 1; - } - if (exe->header.file_size < parsed_size) { - fmt::print("Static analysis bound: 0x{:X} bytes from game.text_size " - "(PS-X EXE header: 0x{:X})\n", - exe->header.file_size, parsed_size); - } - } - - // Patch the bounded image before discovery/CFG analysis so replacements of - // control-flow instructions cannot disagree with the generated graph. - PSXRecompV4::apply_recompiler_patches_to_executable( - *exe, instruction_patches, overlay_mode); - - fmt::print("✓ Successfully parsed PS1-EXE!\n\n"); - - // Print header information - fmt::print("PS1-EXE Header Info:\n"); - fmt::print(" Entry Point: 0x{:08X}\n", exe->header.initial_pc); - fmt::print(" Load Address: 0x{:08X}\n", exe->header.load_address); - fmt::print(" Code Size: {} bytes ({} KB)\n", - exe->header.file_size, exe->header.file_size / 1024); - fmt::print(" End Address: 0x{:08X}\n", exe->end_address()); - fmt::print(" Global Pointer: 0x{:08X}\n", exe->header.initial_gp); - fmt::print(" Stack Pointer: 0x{:08X}\n\n", exe->header.initial_sp); - - // Validate entry point - if (!exe->header.entry_in_range()) { - fmt::print(stderr, "⚠ Warning: Entry point 0x{:08X} is outside loaded code range!\n\n", - exe->header.initial_pc); - } - - if (inspect_mode) { - // Disassemble first 20 instructions from entry point - fmt::print("Disassembly at Entry Point (0x{:08X}):\n", exe->header.initial_pc); - fmt::print("---------------------------------------\n"); - - uint32_t current_addr = exe->header.initial_pc; - const int num_instructions = 20; - - for (int i = 0; i < num_instructions; i++) { - auto word_opt = exe->read_word(current_addr); - - if (!word_opt.has_value()) { - fmt::print(" [End of code or read error]\n"); - break; - } - - uint32_t instr_word = *word_opt; - - // Use Rabbitizer to disassemble the instruction - // Note: Rabbitizer expects big-endian, PS1 is little-endian, so the word is already in correct format - rabbitizer::InstructionCpu instr(instr_word, current_addr); - - // Get disassembly (parameter is flags, 0 = default) - std::string disasm = instr.disassemble(0); - - // Print address, hex, and disassembly - fmt::print(" {:08X}: {:08X} {}\n", current_addr, instr_word, disasm); - - current_addr += 4; - } - - fmt::print("\n"); - fmt::print("✓ Disassembly complete!\n"); - - // Instruction frequency analysis - fmt::print("\n"); - fmt::print("Analyzing instruction frequency...\n"); - - std::map instr_freq; - std::map opcode_freq; - std::map special_funct_freq; - - uint32_t total_instructions = 0; - uint32_t addr = exe->header.load_address; - while (addr < exe->end_address()) { - auto word_opt = exe->read_word(addr); - if (!word_opt.has_value()) break; - - uint32_t instr_word = *word_opt; - rabbitizer::InstructionCpu instr(instr_word, addr); - std::string mnemonic = instr.getOpcodeName(); - - instr_freq[mnemonic]++; - total_instructions++; - - // Track opcode distribution - uint32_t opcode = (instr_word >> 26) & 0x3F; - opcode_freq[opcode]++; - - // Track SPECIAL function codes - if (opcode == 0x00) { - uint32_t funct = instr_word & 0x3F; - special_funct_freq[funct]++; - } - - addr += 4; - } - - fmt::print("Total instructions: {}\n\n", total_instructions); - - // Sort by frequency and print top 20 - std::vector> sorted_freq(instr_freq.begin(), instr_freq.end()); - std::sort(sorted_freq.begin(), sorted_freq.end(), - [](const auto& a, const auto& b) { return a.second > b.second; }); - - fmt::print("=== Top 20 Most Common Instructions ===\n\n"); - fmt::print("{:4s} {:12s} {:10s} {:s}\n", "Rank", "Instruction", "Count", "Percentage"); - fmt::print("-----------------------------------------------------\n"); - - for (size_t i = 0; i < std::min(size_t(20), sorted_freq.size()); i++) { - const auto& [instr_name, count] = sorted_freq[i]; - double pct = 100.0 * count / total_instructions; - fmt::print("{:4d} {:12s} {:10d} {:5.2f}%\n", i + 1, instr_name, count, pct); - } - - fmt::print("\n"); - } - - // Function boundary detection - fmt::print("Performing function analysis...\n"); - - std::vector exact_entries; - exact_entries.push_back(exe->header.initial_pc); - - // Explicit entry points: functions called through pointers but not - // automatically detected by the heuristic-based function analysis. - // Pass game-specific entry points via --extra-funcs file. - - /* Load extra function addresses from a file (one hex address per line). - * Lines of the form `interior 0xXXXXXXXX` mark dispatch-proven interior - * addresses: alias candidates, never walk roots. `hosted_interior TARGET - * HOST` is a narrow cross-variant identity assertion: TARGET may alias only - * the explicitly rooted HOST, and only when TARGET is a syntactic block - * leader in HOST's current-byte range. The shard publisher additionally - * requires both emitted identities to match a previously CRC-validated host - * exactly before the result can reach the runtime. Lines of the form - * `dispatch_root 0xXXXXXXXX` mark classifier-proven dispatch roots - * WITHOUT a callable boundary (the kernel install-slot class, e.g. RAM - * 0xCF0): the static recompiler's install-slot hooks tail-dispatch into - * exactly these PCs, so they are real execution roots even though no - * prologue / preceding jr $ra exists. They are trusted walk roots and - * exempt from the overlay-mode boundary re-check below. Lines of the - * form `call_root 0xXXXXXXXX` carry the same mechanical trust for a - * statically proven direct or constant-register call/tail-call target. - * - * Seeds are accepted within the loaded image's own bounds — overlay mode - * wraps arbitrary regions (kernel RAM at 0x80000000, overlays at - * 0x800E7000, ...), so a hardcoded game-RAM window would silently drop - * valid seeds. */ - std::vector file_seeds; - std::vector interior_seeds; - std::map hosted_interior_seeds; - std::set hosted_interior_hosts; - constexpr size_t kHostedInteriorTargetCap = 4096; - constexpr size_t kHostedInteriorHostCap = 512; - std::set trusted_root_seeds; - std::set trusted_call_root_seeds; - std::vector> producer_ranges; - std::set cross_call_allow; - if (extra_funcs_path) { - std::ifstream ef(extra_funcs_path); - if (ef.is_open()) { - const uint32_t seed_lo = exe->header.load_address; - const uint32_t seed_hi = exe->end_address(); - std::string line; - while (std::getline(ef, line)) { - if (line.empty() || line[0] == '#') continue; - if (line.rfind("producer_range", 0) == 0) { - std::istringstream in(line); - std::string tag, lo_text, hi_text; - in >> tag >> lo_text >> hi_text; - uint32_t lo = static_cast( - std::strtoul(lo_text.c_str(), nullptr, 16)); - uint32_t hi = static_cast( - std::strtoul(hi_text.c_str(), nullptr, 16)); - if (lo_text.empty() || hi_text.empty() || - lo < seed_lo || lo >= hi || hi > seed_hi) { - fmt::print(stderr, "ERROR: invalid producer_range: {}\n", line); - return 1; - } - producer_ranges.emplace_back(lo, hi); - continue; - } - if (line.rfind("cross_call_allow", 0) == 0) { - const char* p = line.c_str() + 16; - uint32_t addr = static_cast( - std::strtoul(p, nullptr, 16)); - if (addr < seed_lo || addr >= seed_hi) { - fmt::print(stderr, "ERROR: invalid cross_call_allow: {}\n", line); - return 1; - } - cross_call_allow.insert(addr); - continue; - } - if (line.rfind("retained_alias", 0) == 0) { - fmt::print(" ignoring legacy retained_alias; old host " - "ranges are never reused: {}\n", line); - continue; - } - if (line.rfind("hosted_interior", 0) == 0) { - std::istringstream in(line); - std::string tag, target_text, host_text, trailing; - in >> tag >> target_text >> host_text >> trailing; - auto parse_hex_u32 = [](const std::string& text, - uint32_t& value) -> bool { - if (text.empty()) return false; - char* end = nullptr; - errno = 0; - unsigned long long parsed = - std::strtoull(text.c_str(), &end, 16); - if (errno != 0 || end == text.c_str() || *end != '\0' || - parsed > 0xFFFFFFFFull) return false; - value = static_cast(parsed); - return true; - }; - uint32_t target = 0, host = 0; - if (tag != "hosted_interior" || !trailing.empty() || - !parse_hex_u32(target_text, target) || - !parse_hex_u32(host_text, host) || - (target & 3u) != 0u || (host & 3u) != 0u || - target < seed_lo || target >= seed_hi || - host < seed_lo || host >= seed_hi || target == host) { - fmt::print(stderr, "ERROR: invalid hosted_interior: {}\n", line); - return 1; - } - auto prior = hosted_interior_seeds.find(target); - if (prior != hosted_interior_seeds.end() && - prior->second != host) { - fmt::print(stderr, "ERROR: conflicting hosted_interior " - "hosts for 0x{:08X}\n", target); - return 1; - } - hosted_interior_seeds.emplace(target, host); - hosted_interior_hosts.insert(host); - if (hosted_interior_seeds.size() > - kHostedInteriorTargetCap || - hosted_interior_hosts.size() > - kHostedInteriorHostCap) { - fmt::print(stderr, "ERROR: hosted_interior capacity " - "exceeded (targets {} / {}, hosts {} / {})\n", - hosted_interior_seeds.size(), - kHostedInteriorTargetCap, - hosted_interior_hosts.size(), - kHostedInteriorHostCap); - return 1; - } - continue; - } - bool interior = false; - bool trusted_root = false; - bool trusted_call_root = false; - const char* p = line.c_str(); - if (line.rfind("interior", 0) == 0) { - interior = true; - p += 8; - } else if (line.rfind("dispatch_root", 0) == 0) { - trusted_root = true; - p += 13; - } else if (line.rfind("call_root", 0) == 0) { - trusted_call_root = true; - p += 9; - } - uint32_t addr = (uint32_t)std::strtoul(p, nullptr, 16); - if (addr >= seed_lo && addr < seed_hi) { - if (interior) { - interior_seeds.push_back(addr); - } else { - if (trusted_root) trusted_root_seeds.insert(addr); - if (trusted_call_root) trusted_call_root_seeds.insert(addr); - file_seeds.push_back(addr); - exact_entries.push_back(addr); - } - } - } - std::sort(producer_ranges.begin(), producer_ranges.end()); - for (size_t i = 1; i < producer_ranges.size(); i++) { - if (producer_ranges[i - 1].second > producer_ranges[i].first) { - fmt::print(stderr, "ERROR: overlapping producer_range entries\n"); - return 1; - } - } - for (uint32_t addr : cross_call_allow) { - bool contained = false; - for (const auto& range : producer_ranges) { - if (addr >= range.first && addr < range.second) { - contained = true; - break; - } - } - if (!contained) { - fmt::print(stderr, - "ERROR: cross_call_allow 0x{:08X} is outside " - "producer ranges\n", addr); - return 1; - } - } - fmt::print("Loaded {} extra function addresses ({} interior, " - "{} hosted-interior, {} dispatch-root, {} call-root, {} producer ranges, " - "{} cross-call allows) from {}\n", - file_seeds.size() + interior_seeds.size() + - hosted_interior_seeds.size(), - interior_seeds.size(), hosted_interior_seeds.size(), - trusted_root_seeds.size(), - trusted_call_root_seeds.size(), - producer_ranges.size(), cross_call_allow.size(), - extra_funcs_path); - } else { - fmt::print("WARNING: Cannot open extra-funcs file: {}\n", extra_funcs_path); - } - } - - PSXRecomp::FunctionAnalysisResult analysis_result; - - if (overlay_mode || reachable_discovery) { - // ── Exact-entry mode: partition seeds into walk roots and interior - // alias candidates. `interior`-marked seeds (classifier-proven - // dispatch targets without a callable boundary) are NEVER walk roots — - // a root inside a host function hard-caps (truncates) it, the - // mid-function-seed softlock class. Unmarked seeds are re-verified - // against the same boundary rule as defense in depth. - const uint32_t exe_lo = exe->header.load_address; - auto read_w = [&](uint32_t a) -> uint32_t { - auto w = exe->read_word(a); - return w.has_value() ? *w : 0u; - }; - auto dense_local_pointer_table = [&](uint32_t a) -> bool { - for (uint32_t i = 0; i < 3u; i++) { - auto value = exe->read_word(a + i * 4u); - if (!value.has_value() || (*value & 3u) != 0u || - *value < exe_lo || *value >= exe->end_address()) { - return false; - } - } - return true; - }; - auto callable_boundary = [&](uint32_t a) -> bool { - uint32_t w = read_w(a); - if (!PSXRecomp::FunctionAnalyzer::is_valid_mips_word(w)) return false; - if (a == exe_lo) return true; - if (a >= exe_lo + 8 && read_w(a - 8) == 0x03E00008u) return true; - // Normal-mode discovery already skips alignment padding after a - // return when it infers a frameless function start. Preserve that - // evidence here. Requiring the next entry to be exactly jr-ra+8 - // demoted legitimate Psy-Q leaf routines separated by one to six - // padding NOPs to orphan interiors, so exact-entry analysis emitted - // no function at all. The delay slot (jr+4) may be non-NOP; only - // words after it and before the proposed entry must be padding. - bool padding_only = true; - for (uint32_t back = 12; back <= 32 && a >= exe_lo + back; - back += 4) { - if (read_w(a - back + 8) != 0u) padding_only = false; - if (!padding_only) break; - if (read_w(a - back) == 0x03E00008u) return true; - } - int32_t frame = 0; - return PSXRecomp::FunctionAnalyzer::is_prologue(w, frame) && - !(a >= exe_lo + 4 && - PSXRecomp::FunctionAnalyzer::is_branch_or_jump(read_w(a - 4))); - }; - - std::set roots; - std::set interior; - std::set hosted_targets; - for (const auto& seed : hosted_interior_seeds) - hosted_targets.insert(seed.first); - for (uint32_t a : exact_entries) { - if (hosted_targets.count(a)) { - // A fragment PS-X header needs one initial PC, but a trusted - // hosted interior must never become a walk root through it. - continue; - } else if (reachable_discovery && a == exe->header.initial_pc) { - // The PS-X EXE header is direct execution evidence for the - // main entry even when it uses a nonstandard prologue. - roots.insert(a); - } else if (trusted_root_seeds.count(a)) { - /* Classifier-proven dispatch root (install-slot class): the - * static code tail-dispatches into this PC, so it is a real - * execution root despite having no callable boundary. */ - fmt::print(" seed 0x{:08X} accepted as dispatch root " - "(install-slot class, no boundary evidence)\n", a); - roots.insert(a); - } else if (trusted_call_root_seeds.count(a) && - !dense_local_pointer_table(a)) { - fmt::print(" seed 0x{:08X} accepted as static call root " - "(direct/constant-register target)\n", a); - roots.insert(a); - } else if (callable_boundary(a)) { - roots.insert(a); - } else { - fmt::print(" seed 0x{:08X} fails the boundary check — " - "treating as interior alias candidate\n", a); - interior.insert(a); - } - } - for (uint32_t a : interior_seeds) interior.insert(a); - - auto containing = [&](uint32_t a) -> const PSXRecomp::Function* { - const PSXRecomp::Function* best = nullptr; - for (const auto& f : analysis_result.functions) { - if (a >= f.start_addr && a < f.end_addr) { best = &f; break; } - } - return best; - }; - - // Interior candidates attach only to functions the EVIDENCE-walked - // root set already owns. No backward-boundary guessing: a false - // prologue/jr-ra in embedded data mints a root that decodes data as - // code (audit-fatal). Orphans stay with the interpreter — it - // self-heals via recapture once real call evidence appears. - { - PSXRecomp::FunctionAnalyzer analyzer(*exe); - std::vector roots_vec(roots.begin(), roots.end()); - analysis_result = analyzer.analyze_exact_entries( - roots_vec, producer_ranges, cross_call_allow); - } - - std::vector alias_entries; - std::set alias_seen; - for (uint32_t a : interior) { - const PSXRecomp::Function* host = containing(a); - if (host && a == host->start_addr) continue; // became a real entry - if (host && analysis_result.exact_reachable_pcs.count(a)) { - if (alias_seen.insert(a).second) - alias_entries.push_back({a, host->start_addr, host->end_addr}); - } else { - fmt::print(" WARNING: interior seed 0x{:08X} has no host " - "function — left to the interpreter\n", a); - } - } - - // Cross-variant propagation never guesses a new walk. The caller names - // a current-byte host that was already CRC-valid in the cache, and the - // recompiler requires that exact root plus a syntactic block boundary - // in the host's current-byte range. This block test alone is NOT proof - // of reachability: the publisher must compare the emitted host+alias - // CRC/range identities with the preselected cached owner before use. - PSXRecomp::ControlFlowAnalyzer hosted_cfg_analyzer(*exe); - std::map hosted_cfgs; - for (const auto& seed : hosted_interior_seeds) { - uint32_t target = seed.first; - uint32_t requested_host = seed.second; - const PSXRecomp::Function* host = nullptr; - for (const auto& function : analysis_result.functions) { - if (function.start_addr == requested_host) { - host = &function; - break; - } - } - bool explicitly_rooted = roots.count(requested_host) != 0; - bool producer_ok = host && - (host->producer_lo == 0 || - (target >= host->producer_lo && target < host->producer_hi)); - bool block_ok = false; - if (host && producer_ok && target > host->start_addr && - target < host->end_addr) { - auto inserted = hosted_cfgs.emplace( - host->start_addr, PSXRecomp::ControlFlowGraph{}); - if (inserted.second) - inserted.first->second = hosted_cfg_analyzer.analyze_function(*host); - block_ok = inserted.first->second.blocks.count(target) != 0; - } - if (host && explicitly_rooted && producer_ok && block_ok) { - if (alias_seen.insert(target).second) - alias_entries.push_back( - {target, host->start_addr, host->end_addr}); - } else { - fmt::print(" WARNING: hosted interior 0x{:08X} rejected for " - "host 0x{:08X} (host={}, rooted={}, producer={}, block={})\n", - target, requested_host, host ? "yes" : "no", - explicitly_rooted ? "yes" : "no", - producer_ok ? "yes" : "no", block_ok ? "yes" : "no"); - } - } - - // Range-ownership completeness uses only analyzer-proven transfers from - // reachable instructions. The analyzer also revalidates each target's - // host against the final root partition and producer boundaries. - size_t proven_alias_added = 0; - for (const auto& absorbed : analysis_result.absorbed_entries) { - if (!alias_seen.insert(absorbed.addr).second) continue; - alias_entries.push_back( - {absorbed.addr, absorbed.host_start, absorbed.host_end}); - proven_alias_added++; - } - if (proven_alias_added) - fmt::print(" +{} analyzer-proven absorbed alias entries " - "(final range ownership)\n", proven_alias_added); - - materialize_alias_groups(analysis_result, alias_entries); - fmt::print("Exact-entry alias entries emitted: {}\n\n", alias_entries.size()); - } else { - // ── Iterative discovery: seed classification + static data-table scan ── - // - // Every candidate entry (explicit seed or scanned data pointer) is - // classified against the current function set: - // * matches a function start → already covered - // * INSIDE a function's range → ALIAS entry (overlapping - // emission; the host is never capped/truncated — forcing such an - // entry is the mid-function-seed softlock class) - // * in a gap between functions → forced entry (explicit - // seeds are trusted; scanned pointers additionally need boundary - // evidence: a jr $ra two slots earlier or a non-delay-slot - // prologue) - // - // The data scan walks every word of the image OUTSIDE code-function - // ranges (pointer tables live in data regions) and collects words that - // point at valid instructions inside code-function ranges. Promotions - // change function boundaries, so iterate to a fixed point. - std::vector alias_entries; - - std::set forced; - forced.insert(exe->header.initial_pc); - const uint32_t exe_lo = exe->header.load_address; - const uint32_t exe_hi = exe->end_address(); - auto read_w = [&](uint32_t a) -> uint32_t { - auto w = exe->read_word(a); - return w.has_value() ? *w : 0u; - }; - - const int kMaxDiscoveryIters = 6; - for (int iter = 0; iter < kMaxDiscoveryIters; ++iter) { - PSXRecomp::FunctionAnalyzer analyzer(*exe); - for (uint32_t a : forced) analyzer.add_forced_entry(a); - analysis_result = analyzer.analyze(); - - std::vector code_funcs; - for (const auto& f : analysis_result.functions) { - if (!f.is_data_section) code_funcs.push_back(&f); - } - auto containing = [&](uint32_t a) -> const PSXRecomp::Function* { - auto it = std::upper_bound(code_funcs.begin(), code_funcs.end(), a, - [](uint32_t v, const PSXRecomp::Function* f) { return v < f->start_addr; }); - if (it == code_funcs.begin()) return nullptr; - --it; - return (a >= (*it)->start_addr && a < (*it)->end_addr) ? *it : nullptr; - }; - - struct Candidate { uint32_t addr; bool trusted; bool table_evidence; }; - std::vector candidates; - for (uint32_t s : file_seeds) candidates.push_back({s, true, true}); - for (uint32_t s : interior_seeds) candidates.push_back({s, true, true}); - uint32_t scanned_words = 0; - auto is_text_ptr = [&](uint32_t v) { - return (v & 3u) == 0 && v >= exe_lo && v < exe_hi && containing(v) != nullptr; - }; - for (uint32_t p = exe_lo; p + 4 <= exe_hi; p += 4) { - if (containing(p)) continue; // instruction word, not a data word - uint32_t w = read_w(p); - if ((w & 3u) || w < exe_lo || w >= exe_hi) continue; - // Table evidence: an adjacent data word that is also a text - // pointer. Interior handler entries live in dense pointer - // tables; a lone pointer-shaped data word is too weak to mint - // an alias from (random data aliases into loose code ranges). - bool table_evidence = - (p >= exe_lo + 4 && is_text_ptr(read_w(p - 4))) || - (p + 8 <= exe_hi && is_text_ptr(read_w(p + 4))); - candidates.push_back({w, false, table_evidence}); - scanned_words++; - } - - alias_entries.clear(); - std::set alias_seen; - bool promoted_new = false; - for (const auto& [a, trusted, table_evidence] : candidates) { - if ((a & 3u) || a < exe_lo || a >= exe_hi) continue; - const PSXRecomp::Function* host = containing(a); - if (host && a == host->start_addr) continue; // already an entry - uint32_t w = read_w(a); - if (!PSXRecomp::FunctionAnalyzer::is_valid_mips_word(w)) continue; - if (host) { - if ((trusted || table_evidence) && alias_seen.insert(a).second) { - alias_entries.push_back({a, host->start_addr, host->end_addr}); - } - continue; - } - bool promote = trusted; - if (!promote) { - bool after_ret = (a >= exe_lo + 8) && (read_w(a - 8) == 0x03E00008u); - int32_t frame = 0; - bool prologue = - PSXRecomp::FunctionAnalyzer::is_prologue(w, frame) && - !(a >= exe_lo + 4 && - PSXRecomp::FunctionAnalyzer::is_branch_or_jump(read_w(a - 4))); - promote = after_ret || prologue; - } - if (promote && forced.insert(a).second) promoted_new = true; - } - - fmt::print("Discovery iteration {}: {} data pointers scanned, " - "{} forced entries, {} alias entries\n", - iter + 1, scanned_words, forced.size(), alias_entries.size()); - if (!promoted_new) break; - } - - materialize_alias_groups(analysis_result, alias_entries); - fmt::print("Alias entries emitted: {}\n\n", alias_entries.size()); - } - - // Print summary statistics - fmt::print("\n=== Function Analysis Summary ===\n\n"); - fmt::print("Total Functions: {}\n", analysis_result.functions.size()); - fmt::print("Code Coverage: {} KB analyzed\n", - (exe->end_address() - exe->header.load_address) / 1024); - - // Count functions with prologues/epilogues - int with_prologue = 0; - int with_epilogue = 0; - int with_both = 0; - - for (const auto& func : analysis_result.functions) { - if (func.has_prologue) with_prologue++; - if (func.has_epilogue) with_epilogue++; - if (func.has_prologue && func.has_epilogue) with_both++; - } - - fmt::print("With Prologue: {} ({:.1f}%)\n", with_prologue, - 100.0 * with_prologue / analysis_result.functions.size()); - fmt::print("With Epilogue: {} ({:.1f}%)\n", with_epilogue, - 100.0 * with_epilogue / analysis_result.functions.size()); - fmt::print("Complete: {} ({:.1f}%)\n\n", with_both, - 100.0 * with_both / analysis_result.functions.size()); - - // Print first 20 functions - fmt::print("First 20 Functions:\n"); - fmt::print("---------------------------------------\n"); - - for (size_t i = 0; i < std::min(size_t(20), analysis_result.functions.size()); i++) { - const auto& func = analysis_result.functions[i]; - fmt::print(" {:08X}-{:08X} {:5} bytes {}{}\n", - func.start_addr, - func.end_addr, - func.size, - func.name, - func.has_prologue ? fmt::format(" (frame={})", func.stack_frame_size) : ""); - } - - - fmt::print("\n... ({} more functions)\n\n", - analysis_result.functions.size() - std::min(size_t(20), analysis_result.functions.size())); - - - // Control flow analysis - PSXRecomp::ControlFlowAnalyzer cfg_analyzer(*exe); - auto all_cfgs = cfg_analyzer.analyze_all_functions(analysis_result.functions); - - // Print CFG summary statistics - fmt::print("=== Control Flow Summary ===\n\n"); - - int total_basic_blocks = 0; - int total_loops = 0; - int total_branches = 0; - - for (const auto& [func_addr, cfg] : all_cfgs) { - total_basic_blocks += static_cast(cfg.blocks.size()); - total_loops += cfg.loop_count; - - // Count branch instructions across all blocks - for (const auto& [block_addr, block] : cfg.blocks) { - if (block.exit_instr.type == PSXRecomp::ControlFlowType::Branch) { - total_branches++; - } - } - } - - fmt::print("Basic Blocks: {}\n", total_basic_blocks); - fmt::print("Loops Detected: {}\n", total_loops); - fmt::print("Branch Instructions: {}\n", total_branches); - fmt::print("Avg Blocks/Function: {:.1f}\n\n", - static_cast(total_basic_blocks) / all_cfgs.size()); - - // Show detailed CFG for first function (as an example) - if (!analysis_result.functions.empty() && !all_cfgs.empty()) { - const auto& first_func = analysis_result.functions[0]; - const auto& first_cfg = all_cfgs.at(first_func.start_addr); - - fmt::print("Example: CFG for {} (0x{:08X}):\n", first_func.name, first_func.start_addr); - fmt::print("---------------------------------------\n"); - fmt::print(" Blocks: {}\n", first_cfg.blocks.size()); - fmt::print(" Loops: {}\n", first_cfg.loop_count); - - if (first_cfg.blocks.size() > 1) { - fmt::print("\n Block Details:\n"); - int count = 0; - for (const auto& block_addr : first_cfg.block_order) { - const auto& block = first_cfg.blocks.at(block_addr); - fmt::print(" Block 0x{:08X}: {} instructions", block.start_addr, block.instruction_count); - - if (block.is_entry) fmt::print(" [ENTRY]"); - if (block.is_exit) fmt::print(" [EXIT]"); - if (block.is_loop_header) fmt::print(" [LOOP]"); - - fmt::print("\n"); - fmt::print(" Successors: {}", block.successors.size()); - if (!block.successors.empty()) { - fmt::print(" ("); - for (size_t i = 0; i < block.successors.size(); i++) { - fmt::print("0x{:08X}", block.successors[i]); - if (i + 1 < block.successors.size()) fmt::print(", "); - } - fmt::print(")"); - } - fmt::print("\n"); - - // Only show first 5 blocks for brevity - if (++count >= 5 && first_cfg.blocks.size() > 5) { - fmt::print(" ... ({} more blocks)\n", first_cfg.blocks.size() - 5); - break; - } - } - } - fmt::print("\n"); - } - - // C code generation - PSXRecomp::CodeGenConfig codegen_config; - codegen_config.emit_comments = true; - codegen_config.emit_line_numbers = true; - codegen_config.split_mid_function_targets = !overlay_mode; - codegen_config.overlay_mode = overlay_mode; - codegen_config.ws_signed_x_bound_sites = ws_signed_x_bound_sites; - codegen_config.ws_sprite_tag_funcs = ws_tag_funcs; - codegen_config.data_shard_funcs = ds_funcs; - codegen_config.mod_function_entry_funcs = mod_entry_funcs; - codegen_config.hot_funcs = hot_funcs; - codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; - codegen_config.ws_bg2d_init_func = ws_bg2d_init_func; - codegen_config.ws_cull_bias_sites = ws_cull_bias; - codegen_config.ws_cull_range_sites = ws_cull_range; - codegen_config.ws_cull_a1_sites = ws_cull_a1; - codegen_config.ws_cull_screen_x_sites = ws_cull_screen_x; - codegen_config.ws_cull_activation_guard_pixels = - ws_cull_activation_guard_pixels; - codegen_config.ws_cull_slti_sites = ws_cull_slti; - codegen_config.ws_cull_slti_lower_sites = ws_cull_slti_lower; - codegen_config.ws_cull_lower_branch_sites = ws_cull_lower_branch; - codegen_config.ws_cull_lower_value_sites = ws_cull_lower_value; - codegen_config.ws_cull_upper_value_sites = ws_cull_upper_value; - codegen_config.ws_cull_span_value_sites = ws_cull_span_value; - codegen_config.ws_cull_bltz_sites = ws_cull_bltz; - codegen_config.ws_cull_negsub_sites = ws_cull_negsub; - codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange; - codegen_config.ws_cull_depth_sites = ws_cull_depth; - codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx; - codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; - codegen_config.ws_cull_keep_sites = ws_cull_keep; - codegen_config.ws_cull_angle_sites = ws_cull_angle; - codegen_config.ws_cull_yaw_cone_sites = ws_cull_yaw_cone; - codegen_config.ws_cull_horizontal_slt_sites = - ws_cull_horizontal_slt; - codegen_config.ws_aspect_cone = ws_aspect_cone; - codegen_config.ws_cull_w_imms = ws_cull_w_imms; - codegen_config.ws_cull_h_imms = ws_cull_h_imms; - codegen_config.ws_backdrop_x_sites = ws_backdrop_x; - codegen_config.ws_backdrop_unsquash_funcs = ws_backdrop_unsquash; - codegen_config.ws_auto_screen_x_cull = ws_auto_screen_x_cull; - codegen_config.persist_init_store_sites = persist_init_sites; - codegen_config.ws_auto_backdrop_preload = ws_auto_backdrop_preload; - codegen_config.ws_bg2d_count_site = ws_bg2d_count_site; - codegen_config.ws_bg2d_startcol_site = ws_bg2d_startcol_site; - codegen_config.ws_bg2d_startx_site = ws_bg2d_startx_site; - codegen_config.ws_bg2d_stream_left_site = ws_bg2d_stream_left_site; - codegen_config.ws_bg2d_stream_right_site = ws_bg2d_stream_right_site; - codegen_config.ws_bg2d_bufbase_site = ws_bg2d_bufbase_site; - codegen_config.ws_bg2d_cap_site = ws_bg2d_cap_site; - if (ws_bg2d_count_site || ws_bg2d_startcol_site || ws_bg2d_startx_site) - fmt::print(" ws_bg2d 2D-background widen = ON (count=0x{:08X} startcol=0x{:08X} startx=0x{:08X})\n", - ws_bg2d_count_site, ws_bg2d_startcol_site, ws_bg2d_startx_site); - if (ws_auto_screen_x_cull) - fmt::print(" ws_auto_screen_x_cull = ON (render-funnel FOV widening)\n"); - if (!persist_init_sites.empty()) - fmt::print(" persist_options = {} init-store hook(s)\n", persist_init_sites.size()); - if (ws_auto_backdrop_preload) - fmt::print(" ws_auto_backdrop_preload = ON (far-backdrop column preload)\n"); - - // Load per-game annotations: annotations/_annotations.csv - // Silently skipped if the file doesn't exist. - PSXRecomp::AnnotationTable annotations; - { - std::string stem = exe_path.filename().string(); - std::string ann_path = "annotations/" + stem + "_annotations.csv"; - if (annotations.load(ann_path.c_str())) - fmt::print("✓ Loaded {} annotations from {}\n\n", annotations.count(), ann_path); - else - fmt::print(" (No annotations file found at {})\n\n", ann_path); - } - - PSXRecomp::CodeGenerator codegen(*exe, codegen_config); - codegen.set_annotations(&annotations); - - // Generate code for first 5 functions (as examples) - fmt::print("=== C Code Generation Examples ===\n\n"); - fmt::print("Generating code for first 5 functions...\n\n"); - - std::vector sample_funcs; - for (size_t i = 0; i < std::min(size_t(5), analysis_result.functions.size()); i++) { - sample_funcs.push_back(analysis_result.functions[i]); - } - - auto generated = codegen.generate_all_functions(sample_funcs, all_cfgs); - - // Print first generated function as example - if (!generated.empty()) { - fmt::print("Example Generated Function:\n"); - fmt::print("---------------------------------------\n"); - fmt::print("{}\n", generated[0].full_code); - fmt::print("---------------------------------------\n\n"); - } - - // Generate full C file and save to the generated directory - // Use argv[2] as output path if provided, otherwise derive from input filename - // Use filename() not stem() because ".36" in SCUS_942.36 is part of the serial, not an extension - std::string exe_stem = exe_path.filename().string(); - std::filesystem::create_directories(out_dir); - std::filesystem::path output_filename = out_dir / (exe_stem + "_full.c"); - fmt::print("Generating complete C file: {}\n", output_filename.string()); - - std::string full_c_code = codegen.generate_file(analysis_result.functions, all_cfgs); - std::set dispatch_addrs; - { - const std::string marker = "void func_"; - size_t pos = 0; - while ((pos = full_c_code.find(marker, pos)) != std::string::npos) { - size_t hex_pos = pos + marker.size(); - if (hex_pos + 8 <= full_c_code.size() && - full_c_code.compare(hex_pos + 8, 14, "(CPUState* cpu") == 0) { - std::string hex = full_c_code.substr(hex_pos, 8); - dispatch_addrs.insert((uint32_t)std::strtoul(hex.c_str(), nullptr, 16)); - } - pos = hex_pos + 8; - } - } - // Data/untranslatable entries are emitted as fail-closed - // psx_unknown_dispatch stubs. Do not put those stubs in the callable game - // dispatch table: a runtime overlay can legitimately replace the same EXE - // address, and a tiny stub range (especially a single NOP) can - // coincidentally byte-match the live overlay continuation. Advertising the - // stub as native-safe then turns valid dynamic code into a false fail-fast. - for (const auto& gen_func : codegen.last_gen_funcs()) { - if (gen_func.dispatchable) continue; - const std::string prefix = "func_"; - if (gen_func.function_name.rfind(prefix, 0) != 0 || - gen_func.function_name.size() < prefix.size() + 8) { - continue; - } - const std::string hex = - gen_func.function_name.substr(prefix.size(), 8); - dispatch_addrs.erase( - (uint32_t)std::strtoul(hex.c_str(), nullptr, 16)); - } - if (dispatch_addrs.empty() && codegen.last_gen_funcs().empty()) { - for (const auto& func : analysis_result.functions) { - dispatch_addrs.insert(func.start_addr); - } - } - - // Split-TU output: instead of one monolithic _full.c, write a - // shared declarations header plus N shards bucketed by cumulative line - // count, so the game build can compile the shards in parallel. The - // in-memory full_c_code / dispatch_addrs scan above is unaffected — only - // the on-disk representation changes. Function bodies are byte-identical - // to what full_c_code would have contained; see - // CodeGenerator::last_gen_funcs() / build_shared_decls_header(). - if (overlay_mode) { - // Overlays are single small TUs consumed by compile_overlays.py, which - // reads _full.c directly. Emit the monolith; the split path below - // would delete _full.c and leave only shards, breaking overlay compile - // (no_output). Splitting a small overlay TU has no parallel-compile - // benefit anyway. - std::ofstream full_file(output_filename); - if (full_file.is_open()) { - full_file << full_c_code; - full_file.close(); - fmt::print("✓ Saved overlay monolith to {}\n", output_filename.string()); - } else { - fmt::print(stderr, "⚠ Failed to write overlay monolith {}\n\n", - output_filename.string()); - } - } else { - // 1. Remove stale outputs: the old monolith and any previously - // written shards (a shard count shrink must not leave orphans). - std::error_code rm_ec; - std::filesystem::remove(output_filename, rm_ec); - for (int stale = 0; stale < 256; stale++) { - std::filesystem::path stale_shard = - out_dir / fmt::format("{}_full_{:02d}.c", exe_stem, stale); - std::filesystem::remove(stale_shard, rm_ec); - } - - // 2. Shared declarations header. - std::filesystem::path decls_filename = out_dir / (exe_stem + "_decls.h"); - { - std::ofstream decls_file(decls_filename); - if (decls_file.is_open()) { - decls_file << codegen.build_shared_decls_header(codegen.last_gen_funcs()); - decls_file.close(); - fmt::print("✓ Saved shared decls header to {}\n", decls_filename.string()); - } else { - fmt::print(stderr, "⚠ Failed to write shared decls header\n\n"); - } - } - - // 3. Bucket generated functions into shards by cumulative line count. - const int SHARD_LINE_BUDGET = 40000; - const std::vector& gen_funcs_for_shards = codegen.last_gen_funcs(); - std::string decls_basename = exe_stem + "_decls.h"; - - int shard_index = 0; - int shard_lines = 0; - int total_shard_lines = 0; - std::stringstream shard_buf; - auto flush_shard = [&]() { - if (shard_buf.tellp() == std::streampos(0)) return; - std::filesystem::path shard_filename = - out_dir / fmt::format("{}_full_{:02d}.c", exe_stem, shard_index); - std::ofstream shard_file(shard_filename); - if (shard_file.is_open()) { - shard_file << fmt::format( - "/* Generated by PSXRecomp - full.c shard {}. DO NOT EDIT. */\n", - shard_index); - shard_file << fmt::format("#include \"{}\"\n\n", decls_basename); - shard_file << shard_buf.str(); - shard_file.close(); - fmt::print("✓ Saved shard {} ({} lines) to {}\n", - shard_index, shard_lines, shard_filename.string()); - } else { - fmt::print(stderr, "⚠ Failed to write shard {}\n\n", shard_index); - } - total_shard_lines += shard_lines; - shard_index++; - shard_lines = 0; - shard_buf.str(std::string()); - shard_buf.clear(); - }; - - for (const auto& gf : gen_funcs_for_shards) { - if (shard_lines > 0 && shard_lines + gf.line_count > SHARD_LINE_BUDGET) { - flush_shard(); - } - shard_buf << gf.full_code << "\n"; - shard_lines += gf.line_count; - } - flush_shard(); - - fmt::print("✓ Wrote {} shards ({} total lines) for {}\n\n", - shard_index, total_shard_lines, exe_stem); - } - - // Per-function code-range manifest (design §8): consumed by the overlay - // loader's per-entry validity hash. Emitted alongside _full.c for every - // build; only the overlay path actually loads it. - const std::string ranges_manifest = codegen.last_ranges_manifest().empty() - ? codegen.generate_ranges_manifest(analysis_result.functions, all_cfgs) - : codegen.last_ranges_manifest(); - { - std::filesystem::path ranges_filename = out_dir / (exe_stem + "_full.ranges"); - std::ofstream rf(ranges_filename); - if (rf.is_open()) { - rf << ranges_manifest; - rf.close(); - fmt::print("✓ Saved code-range manifest to {}\n\n", - ranges_filename.string()); - } - } - - // Generate dispatch table (tomba_dispatch.c) - // This maps PS1 addresses to compiled C functions so call_by_address() can - // dispatch dynamic jalr/jr calls to the right compiled function. - { - std::filesystem::path dispatch_filename = out_dir / (exe_stem + "_dispatch.c"); - fmt::print("Generating dispatch table: {}\n", dispatch_filename.string()); - - std::ostringstream ds; - ds << "/* Generated by PSXRecomp - dynamic dispatch table */\n"; - ds << "#include \"psx_runtime.h\"\n\n"; - ds << "extern void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc);\n\n"; - ds << "extern int dirty_ram_text_native_ok_ranges_from(const uint32_t* lo_len_pairs, uint32_t count, uint32_t exec_pc);\n\n"; - - // Forward declarations - ds << "/* Forward declarations for all recompiled functions */\n"; - for (uint32_t addr : dispatch_addrs) { - ds << fmt::format("extern void func_{:08X}(CPUState* cpu);\n", addr); - } - ds << "\n"; - - // Dispatch function - uint32_t game_text_start = exe->load_address() & 0x1FFFFFFFu; - uint32_t game_text_end = game_text_start + exe->code_size(); - ds << "int psx_game_address_in_text(uint32_t addr) {\n"; - ds << " uint32_t phys = addr & 0x1FFFFFFFu;\n"; - ds << fmt::format(" return phys >= 0x{:08X}u && phys < 0x{:08X}u;\n", - game_text_start, game_text_end); - ds << "}\n\n"; - - // Materialize one sorted data table for dispatch and entry probes. A - // giant sparse switch is catastrophically slow in Debug/-O0 builds: - // GCC lowers X4's roughly 60K cases to a multi-megabyte linear compare - // chain. Binary search stays O(log N) at every optimizer level and - // avoids emitting the same case set twice. - struct DispatchRecord { - uint32_t addr; - uint32_t resume; - uint32_t owner; - uint32_t range_index; - uint32_t range_count; - }; - std::vector records; - records.reserve(dispatch_addrs.size() + - (codegen.cps_enabled() ? codegen.cps_continuations().size() : 0)); - for (uint32_t addr : dispatch_addrs) - records.push_back({addr, 0, addr, 0, 0}); - if (codegen.cps_enabled()) { - const auto& conts = codegen.cps_continuations(); - for (const auto& [cont, owner] : conts) { - if (dispatch_addrs.count(cont)) continue; - // A continuation owned by a fail-closed stub is no more - // executable than the stub entry itself. - if (!dispatch_addrs.count(owner)) continue; - records.push_back({cont, cont, owner, 0, 0}); - } - } - std::sort(records.begin(), records.end(), - [](const DispatchRecord& a, const DispatchRecord& b) { - return a.addr < b.addr; - }); - - // Attach the exact CFG instruction ranges from the manifest to every - // dispatch owner. Mid-function alias wrappers have no separate F record, - // so resolve them to the containing host range. Mutable data and - // jump-table gaps sharing a page remain deliberately excluded. - using CodeRange = std::pair; // virtual lo, byte len - std::map> function_ranges; - { - std::istringstream input(ranges_manifest); - std::string line; - uint32_t current_owner = 0; - while (std::getline(input, line)) { - std::istringstream fields(line); - char kind = 0; - fields >> kind; - if (kind == 'F') { - fields >> std::hex >> current_owner; - function_ranges[current_owner]; - } else if (kind == 'R' && current_owner != 0) { - uint32_t lo = 0, len = 0; - fields >> std::hex >> lo >> len; - if (len != 0) - function_ranges[current_owner].push_back({lo, len}); - } - } - } - std::vector flat_ranges; - std::map> owner_spans; - auto resolve_owner_ranges = [&](uint32_t owner) - -> const std::vector* { - auto exact = function_ranges.find(owner); - if (exact != function_ranges.end() && !exact->second.empty()) - return &exact->second; - for (const auto& [entry, ranges] : function_ranges) { - (void)entry; - for (const auto& [lo, len] : ranges) { - const uint64_t hi = (uint64_t)lo + len; - if (owner >= lo && (uint64_t)owner < hi) return &ranges; - } - } - return nullptr; - }; - for (auto& rec : records) { - auto cached = owner_spans.find(rec.owner); - if (cached == owner_spans.end()) { - const uint32_t first = (uint32_t)flat_ranges.size(); - const auto* ranges = resolve_owner_ranges(rec.owner); - if (ranges) - flat_ranges.insert(flat_ranges.end(), ranges->begin(), ranges->end()); - const uint32_t count = (uint32_t)flat_ranges.size() - first; - cached = owner_spans.emplace(rec.owner, - std::make_pair(first, count)).first; - } - rec.range_index = cached->second.first; - rec.range_count = cached->second.second; - } - - ds << "typedef struct { uint32_t lo; uint32_t len; } PsxGameCodeRange;\n"; - ds << "static const PsxGameCodeRange k_psx_game_code_ranges[] = {\n"; - if (flat_ranges.empty()) ds << " {0u, 0u},\n"; - for (const auto& [lo, len] : flat_ranges) - ds << fmt::format(" {{0x{:08X}u, 0x{:X}u}},\n", lo, len); - ds << "};\n\n"; - - ds << "typedef void (*PsxGameDispatchFn)(CPUState* cpu);\n"; - ds << "typedef struct {\n"; - ds << " uint32_t addr;\n"; - ds << " uint32_t resume_pc;\n"; - ds << " uint32_t range_index;\n"; - ds << " uint32_t range_count;\n"; - ds << " PsxGameDispatchFn fn;\n"; - ds << "} PsxGameDispatchEntry;\n\n"; - ds << "static const PsxGameDispatchEntry k_psx_game_dispatch[] = {\n"; - for (const auto& rec : records) { - ds << fmt::format(" {{0x{:08X}u, 0x{:08X}u, {}u, {}u, func_{:08X}}},\n", - rec.addr, rec.resume, rec.range_index, - rec.range_count, rec.owner); - } - ds << "};\n"; - ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size()); - ds << "static const PsxGameDispatchEntry* psx_game_find_entry(uint32_t addr) {\n"; - ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n"; - ds << " while (lo < hi) {\n"; - ds << " uint32_t mid = lo + (hi - lo) / 2;\n"; - ds << " uint32_t key = k_psx_game_dispatch[mid].addr;\n"; - ds << " if (addr < key) hi = mid;\n"; - ds << " else if (addr > key) lo = mid + 1;\n"; - ds << " else return &k_psx_game_dispatch[mid];\n"; - ds << " }\n"; - ds << " return 0;\n"; - ds << "}\n\n"; - - ds << "/* Exact static-code validity for this entry's emitted CFG ranges. */\n"; - ds << "int psx_game_text_native_ok(uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry || entry->range_count == 0) return 0;\n"; - ds << " return dirty_ram_text_native_ok_ranges_from(\n"; - ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr);\n"; - ds << "}\n\n"; - - ds << "/* Maps PS1 address to compiled game code. Returns 1 if dispatched, 0 if unknown. */\n"; - ds << "int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry) return 0;\n"; - ds << " if (entry->range_count == 0 || !dirty_ram_text_native_ok_ranges_from(\n"; - ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr)) return 0;\n"; - ds << " psx_check_interrupts_dispatch_entry(cpu, addr);\n"; - if (codegen.cps_enabled()) - ds << " cpu->pc = entry->resume_pc;\n"; - ds << " /* load_accel: cycle-faithful VSync(-1) query (config-gated). */\n"; - ds << " {\n"; - ds << " extern int psx_vsync_query_hle_try(CPUState* cpu, uint32_t addr);\n"; - ds << " if (psx_vsync_query_hle_try(cpu, addr)) return 1;\n"; - ds << " }\n"; - ds << " entry->fn(cpu);\n"; - ds << " return 1;\n"; - ds << "}\n"; - - // Non-destructive companion to psx_dispatch_game_compiled: is `addr` a PC - // the top-level dispatch can re-enter (a compiled function entry, or a CPS - // continuation resume-point)? The precise event slicer (dirty_ram_interp.c) - // needs this to know when it may hand a resume PC back to the dispatcher: - // a MID-function clean-text PC is NOT re-enterable (the switch falls to - // `default: return 0` and the dirty path returns 0 -> top-level PC=0 exit), - // so the slicer must keep interpreting until cpu->pc lands on a true entry. - // Same precise address set as dispatch, without executing the entry. - ds << "\n/* 1 iff addr is a re-enterable compiled entry/continuation (no exec). */\n"; - ds << "int psx_game_is_function_entry(uint32_t addr) {\n"; - ds << " return psx_game_find_entry(addr) != 0;\n"; - ds << "}\n"; - - if (codegen.cps_enabled()) { - // RECURSION_BUG.md §25 — mark CPS mode at startup so runtime code that - // routes CPS continuations (overlay_loader.c) sees the contract. - // Static ctor: no clash with the BIOS dispatch's marker. - ds << "\n/* CPS runtime-mode marker (the overlay loader reads g_psx_cps_mode). */\n"; - ds << "static void psx_cps_mark_game(void) {\n"; - ds << " extern int g_psx_cps_mode; g_psx_cps_mode = 1;\n"; - ds << "}\n"; - // Run psx_cps_mark_game before main(). __attribute__((constructor)) is - // GCC/Clang-only; MSVC uses a static initializer pointer in .CRT$XCU. - ds << "#if defined(_MSC_VER)\n"; - ds << "#pragma section(\".CRT$XCU\", read)\n"; - ds << "__declspec(allocate(\".CRT$XCU\")) static void (*psx_cps_mark_game_ctor)(void) = psx_cps_mark_game;\n"; - ds << "#else\n"; - ds << "__attribute__((constructor)) static void psx_cps_mark_game_ctor(void) { psx_cps_mark_game(); }\n"; - ds << "#endif\n"; - } - - std::ofstream dispatch_file(dispatch_filename); - if (dispatch_file.is_open()) { - dispatch_file << ds.str(); - dispatch_file.close(); - fmt::print("✓ Dispatch table written ({} entries)\n\n", - dispatch_addrs.size()); - } else { - fmt::print(stderr, "⚠ Failed to write dispatch file\n\n"); - } - } - - return 0; -} diff --git a/psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp b/psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp deleted file mode 100644 index 1912513..0000000 --- a/psxrecomp-overlay/recompiler/tests/recompiler_patch_test.cpp +++ /dev/null @@ -1,1135 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include "code_generator.h" -#include "config_loader.h" -#include "control_flow.h" -#include "fmt/format.h" -#include "gte_register_classification.h" -#include "recompiler_patch.h" - -namespace fs = std::filesystem; -using PSXRecompV4::RecompilerPatch; - -namespace { - -int failures = 0; - -void check(bool condition, const std::string& name) { - if (condition) { - fmt::print("PASS {}\n", name); - } else { - fmt::print(stderr, "FAIL {}\n", name); - ++failures; - } -} - -template -void check_throws(Fn&& fn, const std::string& needle, - const std::string& name) { - try { - fn(); - check(false, name + " (did not throw)"); - } catch (const std::exception& e) { - check(std::string(e.what()).find(needle) != std::string::npos, - name + " (" + e.what() + ")"); - } -} - -void append_word(std::vector& bytes, uint32_t word) { - bytes.push_back(static_cast(word)); - bytes.push_back(static_cast(word >> 8)); - bytes.push_back(static_cast(word >> 16)); - bytes.push_back(static_cast(word >> 24)); -} - -void write_word(PSXRecomp::PS1Executable& exe, uint32_t address, - uint32_t word) { - const size_t offset = static_cast(address - exe.header.load_address); - exe.code_data[offset + 0] = static_cast(word); - exe.code_data[offset + 1] = static_cast(word >> 8); - exe.code_data[offset + 2] = static_cast(word >> 16); - exe.code_data[offset + 3] = static_cast(word >> 24); -} - -std::string generate_first_instruction(uint32_t first_word, - std::vector patches, - bool overlay_mode, - PSXRecomp::CodeGenConfig config = {}) { - constexpr uint32_t base = 0x80010000u; - PSXRecomp::PS1Executable exe{}; - exe.header.load_address = base; - exe.header.initial_pc = base; - exe.header.file_size = 20; - append_word(exe.code_data, first_word); - append_word(exe.code_data, 0x00000000u); // branch/nonbranch delay-slot nop - append_word(exe.code_data, 0x24030001u); // addiu v1, zero, 1 - append_word(exe.code_data, 0x03E00008u); // jr ra - append_word(exe.code_data, 0x00000000u); // return delay-slot nop - - PSXRecompV4::apply_recompiler_patches_to_executable( - exe, patches, overlay_mode); - - PSXRecomp::Function function{}; - function.start_addr = base; - function.end_addr = base + 20; - function.size = 20; - function.name = "patch_test"; - - PSXRecomp::ControlFlowAnalyzer analyzer(exe); - const auto cfg = analyzer.analyze_function(function); - config.overlay_mode = overlay_mode; - PSXRecomp::CodeGenerator generator(exe, config); - return generator.generate_function(function, cfg).full_code; -} - -std::string base_config() { - return R"toml([game] -name = "Patch Test" -id = "TEST-00000" -exe = "TEST.EXE" -load_address = "0x80010000" -entry_pc = "0x80010000" -text_size = "0x1000" -stack_base = "0x801FFFF0" - -[recompiler] -seeds = "seeds.txt" -out_dir = "generated" -)toml"; -} - -fs::path write_config(const fs::path& root, const std::string& suffix, - const std::string& patch_tables) { - const fs::path path = root / ("game-" + suffix + ".toml"); - std::ofstream file(path, std::ios::binary); - file << base_config() << patch_tables; - file.close(); - return path; -} - -void parser_tests(const fs::path& root) { - const auto valid = write_config(root, "valid", R"toml( -[[recompiler.patch]] -id = "gameplay-rate" -address = "0x80012340" -expected = "0x24020002" -replacement = "0x24020001" -note = "Test-only fixture" -)toml"); - const auto config = PSXRecompV4::load_game_config(valid); - check(config.recompiler_patches.size() == 1, - "parser accepts one guarded patch"); - check(config.recompiler_patches[0].id == "gameplay-rate" && - config.recompiler_patches[0].address == 0x80012340u && - config.recompiler_patches[0].expected == 0x24020002u && - config.recompiler_patches[0].replacement == 0x24020001u, - "parser preserves patch fields"); - - const auto audio_buffer = write_config(root, "audio-buffer", R"toml( -[runtime] - -[audio] -buffer_ms = 60 -)toml"); - const auto audio_config = PSXRecompV4::load_game_config(audio_buffer); - check(audio_config.runtime.audio_buffer_ms == 60, - "parser preserves per-game audio buffer target"); - - const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( -[runtime] - -[audio] -buffer_ms = 20 -)toml"); - check_throws( - [&] { (void)PSXRecompV4::load_game_config(bad_audio_buffer); }, - "[audio] buffer_ms out of range (30..500)", - "parser rejects unsafe audio buffer target"); - - const auto duplicate_address = write_config(root, "duplicate-address", R"toml( -[[recompiler.patch]] -id = "first" -address = "0x80012340" -expected = "0x24020002" -replacement = "0x24020001" - -[[recompiler.patch]] -id = "alias" -address = "0xA0012340" -expected = "0x24020002" -replacement = "0x24020001" -)toml"); - check_throws( - [&] { (void)PSXRecompV4::load_game_config(duplicate_address); }, - "physical address", "parser rejects duplicate aliased addresses"); - - const auto duplicate_id = write_config(root, "duplicate-id", R"toml( -[[recompiler.patch]] -id = "same-id" -address = "0x80012340" -expected = "0x24020002" -replacement = "0x24020001" - -[[recompiler.patch]] -id = "same-id" -address = "0x80012344" -expected = "0x24030002" -replacement = "0x24030001" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(duplicate_id); }, - "duplicate [[recompiler.patch]] id", - "parser rejects duplicate IDs"); - - const auto unaligned = write_config(root, "unaligned", R"toml( -[[recompiler.patch]] -id = "unaligned" -address = "0x80012342" -expected = "0x24020002" -replacement = "0x24020001" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(unaligned); }, - "instruction-aligned", "parser rejects unaligned sites"); - - const auto negsub = write_config(root, "negsub", R"toml( -[widescreen.cull] -negsub_sites = ["0x80012340"] -)toml"); - const auto negsub_config = PSXRecompV4::load_game_config(negsub); - check(negsub_config.ws_cull_negsub_sites == - std::vector{0x80012340u}, - "parser preserves negsub cull sites"); - - const auto manual_window = write_config(root, "manual-window", R"toml( -[widescreen.cull] -lower_branch_sites = ["0x80012340"] -lower_value_sites = ["0x80012344"] -upper_value_sites = ["0x80012348"] -span_value_sites = ["0x8001234C"] -)toml"); - const auto manual_window_config = - PSXRecompV4::load_game_config(manual_window); - check(manual_window_config.ws_cull_lower_branch_sites == - std::vector{0x80012340u} && - manual_window_config.ws_cull_lower_value_sites == - std::vector{0x80012344u} && - manual_window_config.ws_cull_upper_value_sites == - std::vector{0x80012348u} && - manual_window_config.ws_cull_span_value_sites == - std::vector{0x8001234Cu}, - "parser preserves manual screen-window site families"); - auto changed_manual_window_config = manual_window_config; - changed_manual_window_config.ws_cull_span_value_sites.push_back( - 0x80012350u); - check(PSXRecompV4::overlay_codegen_config_hash(manual_window_config) != - PSXRecompV4::overlay_codegen_config_hash( - changed_manual_window_config), - "manual screen-window sites change overlay cache identity"); - - const auto vxrange = write_config(root, "vxrange", R"toml( -[widescreen.cull] -vxrange_sites = ["0x80012340"] -)toml"); - const auto vxrange_config = PSXRecompV4::load_game_config(vxrange); - check(vxrange_config.ws_cull_vxrange_sites == - std::vector{0x80012340u}, - "parser preserves masked-u16 X-window sites"); - - const auto depth = write_config(root, "depth", R"toml( -[widescreen.cull] -depth_sites = ["0x80012340"] -)toml"); - const auto depth_config = PSXRecompV4::load_game_config(depth); - check(depth_config.ws_cull_depth_sites == - std::vector{0x80012340u}, - "parser preserves depth-bound sites"); - - const auto plane_nx = write_config(root, "plane-nx", R"toml( -[widescreen.cull] -plane_nx_sites = ["0x80012340"] -)toml"); - const auto plane_nx_config = PSXRecompV4::load_game_config(plane_nx); - check(plane_nx_config.ws_cull_plane_nx_sites == - std::vector{0x80012340u}, - "parser preserves side-plane nx load sites"); - - const auto xclip_load = write_config(root, "xclip-load", R"toml( -[widescreen.cull] -xclip_load_sites = ["0x80012340"] -)toml"); - const auto xclip_load_config = PSXRecompV4::load_game_config(xclip_load); - check(xclip_load_config.ws_cull_xclip_load_sites == - std::vector{0x80012340u}, - "parser preserves per-prim bound-load sites"); - - const auto keep = write_config(root, "cull-keep", R"toml( -[[widescreen.cull.keep]] -address = "0x8002B310" -expected = "0x28A21C01" -result = 1 - -[[widescreen.cull.keep]] -address = "0x8002B368" -expected = "0x0082202A" -result = 0 -)toml"); - const auto keep_config = PSXRecompV4::load_game_config(keep); - check(keep_config.ws_cull_keep_sites.size() == 2 && - keep_config.ws_cull_keep_sites[0].address == 0x8002B310u && - keep_config.ws_cull_keep_sites[0].expected == 0x28A21C01u && - keep_config.ws_cull_keep_sites[0].result == 1u && - keep_config.ws_cull_keep_sites[1].address == 0x8002B368u && - keep_config.ws_cull_keep_sites[1].expected == 0x0082202Au && - keep_config.ws_cull_keep_sites[1].result == 0u, - "parser preserves full-word-guarded maximal-participation sites"); - - const auto bad_keep = write_config(root, "cull-keep-bad", R"toml( -[[widescreen.cull.keep]] -address = "0x8002B310" -expected = "0x24820001" -result = 1 -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(bad_keep); }, - "expected must be SLT/SLTU/SLTI/SLTIU", - "parser rejects non-comparison maximal-participation sites"); - - const auto range = write_config(root, "range-cull", R"toml( -[widescreen.cull] -range_sites = ["0x80012340"] -activation_guard_pixels = 256 -)toml"); - const auto range_config = PSXRecompV4::load_game_config(range); - check(range_config.ws_cull_range_sites == - std::vector{0x80012340u} && - range_config.ws_cull_activation_guard_pixels == 256, - "parser preserves explicit range sites and activation guard"); - - const auto bad_activation_guard = - write_config(root, "range-cull-bad-activation-guard", R"toml( -[widescreen.cull] -activation_guard_pixels = 257 -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config(bad_activation_guard); - }, - "activation_guard_pixels must be in [0, 256]", - "parser rejects an oversized activation-only guard"); - - const auto angle = write_config(root, "angle-cull", R"toml( -[[widescreen.cull.angle]] -address = "0x8013F138" -expected = "0x24020155" -)toml"); - const auto angle_config = PSXRecompV4::load_game_config(angle); - check(angle_config.ws_cull_angle_sites.size() == 1 && - angle_config.ws_cull_angle_sites[0].address == - 0x8013F138u && - angle_config.ws_cull_angle_sites[0].expected == - 0x24020155u, - "parser preserves exact terrain-frustum angle sites"); - auto changed_angle_config = angle_config; - changed_angle_config.ws_cull_angle_sites[0].expected = - 0x24020156u; - check(PSXRecompV4::overlay_codegen_config_hash(angle_config) != - PSXRecompV4::overlay_codegen_config_hash( - changed_angle_config), - "terrain angle sites change overlay cache identity"); - - const auto bad_angle = write_config(root, "angle-cull-bad", R"toml( -[[widescreen.cull.angle]] -address = "0x8013F138" -expected = "0x24420155" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(bad_angle); }, - "expected must be ADDI/ADDIU rt,zero,imm", - "parser rejects non-constant terrain-angle instructions"); - - const auto yaw_cone = write_config(root, "yaw-cone", R"toml( -[[widescreen.cull.yaw_cone]] -address = "0x80040FE8" -expected = "0x24710020" -full_turn = 256 - -[[widescreen.cull.yaw_cone]] -address = "0x80040FF0" -expected = "0x2472FFE0" -full_turn = 256 - -[[widescreen.cull.horizontal_slt]] -address = "0x8003B8EC" -expected = "0x0067102A" -)toml"); - const auto yaw_cone_config = PSXRecompV4::load_game_config(yaw_cone); - check(yaw_cone_config.ws_cull_yaw_cone_sites.size() == 2 && - yaw_cone_config.ws_cull_yaw_cone_sites[0].address == - 0x80040FE8u && - yaw_cone_config.ws_cull_yaw_cone_sites[0].expected == - 0x24710020u && - yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn == 256u && - yaw_cone_config.ws_cull_yaw_cone_sites[1].expected == - 0x2472FFE0u, - "parser preserves signed wrapping-yaw cone sites"); - check(yaw_cone_config.ws_cull_horizontal_slt_sites.size() == 1 && - yaw_cone_config.ws_cull_horizontal_slt_sites[0].address == - 0x8003B8ECu && - yaw_cone_config.ws_cull_horizontal_slt_sites[0].expected == - 0x0067102Au, - "parser preserves signed horizontal side-plane sites"); - auto changed_yaw_cone_config = yaw_cone_config; - changed_yaw_cone_config.ws_cull_yaw_cone_sites[0].full_turn = 512u; - check(PSXRecompV4::overlay_codegen_config_hash(yaw_cone_config) != - PSXRecompV4::overlay_codegen_config_hash( - changed_yaw_cone_config), - "yaw-cone metadata changes overlay cache identity"); - - const auto bad_yaw_cone = write_config(root, "yaw-cone-bad", R"toml( -[[widescreen.cull.yaw_cone]] -address = "0x80040FE8" -expected = "0x24110020" -full_turn = 256 -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(bad_yaw_cone); }, - "rs != zero", - "parser rejects yaw-cone constants without a live base yaw"); - - const auto bad_horizontal_slt = write_config( - root, "horizontal-slt-bad", R"toml( -[[widescreen.cull.horizontal_slt]] -address = "0x8003B8EC" -expected = "0x0067102B" -)toml"); - check_throws( - [&] { (void)PSXRecompV4::load_game_config(bad_horizontal_slt); }, - "signed register-register SLT", - "parser rejects unsigned horizontal side-plane compares"); - - const auto aspect_cone = write_config(root, "aspect-cone", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -hysteresis_pixels = 24 -queue_reserve = 4 -queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] - -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x28620370" - -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010004" -expected = "0x0082202A" -cosine_threshold = 856 -object_reg = 20 -x_reg = 19 -z_reg = 18 -y_reg = 17 -queue_guard = false -)toml"); - const auto aspect_config = - PSXRecompV4::load_game_config(aspect_cone); - check(aspect_config.ws_aspect_cone.sites.size() == 2 && - aspect_config.ws_aspect_cone.sites[0].address == - 0x80010000u && - aspect_config.ws_aspect_cone.sites[0].expected == - 0x28620370u && - aspect_config.ws_aspect_cone.sites[0].cosine_threshold == - 0x370u && - aspect_config.ws_aspect_cone.sites[1].expected == - 0x0082202Au && - aspect_config.ws_aspect_cone.sites[1].cosine_threshold == - 856u && - aspect_config.ws_aspect_cone.sites[1].object_reg == 20u && - aspect_config.ws_aspect_cone.sites[1].x_reg == 19u && - aspect_config.ws_aspect_cone.sites[1].z_reg == 18u && - aspect_config.ws_aspect_cone.sites[1].y_reg == 17u && - !aspect_config.ws_aspect_cone.sites[1].queue_guard && - aspect_config.ws_aspect_cone.forward_addr == - 0x1F8000E8u && - aspect_config.ws_aspect_cone.object_reg == 19u && - aspect_config.ws_aspect_cone.x_reg == 16u && - aspect_config.ws_aspect_cone.z_reg == 17u && - aspect_config.ws_aspect_cone.y_reg == 18u && - aspect_config.ws_aspect_cone.hysteresis_pixels == 24u && - aspect_config.ws_aspect_cone.queue_reserve == 4u && - aspect_config.ws_aspect_cone.queue_capacities == - std::array{24u, 40u, 28u}, - "parser preserves aspect-aware participation policy"); - - const auto aspect_hash = PSXRecompV4::overlay_codegen_config_hash( - aspect_config); - auto changed_aspect_config = aspect_config; - changed_aspect_config.ws_aspect_cone.hysteresis_pixels++; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "aspect participation policy changes overlay cache identity"); - changed_aspect_config = aspect_config; - changed_aspect_config.ws_cull_guard_pixels++; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "widescreen guard changes overlay cache identity"); - changed_aspect_config = aspect_config; - changed_aspect_config.ws_cull_activation_guard_pixels++; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "activation-only guard changes overlay cache identity"); - changed_aspect_config = aspect_config; - changed_aspect_config.ws_aspect_cone.sites[1].queue_guard = true; - check(aspect_hash != PSXRecompV4::overlay_codegen_config_hash( - changed_aspect_config), - "per-site aspect participation policy changes cache identity"); - - const auto bad_aspect_opcode = - write_config(root, "aspect-cone-bad-opcode", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x2C620370" -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config(bad_aspect_opcode); - }, - "expected must be signed SLTI or SLT", - "parser rejects unsigned aspect-cone guards"); - - const auto bad_aspect_slt_threshold = - write_config(root, "aspect-cone-bad-slt-threshold", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -queue_count_addrs = ["0x1F800144", "0x1F800150", "0x1F80015C"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x0082202A" -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config( - bad_aspect_slt_threshold); - }, - "cosine_threshold must be in [1, 1023]", - "parser requires an explicit Q10 threshold for SLT cones"); - - const auto bad_aspect_arrays = - write_config(root, "aspect-cone-bad-arrays", R"toml( -[widescreen.cull.aspect_cone] -forward_addr = "0x1F8000E8" -object_type_offset = 12 -object_reg = 19 -x_reg = 16 -z_reg = 17 -y_reg = 18 -queue_count_addrs = ["0x1F800144", "0x1F800150"] -queue_capacities = [24, 40, 28] -queue_type_masks = ["0x00000204", "0x00000010", "0x00000020"] -[[widescreen.cull.aspect_cone.sites]] -address = "0x80010000" -expected = "0x28620370" -)toml"); - check_throws( - [&] { - (void)PSXRecompV4::load_game_config(bad_aspect_arrays); - }, - "must contain exactly three values", - "parser rejects incomplete aspect-cone queue metadata"); -} - -void capture_history_config_tests(const fs::path& root) { - const auto valid = write_config(root, "capture-history", R"toml( -[runtime] -overlay_cache = true -overlay_capture_history = true -overlay_capture_persist_dir = ".aot_capture_history/TEST-00000" -)toml"); - const auto cfg = PSXRecompV4::load_game_config(valid); - check(cfg.runtime.overlay_capture_history, - "parser enables durable overlay capture history"); - check(cfg.runtime.overlay_capture_persist_dir == - ".aot_capture_history/TEST-00000", - "parser preserves project-relative capture history directory"); - - const auto escaping = write_config(root, "capture-history-escape", R"toml( -[runtime] -overlay_capture_persist_dir = "../outside" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(escaping); }, - "must stay inside the project", - "parser rejects escaping capture history directory"); - - const auto absolute = write_config(root, "capture-history-absolute", R"toml( -[runtime] -overlay_capture_persist_dir = "C:/outside" -)toml"); - check_throws([&] { (void)PSXRecompV4::load_game_config(absolute); }, - "must be project-relative", - "parser rejects absolute capture history directory"); - - // A game.toml travels between hosts, so each of these must be rejected on - // every host, not only on the one whose path grammar it is written in. - // TOML literal strings ('...') are used because a backslash is an escape - // inside a TOML basic string. Cases marked POSIX-visible are the ones a - // host-native std::filesystem check silently accepted under POSIX. - struct RejectedDir { - const char* value; // as written in game.toml - const char* needle; // expected message fragment - const char* name; - }; - static constexpr RejectedDir rejected[] = { - {"'/outside'", "must be project-relative", - "parser rejects rooted capture history directory"}, - {R"('\outside')", "must be project-relative", - "parser rejects backslash-rooted capture history directory"}, - {R"('C:\outside')", "must be project-relative", - "parser rejects backslash drive-qualified capture history directory"}, - {"'C:outside'", "must be project-relative", - "parser rejects drive-relative capture history directory"}, - {R"('\\server\share\outside')", "must be project-relative", - "parser rejects UNC capture history directory"}, - {R"('..\outside')", "must stay inside the project", - "parser rejects backslash-escaping capture history directory"}, - {R"('nested\..\..\outside')", "must stay inside the project", - "parser rejects nested backslash-escaping capture history directory"}, - {"'nested/..'", "must stay inside the project", - "parser rejects a trailing .. component"}, - // A basic string, not a literal one: TOML forbids a raw NUL byte but - // allows the \u0000 escape that produces it. The runtime passes this - // value on as a C string, so a NUL would truncate it after validation. - {R"("..\u0000keep")", "must not contain a NUL byte", - "parser rejects an embedded NUL escape"}, - }; - for (size_t i = 0; i < std::size(rejected); ++i) { - const auto& c = rejected[i]; - const auto cfg_path = write_config( - root, fmt::format("capture-history-rejected-{}", i), - fmt::format("[runtime]\noverlay_capture_persist_dir = {}\n", - c.value)); - check_throws([&] { (void)PSXRecompV4::load_game_config(cfg_path); }, - c.needle, c.name); - } - - // A dotfile is not a `..` component; the guard must not over-reject. - const auto dotted = write_config(root, "capture-history-dotted", R"toml( -[runtime] -overlay_capture_history = true -overlay_capture_persist_dir = ".aot_capture_history/..keep/TEST-00000" -)toml"); - const auto dotted_cfg = PSXRecompV4::load_game_config(dotted); - check(dotted_cfg.runtime.overlay_capture_persist_dir == - ".aot_capture_history/..keep/TEST-00000", - "parser accepts a capture history component that merely starts with dots"); -} - -void codegen_tests() { - constexpr uint32_t original = 0x24020002u; // addiu v0, zero, 2 - constexpr uint32_t replacement = 0x24020001u; // addiu v0, zero, 1 - const RecompilerPatch physical_alias{ - "gameplay-rate", 0x00010000u, original, replacement, ""}; - - const std::string applied = - generate_first_instruction(original, {physical_alias}, false); - check(applied.find("0x24020001") != std::string::npos && - applied.find("cpu->gpr[2] = 1;") != std::string::npos, - "codegen applies exact patch through physical alias"); - - check_throws( - [&] { (void)generate_first_instruction(0x24020003u, - {physical_alias}, false); }, - "wrong game revision or stale patch", - "main codegen fails on stale opcode guard"); - - const std::string overlay = - generate_first_instruction(0x24020003u, {physical_alias}, true); - check(overlay.find("0x24020003") != std::string::npos && - overlay.find("0x24020001") == std::string::npos, - "overlay nonmatching variant remains unchanged"); - - constexpr uint32_t beq_v0_zero = 0x10400002u; - constexpr uint32_t bne_v0_zero = 0x14400002u; - const RecompilerPatch branch_patch{ - "feature-branch", 0x80010000u, beq_v0_zero, bne_v0_zero, ""}; - const std::string branch = - generate_first_instruction(beq_v0_zero, {branch_patch}, false); - check(branch.find("cpu->gpr[2] != cpu->gpr[0]") != std::string::npos, - "patch is applied before control-flow analysis"); - - std::vector merged{physical_alias}; - PSXRecompV4::merge_recompiler_patches(merged, {physical_alias}); - check(merged.size() == 1, - "config merge deduplicates an identical patch"); - - const RecompilerPatch conflicting_id{ - "gameplay-rate", 0x00010004u, original, replacement, ""}; - check_throws( - [&] { - PSXRecompV4::merge_recompiler_patches(merged, {conflicting_id}); - }, - "conflicting recompiler patches", - "config merge rejects cross-config ID conflicts"); - - PSXRecomp::CodeGenConfig negsub_config; - negsub_config.ws_cull_negsub_sites.insert(0x80010000u); - const std::string negsub = generate_first_instruction( - 0x00041023u, {}, false, negsub_config); // subu v0,zero,a0 - check(negsub.find("cpu->gpr[2] = 0u - cpu->gpr[4] - " - "(uint32_t)psx_ws_x_margin()") != std::string::npos, - "codegen emits configured negsub horizontal low-edge widen"); - - const std::string overlay_mismatch = generate_first_instruction( - 0x00041021u, {}, true, negsub_config); // addu v0,zero,a0 - check(overlay_mismatch.find("ws cull negsub") == std::string::npos, - "overlay nonmatching negsub variant remains unchanged"); - - PSXRecomp::CodeGenConfig cpu_projection_config; - cpu_projection_config.ws_backdrop_x_sites.insert(0x80010000u); - const std::string cpu_projection = generate_first_instruction( - 0x248200A0u, {}, false, cpu_projection_config); // addiu v0,a0,160 - check(cpu_projection.find( - "psx_ws_project_x((int32_t)(cpu->gpr[4] + (uint32_t)160))") != - std::string::npos, - "codegen squashes exact CPU-projected screen-X sites"); - - const std::string cpu_projection_overlay_mismatch = - generate_first_instruction( - 0x288200A0u, {}, true, cpu_projection_config); // slti at same VA - check(cpu_projection_overlay_mismatch.find("psx_ws_project_x") == - std::string::npos, - "overlay nonmatching CPU-projection variant remains unchanged"); - - PSXRecomp::CodeGenConfig lower_branch_config; - lower_branch_config.ws_cull_lower_branch_sites.insert(0x80010000u); - const std::string lower_bgez = generate_first_instruction( - 0x04810002u, {}, false, lower_branch_config); // bgez a0,+2 - check(lower_bgez.find( - "(int32_t)cpu->gpr[4] >= -psx_ws_x_margin()") != - std::string::npos, - "codegen widens a manual bgez lower screen edge"); - const std::string lower_blez = generate_first_instruction( - 0x18800002u, {}, false, lower_branch_config); // blez a0,+2 - check(lower_blez.find( - "(int32_t)cpu->gpr[4] <= -psx_ws_x_margin()") != - std::string::npos, - "codegen preserves manual lower-edge branch sense"); - - PSXRecomp::CodeGenConfig lower_value_config; - lower_value_config.ws_cull_lower_value_sites.insert(0x80010000u); - const std::string lower_zero = generate_first_instruction( - 0x00001021u, {}, false, lower_value_config); // addu v0,zero,zero - check(lower_zero.find("psx_ws_cull_lower_value(0)") != - std::string::npos, - "codegen widens a register lower clamp"); - const std::string lower_store = generate_first_instruction( - 0xAFA0002Cu, {}, false, lower_value_config); // sw zero,44(sp) - check(lower_store.find("cpu->write_word(cpu->gpr[29] + 44, " - "(uint32_t)psx_ws_cull_lower_value(0))") != - std::string::npos, - "codegen widens a stored lower clamp"); - - PSXRecomp::CodeGenConfig upper_value_config; - upper_value_config.ws_cull_upper_value_sites.insert(0x80010000u); - const std::string upper_value = generate_first_instruction( - 0x34020140u, {}, false, upper_value_config); // ori v0,zero,320 - check(upper_value.find("psx_ws_cull_upper_value(320)") != - std::string::npos, - "codegen widens a manual upper bound constant"); - - PSXRecomp::CodeGenConfig span_value_config; - span_value_config.ws_cull_span_value_sites.insert(0x80010000u); - const std::string span_value = generate_first_instruction( - 0x34020140u, {}, false, span_value_config); // packed [0,320] - check(span_value.find("psx_ws_cull_span_value(0x00000140u)") != - std::string::npos, - "codegen widens both halves of a packed screen span"); - - PSXRecomp::CodeGenConfig vxrange_config; - vxrange_config.ws_cull_vxrange_sites.insert(0x80010000u); - const std::string vxrange = generate_first_instruction( - 0x2C820140u, {}, false, vxrange_config); // sltiu v0,a0,0x140 - check(vxrange.find("cpu->gpr[2] = psx_ws_cull_vxrange(cpu->gpr[4], 320)") != - std::string::npos, - "codegen routes masked-u16 X-window sites through shared helper"); - - const std::string vxrange_overlay_mismatch = generate_first_instruction( - 0x24820140u, {}, true, vxrange_config); // addiu v0,a0,0x140 - check(vxrange_overlay_mismatch.find("ws cull masked-u16") == std::string::npos, - "overlay nonmatching masked-u16 variant remains unchanged"); - - PSXRecomp::CodeGenConfig depth_config; - depth_config.ws_cull_depth_sites.insert(0x80010000u); - const std::string signed_depth = generate_first_instruction( - 0x28827FFFu, {}, false, depth_config); // slti v0,a0,0x7fff - check(signed_depth.find("psx_ws_depth_bound(32767)") != std::string::npos, - "codegen emits signed aspect-scaled depth bound"); - - const std::string unsigned_depth = generate_first_instruction( - 0x2C82FFFFu, {}, false, depth_config); // sltiu v0,a0,-1 - check(unsigned_depth.find("psx_ws_depth_bound(-1)") != std::string::npos, - "unsigned depth emit preserves MIPS immediate sign extension"); - - const std::string depth_overlay_mismatch = generate_first_instruction( - 0x24827FFFu, {}, true, depth_config); // addiu v0,a0,0x7fff - check(depth_overlay_mismatch.find("ws cull depth") == std::string::npos, - "overlay nonmatching depth variant remains unchanged"); - - PSXRecomp::CodeGenConfig range_config; - range_config.ws_cull_range_sites.insert(0x80010000u); - const std::string range = generate_first_instruction( - 0x2C8201C1u, {}, false, range_config); // sltiu v0,a0,0x1c1 - check(range.find("2*psx_ws_x_margin()") != std::string::npos, - "native range emit widens by both horizontal margins"); - - auto activation_range_config = range_config; - activation_range_config.ws_cull_activation_guard_pixels = 256; - const std::string activation_range = generate_first_instruction( - 0x2C8201C1u, {}, false, - activation_range_config); // sltiu v0,a0,0x1c1 - check(activation_range.find( - "2*(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + 256 : 0)") != - std::string::npos, - "activation range gains an isolated resident-object lead"); - - PSXRecomp::CodeGenConfig activation_bias_config; - activation_bias_config.ws_cull_bias_sites.insert(0x80010000u); - activation_bias_config.ws_cull_activation_guard_pixels = 256; - const std::string activation_bias = generate_first_instruction( - 0x248200E6u, {}, false, - activation_bias_config); // addiu v0,a0,230 - check(activation_bias.find( - "(psx_ws_x_margin() > 0 ? psx_ws_x_margin() + 256 : 0)") != - std::string::npos, - "activation bias gains the same isolated resident-object lead"); - - PSXRecomp::CodeGenConfig plane_nx_config; - plane_nx_config.ws_cull_plane_nx_sites.insert(0x80010000u); - const std::string plane_nx = generate_first_instruction( - 0x8C84C828u, {}, false, plane_nx_config); // lw a0,-0x37d8(a0) - check(plane_nx.find("psx_ws_plane_nx((int32_t)psx_cyc_load_word") != - std::string::npos, - "codegen routes side-plane nx loads through shared helper"); - - const std::string plane_nx_overlay_mismatch = generate_first_instruction( - 0x9484C828u, {}, true, plane_nx_config); // lhu a0,-0x37d8(a0) - check(plane_nx_overlay_mismatch.find("ws cull plane nx") == std::string::npos, - "overlay nonmatching plane-nx variant remains unchanged"); - - PSXRecomp::CodeGenConfig xclip_config; - xclip_config.ws_cull_xclip_load_sites.insert(0x80010000u); - const std::string xclip = generate_first_instruction( - 0x8C6200F8u, {}, false, xclip_config); // lw v0,0xf8(v1) - check(xclip.find("psx_ws_xclip_bound(psx_cyc_load_word") != std::string::npos, - "codegen routes per-prim bound loads through shared helper"); - - const std::string xclip_overlay_mismatch = generate_first_instruction( - 0x246200F8u, {}, true, xclip_config); // addiu v0,v1,0xf8 - check(xclip_overlay_mismatch.find("ws cull xclip") == std::string::npos, - "overlay nonmatching xclip variant remains unchanged"); - - PSXRecomp::CodeGenConfig keep_config; - keep_config.ws_cull_keep_sites.push_back( - {0x80010000u, 0x28821C01u, 1u}); // slti v0,a0,0x1c01 - const std::string keep_true = generate_first_instruction( - 0x28821C01u, {}, false, keep_config); - check(keep_true.find("psx_ws_cull_keep_result") != std::string::npos && - keep_true.find(", 1u)") != std::string::npos, - "codegen emits guarded widescreen keep-true comparison"); - - keep_config.ws_cull_keep_sites[0] = - {0x80010000u, 0x0082202Au, 0u}; // slt a0,a0,v0 - const std::string keep_false = generate_first_instruction( - 0x0082202Au, {}, false, keep_config); - check(keep_false.find("psx_ws_cull_keep_result") != std::string::npos && - keep_false.find(", 0u)") != std::string::npos, - "codegen emits guarded widescreen keep-false comparison"); - - const std::string keep_overlay_mismatch = generate_first_instruction( - 0x0082202Bu, {}, true, keep_config); // sltu at same overlay VA - check(keep_overlay_mismatch.find("maximal object/model participation") == - std::string::npos, - "overlay full-word mismatch leaves keep site unchanged"); - - PSXRecomp::CodeGenConfig angle_config; - angle_config.ws_cull_angle_sites.push_back( - {0x80010000u, 0x24020155u}); - const std::string angle = generate_first_instruction( - 0x24020155u, {}, false, angle_config); - check(angle.find("psx_ws_angle_widen(341u)") != - std::string::npos, - "codegen emits exact terrain-frustum angle helper"); - auto activation_isolated_angle_config = angle_config; - activation_isolated_angle_config.ws_cull_activation_guard_pixels = 256; - const std::string activation_isolated_angle = generate_first_instruction( - 0x24020155u, {}, false, activation_isolated_angle_config); - check(activation_isolated_angle == angle, - "activation guard leaves terrain-frustum emission byte-identical"); - const std::string angle_overlay_mismatch = - generate_first_instruction(0x240201C7u, {}, true, angle_config); - check(angle_overlay_mismatch.find("psx_ws_angle_widen") == - std::string::npos, - "overlay full-word mismatch leaves terrain angle unchanged"); - - PSXRecomp::CodeGenConfig yaw_config; - yaw_config.ws_cull_yaw_cone_sites.push_back( - {0x80010000u, 0x24710020u, 256u}); - const std::string yaw = generate_first_instruction( - 0x24710020u, {}, false, yaw_config); - check(yaw.find("cpu->gpr[17] = cpu->gpr[3] + (uint32_t)" - "psx_ws_yaw_cone_offset(32, 256u)") != - std::string::npos, - "codegen emits aspect-scaled wrapping-yaw offset"); - const std::string yaw_overlay_mismatch = generate_first_instruction( - 0x2471001Fu, {}, true, yaw_config); - check(yaw_overlay_mismatch.find("psx_ws_yaw_cone_offset") == - std::string::npos, - "overlay full-word mismatch leaves yaw cone unchanged"); - - PSXRecomp::CodeGenConfig horizontal_slt_config; - horizontal_slt_config.ws_cull_horizontal_slt_sites.push_back( - {0x80010000u, 0x0067102Au}); - const std::string horizontal_slt = generate_first_instruction( - 0x0067102Au, {}, false, horizontal_slt_config); - check(horizontal_slt.find( - "cpu->gpr[2] = psx_ws_horizontal_slt(cpu->gpr[3], " - "cpu->gpr[7])") != std::string::npos, - "codegen emits signed camera-horizontal side-plane helper"); - const std::string horizontal_slt_overlay_mismatch = - generate_first_instruction( - 0x0067102Bu, {}, true, horizontal_slt_config); - check(horizontal_slt_overlay_mismatch.find("psx_ws_horizontal_slt") == - std::string::npos, - "overlay full-word mismatch leaves horizontal side plane unchanged"); - - PSXRecomp::CodeGenConfig aspect_config; - aspect_config.ws_aspect_cone.sites.push_back( - {0x80010000u, 0x28620370u}); // slti v0,v1,0x370 reject predicate - aspect_config.ws_aspect_cone.object_reg = 19u; - aspect_config.ws_aspect_cone.x_reg = 16u; - aspect_config.ws_aspect_cone.z_reg = 17u; - aspect_config.ws_aspect_cone.y_reg = 18u; - const std::string aspect = generate_first_instruction( - 0x28620370u, {}, false, aspect_config); - check(aspect.find( - "psx_ws_aspect_cone_result(0x80010000u") != - std::string::npos && - aspect.find("cpu->gpr[19]") != std::string::npos && - aspect.find("(int32_t)(int16_t)cpu->gpr[16]") != - std::string::npos && - aspect.find("(int32_t)(int16_t)cpu->gpr[17]") != - std::string::npos && - aspect.find("(int32_t)(int16_t)cpu->gpr[18]") != - std::string::npos, - "codegen emits full-word-guarded aspect-cone helper"); - - const std::string aspect_overlay_mismatch = - generate_first_instruction(0x28620358u, {}, true, aspect_config); - check(aspect_overlay_mismatch.find("psx_ws_aspect_cone_result") == - std::string::npos, - "overlay full-word mismatch leaves aspect-cone site unchanged"); - - PSXRecomp::CodeGenConfig part_config; - PSXRecompV4::WidescreenAspectConeSite part_site; - part_site.address = 0x80010000u; - part_site.expected = 0x0082202Au; // slt a0,a0,v0 - part_site.cosine_threshold = 856u; - part_site.object_reg = 20u; - part_site.x_reg = 19u; - part_site.z_reg = 18u; - part_site.y_reg = 17u; - part_site.queue_guard = false; - part_config.ws_aspect_cone.sites.push_back(part_site); - const std::string part = generate_first_instruction( - 0x0082202Au, {}, false, part_config); - check(part.find("cpu->gpr[4] = psx_ws_aspect_cone_result") != - std::string::npos && - part.find("cpu->gpr[20]") != std::string::npos && - part.find("(int32_t)(int16_t)cpu->gpr[19]") != - std::string::npos && - part.find("(int32_t)(int16_t)cpu->gpr[18]") != - std::string::npos && - part.find("(int32_t)(int16_t)cpu->gpr[17]") != - std::string::npos, - "codegen supports exact per-child SLT cone metadata"); -} - -void gte_codegen_classification_tests() { - bool all_ok = true; - for (uint8_t reg = 0; reg < 32; ++reg) { - const auto expect = [&](uint32_t word, const std::string& call, - bool needs_helper, const char *kind) { - const std::string code = generate_first_instruction(word, {}, false); - const bool has_helper = code.find(call) != std::string::npos; - if (has_helper != needs_helper) { - fmt::print(stderr, - "FAIL full-game GTE {} reg {} helper={} expected={}\n", - kind, reg, has_helper, needs_helper); - all_ok = false; - } - }; - - const uint32_t cop2 = 0x12u << 26; - expect(cop2 | (0x00u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_read_data(cpu, {})", reg), - PSXRecompGTERegisters::data_read_needs_helper(reg), "MFC2"); - expect(cop2 | (0x02u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_read_ctrl(cpu, {})", reg), - PSXRecompGTERegisters::ctrl_read_needs_helper(reg), "CFC2"); - expect(cop2 | (0x04u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_write_data(cpu, {}", reg), - PSXRecompGTERegisters::data_write_needs_helper(reg), "MTC2"); - expect(cop2 | (0x06u << 21) | (2u << 16) | (uint32_t(reg) << 11), - fmt::format("gte_write_ctrl(cpu, {}", reg), - PSXRecompGTERegisters::ctrl_write_needs_helper(reg), "CTC2"); - expect((0x32u << 26) | (3u << 21) | (uint32_t(reg) << 16), - fmt::format("gte_write_data(cpu, {}", reg), - PSXRecompGTERegisters::data_write_needs_helper(reg), "LWC2"); - expect((0x3Au << 26) | (3u << 21) | (uint32_t(reg) << 16), - fmt::format("gte_read_data(cpu, {})", reg), - PSXRecompGTERegisters::data_read_needs_helper(reg), "SWC2"); - } - check(all_ok, "full-game GTE helper classification covers all registers"); -} - -void jump_table_producer_codegen_test() { - constexpr uint32_t base = 0x80010000u; - constexpr uint32_t entry = base + 0x500u; - PSXRecomp::PS1Executable exe{}; - exe.header.load_address = base; - exe.header.initial_pc = entry; - exe.header.file_size = 0x1000u; - exe.code_data.resize(0x1000u, 0u); - - write_word(exe, base + 0x500u, 0x3C088001u); // lui t0,0x8001 - write_word(exe, base + 0x504u, 0x25100A00u); // addiu s0,t0,0x0a00 - write_word(exe, base + 0x50Cu, 0x2C620003u); // sltiu v0,v1,3 - write_word(exe, base + 0x510u, 0x1040001Bu); // beq v0,zero,+0x580 - write_word(exe, base + 0x518u, 0x00031080u); // sll v0,v1,2 - write_word(exe, base + 0x51Cu, 0x00501021u); // addu v0,v0,s0 - write_word(exe, base + 0x520u, 0x8C420000u); // lw v0,0(v0) - write_word(exe, base + 0x528u, 0x00400008u); // jr v0 - const uint32_t cases[] = { - base + 0x540u, base + 0x550u, base + 0x560u}; - for (size_t i = 0; i < 3u; i++) { - write_word(exe, cases[i], 0x24020001u + static_cast(i)); - write_word(exe, cases[i] + 4u, 0x03E00008u); - write_word(exe, base + 0xA00u + static_cast(i * 4u), - cases[i]); - } - write_word(exe, base + 0x580u, 0x03E00008u); - write_word(exe, base + 0x590u, - 0x08000000u | ((entry >> 2) & 0x03FFFFFFu)); - - PSXRecomp::Function function{}; - function.start_addr = entry; - function.end_addr = base + 0x588u; - function.size = function.end_addr - function.start_addr; - function.name = "producer_switch"; - function.producer_lo = base + 0x400u; - function.producer_hi = base + 0x900u; // table belongs to adjacent producer - - PSXRecomp::ControlFlowAnalyzer analyzer(exe); - const auto bounded_cfg = analyzer.analyze_function(function); - PSXRecomp::CodeGenerator bounded_generator(exe); - const std::string bounded = - bounded_generator.generate_function(function, bounded_cfg).full_code; - check(bounded.find("/* jump table") == std::string::npos, - "codegen cannot resurrect an adjacent-producer jump table"); - - function.producer_lo = 0u; - function.producer_hi = 0u; - const auto single_image_cfg = analyzer.analyze_function(function); - PSXRecomp::CodeGenerator single_image_generator(exe); - const std::string single_image = single_image_generator - .generate_function(function, single_image_cfg).full_code; - check(single_image.find("/* jump table") != std::string::npos, - "codegen still emits the same table for a single owned image"); - - // Exercise the actual overlapping-alias emitter path. The alias begins at - // a later block which jumps back through the host's complete table setup, - // so an unbounded alias really would expose the adjacent producer bytes. - // Producer ownership must survive Function -> alias CFG -> shared body. - PSXRecomp::Function alias = function; - alias.start_addr = base + 0x590u; - alias.end_addr = base + 0x598u; - alias.size = alias.end_addr - alias.start_addr; - alias.name = "producer_switch_alias"; - alias.alias_walk_lo = entry; - alias.alias_group_entries = {alias.start_addr}; - alias.producer_lo = base + 0x400u; - alias.producer_hi = base + 0x900u; - - const auto alias_cfg = analyzer.analyze_function(alias); - check(alias_cfg.producer_lo == alias.producer_lo && - alias_cfg.producer_hi == alias.producer_hi, - "alias CFG retains its host producer bounds"); - PSXRecomp::CodeGenerator alias_generator(exe); - const auto alias_generated = alias_generator.generate_alias_group( - {&alias}, alias_cfg, ""); - check(!alias_generated.empty() && - alias_generated.front().full_code.find("/* jump table") == - std::string::npos, - "alias emitter cannot expose an adjacent producer jump table"); - - alias.producer_lo = 0u; - alias.producer_hi = 0u; - const auto unbounded_alias_cfg = analyzer.analyze_function(alias); - PSXRecomp::CodeGenerator unbounded_alias_generator(exe); - const auto unbounded_alias_generated = - unbounded_alias_generator.generate_alias_group( - {&alias}, unbounded_alias_cfg, ""); - check(!unbounded_alias_generated.empty() && - unbounded_alias_generated.front().full_code.find("/* jump table") != - std::string::npos, - "alias regression fixture reaches the table when ownership is absent"); -} - -} // namespace - -int main() { - const fs::path root = fs::temp_directory_path() / - fmt::format("psxrecomp-patch-test-{}", reinterpret_cast(&failures)); - fs::remove_all(root); - fs::create_directories(root); - - try { - parser_tests(root); - capture_history_config_tests(root); - codegen_tests(); - gte_codegen_classification_tests(); - jump_table_producer_codegen_test(); - } catch (const std::exception& e) { - fmt::print(stderr, "FAIL unexpected exception: {}\n", e.what()); - ++failures; - } - - std::error_code ec; - fs::remove_all(root, ec); - fmt::print("\nGuarded recompiler patches: {}\n", - failures == 0 ? "all tests passed" : "failures detected"); - return failures == 0 ? 0 : 1; -} diff --git a/psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py b/psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py deleted file mode 100644 index 26fd31f..0000000 --- a/psxrecomp-overlay/recompiler/tests/test_reachable_discovery_codegen.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 -"""End-to-end config and codegen checks for reachable main-EXE discovery. - -Usage: python test_reachable_discovery_codegen.py \ - [--recompiler ] -""" -import argparse -import os -import pathlib -import struct -import subprocess -import sys -import tempfile - -LOAD = 0x80010000 - - -def jal(target): - return 0x0C000000 | ((target >> 2) & 0x03FFFFFF) - - -def put32(data, offset, value): - struct.pack_into("=50%-untranslatable body to a fail-closed data stub. - for i, word in enumerate(( - 0x27BDFFF0, - 0x00000001, 0x00000001, 0x00000001, 0x00000001, 0x00000001, - jal(LOAD + 0x100), 0, - 0x03E00008, 0x27BD0010)): - put32(text, 0x300 + i * 4, word) - return bytes(header + text) - - -def write_config(path, discovery="reachable", text_size="0x1000"): - discovery_line = (f'discovery = "{discovery}"\n' - if discovery is not None else "") - with open(path, "w", encoding="utf-8") as f: - f.write(f'''[game] -name = "Reachable discovery test" -exe = "test.exe" -load_address = "0x80010000" -entry_pc = "0x80010000" -text_size = "{text_size}" -stack_base = "0x801FFFF0" - -[recompiler] -seeds = "seeds.txt" -out_dir = "generated" -{discovery_line} -''') - - -def run(recompiler, config): - return subprocess.run( - [recompiler, "--config", config], capture_output=True, text=True) - - -def main(): - here = os.path.dirname(os.path.abspath(__file__)) - default_recompiler = os.path.normpath(os.path.join( - here, "..", "build", "psxrecomp-game.exe")) - parser = argparse.ArgumentParser() - parser.add_argument("--recompiler", default=default_recompiler) - args = parser.parse_args() - if not os.path.isfile(args.recompiler): - raise SystemExit(f"recompiler not found: {args.recompiler}") - - failures = [] - with tempfile.TemporaryDirectory() as tmp: - # Keep find_project_root() anchored to this fixture. Some CI/sandbox - # hosts place a repository marker in the shared temp parent, which - # would otherwise make relative test.exe/seeds.txt paths resolve one - # directory too high and turn this into an environment-dependent test. - pathlib.Path(tmp, ".gitignore").write_text("\n", encoding="utf-8") - exe = os.path.join(tmp, "test.exe") - seeds = os.path.join(tmp, "seeds.txt") - config = os.path.join(tmp, "game.toml") - with open(exe, "wb") as f: - f.write(make_psxexe()) - with open(seeds, "w", encoding="utf-8") as f: - # 0x80010300 is the deliberately untranslatable fixture above. It - # is retained as a fail-closed psx_unknown_dispatch stub for - # diagnostics, but must not be advertised as a callable native - # entry: runtime overlays can replace the same address and - # coincidentally share a short instruction such as NOP. - f.write("0x80010000\n0x80010300\n") - - write_config(config) - result = run(args.recompiler, config) - output = result.stdout + result.stderr - if result.returncode != 0: - failures.append(f"reachable recompile failed:\n{output}") - else: - full_paths = sorted(path for path in - pathlib.Path(tmp, "generated").glob("test.exe_full*.c") - if "_dispatch" not in path.name) - dispatch_path = os.path.join( - tmp, "generated", "test.exe_dispatch.c") - if not full_paths: - failures.append("reachable recompile emitted no _full*.c shards") - full = "" - else: - full = "\n".join(path.read_text(encoding="utf-8") - for path in full_paths) - with open(dispatch_path, encoding="utf-8") as f: - dispatch = f.read() - - if "void func_80010000" not in full: - failures.append("main EXE entry was not emitted") - if "void func_80010100" not in full: - failures.append("reachable direct-JAL target was not emitted") - if "void func_80010200" in full: - failures.append("unseen indirect callback was emitted") - if "void func_80011100" in full: - failures.append("target beyond analysis bound was emitted") - if "phys < 0x00011000u" not in dispatch: - failures.append("dispatch text range did not use verified bound") - if "dirty_ram_text_native_ok_ranges_from(" not in dispatch: - failures.append("exact static-range dispatch guard was lost") - if "uint32_t count, uint32_t exec_pc" not in dispatch: - failures.append("dispatch guard does not accept a continuation PC") - if dispatch.count("entry->range_count, addr)") != 2: - failures.append("dispatch guards do not validate from their resume PC") - if "psx_native_bad_entry(" in full: - failures.append("reachable main image incorrectly used overlay codegen") - if "=== Exact-Entry Function Analysis ===" not in output: - failures.append("reachable config did not select exact-entry analysis") - - # Omitting the key must retain the established whole-image default. - write_config(config, discovery=None, text_size="0x2000") - default_mode = run(args.recompiler, config) - default_output = default_mode.stdout + default_mode.stderr - if default_mode.returncode != 0 or "=== Function Boundary Detection ===" not in default_output: - failures.append("omitted discovery key did not preserve whole-image default") - else: - default_full_paths = sorted(path for path in - pathlib.Path(tmp, "generated").glob("test.exe_full*.c") - if "_dispatch" not in path.name) - default_full = "\n".join(path.read_text(encoding="utf-8") - for path in default_full_paths) - default_dispatch = pathlib.Path( - tmp, "generated", "test.exe_dispatch.c").read_text( - encoding="utf-8") - if "void func_80010300" not in default_full: - failures.append("forced data diagnostic stub was not emitted") - elif ("void func_80010300(CPUState* cpu)\n{\n" - " psx_unknown_dispatch(cpu, 0x80010300u, " - "0x00010300u);") not in default_full: - failures.append("forced data entry was not fail-closed") - if "{0x80010300u," in default_dispatch: - failures.append("fail-closed data stub leaked into game dispatch") - if "{0x80010320u," in default_dispatch: - failures.append("fail-closed stub continuation leaked into game dispatch") - - write_config(config, discovery="invent-functions") - invalid_mode = run(args.recompiler, config) - if invalid_mode.returncode == 0 or "must be 'whole-image' or 'reachable'" not in ( - invalid_mode.stdout + invalid_mode.stderr): - failures.append("invalid discovery mode did not fail closed") - - write_config(config, text_size="0x1800") - invalid_bound = run(args.recompiler, config) - if invalid_bound.returncode == 0 or "must be 4 KiB page-aligned" not in ( - invalid_bound.stdout + invalid_bound.stderr): - failures.append("invalid analysis bound did not fail closed") - - write_config(config, text_size="0x0") - zero_bound = run(args.recompiler, config) - if zero_bound.returncode == 0 or "game.text_size must be nonzero" not in ( - zero_bound.stdout + zero_bound.stderr): - failures.append("zero analysis bound did not fail closed") - - if failures: - for failure in failures: - print("FAIL:", failure) - return 1 - print("PASS: reachable parser, analysis, and exact static codegen behavior") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/psxrecomp-overlay/runtime/CMakeLists.txt b/psxrecomp-overlay/runtime/CMakeLists.txt deleted file mode 100644 index efeecd2..0000000 --- a/psxrecomp-overlay/runtime/CMakeLists.txt +++ /dev/null @@ -1,270 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(psxrecomp-runtime C CXX) -include(CTest) - -set(CMAKE_C_STANDARD 99) -set(CMAKE_CXX_STANDARD 17) - -# ---- Native/oracle runtime targets ---- - -set(PSXRECOMP_ROOT "${CMAKE_SOURCE_DIR}/..") -include("${CMAKE_SOURCE_DIR}/runtime.cmake") - -psxrecomp_add_runtime_target(psx-runtime - DEBUG_PORT 4370 - WINDOW_TITLE "psxrecomp - BIOS" - DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" - EXE_NAME "PSXRecomp" -) -if(WIN32) - target_compile_definitions( - psx-runtime PRIVATE "$<$:NOMINMAX>") -endif() -if(MSVC) - # audio_trace.c is the sole runtime source using C11 atomics. Keep the - # generated C in MSVC's extension-compatible C mode: its static tables use - # address constants that strict /std:c11 rejects. - set_property(SOURCE "${PSXRECOMP_ROOT}/runtime/src/audio_trace.c" - APPEND PROPERTY COMPILE_OPTIONS - "/std:c11" "/experimental:c11atomics") -endif() - -if(BUILD_TESTING) - add_executable(launcher_device_roundtrip_test - tests/test_launcher_device_roundtrip.cpp) - target_include_directories(launcher_device_roundtrip_test PRIVATE include) - add_test(NAME launcher_device_roundtrip_test - COMMAND launcher_device_roundtrip_test) - - add_executable(psx_cyc_batch_test tests/test_psx_cyc_batch.c) - target_include_directories(psx_cyc_batch_test PRIVATE include) - target_compile_definitions(psx_cyc_batch_test PRIVATE - PSX_ENABLE_BLOCK_CYCLES=1) - add_test(NAME psx_cyc_batch_test COMMAND psx_cyc_batch_test) - - add_executable(psx_icache_fastpath_test - tests/test_psx_icache_fastpath.c - src/psx_icache.c - ) - target_include_directories(psx_icache_fastpath_test PRIVATE include) - # PSX_OVERLAY_DLL_BUILD is the seam psx_cycles.h already provides for a TU - # that supplies its own psx_advance_cycles (overlay DLLs forward it through - # a callback shim). Without it the header inlines the production definition - # and this test's counting stub is a redefinition — the whole runtime test - # suite stopped building at "redefinition of 'psx_advance_cycles'", which - # also blocked every test registered after it. - target_compile_definitions(psx_icache_fastpath_test PRIVATE - PSX_ENABLE_BLOCK_CYCLES=1 - PSX_OVERLAY_DLL_BUILD=1) - add_test(NAME psx_icache_fastpath_test COMMAND psx_icache_fastpath_test) - - add_executable(gte_register_access_test - tests/test_gte_register_access.cpp - src/gte.cpp - ) - target_include_directories(gte_register_access_test PRIVATE include) - target_compile_definitions(gte_register_access_test PRIVATE - PSX_NO_DEBUG_TOOLS=1 - PSX_GTE_REGISTER_TEST=1 - PSX_ENABLE_BLOCK_CYCLES=1) - add_test(NAME gte_register_access_test COMMAND gte_register_access_test) - - add_executable(psx_instr_cost_test tests/test_psx_instr_cost.cpp) - target_include_directories(psx_instr_cost_test PRIVATE include) - add_test(NAME psx_instr_cost_test COMMAND psx_instr_cost_test) - - add_executable(ws_aspect_cone_math_test - tests/test_ws_aspect_cone_math.c - src/ws_aspect_cone_math.c) - target_include_directories(ws_aspect_cone_math_test PRIVATE include) - if(NOT MSVC) - target_link_libraries(ws_aspect_cone_math_test PRIVATE m) - endif() - add_test(NAME ws_aspect_cone_math_test COMMAND ws_aspect_cone_math_test) - - add_executable(ws_ui_group_test - tests/test_ws_ui_group.c - src/ws_ui_group.c) - target_include_directories(ws_ui_group_test PRIVATE include) - add_test(NAME ws_ui_group_test COMMAND ws_ui_group_test) - - add_executable(mod_gpu_dma_aperture_test - tests/test_mod_gpu_dma_aperture.c) - target_include_directories(mod_gpu_dma_aperture_test PRIVATE include) - add_test(NAME mod_gpu_dma_aperture_test - COMMAND mod_gpu_dma_aperture_test) - - add_executable(overlay_capture_retry_test - tests/test_overlay_capture_retry.cpp - src/overlay_capture.c) - target_include_directories(overlay_capture_retry_test PRIVATE - include ${PSX_SDL_INCLUDE_DIRS}) - target_compile_definitions(overlay_capture_retry_test PRIVATE - SDL_MAIN_HANDLED PSX_OVERLAY_CAPTURE_TEST=1 - $<$:PSX_SDL3=1>) - target_link_libraries(overlay_capture_retry_test PRIVATE ${PSX_SDL_LIBRARIES}) - add_test(NAME overlay_capture_retry_test COMMAND overlay_capture_retry_test) - - add_executable(overlay_path_canon_test - tests/test_overlay_path_canon.c - src/overlay_path_canon.c) - target_include_directories(overlay_path_canon_test PRIVATE include) - add_test(NAME overlay_path_canon_test COMMAND overlay_path_canon_test) - - # The player-facing "skip the BIOS" flag must resolve to the same boot - # behaviour on retail SCPH-1001 and on the bundled OpenBIOS, whose - # kernel-call HLE is (correctly) refused for want of a DeliverEvent anchor. - add_executable(bios_hle_plan_test - tests/test_bios_hle_plan.c - src/bios_hle_plan.c) - target_include_directories(bios_hle_plan_test PRIVATE include) - add_test(NAME bios_hle_plan_test COMMAND bios_hle_plan_test) - - # Either member of a disc dump (.cue or .bin) is a valid pick: both mount - # the same disc and identify off the same data track. - add_executable(disc_path_resolve_test - tests/test_disc_path_resolve.cpp - src/disc_path.cpp - src/cue_sheet.cpp - src/iso_reader.cpp) - target_include_directories(disc_path_resolve_test PRIVATE include) - target_link_libraries(disc_path_resolve_test PRIVATE chdr-static) - add_test(NAME disc_path_resolve_test COMMAND disc_path_resolve_test) - - # Both BIOS filename conventions (bare vs region-qualified) resolve, in - # both directions, so a config and a dump folder may disagree. - add_executable(bios_rom_alias_test tests/test_bios_rom_alias.cpp) - target_include_directories(bios_rom_alias_test PRIVATE - ${PSXRECOMP_ROOT}/recompiler/include) - add_test(NAME bios_rom_alias_test COMMAND bios_rom_alias_test) - - # Multi-file cue TOC mapping (data track + CD-DA in separate bins). - add_executable(iso_reader_cdda_test - tests/test_iso_reader_cdda.cpp - src/iso_reader.cpp - src/cue_sheet.cpp) - target_include_directories(iso_reader_cdda_test PRIVATE include) - target_link_libraries(iso_reader_cdda_test PRIVATE chdr-static) - add_test(NAME iso_reader_cdda_test COMMAND iso_reader_cdda_test) - - add_executable(mod_packages_test - tests/test_mod_packages.cpp - src/mod_packages.cpp - src/crc32.c - src/psx_sha256.c) - target_include_directories(mod_packages_test PRIVATE - include - ../recompiler/lib/toml11) - add_test(NAME mod_packages_test COMMAND mod_packages_test) - - add_executable(mod_runtime_test - tests/test_mod_runtime.cpp - src/mod_runtime.cpp - src/mod_packages.cpp - src/disc_path.cpp - src/cue_sheet.cpp - src/iso_reader.cpp - src/crc32.c - src/psx_sha256.c) - target_include_directories(mod_runtime_test PRIVATE - include - ../recompiler/lib/toml11) - target_link_libraries(mod_runtime_test PRIVATE chdr-static) - add_test(NAME mod_runtime_test COMMAND mod_runtime_test) - - if(WIN32) - add_executable(autocompile_publication_test - tests/test_autocompile_publication.c - src/autocompile.c) - target_include_directories(autocompile_publication_test PRIVATE include) - target_compile_definitions(autocompile_publication_test PRIVATE - PSX_AUTOCOMPILE_TEST=1) - add_test(NAME autocompile_publication_test - COMMAND autocompile_publication_test) - endif() -endif() - -if(NOT MSVC) - psxrecomp_add_runtime_target(psx-oracle - ORACLE - DEBUG_PORT 4371 - WINDOW_TITLE "psxrecomp - Oracle" - DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" - ) -endif() - -# ---- psx-beetle target (Beetle PSX libretro standalone, MinGW) ---- -# -# Standalone binary that runs Beetle PSX (mednafen-psx libretro core) with -# a debug server exposing the SAME wire-protocol commands as psx-runtime, -# on a different port (4380). No recomp code linked. -# -# Requires beetle-psx built via: -# cd beetle-psx && make platform=mingw_x86_64 STATIC_LINKING=1 HAVE_LIGHTREC=0 - -set(BEETLE_LIB "${CMAKE_SOURCE_DIR}/../beetle-psx/libmednafen_psx.a") - -if(NOT MSVC AND EXISTS "${BEETLE_LIB}") - message(STATUS "psx-beetle: found ${BEETLE_LIB}") - - set(BEETLE_DIR "${CMAKE_SOURCE_DIR}/../beetle-psx") - set(LRC "${BEETLE_DIR}/libretro-common") - set(LIBRETRO_COMMON_SOURCES - ${LRC}/streams/file_stream.c - ${LRC}/streams/file_stream_transforms.c - ${LRC}/vfs/vfs_implementation.c - ${LRC}/file/file_path.c - ${LRC}/compat/compat_strl.c - ${LRC}/compat/fopen_utf8.c - ${LRC}/compat/compat_posix_string.c - ${LRC}/string/stdstring.c - ${LRC}/lists/string_list.c - ${LRC}/encodings/encoding_utf.c - ${LRC}/time/rtime.c - ${LRC}/rthreads/rthreads.c - ${LRC}/memmap/memalign.c - ${LRC}/hash/rhash.c - ) - - add_executable(psx-beetle - src/beetle_main.cpp - src/beetle_libretro.cpp - src/beetle_debug_server.c - src/frame_pacing.c - src/parity_trace.c - src/device_trace.c - src/audio_trace.c - ${LIBRETRO_COMMON_SOURCES} - ) - - target_include_directories(psx-beetle PRIVATE - include - ${PSX_SDL_INCLUDE_DIRS} - ${CMAKE_SOURCE_DIR}/../beetle-psx/libretro-common/include - ${CMAKE_SOURCE_DIR}/../beetle-psx - ) - - target_compile_definitions(psx-beetle PRIVATE - PSX_BEETLE_BUILD=1 - DEFAULT_DEBUG_PORT=4380 - $<$:PSX_SDL3=1> - ) - if(NOT PSX_DEBUG_TOOLS) - target_compile_definitions(psx-beetle PRIVATE PSX_NO_DEBUG_TOOLS=1) - endif() - - target_link_libraries(psx-beetle PRIVATE - "${BEETLE_LIB}" - ${PSX_SDL_LIBRARIES} - ) - - if(WIN32 OR MINGW) - target_link_libraries(psx-beetle PRIVATE ws2_32 -lpthread) - endif() - - if(MINGW) - target_link_options(psx-beetle PRIVATE -Wl,--stack,67108864) - endif() -elseif(NOT MSVC) - message(STATUS "psx-beetle: libmednafen_psx.a not found - skipping psx-beetle") -endif() diff --git a/psxrecomp-overlay/runtime/include/dirty_ram_interp.h b/psxrecomp-overlay/runtime/include/dirty_ram_interp.h deleted file mode 100644 index 7f9b87c..0000000 --- a/psxrecomp-overlay/runtime/include/dirty_ram_interp.h +++ /dev/null @@ -1,348 +0,0 @@ -/* dirty_ram_interp.h — interpret-on-dispatch for install-at-runtime RAM. - * - * See CLAUDE.md Rule 18 and docs/dynamic_handler_install.md for the full - * rationale. The PS1 BIOS dynamically writes 4-instruction dispatch stubs - * into kernel RAM (notably RAM 0xCF0 for the SIO data-byte handler). A - * static recompiler can't see those bytes at compile time, so a small MIPS - * interpreter here runs them at dispatch time on the same CPUState. - * - * Scope: this is NOT a fallback for code the recompiler failed to translate. - * It runs only against PCs in pages that have been written-to since boot. - * Static-recompiled code continues to handle ROM-resident code and game - * RAM. See docs/dynamic_handler_install.md for the inline note about a - * potential future migration to runtime JIT (Option B). - */ -#ifndef PSXRECOMP_DIRTY_RAM_INTERP_H -#define PSXRECOMP_DIRTY_RAM_INTERP_H - -#include -#include "cpu_state.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* Returns 1 if `addr` lies in a dirty kernel-RAM page and the interpreter - * ran a basic block at that PC. Returns 0 if `addr` is clean (caller must - * fall back to the static dispatch table). On return with 1, cpu->pc is - * either 0 (block ended on jr $ra style return) or the target of a tail - * jump that the dispatch trampoline should re-enter. - * - * `stop_addr` is the dispatch loop's return contract (psx_dispatch_call's - * return_addr; 0 for plain tail dispatches). The interpreter's overlay - * local-flow chaining MUST NOT run past it: when a transfer (or fall- - * through) reaches stop_addr, the interpreter exits with cpu->pc == - * stop_addr so the dispatch loop returns into the suspended native - * caller. Skipping this check lets the interp execute the native - * caller's tail itself; the suspended native host frame then resumes and - * double-executes that tail against a moved guest stack, restoring - * garbage callee-saved registers (the pig-throw blue-screen/MMIO-fatal - * corruption, 2026-06-10). */ -int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr); - -/* Retire a deferred R3000A load-delay writeback (see dirty_ram_interp.c). The - * interpreter defers a load's destination-register write past the delay-slot - * instruction, as hardware does; call this anywhere control leaves the - * interpreter or an exception is delivered, so no register write is dropped. */ -void dirty_ram_ld_delay_flush(CPUState* cpu); - -/* Overlay-cache windows — the address ranges eligible for capture, offline - * recompilation, and per-entry-validated native execution (Rule 18 code that - * does not exist in any compile-time image): - * - * Kernel RAM [0x00000, 0x10000): the BIOS part-2 image relocated to RAM - * plus install-at-runtime stubs (e.g. the SIO data-byte stub at 0xCF0). - * Dirty-tracked per CPU store (dirty_ram_mark_kernel_write). - * Overlay region [OVERLAY_REGION_FLOOR, RAM_SIZE): game overlays loaded by - * CD DMA (dirty_ram_mark_executable_range). - * - * Main-EXE text [0x10000, OVERLAY_REGION_FLOOR): CLEAN pages are statically - * recompiled and never captured. But games that discard their boot/init code - * load gameplay overlays OVER this region (Tomba 2: hot gameplay code at - * 0x1DA88/0x24548/0x3116C, well below its 0x38800 floor). Post-baseline - * (dirty_ram_clear_image_baseline) a DIRTY page here is by definition live - * overlay code — the compiled image is stale for it — so dirty boot-text - * pages ARE a capture/candidate window (2026-07-06; before this they could - * only interpret, which dominated wall time at ~300ns/insn). - * - * The interpreter's LOCAL-FLOW gates (is_local_dirty_target / - * phys_is_overlay_flow_region) use DIRTY_RAM_KERNEL_WINDOW_END alone: only - * the kernel window stays per-block (it runs in exception context where the - * verified dispatch cadence is delicate). Native coverage for the kernel - * window comes from the overlay loader, not from interp chaining. */ -#define DIRTY_RAM_KERNEL_WINDOW_END 0x00010000u - -/* The overlay-region floor is the END of THIS game's statically-recompiled - * main-EXE text (phys). Everything at/above it is runtime-loaded overlay code, - * eligible for in-interpreter local-flow chaining and overlay-cache capture. - * - * It is a RUNTIME value (g_overlay_region_floor), NOT a compile-time constant, - * because it differs per game: Tomba 1's text ends at 0x98000 (0x10000+0x88000) - * but Tomba 2's boot EXE is only 0x28800 (text ends at 0x38800). A hardcoded - * 0x98000 misclassified Tomba 2's overlays (loaded at 0x85000+) as main-EXE - * text, forcing them onto the slow block-by-block dispatch path AND through the - * bail-prone non-local-call contract — the Whoopee-Camp splash freeze. main.cpp - * sets g_overlay_region_floor = (load_address + text_size) & 0x1FFFFFFF at game - * load; OVERLAY_REGION_FLOOR_DEFAULT applies for BIOS-only runs. */ -#define OVERLAY_REGION_FLOOR_DEFAULT 0x00098000u -extern uint32_t g_overlay_region_floor; -#define OVERLAY_REGION_FLOOR (g_overlay_region_floor) - -/* Test whether a given physical kernel-RAM address is in a page that was - * written-to since boot. Defined in memory.c. */ -int dirty_ram_is_dirty(uint32_t phys); - -/* Kernel-image bless (memory.c). A dispatch key in the relocated kernel - * window (RAM [0x500,0x8500), the BIOS's boot-time copy of ROM 0x1FC10000+) - * may run its statically-recompiled native function IFF the live RAM bytes - * of the code reachable from it (emitter-supplied body extent) byte-match - * the ROM source. Lazily verified, cached per entry, invalidated precisely - * on writes into the body. Runtime-patched bodies (pad/SIO install stubs) - * never verify and keep interpreting — faithful either way. */ -int psx_kernel_bless_dispatchable(uint32_t phys); -void psx_kernel_bless_note_range(uint32_t phys, uint32_t len); -void psx_kernel_bless_stats(uint64_t out[6]); -/* Detail for the most recent kernel-body validation failure. `count` is the - * cumulative number of failed body validations; out[] is entry, body_lo, - * body_hi, first_mismatch, live_byte, reference_byte. */ -void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]); - -/* Opt-in, low-overhead interpreter hot-PC sampler used by - * PSX_RUNTIME_PERF_DIAG. It samples one retired instruction per `stride` and - * returns/resets the three hottest physical PCs since the previous take. */ -#define DIRTY_RAM_PERF_HOT_COUNT 3u -void dirty_ram_perf_hot_enable(int enabled); -void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t *stride); - -/* Capture/candidate window membership. Kernel window and overlay region are - * unconditional; main-EXE text [KERNEL_WINDOW_END, FLOOR) is included only - * when the page is dirty — i.e. a gameplay overlay overwrote the boot text - * after the game-start baseline, so the bytes there are runtime overlay code - * (see the window model note above). Clean boot text never enters the window - * (it runs compiled; capturing it would violate the static-first design). */ -static inline int overlay_cache_window_contains(uint32_t phys) { - return phys < DIRTY_RAM_KERNEL_WINDOW_END - || phys >= OVERLAY_REGION_FLOOR - || dirty_ram_is_dirty(phys); -} -uint32_t dirty_ram_get_bitmap(void); -uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); -uint32_t dirty_ram_get_bitmap_word_count(void); -void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); -void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, - uint32_t len); -int dirty_ram_text_native_ok(uint32_t phys); -int dirty_ram_text_image_registered(void); -/* Bless an intentional runtime data patch (e.g. text_xlate string/glyph tables) - * into the text reference image so it is not mistaken for self-modifying code. */ -void dirty_ram_text_bless(uint32_t phys, const uint8_t *bytes, uint32_t len); -uint64_t dirty_ram_text_native_blocked(void); -uint32_t dirty_ram_text_diverged_pages(void); - -/* Counters for visibility / TCP debug. Increment in interpreter; expose - * via debug_server.c if helpful. */ -extern uint64_t g_dirty_ram_blocks_run; /* basic blocks interpreted */ -extern uint64_t g_dirty_ram_insns_run; /* instructions interpreted */ -extern uint64_t g_dirty_window_dispatches; /* interp dispatches inside a - * capture window (autocapture - * pressure signal, step 2.8) */ -extern uint64_t g_dirty_ram_aborts; /* unsupported-opcode aborts */ -extern uint64_t g_dirty_ram_guard_yields; /* long dirty loops yielded */ -extern uint64_t g_dirty_ram_unsupported_midblock; -extern uint32_t g_dirty_ram_last_unsupported_entry; -extern uint32_t g_dirty_ram_last_unsupported_entry_ra; -extern uint32_t g_dirty_ram_last_unsupported_entry_sp; -extern uint32_t g_dirty_ram_last_unsupported_insns; -extern uint32_t g_dirty_ram_last_unsupported_pc; -extern uint32_t g_dirty_ram_last_unsupported_insn; -extern const char *g_dirty_ram_last_unsupported_reason; - -/* Per-entry-PC counters. Aggregate counters above hide which install - * stubs actually fire — a single noisy spurious-dispatch site can mask - * a legitimate handler that never runs. This open-addressed table keys - * on the entry PC of each interpreted block. */ -/* 65536 -> 262144 (2026-07-03): Tomba2's Trolley attract demo interprets a - * working set past 64K distinct PCs; a full table degraded every lookup to a - * full-table scan (0.3 fps host burn in pc_table_get_or_insert_in) and - * silently truncated overlay-capture seed lists (MAX_CAPTURE_PCS aliases - * this). Probe length is bounded in pc_table_get_or_insert_in either way. */ -#define DIRTY_RAM_PC_TABLE_SIZE 262144 -typedef struct { - uint32_t pc; /* entry PC, 0 = empty slot */ - uint64_t hits; /* number of times dispatched here */ - uint64_t insns; /* total instructions executed across hits */ - uint64_t entry_hits; /* dispatches that arrived from NATIVE code (call / - * dispatch loop), not from the interpreter's own - * block-to-block chaining. `hits` conflates the two: - * below the local-flow floor every taken branch - * re-dispatches, so chain blocks dominate. entry_hits - * is the evidence stream for interior-alias seeds. */ -} DirtyRamPcEntry; -extern DirtyRamPcEntry g_dirty_ram_pc_table[DIRTY_RAM_PC_TABLE_SIZE]; -/* Every aligned main-RAM word is a possible instruction PC. Execution - * coverage only needs presence, not a hit count, so record it in a direct - * bitmap instead of probing a large hash table for every retired instruction. - * This covers all 524,288 RAM words (the old 262K-entry hash could saturate) - * and is also the execution-verified seed source used by overlay_capture. */ -#define DIRTY_RAM_EXEC_WORD_COUNT ((2u * 1024u * 1024u) / 4u) -#define DIRTY_RAM_EXEC_BITMAP_WORDS ((DIRTY_RAM_EXEC_WORD_COUNT + 31u) / 32u) -extern uint32_t g_dirty_ram_exec_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS]; -/* One bit per 4 KiB page. RAM writes use this as a one-test stale-evidence - * guard; they clear that page's capture bits rather than serializing from the - * universal store hot path. */ -#define DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS 16u -extern uint32_t g_dirty_ram_exec_page_bitmap[DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS]; -/* Presence-only companion for interpreted block/dispatch entries. The richer - * counter table remains for telemetry, while capture can snapshot/reset this - * compact evidence independently at overlay-generation boundaries. */ -extern uint32_t g_dirty_ram_dispatch_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS]; - -/* Block-entry ring buffer. Records every dispatch into dirty RAM with the - * caller's RA at entry, plus argument context — answers - * "who tried to JALR into this RAM stub, with what args". - * Always-on, eviction keeps memory bounded; callers query the window of - * interest (CLAUDE.md global rule on ring buffers). - * - * Limitation: this captures dispatches into RAM-resident code only. ROM - * recompiled-C → recompiled-C calls (direct C function calls in - * generated dispatch table) are NOT captured here. For BIOS shell - * investigation that's fine — shell code lives in RAM (0x800XXXXX). - * - * `ra` is cpu->gpr[31] at dispatch time. For normal JAL-style calls, - * (ra - 8) gives the caller's PC. For J/JR/tail-calls it's only a - * heuristic — treat ra as "ra_callsite_guess", not authoritative. */ -#ifdef PSX_NO_DEBUG_TOOLS -/* Keep ABI-visible one-element sentinels so crash/debug stubs still link, while - * Release does not reserve the diagnostic rings' ~227 MiB of address space. */ -#define DIRTY_RAM_BLOCK_LOG_CAP 1u -#else -#define DIRTY_RAM_BLOCK_LOG_CAP (1u << 22) /* 4M entries (~224 MiB at 56 B each). - * At ~580K dispatches/s during boot, this retains ~7s of history; at - * ~10K/s during modal idle, ~400s. Sized for retroactive press-window - * analysis without the prior 16K ring's 28-ms eviction problem. */ -#endif -typedef struct { - uint64_t seq; /* monotonic, unique per entry */ - uint32_t target; /* entry PC (RAM address) */ - uint32_t ra; /* cpu->gpr[31] at dispatch — caller's return target */ - uint32_t a0; /* cpu->gpr[4] at dispatch */ - uint32_t a1; /* cpu->gpr[5] at dispatch */ - uint32_t a2; /* cpu->gpr[6] at dispatch */ - uint32_t a3; /* cpu->gpr[7] at dispatch */ - uint32_t t0; /* cpu->gpr[8] at dispatch */ - uint32_t t1; /* cpu->gpr[9] at dispatch */ - uint32_t t2; /* cpu->gpr[10] at dispatch */ - uint32_t sp; /* cpu->gpr[29] at dispatch */ - uint32_t frame; /* s_frame_count at the time of dispatch */ -} DirtyRamBlockLogEntry; -extern DirtyRamBlockLogEntry g_dirty_ram_block_log[DIRTY_RAM_BLOCK_LOG_CAP]; -extern uint64_t g_dirty_ram_block_log_seq; - -#ifdef PSX_NO_DEBUG_TOOLS -#define DIRTY_RAM_FLOW_LOG_CAP 1u -#else -#define DIRTY_RAM_FLOW_LOG_CAP (1u << 16) -#endif -typedef struct { - uint64_t seq; - uint32_t pc; - uint32_t target; - uint32_t ra; - uint32_t a0; - uint32_t a1; - uint32_t a2; - uint32_t a3; - uint32_t sp; - uint32_t frame; -} DirtyRamFlowLogEntry; -extern DirtyRamFlowLogEntry g_dirty_ram_flow_log[DIRTY_RAM_FLOW_LOG_CAP]; -extern uint64_t g_dirty_ram_flow_log_seq; - -#ifdef PSX_NO_DEBUG_TOOLS -#define DIRTY_RAM_INSN_LOG_CAP 1u -#else -#define DIRTY_RAM_INSN_LOG_CAP (1u << 16) -#endif -typedef struct { - uint64_t seq; - uint32_t pc; - uint32_t insn; - uint32_t next_pc; - uint32_t target; - uint32_t before_s0; - uint32_t after_s0; - uint32_t sp; - uint32_t ra; - uint32_t v0; - uint32_t v1; - uint32_t a0; - uint32_t a1; - uint32_t a2; - uint32_t a3; - uint32_t t0; - uint32_t t1; - uint32_t t2; - /* Kernel scratch registers. $at is the assembler-temp that hand-written - * kernel asm uses to stash a base across a load-delay slot, and $k0/$k1 - * are the exception handlers' only free registers — i.e. exactly the state - * that matters when debugging BIOS exception/handler code, which is what - * this interpreter mostly runs. */ - uint32_t at; - uint32_t k0; - uint32_t k1; - uint32_t current_tcb; - uint32_t task_ptr; - uint32_t task_mode; - uint32_t task_submode; - uint32_t frame; - uint8_t transferred; - uint8_t pad[3]; -} DirtyRamInsnLogEntry; -extern DirtyRamInsnLogEntry g_dirty_ram_insn_log[DIRTY_RAM_INSN_LOG_CAP]; -extern uint64_t g_dirty_ram_insn_log_seq; - -/* PaRappa rhythm debug ring. Narrow, game-specific probe for the Stage 1 - * rhythm routine when it is running through the dirty interpreter. */ -#ifdef PSX_NO_DEBUG_TOOLS -#define PARAPPA_RHYTHM_EVENT_CAP 1u -#else -#define PARAPPA_RHYTHM_EVENT_CAP 4096u -#endif -typedef struct { - uint64_t seq; - uint32_t frame; - uint32_t pc; - uint32_t addr; - uint32_t value; - uint32_t width; - uint32_t obj; - uint32_t ra; - uint32_t a0; - uint32_t a1; - uint32_t a2; - uint32_t a3; - uint32_t s5; - uint32_t s6; - uint32_t g_800901bc; - uint32_t g_800901c0; - uint32_t g_800916d0; - uint32_t g_800916d8; - uint32_t g_800916da; - uint32_t g_800916dc; - uint32_t g_801d3040; -} ParappaRhythmEvent; -extern ParappaRhythmEvent g_parappa_rhythm_events[PARAPPA_RHYTHM_EVENT_CAP]; -extern uint64_t g_parappa_rhythm_event_seq; -void parappa_rhythm_events_reset(void); -void parappa_timing_window_reset(void); -extern int g_parappa_timing_mode; -extern int g_parappa_timing_extra_early; -extern int g_parappa_timing_extra_late; - -#ifdef __cplusplus -} -#endif - -#endif /* PSXRECOMP_DIRTY_RAM_INTERP_H */ diff --git a/psxrecomp-overlay/runtime/include/gpu.h b/psxrecomp-overlay/runtime/include/gpu.h deleted file mode 100644 index fad42ec..0000000 --- a/psxrecomp-overlay/runtime/include/gpu.h +++ /dev/null @@ -1,624 +0,0 @@ -/* gpu.h — PS1 GPU hardware simulation (Phase 3). - * - * Implements GPUSTAT, GP0, GP1, and 1024x512 16-bit VRAM. - * No rendering to screen — just correct hardware state transitions. - */ - -#ifndef PSXRECOMP_GPU_H -#define PSXRECOMP_GPU_H - -#include - -typedef struct CPUState CPUState; - -#ifdef __cplusplus -extern "C" { -#endif - -void gpu_init(void); -uint32_t gpu_read_gpustat(void); /* 0x1F801814 read */ -uint32_t gpu_read_gpuread(void); /* 0x1F801810 read */ -void gpu_write_gp0(uint32_t val); /* 0x1F801810 write */ -void gpu_write_gp1(uint32_t val); /* 0x1F801814 write */ -void gpu_set_gp0_source(uint32_t addr); /* diagnostic source for next GP0 word */ -/* Linked-list provenance for the next GP0 words. `word_count == 0` identifies - * an ordering-table link node; packet nodes retain the most recently visited - * OT rank. This lets diagnostics distinguish front-layer UI packets from - * depth-sorted world packets without guessing from primitive shape. */ -void gpu_set_gp0_linked_list_node(uint32_t addr, uint32_t word_count); -void gpu_vblank_tick(void); /* Toggle LCF, called at each simulated vblank */ - -/* Display presentation accessors (Phase 3). */ -const uint16_t* gpu_get_vram(void); /* Pointer to 1024x512 16-bit VRAM */ - -typedef struct { - uint32_t display_x, display_y; /* VRAM start of display area (GP1(05h)) */ - uint32_t width, height; /* Derived from display mode + ranges */ - int depth24; /* GP1(08h) display depth flag: RGB888 scanout */ - int disabled; /* GP1(03h) display disable flag */ -} GpuDisplayInfo; - -void gpu_get_display_info(GpuDisplayInfo* out); -/* GP1(08h) bit4 — 24-bit display. Renderers skip FBO upload queues while set: - * packed RGB888 lives in the CPU mirror; treating A0 rects as 1555 FBO uploads - * both wastes bandwidth and force-flushes when UP_RECTS_MAX is hit (MotK FMV). */ -int gpu_display_is_depth24(void); -void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, - uint8_t* r, uint8_t* g, uint8_t* b); -uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y); -/* Depth24: RGB columns covered by CPU→VRAM uploads since the last reset. - * Returns crtc_w when unknown / full coverage. Present uses this to blank a - * trailing margin without shrinking the CRTC-derived width globally. */ -uint32_t gpu_depth24_rgb_limit(uint32_t display_x, uint32_t crtc_w); -void gpu_depth24_upload_span_reset(void); -/* MotK intro cuts retarget GP1(07h). While hold > 0, present should skip - * Swap (keep prior frame) so stale trailing VRAM never flashes. One tick - * per vblank; returns non-zero while the hold is still active after tick. */ -int gpu_depth24_present_hold_tick(void); -/* GP1(06h)/GP1(07h)/GP1(08h) fields for debug (gpu_state). */ -void gpu_get_crtc_debug(uint32_t *x1, uint32_t *x2, uint32_t *y1, uint32_t *y2, - uint32_t *hres1_out, uint32_t *hres2_out); -uint64_t gpu_get_gp0_count(void); /* Total GP0 writes since init */ -void gpu_get_gp0_stats(uint64_t* nop, uint64_t* fill, uint64_t* draw, uint64_t* env, uint64_t* copy); - -typedef struct { - uint32_t left, top, right, bottom; - int32_t offset_x, offset_y; -} GpuDrawArea; -void gpu_get_draw_area(GpuDrawArea* out); -uint16_t gpu_vram_peek(int x, int y); - -/* Shaded quad vertex capture (Phase 4.5 debug). */ -typedef struct { - int32_t vx[4], vy[4]; - uint32_t color[4]; -} GpuSqCapEntry; -void gpu_arm_shaded_quad_capture(void); -int gpu_get_shaded_quad_capture(const GpuSqCapEntry** out); - -/* Per-frame GP0 command ring (always-on; query via debug server). - * Each entry records the GP0 command header + up to 6 payload words - * (longer commands like 0x3C shaded textured quad are truncated to 6). - * Stamped with the s_frame_count value at issue time so a debug - * client can pull all commands for any frame in the ring window. */ -#define GPU_GP0_RING_MAX_WORDS 12 -typedef struct { - uint32_t frame; - uint32_t seq; - uint32_t src_addr; /* RAM/MMIO source address of command header, if known */ - uint32_t pc; /* g_debug_last_store_pc when command completes */ - uint32_t func; /* g_debug_current_func_addr at issue (executing guest fn) */ - uint32_t ra; /* guest $ra at issue: direct caller of the leaf GP0 helper */ - uint8_t opcode; - uint8_t n_words; /* total command length; >MAX means truncated */ - uint16_t ot_rank; /* linked-list OT rank, 0xFFFF outside/unknown */ - uint32_t cmd[GPU_GP0_RING_MAX_WORDS]; - /* Builder attribution (populated only for VRAM->VRAM copies, op 0x80): - * bounded guest-stack unwind of validated return addresses, innermost - * first. Lets a caller skip the libgpu funnel band and name the game-level - * routine that assembled the copy. Zero for non-copy commands. */ - uint32_t bld[6]; - uint32_t csp; /* raw guest $sp at copy time (diagnostic) */ -} GpuGp0RingEntry; - -uint64_t gpu_gp0_ring_total(void); -uint32_t gpu_gp0_ring_capacity(void); -uint32_t gpu_gp0_ring_max_words(void); -int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out); -void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest); - -/* Vblank presentation callback — called from gpu_vblank_tick(). */ -typedef void (*gpu_vblank_cb)(void); -void gpu_set_vblank_callback(gpu_vblank_cb cb); - -/* Present-time screen-colour model (see color_lut.h ScreenKind: 0=raw, - * 1=crt, 2=composite, 3=trinitron). Config/launcher-driven; the PSX_SCREEN - * env var, if set, overrides this. Default 0 (raw) is byte-identical. */ -void gpu_set_screen_kind(int kind); - -/* Presentation-only high-precision geometry. The guest-visible GP0/GTE path - * remains integer; supported renderers mirror gameplay polygons into a - * separate display surface using retained 16.16 projection coordinates. */ -void gpu_geometry_correction_set(int enabled); -int gpu_geometry_correction_enabled(void); -void gpu_geometry_camera_yaw_residual_set(double yaw_units); -/* Display-relative projection horizon shared with the optional presentation - * yaw homography. Values are rounded to an integer pixel; non-finite or - * out-of-range values reset the horizon to the 240-line default of 120. */ -void gpu_geometry_camera_projection_center_y_set(double center_y); -int gpu_geometry_camera_projection_center_y_get(void); -void gpu_geometry_correction_stats(uint64_t *world_triangles, - uint64_t *precise_triangles); - -/* Independent, default-off perspective texture interpolation for exact world - * triangles on the presentation mirror. Canonical VRAM, sprites, rectangles, - * UI, and any polygon without strict projective depth remain affine. */ -void gpu_texture_correction_set(int enabled); -int gpu_texture_correction_enabled(void); -uint32_t gpu_texture_correction_hits(void); - -/* Versioned Test-13 diagnostics. Candidate/accepted primitive arrays count - * polygons; triangle and vertex totals make quad coverage unambiguous. The - * latest_live counters are the most recent presentation which was eligible to - * consume the independent corrected surface, while cumulative includes every - * geometry-correction candidate since the feature was enabled. */ -#define GPU_GEOMETRY_DIAGNOSTICS_VERSION 1u -#define GPU_GEOMETRY_REJECT_REASON_COUNT 15u -#define GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT 8u -#define GPU_GEOMETRY_OPCODE_COUNT 32u -#define GPU_GEOMETRY_DEPTH_BUCKET_COUNT 6u -#define GPU_GEOMETRY_SCREEN_BUCKET_COUNT 10u -#define GPU_GEOMETRY_CORRECTION_BUCKET_COUNT 5u - -typedef enum GpuGeometryRejectReason { - GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE = 0, - GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW, - GPU_GEOMETRY_REJECT_TRACKING_DISABLED, - GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP, - GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM, - GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED, - GPU_GEOMETRY_REJECT_STORE_MISS, - GPU_GEOMETRY_REJECT_STORE_COLLISION, - GPU_GEOMETRY_REJECT_PROJECTION_INVALID, - GPU_GEOMETRY_REJECT_PACKED_MISMATCH, - GPU_GEOMETRY_REJECT_ZERO_DEPTH, - GPU_GEOMETRY_REJECT_SATURATED, - GPU_GEOMETRY_REJECT_INTEGER_MISMATCH, - GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, - GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH -} GpuGeometryRejectReason; - -typedef enum GpuGeometryPrimitiveClass { - GPU_GEOMETRY_PRIMITIVE_FLAT_TRI = 0, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_TRI, - GPU_GEOMETRY_PRIMITIVE_GOURAUD_TRI, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_TRI, - GPU_GEOMETRY_PRIMITIVE_FLAT_QUAD, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_FLAT_QUAD, - GPU_GEOMETRY_PRIMITIVE_GOURAUD_QUAD, - GPU_GEOMETRY_PRIMITIVE_TEXTURED_GOURAUD_QUAD -} GpuGeometryPrimitiveClass; - -typedef struct GpuGeometryCorrectionCounters { - uint64_t triangle_candidates; - uint64_t triangle_accepted; - uint64_t polygon_candidates; - uint64_t polygon_accepted; - uint64_t vertex_candidates; - uint64_t vertex_accepted; - uint64_t vertex_rejections[GPU_GEOMETRY_REJECT_REASON_COUNT]; - uint64_t primitive_candidates[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; - uint64_t primitive_accepted[GPU_GEOMETRY_PRIMITIVE_CLASS_COUNT]; - /* Polygon opcode 0x20..0x3f, indexed by opcode - 0x20. */ - uint64_t opcode_candidates[GPU_GEOMETRY_OPCODE_COUNT]; - uint64_t opcode_accepted[GPU_GEOMETRY_OPCODE_COUNT]; - /* Accepted vertices: z <256,<1024,<4096,<16384,<32768,>=32768. */ - uint64_t depth_vertices[GPU_GEOMETRY_DEPTH_BUCKET_COUNT]; - /* Accepted vertices: 3x3 on-screen cells followed by off-screen. */ - uint64_t screen_vertices[GPU_GEOMETRY_SCREEN_BUCKET_COUNT]; - uint64_t correction_vertices; - uint64_t correction_abs_x16_sum; - uint64_t correction_abs_y16_sum; - uint64_t correction_magnitude16_sum; - uint64_t correction_magnitude16_max; - uint64_t correction_x16_squared_sum; - uint64_t correction_y16_squared_sum; - /* Set to one if any correction sum has saturated at UINT64_MAX. */ - uint64_t correction_accumulators_saturated; - /* max(|dx|,|dy|): zero, <.25px, <.5px, <.75px, <1px. */ - uint64_t correction_magnitude_buckets[ - GPU_GEOMETRY_CORRECTION_BUCKET_COUNT]; - uint64_t partial_polygon_rejections; - uint64_t partial_quad_rejections; -} GpuGeometryCorrectionCounters; - -typedef struct GpuGeometryCorrectionStats { - uint32_t version; - uint32_t size; - uint64_t presentation_sequence; - uint64_t corrected_presentations; - uint64_t latest_live_presentation; - uint32_t latest_live_valid; - uint32_t reserved; - uint64_t provenance_store_collisions; - uint64_t provenance_store_evictions; - uint64_t provenance_lookup_collisions; - GpuGeometryCorrectionCounters cumulative; - GpuGeometryCorrectionCounters latest_live; - /* Additive tail field: older size-bounded callers retain the version-1 - * prefix while current diagnostics expose rejected uncommitted SWC2s. */ - uint64_t provenance_store_uncommitted_rejections; - /* Reviewed MFC2 -> ordinary-SW routes. Attempts count committed-site - * observations; accepts entered exact provenance, while packed rejections - * identify clipped/repacked output that deliberately stayed canonical. */ - uint64_t provenance_registered_store_attempts; - uint64_t provenance_registered_store_accepts; - uint64_t provenance_registered_store_packed_rejections; - /* Reviewed projected-vertex-buffer -> GP0 packet copy routes. */ - uint64_t provenance_copy_load_attempts; - uint64_t provenance_copy_load_accepts; - uint64_t provenance_copy_store_attempts; - uint64_t provenance_copy_store_accepts; - uint64_t provenance_copy_store_packed_rejections; -} GpuGeometryCorrectionStats; - -void gpu_geometry_correction_stats_detailed( - GpuGeometryCorrectionStats *out, uint32_t out_size); - -/* Widescreen proportion correction (active only when aspect != 4:3 and the - * game's [widescreen] block opts in — see config_loader.h). Tagged - * character/billboard prims are re-squashed around their projected anchor; - * untagged SPRT prims (screen-space HUD/menus) around the display centre. - * psx_ws_sprite_tag is the per-prim callback the recompiler emits at the - * entry of each [widescreen] sprite_tag_funcs function. */ -/* mode: 0 = off (4:3 identity), 1 = squash (legacy hack), 2 = native-wide - * (render the wider FOV into a wider frame, present 1:1; GTE not squashed). */ -void gpu_ws_configure(int aspect_num, int aspect_den, - uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode); -/* Add an audited world primitive to the existing classic-squash provenance - * table. This is host presentation metadata only; it never edits guest RAM. - * Calls are ignored outside an active classic-wide gameplay presentation. */ -/* Camera depth of a CPU-projected POLY_FT4 sprite, for frame interpolation. */ -void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, - int32_t depth); -/* The unrounded centre column of the sprite just noted and its distance from its row, 16.16. */ -void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16); -/* The unrounded row the placed sprite is centred on and its unrounded sides, 16.16. A shadow lies on the floor. - * whole: the packet spans the size cut to whole pixels, not one less. */ -void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, - int shadow, int whole); -/* Shadows as the game shapes them (0) or flattened the way the floor recedes (1). */ -void gpu_set_shadow_shape(int improved); -int gpu_shadow_shape(void); -int gpu_sprite_placement_available(void); -void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, - int32_t anchor_x); -/* Mark a completed, audited SPRT packet as a backdrop tile laid out edge to - * edge in widened coordinates: classic squash maps it about the display centre. */ -void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr); -/* Widened counterpart of a frame X for such a tile span. */ -int32_t gpu_ws_widen_x(int32_t x, int round_up); -/* Size of the widescreen HUD in percent, 50 to 100. 100 is the authored size. */ -void gpu_ws_set_hud_scale(int percent); -int gpu_ws_hud_scale(void); -/* HUD widgets of a game that links its HUD to `layers` nodes just below its ordering table, whose address - * is the guest word at `table_pointer`: six numbers each, the box x0, y0, x1, y1 and the pivot x, y. Once - * such a layer has shown a widget, later rectangles inside its box belong to it too, until the game sets - * its next drawing area. The game has to set one every frame. */ -void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count); -/* A game whose text printer draws a line as one picture `wide` across, the words from its left end. */ -void gpu_ws_set_hud_text_strip(int wide); -/* [widescreen] full_2d: opt a pure-2D sprite game into the widescreen present - * path (treat every in-game frame as gameplay, since it never tags 3D prims). */ -void gpu_ws_set_full_2d(int on); -void gpu_ws_set_auto_ui_squash(int on); -/* [widescreen.bg2d] Capcom 2D background tile-loop widen — hooked at the renderer's - * column-count / start-tile-col / start-screen-x instructions. Identity at 4:3 - * and in the engine's 512 hi-res mode. */ -void gpu_ws_bg2d_configure(uint32_t layer_base, uint32_t ring_base, - uint32_t map_size_addr, uint32_t layer_stride_addr, - uint32_t ring_cols, uint32_t layer_count, - uint32_t layer_struct_stride, uint32_t packet_cap); -int psx_ws_bg2d_cols(int base); -int psx_ws_bg2d_startcol(int col, unsigned mask); -int psx_ws_bg2d_startx(int x); -int psx_ws_bg2d_stream_left(int x); -int psx_ws_bg2d_stream_right(int x); -int psx_ws_bg2d_undercap(int counter, int native_cap); -/* MISNAMED, NOT MMX6-SPECIFIC, AND NOT SAFE TO DELETE (surveyed 2026-07-27). - * - * These read as "MMX6 compat shims" and the comment here used to say exactly - * that. They are not. They are the widescreen-2D entry points that the - * recompiler emits for ANY title with a [widescreen.bg2d] block, and live - * generated code in several non-MMX6 games links them today: - * - * psx_ws_mmx6_bg_cols/startcol/startx/stream_left/stream_right - * -> CrashBashRecomp, MegaManX5Recomp, TsumuRecomp (+ -release) - * psx_ws_mmx6_bg_undercap (below, in gpu.c) - * -> ApeEscapeRecomp, CrashBashRecomp, MegaManX4Recomp, - * MegaManX5Recomp, TsumuRecomp, Vigilante8PSXRecomp, THPS2 - * - * Each one forwards to its psx_ws_bg2d_* twin above, which is the real - * implementation. Only the NAME is historical: this began as MMX6 widescreen - * work and was generalised without renaming the emitted symbols. - * - * Deleting or renaming them breaks widescreen in every repo listed above, and - * a rename cannot be runtime-only: the names are emitted by - * recompiler/src/code_generator.cpp, which is in codegen_hash_sources.cmake, - * so changing it rolls the overlay cache tag and forces a regen + reshard of - * every title. That is the reason this is still called mmx6 in 2026. - * - * Verify before touching, don't trust this comment: grep the generated trees - * of the game repos for psx_ws_mmx6_bg_undercap and see who turns up. - */ -int psx_ws_mmx6_bg_cols(int base); -int psx_ws_mmx6_bg_startcol(int col); -int psx_ws_mmx6_bg_startx(int x); -int psx_ws_mmx6_bg_stream_left(int x); -int psx_ws_mmx6_bg_stream_right(int x); -struct CPUState; -void psx_ws_sprite_tag(struct CPUState* cpu); - -/* Native-wide (mode 2) on a game frame. ws_nw_extra() is the total width the - * frame grows by, in display pixels (the present path widens the display read - * by this; 0 when native-wide is inactive). */ -int ws_native_wide_active(void); -int ws_nw_extra(void); -/* True when the current frame must present at native 4:3 (FMV video or a - * full-2D menu/title screen), so the squash is suppressed and content drawn - * pixel-native. The present path uses the same predicate to pillarbox. */ -int gpu_ws_present_native_43(void); -/* The same for the frame on display, which a double-buffered game drew a frame earlier. */ -int gpu_ws_displayed_native_43(void); -/* The frame on display is a front-end screen shown wide: flat, so nothing is put in between its frames. */ -int gpu_ws_displayed_menu(void); -/* A game says what the frame it begins to build is, before it culls or projects for it: 1 a full-2D screen, 0 a world. */ -void gpu_ws_tell_frame_kind(int flat); -int gpu_ws_displayed_flat(void); -/* Per-side X cull-margin (screen/world units) emitted into the game's draw- - * cull immediates by the recompiler ([widescreen.cull]); 0 unless stretching. */ -int psx_ws_x_margin(void); -void gpu_ws_set_cull_guard_pixels(int pixels); -/* Bias/range activation-window margin. This may include an additional - * resident-object lead while render/terrain paths retain psx_ws_x_margin(). */ -int psx_ws_activation_margin(void); -void gpu_ws_set_activation_guard_pixels(int pixels); -void gpu_ws_set_explicit_cull_sites(const uint32_t *bias, int nbias, - const uint32_t *slti, int nslti, - const uint32_t *range, int nrange); -void gpu_ws_set_slti_lower_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_negsub_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_vxrange_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_depth_cull_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_plane_nx_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_xclip_load_sites(const uint32_t *sites, int nsites); -void gpu_ws_set_cull_keep_sites(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *results, int nsites); -void gpu_ws_set_angle_sites(const uint32_t *addresses, - const uint32_t *expected, int nsites); -void gpu_ws_set_aspect_cone(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *cosine_thresholds, - const uint32_t *object_regs, - const uint32_t *x_regs, - const uint32_t *z_regs, - const uint32_t *y_regs, - const uint32_t *queue_guards, - int nsites, - uint32_t forward_addr, - uint32_t object_type_offset, - uint32_t hysteresis_pixels, - uint32_t queue_reserve, - const uint32_t queue_count_addrs[3], - const uint32_t queue_capacities[3], - const uint32_t queue_type_masks[3]); -int psx_ws_is_cull_bias_site(uint32_t pc); -int psx_ws_is_cull_slti_site(uint32_t pc); -int psx_ws_is_cull_slti_lower_site(uint32_t pc); -int psx_ws_is_cull_negsub_site(uint32_t pc); -int psx_ws_is_cull_vxrange_site(uint32_t pc); -int psx_ws_is_cull_depth_site(uint32_t pc); -int32_t psx_ws_depth_bound(int32_t imm); -int psx_ws_is_cull_range_site(uint32_t pc); -int psx_ws_is_cull_plane_nx_site(uint32_t pc); -int32_t psx_ws_plane_nx(int32_t nx); -int psx_ws_is_cull_xclip_load_site(uint32_t pc); -uint32_t psx_ws_xclip_bound(uint32_t vanilla); -uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); -int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, - uint32_t *out); -uint32_t psx_ws_angle_widen(uint32_t vanilla); -int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn); -uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal); -int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out); -uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, - uint32_t object, int32_t x, int32_t z, - int32_t y); -int psx_ws_aspect_cone_site(CPUState *cpu, uint32_t pc, uint32_t instr, - uint32_t vanilla, uint32_t *out); -/* Scale a signed Q16 horizontal gameplay limit into the active native-wide - * game field. Identity at 4:3 / menus / FMV. */ -int32_t psx_ws_player_x_bound(int32_t vanilla); -void gpu_ws_set_signed_x_bound_sites(const uint32_t *addresses, - const uint32_t *expected, int count); -int psx_ws_is_signed_x_bound_site(uint32_t pc, uint32_t instr); - -/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x): - * the gcc emit and the interpreter both route a flagged - * `sltiu rt, sx, 0x140/0x141` through this one helper so every overlay execution - * path widens identically. Returns the sltiu verdict (1 = keep). 0 at 4:3. */ -int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm); -/* Signed-funnel variants (min/max + center±halfwidth idioms — see - * ws_cull_detect.h): right-edge widen for `slti v, minSX, W` and left-edge - * widen for the paired `bltz maxSX` reject. Identity at 4:3. */ -int psx_ws_cull_slti(uint32_t sx, uint32_t imm); -int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm); -int32_t psx_ws_cull_lower_value(int32_t vanilla); -int32_t psx_ws_cull_upper_value(int32_t vanilla); -uint32_t psx_ws_cull_span_value(uint32_t vanilla); -int psx_ws_cull_bltz(uint32_t v); -int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); -/* True if a run of instruction words carries the screen-extent reject signature - * (a width compare AND a height compare from the configured immediate sets). - * Used by the interp to gate the widening to real render funnels. */ -int psx_ws_func_has_screen_cull(const uint32_t *words, int n); -/* Classify words[idx] as an X left-edge reject bltz (runtime imm sets). */ -int psx_ws_cull_bltz_at(const uint32_t *words, int n, int idx); -/* Per-game cull signature immediates ([widescreen.cull] screen_w_imms / - * screen_h_imms); defaults 0x140/0x141 + 0xE0/0xF1. */ -void gpu_ws_set_cull_imms(const uint32_t *w, int nw, const uint32_t *h, int nh); -int psx_ws_is_cull_w_imm(uint32_t imm); -/* Per-game opt-in gates for the pattern-scanned interp widen hooks - * (auto_screen_x cull + auto_backdrop preload). Default OFF: a title that - * never opted in must never have its live code pattern-scanned and rewritten. */ -void gpu_ws_set_auto_hooks(int cull_on, int backdrop_on); -int psx_ws_auto_cull_on(void); -/* GTE-activity gameplay detector ([widescreen] gte_game_mode) for 3D titles - * with no sprite-tag helper: gte.cpp notes every RTPS/RTPT projection; a frame - * that projects enough vertices is stamped as gameplay. */ -void gpu_ws_set_gte_game_mode(int on); -void psx_ws_note_gte_project(int nverts); -/* Optional authoritative gameplay-state gate. When configured, it replaces - * heuristic gameplay classification for native-wide presentation. */ -void gpu_ws_set_gameplay_state_gate(uint32_t addr, - const uint32_t *values, int nvalues); -/* Native-wide HUD corner re-anchoring ([widescreen] nw_hud_corners): push - * outer-third screen-space HUD primitives out to the true wide-frame corners - * (they otherwise sit inset by the reveal). Runtime-only. Off by default. */ -void gpu_ws_set_nw_hud_corners(int on); -/* Targeted alternative for sprite-heavy 2D games: corner-anchor only primitives - * whose ordering-table packet lives in the configured half-open RAM range. */ -void gpu_ws_set_nw_left_hud_packet_range(uint32_t lo, uint32_t hi); -void gpu_ws_begin_linked_list(void); -void gpu_ws_end_linked_list(void); -void gpu_ws_prepass_linked_list(uint32_t start_addr); -/* Native-wide full-frame 2D backdrop stretch ([widescreen] nw_backdrop): - * stretch a screen-space quad that covers the whole 4:3 framebuffer (sky - * gradient / backdrop image) to fill the wide frame, so it no longer - * pillarboxes at the reveal margins. Runtime-only. Off by default. */ -void gpu_ws_set_nw_backdrop(int on); -/* Native-wide flat-polygon backdrop stretch ([widescreen] nw_flat_backdrop): - * stretch untextured primitives in the wide mirror without changing the - * canonical 4:3 framebuffer. Intended for flat-colour sky/water backdrops. */ -void gpu_ws_set_nw_flat_backdrop(int on); -int gpu_ws_nw_flat_backdrop_enabled(void); -/* Stretch the title-opted textured pre-3D backdrop phase in the wide mirror. */ -void gpu_ws_set_nw_phase_backdrop(int on); - -/* Backdrop screen-X correction ([widescreen.backdrop] x_sites). The parallax - * 2D backdrop layer computes screen-X without the GTE, so it misses the - * widescreen squash; the recompiler emits this on each backdrop handler's - * final screenX store to squash it around the screen centre (identity at 4:3). - * Pulls far backdrop pieces in from past the 320px edge to cover the 16:9 FOV. */ -int psx_ws_backdrop_x(int x); -/* CPU-calculated perspective X counterpart to the GTE widescreen squash. - * Exact game sites use this only for final projected screen coordinates. */ -int psx_ws_project_x(int x); -int32_t psx_ws_project_x16(int x, int32_t fraction16); - -/* Backdrop PRELOAD ([widescreen.cull] auto_backdrop). psx_ws_backdrop_preload() - * is nonzero only while native-wide widescreen is engaged (0 at 4:3 / boot / - * FMV / full-2D), so the rewrite is byte-identical when off. psx_ws_backdrop_value() - * is the one value substitution shared by the gcc emit and the - * interpreter for a detected window bound: it returns `orig` unless preload is - * engaged, in which case it forces the bound to preload the WHOLE finite tile - * row (START -> 0, END -> large sentinel pinned by the generator's high clamp to - * extent-1). Every column is submitted, so the widened 16:9 viewport never gaps; - * the generator's clamps + the byte-sized row extent keep it bounded. */ -int psx_ws_backdrop_preload(void); -uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); - -/* auto_backdrop diagnostic ring (always-on). The interpreter records every - * window rewrite (with the live extent / DL count / camera-X); `ws_backdrop_ring` - * dumps it. psx_ws_backdrop_ring_json() formats the recent entries into `buf` - * (returns bytes written, excluding the caller's JSON envelope). */ -void psx_ws_backdrop_ring_note(uint32_t pc, int kind, int wcols, uint32_t orig, - uint32_t finalv, int extent, int camx, int count, - uint32_t base, uint32_t dl); -int psx_ws_backdrop_ring_json(char *buf, int cap); - -/* Live-tunable backdrop widen amount (ws_backdrop_margin command): <0 whole-row, - * 0 off, >0 N-column widen. g_ws_bd_from_interp is the interp's one-shot - * "I will record the rich entry myself" flag (see gpu.c / dirty_ram_interp.c). */ -extern int g_ws_bd_margin; -extern int g_ws_bd_from_interp; - -/* Native-wide: nonzero if the GP0 prim currently being drawn is sprite-tagged - * (a character / Tomba / HUD element). The GL 2D-backdrop stretch uses this to - * EXCLUDE foreground sprites (only the untagged 2D backdrop is stretched). */ -int psx_ws_prim_is_tagged(void); - -/* Flower-field backdrop data-structure address range + a predicate matching the - * prim being drawn against it (gp0_cmd_source_addr ∈ [lo,hi]). The dirty-RAM - * interp sets lo/hi when the backdrop generator runs; the GL stretch gate uses - * psx_ws_prim_in_backdrop() to identify the flower-field tiles precisely. */ -extern uint32_t g_ws_backdrop_lo, g_ws_backdrop_hi; -int psx_ws_prim_in_backdrop(void); - -/* Backdrop store-site registry: the runtime registers the [widescreen.backdrop] - * x_sites here (from game.toml) so the dirty-RAM interpreter applies the same - * psx_ws_backdrop_x() squash at those `sh` PCs that the recompiler emits into - * native cache DLLs — overlay code frequently runs interpreted, where the emit - * can't reach. PCs are masked to the physical (KUSEG) range. */ -void psx_ws_set_backdrop_sites(const uint32_t* pcs, int n); -int psx_ws_is_backdrop_site(uint32_t pc); - -/* Live widescreen state for diagnostics (TCP gpu_state). All pointers - * optional. last_tag_frame/cur_frame let the caller see game_mode freshness. */ -typedef struct { - int configured; /* ws_xnum != ws_xden */ - int active; /* squash currently applied this frame */ - int game_mode; /* tagged char/billboard prim within 2 frames */ - int present_native_43; /* frame presents pillarboxed 4:3 (FMV/full-2D) */ - int x_margin; /* psx_ws_x_margin() right now */ - int activation_margin; /* psx_ws_activation_margin() right now */ - int xnum, xden; /* squash factor */ - int mode; /* 0 = off, 1 = squash, 2 = native-wide */ - int nw_extra; /* native-wide frame growth (display px), 0 if off */ - uint64_t cur_frame; - uint32_t last_tag_frame; /* frame of newest tagged prim */ - uint32_t last_3d_frame; /* frame of newest shaded prim (diagnostic) */ - uint32_t gte_verts; /* RTPS/RTPT verts in the last completed frame */ - uint32_t last_world3d_frame;/* newest SUSTAINED world-scale projection frame */ - uint32_t ovh_prims; /* overhanging polys in the last completed frame */ - uint32_t last_ovh_frame; /* newest SUSTAINED polygon-overhang frame (the - 2D-only-scene classifier's world signal) */ - int auto_ui_squash; /* final-OT grouped UI correction configured */ - int auto_ui_dense; /* current list classified as a dense menu */ - uint32_t auto_ui_ot_rank; /* highest populated UI rank in current list */ - uint64_t auto_ui_candidates; - uint64_t auto_ui_transforms; - uint64_t aspect_cone_calls; - uint64_t aspect_cone_43_identity; - uint64_t aspect_cone_vanilla_keep; - uint64_t aspect_cone_visible_keep; - uint64_t aspect_cone_guard_keep; - uint64_t aspect_cone_hysteresis_keep; - uint64_t aspect_cone_outside_reject; - uint64_t aspect_cone_queue_reject; - uint32_t aspect_cone_queue_highwater[3]; - uint64_t angle_calls; - uint64_t angle_43_identity; - uint32_t angle_max_vanilla; - uint32_t angle_max_widened; -} GpuWsDebug; -void gpu_ws_get_debug(GpuWsDebug* out); - -typedef struct { - uint32_t address; - uint64_t calls; - uint64_t identity_43; - uint64_t vanilla_keep; - uint64_t visible_keep; - uint64_t guard_keep; - uint64_t hysteresis_keep; - uint64_t outside_reject; - uint64_t queue_reject; -} GpuWsAspectConeSiteDebug; -/* Exact-site telemetry for separating actor-list and per-child participation - * predicates. Returns zero when address is not configured. */ -int gpu_ws_get_aspect_cone_site_debug( - uint32_t address, GpuWsAspectConeSiteDebug* out); - -/* Diagnostic: force psx_ws_x_margin() to return v (>=0) regardless of state, - * or -1 to restore the normal computed margin. For live cull-margin sweeps. */ -void gpu_ws_set_margin_override(int v); - -/* Native-wide HUD corner re-anchoring: allow TAGGED rect-family prims to - * shift too (live A/B via TCP ws_hud_mode; some HUD composites render - * through the tagged sprite funnel). */ -void gpu_ws_set_nw_hud_tag_rects(int on); - -/* Always-on draw-census ring: every drawn primitive records frame / source - * addr / camera / first-vertex screen pos, so object spawn/despawn and edge - * culls are observable in data. Dump frames [f0,f1] to a CSV file. */ -int gpu_ws_census_dump(uint32_t f0, uint32_t f1, const char *path); -void gpu_ws_census_set(int on); -uint64_t gpu_ws_census_seq(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PSXRECOMP_GPU_H */ diff --git a/psxrecomp-overlay/runtime/include/gpu_render.h b/psxrecomp-overlay/runtime/include/gpu_render.h deleted file mode 100644 index 6cb1779..0000000 --- a/psxrecomp-overlay/runtime/include/gpu_render.h +++ /dev/null @@ -1,237 +0,0 @@ -#ifndef PSX_GPU_RENDER_H -#define PSX_GPU_RENDER_H - -/* Renderer facade. gpu.c (command processing) and main.cpp (present) call - * these gr_* entry points instead of a specific backend. The facade - * dispatches to a selected backend: the software rasterizer (gpu_sw_renderer.c, - * the default + fallback) or a hardware OpenGL backend (gpu_gl_renderer.c). - * - * The gr_* signatures mirror the software renderer's interface exactly, so a - * backend is just a table of the same functions. Select the backend with - * gr_set_backend() BEFORE gr_init(); gr_backend() reports the EFFECTIVE backend - * (a requested backend that fails to initialize falls back to software). */ - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -typedef enum { - GR_BACKEND_SOFTWARE = 0, - GR_BACKEND_OPENGL = 1, - GR_BACKEND_VULKAN = 2 -} GrBackend; - -void gr_set_backend(GrBackend backend); /* call before gr_init() */ -GrBackend gr_backend(void); /* effective backend after init */ - -/* Lifecycle / global state */ -void gr_init(uint16_t *vram); -void gr_set_scale(int scale); -int gr_scale(void); -void gr_set_texture_filter(int bilinear); -int gr_texture_filter(void); - -/* Per-primitive draw state */ -void gr_set_semi_transparency(int enabled, int mode); -void gr_set_mask_bits(int set_bit, int check_bit); -void gr_set_texture_window(uint32_t raw); -void gr_set_color_modulation(int r, int g, int b, int raw_texture); - -/* Optional presentation-only geometry correction. The authoritative PSX - * framebuffer is always rasterized from the integer GP0 coordinates. A - * backend may use the precise 16.16 coordinates and fractional camera yaw - * only while mirroring the primitive into a separate display surface. */ -void gr_set_geometry_correction(int enabled); -void gr_set_world_triangle(int world); -void gr_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2); -/* One-shot presentation positions of a triangle without exact provenance: where its pinned corners project. */ -void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); -/* One-shot reciprocal-depth metadata for the next textured triangle. Only a - * backend with a separate presentation surface should implement this; the - * authoritative PS1 framebuffer must retain affine texture interpolation. */ -void gr_set_perspective_triangle(int enabled, - float q0, float q1, float q2); -/* One-shot unnormalized GTE camera depth for temporal vertex identity. This - * metadata never reaches the shaders and is independent of perspective q, - * whose scale can differ between polygons. Zero means unknown depth. */ -void gr_set_temporal_depth_triangle(int enabled, - float z0, float z1, float z2); -/* One-shot camera depth of the same corners for the display mirror's own depth: a corner the game pinned has one here, - * where the temporal depth above says 0. Zero means unknown. */ -void gr_set_surface_depth_triangle(float z0, float z1, float z2); -/* One-shot GTE input vertices of the same three corners (zero = unknown). */ -void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2); -/* The next textured rectangle is a world sprite at this camera depth (0 = none). */ -void gr_set_temporal_sprite(float depth, const float sides[4]); -/* center_y is relative to the active drawing band; a backend which stores - * absolute VRAM positions must combine it with its current draw offset. */ -void gr_set_presentation_yaw(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x); - -/* Primitives */ -void gr_fill_rect(int x, int y, int w, int h, uint16_t color); -void gr_copy_rect(int src_x, int src_y, int dst_x, int dst_y, int w, int h); -void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, - uint16_t color); -void gr_draw_gouraud_triangle(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2); -void gr_draw_textured_triangle(int x0, int y0, int u0, int v0, - int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void gr_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, - uint32_t color0, - int x1, int y1, int u1, int v1, - uint32_t color1, - int x2, int y2, int u2, int v2, - uint32_t color2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw_texture); -void gr_draw_flat_rect(int x, int y, int w, int h, uint16_t color); -void gr_draw_textured_rect(int x, int y, int w, int h, - int u, int v, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void gr_draw_textured_rect_scaled(int x, int y, int w, int h, - int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void gr_draw_line(int x0, int y0, int x1, int y1, uint16_t color); -void gr_draw_shaded_line(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1); - -/* Display readout (present path) */ -int gr_render_display(uint32_t *out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); -int gr_render_display_hires(uint32_t *out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); - -/* VRAM transfers */ -void gr_vram_write(int x, int y, uint16_t pixel); -uint16_t gr_vram_read(int x, int y); -void gr_vram_transfer_in(int x, int y, int w, int h, const uint16_t *data); -void gr_vram_transfer_out(int x, int y, int w, int h, uint16_t *data); - -/* Draw area / offset */ -void gr_set_draw_area(int x1, int y1, int x2, int y2); -void gr_get_draw_area(int *x1, int *y1, int *x2, int *y2); -void gr_set_draw_offset(int x, int y); - -/* Native-wide compositor. gr_wide_supported() reports whether the active - * backend implements it; if not, the others are no-ops and the caller keeps - * the canonical present path. */ -int gr_wide_supported(void); -void gr_wide_configure(int wide_w, int offset); -void gr_wide_set_target(int base_x); -void gr_wide_disable_target(void); -void gr_wide_clear(int base_x, int y, int h, uint16_t color); -/* sides: bit 0 = left synthetic margin, bit 1 = right. */ -void gr_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides); -int gr_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x); -int gr_render_wide_display(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h); - -/* ---- Backend vtable ----------------------------------------------------- - * A backend supplies the same set of functions. gpu_gl_renderer.c (Phase 2) - * provides gl_backend_get(); until it does, requesting OpenGL falls back to - * software. Members mirror the gr_* / sw_* signatures 1:1. */ -typedef struct GpuRenderBackend { - const char *name; - void (*init)(uint16_t *vram); - void (*set_scale)(int scale); - int (*scale)(void); - void (*set_texture_filter)(int bilinear); - int (*texture_filter)(void); - void (*set_semi_transparency)(int enabled, int mode); - void (*set_mask_bits)(int set_bit, int check_bit); - void (*set_texture_window)(uint32_t raw); - void (*set_color_modulation)(int r, int g, int b, int raw_texture); - void (*set_geometry_correction)(int enabled); - void (*set_world_triangle)(int world); - void (*set_precise_triangle)(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2); - void (*set_perspective_triangle)(int enabled, - float q0, float q1, float q2); - void (*set_temporal_depth_triangle)(int enabled, - float z0, float z1, float z2); - void (*set_surface_depth_triangle)(float z0, float z1, float z2); - void (*set_temporal_identity_triangle)(uint64_t id0, uint64_t id1, uint64_t id2); - void (*set_temporal_sprite)(float depth, const float sides[4]); - void (*set_presentation_yaw)(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x); - void (*fill_rect)(int x, int y, int w, int h, uint16_t color); - void (*copy_rect)(int src_x, int src_y, int dst_x, int dst_y, int w, int h); - void (*draw_flat_triangle)(int x0, int y0, int x1, int y1, int x2, int y2, - uint16_t color); - void (*draw_gouraud_triangle)(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2); - void (*draw_textured_triangle)(int x0, int y0, int u0, int v0, - int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); - void (*draw_shaded_textured_triangle)(int x0, int y0, int u0, int v0, - uint32_t color0, - int x1, int y1, int u1, int v1, - uint32_t color1, - int x2, int y2, int u2, int v2, - uint32_t color2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw_texture); - void (*draw_flat_rect)(int x, int y, int w, int h, uint16_t color); - void (*draw_textured_rect)(int x, int y, int w, int h, int u, int v, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); - void (*draw_textured_rect_scaled)(int x, int y, int w, int h, - int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); - void (*draw_line)(int x0, int y0, int x1, int y1, uint16_t color); - void (*draw_shaded_line)(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1); - int (*render_display)(uint32_t *out, int pitch, - int dx, int dy, int dw, int dh); - int (*render_display_hires)(uint32_t *out, int pitch, - int dx, int dy, int dw, int dh); - void (*vram_write)(int x, int y, uint16_t pixel); - uint16_t (*vram_read)(int x, int y); - void (*vram_transfer_in)(int x, int y, int w, int h, const uint16_t *data); - void (*vram_transfer_out)(int x, int y, int w, int h, uint16_t *data); - void (*set_draw_area)(int x1, int y1, int x2, int y2); - void (*get_draw_area)(int *x1, int *y1, int *x2, int *y2); - void (*set_draw_offset)(int x, int y); - /* Native-wide compositor (optional; NULL on backends without it — the - * facade then reports gr_wide_supported() == 0 and the caller keeps the - * canonical present). */ - void (*wide_configure)(int wide_w, int offset); - void (*wide_set_target)(int base_x); - void (*wide_disable_target)(void); - void (*wide_clear)(int base_x, int y, int h, uint16_t color); - void (*wide_clear_margins)(int base_x, int y, int h, uint16_t color, int sides); - int (*render_wide_display)(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h); - /* Dump the ENTIRE wide compositor surface for base_x (all double-buffer - * bands + both margins), g_wide_w x VRAM_H. Debug/inspection only; returns - * pixel count, writes width/height to ow/oh. NULL if unsupported. */ - int (*wide_dump_full)(uint32_t *out, int cap_pixels, int *ow, int *oh, - int base_x); - void (*set_unpinned_triangle)(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2); -} GpuRenderBackend; - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_GPU_RENDER_H */ diff --git a/psxrecomp-overlay/runtime/include/gpu_sw_renderer.h b/psxrecomp-overlay/runtime/include/gpu_sw_renderer.h deleted file mode 100644 index 193ecbd..0000000 --- a/psxrecomp-overlay/runtime/include/gpu_sw_renderer.h +++ /dev/null @@ -1,130 +0,0 @@ -#ifndef PSX_GPU_SW_RENDERER_H -#define PSX_GPU_SW_RENDERER_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Maximum internal-resolution supersampling factor (linear, per axis). */ -#define SW_MAX_INTERNAL_SCALE 8 - -/* Initialize software renderer */ -void sw_renderer_init(uint16_t* vram); - -/* Internal-resolution supersampling (SSAA). - * scale == 1 : disabled, renderer behaves exactly as native VRAM only. - * scale > 1 : maintain an S*-scaled mirror of VRAM; the display reads the - * mirror (sw_render_display_hires) and the present path - * downsamples to the window. Clamped to SW_MAX_INTERNAL_SCALE. - * Must be called once after sw_renderer_init, before drawing begins. */ -void sw_renderer_set_scale(int scale); -int sw_renderer_scale(void); - -/* Visual-only high-resolution vertex override for the next triangle. Values - * are signed 16.16 screen coordinates; native VRAM still uses the integer GP0 - * positions, while the supersampled mirror consumes these fractions. */ -void sw_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2); - -/* Perspective-correct UV override for the next triangle. q values are the - * homogeneous interpolation weights associated with each screen vertex. */ -void sw_set_perspective_triangle(int enabled, - float q0, float q1, float q2); -uint32_t sw_perspective_triangle_count(void); - -/* Texture filtering. 0 = nearest (native PSX look, default), 1 = bilinear - * (smooths textures/2D backgrounds; blends in RGB after the CLUT lookup, - * transparency-aware). Independent of supersampling. */ -void sw_set_texture_filter(int bilinear); -int sw_texture_filter(void); - -/* Draw state — must be set by gpu.c before each primitive */ -void sw_set_semi_transparency(int enabled, int mode); -void sw_set_mask_bits(int set_bit, int check_bit); -void sw_set_texture_window(uint32_t raw); -void sw_set_color_modulation(int r, int g, int b, int raw_texture); - -/* Drawing primitives — called from gpu.c command processing */ -void sw_fill_rect(int x, int y, int w, int h, uint16_t color); -void sw_copy_rect(int src_x, int src_y, int dst_x, int dst_y, int w, int h); -void sw_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, - uint16_t color); -void sw_draw_gouraud_triangle(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2); -void sw_draw_textured_triangle(int x0, int y0, int u0, int v0, - int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void sw_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, - uint32_t color0, - int x1, int y1, int u1, int v1, - uint32_t color1, - int x2, int y2, int u2, int v2, - uint32_t color2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw_texture); -void sw_draw_flat_rect(int x, int y, int w, int h, uint16_t color); -void sw_draw_textured_rect(int x, int y, int w, int h, - int u, int v, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void sw_draw_textured_rect_scaled(int x, int y, int w, int h, - int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage); -void sw_draw_line(int x0, int y0, int x1, int y1, uint16_t color); -void sw_draw_shaded_line(int x0, int y0, uint16_t c0, - int x1, int y1, uint16_t c1); - -/* Copy display region to a 32-bit RGBA buffer for SDL presentation. - Returns number of pixels written. */ -int sw_render_display(uint32_t* out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); - -/* Like sw_render_display, but reads the hi-res mirror and emits the region - at (disp_w*scale x disp_h*scale). Falls back to sw_render_display when - supersampling is disabled. Returns number of pixels written. */ -int sw_render_display_hires(uint32_t* out_pixels, int out_pitch, - int disp_x, int disp_y, int disp_w, int disp_h); - -/* VRAM transfer (CPU->VRAM, VRAM->CPU) */ -void sw_vram_write(int x, int y, uint16_t pixel); -uint16_t sw_vram_read(int x, int y); -void sw_vram_transfer_in(int x, int y, int w, int h, const uint16_t* data); -void sw_vram_transfer_out(int x, int y, int w, int h, uint16_t* data); - -/* Set draw area and draw offset */ -void sw_set_draw_area(int x1, int y1, int x2, int y2); -void sw_get_draw_area(int *x1, int *y1, int *x2, int *y2); -void sw_set_draw_offset(int x, int y); - -/* Native-wide compositor (see gpu_sw_renderer.c). Canonical VRAM stays faithful; - * framebuffer draws are mirrored into independent wide surfaces keyed by buffer - * base_x. wide_w<=0 disables. set_target selects/clears the mirror per back - * buffer; render_wide_display is the present source for the displayed buffer. */ -void sw_wide_configure(int wide_w, int offset); -void sw_wide_set_target(int base_x); -void sw_wide_disable_target(void); -void sw_wide_clear(int base_x, int y, int h, uint16_t color); -void sw_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides); -/* MMX6 native-wide reveal-column tile: rasterize a 16x16 textured tile into the - * wide surface ONLY (canonical VRAM + hi-res mirror untouched). See definition. */ -void sw_wide_emit_tile(int band_y, int x, int y, int u, int v, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage, - uint32_t color24, int semi_trans, int semi_mode, int raw_texture); -int sw_render_wide_display(uint32_t* out_pixels, int out_pitch, int base_x, - int disp_y, int disp_h); -int sw_wide_dump_full(uint32_t* out, int cap_pixels, int* ow, int* oh, int base_x); -int sw_wide_width(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_GPU_SW_RENDERER_H */ diff --git a/psxrecomp-overlay/runtime/include/mod_memory.h b/psxrecomp-overlay/runtime/include/mod_memory.h deleted file mode 100644 index c5a5c3f..0000000 --- a/psxrecomp-overlay/runtime/include/mod_memory.h +++ /dev/null @@ -1,56 +0,0 @@ -#ifndef PSXRECOMP_MOD_MEMORY_H -#define PSXRECOMP_MOD_MEMORY_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* - * GPU linked-list tags carry only 24 address bits. Trusted enhancements that - * need a larger primitive arena use this otherwise-unmapped 1 MiB aperture so - * their CPU pointers survive the tag truncation. Nothing is mapped until an - * enhancement explicitly allocates from it. - */ -#define PSX_MOD_GPU_DMA_APERTURE_BASE 0x00F00000u -#define PSX_MOD_GPU_DMA_APERTURE_SIZE (1u * 1024u * 1024u) -#define PSX_MOD_GPU_DMA_GUEST_BASE 0x80F00000u - -static inline int psx_mod_gpu_dma_aperture_offset_for( - uint32_t address, uint32_t width, uint32_t used, uint32_t *offset) { - uint32_t canonical = address & 0x00FFFFFFu; - uint32_t off; - if (canonical < PSX_MOD_GPU_DMA_APERTURE_BASE) return 0; - off = canonical - PSX_MOD_GPU_DMA_APERTURE_BASE; - if (off > used || width > used - off) return 0; - if (offset) *offset = off; - return 1; -} - -/* - * Preserve the retail DMAC's 2 MiB folding unless the address is inside the - * portion of the enhancement aperture that has actually been allocated. - */ -static inline uint32_t psx_mod_gpu_dma_resolve_address_for( - uint32_t address, uint32_t used) { - uint32_t canonical = address & 0x00FFFFFCu; - if (psx_mod_gpu_dma_aperture_offset_for( - canonical, 4u, used, (uint32_t *)0)) - return canonical; - return canonical & 0x001FFFFCu; -} - -uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment); -uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address); -/* Snapshot payload is the allocated byte prefix; restoring its length also - * restores the allocation boundary needed by CPU and 24-bit GPU accesses. */ -uint32_t psx_mod_gpu_dma_memory_bytes(void); -const uint8_t *psx_mod_gpu_dma_memory_data(void); -int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc b/psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc deleted file mode 100644 index ea2e5c7..0000000 --- a/psxrecomp-overlay/runtime/include/overlay_dispatch_preamble.c.inc +++ /dev/null @@ -1,281 +0,0 @@ -#include "overlay_api.h" - -/* ---- Overlay dispatch shim (inserted by compile_overlays.py) ----------- */ -static OverlayCallbacks g_cbs; -static uint32_t s_pending_cycles; - -#ifdef _WIN32 -__declspec(dllexport) -#else -__attribute__((visibility("default"))) -#endif -void overlay_flush_cycles(void) { - uint32_t cycles = s_pending_cycles; - s_pending_cycles = 0; - if (cycles && g_cbs.advance_cycles) g_cbs.advance_cycles(cycles); -} - -/* Call-contract state (ABI v2): generated code reads/bumps the runtime's - * bail state through these pointers (cpu_state.h PSX_OVERLAY_DLL_BUILD - * branch). They start at local dummies so a pre-init call can't fault. */ -static int s_bail_dummy; -static uint64_t s_ctr_dummy0, s_ctr_dummy1; -int *g_psx_call_bail_p = &s_bail_dummy; -uint64_t *g_psx_bail_first_p = &s_ctr_dummy0; -uint64_t *g_psx_bail_resolved_p = &s_ctr_dummy1; - -#ifdef _WIN32 -__declspec(dllexport) -#else -__attribute__((visibility("default"))) -#endif -int overlay_abi(void) { return PSX_OVERLAY_ABI_VERSION | (PSX_OVERLAY_FLAVOR << 16); } - -#ifdef _WIN32 -__declspec(dllexport) -#else -__attribute__((visibility("default"))) -#endif -void overlay_init(const OverlayCallbacks *cbs) { - g_cbs = *cbs; - if (g_cbs.call_bail_flag) g_psx_call_bail_p = g_cbs.call_bail_flag; - if (g_cbs.bail_first) g_psx_bail_first_p = g_cbs.bail_first; - if (g_cbs.bail_resolved) g_psx_bail_resolved_p = g_cbs.bail_resolved; -} - -void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra) { - overlay_flush_cycles(); - (void)ra; - g_cbs.dispatch_call(cpu, addr, cpu->gpr[31]); -} -void psx_check_interrupts(CPUState *cpu) { - overlay_flush_cycles(); - g_cbs.check_interrupts(cpu); -} -void psx_check_interrupts_at(CPUState *cpu, uint32_t resume_pc) { - overlay_flush_cycles(); - if (g_cbs.check_interrupts_at) g_cbs.check_interrupts_at(cpu, resume_pc); - else g_cbs.check_interrupts(cpu); -} -void psx_advance_cycles(uint32_t cycles) { - if (cycles > UINT32_MAX - s_pending_cycles) overlay_flush_cycles(); - s_pending_cycles += cycles; -} -void gte_execute(CPUState *cpu, uint32_t cmd) { - overlay_flush_cycles(); - g_cbs.gte_execute(cpu, cmd); -} -int psx_syscall(CPUState *cpu, uint32_t code) { - overlay_flush_cycles(); - return g_cbs.psx_syscall(cpu, code); -} -void psx_native_bad_entry(CPUState *cpu, uint32_t owner, uint32_t pc) { - overlay_flush_cycles(); - if (g_cbs.psx_native_bad_entry) g_cbs.psx_native_bad_entry(cpu, owner, pc); -} -void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys) { - overlay_flush_cycles(); - g_cbs.psx_unknown_dispatch(cpu, addr, phys); -} -void debug_server_log_call_entry(uint32_t func_addr) { - if (g_cbs.log_call_entry) g_cbs.log_call_entry(func_addr); -} -void psx_restore_state_escape(void) { - overlay_flush_cycles(); - if (g_cbs.psx_restore_state_escape) g_cbs.psx_restore_state_escape(); -} -/* RFE escape marker (ABI v12): emitted at every `rfe`, including in - * overlay-resident exception-context code. Mutates the runtime's shared - * RFE-pending state, so it MUST forward (a local no-op would swallow - * exception returns). Unconditional — v12 hosts always supply it; the - * ABI gate rejects mixes that don't. */ -void psx_rfe_mark_escape(void) { - overlay_flush_cycles(); - g_cbs.rfe_mark_escape(); -} -/* Faithful-timing functions (ABI v9): overlay code built with PSX_ENABLE_BLOCK_CYCLES - * emits these; forward to the runtime's real impls so native overlays charge cycles on - * the SAME timeline as the interp/BIOS (a local copy would diverge). NULL-guarded so a - * v9 DLL stays safe on a host that predates a given callback. */ -uint32_t psx_cyc_load_word(CPUState *cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - overlay_flush_cycles(); - return g_cbs.cyc_load_word ? g_cbs.cyc_load_word(cpu, addr, rt, reg_mask) : cpu->read_word(addr); -} -uint16_t psx_cyc_load_half(CPUState *cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - overlay_flush_cycles(); - return g_cbs.cyc_load_half ? g_cbs.cyc_load_half(cpu, addr, rt, reg_mask) : cpu->read_half(addr); -} -uint8_t psx_cyc_load_byte(CPUState *cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - overlay_flush_cycles(); - return g_cbs.cyc_load_byte ? g_cbs.cyc_load_byte(cpu, addr, rt, reg_mask) : cpu->read_byte(addr); -} -uint32_t psx_cyc_lwc2_read(CPUState *cpu, uint32_t addr) { - overlay_flush_cycles(); - return g_cbs.cyc_lwc2_read ? g_cbs.cyc_lwc2_read(cpu, addr) : cpu->read_word(addr); -} -void psx_icache_fetch(CPUState *cpu, uint32_t addr) { - overlay_flush_cycles(); - if (g_cbs.icache_fetch) g_cbs.icache_fetch(cpu, addr); -} -void psx_muldiv_set(CPUState *cpu, uint32_t latency) { - overlay_flush_cycles(); - if (g_cbs.muldiv_set) g_cbs.muldiv_set(cpu, latency); -} -void psx_muldiv_stall(CPUState *cpu) { - overlay_flush_cycles(); - if (g_cbs.muldiv_stall) g_cbs.muldiv_stall(cpu); -} -uint32_t psx_mult_latency_s(uint32_t rs) { - return g_cbs.mult_latency_s ? g_cbs.mult_latency_s(rs) : 0u; -} -uint32_t psx_mult_latency_u(uint32_t rs) { - return g_cbs.mult_latency_u ? g_cbs.mult_latency_u(rs) : 0u; -} -void psx_gte_stall(CPUState *cpu) { - overlay_flush_cycles(); - if (g_cbs.gte_stall) g_cbs.gte_stall(cpu); -} -void psx_gte_read(CPUState *cpu, uint32_t rt) { - overlay_flush_cycles(); - if (g_cbs.gte_read) g_cbs.gte_read(cpu, rt); -} -int psx_slice_block(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_effects) { - overlay_flush_cycles(); - return g_cbs.slice_block ? g_cbs.slice_block(cpu, block_addr, bcyc, side_effects) : 0; -} -/* GTE special-register accessors (ABI v10). The emitter routes masked, - * sign-extended, aliased/FIFO, read-only, precision-sensitive, flag/IR, and - * derived registers through these as DIRECT calls, so any - * GTE-heavy overlay (Tomba2 lava / Trolley attract demos) fails to link - * without them — undefined gte_read_ctrl was the signature that silently - * blocked all new overlay coverage. GTE state is SHARED runtime state; a - * local fallback would fork that timeline, so these forward - * unconditionally (ABI v10 hosts always supply them; the v10 gate rejects - * DLL/host mixes that don't). - * Proven bit-transparent registers retain direct CPUState-array fast paths; - * the shared emitter classification header is the source of truth. */ -uint32_t gte_read_data(CPUState *cpu, uint8_t reg) { - overlay_flush_cycles(); - return g_cbs.gte_read_data(cpu, reg); -} -uint32_t gte_read_ctrl(CPUState *cpu, uint8_t reg) { - overlay_flush_cycles(); - return g_cbs.gte_read_ctrl(cpu, reg); -} -void gte_write_data(CPUState *cpu, uint8_t reg, uint32_t value) { - overlay_flush_cycles(); - g_cbs.gte_write_data(cpu, reg, value); -} -void gte_write_ctrl(CPUState *cpu, uint8_t reg, uint32_t value) { - overlay_flush_cycles(); - g_cbs.gte_write_ctrl(cpu, reg, value); -} -/* g_debug_last_store_pc: a provenance breadcrumb the emitter writes before stores. - * Overlays are built PSX_NO_DEBUG_TOOLS (the runtime's only consumer is debug tooling), - * so a local definition satisfies the link; overlay-store provenance is simply not - * tracked in a no-debug-tools build (by design). */ -uint32_t g_debug_last_store_pc; -/* Widescreen hooks (ABI v3): forward to the runtime's live widescreen state. - * Identity fallback if the host predates the callbacks (NULL) — so a v3 DLL - * stays correct (4:3) on an older host. */ -int psx_ws_backdrop_x(int x) { - return g_cbs.ws_backdrop_x ? g_cbs.ws_backdrop_x(x) : (int)(short)x; -} -int psx_ws_x_margin(void) { - return g_cbs.ws_x_margin ? g_cbs.ws_x_margin() : 0; -} -/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x). - * The recompiler emits `rt = psx_ws_cull_sltiu(sx, imm)` at every width-compare - * cull site, INCLUDING in overlay-resident code, so the overlay DLL must define - * it or it fails to link (undefined reference) -> no native shard -> that overlay - * variant runs interpreted forever (the dwarf-village-style lag). Self-contained: - * depends only on psx_ws_x_margin() above. Identity at 4:3 (margin 0). MUST stay - * byte-for-byte identical to gpu.c psx_ws_cull_sltiu. */ -int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm) { - int m = psx_ws_x_margin(); - return ((uint32_t)((int32_t)(int16_t)(uint16_t)sx + m) - < (uint32_t)((int32_t)imm + 2 * m)) ? 1 : 0; -} -/* Signed-funnel variants (min/max + center±halfwidth idioms). Same contract: - * self-contained, identity at margin 0, MUST stay byte-for-byte identical to - * the gpu.c implementations. */ -int psx_ws_cull_slti(uint32_t sx, uint32_t imm) { - return ((int32_t)sx < (int32_t)imm + psx_ws_x_margin()) ? 1 : 0; -} -int32_t psx_ws_cull_lower_value(int32_t vanilla) { - return vanilla - psx_ws_x_margin(); -} -int32_t psx_ws_cull_upper_value(int32_t vanilla) { - return vanilla + psx_ws_x_margin(); -} -uint32_t psx_ws_cull_span_value(uint32_t vanilla) { - int32_t margin = psx_ws_x_margin(); - int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); - uint32_t right = vanilla & 0xFFFFu; - return ((uint32_t)(uint16_t)(left - margin) << 16) | - (uint32_t)(uint16_t)((int32_t)right + margin); -} -int psx_ws_cull_bltz(uint32_t v) { - return ((int32_t)v < -psx_ws_x_margin()) ? 1 : 0; -} -int psx_ws_cull_vxrange(uint32_t x, uint32_t imm) { - int32_t margin = psx_ws_x_margin(); - uint32_t bound = (uint32_t)(int32_t)(int16_t)(uint16_t)imm; - return (((x + (uint32_t)margin) & 0xFFFFu) < - (bound + 2u * (uint32_t)margin)) ? 1 : 0; -} -void psx_ws_sprite_tag(CPUState *cpu) { - if (g_cbs.ws_sprite_tag) g_cbs.ws_sprite_tag(cpu); -} -/* Widescreen backdrop column-preload value (ABI v4). Identity fallback if the - * host predates the callback, so the DLL stays correct (4:3) on an older host. */ -uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols) { - return g_cbs.ws_backdrop_value ? g_cbs.ws_backdrop_value(orig, is_end, window_cols) : orig; -} -/* Typed native-wide signed player-X bound (ABI v13). The emitter rewrites a - * configured [widescreen] signed_x_bound LUI site into - * psx_ws_player_x_bound(vanilla) (Einhander player bounds), including in - * overlay-resident code, so the DLL must define it or the shard fails to link - * (undefined reference — the rfe/gte_read_ctrl class). Computes against the - * host's live widescreen state; identity fallback (the vanilla constant, i.e. - * 4:3 behavior) if the host predates the callback. */ -int32_t psx_ws_player_x_bound(int32_t vanilla) { - return g_cbs.ws_player_x_bound ? g_cbs.ws_player_x_bound(vanilla) : vanilla; -} -int32_t psx_ws_depth_bound(int32_t imm) { - return g_cbs.ws_depth_bound ? g_cbs.ws_depth_bound(imm) : imm; -} -int32_t psx_ws_plane_nx(int32_t nx) { - return g_cbs.ws_plane_nx ? g_cbs.ws_plane_nx(nx) : nx; -} -uint32_t psx_ws_xclip_bound(uint32_t vanilla) { - return g_cbs.ws_xclip_bound ? g_cbs.ws_xclip_bound(vanilla) : vanilla; -} -uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { - return g_cbs.ws_cull_keep_result - ? g_cbs.ws_cull_keep_result(vanilla, forced) - : vanilla; -} -uint32_t psx_ws_aspect_cone_result(uint32_t site, uint32_t vanilla, - uint32_t object, int32_t x, int32_t z, - int32_t y) { - return g_cbs.ws_aspect_cone_result - ? g_cbs.ws_aspect_cone_result(site, vanilla, object, x, z, y) - : vanilla; -} -uint32_t psx_ws_angle_widen(uint32_t vanilla) { - return g_cbs.ws_angle_widen - ? g_cbs.ws_angle_widen(vanilla) - : vanilla; -} -/* Sub-pixel GTE precision-store tracker (ABI v14). The emitter emits a direct - * gte_precision_store_word() call for every swc2 (GTE store-word), including in - * overlay-resident code, so the DLL must define it or the shard fails to link - * (undefined reference — the rfe/gte_read_ctrl class). Forwards to the host's - * stateful tracker (gte.cpp); no-op if the host predates the callback - * (precision tracking is a widescreen enhancement, off by default — 4:3 - * behavior is unaffected). */ -void gte_precision_store_word(uint32_t addr, uint8_t reg) { - if (g_cbs.gte_precision_store_word) g_cbs.gte_precision_store_word(addr, reg); -} -/* ----------------------------------------------------------------------- */ diff --git a/psxrecomp-overlay/runtime/include/psx_cyc.h b/psxrecomp-overlay/runtime/include/psx_cyc.h deleted file mode 100644 index 30ef672..0000000 --- a/psxrecomp-overlay/runtime/include/psx_cyc.h +++ /dev/null @@ -1,286 +0,0 @@ -/* psx_cyc.h — R3000A load-delay pipeline interlock (ReadAbsorb / ReadFudge / - * LDAbsorb / LDWhich). TIMING ONLY — the load VALUE delay is handled by the - * existing load-delay correctness path; this models only the CPU cycle cost. - * - * FAITHFUL_TIMING_PLAN.md / accuracy/load_readfudge_ldabsorb.md. Transcribed - * (facts, not code) from the in-tree Beetle oracle - * psxrecomp/beetle-psx/mednafen/psx/cpu.cpp (RunReal §1 base 795-798, GPR_DEPRES - * 702-705, DO_LDS 800, ReadMemory 364-451) and libretro.cpp (main-RAM read wait - * = +3, libretro.cpp:884). Every backend — the dirty-RAM interpreter and BOTH - * static emitters (code_generator.cpp, full_function_emitter.cpp/strict_translator.cpp) - * — accounts guest CPU cycles THROUGH THESE HELPERS so they can never disagree. - * - * Model (per retired instruction, in program order): - * §1 base : if(ReadAbsorb[ReadAbsorbWhich]) ReadAbsorb[ReadAbsorbWhich]--; - * else timestamp++; -- a pending load's give-back is - * consumed INSTEAD of the +1 base (pipeline write-back overlap). - * deps : for each GPR this opcode reads/writes, ReadAbsorb[reg]=0 (ends a - * give-back when the loaded value is used). ReadAbsorb[0] preserved. - * DO_LDS : commit the PREVIOUS instruction's pending load TIMING: - * ReadAbsorb[LDWhich]=LDAbsorb; ReadFudge=LDWhich; ...; LDWhich=0x20. - * load only : ReadMemory — clear the current give-back slot, charge the +2 fudge - * iff the predecessor committed no load (ReadFudge==0x20), charge the - * region wait (main RAM +3) + completion (+2 CPU / +1 LWC2); the - * (region+completion) becomes this load's LDAbsorb give-back, armed on - * LDWhich=rt for the NEXT instruction's DO_LDS to commit. - */ -#ifndef PSX_CYC_H -#define PSX_CYC_H - -#include -#include -#if defined(_MSC_VER) -#include /* MSVC intrinsics: _BitScanForward (no __builtin_ctz) */ -#endif -#include "cpu_state.h" /* CPUState (guard-safe: cpu_state.h includes us last) */ -#include "psx_cycles.h" /* inline psx_advance_cycles */ - -#ifdef __cplusplus -extern "C" { -#endif - -/* Load-charge batching (MotK VLC): cache the nearer of the next device - * deadline or a 64-cycle soft publication limit once per batch. Generated - * GCC/Clang blocks may defer to their IRQ edge; MMIO also flushes. Pipeline - * state still updates per instruction, and guest totals at barriers match. */ -enum { PSX_CYC_BATCH_SOFT = 64u }; - -#if defined(PSX_OVERLAY_DLL_BUILD) -/* The overlay shim already batches cycle charges until a host callback edge. */ -static inline void psx_cyc_bb_defer_begin(void) { } -static inline void psx_cyc_bb_defer_end(void) { } -static inline void psx_cyc_bb_defer_flush(void) { overlay_flush_cycles(); } -#else -static inline void psx_cyc_bb_defer_begin(void) { g_psx_cyc_bb_defer++; } -static inline void psx_cyc_bb_defer_end(void) { - if (g_psx_cyc_bb_defer > 0) g_psx_cyc_bb_defer--; - if (g_psx_cyc_bb_defer == 0) psx_cyc_batch_flush(); -} -static inline void psx_cyc_bb_defer_flush(void) { psx_cyc_batch_flush(); } -#endif - -#if defined(__GNUC__) || defined(__clang__) -static inline void psx_cyc_bb_defer_cleanup(int *guard) { - (void)guard; - psx_cyc_bb_defer_end(); -} -#endif - -#if !defined(PSX_OVERLAY_DLL_BUILD) -/* Enhancement-only guest CPU overclock: instruction cycles are charged at - * 1 / (1 << shift). Zero keeps the faithful cost. */ -extern uint32_t g_psx_cyc_overclock_shift; -extern uint32_t g_psx_cyc_overclock_carry; -#endif - -static inline void psx_cyc_charge(uint32_t cycles) { - if (cycles == 0u) return; -#if defined(PSX_OVERLAY_DLL_BUILD) - psx_advance_cycles(cycles); -#else - if (g_psx_cyc_overclock_shift) { - const uint32_t total = g_psx_cyc_overclock_carry + cycles; - g_psx_cyc_overclock_carry = - total & ((1u << g_psx_cyc_overclock_shift) - 1u); - cycles = total >> g_psx_cyc_overclock_shift; - if (cycles == 0u) return; - } -#if defined(__GNUC__) || defined(__clang__) - if (__builtin_expect(g_ls_replay_active | g_event_step_conservative, 0)) { -#else - if (g_ls_replay_active || g_event_step_conservative) { -#endif - psx_advance_cycles(cycles); - return; - } - if (psx_in_device_service) { - psx_cycle_count += (uint64_t)cycles; - return; - } -#if !defined(PSX_COSIM) - { - uint32_t prior = g_psx_cyc_batch; - uint32_t sum = prior + cycles; - if (sum >= prior) { /* no uint32 wrap */ - if (g_psx_cyc_bb_defer > 0) { - g_psx_cyc_batch = sum; - return; - } - if (prior == 0u) { - uint64_t room = 0u; - if (psx_next_service_cycle > psx_cycle_count) - room = psx_next_service_cycle - psx_cycle_count; - g_psx_cyc_batch_limit = room == 0u ? 1u : - (room < (uint64_t)PSX_CYC_BATCH_SOFT - ? (uint32_t)room : (uint32_t)PSX_CYC_BATCH_SOFT); - } - if (sum < g_psx_cyc_batch_limit) { - g_psx_cyc_batch = sum; - return; - } - } - } -#endif - psx_advance_cycles(cycles); /* publishes any pending batch first */ -#endif -} - -/* §1 base (Beetle cpu.cpp:795-798). */ -static inline void psx_cyc_base(CPUState* cpu) { - uint8_t w = cpu->read_absorb_which; - if (cpu->read_absorb[w]) cpu->read_absorb[w]--; - else psx_cyc_charge(1u); -} - -/* GPR_DEPRES (Beetle cpu.cpp:702-705): zero ReadAbsorb[n] for every source/dest - * GPR of this instruction, preserving ReadAbsorb[0] (skipping bit 0 == Beetle's - * save/restore of ReadAbsorb[0]). */ -static inline void psx_cyc_deps(CPUState* cpu, uint32_t reg_mask) { - reg_mask &= 0xFFFFFFFEu; /* never touch ReadAbsorb[0] */ - if (reg_mask && (reg_mask & (reg_mask - 1u)) == 0u) { -#if defined(_MSC_VER) - unsigned long n; - _BitScanForward(&n, reg_mask); - cpu->read_absorb[(unsigned)n] = 0u; -#else - cpu->read_absorb[(unsigned)__builtin_ctz(reg_mask)] = 0u; -#endif - return; - } - while (reg_mask) { -#if defined(_MSC_VER) - unsigned long _psx_ctz_idx; - _BitScanForward(&_psx_ctz_idx, reg_mask); - unsigned n = (unsigned)_psx_ctz_idx; -#else - unsigned n = (unsigned)__builtin_ctz(reg_mask); -#endif - cpu->read_absorb[n] = 0u; - reg_mask &= reg_mask - 1u; - } -} - -/* DO_LDS timing-commit (Beetle cpu.cpp:800). LDWhich==0x20 (no pending) writes the - * dummy slot read_absorb[32] and sets read_fudge=0x20 (=> next load gets +2 fudge). */ -static inline void psx_cyc_lds(CPUState* cpu) { - uint8_t lw = cpu->ld_which_t; - cpu->read_absorb[lw] = (uint8_t)cpu->ld_absorb; - cpu->read_fudge = lw; - cpu->read_absorb_which = (uint8_t)(cpu->read_absorb_which | (lw & 0x1Fu)); - cpu->ld_which_t = 0x20u; -} - -/* Full per-instruction interlock for a NON-CPU-load instruction (ALU, shift, - * branch, jump, store, COP control, LWC2/SWC2 pre-step, mult/div, mfhi/mflo, ...). - * reg_mask from psx_cyc_dep_res_mask(). MUST be emitted BEFORE the instruction - * body so §1 precedes any muldiv/GTE deadline stall in the body (Beetle order). */ -static inline void psx_cyc_step(CPUState* cpu, uint32_t reg_mask) { - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - psx_cyc_lds(cpu); -} - -/* The GPR dep+res bitmask used by psx_cyc_step lives in psx_instr_cost.h - * (psx_cyc_dep_res_mask) — a standalone pure function shared by the emitters - * (gen-time literal) and the interpreter (runtime), with no CPUState dependency. */ - -/* Main-RAM base + load-delay gate (memory.c). Inlined load helpers use these - * so MotK VLC / decode hot paths avoid an out-of-line call per LW/LH. */ -extern uint8_t *g_psx_ram; -extern int g_psx_load_delay; -extern int g_ls_mode; -extern volatile int g_ds_recording; -int psx_load_delay_enabled(void); -uint32_t psx_cyc_load_word_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); -uint16_t psx_cyc_load_half_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); - -/* Overlay DLLs route timed loads through the host callback shim so they share - * runtime timing and memory state. Normal generated code keeps the main-RAM - * fast path inline. */ -#if defined(PSX_OVERLAY_DLL_BUILD) -uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask); -uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask); -#else -/* CPU data load value+timing. Full Beetle sequence for main RAM is inlined; - * MMIO / lockstep / data-shard fall through to *_slow in memory.c. */ -static inline uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - uint32_t phys = addr & 0x1FFFFFFFu; - if (g_ls_mode == 0 && !g_ds_recording && phys < 0x00800000u) { - if (g_psx_load_delay < 0) (void)psx_load_delay_enabled(); - if (g_psx_load_delay) { - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; - psx_cyc_lds(cpu); - cpu->read_absorb[cpu->read_absorb_which] = 0u; - cpu->read_absorb_which = 0u; - uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); - cpu->ld_absorb = 5u; /* main-RAM wait 3 + completion 2 */ - psx_cyc_charge(fudge + 5u); - cpu->ld_which_t = (uint8_t)rt; - } - uint32_t value; - memcpy(&value, g_psx_ram + (phys & 0x1FFFFFu), sizeof(value)); - return value; - } - return psx_cyc_load_word_slow(cpu, addr, rt, reg_mask); -#else - (void)cpu; (void)rt; (void)reg_mask; - extern uint32_t psx_read_word(uint32_t a); - return psx_read_word(addr); -#endif -} - -static inline uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - uint32_t phys = addr & 0x1FFFFFFFu; - if (g_ls_mode == 0 && !g_ds_recording && phys < 0x00800000u) { - if (g_psx_load_delay < 0) (void)psx_load_delay_enabled(); - if (g_psx_load_delay) { - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; - psx_cyc_lds(cpu); - cpu->read_absorb[cpu->read_absorb_which] = 0u; - cpu->read_absorb_which = 0u; - uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); - cpu->ld_absorb = 5u; - psx_cyc_charge(fudge + 5u); - cpu->ld_which_t = (uint8_t)rt; - } - uint16_t value; - memcpy(&value, g_psx_ram + (phys & 0x1FFFFFu), sizeof(value)); - return value; - } - return psx_cyc_load_half_slow(cpu, addr, rt, reg_mask); -#else - (void)cpu; (void)rt; (void)reg_mask; - extern uint16_t psx_read_half(uint32_t a); - return psx_read_half(addr); -#endif -} -#endif - -extern uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask); - -/* Charge the exact timing/pipeline effects of a 32-bit CPU load without - * invoking the memory handler. Enhancement HLE may use this only when static - * analysis proves the loaded value and read side effects are unobservable. */ -extern void psx_cyc_load_word_timing_only(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask); - -/* LWC2 (GTE load) ReadMemory timing only (completion +1, NO LDWhich arm — the dest - * is a GTE register). Call AFTER psx_cyc_step(cpu,0) (§1+DO_LDS) and psx_gte_stall. - * Returns the raw 32-bit value. */ -extern uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr); - -#ifdef __cplusplus -} -#endif - -#endif /* PSX_CYC_H */ diff --git a/psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h b/psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h deleted file mode 100644 index 8355760..0000000 --- a/psxrecomp-overlay/runtime/include/ws_aspect_cone_math.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Test whether a camera-relative vector is inside a rectangular view cone. - * - * Coordinates use Tomba 2's X,Z,Y ordering. The camera forward vector is Q12. - * `threshold` is the game's Q10 cosine threshold (the SLTI immediate after its - * normalized dot product). `extent_pixels` expands only the horizontal - * half-angle from the vanilla 160-pixel half-width; the vertical half-angle is - * unchanged. - */ -int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, - int32_t fx, int32_t fz, int32_t fy, - uint32_t threshold, int extent_pixels); - -/* Widen a positive PSX 12-bit angular half-extent by the same horizontal - * projection scale used by a 320-wide 4:3 viewport. `extent_pixels` is the - * extra per-side reveal (including any configured guard). Identity is exact - * when extent_pixels is zero. Results stay below a 90-degree half-angle. */ -uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels); - -/* Generic signed counterpart for games whose wrapping yaw uses a different - * unit system. `full_turn` is the number of integer units in 360 degrees. - * The sign is preserved and the magnitude is widened by the same 320-wide - * projection geometry. */ -int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, - int extent_pixels); - -/* Scale a signed camera-horizontal coordinate into the original 320-wide - * projection before an unchanged side-plane comparison. Identity at zero - * extent; symmetric nearest-integer rounding for negative values. */ -int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels); - -#ifdef __cplusplus -} -#endif diff --git a/psxrecomp-overlay/runtime/src/boot_state.c b/psxrecomp-overlay/runtime/src/boot_state.c deleted file mode 100644 index 7ba1437..0000000 --- a/psxrecomp-overlay/runtime/src/boot_state.c +++ /dev/null @@ -1,477 +0,0 @@ -#include "boot_state.h" -#include "mod_memory.h" - -/* Optional v3 section; old snapshots predate enhancement packet allocations. */ -#define BS_SEC_GPU_DMA_MEMORY 0x0Fu -/* Optional: the instruction cost divider of an enhancement and its carry. An old snapshot resumes at the session's. */ -#define BS_SEC_CPU_COST 0x10u -#include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ -#include "gpu_render.h" /* gr_vram_transfer_in / gr_vram_transfer_out */ -#include "psx_cycles.h" -#include "pst_wire.h" -#include -#include -#include - -#define RAM_SIZE (2u * 1024u * 1024u) -#define SPAD_SIZE (1024u) -#define VRAM_W 1024 -#define VRAM_H 512 -#define VRAM_SIZE ((uint32_t)(VRAM_W * VRAM_H * 2)) /* 1 MB, 16bpp */ - -/* ---- core accessors (existing runtime modules) ---- */ -extern uint8_t* memory_get_ram_ptr(void); -extern uint8_t* memory_get_scratchpad_ptr(void); -extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); -extern uint32_t dirty_ram_get_bitmap_word_count(void); -extern void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count); -extern uint32_t i_stat; -extern uint32_t i_mask; -extern uint64_t psx_cycle_count; -extern uint32_t g_psx_cyc_overclock_shift; -extern uint32_t g_psx_cyc_overclock_carry; -extern void timers_get_snapshot(uint16_t counter[3], uint32_t mode[3], - uint16_t target[3], int32_t irq_line[3], - uint32_t frac[3]); -extern void timers_set_snapshot(const uint16_t counter[3], const uint32_t mode[3], - const uint16_t target[3], const int32_t irq_line[3], - const uint32_t frac[3]); - -/* ---- per-subsystem complete-state accessors (defined in each module) ---- */ -extern uint32_t gpu_snapshot_bytes(void); -extern void gpu_snapshot_write(uint8_t* p); -extern int gpu_snapshot_read(const uint8_t* p, uint32_t len); -extern uint32_t spu_snapshot_bytes(void); -extern void spu_snapshot_write(uint8_t* p); -extern int spu_snapshot_read(const uint8_t* p, uint32_t len); -extern uint8_t* spu_get_ram_ptr(void); -extern uint32_t spu_get_ram_bytes(void); -extern uint32_t cdrom_snapshot_bytes(void); -extern void cdrom_snapshot_write(uint8_t* p); -extern int cdrom_snapshot_read(const uint8_t* p, uint32_t len); -extern uint32_t dma_snapshot_bytes(void); -extern void dma_snapshot_write(uint8_t* p); -extern int dma_snapshot_read(const uint8_t* p, uint32_t len); -extern uint32_t sio_snapshot_bytes(void); -extern void sio_snapshot_write(uint8_t* p); -extern int sio_snapshot_read(const uint8_t* p, uint32_t len); - -/* CPU regs wire: 32+3+32+32+32 LE u32 = 131 * 4 = 524 bytes (no padding). */ -#define CPU_REGS_WIRE_BYTES (524u) -/* Timer wire: 3*u16 + 3*u32 + 3*u16 + 3*i32 + 3*u32 = 48 bytes (no pad holes). */ -#define TIMER_REGS_WIRE_BYTES (48u) - -/* ---- deferred capture state (armed before first boot, fired at handoff) ---- */ -static char s_capture_path[512]; -static uint32_t s_capture_checksum; -static uint32_t s_capture_entry_pc; - -static int fwrite_all(FILE* f, const void* p, size_t n) { - return !n || fwrite(p, 1, n, f) == n; -} - -static int write_header_le(FILE* f, const BootStateHeader* h) { - uint8_t buf[BOOT_STATE_HEADER_WIRE_BYTES]; - PstW w; - pst_w_init(&w, buf, sizeof buf); - if (!pst_w_u32(&w, h->magic) || - !pst_w_u32(&w, h->version) || - !pst_w_u32(&w, h->bios_checksum) || - !pst_w_u32(&w, h->entry_pc) || - !pst_w_u32(&w, h->codegen_hash) || - !pst_w_i32(&w, h->abi_tag) || - !pst_w_u32(&w, h->codegen_ver) || - !pst_w_u32(&w, h->section_count) || - !pst_w_u32(&w, h->reserved) || - w.written != BOOT_STATE_HEADER_WIRE_BYTES) - return 0; - return fwrite_all(f, buf, sizeof buf); -} - -static int read_header_le(FILE* f, BootStateHeader* h) { - uint8_t buf[BOOT_STATE_HEADER_WIRE_BYTES]; - PstR r; - if (fread(buf, 1, sizeof buf, f) != sizeof buf) return 0; - pst_r_init(&r, buf, sizeof buf); - memset(h, 0, sizeof *h); - if (!pst_r_u32(&r, &h->magic) || - !pst_r_u32(&r, &h->version) || - !pst_r_u32(&r, &h->bios_checksum) || - !pst_r_u32(&r, &h->entry_pc) || - !pst_r_u32(&r, &h->codegen_hash) || - !pst_r_i32(&r, &h->abi_tag) || - !pst_r_u32(&r, &h->codegen_ver) || - !pst_r_u32(&r, &h->section_count) || - !pst_r_u32(&r, &h->reserved)) - return 0; - return 1; -} - -static int write_section(FILE* f, uint32_t tag, const void* data, uint64_t len) { - uint8_t hdr[16]; - PstW w; - pst_w_init(&w, hdr, sizeof hdr); - if (!pst_w_u32(&w, tag) || !pst_w_u32(&w, 0u) || !pst_w_u64(&w, len)) - return 0; - if (!fwrite_all(f, hdr, sizeof hdr)) return 0; - if (len && !fwrite_all(f, data, (size_t)len)) return 0; - return 1; -} - -static int write_module_section(FILE* f, uint32_t tag, - uint32_t (*bytes)(void), - void (*write)(uint8_t*)) { - uint32_t n = bytes(); - uint8_t* buf = (uint8_t*)malloc(n ? n : 1); - if (!buf) return 0; - write(buf); - int ok = write_section(f, tag, buf, n); - free(buf); - return ok; -} - -static int write_cpu_section(FILE* f, const CPUState* cpu) { - uint8_t buf[CPU_REGS_WIRE_BYTES]; - PstW w; - pst_w_init(&w, buf, sizeof buf); - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gpr[i])) return 0; - if (!pst_w_u32(&w, cpu->pc) || !pst_w_u32(&w, cpu->hi) || !pst_w_u32(&w, cpu->lo)) - return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->cop0[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gte_data[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gte_ctrl[i])) return 0; - if (w.written != CPU_REGS_WIRE_BYTES) return 0; - return write_section(f, BS_SEC_CPU, buf, CPU_REGS_WIRE_BYTES); -} - -static int write_timer_section(FILE* f) { - uint16_t counter[3], target[3]; - uint32_t mode[3], frac[3]; - int32_t irq_line[3]; - uint8_t buf[TIMER_REGS_WIRE_BYTES]; - PstW w; - timers_get_snapshot(counter, mode, target, irq_line, frac); - pst_w_init(&w, buf, sizeof buf); - for (int i = 0; i < 3; i++) - if (!pst_w_u16(&w, counter[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_u32(&w, mode[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_u16(&w, target[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_i32(&w, irq_line[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_w_u32(&w, frac[i])) return 0; - if (w.written != TIMER_REGS_WIRE_BYTES) return 0; - return write_section(f, BS_SEC_TIMER, buf, TIMER_REGS_WIRE_BYTES); -} - -/* ============================ SAVE ============================ */ - -int boot_state_save(const CPUState* cpu, uint32_t bios_checksum, - uint32_t entry_pc, const char* path) { - FILE* f = fopen(path, "wb"); - if (!f) return 0; - - BootStateHeader h; - memset(&h, 0, sizeof h); - h.magic = BOOT_STATE_MAGIC; - h.version = BOOT_STATE_VERSION; - h.bios_checksum = bios_checksum; - h.entry_pc = entry_pc; - h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; - h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; - h.codegen_ver = (uint32_t)PSX_OVERLAY_CODEGEN_VER; - h.section_count = 16; - - int ok = write_header_le(f, &h); - - if (ok) ok = write_cpu_section(f, cpu); - if (ok) ok = write_section(f, BS_SEC_RAM, memory_get_ram_ptr(), RAM_SIZE); - if (ok) ok = write_section(f, BS_SEC_SPAD, memory_get_scratchpad_ptr(), SPAD_SIZE); - if (ok) { - uint8_t irq[8]; - PstW w; - pst_w_init(&w, irq, sizeof irq); - ok = pst_w_u32(&w, i_stat) && pst_w_u32(&w, i_mask) && - write_section(f, BS_SEC_IRQ, irq, 8); - } - if (ok) ok = write_timer_section(f); - if (ok) { - uint8_t cyc[8]; - PstW w; - pst_w_init(&w, cyc, sizeof cyc); - /* Publish deferred load-charge batch before snapshotting the clock. */ - psx_cyc_batch_flush(); - ok = pst_w_u64(&w, psx_cycle_count) && - write_section(f, BS_SEC_CLOCK, cyc, 8); - } - if (ok) ok = write_module_section(f, BS_SEC_GPU, gpu_snapshot_bytes, gpu_snapshot_write); - if (ok) { - uint16_t* vbuf = (uint16_t*)malloc(VRAM_SIZE); - if (!vbuf) ok = 0; - else { - gr_vram_transfer_out(0, 0, VRAM_W, VRAM_H, vbuf); - /* VRAM is uint16 LE guest pixels — emit as LE u16 stream. */ - uint8_t* wire = (uint8_t*)malloc(VRAM_SIZE); - if (!wire) ok = 0; - else { - PstW w; - pst_w_init(&w, wire, VRAM_SIZE); - ok = pst_w_pod(&w, vbuf, VRAM_SIZE, 2) && - write_section(f, BS_SEC_VRAM, wire, VRAM_SIZE); - free(wire); - } - free(vbuf); - } - } - if (ok) ok = write_module_section(f, BS_SEC_SPU, spu_snapshot_bytes, spu_snapshot_write); - if (ok) ok = write_section(f, BS_SEC_SPURAM, spu_get_ram_ptr(), spu_get_ram_bytes()); - if (ok) ok = write_module_section(f, BS_SEC_CDROM, cdrom_snapshot_bytes, cdrom_snapshot_write); - if (ok) ok = write_module_section(f, BS_SEC_DMA, dma_snapshot_bytes, dma_snapshot_write); - if (ok) ok = write_module_section(f, BS_SEC_SIO, sio_snapshot_bytes, sio_snapshot_write); - if (ok) { - uint32_t wc = dirty_ram_get_bitmap_word_count(); - uint64_t nbytes = (uint64_t)wc * 4u; - uint8_t* db = (uint8_t*)malloc(nbytes ? (size_t)nbytes : 1); - if (!db) ok = 0; - else { - PstW w; - pst_w_init(&w, db, (size_t)nbytes); - ok = 1; - for (uint32_t i = 0; ok && i < wc; i++) - ok = pst_w_u32(&w, dirty_ram_get_bitmap_word(i)); - if (ok) ok = write_section(f, BS_SEC_DIRTY, db, nbytes); - free(db); - } - } - - if (ok) ok = write_section(f, BS_SEC_GPU_DMA_MEMORY, - psx_mod_gpu_dma_memory_data(), - psx_mod_gpu_dma_memory_bytes()); - if (ok) { - uint8_t cost[8]; - PstW w; - pst_w_init(&w, cost, sizeof cost); - ok = pst_w_u32(&w, g_psx_cyc_overclock_shift) && - pst_w_u32(&w, g_psx_cyc_overclock_carry) && - write_section(f, BS_SEC_CPU_COST, cost, sizeof cost); - } - - fclose(f); - if (!ok) - remove(path); - return ok; -} - -/* ============================ LOAD ============================ */ - -static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, - CPUState* cpu, uint32_t entry_pc) { - switch (tag) { - case BS_SEC_CPU: { - PstR r; - if (len != CPU_REGS_WIRE_BYTES) return 0; - pst_r_init(&r, p, len); - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->gpr[i])) return 0; - if (!pst_r_u32(&r, &cpu->pc) || !pst_r_u32(&r, &cpu->hi) || - !pst_r_u32(&r, &cpu->lo)) - return 0; - (void)entry_pc; - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->cop0[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->gte_data[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_r_u32(&r, &cpu->gte_ctrl[i])) return 0; - return 1; - } - case BS_SEC_GPU_DMA_MEMORY: - return psx_mod_gpu_dma_memory_restore(p, len); - case BS_SEC_CPU_COST: { - PstR r; - uint32_t shift, carry; - if (len != 8) return 0; - pst_r_init(&r, p, len); - /* A wait drops the divider and leaves the carry for the next frame, so the carry is not held to the divider. */ - if (!pst_r_u32(&r, &shift) || !pst_r_u32(&r, &carry) || shift > 31u || (carry >> 31) != 0u) return 0; - g_psx_cyc_overclock_shift = shift; - g_psx_cyc_overclock_carry = carry; - return 1; - } - case BS_SEC_RAM: - if (len != RAM_SIZE) return 0; - memcpy(memory_get_ram_ptr(), p, RAM_SIZE); - { - extern void psx_kernel_bless_note_range(uint32_t phys, uint32_t l); - psx_kernel_bless_note_range(0, RAM_SIZE); - } - return 1; - case BS_SEC_SPAD: - if (len != SPAD_SIZE) return 0; - memcpy(memory_get_scratchpad_ptr(), p, SPAD_SIZE); - return 1; - case BS_SEC_IRQ: { - PstR r; - uint32_t st, mk; - if (len != 8) return 0; - pst_r_init(&r, p, len); - if (!pst_r_u32(&r, &st) || !pst_r_u32(&r, &mk)) return 0; - i_stat = st; - i_mask = mk; - return 1; - } - case BS_SEC_TIMER: { - uint16_t counter[3], target[3]; - uint32_t mode[3], frac[3]; - int32_t irq_line[3]; - PstR r; - if (len != TIMER_REGS_WIRE_BYTES) return 0; - pst_r_init(&r, p, len); - for (int i = 0; i < 3; i++) - if (!pst_r_u16(&r, &counter[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_u32(&r, &mode[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_u16(&r, &target[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_i32(&r, &irq_line[i])) return 0; - for (int i = 0; i < 3; i++) - if (!pst_r_u32(&r, &frac[i])) return 0; - timers_set_snapshot(counter, mode, target, irq_line, frac); - return 1; - } - case BS_SEC_CLOCK: { - PstR r; - uint64_t cyc; - if (len != 8) return 0; - pst_r_init(&r, p, len); - if (!pst_r_u64(&r, &cyc)) return 0; - psx_cycle_count = cyc; - return 1; - } - case BS_SEC_GPU: - return gpu_snapshot_read(p, len); - case BS_SEC_VRAM: { - uint16_t* vbuf; - PstR r; - if (len != VRAM_SIZE) return 0; - vbuf = (uint16_t*)malloc(VRAM_SIZE); - if (!vbuf) return 0; - pst_r_init(&r, p, len); - if (!pst_r_pod(&r, vbuf, VRAM_SIZE, 2)) { - free(vbuf); - return 0; - } - gr_vram_transfer_in(0, 0, VRAM_W, VRAM_H, vbuf); - free(vbuf); - return 1; - } - case BS_SEC_SPU: - return spu_snapshot_read(p, len); - case BS_SEC_SPURAM: - if (len != spu_get_ram_bytes()) return 0; - memcpy(spu_get_ram_ptr(), p, len); - return 1; - case BS_SEC_CDROM: - return cdrom_snapshot_read(p, len); - case BS_SEC_DMA: - return dma_snapshot_read(p, len); - case BS_SEC_SIO: - return sio_snapshot_read(p, len); - case BS_SEC_DIRTY: { - uint32_t wc; - uint32_t* words; - PstR r; - if (len % 4u) return 0; - wc = len / 4u; - words = (uint32_t*)malloc(len ? len : 1); - if (!words) return 0; - pst_r_init(&r, p, len); - for (uint32_t i = 0; i < wc; i++) { - if (!pst_r_u32(&r, &words[i])) { - free(words); - return 0; - } - } - dirty_ram_set_bitmap_words(words, wc); - free(words); - return 1; - } - default: - return 0; - } -} - -int boot_state_load(const char* path, uint32_t bios_checksum, - uint32_t entry_pc, CPUState* cpu) { - FILE* f = fopen(path, "rb"); - if (!f) return 0; - - BootStateHeader h; - if (!read_header_le(f, &h)) { fclose(f); return 0; } - - if (h.magic != BOOT_STATE_MAGIC || - h.version != BOOT_STATE_VERSION || - h.bios_checksum != bios_checksum || - h.entry_pc != entry_pc || - h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH || - h.abi_tag != (int32_t)PSX_OVERLAY_ABI_TAG || - h.codegen_ver != (uint32_t)PSX_OVERLAY_CODEGEN_VER) { - fclose(f); - return 0; - } - - const uint32_t required = - (1u< 64u * 1024u * 1024u) { ok = 0; break; } - uint8_t* buf = (uint8_t*)malloc(len ? (size_t)len : 1); - if (!buf) { ok = 0; break; } - if (len && fread(buf, 1, (size_t)len, f) != (size_t)len) { free(buf); ok = 0; break; } - if (!apply_section(tag, buf, (uint32_t)len, cpu, entry_pc)) ok = 0; - else if (tag < 32) seen |= (1u << tag); - free(buf); - } - fclose(f); - - if (!ok || (seen & required) != required) - return 0; - if (!(seen & (1u << BS_SEC_GPU_DMA_MEMORY))) - psx_mod_gpu_dma_memory_restore(NULL, 0u); - return 1; -} - -void boot_state_set_capture(const char* path, uint32_t bios_checksum, - uint32_t entry_pc) { - strncpy(s_capture_path, path, sizeof(s_capture_path) - 1); - s_capture_path[sizeof(s_capture_path) - 1] = '\0'; - s_capture_checksum = bios_checksum; - s_capture_entry_pc = entry_pc; -} - -void boot_state_trigger_capture(const CPUState* cpu) { - if (!s_capture_path[0]) return; - boot_state_save(cpu, s_capture_checksum, s_capture_entry_pc, s_capture_path); - s_capture_path[0] = '\0'; -} diff --git a/psxrecomp-overlay/runtime/src/dirty_ram_interp.c b/psxrecomp-overlay/runtime/src/dirty_ram_interp.c deleted file mode 100644 index e988e67..0000000 --- a/psxrecomp-overlay/runtime/src/dirty_ram_interp.c +++ /dev/null @@ -1,4039 +0,0 @@ -/* dirty_ram_interp.c — small MIPS interpreter for install-at-runtime RAM. - * - * See CLAUDE.md Rule 18, docs/dynamic_handler_install.md, and the inline - * note in memory.c (search for "Option B") for the architectural rationale. - * - * Scope: only fires when psx_dispatch lands on a PC whose page has been - * written-to since boot. Runs one basic block (terminator: jr/jalr/j/jal - * or branch) and returns; dispatch trampoline re-enters for the next block. - * - * Strict policy: any opcode not implemented here aborts fatally. This - * surfaces unknown install patterns immediately so we expand the support - * set deliberately, never silently. - * - * Future option (Option B, see docs/dynamic_handler_install.md): JIT-compile - * dirty pages via the existing StrictTranslator instead of interpreting. - * Pros: single source of MIPS semantics shared with the build-time path, - * native-speed install stubs, generalizes to game JIT cases. Cons: gcc-at- - * runtime build dep, ~200 ms compile latency stall on first dispatch, file - * I/O on hot path, cache-invalidation complexity, Windows MinGW + dlopen - * friction. Today install stubs are cold-path glue (~4k instructions per - * directory-load); interpretation is sub-microsecond and the right fit. - * Revisit if measurement shows install-stub instructions becoming a - * meaningful fraction of total runtime work. - */ - -#include "dirty_ram_interp.h" -#include "gte_precision.h" -#include "cpu_state.h" -#include "debug_server.h" -#include "interrupts.h" -#include "psx_cycles.h" -#include "psx_icache.h" -#include "psx_instr_cost.h" /* psx_instr_base_cycles — single-source cycle cost */ -#include "gpu.h" /* psx_ws_is_backdrop_site / psx_ws_backdrop_x (interp hook) */ -#include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */ -#include "lockstep.h" -#include "starvation_ring.h" - -#include -#include -#include - -uint64_t g_dirty_ram_blocks_run = 0; -uint64_t g_dirty_ram_insns_run = 0; -uint64_t g_dirty_window_dispatches = 0; /* capture-window interp dispatches */ -uint64_t g_dirty_ram_aborts = 0; -uint64_t g_dirty_ram_guard_yields = 0; -uint64_t g_dirty_ram_native_handoffs = 0; - -/* One sample per 256 interpreted instructions identifies stable hot loops - * without turning production diagnostics into another per-insn profiler. The - * physical-PC direct table has no hash collisions and is touched only while - * PSX_RUNTIME_PERF_DIAG is enabled. */ -#define DIRTY_RAM_PERF_SAMPLE_SHIFT 8u -#define DIRTY_RAM_PERF_SAMPLE_STRIDE (1u << DIRTY_RAM_PERF_SAMPLE_SHIFT) -static int s_dirty_ram_perf_hot_enabled = 0; -static uint32_t s_dirty_ram_perf_hot_samples[DIRTY_RAM_EXEC_WORD_COUNT]; - -void dirty_ram_perf_hot_enable(int enabled) { - s_dirty_ram_perf_hot_enabled = enabled ? 1 : 0; - memset(s_dirty_ram_perf_hot_samples, 0, - sizeof(s_dirty_ram_perf_hot_samples)); -} - -static inline void dirty_ram_perf_hot_record(uint32_t pc) { - if (!s_dirty_ram_perf_hot_enabled || - (g_dirty_ram_insns_run & (DIRTY_RAM_PERF_SAMPLE_STRIDE - 1u)) != 0u) - return; - uint32_t phys = pc & 0x1FFFFFFFu; - if (phys >= 2u * 1024u * 1024u || (phys & 3u) != 0u) return; - uint32_t *slot = &s_dirty_ram_perf_hot_samples[phys >> 2]; - if (*slot != UINT32_MAX) (*slot)++; -} - -void dirty_ram_perf_take_hot(uint32_t pcs[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t samples[DIRTY_RAM_PERF_HOT_COUNT], - uint32_t *stride) { - uint32_t top_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - uint32_t top_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - for (uint32_t word = 0; word < DIRTY_RAM_EXEC_WORD_COUNT; word++) { - uint32_t n = s_dirty_ram_perf_hot_samples[word]; - if (n == 0u || n <= top_n[DIRTY_RAM_PERF_HOT_COUNT - 1u]) continue; - uint32_t at = DIRTY_RAM_PERF_HOT_COUNT - 1u; - while (at > 0u && n > top_n[at - 1u]) { - top_n[at] = top_n[at - 1u]; - top_pc[at] = top_pc[at - 1u]; - at--; - } - top_n[at] = n; - top_pc[at] = word << 2; - } - memset(s_dirty_ram_perf_hot_samples, 0, - sizeof(s_dirty_ram_perf_hot_samples)); - if (pcs) memcpy(pcs, top_pc, sizeof(top_pc)); - if (samples) memcpy(samples, top_n, sizeof(top_n)); - if (stride) *stride = DIRTY_RAM_PERF_SAMPLE_STRIDE; -} -/* Scheduling-contract pump telemetry (see dirty_ram_dispatch). Always-on: - * g_dirty_pump_max_gap_insns is the largest interpreted-insn gap ever seen - * between two interrupt pumps — must stay bounded (~4096 + one block) once the - * region-independent pump is in place; a runaway value means a dirty path is - * advancing cycles without surfacing to psx_check_interrupts (the softlock). */ -uint64_t g_dirty_pump_max_gap_insns = 0; -uint64_t g_dirty_pump_count = 0; -static uint64_t s_last_dirty_irq_pump_insns = 0; - -/* EPC de-overload signal (Tomba 2 frame-1997 fix). Set to the committed guest PC - * immediately around a dirty-pump psx_check_interrupts() call, 0 otherwise. When - * non-zero at exception entry, the interrupt is delivered at a dirty-interp safe point - * with a precise guest resume PC, so psx_check_interrupts sets COP0_EPC to the REAL PC - * (architectural RFE resume) instead of the host sentinel 0x80000048. The TCB then - * saves the real PC, so a game-installed handler that drives ReturnFromException - * itself (sync OR async) resumes correctly. Compiled-code block-boundary pumps leave - * this 0 -> the sentinel + host-GPR-restore + longjmp path (unchanged: T1/MMX6/Ape). */ -uint32_t g_dirty_safe_resume_pc = 0; - -/* Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md). Set while the - * block-leader slice guard is running a block through the per-instruction - * interpreter so the interrupt is taken at the EXACT architectural instruction - * (not the coarse block edge). While set, exec_one's jal/jalr short-circuit to a - * plain transfer (cpu->pc = target; return 1) so precise-mode steps INTO callees - * per-instruction instead of running them compiled — the "ignore native - * availability" invariant (ChatGPT-validated option b). Default 0 => every other - * path is byte-for-byte unchanged. */ -int g_precise_mode = 0; -/* #2 lockstep: set ONLY around the dirty-interp loop's per-instruction - * cyc_observe so the lockstep comparator can tell a real COMPILED block leader - * (g_ls_dirty_observe==0) from an interpreted per-instruction sample (==1). */ -int g_ls_dirty_observe = 0; -extern int g_ls_replay_active; /* defined in the lockstep section; used by exec_one's jal/jalr guard */ - -/* Interpreter-only production fast path. Do not put this in psx_cyc.h: - * generated overlay DLLs deliberately batch psx_advance_cycles() through a - * DLL-local accumulator and must never bind directly to runtime cycle state. */ -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) && !STARVATION_RING_ENABLED -extern uint64_t g_psx_cycle_fast_limit; -extern int g_event_step_conservative; - -static inline void interp_cyc_step(CPUState *cpu, uint32_t reg_mask) { - uint8_t w = cpu->read_absorb_which; - if (cpu->read_absorb[w]) { - cpu->read_absorb[w]--; - } else if (!g_ls_replay_active) { - uint64_t next = psx_cycle_count + 1u; - if (!g_event_step_conservative && g_psx_cycle_fast_limit != 0u && - next <= g_psx_cycle_fast_limit) { - psx_cycle_count = next; - } else { - psx_advance_cycles(1u); - } - } - psx_cyc_deps(cpu, reg_mask); - psx_cyc_lds(cpu); -} -#else -static inline void interp_cyc_step(CPUState *cpu, uint32_t reg_mask) { - psx_cyc_step(cpu, reg_mask); -} -#endif - -#ifdef PSX_COSIM -static int g_cosim_exec_one_hooked = 0; -static void cosim_exec_one_begin(void) { g_cosim_exec_one_hooked = 0; } -static int cosim_exec_one_did_hook(void) { return g_cosim_exec_one_hooked; } -static void cosim_exec_one_transfer_hook(uint32_t pc) { - extern void cosim_instr(uint32_t); - cosim_instr(pc); - g_cosim_exec_one_hooked = 1; -} -#else -static void cosim_exec_one_begin(void) {} -static int cosim_exec_one_did_hook(void) { return 0; } -static void cosim_exec_one_transfer_hook(uint32_t pc) { (void)pc; } -#endif -uint64_t g_slice_fired = 0; /* diagnostic: slices actually run */ -uint64_t g_slice_irq_taken = 0; /* diagnostic: IRQs taken inside precise-mode */ -/* First-divergence trace for the precise slice (PRECISE_IRQ_SLICE.md Task #4). */ -uint32_t g_slice_last_block = 0; /* block_addr the guard fired on */ -uint32_t g_slice_last_first_pc = 0; /* pc of the first interp instruction */ -uint32_t g_slice_last_first_insn= 0; /* raw word of that first instruction */ -uint32_t g_slice_last_committed = 0; /* committed PC handed back as resume/EPC */ -uint32_t g_slice_last_istat = 0; /* i_stat at the take */ -uint32_t g_slice_last_imask = 0; /* i_mask at the take */ -uint32_t g_slice_last_sr = 0; /* COP0 SR at the take */ -uint32_t g_slice_entry_deliverable = 0; /* was IRQ deliverable at slice entry? */ -uint32_t g_slice_exit_pc = 0; /* PC published by the last precise slice */ -uint32_t g_slice_exit_reason = 0; /* 1=safe, 2=unsupported, 3=bail, 4=guard */ -uint32_t g_slice_exit_iter = 0; /* loop iteration at exit */ -uint32_t g_slice_exit_dispatchable = 0; /* precise_pc_dispatchable(exit_pc) */ -uint32_t g_slice_exit_dirty = 0; /* dirty_ram_is_dirty(exit_pc phys) */ -uint32_t g_slice_exit_in_text = 0; /* psx_game_address_in_text(exit_pc) */ -uint32_t g_slice_exit_want = 0; /* want_exit value at exit */ - -/* Persistent async-RFE resume PC (Tomba 2 frame-1997 fix). psx_check_interrupts latches - * this from g_dirty_safe_resume_pc at each dirty-safe-point exception entry, so it holds - * the real guest PC of the most recent dirty-interp interruption. When a game-installed - * handler drives ReturnFromException ASYNCHRONOUSLY (sentinel RFE with in_exception==0), - * the sentinel gates resume the guest here instead of resolving to pc=0 (abnormal exit). - * Unlike g_dirty_safe_resume_pc (transient, scoped to the pump call) this persists across - * the gap between the interrupt and the game's later ReturnFromException. */ -uint32_t g_async_rfe_resume_pc = 0; -/* Diagnostics for the async-RFE fix: times the resume PC was latched at a dirty-safe - * exception entry, and times the sentinel gates actually redirected to it. */ -uint64_t g_async_rfe_set_count = 0; -uint64_t g_async_rfe_fire_count = 0; -/* Reach diagnostics: which gate the sentinel dispatch lands in, and what - * g_async_rfe_resume_pc held there. */ -uint64_t g_sentinel_reach_dirty = 0; -uint64_t g_sentinel_reach_traps = 0; -uint32_t g_sentinel_reach_async = 0; - -/* One-shot pc=0 producer tripwire (MMX6 boot-wedge investigation, Task #4 family). - * Latches the FIRST time dirty_ram_dispatch returns "handled" (r==1) but leaves - * cpu->pc == 0 — i.e. the dirty path produced the abnormal-exit null PC rather - * than psx_unknown_dispatch (which has its own fail-fast). Captures the dispatch - * addr + interrupt-return context so the exact producing path is identifiable - * without printf. Surfaced in freeze_check (pczero_*). */ -int g_cosim_dirty_pump_site = 0; /* PSX_COSIM diagnostic: current dirty IRQ pump site */ -int g_pczero_latched = 0; -uint32_t g_pczero_addr = 0; -uint32_t g_pczero_ra = 0; -uint32_t g_pczero_in_exc = 0; -uint32_t g_pczero_async_rfe = 0; -uint32_t g_pczero_dirty_safe = 0; -uint64_t g_pczero_count = 0; - -/* Mid-block unsupported-opcode counters. Bumped instead of fprintf-spamming - * stderr (CLAUDE.md §3). Read via dirty_ram_get_unsupported(). The "last_*" - * fields capture the most recent occurrence so a TCP query can see what - * opcode is missing without needing log scraping. */ -uint64_t g_dirty_ram_unsupported_midblock = 0; -uint32_t g_dirty_ram_last_unsupported_entry = 0; -uint32_t g_dirty_ram_last_unsupported_entry_ra = 0; -uint32_t g_dirty_ram_last_unsupported_entry_sp = 0; -uint32_t g_dirty_ram_last_unsupported_insns = 0; -uint32_t g_dirty_ram_last_unsupported_pc = 0; -uint32_t g_dirty_ram_last_unsupported_insn = 0; -const char *g_dirty_ram_last_unsupported_reason = NULL; - -DirtyRamPcEntry g_dirty_ram_pc_table[DIRTY_RAM_PC_TABLE_SIZE] = {0}; -uint32_t g_dirty_ram_exec_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS] = {0}; -uint32_t g_dirty_ram_exec_page_bitmap[DIRTY_RAM_EXEC_PAGE_BITMAP_WORDS] = {0}; -uint32_t g_dirty_ram_dispatch_pc_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS] = {0}; - -DirtyRamBlockLogEntry g_dirty_ram_block_log[DIRTY_RAM_BLOCK_LOG_CAP] = {0}; -uint64_t g_dirty_ram_block_log_seq = 0; -DirtyRamFlowLogEntry g_dirty_ram_flow_log[DIRTY_RAM_FLOW_LOG_CAP] = {0}; -uint64_t g_dirty_ram_flow_log_seq = 0; -DirtyRamInsnLogEntry g_dirty_ram_insn_log[DIRTY_RAM_INSN_LOG_CAP] = {0}; -uint64_t g_dirty_ram_insn_log_seq = 0; -ParappaRhythmEvent g_parappa_rhythm_events[PARAPPA_RHYTHM_EVENT_CAP] = {0}; -uint64_t g_parappa_rhythm_event_seq = 0; -int g_parappa_timing_mode = 0; -int g_parappa_timing_extra_early = 0; -int g_parappa_timing_extra_late = 0; -static uint32_t s_parappa_recent_judge_obj = 0; -static uint32_t s_parappa_recent_judge_idx = 0; -static uint32_t s_parappa_recent_judge_mask = 0; -static uint32_t s_parappa_recent_judge_frame = 0; - -void parappa_timing_window_reset(void) { - s_parappa_recent_judge_obj = 0; - s_parappa_recent_judge_idx = 0; - s_parappa_recent_judge_mask = 0; - s_parappa_recent_judge_frame = 0; -} - -void parappa_rhythm_events_reset(void) { - g_parappa_rhythm_event_seq = 0; - memset(g_parappa_rhythm_events, 0, sizeof(g_parappa_rhythm_events)); - parappa_timing_window_reset(); -} - -/* Current frame counter, defined in debug_server.c. */ -extern uint64_t s_frame_count; - -/* Linear-probed insert/lookup keyed on entry PC. Probe length is HARD - * BOUNDED: a saturated table must degrade to "stop tracking" (NULL) at O(1) - * cost, never to an O(table) scan per lookup. Tomba2's Trolley attract demo - * overflowed the old unbounded probe (working set > table size) and burned - * ~99% of the host emu thread rescanning 64K slots per executed instruction - * (0.3 fps). 128 probes keeps misses vanishingly rare below ~85% fill with - * this hash; past that the tracking data was already degrading anyway. */ -static DirtyRamPcEntry *pc_table_get_or_insert_in(DirtyRamPcEntry *table, uint32_t pc) { - uint32_t h = (pc * 2654435761u) & (DIRTY_RAM_PC_TABLE_SIZE - 1); - for (uint32_t i = 0; i < 128u; i++) { - uint32_t idx = (h + i) & (DIRTY_RAM_PC_TABLE_SIZE - 1); - DirtyRamPcEntry *e = &table[idx]; - if (e->pc == pc) return e; - if (e->pc == 0) { e->pc = pc; return e; } - } - return NULL; -} - -static DirtyRamPcEntry *pc_table_get_or_insert(uint32_t pc) { - return pc_table_get_or_insert_in(g_dirty_ram_pc_table, pc); -} - -/* Record every PC the interpreter executes (not just block entries) so - * overlay_capture can report execution-verified seeds for the region. A PSX - * instruction is aligned, making this a single direct bitmap OR rather than a - * cache-unfriendly open-addressed lookup on every guest instruction. */ -static inline void exec_pc_table_record(uint32_t pc) { - uint32_t phys = pc & 0x1FFFFFFFu; - if (phys < 2u * 1024u * 1024u && (phys & 3u) == 0u) { - uint32_t word = phys >> 2; - uint32_t mask = 1u << (word & 31u); - uint32_t *slot = &g_dirty_ram_exec_pc_bitmap[word >> 5]; - if ((*slot & mask) == 0u) { - *slot |= mask; - uint32_t page = phys >> 12; - g_dirty_ram_exec_page_bitmap[page >> 5] |= 1u << (page & 31u); - } - } -} - -/* From debug_server.c — keep our outer-frame attribution coherent. */ -extern uint32_t g_debug_current_func_addr; -extern uint32_t g_debug_last_store_pc; - -static int parappa_rhythm_store_site(uint32_t pc) { - switch (pc & 0x1FFFFFFFu) { - case 0x001C7ACC: case 0x001C7B24: case 0x001C7B38: - case 0x001C7CAC: case 0x001C7CB0: case 0x001C7CC4: - case 0x001C7CE4: case 0x001C7CE8: case 0x001C7D04: - case 0x001C7D6C: case 0x001C7DD0: case 0x001C7DD8: - case 0x001C7DE0: case 0x001C7DEC: case 0x001C7E10: - case 0x001C7E58: case 0x001C7EB4: case 0x001C7ED0: - case 0x001C7ED8: case 0x001C7F20: case 0x001C7F34: - case 0x001C7F40: case 0x001C7F44: case 0x001C80F0: - case 0x001C8878: case 0x001C8894: case 0x001C88B0: - case 0x001C88B8: case 0x001C91D8: case 0x001C91F8: - return 1; - default: - return 0; - } -} - -static uint32_t parappa_read_u32(CPUState *cpu, uint32_t addr) { - return cpu->read_word(addr); -} - -static uint32_t parappa_read_i16(CPUState *cpu, uint32_t addr) { - return (uint32_t)(int32_t)(int16_t)cpu->read_half(addr); -} - -static ParappaRhythmEvent *parappa_rhythm_next_event(CPUState *cpu, - uint32_t pc) { - uint64_t s = g_parappa_rhythm_event_seq++; - ParappaRhythmEvent *e = - &g_parappa_rhythm_events[s & (PARAPPA_RHYTHM_EVENT_CAP - 1u)]; - e->seq = s; - e->frame = (uint32_t)s_frame_count; - e->pc = pc & 0x1FFFFFFFu; - e->obj = cpu->gpr[17]; - e->ra = cpu->gpr[31]; - e->s5 = cpu->gpr[21]; - e->s6 = cpu->gpr[22]; - e->g_800901bc = parappa_read_u32(cpu, 0x800901BCu); - e->g_800901c0 = parappa_read_u32(cpu, 0x800901C0u); - e->g_800916d0 = parappa_read_i16(cpu, 0x800916D0u); - e->g_800916d8 = parappa_read_i16(cpu, 0x800916D8u); - e->g_800916da = parappa_read_i16(cpu, 0x800916DAu); - e->g_800916dc = parappa_read_i16(cpu, 0x800916DCu); - e->g_801d3040 = parappa_read_u32(cpu, 0x801D3040u); - return e; -} - -static void parappa_rhythm_log_store(CPUState *cpu, uint32_t pc, - uint32_t addr, uint32_t value, - uint32_t width) { - if (!parappa_rhythm_store_site(pc)) return; - ParappaRhythmEvent *e = parappa_rhythm_next_event(cpu, pc); - e->addr = addr; - e->value = value; - e->width = width; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; -} - -static int parappa_rhythm_compare_site(uint32_t pc) { - switch (pc & 0x1FFFFFFFu) { - case 0x001C7D1C: - case 0x001C7D44: - return 1; - default: - return 0; - } -} - -static int32_t parappa_timing_adjust_threshold(uint32_t pc, int32_t rhs) { - (void)pc; - /* Score assist must not change note/action cadence. Keep this compare - * observational until a score-only judgement write is identified. */ - return rhs; -} - -static void parappa_rhythm_log_compare(CPUState *cpu, uint32_t pc, - uint32_t lhs, uint32_t raw_rhs, - uint32_t adjusted_rhs, - uint32_t vanilla, uint32_t kept) { - if (!parappa_rhythm_compare_site(pc)) return; - ParappaRhythmEvent *e = parappa_rhythm_next_event(cpu, pc); - e->addr = cpu->gpr[1]; - e->value = adjusted_rhs; - e->width = 0; - e->a0 = lhs; - e->a1 = raw_rhs; - e->a2 = vanilla; - e->a3 = kept; -} - -static void parappa_rhythm_log_final_window(CPUState *cpu, - uint32_t original_mask, - uint32_t result_mask, - uint32_t recent_mask, - uint32_t frame_delta, - int32_t idx_delta) { - ParappaRhythmEvent *e = parappa_rhythm_next_event(cpu, 0x001C7F1Cu); - e->addr = s_parappa_recent_judge_obj; - e->value = result_mask; - e->width = 0; - e->a0 = original_mask; - e->a1 = recent_mask; - e->a2 = frame_delta; - e->a3 = (uint32_t)idx_delta; -} - -static void parappa_timing_apply_final_window(CPUState *cpu, uint32_t pc) { - if ((pc & 0x1FFFFFFFu) != 0x001C7F1Cu) return; - if (g_parappa_timing_mode <= 0 || g_parappa_timing_extra_late <= 0) return; - - uint32_t mask = cpu->gpr[2]; - uint32_t obj = cpu->gpr[17]; - uint32_t idx = parappa_read_u32(cpu, 0x800901C0u); - uint32_t frame = (uint32_t)s_frame_count; - - if (mask != 0) { - s_parappa_recent_judge_obj = obj; - s_parappa_recent_judge_idx = idx; - s_parappa_recent_judge_mask = mask; - s_parappa_recent_judge_frame = frame; - parappa_rhythm_log_final_window(cpu, mask, mask, mask, 0, 0); - return; - } - - if (s_parappa_recent_judge_mask == 0 || s_parappa_recent_judge_obj != obj) { - parappa_rhythm_log_final_window(cpu, mask, mask, s_parappa_recent_judge_mask, 0, 0); - return; - } - - uint32_t frame_delta = frame - s_parappa_recent_judge_frame; - int32_t idx_delta = (int32_t)idx - (int32_t)s_parappa_recent_judge_idx; - parappa_rhythm_log_final_window(cpu, mask, mask, - s_parappa_recent_judge_mask, - frame_delta, idx_delta); -} - -/* Execution-mode tag for the event-timeline ring: 1 while a dirty_ram_dispatch - * call is on the stack. Cleared defensively at the psx_check_interrupts longjmp - * landing (interrupts.c) so the EPC-sentinel longjmp at dirty_ram_dispatch - * can't leave it stuck on. NATIVE_OVERLAY (inprogress!=0) takes precedence in - * the ring's mode resolution, so this only distinguishes INTERP from STATIC. */ -int g_dirty_interp_active = 0; -int dirty_ram_interp_is_active(void) { return g_dirty_interp_active; } - -/* Innermost execution phase, for the wall-time sampler (phase_profile). - * g_dirty_interp_active is NOT this: it stays 1 across a native overlay call - * made from the interpreter's jal/jalr contract (overlay_loader_call_native), - * so sampling it reads "inside the dispatch tree", not "interpreting". This - * variable is save/set/restored at EVERY backend boundary, so a sample reads - * the backend actually executing at that instant: - * 0 = host/other (SDL, GPU, top-level dispatch glue, idle) - * 1 = dirty-RAM interpreter (exec_one body / precise slice) - * 2 = native overlay shard (gcc/tcc DLL) - * 3 = compiled static text (game EXE / recompiled BIOS, incl. IRQ handler) - * 4 = GPU GP0 command processing (gpu.c — raster/batch/VRAM-transfer work) - * Longjmp contract mirrors g_dirty_interp_active: exception delivery saves it - * at entry and restores after its setjmp loop (interrupts.c), so a skipped - * inner restore self-heals at the next bracket. */ -int g_exec_phase = 0; -int psx_exec_phase(void) { return g_exec_phase; } - -/* TCP-armed instruction-window capture (native↔interp divergence drill). - * g_insn_gate_*: an extra runtime-settable PC range that the per-insn log - * records (on top of the hardwired kernel ranges). Freeze latch: on the Nth - * dispatch of candidate g_insn_freeze_addr the insn ring stops recording, so - * its tail preserves the window immediately BEFORE that dispatch — query the - * ring afterward at leisure (ring-first; no arm-then-hope). */ -/* ── R3000A load-delay VALUE semantics ────────────────────────────────────── - * On MIPS-I there is no load interlock: a load's target register is NOT - * visible to the instruction in the load-delay slot, which still sees the - * PREVIOUS value. The writeback lands one instruction later. - * - * We already modelled the load-delay *interlock timing* (psx_cyc_load_*), - * but wrote the destination register immediately — so the slot observed the - * loaded value. That silently diverged from BOTH real hardware and our own - * compiled backend (full_function_emitter defers the writeback past the - * successor for dependent pairs), and hand-written kernel asm depends on it. - * - * The concrete casualty: OpenBIOS's cardfasttrack.s stashes the pointer - * variable's BASE with `or $at,$k0,$zero` in the delay slot of - * `lw $k0,off($k0)`, then writes the advanced pointer back through $at. With - * an immediate writeback $at became the pointer itself, the write-back landed - * in unrelated memory, the pointer never advanced, and every byte of a - * 128-byte memory-card frame was stored to one address (cards read as - * permanently unformatted; the send side transmitted one byte 128x). - * - * Contract: on a same-register conflict the LOAD wins (it retires later), - * matching the compiled emitter. Pending state is flushed on interpreter exit - * and before exception delivery, where the pipeline would have drained. */ -static uint32_t s_ld_pend_rt = 0; -static uint32_t s_ld_pend_val = 0; -static uint32_t s_ld_pend_age = 0; /* 0 = armed; 1 = delay slot has run */ -static int s_ld_pend_armed = 0; - -/* Retire a deferred load writeback. Call wherever the interpreter stops - * stepping instructions (hand-off to compiled code, exception entry), since - * nothing downstream knows about the pending register write. */ -void dirty_ram_ld_delay_flush(CPUState *cpu) { - if (!s_ld_pend_armed) return; - s_ld_pend_armed = 0; - s_ld_pend_age = 0; - if (s_ld_pend_rt != 0u) cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - cpu->gpr[0] = 0; -} - -uint32_t g_insn_gate_lo = 0; /* extra always-log phys range [lo,hi) */ -uint32_t g_insn_gate_hi = 0; /* 0 = extra range disabled */ -uint32_t g_insn_freeze_addr = 0; /* candidate phys entry to watch */ -uint32_t g_insn_freeze_nth = 0; /* freeze before its Nth dispatch */ -uint32_t g_insn_freeze_count = 0; -int g_insn_log_frozen = 0; -/* Tomba2 wild-jump capture (Confirm-(b)): freeze the insn ring the instant an - * interpreted instruction's transfer TARGET (or next_pc) equals this watched - * value, so the offending jr/jalr is preserved as the ring's last entry with its - * full register snapshot. Set via the `insn_freeze_target` debug command (0 = - * disabled). Matches on full 32-bit value (wild PCs are not phys-normalizable). */ -uint32_t g_insn_freeze_on_target = 0; -/* Register snapshot captured at the instruction that hits g_insn_freeze_on_target - * (the offending jr/jalr). g_freeze_snap_valid latches 1; pc/insn identify the - * branch, gpr[] is the full guest register file at that moment (the source - * register still holds the wild target; scan for which reg == the target). */ -int g_freeze_snap_valid = 0; -uint32_t g_freeze_snap_pc = 0; -uint32_t g_freeze_snap_insn = 0; -uint32_t g_freeze_snap_tcb = 0; -uint32_t g_freeze_snap_gpr[32] = {0}; -/* ra-load watch (Confirm-(b)): capture the instruction that sets $ra to this - * value. 0 = disabled. */ -uint32_t g_ra_load_watch = 0; -/* Callee-smear tripwire (see the interp-loop probe): first $s3 change - * inside [lo,hi) that isn't the walk's own advance, with the call target. */ -uint32_t g_s3_smear_lo = 0, g_s3_smear_hi = 0; -/* Optional exact-encoding exclusion so a watched loop's OWN $s3 advance - * (e.g. an `addi s3,s3,8` list walk) doesn't trip the latch. 0 = none. */ -uint32_t g_s3_smear_excl = 0; -uint32_t g_s3_smear_pc = 0, g_s3_smear_insn = 0, g_s3_smear_old = 0, - g_s3_smear_new = 0, g_s3_smear_tgt = 0, g_s3_smear_frame = 0; -int g_s3_smear_valid = 0; -int g_ra_load_snap_valid = 0; -uint32_t g_ra_load_snap_pc = 0; -uint32_t g_ra_load_snap_insn = 0; -uint32_t g_ra_load_snap_before_ra = 0; -uint32_t g_ra_load_snap_srcaddr = 0; -uint32_t g_ra_load_snap_gpr[32] = {0}; - -/* Call-resolution ring (armed via `callret_watch lo=.. hi=..`). - * For every interp JALR whose call PC lies in [lo,hi), record which resolution - * tier ran the callee and the FULL host-side outcome: post-call guest state, - * bail/escape flags, and engine-attribution deltas (static-dispatch hits vs - * interp blocks across the call). This is the piece the s3 tripwire lacks: - * the tripwire names the callee that came back smeared; this ring names the - * RETURN PATH that let it come back. Zero-cost when disarmed. */ -uint32_t g_callret_lo = 0, g_callret_hi = 0; -/* MUST stay field-for-field identical to the local mirror `E` in - * debug_server.c handle_callret_watch() (which dumps this ring through an - * opaque extern; a divergence is silent garbage, not a compile error). */ -typedef struct { - uint64_t cycle; uint32_t frame; - uint32_t pc, target, sp_b, ra_b, s0_b, s3_b; - uint32_t path; /* CRES_* code, |0x100 if finish() escaped */ - uint32_t pc_a, ra_a, sp_a, s0_a, s3_a, v0_a; - uint32_t bail_a, rfe_a, esc_a, in_exc_a; - uint32_t dstatic, dblocks; /* engine deltas across the call */ - uint32_t dexc; /* exception entries across the call */ - uint32_t last_func_a; /* g_debug_current_func_addr after */ -} CallRetEnt; -#define CALLRET_CAP 64u -CallRetEnt g_callret_ring[CALLRET_CAP]; -uint64_t g_callret_seq = 0; -/* CRES path codes (JALR tiers, in consult order). */ -enum { CRES_PLAIN = 1, - CRES_EC_BAIL = 2, CRES_EC_PC = 3, CRES_EC_CONTRACT = 4, CRES_EC_RET = 5, - CRES_OVERRIDE = 6, - CRES_OV_BAIL = 7, CRES_OV_PC = 8, CRES_OV_CONTRACT = 9, CRES_OV_RET = 10, - CRES_NL_BAIL = 11, CRES_NL_PC = 12, CRES_NL_RET = 13, - CRES_PCCHAIN = 14 }; -extern uint64_t g_dispatch_static_hits; /* debug_server.c; bumped by generated dispatch */ -extern uint64_t psx_cycle_count; -static uint32_t callret_begin(CPUState *cpu, uint32_t pc, uint32_t target) { - if (!g_callret_lo || pc < g_callret_lo || pc >= g_callret_hi) - return 0xFFFFFFFFu; - uint32_t idx = (uint32_t)(g_callret_seq++ & (CALLRET_CAP - 1u)); - CallRetEnt *e = &g_callret_ring[idx]; - e->cycle = psx_cycle_count; e->frame = (uint32_t)s_frame_count; - e->pc = pc; e->target = target; - e->sp_b = cpu->gpr[29]; e->ra_b = cpu->gpr[31]; - e->s0_b = cpu->gpr[16]; e->s3_b = cpu->gpr[19]; - e->path = 0; - e->dstatic = (uint32_t)g_dispatch_static_hits; - e->dblocks = (uint32_t)g_dirty_ram_blocks_run; - { extern void psx_get_freeze_diag(uint64_t*,uint32_t*,int*,int*,uint64_t*,uint64_t*); - uint64_t exc = 0; psx_get_freeze_diag(NULL, NULL, NULL, NULL, &exc, NULL); - e->dexc = (uint32_t)exc; } - return idx; -} -static void callret_end(uint32_t idx, CPUState *cpu, uint32_t path) { - if (idx == 0xFFFFFFFFu) return; - CallRetEnt *e = &g_callret_ring[idx]; - e->path = path; - e->pc_a = cpu->pc; e->ra_a = cpu->gpr[31]; e->sp_a = cpu->gpr[29]; - e->s0_a = cpu->gpr[16]; e->s3_a = cpu->gpr[19]; e->v0_a = cpu->gpr[2]; - e->bail_a = (uint32_t)g_psx_call_bail; - { extern int g_rfe_escape_pending; extern int g_exc_escape_reason; - e->rfe_a = (uint32_t)g_rfe_escape_pending; - e->esc_a = (uint32_t)g_exc_escape_reason; } - e->in_exc_a = (uint32_t)psx_get_in_exception(); - e->dstatic = (uint32_t)g_dispatch_static_hits - e->dstatic; - e->dblocks = (uint32_t)g_dirty_ram_blocks_run - e->dblocks; - { extern void psx_get_freeze_diag(uint64_t*,uint32_t*,int*,int*,uint64_t*,uint64_t*); - uint64_t exc = 0; psx_get_freeze_diag(NULL, NULL, NULL, NULL, &exc, NULL); - e->dexc = (uint32_t)exc - e->dexc; } - e->last_func_a = g_debug_current_func_addr; -} - -/* Overlay-region floor (phys) = the loaded game's main-EXE text end. Defaults to - * the BIOS-only value; main.cpp pins it to (load_address + text_size) at game - * load. See dirty_ram_interp.h for the per-game rationale. */ -uint32_t g_overlay_region_floor = OVERLAY_REGION_FLOOR_DEFAULT; - -#ifdef PSX_HAS_GAME_DISPATCH -extern int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr); -extern int psx_game_address_in_text(uint32_t addr); -extern int psx_game_is_function_entry(uint32_t addr); /* non-destructive entry test */ -extern int psx_game_text_native_ok(uint32_t addr); -#endif -extern void psx_dispatch_call(CPUState* cpu, uint32_t addr, uint32_t return_addr); - -/* Forward decls from memory.c — used to read instruction bytes. */ -extern uint8_t *memory_get_ram_ptr(void); -extern void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); - -/* MIPS instruction field decoders. */ -static inline uint32_t op_field (uint32_t i) { return (i >> 26) & 0x3Fu; } -static inline uint32_t rs_field (uint32_t i) { return (i >> 21) & 0x1Fu; } -static inline uint32_t rt_field (uint32_t i) { return (i >> 16) & 0x1Fu; } -static inline uint32_t rd_field (uint32_t i) { return (i >> 11) & 0x1Fu; } -static inline uint32_t shamt_field (uint32_t i) { return (i >> 6) & 0x1Fu; } -static inline uint32_t funct_field (uint32_t i) { return i & 0x3Fu; } -static inline uint32_t imm16_field (uint32_t i) { return i & 0xFFFFu; } -static inline int32_t simm16_field(uint32_t i) { return (int32_t)(int16_t)imm16_field(i); } -static inline uint32_t target26 (uint32_t i) { return i & 0x03FFFFFFu; } - -/* Read a 32-bit instruction word from kernel RAM at the given physical addr. - * Caller has already verified the address is in dirty kernel RAM. */ -static inline uint32_t fetch_word(uint32_t phys) { - /* Main RAM is a process-lifetime static allocation. Cache its address so - * instruction fetch does not cross translation units for every guest op. */ - static const uint8_t *ram; - if (!ram) ram = memory_get_ram_ptr(); - return (uint32_t)ram[phys] - | ((uint32_t)ram[phys + 1] << 8) - | ((uint32_t)ram[phys + 2] << 16) - | ((uint32_t)ram[phys + 3] << 24); -} - -/* Widescreen render-funnel cull detection for the interpreter ([widescreen.cull] - * auto_screen_x). A `sltiu …,0x140/0x141` is widened ONLY when its enclosing - * render function also carries a `sltiu …,0xE0/0xF1` (the GTE per-vertex trivial- - * reject signature) — a lone 0x140 elsewhere stays vanilla. The interp has no - * function boundaries, so we scan a +/-512-byte window around the site (clamped - * to main RAM) via the shared psx_ws_func_has_screen_cull, cached per-PC so a - * hot render loop pays the scan once. Single-threaded interp => static buffers ok. */ -/* Per-PC site caches: FULL-PC tagged, sized for a whole overlay's working set - * (the old 256-slot direct map thrashed on any two hot PCs 1 KB apart — the - * ±512-byte rescan then ran per EXECUTED INSTRUCTION and collapsed native-wide - * 16:9 to ~12fps, entirely CPU-side). Entries carry the dirty-RAM code - * generation (memory.c g_dirty_ram_code_gen) so an overlay reload re-derives - * every classification instead of trusting a stale kind. */ -extern uint32_t g_dirty_ram_code_gen; -#define WS_SITE_CACHE_SLOTS 8192u /* 32 KB of code coverage per cache */ - -static int ws_cull_site(uint32_t pc) { - enum { WIN = 128 }; /* +/- 128 words = +/- 512 bytes */ - static struct { uint32_t pc; uint32_t gen; uint32_t word; int8_t flag; } cache[WS_SITE_CACHE_SLOTS]; - uint32_t slot = (pc >> 2) & (WS_SITE_CACHE_SLOTS - 1u); - uint32_t phys = pc & 0x1FFFFFFFu; - /* Entry valid iff pc + generation + the SITE'S OWN INSTRUCTION WORD all - * match. The word tag catches code replaced by plain CPU stores (loader - * memcpy), which never routes through the page-marking hooks the - * generation counter sees — a stale verdict here REWRITES a live GPR and - * corrupts the guest (wild-jump fatal). */ - if (cache[slot].pc == pc && cache[slot].gen == g_dirty_ram_code_gen && - cache[slot].word == fetch_word(phys)) - return cache[slot].flag; - uint32_t lo = (phys > (uint32_t)(WIN * 4)) ? phys - (uint32_t)(WIN * 4) : 0u; - uint32_t hi = phys + (uint32_t)(WIN * 4); - if (hi > 0x200000u) hi = 0x200000u; /* 2 MB main RAM */ - static uint32_t words[2 * WIN + 1]; - int n = 0; - for (uint32_t a = lo; a + 4u <= hi && n < (int)(2 * WIN + 1); a += 4u) - words[n++] = fetch_word(a); - int flag = psx_ws_func_has_screen_cull(words, n); - cache[slot].pc = pc; cache[slot].gen = g_dirty_ram_code_gen; - cache[slot].word = fetch_word(phys); cache[slot].flag = (int8_t)flag; - return flag; -} - -/* Widescreen LEFT-edge funnel-bltz classification (auto_screen_x, signed - * min/max + center±halfwidth idioms — ws_cull_detect.h). Same ±512-byte window - * qualification + per-PC cache discipline as ws_cull_site above; additionally - * classifies THIS bltz structurally (delay-slot width compare / addu-subu - * pair), so an unrelated bltz in a qualifying window stays vanilla. */ -static int ws_cull_bltz_site(uint32_t pc) { - enum { WIN = 128 }; /* +/- 128 words = +/- 512 bytes */ - static struct { uint32_t pc; uint32_t gen; uint32_t word; int8_t flag; } cache[WS_SITE_CACHE_SLOTS]; - uint32_t slot = (pc >> 2) & (WS_SITE_CACHE_SLOTS - 1u); - uint32_t phys = pc & 0x1FFFFFFFu; - if (cache[slot].pc == pc && cache[slot].gen == g_dirty_ram_code_gen && - cache[slot].word == fetch_word(phys)) - return cache[slot].flag; - uint32_t lo = (phys > (uint32_t)(WIN * 4)) ? phys - (uint32_t)(WIN * 4) : 0u; - uint32_t hi = phys + (uint32_t)(WIN * 4); - if (hi > 0x200000u) hi = 0x200000u; /* 2 MB main RAM */ - static uint32_t words[2 * WIN + 1]; - int n = 0; - for (uint32_t a = lo; a + 4u <= hi && n < (int)(2 * WIN + 1); a += 4u) - words[n++] = fetch_word(a); - int idx = (int)((phys - lo) / 4u); - int flag = psx_ws_func_has_screen_cull(words, n) && - psx_ws_cull_bltz_at(words, n, idx); - cache[slot].pc = pc; cache[slot].gen = g_dirty_ram_code_gen; - cache[slot].word = fetch_word(phys); cache[slot].flag = (int8_t)flag; - return flag; -} - -/* Widescreen far-backdrop column-PRELOAD site classification for the interpreter - * ([widescreen.cull] auto_backdrop). The scrolling-backdrop column-window - * generators run interpreted in the dev build, so the recompiler emit can't - * reach them; the interp re-derives the same START/END rewrite sites via the - * shared detector over a +/-512-byte window around the PC (physical space, so - * the detector's absolute-PC math matches the masked PC), cached per-PC so a hot - * generator pays the scan once. Returns WS_BD_NONE / WS_BD_START_ZERO / - * WS_BD_END_WIDEN. Single-threaded interp => static buffers are safe. */ -static int ws_backdrop_site_kind(uint32_t pc, int *out_cols) { - enum { WIN = 128 }; /* +/- 128 words = +/- 512 bytes */ - static struct { uint32_t pc; uint32_t gen; uint32_t word; int8_t kind; int16_t cols; } cache[WS_SITE_CACHE_SLOTS]; - uint32_t slot = (pc >> 2) & (WS_SITE_CACHE_SLOTS - 1u); - uint32_t phys = pc & 0x1FFFFFFFu; - /* pc + generation + site instruction word — see ws_cull_site: the word tag - * is what protects against CPU-store code reloads the generation counter - * cannot see. A stale KIND here is worse than a stale cull flag: it - * substitutes a GPR value and skips the real instruction. */ - if (cache[slot].pc == pc && cache[slot].gen == g_dirty_ram_code_gen && - cache[slot].word == fetch_word(phys)) { - if (out_cols) *out_cols = cache[slot].cols; - return cache[slot].kind; - } - uint32_t lo = (phys > (uint32_t)(WIN * 4)) ? phys - (uint32_t)(WIN * 4) : 0u; - uint32_t hi = phys + (uint32_t)(WIN * 4 + 4); - if (hi > 0x200000u) hi = 0x200000u; /* 2 MB main RAM */ - static uint32_t words[2 * WIN + 2]; - int n = 0; - for (uint32_t a = lo; a + 4u <= hi && n < (int)(2 * WIN + 2); a += 4u) - words[n++] = fetch_word(a); - int cols = 0; - int kind = psx_ws_backdrop_kind_at(words, n, lo, phys, &cols); /* all physical-space */ - cache[slot].pc = pc; cache[slot].gen = g_dirty_ram_code_gen; - cache[slot].word = fetch_word(phys); - cache[slot].kind = (int8_t)kind; cache[slot].cols = (int16_t)cols; - if (out_cols) *out_cols = cols; - return kind; -} - -static void dirty_ram_log_instruction(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t before_s0, uint32_t next_pc, - uint32_t target, int transferred) { - if (g_insn_log_frozen) return; - /* Freeze-on-target (Confirm-(b)): checked BEFORE the gate filter so it fires - * for ANY interpreted instruction transferring to the watched wild PC, even - * if that jr/jalr sits outside the recorded gate range. We do NOT record this - * out-of-gate entry (the gate still governs what's stored); we just stop the - * ring so the in-gate window leading up to the wild jump is preserved. */ - if (g_insn_freeze_on_target != 0u && - (target == g_insn_freeze_on_target || next_pc == g_insn_freeze_on_target)) { - if (!g_freeze_snap_valid) { - g_freeze_snap_pc = pc; - g_freeze_snap_insn = insn; - uint32_t tcb_ptr = cpu->read_word(0x00000108u); - g_freeze_snap_tcb = tcb_ptr ? cpu->read_word(tcb_ptr) : 0; - for (int r = 0; r < 32; r++) g_freeze_snap_gpr[r] = cpu->gpr[r]; - g_freeze_snap_valid = 1; - } - g_insn_log_frozen = 1; - return; - } - uint32_t phys = pc & 0x1FFFFFFFu; - if (!((g_insn_gate_hi != 0u && phys >= g_insn_gate_lo && phys < g_insn_gate_hi) || - (phys >= 0x000E0000u && phys < 0x000F0000u) || - (phys >= 0x000000A0u && phys < 0x000000C0u) || - (phys >= 0x000005C0u && phys < 0x00000620u) || - (phys >= 0x00001E00u && phys < 0x00002000u))) { - return; - } - - uint32_t tcb_ptr_addr = cpu->read_word(0x00000108u); - uint32_t current_tcb = tcb_ptr_addr ? cpu->read_word(tcb_ptr_addr) : 0; - uint32_t task_ptr = cpu->read_word(0x1F8001D4u); - - uint64_t s = g_dirty_ram_insn_log_seq++; - DirtyRamInsnLogEntry *e = - &g_dirty_ram_insn_log[s & (DIRTY_RAM_INSN_LOG_CAP - 1u)]; - e->seq = s; - e->pc = pc; - e->insn = insn; - e->next_pc = next_pc; - e->target = target; - e->before_s0 = before_s0; - e->after_s0 = cpu->gpr[16]; - e->sp = cpu->gpr[29]; - e->ra = cpu->gpr[31]; - e->v0 = cpu->gpr[2]; - e->v1 = cpu->gpr[3]; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; - e->t0 = cpu->gpr[8]; - e->t1 = cpu->gpr[9]; - e->t2 = cpu->gpr[10]; - e->at = cpu->gpr[1]; - e->k0 = cpu->gpr[26]; - e->k1 = cpu->gpr[27]; - e->current_tcb = current_tcb; - e->task_ptr = task_ptr; - e->task_mode = task_ptr ? cpu->read_half(task_ptr + 72u) : 0; - e->task_submode = task_ptr ? cpu->read_half(task_ptr + 74u) : 0; - e->frame = (uint32_t)s_frame_count; - e->transferred = (uint8_t)(transferred ? 1u : 0u); -} - -/* Marker entries delimit NATIVE candidate executions in the insn ring (native - * code emits no per-insn entries; markers keep the two runs' timelines - * alignable). transferred: 200 = native entry, 201 = native exit. */ -void dirty_ram_log_marker(uint32_t addr, uint32_t tag, int kind) { - if (g_insn_log_frozen) return; - uint64_t s = g_dirty_ram_insn_log_seq++; - DirtyRamInsnLogEntry *e = - &g_dirty_ram_insn_log[s & (DIRTY_RAM_INSN_LOG_CAP - 1u)]; - memset(e, 0, sizeof *e); - e->seq = s; - e->pc = addr; - e->target = tag; - e->frame = (uint32_t)s_frame_count; - e->transferred = (uint8_t)(200 + kind); -} - -/* LWL/LWR are merge-only here; the timed aligned-word read is done by the caller via - * psx_cyc_load_word (Beetle reads the aligned word; GPR_DEP rs only, arms LDWhich=rt). */ -static inline uint32_t lwl_merge(uint32_t addr, uint32_t word, uint32_t rt_value) { - switch (addr & 3u) { - case 0: return (rt_value & 0x00FFFFFFu) | (word << 24); - case 1: return (rt_value & 0x0000FFFFu) | (word << 16); - case 2: return (rt_value & 0x000000FFu) | (word << 8); - default: return word; - } -} - -static inline uint32_t lwr_merge(uint32_t addr, uint32_t word, uint32_t rt_value) { - switch (addr & 3u) { - case 0: return word; - case 1: return (rt_value & 0xFF000000u) | (word >> 8); - case 2: return (rt_value & 0xFFFF0000u) | (word >> 16); - default: return (rt_value & 0xFFFFFF00u) | (word >> 24); - } -} - -static inline void interp_swl(CPUState *cpu, uint32_t addr, uint32_t value) { - uint32_t aligned = addr & ~3u; - uint32_t word = cpu->read_word(aligned); - switch (addr & 3u) { - case 0: word = (word & 0xFFFFFF00u) | (value >> 24); break; - case 1: word = (word & 0xFFFF0000u) | (value >> 16); break; - case 2: word = (word & 0xFF000000u) | (value >> 8); break; - default: word = value; break; - } - cpu->write_word(aligned, word); -} - -static inline void interp_swr(CPUState *cpu, uint32_t addr, uint32_t value) { - uint32_t aligned = addr & ~3u; - uint32_t word = cpu->read_word(aligned); - switch (addr & 3u) { - case 0: word = value; break; - case 1: word = (word & 0x000000FFu) | (value << 8); break; - case 2: word = (word & 0x0000FFFFu) | (value << 16); break; - default: word = (word & 0x00FFFFFFu) | (value << 24); break; - } - cpu->write_word(aligned, word); -} - -/* Soft-fail thread-local flag. When the interpreter encounters an opcode - * it doesn't implement, it sets this flag and returns instead of aborting, - * letting the caller (psx_dispatch via dirty_ram_dispatch) fall back to - * psx_unknown_dispatch — which has its own ad-hoc resolver for known - * trampoline patterns (jr-based vector dispatch, etc.). - * - * This is a deliberate retreat from "always pick the most complete option" - * for ONE narrow case: dispatch into pages that have been written-to but - * don't actually contain valid stub code at the dispatched PC (e.g. - * stale data, return-target addresses that point to non-code areas). The - * pre-existing psx_unknown_dispatch already handles those — we just need - * to let it. If a true install stub uses an opcode we don't have, this - * will silently route it to psx_unknown_dispatch, which will likely return - * a no-op cpu->pc=0. When that happens, we'll see "card protocol stalls" - * in measurement and add the missing opcode here. */ -static int g_unsupported_seen = 0; -static uint32_t g_unsupported_pc = 0; -static uint32_t g_unsupported_insn = 0; -static const char *g_unsupported_reason = NULL; - -static int abort_unsupported(uint32_t pc, uint32_t insn, const char *reason) { - g_dirty_ram_aborts++; - g_unsupported_seen = 1; - g_unsupported_pc = pc; - g_unsupported_insn = insn; - g_unsupported_reason = reason; - return 1; /* signal "control transferred" so the caller stops */ -} - -/* A dirty page that should run as a locally-chained overlay: anything ABOVE the - * kernel window. The kernel window [0, DIRTY_RAM_KERNEL_WINDOW_END) intentionally - * stays per-block (it runs in exception context, where interrupt-check cadence and - * EPC handling are delicate — see dirty_ram_interp.h window note; kernel native - * coverage comes from overlay_loader candidates, not interp chaining). - * - * NOTE this is the kernel-window end, NOT OVERLAY_REGION_FLOOR. The floor is the - * end of the boot-EXE text, and the original design assumed [0x10000, FLOOR) is - * immutable statically-recompiled text. That assumption is FALSE for games that - * discard their boot/init code and load gameplay overlays OVER the boot-text region - * (Tomba 2: a START.BIN-loader overlay at 0x8001Dxxx, well below its 0x38800 floor). - * Such a page is dirty (RAM != compiled image), so dispatching to it must interpret - * the live overlay and chain locally — exactly like the [FLOOR, RAM_SIZE) overlay - * region — NOT run the stale compiled image or route through the bail-prone - * non-local-call contract (the Whoopee-Camp splash wild-jr). dirty_ram_is_dirty() - * keeps clean boot text on the fast compiled path; only overwritten pages divert. */ -static inline int phys_is_overlay_flow_region(uint32_t phys) { - return phys >= DIRTY_RAM_KERNEL_WINDOW_END; -} - -static int is_local_dirty_target(uint32_t target) { - uint32_t phys = target & 0x1FFFFFFFu; - return phys_is_overlay_flow_region(phys) && dirty_ram_is_dirty(phys); -} - -/* Target the last interp run handed back to the dispatch loop (chained - * continuation). Consumed by the next dirty dispatch to tell apart - * external entries (from native code) from the interpreter's own - * block-to-block chaining. */ -static uint32_t g_dirty_interp_chain_target = 0; - -/* A dirty-RAM target only deserves interpretation if its first word decodes - * as a plausible MIPS instruction. A scatter-loaded overlay leaves data - * bytes in dirty pages; jumping into those and interpreting them as code is - * how the original cache build produced black/blue screens. When the target - * word doesn't look like code, fall back to the normal dispatch path. */ -static int dirty_ram_word_looks_decodable(uint32_t insn) { - if (insn == 0xFFFFFFFFu || insn == 0xFFFFFFFDu) return 0; - - uint32_t op = op_field(insn); - uint32_t fn = funct_field(insn); - uint32_t rt = rt_field(insn); - - if (op == 0x00u) { - switch (fn) { - case 0x00u: case 0x02u: case 0x03u: case 0x04u: - case 0x06u: case 0x07u: case 0x08u: case 0x09u: - case 0x0Cu: case 0x0Du: - case 0x10u: case 0x11u: case 0x12u: case 0x13u: - case 0x18u: case 0x19u: case 0x1Au: case 0x1Bu: - case 0x20u: case 0x21u: case 0x22u: case 0x23u: - case 0x24u: case 0x25u: case 0x26u: case 0x27u: - case 0x2Au: case 0x2Bu: - return 1; - default: - return 0; - } - } - if (op == 0x01u) { - return rt == 0x00u || rt == 0x01u || rt == 0x10u || rt == 0x11u; - } - - switch (op) { - case 0x02u: case 0x03u: case 0x04u: case 0x05u: - case 0x06u: case 0x07u: - case 0x08u: case 0x09u: case 0x0Au: case 0x0Bu: - case 0x0Cu: case 0x0Du: case 0x0Eu: case 0x0Fu: - case 0x10u: case 0x12u: - case 0x20u: case 0x21u: case 0x22u: case 0x23u: - case 0x24u: case 0x25u: case 0x26u: - case 0x28u: case 0x29u: case 0x2Au: case 0x2Bu: - case 0x2Eu: - case 0x30u: case 0x32u: case 0x38u: case 0x3Au: - return 1; - default: - return 0; - } -} - -static int dirty_ram_same_pc(uint32_t a, uint32_t b) { - return (((a ^ b) & 0x1FFFFFFFu) == 0); -} - -static int dirty_ram_pump_boundary(CPUState *cpu, uint32_t committed_pc, int site) { - uint32_t prev_safe = g_dirty_safe_resume_pc; - int prev_site = g_cosim_dirty_pump_site; - - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - if (!psx_interrupts_checked_at_current_cycle(committed_pc)) { - g_dirty_safe_resume_pc = committed_pc; - g_cosim_dirty_pump_site = site; - psx_check_interrupts(cpu); - g_dirty_safe_resume_pc = prev_safe; - g_cosim_dirty_pump_site = prev_site; - } - - if (g_psx_call_bail) return 1; - if (cpu->pc == 0u && committed_pc != 0u) { - cpu->pc = committed_pc; - g_async_rfe_fire_count++; - } - if (cpu->pc != 0u && !dirty_ram_same_pc(cpu->pc, committed_pc)) { - return 1; - } - - cpu->pc = committed_pc; - return 0; -} - -static int dirty_ram_finish_call_return(CPUState *cpu, uint32_t return_pc, - uint32_t *next_pc_out) { - uint32_t prev_pc = cpu->pc; - cpu->pc = return_pc; - if (dirty_ram_pump_boundary(cpu, return_pc, 5)) return 1; - cpu->pc = prev_pc; - *next_pc_out = return_pc; - return 0; -} - -/* Sub-outcome of the last dispatch_nonlocal_call, for the callret ring. */ -static uint32_t g_nl_exit_code = 0; -static int dispatch_nonlocal_call(CPUState *cpu, uint32_t target, - uint32_t return_pc, - uint32_t *next_pc_out) { - cpu->pc = 0; - { - int prev_phase = g_exec_phase; - g_exec_phase = 3; /* compiled route; dirty/native callees re-tag inside */ - psx_dispatch_call(cpu, target, return_pc); - g_exec_phase = prev_phase; - } - /* psx_dispatch_call validated the (return_pc, sp) contract; a bail - * unwind in progress surfaces with cpu->pc = the guest's true target. */ - if (g_psx_call_bail) { g_nl_exit_code = CRES_NL_BAIL; return 1; } - if (cpu->pc != 0) { g_nl_exit_code = CRES_NL_PC; return 1; } - g_nl_exit_code = CRES_NL_RET; - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); -} - -/* ── Mixed interp<->compiled dispatch owner (host-stack recursion fix) ────── - * The dirty interpreter nests psx_dispatch_game_compiled when an interpreted - * block transfers into compiled code. A guest tail-dispatch loop that crosses the - * boundary (an interpreted overlay <-> the main-EXE per-frame loop func_8001A954) - * grows the HOST stack unboundedly while the GUEST stack stays flat — the long-run - * freeze (docs/RECURSION_BUG.md). The compiled side keeps tail-call loops flat via - * the psx_dispatch_impl trampoline + g_psx_call_bail unwind; the interpreter - * bypassed it by nesting directly. - * - * Fix: when an interp->compiled crossing would push the HOST stack past a safe - * watermark, publish the target as a wild-flow bail instead of nesting. The - * existing contract (compiled frames honor `if (g_psx_call_bail) return;`) unwinds - * it to the OUTERMOST psx_dispatch_impl, whose flatten path clears the bail and - * re-dispatches cpu->pc in its loop — so the mutual tail-recursion iterates with - * bounded native depth. Runtime-only (reuses the existing bail + flatten; no regen). - * - * The trigger is the ACTUAL host-stack usage (TEB StackBase - rsp), NOT a nesting - * counter. A counter LEAKS across the fiber exception path (psx_exception_longjmp - * never returns, so interp_enter_compiled's post-call decrement is skipped) and - * creeps up until it falsely trips on a benign call — observed as the FIRST dialogue - * freezing. Stack usage can't leak and is the real resource we protect: legitimate - * code (boot, dialogue, deep-but-bounded recursion) stays far below the watermark; - * only the runaway approaches it. Toggle off with PSX_MIXED_OWNER=0; watermark via - * PSX_MIXED_STACK_KB (default 700; fiber ~1MB, native-stack guard ~768KB used). */ -static int psx_mixed_owner_enabled(void) { - static int v = -1; - if (v < 0) { const char *e = getenv("PSX_MIXED_OWNER"); v = (e && e[0] == '0') ? 0 : 1; } - return v; -} - -#ifdef _WIN32 -#include /* __readgsqword — fiber TEB StackBase */ -static size_t interp_host_stack_used(void) { - char probe; - uintptr_t base = (uintptr_t)__readgsqword(0x08); /* TEB StackBase (high end) */ - uintptr_t sp = (uintptr_t)&probe; - return (base > sp) ? (size_t)(base - sp) : 0; -} -#else -static size_t interp_host_stack_used(void) { return 0; } -#endif - -static size_t psx_mixed_stack_watermark(void) { - static long v = -1; - if (v < 0) { const char *e = getenv("PSX_MIXED_STACK_KB"); - v = (e && *e) ? atol(e) : 700; if (v < 64) v = 64; } - return (size_t)v * 1024u; -} - -/* ── Flight recorder: per-frame count of the re-entry edge interp->0x8001A954 ── - * (RECURSION_BUG.md §15). The long-run freeze is a per-frame update that, at - * ~frame 50k, re-enters func_8001A954 (the per-frame loop head) unboundedly via - * an interpreted overlay. Record, per emulated frame, how many times that edge - * fires + the cycle counter, in a ring the crash report dumps. Answers: is the - * re-entry ORDINARY bounded per-frame behavior that stops TERMINATING at ~50k - * (count goes unbounded), or a brand-new edge — and whether cycles advance. - * Only the INTERP->compiled re-entry passes through here; the normal compiled - * per-frame entry to func_8001A954 does not, so this isolates the suspect edge. */ -extern uint64_t psx_cycle_count; -#define SITE_CAP 32u /* 3 sites x 256 overflowed crash_trace's 8KB sub-buffer; 32 frames of lead-up is plenty */ -typedef struct { uint32_t frame; uint32_t count; uint64_t cycle; } SiteEntry; -typedef struct { SiteEntry ring[SITE_CAP]; uint64_t seq; uint32_t cur; - uint32_t last_frame; uint32_t maxf; } SiteRec; -/* Per-frame dispatch-count recorders for the suspect loop sites (RECURSION_BUG.md - * §15). g_site_interp (interp->0x8001A954) was found ORDINARY (1/frame); the - * recursion re-dispatches the compiled loop via dirty_ram_dispatch's entry because - * the CD-DMA dirty bitmap is saturated. So record dirty_ram_dispatch(0x8001A954) - * = loop head and dirty_ram_dispatch(0x80046264) = loop tail. Bounded normally, - * spike at the freeze => "the per-frame loop stopped terminating." */ -static SiteRec g_site_interp = { .last_frame = 0xFFFFFFFFu }; /* interp -> 0x8001A954 */ -static SiteRec g_site_dd954 = { .last_frame = 0xFFFFFFFFu }; /* dirty_ram_dispatch(0x8001A954) */ -static SiteRec g_site_dd264 = { .last_frame = 0xFFFFFFFFu }; /* dirty_ram_dispatch(0x80046264) */ - -static void site_note(SiteRec *s) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s->last_frame) { - if (s->last_frame != 0xFFFFFFFFu) { - SiteEntry *e = &s->ring[s->seq++ & (SITE_CAP - 1u)]; - e->frame = s->last_frame; e->count = s->cur; e->cycle = psx_cycle_count; - if (s->cur > s->maxf) s->maxf = s->cur; - } - s->cur = 0; s->last_frame = f; - } - s->cur++; -} - -static int site_json(char *out, int cap, const char *name, const SiteRec *s) { - int n = snprintf(out, cap, " \"%s\": {\"max_per_frame\":%u,\"cur_frame\":%u,\"cur_count\":%u,\"history\":[", - name, s->maxf, s->last_frame, s->cur); - uint64_t total = s->seq; - int avail = (total < SITE_CAP) ? (int)total : (int)SITE_CAP; - uint64_t start = total - (uint64_t)avail; - for (int i = 0; i < avail && n < cap - 96; i++) { - const SiteEntry *e = &s->ring[(start + (uint64_t)i) & (SITE_CAP - 1u)]; - n += snprintf(out + n, cap - n, "%s{\"f\":%u,\"n\":%u,\"cyc\":%llu}", - i ? "," : "", e->frame, e->count, (unsigned long long)e->cycle); - } - n += snprintf(out + n, cap - n, "]},\n"); - return n; -} - -/* Emit all three loop-site recorders as JSON (called by crash_trace). */ -int dirty_ram_re954_json(char *out, int cap) { - int n = 0; - n += site_json(out + n, cap - n, "reentry_interp_8001A954", &g_site_interp); - n += site_json(out + n, cap - n, "dispatch_8001A954", &g_site_dd954); - n += site_json(out + n, cap - n, "dispatch_80046264", &g_site_dd264); - return n; -} - -/* ── Boundary control-flow flight recorder (RECURSION_BUG.md §18) ────────────── - * The long-run freeze is a runaway interp↔compiled re-entry. To choose among the - * four shapes (gradual boundary leak / sudden same-frame spin / interpreter - * fabrication / real guest wait-loop) WITHOUT assuming any of them, record every - * interp→compiled crossing at BOTH nesting sites (interp_enter_compiled for guest - * JAL/JALR, and dirty_ram_dispatch_inner's psx_dispatch_game_compiled) plus the - * watched-set tail-transfers (J/JR to the loop addresses). Two always-on rings: - * - per-FRAME summary -> the time-series (does depth/crossings grow across - * frames, or explode in one?). Answers Q1/Q2/Q3. - * - per-CROSSING detail -> src_pc, decoded op, target, delay-slot insn, sp/ra, - * mixed depth, host-stack KB, I_STAT/I_MASK. Answers Q4. - * Trips EARLY (per-frame crossings or host-stack KB over a low threshold) so the - * detail ring still holds the ONSET (the transition frame's first crossings), not - * the 23k steady state that the terminal native-stack guard would capture. The - * dump routes through the normal crash report (xprobe_json), reusing the proven - * flush+halt+serve path. Watched set is flagged, but ALL crossings are recorded - * so the real driver address is not pre-assumed. NB: there is no due-cycle event - * scheduler in this build (psx_cycles.c), so "next scheduled event cycle" is N/A; - * cycles-advanced-per-frame + I_STAT/I_MASK cover event progress instead. */ -extern uint32_t i_stat; /* owned by memory.c */ -extern uint32_t i_mask; - -enum { XOP_JAL = 0, XOP_JALR = 1, XOP_JR = 2, XOP_J = 3, XOP_DD = 4, XOP_BR = 5, - XOP_RES = 6 /* watched-target call RESOLUTION: site = path code (XRES_*), - ds_insn = v0 after the path ran */ }; -enum { XSITE_INTERP = 0, XSITE_DD = 1 }; -/* XOP_RES path codes (in the `site` field). */ -enum { XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5, - XRES_OV_BAIL = 6, XRES_OV_PC = 7, XRES_OV_CONTRACT = 8, XRES_OV_RET = 9, - XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12 }; - -int g_mixed_depth = 0; /* best-effort interp→compiled nesting depth; reset per frame */ - -typedef struct { - uint32_t frame; uint64_t cycle; uint32_t src_pc; uint32_t target; - uint32_t ds_insn; uint32_t sp; uint32_t ra; uint32_t stk_kb; - uint32_t istat; uint32_t imask; uint16_t depth; uint8_t op; uint8_t site; -} XDetail; -typedef struct { - uint32_t frame; uint32_t crossings; uint32_t a954; int32_t depth_max; - uint32_t stk_max_kb; uint64_t cyc_advanced; -} XSum; - -#define XDET_CAP 16384u -#define XSUM_CAP 512u -static XDetail g_xdet[XDET_CAP]; -static XSum g_xsum[XSUM_CAP]; -static uint64_t g_xdet_seq = 0, g_xsum_seq = 0; - -/* per-frame accumulators */ -static uint32_t s_xf_frame = 0xFFFFFFFFu; -static uint32_t s_xf_crossings = 0, s_xf_a954 = 0; -static int32_t s_xf_depth_max = 0; -static uint32_t s_xf_stk_max_kb = 0; -static uint64_t s_xf_cyc_start = 0; - -/* first crossing where mixed depth became > 0 this run (Q1) */ -static uint32_t s_xf_first_depth_frame = 0xFFFFFFFFu; -static uint64_t s_xf_first_depth_cycle = 0; -static int s_xprobe_tripped = 0; - -/* Trip thresholds — DISABLED by default (0). Deep but LEGITIMATE boot stacks - * reach ~300KB host at crossing points (mixed-depth small) and would spuriously - * trip an absolute threshold. So arm at RUNTIME via the `xprobe_arm` TCP command - * once the game is idling and the normal per-frame baseline is known (idle stack - * is a flat ~5KB, so a threshold ~3x the idle crossing baseline / a stack KB well - * above 5KB then fires only on the runaway, with the onset still in the ring). - * warmup suppresses any trip before that frame regardless. Env overrides allow - * arming at launch (PSX_XPROBE_FRAME_TRIP / _STK_KB / _WARMUP). */ -int g_xprobe_frame_trip = 0; /* per-frame crossings; 0 = disabled */ -int g_xprobe_stk_kb = 0; /* host-stack KB; 0 = disabled */ -int g_xprobe_warmup = 0; /* no trip before this frame */ -static int g_xprobe_env_done = 0; -static void xprobe_env_init(void) { - if (g_xprobe_env_done) return; - g_xprobe_env_done = 1; - const char *a = getenv("PSX_XPROBE_FRAME_TRIP"); if (a && *a) g_xprobe_frame_trip = atoi(a); - const char *b = getenv("PSX_XPROBE_STK_KB"); if (b && *b) g_xprobe_stk_kb = atoi(b); - const char *c = getenv("PSX_XPROBE_WARMUP"); if (c && *c) g_xprobe_warmup = atoi(c); -} -void dirty_ram_xprobe_arm(int frame_trip, int stk_kb, int warmup) { - g_xprobe_frame_trip = frame_trip; g_xprobe_stk_kb = stk_kb; g_xprobe_warmup = warmup; - s_xprobe_tripped = 0; /* (re-)arm */ -} - -static void xprobe_flush_frame(void) { - if (s_xf_frame == 0xFFFFFFFFu) return; - XSum *s = &g_xsum[g_xsum_seq++ & (XSUM_CAP - 1u)]; - s->frame = s_xf_frame; s->crossings = s_xf_crossings; s->a954 = s_xf_a954; - s->depth_max = s_xf_depth_max; s->stk_max_kb = s_xf_stk_max_kb; - s->cyc_advanced = psx_cycle_count - s_xf_cyc_start; -} - -/* Record one boundary crossing. want_detail=1 for interp-site guest transfers - * (rich context), 0 for the dd-site (count + depth only). Runs the per-frame - * flush on frame change (leak-proof reset of g_mixed_depth) and the early trip. */ -static int g_xprobe_watch(uint32_t t) { - return t == 0x8001A954u || t == 0x80046264u || t == 0x8004630Cu || t == 0x8004DFA0u - /* MMX6 card-load firstfile flow (mmx6_card_load_regression_state): - * the mount 0x8001C1AC (works) vs firstfile flow 0x8001C4C0 (its body - * never runs) — record every call event + resolution for both. */ - || t == 0x8001C1ACu || t == 0x8001C4C0u; -} -static void xprobe_event(uint32_t src_pc, uint8_t op, uint8_t site, uint32_t target, - uint32_t ds_insn, uint32_t sp, uint32_t ra, int want_detail); -/* Watched-target call note for NON-interp call sites (overlay shard call-outs - * via dispatch_call). phase 0 = before the call (ds = a0), - * phase 1 = after it returned (ds = v0). Recorded as XOP_RES with - * site = 20+phase so the `watched` dump shows the shard-side call + result. */ -void dirty_ram_xprobe_call_note(CPUState *cpu, uint32_t target, uint32_t ra, uint8_t phase) { - if (!g_xprobe_watch(target)) return; - xprobe_event(ra, XOP_RES, (uint8_t)(20u + phase), target, - phase ? cpu->gpr[2] : cpu->gpr[4], cpu->gpr[29], cpu->gpr[31], 1); -} -static void xprobe_event(uint32_t src_pc, uint8_t op, uint8_t site, uint32_t target, - uint32_t ds_insn, uint32_t sp, uint32_t ra, int want_detail) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s_xf_frame) { - xprobe_flush_frame(); - s_xf_frame = f; s_xf_crossings = 0; s_xf_a954 = 0; s_xf_depth_max = 0; - s_xf_stk_max_kb = 0; s_xf_cyc_start = psx_cycle_count; - g_mixed_depth = 0; /* shallow at frame top (proven ~5KB) — leak-proof reset */ - } - s_xf_crossings++; - if (target == 0x8001A954u) s_xf_a954++; - if (g_mixed_depth > s_xf_depth_max) s_xf_depth_max = g_mixed_depth; - if (g_mixed_depth > 0 && s_xf_first_depth_frame == 0xFFFFFFFFu) { - s_xf_first_depth_frame = f; s_xf_first_depth_cycle = psx_cycle_count; - } - uint32_t stk = (uint32_t)(interp_host_stack_used() >> 10); - if (stk > s_xf_stk_max_kb) s_xf_stk_max_kb = stk; - - if (want_detail || g_xprobe_watch(target)) { - XDetail *e = &g_xdet[g_xdet_seq++ & (XDET_CAP - 1u)]; - e->frame = f; e->cycle = psx_cycle_count; e->src_pc = src_pc; e->target = target; - e->ds_insn = ds_insn; e->sp = sp; e->ra = ra; e->stk_kb = stk; - e->istat = i_stat; e->imask = i_mask; - e->depth = (uint16_t)(g_mixed_depth > 0xFFFF ? 0xFFFF : g_mixed_depth); - e->op = op; e->site = site; - } - - xprobe_env_init(); - if (!s_xprobe_tripped && f >= (uint32_t)g_xprobe_warmup && - ((g_xprobe_frame_trip > 0 && (int)s_xf_crossings > g_xprobe_frame_trip) || - (g_xprobe_stk_kb > 0 && (int)stk > g_xprobe_stk_kb))) { - s_xprobe_tripped = 1; - extern void psx_fatal_halt(const char *reason); - psx_fatal_halt("xprobe: interp<->compiled boundary onset trip " - "(per-frame crossings/host-stack over armed threshold — see xprobe rings)"); - } -} - -/* Emit the flight-recorder rings as JSON (called by crash_trace). The detail dump - * is OLDEST-first so the transition (last normal frame -> first runaway crossings) - * is visible; the trip fires early enough that the onset is still in the ring. */ -int dirty_ram_xprobe_json(char *out, int cap) { - xprobe_flush_frame(); /* fold the in-progress frame into the summary */ - int n = snprintf(out, cap, - "{\n" - " \"tripped\": %d, \"trip_frame\": %u, \"mixed_depth_now\": %d,\n" - " \"first_depth_frame\": %u, \"first_depth_cycle\": %llu,\n" - " \"frame_trip\": %d, \"stk_kb_trip\": %d, \"warmup\": %d,\n", - s_xprobe_tripped, s_xf_frame, g_mixed_depth, - s_xf_first_depth_frame, (unsigned long long)s_xf_first_depth_cycle, - g_xprobe_frame_trip, g_xprobe_stk_kb, g_xprobe_warmup); - - /* watched-target events FIRST (sparse, the highest-value content): the WHOLE - * ring filtered to g_xprobe_watch targets + every XOP_RES resolution record. - * Emitted before summary/detail so the 56KB response cap can never truncate - * it (it truncated to [] when this section trailed the detail dump). */ - n += snprintf(out + n, cap - n, " \"watched\": ["); - { - static const char *opn2[] = {"JAL","JALR","JR","J","DD","BR","RES"}; - uint64_t total = g_xdet_seq; - uint32_t avail = total < XDET_CAP ? (uint32_t)total : XDET_CAP; - uint64_t base = total - avail; - int firstw = 1; - for (uint32_t i = 0; i < avail && n < cap - 240; i++) { - const XDetail *e = &g_xdet[(base + i) & (XDET_CAP - 1u)]; - if (e->op != XOP_RES && !g_xprobe_watch(e->target)) continue; - n += snprintf(out + n, cap - n, - "%s{\"f\":%u,\"cyc\":%llu,\"op\":\"%s\",\"site\":%u,\"src\":\"0x%08X\"," - "\"tgt\":\"0x%08X\",\"ds\":\"0x%08X\",\"sp\":\"0x%08X\",\"ra\":\"0x%08X\"," - "\"d\":%u,\"stk\":%u}", - firstw ? "" : ",", e->frame, (unsigned long long)e->cycle, - e->op < 7 ? opn2[e->op] : "?", e->site, e->src_pc, e->target, e->ds_insn, - e->sp, e->ra, e->depth, e->stk_kb); - firstw = 0; - } - } - n += snprintf(out + n, cap - n, "],\n"); - - /* per-frame summary (whole window in the ring) */ - n += snprintf(out + n, cap - n, " \"summary\": ["); - { - uint64_t total = g_xsum_seq; - uint32_t avail = total < XSUM_CAP ? (uint32_t)total : XSUM_CAP; - uint64_t start = total - avail; - for (uint32_t i = 0; i < avail && n < cap - 160; i++) { - const XSum *s = &g_xsum[(start + i) & (XSUM_CAP - 1u)]; - n += snprintf(out + n, cap - n, - "%s{\"f\":%u,\"cr\":%u,\"a954\":%u,\"dmax\":%d,\"stk\":%u,\"cyc\":%llu}", - i ? "," : "", s->frame, s->crossings, s->a954, s->depth_max, - s->stk_max_kb, (unsigned long long)s->cyc_advanced); - } - } - n += snprintf(out + n, cap - n, "],\n"); - - /* per-crossing detail, CENTERED on the focus frame (trip frame, or the current - * frame when live): dump from ~24 crossings before the focus frame's first - * crossing forward, so the transition (last normal frame -> runaway onset) is - * always captured regardless of ring fill. */ - n += snprintf(out + n, cap - n, " \"detail\": ["); - { - static const char *opn[] = {"JAL","JALR","JR","J","DD","BR"}; - uint64_t total = g_xdet_seq; - uint32_t avail = total < XDET_CAP ? (uint32_t)total : XDET_CAP; - uint64_t base = total - avail; /* oldest live entry */ - uint32_t focus = s_xf_frame; - uint32_t first = avail; /* first index of the focus frame */ - for (uint32_t i = 0; i < avail; i++) { - if (g_xdet[(base + i) & (XDET_CAP - 1u)].frame == focus) { first = i; break; } - } - uint32_t starto = (first == avail) ? (avail > 24u ? avail - 24u : 0u) - : (first > 24u ? first - 24u : 0u); - int firstdump = 1; - for (uint32_t i = starto; i < avail && n < cap - 240; i++) { - const XDetail *e = &g_xdet[(base + i) & (XDET_CAP - 1u)]; - n += snprintf(out + n, cap - n, - "%s{\"f\":%u,\"cyc\":%llu,\"op\":\"%s\",\"site\":%u,\"src\":\"0x%08X\"," - "\"tgt\":\"0x%08X\",\"ds\":\"0x%08X\",\"sp\":\"0x%08X\",\"ra\":\"0x%08X\"," - "\"d\":%u,\"stk\":%u,\"ist\":\"0x%X\",\"imk\":\"0x%X\"}", - firstdump ? "" : ",", e->frame, (unsigned long long)e->cycle, - e->op < 6 ? opn[e->op] : "?", e->site, e->src_pc, e->target, e->ds_insn, - e->sp, e->ra, e->depth, e->stk_kb, e->istat, e->imask); - firstdump = 0; - } - } - n += snprintf(out + n, cap - n, "]\n }"); - return n; -} - -/* Enter compiled code from the interpreter for a guest control transfer. Returns - * like psx_dispatch_game_compiled (1 = handled, OR a stack-watermark bail was - * surfaced; 0 = not a compiled target — caller falls through to overlay/dirty/ - * nonlocal paths). On surface: cpu->pc = target and g_psx_call_bail set. - * - * Only present when a game dispatch table is linked (PSX_HAS_GAME_DISPATCH). - * In the BIOS-only build psx_dispatch_game_compiled does not exist, and the - * two callers below are themselves gated behind the same macro. */ -#ifdef PSX_HAS_GAME_DISPATCH -static int interp_enter_compiled(CPUState *cpu, uint32_t target) { - if (target == 0x8001A954u) site_note(&g_site_interp); - /* Decline when the target page no longer matches the static game image. - * Returning 0 lets the JAL/JALR handler fall through to local-flow interp - * of the live RAM bytes instead of running stale compiled code. */ - if (!psx_game_text_native_ok(target)) return 0; - if (psx_mixed_owner_enabled() - && interp_host_stack_used() > psx_mixed_stack_watermark()) { - cpu->pc = target; - g_psx_call_bail = 1; - return 1; - } - g_mixed_depth++; - ls_func_enter(target, cpu); - int prev_phase = g_exec_phase; - g_exec_phase = 3; - int r = psx_dispatch_game_compiled(cpu, target); - g_exec_phase = prev_phase; - ls_func_exit(target, cpu, r); - g_mixed_depth--; - return r; -} -#endif /* PSX_HAS_GAME_DISPATCH */ - -/* Execute ONE instruction at *pc on the given CPU state. Returns: - * 0 = continue (advance pc by 4) - * 1 = control transferred OR unsupported opcode (caller checks - * g_unsupported_seen to distinguish). - * Branches encode their delay slot themselves before returning 1. */ -static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out); -static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { - return exec_one_fetched(cpu, pc, fetch_word(pc & 0x1FFFFFFFu), next_pc_out); -} - -/* Forward: helper for delay-slot execution on jumps/branches. */ -static void exec_delay_slot(CPUState *cpu, uint32_t pc) { - /* Delay-slot instruction at pc must NOT be a control transfer. - * Recursively interpret as a single non-branching instruction. */ - uint32_t ds_phys = pc & 0x1FFFFFFFu; - uint32_t insn = fetch_word(ds_phys); - uint32_t opc = op_field(insn); - uint32_t fnt = funct_field(insn); - /* Reject branches/jumps in delay slots — undefined on R3000A and our - * static recompiler explicitly handles this case differently (the - * fall-through fix from 2026-04-21). In install stubs, delay slots - * are always nop or simple arithmetic. */ - if (opc == 0x02 /*j*/ || opc == 0x03 /*jal*/ || - opc == 0x04 /*beq*/ || opc == 0x05 /*bne*/ || - opc == 0x06 /*blez*/ || opc == 0x07 /*bgtz*/ || - opc == 0x01 /*regimm*/ || - (opc == 0x00 && (fnt == 0x08 /*jr*/ || fnt == 0x09 /*jalr*/))) { - (void)abort_unsupported(pc, insn, "control-transfer in delay slot"); - return; - } - uint32_t dummy_next = 0; - (void)exec_one_fetched(cpu, pc, insn, &dummy_next); - g_dirty_ram_insns_run++; - dirty_ram_perf_hot_record(pc); - /* CYCLE MODEL: the delay-slot instruction is a real retired R3000A instruction - * and is charged its own per-instruction interlock INSIDE exec_one (top-of-fn - * §1+deps+DO_LDS, or psx_cyc_load_* for a load delay slot) — so a branch+slot - * pair costs both, matching hardware. No separate charge here. */ -} - -/* Load-delay shim around the decoder (see dirty_ram_ld_delay_flush above). - * Wrapping here keeps every individual load case untouched: the inner decoder - * still writes gpr[rt] eagerly, and we roll that write back by one instruction - * so the delay-slot instruction observes the architecturally-correct value. */ -static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out); - -#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA -extern void disruptor_vertical_camera_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_vertical_camera_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003AD54u: case 0x8003ADE4u: case 0x8003B140u: - case 0x8003B1ECu: case 0x8003B764u: case 0x8003B990u: - case 0x8003BDA4u: case 0x8003C4B4u: case 0x8003C99Cu: - case 0x8003D07Cu: case 0x8003F6B0u: - return insn == 0x34020078u; - case 0x8003D398u: - return insn == 0x34030078u; - case 0x8003B900u: - return insn == 0x0205102Au; - case 0x8003B90Cu: - return insn == 0x0202102Au; - case 0x8003BD14u: - return insn == 0x0206102Au; - case 0x8003BD20u: - return insn == 0x0202102Au; - case 0x8003CDB8u: - return insn == 0x0065102Au; - case 0x8003CDC4u: - return insn == 0x0062102Au; - case 0x8002E4B8u: - return insn == 0x92640022u; - case 0x8002EAF0u: - return insn == 0x92430020u; - case 0x8004279Cu: - return insn == 0x8FBF016Cu; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -extern void disruptor_far_rendering_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_far_rendering_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003A0F8u: - return insn == 0x93B300C0u; - case 0x8003A390u: - return insn == 0xA082001Fu; - case 0x8003A92Cu: - return insn == 0x00005812u; - case 0x8003A964u: - return insn == 0x00681021u; - case 0x8003AA74u: - return insn == 0x00AC1021u; - case 0x8003A914u: - return insn == 0x8F8C04A0u; - case 0x8003B8C8u: case 0x8003C168u: case 0x8003CD64u: - return insn == 0x8F8304A0u; - case 0x8003BCE0u: - return insn == 0x8F8204A0u; - case 0x8003B8D8u: - return insn == 0x0072182Au; - case 0x8003BCECu: - return insn == 0x0053102Au; - case 0x8003C178u: case 0x8003CD70u: - return insn == 0x0077182Au; - case 0x8003B2CCu: case 0x8003BA30u: case 0x8003BE40u: - case 0x8003C628u: case 0x8003C878u: case 0x8003D28Cu: - case 0x80043184u: - return insn == 0x8F820494u; - case 0x8003FB0Cu: - return insn == 0x8F840494u; - case 0x800410E0u: - return insn == 0x93830310u; - case 0x800410F8u: - return insn == 0x8F820680u; - case 0x800411D8u: - return insn == 0x8F8301D4u; - case 0x8004279Cu: - return insn == 0x8FBF016Cu; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT -extern void disruptor_billboard_aspect_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_billboard_aspect_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003BB88u: case 0x8003BFB0u: case 0x8003C848u: - return insn == 0xA6030016u; - case 0x8003CAD4u: - return insn == 0xA6020016u; - case 0x8003D488u: - return insn == 0xA2060025u; - case 0x800433A0u: - return insn == 0x02A02821u; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP -extern void disruptor_intro_skip_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_intro_skip_site(uint32_t pc, uint32_t insn) { - return (pc == 0x800147B0u && insn == 0x2A820003u) || - (pc == 0x80020A80u && insn == 0x2A02005Eu) || - (pc == 0x80048678u && insn == 0x30420940u); -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH -extern void disruptor_sprite_depth_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_sprite_depth_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003B98Cu: case 0x8003BDA0u: case 0x8003C4B0u: case 0x8003D078u: - return insn == 0xAFA20010u; - case 0x8003D1B4u: - return insn == 0xA4379EA0u; - default: - return 0; - } -} -#endif - -#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES -extern void disruptor_backdrop_tiles_instruction_hook( - CPUState *cpu, uint32_t address, uint32_t instruction, int phase); - -static int disruptor_backdrop_tile_site(uint32_t pc, uint32_t insn) { - switch (pc) { - case 0x8003B328u: case 0x8003B638u: - return insn == 0x8FB80040u; - case 0x8003B338u: - return insn == 0x00621821u; - case 0x8003B348u: - return insn == 0x8FB80028u; - case 0x8003B5A8u: - return insn == 0x8F8205B8u; - default: - return 0; - } -} -#endif - -static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out) { - /* A load's writeback becomes visible to the instruction AFTER its delay - * slot: load at N, hidden from N+1, visible from N+2. s_ld_pend_age tracks - * that: 0 = armed by the instruction just executed, 1 = the delay slot has - * now run, so retire it before executing anything else. - * - * Doing the retire HERE (rather than after the slot returns) is what makes - * branches correct: a branch's own delay slot is executed nested inside - * exec_one_fetched_inner, and re-enters this wrapper — by then age is 1, so - * the slot correctly observes the loaded value. */ - if (s_ld_pend_armed && s_ld_pend_age != 0u) { - s_ld_pend_armed = 0; - if (s_ld_pend_rt != 0u) cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - cpu->gpr[0] = 0; - } else if (s_ld_pend_armed) { - /* LWL/LWR exemption. `lwl rt,x` immediately followed by `lwr rt,y` is - * THE unaligned-load idiom every MIPS compiler emits, and MIPS-I - * explicitly permits the pair back-to-back: the second half merges with - * the first's result rather than the pre-load register. Hiding the - * pending write here would make the merge read a stale rt and silently - * corrupt every unaligned load (it wedged Tomba 2 at boot). Retire the - * write early so the partner sees it — this is forwarding, not a - * shortcut around the delay slot. */ - const uint32_t nx_op = op_field(insn); - const int is_lwlr = (nx_op == 0x22u || nx_op == 0x26u); - if (is_lwlr && rt_field(insn) == s_ld_pend_rt) { - s_ld_pend_armed = 0; - if (s_ld_pend_rt != 0u) cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - cpu->gpr[0] = 0; - } else { - s_ld_pend_age = 1u; /* this instruction IS the delay slot: stay hidden */ - } - } - - /* op 0x20..0x26 = LB/LH/LWL/LW/LBU/LHU/LWR. LWC2 (GTE, 0x32) targets a COP2 - * register, not a GPR, so it needs no deferral here. */ - const uint32_t ld_op = op_field(insn); - const uint32_t ld_rt = rt_field(insn); - const uint32_t pc_phys = pc & 0x1FFFFFFFu; - /* OpenBIOS executes cardfasttrack both from ROM and from its low-RAM - * kernel copy (for example 0x3554..0x36D4). Its hand-written dependent - * load requires the real R3000A value delay. Game-owned dirty RAM crosses - * mixed compiled/interpreted boundaries that do not carry this pending - * writeback yet, so preserve the historical eager-value contract there. */ - const int in_bios_rom = - pc_phys >= 0x1FC00000u && pc_phys < 0x1FC80000u; - const int in_bios_kernel_ram = pc_phys < 0x00010000u; - const int is_ld = (in_bios_rom || in_bios_kernel_ram) && - (ld_op >= 0x20u && ld_op <= 0x26u) && - (ld_rt != 0u); - const uint32_t ld_before = is_ld ? cpu->gpr[ld_rt] : 0u; - - const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out); - -#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA - if (disruptor_vertical_camera_site(pc, insn)) { - disruptor_vertical_camera_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING - if (disruptor_far_rendering_site(pc, insn)) { - disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT - if (disruptor_billboard_aspect_site(pc, insn)) { - disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP - if (disruptor_intro_skip_site(pc, insn)) { - disruptor_intro_skip_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH - if (disruptor_sprite_depth_site(pc, insn)) { - disruptor_sprite_depth_instruction_hook(cpu, pc, insn, 1); - } -#endif - -#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES - if (disruptor_backdrop_tile_site(pc, insn)) { - disruptor_backdrop_tiles_instruction_hook(cpu, pc, insn, 1); - } -#endif - - if (is_ld) { - const uint32_t loaded = cpu->gpr[ld_rt]; - if (loaded != ld_before) { - /* Back-to-back loads: retire the older writeback before reusing the - * slot, otherwise its register write would be dropped entirely. */ - if (s_ld_pend_armed && s_ld_pend_rt != ld_rt && s_ld_pend_rt != 0u) - cpu->gpr[s_ld_pend_rt] = s_ld_pend_val; - /* Undo the eager write; it becomes visible one instruction later. - * On a same-register conflict this naturally makes the LOAD win, - * matching what the compiled backend emits for a dependent pair. */ - cpu->gpr[ld_rt] = ld_before; - s_ld_pend_rt = ld_rt; - s_ld_pend_val = loaded; - s_ld_pend_age = 0u; - s_ld_pend_armed = 1; - cpu->gpr[0] = 0; - } - } - return rv; -} - -static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out) { - exec_pc_table_record(pc); - uint32_t opc = op_field(insn); - uint32_t rs = rs_field(insn); - uint32_t rt = rt_field(insn); - uint32_t rd = rd_field(insn); - uint32_t sh = shamt_field(insn); - uint32_t fnt = funct_field(insn); - int32_t simm = simm16_field(insn); - uint32_t imm = imm16_field(insn); - - *next_pc_out = pc + 4; - -#ifdef PSX_ENABLE_BLOCK_CYCLES - /* Instruction FETCH cost (I-cache) — charged FIRST, before the §1 base, exactly - * like Beetle ReadInstruction precedes the per-instruction base (cpu.cpp). HIT=+0, - * KSEG1=+4, cached miss=+3+refill; a miss also clears the load give-back. */ - psx_icache_fetch_interp(cpu, pc); - - /* Per-instruction R3000A load-delay interlock (single-source: psx_cyc.h, shared - * with both static emitters). §1 base + GPR_DEPRES + DO_LDS run HERE, before the - * instruction body, so §1 precedes any muldiv/GTE deadline stall the body applies - * (Beetle order). CPU loads (op 0x20-0x26) are skipped here — psx_cyc_load_* runs - * their full interlock inside the body (and arms LDWhich=rt). This replaces the - * old flat per-instruction psx_advance_cycles(psx_instr_base_cycles). */ - if (!(opc >= 0x20u && opc <= 0x26u)) - interp_cyc_step(cpu, psx_cyc_dep_res_mask(insn)); -#endif - - /* Widescreen far-backdrop column PRELOAD (auto_backdrop). At a detected - * window START/END finalize, force the loop bound (START->0 / END->sentinel) - * so the generator's own clamps preload the WHOLE finite tile row into the - * revealed 16:9 margin. The site is a move/addiu that writes exactly one GPR - * and has no other effect, so substituting the value and advancing pc+4 is - * complete. Gated on the runtime predicate first => 4:3 pays nothing. Each - * rewrite is recorded to the always-on backdrop ring with the live extent - * (s7), camera-X (scratchpad 0x176) and DL count for `ws_backdrop_ring`. */ - /* Opcode pre-filter FIRST: only addu/or/addi/addiu can be a rewrite site - * (the detector below rewrites exactly one GPR-writing move/addiu). The old - * order ran the ±512-byte site scan for EVERY interpreted instruction; with - * the small direct-mapped cache thrashing, that was the mechanism of the - * native-wide 16:9 CPU collapse (Tomba2 attract 12fps, stack-sampled). */ - if (((opc == 0x00u && (fnt == 0x21u || fnt == 0x25u)) || - opc == 0x09u || opc == 0x08u) && - psx_ws_backdrop_preload()) { - int wcols = 0; - int bk = ws_backdrop_site_kind(pc, &wcols); - if (bk != WS_BD_NONE) { - uint32_t dest = (opc == 0x00u && (fnt == 0x21u || fnt == 0x25u)) ? rd - : (opc == 0x09u || opc == 0x08u) ? rt - : 0xFFFFFFFFu; - if (dest != 0xFFFFFFFFu) { - /* orig = the instruction's normal result, so the widen shifts the - * real camera-tracked bound (move: gpr[src]; addiu: gpr[rs]+simm). */ - uint32_t orig = (opc == 0x00u) - ? ((rt == 0u) ? cpu->gpr[rs] : cpu->gpr[rt]) - : (cpu->gpr[rs] + (uint32_t)simm); - /* Tell psx_ws_backdrop_value the interp will record the rich ring - * entry (it skips its own note when this flag is set). */ - g_ws_bd_from_interp = 1; - uint32_t finalv = psx_ws_backdrop_value(orig, bk == WS_BD_END, wcols); - cpu->gpr[dest] = finalv; - cpu->gpr[0] = 0; - /* Rich snapshot for the ring: s7 (gpr[23]) = byte tile-row extent; - * s0 (gpr[16]) = DL object, count byte at +3; camera-X = scratchpad - * halfword 0x176. */ - { - int extent = (int)(int16_t)cpu->gpr[23]; - int camx = (int)(int16_t)(cpu->read_word(0x1F800174u) >> 16); - uint32_t s0a = cpu->gpr[16] + 3u; - int count = (int)((cpu->read_word(s0a & ~3u) >> ((s0a & 3u) * 8u)) & 0xFFu); - /* base = s4 (gpr[20]) = backdrop data ptr (extent@+0, table@+4); - * dl = s0 (gpr[16]) = the ordering-table object. */ - uint32_t a1 = cpu->gpr[20]; - psx_ws_backdrop_ring_note(pc, bk, wcols, orig, finalv, extent, camx, - count, a1, cpu->gpr[16]); - /* Publish the backdrop structure's address range so the GL - * 2D-stretch gate can match tile prims by gp0_cmd_source_addr. - * Tiles live at a1 + table[col]; bound by the LAST table entry - * (table is ascending) + packet slack. */ - if (extent > 0 && extent <= 256) { - extern uint32_t g_ws_backdrop_lo, g_ws_backdrop_hi; - uint32_t tbl_last = cpu->read_word(a1 + 4u + (uint32_t)(extent - 1) * 4u); - g_ws_backdrop_lo = a1; - g_ws_backdrop_hi = a1 + tbl_last + 0x400u; - } - } - return 0; - } - } - } - - switch (opc) { - case 0x00: /* SPECIAL */ - switch (fnt) { - case 0x00: /* SLL rd, rt, sh (also nop when all fields are 0) */ - cpu->gpr[rd] = cpu->gpr[rt] << sh; - cpu->gpr[0] = 0; - return 0; - case 0x02: /* SRL */ - cpu->gpr[rd] = cpu->gpr[rt] >> sh; - cpu->gpr[0] = 0; - return 0; - case 0x03: /* SRA */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> sh); - cpu->gpr[0] = 0; - return 0; - case 0x04: /* SLLV */ - cpu->gpr[rd] = cpu->gpr[rt] << (cpu->gpr[rs] & 31); - cpu->gpr[0] = 0; - return 0; - case 0x06: /* SRLV */ - cpu->gpr[rd] = cpu->gpr[rt] >> (cpu->gpr[rs] & 31); - cpu->gpr[0] = 0; - return 0; - case 0x07: /* SRAV */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> (cpu->gpr[rs] & 31)); - cpu->gpr[0] = 0; - return 0; - case 0x08: { /* JR rs */ - uint32_t target = cpu->gpr[rs]; - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - /* crossing (if target is compiled) is counted at the block-loop - * tail-transfer site (interp_enter_compiled, §18) — not here, to - * avoid double-counting J/JR-to-compiled. */ - cpu->pc = target; - return 1; - } - case 0x09: { /* JALR rd, rs */ - uint32_t target = cpu->gpr[rs]; - uint32_t return_pc = pc + 8; - cpu->gpr[rd ? rd : 31] = return_pc; - cpu->gpr[0] = 0; - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */ -#ifndef PSX_NO_DEBUG_TOOLS - xprobe_event(pc, XOP_JALR, XSITE_INTERP, target, - fetch_word((pc + 4) & 0x1FFFFFFFu), site_sp, cpu->gpr[31], 1); -#endif - uint32_t _cr = callret_begin(cpu, pc, target); /* call-resolution ring */ -#define CRET(code, rv) do { callret_end(_cr, cpu, (code)); return (rv); } while (0) - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; CRET(CRES_PLAIN, 1); } /* slice / lockstep-replay: plain transfer, never execute the callee */ -#ifdef PSX_HAS_GAME_DISPATCH - cpu->pc = 0; - if (interp_enter_compiled(cpu, target)) { - if (g_psx_call_bail) CRET(CRES_EC_BAIL, 1); /* wild unwind: cpu->pc = true target */ - if (cpu->pc != 0) CRET(CRES_EC_PC, 1); - if (rd == 0 || rd == 31) { - if (psx_call_contract(cpu, return_pc, site_sp)) CRET(CRES_EC_CONTRACT, 1); - } - { int _r = dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - CRET(CRES_EC_RET | (_r ? 0x100u : 0u), _r); } - } -#endif - /* Native overlay candidates get the SAME call contract as - * statically-compiled callees: run as a unit, resume at - * return_pc. A bare pc-chain here loses the return obligation - * when the callee runs natively (C return, pc==0) — the dispatch - * loop unwinds past the suspended caller, leaking its frame. */ - { - extern int overlay_loader_call_native(CPUState *cpu, uint32_t addr); - cpu->pc = 0; - if (overlay_loader_call_native(cpu, target)) { - if (g_psx_call_bail) CRET(CRES_OV_BAIL, 1); - if (cpu->pc != 0) CRET(CRES_OV_PC, 1); - if (rd == 0 || rd == 31) { - if (psx_call_contract(cpu, return_pc, site_sp)) CRET(CRES_OV_CONTRACT, 1); - } - { int _r = dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - CRET(CRES_OV_RET | (_r ? 0x100u : 0u), _r); } - } - } - if (!is_local_dirty_target(target)) { - int _r = dispatch_nonlocal_call(cpu, target, return_pc, next_pc_out); - CRET(g_nl_exit_code | ((_r && g_nl_exit_code == CRES_NL_RET) ? 0x100u : 0u), _r); - } - cpu->pc = target; - CRET(CRES_PCCHAIN, 1); -#undef CRET - } - case 0x0C: /* SYSCALL */ - cpu->pc = pc; - psx_syscall(cpu, (insn >> 6) & 0xFFFFFu); - return (cpu->pc != 0); - case 0x0D: /* BREAK */ - psx_break(cpu, (insn >> 6) & 0xFFFFFu, pc); - return 1; - case 0x0F: /* SYNC */ - return 0; - case 0x10: /* MFHI */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ -#endif - cpu->gpr[rd] = cpu->hi; - cpu->gpr[0] = 0; - return 0; - case 0x11: /* MTHI */ - cpu->hi = cpu->gpr[rs]; - return 0; - case 0x12: /* MFLO */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ -#endif - cpu->gpr[rd] = cpu->lo; - cpu->gpr[0] = 0; - return 0; - case 0x13: /* MTLO */ - cpu->lo = cpu->gpr[rs]; - return 0; - case 0x18: { /* MULT */ - int64_t r = (int64_t)(int32_t)cpu->gpr[rs] * (int64_t)(int32_t)cpu->gpr[rt]; - cpu->lo = (uint32_t)r; - cpu->hi = (uint32_t)((uint64_t)r >> 32); -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, psx_mult_latency_s(cpu->gpr[rs])); /* completion deadline */ -#endif - return 0; - } - case 0x19: { /* MULTU */ - uint64_t r = (uint64_t)cpu->gpr[rs] * (uint64_t)cpu->gpr[rt]; - cpu->lo = (uint32_t)r; - cpu->hi = (uint32_t)(r >> 32); -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, psx_mult_latency_u(cpu->gpr[rs])); /* completion deadline */ -#endif - return 0; - } - case 0x1A: { /* DIV */ - int32_t a = (int32_t)cpu->gpr[rs]; - int32_t b = (int32_t)cpu->gpr[rt]; - if (b == 0) { - cpu->lo = (a < 0) ? 1u : 0xFFFFFFFFu; - cpu->hi = (uint32_t)a; - } else if ((uint32_t)a == 0x80000000u && b == -1) { - cpu->lo = 0x80000000u; - cpu->hi = 0; - } else { - cpu->lo = (uint32_t)(a / b); - cpu->hi = (uint32_t)(a % b); - } -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, 37u); /* DIV completion deadline (fixed) */ -#endif - return 0; - } - case 0x1B: /* DIVU */ - if (cpu->gpr[rt] == 0) { - cpu->lo = 0xFFFFFFFFu; - cpu->hi = cpu->gpr[rs]; - } else { - cpu->lo = cpu->gpr[rs] / cpu->gpr[rt]; - cpu->hi = cpu->gpr[rs] % cpu->gpr[rt]; - } -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_muldiv_set(cpu, 37u); /* DIVU completion deadline (fixed) */ -#endif - return 0; - case 0x20: /* ADD - overflow traps are delegated if they occur. */ - case 0x21: /* ADDU rd, rs, rt */ - cpu->gpr[rd] = cpu->gpr[rs] + cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x22: /* SUB - overflow traps are delegated if they occur. */ - case 0x23: /* SUBU */ - if (rs == 0 && psx_ws_is_cull_negsub_site(pc)) - cpu->gpr[rd] = 0u - cpu->gpr[rt] - (uint32_t)psx_ws_x_margin(); - else - cpu->gpr[rd] = cpu->gpr[rs] - cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x24: /* AND */ - cpu->gpr[rd] = cpu->gpr[rs] & cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x25: /* OR */ - cpu->gpr[rd] = cpu->gpr[rs] | cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x26: /* XOR */ - cpu->gpr[rd] = cpu->gpr[rs] ^ cpu->gpr[rt]; - cpu->gpr[0] = 0; - return 0; - case 0x27: /* NOR */ - cpu->gpr[rd] = ~(cpu->gpr[rs] | cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - case 0x2A: /* SLT */ - { - int32_t lhs = (int32_t)cpu->gpr[rs]; - int32_t rhs = (int32_t)cpu->gpr[rt]; - int32_t adjusted_rhs = parappa_timing_adjust_threshold(pc, rhs); - uint32_t vanilla = - (lhs < rhs) ? 1u : 0u; - uint32_t kept = vanilla; - if (adjusted_rhs != rhs) - kept = (lhs < adjusted_rhs) ? 1u : 0u; - if (!psx_ws_aspect_cone_site(cpu, pc, insn, vanilla, &kept)) - (void)psx_ws_cull_keep_site(pc, insn, vanilla, &kept); - parappa_rhythm_log_compare(cpu, pc, (uint32_t)lhs, - (uint32_t)rhs, - (uint32_t)adjusted_rhs, - vanilla, kept); - cpu->gpr[rd] = kept; - cpu->gpr[0] = 0; - return 0; - } - case 0x2B: /* SLTU */ - { - uint32_t vanilla = (cpu->gpr[rs] < cpu->gpr[rt]) ? 1u : 0u; - uint32_t kept = vanilla; - (void)psx_ws_cull_keep_site(pc, insn, vanilla, &kept); - cpu->gpr[rd] = kept; - cpu->gpr[0] = 0; - return 0; - } - default: - return abort_unsupported(pc, insn, "SPECIAL funct"); - } - break; - - case 0x02: { /* J target */ - uint32_t target = ((pc + 4) & 0xF0000000u) | (target26(insn) << 2); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - /* crossing counted at the block-loop tail-transfer site (§18). */ - cpu->pc = target; - return 1; - } - case 0x03: { /* JAL target */ - uint32_t target = ((pc + 4) & 0xF0000000u) | (target26(insn) << 2); - uint32_t return_pc = pc + 8; - cpu->gpr[31] = return_pc; - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - uint32_t site_sp = cpu->gpr[29]; /* call contract: sp at the call */ -#ifndef PSX_NO_DEBUG_TOOLS - int xw = g_xprobe_watch(target); /* record RESOLUTION path for watched targets */ - xprobe_event(pc, XOP_JAL, XSITE_INTERP, target, - fetch_word((pc + 4) & 0x1FFFFFFFu), site_sp, cpu->gpr[31], 1); -#define XRES(code) do { if (xw) xprobe_event(pc, XOP_RES, (uint8_t)(code), target, \ - cpu->gpr[2], cpu->gpr[29], cpu->gpr[31], 1); } while (0) -#else -#define XRES(code) do { (void)(code); } while (0) -#endif - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; return 1; } /* slice / lockstep-replay: plain transfer, never execute the callee */ -#ifdef PSX_HAS_GAME_DISPATCH - cpu->pc = 0; - if (interp_enter_compiled(cpu, target)) { - if (g_psx_call_bail) { XRES(XRES_EC_BAIL); return 1; } /* wild unwind: cpu->pc = true target */ - if (cpu->pc != 0) { XRES(XRES_EC_PC); return 1; } - if (psx_call_contract(cpu, return_pc, site_sp)) { XRES(XRES_EC_CONTRACT); return 1; } - XRES(XRES_EC_RET); - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - } -#endif - /* Native overlay candidates get the SAME call contract as statically- - * compiled callees: run as a unit, resume at return_pc. A bare - * pc-chain here loses the return obligation when the callee runs - * natively (C return, pc==0) — the dispatch loop unwinds past the - * suspended caller, leaking its frame (dwarf->overworld root cause: - * F=0x800338A8's epilogue skipped, sp leaked 0x18 per entity). */ - { - extern int overlay_loader_call_native(CPUState *cpu, uint32_t addr); - cpu->pc = 0; - if (overlay_loader_call_native(cpu, target)) { - if (g_psx_call_bail) { XRES(XRES_OV_BAIL); return 1; } - if (cpu->pc != 0) { XRES(XRES_OV_PC); return 1; } - if (psx_call_contract(cpu, return_pc, site_sp)) { XRES(XRES_OV_CONTRACT); return 1; } - XRES(XRES_OV_RET); - return dirty_ram_finish_call_return(cpu, return_pc, next_pc_out); - } - } - if (!is_local_dirty_target(target)) { - XRES(XRES_NONLOCAL); - return dispatch_nonlocal_call(cpu, target, return_pc, next_pc_out); - } - if (!dirty_ram_word_looks_decodable(fetch_word(target & 0x1FFFFFFFu))) { - XRES(XRES_UNDECODABLE); - return dispatch_nonlocal_call(cpu, target, return_pc, next_pc_out); - } - XRES(XRES_PCCHAIN); - cpu->pc = target; - return 1; -#undef XRES - } - case 0x04: { /* BEQ rs, rt, simm */ - parappa_timing_apply_final_window(cpu, pc); - int taken = (cpu->gpr[rs] == cpu->gpr[rt]); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x05: { /* BNE */ - int taken = (cpu->gpr[rs] != cpu->gpr[rt]); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x06: { /* BLEZ */ - int taken = ((int32_t)cpu->gpr[rs] <= 0); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x07: { /* BGTZ */ - int taken = ((int32_t)cpu->gpr[rs] > 0); - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x01: { /* REGIMM: BLTZ/BGEZ/BLTZAL/BGEZAL by rt field */ - int taken; - switch (rt) { - case 0x00: /* BLTZ */ - /* Widescreen render-funnel LEFT-edge widen (auto_screen_x): a - * classified funnel bltz rejects only past the revealed margin. - * Identity at 4:3 (margin 0). Gated per-game, cheap cached scan. */ - if (psx_ws_auto_cull_on() && ws_cull_bltz_site(pc)) - taken = psx_ws_cull_bltz(cpu->gpr[rs]); - else - taken = ((int32_t)cpu->gpr[rs] < 0); - break; - case 0x01: /* BGEZ */ taken = ((int32_t)cpu->gpr[rs] >= 0); break; - case 0x10: /* BLTZAL */ taken = ((int32_t)cpu->gpr[rs] < 0); - cpu->gpr[31] = pc + 8; break; - case 0x11: /* BGEZAL */ taken = ((int32_t)cpu->gpr[rs] >= 0); - cpu->gpr[31] = pc + 8; break; - default: return abort_unsupported(pc, insn, "REGIMM rt"); - } - exec_delay_slot(cpu, pc + 4); - cosim_exec_one_transfer_hook(pc + 4); - cpu->pc = taken ? (pc + 4 + (simm << 2)) : (pc + 8); - return 1; - } - case 0x08: /* ADDI rt, rs, simm — same as ADDIU, sans overflow trap (we don't model traps here) */ - { - uint32_t widened = 0; - if (psx_ws_angle_site(pc, insn, &widened)) - cpu->gpr[rt] = widened; - else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm - + (psx_ws_is_cull_bias_site(pc) - ? (uint32_t)psx_ws_activation_margin() : 0u); - cpu->gpr[0] = 0; - return 0; - } - case 0x09: /* ADDIU rt, rs, simm */ - { - uint32_t widened = 0; - if (psx_ws_angle_site(pc, insn, &widened)) - cpu->gpr[rt] = widened; - else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm - + (psx_ws_is_cull_bias_site(pc) - ? (uint32_t)psx_ws_activation_margin() : 0u); - cpu->gpr[0] = 0; - return 0; - } - case 0x0A: /* SLTI */ - { - uint32_t vanilla = ((int32_t)cpu->gpr[rs] < simm) ? 1u : 0u; - uint32_t kept = vanilla; - if (psx_ws_aspect_cone_site(cpu, pc, insn, vanilla, &kept)) - cpu->gpr[rt] = kept; - else if (psx_ws_cull_keep_site(pc, insn, vanilla, &kept)) - cpu->gpr[rt] = kept; - else if (psx_ws_is_cull_depth_site(pc)) - cpu->gpr[rt] = ((int32_t)cpu->gpr[rs] < psx_ws_depth_bound(simm)) ? 1u : 0u; - /* Widescreen render-funnel RIGHT-edge widen (auto_screen_x) for the - * signed min/max funnel idiom (`slti v, minSX, W`) — the paired left - * edge is the bltz above. Identity at 4:3 (margin 0). */ - else if (psx_ws_is_cull_slti_lower_site(pc)) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_slti_lower( - cpu->gpr[rs], imm); - else if (psx_ws_is_cull_slti_site(pc) || - (psx_ws_auto_cull_on() && psx_ws_is_cull_w_imm(imm) && ws_cull_site(pc))) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_slti(cpu->gpr[rs], imm); - else - cpu->gpr[rt] = ((int32_t)cpu->gpr[rs] < simm) ? 1u : 0u; - cpu->gpr[0] = 0; - return 0; - } - case 0x0B: /* SLTIU */ - { - uint32_t vanilla = (cpu->gpr[rs] < (uint32_t)simm) ? 1u : 0u; - uint32_t kept = vanilla; - /* Widescreen render-funnel cull widening (auto_screen_x): apply the - * shared helper for a flagged render-cull site — it is byte-identical - * to the vanilla compare at 4:3 (margin 0) and widens at 16:9, so the one - * code path serves both aspects (no widescreen-specific caching). */ - if (psx_ws_cull_keep_site(pc, insn, vanilla, &kept)) - cpu->gpr[rt] = kept; - else if (psx_ws_is_cull_depth_site(pc)) - cpu->gpr[rt] = (cpu->gpr[rs] < - (uint32_t)psx_ws_depth_bound(simm)) ? 1u : 0u; - else if (psx_ws_is_cull_vxrange_site(pc)) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_vxrange(cpu->gpr[rs], imm); - else if (psx_ws_is_cull_range_site(pc)) { - /* Explicit world-space classifier widen. The native emitter uses - * the same bound transform for configured range_sites. */ - cpu->gpr[rt] = (cpu->gpr[rs] < - ((uint32_t)simm + 2u * - (uint32_t)psx_ws_activation_margin())) ? 1u : 0u; - } - else if (psx_ws_auto_cull_on() && psx_ws_is_cull_w_imm(imm) && ws_cull_site(pc)) - cpu->gpr[rt] = (uint32_t)psx_ws_cull_sltiu(cpu->gpr[rs], imm); - else - cpu->gpr[rt] = (cpu->gpr[rs] < (uint32_t)simm) ? 1u : 0u; - cpu->gpr[0] = 0; - return 0; - } - case 0x0C: /* ANDI */ - cpu->gpr[rt] = cpu->gpr[rs] & imm; - cpu->gpr[0] = 0; - return 0; - case 0x0D: /* ORI */ - cpu->gpr[rt] = cpu->gpr[rs] | imm; - cpu->gpr[0] = 0; - return 0; - case 0x0E: /* XORI */ - cpu->gpr[rt] = cpu->gpr[rs] ^ imm; - cpu->gpr[0] = 0; - return 0; - case 0x0F: /* LUI rt, imm */ - if (psx_ws_is_signed_x_bound_site(pc, insn)) - cpu->gpr[rt] = (uint32_t)psx_ws_player_x_bound((int32_t)(imm << 16)); - else - cpu->gpr[rt] = imm << 16; - cpu->gpr[0] = 0; - return 0; - case 0x10: { /* COP0 */ - uint32_t cop_op = rs; - if (cop_op == 0x00) { /* MFC0 — delayed load (Beetle: LDAbsorb=0, LDWhich=rt) */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - cpu->ld_absorb = 0u; - cpu->ld_which_t = (uint8_t)rt; -#endif - cpu->gpr[rt] = cpu->cop0[rd]; - cpu->gpr[0] = 0; - return 0; - } - if (cop_op == 0x04) { /* MTC0 */ - cpu->cop0[rd] = cpu->gpr[rt]; - return 0; - } - if (cop_op == 0x10 && fnt == 0x10) { /* RFE */ - uint32_t sr = cpu->cop0[12]; - cpu->cop0[12] = (sr & 0xFFFFFFF0u) | ((sr >> 2) & 0x0Fu); - /* Backend contract parity (overlay_api.h v12; the emitter calls - * this after every recompiled rfe): inside the synchronous - * handler window this arms the host escape taken at the next - * committed transfer. Without it an interpreted handler whose - * post-RFE EPC lands in dirty RAM keeps interpreting flat inside - * psx_check_interrupts' window — in_exception never clears, the - * cycle-paced VBlank authority is vetoed forever, and the guest - * livelocks in the kernel idle loop (dbg-build boot wedge). */ - psx_rfe_mark_escape(); - return 0; - } - return abort_unsupported(pc, insn, "COP0 op"); - } - case 0x12: { /* COP2 / GTE */ - uint32_t cop_op = rs; - /* Faithful GTE: any COP2 register access stalls to the pending command - * completion deadline (gte_execute armed it via psx_gte_set). */ - if (cop_op == 0x00) { /* MFC2 — read: stall + give-back (Beetle) */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_read(cpu, rt); -#endif - cpu->gpr[rt] = gte_precision_mfc2_pc_read( - pc, insn, (uint8_t)rd, - gte_read_data(cpu, (uint8_t)rd)); - cpu->gpr[0] = 0; - return 0; - } - if (cop_op == 0x02) { /* CFC2 — read: stall + give-back (Beetle) */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_read(cpu, rt); -#endif - cpu->gpr[rt] = gte_read_ctrl(cpu, (uint8_t)rd); - cpu->gpr[0] = 0; - return 0; - } - if (cop_op == 0x04) { /* MTC2 */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); -#endif - gte_write_data(cpu, (uint8_t)rd, cpu->gpr[rt]); - return 0; - } - if (cop_op == 0x06) { /* CTC2 */ -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); -#endif - gte_write_ctrl(cpu, (uint8_t)rd, cpu->gpr[rt]); - return 0; - } - if (cop_op & 0x10) { - gte_execute(cpu, insn & 0x1FFFFFFu); - return 0; - } - return abort_unsupported(pc, insn, "COP2 op"); - } - /* CPU loads: the full per-instruction R3000A interlock (§1 + GPR_DEPRES(rs) + - * DO_LDS + ReadMemory timing + arm LDWhich=rt) lives in psx_cyc_load_*; exec_one's - * top-of-function step is skipped for op 0x20-0x26 so it is not double-charged. - * #ifndef PSX_ENABLE_BLOCK_CYCLES these still read the value via the uncharged - * accessor (psx_cyc_load_* falls back to a plain read when cycles are off). */ - case 0x20: { /* LB rt, simm(rs) */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)(int32_t)(int8_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x21: { /* LH */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)(int32_t)(int16_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x22: { /* LWL */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); - cpu->gpr[rt] = lwl_merge(addr, word, cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - } - case 0x23: { /* LW */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - if (psx_ws_is_cull_plane_nx_site(pc)) - /* Side-plane normal-X: inverse-aspect scale while revealed. */ - cpu->gpr[rt] = (uint32_t)psx_ws_plane_nx( - (int32_t)psx_cyc_load_word(cpu, addr, rt, 1u << rs)); - else if (psx_ws_is_cull_xclip_load_site(pc)) - /* Per-prim X-reject bound: INT32_MAX while revealed (gpu.c). */ - cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); - else - cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); - cpu->gpr[rt] = gte_precision_copy_pc_read( - pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - } - case 0x24: { /* LBU */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x25: { /* LHU */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - cpu->gpr[rt] = (uint32_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); - cpu->gpr[0] = 0; - return 0; - } - case 0x26: { /* LWR */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); - cpu->gpr[rt] = lwr_merge(addr, word, cpu->gpr[rt]); - cpu->gpr[0] = 0; - return 0; - } - case 0x28: { /* SB */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt] & 0xFFu, 1u); - cpu->write_byte(addr, (uint8_t)cpu->gpr[rt]); - return 0; - } - case 0x29: { /* SH */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - uint16_t val = (uint16_t)cpu->gpr[rt]; - /* Widescreen backdrop screenX squash on the interpreter path: mirrors - * the recompiler emit at [widescreen.backdrop] x_sites. Overlay code - * (the parallax backdrop handlers) runs interpreted when no cache DLL - * is loaded, so without this the squash never happens. Identity at 4:3 - * (psx_ws_backdrop_x gates on ws_active). */ - if (psx_ws_is_backdrop_site(pc)) - val = (uint16_t)psx_ws_backdrop_x((int16_t)val); - g_debug_last_store_pc = pc; - parappa_rhythm_log_store(cpu, pc, addr, (uint32_t)val, 2u); - cpu->write_half(addr, val); - return 0; - } - case 0x2A: { /* SWL */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - interp_swl(cpu, addr, cpu->gpr[rt]); - return 0; - } - case 0x2B: { /* SW */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt], 4u); - cpu->write_word(addr, cpu->gpr[rt]); - gte_precision_scratch_store_pc_word( - pc, insn, addr, cpu->gpr[rt]); - gte_precision_copy_pc_word( - pc, insn, (uint8_t)rt, addr, cpu->gpr[rt]); - gte_precision_store_pc_word(pc, insn, addr, cpu->gpr[rt]); - return 0; - } - case 0x2E: { /* SWR */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; - g_debug_last_store_pc = pc; - interp_swr(cpu, addr, cpu->gpr[rt]); - return 0; - } - case 0x32: { /* LWC2 — §1+DO_LDS charged by exec_one's top step (mask 0) */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); /* COP2 reg write stalls to GTE completion */ -#endif - gte_write_data(cpu, (uint8_t)rt, psx_cyc_lwc2_read(cpu, addr)); - return 0; - } - case 0x3A: { /* SWC2 */ - uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_gte_stall(cpu); /* COP2 reg read stalls to GTE completion */ -#endif - g_debug_last_store_pc = pc; - cpu->write_word(addr, gte_read_data(cpu, (uint8_t)rt)); - gte_precision_store_word(addr, (uint8_t)rt); - return 0; - } - default: - return abort_unsupported(pc, insn, "primary opcode"); - } - return 0; -} - -static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr); - -/* Public entry point. Caller (psx_dispatch) has translated `addr` to a - * KSEG-stripped form already in some cases, so accept any address and - * mask. Returns 1 if interpretation handled the basic block; 0 if the - * caller should fall back (e.g. unsupported opcode at the entry, page - * not dirty). - * - * Thin wrapper that brackets the body with g_dirty_interp_active so the - * event-timeline ring can tag events as INTERP vs STATIC. The EPC-sentinel - * branch inside can longjmp out (not restoring here); psx_check_interrupts - * clears the flag at its longjmp landing to cover that case. */ -/* Always-on runaway-recursion guard. The host C stack mirrors the guest call - * graph (each guest call = a nested psx_dispatch_call -> dirty_ram_dispatch C - * frame), so an unbounded self-recursion — e.g. a bogus jump-table handler that - * re-enters its own entity loop (issue #1 seesaw crash: v0=0x8001DFF8) — silently - * overflows the host stack with no diagnostic (raw STATUS_STACK_OVERFLOW). Trip - * well below overflow: dump full state (crash report carries dispatch_depth + the - * dirty_block cycle that NAMES the recursing PC) and halt-and-serve, so the next - * reproduction (whoever hits it) is captured cleanly + live-inspectable instead of - * a bare SEH. Normal guest nesting is shallow (<~64); the default 256 sits far - * above that and below overflow (~500+ frames on a 1 MB stack). Override via - * PSX_RECURSION_LIMIT. One-shot. NOT a fix — the upstream bogus-pointer divergence - * still needs the oracle; this converts a hard crash into a catchable, diagnosed - * bail (Rule 15: make the failure observable). */ -int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { - extern int g_psx_dispatch_depth; - extern void psx_fatal_halt(const char *reason); -#ifndef PSX_NO_DEBUG_TOOLS - /* A0/B0/C0 kernel-vector stubs are runtime-written, so calls to them - * land HERE, not in the static dispatcher — which meant the bioscall - * ring (debug_server_trace_dispatch) never saw a single vector call on - * a BIOS whose stubs stay dirty (OpenBIOS bring-up: ring total 0 while - * the guest hammered B0). Feed the same observer from this path. */ - { - uint32_t vphys = addr & 0x1FFFFFFFu; - if (vphys == 0xA0u || vphys == 0xB0u || vphys == 0xC0u) { - extern void debug_server_trace_dispatch(uint32_t func_addr); - debug_server_trace_dispatch(vphys); - } - } -#endif - static int s_rec_guard = 0; - static int s_rec_limit = 0; - if (s_rec_limit == 0) { - const char *e = getenv("PSX_RECURSION_LIMIT"); - s_rec_limit = (e && *e) ? atoi(e) : 256; - if (s_rec_limit < 32) s_rec_limit = 32; - } - if (!s_rec_guard && g_psx_dispatch_depth > s_rec_limit) { - s_rec_guard = 1; /* one-shot: first trip wins the report */ - psx_fatal_halt("dispatch recursion guard tripped — runaway self-call " - "(see dispatch_depth + dirty_block cycle for the recursing PC)"); - } - if (addr == 0x8001A954u) site_note(&g_site_dd954); /* loop head re-dispatch */ - else if (addr == 0x80046264u) site_note(&g_site_dd264); /* loop tail re-dispatch */ - int prev = g_dirty_interp_active; - g_dirty_interp_active = 1; - int prev_phase = g_exec_phase; - g_exec_phase = 1; - int r = dirty_ram_dispatch_inner(cpu, addr, stop_addr); - - /* pc=0 producer tripwire: the dirty path reported "handled" yet published a - * null PC — the abnormal-exit source we're hunting (MMX6 boot wedge). Latch - * full context the first time; count every occurrence. */ - if (r == 1 && cpu->pc == 0u) { - extern uint32_t g_async_rfe_resume_pc, g_dirty_safe_resume_pc; - g_pczero_count++; - if (!g_pczero_latched) { - g_pczero_latched = 1; - g_pczero_addr = addr; - g_pczero_ra = cpu->gpr[31]; - g_pczero_in_exc = (uint32_t)psx_get_in_exception(); - g_pczero_async_rfe = g_async_rfe_resume_pc; - g_pczero_dirty_safe = g_dirty_safe_resume_pc; - } - } - - /* Dirty-RAM scheduling contract (Tomba 2 Whoopee-Camp softlock fix): - * ANY path that advanced guest cycles must periodically expose the CPU to - * the interrupt/event pump, regardless of RAM region. The local-dirty-flow - * fast path (overlay region, phys >= OVERLAY_REGION_FLOOR) already pumps - * psx_check_interrupts() every 4096 insns internally — but a LOW-RAM kernel - * dirty loop (e.g. 0x2CA8 / 0xE10 / 0xB0 polling for VBlank) takes the - * per-block-return path instead and re-dispatches through the CPS chain - * WITHOUT ever surfacing to the top-level interrupt check. Result: VBlank is - * never raised, i_stat stays 0, the wait-loop deadlocks (frame frozen, - * total_checks frozen, dirty_ram_insns -> billions). OVERLAY_REGION_FLOOR may - * decide HOW dirty code is dispatched; it must never decide whether time - * exists. So pump here on a region-independent insn budget. This is a clean - * poll boundary: the inner committed cpu->pc, the executed instruction and - * its delay slot are fully retired, and psx_check_interrupts() clears - * g_dirty_interp_active at its longjmp landing (same contract as the inner - * local-flow pump), so a deliver-via-longjmp here is safe. */ - if (r == 1) { - uint64_t now = g_dirty_ram_insns_run; - uint64_t gap = now - s_last_dirty_irq_pump_insns; - if (gap > g_dirty_pump_max_gap_insns) g_dirty_pump_max_gap_insns = gap; - if (gap >= 4096u) { - s_last_dirty_irq_pump_insns = now; - g_dirty_pump_count++; - uint32_t committed = cpu->pc; /* block already retired; this is next PC */ - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 6; - g_dirty_safe_resume_pc = committed; - psx_check_interrupts(cpu); /* may exception-enter / longjmp */ - /* Frame-1997 fix: a game-driven ReturnFromException longjmp'd through the - * handler and left cpu->pc=0. Here (outer pump, the block already returned) - * that null PC would propagate up the trampoline to the OUTERMOST dispatch - * and be read as a clean "execution completed" exit. Restore the committed - * guest PC so the trampoline re-dispatches and the game resumes. (The - * local-flow internal pump resumes via its own loop, so this only matters at - * block-return pumps.) */ - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; - } - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - } - } - - g_dirty_interp_active = prev; - g_exec_phase = prev_phase; - return r; -} - -/* ===== Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md) ===== */ - -/* Is a hardware interrupt deliverable to the guest at this exact instruction - * boundary? Mirrors the gate in psx_check_interrupts: a pending+unmasked I_STAT - * bit, COP0 IEc + IM2 set, and not already inside the exception handler. */ -static int precise_irq_deliverable(CPUState *cpu) { - extern uint32_t i_stat; - uint32_t sr = cpu->cop0[12]; - /* COP0 software interrupts (CAUSE.IP0/IP1 & SR.IM0/IM1): guest-raised via - * mtc0 to CAUSE, independent of the INTC line (see psx_check_interrupts). */ - uint32_t sw_pending = cpu->cop0[13] & sr & 0x0300u; - if ((i_stat & i_mask) == 0 && sw_pending == 0) return 0; - if (psx_get_in_exception()) return 0; - if (!(sr & 0x1u)) return 0; /* IEc: interrupts globally enabled */ - /* INTC needs IM2; a pending software interrupt is deliverable without it. */ - if (!(sr & (1u << 10)) && sw_pending == 0) return 0; - return 1; -} - -/* Is `pc` a point the TOP-LEVEL dispatcher can re-enter? Clean (compiled) game - * text is only re-enterable at a FUNCTION ENTRY: psx_dispatch_game_compiled is a - * switch over entries and returns 0 for any mid-function PC; the dirty path then - * sees a non-dirty page and also returns 0, which the top dispatch reads as PC=0 - * ("execution completed" — the deterministic precise-slice boot exit, root-caused - * to a mid-function clean-text resume PC, PRECISE_IRQ_SLICE.md Task #4). Every - * other PC class — BIOS ROM, dirty RAM (overlays / install-at-runtime stubs), - * overlay candidates, the A0/B0/C0 kernel call vectors, scratchpad — is handled by - * psx_dispatch / the dirty interp / the overlay loader, so it is dispatchable. - * Therefore the precise slicer must never hand control back at a mid-function - * clean-text PC: it keeps interpreting (exec_one handles arbitrary mid-function - * flow) until cpu->pc satisfies this predicate. */ -static int precise_pc_dispatchable(uint32_t pc) { -#ifdef PSX_HAS_GAME_DISPATCH - uint32_t phys = pc & 0x1FFFFFFFu; - if (psx_game_address_in_text(pc) && !dirty_ram_is_dirty(phys)) - return psx_game_is_function_entry(pc); -#endif - (void)pc; - return 1; -} - -/* Run the guest per-instruction from cpu->pc through the interpreter, taking any - * deliverable interrupt at the EXACT instruction boundary, until a safe resume - * point. g_precise_mode makes exec_one step INTO jal/jalr callees per-instruction - * (ignore native availability), so the slice is owned by the event deadline, not - * the function boundary. ALL exits land on a control-transfer target — every - * branch/jump target is dispatchable (compiled blocks end with cpu->pc=target; - * return), whereas an arbitrary mid-block PC is not — so after taking a mid-block - * IRQ we keep interpreting to the next transfer before handing back to compiled. - * `bcyc` = originating block's cycle budget; `deadline_entry` = entered because an - * event is due within bcyc (vs a side-effect-only block, which runs one block). */ -static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) { - int prev_precise = g_precise_mode; - int prev_active = g_dirty_interp_active; - int prev_phase = g_exec_phase; - g_precise_mode = 1; - g_dirty_interp_active = 1; - g_exec_phase = 1; - - uint32_t pc = cpu->pc; - g_slice_last_block = pc; - g_slice_last_first_pc = pc; - g_slice_last_first_insn = fetch_word(pc & 0x1FFFFFFFu); - g_slice_exit_pc = pc; - g_slice_exit_reason = 0; - g_slice_exit_iter = 0; - g_slice_exit_dispatchable = 0; - g_slice_exit_dirty = dirty_ram_is_dirty(pc & 0x1FFFFFFFu) ? 1u : 0u; -#ifdef PSX_HAS_GAME_DISPATCH - g_slice_exit_in_text = psx_game_address_in_text(pc) ? 1u : 0u; -#else - g_slice_exit_in_text = 0; -#endif - g_slice_exit_want = 0; - int irq_taken = 0; /* one take per slice (avoid re-taking an unacked IRQ) */ - enum { MAX_PRECISE_INSNS = 200000 }; - for (int i = 0; i < MAX_PRECISE_INSNS; i++) { - if (!irq_taken && precise_irq_deliverable(cpu)) { - uint32_t committed = pc; - extern uint32_t i_stat; - g_slice_last_committed = committed; - g_slice_last_istat = i_stat; - g_slice_last_imask = i_mask; - g_slice_last_sr = cpu->cop0[12]; - cpu->pc = committed; - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 7; - g_dirty_safe_resume_pc = committed; - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - psx_check_interrupts(cpu); - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; - } - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - g_slice_irq_taken++; - irq_taken = 1; - pc = cpu->pc ? cpu->pc : committed; - cpu->pc = pc; - if (precise_pc_dispatchable(cpu->pc)) { - g_slice_exit_reason = 1; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = 1; - break; - } - continue; - } - - uint32_t next_pc = 0; - g_unsupported_seen = 0; - cosim_exec_one_begin(); - int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */ - g_dirty_ram_insns_run++; - dirty_ram_perf_hot_record(pc); -#ifdef PSX_COSIM - if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } -#endif - if (g_unsupported_seen) { - /* Valid compiled code always decodes; if not, hand the committed PC to - * the dispatcher rather than abort the slice. */ - g_unsupported_seen = 0; - pc = transferred ? cpu->pc : next_pc; - g_slice_exit_reason = 2; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = 0; - break; - } - uint32_t committed = transferred ? cpu->pc : next_pc; - - /* `want_exit`: the slice has done its job and would like to hand back. But - * it may ONLY return control at a dispatchable PC (precise_pc_dispatchable) - * — a mid-function clean-text resume PC is unhandleable by the top dispatch - * (root cause of the deterministic boot PC=0). When we want to exit but the - * committed PC is mid-function clean text, keep interpreting (exec_one runs - * arbitrary mid-function flow) until control lands on a real entry / leaves - * clean game text. */ - int want_exit = 0; - - /* Exact take-point: an interrupt deliverable on THIS instruction's cycle is - * taken before the next instruction retires — once per slice. Re-checking - * after a take re-fires an IRQ the handler has not acked yet (an - * 8-takes-in-11-insns storm), so gate on !irq_taken. */ - if (!irq_taken && precise_irq_deliverable(cpu)) { - extern uint32_t i_stat; - g_slice_last_committed = committed; - g_slice_last_istat = i_stat; - g_slice_last_imask = i_mask; - g_slice_last_sr = cpu->cop0[12]; - cpu->pc = committed; - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 7; - g_dirty_safe_resume_pc = committed; /* real EPC for exception entry */ - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - psx_check_interrupts(cpu); /* takes it; runs handler; restores GPRs */ - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - g_slice_irq_taken++; - irq_taken = 1; - committed = cpu->pc ? cpu->pc : committed; - want_exit = 1; - } - - pc = committed; - cpu->pc = committed; - - if (g_psx_call_bail) { - g_slice_exit_reason = 3; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = want_exit ? 1u : 0u; - break; /* wild unwind: hand cpu->pc to the dispatcher */ - } - - if (irq_taken) { - want_exit = 1; /* after the take, leave as soon as it is safe */ - } else if (transferred) { - want_exit = 1; - /* else still imminent — keep slicing across this boundary */ - } - - /* Hand back ONLY at a dispatchable PC. Otherwise (mid-function clean text) - * keep interpreting until one is reached. */ - if (want_exit && precise_pc_dispatchable(cpu->pc)) { - g_slice_exit_reason = 1; - g_slice_exit_iter = (uint32_t)i; - g_slice_exit_want = 1; - break; - } - } - if (g_slice_exit_reason == 0) { - g_slice_exit_reason = 4; - g_slice_exit_iter = MAX_PRECISE_INSNS; - } - - cpu->pc = pc; - g_slice_exit_pc = cpu->pc; - g_slice_exit_dispatchable = precise_pc_dispatchable(cpu->pc) ? 1u : 0u; - g_slice_exit_dirty = dirty_ram_is_dirty(cpu->pc & 0x1FFFFFFFu) ? 1u : 0u; -#ifdef PSX_HAS_GAME_DISPATCH - g_slice_exit_in_text = psx_game_address_in_text(cpu->pc) ? 1u : 0u; -#else - g_slice_exit_in_text = 0; -#endif - g_precise_mode = prev_precise; - g_dirty_interp_active = prev_active; - g_exec_phase = prev_phase; -} - -/* Precise-slice gate (PARKED default OFF). Hot callers use the cpu_state.h - * inline which returns 0 when this is 0 — no out-of-line call. Opt in with - * PSX_PRECISE_SLICE=1 (same binary A/B). */ -int g_psx_precise_slice = 0; - -void psx_precise_slice_init_from_env(void) { - const char *e = getenv("PSX_PRECISE_SLICE"); - g_psx_precise_slice = (e && e[0] == '1') ? 1 : 0; -} - -/* Block-leader slice guard impl. Emitted code calls the inline wrapper - * psx_slice_block(); overlay CPS callbacks point here directly. - * Returns 1 if it sliced (ran the block — and possibly more — through the precise - * interpreter and left cpu->pc at a dispatchable resume point; the caller MUST - * `return` so its compiled body does not re-execute the same instructions). - * Returns 0 if the whole block is provably safe to run as fast compiled C. */ -int psx_slice_block_impl(CPUState *cpu, uint32_t block_addr, uint32_t bcyc, int side_effects) { - /* PARKED (PRECISE_IRQ_SLICE.md): precise take-point slicing is a later - * correctness upgrade, NOT the current FMV blocker (that is the -8 cycle - * drift / faithful per-instruction cycle model — see CLAUDE.md Rule -1). */ - if (!g_psx_precise_slice) return 0; - - /* No nested slicing: a handler dispatched from inside precise-mode, and any - * block executed while in_exception, run compiled (interrupts are gated during - * exception handling anyway). Keeps re-entrancy structurally impossible. */ - if (g_precise_mode || psx_get_in_exception()) return 0; - - static int s_slice_always = -1; - static int s_slice_margin = -1; - if (s_slice_always < 0) { - const char *e = getenv("PSX_PRECISE_ALWAYS"); - s_slice_always = (e && e[0] == '1') ? 1 : 0; - } - if (s_slice_margin < 0) { - const char *e = getenv("PSX_PRECISE_MARGIN"); - s_slice_margin = (e && *e) ? atoi(e) : 0; - if (s_slice_margin < 0) s_slice_margin = 0; - } - - uint32_t deadline = cycles_to_next_event(); - uint32_t budget = bcyc + (uint32_t)s_slice_margin; - if (budget < bcyc) budget = 0xFFFFFFFFu; - int entry_deliverable = precise_irq_deliverable(cpu); - int has_deadline = s_slice_always || entry_deliverable || (deadline <= budget); - if (!has_deadline && !side_effects) return 0; /* fast path: no event in this block */ - - g_slice_entry_deliverable = (uint32_t)entry_deliverable; - g_slice_fired++; - cpu->pc = block_addr; - psx_run_precise(cpu, bcyc, has_deadline); - return 1; -} - -static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { - uint32_t phys = addr & 0x1FFFFFFFu; - int clean_game_text_miss = 0; - - if (addr == 0x80000048u) { - g_sentinel_reach_dirty++; - if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */ - g_sentinel_reach_async = g_async_rfe_resume_pc; - cpu->pc = 0; - if (psx_get_in_exception()) { - psx_exception_longjmp(); /* does not return */ - } - /* Async ReturnFromException (Tomba 2 frame-1997 fix): game-installed handler - * RFE'd outside our exception window. Resume at the latched real guest PC - * instead of returning pc=0 (which the top dispatch reads as a clean exit). */ - if (g_async_rfe_resume_pc != 0u) { - g_async_rfe_fire_count++; - cpu->pc = g_async_rfe_resume_pc; - return 1; - } - return 1; - } - -#ifdef PSX_HAS_GAME_DISPATCH -#ifndef PSX_NO_DEBUG_TOOLS - xprobe_event(cpu->gpr[31], XOP_DD, XSITE_DD, addr, 0u, cpu->gpr[29], cpu->gpr[31], 0); -#endif - /* Run the statically-compiled game function only while the target is still - * native-safe. Dirty overlay pages and pages whose text bytes diverged from - * the original EXE image fall through to interpret the live RAM bytes. */ - if (psx_game_text_native_ok(addr)) { - g_mixed_depth++; - { - ls_func_enter(addr, cpu); - int prev_phase = g_exec_phase; - g_exec_phase = 3; - int _gc = psx_dispatch_game_compiled(cpu, addr); - g_exec_phase = prev_phase; - ls_func_exit(addr, cpu, _gc); - g_mixed_depth--; - if (_gc) return 1; - } - clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0; - } else if (psx_game_address_in_text(addr)) { - /* RAM at a game-text address diverged from the static EXE image - * (runtime-relocated / overlaid / self-modified code the compiled - * static function no longer reflects). The live RAM is the truth: - * fall through to INTERPRET it here rather than bail (line below) to - * the shell shadow (normalize() -> shell ROM). Crash Bash relocates a - * code page onto 0x30000 (inside the BIOS shell window); without this - * its 0x30FF4 call diverged (native_ok=0) yet was not dirty, so the - * interpreter bailed and normalize() shadowed it to dead shell ROM -> - * unknown-dispatch abort. Marking it a clean game-text miss lets the - * dirty interpreter execute the real RAM bytes. */ - clean_game_text_miss = 1; - } -#endif - - /* B-2: statically-compiled overlay functions (generated/overlays_static.c). - * Inert unless a game provides an overlays_static.c at build time. */ -#ifdef PSX_HAS_OVERLAY_DISPATCH - { - extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); - if (psx_overlay_dispatch(cpu, addr)) return 1; - } -#endif - - /* A-1: dynamically-loaded overlay DLL functions, checked before the - * interpreter. No-op (returns 0) until overlay_loader_init() runs, which - * only happens when the overlay cache is enabled in config. */ - /* §5-E native↔interp fingerprint: capture entry register state for a - * candidate overlay function, so native and interpreted runs can be diffed - * by sequence. Additive only — no control-flow change. */ - extern int overlay_loader_is_candidate(uint32_t phys); - extern int overlay_fp_enabled(void); - extern void overlay_regs_snap(uint32_t out[34], const CPUState *cpu); - extern void overlay_fp_log(uint32_t addr, const uint32_t *in_regs, - const CPUState *cpu, int native); - int _ovfp = overlay_fp_enabled() && - overlay_cache_window_contains(phys) && - overlay_loader_is_candidate(phys); - uint32_t _in_regs[34]; - if (_ovfp) { - overlay_regs_snap(_in_regs, cpu); - /* One-shot insn-ring freeze BEFORE the Nth dispatch of the watched - * candidate — the ring tail then holds the pre-divergence window. */ - if (g_insn_freeze_addr && phys == g_insn_freeze_addr && - ++g_insn_freeze_count == g_insn_freeze_nth) - g_insn_log_frozen = 1; - } - - { - extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); - if (overlay_loader_dispatch(cpu, addr)) { - if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); - return 1; - } - } -/* Every exit retires any deferred load writeback: once we hand control back to - * compiled code (or the dispatch loop) nothing downstream knows a register - * write is still owed, and the pipeline would have drained by then anyway. */ -#define OV_FPLOG_RET1() do { dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) - - if (!dirty_ram_is_dirty(phys) && !clean_game_text_miss) { - /* Bulk host transfers can populate post-EXE executable RAM without - * passing through the write hooks that mark dirty pages. A real - * control transfer to a decodable word above the configured boot-EXE - * text end is enough evidence to admit that word to the interpreter. - * Data and invalid targets still fail closed. */ - if (phys < (2u * 1024u * 1024u) && - phys >= g_overlay_region_floor && - dirty_ram_word_looks_decodable(fetch_word(phys))) { - dirty_ram_mark_executable_range(phys, 4u); - } else { - return 0; - } - } - - /* Interp-pressure signal for variant-capture automation (step 2.8): - * counts dispatches the interpreter actually handles inside a capture - * window. The autocapture tick reads-and-resets this to decide whether - * an unseen region variant is being interp-executed right now. */ - if (overlay_cache_window_contains(phys)) g_dirty_window_dispatches++; - - /* Reset soft-fail state at block entry. */ - g_unsupported_seen = 0; - /* Overlay flow above the kernel window — kernel window stays per-block (see - * is_local_dirty_target / phys_is_overlay_flow_region). Includes boot-text - * pages overwritten by a runtime overlay (Tomba 2), not just [FLOOR, RAM). */ - int allow_local_dirty_flow = phys_is_overlay_flow_region(phys); - - /* Per-PC entry counter (visible via dirty_ram_stats). */ - DirtyRamPcEntry *pc_entry = pc_table_get_or_insert(phys); - if (pc_entry) pc_entry->hits++; - { - uint32_t word = phys >> 2; - g_dirty_ram_dispatch_pc_bitmap[word >> 5] |= 1u << (word & 31u); - } - - /* External-entry attribution: when the previous interp run exited by - * handing a target back to the dispatch loop, the very next dirty - * dispatch at that target is the SAME logical execution continuing — - * not a new entry. Anything else arrived from native code (a call or - * a fresh dispatch) and is real interior-entry evidence for alias - * seeding. */ - if (pc_entry && addr != g_dirty_interp_chain_target) pc_entry->entry_hits++; - g_dirty_interp_chain_target = 0; - - /* Block-entry ring buffer — answers "who tried to JALR into this RAM - * stub" by capturing cpu->gpr[31] (the caller's RA) at dispatch time. - * Always-on; eviction keeps memory bounded. Honors the capture freeze. */ -#ifndef PSX_NO_DEBUG_TOOLS - if (!g_insn_log_frozen) { - uint64_t s = g_dirty_ram_block_log_seq++; - DirtyRamBlockLogEntry *e = - &g_dirty_ram_block_log[s & (DIRTY_RAM_BLOCK_LOG_CAP - 1u)]; - e->seq = s; - e->target = addr; - e->ra = cpu->gpr[31]; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; - e->t0 = cpu->gpr[8]; - e->t1 = cpu->gpr[9]; - e->t2 = cpu->gpr[10]; - e->sp = cpu->gpr[29]; - e->frame = (uint32_t)s_frame_count; - } -#endif - -#ifndef PSX_NO_DEBUG_TOOLS - if (debug_server_dirty_break_maybe_pause(addr, cpu)) { - debug_server_wait_if_paused(); - } -#endif - - /* Run dirty code locally until it returns to compiled/non-dirty code. - * Runtime-loaded overlays are larger than BIOS install stubs, so stopping - * at every local branch burns the dispatch loop. */ - enum { MAX_INSNS_PER_DISPATCH = 1000000 }; - uint32_t pc = addr; - uint32_t current_page = phys >> 12; - int current_page_dirty = dirty_ram_is_dirty(phys); - int insns_executed = 0; -#ifndef PSX_NO_DEBUG_TOOLS - extern void debug_server_cyc_observe(uint32_t block_leader_phys); -#endif - /* Async-interrupt latency fix. A guest wait loop that lives ENTIRELY in - * dirty RAM (e.g. libcd's CdSync spin, as in Kula World) re-enters this - * interpreter once per short (~7-insn) block and exits before the - * per-invocation `(insns_executed & 0xFFF)` gate below can fire — so a - * pending, IEc+IM2-enabled interrupt is never taken and the loop spins for - * seconds while the CD IRQ that would set its wait-flag is never serviced. - * Poll on a GLOBAL invocation counter so short-block loops still yield to - * interrupts (this is the interpreter analogue of a block-leader poll in - * static code). psx_check_interrupts runs the handler and returns with - * registers restored, so continuing the loop afterward is safe. */ - { - static uint32_t s_interp_entry_poll = 0; - static int s_entry_poll_enabled = -1; /* DIAGNOSTIC toggle (590c236 x kind-30 escape regression hunt) */ - if (s_entry_poll_enabled < 0) { - const char* e = getenv("PSX_DIRTY_ENTRY_POLL"); - s_entry_poll_enabled = (e && e[0] == '0') ? 0 : 1; - } - if (s_entry_poll_enabled && (++s_interp_entry_poll & 0x3Fu) == 0) { - cpu->pc = pc; - psx_check_interrupts(cpu); - } - } - for (int i = 0; i < MAX_INSNS_PER_DISPATCH; i++) { - uint32_t next_pc = 0; -#ifndef PSX_NO_DEBUG_TOOLS - /* Interp-path cycle ruler: make every interpreted PC anchorable by - * cyc_watch (parity with the compiled emitter's block-leader observe). - * Early-returns when cyc_watch is disarmed → ~free in normal runs. */ - g_ls_dirty_observe = 1; - debug_server_cyc_observe(pc & 0x1FFFFFFFu); - g_ls_dirty_observe = 0; -#endif -#ifdef PSX_COSIM - { extern void cosim_block(uint32_t); cosim_block(pc); } -#endif - uint32_t insn = fetch_word(pc & 0x1FFFFFFFu); -#ifndef PSX_NO_DEBUG_TOOLS - uint32_t before_s0 = cpu->gpr[16]; - uint32_t before_ra = cpu->gpr[31]; - /* Callee-smear tripwire: latch the first instruction in the - * watched pc window whose execution changes $s3 (gpr19) — for a jalr - * this spans the ENTIRE nested native callee, naming the callee that - * returned with a clobbered callee-saved register. Armed via the - * s3_smear_watch TCP command; zero-cost when disarmed. */ - extern uint32_t g_s3_smear_lo, g_s3_smear_hi; - extern uint32_t g_s3_smear_pc, g_s3_smear_insn, g_s3_smear_old, - g_s3_smear_new, g_s3_smear_tgt, g_s3_smear_frame; - extern int g_s3_smear_valid; - uint32_t before_s3 = cpu->gpr[19]; -#endif - cosim_exec_one_begin(); - int transferred = exec_one_fetched(cpu, pc, insn, &next_pc); -#ifndef PSX_NO_DEBUG_TOOLS - if (g_s3_smear_lo && !g_s3_smear_valid && - pc >= g_s3_smear_lo && pc < g_s3_smear_hi && - cpu->gpr[19] != before_s3 && - (g_s3_smear_excl == 0u || insn != g_s3_smear_excl)) { - g_s3_smear_valid = 1; - g_s3_smear_pc = pc; - g_s3_smear_insn = insn; - g_s3_smear_old = before_s3; - g_s3_smear_new = cpu->gpr[19]; - /* for jr/jalr the smearing callee = rs at the call site */ - g_s3_smear_tgt = cpu->gpr[(insn >> 21) & 0x1Fu]; - g_s3_smear_frame = (uint32_t)s_frame_count; - extern int g_insn_log_frozen; - g_insn_log_frozen = 1; /* freeze the insn ring at the smear */ - } - /* $ra->1 corruption tripwire (confirm-first probe): did THIS overlay - * instruction clobber $ra to 1? Latches once, cheap after. */ - if (cpu->gpr[31] == 1u && before_ra != 1u) { - extern void psx_ra_tripwire(CPUState *, uint32_t, uint32_t, uint32_t); - psx_ra_tripwire(cpu, before_ra, pc, 0u /*INTERP*/); - } - /* Tomba2 ra-corruption pin (Confirm-(b)): capture the EXACT instruction - * that loads/sets $ra to the watched wild value (0x49422E54). For a - * `lw ra, off(sp)` this records the loading pc/insn + sp + the source - * stack address so we can see whether sp is wrong or the filename string - * was written onto the saved-ra slot. Latches g_ra_load_snap_valid. */ - if (g_ra_load_watch != 0u && cpu->gpr[31] == g_ra_load_watch && - before_ra != g_ra_load_watch && !g_ra_load_snap_valid) { - g_ra_load_snap_valid = 1; - g_ra_load_snap_pc = pc; - g_ra_load_snap_insn = insn; - g_ra_load_snap_before_ra = before_ra; - for (int r = 0; r < 32; r++) g_ra_load_snap_gpr[r] = cpu->gpr[r]; - /* If it's a load (lw/lbu/...), decode base+imm to record the source addr. */ - uint32_t op = insn >> 26; - if (op >= 0x20u && op <= 0x25u) { /* lb/lh/lwl/lw/lbu/lhu */ - uint32_t base = cpu->gpr[(insn >> 21) & 0x1Fu]; - int16_t imm = (int16_t)(insn & 0xFFFFu); - g_ra_load_snap_srcaddr = base + (int32_t)imm; - } else { - g_ra_load_snap_srcaddr = 0; - } - } -#endif - /* Per-instruction cycle cost (R3000A load-delay interlock) is charged - * INSIDE exec_one (top-of-fn §1+deps+DO_LDS, or psx_cyc_load_* for loads). */ -#ifndef PSX_NO_DEBUG_TOOLS - dirty_ram_log_instruction(cpu, pc, insn, before_s0, next_pc, - transferred ? cpu->pc : next_pc, - transferred); -#endif -#ifdef PSX_COSIM - if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } -#endif - if (g_unsupported_seen) { - if (insns_executed == 0) { - /* Couldn't decode the first instruction. Most likely - * dispatch landed in a dirty page that's not actually - * code (stale data, return-target into save area, etc.). - * Hand off to psx_unknown_dispatch which has its own - * pattern-matching trampoline resolver. */ - return 0; - } - /* Made some progress, then hit unknown. Treat as a no-op - * return like psx_unknown_dispatch does for unrecognized - * targets — set cpu->pc=0 so the trampoline exits cleanly. - * If this turns out to be load-bearing, measurement will - * surface it as a card-protocol stall and we can add the - * missing opcode. - * - * No fprintf — read the last-* globals via TCP if needed - * (CLAUDE.md §3). Synchronous stderr at the rate this fires - * starves the dispatch loop and the debug-server poll. */ - g_dirty_ram_unsupported_midblock++; - g_dirty_ram_last_unsupported_entry = addr; - g_dirty_ram_last_unsupported_entry_ra = cpu->gpr[31]; - g_dirty_ram_last_unsupported_entry_sp = cpu->gpr[29]; - g_dirty_ram_last_unsupported_insns = (uint32_t)insns_executed; - g_dirty_ram_last_unsupported_pc = g_unsupported_pc; - g_dirty_ram_last_unsupported_insn = g_unsupported_insn; - g_dirty_ram_last_unsupported_reason = g_unsupported_reason; - cpu->pc = 0; - OV_FPLOG_RET1(); - } - g_dirty_ram_insns_run++; - dirty_ram_perf_hot_record(pc); - insns_executed++; - /* COP0 software-interrupt latency: an MTC0 to CAUSE or SR that makes a - * software interrupt deliverable (CAUSE.IP0/IP1 & SR.IM0/IM1 & IEc) - * must be taken IMMEDIATELY — real hardware vectors within the next - * instruction. Waiting for the next block boundary opens a window in - * which another hardware IRQ can re-enter the guest's (single-slot, - * legitimately non-reentrant) dispatcher and destroy its saved SR: - * Jackie Chan Stuntmaster's stage-1 handler does exactly - * `mtc0 CAUSE,0x100; mtc0 SR,0x101` and relies on the instant sw-int - * to reach its stage-2 before anything else runs. Same nested-delivery - * machinery as the entry poll above; EPC = the committed next pc. */ - if ((insn & 0xFFE00000u) == 0x40800000u) { /* MTC0 */ - uint32_t sw_rd = (insn >> 11) & 0x1Fu; - if ((sw_rd == 12u || sw_rd == 13u) && - (cpu->cop0[13] & cpu->cop0[12] & 0x0300u) != 0u && - (cpu->cop0[12] & 0x1u) != 0u) { - extern uint32_t g_dirty_safe_resume_pc; - uint32_t saved_resume = g_dirty_safe_resume_pc; - cpu->pc = next_pc ? next_pc : pc + 4u; - g_dirty_safe_resume_pc = cpu->pc; - /* Retire an owed load writeback before vectoring: the R3000A - * pipeline drains on exception entry, and the handler must not - * observe a stale destination register. */ - dirty_ram_ld_delay_flush(cpu); - psx_check_interrupts(cpu); - g_dirty_safe_resume_pc = saved_resume; - } - } - if (transferred) { - if (g_psx_call_bail) { - /* A bail unwind began inside a surfaced call: stop the interp - * run and hand the wild target (cpu->pc) up to the dispatch - * loop's bail handling. */ - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - OV_FPLOG_RET1(); - } - /* Trampoline contract parity (full_function_emitter emits this - * check after every compiled function return): the transfer that - * just committed may be the jr whose delay-slot RFE armed the - * host escape — cpu->pc now holds the real EPC, so take the - * escape BEFORE local dirty flow can continue interpreting the - * resumed code inside the handler's host window. Same-fiber: this - * longjmps to the psx_check_interrupts frame that dispatched the - * handler (its epilogue restores in_exception and it returns with - * cpu->pc = EPC through the existing pump-boundary redirect). - * Foreign fiber: declines, and the normal surfacing below applies. */ - if (g_rfe_escape_pending) - psx_rfe_escape_check(cpu); - uint32_t target = cpu->pc; - if (target != 0u && dirty_ram_pump_boundary(cpu, target, 1)) { - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - target = cpu->pc; -#ifdef PSX_HAS_GAME_DISPATCH - if (target != 0) { - /* §20 FIX — the long-run idle freeze. A guest TAIL-transfer (j/jr/ - * branch) from an interpreted overlay into compiled code used to NEST - * a fresh psx_dispatch_game_compiled trampoline here. That nest never - * unwound for the per-frame render chain, leaking ~one host call chain - * (~1.17 KB) PER FRAME -> the 64 MB guest stack overflowed after ~40k - * idle frames -> the native-stack guard tripped (RECURSION_BUG.md - * §19/§20; confirmed by the ce_profile linear climb). - * - * A tail-transfer carries NO return obligation, so the correct action - * is to SURFACE the target (leave it in cpu->pc) and return; the OUTER - * psx_dispatch_impl trampoline re-dispatches cpu->pc FLAT - * (full_function_emitter.cpp:1230), keeping the host stack bounded - * across frames. Dirty-overlay targets still take the local-dirty-flow - * just below (kept flat in-interpreter); compiled / static / unknown - * targets fall through to the surface return (case 3). Real guest - * CALLS (jal/jalr) are unaffected — they nest inline in exec_one and - * return normally. (The §14 watermark surfaced via g_psx_call_bail, - * which is wild-return semantics and wedged; a plain tail surface does - * not touch the bail.) */ -#ifndef PSX_NO_DEBUG_TOOLS - xprobe_event(pc, XOP_BR, XSITE_INTERP, target, insn, - cpu->gpr[29], cpu->gpr[31], 1); -#endif - cpu->pc = target; /* surfaced; trampoline re-dispatches flat */ - } -#endif - uint32_t target_phys = target & 0x1FFFFFFFu; - if (allow_local_dirty_flow && target != 0 && - target != stop_addr && - phys_is_overlay_flow_region(target_phys) && - dirty_ram_is_dirty(target_phys)) { - /* A runtime overlay may start executing while its final code - * bytes are still being installed. Entry-time native validation - * must reject that partial image, but local dirty flow used to - * remain here for up to one million instructions after the bytes - * became an exact cached match. Tomba 2's MDEC path turns that - * one early miss into an entire FMV interpreted at ~23 fps. - * - * Re-check exact cached entries at safe guest transfer - * boundaries. The loader re-hashes generation-changed code - * before executing it, so incomplete/self-modified bytes stay on - * this authoritative interpreter path. */ - if (overlay_loader_is_candidate(target_phys)) { - extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); - if (overlay_loader_dispatch(cpu, target)) { - g_dirty_ram_native_handoffs++; - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - OV_FPLOG_RET1(); - } - } -#ifdef PSX_HAS_GAME_DISPATCH - /* A patched prologue can force entry through the interpreter, - * while the remaining static ranges at a later continuation - * are still safe to run as compiled code. */ - if (clean_game_text_miss && interp_enter_compiled(cpu, target)) { - g_dirty_ram_native_handoffs++; - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } -#endif - /* Capture freeze gates ONLY the ring write — never flow. */ -#ifndef PSX_NO_DEBUG_TOOLS - if (!g_insn_log_frozen) { - uint64_t s = g_dirty_ram_flow_log_seq++; - DirtyRamFlowLogEntry *e = - &g_dirty_ram_flow_log[s & (DIRTY_RAM_FLOW_LOG_CAP - 1u)]; - e->seq = s; - e->pc = pc; - e->target = target; - e->ra = cpu->gpr[31]; - e->a0 = cpu->gpr[4]; - e->a1 = cpu->gpr[5]; - e->a2 = cpu->gpr[6]; - e->a3 = cpu->gpr[7]; - e->sp = cpu->gpr[29]; - e->frame = (uint32_t)s_frame_count; - } -#endif - pc = target; - current_page = target_phys >> 12; - current_page_dirty = 1; /* is_local_dirty_target proved it */ - if ((insns_executed & 0xFFF) == 0) { - debug_server_poll(); - debug_server_wait_if_paused(); - } - continue; - } - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - pc = next_pc; -#ifdef PSX_HAS_GAME_DISPATCH - /* Guest call returns advance without transferred set. Re-check the - * resume PC so a patched entry can hand its unchanged tail back to - * compiled code without adding probes to ordinary dirty overlay runs. */ - if (clean_game_text_miss && interp_enter_compiled(cpu, pc)) { - g_dirty_ram_native_handoffs++; - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } -#endif - /* Straight-line flow reaching the dispatch return contract — exit - * so the loop returns into the suspended native caller (same - * hazard as a transfer to stop_addr). */ - if (stop_addr != 0 && pc == stop_addr) { - cpu->pc = pc; - if (dirty_ram_pump_boundary(cpu, pc, 2)) { - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = pc; - OV_FPLOG_RET1(); - } - /* Straight-line code that left the dirty page — hand back to - * static dispatch by setting cpu->pc and returning. */ - uint32_t next_phys = pc & 0x1FFFFFFFu; - uint32_t next_page = next_phys >> 12; - if ((!current_page_dirty || next_page != current_page) && - !dirty_ram_is_dirty(next_phys)) { - cpu->pc = pc; - if (dirty_ram_pump_boundary(cpu, pc, 3)) { - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = cpu->pc; - OV_FPLOG_RET1(); - } - g_dirty_ram_blocks_run++; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = pc; - OV_FPLOG_RET1(); - } - if (next_page != current_page) current_page_dirty = 1; - current_page = next_page; - } - g_dirty_ram_last_unsupported_pc = pc; - g_dirty_ram_last_unsupported_insn = fetch_word(pc & 0x1FFFFFFFu); - g_dirty_ram_last_unsupported_reason = "instruction guard"; - g_dirty_ram_guard_yields++; - g_dirty_ram_blocks_run++; - cpu->pc = pc; - if (pc_entry) pc_entry->insns += (uint64_t)insns_executed; - g_dirty_interp_chain_target = pc; - { - uint64_t gap = g_dirty_ram_insns_run - s_last_dirty_irq_pump_insns; - if (gap >= 4096u) { - uint32_t committed = cpu->pc; - int prev_site = g_cosim_dirty_pump_site; - g_cosim_dirty_pump_site = 4; - g_dirty_safe_resume_pc = committed; - s_last_dirty_irq_pump_insns = g_dirty_ram_insns_run; - dirty_ram_ld_delay_flush(cpu); /* pipeline drains on exception entry */ - psx_check_interrupts(cpu); - /* Frame-1997 fix (see outer pump): restore the committed PC if a game RFE - * longjmp left cpu->pc=0, so the trampoline re-dispatches instead of exiting. */ - if (cpu->pc == 0u && committed != 0u) { - cpu->pc = committed; - g_async_rfe_fire_count++; - } - g_dirty_safe_resume_pc = 0; - g_cosim_dirty_pump_site = prev_site; - } - } - OV_FPLOG_RET1(); -} -#undef OV_FPLOG_RET1 - -/* ============================================================================ - * #2 — Lockstep "unit-test interp" comparator. See lockstep.h. - * Compiled-first + read-trace replay, per basic block, window-gated. - * ==========================================================================*/ -#include "lockstep.h" - -int g_ls_mode = 0; -int g_ls_replay_active = 0; -int g_ls_suppress_record = 0; - -static uint32_t s_ls_frame_lo = 0, s_ls_frame_hi = 0; /* hi==0 => disabled */ - -typedef struct { uint8_t is_write, size; uint32_t addr, val; } ls_op_t; -enum { LS_TRACE_CAP = 65536 }; -static ls_op_t s_ls_trace[LS_TRACE_CAP]; -static int s_ls_trace_n = 0, s_ls_trace_idx = 0; -static int s_ls_overflow = 0, s_ls_mismatch = 0; -static int s_ls_replay_done = 0; /* trace exhausted: stop replay (benign count mismatch) */ -static uint32_t s_ls_cur_pc = 0; /* pc of the instruction being replayed */ -static int s_ls_shadow_owner = 0; /* 0 none, 1 record, 2 replay */ -static int s_ls_shadow_saw_exception = 0; -static CPUState s_ls_R0; /* register snapshot at block entry */ -static uint32_t s_ls_block = 0; -static int s_ls_prev_valid = 0; - -/* mismatch detail captured by the hooks (read by ls_replay_and_compare) */ -static int s_ls_m_kind = 0; /* 1 read-addr 2 write-addr 3 write-val 4 trace-exhausted */ -static uint32_t s_ls_m_pc = 0, s_ls_m_addr = 0, s_ls_m_exp = 0, s_ls_m_act = 0; - -static struct { - int found; - int kind; /* see ls_kind below */ - uint32_t frame, block, pc, addr; - uint32_t exp, act; /* exp = interp(correct), act = compiled(actual) */ - int detail; /* reg index for REG kind, else 0 */ - uint64_t blocks_checked; -} s_ls_div = {0}; -/* kinds: 1 REG 2 HI 3 LO 4 WRITE-VAL 5 READ-ADDR 6 WRITE-ADDR 7 TRACE-EXHAUSTED - * 8 COMPILED-EXTRA-OPS 9 PATH-CAP */ -/* Post-mortem: the recorded op-trace of the diverging block + the replay's - * op index where it diverged, so the desync can be read off directly. */ -static ls_op_t s_ls_div_trace[48]; -static int s_ls_div_trace_n = 0, s_ls_div_idx = 0; - -static int s_ls_record_only = 0; /* diagnostic: record but skip inline replay (perturbation test) */ - -static uint32_t s_lsf_frame_lo = 0, s_lsf_frame_hi = 0; /* hi==0 => disabled */ -static int s_lsf_record_only = 0; -static int s_lsf_active = 0; -static int s_lsf_recording = 0; -static int s_lsf_saw_irq = 0; -static CPUState s_lsf_R0; -static uint32_t s_lsf_entry = 0, s_lsf_dispatch_entry = 0, s_lsf_frame = 0; - -static struct { - int found; - int kind; /* see lsf_kind names in ls_get_func_json */ - uint32_t frame, entry, pc, addr; - uint32_t exp, act; - int detail; - uint64_t segments_checked, skipped_irq, skipped_overflow; - uint64_t skipped_unhandled, skipped_conflict, skipped_disabled; - uint32_t trace_ops, replay_ops, steps; -} s_lsf_div = {0}; - -static ls_op_t s_lsf_div_trace[48]; -static int s_lsf_div_trace_n = 0, s_lsf_div_idx = 0; - -void ls_set_window(uint32_t lo, uint32_t hi) { s_ls_frame_lo = lo; s_ls_frame_hi = hi; } -void ls_set_record_only(int on) { s_ls_record_only = on; } -void ls_func_set_window(uint32_t lo, uint32_t hi) { s_lsf_frame_lo = lo; s_lsf_frame_hi = hi; } -void ls_func_set_record_only(int on) { s_lsf_record_only = on; } - -void ls_note_exception_entry(void) { - if (s_lsf_active) s_lsf_saw_irq = 1; - if (s_ls_shadow_owner == 1) s_ls_shadow_saw_exception = 1; -} - -int ls_shadow_record_begin(void) { - if (s_ls_shadow_owner || g_ls_mode != 0 || g_ls_replay_active || - s_lsf_active || s_ls_prev_valid) - return 0; - s_ls_trace_n = 0; - s_ls_trace_idx = 0; - s_ls_overflow = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_ls_replay_done = 0; - s_ls_shadow_saw_exception = 0; - s_ls_shadow_owner = 1; - g_ls_mode = 1; - return 1; -} - -int ls_shadow_record_end(uint32_t *ops, int *saw_exception) { - if (s_ls_shadow_owner != 1) return 0; - g_ls_mode = 0; - s_ls_shadow_owner = 0; - if (ops) *ops = (uint32_t)s_ls_trace_n; - if (saw_exception) *saw_exception = s_ls_shadow_saw_exception; - return !s_ls_overflow; -} - -int ls_shadow_replay_begin(void) { - if (s_ls_shadow_owner || g_ls_mode != 0 || g_ls_replay_active || - s_ls_overflow) - return 0; - s_ls_trace_idx = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_ls_replay_done = 0; - s_ls_shadow_owner = 2; - g_ls_replay_active = 1; - g_ls_mode = 2; - return 1; -} - -int ls_shadow_replay_end(uint32_t *ops, int *mismatch_kind, - uint32_t *pc, uint32_t *addr, - uint32_t *expected, uint32_t *actual) { - if (s_ls_shadow_owner != 2) return 0; - int complete = !s_ls_mismatch && !s_ls_replay_done && - s_ls_trace_idx == s_ls_trace_n; - g_ls_mode = 0; - g_ls_replay_active = 0; - s_ls_shadow_owner = 0; - if (ops) *ops = (uint32_t)s_ls_trace_idx; - if (mismatch_kind) *mismatch_kind = s_ls_m_kind; - if (pc) *pc = s_ls_m_pc; - if (addr) *addr = s_ls_m_addr; - if (expected) *expected = s_ls_m_exp; - if (actual) *actual = s_ls_m_act; - return complete; -} - -void ls_shadow_abort(void) { - g_ls_mode = 0; - g_ls_replay_active = 0; - s_ls_shadow_owner = 0; - s_ls_shadow_saw_exception = 0; -} - -void ls_suppress_begin(void) { - g_ls_suppress_record++; -} - -void ls_suppress_end(void) { - if (g_ls_suppress_record > 0) g_ls_suppress_record--; -} - -uint32_t ls_read_hook(uint32_t addr, int size, uint32_t real_val) { - if (g_ls_mode == 2) { /* replay: serve from trace */ - if (s_ls_trace_idx >= s_ls_trace_n) { s_ls_replay_done = 1; return 0; } /* count mismatch: benign block-boundary artifact */ - ls_op_t *o = &s_ls_trace[s_ls_trace_idx]; - if (o->is_write || o->size != (uint8_t)size || o->addr != addr) { - if (!s_ls_mismatch) { s_ls_mismatch = 1; s_ls_m_kind = 1; s_ls_m_pc = s_ls_cur_pc; - s_ls_m_addr = addr; s_ls_m_exp = addr; s_ls_m_act = o->addr; } - return 0; - } - s_ls_trace_idx++; - return o->val; - } - if (g_ls_mode == 1) { /* record */ - if (s_ls_trace_n < LS_TRACE_CAP) { - ls_op_t *o = &s_ls_trace[s_ls_trace_n++]; - o->is_write = 0; o->size = (uint8_t)size; o->addr = addr; o->val = real_val; - } else s_ls_overflow = 1; - } - return real_val; -} - -void ls_write_hook(uint32_t addr, int size, uint32_t val) { - if (g_ls_mode == 2) { /* replay: verify against trace */ - if (s_ls_trace_idx >= s_ls_trace_n) { s_ls_replay_done = 1; return; } /* count mismatch: benign block-boundary artifact */ - ls_op_t *o = &s_ls_trace[s_ls_trace_idx]; - if (!o->is_write || o->size != (uint8_t)size || o->addr != addr) { - if (!s_ls_mismatch) { s_ls_mismatch = 1; s_ls_m_kind = 2; s_ls_m_pc = s_ls_cur_pc; - s_ls_m_addr = addr; s_ls_m_exp = addr; s_ls_m_act = o->addr; } - return; - } - if (o->val != val) { /* compiled wrote a different value than interp */ - if (!s_ls_mismatch) { s_ls_mismatch = 1; s_ls_m_kind = 3; s_ls_m_pc = s_ls_cur_pc; - s_ls_m_addr = addr; s_ls_m_exp = val; s_ls_m_act = o->val; } - } - s_ls_trace_idx++; - return; - } - if (g_ls_mode == 1) { /* record */ - if (s_ls_trace_n < LS_TRACE_CAP) { - ls_op_t *o = &s_ls_trace[s_ls_trace_n++]; - o->is_write = 1; o->size = (uint8_t)size; o->addr = addr; o->val = val; - } else s_ls_overflow = 1; - } -} - -static void ls_latch(int kind, uint32_t frame, uint32_t pc, uint32_t addr, - uint32_t exp, uint32_t act, int detail) { - if (s_ls_div.found) return; - s_ls_div.found = 1; s_ls_div.kind = kind; s_ls_div.frame = frame; - s_ls_div.block = s_ls_block; s_ls_div.pc = pc; s_ls_div.addr = addr; - s_ls_div.exp = exp; s_ls_div.act = act; s_ls_div.detail = detail; -} - -/* Re-interpret the just-recorded block from the entry snapshot, comparing the - * interp result to native (cpu == post-block native state). */ -static void ls_replay_and_compare(CPUState *cpu, uint32_t frame, uint32_t target_phys) { - CPUState rep = s_ls_R0; /* registers from block entry (pc is stale in compiled) */ - rep.pc = s_ls_block | 0x80000000u; /* start at the recorded block's leader (KSEG0) */ - s_ls_trace_idx = 0; s_ls_mismatch = 0; s_ls_m_kind = 0; s_ls_replay_done = 0; - /* The replay's exec_one writes process-global decode state on any - * instruction it can't handle; snapshot+restore so the replay can never - * divert the REAL dispatch (which reacts to g_unsupported_seen). */ - int sav_unsup_seen = g_unsupported_seen; - uint32_t sav_unsup_pc = g_unsupported_pc; - uint32_t sav_unsup_insn = g_unsupported_insn; - const char *sav_unsup_reason = g_unsupported_reason; - int gte_sandbox = gte_replay_side_effects_begin(); - g_ls_replay_active = 1; - g_ls_mode = 2; - uint32_t pc = rep.pc; - int cap = 512, steps = 0; - for (;;) { - /* Stop at the next block leader (fall-through block, or a branch's - * target). steps>0 so a self-looping block runs one iteration. */ - if (steps > 0 && (pc & 0x1FFFFFFFu) == target_phys) break; - if (cap-- <= 0 || s_ls_mismatch || s_ls_replay_done) break; - uint32_t next_pc = 0; - s_ls_cur_pc = pc; - int transferred = exec_one(&rep, pc, &next_pc); - pc = transferred ? rep.pc : next_pc; - steps++; - /* Also stop at the block TERMINATOR (branch/jump/call) — do NOT follow - * it. A call would run the callee (kernel/syscall/another fn) which the - * recording dispatched separately or whose ops trail in the trace; - * following it desyncs. Compare only the block's own in-line memory ops, - * checked op-by-op against the trace as we go. */ - if (transferred) break; - } - g_ls_mode = 0; - g_ls_replay_active = 0; - if (gte_sandbox) gte_replay_side_effects_end(); - g_unsupported_seen = sav_unsup_seen; /* undo any replay-side decode-state leak */ - g_unsupported_pc = sav_unsup_pc; - g_unsupported_insn = sav_unsup_insn; - g_unsupported_reason = sav_unsup_reason; - s_ls_div.blocks_checked++; - - /* Divergence = an in-block memory-op mismatch (the interp replay read a - * different address, or the compiled block wrote a different value, at the - * same op index given identical entry state). Leftover trace ops past the - * terminator are the callee's (recording spanned a call) and are ignored; - * registers are not compared (boundary-sensitive across calls). A wrong - * register value still surfaces here later, as a wrong store value / load - * address, when the bad value is used. */ - if (s_ls_mismatch) { - int n = s_ls_trace_n; if (n > 48) n = 48; - for (int i = 0; i < n; i++) s_ls_div_trace[i] = s_ls_trace[i]; - s_ls_div_trace_n = n; - s_ls_div_idx = s_ls_trace_idx; - int k = (s_ls_m_kind == 1) ? 5 : (s_ls_m_kind == 2) ? 6 : (s_ls_m_kind == 3) ? 4 : 7; - ls_latch(k, frame, s_ls_m_pc, s_ls_m_addr, s_ls_m_exp, s_ls_m_act, 0); - return; - } -} - -static int ls_same_phys(uint32_t a, uint32_t b) { - return (((a ^ b) & 0x1FFFFFFFu) == 0); -} - -static void lsf_copy_div_trace(void) { - int n = s_ls_trace_n; - if (n > 48) n = 48; - for (int i = 0; i < n; i++) s_lsf_div_trace[i] = s_ls_trace[i]; - s_lsf_div_trace_n = n; - s_lsf_div_idx = s_ls_trace_idx; -} - -static void lsf_latch(int kind, uint32_t frame, uint32_t entry, uint32_t pc, - uint32_t addr, uint32_t exp, uint32_t act, int detail, - uint32_t steps) { - if (s_lsf_div.found) return; - s_lsf_div.found = 1; - s_lsf_div.kind = kind; - s_lsf_div.frame = frame; - s_lsf_div.entry = entry; - s_lsf_div.pc = pc; - s_lsf_div.addr = addr; - s_lsf_div.exp = exp; - s_lsf_div.act = act; - s_lsf_div.detail = detail; - s_lsf_div.trace_ops = (uint32_t)s_ls_trace_n; - s_lsf_div.replay_ops = (uint32_t)s_ls_trace_idx; - s_lsf_div.steps = steps; - lsf_copy_div_trace(); -} - -static void lsf_compare_regs(CPUState *rep, CPUState *nat, uint32_t frame, - uint32_t entry, uint32_t steps) { - if (!ls_same_phys(rep->pc, nat->pc)) { - lsf_latch(4, frame, entry, rep->pc, 0, rep->pc, nat->pc, 0, steps); - return; - } - for (int i = 0; i < 32; i++) { - if (rep->gpr[i] != nat->gpr[i]) { - lsf_latch(1, frame, entry, rep->pc, 0, rep->gpr[i], nat->gpr[i], i, steps); - return; - } - } - if (rep->hi != nat->hi) { - lsf_latch(2, frame, entry, rep->pc, 0, rep->hi, nat->hi, 0, steps); - return; - } - if (rep->lo != nat->lo) { - lsf_latch(3, frame, entry, rep->pc, 0, rep->lo, nat->lo, 0, steps); - return; - } -} - -static void lsf_replay_and_compare(CPUState *native_post, uint32_t frame, - uint32_t entry_pc) { - CPUState rep = s_lsf_R0; - uint32_t target = native_post->pc; - rep.pc = entry_pc; - - s_ls_trace_idx = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_ls_replay_done = 0; - - int sav_unsup_seen = g_unsupported_seen; - uint32_t sav_unsup_pc = g_unsupported_pc; - uint32_t sav_unsup_insn = g_unsupported_insn; - const char *sav_unsup_reason = g_unsupported_reason; - uint32_t sav_store_pc = g_debug_last_store_pc; - uint32_t sav_func_addr = g_debug_current_func_addr; - int gte_sandbox = gte_replay_side_effects_begin(); - - g_ls_replay_active = 1; - g_ls_mode = 2; - - uint32_t pc = entry_pc; - uint32_t steps = 0; - int done = 0; - enum { MAX_FUNC_REPLAY_INSNS = 250000 }; - for (;;) { - if (steps > 0 && target != 0 && ls_same_phys(pc, target) && - s_ls_trace_idx == s_ls_trace_n) { - done = 1; - break; - } - if (steps >= MAX_FUNC_REPLAY_INSNS) { - lsf_latch(10, frame, entry_pc, pc, 0, target, pc, 0, steps); - break; - } - if (s_ls_mismatch || s_ls_replay_done) break; - - uint32_t next_pc = 0; - s_ls_cur_pc = pc; - g_unsupported_seen = 0; - int transferred = exec_one(&rep, pc, &next_pc); - if (g_unsupported_seen) { - lsf_latch(11, frame, entry_pc, pc, 0, 0, g_unsupported_insn, 0, steps); - break; - } - pc = transferred ? rep.pc : next_pc; - rep.pc = pc; - steps++; - } - - g_ls_mode = 0; - g_ls_replay_active = 0; - if (gte_sandbox) gte_replay_side_effects_end(); - g_unsupported_seen = sav_unsup_seen; - g_unsupported_pc = sav_unsup_pc; - g_unsupported_insn = sav_unsup_insn; - g_unsupported_reason = sav_unsup_reason; - g_debug_last_store_pc = sav_store_pc; - g_debug_current_func_addr = sav_func_addr; - - s_lsf_div.segments_checked++; - - if (s_ls_mismatch) { - int k = (s_ls_m_kind == 1) ? 6 : (s_ls_m_kind == 2) ? 7 : - (s_ls_m_kind == 3) ? 5 : 8; - lsf_latch(k, frame, entry_pc, s_ls_m_pc, s_ls_m_addr, - s_ls_m_exp, s_ls_m_act, 0, steps); - return; - } - if (s_ls_replay_done) { - lsf_latch(8, frame, entry_pc, s_ls_cur_pc, 0, (uint32_t)s_ls_trace_n, - (uint32_t)s_ls_trace_idx, 0, steps); - return; - } - if (!done) { - lsf_latch(10, frame, entry_pc, pc, 0, target, pc, 0, steps); - return; - } - if (s_ls_trace_idx != s_ls_trace_n) { - lsf_latch(9, frame, entry_pc, pc, 0, (uint32_t)s_ls_trace_n, - (uint32_t)s_ls_trace_idx, 0, steps); - return; - } - - lsf_compare_regs(&rep, native_post, frame, entry_pc, steps); -} - -void ls_func_enter(uint32_t entry_pc, CPUState *cpu) { - if (s_lsf_frame_hi == 0 || s_lsf_div.found || !cpu) return; - if (g_ls_replay_active || g_ls_mode != 0 || s_lsf_active) { - s_lsf_div.skipped_conflict++; - return; - } - if (psx_get_in_exception()) { - s_lsf_div.skipped_irq++; - return; - } - uint32_t frame = (uint32_t)s_frame_count; - if (frame < s_lsf_frame_lo || frame > s_lsf_frame_hi) { - s_lsf_div.skipped_disabled++; - return; - } - uint32_t phys = entry_pc & 0x1FFFFFFFu; - if (phys < 0x00010000u || phys >= 0x00200000u) return; - - s_lsf_dispatch_entry = entry_pc; - s_lsf_entry = entry_pc; - s_lsf_frame = frame; - s_lsf_saw_irq = 0; - s_lsf_active = 1; - s_lsf_recording = 0; -} - -void ls_func_exit(uint32_t entry_pc, CPUState *cpu, int handled) { - if (!s_lsf_active) return; - if (s_lsf_dispatch_entry != entry_pc) { - g_ls_mode = 0; - s_lsf_active = 0; - s_lsf_recording = 0; - s_lsf_div.skipped_conflict++; - return; - } - g_ls_mode = 0; - s_lsf_active = 0; - if (!s_lsf_recording) { - s_lsf_div.skipped_unhandled++; - return; - } - s_lsf_recording = 0; - - if (!handled || !cpu) { - s_lsf_div.skipped_unhandled++; - return; - } - if (s_lsf_saw_irq) { - s_lsf_div.skipped_irq++; - return; - } - if (s_ls_overflow) { - s_lsf_div.skipped_overflow++; - return; - } - if (s_lsf_record_only) { - s_lsf_div.segments_checked++; - return; - } - lsf_replay_and_compare(cpu, s_lsf_frame, s_lsf_entry); -} - -void ls_at_leader(uint32_t leader_phys, CPUState *cpu) { - if (s_lsf_active) { - if (!s_lsf_recording && cpu && !s_lsf_div.found && g_ls_mode == 0) { - s_lsf_R0 = *cpu; - s_lsf_entry = leader_phys | 0x80000000u; - s_ls_trace_n = 0; - s_ls_trace_idx = 0; - s_ls_overflow = 0; - s_ls_mismatch = 0; - s_ls_m_kind = 0; - s_lsf_recording = 1; - g_ls_mode = 1; - } - return; /* function-scope recording owns g_ls_mode */ - } - if (s_ls_frame_hi == 0 || s_ls_div.found || !cpu) return; - if (g_ls_mode == 2) return; /* re-entrancy guard (shouldn't happen) */ - uint32_t frame = (uint32_t)s_frame_count; - int in_win = (frame >= s_ls_frame_lo && frame <= s_ls_frame_hi); - if (!in_win) { if (g_ls_mode == 1) { g_ls_mode = 0; s_ls_prev_valid = 0; } return; } - - if (s_ls_prev_valid && g_ls_mode == 1) { /* finalize previous game block */ - g_ls_mode = 0; - if (!s_ls_record_only) { /* record_only: skip inline replay (perturbation test) */ - ls_replay_and_compare(cpu, frame, leader_phys); /* this leader = where prev block went */ - if (s_ls_div.found) { s_ls_prev_valid = 0; return; } - } else { - s_ls_div.blocks_checked++; /* count recorded blocks */ - } - s_ls_prev_valid = 0; - } - /* Only START recording for a genuinely COMPILED game-text block: - * - !g_ls_dirty_observe: the dirty-RAM interp loop also calls cyc_observe - * (per-instruction, for the cycle ruler); skip those — they're already - * interpreted (clean game text dispatched FROM the dirty path still runs - * compiled, so we can't use g_dirty_interp_active here). - * - [0x10000, 0x200000): game EXE text in main RAM (above the low-RAM - * kernel/relocated-BIOS area, which isn't the regression locus). */ - if (!g_ls_dirty_observe && leader_phys >= 0x00010000u && leader_phys < 0x00200000u) { - s_ls_R0 = *cpu; - s_ls_block = leader_phys; - s_ls_trace_n = 0; s_ls_trace_idx = 0; s_ls_overflow = 0; s_ls_mismatch = 0; - s_ls_prev_valid = 1; - g_ls_mode = 1; - } -} - -int ls_get_diverge_json(char *buf, int buflen) { - static const char *kn[] = {"none","reg","hi","lo","write-val","read-addr", - "write-addr","trace-exhausted","compiled-extra-ops","path-cap"}; - int k = (s_ls_div.kind >= 0 && s_ls_div.kind <= 9) ? s_ls_div.kind : 0; - int p = snprintf(buf, buflen, - "{\"found\":%d,\"kind\":\"%s\",\"frame\":%u,\"block\":\"0x%08X\",\"pc\":\"0x%08X\"," - "\"addr\":\"0x%08X\",\"interp_expected\":\"0x%08X\",\"compiled_actual\":\"0x%08X\"," - "\"reg\":%d,\"blocks_checked\":%llu,\"window\":[%u,%u],\"div_idx\":%d,\"trace\":[", - s_ls_div.found, kn[k], s_ls_div.frame, s_ls_div.block, s_ls_div.pc, - s_ls_div.addr, s_ls_div.exp, s_ls_div.act, s_ls_div.detail, - (unsigned long long)s_ls_div.blocks_checked, s_ls_frame_lo, s_ls_frame_hi, - s_ls_div_idx); - for (int i = 0; i < s_ls_div_trace_n && p < buflen - 48; i++) { - p += snprintf(buf + p, buflen - p, "%s\"%c%d:%08X=%08X\"", - i ? "," : "", s_ls_div_trace[i].is_write ? 'W' : 'R', - s_ls_div_trace[i].size, s_ls_div_trace[i].addr, s_ls_div_trace[i].val); - } - p += snprintf(buf + p, buflen - p, "]}"); - return p; -} - -int ls_get_func_json(char *buf, int buflen) { - static const char *kn[] = { - "none","gpr","hi","lo","pc","write-val","read-addr","write-addr", - "trace-exhausted","trace-leftover","path-cap","unsupported" - }; - int k = (s_lsf_div.kind >= 0 && s_lsf_div.kind <= 11) ? s_lsf_div.kind : 0; - int p = snprintf(buf, buflen, - "{\"found\":%d,\"kind\":\"%s\",\"frame\":%u,\"entry\":\"0x%08X\"," - "\"pc\":\"0x%08X\",\"addr\":\"0x%08X\"," - "\"interp_expected\":\"0x%08X\",\"compiled_actual\":\"0x%08X\"," - "\"reg\":%d,\"segments_checked\":%llu,\"skipped_irq\":%llu," - "\"skipped_overflow\":%llu,\"skipped_unhandled\":%llu," - "\"skipped_conflict\":%llu,\"skipped_disabled\":%llu," - "\"window\":[%u,%u],\"trace_ops\":%u,\"replay_ops\":%u," - "\"steps\":%u,\"div_idx\":%d,\"trace\":[", - s_lsf_div.found, kn[k], s_lsf_div.frame, s_lsf_div.entry, - s_lsf_div.pc, s_lsf_div.addr, s_lsf_div.exp, s_lsf_div.act, - s_lsf_div.detail, (unsigned long long)s_lsf_div.segments_checked, - (unsigned long long)s_lsf_div.skipped_irq, - (unsigned long long)s_lsf_div.skipped_overflow, - (unsigned long long)s_lsf_div.skipped_unhandled, - (unsigned long long)s_lsf_div.skipped_conflict, - (unsigned long long)s_lsf_div.skipped_disabled, - s_lsf_frame_lo, s_lsf_frame_hi, s_lsf_div.trace_ops, - s_lsf_div.replay_ops, s_lsf_div.steps, s_lsf_div_idx); - for (int i = 0; i < s_lsf_div_trace_n && p < buflen - 48; i++) { - p += snprintf(buf + p, buflen - p, "%s\"%c%d:%08X=%08X\"", - i ? "," : "", s_lsf_div_trace[i].is_write ? 'W' : 'R', - s_lsf_div_trace[i].size, s_lsf_div_trace[i].addr, - s_lsf_div_trace[i].val); - } - p += snprintf(buf + p, buflen - p, "]}"); - return p; -} diff --git a/psxrecomp-overlay/runtime/src/gpu.c b/psxrecomp-overlay/runtime/src/gpu.c deleted file mode 100644 index 2bd3df7..0000000 --- a/psxrecomp-overlay/runtime/src/gpu.c +++ /dev/null @@ -1,6336 +0,0 @@ -/* gpu.c — PS1 GPU hardware simulation (Phase 3, Step 1). - * - * Implements: - * - GPUSTAT register with correct bit semantics - * - GP1 commands 00h-08h (reset, display config) - * - VRAM storage (1024x512 x 16-bit) - * - GP0 command write — ABORTS (not yet implemented) - * - GPUREAD — returns last latched value - * - * Reference: nocash PSX specs, DuckStation src/core/gpu.cpp - */ - -#include "gpu.h" -#include "gte_precision.h" -#include "mod_memory.h" -#include "gpu_primitive_reject.h" -#include "gpu_sw_renderer.h" -#include "gpu_render.h" -#include "text_xlate.h" -#include "crash_trace.h" -#include "debug_server.h" -#include "cpu_state.h" -#include "event_ring.h" -#include "color_lut.h" -#include "mod_runtime.h" -#include "ws_cull_detect.h" -#include "ws_aspect_cone_math.h" -#include "gpu_pinned_corner.h" -#include "gpu_ws_hud_scale.h" -#include "gpu_ws_hud_widget.h" -#include "gpu_ws_screen_tile.h" -#include "gpu_ws_tag_match.h" -#include "gpu_ws_frame_kind.h" -#include "gpu_ws_menu.h" -#include "gpu_temporal_sprite.h" -#include "ws_ui_group.h" -#include -#include -#include -#include -#include - -extern uint16_t psx_read_half(uint32_t addr); -extern uint8_t psx_read_byte(uint32_t addr); -extern uint32_t psx_read_word(uint32_t addr); - -/* ---- VRAM ---- */ -static uint16_t vram[1024 * 512]; - -/* ---- GP0 state machine ---- */ - -typedef enum { - GP0_IDLE, - GP0_COLLECTING, - GP0_VRAM_WRITE, - GP0_POLYLINE_MONO, /* collecting mono polyline vertices */ - GP0_POLYLINE_SHADED /* collecting shaded polyline color+vertex pairs */ -} Gp0State; - -static Gp0State gp0_state; -static uint64_t gp0_write_count; -static uint64_t gp0_nop_count, gp0_fill_count, gp0_draw_count, gp0_env_count, gp0_copy_count; -static uint32_t gp0_cmd_buf[16]; /* max fixed-length command is 12 words */ -static int gp0_words_collected; -static int gp0_words_needed; -static uint32_t gp0_next_source_addr = 0xFFFFFFFFu; -static uint32_t gp0_cmd_source_addr = 0xFFFFFFFFu; -static uint16_t gp0_ot_rank = 0xFFFFu; - -/* ---- Widescreen proportion correction -------------------------------------- - * Active only when [video] aspect_ratio != 4:3 AND the game's [widescreen] - * block opts in (see config_loader.h). Two mechanisms, both driven from the - * game.toml so nothing here is game-specific: - * - * 1. Tagged character/billboard prims. The recompiler emits a - * psx_ws_sprite_tag(cpu) call at the entry of each configured - * sprite-tag function ($a0 = prim pointer, scratchpad holds the prim's - * GTE-projected anchor). The game computes the prim's screen-pixel - * offsets/widths itself — which our GTE X-squash never sees — so tagged - * prims get every X re-squashed around their own anchor at execution - * time, restoring correct proportions on the stretched present. - * - * 2. Untagged textured rects (SPRT family). These never went through the - * GTE at all (HUD, menus — pure screen space), so they're squashed - * around the display centre, presenting at native proportions. - * Untextured TILEs are never touched: full-screen fades/flashes must - * keep covering the whole frame. - * - * The tag table is keyed by the prim's guest address (the DMA linked-list - * walk reports each word's source address) and stamped with the frame - * counter, so stale entries age out without an explicit clear. */ -static int32_t ws_xnum = 1, ws_xden = 1; /* X squash factor; 1/1 = off */ -static uint32_t ws_anchor_addr = 0; /* scratchpad addr of anchor SXY */ -static int ws_hud_sprt = 0; /* edge-anchor untagged HUD SPRTs */ -static int ws_auto_ui_squash; -static int ws_auto_ui_dense; -static uint64_t ws_auto_ui_candidate_count; -static uint64_t ws_auto_ui_transform_count; -#define WS_UI_PREPASS_MAX 2048u -typedef struct { - WsUiGroupItem group; - uint32_t src_addr; - uint16_t ot_rank; -} WsUiPrepassItem; -static WsUiPrepassItem ws_ui_prepass[WS_UI_PREPASS_MAX]; -static uint32_t ws_ui_prepass_count; -static uint16_t ws_ui_prepass_rank = 0xFFFFu; - -/* Wide-aspect mode: 0 = off (4:3 identity), 1 = squash (legacy hack — compress - * a wider FOV into the 320 frame, present stretched), 2 = native-wide (render - * the wider FOV into an actually wider frame and present 1:1; the GTE is NOT - * squashed — the frame is widened via draw_offset/draw_area and the display - * read). ws_cfg_num/den hold the configured aspect that drives the wide extent - * in native-wide mode. */ -static int ws_mode = 0; -static int ws_cfg_num = 4, ws_cfg_den = 3; -static int ws_geometry_correction = 0; -static int s_texture_correction_enabled = 0; -static uint32_t s_texture_correction_triangles = 0; -static double s_geometry_camera_yaw_residual = 0.0; -static int s_geometry_camera_projection_center_y = 120; -static uint64_t ws_geometry_world_triangles = 0; -static uint64_t ws_geometry_precise_triangles = 0; -static GpuGeometryCorrectionCounters s_geometry_diag_cumulative; -static GpuGeometryCorrectionCounters s_geometry_diag_live_current; -static GpuGeometryCorrectionCounters s_geometry_diag_live_latest; -static uint64_t s_geometry_diag_presentation_sequence; -static uint64_t s_geometry_diag_corrected_presentations; -static uint64_t s_geometry_diag_latest_live_presentation; -static int s_geometry_diag_latest_live_valid; -static int s_geometry_diag_live_current_touched; -static void ws_nw_sync_target(void); - -#define WS_TAG_BUCKETS 4096 /* power of two */ -#define WS_TAG_PROBES 8 -#define WS_FMV_HYSTERESIS 30 /* frames a colour MDEC decode pins 4:3 */ -typedef struct { - uint32_t key; - uint32_t stamp; - int32_t anchor_x; - uint32_t signature; - uint8_t content_validated; - uint8_t placed, sized, shadow, whole; - int32_t centre_x, offset_y; /* 16.16: a sprite's unrounded centre, and how far above or below its row */ - int32_t row, width, height; /* 16.16: the unrounded row it is centred on and its unrounded sides */ -} WsTag; -static WsTag ws_tags[WS_TAG_BUCKETS]; -static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */ -static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */ -extern uint64_t s_frame_count; /* defined in debug_server.c */ -extern int mdec_recently_active(uint32_t within_frames); /* mdec.c */ - -static int ws_configured(void) { return ws_xnum != ws_xden; } - -/* Any wide mode engaged (squash or native-wide). Drives the FMV/menu 4:3 - * pillarbox + the game-vs-2D detector, which both modes share. */ -static int ws_engaged(void) { return ws_mode != 0; } - -/* Forward decls: defined later but used by psx_ws_backdrop_x above them. */ -static int32_t ws_scale_about(int32_t x, int32_t ax); -static int32_t ws_disp_w(void); -static void ws_clear_all_reveal_margins(void); - -/* Gameplay vs full-2D screen. Character/billboard prims tag (psx_ws_sprite_tag) - * within the last couple of frames => the actor render funnel is running => - * gameplay. A menu/title/save screen draws no tagged prims. The 2-frame window - * makes this frame-stable regardless of per-prim ordering within a frame. */ -/* Full-2D widescreen ([widescreen] full_2d). A pure 2D sprite game (e.g. MMX6) - * never emits the sprite-tag hook the 3D gameplay detector keys on, so every - * in-game frame would be misclassified as a "full-2D menu" and pillarboxed 4:3. - * When this is set, treat every in-game frame as gameplay so native-wide engages - * and the 2D scene presents widescreen. Set once from game.toml at widescreen- - * engage (gpu_ws_set_full_2d); PSX_WS_FORCE_2D=1 forces it on for testing. */ -static int ws_full_2d = 0; -void gpu_ws_set_full_2d(int on) { ws_full_2d = on ? 1 : 0; } -void gpu_ws_set_auto_ui_squash(int on) { - ws_auto_ui_squash = on ? 1 : 0; - ws_ui_prepass_count = 0; - ws_ui_prepass_rank = 0xFFFFu; - ws_auto_ui_dense = 0; - ws_auto_ui_candidate_count = 0; - ws_auto_ui_transform_count = 0; -} -static int ws_clear_reveal = 0; -static int g_mmx6_void_sides = 0; -static uint32_t g_mmx6_void_generation = 1; -void gpu_ws_set_clear_reveal(int on) { ws_clear_reveal = on ? 1 : 0; } - -/* GTE-activity gameplay detector ([widescreen] gte_game_mode) — the generic - * 3D-title analog of the sprite-tag stamp. A fully-3D game (e.g. Ape Escape) - * has no per-prim tag helper to hook, but every gameplay frame projects a - * meaningful number of vertices through RTPS/RTPT, while a full-2D screen - * (save/options/memory-card) projects none (or a token few). gte.cpp notes - * every projection here; when a frame accumulates >= the threshold the frame - * is stamped as gameplay, with the same 2-frame hysteresis the tag path uses. - * Off unless the game opts in, so existing titles are untouched. */ -static int ws_gte_game_mode_cfg = 0; -static uint32_t ws_gte_frame = (uint32_t)-1; -static uint32_t ws_gte_count = 0; -static uint32_t ws_last_gte_stamp = (uint32_t)-1000; -#define WS_GAMEPLAY_STATE_VALUES_MAX 16 -static uint32_t ws_gameplay_state_addr = 0; -static uint32_t ws_gameplay_state_values[WS_GAMEPLAY_STATE_VALUES_MAX]; -static int ws_gameplay_state_value_count = 0; -/* Any frame that projects a handful of vertices is "3D" (a low threshold so a - * sparse close-up cutscene frame still counts — the flicker was frames dipping - * below a high 16-vert bar and pillarboxing for a frame or two). */ -#define WS_GTE_GAME_MODE_MIN_VERTS 3u -/* STICKY: stay in native-wide for ~0.75s after the last 3D frame, so brief - * low-poly frames in a real-time 3D cutscene never flip to a 4:3 pillarbox (the - * intro-cutscene flicker). Only a genuine full-2D screen — no GTE projection for - * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ -#define WS_GTE_GAME_MODE_HYSTERESIS 45u -static PsxWsFrameKinds ws_frame_kinds; -static uint32_t display_area_x, display_area_y; -void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } -void gpu_ws_set_gameplay_state_gate(uint32_t addr, - const uint32_t *values, int nvalues) { - if (nvalues < 0) nvalues = 0; - if (nvalues > WS_GAMEPLAY_STATE_VALUES_MAX) - nvalues = WS_GAMEPLAY_STATE_VALUES_MAX; - ws_gameplay_state_addr = addr; - ws_gameplay_state_value_count = nvalues; - for (int i = 0; i < nvalues; i++) - ws_gameplay_state_values[i] = values[i]; -} - -static int ws_gameplay_state_matches(void) { - if (!ws_gameplay_state_addr || ws_gameplay_state_value_count == 0) - return -1; - uint32_t state = psx_read_word(ws_gameplay_state_addr); - for (int i = 0; i < ws_gameplay_state_value_count; i++) - if (state == ws_gameplay_state_values[i]) return 1; - return 0; -} - -/* World-scale 3D signal for the 2D-only-scene classifier (sprite-tag titles). - * Shaded-prim presence proved to be a FALSE world signal: task-clear / - * new-task title cards are gouraud-shaded screen-space tiles animated over - * many consecutive frames, which defeated both the shaded test and its - * sustained guard (room re-stretched whenever a card played). Projection - * volume can't be fooled that way: a real 3D world projects hundreds of - * verts per frame through RTPS/RTPT, a room interior projects only its - * character anchors (a handful), and screen-space overlays project ZERO. - * Sustained rule as elsewhere: 2+ consecutive frames over threshold, so an - * isolated projection burst can't flip a 2D scene wide. */ -#define WS_WORLD3D_MIN_VERTS 48u -static uint32_t ws_gte_prev_verts = 0; /* final count of last completed frame */ -static uint32_t ws_last_world3d_stamp = (uint32_t)-1000; -static uint32_t ws_sust_world3d_stamp = (uint32_t)-1000; - -/* Natural-overhang world signal — the classifier's actual input. GTE volume - * above proved to be a SECOND false world signal: the task-found/task-clear - * jingle projects world-scale vert counts inside a room (stamped world3d at - * ≥48 while the room idles at 4). What can't be faked is CONTENT: a real 3D - * scene always submits polygons whose raw vertex X extent crosses outside - * the canonical display window (that overhang is the very content native- - * wide reveals — the working outdoor reveal proves it exists), while rooms, - * dialogs, HUDs and jingle popups compose everything inside it. Polygons - * only (sprites/rects slide in from edges routinely — the task icon enters - * from the right edge), ≥3 prims per frame, sustained 2+ consecutive - * frames. Counted at gp0_execute_command via prim_sx_extent (raw SX, - * pre-draw_offset, so our native-wide offset injection can't feed back). */ -/* Depth qualifier: only polys crossing DEEP past the canonical edge count. - * Census-measured (SCUS-94236): outdoor terrain = 55/frame at >=24px (depths - * to 128px); the task-jingle sparkles poke <=20px and its sliding icon is a - * single prim at 34px — so >=24px depth + >=4 prims excludes every observed - * screen-edge effect with ~14x headroom to the real world's count. */ -#define WS_OVERHANG_DEEP_PX 24 -#define WS_OVERHANG_MIN_PRIMS 4u -static uint32_t ws_ovh_frame = (uint32_t)-1; -static uint32_t ws_ovh_count = 0; -static uint32_t ws_ovh_prev = 0; /* final count of last completed frame */ -static uint32_t ws_last_ovh_stamp = (uint32_t)-1000; -static uint32_t ws_sust_ovh_stamp = (uint32_t)-1000; - -void psx_ws_note_gte_project(int nverts) { - uint32_t f = (uint32_t)s_frame_count; - psx_ws_frame_kinds_project(&ws_frame_kinds, (uint32_t)nverts); - if (f != ws_gte_frame) { - if (f == ws_gte_frame + 1u) ws_gte_prev_verts = ws_gte_count; - else ws_gte_prev_verts = 0; - ws_gte_frame = f; ws_gte_count = 0; - } - ws_gte_count += (uint32_t)nverts; - if (ws_gte_game_mode_cfg && ws_gte_count >= WS_GTE_GAME_MODE_MIN_VERTS) - ws_last_gte_stamp = f; - if (ws_gte_count >= WS_WORLD3D_MIN_VERTS && ws_last_world3d_stamp != f) { - if (f == ws_last_world3d_stamp + 1u) ws_sust_world3d_stamp = f; - ws_last_world3d_stamp = f; - } -} - -/* Full-2D tile-engine mode (MMX6): config [widescreen] full_2d, or the PSX_WS_FORCE_2D - * test override. Distinct from ws_game_mode (which also fires on the 3D sprite-tag path, - * e.g. Tomba) — only true full-2D games get the BG tile-budget reveal cap. */ -static int ws_full_2d_mode(void) { - static int env = -1; - if (env < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); env = (e && e[0] == '1') ? 1 : 0; } - return ws_full_2d || env; -} -/* `displayed` asks about the frame on display, which a double-buffered game drew one frame before the one it is - * drawing now. A GTE-detected game's frames are told apart per buffer, and the grace below only covers the start. */ -static int ws_game_mode_of(int displayed) { - int state_match = ws_gameplay_state_matches(); - if (state_match >= 0) return state_match; - if (ws_full_2d_mode()) return 1; - if (ws_gte_game_mode_cfg) { - const int flat = displayed - ? psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) - : psx_ws_frame_kinds_drawing(&ws_frame_kinds, WS_GTE_GAME_MODE_MIN_VERTS); - if (flat >= 0) return !flat; - if ((uint32_t)s_frame_count - ws_last_gte_stamp <= WS_GTE_GAME_MODE_HYSTERESIS) return 1; - } - return (uint32_t)s_frame_count - ws_last_tag_stamp <= 2; -} -static int ws_game_mode(void) { return ws_game_mode_of(0); } - -/* True when the current frame is presented at native 4:3 (NOT stretched), so - * ALL squash must be suppressed and the content rendered pixel-native: - * - FMV video (24-bit, or streamed 15-bit colour MDEC), and - * - full-2D screens (menus/title/save — no character billboards this frame). - * Coupling squash to this exact predicate keeps content and present in lock- - * step: we squash IFF we stretch. Per-prim center-squash mangles composite 2D - * UI (dialog boxes built from tiled cap/middle pieces), so such screens get - * zero squash + a 4:3 pillarbox instead. The FMV check is cached per frame; - * game_mode is a cheap live check. */ -/* 2D-only gameplay scenes (sprite-tag titles). A room interior or a sky-only - * fall is gameplay-classified (the character prims tag every frame) but has - * no world-scale GTE projection — there is no 3D world and nothing beyond - * the canonical frame to reveal, so wide presents can only stretch flat art - * (the backdrop stretch fires wholesale because the background phase never - * ends). Present those scenes native 4:3 instead: the canonical buffer is - * always faithful. The signal is natural polygon OVERHANG past the canonical - * window (ws_sust_ovh_stamp) — content that would actually be revealed. - * Shaded-prim presence and GTE projection volume both proved to be false - * world signals (title-card letter tiles; the task-jingle's GTE effect) — - * see the overhang block for the full lineage. Scoped to the tag-classified - * (2.5D) mechanism: full-2D titles (MMX6) are 2D-only by definition and - * genuinely reveal more, and GTE-detector titles (Ape) already classify 2D - * screens by projection count. The hysteresis rides out 1-2 frame gaps; a - * real scene change crosses it in ~0.1 s. */ -#define WS_2D_SCENE_HYSTERESIS 6u -static int ws_2d_only_scene(void) { - if (ws_full_2d_mode() || ws_gte_game_mode_cfg) return 0; - return (uint32_t)s_frame_count - ws_sust_ovh_stamp > WS_2D_SCENE_HYSTERESIS; -} - -static uint32_t s_ws_fmv_frame_cache = 0xFFFFFFFFu; -static int s_ws_fmv_cached = 0; - -/* The front end's backdrop on a wide screen (gpu_ws_menu.h). Squash mode only: a wider frame of its own has nothing to put beside the picture. */ -static int ws_menu_logo; /* the picture at (320,0) is the front end's, and its logo is kept at (512,0) */ -static int ws_menu_halves; /* the halves of the backdrop drawn since the logo was last drawn */ -static int ws_menu_wanted(void) { return ws_mode == 1 && ws_xnum != ws_xden; } -static int ws_menu_frame(void) { return psx_ws_frame_kinds_menu(&ws_frame_kinds); } -/* On a front-end frame everything but the backdrop keeps its shape on the stretched screen. Returns the rectangle's width. */ -static int ws_menu_rect(int32_t *x0, int w) { return ws_menu_frame() ? psx_ws_menu_squash_rect(x0, w, ws_xnum, ws_xden) : w; } -int gpu_ws_displayed_menu(void) { return psx_ws_frame_kinds_displayed_menu(&ws_frame_kinds, display_area_x, display_area_y); } - -static int ws_native_43(int displayed) { - if (!ws_engaged()) return 0; - if (displayed ? gpu_ws_displayed_menu() : ws_menu_frame()) return 0; - if (!ws_game_mode_of(displayed)) return 1; /* full-2D screen */ - if (ws_2d_only_scene()) return 1; /* 2D-only gameplay scene */ - uint32_t f = (uint32_t)s_frame_count; - if (f != s_ws_fmv_frame_cache) { - s_ws_fmv_frame_cache = f; - GpuDisplayInfo di; gpu_get_display_info(&di); - s_ws_fmv_cached = di.depth24 || mdec_recently_active(WS_FMV_HYSTERESIS); - } - return s_ws_fmv_cached; -} -int gpu_ws_present_native_43(void) { return ws_native_43(0); } -int gpu_ws_displayed_native_43(void) { return ws_native_43(1); } -void gpu_ws_tell_frame_kind(int flat) { psx_ws_frame_kinds_tell(&ws_frame_kinds, flat); } -/* The frame on display is a full-2D one of a GTE-detected game and not the front end's: uploads and copies make it, and they do not reach the corrected mirror. */ -int gpu_ws_displayed_flat(void) { - return ws_gte_game_mode_cfg && !gpu_ws_displayed_menu() && - psx_ws_frame_kinds_displayed(&ws_frame_kinds, display_area_x, display_area_y) == 1; -} - -/* Squash applies only when configured AND the frame is being stretched. */ -static int ws_active(void) { return ws_configured() && !gpu_ws_present_native_43(); } - -/* ----- Native-wide (mode 2) ------------------------------------------------ - * Render the wider FOV into an actually wider frame instead of squashing it. - * Engaged on game frames only (FMV/menu frames pillarbox 4:3 like squash). The - * frame grows symmetrically: each side by OFFSET display-pixels, total by - * EXTRA = 2*OFFSET. OFFSET = round(W*(3*num-4*den)/(8*den)) where W is the live - * display width — derived from the aspect so it generalises past 16:9 and - * tracks display-mode changes; EXTRA is forced even so OFFSET is integral - * (e.g. W=320 @ 16:9 -> OFFSET=53, EXTRA=106, frame 320 -> 426). The shift is - * applied via the GPU draw_offset (so 3D and 2D move together and stay - * aligned), the draw-area widens by EXTRA so the shifted content is not clipped - * on the right, and the present widens the display read by EXTRA. 0 when - * native-wide is inactive (4:3 / boot / squash mode / FMV / full-2D). */ -int ws_native_wide_active(void) { - return ws_mode == 2 && !gpu_ws_present_native_43(); -} -/* Cull/spawn setup often runs while a scene is loading, before the first GTE - * frame can classify it as gameplay. Keep that pre-render setup aware of the - * configured native-wide viewport; presentation itself remains gated by - * ws_native_wide_active(). */ -static int ws_native_wide_configured(void) { - return ws_mode == 2 && ws_cfg_num * 3 > ws_cfg_den * 4; -} -static int ws_nw_configured_offset(void) { - if (!ws_native_wide_configured()) return 0; - int numr = 3 * ws_cfg_num - 4 * ws_cfg_den; - int w = (int)ws_disp_w(); - return (w * numr + 4 * ws_cfg_den) / (8 * ws_cfg_den); -} -static int ws_nw_offset(void) { - if (!ws_native_wide_active()) return 0; - return ws_nw_configured_offset(); -} -int ws_nw_extra(void) { return 2 * ws_nw_offset(); } - -/* Per-side X cull margin in screen/world units (the game's draw classifier - * works in objX-camX where 1 unit ~= 1 native-4:3 screen pixel). The squash - * shows a half-view of 160/s pixels (s = squash factor = ws_xnum/ws_xden), so - * the visible edge moves out by 160*(1/s - 1) = 160*(xden-xnum)/xnum. Widening - * the cull window by this restores the original off-screen margin at the new - * edge. 0 whenever squash is inactive (4:3/boot/menu/FMV) → original cull. */ -/* Diagnostic override (8C): when >= 0, psx_ws_x_margin() returns this value - * unconditionally so a probe can sweep the cull margin live (0 = force 4:3 - * cull while still stretching; large = over-draw) at a fixed camera position. - * -1 = normal computed margin. */ -static int ws_margin_override = -1; -static int ws_cull_guard_pixels = 0; -static int ws_activation_guard_pixels = 0; -void gpu_ws_set_margin_override(int v) { ws_margin_override = v; } -void gpu_ws_set_cull_guard_pixels(int pixels) { - if (pixels < 0) pixels = 0; - if (pixels > 256) pixels = 256; - ws_cull_guard_pixels = pixels; -} -void gpu_ws_set_activation_guard_pixels(int pixels) { - if (pixels < 0) pixels = 0; - if (pixels > 256) pixels = 256; - ws_activation_guard_pixels = pixels; -} - -#define WS_EXPLICIT_CULL_SITES_MAX 64 -static uint32_t ws_explicit_bias_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static uint32_t ws_explicit_slti_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static uint32_t ws_explicit_range_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_bias_n = 0; -static int ws_explicit_slti_n = 0; -static int ws_explicit_range_n = 0; -void gpu_ws_set_explicit_cull_sites(const uint32_t *bias, int nbias, - const uint32_t *slti, int nslti, - const uint32_t *range, int nrange) { - if (nbias < 0) nbias = 0; - if (nslti < 0) nslti = 0; - if (nrange < 0) nrange = 0; - if (nbias > WS_EXPLICIT_CULL_SITES_MAX) nbias = WS_EXPLICIT_CULL_SITES_MAX; - if (nslti > WS_EXPLICIT_CULL_SITES_MAX) nslti = WS_EXPLICIT_CULL_SITES_MAX; - if (nrange > WS_EXPLICIT_CULL_SITES_MAX) nrange = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_bias_n = nbias; - ws_explicit_slti_n = nslti; - ws_explicit_range_n = nrange; - for (int i = 0; i < nbias; i++) ws_explicit_bias_sites[i] = bias[i] & 0x1FFFFFFFu; - for (int i = 0; i < nslti; i++) ws_explicit_slti_sites[i] = slti[i] & 0x1FFFFFFFu; - for (int i = 0; i < nrange; i++) ws_explicit_range_sites[i] = range[i] & 0x1FFFFFFFu; -} -static int ws_explicit_site(const uint32_t *sites, int n, uint32_t pc) { - uint32_t p = pc & 0x1FFFFFFFu; - for (int i = 0; i < n; i++) if (sites[i] == p) return 1; - return 0; -} -int psx_ws_is_cull_bias_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_bias_sites, ws_explicit_bias_n, pc); -} -int psx_ws_is_cull_slti_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_slti_sites, ws_explicit_slti_n, pc); -} -static uint32_t ws_explicit_slti_lower_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_slti_lower_n = 0; -void gpu_ws_set_slti_lower_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) - nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_slti_lower_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_slti_lower_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_slti_lower_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_slti_lower_sites, - ws_explicit_slti_lower_n, pc); -} -static uint32_t ws_explicit_negsub_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_negsub_n = 0; -void gpu_ws_set_negsub_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_negsub_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_negsub_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_negsub_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_negsub_sites, ws_explicit_negsub_n, pc); -} -static uint32_t ws_explicit_vxrange_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_vxrange_n = 0; -void gpu_ws_set_vxrange_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_vxrange_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_vxrange_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_vxrange_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_vxrange_sites, ws_explicit_vxrange_n, pc); -} -static uint32_t ws_explicit_depth_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_depth_n = 0; -void gpu_ws_set_depth_cull_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_depth_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_depth_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_depth_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_depth_sites, ws_explicit_depth_n, pc); -} -int32_t psx_ws_depth_bound(int32_t imm) { - if (psx_ws_x_margin() <= 0) return imm; - int64_t numerator = (int64_t)imm * 3 * ws_cfg_num; - int64_t denominator = 4 * ws_cfg_den; - if (denominator <= 0) return imm; - int64_t result = numerator >= 0 - ? (numerator + denominator / 2) / denominator - : -((-numerator + denominator / 2) / denominator); - return (int32_t)result; -} -int psx_ws_is_cull_range_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_range_sites, ws_explicit_range_n, pc); -} -static uint32_t ws_explicit_plane_nx_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_plane_nx_n = 0; -void gpu_ws_set_plane_nx_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_plane_nx_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_plane_nx_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_plane_nx_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_plane_nx_sites, ws_explicit_plane_nx_n, pc); -} -/* Side frustum-plane normal-X scale ([widescreen.cull] plane_nx_sites). The - * plane half-angle goes as atan(nz/nx), so widening the cone by the aspect - * ratio means scaling nx by the INVERSE factor (4*den)/(3*num): - * atan(nz/(nx*4*den/(3*num))) == atan((3*num)/(4*den)*tan(theta)). Identity - * at 4:3 (margin 0). */ -int32_t psx_ws_plane_nx(int32_t nx) { - if (psx_ws_x_margin() <= 0) return nx; - int64_t numerator = (int64_t)nx * 4 * ws_cfg_den; - int64_t denominator = 3 * ws_cfg_num; - if (denominator <= 0) return nx; - int64_t result = numerator >= 0 - ? (numerator + denominator / 2) / denominator - : -((-numerator + denominator / 2) / denominator); - return (int32_t)result; -} - -static uint32_t ws_explicit_xclip_load_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_explicit_xclip_load_n = 0; -void gpu_ws_set_xclip_load_sites(const uint32_t *sites, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_explicit_xclip_load_n = nsites; - for (int i = 0; i < nsites; i++) - ws_explicit_xclip_load_sites[i] = sites[i] & 0x1FFFFFFFu; -} -int psx_ws_is_cull_xclip_load_site(uint32_t pc) { - return ws_explicit_site(ws_explicit_xclip_load_sites, ws_explicit_xclip_load_n, pc); -} -/* Per-primitive X-reject bound ([widescreen.cull] xclip_load_sites). While - * the margins are revealed the reject is disabled (INT32_MAX passes every - * ANDI-masked u16 screen X, including wrapped off-left coords at 655xx); the - * wide-surface scissor clips the overflow. Vanilla loaded value at 4:3. */ -uint32_t psx_ws_xclip_bound(uint32_t vanilla) { - return psx_ws_x_margin() > 0 ? 0x7FFFFFFFu : vanilla; -} - -typedef struct { - uint32_t address; - uint32_t expected; - uint32_t result; -} WsCullKeepSite; -static WsCullKeepSite ws_cull_keep_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_cull_keep_n = 0; -void gpu_ws_set_cull_keep_sites(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *results, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_cull_keep_n = nsites; - for (int i = 0; i < nsites; i++) { - ws_cull_keep_sites[i].address = addresses[i] & 0x1FFFFFFFu; - ws_cull_keep_sites[i].expected = expected[i]; - ws_cull_keep_sites[i].result = results[i] ? 1u : 0u; - } -} -uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { - return psx_ws_x_margin() > 0 ? (forced ? 1u : 0u) : vanilla; -} -int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, - uint32_t *out) { - const uint32_t phys = pc & 0x1FFFFFFFu; - for (int i = 0; i < ws_cull_keep_n; i++) { - const WsCullKeepSite *site = &ws_cull_keep_sites[i]; - if (site->address != phys || site->expected != instr) continue; - if (out) *out = psx_ws_cull_keep_result(vanilla, site->result); - return 1; - } - return 0; -} - -typedef struct { - uint32_t address; - uint32_t expected; -} WsAngleSite; -static WsAngleSite ws_angle_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_angle_n = 0; -static uint64_t ws_angle_calls = 0; -static uint64_t ws_angle_43_identity = 0; -static uint32_t ws_angle_max_vanilla = 0; -static uint32_t ws_angle_max_widened = 0; - -void gpu_ws_set_angle_sites(const uint32_t *addresses, - const uint32_t *expected, int nsites) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) - nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_angle_n = nsites; - for (int i = 0; i < nsites; i++) { - ws_angle_sites[i].address = addresses[i] & 0x1FFFFFFFu; - ws_angle_sites[i].expected = expected[i]; - } - ws_angle_calls = 0; - ws_angle_43_identity = 0; - ws_angle_max_vanilla = 0; - ws_angle_max_widened = 0; -} - -uint32_t psx_ws_angle_widen(uint32_t vanilla) { - ws_angle_calls++; - const int margin = psx_ws_x_margin(); - if (margin <= 0) { - ws_angle_43_identity++; - return vanilla; - } - const uint32_t widened = - psx_ws_widen_angle_q12(vanilla, margin); - if (vanilla > ws_angle_max_vanilla) - ws_angle_max_vanilla = vanilla; - if (widened > ws_angle_max_widened) - ws_angle_max_widened = widened; - return widened; -} - -/* A diagnostic launcher can isolate the two discovered frustum layers without - * rebuilding: "off" keeps both vanilla, "yaw" widens only portal traversal, - * and the default "full" also widens the later horizontal side planes. */ -static int ws_frustum_mode(void) { - static int mode = -1; - if (mode >= 0) return mode; - const char *value = getenv("PSX_WS_FRUSTUM_MODE"); - if (value && (strcmp(value, "off") == 0 || strcmp(value, "0") == 0)) - mode = 0; - else if (value && (strcmp(value, "yaw") == 0 || strcmp(value, "1") == 0)) - mode = 1; - else - mode = 2; - printf("psxrecomp: widescreen frustum mode %s\n", - mode == 0 ? "off" : (mode == 1 ? "yaw-only" : "full")); - return mode; -} - -int32_t psx_ws_yaw_cone_offset(int32_t vanilla, uint32_t full_turn) { - if (ws_frustum_mode() == 0) return vanilla; - return psx_ws_widen_angle_units( - vanilla, full_turn, psx_ws_x_margin()); -} - -uint32_t psx_ws_horizontal_slt(uint32_t bound, uint32_t horizontal) { - if (ws_frustum_mode() < 2) - return (int32_t)bound < (int32_t)horizontal ? 1u : 0u; - const int32_t scaled_horizontal = psx_ws_scale_horizontal_320( - (int32_t)horizontal, psx_ws_x_margin()); - return (int32_t)bound < scaled_horizontal ? 1u : 0u; -} - -int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out) { - const uint32_t phys = pc & 0x1FFFFFFFu; - for (int i = 0; i < ws_angle_n; i++) { - const WsAngleSite *site = &ws_angle_sites[i]; - if (site->address != phys || site->expected != instr) continue; - if (out) - *out = psx_ws_angle_widen( - (uint32_t)(int32_t)(int16_t)(instr & 0xFFFFu)); - return 1; - } - return 0; -} - -typedef struct { - uint32_t address; - uint32_t expected; - uint32_t cosine_threshold; - uint8_t object_reg; - uint8_t x_reg; - uint8_t z_reg; - uint8_t y_reg; - uint8_t queue_guard; - uint64_t calls; - uint64_t identity_43; - uint64_t vanilla_keep; - uint64_t visible_keep; - uint64_t guard_keep; - uint64_t hysteresis_keep; - uint64_t outside_reject; - uint64_t queue_reject; -} WsAspectConeSite; - -typedef struct { - uint32_t site; - uint32_t object; - uint32_t last_seen_frame; - uint8_t active; -} WsAspectConeState; - -#define WS_ASPECT_CONE_STATE_CAP 512 -static WsAspectConeSite ws_aspect_cone_sites[WS_EXPLICIT_CULL_SITES_MAX]; -static int ws_aspect_cone_n = 0; -static uint32_t ws_aspect_cone_forward_addr = 0; -static uint32_t ws_aspect_cone_object_type_offset = 0; -static uint32_t ws_aspect_cone_hysteresis_pixels = 0; -static uint32_t ws_aspect_cone_queue_reserve = 0; -static uint32_t ws_aspect_cone_queue_count_addrs[3]; -static uint32_t ws_aspect_cone_queue_capacities[3]; -static uint32_t ws_aspect_cone_queue_type_masks[3]; -static WsAspectConeState ws_aspect_cone_state[WS_ASPECT_CONE_STATE_CAP]; -static int ws_aspect_cone_wide_latched = 0; -static uint64_t ws_aspect_cone_calls = 0; -static uint64_t ws_aspect_cone_43_identity = 0; -static uint64_t ws_aspect_cone_vanilla_keep = 0; -static uint64_t ws_aspect_cone_visible_keep = 0; -static uint64_t ws_aspect_cone_guard_keep = 0; -static uint64_t ws_aspect_cone_hysteresis_keep = 0; -static uint64_t ws_aspect_cone_outside_reject = 0; -static uint64_t ws_aspect_cone_queue_reject = 0; -static uint32_t ws_aspect_cone_queue_highwater[3]; - -void gpu_ws_set_aspect_cone(const uint32_t *addresses, - const uint32_t *expected, - const uint32_t *cosine_thresholds, - const uint32_t *object_regs, - const uint32_t *x_regs, - const uint32_t *z_regs, - const uint32_t *y_regs, - const uint32_t *queue_guards, - int nsites, - uint32_t forward_addr, - uint32_t object_type_offset, - uint32_t hysteresis_pixels, - uint32_t queue_reserve, - const uint32_t queue_count_addrs[3], - const uint32_t queue_capacities[3], - const uint32_t queue_type_masks[3]) { - if (nsites < 0) nsites = 0; - if (nsites > WS_EXPLICIT_CULL_SITES_MAX) - nsites = WS_EXPLICIT_CULL_SITES_MAX; - ws_aspect_cone_n = nsites; - memset(ws_aspect_cone_sites, 0, sizeof(ws_aspect_cone_sites)); - for (int i = 0; i < nsites; i++) { - ws_aspect_cone_sites[i].address = addresses[i] & 0x1FFFFFFFu; - ws_aspect_cone_sites[i].expected = expected[i]; - ws_aspect_cone_sites[i].cosine_threshold = - cosine_thresholds ? cosine_thresholds[i] : 0; - ws_aspect_cone_sites[i].object_reg = - (uint8_t)(object_regs ? object_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].x_reg = - (uint8_t)(x_regs ? x_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].z_reg = - (uint8_t)(z_regs ? z_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].y_reg = - (uint8_t)(y_regs ? y_regs[i] & 31u : 0u); - ws_aspect_cone_sites[i].queue_guard = - (uint8_t)(!queue_guards || queue_guards[i] != 0); - } - ws_aspect_cone_forward_addr = forward_addr; - ws_aspect_cone_object_type_offset = object_type_offset; - ws_aspect_cone_hysteresis_pixels = hysteresis_pixels; - ws_aspect_cone_queue_reserve = queue_reserve; - for (int i = 0; i < 3; i++) { - ws_aspect_cone_queue_count_addrs[i] = - queue_count_addrs ? queue_count_addrs[i] : 0; - ws_aspect_cone_queue_capacities[i] = - queue_capacities ? queue_capacities[i] : 0; - ws_aspect_cone_queue_type_masks[i] = - queue_type_masks ? queue_type_masks[i] : 0; - } - memset(ws_aspect_cone_state, 0, sizeof(ws_aspect_cone_state)); - ws_aspect_cone_wide_latched = 0; - ws_aspect_cone_calls = 0; - ws_aspect_cone_43_identity = 0; - ws_aspect_cone_vanilla_keep = 0; - ws_aspect_cone_visible_keep = 0; - ws_aspect_cone_guard_keep = 0; - ws_aspect_cone_hysteresis_keep = 0; - ws_aspect_cone_outside_reject = 0; - ws_aspect_cone_queue_reject = 0; - memset(ws_aspect_cone_queue_highwater, 0, - sizeof(ws_aspect_cone_queue_highwater)); -} - -static WsAspectConeSite *ws_aspect_cone_find(uint32_t address, - uint32_t expected) { - const uint32_t phys = address & 0x1FFFFFFFu; - for (int i = 0; i < ws_aspect_cone_n; i++) { - WsAspectConeSite *site = &ws_aspect_cone_sites[i]; - if (site->address == phys && - (expected == 0 || site->expected == expected)) - return site; - } - return NULL; -} - -static WsAspectConeState *ws_aspect_cone_object_state(uint32_t site, - uint32_t object) { - const uint32_t now = (uint32_t)s_frame_count; - uint32_t slot = (((object >> 2) * 2654435761u) ^ - ((site >> 2) * 2246822519u)) & - (WS_ASPECT_CONE_STATE_CAP - 1u); - WsAspectConeState *oldest = NULL; - for (uint32_t probe = 0; probe < 8; probe++) { - WsAspectConeState *entry = - &ws_aspect_cone_state[(slot + probe) & - (WS_ASPECT_CONE_STATE_CAP - 1u)]; - if (entry->site == site && entry->object == object) { - entry->last_seen_frame = now; - return entry; - } - if (entry->object == 0 || now - entry->last_seen_frame > 120u) { - entry->site = site; - entry->object = object; - entry->last_seen_frame = now; - entry->active = 0; - return entry; - } - if (!oldest || - entry->last_seen_frame < oldest->last_seen_frame) - oldest = entry; - } - oldest->site = site; - oldest->object = object; - oldest->last_seen_frame = now; - oldest->active = 0; - return oldest; -} - -static int ws_aspect_cone_queue_has_guard_room(uint32_t object) { - if (object == 0) return 0; - const uint8_t type = - psx_read_byte(object + ws_aspect_cone_object_type_offset); - if (type >= 32) return 1; - const uint32_t bit = 1u << type; - for (int i = 0; i < 3; i++) { - if ((ws_aspect_cone_queue_type_masks[i] & bit) == 0) continue; - const uint32_t cap = ws_aspect_cone_queue_capacities[i]; - const uint32_t reserve = ws_aspect_cone_queue_reserve; - if (cap == 0 || ws_aspect_cone_queue_count_addrs[i] == 0) - return 1; - const uint32_t count = - psx_read_half(ws_aspect_cone_queue_count_addrs[i]); - return count < ((cap > reserve) ? cap - reserve : 0u); - } - return 1; -} - -static void ws_aspect_cone_sample_queues(void) { - /* The game clears each queue at frame start and appends as candidates are - * accepted. Sample throughout the producer loop so the high-water mark - * sees the final occupancy rather than only the first zero count. */ - for (int i = 0; i < 3; i++) { - if (ws_aspect_cone_queue_count_addrs[i] == 0) continue; - const uint32_t count = - psx_read_half(ws_aspect_cone_queue_count_addrs[i]); - if (count > ws_aspect_cone_queue_highwater[i]) - ws_aspect_cone_queue_highwater[i] = count; - } -} - -uint32_t psx_ws_aspect_cone_result(uint32_t site_address, uint32_t vanilla, - uint32_t object, int32_t x, int32_t z, - int32_t y) { - WsAspectConeSite *site = ws_aspect_cone_find(site_address, 0); - if (!site) return vanilla; - - ws_aspect_cone_calls++; - site->calls++; - const int total_margin = psx_ws_x_margin(); - if (total_margin <= 0) { - ws_aspect_cone_43_identity++; - site->identity_43++; - if (ws_aspect_cone_wide_latched) { - memset(ws_aspect_cone_state, 0, sizeof(ws_aspect_cone_state)); - ws_aspect_cone_wide_latched = 0; - } - return vanilla; - } - ws_aspect_cone_wide_latched = 1; - ws_aspect_cone_sample_queues(); - - WsAspectConeState *state = - ws_aspect_cone_object_state(site->address, object); - /* The guarded Tomba sites are signed reject predicates: zero falls - * through to model participation, while one branches to rejection. */ - if (!vanilla) { - state->active = 1; - ws_aspect_cone_vanilla_keep++; - site->vanilla_keep++; - return 0; - } - - const int visible_margin = - (total_margin > ws_cull_guard_pixels) - ? total_margin - ws_cull_guard_pixels : 0; - const int32_t fx = - (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 0u); - const int32_t fz = - (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 2u); - const int32_t fy = - (int16_t)psx_read_half(ws_aspect_cone_forward_addr + 4u); - const uint32_t threshold = site->cosine_threshold; - - const int in_visible = - psx_ws_aspect_cone_contains(x, z, y, fx, fz, fy, threshold, - visible_margin); - const int in_guard = - !in_visible && psx_ws_aspect_cone_contains( - x, z, y, fx, fz, fy, threshold, total_margin); - const int in_hysteresis = - !in_visible && !in_guard && state->active && - psx_ws_aspect_cone_contains( - x, z, y, fx, fz, fy, threshold, - total_margin + (int)ws_aspect_cone_hysteresis_pixels); - - if (!in_visible && !in_guard && !in_hysteresis) { - state->active = 0; - ws_aspect_cone_outside_reject++; - site->outside_reject++; - return 1; - } - if (!in_visible && site->queue_guard && - !ws_aspect_cone_queue_has_guard_room(object)) { - state->active = 0; - ws_aspect_cone_queue_reject++; - site->queue_reject++; - return 1; - } - state->active = 1; - if (in_visible) { - ws_aspect_cone_visible_keep++; - site->visible_keep++; - } else if (in_guard) { - ws_aspect_cone_guard_keep++; - site->guard_keep++; - } else { - ws_aspect_cone_hysteresis_keep++; - site->hysteresis_keep++; - } - return 0; -} - -int psx_ws_aspect_cone_site(CPUState *cpu, uint32_t pc, uint32_t instr, - uint32_t vanilla, uint32_t *out) { - const WsAspectConeSite *site = ws_aspect_cone_find(pc, instr); - if (!site) return 0; - if (out) { - *out = psx_ws_aspect_cone_result( - pc, vanilla, cpu->gpr[site->object_reg], - (int32_t)(int16_t)cpu->gpr[site->x_reg], - (int32_t)(int16_t)cpu->gpr[site->z_reg], - (int32_t)(int16_t)cpu->gpr[site->y_reg]); - } - return 1; -} - -int psx_ws_x_margin(void) { - if (ws_margin_override >= 0) return ws_margin_override; - /* Native-wide: widen the world-space draw cull by the per-side reveal - * (== the centering OFFSET in screen px) so the game SUBMITS the geometry - * that previously fell outside the 4:3 cull window; the wide compositor then - * rasterizes it into the revealed margins. Same recompiler emit sites as the - * squash path ([widescreen.cull]); 0 at 4:3 so the cull stays byte-identical. */ - /* Unlike rendering/presentation, do not wait for game-mode detection here. - * Tomba 2 builds its terrain-cell and actor spawn lists during scene load; - * returning zero until the first 3D frame permanently bakes a 4:3 frustum - * into those lists. */ - if (ws_native_wide_configured()) - return ws_nw_configured_offset() + ws_cull_guard_pixels; - if (!ws_active()) return 0; - return (160 * (ws_xden - ws_xnum) + ws_xnum / 2) / ws_xnum - + ws_cull_guard_pixels; -} - -int psx_ws_activation_margin(void) { - const int margin = psx_ws_x_margin(); - return margin > 0 ? margin + ws_activation_guard_pixels : 0; -} - -int32_t psx_ws_player_x_bound(int32_t vanilla) -{ - const int margin = psx_ws_x_margin(); - if (margin <= 0) return vanilla; - return (int32_t)(((int64_t)vanilla * (320 + 2 * margin)) / 320); -} - -#define WS_SIGNED_BOUND_MAX 64 -static uint32_t ws_signed_bound_addr[WS_SIGNED_BOUND_MAX]; -static uint32_t ws_signed_bound_expected[WS_SIGNED_BOUND_MAX]; -static int ws_signed_bound_count = 0; -void gpu_ws_set_signed_x_bound_sites(const uint32_t *addresses, - const uint32_t *expected, int count) { - if (count < 0) count = 0; - if (count > WS_SIGNED_BOUND_MAX) count = WS_SIGNED_BOUND_MAX; - ws_signed_bound_count = count; - for (int i = 0; i < count; i++) { - ws_signed_bound_addr[i] = addresses[i] & 0x1FFFFFFFu; - ws_signed_bound_expected[i] = expected[i]; - } -} -int psx_ws_is_signed_x_bound_site(uint32_t pc, uint32_t instr) { - const uint32_t phys = pc & 0x1FFFFFFFu; - for (int i = 0; i < ws_signed_bound_count; i++) - if (ws_signed_bound_addr[i] == phys && ws_signed_bound_expected[i] == instr) - return 1; - return 0; -} - -/* ---- Capcom 2D background tile-loop widen ([widescreen.bg2d]) -------------- - * Mega Man X5/X6 use a pure-2D sprite engine that renders only a 4:3 (320px) field - * of view — there is no overscan to "reveal." Its per-layer background renderer - * (FUN_800270d0) draws `count` 16px tile columns × 16 rows from a start tile - * column / start screen-x derived from the camera scroll. To produce a TRUE - * wider FOV we widen that loop so it draws extra columns on BOTH sides of the - * 320 view, filling the 16:9 reveal margins with real adjacent stage. The three - * helpers below are hooked at the renderer's count / start-col / start-x - * instructions by the recompiler (gen-time). They are IDENTITY unless native- - * wide is engaged on a ~320 screen, so 4:3 (and the engine's own 512 hi-res - * mode, which already draws 33 columns) stay byte-identical. - * - * LEFT = per-side reveal in whole 16px tile columns = ceil(nw_offset / 16). The - * loop start moves LEFT columns earlier (tile col -LEFT in the 64-col ring, - * screen-x -LEFT*16) and the count grows by 2*LEFT, so existing columns keep - * their screen positions (stay aligned with objects/player) while new columns - * are prepended left and appended right. The 64-column tile ring (mask 0x3f) is - * far wider than the visible window, so the prepended columns fetch the correct - * already-streamed adjacent tiles. */ -static uint32_t g_bg2d_layer_base = 0x800971F8u; -static uint32_t g_bg2d_ring_base = 0x800A21B8u; -static uint32_t g_bg2d_map_size_addr = 0x800CD338u; -static uint32_t g_bg2d_layer_stride_addr = 0x8008EC10u; -static uint32_t g_bg2d_ring_cols = 64; -static uint32_t g_bg2d_layer_count = 3; -static uint32_t g_bg2d_layer_struct_stride = 0x54; -static uint32_t g_bg2d_packet_cap = 1000; -static int g_bg2d_native_cols = 21; - -void gpu_ws_bg2d_configure(uint32_t layer_base, uint32_t ring_base, - uint32_t map_size_addr, uint32_t layer_stride_addr, - uint32_t ring_cols, uint32_t layer_count, - uint32_t layer_struct_stride, uint32_t packet_cap) { - g_bg2d_layer_base = layer_base; - g_bg2d_ring_base = ring_base; - g_bg2d_map_size_addr = map_size_addr; - g_bg2d_layer_stride_addr = layer_stride_addr; - g_bg2d_ring_cols = ring_cols; - g_bg2d_layer_count = layer_count; - g_bg2d_layer_struct_stride = layer_struct_stride; - g_bg2d_packet_cap = packet_cap; -} - -static int ws_bg2d_left_cols(void) { - if (!ws_native_wide_active()) return 0; - /* Only the ~320 gameplay mode; the engine's 512 hi-res mode (title) draws - * its own 33 columns and centres itself — never double-shift it. */ - if (ws_disp_w() > 384) return 0; - int off = ws_nw_offset(); /* per-side reveal in screen px */ - if (off <= 0) return 0; - return (off + 15) / 16; /* ceil to whole tile columns */ -} -/* Column count: base + both-side reveal. */ -static void mmx6_bg_refill_tick(void); /* defined below (ring-freshness fix) */ -int psx_ws_bg2d_cols(int base) { - g_bg2d_native_cols = base; - mmx6_bg_refill_tick(); - return base + 2 * ws_bg2d_left_cols(); -} -/* Start tile column: refill before the first column is consumed, then begin LEFT - * earlier in the ring. The count hook retains the same tick for layouts that load - * their loop bound before calculating the starting column. */ -int psx_ws_bg2d_startcol(int col, unsigned mask) { - mmx6_bg_refill_tick(); - return (col - ws_bg2d_left_cols()) & (int)mask; -} -/* Start screen-x: LEFT*16 further left. */ -int psx_ws_bg2d_startx(int x) { return x - ws_bg2d_left_cols() * 16; } - -/* Tile-RING STREAMER widen (same [widescreen.bg2d], FUN_800273e4). The renderer - * above now draws LEFT extra columns each side, but the engine's 64-column tile - * ring is only refreshed at its leading edge: the streamer re-streams ONE column - * per side per frame at world-X scrollX-16 (left) and scrollX+16+width (right), - * so the columns the widened renderer reaches are never populated (they show - * empty=black or stale=old tiles, the "stale margins"). Push the streamed left/ - * right edge out by LEFT*16 so the ring stays valid across the widened window as - * the camera scrolls (the incremental streamer fills each newly-leading column). - * Identity (no extra streaming) at 4:3 / 512 hi-res, so the ring is byte-identical - * there. The 64-col ring has ample slack (visible ~21 cols) for ±LEFT more. */ -int psx_ws_bg2d_stream_left(int x) { return x - ws_bg2d_left_cols() * 16; } -int psx_ws_bg2d_stream_right(int x) { return x + ws_bg2d_left_cols() * 16; } -int psx_ws_bg2d_undercap(int counter, int native_cap) { - int cap = ws_bg2d_left_cols() > 0 ? (int)g_bg2d_packet_cap : native_cap; - return counter < cap; -} - -/* NOT MMX6-only, and NOT dead -- see the survey note on the declarations in - * runtime/include/gpu.h. Live generated code in CrashBash, MegaManX5 and Tsumu - * calls these, and seven repos call psx_ws_mmx6_bg_undercap below. The mmx6 in - * the name is historical; the recompiler emits these names for any title with - * a [widescreen.bg2d] block. Renaming means touching code_generator.cpp, which - * rolls the overlay cache tag for every game. */ -int psx_ws_mmx6_bg_cols(int base) { return psx_ws_bg2d_cols(base); } -int psx_ws_mmx6_bg_startcol(int col) { return psx_ws_bg2d_startcol(col, 0x3fu); } -int psx_ws_mmx6_bg_startx(int x) { return psx_ws_bg2d_startx(x); } -int psx_ws_mmx6_bg_stream_left(int x) { return psx_ws_bg2d_stream_left(x); } -int psx_ws_mmx6_bg_stream_right(int x) { return psx_ws_bg2d_stream_right(x); } - -/* Called at entry to MMX6's full tile-ring initializer (FUN_800269F4). The - * independent layers invoke it only when stage/background data is dirty; gate - * by frame because up to three layers initialize together. This is the exact - * point where pixels retained from the old stage cease to be meaningful. */ -void psx_ws_mmx6_bg_stage_init(void) { - static uint32_t last_frame = 0xFFFFFFFFu; - uint32_t frame = (uint32_t)s_frame_count; - if (!ws_clear_reveal || ws_mode != 2 || frame == last_frame) return; - last_frame = frame; - g_mmx6_void_generation++; - ws_clear_all_reveal_margins(); -} - -/* Retired packet-buffer relocation hooks. These symbols remain part of the - * generated-code ABI; their bodies preserve the committed guest-widen path. */ -int psx_ws_mmx6_bg_bufbase(int addr) { - return addr; -} - -int psx_ws_mmx6_bg_undercap(int counter) { - return psx_ws_bg2d_undercap(counter, 1000); -} - -/* ===== MMX6 BG tile-ring freshness fix ([widescreen.bg2d]) ================== * - * The widened renderer (FUN_800270d0) draws LEFT extra tile columns on each side of - * the 320 view, but the engine's streamer (FUN_800273e4 -> FUN_800274a0) only - * refreshes ONE column per side per frame at the leading edge. On scene entry or - * fast horizontal scroll the inner reveal columns therefore hold STALE tiles from a - * previous scroll position (different scrollY) -> real-but-misplaced tiles spilling - * up-and-left (the "staircase"). 4:3 is unaffected (it draws only the 21 columns the - * engine keeps streamed). Fix: before the renderer draws, re-stream EVERY tile column - * of the widened window for all 3 layers, so the render window and the ring-refresh - * window are the same window. This is a faithful clone of FUN_800274a0's inner - * metatile->ring fill, MINUS the scrollX wrap-bookkeeping writes (those drive the - * engine's own incremental streamer; perturbing them would desync its leading edge). - * Off-map columns fill tile 0 (the renderer skips them) = the engine's own void; at a - * horizontal map-loop seam (engine early-return) we skip the column. Gated on - * native-wide + ~320 mode, so 4:3 and the 512 hi-res title stay byte-identical. */ -extern void psx_write_half(uint32_t addr, uint16_t val); - -static void bg2d_clear_column(int layer, int ringcol, int worldY) { - int rows = 0x12; - if (worldY < 0) { worldY = 0; rows = 0x11; } - int wrapY = worldY - (((worldY < 0) ? worldY + 0x1ff : worldY) >> 9) * 0x200; - int ringrow = ((wrapY < 0) ? wrapY + 0xf : wrapY) >> 4; - uint32_t ringbase = g_bg2d_ring_base - + (uint32_t)layer * (g_bg2d_ring_cols * 64u); - for (int i = 0; i < rows; i++) { - uint32_t cell = ringbase - + (uint32_t)((ringcol & (int)(g_bg2d_ring_cols - 1u)) * 2) - + (uint32_t)((ringrow & 0x1f) * (int)(g_bg2d_ring_cols * 2u)); - psx_write_half(cell, 0); - ringrow = (ringrow + 1) & 0x1f; - } -} - -/* Faithful clone of FUN_800274a0's inner fill: stream one tile column at guest pixel - * (worldX, worldY) of `layer` into the 64x32 ring. Compares mirror the engine's sltu - * (UNSIGNED). When write!=0 the tiles are written to the ring; when write==0 they are - * compared against the live ring (validation), counting *cmp_total / *cmp_bad. Returns - * 1 if the column was streamed, 0 if skipped (map-loop seam early-return). */ -static int bg2d_fill_column(int layer, int worldX, int worldY, int scrollX, int write, - int *cmp_total, int *cmp_bad) { - /* The guest column streamer returns immediately for negative world X. Do - * not wrap it into the far edge of the map, which produces repeated blocks - * in the left reveal near a level boundary. */ - if (worldX < 0) return 0; - uint32_t lbase = g_bg2d_layer_base + (uint32_t)layer * g_bg2d_layer_struct_stride; - int rows = 0x12; - if (worldY < 0) { worldY = 0; rows = 0x11; } - int32_t metaCol = ((worldX < 0) ? worldX + 0xff : worldX) >> 8; /* t1 */ - int32_t metaRow = ((worldY < 0) ? worldY + 0xff : worldY) >> 8; /* t4 */ - int worldPeriod = (int)g_bg2d_ring_cols * 16; - int worldShift = 0; - for (int n = worldPeriod; n > 1; n >>= 1) worldShift++; - int wrapX = worldX - - (((worldX < 0) ? worldX + worldPeriod - 1 : worldX) >> worldShift) - * worldPeriod; - int colsh = ((wrapX < 0) ? wrapX + 0xf : wrapX) >> 4; /* t7 = wrapX>>4 */ - int ringcol = colsh & (int)(g_bg2d_ring_cols - 1u); - int tcolInMeta = colsh & 0xf; /* s0 = t7 & 0xf */ - int wrapY = worldY - (((worldY < 0) ? worldY + 0x1ff : worldY) >> 9) * 0x200; - int ringrow = ((wrapY < 0) ? wrapY + 0xf : wrapY) >> 4; /* a2 */ - int trowInMeta = ringrow & 0xf; /* t3 */ - - int mapLeft = psx_read_byte(lbase + 0x4d); - int mapRight = psx_read_byte(lbase + 0x4e); - /* C integer division toward zero makes worldX -255..-1 resolve to metatile - * column 0 below. The native 4:3 streamer never requests those coordinates, - * but the widened left window does at a stage starting on mapLeft. Treat - * them as finite-map void before the engine's loop/wrap bookkeeping. */ - if (worldX < mapLeft * 0x100) - return 2; - if ((uint32_t)metaCol < (uint32_t)mapLeft) { - metaCol = metaCol + 1 + (mapRight - mapLeft); - if (scrollX <= (mapLeft - 1) * 0x100) { - /* The native renderer never sees this column, so the engine leaves - * its ring slot alone. Report finite-map void to the DMA-start - * cleanup without mutating ring slots canonical draws can alias. */ - return 2; /* finite-map void, not merely a normal streamed column */ - } - } - if ((uint32_t)mapRight < (uint32_t)metaCol) { - metaCol = (metaCol - 1 - mapRight) + mapLeft; - if ((mapRight + 1) * 0x100 <= scrollX) { - return 2; - } - } - int mapW = psx_read_byte(g_bg2d_map_size_addr); - int mapH = psx_read_byte(g_bg2d_map_size_addr + 1u); - uint32_t mapBase = psx_read_word(0x1F800004u); - uint32_t metaBase = psx_read_word(0x1F800008u); - if (mapBase == 0 || metaBase == 0 || mapW <= 0 || mapH <= 0) return 0; - int layerStride = (uint16_t)psx_read_half(g_bg2d_layer_stride_addr); - - int metaIdx = 0; - if ((uint32_t)metaCol < (uint32_t)mapW && (uint32_t)metaRow < (uint32_t)mapH) - metaIdx = psx_read_byte(mapBase + (uint32_t)(layer * layerStride + mapW * metaRow + metaCol)); - - uint32_t ringbase = g_bg2d_ring_base - + (uint32_t)layer * (g_bg2d_ring_cols * 64u); - for (int i = 0; i < rows; i++) { - uint32_t cell = ringbase + (uint32_t)(ringcol * 2) - + (uint32_t)((ringrow & 0x1f) * (int)(g_bg2d_ring_cols * 2u)); - uint16_t tile = psx_read_half(metaBase + (uint32_t)metaIdx * 0x200u - + (uint32_t)(trowInMeta * 0x20) + (uint32_t)(tcolInMeta * 2)); - if (write) { - psx_write_half(cell, tile); - } else if (cmp_total) { - (*cmp_total)++; - if (psx_read_half(cell) != tile && cmp_bad) (*cmp_bad)++; - } - ringrow = (ringrow + 1) & 0x1f; - if (++trowInMeta == 0x10) { - trowInMeta = 0; - metaRow++; - metaIdx = 0; - if ((uint32_t)metaCol < (uint32_t)mapW && (uint32_t)metaRow < (uint32_t)mapH) - metaIdx = psx_read_byte(mapBase + (uint32_t)(layer * layerStride + mapW * metaRow + metaCol)); - } - } - return 1; -} - -static int g_mmx6_freshfix = 1; /* re-stream the widened window each frame (the fix) */ -static long g_mmx6_refill_cols = 0; /* columns streamed in the last refill (diag) */ -static uint32_t g_mmx6_refill_frame = 0xFFFFFFFFu; /* frame of the last refill (once/frame) */ -void gpu_ws_mmx6_set_freshfix(int on) { g_mmx6_freshfix = on ? 1 : 0; } -int gpu_ws_mmx6_freshfix_get(void) { return g_mmx6_freshfix; } -long gpu_ws_mmx6_refill_cols(void) { return g_mmx6_refill_cols; } - -/* Re-stream every tile column of the widened window for every BG layer. Independent - * layers use their own scroll; parent-linked layers use the same combined scroll - * that the renderer uses to index their ring. The guest's incremental streamer - * skips linked layers because their native 4:3 window is prefilled, but the newly - * revealed columns still need to be populated here. worldY = scrollY - 0x10 mirrors - * the driver. ci spans the renderer's widened span [-LEFT, 20+LEFT]. - * No-op at 4:3 / 512 hi-res (left==0). Idempotent over the native columns the engine - * already streamed correctly, so re-streaming them is harmless. */ -void psx_ws_mmx6_bg_refill_all(void) { - if (!g_mmx6_freshfix) return; - if (!ws_native_wide_active() || ws_disp_w() > 384) return; - int left = ws_bg2d_left_cols(); - if (left <= 0) return; - g_mmx6_refill_cols = 0; - for (uint32_t layer = 0; layer < g_bg2d_layer_count; layer++) { - uint32_t lbase = g_bg2d_layer_base + layer * g_bg2d_layer_struct_stride; - int sx = (int16_t)psx_read_half(lbase + 0xa); - int sy = (int16_t)psx_read_half(lbase + 0xe); - int8_t parent = (int8_t)psx_read_byte(lbase + 0x52); - if (parent >= 0 && (uint32_t)(uint8_t)parent < g_bg2d_layer_count) { - uint32_t pbase = g_bg2d_layer_base - + (uint32_t)(uint8_t)parent * g_bg2d_layer_struct_stride; - sx += (int16_t)psx_read_half(pbase + 0xa); - sy += (int16_t)psx_read_half(pbase + 0xe); - } - int sxr = sx; - if (sxr < 0) sxr += 0xf; - int start_col = sxr >> 4; - for (int ci = -left; ci < g_bg2d_native_cols + left; ci++) { - int world_x = sx + ci * 16; - if (world_x < 0) { - bg2d_clear_column((int)layer, start_col + ci, sy - 0x10); - continue; - } - if (bg2d_fill_column((int)layer, world_x, sy - 0x10, - sx, 1, NULL, NULL)) - g_mmx6_refill_cols++; - } - } -} - -/* Trigger the once-per-frame widened-window refill. Both renderer setup hooks call - * this so differing instruction order between games still refreshes the ring before - * the first widened column is consumed. */ -static void mmx6_bg_refill_tick(void) { - uint32_t f = (uint32_t)s_frame_count; - if (f == g_mmx6_refill_frame) return; - g_mmx6_refill_frame = f; - psx_ws_mmx6_bg_refill_all(); -} - -/* Validation harness (debug mmx6_freshfix {validate:1}): for the NATIVE 21 columns — - * which the engine streams correctly — run the clone in compare-only mode against the - * live ring. Proves the clone is byte-exact before the refill (which overwrites the - * ring) is trusted. Returns total cells compared; *bad = mismatches. */ -int gpu_ws_mmx6_validate(int *bad_out) { - int total = 0, bad = 0; - for (uint32_t layer = 0; layer < g_bg2d_layer_count; layer++) { - uint32_t lbase = g_bg2d_layer_base + layer * g_bg2d_layer_struct_stride; - int sx = (int16_t)psx_read_half(lbase + 0xa); - int sy = (int16_t)psx_read_half(lbase + 0xe); - int8_t parent = (int8_t)psx_read_byte(lbase + 0x52); - if (parent >= 0 && (uint32_t)(uint8_t)parent < g_bg2d_layer_count) { - uint32_t pbase = g_bg2d_layer_base - + (uint32_t)(uint8_t)parent * g_bg2d_layer_struct_stride; - sx += (int16_t)psx_read_half(pbase + 0xa); - sy += (int16_t)psx_read_half(pbase + 0xe); - } - for (int ci = 0; ci < g_bg2d_native_cols; ci++) - bg2d_fill_column((int)layer, sx + ci * 16, sy - 0x10, - sx, 0, &total, &bad); - } - if (bad_out) *bad_out = bad; - return total; -} - -/* Shared render-funnel screen-X cull widening ([widescreen.cull] auto_screen_x), - * called identically by the gcc emit and the interpreter so every - * overlay execution path widens the same way. sx = the GTE screen-X as loaded by - * the guest's lhu (low 16 bits significant); imm = the original bound (0x140 / - * 0x141). Returns the sltiu verdict (1 = on-screen/keep). Sign-extends sx and - * shifts by +margin so BOTH 16:9 margins pass; at 4:3 margin==0 so it reduces - * bit-for-bit to the vanilla `(uint16)sx < imm`. */ -int psx_ws_cull_sltiu(uint32_t sx, uint32_t imm) { - int m = psx_ws_x_margin(); - return ((uint32_t)((int32_t)(int16_t)(uint16_t)sx + m) - < (uint32_t)((int32_t)imm + 2 * m)) ? 1 : 0; -} - -/* Signed right-edge widen for the min/max funnel idiom (`slti v, minSX, W`): - * the paired LEFT edge is a separate bltz (psx_ws_cull_bltz below), so this - * bound moves out by ONE margin only. Operand is an already sign-extended / - * computed 32-bit screen X. Identity at margin 0 (4:3). */ -int psx_ws_cull_slti(uint32_t sx, uint32_t imm) { - return ((int32_t)sx < (int32_t)imm + psx_ws_x_margin()) ? 1 : 0; -} - -/* Signed fixed lower-bound widen (`slti v, x, -W`): move the reject edge left - * by one live reveal margin. The encoded immediate must be sign-extended. */ -int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm) { - int32_t bound = (int32_t)(int16_t)(uint16_t)imm; - return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; -} - -/* Manual CPU-projected screen-window constants. These complement the compare - * helpers above for games which clamp and queue signed [left,right] spans in - * ordinary MIPS code. All three are exact identities when margin==0. */ -int32_t psx_ws_cull_lower_value(int32_t vanilla) { - return vanilla - psx_ws_x_margin(); -} -int32_t psx_ws_cull_upper_value(int32_t vanilla) { - return vanilla + psx_ws_x_margin(); -} -uint32_t psx_ws_cull_span_value(uint32_t vanilla) { - int32_t margin = psx_ws_x_margin(); - int32_t left = (int32_t)(int16_t)(uint16_t)(vanilla >> 16); - uint32_t right = vanilla & 0xFFFFu; - return ((uint32_t)(uint16_t)(left - margin) << 16) | - (uint32_t)(uint16_t)((int32_t)right + margin); -} - -/* Signed left-edge widen for the funnel's `bltz maxSX, reject`: reject only - * when the prim ends left of the REVEALED edge (maxSX < -margin). Returns the - * branch predicate. Identity at margin 0 (4:3). */ -int psx_ws_cull_bltz(uint32_t v) { - return ((int32_t)v < -psx_ws_x_margin()) ? 1 : 0; -} -int psx_ws_cull_vxrange(uint32_t x, uint32_t imm) { - int32_t margin = psx_ws_x_margin(); - uint32_t bound = (uint32_t)(int32_t)(int16_t)(uint16_t)imm; - return (((x + (uint32_t)margin) & 0xFFFFu) < - (bound + 2u * (uint32_t)margin)) ? 1 : 0; -} - -/* ---- Cull signature configuration ([widescreen.cull] screen_w_imms / - * screen_h_imms). The width/height immediates are per-game (Tomba: 0x140/0x141 - * + 0xE0/0xF1 on a 320 display; Ape Escape: 0x181 + 0xF1 on 368). Defaults - * keep the original Tomba signature so existing configs are unchanged. The - * sets are consulted by the shared detector on every backend (interp; - * the recompiler reads the same config at gen time). */ -static uint32_t ws_cull_w_imms[8] = { 0x140, 0x141 }; -static int ws_cull_w_n = 2; -static uint32_t ws_cull_h_imms[8] = { 0xE0, 0xF1 }; -static int ws_cull_h_n = 2; -void gpu_ws_set_cull_imms(const uint32_t *w, int nw, const uint32_t *h, int nh) { - if (w && nw > 0) { - if (nw > 8) nw = 8; - for (int i = 0; i < nw; i++) ws_cull_w_imms[i] = w[i]; - ws_cull_w_n = nw; - } - if (h && nh > 0) { - if (nh > 8) nh = 8; - for (int i = 0; i < nh; i++) ws_cull_h_imms[i] = h[i]; - ws_cull_h_n = nh; - } -} -int psx_ws_is_cull_w_imm(uint32_t imm) { - return psx_ws_cull_imm_in(imm, ws_cull_w_imms, ws_cull_w_n); -} - -/* ---- Runtime gates for the pattern-scanned widescreen hooks. The interp - * derives widen sites by scanning live code; that derivation must honor - * the SAME per-game [widescreen.cull] opt-ins the recompiler emit does. These - * default OFF: a title that never opted in must never have its code - * pattern-scanned and rewritten (an ungated backdrop false positive rewrites a - * live GPR = wild-jump fatal — the exact class this gate closes). Set from - * game.toml at startup (main.cpp). */ -static int ws_auto_cull_on_cfg = 0; -static int ws_auto_backdrop_on_cfg = 0; -void gpu_ws_set_auto_hooks(int cull_on, int backdrop_on) { - ws_auto_cull_on_cfg = cull_on ? 1 : 0; - ws_auto_backdrop_on_cfg = backdrop_on ? 1 : 0; -} -int psx_ws_auto_cull_on(void) { return ws_auto_cull_on_cfg; } - -/* Detect the GTE screen-extent trivial-reject signature in a run of - * instruction words: at least one width compare AND one height compare - * (slti or sltiu, immediates from the configured sets). Lets the - * interpreter gate the cull-widening to real render funnels (a lone width - * compare elsewhere must stay vanilla). Same shared scan the recompiler's - * func_has_screen_extent_cull uses (ws_cull_detect.h). */ -int psx_ws_func_has_screen_cull(const uint32_t *words, int n) { - return psx_ws_cull_scan(words, n, ws_cull_w_imms, ws_cull_w_n, - ws_cull_h_imms, ws_cull_h_n); -} - -/* Classify words[idx] as an X left-edge reject bltz (shared structural - * detector, runtime imm sets). Caller qualifies the window first. */ -int psx_ws_cull_bltz_at(const uint32_t *words, int n, int idx) { - return psx_ws_cull_bltz_here(words, n, idx, ws_cull_w_imms, ws_cull_w_n); -} - -/* Widescreen backdrop screen-X correction ([widescreen.backdrop] x_sites). - * The parallax 2D backdrop layer (ocean/cloud/mountain/grass — overlay actor - * handlers e.g. 0x801216BC) computes its screen-X in pure integer math - * (screenX = (worldX - camX) >> parallax) and NEVER goes through the GTE, so - * the GTE X-squash (gte_set_display_aspect) that gives 3D the wider 16:9 FOV - * does not touch it. The recompiler emits this on each handler's final - * screenX store so the backdrop is squashed by the SAME factor around the - * screen centre: a far piece whose 4:3 screenX sat past the 320px edge (and - * was GPU-clipped → the blue void / half-rectangles at the edges) is pulled - * in to cover the revealed FOV. Identity at 4:3 / boot / FMV / full-2D (the - * exact ws_active() predicate the GTE squash uses), so one build serves both. - * In native-wide mode there is nothing to squash (the 4:3 frame is presented - * with side reveal instead), so the same sites are STRETCHED about the screen - * centre by (disp_w + nw_extra) / disp_w: a 4:3-authored backdrop then covers - * the widened frame instead of leaving unpainted margins (Xenogears battle - * mountain panels — pre-calculated POLY_FT4 screen coords stored by main-EXE - * `sh` sites, wtrace-evidenced). - * x is the int16 screenX the handler was about to store. */ -int psx_ws_backdrop_x(int x) { - if (ws_native_wide_active()) { - int32_t W = (int32_t)ws_disp_w(); - int32_t extra = ws_nw_extra(); - int32_t cx = W / 2; - int32_t d = (int16_t)x - cx; - return (int)(cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W); - } - if (!ws_active()) return (int16_t)x; - int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ - return ws_scale_about((int16_t)x, cx); -} - -/* Classic-wide counterpart for engines which calculate perspective screen X - * in ordinary CPU code instead of the GTE. Exact recompiler sites call this - * immediately after adding the projection centre. Squashing here keeps the - * game's original non-negative 0..display-width portal spans and clip logic - * valid; the classic present stretch restores native proportions afterward. - * Native-wide and 4:3 are exact identities. Use 64-bit intermediates because - * a near-plane projection may be far outside the eventual screen clamp. */ -int psx_ws_project_x(int x) { - if (ws_mode != 1 || !ws_configured()) return x; - int64_t cx = (int64_t)ws_disp_w() / 2; - int64_t d = (int64_t)(int32_t)x - cx; - int64_t n = d * (int64_t)ws_xnum; - n += d >= 0 ? ws_xden / 2 : -ws_xden / 2; - return (int32_t)(cx + n / ws_xden); -} - -/* psx_ws_project_x for a point with a 16.16 fraction, without its rounding. */ -int32_t psx_ws_project_x16(int x, int32_t fraction16) { - const int64_t exact = (int64_t)x * 65536 + fraction16; - if (ws_mode != 1 || !ws_configured()) return (int32_t)exact; - const int64_t cx = ((int64_t)ws_disp_w() / 2) * 65536; - return (int32_t)(cx + (exact - cx) * ws_xnum / ws_xden); -} - -/* Backdrop PRELOAD predicate + value substitution ([widescreen.cull] - * auto_backdrop). The recompiler/interp detect each scrolling-backdrop - * column-window generator (see ws_backdrop_detect.h) and route its window START - * and END bounds through psx_ws_backdrop_value(). When native-wide is engaged we - * WIDEN the camera-tracked window by the 16:9 reveal: the START (left) bound - * moves left by `margin`, the END (right) bound moves right by `margin`, where - * margin is the per-side reveal in COLUMNS. The window of window_cols columns - * spans (at least) the full display width, so reveal_cols = window_cols * - * reveal_px / display_px = window_cols * nw_offset / disp_w (+slack). This draws - * ONLY the now-visible columns (no whole-row overdraw, no fixed cap), and the - * generator's own low/high clamps still bound it at the level edges. Gated on - * native-wide only (NOT squash: that path uses psx_ws_backdrop_x()). Returns - * `orig` unchanged at 4:3 / squash / boot / FMV, so 4:3 stays byte-identical. */ -int psx_ws_backdrop_preload(void) { - return ws_auto_backdrop_on_cfg && ws_native_wide_active(); -} - -/* Live-tunable widen amount (set via the `ws_backdrop_margin` debug command): - * <0 => WHOLE-ROW preload: START->0, END->extent-1 (max generous) - * 0 => identity: no widening, native-wide still on (A/B the effect) - * >0 => widen the camera-tracked window by N columns each side (bounded) - * Default is a safe-but-generous bounded value; dialed live while debugging so - * the widen strategy can be tuned without a rebuild. */ -int g_ws_bd_margin = 0; /* column preload proven irrelevant to the void; default off */ -/* Set to 1 by the interpreter around its psx_ws_backdrop_value call so the value - * function does NOT also ring-note: the interp records a richer entry (live - * extent / camera / DL count). Native cache-DLL calls leave it 0, so the value - * function notes them itself -- that is how the ring sees native execution. */ -int g_ws_bd_from_interp = 0; - -uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols) { - if (!psx_ws_backdrop_preload()) return orig; /* 4:3 -> byte-identical */ - if (window_cols < 0) window_cols = -window_cols; - int m = g_ws_bd_margin; - int from_interp = g_ws_bd_from_interp; - g_ws_bd_from_interp = 0; /* consume one-shot flag */ - uint32_t finalv; - if (m < 0) { - /* Whole-row: the generator's shared clamps (START<0 -> 0; END>=extent -> - * extent-1) pin the loop to [0, extent-1]. Bounded because `extent` is a - * byte and the DL count is a byte (row <= 255 cols). */ - enum { WS_BD_END_SENTINEL = 0x7FFF }; - finalv = is_end ? (uint32_t)WS_BD_END_SENTINEL : 0u; - } else if (m == 0) { - finalv = orig; /* effect off (still recorded) */ - } else { - finalv = is_end ? (orig + (uint32_t)m) : (orig - (uint32_t)m); - } - /* Single chokepoint for every path (interp hook, native cache-DLL via the - * overlay callback). The interp records its own richer entry, so skip - * here when it set the flag; native-DLL calls (flag 0) are recorded here with - * pc/extent/camera = 0 -- which is how the ring sees native execution. */ - if (!from_interp) - psx_ws_backdrop_ring_note(0u, is_end ? 2 /*WS_BD_END*/ : 1 /*WS_BD_START*/, - window_cols, orig, finalv, 0, 0, 0, 0u, 0u); - return finalv; -} - -/* ---- auto_backdrop diagnostic ring (always-on; see BACKDROP_PRELOAD.md) ----- - * Every backdrop-window rewrite (interp or native) is recorded here so the live - * tile-row extent, the camera-tracked bounds, and exactly WHICH of a scene's - * windows fire can be read back via the `ws_backdrop_ring` debug command -- - * query the ring for the window of interest, never pause/step. The recorder is - * fed by the interpreter (which has the live CPU state: s7=extent, the DL count, - * and the scratchpad camera-X) at the rewrite site. */ -typedef struct { - uint32_t frame, pc, orig, finalv, base, dl; - int kind, wcols, extent, camx, count; -} WsBdRingEnt; -#define WS_BD_RING_CAP 512 -static WsBdRingEnt s_bd_ring[WS_BD_RING_CAP]; -static uint32_t s_bd_ring_seq = 0; - -void psx_ws_backdrop_ring_note(uint32_t pc, int kind, int wcols, uint32_t orig, - uint32_t finalv, int extent, int camx, int count, - uint32_t base, uint32_t dl) { - WsBdRingEnt *e = &s_bd_ring[s_bd_ring_seq & (WS_BD_RING_CAP - 1)]; - e->frame = (uint32_t)s_frame_count; e->pc = pc; e->kind = kind; e->wcols = wcols; - e->orig = orig; e->finalv = finalv; e->extent = extent; e->camx = camx; e->count = count; - e->base = base; e->dl = dl; - s_bd_ring_seq++; -} - -int psx_ws_backdrop_ring_json(char *buf, int cap) { - int n = (int)(s_bd_ring_seq < WS_BD_RING_CAP ? s_bd_ring_seq : WS_BD_RING_CAP); - uint32_t start = s_bd_ring_seq - (uint32_t)n; - int off = snprintf(buf, (size_t)cap, "\"seq\":%u,\"n\":%d,\"ents\":[", s_bd_ring_seq, n); - for (int i = 0; i < n && off < cap - 200; i++) { - WsBdRingEnt *e = &s_bd_ring[(start + (uint32_t)i) & (WS_BD_RING_CAP - 1)]; - off += snprintf(buf + off, (size_t)(cap - off), - "%s{\"f\":%u,\"pc\":\"%08x\",\"k\":%d,\"wc\":%d,\"o\":%d,\"fin\":%d,\"ext\":%d,\"cam\":%d,\"cnt\":%d,\"base\":\"%08x\",\"dl\":\"%08x\"}", - i ? "," : "", e->frame, e->pc, e->kind, e->wcols, - (int)(int16_t)e->orig, (int)(int16_t)e->finalv, e->extent, e->camx, e->count, - e->base, e->dl); - } - off += snprintf(buf + off, (size_t)(cap - off), "]"); - return off; -} - -/* Backdrop store-site registry. The [widescreen.backdrop] x_sites are emitted - * into native cache-DLL code by the recompiler, but overlay code very often - * runs INTERPRETED (no DLL loaded), where the emit can't reach. So the runtime - * also registers the same site PCs here and the dirty-RAM interpreter applies - * psx_ws_backdrop_x() at those `sh` PCs — same transform, both paths. Tiny set; - * a linear scan per matching SH is negligible. */ -#define WS_BACKDROP_SITES_MAX 16 -static uint32_t ws_backdrop_sites[WS_BACKDROP_SITES_MAX]; -static int ws_backdrop_site_n = 0; - -void psx_ws_set_backdrop_sites(const uint32_t* pcs, int n) { - ws_backdrop_site_n = 0; - if (!pcs) return; - for (int i = 0; i < n && ws_backdrop_site_n < WS_BACKDROP_SITES_MAX; i++) - ws_backdrop_sites[ws_backdrop_site_n++] = pcs[i] & 0x1FFFFFFFu; -} - -int psx_ws_is_backdrop_site(uint32_t pc) { - if (!ws_backdrop_site_n) return 0; - pc &= 0x1FFFFFFFu; - for (int i = 0; i < ws_backdrop_site_n; i++) - if (ws_backdrop_sites[i] == pc) return 1; - return 0; -} - -/* Snapshot live widescreen state for the TCP gpu_state diagnostic. Mirrors - * the predicates above so a probe can see, mid-gameplay, whether the squash - * and the cull-margin are actually engaged (8C). */ -void gpu_ws_get_debug(GpuWsDebug* out) { - if (!out) return; - out->configured = ws_configured(); - out->active = ws_active(); - out->game_mode = ws_game_mode(); - out->present_native_43 = gpu_ws_present_native_43(); - out->x_margin = psx_ws_x_margin(); - out->activation_margin = psx_ws_activation_margin(); - out->xnum = ws_xnum; - out->xden = ws_xden; - out->mode = ws_mode; - out->nw_extra = ws_nw_extra(); - out->cur_frame = s_frame_count; - out->last_tag_frame = ws_last_tag_stamp; - out->last_3d_frame = ws_last_3d_stamp; - out->gte_verts = ws_gte_prev_verts; - out->last_world3d_frame = ws_sust_world3d_stamp; - out->ovh_prims = ws_ovh_prev; - out->last_ovh_frame = ws_sust_ovh_stamp; - out->auto_ui_squash = ws_auto_ui_squash; - out->auto_ui_dense = ws_auto_ui_dense; - out->auto_ui_ot_rank = - ws_ui_prepass_rank != 0xFFFFu ? ws_ui_prepass_rank : UINT32_MAX; - out->auto_ui_candidates = ws_auto_ui_candidate_count; - out->auto_ui_transforms = ws_auto_ui_transform_count; - out->aspect_cone_calls = ws_aspect_cone_calls; - out->aspect_cone_43_identity = ws_aspect_cone_43_identity; - out->aspect_cone_vanilla_keep = ws_aspect_cone_vanilla_keep; - out->aspect_cone_visible_keep = ws_aspect_cone_visible_keep; - out->aspect_cone_guard_keep = ws_aspect_cone_guard_keep; - out->aspect_cone_hysteresis_keep = - ws_aspect_cone_hysteresis_keep; - out->aspect_cone_outside_reject = - ws_aspect_cone_outside_reject; - out->aspect_cone_queue_reject = ws_aspect_cone_queue_reject; - for (int i = 0; i < 3; i++) - out->aspect_cone_queue_highwater[i] = - ws_aspect_cone_queue_highwater[i]; - out->angle_calls = ws_angle_calls; - out->angle_43_identity = ws_angle_43_identity; - out->angle_max_vanilla = ws_angle_max_vanilla; - out->angle_max_widened = ws_angle_max_widened; -} - -int gpu_ws_get_aspect_cone_site_debug( - uint32_t address, GpuWsAspectConeSiteDebug* out) { - WsAspectConeSite *site = ws_aspect_cone_find(address, 0); - if (!site || !out) return 0; - out->address = site->address | 0x80000000u; - out->calls = site->calls; - out->identity_43 = site->identity_43; - out->vanilla_keep = site->vanilla_keep; - out->visible_keep = site->visible_keep; - out->guard_keep = site->guard_keep; - out->hysteresis_keep = site->hysteresis_keep; - out->outside_reject = site->outside_reject; - out->queue_reject = site->queue_reject; - return 1; -} - -void gpu_ws_configure(int aspect_num, int aspect_den, - uint32_t sprite_anchor_addr, int hud_sprt_squash, int mode) { - ws_cfg_num = aspect_num > 0 ? aspect_num : 4; - ws_cfg_den = aspect_den > 0 ? aspect_den : 3; - ws_mode = mode; - if (mode == 1) { - /* Squash factor = (4*den)/(3*num) — the same factor the GTE applies. */ - int32_t n = 4 * ws_cfg_den, d = 3 * ws_cfg_num; - int32_t a = n, b = d; - while (b) { int32_t t = a % b; a = b; b = t; } - ws_xnum = n / a; - ws_xden = d / a; - } else { - /* Native-wide (2) and off (0): the GTE is NOT squashed. */ - ws_xnum = ws_xden = 1; - } - ws_anchor_addr = sprite_anchor_addr; - ws_hud_sprt = hud_sprt_squash; - /* Adaptive view can change mode/extent without the guest reissuing E3/E4 - * immediately. Keep the active mirror target and its scissor in lockstep - * with the new live aspect instead of waiting for another draw-env packet. */ - ws_nw_sync_target(); -} - -static void ws_tag_primitive(uint32_t primitive_addr, int32_t anchor_x, - uint32_t signature, int content_validated) { - uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - if (!key) return; - uint32_t now = (uint32_t)s_frame_count; - uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); - uint32_t victim = idx; - for (int i = 0; i < WS_TAG_PROBES; i++) { - uint32_t j = (idx + i) & (WS_TAG_BUCKETS - 1); - WsTag *t = &ws_tags[j]; - if (t->key == key || t->key == 0) { victim = j; break; } - if (now - t->stamp > 2) victim = j; /* stale — reusable */ - } - ws_tags[victim].key = key; - ws_tags[victim].stamp = now; - ws_tags[victim].anchor_x = anchor_x; - ws_tags[victim].signature = signature; - ws_tags[victim].content_validated = content_validated != 0; - ws_last_tag_stamp = now; -} - -/* Explicit provenance seam for a title-specific, reviewed world-billboard - * builder. Unlike sprite_tag_funcs this does not depend on a fixed scratchpad - * anchor: the caller has already identified both the completed P_TAG packet and - * its projected centre. Restrict it to the classic squash path so a tag cannot - * affect native-wide backdrop classification or a native-4:3 presentation. */ -void gpu_ws_tag_primitive(CPUState *cpu, uint32_t primitive_addr, - int32_t anchor_x) { - if (!ws_active() || !cpu || !cpu->read_word) return; - uint32_t words[9]; - for (int i = 0; i < 9; i++) - words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); - ws_tag_primitive(primitive_addr, anchor_x, - psx_ws_ft4_signature_words(words), 1); -} - -#define TEMPORAL_SPRITE_BUCKETS 4096 -static WsTag temporal_sprites[TEMPORAL_SPRITE_BUCKETS]; - -static WsTag *temporal_sprite_slot(uint32_t key, int claim) { - const uint32_t now = (uint32_t)s_frame_count; - const uint32_t first = (key >> 2) & (TEMPORAL_SPRITE_BUCKETS - 1); - WsTag *stale = NULL; - for (int i = 0; i < WS_TAG_PROBES; i++) { - WsTag *t = &temporal_sprites[(first + (uint32_t)i) & (TEMPORAL_SPRITE_BUCKETS - 1)]; - if (t->key == key) return t; - if (!stale && (t->key == 0 || now - t->stamp > 2)) stale = t; - } - return claim ? stale : NULL; -} - -void gpu_temporal_note_sprite(CPUState *cpu, uint32_t primitive_addr, - int32_t depth) { - if (!cpu || !cpu->read_word || depth <= 0) return; - const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - WsTag *t = key ? temporal_sprite_slot(key, 1) : NULL; - uint32_t words[9]; - if (!t) return; - for (int i = 0; i < 9; i++) - words[i] = cpu->read_word(primitive_addr + 4u + (uint32_t)i * 4u); - t->key = key; - t->stamp = (uint32_t)s_frame_count; - t->anchor_x = depth; - t->signature = psx_ws_ft4_signature_words(words); - t->content_validated = 1; - t->placed = t->sized = t->shadow = t->whole = 0; -} - -void gpu_temporal_place_sprite(uint32_t primitive_addr, int32_t centre_x16, int32_t offset_y16) { - const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; - if (!t || t->stamp != (uint32_t)s_frame_count) return; - t->placed = 1; - t->centre_x = centre_x16; - t->offset_y = offset_y16; -} - -void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16, - int shadow, int whole) { - const uint32_t key = psx_mod_gpu_dma_resolve_address(primitive_addr); - WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; - if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return; - t->sized = 1; - t->shadow = shadow != 0; - t->whole = whole != 0; - t->row = row16; - t->width = width16; - t->height = height16; -} - -static int s_shadow_shape = -1; /* not chosen yet: PSX_DISRUPTOR_IMPROVED_SHADOWS=1 of the launcher decides */ -void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; } -int gpu_shadow_shape(void) { - if (s_shadow_shape < 0) { - const char *asked = getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS"); - s_shadow_shape = asked && asked[0] == '1'; - } - return s_shadow_shape; -} -int gpu_sprite_placement_available(void) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - return 1; -#else - return 0; -#endif -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static const WsTag *temporal_sprite_tag(void) { - if (gp0_cmd_source_addr == 0xFFFFFFFFu) return NULL; - const uint32_t key = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u); - const WsTag *t = key ? temporal_sprite_slot(key, 0) : NULL; - if (!t || psx_ws_tag_match_result((uint32_t)s_frame_count, t->stamp, 1, - t->signature, gp0_cmd_buf) != PSX_WS_TAG_MATCH) - return NULL; - return t; -} - -/* How far the presentation moves the left, top, right and bottom side of a sprite's rectangle (x, y, w, h on screen, - * raw_w and raw_h as the packet has them). Returns the sprite's camera depth, 0 without one. */ -static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4]) { - const WsTag *t = temporal_sprite_tag(); - sides[0] = sides[1] = sides[2] = sides[3] = 0.0f; - if (!t) return 0.0f; - const GpuTemporalSprite sprite = gpu_temporal_sprite_from_fixed( - t->placed, t->sized, t->shadow, t->whole, t->centre_x, t->offset_y, t->row, t->width, t->height); - gpu_temporal_sprite_sides(&sprite, (float)x, (float)y, w, h, raw_w, raw_h, - squashed ? (float)ws_xnum / (float)ws_xden : 1.0f, gpu_shadow_shape(), sides); - return (float)t->anchor_x; -} -#endif - -/* Backdrop tiles tile the frame exactly, so squashing each one around a HUD - * pivot pulls them apart and exposes the clear colour between them. */ -static PsxWsScreenTiles ws_screen_tiles; - -void gpu_ws_tag_screen_tile(CPUState *cpu, uint32_t primitive_addr) { - if (!ws_active() || !cpu || !cpu->read_word) return; - uint32_t words[PSX_WS_SCREEN_TILE_WORDS]; - for (uint32_t i = 0; i < PSX_WS_SCREEN_TILE_WORDS; i++) - words[i] = cpu->read_word(primitive_addr + 4u + i * 4u); - psx_ws_screen_tile_mark(&ws_screen_tiles, - psx_mod_gpu_dma_resolve_address(primitive_addr), - words, (uint32_t)s_frame_count); -} - -int32_t gpu_ws_widen_x(int32_t x, int round_up) { - if (!ws_active()) return x; - return psx_ws_widen_about(x, ws_disp_w() / 2, ws_xnum, ws_xden, round_up); -} - -/* A two-frame window was tried instead of one-shot marks and lost them on - * 4-VBlank frames. */ -static int ws_screen_tile_take(void) { - if (gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; - return psx_ws_screen_tile_take( - &ws_screen_tiles, - psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr - 4u), - gp0_cmd_buf, (uint32_t)s_frame_count); -} - -/* Called from generated code at the entry of each [widescreen] - * sprite_tag_funcs function: record prim pointer ($a0) -> projected anchor. - * Gated on ws_engaged (NOT ws_active): ws_active can depend on game_mode which - * this legacy tag path establishes for sprite-classified games. */ -void psx_ws_sprite_tag(CPUState* cpu) { - if (!ws_engaged() || !ws_anchor_addr) return; - uint32_t sxy = cpu->read_word(ws_anchor_addr); - int32_t ax = (int32_t)(int16_t)(sxy & 0xFFFFu); - ws_tag_primitive(cpu->gpr[4], ax, 0, 0); -} - -static int ws_tag_matches_current(WsTag *tag, uint32_t now) { - const int result = psx_ws_tag_match_result( - now, tag->stamp, tag->content_validated, - tag->signature, gp0_cmd_buf); - if (result == PSX_WS_TAG_MATCH) return 1; - if (result != PSX_WS_TAG_CONTENT_MISMATCH) return 0; - - /* The packet pool reused this address. Invalidate immediately instead of - * letting a stale world-billboard tag flicker onto an unrelated primitive. */ - tag->key = 0; - tag->content_validated = 0; - return 0; -} - -/* Look up the executing GP0 command's prim in the tag table. The command's - * first word lives at prim+4 (the PsyQ P_TAG header precedes it), but accept - * a direct hit too in case a tag site passes the colour-word address. */ -static int ws_tagged_anchor(int32_t *out_ax) { - if (!ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; - uint32_t now = (uint32_t)s_frame_count; - for (int variant = 0; variant < 2; variant++) { - uint32_t key = psx_mod_gpu_dma_resolve_address( - gp0_cmd_source_addr - (variant ? 0u : 4u)); - uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); - for (int i = 0; i < WS_TAG_PROBES; i++) { - WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; - if (t->key == key && ws_tag_matches_current(t, now)) { - *out_ax = t->anchor_x; - return 1; - } - } - } - return 0; -} - -/* Native-wide-compatible "is the current GP0 prim sprite-tagged?" — same ws_tags - * lookup as ws_tagged_anchor but gated on ws_engaged() (squash OR native-wide) - * instead of ws_active() (squash only). Used by the GL native-wide 2D-backdrop - * stretch to EXCLUDE foreground sprites: characters / Tomba / HUD are tagged by - * psx_ws_sprite_tag (their prim ptr -> ws_tags), the 2D backdrop tiles are not. - * Reads the live gp0_cmd_source_addr (the source addr of the prim being drawn). */ -/* Flower-field backdrop data-structure address range — set by the dirty-RAM - * interpreter when the backdrop generator (overlay 0x80116808) runs; its tile - * packets live in [lo, hi]. The GL native-wide 2D-stretch gate matches the prim - * being drawn (gp0_cmd_source_addr) against this to PRECISELY identify the - * flower-field tiles — the 3D rock/foreground is untagged AND has narrow prims, - * so tag/narrow heuristics alone mis-stretch and tear it. */ -uint32_t g_ws_backdrop_lo = 0, g_ws_backdrop_hi = 0; -static int ws_nw_phase_backdrop = 0; -void gpu_ws_set_nw_phase_backdrop(int on) { ws_nw_phase_backdrop = on ? 1 : 0; } -static int ws_nw_textured_edges = 0; -int g_ws_tex_edge_pct = 0; -void gpu_ws_set_nw_textured_edges(int on, int scale_pct) { - ws_nw_textured_edges = on ? 1 : 0; - g_ws_tex_edge_pct = scale_pct; -} -/* diag: per-frame min/max of the prim source addrs the GL gate sees */ -uint32_t g_bdg_src_lo = 0xFFFFFFFFu, g_bdg_src_hi = 0; -static uint32_t bdg_src_frame = 0xFFFFFFFFu; - -/* ---- prim<->pixel correlation debug gate (ws_dbg_stretch command) ------------ - * To identify WHICH drawn prims constitute the native-wide 2D-backdrop void layer - * (vs Tomba/HUD/3D), this lets a probe stretch an exact SELECTABLE prim set and - * screenshot the result. When g_dbg_mode != 0 the GL stretch gate uses this - * instead of the flower-struct range. Modes (buffer/frame-independent except 1): - * 1 OT-range : gp0_cmd_source_addr in [g_dbg_lo,g_dbg_hi] - * 2 clut : textured prim whose clut == g_dbg_clut - * 3 tpage : textured prim whose tpage == (g_dbg_clut & 0x1FF) - * 4 tagged : prim is sprite-tagged (psx_ws_prim_is_tagged) - * 5 untag-tex : textured AND not sprite-tagged - * 6 all-tex : every textured prim (sanity: should fill void + distort 3D) - * 7 all : every primitive (coarse correlation sanity check) - * 8 all-flat : every untextured primitive - * Counters (per-frame, snapshotted by the present hook) report how many prims - * matched and how many of those were sprite-tagged. */ -int g_dbg_mode = 0; -uint32_t g_dbg_lo = 0, g_dbg_hi = 0; -uint16_t g_dbg_clut = 0; -int g_dbg_match_n = 0, g_dbg_match_tagged = 0; /* snapshot (reported) */ -static int s_dbg_match_n = 0, s_dbg_match_tagged = 0; /* accumulate per frame */ -int psx_ws_prim_is_tagged(void); /* defined below */ - -static int dbg_gate_match(void) { - uint32_t op = (gp0_cmd_buf[0] >> 24) & 0xFFu; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint32_t a = gp0_cmd_source_addr & 0x1FFFFFFFu; - int textured = ((op >= 0x20 && op <= 0x3F) && (op & 0x04)) || (op >= 0x64 && op <= 0x67); - int tagged = psx_ws_prim_is_tagged(); - int m = 0; - switch (g_dbg_mode) { - case 1: m = (g_dbg_hi > g_dbg_lo && a >= g_dbg_lo && a <= g_dbg_hi); break; - case 2: m = (textured && clut == g_dbg_clut); break; - case 3: m = (textured && ((uint16_t)(gp0_cmd_buf[4] >> 16) & 0x1FF) == (g_dbg_clut & 0x1FF)); break; - case 4: m = tagged; break; - case 5: m = (textured && !tagged); break; - case 6: m = textured; break; - case 7: m = 1; break; - case 8: m = !textured; break; - default: m = 0; break; - } - if (m) { s_dbg_match_n++; if (tagged) s_dbg_match_tagged++; } - return m; -} - -/* Called by the present hook each frame to publish the dbg match counters. */ -void psx_ws_dbg_gate_frame_snapshot(void) { - g_dbg_match_n = s_dbg_match_n; g_dbg_match_tagged = s_dbg_match_tagged; - s_dbg_match_n = 0; s_dbg_match_tagged = 0; -} - -/* Background-phase latch for the native-wide 2D-backdrop stretch. The far 2D - * backdrop (a sprite-tagged tile grid: sky gradient + flower-ball field) draws - * FIRST each frame, before the GTE 3D world. Once a shaded/gouraud prim (the 3D - * world's signature -- the backdrop is sprites/flat) is drawn, the backdrop phase - * is over: later TAGGED prims (HUD, characters) must NOT be stretched. Latched - * per-frame at GP0 decode (ws_bg_phase_note, sees every prim in draw order). */ -static uint32_t s_bg_phase_frame = 0xFFFFFFFFu; -static int s_bg_phase_over = 0; -void ws_bg_phase_note(uint32_t op) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s_bg_phase_frame) { s_bg_phase_frame = f; s_bg_phase_over = 0; } - if (op >= 0x30u && op <= 0x3Fu) { - s_bg_phase_over = 1; /* shaded = 3D world (within-frame draw order) */ - ws_last_3d_stamp = f; /* diagnostic only — see ws_2d_only_scene() for - why shaded prims are NOT the scene classifier */ - } -} -static int ws_bg_phase_over(void) { - uint32_t f = (uint32_t)s_frame_count; - if (f != s_bg_phase_frame) { s_bg_phase_frame = f; s_bg_phase_over = 0; } - return s_bg_phase_over; -} - -int psx_ws_prim_in_backdrop(void) { - if (gp0_cmd_source_addr != 0xFFFFFFFFu) { - uint32_t f = (uint32_t)s_frame_count; - if (f != bdg_src_frame) { bdg_src_frame = f; g_bdg_src_lo = 0xFFFFFFFFu; g_bdg_src_hi = 0; } - if (gp0_cmd_source_addr < g_bdg_src_lo) g_bdg_src_lo = gp0_cmd_source_addr; - if (gp0_cmd_source_addr > g_bdg_src_hi) g_bdg_src_hi = gp0_cmd_source_addr; - } - if (g_dbg_mode != 0) return dbg_gate_match(); /* correlation override */ - if (ws_nw_textured_edges) { - uint32_t op = (gp0_cmd_buf[0] >> 24) & 0xFFu; - if (op >= 0x20u && op <= 0x3Fu && (op & 0x04u)) - return 2; /* GL gate: expand only vertices beyond canonical edges */ - } - if (ws_nw_phase_backdrop && !ws_bg_phase_over()) return 1; - /* Real gate: stretch the 2D backdrop = sprite-tagged prims drawn in the - * background phase (before the 3D world). Fills the native-wide void for both - * the flower grid and the sky band; HUD/characters (tagged but post-3D) and the - * GTE world (untagged) are excluded. */ - if (ws_bg_phase_over()) return 0; - return psx_ws_prim_is_tagged(); -} - -int psx_ws_prim_is_tagged(void) { - if (!ws_engaged() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; - uint32_t now = (uint32_t)s_frame_count; - for (int variant = 0; variant < 2; variant++) { - uint32_t key = psx_mod_gpu_dma_resolve_address( - gp0_cmd_source_addr - (variant ? 0u : 4u)); - uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); - for (int i = 0; i < WS_TAG_PROBES; i++) { - WsTag *t = &ws_tags[(idx + i) & (WS_TAG_BUCKETS - 1)]; - if (t->key == key && ws_tag_matches_current(t, now)) return 1; - } - } - return 0; -} - -/* Squash x around pivot ax by ws_xnum/ws_xden, round to nearest. */ -static int32_t ws_scale_about(int32_t x, int32_t ax) { - int32_t d = x - ax; - int32_t s = (d * ws_xnum + (d >= 0 ? ws_xden / 2 : -ws_xden / 2)) / ws_xden; - return ax + s; -} - -/* Squash a width; never below 1px so nothing vanishes. */ -static int32_t ws_scale_len(int32_t w) { - int32_t s = (w * ws_xnum + ws_xden / 2) / ws_xden; - return s < 1 ? 1 : s; -} - -/* Horizontal display width in drawing space (e.g. 320). */ -static int32_t ws_disp_w(void) { - GpuDisplayInfo di; - gpu_get_display_info(&di); - return di.width ? (int32_t)di.width : 320; -} - -static int32_t ws_disp_h(void) { - GpuDisplayInfo di; - gpu_get_display_info(&di); - return di.height ? (int32_t)di.height : 240; -} - -/* An upload or a copy into a frame buffer has no drawing area to announce it. */ -static void ws_frame_pictured(int x, int y, int w, int h) { - psx_ws_frame_kinds_pictured(&ws_frame_kinds, (uint32_t)x, (uint32_t)y, (uint32_t)w, (uint32_t)h, - (uint32_t)ws_disp_w(), (uint32_t)ws_disp_h()); -} - -/* Full-screen fades and environmental filters are authored as 320x240 TILEs. - * In native-wide mode, grow only primitives that cover the complete native - * display; ordinary world-space rectangles remain untouched. */ -static void ws_expand_fullscreen_rect(int32_t *x, int32_t y, int *w, int h) { - if (!ws_native_wide_active()) return; - int W = (int)ws_disp_w(), H = (int)ws_disp_h(); - if (*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) { - int off = ws_nw_offset(); - *x -= off; - *w += 2 * off; - } -} - -/* In-game HUD pivot for an untagged screen-space SPRT spanning [x, x+w). - * Only reached during gameplay (ws_active true). HUD elements are sparse and - * corner-anchored, so pivot by thirds: outer-third elements anchor to their - * screen edge (keeping the wide-screen corner position at native proportions), - * the middle third to centre. A composite (counter box + digits) sits inside - * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu - * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ -static int32_t ws_hud_strip_wide(void); -static int32_t ws_hud_pivot(int32_t x, int32_t w) { - int32_t W = ws_disp_w(); - int32_t cx = psx_ws_hud_thirds_centre(x, w, ws_hud_strip_wide(), W); /* 2*centre, avoids losing the half */ - if (3 * cx < 2 * W) return 0; - if (3 * cx > 4 * W) return W; - return W / 2; -} - -/* Automatic UI correction is deliberately tied to draw provenance, not to - * primitive size or "does this look 2D?" guesses. Ape's ordering table submits - * HUD/font/icon packets in the final layer, after every depth-sorted world - * bucket. A read-only prepass finds that final rank and complete spatial groups - * before the list streams through GP0. This excludes CPU-built characters (the - * source of the old squashed-Spike regression) even when their packets are - * axis-aligned, and gives animated glyphs a shared anchor on their first frame. */ -static int ws_auto_ui_anchor(int32_t *out_anchor) { - if (!ws_auto_ui_squash || !ws_active() || - gp0_cmd_source_addr == 0xFFFFFFFFu) - return 0; - uint32_t src = psx_mod_gpu_dma_resolve_address(gp0_cmd_source_addr); - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].src_addr != src) continue; - if (out_anchor) *out_anchor = ws_ui_prepass[i].group.anchor; - ws_auto_ui_candidate_count++; - return 1; - } - return 0; -} - -static uint32_t ws_auto_ui_group_key_words(const uint32_t *words, - uint32_t op, - int32_t y, int32_t h) { - uint32_t clut = words[2] >> 16; - uint32_t tpage = 0; - if (op >= 0x20u && op <= 0x3Fu && (op & 0x04u)) { - int tp_index = (op & 0x10u) ? 5 : 4; - tpage = (words[tp_index] >> 16) & 0x1FFu; - } - int32_t centre_y = y + h / 2; - uint32_t band = (uint32_t)(centre_y < 0 ? 0 : centre_y / 24) & 0x1Fu; - uint32_t family = op < 0x60u ? 1u : 2u; - uint32_t key = (clut * 2654435761u) ^ (tpage << 11) ^ - (band << 3) ^ family; - return key ? key : 1u; -} - -static int ws_axis_aligned_quad(const int32_t vx[4], const int32_t vy[4]) { - int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - unsigned corners = 0; - for (int i = 0; i < 4; i++) { - if (vx[i] == min_x && vy[i] == min_y) corners |= 1u; - else if (vx[i] == max_x && vy[i] == min_y) corners |= 2u; - else if (vx[i] == min_x && vy[i] == max_y) corners |= 4u; - else if (vx[i] == max_x && vy[i] == max_y) corners |= 8u; - else return 0; - } - return corners == 15u; -} - -static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { - if (psx_ws_prim_is_tagged() || !ws_axis_aligned_quad(vx, vy)) - return 0; - - int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || - (width > W / 2 && height > H / 4)) - return 0; - - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - for (int i = 0; i < 4; i++) vx[i] = ws_scale_about(vx[i], anchor); - ws_auto_ui_transform_count++; - return 1; -} - -static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { - if (!x || !w || *w <= 0 || psx_ws_prim_is_tagged()) - return 0; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) || - (*w > W / 2 && h > H / 4)) - return 0; - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - *x = ws_scale_about(*x, anchor); - *w = (int)ws_scale_len(*w); - ws_auto_ui_transform_count++; - return 1; -} - -static int ws_hud_scale_percent = PSX_WS_HUD_SCALE_MAX; -void gpu_ws_set_hud_scale(int percent) { - ws_hud_scale_percent = psx_ws_hud_scale_clamp(percent); -} -int gpu_ws_hud_scale(void) { return ws_hud_scale_percent; } - -/* The user's HUD size for an untagged screen-space SPRT. 0 leaves the - * stock horizontal squash to the caller. */ -static int ws_hud_user_scale(int32_t *x, int32_t *y, int *w, int *h) { - int32_t scaled_w = *w, scaled_h = *h; - if (ws_hud_scale_percent >= PSX_WS_HUD_SCALE_MAX || - !psx_ws_hud_scale_rect_strip(x, y, &scaled_w, &scaled_h, ws_disp_w(), ws_disp_h(), - ws_xnum, ws_xden, ws_hud_scale_percent, ws_hud_strip_wide())) - return 0; - *w = (int)scaled_w; - *h = (int)scaled_h; - return 1; -} - -static PsxWsHudWidgets ws_hud; -static uint32_t ws_hud_table_pointer; -static int ws_hud_strip; /* how wide the game's text printer makes a line, 0 when the game has not said */ - -void gpu_ws_set_hud_text_strip(int wide) { ws_hud_strip = wide > 0 ? wide : 0; } - -/* A line of text is printed as a list of its own, outside the HUD layer: only there is a rectangle of that width a line. */ -static int32_t ws_hud_strip_wide(void) { return ws_hud.in_hud_layer ? 0 : ws_hud_strip; } - -void gpu_ws_set_hud_widgets(uint32_t table_pointer, int layers, const int32_t *boxes, int count) { - ws_hud_table_pointer = table_pointer; - memset(&ws_hud, 0, sizeof(ws_hud)); - ws_hud.layers = layers; - for (int i = 0; boxes && i < count && i < PSX_WS_HUD_WIDGET_MAX; ++i) { - const int32_t *box = boxes + 6 * i; - const PsxWsHudWidget widget = { box[0], box[1], box[2], box[3], box[4], box[5] }; - ws_hud.widgets[ws_hud.count++] = widget; - } -} - -/* The widget of an untagged widescreen primitive with these bounds. See psx_ws_hud_widgets_take. */ -static const PsxWsHudWidget *ws_hud_widget(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int rectangle) { - if (!ws_active() || psx_ws_prim_is_tagged()) return NULL; - return psx_ws_hud_widgets_take(&ws_hud, x0, y0, x1, y1, rectangle); -} - -/* A widget's rectangle in place. 0 for a rectangle of no widget. */ -static int ws_hud_widget_rect(int32_t *x, int32_t *y, int32_t *w, int32_t *h) { - const PsxWsHudWidget *widget = ws_hud_widget(*x, *y, *x + *w, *y + *h, 1); - if (!widget) return 0; - psx_ws_hud_widget_rect(widget, x, y, w, h, ws_xnum, ws_xden, ws_hud_scale_percent); - return 1; -} - -/* A widget's axis-aligned quad, every corner through the same mapping. A quad joins in a HUD layer only. */ -static void ws_hud_widget_quad(int32_t vx[4], int32_t vy[4]) { - int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; - if (!ws_hud.in_hud_layer || !ws_axis_aligned_quad(vx, vy)) return; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - const PsxWsHudWidget *widget = ws_hud_widget(min_x, min_y, max_x, max_y, 0); - if (!widget) return; - for (int i = 0; i < 4; i++) - psx_ws_hud_widget_corner(widget, &vx[i], &vy[i], ws_xnum, ws_xden, ws_hud_scale_percent); -} - -/* Shared transform for fixed-size textured sprites (8x8 / 16x16 / 1x1 dot): - * squash *x0 in place (around the tagged anchor, else the HUD pivot) and - * return the squashed draw width, or 0 = no change. *out_h is the draw - * height when the user's HUD size changed it, else 0. */ -static int ws_sprt_fixed_transform(int32_t *x0, int32_t *y0, int w, int *out_h) { - *out_h = 0; - if (ws_menu_frame()) return ws_menu_rect(x0, w); - if (!ws_active()) return 0; - int32_t ax; - if (ws_tagged_anchor(&ax)) { - *x0 = ws_scale_about(*x0, ax); - return (int)ws_scale_len(w); - } - int32_t widget_w = w, widget_h = w; - if (ws_hud_widget_rect(x0, y0, &widget_w, &widget_h)) { - *out_h = (int)widget_h; - return (int)widget_w; - } - int auto_w = w; - if (ws_auto_ui_transform_rect(x0, *y0, &auto_w, w)) - return auto_w; - if (ws_hud_sprt) { - int scaled_w = w, scaled_h = w; - if (ws_hud_user_scale(x0, y0, &scaled_w, &scaled_h)) { - *out_h = scaled_h; - return scaled_w; - } - *x0 = ws_scale_about(*x0, ws_hud_pivot(*x0, w)); - return (int)ws_scale_len(w); - } - return 0; -} - -/* ---- Native-wide HUD corner re-anchoring ([widescreen] nw_hud_corners) ------ - * In native-wide the whole frame is composited into a wider surface centred by - * ws_nw_offset() per side (the reveal). Screen-space 2D HUD (drawn with fixed - * rect/sprite GP0 commands, never through the GTE — a 3D title's world is all - * polygons) therefore lands inset from the true wide edges by exactly the - * reveal. This pushes an outer-third HUD primitive the rest of the way to its - * wide corner with an additive thirds shift: left third −offset, right third - * +offset, middle unchanged (matches the squash-path ws_hud_pivot geometry, but - * as a translate since native-wide does not squash). Composite pieces in one - * zone share a shift and stay aligned. A configured command-source range lets - * pure-2D games opt in only their HUD packet arena, leaving world primitives - * untouched. Identity unless native-wide is engaged AND the game opts in, so - * 4:3 and non-opted titles are byte-identical. - * Returns the signed x delta to add to the prim's x before draw_offset. */ -static int ws_nw_hud_corners = 0; -static int ws_nw_hud_tag_rects = 0; /* rects shift even when tagged (A/B) */ -static uint32_t ws_nw_left_hud_packet_lo = 0; -static uint32_t ws_nw_left_hud_packet_hi = 0; -void gpu_ws_set_nw_hud_corners(int on) { ws_nw_hud_corners = on ? 1 : 0; } -void gpu_ws_set_nw_hud_tag_rects(int on) { ws_nw_hud_tag_rects = on ? 1 : 0; } -void gpu_ws_set_nw_left_hud_packet_range(uint32_t lo, uint32_t hi) { - ws_nw_left_hud_packet_lo = lo & 0x1FFFFFFFu; - ws_nw_left_hud_packet_hi = hi & 0x1FFFFFFFu; -} -static int ws_nw_left_hud_packet(void) { - if (!ws_native_wide_active() || gp0_cmd_source_addr == 0xFFFFFFFFu || - ws_nw_left_hud_packet_hi <= ws_nw_left_hud_packet_lo) - return 0; - uint32_t a = gp0_cmd_source_addr & 0x1FFFFFFFu; - int matched = a >= ws_nw_left_hud_packet_lo && a < ws_nw_left_hud_packet_hi; - return matched; -} -static int32_t ws_nw_hud_shift(int32_t x, int32_t w) { - if (!ws_native_wide_active()) return 0; - int32_t off = ws_nw_offset(); - if (off <= 0) return 0; - if (!ws_nw_left_hud_packet() && !ws_nw_hud_corners) return 0; - /* Sprite-tag titles (anchor configured): HUD ≡ UNTAGGED rect-family prims - * — the same discriminator the squash path's hud_sprt_squash used. Tagged - * prims are character billboards positioned from their GTE anchor; they - * must never re-anchor. (Poly/line sites are excluded wholesale for tag - * titles in ws_nw_hud_shift_vertices — their polys are the world.) - * ws_nw_hud_tag_rects (TCP ws_hud_mode) lifts the exclusion for rects, - * for live A/B: some HUD composites (Tomba's AP counter) render through - * the tagged sprite funnel and stay inset without it. */ - if (ws_anchor_addr && !ws_nw_hud_tag_rects && psx_ws_prim_is_tagged()) return 0; - int32_t W = ws_disp_w(); - int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ - if (3 * cx < 2 * W) return -off; /* left third -> pull to left edge */ - if (3 * cx > 4 * W) return off; /* right third -> push to right edge */ - return 0; /* middle third -> stay centred */ -} - -/* Re-anchor every X coordinate of a HUD polygon as one rigid composite. The - * command-source filter above is what makes this safe for pure-2D titles: only - * the game's dedicated HUD packet arena reaches here, never world polygons. */ -static void ws_nw_hud_shift_vertices(int32_t *vx, int count) { - if (count <= 0) return; - if (ws_menu_frame()) { /* the front end's polygons and lines keep their shape like its text */ - psx_ws_menu_squash_corners(vx, count, ws_xnum, ws_xden); - return; - } - /* Sprite-tag titles: polygon/line prims are the GTE world and the tagged - * character billboards, never HUD — only the rect-family sites (which - * call ws_nw_hud_shift directly, with the untagged filter) re-anchor. */ - if (ws_anchor_addr) return; - int32_t lo = vx[0], hi = vx[0]; - for (int i = 1; i < count; i++) { - if (vx[i] < lo) lo = vx[i]; - if (vx[i] > hi) hi = vx[i]; - } - int32_t d = ws_nw_hud_shift(lo, hi - lo); - if (d) for (int i = 0; i < count; i++) vx[i] += d; -} - -/* ---- Native-wide full-frame 2D backdrop stretch ([widescreen] nw_backdrop) --- - * A screen-space background plane (sky gradient / backdrop image) is drawn as an - * axis-aligned quad covering the whole 4:3 framebuffer [0,W]x[0,H]. It is NOT - * GTE-projected, so native-wide leaves it at its 4:3 span, composited centred = - * black bars in the revealed side margins (the pillarboxed sky). Detect exactly - * that shape — a 4-vertex quad whose corners form a rectangle spanning ~the full - * display width from ~the left edge — and stretch its X vertices about the - * display centre by the wide ratio so it fills the wider frame; the texture/UV - * (or gradient) simply stretches horizontally (invisible on a sky). GTE-drawn - * world quads are perspective-distorted (not axis-aligned) and partial-width, so - * they never match. Identity unless native-wide is engaged AND the game opts in. - * Transforms vx[0..3] IN PLACE (pre-draw_offset); returns 1 if it applied. */ -static int ws_nw_backdrop = 0; -void gpu_ws_set_nw_backdrop(int on) { ws_nw_backdrop = on ? 1 : 0; } -static int ws_nw_flat_backdrop = 0; -void gpu_ws_set_nw_flat_backdrop(int on) { ws_nw_flat_backdrop = on ? 1 : 0; } -int gpu_ws_nw_flat_backdrop_enabled(void) { return ws_nw_flat_backdrop; } -static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { - if (!ws_nw_backdrop || !ws_native_wide_active()) return 0; - int32_t extra = ws_nw_extra(); - if (extra <= 0) return 0; - int32_t W = ws_disp_w(); - const int32_t EDGE = 24; /* slack for "touches the frame edge" */ - int32_t minx = vx[0], maxx = vx[0], miny = vy[0], maxy = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < minx) minx = vx[i]; - if (vx[i] > maxx) maxx = vx[i]; - if (vy[i] < miny) miny = vy[i]; - if (vy[i] > maxy) maxy = vy[i]; - } - /* Must span the full display width and a real vertical extent. */ - if (minx > EDGE || maxx < W - EDGE || (maxy - miny) < 64) return 0; - /* Axis-aligned: every vertex X sits at either the min or the max edge, and - * every Y at the top or bottom edge (a true screen-space rectangle). */ - for (int i = 0; i < 4; i++) { - int xe = (vx[i] - minx <= EDGE) || (maxx - vx[i] <= EDGE); - int ye = (vy[i] - miny <= EDGE) || (maxy - vy[i] <= EDGE); - if (!xe || !ye) return 0; - } - /* Stretch X about the display centre by (W+extra)/W so [0,W] -> [-off, W+off], - * which the wide compositor (+off) maps onto the full [0, W+extra] surface. */ - int32_t cx = W / 2; - for (int i = 0; i < 4; i++) { - int32_t d = vx[i] - cx; - vx[i] = cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W; - } - return 1; -} - -/* Polyline state */ -static uint16_t polyline_color; /* mono polyline: current color */ -static int32_t polyline_prev_x, polyline_prev_y; /* previous vertex */ -static uint16_t polyline_prev_c; /* shaded polyline: previous color */ -static int polyline_semi_trans; /* semi-transparency flag from command word */ -static int polyline_has_prev; /* have we seen at least one vertex? */ - -/* VRAM write transfer state (CPU→VRAM, command 0xA0) */ -static uint16_t vram_write_x, vram_write_y; /* start coords */ -static uint16_t vram_write_w, vram_write_h; /* dimensions */ -static uint16_t vram_write_col, vram_write_row; /* current offset */ -static uint32_t vram_write_remaining; /* words remaining */ -/* Stage one complete GP0(A0) transfer so renderer backends receive one bulk - * rectangle instead of hundreds of thousands of single-pixel callbacks. The - * CPU-visible transfer remains ordered because GP0 accepts no next command - * until this payload is complete. Maximum PS1 transfer = full VRAM (1 MiB). */ -static uint16_t vram_write_pixels[1024 * 512]; -static uint16_t ws_menu_clut_x = 0, ws_menu_clut_y = 480; /* the backdrop's palette, as its rectangles last named it */ - -/* Depth24 CPU→VRAM upload span (halfwords, exclusive end). See - * gpu_depth24_rgb_limit — declared early so gpu_reset_state can clear it. */ -static uint32_t s_d24_upload_x1 = 0; -static int s_d24_present_hold = 0; /* vblanks to skip Swap after GP1(07h) */ -static uint32_t s_d24_prev_disp_h = 0; /* last GP1(07h) band height */ -static void depth24_note_upload(uint32_t x, uint32_t w); - -/* The wide front end's logo and paint are in the renderer's video memory alone, the one the game reads and a save state holds. */ -static uint16_t ws_menu_kept[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE], ws_menu_under[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; -static uint16_t ws_menu_painted[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS], ws_menu_uploaded[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_WORDS]; -static int ws_menu_fresh; /* the CPU copy at (320,0) is a whole picture with the logo, and no state was loaded since it came */ -static int ws_menu_lent; /* the renderer holds the kept logo at (512,0) */ -static int ws_menu_rows; /* and the painted rows in the picture */ -static int ws_menu_due; /* the frame has drawn nothing yet: its first primitive says whether it is the front end's */ - -static const uint16_t *ws_menu_palette(void) { return &vram[ws_menu_clut_y * 1024u + ws_menu_clut_x]; } - -/* Gives a rectangle of the renderer's back word by word: what is still as it was put there becomes what it was before. */ -static void ws_menu_return(int x, int y, int w, int h, const uint16_t *put, const uint16_t *before) { - static uint16_t now[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; - gr_vram_transfer_out(x, y, w, h, now); - if (psx_ws_menu_return(now, w, w, h, put, before)) gr_vram_transfer_in(x, y, w, h, now); -} - -/* The front end is left, or the game is about to read or write there: it finds its own words. */ -static void ws_menu_give_back(void) { - if (ws_menu_lent) ws_menu_return(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept, ws_menu_under); - if (ws_menu_rows) ws_menu_return(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted, ws_menu_uploaded); - ws_menu_lent = ws_menu_rows = ws_menu_logo = 0; -} - -/* The game is about to read or write this rectangle of video memory, in words. What it writes there the CPU copy does not have. */ -static void ws_menu_yield(int x, int y, int w, int h, int written) { - if (!psx_ws_menu_reaches(x, y, w, h)) return; - ws_menu_give_back(); - if (written) ws_menu_fresh = 0; -} - -/* The front end's picture is at (320,0): its logo goes to (512,0) and is painted over in the picture, both in the renderer's video memory. */ -static void ws_menu_take_picture(void) { - static uint8_t mask[PSX_WS_MENU_LOGO_ROWS * PSX_WS_MENU_LOGO_WIDE]; - static uint16_t picture[PSX_WS_MENU_HIGH * PSX_WS_MENU_WORDS]; - const uint16_t *palette = ws_menu_palette(); - if (!ws_menu_fresh || !ws_menu_wanted()) { - ws_menu_give_back(); - return; - } - if (!ws_menu_lent) gr_vram_transfer_out(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_under); - for (int row = 0; row < PSX_WS_MENU_HIGH; ++row) memcpy(&picture[row * PSX_WS_MENU_WORDS], &vram[row * 1024 + PSX_WS_MENU_X], PSX_WS_MENU_WORDS * sizeof(uint16_t)); - memcpy(ws_menu_uploaded, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_uploaded)); - psx_ws_menu_logo_mask(picture, PSX_WS_MENU_WORDS, palette, mask); - psx_ws_menu_keep_logo(picture, PSX_WS_MENU_WORDS, palette, mask, ws_menu_kept); - gr_vram_transfer_in(PSX_WS_MENU_KEPT_X, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, ws_menu_kept); - psx_ws_menu_paint(picture, PSX_WS_MENU_WORDS, mask); - memcpy(ws_menu_painted, &picture[PSX_WS_MENU_LOGO_TOP * PSX_WS_MENU_WORDS], sizeof(ws_menu_painted)); - gr_vram_transfer_in(PSX_WS_MENU_X, PSX_WS_MENU_LOGO_TOP, PSX_WS_MENU_WORDS, PSX_WS_MENU_LOGO_ROWS, ws_menu_painted); - ws_menu_logo = ws_menu_lent = ws_menu_rows = 1; -} - -static void gp0_commit_cpu_to_vram(void) { - for (uint32_t row = 0; row < vram_write_h; row++) - for (uint32_t col = 0; col < vram_write_w; col++) - vram_write_pixels[row * vram_write_w + col] = - vram[((vram_write_y + row) & 511u) * 1024u + - ((vram_write_x + col) & 1023u)]; - gr_vram_transfer_in(vram_write_x, vram_write_y, - vram_write_w, vram_write_h, vram_write_pixels); - depth24_note_upload(vram_write_x, vram_write_w); - if (psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) { - ws_menu_rows = 0; /* the renderer's rows are the game's again */ - ws_menu_fresh = psx_ws_menu_has_logo(&vram[PSX_WS_MENU_X], 1024, ws_menu_palette()); - ws_menu_take_picture(); - } - gp0_state = GP0_IDLE; - vram_write_remaining = 0; - text_xlate_vram_upload(vram_write_x, vram_write_y, - vram_write_w, vram_write_h); -} - -/* VRAM read transfer state (VRAM→CPU, command 0xC0) */ -static int vram_read_active; -static uint16_t vram_read_x, vram_read_y; -static uint16_t vram_read_w, vram_read_h; -static uint16_t vram_read_col, vram_read_row; - -/* ---- GPU internal state ---- */ - -/* Texture page / draw mode (set by GP0(E1h), reflected in GPUSTAT bits 0-10) */ -static uint32_t texpage_x; /* bits 0-3: texture page X base (N*64) */ -static uint32_t texpage_y; /* bit 4: texture page Y base (0 or 256) */ -static uint32_t semi_transparency; /* bits 5-6 */ -static uint32_t texpage_colors; /* bits 7-8: 4bit/8bit/15bit */ -static uint32_t dither_enabled; /* bit 9 */ -static uint32_t draw_to_display; /* bit 10: drawing to display area allowed */ -static uint32_t set_mask_bit; /* bit 11 */ -static uint32_t check_mask_bit; /* bit 12 */ -static uint32_t interlace_field; /* bit 13 */ -static uint32_t reverse_flag; /* bit 14 */ -static uint32_t texture_disable; /* bit 15 */ - -/* Draw area (set by GP0(E3h)/GP0(E4h)) */ -static uint32_t draw_area_left, draw_area_top; -static uint32_t draw_area_right, draw_area_bottom; - -/* Draw offset (set by GP0(E5h)) */ -static int32_t draw_offset_x, draw_offset_y; -/* Instrumentation: per-vblank range/count of GP0(E5) draw-offset-Y sets. If a - * single frame sets offsets in BOTH the top (y<128) and bottom (y>=128) buffer - * bands, the game is drawing different parts of the scene into different display - * buffers in the same frame (candidate root of a character-in-one-buffer - * strobe). Snapshotted each vblank into the *_last copies, exposed via debug. */ -int32_t g_doff_min_this = 0x7fffffff, g_doff_max_this = -0x7fffffff; -uint32_t g_doff_cnt_this = 0; -int32_t g_doff_min_last = 0, g_doff_max_last = 0; -uint32_t g_doff_cnt_last = 0; - -/* Texture window raw value (set by GP0(E2h), readback via GP1(10h)) */ -static uint32_t texture_window_value; - -/* Display mode (set by GP1(08h), reflected in GPUSTAT bits 16-22) */ -static uint32_t hres2; /* bit 16: horizontal resolution 2 (368 mode) */ -static uint32_t hres1; /* bits 17-18: horizontal resolution 1 */ -static uint32_t vres; /* bit 19: vertical resolution (0=240, 1=480) */ -static uint32_t video_mode; /* bit 20: 0=NTSC, 1=PAL */ -static uint32_t display_depth; /* bit 21: 0=15bit, 1=24bit */ -static uint32_t vertical_interlace; /* bit 22 */ - -/* Display enable (set by GP1(03h)) */ -static uint32_t display_disabled; /* bit 23: 0=enabled, 1=disabled */ - -/* IRQ1 (set by GP0(1Fh), acked by GP1(02h)) */ -static uint32_t irq1_flag; /* bit 24 */ - -/* DMA direction (set by GP1(04h)) */ -static uint32_t dma_direction; /* bits 29-30 in GPUSTAT */ - -/* LCF (even/odd line in interlace, toggles per vblank) */ -static uint32_t lcf; /* bit 31 */ - -/* I_STAT — defined in memory.c; declared early so gpu_read_gpustat can - * raise the VBLANK bit when a synthetic vblank fires from a tight - * BIOS-shell GPUSTAT poll loop, without going through the - * vblank-callback path (which would call SDL_Delay/Sleep). */ -extern uint32_t i_stat; -/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */ -extern void psx_irq_raise(uint32_t bit, uint32_t detail); - -/* Display area start (GP1(05h)) */ -static uint32_t display_area_x; -static uint32_t display_area_y; - -/* ----- Native-wide compositor driving (see runtime/src/gpu_sw_renderer.c) ---- - * The renderer keeps a separate wide surface per framebuffer; we tell it which - * back buffer each draw targets and present from the displayed buffer's surface. - * A "framebuffer base" is a VRAM x-origin the display has scanned out from — we - * learn that set from GP1(05h) so the mirror only engages for real display - * buffers (not offscreen texture builds). Generic: no per-game constants. */ -#define WS_FB_BASES 4 -static uint32_t ws_fb_base[WS_FB_BASES]; -static int ws_fb_n = 0; -static void ws_note_display_base(uint32_t bx) { - for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return; - if (ws_fb_n < WS_FB_BASES) { ws_fb_base[ws_fb_n++] = bx; return; } - for (int i = 1; i < WS_FB_BASES; i++) ws_fb_base[i - 1] = ws_fb_base[i]; - ws_fb_base[WS_FB_BASES - 1] = bx; -} -static int ws_is_fb_base(uint32_t bx) { - for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return 1; - return 0; -} -/* Point the renderer's wide mirror at the current back buffer (draw_area_left) - * when native-wide is active and that buffer is a known display buffer; else - * disable mirroring for this draw. Called when the draw env changes. */ -static void ws_nw_sync_target(void) { - const int native_wide = ws_native_wide_active(); - /* The corrected presentation mirror is also required at authentic 4:3. - * main.cpp's game-entry gate deliberately considers that mode engaged even - * though this file's ws_engaged() means only "a non-identity wide mode". - * Gating visual-only here on ws_mode therefore left 4:3 correction without - * a surface and sent OpenGL into its CPU fallback at the wrong SSAA scale. */ - const int visual_only = ws_geometry_correction; - if (!native_wide && !visual_only) { - gr_wide_disable_target(); - return; - } - /* Geometry correction deliberately reuses the renderer's independent wide - * surface even in classic squash mode. With zero side offset it becomes a - * same-width presentation mirror: canonical VRAM remains integer-exact, - * while the mirror may consume subpixel vertices and fractional yaw. */ - gr_wide_configure(native_wide ? (int)ws_disp_w() + ws_nw_extra() - : (int)ws_disp_w(), - native_wide ? ws_nw_offset() : 0); - uint32_t base = draw_area_left; - if (ws_is_fb_base(base)) gr_wide_set_target((int)base); - else gr_wide_disable_target(); -} - -extern void gte_geometry_correction_set(int enabled); -extern void gte_precision_tracking_set(int enabled); - -static void geometry_diag_reset(void) { - memset(&s_geometry_diag_cumulative, 0, - sizeof(s_geometry_diag_cumulative)); - memset(&s_geometry_diag_live_current, 0, - sizeof(s_geometry_diag_live_current)); - memset(&s_geometry_diag_live_latest, 0, - sizeof(s_geometry_diag_live_latest)); - s_geometry_diag_presentation_sequence = 0; - s_geometry_diag_corrected_presentations = 0; - s_geometry_diag_latest_live_presentation = 0; - s_geometry_diag_latest_live_valid = 0; - s_geometry_diag_live_current_touched = 0; - gte_precision_diagnostics_reset(); -} - -static int geometry_diag_display_active(void) { - GpuDisplayInfo display; - gpu_get_display_info(&display); - return !display.disabled && display.width != 0u && display.height != 0u; -} - -/* Match main.cpp's corrected-present eligibility using state available at GP0 - * decode time. A known framebuffer target is additionally required because an - * offscreen texture draw cannot reach the independent presentation surface. */ -static int geometry_diag_presentation_live(void) { - return geometry_diag_display_active() && - ws_geometry_correction && - !gpu_ws_present_native_43() && !(display_depth & 1u) && - gr_wide_supported() && ws_is_fb_base(draw_area_left); -} - -static void geometry_diag_finish_presentation(void) { - const int live = geometry_diag_display_active() && - (s_geometry_diag_live_current_touched || - geometry_diag_presentation_live()); - ++s_geometry_diag_presentation_sequence; - if (live) { - s_geometry_diag_live_latest = s_geometry_diag_live_current; - ++s_geometry_diag_corrected_presentations; - s_geometry_diag_latest_live_presentation = - s_geometry_diag_presentation_sequence; - s_geometry_diag_latest_live_valid = 1; - } - memset(&s_geometry_diag_live_current, 0, - sizeof(s_geometry_diag_live_current)); - s_geometry_diag_live_current_touched = 0; -} - -void gpu_geometry_correction_set(int enabled) { - ws_geometry_correction = enabled ? 1 : 0; - ws_geometry_world_triangles = 0; - ws_geometry_precise_triangles = 0; - geometry_diag_reset(); - gte_geometry_correction_set(ws_geometry_correction); - /* Exact committed packet-address provenance is preferred over the fallback - * packed-SXY cache whenever the game stores GTE results into its OT. */ - gte_precision_tracking_set(ws_geometry_correction || - s_texture_correction_enabled); - gr_set_geometry_correction(ws_geometry_correction); - gpu_geometry_camera_yaw_residual_set( - s_geometry_camera_yaw_residual); - ws_nw_sync_target(); -} - -void gpu_geometry_correction_stats(uint64_t *world_triangles, - uint64_t *precise_triangles) { - if (world_triangles) *world_triangles = ws_geometry_world_triangles; - if (precise_triangles) *precise_triangles = ws_geometry_precise_triangles; -} - -void gpu_geometry_correction_stats_detailed( - GpuGeometryCorrectionStats *out, uint32_t out_size) { - if (!out || out_size == 0u) return; - GtePrecisionDiagnostics provenance = { 0 }; - GpuGeometryCorrectionStats snapshot; - gte_precision_diagnostics(&provenance); - memset(&snapshot, 0, sizeof(snapshot)); - snapshot.version = GPU_GEOMETRY_DIAGNOSTICS_VERSION; - snapshot.size = (uint32_t)sizeof(snapshot); - snapshot.presentation_sequence = s_geometry_diag_presentation_sequence; - snapshot.corrected_presentations = s_geometry_diag_corrected_presentations; - snapshot.latest_live_presentation = s_geometry_diag_latest_live_presentation; - snapshot.latest_live_valid = (uint32_t)s_geometry_diag_latest_live_valid; - snapshot.provenance_store_collisions = provenance.store_collisions; - snapshot.provenance_store_evictions = provenance.store_evictions; - snapshot.provenance_lookup_collisions = provenance.lookup_collisions; - snapshot.cumulative = s_geometry_diag_cumulative; - snapshot.latest_live = s_geometry_diag_live_latest; - snapshot.provenance_store_uncommitted_rejections = - provenance.store_uncommitted_rejections; - snapshot.provenance_registered_store_attempts = - provenance.registered_store_attempts; - snapshot.provenance_registered_store_accepts = - provenance.registered_store_accepts; - snapshot.provenance_registered_store_packed_rejections = - provenance.registered_store_packed_rejections; - snapshot.provenance_copy_load_attempts = - provenance.copy_load_attempts; - snapshot.provenance_copy_load_accepts = - provenance.copy_load_accepts; - snapshot.provenance_copy_store_attempts = - provenance.copy_store_attempts; - snapshot.provenance_copy_store_accepts = - provenance.copy_store_accepts; - snapshot.provenance_copy_store_packed_rejections = - provenance.copy_store_packed_rejections; - { - const uint32_t copy_size = out_size < (uint32_t)sizeof(snapshot) - ? out_size : (uint32_t)sizeof(snapshot); - memcpy(out, &snapshot, copy_size); - } -} - -int gpu_geometry_correction_enabled(void) { - return ws_geometry_correction; -} - -static void geometry_camera_presentation_update(void) { - double center = (double)ws_disp_w() * 0.5; - /* Keep Y relative to the drawing band. The GL backend adds its current - * GP0(E5) draw offset after flushing the prior batch, so a framebuffer - * switch after VBlank cannot leave the next frame on the old horizon. */ - const double center_y = - (double)s_geometry_camera_projection_center_y; - double focal = center; - if (ws_mode == 1 && ws_xden != 0) - focal = focal * (double)ws_xnum / (double)ws_xden; - gr_set_presentation_yaw(ws_geometry_correction && - isfinite(s_geometry_camera_yaw_residual) - ? s_geometry_camera_yaw_residual : 0.0, - 256.0, center, center_y, focal); -} - -void gpu_geometry_camera_yaw_residual_set(double yaw_units) { - s_geometry_camera_yaw_residual = yaw_units; - geometry_camera_presentation_update(); -} - -void gpu_geometry_camera_projection_center_y_set(double center_y) { - int next = 120; - if (isfinite(center_y) && center_y >= 24.0 && center_y <= 216.0) - next = (int)floor(center_y + 0.5); - if (next == s_geometry_camera_projection_center_y) return; - - s_geometry_camera_projection_center_y = next; - /* Reissuing the current yaw lands renderer batches under the old horizon - * before the backend adopts the new one. This call is also intentional - * while correction is disabled: it stores the requested shared center but - * supplies an identity yaw to the presentation backend. */ - geometry_camera_presentation_update(); -} - -int gpu_geometry_camera_projection_center_y_get(void) { - return s_geometry_camera_projection_center_y; -} - -static void ws_clear_all_reveal_margins(void) { - for (int i = 0; i < ws_fb_n; i++) - gr_wide_clear_margins((int)ws_fb_base[i], 0, 512, 0, 3); -} - -/* Stage-init already clears both synthetic margins once. Do not keep clearing - * a guessed finite-map side here: MMX6's authored layers enter the reveal at - * different times, so the side guess produced a moving black trim over valid - * stage art. A stale reveal tile is safer than deleting submitted content. */ -void gpu_ws_begin_linked_list(void) { - gp0_ot_rank = 0xFFFFu; - psx_ws_hud_widgets_list(&ws_hud, ws_hud.count && ws_active() ? psx_read_word(ws_hud_table_pointer) : 0u); -} - -void gpu_set_gp0_linked_list_node(uint32_t addr, uint32_t word_count) { - (void)addr; - psx_ws_frame_kinds_list(&ws_frame_kinds, 1); - if (word_count == 0) { - gp0_ot_rank = gp0_ot_rank == 0xFFFFu ? 0u - : (uint16_t)(gp0_ot_rank + 1u); - psx_ws_hud_widgets_node(&ws_hud, addr); - } -} - -void gpu_ws_end_linked_list(void) { - psx_ws_frame_kinds_list(&ws_frame_kinds, 0); - gp0_ot_rank = 0xFFFFu; - psx_ws_hud_widgets_list_end(&ws_hud); -} - - -/* Horizontal display range (GP1(06h)) */ -static uint32_t h_display_x1; -static uint32_t h_display_x2; - -/* Vertical display range (GP1(07h)) */ -static uint32_t v_display_y1; -static uint32_t v_display_y2; - -/* GPUREAD latch (GP1(10h) get-info result, or VRAM read data) */ -static uint32_t gpuread_latch; - -/* C0 (VRAM→CPU) capture slot — forward declaration for gpu_read_gpuread */ -#define C0_HISTORY_CAP_FWD 32 -static struct C0HistEntry { - uint16_t x, y, w, h; - uint32_t func_addr, sp_val, s1_val; - uint32_t first_words[4]; - int read_count; -} c0_history_fwd[C0_HISTORY_CAP_FWD]; -static int c0_history_count_fwd = 0; -static int c0_capture_slot_fwd = -1; - -/* Vblank presentation callback */ -static gpu_vblank_cb vblank_callback; - -/* Shaded quad vertex capture (Phase 4.5 debug) — forward declarations - * so gpu_vblank_tick can reference sq_cap_armed. */ -#define SQ_CAP_MAX 32 -static GpuSqCapEntry sq_cap_buf[SQ_CAP_MAX]; -static int sq_cap_count; -static int sq_cap_armed; - -/* GPUSTAT poll counter: in v4, recompiled code runs as native C without - * per-instruction stepping. The BIOS contains tight VSYNC wait loops that - * poll GPUSTAT bit 31 (LCF) waiting for it to toggle. LCF only changes - * via gpu_vblank_tick(), which normally fires from psx_check_interrupts() - * at dispatch boundaries. A polling loop within a single recompiled - * function would spin forever. - * - * Fix: count GPUSTAT reads and trigger a vblank when the BIOS has polled - * enough times, approximating the real hardware timing where the field - * flips every ~33,868 GPU clocks (one NTSC frame). */ -#define GPUSTAT_POLL_VBLANK_THRESHOLD 1000 -static uint32_t gpustat_poll_count; -uint64_t g_pollhack_vblank_count = 0; /* instrumentation: poll-fallback VBlank IRQ count */ - -/* ---- Initialization ---- */ - -static void gpu_reset_state(int clear_vram) { - if (clear_vram) { - memset(vram, 0, sizeof(vram)); - } - gr_init(vram); - - /* Reset GP0 state machine */ - gp0_state = GP0_IDLE; - gp0_words_collected = 0; - gp0_words_needed = 0; - vram_write_remaining = 0; - vram_read_active = 0; - - /* Reset all state to power-on defaults */ - texpage_x = 0; - texpage_y = 0; - semi_transparency = 0; - texpage_colors = 0; - dither_enabled = 0; - draw_to_display = 0; - set_mask_bit = 0; - check_mask_bit = 0; - interlace_field = 0; - reverse_flag = 0; - texture_disable = 0; - - draw_area_left = 0; - draw_area_top = 0; - draw_area_right = 0; - draw_area_bottom = 0; - draw_offset_x = 0; - draw_offset_y = 0; - texture_window_value = 0; - - hres2 = 0; - hres1 = 0; - vres = 0; - video_mode = 0; - display_depth = 0; - vertical_interlace = 0; - - display_disabled = 1; /* display is OFF after reset */ - irq1_flag = 0; - dma_direction = 0; - lcf = 0; - - display_area_x = 0; - display_area_y = 0; - h_display_x1 = 0x200; - h_display_x2 = 0xC00; - v_display_y1 = 0x010; - v_display_y2 = 0x100; - - gpuread_latch = 0; - gpustat_poll_count = 0; - s_ws_fmv_frame_cache = 0xFFFFFFFFu; - s_ws_fmv_cached = 0; - s_d24_upload_x1 = 0; - s_d24_present_hold = 0; - s_d24_prev_disp_h = 0; - - /* gr_init can retain backend-side draw state across GP1(00h). Keep the - * restored canonical clip and the framebuffer-relative yaw horizon in - * lockstep with the power-on GP0 defaults. */ - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - gr_set_draw_offset(draw_offset_x, draw_offset_y); - s_geometry_camera_yaw_residual = 0.0; - s_geometry_camera_projection_center_y = 120; - geometry_camera_presentation_update(); -} - -void gpu_init(void) { - gpu_reset_state(1); -} - -/* ---- GPUSTAT read (0x1F801814) ---- */ - -uint32_t gpu_read_gpustat(void) { - /* Advance vblank when polled enough times from within a single function. - * This handles BIOS VSYNC wait loops that poll LCF in tight loops. - * - * IMPORTANT: only update emulation state here (LCF + I_STAT). Do NOT - * fire the vblank callback — that calls sdl_vblank_present, which - * runs SDL_RenderPresent and SDL_Delay (Sleep). Entering Sleep from - * an MMIO-read code path means recompiled MIPS code spends real wall - * time inside Sleep — wrong context, hard to reason about, and - * accumulates host stack frames inside the recompiled call tree. - * The callback fires from the proper VBLANK trigger in - * psx_check_interrupts (cycle-paced). */ - gpustat_poll_count++; - if (gpustat_poll_count >= GPUSTAT_POLL_VBLANK_THRESHOLD) { - gpustat_poll_count = 0; - /* The recompiled BIOS + game carry per-block interrupt checks - * (psx_check_interrupts_at) and per-block cycle charging, so tight - * LCF/VSync poll loops DO advance guest cycles and let the ONE - * cycle-paced VBlank authority (interrupts.c) fire on schedule. The old - * "spin forever" premise that justified raising VBlank+LCF from a raw - * GPUSTAT read-count is stale — and it injected ~38 fake VBlanks/s - * (measured on Crash Bash), delivering ~96/s to the game instead of 60, - * over-advancing the game's VSync frame counter and jittering animation - * (character strobe). Firing is now OFF by default; PSX_POLLHACK_VBLANK=1 - * restores the legacy behavior for A/B. */ - static int poll_fire = -1; - if (poll_fire < 0) { - const char* e = getenv("PSX_POLLHACK_VBLANK"); - poll_fire = (e && e[0] == '1') ? 1 : 0; - } - if (poll_fire) { - g_pollhack_vblank_count++; /* count only when actually firing */ - lcf ^= 1; - psx_irq_raise(0, 0); /* IRQ_VBLANK (GPUSTAT-poll fallback path) */ - /* DEQUEUE: VBlank fired via the GPUSTAT-poll fallback path (distinct - * from the cycle-paced VBlank in psx_check_interrupts). */ - event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, 0xFFFFFFFFu); - } - } - - uint32_t stat = 0; - - stat |= (texpage_x & 0xF); - stat |= (texpage_y & 1) << 4; - stat |= (semi_transparency & 3) << 5; - stat |= (texpage_colors & 3) << 7; - stat |= (dither_enabled & 1) << 9; - stat |= (draw_to_display & 1) << 10; - stat |= (set_mask_bit & 1) << 11; - stat |= (check_mask_bit & 1) << 12; - stat |= (interlace_field & 1) << 13; - stat |= (reverse_flag & 1) << 14; - stat |= (texture_disable & 1) << 15; - stat |= (hres2 & 1) << 16; - stat |= (hres1 & 3) << 17; - stat |= (vres & 1) << 19; - stat |= (video_mode & 1) << 20; - stat |= (display_depth & 1) << 21; - stat |= (vertical_interlace & 1) << 22; - stat |= (display_disabled & 1) << 23; - stat |= (irq1_flag & 1) << 24; - - /* Bit 25: DMA request — depends on DMA direction. - * Direction 0: always 0 - * Direction 1: FIFO not full (always 1 for now — we process instantly) - * Direction 2: same as bit 28 (ready to receive DMA block) - * Direction 3: same as bit 27 (ready to send VRAM to CPU) - */ - switch (dma_direction) { - case 0: break; /* bit 25 = 0 */ - case 1: stat |= (1u << 25); break; - case 2: stat |= (1u << 25); break; /* mirrors ready-to-receive */ - case 3: stat |= (1u << 25); break; /* mirrors ready-to-send */ - } - - /* Bit 26: ready to receive cmd word — 1 when not busy */ - stat |= (1u << 26); - - /* Bit 27: ready to send VRAM to CPU — 1 when VRAM read is active */ - if (vram_read_active) - stat |= (1u << 27); - - /* Bit 28: ready to receive DMA block — 1 when not busy */ - stat |= (1u << 28); - - /* Bits 29-30: DMA direction */ - stat |= (dma_direction & 3) << 29; - - /* Bit 31: LCF — drawing even/odd lines in interlace mode */ - stat |= (lcf & 1) << 31; - - return stat; -} - -/* ---- GPUREAD (0x1F801810 read) ---- */ - -uint32_t gpu_read_gpuread(void) { - if (!vram_read_active) - return gpuread_latch; - - /* Read two 16-bit pixels from VRAM and pack into one 32-bit word. - * Routed through the renderer facade: under the GL backend the GPU-side - * framebuffer is authoritative and must be synced down before the CPU - * array is read (no-op cost on the software backend). */ - uint32_t value = 0; - for (int i = 0; i < 2; i++) { - uint16_t rx = (vram_read_x + vram_read_col) % 1024; - uint16_t ry = (vram_read_y + vram_read_row) % 512; - value |= (uint32_t)gr_vram_read((int)rx, (int)ry) << (i * 16); - - if (++vram_read_col == vram_read_w) { - vram_read_col = 0; - if (++vram_read_row == vram_read_h) { - /* Transfer complete */ - vram_read_active = 0; - break; - } - } - } - - /* Capture first words for C0 debug */ - if (c0_capture_slot_fwd >= 0 && c0_capture_slot_fwd < C0_HISTORY_CAP_FWD) { - int rc = c0_history_fwd[c0_capture_slot_fwd].read_count++; - if (rc < 4) - c0_history_fwd[c0_capture_slot_fwd].first_words[rc] = value; - if (!vram_read_active) - c0_capture_slot_fwd = -1; /* transfer complete */ - } - - gpuread_latch = value; - return value; -} - -/* ---- GP0 command helpers ---- */ - -/* Convert 24-bit RGB888 to 16-bit RGB555 (PS1 VRAM format) */ -static uint16_t rgb888_to_rgb555(uint32_t color24) { - uint32_t r = (color24 >> 0) & 0xFF; - uint32_t g = (color24 >> 8) & 0xFF; - uint32_t b = (color24 >> 16) & 0xFF; - return (uint16_t)((r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10)); -} - -/* i_stat extern declared earlier in this file (above gpu_read_gpustat). */ - -void gpu_vblank_tick(void) { - lcf ^= 1; - /* GPUSTAT.13 (interlace FIELD): on real hardware this alternates per - * field while GP1(08h) vertical interlace is on, in antiphase with the - * even/odd-lines bit 31 during active display. Modeled here at vblank - * granularity: field = !LCF while interlaced, pinned to the legacy 0 in - * progressive (GP1(08h) clears it) so titles see identical GPUSTAT. - * First consumer - * is OpenBIOS's shell waitVSync, which polls for the alternating - * (bit31,bit13) = (1,0)/(0,1) pattern on real hardware — under - * PCSX-Redux it never runs this path (pcsx_present() short-circuits to - * the vblank-IRQ wait, and Redux holds bit13 constant at 1), so this - * poll first became reachable in this runtime. */ - if (vertical_interlace) - interlace_field = (lcf ^ 1) & 1; - /* snapshot per-frame draw-offset-Y range for the strobe instrumentation */ - if (g_doff_cnt_this) { - g_doff_min_last = g_doff_min_this; - g_doff_max_last = g_doff_max_this; - g_doff_cnt_last = g_doff_cnt_this; - } - g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; - gpustat_poll_count = 0; - /* Latch the just-finished frame before the mouse/mod hook samples the - * diagnostics below. */ - geometry_diag_finish_presentation(); - /* Trusted package-selected plugins run on guest VBlank, independent of - * host presentation, pacing, turbo, or skipped frames. */ - mod_runtime_on_vblank(); - psx_irq_raise(0, 0); /* IRQ_VBLANK (gpu_vblank_tick) */ - if (vblank_callback) vblank_callback(); -} - -const uint16_t* gpu_get_vram(void) { - return vram; -} - -static uint8_t gpu_vram_byte(uint32_t byte_x, uint32_t y) { - uint16_t hw = vram[((y & 511u) * 1024u) + ((byte_x >> 1) & 1023u)]; - return (byte_x & 1u) ? (uint8_t)(hw >> 8) : (uint8_t)hw; -} - -/* Depth24: note/query/reset the CPU→VRAM upload span tracked above. Used to - * hide trailing RGB columns when a movie blit doesn't fill the full CRTC - * width — MotK's Star Wars crawl leaves ~8px of stale VRAM on the right. - * Only FB-class A0s (w >= 256 halfwords) grow the span; texture uploads must - * not collapse it. During present-hold, ignore updates entirely. */ -static void depth24_note_upload(uint32_t x, uint32_t w) { - if (!(display_depth & 1u) || w < 256u) return; - if (s_d24_present_hold > 0) return; - uint32_t x1 = x + w; - if (x1 > 1024u) x1 = 1024u; - if (x1 > s_d24_upload_x1) s_d24_upload_x1 = x1; -} - -uint32_t gpu_depth24_rgb_limit(uint32_t display_x, uint32_t crtc_w) { - if (!(display_depth & 1u) || s_d24_upload_x1 == 0u || crtc_w == 0u) - return crtc_w; - uint32_t dx = display_x & 1023u; - if (s_d24_upload_x1 <= dx) return crtc_w; - uint32_t hw = s_d24_upload_x1 - dx; - uint32_t rgb = (hw * 2u) / 3u; - if (rgb == 0u || rgb >= crtc_w) return crtc_w; - return rgb; -} - -void gpu_depth24_upload_span_reset(void) { - s_d24_upload_x1 = 0; -} - -int gpu_depth24_present_hold_tick(void) { - if (s_d24_present_hold <= 0) return 0; - s_d24_present_hold--; - return 1; -} - -/* ---- Present-time screen-colour LUT (verified-enhancement, opt-in) ------- - * - * PRESENT-TIME ONLY. This sits on the 15-bit-scanout -> RGB888 conversion that - * feeds the SDL/GL present path. It never touches VRAM and never runs on the - * depth24 (FMV) scanout (see gpu_display_pixel_rgb). It defaults to SCREEN_RAW - * (the original exact 5->3-replicated expansion below), so with the feature off - * the conversion — and therefore every hashed/oracle-diffed frame — is - * byte-identical to upstream. Opt in via PSX_SCREEN={crt,composite,trinitron}; - * any other value (or unset) keeps the raw path. - * - * Built lazily on first use because env is read once and the GPU has no - * settings plumb yet. The raw fast-path below is preserved verbatim so the - * default never even consults the LUT. */ -static ColorLut* s_screen_lut = NULL; -static int s_screen_lut_init = 0; -static int s_screen_kind_cfg = SCREEN_RAW; /* config/launcher-set; env overrides */ - -void gpu_set_screen_kind(int kind) { - if (kind < SCREEN_RAW || kind > SCREEN_TRINITRON) kind = SCREEN_RAW; - if (kind == s_screen_kind_cfg) return; - s_screen_kind_cfg = kind; - s_screen_lut_init = 0; /* rebuild on next scanout */ -} - -static void screen_lut_ensure(void) { - if (s_screen_lut_init) return; - s_screen_lut_init = 1; - if (s_screen_lut) { color_lut_destroy(s_screen_lut); s_screen_lut = NULL; } - /* Precedence: PSX_SCREEN env (debug override) wins if set+valid; otherwise - * the config/launcher value. Default (no env, raw config) = passthrough. */ - ScreenKind kind = (ScreenKind)s_screen_kind_cfg; - const char* name = getenv("PSX_SCREEN"); - ScreenKind envk; - if (name && screen_kind_from_name(name, &envk)) kind = envk; - if (kind == SCREEN_RAW) { - s_screen_lut = NULL; /* raw fast-path; passthrough */ - return; - } - ColorSettings s; - s.screen = kind; - s.darken = -1.0; /* per-screen default */ - s.target = DISPLAY_SRGB; - s_screen_lut = color_lut_create(&s); - if (s_screen_lut && color_lut_is_passthrough(s_screen_lut)) { - color_lut_destroy(s_screen_lut); - s_screen_lut = NULL; - } -} - -static void gpu_rgb555_to_rgb888(uint16_t c, uint8_t* r, uint8_t* g, uint8_t* b) { - screen_lut_ensure(); - if (s_screen_lut) { - color_lut_map555(s_screen_lut, c, r, g, b); - return; - } - /* Default raw path — byte-identical to upstream. */ - *r = (uint8_t)((c & 0x1Fu) << 3); - *g = (uint8_t)(((c >> 5) & 0x1Fu) << 3); - *b = (uint8_t)(((c >> 10) & 0x1Fu) << 3); -} - -void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, - uint8_t* r, uint8_t* g, uint8_t* b) { - if (di->depth24) { - uint32_t byte_x = ((di->display_x & 1023u) * 2u) + x * 3u; - uint32_t vy = (di->display_y + y) & 511u; - /* No horizontal wrap for 24-bit DAC scanout: bytes past the 2048-byte - * VRAM row are blank on hardware / accurate emulators, not sheared - * from X=0. Wrapping here produced a flickering right-edge strip. */ - if (byte_x + 2u >= 2048u) { - *r = *g = *b = 0; - return; - } - *r = gpu_vram_byte(byte_x + 0u, vy); - *g = gpu_vram_byte(byte_x + 1u, vy); - *b = gpu_vram_byte(byte_x + 2u, vy); - return; - } - - uint32_t vx = (di->display_x + x) & 1023u; - uint32_t vy = (di->display_y + y) & 511u; - gpu_rgb555_to_rgb888(vram[vy * 1024u + vx], r, g, b); -} - -uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y) { - uint8_t r, g, b; - gpu_display_pixel_rgb(di, x, y, &r, &g, &b); - return 0xFF000000u | ((uint32_t)r << 16) | ((uint32_t)g << 8) | (uint32_t)b; -} - -int gpu_display_is_depth24(void) { - return (int)(display_depth & 1u); -} - -void gpu_get_display_info(GpuDisplayInfo* out) { - out->display_x = display_area_x; - out->display_y = display_area_y; - out->depth24 = (int)(display_depth & 1u); - out->disabled = (int)display_disabled; - - /* Dot-clock divider from GP1(08h) hres (psx-spx / DuckStation). */ - uint32_t cycles; - uint32_t mode_w; - if (hres2) { - cycles = 7; - mode_w = 368; - } else { - static const uint32_t cyc_lut[4] = { 10, 8, 5, 4 }; - static const uint32_t hres_lut[4] = { 256, 320, 512, 640 }; - cycles = cyc_lut[hres1 & 3]; - mode_w = hres_lut[hres1 & 3]; - } - - /* Visible pixel width from GP1(06h): (((X2-X1)/cycles)+2) & ~3 (psx-spx). - * Fall back to the mode width when the range is unset/degenerate. */ - uint32_t dots = (h_display_x2 > h_display_x1) ? (h_display_x2 - h_display_x1) : 0; - uint32_t w = mode_w; - if (dots > 0u && cycles > 0u) { - w = ((dots / cycles) + 2u) & ~3u; - if (w == 0u) w = 4u; - } - - uint32_t h = (v_display_y2 > v_display_y1) ? (v_display_y2 - v_display_y1) : 240; - if (vres) h *= 2; /* 480i */ - - /* 24-bit scanout uses the same CRTC pixel width as 15-bit (DuckStation / - * Beetle: coordinates stay 16-bit-based; W RGB occupies W*3/2 halfwords). - * MotK FMV: GP1(06h) yields 512; the logo is centered in that RGB line. - * A blanket (W*2)/3 (512→341) left-shifts the frame and clips the right - * of the video — do not reintroduce it. Right-edge junk is a separate - * present/filter issue, not a reason to shrink CRTC width. */ - - /* Clamp to sane maximums */ - if (w > 640) w = 640; - if (h > 512) h = 512; - - out->width = w; - out->height = h; -} - -/* Debug accessors for GP1 display-range / mode (TCP gpu_state). */ -void gpu_get_crtc_debug(uint32_t *x1, uint32_t *x2, uint32_t *y1, uint32_t *y2, - uint32_t *hres1_out, uint32_t *hres2_out) { - if (x1) *x1 = h_display_x1; - if (x2) *x2 = h_display_x2; - if (y1) *y1 = v_display_y1; - if (y2) *y2 = v_display_y2; - if (hres1_out) *hres1_out = hres1; - if (hres2_out) *hres2_out = hres2; -} - -void gpu_set_vblank_callback(gpu_vblank_cb cb) { - vblank_callback = cb; -} - -/* Sign-extend an N-bit value to int32_t */ -static int32_t sign_extend(uint32_t val, int bits) { - uint32_t sign_bit = 1u << (bits - 1); - return (int32_t)((val ^ sign_bit) - sign_bit); -} - -/* ---- Polygon rasterizer ---- */ - -/* Parse a vertex position word: signed 11-bit X and Y */ -static void parse_vertex(uint32_t word, int32_t* x, int32_t* y) { - *x = sign_extend(word & 0x7FFu, 11); - *y = sign_extend((word >> 16) & 0x7FFu, 11); -} - -extern int gte_geometry_correction_enabled(void); -extern void gte_precision_tracking_set(int enabled); - -void gpu_texture_correction_set(int enabled) { - s_texture_correction_enabled = enabled ? 1 : 0; - s_texture_correction_triangles = 0; - gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - gte_precision_tracking_set(s_texture_correction_enabled || - ws_geometry_correction); -} - -int gpu_texture_correction_enabled(void) { - return s_texture_correction_enabled; -} - -uint32_t gpu_texture_correction_hits(void) { - return s_texture_correction_triangles; -} - -static int gp0_source_word_address(int word_index, uint32_t *out) { - const uint32_t delta = (uint32_t)word_index * 4u; - if (gp0_cmd_source_addr == 0xFFFFFFFFu || - gp0_cmd_source_addr > UINT32_MAX - delta) - return 0; - *out = gp0_cmd_source_addr + delta; - return 1; -} - -static int32_t fixed16_integer_floor(int32_t value) { - const int64_t wide = value; - if (wide >= 0) return (int32_t)(wide / 65536); - return (int32_t)(-(((-wide) + 65535) / 65536)); -} - -static uint64_t abs_i64_u64(int64_t value) { - return (uint64_t)(value < 0 ? -value : value); -} - -static uint32_t geometry_primitive_class(uint8_t opcode) { - const uint32_t quad = (opcode & 0x08u) ? 4u : 0u; - const uint32_t gouraud = (opcode & 0x10u) ? 2u : 0u; - const uint32_t textured = (opcode & 0x04u) ? 1u : 0u; - return quad | gouraud | textured; -} - -static GpuGeometryRejectReason geometry_reject_from_lookup( - GtePrecisionLookupResult result) { - switch (result) { - case GTE_PRECISION_LOOKUP_TRACKING_DISABLED: - return GPU_GEOMETRY_REJECT_TRACKING_DISABLED; - case GTE_PRECISION_LOOKUP_SPECULATIVE: - return GPU_GEOMETRY_REJECT_SPECULATIVE_LOOKUP; - case GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM: - return GPU_GEOMETRY_REJECT_SOURCE_NOT_RAM; - case GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED: - return GPU_GEOMETRY_REJECT_SOURCE_UNALIGNED; - case GTE_PRECISION_LOOKUP_STORE_MISS: - return GPU_GEOMETRY_REJECT_STORE_MISS; - case GTE_PRECISION_LOOKUP_STORE_COLLISION: - return GPU_GEOMETRY_REJECT_STORE_COLLISION; - case GTE_PRECISION_LOOKUP_PROJECTION_INVALID: - return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; - case GTE_PRECISION_LOOKUP_PACKED_MISMATCH: - return GPU_GEOMETRY_REJECT_PACKED_MISMATCH; - case GTE_PRECISION_LOOKUP_ZERO_DEPTH: - return GPU_GEOMETRY_REJECT_ZERO_DEPTH; - case GTE_PRECISION_LOOKUP_SATURATED: - return GPU_GEOMETRY_REJECT_SATURATED; - case GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID: - return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; - case GTE_PRECISION_LOOKUP_ACCEPTED: - case GTE_PRECISION_LOOKUP_RESULT_COUNT: - default: - return GPU_GEOMETRY_REJECT_PROJECTION_INVALID; - } -} - -static void geometry_diag_note_candidate( - GpuGeometryCorrectionCounters *stats, int count, int triangle_count, - uint32_t primitive_class, uint32_t opcode_index) { - if (!stats) return; - stats->triangle_candidates += (uint64_t)triangle_count; - ++stats->polygon_candidates; - stats->vertex_candidates += (uint64_t)count; - ++stats->primitive_candidates[primitive_class]; - ++stats->opcode_candidates[opcode_index]; -} - -static void geometry_diag_note_rejection( - GpuGeometryCorrectionCounters *stats, - GpuGeometryRejectReason reason, uint64_t count) { - if (stats) stats->vertex_rejections[(uint32_t)reason] += count; -} - -/* Axis-aligned textured quads use a rectangle renderer and never consume the - * per-triangle precision metadata. Keep them visible in the coverage - * denominator as an explicit, safe canonical fallback. */ -static void geometry_diag_note_rectangle_fast_path(void) { - if (!gte_geometry_correction_enabled()) return; - const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); - const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; - const uint32_t primitive_class = geometry_primitive_class(opcode); - GpuGeometryCorrectionCounters *live = NULL; - if (geometry_diag_presentation_live()) { - live = &s_geometry_diag_live_current; - s_geometry_diag_live_current_touched = 1; - } - geometry_diag_note_candidate(&s_geometry_diag_cumulative, 4, 2, - primitive_class, opcode_index); - geometry_diag_note_candidate(live, 4, 2, - primitive_class, opcode_index); - geometry_diag_note_rejection( - &s_geometry_diag_cumulative, - GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); - geometry_diag_note_rejection( - live, GPU_GEOMETRY_REJECT_RECTANGLE_FAST_PATH, 4); -} - -static uint32_t geometry_depth_bucket(uint16_t z) { - if (z < 256u) return 0; - if (z < 1024u) return 1; - if (z < 4096u) return 2; - if (z < 16384u) return 3; - if (z < 32768u) return 4; - return 5; -} - -static uint32_t geometry_screen_bucket(int32_t x, int32_t y) { - const int32_t width = ws_disp_w(); - const int32_t height = ws_disp_h(); - if (x < 0 || y < 0 || x >= width || y >= height) - return 9; - int32_t column = width > 0 ? (x * 3) / width : 0; - int32_t row = height > 0 ? (y * 3) / height : 0; - if (column > 2) column = 2; - if (row > 2) row = 2; - return (uint32_t)(row * 3 + column); -} - -static uint32_t geometry_correction_bucket(uint64_t magnitude16) { - if (magnitude16 == 0) return 0; - if (magnitude16 < 16384u) return 1; - if (magnitude16 < 32768u) return 2; - if (magnitude16 < 49152u) return 3; - return 4; -} - -static void geometry_diag_saturating_add( - GpuGeometryCorrectionCounters *stats, - uint64_t *destination, uint64_t value) { - if (UINT64_MAX - *destination < value) { - *destination = UINT64_MAX; - stats->correction_accumulators_saturated = 1; - } else { - *destination += value; - } -} - -static void geometry_diag_note_accepted( - GpuGeometryCorrectionCounters *stats, int count, int triangle_count, - uint32_t primitive_class, uint32_t opcode_index, - const int32_t *precise_x, const int32_t *precise_y, - const int32_t *raw_x, const int32_t *raw_y, - const int32_t *screen_x, const int32_t *screen_y, - const uint16_t *z) { - if (!stats) return; - stats->triangle_accepted += (uint64_t)triangle_count; - ++stats->polygon_accepted; - stats->vertex_accepted += (uint64_t)count; - ++stats->primitive_accepted[primitive_class]; - ++stats->opcode_accepted[opcode_index]; - for (int i = 0; i < count; ++i) { - const int64_t dx = (int64_t)precise_x[i] - - (int64_t)raw_x[i] * 65536; - const int64_t dy = (int64_t)precise_y[i] - - (int64_t)raw_y[i] * 65536; - const uint64_t ax = abs_i64_u64(dx); - const uint64_t ay = abs_i64_u64(dy); - const uint64_t magnitude = ax > ay ? ax : ay; - ++stats->depth_vertices[geometry_depth_bucket(z[i])]; - ++stats->screen_vertices[ - geometry_screen_bucket(screen_x[i], screen_y[i])]; - ++stats->correction_vertices; - geometry_diag_saturating_add( - stats, &stats->correction_abs_x16_sum, ax); - geometry_diag_saturating_add( - stats, &stats->correction_abs_y16_sum, ay); - geometry_diag_saturating_add( - stats, &stats->correction_magnitude16_sum, magnitude); - if (magnitude > stats->correction_magnitude16_max) - stats->correction_magnitude16_max = magnitude; - geometry_diag_saturating_add( - stats, &stats->correction_x16_squared_sum, ax * ax); - geometry_diag_saturating_add( - stats, &stats->correction_y16_squared_sum, ay * ay); - ++stats->correction_magnitude_buckets[ - geometry_correction_bucket(magnitude)]; - } -} - -/* Resolve vertices only through the exact RAM address at which a reviewed GTE - * projection store committed that result. A rounded packed SXY value is not a - * unique identity: many - * unrelated 3D vertices can land on the same PS1 pixel. Test 11's packed-SXY - * fallback therefore paired polygons with unrelated projections and opened - * cracks between adjacent primitives. Missing provenance is deliberately a - * canonical-render fallback for the whole polygon. The integer delta folds in - * draw offsets and any widescreen adjustment already applied. */ -/* Valid only for the polygon most recently resolved below. Both halves of a - * quad use their original indices; shader q remains a separate attribute. */ -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static uint16_t s_precise_vertex_depth[4]; -static uint16_t s_precise_vertex_surface[4]; /* the same with a pinned corner's depth kept: for the mirror's depth */ -static uint64_t s_precise_vertex_identity[4]; -static int s_precise_vertex_partial; -#endif -/* A polygon that falls back still draws the corners the game pinned where they project: its neighbours do. */ -static PsxPinnedPlaces s_pinned_places; -/* The polygon resolved last is the game's grown second copy of the one before it. */ -static int s_precise_grown; -#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) -/* A grown corner lies off its source corner, on the same plane: its depth is the plane's there, 1/z being linear on screen. */ -static void grown_corner_depths(const int32_t *source_x, const int32_t *source_y, const int32_t *raw_x, const int32_t *raw_y, - const int *indices) { - static const int beside[4][2] = {{1, 2}, {0, 3}, {0, 3}, {1, 2}}; - uint16_t moved[4]; - for (int i = 0; i < 4; ++i) { - const int j = beside[i][0], k = beside[i][1]; - const double zi = s_precise_vertex_depth[i], zj = s_precise_vertex_depth[j], zk = s_precise_vertex_depth[k]; - const double ax = ((double)source_x[j] - source_x[i]) / 65536.0, ay = ((double)source_y[j] - source_y[i]) / 65536.0; - const double bx = ((double)source_x[k] - source_x[i]) / 65536.0, by = ((double)source_y[k] - source_y[i]) / 65536.0; - const double det = ax * by - bx * ay; - int32_t grown_x, grown_y; - moved[i] = s_precise_vertex_depth[i]; - if (zi <= 0.0 || zj <= 0.0 || zk <= 0.0 || (det < 1.0e-6 && det > -1.0e-6)) continue; - parse_vertex(gp0_cmd_buf[indices[i]], &grown_x, &grown_y); - { - const double ga = 1.0 / zj - 1.0 / zi, gb = 1.0 / zk - 1.0 / zi; - const double px = (double)grown_x - raw_x[i], py = (double)grown_y - raw_y[i]; - const double g = 1.0 / zi + ((ga * by - gb * ay) * px + (ax * gb - bx * ga) * py) / det; - const double z = g > 1.0 / 65535.0 ? 1.0 / g : 65535.0; - if (z >= 17.0) moved[i] = (uint16_t)(z + 0.5); /* 16 and nearer reads as no depth: the corner keeps its source's */ - } - } - memcpy(s_precise_vertex_depth, moved, sizeof(moved)); -} -#endif -#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD -extern int disruptor_grown_quad_sources( - uint32_t command_address, const uint32_t *packet, - uint32_t source_address[4], uint32_t source_word[4]); -#endif -static int resolve_precise_vertices(const int *indices, int count, - int triangle_count, - const int32_t *vx, const int32_t *vy, - int32_t *fx, int32_t *fy) { - s_precise_grown = 0; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - memset(s_precise_vertex_depth, 0, sizeof(s_precise_vertex_depth)); - memset(s_precise_vertex_surface, 0, sizeof(s_precise_vertex_surface)); - memset(s_precise_vertex_identity, 0, sizeof(s_precise_vertex_identity)); - s_precise_vertex_partial = 0; -#endif - s_pinned_places.unpinned = 0; - if (!gte_geometry_correction_enabled()) - return 0; - - const uint8_t opcode = (uint8_t)(gp0_cmd_buf[0] >> 24); - const uint32_t opcode_index = (uint32_t)(opcode - 0x20u) & 31u; - const uint32_t primitive_class = geometry_primitive_class(opcode); - GpuGeometryCorrectionCounters *live = NULL; - if (geometry_diag_presentation_live()) { - live = &s_geometry_diag_live_current; - s_geometry_diag_live_current_touched = 1; - } - geometry_diag_note_candidate(&s_geometry_diag_cumulative, count, - triangle_count, - primitive_class, opcode_index); - geometry_diag_note_candidate(live, count, triangle_count, - primitive_class, opcode_index); - - if (gp0_cmd_source_addr == 0xFFFFFFFFu) { - geometry_diag_note_rejection(&s_geometry_diag_cumulative, - GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, - (uint64_t)count); - geometry_diag_note_rejection(live, - GPU_GEOMETRY_REJECT_NO_COMMAND_SOURCE, - (uint64_t)count); - return 0; - } - - int32_t precise_x[4], precise_y[4], raw_x[4], raw_y[4]; - uint16_t z[4]; - int resolved = 0; - int pinned[4] = {0}; - const int unpin = gte_precision_unpin_enabled(); - uint32_t grown_addr[4] = {0}, grown_word[4] = {0}; -#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD - /* Disruptor's second, grown copy of a world quad stands on the first packet's corners. */ - const int grown = count == 4 && disruptor_grown_quad_sources( - gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word); -#else - const int grown = 0; -#endif - - for (int i = 0; i < count; ++i) { - const uint32_t word = grown ? grown_word[i] : gp0_cmd_buf[indices[i]]; - uint32_t addr = grown_addr[i]; - GpuGeometryRejectReason reject; - int accepted = 0; - if (!grown && !gp0_source_word_address(indices[i], &addr)) { - reject = GPU_GEOMETRY_REJECT_SOURCE_ADDRESS_OVERFLOW; - } else { - const GtePrecisionLookupResult result = - gte_precision_load_word_ex(addr, word, - &precise_x[i], &precise_y[i], &z[i]); - if (result != GTE_PRECISION_LOOKUP_ACCEPTED) { - reject = geometry_reject_from_lookup(result); - } else { - parse_vertex(word, &raw_x[i], &raw_y[i]); - /* This test happens on the retained raw projection. Draw - * offsets and widescreen transforms in vx/vy must not hide a - * saturated or otherwise non-subpixel discrepancy. */ - /* A corner the game pinned to its guard band is drawn where it projects. */ - pinned[i] = gte_precision_word_clamped(addr, word); - if ((fixed16_integer_floor(precise_x[i]) != raw_x[i] || - fixed16_integer_floor(precise_y[i]) != raw_y[i]) && - !pinned[i]) { - reject = GPU_GEOMETRY_REJECT_INTEGER_MISMATCH; - } else { - accepted = 1; - ++resolved; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); - s_precise_vertex_surface[i] = z[i]; -#endif - } - } - } - if (!accepted) { - geometry_diag_note_rejection(&s_geometry_diag_cumulative, - reject, 1); - geometry_diag_note_rejection(live, reject, 1); - } - } - - if (resolved != count) { - if (resolved != 0) { - geometry_diag_note_rejection( - &s_geometry_diag_cumulative, - GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, (uint64_t)resolved); - geometry_diag_note_rejection( - live, GPU_GEOMETRY_REJECT_ATOMIC_FALLBACK, - (uint64_t)resolved); - ++s_geometry_diag_cumulative.partial_polygon_rejections; - if (live) ++live->partial_polygon_rejections; - if (count == 4) { - ++s_geometry_diag_cumulative.partial_quad_rejections; - if (live) ++live->partial_quad_rejections; - } -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_precise_vertex_partial = 1; -#endif - psx_pinned_places(count, unpin, pinned, precise_x, precise_y, raw_x, raw_y, vx, vy, &s_pinned_places); - } - return 0; - } - - geometry_diag_note_accepted(&s_geometry_diag_cumulative, count, - triangle_count, - primitive_class, opcode_index, - precise_x, precise_y, raw_x, raw_y, - raw_x, raw_y, z); - geometry_diag_note_accepted(live, count, triangle_count, - primitive_class, opcode_index, - precise_x, precise_y, raw_x, raw_y, - raw_x, raw_y, z); - for (int i = 0; i < count; ++i) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_precise_vertex_depth[i] = psx_pinned_corner_depth(unpin, pinned[i], z[i]); - s_precise_vertex_surface[i] = z[i]; - { - uint32_t identity_addr; - /* A grown corner is off its model point: it gets the depth and no identity. */ - if (!grown && gp0_source_word_address(indices[i], &identity_addr)) - (void)gte_precision_load_identity(identity_addr, gp0_cmd_buf[indices[i]], - &s_precise_vertex_identity[i]); - } -#endif - fx[i] = (int32_t)((int64_t)precise_x[i] + - (int64_t)(vx[i] - raw_x[i]) * 65536); - fy[i] = (int32_t)((int64_t)precise_y[i] + - (int64_t)(vy[i] - raw_y[i]) * 65536); - } - s_precise_grown = grown; -#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) - if (grown) { - grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices); - memcpy(s_precise_vertex_surface, s_precise_vertex_depth, sizeof(s_precise_vertex_surface)); - } -#endif - return 1; -} - -/* Queue one triangle's one-shot presentation metadata. Fractional yaw uses the - * same exact-provenance gate as subpixel positions, so CPU-built 2D polygons - * and incomplete world packets remain completely canonical. */ -static void queue_precise_triangle(int exact, - const int32_t *fx, const int32_t *fy, - int a, int b, int c) { - const int geometry_enabled = gte_geometry_correction_enabled(); - gr_set_world_triangle(0); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - gr_set_temporal_depth_triangle(0, 0.0f, 0.0f, 0.0f); - gr_set_temporal_identity_triangle(0u, 0u, 0u); - gr_set_surface_depth_triangle(0.0f, 0.0f, 0.0f); -#endif - gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - if (geometry_enabled) ++ws_geometry_world_triangles; - if (!geometry_enabled || !exact) { - gr_set_precise_triangle(0, 0,0, 0,0, 0,0); - if (geometry_enabled && s_pinned_places.unpinned) - gr_set_unpinned_triangle(s_pinned_places.x16[a], s_pinned_places.y16[a], s_pinned_places.x16[b], s_pinned_places.y16[b], - s_pinned_places.x16[c], s_pinned_places.y16[c]); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - /* The corners that did resolve keep their depth: an in-between frame moves them with the camera. */ - if (geometry_enabled && s_precise_vertex_partial) - gr_set_temporal_depth_triangle(2, - (float)s_precise_vertex_depth[a], - (float)s_precise_vertex_depth[b], - (float)s_precise_vertex_depth[c]); - if (geometry_enabled && s_precise_vertex_partial) - gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], - (float)s_precise_vertex_surface[c]); -#endif - return; - } - ++ws_geometry_precise_triangles; - gr_set_world_triangle(1); - if (s_precise_grown) gr_set_world_triangle(2); /* the game's grown second copy */ -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - gr_set_temporal_depth_triangle(psx_pinned_depth_mode(s_precise_vertex_depth[a], s_precise_vertex_depth[b], s_precise_vertex_depth[c]), - (float)s_precise_vertex_depth[a], - (float)s_precise_vertex_depth[b], - (float)s_precise_vertex_depth[c]); - gr_set_temporal_identity_triangle(s_precise_vertex_identity[a], - s_precise_vertex_identity[b], - s_precise_vertex_identity[c]); - gr_set_surface_depth_triangle((float)s_precise_vertex_surface[a], (float)s_precise_vertex_surface[b], - (float)s_precise_vertex_surface[c]); -#endif - gr_set_precise_triangle(1, - fx[a], fy[a], fx[b], fy[b], fx[c], fy[c]); -} - -static int prepare_precise_triangle(int i0, int i1, int i2, - const int32_t vx[3], const int32_t vy[3]) { - const int indices[3] = { i0, i1, i2 }; - int32_t fx[3], fy[3]; - const int exact = resolve_precise_vertices( - indices, 3, 1, vx, vy, fx, fy); - queue_precise_triangle(exact, fx, fy, 0, 1, 2); - return exact; -} - -/* Resolve a GP0 quad as one unit before either of its raster triangles is - * submitted. This prevents a partially tracked quad from correcting only one - * side of its shared diagonal. */ -static int prepare_precise_quad_vertices(const int indices[4], - int triangle_count, - const int32_t vx[4], - const int32_t vy[4], - int32_t fx[4], int32_t fy[4]) { - return resolve_precise_vertices( - indices, 4, triangle_count, vx, vy, fx, fy); -} - -/* Resolve projective depth as one polygon-wide transaction. Geometry's exact - * verdict is a prerequisite: this keeps both halves of a quad affine whenever - * any one of its four vertices falls back, and excludes CPU-built UI. */ -static int resolve_texture_vertices(int exact, const int *indices, int count, - float *q) { - if (!exact || !s_texture_correction_enabled || - gp0_cmd_source_addr == 0xFFFFFFFFu || count < 3 || count > 4) - return 0; - uint16_t z[4]; - for (int i = 0; i < count; i++) { - uint32_t addr; - if (!gp0_source_word_address(indices[i], &addr) || - gte_precision_load_perspective_word_ex( - addr, gp0_cmd_buf[indices[i]], &z[i]) != - GTE_PRECISION_LOOKUP_ACCEPTED) - return 0; - } - for (int i = 0; i < count; ++i) q[i] = 1.0f / (float)z[i]; - float qmax = q[0]; - for (int i = 1; i < count; ++i) - if (q[i] > qmax) qmax = q[i]; - if (qmax <= 0.0f) return 0; - for (int i = 0; i < count; ++i) q[i] /= qmax; - return 1; -} - -static void queue_texture_triangle(int exact, const float *q, - int a, int b, int c) { - if (!exact) { - gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - return; - } - gr_set_perspective_triangle(1, q[a], q[b], q[c]); - if (s_texture_correction_triangles != UINT32_MAX) - ++s_texture_correction_triangles; -} - -/* Triangle wrapper; quads preflight all four vertices once and queue their two - * reciprocal-depth orderings explicitly below. */ -static void prepare_texture_triangle(int exact, int i0, int i1, int i2) { - const int indices[3] = { i0, i1, i2 }; - float q[3]; - const int perspective = resolve_texture_vertices(exact, indices, 3, q); - queue_texture_triangle(perspective, q, 0, 1, 2); -} - -/* Write a single pixel to VRAM with draw area clipping and mask bit handling */ -static void raster_pixel(int32_t x, int32_t y, uint16_t color) { - if (x < (int32_t)draw_area_left || x > (int32_t)draw_area_right) return; - if (y < (int32_t)draw_area_top || y > (int32_t)draw_area_bottom) return; - uint32_t vx = (uint32_t)x & 1023u; - uint32_t vy = (uint32_t)y & 511u; - uint32_t idx = vy * 1024 + vx; - if (check_mask_bit && (vram[idx] & 0x8000u)) return; - vram[idx] = color | (set_mask_bit ? 0x8000u : 0u); -} - -/* Inclusive draw-area reject (same predicate as raster_pixel / hardware clip). - * - * MotK inter-movie / title / char-select OT drains often set GP0(E3/E4) to - * (0,0)-(0,0) then submit thousands of 1x1 dots and shaded quads. Real GPU - * clips those for free; our GL path was building two triangles per clipped - * prim (gpu_share ~0.9, host FPS ~5–10). Skip the host rasterizer when the - * post-offset bbox cannot touch the draw area. Side effects that must still - * run (texpage latch, oversize reject) happen before these checks. - * - * Native-wide is intentionally different in X: the mirror renderer translates - * canonical framebuffer coordinates by the live reveal offset and scissors to - * the FULL wide surface, not GP0(E3/E4)'s 4:3 X range. Commit 31015ce originally - * compared every primitive only with the guest draw area here, rejecting the - * margin geometry before the mirror renderer could see it. Match the mirror's - * exact X extent while its framebuffer target is active; Y remains the guest - * draw area because native-wide does not extend vertically. At 4:3, in FMV/ - * menus, in squash mode, and for offscreen texture targets, margin is zero and - * this remains the original fast reject byte-for-byte. - * - * OpokXeno independently identified the same host-side regression on Xenogears - * and contributed the original generalized fix in psxrecomp PR #73: - * https://github.com/mstan/psxrecomp/pull/73 - * Keep that credit with this guarded framebuffer-target variant. */ -static inline int32_t draw_area_wide_x_margin(void) { - if (!ws_native_wide_active() || !ws_is_fb_base(draw_area_left)) return 0; - return (int32_t)ws_nw_offset(); -} - -static inline void draw_area_host_x_bounds(int32_t *left, int32_t *right) { - int32_t margin = draw_area_wide_x_margin(); - *left = (int32_t)draw_area_left; - *right = (int32_t)draw_area_right; - if (margin > 0) { - /* Use the union of the guest draw area and the widescreen mirror. - * Wider staging areas may share the framebuffer X origin; clamping them - * to the mirror width would drop valid canonical VRAM writes. */ - int32_t wide_left = (int32_t)draw_area_left - margin; - int32_t wide_right = (int32_t)draw_area_left + (int32_t)ws_disp_w() + margin - 1; - if (wide_left < *left) *left = wide_left; - if (wide_right > *right) *right = wide_right; - } -} - -static inline int draw_area_out_point(int32_t x, int32_t y) { - int32_t left, right; - draw_area_host_x_bounds(&left, &right); - return x < left || x > right - || y < (int32_t)draw_area_top || y > (int32_t)draw_area_bottom; -} - -static inline int draw_area_out_bbox(const int32_t *vx, const int32_t *vy, int n) { - int32_t minx = vx[0], maxx = vx[0], miny = vy[0], maxy = vy[0]; - for (int i = 1; i < n; i++) { - if (vx[i] < minx) minx = vx[i]; - if (vx[i] > maxx) maxx = vx[i]; - if (vy[i] < miny) miny = vy[i]; - if (vy[i] > maxy) maxy = vy[i]; - } - int32_t left, right; - draw_area_host_x_bounds(&left, &right); - return maxx < left || minx > right - || maxy < (int32_t)draw_area_top || miny > (int32_t)draw_area_bottom; -} - -static inline int draw_area_out_rect(int32_t x, int32_t y, int w, int h) { - if (w <= 0 || h <= 0) return 1; - int32_t left, right; - draw_area_host_x_bounds(&left, &right); - return (x + w - 1) < left - || x > right - || (y + h - 1) < (int32_t)draw_area_top - || y > (int32_t)draw_area_bottom; -} - -/* Rasterize a flat-shaded triangle using DDA scanline fill. - * Vertices are in screen coordinates (draw offset already applied). */ -static void raster_triangle(int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2, - uint16_t color) -{ - /* Sort vertices by Y (ascending). */ - int32_t tx, ty; - if (y0 > y1) { tx=x0; ty=y0; x0=x1; y0=y1; x1=tx; y1=ty; } - if (y1 > y2) { tx=x1; ty=y1; x1=x2; y1=y2; x2=tx; y2=ty; } - if (y0 > y1) { tx=x0; ty=y0; x0=x1; y0=y1; x1=tx; y1=ty; } - - /* Reject degenerate (zero-height) or oversized triangles. */ - if (y0 == y2) return; - if ((x2 - x0) > 1023 || (x0 - x2) > 1023) return; - if ((y2 - y0) > 511) return; - - /* 64-bit fixed-point DDA (32.32) matching PS1 hardware behavior. - * Reference: DuckStation gpu_sw_rasterizer.inl makefp_xy / makestep_xy */ - #define FP_ONE (1LL << 32) - #define MAKE_FP(v) (((int64_t)(v) << 32) + (FP_ONE - (1 << 11))) - #define MAKE_STEP(dx, dy) \ - ((((int64_t)(dx) << 32) + ((dx) < 0 ? -((dy)-1) : (((dx) > 0) ? ((dy)-1) : 0))) / (dy)) - #define UNFP(fp) ((int32_t)((uint64_t)(fp) >> 32)) - - /* Upper half: y0 to y1 */ - if (y1 > y0) { - int32_t dy_long = y2 - y0; - int32_t dy_short = y1 - y0; - int64_t base = MAKE_FP(x0); - int64_t step_long = MAKE_STEP(x2 - x0, dy_long); - int64_t step_short = MAKE_STEP(x1 - x0, dy_short); - /* Determine which edge is left vs right */ - int64_t lx, rx, ls, rs; - if (step_long < step_short) { - lx = base; ls = step_long; rx = base; rs = step_short; - } else { - lx = base; ls = step_short; rx = base; rs = step_long; - } - for (int32_t y = y0; y < y1; y++) { - int32_t xl = UNFP(lx); - int32_t xr = UNFP(rx); - for (int32_t x = xl; x < xr; x++) - raster_pixel(x, y, color); - lx += ls; rx += rs; - } - } - - /* Lower half: y1 to y2 */ - if (y2 > y1) { - int32_t dy_long = y2 - y0; - int32_t dy_short = y2 - y1; - /* Long edge continues from y0 to y2 */ - int64_t step_long = MAKE_STEP(x2 - x0, dy_long); - int64_t long_at_y1 = MAKE_FP(x0) + step_long * (y1 - y0); - int64_t short_start = MAKE_FP(x1); - int64_t step_short = MAKE_STEP(x2 - x1, dy_short); - /* Determine left/right from the upper half's orientation */ - int64_t lx, rx, ls, rs; - int64_t full_step_short_upper = (y1 > y0) ? MAKE_STEP(x1 - x0, y1 - y0) : 0; - if (step_long < full_step_short_upper || - (y1 == y0 && long_at_y1 <= short_start)) { - lx = long_at_y1; ls = step_long; rx = short_start; rs = step_short; - } else { - lx = short_start; ls = step_short; rx = long_at_y1; rs = step_long; - } - for (int32_t y = y1; y < y2; y++) { - int32_t xl = UNFP(lx); - int32_t xr = UNFP(rx); - for (int32_t x = xl; x < xr; x++) - raster_pixel(x, y, color); - lx += ls; rx += rs; - } - } - - #undef FP_ONE - #undef MAKE_FP - #undef MAKE_STEP - #undef UNFP -} - -/* Execute mono triangle (GP0 0x20-0x23) */ -static void gp0_exec_mono_tri(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t vx[3], vy[3]; - for (int i = 0; i < 3; i++) { - parse_vertex(gp0_cmd_buf[1 + i], &vx[i], &vy[i]); - } - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(1, 2, 3, vx, vy); - gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); -} - -/* Execute mono quad (GP0 0x28-0x2B) — two triangles: (0,1,2) and (2,1,3) */ -static void gp0_exec_mono_quad(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t vx[4], vy[4]; - for (int i = 0; i < 4; i++) - parse_vertex(gp0_cmd_buf[1 + i], &vx[i], &vy[i]); - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - - /* Full-screen filters are commonly encoded as an axis-aligned quad. Drawing - * a semi-transparent quad as two independent triangles blends their shared - * diagonal twice; render the equivalent rectangle once to avoid that seam. - * The full-screen helper also grows the native 320-wide filter across the - * sidecar surface in native-wide mode. Ordinary world quads are unchanged. */ - if (vx[0] == vx[2] && vx[1] == vx[3] && - vy[0] == vy[1] && vy[2] == vy[3] && - vx[1] > vx[0] && vy[2] > vy[0] && - vx[0] <= 0 && vx[1] >= ws_disp_w() && - vy[0] <= 0 && vy[2] >= ws_disp_h()) { - int32_t x = vx[0]; - int32_t y = vy[0]; - int w = (int)(vx[1] - vx[0]); - int h = (int)(vy[2] - vy[0]); - ws_expand_fullscreen_rect(&x, y, &w, h); - x += draw_offset_x; - y += draw_offset_y; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x, y, w, h, color); - return; - } - ws_hud_widget_quad(vx, vy); - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise_indices[4] = { 1, 2, 3, 4 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); - } -} - -/* Execute shaded triangle (GP0 0x30-0x33) — Gouraud shaded */ -static void gp0_exec_shaded_tri(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int32_t vx[3], vy[3]; - uint16_t c[3]; - /* Layout: C0, V0, C1, V1, C2, V2 */ - for (int i = 0; i < 3; i++) { - c[i] = rgb888_to_rgb555(gp0_cmd_buf[i * 2] & 0xFFFFFFu); - parse_vertex(gp0_cmd_buf[1 + i * 2], &vx[i], &vy[i]); - } - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(1, 3, 5, vx, vy); - gr_draw_gouraud_triangle(vx[0], vy[0], c[0], - vx[1], vy[1], c[1], - vx[2], vy[2], c[2]); -} - -void gpu_arm_shaded_quad_capture(void) { sq_cap_armed = 1; sq_cap_count = 0; } -int gpu_get_shaded_quad_capture(const GpuSqCapEntry** out) { - sq_cap_armed = 0; /* disarm on read */ - *out = sq_cap_buf; - return sq_cap_count; -} - -/* Execute shaded quad (GP0 0x38-0x3B) */ -static void gp0_exec_shaded_quad(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int32_t vx[4], vy[4]; - uint16_t c[4]; - /* Layout: C0, V0, C1, V1, C2, V2, C3, V3 */ - for (int i = 0; i < 4; i++) { - c[i] = rgb888_to_rgb555(gp0_cmd_buf[i * 2] & 0xFFFFFFu); - parse_vertex(gp0_cmd_buf[1 + i * 2], &vx[i], &vy[i]); - } - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (sky gradient; no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - /* Capture vertex data when armed. */ - if (sq_cap_armed && sq_cap_count < SQ_CAP_MAX) { - GpuSqCapEntry* e = &sq_cap_buf[sq_cap_count++]; - for (int i = 0; i < 4; i++) { - e->vx[i] = vx[i]; e->vy[i] = vy[i]; - e->color[i] = gp0_cmd_buf[i * 2] & 0xFFFFFFu; - } - } - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise_indices[4] = { 1, 3, 5, 7 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - gr_draw_gouraud_triangle(vx[0], vy[0], c[0], - vx[1], vy[1], c[1], - vx[2], vy[2], c[2]); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - gr_draw_gouraud_triangle(vx[2], vy[2], c[2], - vx[1], vy[1], c[1], - vx[3], vy[3], c[3]); - } -} - -/* Helper: build texpage word from GPU state for SW renderer. - * Format: bits 0-3 = X base, bit 4 = Y base, bits 5-6 = semi-trans, bits 7-8 = color depth */ -static uint16_t current_texpage(void) { - return (uint16_t)(texpage_x | (texpage_y << 4) | - (semi_transparency << 5) | (texpage_colors << 7)); -} - -/* Hardware: the texpage attribute word carried inside every textured polygon - * (GP0 0x24-0x3F with the texture bit) is copied into the GPU draw-mode state - * (GPUSTAT bits 0-8) exactly like GP0(E1) bits 0-8 — the poly's own word, not - * the last E1, decides its semi-transparency mode, and later rectangle/sprite - * prims (which carry no texpage word) consume the state the poly left behind. - * Beetle: SetTPage(CB[4 + ((cc>>4)&1)] >> 16) on every textured poly. - * Bits 9-10 (dither / draw-to-display) exist only in E1; bit 11 (texture - * disable) only latches when GP1(09h) allowed it, which polys can't grant. */ -static void set_tpage_from_poly(uint16_t tpage_word) { - texpage_x = tpage_word & 0xF; - texpage_y = (tpage_word >> 4) & 1; - semi_transparency = (tpage_word >> 5) & 3; - texpage_colors = (tpage_word >> 7) & 3; -} - -/* Helper: set up SW renderer state before a textured draw. - * semi_trans: whether the primitive has semi-transparency enabled (from opcode bit) - * raw_texture: 1 if this is a raw-texture opcode (bit 0 of GP0 opcode set) */ -static void setup_textured_draw(uint32_t color24, int semi_trans, int raw_texture) { - uint32_t r = (color24 >> 0) & 0xFF; - uint32_t g = (color24 >> 8) & 0xFF; - uint32_t b = (color24 >> 16) & 0xFF; - gr_set_color_modulation((int)r, (int)g, (int)b, raw_texture); - gr_set_semi_transparency(semi_trans, (int)semi_transparency); -} - -/* Execute textured triangle (GP0 0x24-0x27) */ -static void gp0_exec_textured_tri(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[3], vy[3]; - int u[3], v[3]; - /* Layout: color+v0, texcoord0+clut, v1, texcoord1+tpage, v2, texcoord2 */ - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[3], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[5], &vx[2], &vy[2]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[4] & 0xFF; v[1] = (gp0_cmd_buf[4] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[6] & 0xFF; v[2] = (gp0_cmd_buf[6] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - /* Texpage from word 4 bits 16-31 */ - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[4] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - - setup_textured_draw(color24, semi_trans, raw_texture); - const int precise = prepare_precise_triangle(1, 3, 5, vx, vy); - prepare_texture_triangle(precise, 1, 3, 5); - gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], - vx[1], vy[1], u[1], v[1], - vx[2], vy[2], u[2], v[2], - clut_x, clut_y, tpage); -} - -/* Execute textured quad (GP0 0x2C-0x2F) */ -static void gp0_exec_textured_quad(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[4], vy[4]; - int u[4], v[4]; - /* Layout: color+v0, tc0+clut, v1, tc1+tpage, v2, tc2, v3, tc3 */ - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[3], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[5], &vx[2], &vy[2]); - parse_vertex(gp0_cmd_buf[7], &vx[3], &vy[3]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[4] & 0xFF; v[1] = (gp0_cmd_buf[4] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[6] & 0xFF; v[2] = (gp0_cmd_buf[6] >> 8) & 0xFF; - u[3] = gp0_cmd_buf[8] & 0xFF; v[3] = (gp0_cmd_buf[8] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[4] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - - /* Widescreen: tagged billboard quads carry CPU-computed pixel offsets the - * GTE squash never saw — re-squash every X around the prim's anchor. */ - const int raw_w = abs(vx[1] - vx[0]) + 1, raw_h = abs(vy[2] - vy[0]) + 1; - int squashed = 0; - { - int32_t ws_ax; - if (ws_tagged_anchor(&ws_ax)) { - squashed = 1; - for (int i = 0; i < 4; i++) vx[i] = ws_scale_about(vx[i], ws_ax); - } - } - ws_hud_widget_quad(vx, vy); - ws_auto_ui_transform_quad(vx, vy); - ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop image stretch (no-op else) */ - ws_nw_hud_shift_vertices(vx, 4); - - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - - setup_textured_draw(color24, semi_trans, raw_texture); - - if (vy[0] == vy[1] && vy[2] == vy[3] && - vx[0] == vx[2] && vx[1] == vx[3] && - u[0] == u[2] && u[1] == u[3] && - v[0] == v[1] && v[2] == v[3]) { - int x = vx[0] < vx[1] ? vx[0] : vx[1]; - int y = vy[0] < vy[2] ? vy[0] : vy[2]; - int w = vx[0] < vx[1] ? vx[1] - vx[0] : vx[0] - vx[1]; - int h = vy[0] < vy[2] ? vy[2] - vy[0] : vy[0] - vy[2]; - int left_u = vx[0] < vx[1] ? u[0] : u[1]; - int right_u = vx[0] < vx[1] ? u[1] : u[0]; - int top_v = vy[0] < vy[2] ? v[0] : v[2]; - int bot_v = vy[0] < vy[2] ? v[2] : v[0]; - if (w > 0 && h > 0) { - geometry_diag_note_rectangle_fast_path(); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - float sides[4]; - const float sprite_depth = temporal_sprite_sides( - x - draw_offset_x, y - draw_offset_y, w, h, raw_w, raw_h, squashed, sides); - gr_set_temporal_sprite(sprite_depth, sides); -#endif - if (right_u - left_u == w && bot_v - top_v == h) { - gr_draw_textured_rect(x, y, w, h, left_u, top_v, - clut_x, clut_y, tpage); - return; - } - gr_draw_textured_rect_scaled(x, y, w, h, left_u, top_v, - right_u, bot_v, - clut_x, clut_y, tpage); - return; - } - } - - const int precise_indices[4] = { 1, 3, 5, 7 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - float texture_q[4]; - const int perspective_quad = resolve_texture_vertices( - precise_quad, precise_indices, 4, texture_q); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); - gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], - vx[1], vy[1], u[1], v[1], - vx[2], vy[2], u[2], v[2], - clut_x, clut_y, tpage); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); - gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], - vx[1], vy[1], u[1], v[1], - vx[3], vy[3], u[3], v[3], - clut_x, clut_y, tpage); - } -} - -/* Execute shaded textured triangle (GP0 0x34-0x37) */ -static void gp0_exec_shaded_textured_tri(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[3], vy[3]; - int u[3], v[3]; - uint32_t c[3]; - /* Layout: C0, V0, TC0+clut, C1, V1, TC1+tpage, C2, V2, TC2 */ - c[0] = gp0_cmd_buf[0] & 0xFFFFFFu; - c[1] = gp0_cmd_buf[3] & 0xFFFFFFu; - c[2] = gp0_cmd_buf[6] & 0xFFFFFFu; - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[4], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[7], &vx[2], &vy[2]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[5] & 0xFF; v[1] = (gp0_cmd_buf[5] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[8] & 0xFF; v[2] = (gp0_cmd_buf[8] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[5] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - if (psx_gpu_triangle_oversize(vx, vy, 0, 1, 2)) return; - - ws_nw_hud_shift_vertices(vx, 3); - for (int i = 0; i < 3; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 3)) return; - - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise = prepare_precise_triangle(1, 4, 7, vx, vy); - prepare_texture_triangle(precise, 1, 4, 7); - gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], - vx[1], vy[1], u[1], v[1], c[1], - vx[2], vy[2], u[2], v[2], c[2], - clut_x, clut_y, tpage, raw_texture); -} - -/* Execute shaded textured quad (GP0 0x3C-0x3F) */ -static void gp0_exec_shaded_textured_quad(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t vx[4], vy[4]; - int u[4], v[4]; - uint32_t c[4]; - /* Layout: C0, V0, TC0+clut, C1, V1, TC1+tpage, C2, V2, TC2, C3, V3, TC3 */ - c[0] = gp0_cmd_buf[0] & 0xFFFFFFu; - c[1] = gp0_cmd_buf[3] & 0xFFFFFFu; - c[2] = gp0_cmd_buf[6] & 0xFFFFFFu; - c[3] = gp0_cmd_buf[9] & 0xFFFFFFu; - parse_vertex(gp0_cmd_buf[1], &vx[0], &vy[0]); - parse_vertex(gp0_cmd_buf[4], &vx[1], &vy[1]); - parse_vertex(gp0_cmd_buf[7], &vx[2], &vy[2]); - parse_vertex(gp0_cmd_buf[10], &vx[3], &vy[3]); - u[0] = gp0_cmd_buf[2] & 0xFF; v[0] = (gp0_cmd_buf[2] >> 8) & 0xFF; - u[1] = gp0_cmd_buf[5] & 0xFF; v[1] = (gp0_cmd_buf[5] >> 8) & 0xFF; - u[2] = gp0_cmd_buf[8] & 0xFF; v[2] = (gp0_cmd_buf[8] >> 8) & 0xFF; - u[3] = gp0_cmd_buf[11] & 0xFF; v[3] = (gp0_cmd_buf[11] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - uint16_t tpage_word = (uint16_t)(gp0_cmd_buf[5] >> 16); - uint16_t tpage = tpage_word & 0x1FF; - set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ - int rej_a = psx_gpu_triangle_oversize(vx, vy, 0, 1, 2); - int rej_b = psx_gpu_triangle_oversize(vx, vy, 2, 1, 3); - if (rej_a && rej_b) return; - - ws_auto_ui_transform_quad(vx, vy); - ws_nw_hud_shift_vertices(vx, 4); - for (int i = 0; i < 4; i++) { - vx[i] += draw_offset_x; - vy[i] += draw_offset_y; - } - if (draw_area_out_bbox(vx, vy, 4)) return; - - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - const int precise_indices[4] = { 1, 4, 7, 10 }; - int32_t precise_x[4], precise_y[4]; - const int precise_quad = prepare_precise_quad_vertices( - precise_indices, (!rej_a) + (!rej_b), - vx, vy, precise_x, precise_y); - float texture_q[4]; - const int perspective_quad = resolve_texture_vertices( - precise_quad, precise_indices, 4, texture_q); - if (!rej_a) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 0, 1, 2); - queue_texture_triangle(perspective_quad, texture_q, 0, 1, 2); - gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], - vx[1], vy[1], u[1], v[1], c[1], - vx[2], vy[2], u[2], v[2], c[2], - clut_x, clut_y, tpage, raw_texture); - } - if (!rej_b) { - queue_precise_triangle(precise_quad, precise_x, precise_y, 2, 1, 3); - queue_texture_triangle(perspective_quad, texture_q, 2, 1, 3); - gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], - vx[1], vy[1], u[1], v[1], c[1], - vx[3], vy[3], u[3], v[3], c[3], - clut_x, clut_y, tpage, raw_texture); - } -} - -/* Execute mono line (GP0 0x40-0x47) — Bresenham */ -static void gp0_exec_mono_line(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0, x1, y1; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - parse_vertex(gp0_cmd_buf[2], &x1, &y1); - if (psx_gpu_line_oversize(x0, y0, x1, y1)) return; - int32_t vx[2] = { x0, x1 }; - ws_nw_hud_shift_vertices(vx, 2); - x0 = vx[0]; x1 = vx[1]; - x0 += draw_offset_x; y0 += draw_offset_y; - x1 += draw_offset_x; y1 += draw_offset_y; - { - int32_t lx[2] = { x0, x1 }, ly[2] = { y0, y1 }; - if (draw_area_out_bbox(lx, ly, 2)) return; - } - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_line(x0, y0, x1, y1, color); -} - -/* Execute shaded line (GP0 0x50-0x57) */ -static void gp0_exec_shaded_line(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t c0 = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - uint16_t c1 = rgb888_to_rgb555(gp0_cmd_buf[2] & 0xFFFFFFu); - int32_t x0, y0, x1, y1; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - parse_vertex(gp0_cmd_buf[3], &x1, &y1); - if (psx_gpu_line_oversize(x0, y0, x1, y1)) return; - int32_t vx[2] = { x0, x1 }; - ws_nw_hud_shift_vertices(vx, 2); - x0 = vx[0]; x1 = vx[1]; - x0 += draw_offset_x; y0 += draw_offset_y; - x1 += draw_offset_x; y1 += draw_offset_y; - { - int32_t lx[2] = { x0, x1 }, ly[2] = { y0, y1 }; - if (draw_area_out_bbox(lx, ly, 2)) return; - } - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_shaded_line(x0, y0, c0, x1, y1, c1); -} - -/* Execute mono rectangle (GP0 0x60-0x63) */ -static void gp0_exec_mono_rect(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int w = gp0_cmd_buf[2] & 0xFFFFu; - int h = (gp0_cmd_buf[2] >> 16) & 0xFFFFu; - if (w > 1023) w = 1023; - if (h > 511) h = 511; - ws_expand_fullscreen_rect(&x0, y0, &w, h); - w = ws_menu_rect(&x0, w); - x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ - x0 += draw_offset_x; y0 += draw_offset_y; - if (draw_area_out_rect(x0, y0, w, h)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, w, h, color); -} - -/* Execute textured rectangle (GP0 0x64-0x67) */ -static void gp0_exec_textured_rect(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - int w = gp0_cmd_buf[3] & 0x3FF; - int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; - if (w > 1023) w = 1023; - if (h > 511) h = 511; - - /* Widescreen: tagged sprite parts squash around their projected anchor; - * untagged SPRTs are screen-space 2D (HUD/menus) and squash around the - * display centre. Texels keep full coverage via the scaled-rect path. */ - int ws_w = 0, ws_h = 0; - const int menu_page = texpage_y != 0 || texpage_colors != 1 || clut_x > 1024 - 256 ? 0 : texpage_x == PSX_WS_MENU_X / 64 ? 1 : texpage_x == (PSX_WS_MENU_X + 128) / 64 ? 2 : 0; /* a palette that runs off its row is not the picture's */ - const int menu_shape = psx_ws_menu_backdrop_half(x0, y0, w, h) & menu_page; - if (ws_menu_due) { /* the front end's frame begins with its backdrop */ - ws_menu_due = 0; - if (!menu_shape) ws_menu_give_back(); - } - if (menu_shape) { - ws_menu_clut_x = clut_x; - ws_menu_clut_y = clut_y; - if (!ws_menu_logo) ws_menu_take_picture(); /* a save state or a read took it away, or the screen has become wide */ - } - const int menu_half = ws_menu_logo ? menu_shape : 0; - if (menu_half && ws_menu_wanted()) psx_ws_frame_kinds_menu_begin(&ws_frame_kinds); - if (ws_menu_frame()) { - if (!menu_half) ws_w = ws_menu_rect(&x0, w); /* the backdrop itself is stretched */ - } else if (ws_active() && w > 0) { - int32_t ws_ax; - if (ws_screen_tile_take()) { - /* Both edges go through one mapping, so neighbours keep touching. */ - const int32_t centre = ws_disp_w() / 2; - const int32_t right = - psx_ws_squash_about(x0 + w, centre, ws_xnum, ws_xden); - x0 = psx_ws_squash_about(x0, centre, ws_xnum, ws_xden); - if (right <= x0) return; - ws_w = (int)(right - x0); - } else if (ws_tagged_anchor(&ws_ax)) { - x0 = ws_scale_about(x0, ws_ax); - ws_w = (int)ws_scale_len(w); - } else { - int corrected_w = w; - int32_t widget_w = w, widget_h = h; - if (ws_hud_widget_rect(&x0, &y0, &widget_w, &widget_h)) { - ws_w = (int)widget_w; - ws_h = (int)widget_h; - } else if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) - ws_w = corrected_w; - else if (ws_hud_sprt) { - int scaled_w = w, scaled_h = h; - if (ws_hud_user_scale(&x0, &y0, &scaled_w, &scaled_h)) { - ws_w = scaled_w; - ws_h = scaled_h; - } else { - x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); - ws_w = (int)ws_scale_len(w); - } - } - } - } - x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ - - x0 += draw_offset_x; y0 += draw_offset_y; - const int dw = ws_w ? ws_w : w, dh = ws_h ? ws_h : h; - if (draw_area_out_rect(x0, y0, dw, dh)) return; - setup_textured_draw(color24, semi_trans, raw_texture); - if (dw != w || dh != h) - gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + w, v0 + h, - clut_x, clut_y, current_texpage()); - else - gr_draw_textured_rect(x0, y0, w, h, u0, v0, clut_x, clut_y, current_texpage()); - if (!menu_half) return; - if (ws_menu_halves & menu_half) ws_menu_halves = 0; - ws_menu_halves |= menu_half; - if (ws_menu_halves != 3) return; - ws_menu_halves = 0; - int32_t logo_left = PSX_WS_MENU_LOGO_LEFT, logo_right = PSX_WS_MENU_LOGO_RIGHT; /* where it was when the screen is not wide after all */ - if (ws_menu_frame()) psx_ws_menu_logo_span(PSX_WS_MENU_WIDE / 2, ws_xnum, ws_xden, &logo_left, &logo_right); - gr_draw_textured_rect_scaled(logo_left + draw_offset_x, PSX_WS_MENU_LOGO_TOP + draw_offset_y, logo_right - logo_left, PSX_WS_MENU_LOGO_ROWS, - 0, 0, PSX_WS_MENU_LOGO_WIDE, PSX_WS_MENU_LOGO_ROWS, - clut_x, clut_y, PSX_WS_MENU_KEPT_PAGE); -} - -/* Execute 1x1 dot (GP0 0x68-0x6B) */ -static void gp0_exec_mono_dot(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x, y; - parse_vertex(gp0_cmd_buf[1], &x, &y); - (void)ws_menu_rect(&x, 1); - x += ws_nw_hud_shift(x, 1); - x += draw_offset_x; y += draw_offset_y; - if (draw_area_out_point(x, y)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x, y, 1, 1, color); -} - -/* Execute 8x8 textured sprite (GP0 0x74-0x77) */ -static void gp0_exec_textured_8x8(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int ws_h = 0; - int ws_w = ws_sprt_fixed_transform(&x0, &y0, 8, &ws_h); - x0 += ws_nw_hud_shift(x0, 8); /* native-wide HUD corner re-anchor (no-op else) */ - x0 += draw_offset_x; y0 += draw_offset_y; - const int dw = ws_w ? ws_w : 8, dh = ws_h ? ws_h : 8; - if (draw_area_out_rect(x0, y0, dw, dh)) return; - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - - setup_textured_draw(color24, semi_trans, raw_texture); - if (dw != 8 || dh != 8) - gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 8, v0 + 8, - clut_x, clut_y, current_texpage()); - else - gr_draw_textured_rect(x0, y0, 8, 8, u0, v0, clut_x, clut_y, current_texpage()); -} - -/* Execute 8x8 sprite (GP0 0x70-0x73) */ -static void gp0_exec_mono_8x8(void) { - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - const int w = ws_menu_rect(&x0, 8); - x0 += ws_nw_hud_shift(x0, 8); - x0 += draw_offset_x; y0 += draw_offset_y; - if (draw_area_out_rect(x0, y0, w, 8)) return; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, w, 8, color); -} - -/* Execute 16x16 textured sprite (GP0 0x7C-0x7F) */ -static void gp0_exec_textured_16x16(void) { - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - /* Never suppress MMX6 BG packets at a guessed finite-map boundary. The - * classifier cannot distinguish an authored layer entering the reveal from - * a stale ring slot; suppressing here caused the stage-start black flicker. */ - int ws_h = 0; - int ws_w = ws_sprt_fixed_transform(&x0, &y0, 16, &ws_h); - x0 += ws_nw_hud_shift(x0, 16); /* native-wide HUD corner re-anchor (no-op else) */ - x0 += draw_offset_x; y0 += draw_offset_y; - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - - const int dw = ws_w ? ws_w : 16, dh = ws_h ? ws_h : 16; - if (draw_area_out_rect(x0, y0, dw, dh)) return; - setup_textured_draw(color24, semi_trans, raw_texture); - if (dw != 16 || dh != 16) - gr_draw_textured_rect_scaled(x0, y0, dw, dh, u0, v0, u0 + 16, v0 + 16, - clut_x, clut_y, current_texpage()); - else - gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage()); -} - -/* ---- GP0 command execution ---- */ - -static void gp0_exec_nop(void) { - /* 0x00 / 0x01 — nothing to do */ -} - -static void gp0_exec_fill_rect(void) { - /* 0x02 — Fill Rectangle in VRAM - * Word 0: 0x02BBGGRR (color) - * Word 1: YstartXstart (X bits 0-9 aligned to 16, Y bits 16-24) - * Word 2: YsizXsiz (W bits 0-9 rounded up to 16, H bits 16-24) */ - uint32_t color24 = gp0_cmd_buf[0] & 0x00FFFFFF; - uint16_t color16 = rgb888_to_rgb555(color24); - - uint32_t dst_x = gp0_cmd_buf[1] & 0x3F0u; /* X masked to 16-pixel alignment */ - uint32_t dst_y = (gp0_cmd_buf[1] >> 16) & 0x1FFu; - uint32_t width = ((gp0_cmd_buf[2] & 0x3FFu) + 0xFu) & ~0xFu; /* round up to 16 */ - uint32_t height = (gp0_cmd_buf[2] >> 16) & 0x1FFu; - - /* Fill ignores draw area, mask bits, and draw offset — writes directly to - * VRAM. Routed through the renderer so it also fills the hi-res - * supersampling mirror (no-op cost when supersampling is off). */ - ws_menu_yield((int)dst_x, (int)dst_y, (int)width, (int)height, 1); - gr_fill_rect((int)dst_x, (int)dst_y, (int)width, (int)height, color16); - - /* Keep every active presentation mirror in lockstep with the canonical - * framebuffer clear. The exact-geometry mirror is also used at authentic - * 4:3, where this file's ws_engaged() is deliberately false; gating it on - * wide mode left prior frames resident and produced persistent trails. */ - if ((ws_native_wide_active() || ws_geometry_correction) && - ws_is_fb_base(dst_x)) { - gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); - } -} - -static void gp0_exec_draw_mode(void) { - /* 0xE1 — Draw Mode / Texpage - * Bits 0-3: texpage X base - * Bit 4: texpage Y base - * Bits 5-6: semi-transparency mode - * Bits 7-8: texture color mode - * Bit 9: dither - * Bit 10: draw to display area - * Bit 11: texture disable (when allowed by GP1) */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - texpage_x = param & 0xF; - texpage_y = (param >> 4) & 1; - semi_transparency = (param >> 5) & 3; - texpage_colors = (param >> 7) & 3; - dither_enabled = (param >> 9) & 1; - draw_to_display = (param >> 10) & 1; - texture_disable = (param >> 11) & 1; - /* Sync semi-transparency to SW renderer — actual blending is per-primitive */ -} - -static void gp0_exec_texture_window(void) { - /* 0xE2 — Texture Window - * Bits 0-4: mask X (in 8-pixel steps) - * Bits 5-9: mask Y - * Bits 10-14: offset X - * Bits 15-19: offset Y */ - texture_window_value = gp0_cmd_buf[0] & 0x000FFFFFu; - gr_set_texture_window(texture_window_value); -} - -/* The game may draw anywhere in its drawing area. */ -static void ws_menu_yield_drawing_area(void) { - ws_menu_yield((int)draw_area_left, (int)draw_area_top, (int)draw_area_right - (int)draw_area_left + 1, (int)draw_area_bottom - (int)draw_area_top + 1, 1); -} - -static void gp0_exec_draw_area_tl(void) { - /* 0xE3 — Set Drawing Area Top-Left - * Bits 0-9: X - * Bits 10-19: Y */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - draw_area_left = param & 0x3FF; - draw_area_top = (param >> 10) & 0x3FF; - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - psx_ws_hud_widgets_frame(&ws_hud); - ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ - ws_menu_due = 1; - ws_menu_halves = 0; - ws_menu_yield_drawing_area(); - psx_ws_frame_kinds_area(&ws_frame_kinds, draw_area_left, draw_area_top, WS_GTE_GAME_MODE_MIN_VERTS); -} - -static void gp0_exec_draw_area_br(void) { - /* 0xE4 — Set Drawing Area Bottom-Right - * Bits 0-9: X - * Bits 10-19: Y */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - draw_area_right = param & 0x3FF; - draw_area_bottom = (param >> 10) & 0x3FF; - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - ws_menu_yield_drawing_area(); - ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ -} - -static void gp0_exec_draw_offset(void) { - /* 0xE5 — Set Drawing Offset - * Bits 0-10: X (signed 11-bit) - * Bits 11-21: Y (signed 11-bit) */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - draw_offset_x = sign_extend(param & 0x7FFu, 11); - draw_offset_y = sign_extend((param >> 11) & 0x7FFu, 11); - if (draw_offset_y < g_doff_min_this) g_doff_min_this = draw_offset_y; - if (draw_offset_y > g_doff_max_this) g_doff_max_this = draw_offset_y; - g_doff_cnt_this++; - /* Native-wide mirrors framebuffer draws into a separate wide surface (canonical - * VRAM stays faithful); the renderer needs the live draw offset to translate - * into surface-local coords, so keep gr_set_draw_offset current. */ - gr_set_draw_offset(draw_offset_x, draw_offset_y); -} - -static void gp0_exec_mask_bit(void) { - /* 0xE6 — Mask Bit Setting - * Bit 0: set mask bit when drawing (force bit 15 of pixels) - * Bit 1: check mask bit (don't draw to pixels with bit 15 set) */ - uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; - set_mask_bit = param & 1; - check_mask_bit = (param >> 1) & 1; - gr_set_mask_bits((int)set_mask_bit, (int)check_mask_bit); -} - -/* A0 upload history for debug inspection */ -#define A0_HISTORY_CAP 128 -typedef struct { - uint16_t x, y, w, h; - uint32_t first_words[4]; - int word_count; - uint32_t func_addr; - uint32_t sp_val; /* CPU $sp at time of A0 header */ - uint32_t ra_val; /* CPU $ra register at time of A0 header */ - uint32_t s1_val; /* CPU $s1 = LoadImage RECT ptr (x/y/w/h) in FUN_80069dfc */ - uint32_t s2_val; /* CPU $s2 = TRUE source pixel ptr (`move s2,a1` at entry) */ - uint32_t a0_val; /* CPU $a0 at capture (RECT arg; may be clobbered) */ - uint32_t a1_val; /* CPU $a1 at capture (source arg; may be clobbered) */ - uint32_t frame_stamp; /* s_frame_count at the upload — for load-vs-upload ordering */ - uint32_t stack[10]; /* first 10 words from sp (sp+32=saved $s1, sp+36=saved $ra) */ -} A0HistEntry; -static A0HistEntry a0_history[A0_HISTORY_CAP]; -static int a0_history_count = 0; -static int a0_capture_slot = -1; /* currently capturing data words for this slot */ - -extern uint32_t psx_read_word(uint32_t addr); - -int gpu_get_a0_history(int index, int *x, int *y, int *w, int *h, - uint32_t *fw0, uint32_t *fw1, int *wcount) { - if (index < 0 || index >= a0_history_count) return 0; - *x = a0_history[index].x; *y = a0_history[index].y; - *w = a0_history[index].w; *h = a0_history[index].h; - *fw0 = a0_history[index].first_words[0]; - *fw1 = a0_history[index].first_words[1]; - *wcount = a0_history[index].word_count; - return 1; -} -int gpu_get_a0_count(void) { return a0_history_count; } -int gpu_get_a0_extra(int index, uint32_t *func, uint32_t *sp, uint32_t *ra, - uint32_t *s1, uint32_t *stack10) { - if (index < 0 || index >= a0_history_count) return 0; - *func = a0_history[index].func_addr; - *sp = a0_history[index].sp_val; - *ra = a0_history[index].ra_val; - *s1 = a0_history[index].s1_val; - memcpy(stack10, a0_history[index].stack, 10 * sizeof(uint32_t)); - return 1; -} -/* True source pointer + arg regs + frame for the LoadImage upload (s2 = source). */ -int gpu_get_a0_src(int index, uint32_t *s2, uint32_t *a0, uint32_t *a1, uint32_t *frame) { - if (index < 0 || index >= a0_history_count) return 0; - *s2 = a0_history[index].s2_val; - *a0 = a0_history[index].a0_val; - *a1 = a0_history[index].a1_val; - *frame = a0_history[index].frame_stamp; - return 1; -} - -static void gp0_exec_cpu_to_vram(void) { - /* 0xA0 — CPU→VRAM Copy (header: 3 words) - * Word 0: command - * Word 1: destination coords (X bits 0-9, Y bits 16-24) - * Word 2: dimensions (W bits 0-9, H bits 16-24) - * Followed by pixel data words */ - vram_write_x = gp0_cmd_buf[1] & 0x3FFu; - vram_write_y = (gp0_cmd_buf[1] >> 16) & 0x1FFu; - - uint32_t w = gp0_cmd_buf[2] & 0x3FFu; - uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; - /* 0 means max dimension */ - vram_write_w = (w == 0) ? 0x400 : (uint16_t)w; - vram_write_h = (h == 0) ? 0x200 : (uint16_t)h; - if (!psx_ws_menu_is_picture(vram_write_x, vram_write_y, vram_write_w, vram_write_h)) /* the whole picture is dealt with once it has arrived */ - ws_menu_yield(vram_write_x, vram_write_y, vram_write_w, vram_write_h, 1); /* before its words land */ - - ws_frame_pictured(vram_write_x, vram_write_y, vram_write_w, vram_write_h); - /* Record for debug */ - if (a0_history_count < A0_HISTORY_CAP) { - int slot = a0_history_count++; - a0_history[slot].x = vram_write_x; - a0_history[slot].y = vram_write_y; - a0_history[slot].w = vram_write_w; - a0_history[slot].h = vram_write_h; - a0_history[slot].word_count = 0; - a0_history[slot].func_addr = g_debug_current_func_addr; - memset(a0_history[slot].first_words, 0, sizeof(a0_history[slot].first_words)); - /* Capture CPU context for caller tracing. - * func_1FC38524 (shell LoadImage) uses $s1 as source data pointer. */ - a0_history[slot].sp_val = 0; - a0_history[slot].ra_val = 0; - a0_history[slot].s1_val = 0; - a0_history[slot].s2_val = 0; - a0_history[slot].a0_val = 0; - a0_history[slot].a1_val = 0; - a0_history[slot].frame_stamp = (uint32_t)s_frame_count; - memset(a0_history[slot].stack, 0, sizeof(a0_history[slot].stack)); - if (debug_cpu_ptr) { - uint32_t sp = debug_cpu_ptr->gpr[29]; - a0_history[slot].sp_val = sp; - a0_history[slot].ra_val = debug_cpu_ptr->gpr[31]; - a0_history[slot].s1_val = debug_cpu_ptr->gpr[17]; /* $s1 */ - a0_history[slot].s2_val = debug_cpu_ptr->gpr[18]; /* $s2 = source ptr */ - a0_history[slot].a0_val = debug_cpu_ptr->gpr[4]; /* $a0 */ - a0_history[slot].a1_val = debug_cpu_ptr->gpr[5]; /* $a1 */ - uint32_t sp_phys = sp & 0x1FFFFFu; - for (int si = 0; si < 10 && sp_phys + (si + 1) * 4 <= 0x200000u; si++) - a0_history[slot].stack[si] = psx_read_word(sp + si * 4); - } - a0_capture_slot = slot; - } - - vram_write_col = 0; - vram_write_row = 0; - - uint32_t num_pixels = (uint32_t)vram_write_w * (uint32_t)vram_write_h; - vram_write_remaining = (num_pixels + 1) / 2; - - if (vram_write_remaining > 0) - gp0_state = GP0_VRAM_WRITE; -} - -/* C0 (VRAM→CPU) history — uses c0_history_fwd declared at top of file */ -#define C0_HISTORY_CAP C0_HISTORY_CAP_FWD -#define c0_history c0_history_fwd -#define c0_history_count c0_history_count_fwd -#define c0_capture_slot c0_capture_slot_fwd - -int gpu_get_c0_count(void) { return c0_history_count; } -int gpu_get_c0_history(int index, int *x, int *y, int *w, int *h, - uint32_t *func, uint32_t *sp, uint32_t *s1, - uint32_t *fw0, uint32_t *fw1, int *rcount) { - if (index < 0 || index >= c0_history_count) return 0; - *x = c0_history[index].x; *y = c0_history[index].y; - *w = c0_history[index].w; *h = c0_history[index].h; - *func = c0_history[index].func_addr; - *sp = c0_history[index].sp_val; - *s1 = c0_history[index].s1_val; - *fw0 = c0_history[index].first_words[0]; - *fw1 = c0_history[index].first_words[1]; - *rcount = c0_history[index].read_count; - return 1; -} - -static void gp0_exec_vram_to_cpu(void) { - /* 0xC0 — VRAM→CPU Copy (3 words) - * Word 1: source coords - * Word 2: dimensions - * After this, data is read via GPUREAD */ - vram_read_x = gp0_cmd_buf[1] & 0x3FFu; - vram_read_y = (gp0_cmd_buf[1] >> 16) & 0x1FFu; - - uint32_t w = gp0_cmd_buf[2] & 0x3FFu; - uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; - vram_read_w = (w == 0) ? 0x400 : (uint16_t)w; - vram_read_h = (h == 0) ? 0x200 : (uint16_t)h; - ws_menu_yield(vram_read_x, vram_read_y, vram_read_w, vram_read_h, 0); - - /* Record for debug */ - if (c0_history_count < C0_HISTORY_CAP) { - int slot = c0_history_count++; - c0_history[slot].x = vram_read_x; - c0_history[slot].y = vram_read_y; - c0_history[slot].w = vram_read_w; - c0_history[slot].h = vram_read_h; - c0_history[slot].func_addr = g_debug_current_func_addr; - c0_history[slot].sp_val = debug_cpu_ptr ? debug_cpu_ptr->gpr[29] : 0; - c0_history[slot].s1_val = debug_cpu_ptr ? debug_cpu_ptr->gpr[17] : 0; - memset(c0_history[slot].first_words, 0, sizeof(c0_history[slot].first_words)); - c0_history[slot].read_count = 0; - c0_capture_slot = slot; - } - - vram_read_col = 0; - vram_read_row = 0; - vram_read_active = 1; -} - -/* Determine how many words a GP0 command requires (header only, not counting - * variable-length data for 0xA0). Returns -1 for polylines (terminated by - * sentinel). Returns 0 for unknown commands (will be fatal). */ -static int gp0_command_word_count(uint8_t opcode) { - switch (opcode) { - /* NOP / control */ - case 0x00: return 1; - case 0x01: return 1; /* clear cache */ - case 0x02: return 3; /* fill rect */ - case 0x1F: return 1; /* IRQ request */ - - /* Drawing commands — polygons */ - case 0x20: case 0x21: case 0x22: case 0x23: return 4; /* mono tri */ - case 0x24: case 0x25: case 0x26: case 0x27: return 7; /* textured tri */ - case 0x28: case 0x29: case 0x2A: case 0x2B: return 5; /* mono quad */ - case 0x2C: case 0x2D: case 0x2E: case 0x2F: return 9; /* textured quad */ - case 0x30: case 0x31: case 0x32: case 0x33: return 6; /* shaded tri */ - case 0x34: case 0x35: case 0x36: case 0x37: return 9; /* shaded textured tri */ - case 0x38: case 0x39: case 0x3A: case 0x3B: return 8; /* shaded quad */ - case 0x3C: case 0x3D: case 0x3E: case 0x3F: return 12; /* shaded textured quad */ - - /* Lines */ - case 0x40: case 0x41: case 0x42: case 0x43: - case 0x44: case 0x45: case 0x46: case 0x47: return 3; /* mono line */ - case 0x48: case 0x49: case 0x4A: case 0x4B: - case 0x4C: case 0x4D: case 0x4E: case 0x4F: return -1; /* mono polyline */ - case 0x50: case 0x51: case 0x52: case 0x53: - case 0x54: case 0x55: case 0x56: case 0x57: return 4; /* shaded line */ - case 0x58: case 0x59: case 0x5A: case 0x5B: - case 0x5C: case 0x5D: case 0x5E: case 0x5F: return -1; /* shaded polyline */ - - /* Rectangles */ - case 0x60: case 0x61: case 0x62: case 0x63: return 3; /* variable rect */ - case 0x64: case 0x65: case 0x66: case 0x67: return 4; /* variable textured rect */ - case 0x68: case 0x69: case 0x6A: case 0x6B: return 2; /* 1x1 dot */ - case 0x6C: case 0x6D: case 0x6E: case 0x6F: return 3; /* 1x1 textured */ - case 0x70: case 0x71: case 0x72: case 0x73: return 2; /* 8x8 rect */ - case 0x74: case 0x75: case 0x76: case 0x77: return 3; /* 8x8 textured */ - case 0x78: case 0x79: case 0x7A: case 0x7B: return 2; /* 16x16 rect */ - case 0x7C: case 0x7D: case 0x7E: case 0x7F: return 3; /* 16x16 textured */ - - /* VRAM copy / transfer */ - case 0x80: case 0x81: case 0x82: case 0x83: - case 0x84: case 0x85: case 0x86: case 0x87: - case 0x88: case 0x89: case 0x8A: case 0x8B: - case 0x8C: case 0x8D: case 0x8E: case 0x8F: - case 0x90: case 0x91: case 0x92: case 0x93: - case 0x94: case 0x95: case 0x96: case 0x97: - case 0x98: case 0x99: case 0x9A: case 0x9B: - case 0x9C: case 0x9D: case 0x9E: case 0x9F: return 4; /* VRAM→VRAM */ - - case 0xA0: case 0xA1: case 0xA2: case 0xA3: - case 0xA4: case 0xA5: case 0xA6: case 0xA7: - case 0xA8: case 0xA9: case 0xAA: case 0xAB: - case 0xAC: case 0xAD: case 0xAE: case 0xAF: - case 0xB0: case 0xB1: case 0xB2: case 0xB3: - case 0xB4: case 0xB5: case 0xB6: case 0xB7: - case 0xB8: case 0xB9: case 0xBA: case 0xBB: - case 0xBC: case 0xBD: case 0xBE: case 0xBF: return 3; /* CPU→VRAM (header) */ - - case 0xC0: case 0xC1: case 0xC2: case 0xC3: - case 0xC4: case 0xC5: case 0xC6: case 0xC7: - case 0xC8: case 0xC9: case 0xCA: case 0xCB: - case 0xCC: case 0xCD: case 0xCE: case 0xCF: - case 0xD0: case 0xD1: case 0xD2: case 0xD3: - case 0xD4: case 0xD5: case 0xD6: case 0xD7: - case 0xD8: case 0xD9: case 0xDA: case 0xDB: - case 0xDC: case 0xDD: case 0xDE: case 0xDF: return 3; /* VRAM→CPU (header) */ - - /* Environment */ - case 0xE0: return 1; /* NOP */ - case 0xE1: return 1; /* draw mode */ - case 0xE2: return 1; /* texture window */ - case 0xE3: return 1; /* draw area TL */ - case 0xE4: return 1; /* draw area BR */ - case 0xE5: return 1; /* draw offset */ - case 0xE6: return 1; /* mask bits */ - - default: - /* 0x03-0x1E, 0xE7-0xEF, 0xFF: NOP (1 word) per DuckStation */ - if ((opcode >= 0x03 && opcode <= 0x1E) || - (opcode >= 0xE7 && opcode <= 0xEF) || - opcode == 0xFF) { - return 1; - } - return 0; /* unknown */ - } -} - -static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, - uint16_t rank) { - if (ws_ui_prepass_count >= WS_UI_PREPASS_MAX || rank == 0xFFFFu) - return; - uint32_t op = words[0] >> 24; - int32_t min_x, max_x, min_y, max_y; - - if (op >= 0x20u && op <= 0x3Fu && (op & 0x04u) && - (op & 0x08u)) { - int indices[4]; - if (op & 0x10u) { - indices[0] = 1; indices[1] = 4; - indices[2] = 7; indices[3] = 10; - } else { - indices[0] = 1; indices[1] = 3; - indices[2] = 5; indices[3] = 7; - } - int32_t vx[4], vy[4]; - for (int i = 0; i < 4; i++) - parse_vertex(words[indices[i]], &vx[i], &vy[i]); - if (!ws_axis_aligned_quad(vx, vy)) return; - min_x = max_x = vx[0]; min_y = max_y = vy[0]; - for (int i = 1; i < 4; i++) { - if (vx[i] < min_x) min_x = vx[i]; - if (vx[i] > max_x) max_x = vx[i]; - if (vy[i] < min_y) min_y = vy[i]; - if (vy[i] > max_y) max_y = vy[i]; - } - } else if ((op >= 0x64u && op <= 0x67u) || - (op >= 0x74u && op <= 0x77u) || - (op >= 0x7Cu && op <= 0x7Fu)) { - parse_vertex(words[1], &min_x, &min_y); - int32_t width, height; - if (op >= 0x64u && op <= 0x67u) { - width = (int32_t)(words[3] & 0x3FFu); - height = (int32_t)((words[3] >> 16) & 0x1FFu); - } else { - width = height = op >= 0x7Cu ? 16 : 8; - } - if (width <= 0 || height <= 0) return; - max_x = min_x + width; - max_y = min_y + height; - } else { - return; - } - - int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || - (width > W / 2 && height > H / 4)) - return; - - WsUiPrepassItem *item = &ws_ui_prepass[ws_ui_prepass_count++]; - item->group.key = - ws_auto_ui_group_key_words(words, op, min_y, height); - item->group.x = min_x; - item->group.width = width; - item->group.anchor = 0; - item->src_addr = psx_mod_gpu_dma_resolve_address(source_addr); - item->ot_rank = rank; -} - -void gpu_ws_prepass_linked_list(uint32_t start_addr) { - ws_ui_prepass_count = 0; - ws_ui_prepass_rank = 0xFFFFu; - ws_auto_ui_dense = 0; - if (!ws_auto_ui_squash || !ws_active()) return; - - uint32_t addr = psx_mod_gpu_dma_resolve_address(start_addr); - uint32_t safety = 0; - uint16_t rank = 0xFFFFu; - const uint32_t max_nodes = 0x40000u; - - for (;;) { - if (safety++ > max_nodes) { - ws_ui_prepass_count = 0; - return; - } - uint32_t header = psx_read_word(addr); - uint32_t num_words = (header >> 24) & 0xFFu; - if (num_words == 0) { - rank = rank == 0xFFFFu ? 0u : (uint16_t)(rank + 1u); - } else if (rank != 0xFFFFu) { - uint32_t word_addr = - psx_mod_gpu_dma_resolve_address(addr + 4u); - uint32_t offset = 0; - while (offset < num_words) { - uint32_t first = psx_read_word( - psx_mod_gpu_dma_resolve_address( - word_addr + offset * 4u)); - uint8_t op = (uint8_t)(first >> 24); - int count = gp0_command_word_count(op); - if (count <= 0 || offset + (uint32_t)count > num_words) - break; - /* CPU->VRAM data follows its 3-word header and is not a command - * stream. Such transfers are not UI draws; stop this node. */ - if (op >= 0xA0u && op <= 0xBFu) break; - uint32_t words[12] = {0}; - for (int i = 0; i < count && i < 12; i++) { - words[i] = psx_read_word( - psx_mod_gpu_dma_resolve_address( - word_addr + (offset + (uint32_t)i) * 4u)); - } - ws_ui_prepass_add(words, - psx_mod_gpu_dma_resolve_address( - word_addr + offset * 4u), rank); - offset += (uint32_t)count; - } - } - - uint32_t next = header & 0xFFFFFFu; - if (next == 0xFFFFFFu) break; - addr = psx_mod_gpu_dma_resolve_address(next); - } - if (ws_ui_prepass_count == 0) { - ws_ui_prepass_count = 0; - return; - } - - /* Empty ordering-table buckets can trail the actual frontmost layer. - * Selecting the last empty bucket made the memory-card glyph layer (rank - * 4095 followed by an empty rank 4096) disappear from the correction - * pass. Pick the highest rank that contains an eligible UI primitive. */ - uint16_t max_rank = ws_ui_prepass[0].ot_rank; - for (uint32_t i = 1; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].ot_rank > max_rank) - max_rank = ws_ui_prepass[i].ot_rank; - } - ws_ui_prepass_rank = max_rank; - - uint32_t out = 0; - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { - if (ws_ui_prepass[i].ot_rank == max_rank) - ws_ui_prepass[out++] = ws_ui_prepass[i]; - } - ws_ui_prepass_count = out; - ws_auto_ui_dense = ws_ui_prepass_count >= 32u; - if (ws_ui_prepass_count == 0) return; - - WsUiGroupItem groups[WS_UI_PREPASS_MAX]; - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) - groups[i] = ws_ui_prepass[i].group; - ws_ui_group_assign(groups, ws_ui_prepass_count, ws_disp_w(), - ws_auto_ui_dense); - for (uint32_t i = 0; i < ws_ui_prepass_count; i++) - ws_ui_prepass[i].group.anchor = groups[i].anchor; -} - -/* Per-opcode execution counters (exposed via gpu_get_opcode_stats) */ -static uint32_t gp0_opcode_count[256]; - -uint32_t gpu_get_opcode_count(uint8_t op) { return gp0_opcode_count[op]; } - -/* ---- Per-frame GP0 command ring (always-on, queried via debug server) ---- */ -/* We record every GP0 command (header + up to 6 payload words) with the - * frame number it was issued in. Per CLAUDE.md ring-buffer rule: capture - * is continuous and observers query a window of interest later, not arm- - * then-record. ~34 MB at 1M entries. Polyline / long commands get the - * first 6 payload words; that's enough for the header + first vertex pair - * + first uv/color word for diagnosing per-primitive state. */ - -extern uint64_t s_frame_count; /* defined in debug_server.c */ -extern uint32_t g_debug_last_store_pc; /* defined in debug_server.c */ -extern uint32_t g_debug_current_func_addr; /* defined in debug_server.c */ -uint32_t debug_guest_ra(void); /* accessor in debug_server.c (guest $ra) */ -uint32_t debug_guest_sp(void); /* accessor in debug_server.c (guest $sp) */ -extern uint8_t *memory_get_ram_ptr(void); /* raw 2MB main-RAM base (no lockstep) */ - -/* Bounded guest-stack unwind for VRAM-copy builder attribution. Scans the live - * stack for words that are valid return addresses (main-RAM code range, and the - * instruction at ret-8 is a jal/jalr), innermost first. A consumer skips the - * libgpu funnel band to name the game-level routine that issued the copy. Pure - * reads against the raw RAM array — no lockstep/observer pollution. */ -uint32_t g_gp0_last_copy_sp = 0; /* diag: raw guest $sp at last op-0x80 copy */ -static void gp0_capture_builder_chain(uint32_t out[6]) { - for (int i = 0; i < 6; i++) out[i] = 0; - uint8_t *ram = memory_get_ram_ptr(); - if (!ram) return; - const uint32_t RMASK = 0x001FFFFFu; /* 2 MB, mirror-folded */ - uint32_t rawsp = debug_guest_sp(); - g_gp0_last_copy_sp = rawsp; - if ((rawsp & 0x1FFFFFFFu) >= 0x00800000u) return; /* not in RAM/mirror */ - uint32_t sp = rawsp & RMASK & ~3u; /* fold to 2MB, word-align */ - /* Two-pass: prefer words that are genuine return addresses (call at ret-8), - * but if guest code validation yields none, fall back to any code-range - * stack word so the chain is never silently empty. */ - int found = 0; - for (int pass = 0; pass < 2 && found == 0; pass++) { - for (uint32_t off = 0; off + 4 <= 0x400u && found < 6; off += 4) { - uint32_t w; memcpy(&w, ram + ((sp + off) & RMASK), 4); - if ((w & 3u) || w < 0x80010000u || w >= 0x80120000u) continue; - if (pass == 0) { - uint32_t ins; memcpy(&ins, ram + ((w - 8u) & RMASK), 4); - uint32_t op = ins >> 26; - int is_call = (op == 3u) || (op == 0u && (ins & 0x3Fu) == 9u); - if (!is_call) continue; - } - out[found++] = w; - } - } -} - -#define GP0_RING_CAP (1u << 20) /* 1 048 576 entries */ -/* GpuGp0RingEntry + GPU_GP0_RING_MAX_WORDS are public types in gpu.h. */ - -static GpuGp0RingEntry *gp0_ring = NULL; /* lazy-alloc on first record */ -static uint64_t gp0_ring_seq = 0; /* total commands recorded */ -static uint32_t gp0_ring_head = 0; /* next write slot */ - -static void gp0_ring_record(const uint32_t *words, int n) { - if (!gp0_ring) { - gp0_ring = (GpuGp0RingEntry *)calloc(GP0_RING_CAP, sizeof(*gp0_ring)); - if (!gp0_ring) return; /* OOM — capture disabled, no other effect */ - } - GpuGp0RingEntry *e = &gp0_ring[gp0_ring_head]; - e->frame = (uint32_t)s_frame_count; - e->seq = (uint32_t)gp0_ring_seq; - e->src_addr = gp0_cmd_source_addr; - e->pc = g_debug_last_store_pc; - e->func = g_debug_current_func_addr; - e->ra = debug_guest_ra(); - e->opcode = (uint8_t)((words[0] >> 24) & 0xFF); - e->n_words = (uint8_t)(n > 255 ? 255 : (n < 0 ? 1 : n)); - e->ot_rank = gp0_ot_rank; - int copy_n = e->n_words > GPU_GP0_RING_MAX_WORDS ? GPU_GP0_RING_MAX_WORDS : e->n_words; - for (int i = 0; i < copy_n; i++) e->cmd[i] = words[i]; - for (int i = copy_n; i < GPU_GP0_RING_MAX_WORDS; i++) e->cmd[i] = 0; - if (e->opcode == 0x80) { gp0_capture_builder_chain(e->bld); e->csp = g_gp0_last_copy_sp; } - else { for (int i = 0; i < 6; i++) e->bld[i] = 0; e->csp = 0; } - gp0_ring_head = (gp0_ring_head + 1) % GP0_RING_CAP; - gp0_ring_seq++; -} - -/* Public accessors for debug_server.c */ -uint64_t gpu_gp0_ring_total(void) { return gp0_ring_seq; } -uint32_t gpu_gp0_ring_capacity(void) { return GP0_RING_CAP; } -uint32_t gpu_gp0_ring_max_words(void){ return GPU_GP0_RING_MAX_WORDS; } - -void gpu_set_gp0_source(uint32_t addr) { - gp0_next_source_addr = addr; -} - -/* Fill `out[0..max_out-1]` with entries from the requested frame; returns - * count. Walks from oldest in-buffer to newest so iteration order matches - * draw order within a frame. */ -int gpu_gp0_ring_dump_frame(uint32_t frame, GpuGp0RingEntry *out, int max_out) { - if (!gp0_ring || max_out <= 0) return 0; - uint32_t avail = (gp0_ring_seq < GP0_RING_CAP) - ? (uint32_t)gp0_ring_seq : GP0_RING_CAP; - uint32_t start = (gp0_ring_seq < GP0_RING_CAP) ? 0 : gp0_ring_head; - int n_out = 0; - for (uint32_t i = 0; i < avail && n_out < max_out; i++) { - uint32_t idx = (start + i) % GP0_RING_CAP; - if (gp0_ring[idx].frame == frame) { - out[n_out++] = gp0_ring[idx]; - } - } - return n_out; -} - -/* Report the frame range currently held in the ring (oldest..newest). */ -void gpu_gp0_ring_frame_span(uint32_t *out_oldest, uint32_t *out_newest) { - if (out_oldest) *out_oldest = 0; - if (out_newest) *out_newest = 0; - if (!gp0_ring || gp0_ring_seq == 0) return; - uint32_t avail = (gp0_ring_seq < GP0_RING_CAP) - ? (uint32_t)gp0_ring_seq : GP0_RING_CAP; - uint32_t start = (gp0_ring_seq < GP0_RING_CAP) ? 0 : gp0_ring_head; - uint32_t newest_idx = (start + avail - 1) % GP0_RING_CAP; - if (out_oldest) *out_oldest = gp0_ring[start].frame; - if (out_newest) *out_newest = gp0_ring[newest_idx].frame; -} - -/* ---- Draw census ring (ALWAYS-ON) ----------------------------------------- - * Every drawn primitive (GP0 opcode 0x20-0x7F) records: frame, the prim's DMA - * source address, the live camera (scratchpad camX/camY), and the prim's first - * vertex in DRAWING space (pre draw_offset). Purpose: see object spawn/despawn - * and edge-cull in DATA, not screenshots. When a background object despawns, - * its prim simply stops appearing in the census at a specific camX while its - * last recorded screen-x shows how far on-screen it still was — i.e. the - * effective (4:3-sized) despawn margin, which the 16:9 view exceeds. A prim - * present in the census but absent on screen would instead indict the renderer - * gate. Query via TCP `ws_census` → CSV file (large dumps mustn't ride TCP). */ -#define WS_CENSUS_CAP (1u << 21) /* 2,097,152 entries * 24B = 48 MiB */ -typedef struct { - uint32_t frame; - uint32_t src_addr; - int16_t cam_x, cam_y; - int16_t x, y; /* first vertex (screen / pre-draw_offset) */ - int16_t xmin, xmax; /* screen-X extent across the prim's position verts */ - int16_t base_x; /* back-buffer origin (draw_area_left) at draw time */ - uint8_t opcode; - uint8_t tagged; /* psx_ws_prim_is_tagged() at draw time */ -} WsCensusEntry; -static WsCensusEntry *ws_census = NULL; -static uint64_t ws_census_seq = 0; -static int ws_census_on = 1; /* always-on; toggle via ws_census */ -extern uint16_t psx_read_half(uint32_t addr); - -/* Screen-X extent (raw vertex coords = SX, pre-draw_offset) across a prim's - * position vertices. Polygons (0x20-0x3F) decoded exactly via the packet stride - * (pos word = 1 + i*(1 + gouraud + textured)); rects/sprites/lines fall back to - * the first vertex. Lets the census show how far right each path reaches and - * whether anything is drawn past the 4:3 edge (native-wide margin diagnosis). */ -static void prim_sx_extent(uint8_t op, int32_t *xmin, int32_t *xmax) { - int32_t lo = 0x7FFF, hi = -0x8000; - if (op >= 0x20 && op <= 0x3F) { - int nv = (op & 0x08) ? 4 : 3; - int stride = 1 + ((op & 0x10) ? 1 : 0) + ((op & 0x04) ? 1 : 0); - for (int i = 0; i < nv; i++) { - int32_t vx, vy; - parse_vertex(gp0_cmd_buf[1 + i * stride], &vx, &vy); - if (vx < lo) lo = vx; - if (vx > hi) hi = vx; - } - } else { - int32_t vx, vy; - parse_vertex(gp0_cmd_buf[1], &vx, &vy); - lo = hi = vx; - } - *xmin = lo; *xmax = hi; -} - -/* Natural-overhang noter (see the ws_ovh_* block up top for the rationale). - * Called for every draw prim; counts POLYGONS whose raw SX extent crosses - * outside [0, disp_w] by more than the jitter margin, and stamps the frame - * (with the 2-consecutive-frames sustained rule) when enough do. */ -static void ws_note_overhang(uint8_t op) { - if (op < 0x20 || op > 0x3F) return; /* polygons only — the world's prims */ - /* Sprite-funnel (tagged) prims slide in from off-screen routinely — - * title-card letter tiles are tagged poly quads entering from the edges - * and stamped a sustained overhang inside the hut (observed at frame - * 20245). The WORLD funnel (Cluster-A RTPT terrain) never tags, so - * untagged overhang is the uncorrupted "revealable content" signal. - * Untagged-only also leaves non-tag titles unchanged (is_tagged == 0). */ - if (psx_ws_prim_is_tagged()) return; - int32_t xmn, xmx; - prim_sx_extent(op, &xmn, &xmx); - int32_t W = ws_disp_w(); - if (xmn >= -WS_OVERHANG_DEEP_PX && xmx <= W + WS_OVERHANG_DEEP_PX) return; - uint32_t f = (uint32_t)s_frame_count; - if (f != ws_ovh_frame) { - ws_ovh_prev = (f == ws_ovh_frame + 1u) ? ws_ovh_count : 0; - ws_ovh_frame = f; ws_ovh_count = 0; - } - ws_ovh_count++; - if (ws_ovh_count >= WS_OVERHANG_MIN_PRIMS && ws_last_ovh_stamp != f) { - if (f == ws_last_ovh_stamp + 1u) ws_sust_ovh_stamp = f; - ws_last_ovh_stamp = f; - } -} - -static void ws_census_record(uint8_t opcode, int32_t x, int32_t y) { - if (!ws_census_on) return; - if (!ws_census) { - ws_census = (WsCensusEntry *)calloc(WS_CENSUS_CAP, sizeof(WsCensusEntry)); - if (!ws_census) { ws_census_on = 0; return; } - } - int32_t xmn, xmx; - prim_sx_extent(opcode, &xmn, &xmx); - WsCensusEntry *e = &ws_census[ws_census_seq & (WS_CENSUS_CAP - 1)]; - e->frame = (uint32_t)s_frame_count; - e->src_addr = gp0_cmd_source_addr; - e->cam_x = (int16_t)psx_read_half(0x1F800176); - e->cam_y = (int16_t)psx_read_half(0x1F800186); - e->x = (int16_t)x; - e->y = (int16_t)y; - e->xmin = (int16_t)xmn; - e->xmax = (int16_t)xmx; - e->base_x = (int16_t)draw_area_left; - e->opcode = opcode; - e->tagged = (uint8_t)psx_ws_prim_is_tagged(); - ws_census_seq++; -} - -/* Dump census rows for frames [f0,f1] to a CSV file. Returns row count. */ -int gpu_ws_census_dump(uint32_t f0, uint32_t f1, const char *path) { - if (!ws_census) return 0; - FILE *fp = fopen(path, "w"); - if (!fp) return -1; - fprintf(fp, "frame,src_addr,cam_x,cam_y,x,y,xmin,xmax,base_x,opcode,tagged\n"); - uint64_t total = ws_census_seq; - uint64_t avail = total < WS_CENSUS_CAP ? total : WS_CENSUS_CAP; - uint64_t start = total - avail; - int n = 0; - for (uint64_t s = start; s < total; s++) { - WsCensusEntry *e = &ws_census[s & (WS_CENSUS_CAP - 1)]; - if (e->frame < f0 || e->frame > f1) continue; - fprintf(fp, "%u,0x%08X,%d,%d,%d,%d,%d,%d,%d,0x%02X,%u\n", - e->frame, e->src_addr, e->cam_x, e->cam_y, e->x, e->y, - e->xmin, e->xmax, e->base_x, e->opcode, e->tagged); - n++; - } - fclose(fp); - return n; -} - -void gpu_ws_census_set(int on) { ws_census_on = on ? 1 : 0; } -uint64_t gpu_ws_census_seq(void) { return ws_census_seq; } - -/* Execute a fully-collected GP0 command */ -static void gp0_execute_command(void) { - uint8_t opcode = (gp0_cmd_buf[0] >> 24) & 0xFF; - gp0_opcode_count[opcode]++; - gp0_ring_record(gp0_cmd_buf, gp0_words_needed); - extern void ws_bg_phase_note(uint32_t op); - ws_bg_phase_note(opcode); /* native-wide 2D-backdrop stretch: background-phase latch */ - - if (ws_menu_due && opcode >= 0x20 && opcode <= 0x7F && (opcode & 0xFC) != 0x64) { /* a frame that begins with anything but a textured rectangle is not the front end's */ - ws_menu_due = 0; - ws_menu_give_back(); - } - /* Draw-census: capture every drawing primitive's first vertex + camera. */ - if (opcode >= 0x20 && opcode <= 0x7F) { - int32_t cvx, cvy; - parse_vertex(gp0_cmd_buf[1], &cvx, &cvy); - ws_census_record(opcode, cvx, cvy); - ws_note_overhang(opcode); /* 2D-only-scene classifier world signal */ - } - - /* Categorize for diagnostics */ - if (opcode <= 0x01) gp0_nop_count++; - else if (opcode == 0x02) gp0_fill_count++; - else if (opcode >= 0x20 && opcode <= 0x7F) gp0_draw_count++; - else if (opcode >= 0x80 && opcode <= 0xDF) gp0_copy_count++; - else if (opcode >= 0xE1 && opcode <= 0xE6) gp0_env_count++; - - switch (opcode) { - case 0x00: - case 0x01: - gp0_exec_nop(); - break; - - case 0x02: - gp0_exec_fill_rect(); - break; - - case 0xE1: - gp0_exec_draw_mode(); - break; - - case 0xE2: - gp0_exec_texture_window(); - break; - - case 0xE3: - gp0_exec_draw_area_tl(); - break; - - case 0xE4: - gp0_exec_draw_area_br(); - break; - - case 0xE5: - gp0_exec_draw_offset(); - break; - - case 0xE6: - gp0_exec_mask_bit(); - break; - - /* Drawing commands — polygons */ - case 0x20: case 0x21: case 0x22: case 0x23: - gp0_exec_mono_tri(); - break; - case 0x24: case 0x25: case 0x26: case 0x27: - gp0_exec_textured_tri(); - break; - case 0x28: case 0x29: case 0x2A: case 0x2B: - gp0_exec_mono_quad(); - break; - case 0x2C: case 0x2D: case 0x2E: case 0x2F: - gp0_exec_textured_quad(); - break; - case 0x30: case 0x31: case 0x32: case 0x33: - gp0_exec_shaded_tri(); - break; - case 0x34: case 0x35: case 0x36: case 0x37: - gp0_exec_shaded_textured_tri(); - break; - case 0x38: case 0x39: case 0x3A: case 0x3B: - gp0_exec_shaded_quad(); - break; - case 0x3C: case 0x3D: case 0x3E: case 0x3F: - gp0_exec_shaded_textured_quad(); - break; - - /* Lines */ - case 0x40: case 0x41: case 0x42: case 0x43: - case 0x44: case 0x45: case 0x46: case 0x47: - gp0_exec_mono_line(); - break; - case 0x50: case 0x51: case 0x52: case 0x53: - case 0x54: case 0x55: case 0x56: case 0x57: - gp0_exec_shaded_line(); - break; - - /* Rectangles */ - case 0x60: case 0x61: case 0x62: case 0x63: - gp0_exec_mono_rect(); - break; - case 0x64: case 0x65: case 0x66: case 0x67: - gp0_exec_textured_rect(); - break; - case 0x68: case 0x69: case 0x6A: case 0x6B: - gp0_exec_mono_dot(); - break; - case 0x6C: case 0x6D: case 0x6E: case 0x6F: { - /* 1x1 textured dot: cmd, vertex, texcoord+clut (no size word) */ - uint32_t color24 = gp0_cmd_buf[0] & 0xFFFFFFu; - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int dot_h; - (void)ws_sprt_fixed_transform(&x0, &y0, 1, &dot_h); /* position only; 1px stays 1px */ - x0 += draw_offset_x; y0 += draw_offset_y; - int u0 = gp0_cmd_buf[2] & 0xFF; - int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; - uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); - uint16_t clut_x = (clut & 0x3F) * 16; - uint16_t clut_y = (clut >> 6) & 0x1FF; - setup_textured_draw(color24, semi_trans, raw_texture); - gr_draw_textured_rect(x0, y0, 1, 1, u0, v0, clut_x, clut_y, current_texpage()); - break; - } - case 0x70: case 0x71: case 0x72: case 0x73: - gp0_exec_mono_8x8(); - break; - case 0x74: case 0x75: case 0x76: case 0x77: - gp0_exec_textured_8x8(); - break; - case 0x78: case 0x79: case 0x7A: case 0x7B: { - /* 16x16 mono sprite */ - int semi_trans = (gp0_cmd_buf[0] >> 25) & 1; - uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); - int32_t x0, y0; - parse_vertex(gp0_cmd_buf[1], &x0, &y0); - const int w = ws_menu_rect(&x0, 16); - x0 += draw_offset_x; y0 += draw_offset_y; - gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, w, 16, color); - break; - } - case 0x7C: case 0x7D: case 0x7E: case 0x7F: - gp0_exec_textured_16x16(); - break; - - /* VRAM→VRAM copy */ - case 0x80: case 0x81: case 0x82: case 0x83: - case 0x84: case 0x85: case 0x86: case 0x87: - case 0x88: case 0x89: case 0x8A: case 0x8B: - case 0x8C: case 0x8D: case 0x8E: case 0x8F: - case 0x90: case 0x91: case 0x92: case 0x93: - case 0x94: case 0x95: case 0x96: case 0x97: - case 0x98: case 0x99: case 0x9A: case 0x9B: - case 0x9C: case 0x9D: case 0x9E: case 0x9F: { - /* Word 1: source coords, Word 2: dest coords, Word 3: dimensions */ - int src_x = gp0_cmd_buf[1] & 0x3FF; - int src_y = (gp0_cmd_buf[1] >> 16) & 0x1FF; - int dst_x = gp0_cmd_buf[2] & 0x3FF; - int dst_y = (gp0_cmd_buf[2] >> 16) & 0x1FF; - int w = gp0_cmd_buf[3] & 0x3FF; - int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; - if (w == 0) w = 0x400; - if (h == 0) h = 0x200; - ws_menu_yield(src_x, src_y, w, h, 0); - ws_menu_yield(dst_x, dst_y, w, h, 1); - gr_copy_rect(src_x, src_y, dst_x, dst_y, w, h); - ws_frame_pictured(dst_x, dst_y, w, h); - break; - } - - case 0xA0: case 0xA1: case 0xA2: case 0xA3: - case 0xA4: case 0xA5: case 0xA6: case 0xA7: - case 0xA8: case 0xA9: case 0xAA: case 0xAB: - case 0xAC: case 0xAD: case 0xAE: case 0xAF: - case 0xB0: case 0xB1: case 0xB2: case 0xB3: - case 0xB4: case 0xB5: case 0xB6: case 0xB7: - case 0xB8: case 0xB9: case 0xBA: case 0xBB: - case 0xBC: case 0xBD: case 0xBE: case 0xBF: - gp0_exec_cpu_to_vram(); - break; - - case 0xC0: case 0xC1: case 0xC2: case 0xC3: - case 0xC4: case 0xC5: case 0xC6: case 0xC7: - case 0xC8: case 0xC9: case 0xCA: case 0xCB: - case 0xCC: case 0xCD: case 0xCE: case 0xCF: - case 0xD0: case 0xD1: case 0xD2: case 0xD3: - case 0xD4: case 0xD5: case 0xD6: case 0xD7: - case 0xD8: case 0xD9: case 0xDA: case 0xDB: - case 0xDC: case 0xDD: case 0xDE: case 0xDF: - gp0_exec_vram_to_cpu(); - break; - - default: - /* NOP range: 0x03-0x1E, 0xE0, 0xE7-0xEF, 0xFF */ - if ((opcode >= 0x03 && opcode <= 0x1E) || - opcode == 0xE0 || - (opcode >= 0xE7 && opcode <= 0xEF) || - opcode == 0xFF) { - break; /* NOP — silently consume */ - } - - /* Any other command (drawing, VRAM-to-VRAM, etc.) is not yet - * implemented. Fatal halt so we know exactly what's needed next, - * with all rings queryable post-mortem. */ - { - static char reason[96]; - snprintf(reason, sizeof(reason), - "GPU GP0 unimplemented command 0x%02X (word 0x%08X)", - opcode, gp0_cmd_buf[0]); - psx_fatal_halt(reason); - } - } -} - -/* ---- GP0 write (0x1F801810 write) — command state machine ---- */ - -uint64_t gpu_get_gp0_count(void) { return gp0_write_count; } - -void gpu_get_gp0_stats(uint64_t* nop, uint64_t* fill, uint64_t* draw, uint64_t* env, uint64_t* copy) { - *nop = gp0_nop_count; *fill = gp0_fill_count; - *draw = gp0_draw_count; *env = gp0_env_count; *copy = gp0_copy_count; -} - -void gpu_get_draw_area(GpuDrawArea* out) { - out->left = draw_area_left; - out->top = draw_area_top; - out->right = draw_area_right; - out->bottom = draw_area_bottom; - out->offset_x = draw_offset_x; - out->offset_y = draw_offset_y; -} - -uint16_t gpu_vram_peek(int x, int y) { - if (x < 0 || x >= 1024 || y < 0 || y >= 512) return 0; - /* Through the facade so the GL backend syncs its FBO down first. */ - return gr_vram_read(x, y); -} - -static void gpu_write_gp0_body(uint32_t val) { - gp0_write_count++; - - /* State: consuming pixel data for CPU→VRAM transfer */ - if (gp0_state == GP0_VRAM_WRITE) { - /* Capture first few data words for debug */ - if (a0_capture_slot >= 0 && a0_capture_slot < A0_HISTORY_CAP) { - int wc = a0_history[a0_capture_slot].word_count++; - if (wc < 4) - a0_history[a0_capture_slot].first_words[wc] = val; - } - /* Each word contains two 16-bit pixels (low halfword first) */ - for (int i = 0; i < 2; i++) { - uint16_t pixel = (uint16_t)(val >> (i * 16)); - uint16_t wx = (vram_write_x + vram_write_col) % 1024; - uint16_t wy = (vram_write_y + vram_write_row) % 512; - - /* Respect mask bit settings. The check reads through the facade: - * under the GL backend GPU-drawn mask bits live in the FBO, not - * the CPU array (one sync per burst; free when check is off). */ - if (check_mask_bit && (gr_vram_read((int)wx, (int)wy) & 0x8000)) { - goto next_pixel; - } - - if (set_mask_bit) - pixel |= 0x8000; - - /* CPU VRAM remains immediately authoritative, preserving mid-DMA - * reads/savestates. The renderer mirror is committed in bulk when - * the GP0 payload completes. */ - vram[(uint32_t)wy * 1024u + wx] = pixel; - - next_pixel: - if (++vram_write_col == vram_write_w) { - vram_write_col = 0; - if (++vram_write_row == vram_write_h) { - /* Transfer complete */ - gp0_commit_cpu_to_vram(); - return; - } - } - } - - if (--vram_write_remaining == 0) { - gp0_commit_cpu_to_vram(); - } - - return; - } - - /* State: mono polyline — each word is a vertex (or terminator) */ - if (gp0_state == GP0_POLYLINE_MONO) { - if ((val & 0xF000F000u) == 0x50005000u) { - /* Terminator: hardware ends a polyline ONLY when the masked word - * matches 0x50005000 (the 0x55555555 terminator) — Beetle - * gpu.cpp:1030, psx-spx. The old `(val & 0xF000F000) != 0` test - * also fired on any NEGATIVE vertex coordinate (Y=0xFFxx) and, at - * shaded color positions, on any color component >= 0x10 in the - * G byte — ending the polyline early and re-parsing its remaining - * words as new GP0 commands. That de-phased the whole command - * stream: garbage prims all over the Tomba2 attract (texture - * garble) and eventually a legit texcoord word 0xFE65FE58 parsed - * in IDLE state -> "GP0 unknown command 0xFE" fatal (village). */ - gp0_state = GP0_IDLE; - return; - } - int32_t x, y; - parse_vertex(val, &x, &y); - x += draw_offset_x; y += draw_offset_y; - if (polyline_has_prev && - !psx_gpu_line_oversize(polyline_prev_x, polyline_prev_y, x, y)) { - gr_draw_line(polyline_prev_x, polyline_prev_y, x, y, polyline_color); - } - polyline_prev_x = x; polyline_prev_y = y; - polyline_has_prev = 1; - return; - } - - /* State: shaded polyline — alternating color, vertex words */ - if (gp0_state == GP0_POLYLINE_SHADED) { - /* The terminator can arrive in either the color or vertex position. - * Check it before interpreting the alternating shaded-polyline stream; - * otherwise a vertex-position terminator is consumed as coordinates and - * de-phases all following GP0 commands. */ - if ((val & 0xF000F000u) == 0x50005000u) { - gp0_state = GP0_IDLE; - return; - } - /* Even words (after cmd) are colors, odd words are vertices. - * Sequence: [cmd+C0] [V0] [C1] [V1] [C2] [V2] ... - * polyline_has_prev tracks: 0=need V0, 1=need C_next, 2=need V_next */ - if (!polyline_has_prev) { - /* First vertex */ - int32_t x, y; - parse_vertex(val, &x, &y); - x += draw_offset_x; y += draw_offset_y; - polyline_prev_x = x; polyline_prev_y = y; - polyline_prev_c = polyline_color; - polyline_has_prev = 1; - return; - } - if (polyline_has_prev == 1) { - /* Expecting color word. */ - polyline_color = rgb888_to_rgb555(val & 0xFFFFFFu); - polyline_has_prev = 2; - return; - } - /* polyline_has_prev == 2: vertex word */ - { - int32_t x, y; - parse_vertex(val, &x, &y); - x += draw_offset_x; y += draw_offset_y; - if (!psx_gpu_line_oversize(polyline_prev_x, polyline_prev_y, x, y)) - gr_draw_shaded_line(polyline_prev_x, polyline_prev_y, - polyline_prev_c, x, y, polyline_color); - polyline_prev_x = x; polyline_prev_y = y; - polyline_prev_c = polyline_color; - polyline_has_prev = 1; - } - return; - } - - /* State: collecting words for a multi-word command */ - if (gp0_state == GP0_COLLECTING) { - gp0_cmd_buf[gp0_words_collected++] = val; - if (gp0_words_collected >= gp0_words_needed) { - gp0_state = GP0_IDLE; - gp0_execute_command(); - } - return; - } - - /* State: IDLE — this is the first word of a new command */ - uint8_t opcode = (val >> 24) & 0xFF; - int word_count = gp0_command_word_count(opcode); - - if (word_count == 0) { - /* Unknown command — fatal halt with rings queryable post-mortem */ - static char reason[96]; - snprintf(reason, sizeof(reason), - "GPU GP0 unknown command 0x%02X (word 0x%08X)", opcode, val); - psx_fatal_halt(reason); - } - - if (word_count < 0) { - /* Variable-length polyline command. - * Mono (0x48-0x4F): [cmd+color] [v0] [v1] ... [terminator] - * Shaded(0x58-0x5F): [cmd+C0] [v0] [C1] [v1] ... [terminator] - * Terminator: (word & 0xF000F000) == 0x50005000 (0x55555555). */ - int shaded = (opcode & 0x10) != 0; /* 0x58+ = shaded, 0x48+ = mono */ - polyline_semi_trans = (val >> 25) & 1; - polyline_color = rgb888_to_rgb555(val & 0xFFFFFFu); - polyline_prev_c = polyline_color; - polyline_has_prev = 0; - gr_set_semi_transparency(polyline_semi_trans, (int)semi_transparency); - gp0_state = shaded ? GP0_POLYLINE_SHADED : GP0_POLYLINE_MONO; - gp0_draw_count++; - /* Record polyline header (variable-length body not captured; - * just enough so per-frame stream shows the polyline existed). */ - gp0_opcode_count[opcode]++; - uint32_t hdr_only[1] = { val }; - gp0_ring_record(hdr_only, 1); - return; - } - - gp0_cmd_buf[0] = val; - gp0_cmd_source_addr = gp0_next_source_addr; - - if (word_count == 1) { - gp0_words_collected = 1; - gp0_words_needed = 1; - gp0_execute_command(); - } else { - gp0_state = GP0_COLLECTING; - gp0_words_collected = 1; - gp0_words_needed = word_count; - } -} - -/* Wall-time sampler bracket (phase_profile): tag GP0 command processing — - * rasterization / batching / VRAM transfer work on the emu thread — as its - * own phase so it is separable from the guest code that issued the write. - * Covers both the MMIO store chokepoint and DMA channel-2 feeds. */ -void gpu_write_gp0(uint32_t val) { - extern int g_exec_phase; - int prev_phase = g_exec_phase; - g_exec_phase = 4; - gpu_write_gp0_body(val); - g_exec_phase = prev_phase; -} - -/* ---- GP1 write (0x1F801814 write) ---- */ - -static void gp1_reset(void) { - /* GP1(00h): Reset GPU — clears FIFO/control state and disables display. - * VRAM contents survive a GPU reset on real hardware; only power-on init - * clears our backing store. */ - gpu_reset_state(0); -} - -static void gp1_reset_command_buffer(void) { - /* GP1(01h): Reset command buffer — clears FIFO, aborts current command */ - gp0_state = GP0_IDLE; - gp0_words_collected = 0; - gp0_words_needed = 0; - vram_write_remaining = 0; -} - -static void gp1_ack_irq1(void) { - /* GP1(02h): Acknowledge IRQ1 */ - irq1_flag = 0; -} - -static void gp1_display_enable(uint32_t val) { - /* GP1(03h): Display enable — bit 0: 0=on, 1=off */ - uint32_t next_disabled = val & 1; - /* Native-wide reveal pixels have no guest-VRAM backing, so they cannot be - * reconstructed from the canonical framebuffer after a scene change. Treat - * the guest's ON->OFF transition as invalidation of those synthetic strips. - * This is deliberately transition-scoped: MMX6 retains valid background - * pixels across ordinary frames, so per-frame clearing pillarboxes gameplay. */ - if (ws_clear_reveal && next_disabled && !display_disabled && ws_mode == 2) { - ws_clear_all_reveal_margins(); - } - display_disabled = next_disabled; -} - -static void gp1_dma_direction(uint32_t val) { - /* GP1(04h): DMA direction — bits 0-1 */ - dma_direction = val & 3; -} - -static void gp1_display_area_start(uint32_t val) { - /* GP1(05h): Start of display area in VRAM - * bits 0-9: X (in halfwords, 0-1023) - * bits 10-18: Y (0-511) */ - display_area_x = val & 0x3FF; - display_area_y = (val >> 10) & 0x1FF; - ws_note_display_base(display_area_x); /* learn the display buffer set (native-wide) */ -} - -static void gp1_h_display_range(uint32_t val) { - /* GP1(06h): Horizontal display range - * bits 0-11: X1 - * bits 12-23: X2 */ - h_display_x1 = val & 0xFFF; - h_display_x2 = (val >> 12) & 0xFFF; -} - -static void gp1_v_display_range(uint32_t val) { - /* GP1(07h): Vertical display range - * bits 0-9: Y1 - * bits 10-19: Y2 */ - uint32_t y1 = val & 0x3FF; - uint32_t y2 = (val >> 10) & 0x3FF; - uint32_t h = (y2 > y1) ? (y2 - y1) : 0u; - /* MotK intro→crawl retargets the band while staying in depth24. Stale - * trailing RGB from the prior movie would flash for a frame or two — - * reset the upload span and hold present (skip Swap) for 3 vblanks. */ - if ((display_depth & 1u) && s_d24_prev_disp_h != 0u && h != s_d24_prev_disp_h) { - s_d24_upload_x1 = 0; - s_d24_present_hold = 3; - } - v_display_y1 = y1; - v_display_y2 = y2; - if (h != 0u) s_d24_prev_disp_h = h; -} - -static void gp1_display_mode(uint32_t val) { - /* GP1(08h): Display mode - * bit 0-1: horizontal resolution 1 (0=256, 1=320, 2=512, 3=640) - * bit 2: vertical resolution (0=240, 1=480) - * bit 3: video mode (0=NTSC, 1=PAL) - * bit 4: display area color depth (0=15bit, 1=24bit) - * bit 5: vertical interlace (0=off, 1=on) - * bit 6: horizontal resolution 2 (0=normal, 1=368) - * bit 7: "reverseflag" */ - hres1 = val & 3; - vres = (val >> 2) & 1; - video_mode = (val >> 3) & 1; - const uint32_t depth = (val >> 4) & 1; - /* A 24-bit scanout reads the CPU copy of VRAM, and a GPU backend leaves that copy without what it drew. Disruptor - * blacks out its movie's area with a rectangle a few VBlanks before it switches, so sync once, at the switch. */ - if (depth && !display_depth) (void)gr_vram_read(0, 0); - display_depth = depth; - vertical_interlace = (val >> 5) & 1; - /* GPUSTAT.13 holds the legacy constant 0 in progressive (see the vblank - * field flip); clear it on the switch so a title that toggles interlace - * on and back off doesn't leave the field bit latched at 1. */ - if (!vertical_interlace) - interlace_field = 0; - hres2 = (val >> 6) & 1; - reverse_flag = (val >> 7) & 1; -} - -static void gp1_get_info(uint32_t val) { - /* GP1(10h): Get GPU info — writes result to GPUREAD latch. - * Mednafen-psx masks the subcommand to 4 bits (val & 0x0F) and - * services cases 2..5, 7, 8. Tomba's ResetGraph() uses param 7 to - * read the GPU version (must be 2) to pick its video-mode path — - * the wrong value here lands the game on a no-draw branch. */ - uint32_t which = val & 0x0F; - switch (which) { - case 2: /* texture window */ - gpuread_latch = texture_window_value; - break; - case 3: /* draw area top-left */ - gpuread_latch = draw_area_left | (draw_area_top << 10); - break; - case 4: /* draw area bottom-right */ - gpuread_latch = draw_area_right | (draw_area_bottom << 10); - break; - case 5: /* draw offset */ - gpuread_latch = ((uint32_t)draw_offset_x & 0x7FFu) | - (((uint32_t)draw_offset_y & 0x7FFu) << 11); - break; - case 7: /* GPU version (real-hw + mednafen return 2) */ - gpuread_latch = 2; - break; - case 8: /* unknown info index, real hw / mednafen return 0 */ - gpuread_latch = 0; - break; - default: - /* N=0,1,6,9..15: leave latch unchanged */ - break; - } -} - -void gpu_write_gp1(uint32_t val) { - uint32_t cmd = (val >> 24) & 0x3F; - - switch (cmd) { - case 0x00: gp1_reset(); break; - case 0x01: gp1_reset_command_buffer(); break; - case 0x02: gp1_ack_irq1(); break; - case 0x03: gp1_display_enable(val); break; - case 0x04: gp1_dma_direction(val); break; - case 0x05: gp1_display_area_start(val); break; - case 0x06: gp1_h_display_range(val); break; - case 0x07: gp1_v_display_range(val); break; - case 0x08: gp1_display_mode(val); break; - case 0x10: case 0x11: case 0x12: case 0x13: - case 0x14: case 0x15: case 0x16: case 0x17: - case 0x18: case 0x19: case 0x1A: case 0x1B: - case 0x1C: case 0x1D: case 0x1E: case 0x1F: - gp1_get_info(val); break; - default: { - static char reason[96]; - snprintf(reason, sizeof(reason), - "GPU GP1 unknown command 0x%02X (word 0x%08X)", cmd, val); - psx_fatal_halt(reason); - } - } -} - -/* ---- boot snapshot: complete GPU register state (see boot_state.h) ---- */ -#define GPU_SNAP_FIELDS(X) \ - X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) \ - X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) \ - X(set_mask_bit) X(check_mask_bit) \ - X(interlace_field) X(reverse_flag) \ - X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) \ - X(draw_offset_x) X(draw_offset_y) \ - X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) \ - X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) \ - X(display_area_x) X(display_area_y) \ - X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) \ - X(gpuread_latch) \ - X(gp0_state) \ - X(gp0_cmd_buf) X(gp0_words_collected) X(gp0_words_needed) \ - X(gp0_next_source_addr) X(gp0_cmd_source_addr) \ - X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) \ - X(polyline_semi_trans) X(polyline_has_prev) \ - X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) \ - X(vram_write_col) X(vram_write_row) X(vram_write_remaining) \ - X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) \ - X(vram_read_col) X(vram_read_row) -#define GPU_COSIM_SNAP_FIELDS(X) \ - X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) \ - X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) \ - X(set_mask_bit) X(check_mask_bit) \ - X(interlace_field) X(reverse_flag) \ - X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) \ - X(draw_offset_x) X(draw_offset_y) \ - X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) \ - X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) \ - X(display_area_x) X(display_area_y) \ - X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) \ - X(gpuread_latch) \ - X(gp0_state) \ - X(gp0_cmd_buf) X(gp0_words_collected) X(gp0_words_needed) \ - X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) \ - X(polyline_semi_trans) X(polyline_has_prev) \ - X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) \ - X(vram_write_col) X(vram_write_row) X(vram_write_remaining) \ - X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) \ - X(vram_read_col) X(vram_read_row) -#include "pst_wire.h" - -/* GPU snap fields are scalars / u32 arrays — emit as LE u32/i32 (no struct pad). */ -static int gpu_snap_emit(PstW *w) { -#define WU(f) do { if (!pst_w_u32(w, (uint32_t)(f))) return 0; } while (0) -#define WI(f) do { if (!pst_w_i32(w, (int32_t)(f))) return 0; } while (0) -#define WH(f) do { if (!pst_w_u16(w, (uint16_t)(f))) return 0; } while (0) - WU(texpage_x); WU(texpage_y); WU(semi_transparency); WU(texpage_colors); - WU(dither_enabled); WU(draw_to_display); WU(texture_disable); WU(texture_window_value); - WU(set_mask_bit); WU(check_mask_bit); - WU(interlace_field); WU(reverse_flag); - WU(draw_area_left); WU(draw_area_top); WU(draw_area_right); WU(draw_area_bottom); - WI(draw_offset_x); WI(draw_offset_y); - WU(hres1); WU(hres2); WU(vres); WU(video_mode); WU(display_depth); WU(vertical_interlace); - WU(display_disabled); WU(irq1_flag); WU(dma_direction); WU(lcf); - WU(display_area_x); WU(display_area_y); - WU(h_display_x1); WU(h_display_x2); WU(v_display_y1); WU(v_display_y2); - WU(gpuread_latch); - WU((uint32_t)gp0_state); - for (int i = 0; i < 16; i++) WU(gp0_cmd_buf[i]); - WI(gp0_words_collected); WI(gp0_words_needed); - WU(gp0_next_source_addr); WU(gp0_cmd_source_addr); - WH(polyline_color); WI(polyline_prev_x); WI(polyline_prev_y); WH(polyline_prev_c); - WI(polyline_semi_trans); WI(polyline_has_prev); - WH(vram_write_x); WH(vram_write_y); WH(vram_write_w); WH(vram_write_h); - WH(vram_write_col); WH(vram_write_row); WU(vram_write_remaining); - WI(vram_read_active); WH(vram_read_x); WH(vram_read_y); WH(vram_read_w); WH(vram_read_h); - WH(vram_read_col); WH(vram_read_row); -#undef WU -#undef WI -#undef WH - return 1; -} -static int gpu_snap_parse(PstR *r) { - uint32_t u; int32_t i; uint16_t h; -#define RU(f) do { if (!pst_r_u32(r, &u)) return 0; (f) = u; } while (0) -#define RI(f) do { if (!pst_r_i32(r, &i)) return 0; (f) = i; } while (0) -#define RH(f) do { if (!pst_r_u16(r, &h)) return 0; (f) = h; } while (0) - RU(texpage_x); RU(texpage_y); RU(semi_transparency); RU(texpage_colors); - RU(dither_enabled); RU(draw_to_display); RU(texture_disable); RU(texture_window_value); - RU(set_mask_bit); RU(check_mask_bit); - RU(interlace_field); RU(reverse_flag); - RU(draw_area_left); RU(draw_area_top); RU(draw_area_right); RU(draw_area_bottom); - RI(draw_offset_x); RI(draw_offset_y); - RU(hres1); RU(hres2); RU(vres); RU(video_mode); RU(display_depth); RU(vertical_interlace); - RU(display_disabled); RU(irq1_flag); RU(dma_direction); RU(lcf); - RU(display_area_x); RU(display_area_y); - RU(h_display_x1); RU(h_display_x2); RU(v_display_y1); RU(v_display_y2); - RU(gpuread_latch); - if (!pst_r_u32(r, &u)) return 0; - gp0_state = (Gp0State)u; - for (int k = 0; k < 16; k++) RU(gp0_cmd_buf[k]); - RI(gp0_words_collected); RI(gp0_words_needed); - RU(gp0_next_source_addr); RU(gp0_cmd_source_addr); - RH(polyline_color); RI(polyline_prev_x); RI(polyline_prev_y); RH(polyline_prev_c); - RI(polyline_semi_trans); RI(polyline_has_prev); - RH(vram_write_x); RH(vram_write_y); RH(vram_write_w); RH(vram_write_h); - RH(vram_write_col); RH(vram_write_row); RU(vram_write_remaining); - RI(vram_read_active); RH(vram_read_x); RH(vram_read_y); RH(vram_read_w); RH(vram_read_h); - RH(vram_read_col); RH(vram_read_row); -#undef RU -#undef RI -#undef RH - return 1; -} - -uint32_t gpu_snapshot_bytes(void) { - PstW w; pst_w_init(&w, NULL, 0); - (void)gpu_snap_emit(&w); - return (uint32_t)w.written; -} -void gpu_snapshot_write(uint8_t *p) { - PstW w; uint32_t n = gpu_snapshot_bytes(); - ws_menu_give_back(); /* a save state reads video memory from the renderer after this, and holds the game's alone */ - pst_w_init(&w, p, n); - (void)gpu_snap_emit(&w); -} -uint32_t gpu_cosim_snapshot_bytes(void) { return gpu_snapshot_bytes(); } -void gpu_cosim_snapshot_write(uint8_t *p) { /* a hash of the registers between blocks, not a save state: nothing is given back for it */ - PstW w; - pst_w_init(&w, p, gpu_snapshot_bytes()); - (void)gpu_snap_emit(&w); -} -void gpu_cosim_dump(char *out, int cap) { - if (!out || cap <= 0) return; - char *p = out; - size_t rem = (size_t)cap; -#define APPEND(fmt, ...) do { \ - if (rem <= 1) break; \ - int w = snprintf(p, rem, fmt, __VA_ARGS__); \ - if (w < 0) { out[0] = 0; return; } \ - if ((size_t)w >= rem) { p += rem - 1; rem = 1; } \ - else { p += (size_t)w; rem -= (size_t)w; } \ - } while (0) -#define X(f) APPEND(" %s %llx", #f, (unsigned long long)(uint32_t)(f)); - X(texpage_x) X(texpage_y) X(semi_transparency) X(texpage_colors) - X(dither_enabled) X(draw_to_display) X(texture_disable) X(texture_window_value) - X(set_mask_bit) X(check_mask_bit) - X(interlace_field) X(reverse_flag) - X(draw_area_left) X(draw_area_top) X(draw_area_right) X(draw_area_bottom) - X(draw_offset_x) X(draw_offset_y) - X(hres1) X(hres2) X(vres) X(video_mode) X(display_depth) X(vertical_interlace) - X(display_disabled) X(irq1_flag) X(dma_direction) X(lcf) - X(display_area_x) X(display_area_y) - X(h_display_x1) X(h_display_x2) X(v_display_y1) X(v_display_y2) - X(gpuread_latch) - X(gp0_state) - for (int i = 0; i < 16; i++) { - APPEND(" gp0_cmd_buf%d %08x", i, gp0_cmd_buf[i]); - } - X(gp0_words_collected) X(gp0_words_needed) - X(gp0_next_source_addr) X(gp0_cmd_source_addr) - X(polyline_color) X(polyline_prev_x) X(polyline_prev_y) X(polyline_prev_c) - X(polyline_semi_trans) X(polyline_has_prev) - X(vram_write_x) X(vram_write_y) X(vram_write_w) X(vram_write_h) - X(vram_write_col) X(vram_write_row) X(vram_write_remaining) - X(vram_read_active) X(vram_read_x) X(vram_read_y) X(vram_read_w) X(vram_read_h) - X(vram_read_col) X(vram_read_row) -#undef X - APPEND("%s", "\n"); -#undef APPEND -} -int gpu_snapshot_read(const uint8_t *p, uint32_t len) { - PstR r; - if (len != gpu_snapshot_bytes()) return 0; - pst_r_init(&r, p, len); - if (!gpu_snap_parse(&r)) return 0; - ws_menu_lent = ws_menu_rows = ws_menu_logo = ws_menu_fresh = 0; /* the loaded video memory holds nothing of the wide menu's, and the CPU copy is not it */ - /* Sync renderer clip/scissor to restored GP0(E3/E4); vars alone leave GL - * on stale drawing state after savestate load. The offset also selects the - * correct framebuffer-relative horizon for optional full-Y yaw. */ - gr_set_draw_area((int)draw_area_left, (int)draw_area_top, - (int)draw_area_right, (int)draw_area_bottom); - gr_set_draw_offset(draw_offset_x, draw_offset_y); - geometry_camera_presentation_update(); - ws_nw_sync_target(); - return 1; -} -uint16_t* gpu_get_vram_ptr(void){ return vram; } -uint32_t gpu_get_vram_bytes(void){ return (uint32_t)sizeof(vram); } diff --git a/psxrecomp-overlay/runtime/src/gpu_gl_renderer.c b/psxrecomp-overlay/runtime/src/gpu_gl_renderer.c deleted file mode 100644 index de176b9..0000000 --- a/psxrecomp-overlay/runtime/src/gpu_gl_renderer.c +++ /dev/null @@ -1,5309 +0,0 @@ -/* gpu_gl_renderer.c — hardware OpenGL renderer backend. - * - * ARCHITECTURE (v2 — GPU-authoritative VRAM) - * ------------------------------------------- - * The FBO color texture (`s_hr_tex`, RGBA8, 1024*S x 512*S where S is the - * internal-resolution scale from [video] supersampling) is the single - * authoritative copy of VRAM. EVERY mutation goes through the GPU: - * - * - polys / rects / lines -> rasterized into the hr FBO - * - GP0(02h) fills -> scissored glClear (color + stencil) - * - VRAM->VRAM copies -> hr FBO -> scratch texture blit, then a - * masked quad draw back into the hr FBO - * - CPU->VRAM transfers -> written to the CPU mirror immediately and - * accumulated into a pending-upload rect; the - * rect is flushed (staging texture + quad into - * the hr FBO, plus a direct R16UI subimage) - * before the next GPU op, preserving op order - * - * PS1 MASK BIT (bit15). The FBO alpha channel carries bit15 exactly - * (1.0 = set), and a stencil buffer mirrors it (stencil bit0 == bit15): - * - "set mask" -> fragment alpha + stencil write value - * - "check mask"-> stencil test (pass iff stored == 0). The stencil write - * value is coupled to the test reference in GL, so when checking we use - * GL_INVERT on pass to write a 1 (stored is known 0 when the test - * passes) and GL_KEEP to write a 0. - * - textured prims (and copies/uploads, whose pixels carry per-texel STP - * bits) are drawn in TWO passes split by the STP bit via discard, so - * each pass writes a single known stencil value. The same split already - * existed for semi-transparent texture blending. - * - blending uses glBlendFuncSeparate so the alpha (mask) channel is - * always REPLACED by the source fragment's mask bit, never blended. - * - * TEXTURE SAMPLING / RENDER-TO-TEXTURE. Textured prims sample a native-res - * R16UI mirror (`s_raw_tex`) holding raw 1555 VRAM values (CLUT decode + - * texture window + optional bilinear in the fragment shader). GPU draws - * mark a native-coords dirty union; before any textured draw whose texture - * page or CLUT intersects the union, a PACK pass re-encodes the dirty - * region of the hr FBO into the raw mirror (point-sampled at native - * coords). CPU->VRAM uploads update the raw mirror directly. So content - * rendered by the GPU is immediately valid as a texture source. - * - * CPU READBACKS (VRAM->CPU transfers, GPUREAD, screenshots, 24-bit FMV - * display) flush uploads + pack, then glReadPixels the raw mirror straight - * into the CPU VRAM array (raw 1555, no conversion loop). - * - * PRESENT is deterministic: 15-bit frames always blit the display region - * from the hr FBO into a 4:3 letterboxed rect (single path — no more - * frame-to-frame alternation between FBO and CPU presents). 24-bit (FMV) - * frames sync to CPU and use the quad-present path, also letterboxed. - * PSX_GL_FORCE_CPU_PRESENT=1 (read by main.cpp) forces the CPU path as a - * diagnostic. - * - * Known divergences from the software rasterizer (accepted, documented): - * - GL triangle/line coverage rules differ from the PS1 DDA by ±1px on - * edges; lines use GL_LINES (width S) instead of Bresenham. - * - No dithering (the software path doesn't dither either). - * - Gouraud interpolation happens at 8-bit precision instead of 5-bit - * (smoother gradients; readback re-quantizes to 5-bit). - * - VRAM-wrapping draws are clamped, except GP0(02h) fills which split - * into wrapped segments. Wrapping copies/draws are unused by real SDKs. - * - * Init is all-or-nothing: if any shader/FBO fails, gl_renderer_init_context - * returns 0 and the runtime falls back to the pure software renderer — no - * half-GL hybrid. */ - -#include "gpu.h" -#include "gpu_render.h" -#include "gpu_sw_renderer.h" -#include "gpu_gl_renderer.h" -#include "gpu_turned_perspective.h" -#include "gpu_world_depth.h" -#include "host_ui.h" -#include "latency_ring.h" - -#include "psx_sdl.h" -#if defined(PSX_SDL3) -#include -#else -#include -#endif -#include -#include -#include -#include -#include - -#ifndef GL_BGRA -#define GL_BGRA 0x80E1 -#endif -#ifndef GL_CLAMP_TO_EDGE -#define GL_CLAMP_TO_EDGE 0x812F -#endif -#ifndef GL_RGBA8 -#define GL_RGBA8 0x8058 -#endif -/* GL 1.4+ enums absent from MinGW's GL 1.1 headers. */ -#define PSXGL_FRAGMENT_SHADER 0x8B30 -#define PSXGL_VERTEX_SHADER 0x8B31 -#define PSXGL_COMPILE_STATUS 0x8B81 -#define PSXGL_LINK_STATUS 0x8B82 -#define PSXGL_TEXTURE0 0x84C0 -#define PSXGL_ARRAY_BUFFER 0x8892 -#define PSXGL_STREAM_DRAW 0x88E0 -#define PSXGL_FRAMEBUFFER 0x8D40 -#define PSXGL_READ_FRAMEBUFFER 0x8CA8 -#define PSXGL_DRAW_FRAMEBUFFER 0x8CA9 -#define PSXGL_COLOR_ATTACHMENT0 0x8CE0 -#define PSXGL_DEPTH_STENCIL_ATTACHMENT 0x821A -#define PSXGL_FRAMEBUFFER_COMPLETE 0x8CD5 -#define PSXGL_RENDERBUFFER 0x8D41 -#define PSXGL_DEPTH24_STENCIL8 0x88F0 -#define PSXGL_R16UI 0x8234 -#define PSXGL_RED_INTEGER 0x8D94 -#define PSXGL_FUNC_ADD 0x8006 -#define PSXGL_FUNC_REVERSE_SUBTRACT 0x800B -#define PSXGL_CONSTANT_ALPHA 0x8003 -#define PSXGL_UNPACK_ROW_LENGTH 0x0CF2 -#define PSXGL_SRC1_ALPHA 0x8589 -#define PSXGL_SYNC_GPU_COMMANDS_COMPLETE 0x9117 -#define PSXGL_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFFull - -#ifndef APIENTRY -#define APIENTRY -#endif - -#define VRAM_W 1024 -#define VRAM_H 512 -#define GL_MAX_INTERNAL_SCALE SW_MAX_INTERNAL_SCALE - -/* ---- Loaded modern-GL entry points ------------------------------------- */ -typedef GLuint (APIENTRY *PFN_glCreateShader)(GLenum); -typedef void (APIENTRY *PFN_glShaderSource)(GLuint, GLsizei, const char *const *, const GLint *); -typedef void (APIENTRY *PFN_glCompileShader)(GLuint); -typedef void (APIENTRY *PFN_glGetShaderiv)(GLuint, GLenum, GLint *); -typedef void (APIENTRY *PFN_glGetShaderInfoLog)(GLuint, GLsizei, GLsizei *, char *); -typedef void (APIENTRY *PFN_glDeleteShader)(GLuint); -typedef GLuint (APIENTRY *PFN_glCreateProgram)(void); -typedef void (APIENTRY *PFN_glAttachShader)(GLuint, GLuint); -typedef void (APIENTRY *PFN_glLinkProgram)(GLuint); -typedef void (APIENTRY *PFN_glGetProgramiv)(GLuint, GLenum, GLint *); -typedef void (APIENTRY *PFN_glGetProgramInfoLog)(GLuint, GLsizei, GLsizei *, char *); -typedef void (APIENTRY *PFN_glUseProgram)(GLuint); -typedef GLint (APIENTRY *PFN_glGetUniformLocation)(GLuint, const char *); -typedef void (APIENTRY *PFN_glUniform1i)(GLint, GLint); -typedef void (APIENTRY *PFN_glUniform1f)(GLint, GLfloat); -typedef void (APIENTRY *PFN_glUniform2i)(GLint, GLint, GLint); -typedef void (APIENTRY *PFN_glUniform4i)(GLint, GLint, GLint, GLint, GLint); -typedef void (APIENTRY *PFN_glUniform4f)(GLint, GLfloat, GLfloat, GLfloat, GLfloat); -typedef void (APIENTRY *PFN_glBlendColor)(GLfloat, GLfloat, GLfloat, GLfloat); -typedef void (APIENTRY *PFN_glBlendFuncSeparate)(GLenum, GLenum, GLenum, GLenum); -typedef void (APIENTRY *PFN_glBlendEquationSeparate)(GLenum, GLenum); -typedef void (APIENTRY *PFN_glGenVertexArrays)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glBindVertexArray)(GLuint); -typedef void (APIENTRY *PFN_glActiveTexture)(GLenum); -typedef void (APIENTRY *PFN_glGenBuffers)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glBindBuffer)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glBufferData)(GLenum, ptrdiff_t, const void *, GLenum); -typedef void (APIENTRY *PFN_glVertexAttribPointer)(GLuint, GLint, GLenum, GLboolean, GLsizei, const void *); -typedef void (APIENTRY *PFN_glEnableVertexAttribArray)(GLuint); -typedef void (APIENTRY *PFN_glBindFragDataLocationIndexed)(GLuint, GLuint, GLuint, const char *); -typedef GLsync (APIENTRY *PFN_glFenceSync)(GLenum, GLbitfield); -typedef void (APIENTRY *PFN_glWaitSync)(GLsync, GLbitfield, GLuint64); -typedef void (APIENTRY *PFN_glDeleteSync)(GLsync); -typedef void (APIENTRY *PFN_glGenFramebuffers)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glDeleteFramebuffers)(GLsizei, const GLuint *); -typedef void (APIENTRY *PFN_glBindFramebuffer)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glFramebufferTexture2D)(GLenum, GLenum, GLenum, GLuint, GLint); -typedef GLenum (APIENTRY *PFN_glCheckFramebufferStatus)(GLenum); -typedef void (APIENTRY *PFN_glBlitFramebuffer)(GLint,GLint,GLint,GLint,GLint,GLint,GLint,GLint,GLbitfield,GLenum); -typedef void (APIENTRY *PFN_glGenRenderbuffers)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glDeleteRenderbuffers)(GLsizei, const GLuint *); -typedef void (APIENTRY *PFN_glBindRenderbuffer)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glRenderbufferStorage)(GLenum, GLenum, GLsizei, GLsizei); -typedef void (APIENTRY *PFN_glFramebufferRenderbuffer)(GLenum, GLenum, GLenum, GLuint); -/* GPU timer queries (ARB_timer_query / core GL 3.3) — frame_perf instrumentation. */ -typedef void (APIENTRY *PFN_glGenQueries)(GLsizei, GLuint *); -typedef void (APIENTRY *PFN_glDeleteQueries)(GLsizei, const GLuint *); -typedef void (APIENTRY *PFN_glBeginQuery)(GLenum, GLuint); -typedef void (APIENTRY *PFN_glEndQuery)(GLenum); -typedef void (APIENTRY *PFN_glGetQueryObjectui64v)(GLuint, GLenum, GLuint64 *); -typedef void (APIENTRY *PFN_glGetQueryObjectiv)(GLuint, GLenum, GLint *); -typedef void (APIENTRY *PFN_glQueryCounter)(GLuint, GLenum); -#ifndef GL_TIME_ELAPSED -#define GL_TIME_ELAPSED 0x88BF -#endif -#ifndef GL_TIMESTAMP -#define GL_TIMESTAMP 0x8E28 -#endif -#ifndef GL_QUERY_RESULT -#define GL_QUERY_RESULT 0x8866 -#endif -#ifndef GL_QUERY_RESULT_AVAILABLE -#define GL_QUERY_RESULT_AVAILABLE 0x8867 -#endif - -static PFN_glCreateShader p_glCreateShader; -static PFN_glShaderSource p_glShaderSource; -static PFN_glCompileShader p_glCompileShader; -static PFN_glGetShaderiv p_glGetShaderiv; -static PFN_glGetShaderInfoLog p_glGetShaderInfoLog; -static PFN_glDeleteShader p_glDeleteShader; -static PFN_glCreateProgram p_glCreateProgram; -static PFN_glAttachShader p_glAttachShader; -static PFN_glLinkProgram p_glLinkProgram; -static PFN_glGetProgramiv p_glGetProgramiv; -static PFN_glGetProgramInfoLog p_glGetProgramInfoLog; -static PFN_glUseProgram p_glUseProgram; -static PFN_glGetUniformLocation p_glGetUniformLocation; -static PFN_glUniform1i p_glUniform1i; -static PFN_glUniform1f p_glUniform1f; -static PFN_glUniform2i p_glUniform2i; -static PFN_glUniform4i p_glUniform4i; -static PFN_glUniform4f p_glUniform4f; -static PFN_glBlendColor p_glBlendColor; -static PFN_glBlendFuncSeparate p_glBlendFuncSeparate; -static PFN_glBlendEquationSeparate p_glBlendEquationSeparate; -static PFN_glGenVertexArrays p_glGenVertexArrays; -static PFN_glBindVertexArray p_glBindVertexArray; -static PFN_glActiveTexture p_glActiveTexture; -static PFN_glGenBuffers p_glGenBuffers; -static PFN_glBindBuffer p_glBindBuffer; -static PFN_glBufferData p_glBufferData; -static PFN_glVertexAttribPointer p_glVertexAttribPointer; -static PFN_glEnableVertexAttribArray p_glEnableVertexAttribArray; -static PFN_glBindFragDataLocationIndexed p_glBindFragDataLocationIndexed; -static PFN_glFenceSync p_glFenceSync; -static PFN_glWaitSync p_glWaitSync; -static PFN_glDeleteSync p_glDeleteSync; -static PFN_glGenFramebuffers p_glGenFramebuffers; -static PFN_glDeleteFramebuffers p_glDeleteFramebuffers; -static PFN_glBindFramebuffer p_glBindFramebuffer; -static PFN_glFramebufferTexture2D p_glFramebufferTexture2D; -static PFN_glCheckFramebufferStatus p_glCheckFramebufferStatus; -static PFN_glBlitFramebuffer p_glBlitFramebuffer; -static PFN_glGenQueries p_glGenQueries; -static PFN_glDeleteQueries p_glDeleteQueries; -static PFN_glBeginQuery p_glBeginQuery; -static PFN_glEndQuery p_glEndQuery; -static PFN_glGetQueryObjectui64v p_glGetQueryObjectui64v; -static PFN_glGetQueryObjectiv p_glGetQueryObjectiv; -static PFN_glQueryCounter p_glQueryCounter; -static void gl_perf_init(void); /* frame_perf — defined below, called from init_gpu_raster */ -static void gl_perf_mirror_begin(void); /* frame_perf: GPU-time bracket around ONE native-wide */ -static void gl_perf_mirror_end(void); /* mirror pass (timestamp pair; splits scene canonical/mirror) */ -/* Native-wide mirror ABLATION (perf attribution, debug cmd gl_ws_ablate): - * 0 = normal, 1 = skip the whole mirror pass, 2 = full mirror state churn but no - * draw calls, 3 = mirror draws land in the hr FBO (no per-pass FBO rebind; wide - * margins go stale + hr gets garbage — perf probe only). */ -static int s_ws_ablate = 0; -static void flush_tex_batch(void); /* textured-prim batch — defined below, flushed from coherency points */ -static void flush_flat_batch(void); /* flat/gouraud GEO batch (MotK starfield 0x68 dots) */ -static int resize_gpu_raster(int scale); /* live internal-resolution rebuild */ -static PFN_glGenRenderbuffers p_glGenRenderbuffers; -static PFN_glDeleteRenderbuffers p_glDeleteRenderbuffers; -static PFN_glBindRenderbuffer p_glBindRenderbuffer; -static PFN_glRenderbufferStorage p_glRenderbufferStorage; -static PFN_glFramebufferRenderbuffer p_glFramebufferRenderbuffer; - -static int load_modern_gl(void) { - int ok = 1; -#define LOAD(p, n) do { p = (void *)SDL_GL_GetProcAddress(n); if (!p) ok = 0; } while (0) - LOAD(p_glCreateShader, "glCreateShader"); LOAD(p_glShaderSource, "glShaderSource"); - LOAD(p_glCompileShader, "glCompileShader"); LOAD(p_glGetShaderiv, "glGetShaderiv"); - LOAD(p_glGetShaderInfoLog, "glGetShaderInfoLog"); LOAD(p_glDeleteShader, "glDeleteShader"); - LOAD(p_glCreateProgram, "glCreateProgram"); LOAD(p_glAttachShader, "glAttachShader"); - LOAD(p_glLinkProgram, "glLinkProgram"); LOAD(p_glGetProgramiv, "glGetProgramiv"); - LOAD(p_glGetProgramInfoLog, "glGetProgramInfoLog"); LOAD(p_glUseProgram, "glUseProgram"); - LOAD(p_glGetUniformLocation, "glGetUniformLocation"); LOAD(p_glUniform1i, "glUniform1i"); - LOAD(p_glUniform1f, "glUniform1f"); - LOAD(p_glUniform2i, "glUniform2i"); LOAD(p_glUniform4i, "glUniform4i"); - LOAD(p_glUniform4f, "glUniform4f"); - LOAD(p_glBlendColor, "glBlendColor"); - LOAD(p_glBlendFuncSeparate, "glBlendFuncSeparate"); - LOAD(p_glBlendEquationSeparate, "glBlendEquationSeparate"); - LOAD(p_glGenVertexArrays, "glGenVertexArrays"); LOAD(p_glBindVertexArray, "glBindVertexArray"); - LOAD(p_glActiveTexture, "glActiveTexture"); LOAD(p_glGenBuffers, "glGenBuffers"); - LOAD(p_glBindBuffer, "glBindBuffer"); LOAD(p_glBufferData, "glBufferData"); - LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); - LOAD(p_glEnableVertexAttribArray, "glEnableVertexAttribArray"); - LOAD(p_glBindFragDataLocationIndexed, "glBindFragDataLocationIndexed"); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - LOAD(p_glFenceSync, "glFenceSync"); - LOAD(p_glWaitSync, "glWaitSync"); - LOAD(p_glDeleteSync, "glDeleteSync"); -#endif - LOAD(p_glGenFramebuffers, "glGenFramebuffers"); LOAD(p_glBindFramebuffer, "glBindFramebuffer"); - LOAD(p_glDeleteFramebuffers, "glDeleteFramebuffers"); - LOAD(p_glFramebufferTexture2D, "glFramebufferTexture2D"); - LOAD(p_glCheckFramebufferStatus, "glCheckFramebufferStatus"); - LOAD(p_glBlitFramebuffer, "glBlitFramebuffer"); - LOAD(p_glGenRenderbuffers, "glGenRenderbuffers"); - LOAD(p_glDeleteRenderbuffers, "glDeleteRenderbuffers"); - LOAD(p_glBindRenderbuffer, "glBindRenderbuffer"); - LOAD(p_glRenderbufferStorage, "glRenderbufferStorage"); - LOAD(p_glFramebufferRenderbuffer, "glFramebufferRenderbuffer"); - /* GPU timer queries — optional (frame_perf). Don't fail the renderer if - * absent; gl_perf just stays disabled. */ - p_glGenQueries = (void *)SDL_GL_GetProcAddress("glGenQueries"); - p_glDeleteQueries = (void *)SDL_GL_GetProcAddress("glDeleteQueries"); - p_glBeginQuery = (void *)SDL_GL_GetProcAddress("glBeginQuery"); - p_glEndQuery = (void *)SDL_GL_GetProcAddress("glEndQuery"); - p_glGetQueryObjectui64v = (void *)SDL_GL_GetProcAddress("glGetQueryObjectui64v"); - p_glGetQueryObjectiv = (void *)SDL_GL_GetProcAddress("glGetQueryObjectiv"); - p_glQueryCounter = (void *)SDL_GL_GetProcAddress("glQueryCounter"); -#undef LOAD - return ok; -} - -/* ---- state ------------------------------------------------------------- */ -static SDL_Window *s_win = NULL; -static SDL_GLContext s_ctx = NULL; -static uint16_t *s_vram = NULL; /* CPU VRAM array (gpu.c's storage) */ -static int s_swap_interval = 1; /* SDL_GL swap interval (vsync mode) */ - -static int s_scale = 1; /* internal-res scale (hr FBO) */ -static int s_req_scale = 1; /* requested before context init */ - -static GLuint s_present_tex = 0; /* CPU-readout present path (24bpp) */ -static int s_present_w = 0, s_present_h = 0; -static GLuint s_present_prog = 0, s_present_vao = 0; -static GLint s_present_uTex = -1, s_present_uUvRect = -1; -/* Experimental ambient occlusion: the display mirror keeps the depth the painter's order leaves, the present pass shades by it. */ -static int s_ssao = 0; -/* What s_ssao is made of: the player's switch, and a layer shown in the picture's place (1 shading, 2 depth, 3 normals, 4 fade). */ -static int s_ssao_on = 0, s_ssao_view = 0; -static float s_ssao_radius = 160.0f, s_ssao_strength = 0.8f; -static float s_ssao_tilt = 0.35f; /* the sine of the angle under which a joint between two faces is no corner: 20 degrees */ -static GLuint s_ssao_prog = 0, s_ssao_mix_prog = 0; -static GLint s_ssao_uDepth = -1, s_ssao_uTex = -1, s_ssao_uUvRect = -1, s_ssao_uTexel = -1, s_ssao_uProj = -1, s_ssao_uAo = -1, - s_ssao_uFade = -1; -static float s_ssao_fade_from = 0.0f, s_ssao_fade_to = 0.0f; -static GLint s_ssao_mix_uTex = -1, s_ssao_mix_uShade = -1, s_ssao_mix_uUvRect = -1, s_ssao_mix_uSpan = -1; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static GLuint s_interp_prog = 0, s_interp_tex[3]; -static GLsync s_interp_fence[3]; -static GLsync s_interp_draw_fence = NULL; -static GLint s_interp_uPrev = -1, s_interp_uCurr = -1; -static GLint s_interp_uAlpha = -1, s_interp_uUvRect = -1; -static GLint s_interp_uBlendMode = -1; -static int s_interp_enabled = 0, s_interp_valid = 0; -static int s_interp_suspended = 0; -static int s_interp_content_suspended = 0; -static int s_interp_host_ui_suspended = 0; -static int s_interp_blend_mode = 0; -static int s_interp_prev = 0, s_interp_cur = 0; -static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0, s_interp_scale = 1; -static int s_interp_force_4_3 = 0, s_interp_source_path = -1; -static uint64_t s_interp_start = 0, s_interp_duration = 1; -static uint64_t s_interp_last_capture = 0, s_interp_swaps = 0; -static uint64_t s_interp_captures = 0; -static int s_interp_diag = 0; -static double s_interp_host_hz = 0.0; -static double s_interp_target_hz = 0.0; -static SDL_GLContext s_interp_ctx = NULL; -static SDL_Thread *s_interp_thread = NULL; -static SDL_mutex *s_interp_mutex = NULL; -static SDL_atomic_t s_interp_thread_run; -static GLuint s_interp_thread_vao = 0; -static void interp_reset_history_unlocked(void); -static void interp_reset_history(void); -static int interp_thread_main(void *opaque); -static int interp_present(void); -static void shown_capture(int kind, float alpha); -int gl_renderer_record_shown(const char *folder, int skip, int count, int width); -static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); -static uint64_t s_temporal_raw_generation = 1; -static void temporal_reset(void); -static void temporal_release(void); -static void temporal_begin(GLuint fbo, int y, int h, uint16_t color); -static void temporal_invalidate_surface(GLuint fbo); -static void temporal_draw(const float *vertices, const uint64_t *identities, int n, - int textured, GLenum mode, int semi, int mask, int filter, - const int *twin, int gate, int direct); -#define temporal_note_raw_change() (++s_temporal_raw_generation) -#else -#define s_interp_enabled 0 -#define interp_reset_history() ((void)0) -#define temporal_note_raw_change() ((void)0) -#define temporal_begin(...) ((void)0) -#define temporal_invalidate_surface(...) ((void)0) -#define temporal_draw(...) ((void)0) -#define interp_capture(...) 0 -#endif - -static int s_raster_ok = 0; /* full GPU pipeline available */ - -/* Presentation-only geometry correction state. Precise vertices are queued by - * gpu.c immediately before a polygon draw. Every GL vertex carries the exact - * integer GP0 position separately from its optional precise position; the - * canonical pass always consumes the former, while only the independent - * display mirror may consume the latter and the fractional camera yaw. */ -static int s_geometry_correction = 0; -static int s_next_world = 0; -static int s_next_precise = 0; -static int32_t s_next_x16[3], s_next_y16[3]; -static int s_next_unpinned = 0; -static int32_t s_next_unpinned_x16[3], s_next_unpinned_y16[3]; -static int s_next_perspective = 0; -static float s_next_q[3] = { 0.0f, 0.0f, 0.0f }; -static int s_next_temporal_depth = 0; -static float s_next_z[3] = { 0.0f, 0.0f, 0.0f }; -static float s_next_surface_z[3] = { 0.0f, 0.0f, 0.0f }; /* see gr_set_surface_depth_triangle */ -static uint64_t s_next_identity[3] = { 0u, 0u, 0u }; -#define TEMPORAL_SPRITE_IDENTITY UINT64_C(0x4000000000000000) -#define TEMPORAL_PARTIAL_IDENTITY UINT64_C(0x2000000000000000) /* a world polygon with a corner off its projection */ -static float s_next_sprite_depth = 0.0f, s_sprite_depth = 0.0f; -static float s_next_sprite_sides[4], s_sprite_sides[4]; /* left, top, right, bottom */ -static int s_sprite_x, s_sprite_y; -static float s_yaw_sin = 0.0f, s_yaw_cos = 1.0f; -static float s_yaw_center_x = 160.0f; -static float s_yaw_center_y_relative = 120.0f; -static float s_yaw_focal_x = 160.0f; -static int s_geometry_full_yaw = 0; -static int s_geometry_coverage_tint = 0; - -/* Authoritative VRAM: hr color texture + stencil (mask bit) FBO. */ -static GLuint s_hr_tex = 0, s_hr_fbo = 0, s_hr_rb = 0; -/* Native raw-1555 sampling mirror + readback source. */ -static GLuint s_raw_tex = 0, s_raw_fbo = 0; -/* CPU->VRAM upload staging (native RGBA8). */ -static GLuint s_up_tex = 0; -/* copy_rect staging (hr-sized RGBA8). */ -static GLuint s_scratch_tex = 0, s_scratch_fbo = 0; - -/* Programs. */ -static GLuint s_geo_prog = 0, s_geo_vao = 0, s_geo_vbo = 0; -static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; -/* Vertex formats retain both canonical GP0 and presentation-only positions. - * Per-primitive texture state stays in flat attributes so prims can batch. */ -#define GEOV 10 -#define TEXV 24 -static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; -static GLuint s_pack_prog = 0, s_stencil_prog = 0, s_empty_vao = 0; - -/* TEX program uniforms. */ -static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1; -static GLint s_uRaw = -1, s_uSemipass = -1, s_uSemimode = -1; -static GLint s_uTwin = -1, s_uMaskset = -1, s_uFilter = -1; -static GLint s_uLimits = -1; -/* Native-wide x-projection uniforms (per program). u_xoff = x translation in - * native px (0 canonical), u_xhalf = x clip half-extent in native px (512 - * canonical). When wide is off these stay 0 / 512 so the canonical pass is - * bit-identical to the pre-native-wide projection. */ -static GLint s_geo_uXoff = -1, s_geo_uXhalf = -1; -static GLint s_tex_uXoff = -1, s_tex_uXhalf = -1; -/* Native-wide 2D-backdrop x-stretch uniforms. The far 2D backdrop layer is a - * ~4:3-width set of static prims scrolled by the draw offset; native-wide widens - * 3D via the GTE but never these, so they leave a void in the 16:9 margins. The - * wide mirror scales them about screen centre (u_xscale, u_xcenter) to fill it. - * Applied only to "backdrop-phase" prims (drawn before the first clearly-wide - * prim each frame). 1.0 / 0.0 => no-op, so the canonical pass stays identical. */ -static GLint s_geo_uXscale = -1, s_geo_uXcenter = -1; -static GLint s_tex_uXscale = -1, s_tex_uXcenter = -1; -/* Presentation-mirror-only subpixel/yaw uniforms. The canonical pass keeps - * u_visual=0 and reads the explicit GP0-position attribute. World/exact - * classification is carried per vertex so sprites and HUD rectangles remain - * fixed and the optional exact-coverage tint cannot affect canonical output. */ -static GLint s_geo_uVisual = -1, s_geo_uCamera = -1, s_geo_uVisualOptions = -1; -static GLint s_tex_uVisual = -1, s_tex_uCamera = -1, s_tex_uVisualOptions = -1; -/* Runtime controls (ws_backdrop_stretch debug command). */ -int g_ws_bd_stretch_on = 1; /* feature on (gated by native-wide + per-prim gate) */ -int g_ws_bd_stretch_pct = 0; /* 0 = auto (g_wide_w/native_w); else pct/100 */ -int g_ws_bd_phase_thresh = 24; /* "narrow" margin (px): a prim overhanging the 4:3 - * region by more than this is treated as already- - * widened GTE geometry and NOT stretched */ -int g_ws_bd_phase_mode = 1; /* (retained for the debug command; unused since the - * gate is now per-prim !tagged && narrow) */ -/* Per-prim 2D-backdrop gate (replaces the old draw-order phase): a prim is - * stretched iff native-wide + feature on + NOT sprite-tagged (foreground chars/ - * HUD are tagged) + NARROW (the GTE far-parallax/3D extend into the margins). - * s_bd_gate is what wide_set_bd_scale reads; s_tb_gate is the open textured - * batch's gate (batch flushes when a prim's gate differs, so a batch is uniform). */ -static int s_bd_gate = 0; -static int s_tb_gate = 0; -/* ws_backdrop_stretch diagnostics: per-frame snapshot reported by the command. */ -int g_bdg_applied = 0, g_bdg_prims = 0, g_bdg_clearx = -999999; -int g_bdg_cur = 0, g_bdg_base = 0, g_bdg_w = 0, g_bdg_off = 0; -static int s_bdg_applied = 0, s_bdg_prims = 0, s_bdg_clearx = -999999; -/* per-prim draw-order trace (ws_backdrop_trace): x-extent + textured flag, in - * draw order, for the last frame -- so we can SEE where the background sits. */ -typedef struct { short x0, x1, y0, y1; unsigned char tex; } PrimRec; -#define PTRACE_CAP 200 -static PrimRec s_ptrace[PTRACE_CAP]; static int s_ptrace_n = 0; -PrimRec g_ptrace[PTRACE_CAP]; int g_ptrace_n = 0; /* snapshot (extern) */ -/* BLIT program uniforms. */ -static GLint s_uBlitSrc = -1, s_uBlitPass = -1, s_uBlitMaskset = -1; -static GLint s_uBlitSrcDiv = -1, s_uBlitSrcOff = -1; -/* PACK program uniforms. */ -static GLint s_uPackHr = -1, s_uPackScale = -1; -static GLint s_uStencilSrc = -1; - -static uint32_t *s_conv = NULL; /* RGBA8 staging for uploads */ -static int s_gpu_dirty = 0; /* CPU VRAM array may be stale */ - -/* Dirty-rect unions, native VRAM coords, inclusive bounds. */ -typedef struct { int x0, y0, x1, y1, set; } DirtyRect; -static DirtyRect s_pack_dirty; /* hr FBO content not in raw mirror */ - -/* CPU writes not yet in the FBO — an EXACT rect list, NOT a single union. - * - * THE FLICKER CLASS BUG (MMX6 GL black-frame flicker, ISSUES.md #7): the old - * single-union s_up_pending merged DISJOINT uploads (e.g. a sprite column at - * x>=320 and a tile row at y>=480) into one bounding box that covered the - * framebuffers in between; flush_cpu_upload then painted that whole box from - * the CPU VRAM array — which is STALE under GL (the FBO is authoritative, - * ensure_cpu only syncs on demand) — stomping freshly-rendered framebuffer - * content with stale (typically black) pixels for 1-2 presents until the game - * redrew each buffer. Fix: track the exact uploaded rects and flush each one; - * only pixels the CPU actually wrote are ever painted. Merging is allowed only - * when it adds NO uncovered pixels (containment / same-band extension). On - * overflow the pending set is flushed and the new rect starts a fresh list — - * order-preserving and still batched for the common poke patterns. - * (Pre-context, s_raster_ok == 0, the CPU array is fully authoritative — the - * software rasterizer mirrors every draw — so union-merging is harmless and - * used as the overflow strategy there.) */ -#define UP_RECTS_MAX 16 -static DirtyRect s_up_rects[UP_RECTS_MAX]; -static int s_up_nrects = 0; -static uint64_t s_rt_up_diag[6]; - -static int runtime_upload_diag_enabled(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_RUNTIME_PERF_DIAG"); - enabled = e && e[0] && e[0] != '0'; - } - return enabled; -} - -void gl_renderer_runtime_diag(uint64_t out[6]) { - for (int i = 0; i < 6; i++) out[i] = s_rt_up_diag[i]; -} - -/* Draw state mirrored from the vtable set_* calls. */ -static int s_off_x = 0, s_off_y = 0; -static int s_area_x1 = 0, s_area_y1 = 0, s_area_x2 = VRAM_W - 1, s_area_y2 = VRAM_H - 1; -static int s_semi_en = 0, s_semi_mode = 0; -static int s_mod_r = 128, s_mod_g = 128, s_mod_b = 128, s_mod_raw = 0; -static int s_mask_set = 0, s_mask_check = 0; -static int s_tw_mask_x = 0, s_tw_mask_y = 0, s_tw_off_x = 0, s_tw_off_y = 0; -static int s_tex_filter = 0; -/* Opaque textured draws carry the exact mask bit in FBO alpha. Keeping the - * duplicate stencil copy current is deferred until mask checking is requested. */ -static int s_stencil_valid = 1; - -/* ---- native-wide compositor (mirrors gpu_sw_renderer.c g_wide_*) ---------- - * Canonical VRAM (the hr FBO) stays 100% faithful. Native-wide lives in - * SEPARATE wide FBOs keyed by framebuffer base_x; each framebuffer-targeting - * primitive is mirrored into the active wide surface at local x = vram_x - - * base_x + OFFSET. Present reads the displayed buffer's wide surface. Textures - * always sample canonical VRAM (s_raw_tex). 4:3 / non-opted games never call - * wide_configure, so g_wide_cur stays 0 and the wide pass never runs. */ -#define WIDE_MAX_SURF 4 -static GLuint s_wide_tex[WIDE_MAX_SURF]; /* color tex per surface (0 = free) */ -static GLuint s_wide_fbo[WIDE_MAX_SURF]; /* FBO per surface */ -static uint32_t s_wide_depth_shown[WIDE_MAX_SURF] = {~0u, ~0u, ~0u, ~0u}; /* rows shown since their depth was last cleared */ -static GLuint s_wide_rb[WIDE_MAX_SURF]; /* depth-stencil RB per surface (mask - * mirror, same as hr). PERF-CRITICAL: - * a stencil-less wide FBO made every - * stencil-enabled mirror draw cost - * ~0.6ms of driver work (Tomba2 16:9 - * collapsed to 12fps); with the RB - * attached the pass costs ~2us. */ -static int s_wide_base[WIDE_MAX_SURF]; /* base_x per surface (-1 = free) */ -static int g_wide_w = 0; /* wide width (native px); 0 = disabled */ -static int g_wide_off = 0; /* centering OFFSET (native px) */ -static GLuint g_wide_cur = 0; /* active mirror FBO (0 = no mirror) */ -static int g_wide_cur_base = 0; /* base_x of g_wide_cur */ -/* Set by gpu_flat_rect for the full-screen-overlay case so the generic - * gpu_geometry wide mirror is skipped and the flat path emits its own - * full-wide-width pass instead (mirrors sw_draw_flat_rect). */ -static int s_wide_suppress = 0; - -/* X-translation (native px) from canonical VRAM space into the active wide - * surface: local_x = vram_x - base_x + OFFSET. Same as SW wide_dx(). */ -static inline int wide_dx(void) { return g_wide_off - g_wide_cur_base; } - -/* A filler closes gaps between polygons of a textured batch. It is drawn before its batch, colour only: the batch's opaque - * fragments replace it and its blended ones blend over it. Over earlier batches it lands only where their depth is farther: - * the game's grown second copy of a near polygon (visual flag 4) by its own depth, and the outline of every exact world - * polygon (s_crack_fill native pixels wide, 0 for none) only where no depth was left at all. */ -static float s_crack_fill = 0.5f, s_crack_width = 0.5f; -static float s_line_width_max = 1.0f; /* the widest line this context draws */ -static int s_mesh_view = 0; /* diagnostic: in-between frames draw their triangles' edges over the picture */ -static int s_grown_under = 1; -static int s_tb_fillers = 0; /* grown copies in the open batch */ -static int s_draw_filler = 0; /* the batch being recorded for the in-between presenter holds grown copies */ -/* What the mirror draws of a batch: 0 its polygons, between 0 and 1 its grown copies pushed back by that share, 2 the outlines, - * -1 all of it as it comes. */ -static float s_fill_pass = 0.0f; -#define GROWN_COPY_MARGIN 0.03f - -/* Experimental: exact world polygons are drawn by depth, not by the game's order alone. A batch of them first writes the - * depth of its opaque fragments, then draws its colours in the game's order where they lie within s_world_margin of the - * nearest depth: polygons in one plane, whose depths differ by rounding, keep the game's order, and a polygon behind - * another by more is not drawn over it. Measured between a coarse wall quad and the pieces the game draws over it: up to - * 0.1 percent. The in-between presenter gives a departed face the same clearance (TEMP_ORDER_CLEARANCE). */ -static int s_world_depth = 0; -static float s_world_margin = 0.03f; -static int s_tb_world = 0; /* the open batch holds only such polygons */ -static int s_draw_world = 0; /* the batch being recorded for the in-between presenter is such a batch */ -static GLint s_tex_uPull = -1; - -static int mirror_depth(void) { return s_geometry_correction && (s_ssao || s_grown_under || s_crack_fill > 0.0f || s_world_depth); } - -static void geometry_visual_uniforms(GLint visual, GLint camera, GLint options, - int enabled) { - const float on = enabled && s_geometry_correction ? 1.0f : 0.0f; - p_glUniform1f(visual, on); - p_glUniform4f(camera, s_yaw_sin, s_yaw_cos, - (float)g_wide_cur_base + s_yaw_center_x, - (float)s_off_y + s_yaw_center_y_relative); - p_glUniform4f(options, s_yaw_focal_x, - s_geometry_full_yaw ? 1.0f : 0.0f, - on && s_geometry_coverage_tint ? 0.55f : 0.0f, on > 0.0f ? s_fill_pass : 0.0f); -} - -/* Capture the one-shot metadata queued by gpu.c for the next polygon. `xs/ys` - * remain the exact GP0 coordinates used for canonical dirty tracking and - * guest-visible rasterization. The returned floats are consumed only by the - * presentation mirror when the shader's u_visual switch is set. The returned - * flags are bit 0 = world, bit 1 = exact precise provenance and bit 2 = the game's grown copy, a filler. */ -static int take_visual_triangle(const int *xs, const int *ys, - float px[3], float py[3], float pq[3], - float pz[3], uint64_t pid[3]) { - const int world = s_geometry_correction && s_next_world; - const int exact = world && s_next_precise; - const int perspective = exact && s_next_perspective; - const int partial = !exact && s_next_temporal_depth == 2; - const int unpinned = !exact && s_geometry_correction && s_next_unpinned; - const int sprite = !world && s_geometry_correction && s_sprite_depth > 0.0f; - for (int i = 0; i < 3; ++i) { - if (exact) { - px[i] = (float)((double)s_next_x16[i] / 65536.0); - py[i] = (float)((double)s_next_y16[i] / 65536.0); - } else if (unpinned) { - px[i] = (float)((double)s_next_unpinned_x16[i] / 65536.0); - py[i] = (float)((double)s_next_unpinned_y16[i] / 65536.0); - } else { - px[i] = (float)xs[i] + (sprite ? s_sprite_sides[xs[i] == s_sprite_x ? 0 : 2] : 0.0f); - py[i] = (float)ys[i] + (sprite ? s_sprite_sides[ys[i] == s_sprite_y ? 1 : 3] : 0.0f); - } - if (pq) pq[i] = perspective ? s_next_q[i] : 0.0f; - if (pz) pz[i] = (exact && s_next_temporal_depth) || partial ? s_next_z[i] : s_sprite_depth; - /* A corner the presenter has no depth for, the mirror has: negative, so that only the mirror's shaders take it. */ - if (pz && (exact || partial) && !(pz[i] > 0.0f) && s_next_surface_z[i] > 0.0f) pz[i] = -s_next_surface_z[i]; - s_next_surface_z[i] = 0.0f; - if (pid) pid[i] = exact && s_next_temporal_depth ? s_next_identity[i] - : partial ? TEMPORAL_PARTIAL_IDENTITY - : s_sprite_depth > 0.0f ? TEMPORAL_SPRITE_IDENTITY : 0u; - s_next_identity[i] = 0u; - } - const int filler = s_grown_under && exact && s_next_world == 2; - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - return (world || sprite || unpinned ? 1 : 0) | (exact ? 2 : 0) | (filler ? 4 : 0); -} - -static void turned_perspective(const float px[3], float pq[3], int visual_flags) { - if (visual_flags & 2) - psx_turned_perspective(px, pq, s_yaw_sin, s_yaw_cos, (float)g_wide_cur_base + s_yaw_center_x, s_yaw_focal_x); -} - -/* ---- dirty-rect helpers ------------------------------------------------- */ -static void rect_clear(DirtyRect *r) { r->set = 0; } -static void rect_add(DirtyRect *r, int x0, int y0, int x1, int y1) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 > VRAM_W - 1) x1 = VRAM_W - 1; - if (y1 > VRAM_H - 1) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return; - if (!r->set) { r->x0 = x0; r->y0 = y0; r->x1 = x1; r->y1 = y1; r->set = 1; return; } - if (x0 < r->x0) r->x0 = x0; - if (y0 < r->y0) r->y0 = y0; - if (x1 > r->x1) r->x1 = x1; - if (y1 > r->y1) r->y1 = y1; -} -static int rect_intersects(const DirtyRect *r, int x0, int y0, int x1, int y1) { - if (!r->set) return 0; - return !(x1 < r->x0 || x0 > r->x1 || y1 < r->y0 || y0 > r->y1); -} - -/* ---- exact pending-upload rect list (see s_up_rects comment) ------------- */ -static void flush_cpu_upload(void); /* fwd: overflow flushes then re-adds */ - -/* Add an uploaded rect. Merges ONLY when the merge adds no uncovered pixels: - * containment either way, or an extension within the same row-band / column- - * band (equal y-range with touching/overlapping x-ranges, or equal x-range - * with touching/overlapping y-ranges — the row-scan / column-scan poke - * patterns). Never unions disjoint rects post-init (that was the flicker bug). */ -static void up_add(int x0, int y0, int x1, int y1) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 > VRAM_W - 1) x1 = VRAM_W - 1; - if (y1 > VRAM_H - 1) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return; - for (int i = 0; i < s_up_nrects; i++) { - DirtyRect *r = &s_up_rects[i]; - if (x0 >= r->x0 && x1 <= r->x1 && y0 >= r->y0 && y1 <= r->y1) - return; /* contained */ - if (x0 <= r->x0 && x1 >= r->x1 && y0 <= r->y0 && y1 >= r->y1) { - r->x0 = x0; r->y0 = y0; r->x1 = x1; r->y1 = y1; /* contains */ - return; - } - if (y0 == r->y0 && y1 == r->y1 && - x0 <= r->x1 + 1 && x1 >= r->x0 - 1) { /* same row-band extension */ - if (x0 < r->x0) r->x0 = x0; - if (x1 > r->x1) r->x1 = x1; - return; - } - if (x0 == r->x0 && x1 == r->x1 && - y0 <= r->y1 + 1 && y1 >= r->y0 - 1) { /* same column-band extension */ - if (y0 < r->y0) r->y0 = y0; - if (y1 > r->y1) r->y1 = y1; - return; - } - } - if (s_up_nrects >= UP_RECTS_MAX) { - if (s_raster_ok) { - flush_cpu_upload(); /* order-preserving: land the old ones */ - } else { - /* Pre-context: the CPU array is fully authoritative (software - * rasterizer mirrors every op), so a union is harmless. */ - DirtyRect *r = &s_up_rects[0]; - for (int i = 1; i < s_up_nrects; i++) { - if (s_up_rects[i].x0 < r->x0) r->x0 = s_up_rects[i].x0; - if (s_up_rects[i].y0 < r->y0) r->y0 = s_up_rects[i].y0; - if (s_up_rects[i].x1 > r->x1) r->x1 = s_up_rects[i].x1; - if (s_up_rects[i].y1 > r->y1) r->y1 = s_up_rects[i].y1; - } - if (x0 < r->x0) r->x0 = x0; - if (y0 < r->y0) r->y0 = y0; - if (x1 > r->x1) r->x1 = x1; - if (y1 > r->y1) r->y1 = y1; - s_up_nrects = 1; - return; - } - } - DirtyRect *r = &s_up_rects[s_up_nrects++]; - r->x0 = x0; r->y0 = y0; r->x1 = x1; r->y1 = y1; r->set = 1; -} - -/* Add a GP0(A0) transfer's exact touched region. The software reference wraps - * per pixel (px = (x+col) & 1023, py = (y+row) & 511), so a wrapping transfer - * touches up to four exact rects — NOT all of VRAM (the old "wrapped: take - * all" full-VRAM union painted stale CPU content over the framebuffers). */ -static void up_add_transfer(int x, int y, int w, int h) { - x &= VRAM_W - 1; y &= VRAM_H - 1; - if (w > VRAM_W) w = VRAM_W; - if (h > VRAM_H) h = VRAM_H; - if (w <= 0 || h <= 0) return; - int w1 = w, w2 = 0, h1 = h, h2 = 0; - if (x + w > VRAM_W) { w1 = VRAM_W - x; w2 = w - w1; } - if (y + h > VRAM_H) { h1 = VRAM_H - y; h2 = h - h1; } - up_add(x, y, x + w1 - 1, y + h1 - 1); - if (w2) up_add(0, y, w2 - 1, y + h1 - 1); - if (h2) up_add(x, 0, x + w1 - 1, h2 - 1); - if (w2 && h2) up_add(0, 0, w2 - 1, h2 - 1); -} - -/* ---- coherency event ring (always-on, debug server "gl_coh_ring") -------- */ -/* Every coherency-relevant operation — upload flushes, fills, copies, draw - * bboxes, packs, full readbacks, presents, and probe perturbations — is - * recorded with its rect and frame number. Per CLAUDE.md ring-buffer rule: - * capture is continuous, observers query a window after the fact. Trigger - * attribution convention: an op that flushes internally (fill/copy/draw/ - * present/peek) records its own event AFTER the FLUSH event it caused, so - * the event following a FLUSH names the trigger. 16 B * 64 Ki = 1 MB. */ -extern uint64_t s_frame_count; /* defined in debug_server.c */ - -#define GL_COH_RING_CAP (1u << 16) -static GlCohEvent s_coh_ring[GL_COH_RING_CAP]; -static uint64_t s_coh_seq = 0; - -/* 16x16 native-pixel tiles changed since their last on-screen present. This - * lets a double-buffered 30 Hz game avoid swapping the unchanged front buffer - * on the intervening 60 Hz vblank without guessing from game identity. */ -#define PRES_TILE 16 -#define PRES_ROWS (VRAM_H / PRES_TILE) -static uint64_t s_present_dirty[PRES_ROWS]; -static int s_last_present_path = -1; -static int s_last_dx, s_last_dy, s_last_dw, s_last_dh; -/* After savestate restore: keep swapping for a few presents even when the - * display rect is byte-identical to the last swap. Double/triple-buffered - * windows otherwise can leave a stale back buffer on screen while vblanks - * (and FPS) keep advancing — especially on a 2nd+ load of the same slot. */ -static int s_force_present_remaining = 0; - -static void present_dirty_rect(int x0, int y0, int x1, int y1, int set) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 >= VRAM_W) x1 = VRAM_W - 1; if (y1 >= VRAM_H) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return; - int tx0 = x0 / PRES_TILE, tx1 = x1 / PRES_TILE; - uint64_t mask = (~0ull << tx0) & (~0ull >> (63 - tx1)); - for (int ty = y0 / PRES_TILE; ty <= y1 / PRES_TILE; ty++) { - if (set) s_present_dirty[ty] |= mask; else s_present_dirty[ty] &= ~mask; - } -} - -static int present_dirty_test(int x0, int y0, int x1, int y1) { - if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; - if (x1 >= VRAM_W) x1 = VRAM_W - 1; if (y1 >= VRAM_H) y1 = VRAM_H - 1; - if (x0 > x1 || y0 > y1) return 0; - int tx0 = x0 / PRES_TILE, tx1 = x1 / PRES_TILE; - uint64_t mask = (~0ull << tx0) & (~0ull >> (63 - tx1)); - for (int ty = y0 / PRES_TILE; ty <= y1 / PRES_TILE; ty++) - if (s_present_dirty[ty] & mask) return 1; - return 0; -} - -static void present_force_consumed(void) { - if (s_force_present_remaining > 0) - s_force_present_remaining--; -} - -static void coh_record(int kind, int x0, int y0, int x1, int y1) { - GlCohEvent *e = &s_coh_ring[s_coh_seq % GL_COH_RING_CAP]; - e->frame = (uint32_t)s_frame_count; - e->kind = (uint8_t)kind; - e->x0 = (int16_t)x0; e->y0 = (int16_t)y0; - e->x1 = (int16_t)x1; e->y1 = (int16_t)y1; - s_coh_seq++; - if (kind == GL_COH_FLUSH || kind == GL_COH_FILL || - kind == GL_COH_COPY || kind == GL_COH_DRAW) - present_dirty_rect(x0, y0, x1, y1, 1); -} - -uint64_t gl_renderer_coh_total(void) { return s_coh_seq; } -int gl_renderer_coh_get(uint64_t seq, GlCohEvent *out) { - if (seq >= s_coh_seq) return 0; - if (s_coh_seq - seq > GL_COH_RING_CAP) return 0; /* evicted */ - *out = s_coh_ring[seq % GL_COH_RING_CAP]; - return 1; -} - -/* ---- present ring (always-on, debug server "present_ring") --------------- */ -/* Records every SwapWindow (vram/wide/cpu/blank/interpolated) and its - * source + letterbox rects. PSX_GL_PRESENT_PROBE=1 additionally drains - * glGetError and samples one backbuffer pixel before the swap; that synchronous - * diagnostic is intentionally opt-in. Observers query a window after the fact. */ -#define GL_PRES_RING_CAP 4096 -static GlPresEvent s_pres_ring[GL_PRES_RING_CAP]; -static uint64_t s_pres_seq = 0; - -static void pres_record(int path, int dx, int dy, int w, int h, - int lx, int ly, int lw, int lh) { - /* The ring metadata stays always-on, but pixel probing must not: each - * glReadPixels synchronously drains queued GPU work. Two probes per frame - * were enough to make Tomba 2 miss its frame budget. */ - static int probe_pixels = -1; - if (probe_pixels < 0) { - const char *cfg = getenv("PSX_GL_PRESENT_PROBE"); - probe_pixels = (cfg && cfg[0] == '1') ? 1 : 0; - } - GlPresEvent *e = &s_pres_ring[s_pres_seq % GL_PRES_RING_CAP]; - e->frame = (uint32_t)s_frame_count; - e->t_ms = (uint32_t)SDL_GetTicks(); - e->path = (uint8_t)path; - e->glerr = probe_pixels ? (uint16_t)glGetError() : 0; - e->dx = (int16_t)dx; e->dy = (int16_t)dy; - e->w = (int16_t)w; e->h = (int16_t)h; - e->lx = (int16_t)lx; e->ly = (int16_t)ly; - e->lw = (int16_t)lw; e->lh = (int16_t)lh; - /* Backbuffer sample at the letterbox centre (GL bottom-origin; the rects - * we pass in are already bottom-origin GL window coords). */ - uint8_t px[3] = { 0, 0, 0 }; - if (probe_pixels && lw > 0 && lh > 0) { - glReadBuffer(GL_BACK); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(lx + lw / 2, ly + lh / 2, 1, 1, GL_RGB, GL_UNSIGNED_BYTE, px); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - } - e->px_r = px[0]; e->px_g = px[1]; e->px_b = px[2]; - /* Blit-source sample: the hr FBO pixel at the display-rect centre. Splits - * "FBO content was black" from "the blit malfunctioned". */ - uint8_t sp[3] = { 0, 0, 0 }; - e->src_valid = 0; - if (probe_pixels && (path == GL_PRES_VRAM) && w > 0 && h > 0 && s_hr_fbo) { - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels((dx + w / 2) * s_scale, (dy + h / 2) * s_scale, - 1, 1, GL_RGB, GL_UNSIGNED_BYTE, sp); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - e->src_valid = 1; - } - e->src_r = sp[0]; e->src_g = sp[1]; e->src_b = sp[2]; - s_pres_seq++; -} - -uint64_t gl_renderer_pres_total(void) { return s_pres_seq; } -int gl_renderer_pres_get(uint64_t seq, GlPresEvent *out) { - if (seq >= s_pres_seq) return 0; - if (s_pres_seq - seq > GL_PRES_RING_CAP) return 0; /* evicted */ - *out = s_pres_ring[seq % GL_PRES_RING_CAP]; - return 1; -} - -/* ---- shaders ------------------------------------------------------------ */ -static const char *PRESENT_VS = - "#version 330\n" - "out vec2 v_uv;\n" - "uniform vec4 u_uv_rect;\n" - "void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2);\n" - " v_uv = vec2(mix(u_uv_rect.x,u_uv_rect.z,p.x),\n" - " mix(u_uv_rect.y,u_uv_rect.w,1.0-p.y));\n" - " gl_Position = vec4(p*2.0-1.0,0.0,1.0); }\n"; -static const char *PRESENT_FS = - "#version 330\n" - "in vec2 v_uv; uniform sampler2D u_tex; out vec4 frag;\n" - "void main(){ frag = texture(u_tex, v_uv); }\n"; -/* Scalable ambient obscurance over the mirror's depth, one value a screen pixel. A pixel without camera depth (HUD, backdrop, 2D) - * is neither shaded nor an occluder. Red is the light left, green a coarse code of the depth for the blur, 0 for none. */ -static const char *SSAO_FS = - "#version 330\n" - "in vec2 v_uv; out vec4 frag;\n" - "uniform sampler2D u_depth, u_tex;\n" - "uniform vec4 u_span; /* one texel of the surface in x and y, the view asked for */\n" - "#define u_texel u_span.xy\n" - "uniform vec4 u_proj; /* focal x, focal y, centre x, centre y, in texels */\n" - "uniform vec4 u_ao; /* radius in depth units, strength, widest reach in texels, one native pixel in texels */\n" - "uniform vec4 u_fade; /* the depths between which the game fades the world into its far colour (0 0 for none), s_ssao_tilt */\n" - "float cam(vec2 uv){ float z = texture(u_depth, uv).r; return (z < 0.00002 || z > 0.99998) ? 0.0 : 16.0/(1.0-z); }\n" - "/* The depth of a surface to shade by: none where a blended fragment or one without a whole depth is marked. */\n" - "float surf(vec2 uv){\n" - " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" - " return (mark > 0.2 && mark < 0.3) ? 0.0 : cam(uv); }\n" - "/* The same for a pixel's normal: a sprite beside it, or a sliver of a polygon drawn on the game's own pixels, is not its surface. */\n" - "float flat_surf(vec2 uv){\n" - " float mark = texelFetch(u_tex, clamp(ivec2(uv*vec2(textureSize(u_tex, 0))), ivec2(0), textureSize(u_tex, 0) - 1), 0).a;\n" - " return (mark > 0.2 && mark < 0.6) ? 0.0 : cam(uv); }\n" - "vec3 at(vec2 uv, float d){ return vec3((uv/u_texel - u_proj.zw)/u_proj.xy*d, d); }\n" - "vec3 edge(vec2 step, vec3 p, float d){\n" - " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" - " if (a == 0.0 && b == 0.0) return vec3(0.0);\n" - " return (b == 0.0 || (a != 0.0 && abs(a-d) < abs(b-d))) ? p - at(v_uv - step, a) : at(v_uv + step, b) - p; }\n" - "/* A hairline between two polygons shows what lies behind them, or a polygon an in-between frame holds in place under them:\n" - " * where the depths half a pixel or a pixel to both sides agree and this one does not, it is theirs. */\n" - "float mend(float d){\n" - " if (d == 0.0) return d;\n" - " for (int i = 0; i < 4; ++i) {\n" - " vec2 step = (i < 2 ? vec2(u_texel.x, 0.0) : vec2(0.0, u_texel.y))*u_ao.w*((i & 1) == 0 ? 0.5 : 1.0);\n" - " float a = flat_surf(v_uv - step), b = flat_surf(v_uv + step);\n" - " if (a != 0.0 && b != 0.0 && abs(a - b) < 0.03*min(a, b) + 1.0 && abs(d - 0.5*(a + b)) > 0.04*d + 4.0) return 0.5*(a + b);\n" - " }\n" - " return d; }\n" - "void main(){\n" - " float d = mend(cam(v_uv));\n" - " frag = vec4(1.0, 0.0, 0.0, 1.0);\n" - " if (d == 0.0) return;\n" - " frag.g = clamp(log2(d)/16.0, 1.0/255.0, 1.0);\n" - " float clear = u_fade.y > u_fade.x ? 1.0 - clamp((d - u_fade.x)/(u_fade.y - u_fade.x), 0.0, 1.0) : 1.0;\n" - " if (u_span.z > 4.5) { frag.rb = vec2(clear); return; }\n" - " if (clear <= 0.0) return;\n" - " if (u_span.z > 2.5 && u_span.z < 3.5) { frag.rb = vec2(fract(d/256.0), d/4096.0); return; }\n" - " vec3 p = at(v_uv, d);\n" - " vec3 n = cross(edge(vec2(0.0, u_ao.w*u_texel.y), p, d), edge(vec2(u_ao.w*u_texel.x, 0.0), p, d));\n" - " if (dot(n, n) < 1e-12) return;\n" - " n = normalize(n); if (dot(n, p) > 0.0) n = -n;\n" - " float mark = texelFetch(u_tex, ivec2(v_uv*vec2(textureSize(u_tex, 0))), 0).a;\n" - " if (mark > 0.2 && mark < 0.6 && u_span.z < 3.5) return; /* a sprite stands in the floor it is drawn over, and under a blended fragment the depth is not its own */\n" - " if (u_span.z > 3.5) { frag = vec4(n*0.5 + 0.5, 1.0); return; }\n" - " float reach = min(u_ao.x*u_proj.x/d, u_ao.z);\n" - " if (reach < u_ao.w) return;\n" - " float turn = 6.2831853*fract(52.9829189*fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n" - " float r2 = u_ao.x*u_ao.x, sum = 0.0;\n" - " for (int i = 0; i < 16; ++i) {\n" - " float f = (float(i)+0.5)/16.0, angle = turn + f*6.2831853*7.0;\n" - " vec2 uv = v_uv + vec2(cos(angle), sin(angle))*sqrt(f)*reach*u_texel;\n" - " float ds = surf(uv);\n" - " if (ds == 0.0) continue;\n" - " vec3 v = at(uv, ds) - p;\n" - " float vv = dot(v, v), fall = max(r2 - vv, 0.0);\n" - " sum += fall*fall*fall*max(((dot(v, n) - u_fade.z*sqrt(vv))/(1.0 - u_fade.z) - 0.012*u_ao.x - 0.0015*d)/(vv + 0.0001*r2), 0.0);\n" - " }\n" - " frag.r = mix(1.0, max(0.4, 1.0 - u_ao.y*5.0*u_ao.x*sum/(r2*r2*r2*16.0)), clear); }\n"; -/* The frame with the shading of the pass above, averaged over the 4x4 pixels around that lie at the pixel's own depth. */ -static const char *SSAO_MIX_FS = - "#version 330\n" - "in vec2 v_uv; out vec4 frag;\n" - "uniform sampler2D u_tex, u_shade;\n" - "uniform vec4 u_span; /* x, y of the picture's corner on screen, the view asked for */\n" - "void main(){\n" - " ivec2 at = ivec2(gl_FragCoord.xy - u_span.xy), most = textureSize(u_shade, 0) - 1;\n" - " vec4 here = texelFetch(u_shade, clamp(at, ivec2(0), most), 0);\n" - " vec3 c = texture(u_tex, v_uv).rgb;\n" - " if (u_span.z > 4.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : vec3(here.r), 1.0); return; }\n" - " if (u_span.z > 2.5) { frag = vec4(here.g == 0.0 ? vec3(0.0, 0.0, 0.25) : here.rgb, 1.0); return; }\n" - " frag = vec4(u_span.z > 1.5 ? vec3(0.0, 0.0, 0.25) : c, 1.0);\n" - " if (here.g == 0.0) return;\n" - " float lit = 0.0, count = 0.0;\n" - " for (int y = -2; y < 2; ++y) for (int x = -2; x < 2; ++x) {\n" - " vec4 near = texelFetch(u_shade, clamp(at + ivec2(x, y), ivec2(0), most), 0);\n" - " if (near.g == 0.0 || abs(near.g - here.g) > 2.5/255.0) continue;\n" - " lit += near.r; count += 1.0;\n" - " }\n" - " lit = count > 0.0 ? lit/count : here.r;\n" - " frag = vec4(u_span.z > 1.5 ? vec3(lit) : c*lit, 1.0); }\n"; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static const char *INTERP_FS = - "#version 330\n" - "in vec2 v_uv; uniform sampler2D u_prev; uniform sampler2D u_curr;\n" - "uniform float u_alpha; uniform int u_blend_mode; out vec4 frag;\n" - "void main(){\n" - " vec4 prev=texture(u_prev,v_uv), curr=texture(u_curr,v_uv);\n" - " float alpha=u_alpha;\n" - " if(u_blend_mode==1){\n" - " vec3 d=abs(prev.rgb-curr.rgb);\n" - " float change=max(max(d.r,d.g),d.b);\n" - " float safe_blend=1.0-smoothstep(0.08,0.20,change);\n" - " alpha=mix(step(0.5,u_alpha),u_alpha,safe_blend);\n" - " }\n" - " frag=mix(prev,curr,alpha);\n" - "}\n"; -#endif - -/* Geometry: position in VRAM pixels (draw offset already applied by gpu.c), - * color rgb in 0..1, color a = mask bit (0/1). The clip transform is in - * native VRAM space; the viewport at S* the size scales rasterization. - * - * ALL drawn prims shift positions by u_shift = half an HR pixel (0.5/S in - * native units): GL samples coverage/attributes at pixel CENTERS, the PS1 - * DDA at INTEGER coords. The shift aligns GL's sample grid with the PS1 - * grid — without it, any texture mapping with slope != 1 (scaled sprites, - * squished menu fonts) samples one texel off per row/column (striped - * glyphs, seam lines). Half an HR pixel (not half a native pixel!) keeps - * rect coverage exactly [x*S, (x+w)*S) at every scale AND makes the - * top-left subpixel of each S*S block sample the exact PS1 value (which is - * what the PACK pass reads back). */ -static const char *GEO_VS = - "#version 330\n" - "layout(location=0) in vec2 a_pos;\n" - "layout(location=1) in vec4 a_col;\n" - "layout(location=2) in float a_visual_flags;\n" - "layout(location=3) in vec2 a_visual_pos;\n" - "uniform float u_shift;\n" - "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" - "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" - "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" - "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" - "uniform float u_visual; /* 1 only for the independent display mirror */\n" - "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" - "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" - "noperspective out vec4 v_col; noperspective out float v_tint;\n" - "void main(){ v_col = a_col;\n" - " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" - " vec2 p = corrected ? a_visual_pos : a_pos;\n" - " float xb = p.x, yb = p.y;\n" - " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" - " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" - " float ux = (xb-u_camera.z)/u_visual_options.x;\n" - " float den = u_camera.y-ux*u_camera.x;\n" - " if (abs(den) > 0.01) {\n" - " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" - " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" - " }\n" - " }\n" - " if (u_xscale < 0.0) {\n" - " float s = -u_xscale; float h = u_xhalf / s;\n" - " float l = u_xcenter - h, r = u_xcenter + h;\n" - " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" - " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" - " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; -static const char *GEO_FS = - "#version 330\n" - "noperspective in vec4 v_col; noperspective in float v_tint; out vec4 frag;\n" - "void main(){ frag = v_col;\n" - " if (v_tint > 0.0) frag.rgb = mix(frag.rgb, vec3(1.0,0.15,0.85),v_tint); }\n"; - -/* Textured prims: sample raw 1555 VRAM (integer), CLUT decode per depth, - * texture window, optional bilinear, texel-0 discard, STP-split discard, - * PS1 *2-around-0x80 modulation. Output alpha = bit15 of the written pixel. - * Texel coords use floor() to match the software rasterizer's truncation - * (rounding shifted sampling +1 texel half the time: smeared text). */ -/* Textured program. Per-prim texture state (texpage, clut, depth, raw, uv - * limits) is carried in FLAT vertex attributes — constant across a prim's - * vertices — instead of uniforms, so consecutive textured prims with the same - * blend/mask/texture-window state batch into one draw (see flush_tex_batch). - * The remaining uniforms (u_twin/u_maskset/u_filter/u_semipass) are the batch - * keys + per-pass state. */ -static const char *TEX_VS = - "#version 330\n" - "layout(location=0) in vec2 a_pos;\n" - "layout(location=1) in vec2 a_uv;\n" - "layout(location=2) in vec4 a_col;\n" - "layout(location=3) in vec2 a_tpage;\n" - "layout(location=4) in vec2 a_clut;\n" - "layout(location=5) in float a_depth;\n" - "layout(location=6) in float a_raw;\n" - "layout(location=7) in vec4 a_limits;\n" - "layout(location=8) in float a_semi;\n" - "layout(location=9) in float a_visual_flags;\n" - "layout(location=10) in vec2 a_visual_pos;\n" - "layout(location=11) in float a_q;\n" - "uniform float u_shift;\n" - "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" - "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" - "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" - "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" - "uniform float u_visual; /* 1 only for the independent display mirror */\n" - "uniform vec4 u_camera; /* sin(yaw), cos(yaw), centre-x, centre-y */\n" - "uniform vec4 u_visual_options; /* focal-x, full-yaw, tint-strength, unused */\n" - "noperspective out vec2 v_uv; noperspective out vec3 v_uvq;\n" - "noperspective out vec4 v_col; flat out int v_perspective;\n" - "noperspective out float v_tint;\n" - "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" - "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" - "void main(){ v_uv = a_uv; v_col = a_col;\n" - " v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5);\n" - " v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5);\n" - " v_semi = int(a_semi + 0.5);\n" - " v_limits = ivec4(floor(a_limits + 0.5));\n" - " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" - " * GEO_VS) so interpolated uv at a fragment equals the PS1 DDA value. */\n" - " bool corrected = u_visual > 0.5 && a_visual_flags > 0.5;\n" - " vec2 p = corrected ? a_visual_pos : a_pos;\n" - " float xb = p.x, yb = p.y;\n" - " v_tint = (u_visual > 0.5 && a_visual_flags > 1.5) ? u_visual_options.z : 0.0;\n" - " if (corrected && abs(u_camera.x) > 0.0000001 && u_visual_options.x > 0.0) {\n" - " float ux = (xb-u_camera.z)/u_visual_options.x;\n" - " float den = u_camera.y-ux*u_camera.x;\n" - " if (abs(den) > 0.01) {\n" - " xb = u_camera.z + u_visual_options.x*((ux*u_camera.y+u_camera.x)/den);\n" - " if (u_visual_options.y > 0.5) yb = u_camera.w + (yb-u_camera.w)/den;\n" - " }\n" - " }\n" - " /* Under a fractional yaw a_q comes divided by the turn's denominator, or 0 for the whole triangle. */\n" - " float q = (u_visual > 0.5 && a_visual_flags > 1.5 && a_q > 0.0) ? a_q : 0.0;\n" - " v_uvq = vec3(a_uv*q, q); v_perspective = q > 0.0 ? 1 : 0;\n" - " if (u_xscale < 0.0) {\n" - " float s = -u_xscale; float h = u_xhalf / s;\n" - " float l = u_xcenter - h, r = u_xcenter + h;\n" - " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" - " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" - " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (yb+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; -static const char *TEX_FS = - "#version 330\n" - "noperspective in vec2 v_uv; noperspective in vec3 v_uvq;\n" - "noperspective in vec4 v_col; flat in int v_perspective;\n" - "noperspective in float v_tint;\n" - "out vec4 frag; out vec4 blend_factor;\n" - "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" - "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" - "flat in int v_depth; /* 0=4bit 1=8bit 2=15bit */\n" - "flat in int v_raw; /* 1 = no color modulation */\n" - "flat in ivec4 v_limits; /* prim uv sampling bounds (inclusive, post-wrap) */\n" - "flat in int v_semi; /* GP0 command has semi-transparency enabled */\n" - "uniform usampler2D u_vram;\n" - "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" - "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" - "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" - "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 1 = bilinear */\n" - "int vram_at(int x, int y){\n" - " return int(texelFetch(u_vram, ivec2(x & 1023, y & 511), 0).r);\n" - "}\n" - "int fetch_texel(int u, int v){\n" - " u &= 255; v &= 255;\n" - " if ((u_twin.x | u_twin.y) != 0) {\n" - " u = (u & ~(u_twin.x * 8)) | ((u_twin.z & u_twin.x) * 8);\n" - " v = (v & ~(u_twin.y * 8)) | ((u_twin.w & u_twin.y) * 8);\n" - " } else {\n" - " u = clamp(u, v_limits.x, v_limits.z);\n" - " v = clamp(v, v_limits.y, v_limits.w);\n" - " }\n" - " if (v_depth == 0) {\n" - " int px = vram_at(v_tpage.x + (u >> 2), v_tpage.y + v);\n" - " return vram_at(v_clut.x + ((px >> ((u & 3) * 4)) & 0xF), v_clut.y);\n" - " } else if (v_depth == 1) {\n" - " int px = vram_at(v_tpage.x + (u >> 1), v_tpage.y + v);\n" - " return vram_at(v_clut.x + ((px >> ((u & 1) * 8)) & 0xFF), v_clut.y);\n" - " }\n" - " return vram_at(v_tpage.x + u, v_tpage.y + v);\n" - "}\n" - "vec3 col5(int raw){\n" - " return vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31)) / 31.0;\n" - "}\n" - "void main(){\n" - " vec2 sample_uv = v_uv;\n" - " if (v_perspective != 0 && v_uvq.z > 1.0e-12) sample_uv = v_uvq.xy/v_uvq.z;\n" - " int stp; vec3 rgb;\n" - " if (u_filter == 0) {\n" - " int raw = fetch_texel(int(floor(sample_uv.x)), int(floor(sample_uv.y)));\n" - " if (raw == 0) discard;\n" - " rgb = col5(raw);\n" - " stp = (raw >> 15) & 1;\n" - " } else {\n" - " /* Bilinear, Beetle-PSX formulation: the NEAREST texel is the base\n" - " * (cutout + STP authority), the neighbours lie toward the sub-texel\n" - " * offset and clamp to u_limits, and each texel's weight is gated by\n" - " * its opacity with the colour renormalised — so prim edges and\n" - " * cutout borders keep their colour instead of dissolving into the\n" - " * transparent (black) neighbour and discarding whole edge columns. */\n" - " int iu = int(floor(sample_uv.x)), iv = int(floor(sample_uv.y));\n" - " float fx = sample_uv.x - float(iu) - 0.5, fy = sample_uv.y - float(iv) - 0.5;\n" - " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" - " fx = abs(fx); fy = abs(fy);\n" - " int c00 = fetch_texel(iu, iv);\n" - " int c10 = fetch_texel(iu + sx, iv);\n" - " int c01 = fetch_texel(iu, iv + sy);\n" - " int c11 = fetch_texel(iu + sx, iv + sy);\n" - " float w00 = (c00 == 0 ? 0.0 : 1.0) * (1.0 - fx) * (1.0 - fy);\n" - " float w10 = (c10 == 0 ? 0.0 : 1.0) * fx * (1.0 - fy);\n" - " float w01 = (c01 == 0 ? 0.0 : 1.0) * (1.0 - fx) * fy;\n" - " float w11 = (c11 == 0 ? 0.0 : 1.0) * fx * fy;\n" - " float opac = w00 + w10 + w01 + w11;\n" - " if (opac < 0.5) discard;\n" - " rgb = (col5(c00)*w00 + col5(c10)*w10 + col5(c01)*w01 + col5(c11)*w11) / opac;\n" - " float stpf = (float((c00 >> 15) & 1) * w00 + float((c10 >> 15) & 1) * w10\n" - " + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac;\n" - " stp = stpf >= 0.5 ? 1 : 0;\n" - " }\n" - " if (u_semipass == 1 && stp == 1) discard;\n" - " if (u_semipass == 2 && stp == 0) discard;\n" - " if (v_raw == 0) rgb = clamp(rgb * v_col.rgb * 2.0, 0.0, 1.0);\n" - " if (v_tint > 0.0) rgb = mix(rgb, vec3(1.0,0.15,0.85),v_tint);\n" - " float dst_factor = 0.0;\n" - " if (u_semimode == 4 && v_semi != 0 && stp != 0) {\n" - " dst_factor = v_semi == 1 ? 0.5 : 1.0;\n" - " if (v_semi == 1) rgb *= 0.5; else if (v_semi == 4) rgb *= 0.25;\n" - " }\n" - " frag = vec4(rgb, (stp == 1 || u_maskset == 1) ? 1.0 : 0.0);\n" - " blend_factor = vec4(0.0, 0.0, 0.0, dst_factor);\n" - "}\n"; - -/* Quad blit: used for CPU->VRAM upload flushes and VRAM->VRAM copies. - * Samples an RGBA8 source (alpha = bit15), splits by STP for the stencil - * write, optionally ORs set-mask into the output alpha. */ -static const char *BLIT_VS = - "#version 330\n" - "layout(location=0) in vec2 a_pos; /* native VRAM px */\n" - "uniform float u_shift;\n" - "void main(){\n" - " gl_Position = vec4((a_pos.x+u_shift)/512.0 - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; -static const char *BLIT_FS = - "#version 330\n" - "out vec4 frag;\n" - "uniform sampler2D u_src;\n" - "uniform int u_stp_pass; /* 0=all, 1=bit15=0 only, 2=bit15=1 only */\n" - "uniform int u_maskset;\n" - "uniform int u_src_div; /* fragcoord -> src texel divisor (S for native-res\n" - " sources, 1 for hr-res sources) */\n" - "uniform ivec2 u_src_off; /* added after the divide, in src texel units */\n" - "void main(){\n" - " /* Exact integer source fetch — no normalized-uv edge precision. */\n" - " ivec2 p = ivec2(gl_FragCoord.xy);\n" - " vec4 c = texelFetch(u_src, p / u_src_div + u_src_off, 0);\n" - " bool stp = c.a >= 0.5;\n" - " if (u_stp_pass == 1 && stp) discard;\n" - " if (u_stp_pass == 2 && !stp) discard;\n" - " frag = vec4(c.rgb, (stp || u_maskset != 0) ? 1.0 : 0.0);\n" - "}\n"; - -/* Pack: re-encode the hr FBO into the native R16UI raw mirror. Runs over a - * native-res viewport with scissor = dirty rect; each native pixel takes the - * top-left sample of its S*S block (the sample at the exact native coord). */ -static const char *PACK_VS = - "#version 330\n" - "void main(){ vec2 p = vec2((gl_VertexID<<1)&2, gl_VertexID&2);\n" - " gl_Position = vec4(p*2.0-1.0, 0.0, 1.0); }\n"; -static const char *PACK_FS = - "#version 330\n" - "uniform sampler2D u_hr;\n" - "uniform int u_scale;\n" - "out uint o_pix;\n" - "void main(){\n" - " ivec2 p = ivec2(gl_FragCoord.xy);\n" - " vec4 c = texelFetch(u_hr, p * u_scale, 0);\n" - " uint r = uint(c.r * 255.0 + 0.5) >> 3;\n" - " uint g = uint(c.g * 255.0 + 0.5) >> 3;\n" - " uint b = uint(c.b * 255.0 + 0.5) >> 3;\n" - " o_pix = r | (g << 5) | (b << 10) | (c.a >= 0.5 ? 0x8000u : 0u);\n" - "}\n"; - -/* Rebuild stencil bit 0 from a copied RGBA target's alpha. Color writes are - * disabled while this shader runs; only alpha>=0.5 fragments replace stencil. */ -static const char *STENCIL_FS = - "#version 330\n" - "uniform sampler2D u_src; out vec4 frag;\n" - "void main(){ vec4 c=texelFetch(u_src,ivec2(gl_FragCoord.xy),0);\n" - " if(c.a<0.5) discard; frag=vec4(0.0); }\n"; - -static GLuint compile_shader(GLenum type, const char *src) { - GLuint s = p_glCreateShader(type); - p_glShaderSource(s, 1, &src, NULL); - p_glCompileShader(s); - GLint ok = 0; p_glGetShaderiv(s, PSXGL_COMPILE_STATUS, &ok); - if (!ok) { char log[1024]; log[0]=0; p_glGetShaderInfoLog(s, sizeof log, NULL, log); - fprintf(stdout, "psxrecomp: GL shader compile failed: %s\n", log); - p_glDeleteShader(s); return 0; } - return s; -} -static GLuint build_program_ex(const char *vs, const char *fs, int dual_source) { - GLuint v = compile_shader(PSXGL_VERTEX_SHADER, vs), f = compile_shader(PSXGL_FRAGMENT_SHADER, fs); - if (!v || !f) return 0; - GLuint p = p_glCreateProgram(); - p_glAttachShader(p, v); p_glAttachShader(p, f); - if (dual_source) { - p_glBindFragDataLocationIndexed(p, 0, 0, "frag"); - p_glBindFragDataLocationIndexed(p, 0, 1, "blend_factor"); - } - p_glLinkProgram(p); - p_glDeleteShader(v); p_glDeleteShader(f); - GLint ok = 0; p_glGetProgramiv(p, PSXGL_LINK_STATUS, &ok); - if (!ok) { char log[1024]; log[0]=0; p_glGetProgramInfoLog(p, sizeof log, NULL, log); - fprintf(stdout, "psxrecomp: GL program link failed: %s\n", log); return 0; } - return p; -} -/* The drawing programs' own copy of a vertex shader: on the mirror the camera depth of slot 9 or 23 becomes the fragment's depth, - * and v_bill says what is drawn: 0 off the mirror, 1 on it, 2 there with a depth that is not a world polygon's exact one - * (a sprite's one depth, or a clipped polygon's partial one: flags of 1, an exact polygon has 3). - * The source strings stay as they are: the in-between presenter makes its own copies of them. */ -/* A copy of a shader with a line after its first one and another end. NULL when it does not end as expected. */ -static char *shader_variant(const char *source, const char *declared, const char *end, const char *ending) { - const size_t length = strlen(source), tail = strlen(end), room = length + strlen(declared) + strlen(ending) + 8; - const char *body = strchr(source, '\n'); - char *out; - int written; - if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; - out = (char *)malloc(room); - if (!out) return NULL; - written = snprintf(out, room, "%.*s%s", (int)(body + 1 - source), source, declared); - memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); - strcpy(out + written + (source + length - tail - (body + 1)), ending); - return out; -} - -static char *depth_writing_shader(const char *source, int location) { - static const char flat[] = "0.0, 1.0); }\n"; - static const char deep[] = "u_visual_options.w >= 1.0 ? 0.99999 : ((u_visual > 0.5 && abs(a_camz) > 16.0) ? max(1.0 - 32.0*(1.0 + u_pull)/(abs(a_camz)*(1.0 + max(u_visual_options.w, 0.0))), -1.0) : 0.999999), 1.0);\n" - " bool grown = a_visual_flags > 5.5;\n" - " if (u_visual > 0.5 && (u_visual_options.w == 0.0 ? grown : u_visual_options.w >= 1.0 ? (grown || a_visual_flags < 1.5) : (u_visual_options.w > 0.0 && !grown)))\n" - " gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n" - " v_deep = abs(a_camz) > 16.0 ? 1.0 : -1000.0;\n" - " v_bill = u_visual > 0.5 ? ((abs(a_camz) > 16.0 && a_visual_flags < 1.5) ? 2.0 : 1.0) : 0.0; }\n"; - const size_t length = strlen(source), tail = sizeof(flat) - 1, room = length + sizeof(deep) + 160; - const char *body = strchr(source, '\n'); - char *out; - int written; - if (!body || length < tail || strcmp(source + length - tail, flat)) return NULL; - out = (char *)malloc(room); - if (!out) return NULL; - written = snprintf(out, room, "%.*slayout(location=%d) in float a_camz; flat out float v_bill; noperspective out float v_deep; uniform float u_pull;\n", (int)(body + 1 - source), source, location); - memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); - strcpy(out + written + (source + length - tail - (body + 1)), deep); - return out; -} - -/* The textured program's own copy of its fragment shader. Pass 3 and 4 write depth alone, 4 for a batch whose STP texels are blended: - * a blended fragment leaves the depth under it. On the mirror, whose alpha nothing reads back, a blended fragment marks it 0.25 - * and an opaque one of a sprite 0.5. */ -static char *sprite_marking_shader(const char *source) { - static const char end[] = "}\n"; - static const char marked[] = - " bool blended = stp != 0 && (u_semimode == 4 ? v_semi != 0 : (u_semipass == 2 || u_semipass == 4));\n" - " if (u_semipass == 4 && blended) discard;\n" - " if (v_bill > 0.5 && blended) frag.a = 0.25; else if (v_bill > 1.5) frag.a = 0.5;\n" - " if (v_bill > 0.5 && v_deep > -999.5 && v_deep < 0.999) frag.a = 0.25; /* some corner has no depth (it weighs -1000): its depth is no surface */\n" - "}\n"; - const size_t length = strlen(source), tail = sizeof(end) - 1, room = length + sizeof(marked) + 64; - const char *body = strchr(source, '\n'); - char *out; - int written; - if (!body || length < tail || strcmp(source + length - tail, end)) return NULL; - out = (char *)malloc(room); - if (!out) return NULL; - written = snprintf(out, room, "%.*sflat in float v_bill; noperspective in float v_deep;\n", (int)(body + 1 - source), source); - memcpy(out + written, body + 1, (size_t)(source + length - tail - (body + 1))); - strcpy(out + written + (source + length - tail - (body + 1)), marked); - return out; -} - -static GLuint build_program(const char *vs, const char *fs) { - return build_program_ex(vs, fs, 0); -} - -/* ---- pixel conversion (PS1 1555: bit15=mask, B[14:10] G[9:5] R[4:0]) ---- */ -static inline uint32_t conv_1555_to_rgba8(uint16_t p) { - uint32_t r = (p & 0x1F) << 3, g = ((p >> 5) & 0x1F) << 3, b = ((p >> 10) & 0x1F) << 3; - uint32_t a = (p >> 15) & 1 ? 0xFF : 0; - return r | (g << 8) | (b << 16) | (a << 24); /* RGBA8 little-endian */ -} - -/* ---- mask-bit stencil --------------------------------------------------- * - * Stencil bit0 mirrors bit15 of every pixel. - * check off: test ALWAYS, op REPLACE with ref = write value. - * check on: test EQUAL 0 (pass iff dest unmasked). GL couples the REPLACE - * value to the test reference, so write a 1 via INVERT (the - * stored value is known to be 0 when the test passed) and a 0 - * via KEEP. */ -static void mask_stencil(int write_val) { - if (s_fill_pass > 0.0f) { /* a filler obeys the mask and leaves it as it is */ - if (!s_mask_check) { glDisable(GL_STENCIL_TEST); return; } - glEnable(GL_STENCIL_TEST); - glStencilMask(0x00); - glStencilFunc(GL_EQUAL, 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); - return; - } - glEnable(GL_STENCIL_TEST); - glStencilMask(0x01); - if (s_mask_check) { - glStencilFunc(GL_EQUAL, 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, write_val ? GL_INVERT : GL_KEEP); - } else { - glStencilFunc(GL_ALWAYS, write_val ? 1 : 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); - } -} -/* Like mask_stencil but never checks (uploads: gpu.c already applied mask). */ -static void plain_stencil(int write_val) { - glEnable(GL_STENCIL_TEST); - glStencilMask(0x01); - glStencilFunc(GL_ALWAYS, write_val ? 1 : 0, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); -} - -/* PS1 semi-transparency as fixed-function blending, RGB only — the alpha - * channel (mask bit) is always replaced by the source fragment's alpha. - * 0: B/2 + F/2 1: B + F 2: B - F 3: B + F/4 */ -static void apply_psx_blend(int mode) { - glEnable(GL_BLEND); - p_glBlendEquationSeparate((mode & 3) == 2 ? PSXGL_FUNC_REVERSE_SUBTRACT - : PSXGL_FUNC_ADD, - PSXGL_FUNC_ADD); - switch (mode & 3) { - case 0: - p_glBlendColor(0.5f, 0.5f, 0.5f, 0.5f); - p_glBlendFuncSeparate(PSXGL_CONSTANT_ALPHA, PSXGL_CONSTANT_ALPHA, GL_ONE, GL_ZERO); - break; - case 1: - case 2: - p_glBlendFuncSeparate(GL_ONE, GL_ONE, GL_ONE, GL_ZERO); - break; - case 3: - p_glBlendColor(0.25f, 0.25f, 0.25f, 0.25f); - p_glBlendFuncSeparate(PSXGL_CONSTANT_ALPHA, GL_ONE, GL_ONE, GL_ZERO); - break; - } -} - -/* ---- hr FBO render-state bracket ---------------------------------------- */ -static void hr_begin(int clip_to_draw_area) { - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - if (clip_to_draw_area) { - int sw = s_area_x2 - s_area_x1 + 1, sh = s_area_y2 - s_area_y1 + 1; - if (sw < 0) sw = 0; if (sh < 0) sh = 0; - glScissor(s_area_x1 * s_scale, s_area_y1 * s_scale, - sw * s_scale, sh * s_scale); - } -} -static void hr_end(void) { - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDisable(GL_SCISSOR_TEST); - p_glBindVertexArray(0); - p_glUseProgram(0); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -} - -/* ---- coherency: CPU -> GPU upload flush --------------------------------- */ -/* CPU-side VRAM writes (GP0 A0 transfers, DMA, single pixel pokes) land in - * the CPU array immediately and accumulate s_up_rects. Flushing before the - * next GPU op (or readback/present) preserves PS1 command order. */ -static void flush_cpu_upload(void) { - if (!s_raster_ok || s_up_nrects == 0) return; - const int diag = runtime_upload_diag_enabled(); - if (diag) { s_rt_up_diag[0]++; s_rt_up_diag[1] += (uint64_t)s_up_nrects; } - flush_flat_batch(); /* queued flat GEO before upload mutates VRAM */ - flush_tex_batch(); /* queued textured draws before this upload writes VRAM */ - /* Snapshot + clear first (re-entrancy safe; up_add overflow calls back in). */ - DirtyRect rects[UP_RECTS_MAX]; - int nrects = s_up_nrects; - memcpy(rects, s_up_rects, (size_t)nrects * sizeof(DirtyRect)); - s_up_nrects = 0; - - /* Stage every rect's CPU data first (texture uploads outside the FBO - * bracket), then draw all the quads in one bracket. Only the exact - * uploaded rects are painted — never the union bounding box (stale-CPU - * flicker class bug, see s_up_rects). */ - for (int i = 0; i < nrects; i++) { - int x = rects[i].x0, y = rects[i].y0; - int w = rects[i].x1 - rects[i].x0 + 1; - int h = rects[i].y1 - rects[i].y0 + 1; - if (diag) s_rt_up_diag[2] += (uint64_t)w * (uint64_t)h; - coh_record(GL_COH_FLUSH, x, y, x + w - 1, y + h - 1); - - /* RGBA8 staging for the hr quad draw. */ - uint64_t t0 = diag ? SDL_GetPerformanceCounter() : 0; - for (int row = 0; row < h; row++) { - const uint16_t *src = s_vram + (size_t)(y + row) * VRAM_W + x; - uint32_t *dst = s_conv + (size_t)row * w; - for (int col = 0; col < w; col++) dst[col] = conv_1555_to_rgba8(src[col]); - } - if (diag) s_rt_up_diag[3] += SDL_GetPerformanceCounter() - t0; - t0 = diag ? SDL_GetPerformanceCounter() : 0; - glBindTexture(GL_TEXTURE_2D, s_up_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, GL_RGBA, GL_UNSIGNED_BYTE, s_conv); - - /* Raw mirror takes the CPU data directly — current for this rect, so no - * pack is needed for uploaded content. */ - glBindTexture(GL_TEXTURE_2D, s_raw_tex); - glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, VRAM_W); - glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, - PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, - s_vram + (size_t)y * VRAM_W + x); - glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); - temporal_note_raw_change(); - if (diag) s_rt_up_diag[4] += SDL_GetPerformanceCounter() - t0; - } - - /* Quads into the hr FBO; two passes split by bit15 so the stencil mirror - * stays exact. gpu.c applied mask set/check per pixel already — no check - * here, the data is final. up_tex is VRAM-aligned: src texel = frag/S. */ - uint64_t draw_t0 = diag ? SDL_GetPerformanceCounter() : 0; - hr_begin(0); - glDisable(GL_BLEND); - p_glUseProgram(s_blit_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_up_tex); - p_glUniform1i(s_uBlitSrc, 0); - p_glUniform1i(s_uBlitMaskset, 0); - p_glUniform1i(s_uBlitSrcDiv, s_scale); - p_glUniform2i(s_uBlitSrcOff, 0, 0); - p_glBindVertexArray(s_blit_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); - for (int i = 0; i < nrects; i++) { - int x = rects[i].x0, y = rects[i].y0; - int w = rects[i].x1 - rects[i].x0 + 1; - int h = rects[i].y1 - rects[i].y0 + 1; - glScissor(x * s_scale, y * s_scale, w * s_scale, h * s_scale); - float fx0 = (float)x, fy0 = (float)y, fx1 = (float)(x + w), fy1 = (float)(y + h); - float verts[6 * 2] = { - fx0, fy0, fx1, fy0, fx0, fy1, - fx1, fy0, fx0, fy1, fx1, fy1, - }; - p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); - plain_stencil(0); p_glUniform1i(s_uBlitPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); - plain_stencil(1); p_glUniform1i(s_uBlitPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); - } - hr_end(); - if (diag) s_rt_up_diag[5] += SDL_GetPerformanceCounter() - draw_t0; -} - -/* Recreate one target's stencil mask from its authoritative alpha channel. - * Sampling an attached render target is undefined, so copy color to the shared - * scratch texture first. Wide targets never exceed the 1024-pixel VRAM width. */ -static void rebuild_target_stencil(GLuint target_fbo, int target_w, int target_h) { - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, target_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); - p_glBlitFramebuffer(0, 0, target_w, target_h, 0, 0, target_w, target_h, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, target_fbo); - glViewport(0, 0, target_w, target_h); - glDisable(GL_SCISSOR_TEST); - glDisable(GL_BLEND); - glEnable(GL_STENCIL_TEST); - glStencilMask(0x01); - glClearStencil(0); - glClear(GL_STENCIL_BUFFER_BIT); - glStencilFunc(GL_ALWAYS, 1, 0x01); - glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - p_glUseProgram(s_stencil_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_scratch_tex); - p_glUniform1i(s_uStencilSrc, 0); - p_glBindVertexArray(s_empty_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glDisable(GL_STENCIL_TEST); - p_glBindVertexArray(0); - p_glUseProgram(0); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -} - -static void rebuild_mask_stencils(void) { - if (s_stencil_valid || !s_raster_ok) return; - int hw = VRAM_W * s_scale, hh = VRAM_H * s_scale; - rebuild_target_stencil(s_hr_fbo, hw, hh); - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (s_wide_fbo[i]) - rebuild_target_stencil(s_wide_fbo[i], g_wide_w * s_scale, hh); - } - s_stencil_valid = 1; -} - -/* ---- coherency: hr FBO -> raw mirror (pack) ------------------------------ */ -static void pack_flush(void) { - if (!s_raster_ok || !s_pack_dirty.set) return; - int x = s_pack_dirty.x0, y = s_pack_dirty.y0; - int w = s_pack_dirty.x1 - s_pack_dirty.x0 + 1; - int h = s_pack_dirty.y1 - s_pack_dirty.y0 + 1; - rect_clear(&s_pack_dirty); - coh_record(GL_COH_PACK, x, y, x + w - 1, y + h - 1); - temporal_note_raw_change(); - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); - glViewport(0, 0, VRAM_W, VRAM_H); - glEnable(GL_SCISSOR_TEST); - glScissor(x, y, w, h); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - p_glUseProgram(s_pack_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_hr_tex); - p_glUniform1i(s_uPackHr, 0); - p_glUniform1i(s_uPackScale, s_scale); - p_glBindVertexArray(s_empty_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -} - -/* Make sure the raw mirror is current for a textured draw that samples the - * given texture page / CLUT. */ -static void flush_pack_if_sampling(int tpage_x, int tpage_y, int depth, - int clut_x, int clut_y) { - if (!s_pack_dirty.set) return; - int page_w = depth == 0 ? 64 : depth == 1 ? 128 : 256; /* VRAM columns */ - if (rect_intersects(&s_pack_dirty, tpage_x, tpage_y, - tpage_x + page_w - 1, tpage_y + 255)) { - flush_flat_batch(); - flush_tex_batch(); /* queued draws are part of s_pack_dirty — realise them before packing */ - pack_flush(); return; - } - if (depth <= 1) { - int n = depth == 0 ? 16 : 256; - if (rect_intersects(&s_pack_dirty, clut_x, clut_y, clut_x + n - 1, clut_y)) { - flush_flat_batch(); - flush_tex_batch(); - pack_flush(); - } - } -} - -/* ---- coherency: GPU -> CPU readback -------------------------------------- */ -static void ensure_cpu(void) { - if (!s_raster_ok || !s_gpu_dirty) return; - flush_flat_batch(); - flush_tex_batch(); /* realise queued textured draws before reading the FBO back */ - flush_cpu_upload(); - pack_flush(); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo); - glReadPixels(0, 0, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, s_vram); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - s_gpu_dirty = 0; - coh_record(GL_COH_ENSURE, 0, 0, VRAM_W - 1, VRAM_H - 1); -} - -/* ---- GPU primitives ------------------------------------------------------ */ - -static uint64_t s_scene_prims = 0; /* frame_perf: scene primitives submitted (pre double-draw) */ -static uint64_t s_scene_prims_tex = 0; /* frame_perf: of which textured (vs flat geometry) */ -static void flush_tex_batch(void); /* fwd: drained at the backdrop-phase boundary below */ -static void mark_prim_dirty(const int *xs, const int *ys, int n, int textured) { - s_scene_prims++; - int x0 = xs[0], x1 = xs[0], y0 = ys[0], y1 = ys[0]; - for (int i = 1; i < n; i++) { - if (xs[i] < x0) x0 = xs[i]; if (xs[i] > x1) x1 = xs[i]; - if (ys[i] < y0) y0 = ys[i]; if (ys[i] > y1) y1 = ys[i]; - } - s_bdg_prims++; /* dbg: prims seen this frame (gate is now per-prim, see bd_prim_gate) */ - if (s_ptrace_n < PTRACE_CAP) { - PrimRec *p = &s_ptrace[s_ptrace_n++]; - p->x0 = (short)x0; p->x1 = (short)x1; p->y0 = (short)y0; p->y1 = (short)y1; - p->tex = (unsigned char)textured; - } - if (x0 < s_area_x1) x0 = s_area_x1; - if (y0 < s_area_y1) y0 = s_area_y1; - if (x1 > s_area_x2) x1 = s_area_x2; - if (y1 > s_area_y2) y1 = s_area_y2; - rect_add(&s_pack_dirty, x0, y0, x1, y1); - s_gpu_dirty = 1; - coh_record(GL_COH_DRAW, x0, y0, x1, y1); -} - -/* ---- native-wide mirror pass plumbing ----------------------------------- * - * Re-issue an already-set-up draw (program/VAO/VBO/blend/stencil bound) into - * the active wide surface. The geometry positions are identical on the host - * side; the x translation (wide_dx) and the wider clip are applied entirely in - * the vertex shader via u_xoff / u_xhalf, so the SAME glDrawArrays produces the - * shifted copy. wide_target_begin binds the wide FBO + viewport + scissor and - * sets the projection uniforms; wide_target_end restores u_xoff=0/u_xhalf=512 - * on the active program so the next canonical pass is bit-identical. The - * caller stays inside hr_begin/hr_end; hr_end unbinds and the next hr_begin - * resets the viewport, so no viewport restore is needed mid-function. - * - * The scissor X is the FULL wide surface (NOT the draw area): the SW reference - * (rt_wide()) deliberately lets the shifted geometry fill the revealed 16:9 - * margins that lie OUTSIDE the game's 4:3 draw-area x-range; scissoring X to - * the (translated) draw area would crop exactly the margin content native-wide - * exists to reveal. The scissor Y stays clamped to the DRAW AREA, exactly like - * rt_wide() (t.cy1/cy2 = g_clip_y1/y2): native-wide only widens X. A full-height - * Y scissor let draws that canonically clip at a vertical double-buffer band - * boundary (MMX6: draw area alternates y=0/y=240, both bands in ONE wide - * surface) bleed into the OTHER band's rows — presented one frame later as - * top/bottom edge flicker (16:9 GL only). */ -/* Rows y..y+h-1 of a surface as bits, one for every 16 rows. */ -static uint32_t wide_depth_rows(int y, int h) { - uint32_t rows = 0; - for (int block = y < 0 ? 0 : y / 16; block < 32 && block * 16 < y + h; block++) rows |= 1u << block; - return rows; -} - -/* The depth of a frame buffer is one frame's: drawing into rows that have been shown starts from none. The game's frame buffers - * share a surface, one above the other, so the rows on screen keep theirs. Polygons the game blends into its far colour write - * no depth: without this the last frame's would stay under them. */ -static void wide_depth_shown(GLuint fbo, int y, int h) { - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); -} - -static void wide_depth_begin(void) { - int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1; - if (!mirror_depth()) return; - if (sy < 0) { sh += sy; sy = 0; } - if (sy + sh > VRAM_H) sh = VRAM_H - sy; - if (sh <= 0) return; - for (int i = 0; i < WIDE_MAX_SURF; i++) { - const uint32_t rows = wide_depth_rows(sy, sh); - if (s_wide_fbo[i] != g_wide_cur || !(s_wide_depth_shown[i] & rows)) continue; - s_wide_depth_shown[i] &= ~rows; - glEnable(GL_SCISSOR_TEST); - glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); - glDepthMask(GL_TRUE); - glClearDepth(1.0); - glClear(GL_DEPTH_BUFFER_BIT); - } -} - -static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) { - if (s_ws_ablate != 3) /* ablate 3: no FBO rebind (draws land in hr — perf probe) */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); - wide_depth_begin(); - glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - { - int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1; - if (sy < 0) { sh += sy; sy = 0; } - if (sy + sh > VRAM_H) sh = VRAM_H - sy; - if (sh < 0) sh = 0; - glScissor(0, sy * s_scale, g_wide_w * s_scale, sh * s_scale); - } - if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(GL_FALSE); } - p_glUniform1f(uXoff, (float)dx); - p_glUniform1f(uXhalf, (float)g_wide_w / 2.0f); -} -static void wide_target_end(GLint uXoff, GLint uXhalf) { - if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } - p_glUniform1f(uXoff, 0.0f); - p_glUniform1f(uXhalf, 512.0f); - if (s_ws_ablate != 3) - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); -} - -extern int psx_ws_prim_is_tagged(void); /* gpu.c: is the current GP0 prim sprite-tagged? */ -extern int psx_ws_prim_in_backdrop(void); /* gpu.c: is its source addr in the flower-field struct? */ -extern int gpu_ws_nw_flat_backdrop_enabled(void); /* gpu.c: per-title flat backdrop opt-in */ - -/* Per-prim gate: stretch this prim iff native-wide + feature on AND the prim's - * source address is inside the flower-field backdrop data structure (precise — - * excludes the 3D rock/foreground, which is untagged AND has narrow prims so the - * earlier tag/narrow heuristic tore it). mode!=0 falls back to the old - * tag+narrow heuristic for A/B. */ -static int bd_prim_gate(const int *xs, int n, int textured) { - if (g_wide_w <= 0 || !g_ws_bd_stretch_on) return 0; - /* Some games draw their authored 4:3 sky/water as flat-colour polygons. - * Stretch those only in the native-wide mirror: the canonical framebuffer - * remains byte-for-byte 4:3, while the flat backdrop reaches the reveal - * margins. Opt-in because flat foreground geometry is title-dependent. */ - if (!textured && gpu_ws_nw_flat_backdrop_enabled()) return 1; - if (g_ws_bd_phase_mode != 0) return psx_ws_prim_in_backdrop(); /* default: precise address gate */ - /* mode 0: legacy tag+narrow heuristic (kept for comparison) */ - int native_w = g_wide_w - 2 * g_wide_off; - if (native_w <= 0) return 0; - if (psx_ws_prim_is_tagged()) return 0; - int base = g_wide_cur_base, lo = xs[0], hi = xs[0]; - for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } - if (lo < base - g_ws_bd_phase_thresh) return 0; /* into left margin -> GTE-wide */ - if (hi > base + native_w + g_ws_bd_phase_thresh) return 0; /* into right margin -> GTE-wide */ - return 1; -} - -/* ---- native-wide FAST path (skip redundant center mirror) ----------------- * - * The wide surface's CENTRE columns [g_wide_off, g_wide_off+native_w) are, by - * construction, identical to the canonical 4:3 framebuffer. So instead of - * re-rasterizing every primitive into the wide surface (the "mirror" pass — the - * dominant native-wide GPU cost, ~2x scene fill), we copy the canonical centre - * into the wide surface once at present (blit_wide_center_from_canonical), and - * the per-prim mirror only needs to produce the reveal MARGINS. Any prim/batch - * whose x-range is fully inside the 4:3 frame contributes nothing to the margins, - * so its mirror is skipped entirely. Correctness does not depend on the skip - * being precise: the centre is authoritatively overwritten by the blit, so the - * ONLY requirement is that a margin-reaching prim is NOT skipped — hence the - * conservative strict-inside test. 4:3 never runs any of this (g_wide_cur == 0). - * Toggle via gl_wide_fast for A/B; default ON. */ -static int s_wide_fast = 1; -void gl_renderer_set_wide_fast(int on) { s_wide_fast = on ? 1 : 0; } -int gl_renderer_get_wide_fast(void) { return s_wide_fast; } -static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h); /* def below */ -/* True if [lo,hi] (canonical draw-x) lies strictly inside the 4:3 frame, so the - * prim adds nothing to either reveal margin and its mirror can be skipped. */ -static int mirror_x_center_only(int lo, int hi) { - if (!s_wide_fast || s_geometry_correction) return 0; - int base = g_wide_cur_base, native_w = g_wide_w - 2 * g_wide_off; - if (native_w <= 0) return 0; - return (lo >= base) && (hi < base + native_w); -} -static int mirror_geo_center_only(const int *xs, int n) { - if (!s_wide_fast || s_geometry_correction) return 0; - int lo = xs[0], hi = xs[0]; - for (int i = 1; i < n; i++) { if (xs[i] < lo) lo = xs[i]; if (xs[i] > hi) hi = xs[i]; } - return mirror_x_center_only(lo, hi); -} -/* mirror_batch_center_only (textured-batch variant) is defined after s_tb below. */ - -/* Set / clear the 2D-backdrop x-stretch for a wide-mirror draw, per the current - * gate (s_bd_gate, set by the caller from bd_prim_gate / the batch gate). */ -static void wide_set_bd_scale(GLint uScale, GLint uCenter) { - extern int g_ws_tex_edge_pct; - float scale = 1.0f, center = 0.0f; - if (s_bd_gate && g_ws_bd_stretch_on && g_wide_w > 0) { - int native_w = g_wide_w - 2 * g_wide_off; - if (native_w > 0) { - scale = g_ws_bd_stretch_pct > 0 ? (float)g_ws_bd_stretch_pct / 100.0f - : (float)g_wide_w / (float)native_w; - if (s_bd_gate == 2) { - scale = g_ws_tex_edge_pct > 0 - ? (float)g_ws_tex_edge_pct / 100.0f : scale; - scale = -scale; /* shader: expand only beyond canonical edges */ - } - center = (float)g_wide_cur_base + (float)native_w / 2.0f; - } - } - if (scale != 1.0f) s_bdg_applied++; - p_glUniform1f(uScale, scale); - p_glUniform1f(uCenter, center); -} -static void wide_clear_bd_scale(GLint uScale, GLint uCenter) { - p_glUniform1f(uScale, 1.0f); - p_glUniform1f(uCenter, 0.0f); -} - -/* ---- textured-prim batching -------------------------------------------- * - * Consecutive textured prims sharing blend/mask/texwindow/filter coalesce into - * one draw. Per-prim texture state (texpage/clut/depth/raw/uv-limits) rides in - * the vertex (TEXV flat attributes), so only `semi` (blend) and the global - * mask/twin/filter are batch keys. flush_tex_batch() draws the queued verts; it - * is called before any op that reads VRAM, writes it outside the batch, or - * changes batch state (see callers: flush_cpu_upload, flush_pack_if_sampling, - * every non-textured glb_ wrapper, and the present path). Drawing reads only the - * already-coherent texture (per-prim coherency was ensured at append time), so - * flush_tex_batch never re-enters those helpers. */ -#define TEXBATCH_MAXV 8190 /* multiple of 3; ~2730 tris */ -static float s_tb[TEXBATCH_MAXV * TEXV]; -static uint64_t s_tb_identity[TEXBATCH_MAXV]; -static int s_tb_n = 0; /* verts queued */ -static int s_tb_semi = -2; -static int s_tb_mask = 0, s_tb_filter = 0; -static int s_tb_twin[4] = {0, 0, 0, 0}; -static uint64_t s_batch_total = 0, s_batch_reason[7]; - -void gl_renderer_batch_diag(uint64_t out[8]) { - out[0] = s_batch_total; - for (int i = 0; i < 7; i++) out[i + 1] = s_batch_reason[i]; -} - -/* Draw the queued textured batch with correct PSX mask-bit handling AND correct - * painter's order. The mask bit lives in both the colour-attachment alpha - * (frag.a) and the stencil; STP=1 texels must always set it. - * - * OPAQUE batch (semi < 0): the STP bit does NOT gate COLOUR (every texel is - * opaque), so colour is drawn in ONE ordered pass over all texels — frag.a still - * carries the per-texel mask bit, so the alpha mask is correct — followed by a - * COLOUR-MASKED pass that only fixes the STENCIL for STP=1 texels. The old code - * split colour into STP=0 (pass 1) then STP=1 (pass 2) across the WHOLE batch, - * which let a behind prim's STP=1 texels overwrite a front prim's STP=0 colour - * (the Tomba character drew behind an AP-block's letters / a save post on GL - * only). Same draw count, order preserved, mask preserved. - * - * SEMI batch (semi >= 0): genuine two-pass — STP=0 texels opaque, STP=1 texels - * blended per the PSX mode. Cross-prim order is kept by isolating semi prims to - * one per batch (see gpu_textured_triangle), so this batch holds a single prim - * whose two passes do not self-overlap. */ -static void tex_batch_draw_passes(int nverts, int semi) { - p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); - if (semi < 0) { - glDisable(GL_BLEND); - mask_stencil(s_tb_mask); - p_glUniform1i(s_uSemipass, 0); /* all texels, one ordered colour pass */ - glDrawArrays(GL_TRIANGLES, 0, nverts); - if (s_mask_check) { - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); /* stencil-only fixup */ - mask_stencil(1); - p_glUniform1i(s_uSemipass, 2); /* STP=1 texels set the mask bit */ - glDrawArrays(GL_TRIANGLES, 0, nverts); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - } else if (!s_tb_mask) { - /* Alpha is already exact; defer its duplicate stencil encoding - * until a later GP0(E6h) actually enables destination masking. */ - s_stencil_valid = 0; - } - } else if (!s_mask_check && semi == 4) { - /* Modes 0/1/3 can select opaque-vs-semi behavior per fragment with - * dual-source factors, so the whole painter-ordered batch is one draw. - * Mode 2 needs a different blend equation and stays on the conservative - * isolated path below. */ - glEnable(GL_BLEND); - p_glBlendEquationSeparate(PSXGL_FUNC_ADD, PSXGL_FUNC_ADD); - p_glBlendFuncSeparate(GL_ONE, PSXGL_SRC1_ALPHA, GL_ONE, GL_ZERO); - if (s_tb_mask) mask_stencil(1); else glDisable(GL_STENCIL_TEST); - p_glUniform1i(s_uSemipass, 0); - glDrawArrays(GL_TRIANGLES, 0, nverts); - if (!s_tb_mask && s_fill_pass <= 0.0f) s_stencil_valid = 0; - } else { - glDisable(GL_BLEND); /* Pass 1: STP=0 texels (opaque) */ - mask_stencil(s_tb_mask); - p_glUniform1i(s_uSemipass, 1); - glDrawArrays(GL_TRIANGLES, 0, nverts); - apply_psx_blend(semi); /* Pass 2: STP=1 texels (blended) */ - mask_stencil(1); - p_glUniform1i(s_uSemipass, 2); - glDrawArrays(GL_TRIANGLES, 0, nverts); - } -} - -/* ---- frame_perf CPU attribution (native-wide wedge hunt) ----------------- * - * Per-frame CPU wall time spent inside the GL submission paths (driver CPU - * cost surfaces INSIDE our gl* calls) + counters for the wide plumbing, so a - * CPU-bound wide frame (emu_cpu >> scene_gpu) can be attributed without a - * sampling profiler. Reset at present enter; reported by frame_perf. */ -static double cw_ms(void) { - static double freq = 0.0; - if (freq == 0.0) { - uint64_t f = SDL_GetPerformanceFrequency(); - freq = f ? (double)f : 1.0; - } - return (double)SDL_GetPerformanceCounter() * 1000.0 / freq; -} -static double s_cw_flush_ms = 0.0; /* CPU wall inside flush_tex_batch */ -static double s_cw_wide_ms = 0.0; /* CPU wall inside glb_wide_* entry points */ -static int s_cw_batches = 0, s_cw_wide_sets = 0, s_cw_wide_cfgs = 0, - s_cw_wide_clears = 0, s_cw_fbo_creates = 0, s_cw_flush_depth = 0; - -/* Textured-batch variant of mirror_x_center_only: scan the queued verts' x - * (attr 0, stride TEXV). Defined here so s_tb / TEXV are in scope. */ -static int mirror_batch_center_only(int nverts) { - if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; - int lo = (int)s_tb[0], hi = (int)s_tb[0]; - for (int i = 1; i < nverts; i++) { - int x = (int)s_tb[i * TEXV]; - if (x < lo) lo = x; if (x > hi) hi = x; - } - return mirror_x_center_only(lo, hi); -} - -static float filler_line_width(int scale) { - const float width = s_crack_fill * (float)scale; - return width < 1.0f ? 1.0f : width > s_line_width_max ? s_line_width_max : width; -} - -/* What a filler pass of the mirror leaves as it found it: the mask bit in alpha and stencil, and the depth. */ -static void filler_pass_begin(float pass) { - s_fill_pass = pass; - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); - glDepthFunc(GL_LESS); - glDepthMask(GL_FALSE); -} -static void filler_pass_end(void) { - s_fill_pass = 0.0f; - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glStencilMask(0x01); - glDepthFunc(GL_ALWAYS); -} - -/* Under a batch, the edges of its exact world polygons: exact corners open the hairline gaps that whole pixels closed. */ -static void tex_batch_outline(int nverts, int semi) { - int exact = 0; - for (int i = 0; i < nverts && !exact; i += 3) exact = s_tb[i * TEXV + 19] > 1.5f; - if (!exact) return; - filler_pass_begin(2.0f); - glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); - glLineWidth(filler_line_width(s_scale)); - glDisable(GL_BLEND); - mask_stencil(s_tb_mask); - p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); - p_glUniform1i(s_uSemipass, semi < 0 ? 0 : 4); - glDrawArrays(GL_TRIANGLES, 0, nverts); - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); - filler_pass_end(); -} - -/* A batch of exact world polygons by depth, see s_world_depth. */ -static void tex_batch_by_depth(int nverts, int semi) { - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDepthFunc(GL_LEQUAL); - glDepthMask(GL_TRUE); - p_glUniform1i(s_uSemimode, semi < 0 ? 0 : semi); - p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); - glDrawArrays(GL_TRIANGLES, 0, nverts); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glDepthMask(GL_FALSE); - p_glUniform1f(s_tex_uPull, s_world_margin); - tex_batch_draw_passes(nverts, semi); - p_glUniform1f(s_tex_uPull, 0.0f); - glDepthFunc(GL_ALWAYS); -} - -static void flush_tex_batch(void) { - if (s_tb_n == 0) return; - int nverts = s_tb_n, semi = s_tb_semi; - const int fillers = s_tb_fillers; - const int world = s_tb_world; - s_tb_n = 0; /* clear first: re-entrancy safe */ - double cw_t0 = cw_ms(); - s_cw_batches++; s_batch_total++; s_cw_flush_depth++; - - hr_begin(1); - p_glUseProgram(s_tex_prog); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_raw_tex); - p_glUniform1i(s_uVram, 0); - p_glUniform4i(s_uTwin, s_tb_twin[0], s_tb_twin[1], s_tb_twin[2], s_tb_twin[3]); - p_glUniform1i(s_uMaskset, s_tb_mask); - p_glUniform1i(s_uFilter, s_tb_filter); - p_glBindVertexArray(s_tex_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_tex_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * TEXV * sizeof(float)), s_tb, PSXGL_STREAM_DRAW); - - tex_batch_draw_passes(nverts, semi); - - /* Native-wide mirror — skipped for a batch fully inside the 4:3 frame (its - * centre content comes from the present-time canonical blit; nothing to add - * to the margins). A backdrop-stretched batch (s_tb_gate) widens past the - * frame, so it is never treated as centre-only. */ - if (g_wide_cur && s_ws_ablate != 1 && - !(s_tb_gate == 0 && mirror_batch_center_only(nverts))) { /* native-wide mirror */ - int dx = wide_dx(); - s_bd_gate = s_tb_gate; /* this batch is uniform-gate (flushed on change) */ - gl_perf_mirror_begin(); - wide_target_begin(dx, s_tex_uXoff, s_tex_uXhalf); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 1); - wide_set_bd_scale(s_tex_uXscale, s_tex_uXcenter); - s_draw_filler = mirror_depth() && fillers; - s_draw_world = mirror_depth() && world; - temporal_draw(s_tb, s_tb_identity, nverts, 1, GL_TRIANGLES, semi, s_tb_mask, - s_tb_filter, s_tb_twin, s_tb_gate, 0); - s_draw_filler = s_draw_world = 0; - if (s_ws_ablate != 2) { - if (mirror_depth() && s_crack_fill > 0.0f) tex_batch_outline(nverts, semi); - if (mirror_depth() && fillers) { /* in sequence each copy would break the batch: 135 batches a frame against 2 */ - filler_pass_begin(GROWN_COPY_MARGIN); - tex_batch_draw_passes(nverts, semi); - filler_pass_end(); - } - if (world && mirror_depth()) tex_batch_by_depth(nverts, semi); - else tex_batch_draw_passes(nverts, semi); - } - if (mirror_depth() && !world && s_ws_ablate != 2) { /* the depth of the opaque fragments alone */ - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDepthMask(GL_TRUE); - p_glUniform1i(s_uSemipass, semi < 0 ? 3 : 4); - glDrawArrays(GL_TRIANGLES, 0, nverts); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - } - wide_clear_bd_scale(s_tex_uXscale, s_tex_uXcenter); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); - wide_target_end(s_tex_uXoff, s_tex_uXhalf); - gl_perf_mirror_end(); - } - hr_end(); - if (--s_cw_flush_depth == 0) s_cw_flush_ms += cw_ms() - cw_t0; -} - -/* Flat / gouraud GEO batch — MotK title/char-select starfields issue ~30k/s - * GP0(68h) 1x1 dots; each was two immediate gpu_triangle draws (BufferData + - * DrawArrays each). Coalesce opaque/semi-uniform tris into one draw. */ -#define FLATBATCH_MAXV 8190 /* multiple of 3 */ -static float s_fb[FLATBATCH_MAXV * GEOV]; -static uint64_t s_fb_identity[FLATBATCH_MAXV]; -static int s_fb_n = 0; -static int s_fb_semi = -2; -static int s_fb_mask = -1; - -static int mirror_flat_batch_center_only(int nverts) { - if (!s_wide_fast || s_geometry_correction || nverts <= 0) return 0; - int lo = (int)s_fb[0], hi = (int)s_fb[0]; - for (int i = 1; i < nverts; i++) { - int x = (int)s_fb[i * GEOV]; - if (x < lo) lo = x; if (x > hi) hi = x; - } - return mirror_x_center_only(lo, hi); -} - -static void flush_flat_batch(void) { - if (s_fb_n == 0) return; - int nverts = s_fb_n, semi = s_fb_semi, mask = s_fb_mask; - s_fb_n = 0; - - hr_begin(1); - if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); - mask_stencil(mask); - p_glUseProgram(s_geo_prog); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glBindVertexArray(s_geo_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * GEOV * sizeof(float)), - s_fb, PSXGL_STREAM_DRAW); - glDrawArrays(GL_TRIANGLES, 0, nverts); - - if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && - !(!g_ws_bd_stretch_on && mirror_flat_batch_center_only(nverts))) { - int dx = wide_dx(); - /* Batch may span many prims; use stretch gate off (flat dots/UI). */ - s_bd_gate = 0; - gl_perf_mirror_begin(); - wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); - wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); - temporal_draw(s_fb, s_fb_identity, nverts, 0, GL_TRIANGLES, semi, mask, 0, NULL, 0, 0); - if (mirror_depth()) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); - if (s_ws_ablate != 2) glDrawArrays(GL_TRIANGLES, 0, nverts); - wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - wide_target_end(s_geo_uXoff, s_geo_uXhalf); - gl_perf_mirror_end(); - } - hr_end(); -} - -/* Flat / gouraud triangles and lines share the GEO program. mode: GL_TRIANGLES - * or GL_LINES; vertices carry canonical x/y, color, visual flags, and separate - * presentation x/y. Bit 0 of the flags gates the fractional-yaw transform; - * bit 1 identifies exact provenance for the optional coverage tint. */ -static void gpu_geometry(GLenum mode, const int *xs, const int *ys, - const uint16_t *cs, int n, int semi) { - float px[3] = {0}, py[3] = {0}, pz[3] = {0}; - uint64_t pid[3] = {0}; - int visual_flags = 0; - if (mode == GL_TRIANGLES && n == 3) - visual_flags = take_visual_triangle(xs, ys, px, py, NULL, pz, pid) & 3; - else { - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - } - flush_tex_batch(); /* flat prim: drain textured draws first (order + program) */ - flush_cpu_upload(); /* also drains flat batch if an upload was pending */ - mark_prim_dirty(xs, ys, n, 0 /* flat */); - - /* Lines stay immediate (rare); tris batch for MotK 0x68 starfields. */ - if (mode != GL_TRIANGLES || n < 3) { - flush_flat_batch(); - float verts[3 * GEOV]; - float mask_a = s_mask_set ? 1.0f : 0.0f; - for (int i = 0; i < n; i++) { - verts[i*GEOV+0] = (float)xs[i]; - verts[i*GEOV+1] = (float)ys[i]; - verts[i*GEOV+2] = ((cs[i] & 0x1F) << 3) / 255.0f; - verts[i*GEOV+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; - verts[i*GEOV+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; - verts[i*GEOV+5] = mask_a; - verts[i*GEOV+6] = 0.0f; - verts[i*GEOV+7] = (float)xs[i]; - verts[i*GEOV+8] = (float)ys[i]; - verts[i*GEOV+9] = 0.0f; - } - hr_begin(1); - if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); - mask_stencil(s_mask_set); - if (mode == GL_LINES) glLineWidth((float)s_scale); - p_glUseProgram(s_geo_prog); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glBindVertexArray(s_geo_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(n * GEOV * sizeof(float)), - verts, PSXGL_STREAM_DRAW); - glDrawArrays(mode, 0, n); - if (g_wide_cur && !s_wide_suppress && s_ws_ablate != 1 && - !(!g_ws_bd_stretch_on && mirror_geo_center_only(xs, n))) { - int dx = wide_dx(); - s_bd_gate = bd_prim_gate(xs, n, 0); - gl_perf_mirror_begin(); - wide_target_begin(dx, s_geo_uXoff, s_geo_uXhalf); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 1); - wide_set_bd_scale(s_geo_uXscale, s_geo_uXcenter); - temporal_draw(verts, NULL, n, 0, mode, semi, s_mask_set, 0, NULL, s_bd_gate, 0); - if (s_ssao) glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); - if (s_ws_ablate != 2) glDrawArrays(mode, 0, n); - wide_clear_bd_scale(s_geo_uXscale, s_geo_uXcenter); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - wide_target_end(s_geo_uXoff, s_geo_uXhalf); - gl_perf_mirror_end(); - } - hr_end(); - return; - } - - if (s_fb_n > 0 && (s_fb_semi != semi || s_fb_mask != (int)s_mask_set)) - flush_flat_batch(); - if (s_fb_n + n > FLATBATCH_MAXV) - flush_flat_batch(); - s_fb_semi = semi; - s_fb_mask = (int)s_mask_set; - - float mask_a = s_mask_set ? 1.0f : 0.0f; - for (int i = 0; i < n; i++) { - float *v = &s_fb[s_fb_n * GEOV]; - v[0] = (float)xs[i]; - v[1] = (float)ys[i]; - v[2] = ((cs[i] & 0x1F) << 3) / 255.0f; - v[3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; - v[4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; - v[5] = mask_a; - v[6] = (float)visual_flags; - v[7] = visual_flags ? px[i] : (float)xs[i]; - v[8] = visual_flags ? py[i] : (float)ys[i]; - v[9] = pz[i]; /* temporal identity only; no shader attribute */ - s_fb_identity[s_fb_n] = pid[i]; - s_fb_n++; - } -} - -static void gpu_triangle(int x0,int y0,uint16_t c0, int x1,int y1,uint16_t c1, - int x2,int y2,uint16_t c2, int semi) { - int xs[3] = {x0, x1, x2}, ys[3] = {y0, y1, y2}; - uint16_t cs[3] = {c0, c1, c2}; - gpu_geometry(GL_TRIANGLES, xs, ys, cs, 3, semi); -} - -static void gpu_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int semi) { - int xs[2] = {x0, x1}, ys[2] = {y0, y1}; - uint16_t cs[2] = {c0, c1}; - gpu_geometry(GL_LINES, xs, ys, cs, 2, semi); -} - -/* Shared PS1 uv-sampling model (limits + mirrored-2D compensation) — one - * implementation for GL/VK/SW, see gpu_uv.h. */ -#include "gpu_uv.h" - -/* Textured triangle. Always two passes split by the per-texel STP bit so the - * stencil (mask) write value is constant within each pass; the semi pass is - * also where PS1 blending applies. lim = uv sampling bounds (see - * tri_uv_limits); NULL computes them from the vertices. */ -static void gpu_textured_triangle(const int *xs, const int *ys, - const int *us, const int *vs, - const float *col, uint16_t texpage, - uint16_t clut_x, uint16_t clut_y, int rawtex, - int semi, const int *lim) { - float px[3], py[3], pq[3], pz[3]; - uint64_t pid[3]; - const int visual_flags = take_visual_triangle(xs, ys, px, py, pq, pz, pid); - turned_perspective(px, pq, visual_flags); - int lim_buf[4]; - int uv_buf[6]; - if (!lim) { - /* Poly path: exact sampled bounds from the ORIGINAL uvs, then the - * center-sampling mirror compensation (rect prims arrive with their - * own precomputed lim and pre-bumped uvs). */ - int *mu = uv_buf, *mv = uv_buf + 3; - for (int i = 0; i < 3; i++) { mu[i] = us[i]; mv[i] = vs[i]; } - psx_uv_tri_limits(xs, ys, mu, mv, lim_buf); - psx_uv_tri_mirror_offset(xs, ys, mu, mv); - us = mu; vs = mv; - lim = lim_buf; - } - s_scene_prims_tex++; - int base_x = (texpage & 0xF) * 64; - int base_y = ((texpage >> 4) & 1) * 256; - int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2; - - flush_cpu_upload(); /* if a CPU->VRAM upload is pending it flushes the batch first */ - flush_pack_if_sampling(base_x, base_y, depth, clut_x, clut_y); /* flushes batch iff it must pack */ - mark_prim_dirty(xs, ys, 3, 1 /* textured */); - - /* Append to the textured batch. Flush first if this prim's blend/mask/twin/ - * filter differ from the open batch, or the buffer is full. Per-prim texture - * state goes in the vertex; only these keys force a new draw. */ - { - flush_flat_batch(); /* painter order: flat GEO before textured */ - int twx = s_tw_mask_x, twy = s_tw_mask_y, tox = s_tw_off_x, toy = s_tw_off_y; - int gate = bd_prim_gate(xs, 3, 1); /* backdrop-stretch gate is also a batch key */ - const int world = psx_world_depth_class(s_world_depth, visual_flags, pz, s_world_margin); - if (s_tb_n > 0 && world != s_tb_world) flush_tex_batch(); - /* With mask checking off, opaque and mode-0 semi primitives use the - * same dual-source blend state. The per-vertex a_semi flag selects - * replace vs half-blend without breaking painter order. */ - int batch_semi = (!s_mask_check && semi != 2) ? 4 : semi; - /* STP draw-ORDER correctness. flush_tex_batch draws a batch in two passes - * over the WHOLE batch (pass 1 = every prim's STP=0/opaque texels, pass 2 - * = every prim's STP=1/semi texels with the PSX blend). For overlapping - * prims that share a batch, a BEHIND prim's semi texels (pass 2) then - * paint OVER a FRONT prim's opaque texels (pass 1) — a painter's-order - * violation that only exists on GL (Tomba: the character drew behind the - * AP-block letters / a save post on GL, correct on software). So a - * semi-transparent prim must NOT coalesce with its neighbours: drain the - * open batch, draw this prim alone (its own STP=0+STP=1 passes, which do - * not self-overlap → composited fully before the next prim, exactly like - * the software renderer), and let opaque prims keep batching. Opaque - * content (terrain/foliage — the batching perf win) is untouched; the cost - * is one draw per semi prim, which are sparse. (A future single-pass - * optimization for modes 0/1/3 via GL_ONE,GL_SRC_ALPHA with a per-fragment - * destination factor in alpha could re-batch semi prims — see memory - * tomba_sprite_zorder_bug; mode 2 subtractive still needs isolation.) */ - int isolate = (semi >= 0 && (semi == 2 || s_mask_check)); - int reason = -1; - if (s_tb_n > 0) { - if (isolate) reason = 0; - else if (batch_semi != s_tb_semi) reason = 1; - else if (s_mask_set != s_tb_mask) reason = 2; - else if (s_tex_filter != s_tb_filter) reason = 3; - else if (gate != s_tb_gate) reason = 4; - else if (twx != s_tb_twin[0] || twy != s_tb_twin[1] || - tox != s_tb_twin[2] || toy != s_tb_twin[3]) reason = 5; - } - if (reason >= 0) { - s_batch_reason[reason]++; - flush_tex_batch(); - } - if (s_tb_n + 3 > TEXBATCH_MAXV) { s_batch_reason[6]++; flush_tex_batch(); } - if (s_tb_n == 0) { /* opening a batch: capture its keyed state */ - s_tb_semi = batch_semi; s_tb_mask = s_mask_set; s_tb_filter = s_tex_filter; s_tb_gate = gate; - s_tb_fillers = 0; - s_tb_world = world; - s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy; - } - float *vp = &s_tb[s_tb_n * TEXV]; - for (int i = 0; i < 3; i++, vp += TEXV) { - vp[0] = (float)xs[i]; - vp[1] = (float)ys[i]; - vp[2] = (float)us[i]; vp[3] = (float)vs[i]; - vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; - vp[8] = (float)base_x; vp[9] = (float)base_y; /* a_tpage */ - vp[10] = (float)clut_x; vp[11] = (float)clut_y; /* a_clut */ - vp[12] = (float)depth; vp[13] = (float)rawtex; /* a_depth, a_raw */ - vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ - vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; - vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ - vp[19] = (float)visual_flags; /* a_visual_flags */ - vp[20] = visual_flags ? px[i] : (float)xs[i]; /* a_visual_pos */ - vp[21] = visual_flags ? py[i] : (float)ys[i]; - vp[22] = pq[i]; /* a_q (0 = affine) */ - vp[23] = pz[i]; /* temporal identity only; no shader attribute */ - s_tb_identity[s_tb_n + i] = pid[i]; - } - if (visual_flags & 4) s_tb_fillers++; - s_tb_n += 3; - if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ - } -} - -/* Draw a flat-colored rect (GEO program) DIRECTLY into the active wide surface - * at wide-space coords [wx, wx+ww) × [y, y+h). Used only by the full-screen- - * overlay path; positions are already in wide space so u_xoff stays 0. Mirrors - * raster_flat_rect(&wt, ...) in sw_draw_flat_rect. Caller stays inside - * hr_begin/hr_end. */ -static void wide_flat_rect_direct(int wx, int y, int ww, int h, uint16_t c, int semi) { - if (ww <= 0 || h <= 0) return; - float r = ((c & 0x1F) << 3) / 255.0f; - float g = (((c >> 5) & 0x1F) << 3) / 255.0f; - float b = (((c >> 10) & 0x1F) << 3) / 255.0f; - float a = s_mask_set ? 1.0f : 0.0f; - float fx0 = (float)wx, fy0 = (float)y, fx1 = (float)(wx + ww), fy1 = (float)(y + h); - float verts[6 * GEOV] = { - fx0,fy0,r,g,b,a,0,fx0,fy0,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, - fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy0,r,g,b,a,0,fx1,fy0,0, - fx0,fy1,r,g,b,a,0,fx0,fy1,0, fx1,fy1,r,g,b,a,0,fx1,fy1,0, - }; - /* Wide target: positions already wide-space so u_xoff = 0; full-width - * scissor; u_xhalf = g_wide_w/2. */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, g_wide_cur); - wide_depth_begin(); - glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - glScissor(0, 0, g_wide_w * s_scale, VRAM_H * s_scale); /* full surface (rt_wide) */ - if (semi >= 0) apply_psx_blend(semi); else glDisable(GL_BLEND); - mask_stencil(s_mask_set); - p_glUseProgram(s_geo_prog); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glUniform1f(s_geo_uXoff, 0.0f); - p_glUniform1f(s_geo_uXhalf, (float)g_wide_w / 2.0f); - p_glBindVertexArray(s_geo_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); - temporal_draw(verts, NULL, 6, 0, GL_TRIANGLES, semi, s_mask_set, 0, NULL, 0, 1); - if (mirror_depth()) { glEnable(GL_DEPTH_TEST); glDepthFunc(GL_ALWAYS); glDepthMask(semi < 0 ? GL_TRUE : GL_FALSE); } - glDrawArrays(GL_TRIANGLES, 0, 6); - if (mirror_depth()) { glDepthMask(GL_TRUE); glDisable(GL_DEPTH_TEST); } - p_glUniform1f(s_geo_uXoff, 0.0f); - p_glUniform1f(s_geo_uXhalf, 512.0f); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); -} - -static void gpu_flat_rect(int x,int y,int w,int h,uint16_t c,int semi) { - /* Rectangles are 2D UI/sprite primitives. Never let one-shot metadata - * prepared for a rejected polygon leak into the first triangle here. */ - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - if (w <= 0 || h <= 0) return; - /* Full-screen 2D overlay (pause gray-filter / load fade): a flat rect - * spanning the whole 4:3 framebuffer must cover the whole wide surface too, - * else the revealed 16:9 margins are left undimmed/unfaded. Same detection - * as sw_draw_flat_rect: native_w = the 4:3 framebuffer width (g_wide_w less - * the per-side reveal on both sides); the rect's native screen-X span - * (x - base) must cover [0, native_w). When it does, the two canonical - * triangles are drawn WITHOUT the per-triangle wide mirror (suppressed) and - * a single full-width rect is drawn into the wide surface instead; every - * other rect mirrors 1:1 via the generic gpu_geometry path. Only runs in - * native-wide (g_wide_cur != 0), so 4:3 is unaffected. */ - int overlay = 0; - if (g_wide_cur) { - int native_w = g_wide_w - 2 * g_wide_off; - int lx = x - g_wide_cur_base, rx = x + w - g_wide_cur_base; - overlay = (native_w > 0 && lx <= 0 && rx >= native_w); - } - if (overlay) s_wide_suppress = 1; - gpu_triangle(x, y, c, x+w, y, c, x, y+h, c, semi); - gpu_triangle(x+w, y, c, x, y+h, c, x+w, y+h, c, semi); - if (overlay) { - flush_flat_batch(); - s_wide_suppress = 0; - /* The two canonical triangles drew into the hr FBO already (each - * gpu_triangle ran its own hr_begin/hr_end). Re-open the bracket just - * for the full-width wide pass so blend/scissor/program state is - * clean. */ - if (s_ws_ablate != 1) { - hr_begin(0); - gl_perf_mirror_begin(); - wide_flat_rect_direct(0, y, g_wide_w, h, c, semi); - gl_perf_mirror_end(); - hr_end(); - } - } -} - -static void gpu_textured_rect(int x,int y,int w,int h, - int u0,int v0,int u1,int v1, - uint16_t clut_x,uint16_t clut_y,uint16_t tp,int semi) { - /* As above, a textured rectangle is never a GTE-projected world - * triangle, even though it is decomposed into two triangles below. */ - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - s_sprite_depth = s_next_sprite_depth; - s_next_sprite_depth = 0.0f; - memcpy(s_sprite_sides, s_next_sprite_sides, sizeof(s_sprite_sides)); - memset(s_next_sprite_sides, 0, sizeof(s_next_sprite_sides)); - s_sprite_x = x; s_sprite_y = y; - if (w <= 0 || h <= 0) { s_sprite_depth = 0.0f; return; } - float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; - float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; - /* gpu.c routes axis-aligned MIRRORED quads (X/Y-flipped 2D sprites, - * e.g. right-facing MMX entities) through THIS path as scaled rects - * with u0>u1 / v0>v1 — they never reach the poly path. Exact bounds - * from the original corners, then the mirror bump (see gpu_uv.h). */ - int lim[4]; - psx_uv_rect_limits(u0, v0, u1, v1, lim); - psx_uv_rect_mirror_offset(&u0, &v0, &u1, &v1); - int xs1[3]={x, x+w, x}, ys1[3]={y, y, y+h}; - int us1[3]={u0,u1,u0}, vs1[3]={v0,v0,v1}; - gpu_textured_triangle(xs1,ys1,us1,vs1,col,tp,clut_x,clut_y,s_mod_raw,semi,lim); - int xs2[3]={x+w, x, x+w}, ys2[3]={y, y+h, y+h}; - int us2[3]={u1,u0,u1}, vs2[3]={v0,v1,v1}; - gpu_textured_triangle(xs2,ys2,us2,vs2,col,tp,clut_x,clut_y,s_mod_raw,semi,lim); - s_sprite_depth = 0.0f; -} - -/* GP0(02h) fill: writes color with bit15=0, ignoring draw area, mask and - * offset; coordinates wrap. A scissored clear (color + stencil) per wrapped - * segment is exactly this. */ -static void fill_segment(int x, int y, int w, int h, float r, float g, float b) { - if (w <= 0 || h <= 0) return; - glScissor(x * s_scale, y * s_scale, w * s_scale, h * s_scale); - glClearColor(r, g, b, 0.0f); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - rect_add(&s_pack_dirty, x, y, x + w - 1, y + h - 1); -} - -static void gpu_fill(int x,int y,int w,int h,uint16_t c) { - if (w <= 0 || h <= 0) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - float r=(c&0x1F)/31.0f, g=((c>>5)&0x1F)/31.0f, b=((c>>10)&0x1F)/31.0f; - x &= VRAM_W - 1; y &= VRAM_H - 1; - if (w > VRAM_W) w = VRAM_W; - if (h > VRAM_H) h = VRAM_H; - int w1 = w, w2 = 0, h1 = h, h2 = 0; - if (x + w > VRAM_W) { w1 = VRAM_W - x; w2 = w - w1; } - if (y + h > VRAM_H) { h1 = VRAM_H - y; h2 = h - h1; } - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale); - glEnable(GL_SCISSOR_TEST); - fill_segment(x, y, w1, h1, r, g, b); - if (w2) fill_segment(0, y, w2, h1, r, g, b); - if (h2) fill_segment(x, 0, w1, h2, r, g, b); - if (w2 && h2) fill_segment(0, 0, w2, h2, r, g, b); - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - s_gpu_dirty = 1; - coh_record(GL_COH_FILL, x, y, x + w - 1, y + h - 1); -} - -/* VRAM->VRAM copy: blit the source region to the scratch texture (resolves - * overlap), then draw it back at the destination through the BLIT program so - * mask set/check and the stencil mirror apply, exactly like sw_copy_rect. */ -static void gpu_copy_rect(int sx,int sy,int dx,int dy,int w,int h) { - if (w <= 0 || h <= 0) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - /* Clamp to bounds (the software path wraps; wrapping copies are unused - * in practice — see the file header). */ - if (sx < 0) sx = 0; if (sy < 0) sy = 0; - if (dx < 0) dx = 0; if (dy < 0) dy = 0; - if (sx + w > VRAM_W) w = VRAM_W - sx; - if (dx + w > VRAM_W) w = VRAM_W - dx; - if (sy + h > VRAM_H) h = VRAM_H - sy; - if (dy + h > VRAM_H) h = VRAM_H - dy; - if (w <= 0 || h <= 0) return; - - int S = s_scale; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_scratch_fbo); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(sx*S, sy*S, (sx+w)*S, (sy+h)*S, - sx*S, sy*S, (sx+w)*S, (sy+h)*S, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - - hr_begin(0); /* copies ignore the draw area */ - glScissor(dx*S, dy*S, w*S, h*S); - glDisable(GL_BLEND); - p_glUseProgram(s_blit_prog); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_scratch_tex); - p_glUniform1i(s_uBlitSrc, 0); - p_glUniform1i(s_uBlitMaskset, s_mask_set); - /* Scratch holds the source at its own hr coords: texel = frag + (src-dst)*S. */ - p_glUniform1i(s_uBlitSrcDiv, 1); - p_glUniform2i(s_uBlitSrcOff, (sx - dx) * S, (sy - dy) * S); - float fx0 = (float)dx, fy0 = (float)dy, fx1 = (float)(dx + w), fy1 = (float)(dy + h); - float verts[6 * 2] = { - fx0, fy0, fx1, fy0, fx0, fy1, - fx1, fy0, fx0, fy1, fx1, fy1, - }; - p_glBindVertexArray(s_blit_vao); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); - p_glBufferData(PSXGL_ARRAY_BUFFER, sizeof verts, verts, PSXGL_STREAM_DRAW); - /* Two passes split by source bit15 so stencil tracks the copied mask. */ - mask_stencil(s_mask_set); p_glUniform1i(s_uBlitPass, 1); glDrawArrays(GL_TRIANGLES, 0, 6); - mask_stencil(1); p_glUniform1i(s_uBlitPass, 2); glDrawArrays(GL_TRIANGLES, 0, 6); - hr_end(); - - rect_add(&s_pack_dirty, dx, dy, dx + w - 1, dy + h - 1); - s_gpu_dirty = 1; - coh_record(GL_COH_COPY_SRC, sx, sy, sx + w - 1, sy + h - 1); - coh_record(GL_COH_COPY, dx, dy, dx + w - 1, dy + h - 1); -} - -/* ---- backend vtable wrappers ------------------------------------------- */ -static void glb_init(uint16_t *vram) { s_vram = vram; sw_renderer_init(vram); } - -/* Under GL the internal-resolution scale lives in the hr FBO; the CPU-side - * (software mirror, readbacks, screenshots) stays native, so the reported - * scale is 1 and the software hi-res mirror stays off. */ -static void glb_set_scale(int s) { - if (s < 1) s = 1; - if (s > GL_MAX_INTERNAL_SCALE) s = GL_MAX_INTERNAL_SCALE; - s_req_scale = s; - if (s_raster_ok && s != s_scale && !resize_gpu_raster(s)) - s_req_scale = s_scale; - sw_renderer_set_scale(1); -} -static int glb_scale(void) { return s_scale; } /* real internal SSAA scale (was a stub 1; the - native-wide CPU present path + gr_scale() callers - need the true scale — the FBO-direct present is - unaffected since it never reads gr_scale()) */ -static void glb_set_texture_filter(int b) { s_tex_filter = b ? 1 : 0; sw_set_texture_filter(b); } -static int glb_texture_filter(void) { return s_tex_filter; } - -static void glb_set_geometry_correction(int enabled) { - if ((enabled ? 1 : 0) != s_geometry_correction) { - interp_reset_history(); - memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - } - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - s_geometry_correction = enabled ? 1 : 0; - if (!s_geometry_correction) { - s_next_world = 0; - s_next_precise = 0; - s_next_unpinned = 0; - s_next_perspective = 0; - s_next_temporal_depth = 0; - s_yaw_sin = 0.0f; - s_yaw_cos = 1.0f; - } -} - -static void glb_set_world_triangle(int world) { - if (!s_raster_ok) return; - s_next_world = world; /* 2: the game's grown second copy */ - if (!world) s_next_temporal_depth = 0; -} - -static void glb_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2) { - if (!s_raster_ok) { - sw_set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); - return; - } - s_next_precise = enabled ? 1 : 0; - if (!enabled) s_next_temporal_depth = 0; - s_next_x16[0] = x0; s_next_y16[0] = y0; - s_next_x16[1] = x1; s_next_y16[1] = y1; - s_next_x16[2] = x2; s_next_y16[2] = y2; -} - -static void glb_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { - if (!s_raster_ok) return; - s_next_unpinned = 1; - s_next_unpinned_x16[0] = x0; s_next_unpinned_y16[0] = y0; - s_next_unpinned_x16[1] = x1; s_next_unpinned_y16[1] = y1; - s_next_unpinned_x16[2] = x2; s_next_unpinned_y16[2] = y2; -} - -static void glb_set_perspective_triangle(int enabled, - float q0, float q1, float q2) { - /* Pre-context fallback draws canonical software VRAM, where perspective - * correction is deliberately forbidden. Drop rather than forward it. */ - s_next_perspective = 0; - if (!s_raster_ok || !enabled || - !isfinite(q0) || !isfinite(q1) || !isfinite(q2) || - q0 <= 0.0f || q1 <= 0.0f || q2 <= 0.0f) - return; - s_next_q[0] = q0; - s_next_q[1] = q1; - s_next_q[2] = q2; - s_next_perspective = 1; -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static void glb_set_temporal_depth_triangle(int enabled, - float z0, float z1, float z2) { - s_next_temporal_depth = 0; - if (!s_raster_ok || !enabled || - !isfinite(z0) || !isfinite(z1) || !isfinite(z2)) - return; - /* enabled == 2: a polygon some of whose corners have no depth, which stay 0. */ - if (enabled == 2 ? z0 < 0.0f || z1 < 0.0f || z2 < 0.0f || z0 + z1 + z2 <= 0.0f - : z0 <= 0.0f || z1 <= 0.0f || z2 <= 0.0f) - return; - s_next_z[0] = z0; s_next_z[1] = z1; s_next_z[2] = z2; - s_next_temporal_depth = enabled == 2 ? 2 : 1; -} -static void glb_set_surface_depth_triangle(float z0, float z1, float z2) { - const int usable = s_raster_ok && isfinite(z0) && isfinite(z1) && isfinite(z2) && z0 >= 0.0f && z1 >= 0.0f && z2 >= 0.0f; - s_next_surface_z[0] = usable ? z0 : 0.0f; - s_next_surface_z[1] = usable ? z1 : 0.0f; - s_next_surface_z[2] = usable ? z2 : 0.0f; -} -static void glb_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { - s_next_identity[0] = id0; s_next_identity[1] = id1; s_next_identity[2] = id2; -} -static void glb_set_temporal_sprite(float depth, const float sides[4]) { - s_next_sprite_depth = s_raster_ok && isfinite(depth) && depth > 0.0f ? depth : 0.0f; - int placed = s_next_sprite_depth > 0.0f && sides != NULL; - for (int side = 0; placed && side < 4; ++side) placed = isfinite(sides[side]) && fabsf(sides[side]) < 64.0f; -#ifndef PSX_NO_DEBUG_TOOLS - /* PSX_GL_SPRITE_WHOLE_PIXELS=1 puts sprites back on whole pixels, to measure against. */ - static int whole = -1; - if (whole < 0) { const char *asked = getenv("PSX_GL_SPRITE_WHOLE_PIXELS"); whole = asked && asked[0] == '1'; } - if (whole) placed = 0; -#endif - for (int side = 0; side < 4; ++side) s_next_sprite_sides[side] = placed ? sides[side] : 0.0f; -} -#endif - -static void glb_set_presentation_yaw(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x) { - /* A frame may still have batched polygons when the vblank mouse hook - * advances the residual. Land those with the OLD residual first so the - * presentation transform stays paired with the guest yaw that built them. */ - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - if (!isfinite(yaw_units)) yaw_units = 0.0; - if (yaw_units > 4.0) yaw_units = 4.0; - if (yaw_units < -4.0) yaw_units = -4.0; - if (!isfinite(full_turn) || full_turn <= 0.0) { - s_yaw_sin = 0.0f; - s_yaw_cos = 1.0f; - } else { - const double radians = - yaw_units * (6.28318530717958647692 / full_turn); - s_yaw_sin = (float)sin(radians); - s_yaw_cos = (float)cos(radians); - } - s_yaw_center_x = isfinite(center_x) ? (float)center_x : 160.0f; - s_yaw_center_y_relative = isfinite(center_y) - ? (float)center_y : 120.0f; - s_yaw_focal_x = isfinite(focal_x) && focal_x > 1.0 - ? (float)focal_x : 160.0f; -} - -static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } -static void glb_set_mask_bits(int s, int c) { - int next_check = c ? 1 : 0; - if (next_check && !s_mask_check) { - /* Land all alpha-authoritative work before deriving stencil from it. */ - flush_tex_batch(); - flush_cpu_upload(); - rebuild_mask_stencils(); - } - s_mask_set = s ? 1 : 0; - s_mask_check = next_check; - sw_set_mask_bits(s, c); -} -static void glb_set_texture_window(uint32_t r) { - s_tw_mask_x = (int)(r & 0x1F); - s_tw_mask_y = (int)((r >> 5) & 0x1F); - s_tw_off_x = (int)((r >> 10) & 0x1F); - s_tw_off_y = (int)((r >> 15) & 0x1F); - sw_set_texture_window(r); -} -static void glb_set_color_modulation(int r,int g,int b,int raw) { s_mod_r=r; s_mod_g=g; s_mod_b=b; s_mod_raw=raw; sw_set_color_modulation(r,g,b,raw); } -static void glb_set_draw_area(int x1,int y1,int x2,int y2) { flush_flat_batch(); flush_tex_batch(); s_area_x1=x1; s_area_y1=y1; s_area_x2=x2; s_area_y2=y2; sw_set_draw_area(x1,y1,x2,y2); } -static void glb_get_draw_area(int *x1,int *y1,int *x2,int *y2) { sw_get_draw_area(x1,y1,x2,y2); } -static void glb_set_draw_offset(int x,int y) { flush_flat_batch(); flush_tex_batch(); s_off_x=x; s_off_y=y; sw_set_draw_offset(x,y); } - -/* Pre-context draws (s_raster_ok == 0) fall back to the software rasterizer - * over CPU VRAM; the initial full-VRAM upload at context init folds them in. - * Post-init, the GPU pipeline is all-or-nothing — no per-prim fallback. */ -static void glb_draw_flat_triangle(int x0,int y0,int x1,int y1,int x2,int y2,uint16_t col) { - if (!s_raster_ok) { sw_draw_flat_triangle(x0,y0,x1,y1,x2,y2,col); return; } - gpu_triangle(x0,y0,col, x1,y1,col, x2,y2,col, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int x2,int y2,uint16_t c2) { - if (!s_raster_ok) { sw_draw_gouraud_triangle(x0,y0,c0,x1,y1,c1,x2,y2,c2); return; } - gpu_triangle(x0,y0,c0, x1,y1,c1, x2,y2,c2, s_semi_en?s_semi_mode:-1); -} -static void glb_fill_rect(int x,int y,int w,int h,uint16_t c){ - if (!s_raster_ok) { sw_fill_rect(x,y,w,h,c); return; } - gpu_fill(x,y,w,h,c); -} -static void glb_copy_rect(int sx,int sy,int dx,int dy,int w,int h){ - if (!s_raster_ok) { sw_copy_rect(sx,sy,dx,dy,w,h); return; } - gpu_copy_rect(sx,sy,dx,dy,w,h); -} -static void glb_draw_textured_triangle(int x0,int y0,int u0,int v0,int x1,int y1,int u1,int v1,int x2,int y2,int u2,int v2,uint16_t cx,uint16_t cy,uint16_t tp){ - if (!s_raster_ok) { sw_draw_textured_triangle(x0,y0,u0,v0,x1,y1,u1,v1,x2,y2,u2,v2,cx,cy,tp); return; } - int xs[3]={x0,x1,x2}, ys[3]={y0,y1,y2}, us[3]={u0,u1,u2}, vs[3]={v0,v1,v2}; - float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; - float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; - gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,s_mod_raw, s_semi_en?s_semi_mode:-1, NULL); -} -static void glb_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32_t c0,int x1,int y1,int u1,int v1,uint32_t c1,int x2,int y2,int u2,int v2,uint32_t c2,uint16_t cx,uint16_t cy,uint16_t tp,int raw){ - if (!s_raster_ok) { sw_draw_shaded_textured_triangle(x0,y0,u0,v0,c0,x1,y1,u1,v1,c1,x2,y2,u2,v2,c2,cx,cy,tp,raw); return; } - int xs[3]={x0,x1,x2}, ys[3]={y0,y1,y2}, us[3]={u0,u1,u2}, vs[3]={v0,v1,v2}; - uint32_t cc[3]={c0,c1,c2}; float col[9]; - for (int i=0;i<3;i++){ col[i*3+0]=(cc[i]&0xFF)/255.0f; col[i*3+1]=((cc[i]>>8)&0xFF)/255.0f; col[i*3+2]=((cc[i]>>16)&0xFF)/255.0f; } - gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,raw, s_semi_en?s_semi_mode:-1, NULL); -} -static void glb_draw_flat_rect(int x,int y,int w,int h,uint16_t c){ - if (!s_raster_ok) { sw_draw_flat_rect(x,y,w,h,c); return; } - gpu_flat_rect(x,y,w,h,c, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_textured_rect(int x,int y,int w,int h,int u,int v,uint16_t cx,uint16_t cy,uint16_t tp){ - if (!s_raster_ok) { sw_draw_textured_rect(x,y,w,h,u,v,cx,cy,tp); return; } - gpu_textured_rect(x,y,w,h, u,v, u+w,v+h, cx,cy,tp, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_textured_rect_scaled(int x,int y,int w,int h,int u0,int v0,int u1,int v1,uint16_t cx,uint16_t cy,uint16_t tp){ - if (!s_raster_ok) { sw_draw_textured_rect_scaled(x,y,w,h,u0,v0,u1,v1,cx,cy,tp); return; } - gpu_textured_rect(x,y,w,h, u0,v0, u1,v1, cx,cy,tp, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_line(int x0,int y0,int x1,int y1,uint16_t c){ - if (!s_raster_ok) { sw_draw_line(x0,y0,x1,y1,c); return; } - gpu_line(x0,y0,c, x1,y1,c, s_semi_en?s_semi_mode:-1); -} -static void glb_draw_shaded_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1){ - if (!s_raster_ok) { sw_draw_shaded_line(x0,y0,c0,x1,y1,c1); return; } - gpu_line(x0,y0,c0, x1,y1,c1, s_semi_en?s_semi_mode:-1); -} -static int glb_render_display(uint32_t *o,int p,int dx,int dy,int dw,int dh){ ensure_cpu(); return sw_render_display(o,p,dx,dy,dw,dh); } -static int glb_render_display_hires(uint32_t *o,int p,int dx,int dy,int dw,int dh){ ensure_cpu(); return sw_render_display_hires(o,p,dx,dy,dw,dh); } -/* While GP1 depth24 is on, packed RGB888 lives in the CPU mirror and is - * presented via gl_renderer_present — never as 1555 FBO texels. Queuing those - * MDEC A0 rects hits UP_RECTS_MAX (16) and force-flushes mid-movie (MotK intro - * ~50→~30 FPS). Skip ONLY framebuffer-sized transfers (RGB888); still upload - * smaller texture A0s so post-FMV menus keep VRAM pages coherent. - * On leave: clear the skipped FB union in the FBO — do NOT restage CPU RGB888 - * as 1555 (that painted MotK title rainbow/static). */ -static int s_depth24_skip_up = 0; -static DirtyRect s_d24_skip_fb; /* union of skipped MDEC FB rects (VRAM halfwords) */ - -static int depth24_is_fb_transfer(int w, int h) { - if (!gpu_display_is_depth24() || w <= 0 || h <= 0) return 0; - GpuDisplayInfo di; - gpu_get_display_info(&di); - int fb_w = (int)((di.width * 3u + 1u) / 2u); /* RGB W → halfwords */ - int fb_h = (int)di.height; - if (fb_w < 8) fb_w = 8; - if (fb_h < 1) fb_h = 1; - /* MotK: 768×128 class blits; allow slack. Reject small texture pages. */ - if (h >= fb_h - 8 && h <= fb_h + 16 && w >= (fb_w * 3) / 4) return 1; - if ((int64_t)w * (int64_t)h >= ((int64_t)fb_w * fb_h) / 2) return 1; - return 0; -} - -static void depth24_clear_skipped_fb(void) { - if (!s_raster_ok || !s_d24_skip_fb.set) return; - flush_flat_batch(); - flush_tex_batch(); - int x0 = s_d24_skip_fb.x0, y0 = s_d24_skip_fb.y0; - int x1 = s_d24_skip_fb.x1, y1 = s_d24_skip_fb.y1; - int rw = x1 - x0 + 1, rh = y1 - y0 + 1; - int S = s_scale; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glEnable(GL_SCISSOR_TEST); - glViewport(0, 0, VRAM_W * S, VRAM_H * S); - glScissor(x0 * S, y0 * S, rw * S, rh * S); - glClearColor(0.f, 0.f, 0.f, 0.f); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - if (s_raw_fbo) { - temporal_note_raw_change(); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_raw_fbo); - glViewport(0, 0, VRAM_W, VRAM_H); - glScissor(x0, y0, rw, rh); - glClear(GL_COLOR_BUFFER_BIT); - } - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - rect_add(&s_pack_dirty, x0, y0, x1, y1); - present_dirty_rect(x0, y0, x1, y1, 1); - rect_clear(&s_d24_skip_fb); -} - -static void depth24_upload_policy(void) { - int d24 = gpu_display_is_depth24(); - if (d24 && !s_depth24_skip_up) { - s_up_nrects = 0; - rect_clear(&s_d24_skip_fb); - } else if (!d24 && s_depth24_skip_up) { - s_up_nrects = 0; - depth24_clear_skipped_fb(); - gpu_depth24_upload_span_reset(); - } - s_depth24_skip_up = d24; -} - -static void glb_vram_write(int x,int y,uint16_t px){ - sw_vram_write(x,y,px); - depth24_upload_policy(); - /* Point pokes are never MDEC frames — always stage to FBO. */ - up_add(x & (VRAM_W-1), y & (VRAM_H-1), x & (VRAM_W-1), y & (VRAM_H-1)); -} -static uint16_t glb_vram_read(int x,int y){ ensure_cpu(); return sw_vram_read(x,y); } -static void glb_vram_transfer_in(int x,int y,int w,int h,const uint16_t *d){ - sw_vram_transfer_in(x,y,w,h,d); - depth24_upload_policy(); - if (s_depth24_skip_up && depth24_is_fb_transfer(w, h)) { - int x0 = x & (VRAM_W - 1), y0 = y & (VRAM_H - 1); - rect_add(&s_d24_skip_fb, x0, y0, x0 + w - 1, y0 + h - 1); - coh_record(GL_COH_UPLOAD, x, y, x+w-1, y+h-1); - return; - } - up_add_transfer(x, y, w, h); /* exact touched rects, incl. per-pixel wrap */ - coh_record(GL_COH_UPLOAD, x, y, x+w-1, y+h-1); -} -static void glb_vram_transfer_out(int x,int y,int w,int h,uint16_t *d){ ensure_cpu(); sw_vram_transfer_out(x,y,w,h,d); } - -/* ---- context init / present -------------------------------------------- */ -static void upload_present_tex(const uint32_t *pixels, int w, int h, int linear) { - glBindTexture(GL_TEXTURE_2D, s_present_tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - /* Re-assert clamp every upload: a stale REPEAT wrap samples past the - * right edge into garbage (MotK FMV right-strip flicker). */ - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - if (w != s_present_w || h != s_present_h) { - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_BGRA, GL_UNSIGNED_BYTE, pixels); - s_present_w = w; s_present_h = h; - } else { - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, w, h, GL_BGRA, GL_UNSIGNED_BYTE, pixels); - } -} - -/* Display aspect for the present letterbox. Default 4:3 (native). When a wide - * aspect is configured the 4:3 frame is stretched into it — paired with the - * GTE X-squash (gte_set_display_aspect) this nets a wider field of view. */ -static int s_aspect_num = 4, s_aspect_den = 3; - -void gl_renderer_set_display_aspect(int num, int den) { - if (num <= 0 || den <= 0) { num = 4; den = 3; } - s_aspect_num = num; s_aspect_den = den; -} - -/* Letterbox: largest num:den rect centered in the drawable. */ -static void letterbox_rect_aspect(int ww, int wh, int num, int den, - int *x, int *y, int *w, int *h) { - int dw = ww, dh = (ww * den) / num; - if (dh > wh) { dh = wh; dw = (wh * num) / den; } - *x = (ww - dw) / 2; - *y = (wh - dh) / 2; - *w = dw; *h = dh; -} -static void letterbox_rect(int ww, int wh, int *x, int *y, int *w, int *h) { - letterbox_rect_aspect(ww, wh, s_aspect_num, s_aspect_den, x, y, w, h); -} - -static GLuint make_tex(GLenum internal, int w, int h, GLenum fmt, GLenum type) { - GLuint t = 0; - glGenTextures(1, &t); - glBindTexture(GL_TEXTURE_2D, t); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, (GLint)internal, w, h, 0, fmt, type, NULL); - return t; -} - -static int make_fbo(GLuint *out_fbo, GLuint color_tex, GLuint stencil_rb) { - p_glGenFramebuffers(1, out_fbo); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, *out_fbo); - p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0); - if (stencil_rb) - p_glFramebufferRenderbuffer(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, - PSXGL_RENDERBUFFER, stencil_rb); - GLenum st = p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - if (st != PSXGL_FRAMEBUFFER_COMPLETE) { - fprintf(stdout, "psxrecomp: GL FBO incomplete (0x%X)\n", st); - return 0; - } - return 1; -} - -static void delete_scale_target(GLuint *fbo, GLuint *tex, GLuint *rb) { - if (*fbo) { p_glDeleteFramebuffers(1, fbo); *fbo = 0; } - if (*tex) { glDeleteTextures(1, tex); *tex = 0; } - if (rb && *rb) { p_glDeleteRenderbuffers(1, rb); *rb = 0; } -} - -/* Rebuild only the scale-dependent GL targets while keeping the current VRAM - * image alive. All replacement targets are validated before any live handle - * changes; a failed allocation therefore leaves the old renderer untouched. - * Colour is nearest-blitted between equivalent logical grids, then stencil is - * reconstructed from the copied bit-15 alpha. Native-wide surfaces are - * migrated too, avoiding a black/stale transition when the game does not - * repaint every framebuffer pixel on the following frame. */ -static int resize_gpu_raster(int scale) { - if (!s_raster_ok || scale == s_scale) return 1; - if (scale < 1 || scale > GL_MAX_INTERNAL_SCALE) return 0; - - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - - const int old_scale = s_scale; - const int new_hw = VRAM_W * scale; - const int new_hh = VRAM_H * scale; - GLuint new_hr_tex = 0, new_hr_fbo = 0, new_hr_rb = 0; - GLuint new_scratch_tex = 0, new_scratch_fbo = 0; - GLuint new_wide_tex[WIDE_MAX_SURF] = {0}; - GLuint new_wide_fbo[WIDE_MAX_SURF] = {0}; - GLuint new_wide_rb[WIDE_MAX_SURF] = {0}; - int current_wide_index = -1; - - new_hr_tex = make_tex(GL_RGBA8, new_hw, new_hh, - GL_RGBA, GL_UNSIGNED_BYTE); - new_scratch_tex = make_tex(GL_RGBA8, new_hw, new_hh, - GL_RGBA, GL_UNSIGNED_BYTE); - p_glGenRenderbuffers(1, &new_hr_rb); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_hr_rb); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, - new_hw, new_hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - if (!new_hr_tex || !new_scratch_tex || !new_hr_rb || - !make_fbo(&new_hr_fbo, new_hr_tex, new_hr_rb) || - !make_fbo(&new_scratch_fbo, new_scratch_tex, 0)) { - goto fail; - } - - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - if (!s_wide_fbo[i]) continue; - if (g_wide_cur == s_wide_fbo[i]) current_wide_index = i; - const int wide_w = g_wide_w * scale; - new_wide_tex[i] = make_tex(GL_RGBA8, wide_w, new_hh, - GL_RGBA, GL_UNSIGNED_BYTE); - p_glGenRenderbuffers(1, &new_wide_rb[i]); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, new_wide_rb[i]); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, - PSXGL_DEPTH24_STENCIL8, wide_w, new_hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - if (!new_wide_tex[i] || !new_wide_rb[i] || - !make_fbo(&new_wide_fbo[i], new_wide_tex[i], - new_wide_rb[i])) { - goto fail; - } - } - - glDisable(GL_SCISSOR_TEST); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_hr_fbo); - p_glBlitFramebuffer(0, 0, VRAM_W * old_scale, VRAM_H * old_scale, - 0, 0, new_hw, new_hh, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - if (!new_wide_fbo[i]) continue; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_wide_fbo[i]); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, new_wide_fbo[i]); - p_glBlitFramebuffer(0, 0, g_wide_w * old_scale, - VRAM_H * old_scale, - 0, 0, g_wide_w * scale, new_hh, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - } - - { - GLuint old_hr_tex = s_hr_tex, old_hr_fbo = s_hr_fbo, - old_hr_rb = s_hr_rb; - GLuint old_scratch_tex = s_scratch_tex, - old_scratch_fbo = s_scratch_fbo; - GLuint old_wide_tex[WIDE_MAX_SURF]; - GLuint old_wide_fbo[WIDE_MAX_SURF]; - GLuint old_wide_rb[WIDE_MAX_SURF]; - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - old_wide_tex[i] = s_wide_tex[i]; - old_wide_fbo[i] = s_wide_fbo[i]; - old_wide_rb[i] = s_wide_rb[i]; - s_wide_tex[i] = new_wide_tex[i]; new_wide_tex[i] = 0; - s_wide_fbo[i] = new_wide_fbo[i]; new_wide_fbo[i] = 0; - s_wide_rb[i] = new_wide_rb[i]; new_wide_rb[i] = 0; - } - s_hr_tex = new_hr_tex; new_hr_tex = 0; - s_hr_fbo = new_hr_fbo; new_hr_fbo = 0; - s_hr_rb = new_hr_rb; new_hr_rb = 0; - s_scratch_tex = new_scratch_tex; new_scratch_tex = 0; - s_scratch_fbo = new_scratch_fbo; new_scratch_fbo = 0; - s_scale = scale; - s_req_scale = scale; - g_wide_cur = current_wide_index >= 0 - ? s_wide_fbo[current_wide_index] : 0; - - const float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; - p_glUseProgram(s_geo_prog); - p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); - p_glUseProgram(s_tex_prog); - p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); - p_glUseProgram(s_blit_prog); - p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), shift); - p_glUseProgram(0); - - rebuild_target_stencil(s_hr_fbo, new_hw, new_hh); - for (int i = 0; i < WIDE_MAX_SURF; ++i) { - if (s_wide_fbo[i]) - rebuild_target_stencil(s_wide_fbo[i], - g_wide_w * s_scale, new_hh); - } - s_stencil_valid = 1; - - delete_scale_target(&old_hr_fbo, &old_hr_tex, &old_hr_rb); - delete_scale_target(&old_scratch_fbo, &old_scratch_tex, NULL); - for (int i = 0; i < WIDE_MAX_SURF; ++i) - delete_scale_target(&old_wide_fbo[i], &old_wide_tex[i], - &old_wide_rb[i]); - } - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - gl_renderer_invalidate_present(); - fprintf(stdout, "psxrecomp: live internal scale = %dx\n", s_scale); - return 1; - -fail: - delete_scale_target(&new_hr_fbo, &new_hr_tex, &new_hr_rb); - delete_scale_target(&new_scratch_fbo, &new_scratch_tex, NULL); - for (int i = 0; i < WIDE_MAX_SURF; ++i) - delete_scale_target(&new_wide_fbo[i], &new_wide_tex[i], - &new_wide_rb[i]); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - fprintf(stderr, - "psxrecomp: live internal-scale allocation failed; keeping %dx\n", - s_scale); - return 0; -} - -static int init_gpu_raster(void) { - s_scale = s_req_scale; - { - const char *full_yaw = getenv("PSX_GEOMETRY_FULL_YAW"); - const char *coverage_tint = getenv("PSX_GEOMETRY_COVERAGE_TINT"); - s_geometry_full_yaw = full_yaw && full_yaw[0] && full_yaw[0] != '0'; - s_geometry_coverage_tint = coverage_tint && coverage_tint[0] && - coverage_tint[0] != '0'; - } - - { - char *geo_vs = depth_writing_shader(GEO_VS, 4), *tex_vs = depth_writing_shader(TEX_VS, 12); - char *tex_fs = tex_vs ? sprite_marking_shader(TEX_FS) : NULL; - s_geo_prog = build_program(geo_vs ? geo_vs : GEO_VS, GEO_FS); - s_tex_prog = build_program_ex(tex_fs ? tex_vs : TEX_VS, tex_fs ? tex_fs : TEX_FS, 1); - free(geo_vs); - free(tex_vs); - free(tex_fs); - } - s_blit_prog = build_program(BLIT_VS, BLIT_FS); - s_pack_prog = build_program(PACK_VS, PACK_FS); - s_stencil_prog = build_program(PACK_VS, STENCIL_FS); - if (!s_geo_prog || !s_tex_prog || !s_blit_prog || !s_pack_prog || !s_stencil_prog) return 0; - - s_conv = (uint32_t *)malloc((size_t)VRAM_W * VRAM_H * sizeof(uint32_t)); - if (!s_conv) return 0; - - int hw = VRAM_W * s_scale, hh = VRAM_H * s_scale; - s_hr_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); - s_scratch_tex = make_tex(GL_RGBA8, hw, hh, GL_RGBA, GL_UNSIGNED_BYTE); - s_up_tex = make_tex(GL_RGBA8, VRAM_W, VRAM_H, GL_RGBA, GL_UNSIGNED_BYTE); - s_raw_tex = make_tex(PSXGL_R16UI, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT); - /* Force the driver's first texture-upload allocation while the renderer is - * initializing. NVIDIA otherwise defers it until the first MDEC frame, - * producing a measured ~33 ms glTexSubImage hitch and an audible underrun. */ - { - const uint32_t zero_rgba = 0; - const uint16_t zero_raw = 0; - glBindTexture(GL_TEXTURE_2D, s_up_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, - GL_RGBA, GL_UNSIGNED_BYTE, &zero_rgba); - glBindTexture(GL_TEXTURE_2D, s_raw_tex); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, - PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, &zero_raw); - glFinish(); - } - - p_glGenRenderbuffers(1, &s_hr_rb); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_hr_rb); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, hw, hh); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); - - if (!make_fbo(&s_hr_fbo, s_hr_tex, s_hr_rb)) return 0; - if (!make_fbo(&s_raw_fbo, s_raw_tex, 0)) return 0; - if (!make_fbo(&s_scratch_fbo, s_scratch_tex, 0)) return 0; - - s_uVram = p_glGetUniformLocation(s_tex_prog, "u_vram"); - s_uTpage = p_glGetUniformLocation(s_tex_prog, "u_tpage"); - s_uClut = p_glGetUniformLocation(s_tex_prog, "u_clut"); - s_uDepth = p_glGetUniformLocation(s_tex_prog, "u_depth"); - s_uRaw = p_glGetUniformLocation(s_tex_prog, "u_raw"); - s_uSemipass = p_glGetUniformLocation(s_tex_prog, "u_semipass"); - s_uSemimode = p_glGetUniformLocation(s_tex_prog, "u_semimode"); - s_uTwin = p_glGetUniformLocation(s_tex_prog, "u_twin"); - s_uMaskset = p_glGetUniformLocation(s_tex_prog, "u_maskset"); - s_uFilter = p_glGetUniformLocation(s_tex_prog, "u_filter"); - s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); - s_uBlitSrc = p_glGetUniformLocation(s_blit_prog, "u_src"); - s_uBlitPass = p_glGetUniformLocation(s_blit_prog, "u_stp_pass"); - s_uBlitMaskset = p_glGetUniformLocation(s_blit_prog, "u_maskset"); - s_uBlitSrcDiv = p_glGetUniformLocation(s_blit_prog, "u_src_div"); - s_uBlitSrcOff = p_glGetUniformLocation(s_blit_prog, "u_src_off"); - s_uPackHr = p_glGetUniformLocation(s_pack_prog, "u_hr"); - s_uPackScale = p_glGetUniformLocation(s_pack_prog, "u_scale"); - s_uStencilSrc = p_glGetUniformLocation(s_stencil_prog, "u_src"); - s_geo_uXoff = p_glGetUniformLocation(s_geo_prog, "u_xoff"); - s_geo_uXhalf = p_glGetUniformLocation(s_geo_prog, "u_xhalf"); - s_tex_uXoff = p_glGetUniformLocation(s_tex_prog, "u_xoff"); - s_tex_uXhalf = p_glGetUniformLocation(s_tex_prog, "u_xhalf"); - s_geo_uXscale = p_glGetUniformLocation(s_geo_prog, "u_xscale"); - s_geo_uXcenter = p_glGetUniformLocation(s_geo_prog, "u_xcenter"); - s_tex_uXscale = p_glGetUniformLocation(s_tex_prog, "u_xscale"); - s_tex_uXcenter = p_glGetUniformLocation(s_tex_prog, "u_xcenter"); - s_geo_uVisual = p_glGetUniformLocation(s_geo_prog, "u_visual"); - s_geo_uCamera = p_glGetUniformLocation(s_geo_prog, "u_camera"); - s_geo_uVisualOptions = p_glGetUniformLocation(s_geo_prog, "u_visual_options"); - s_tex_uVisual = p_glGetUniformLocation(s_tex_prog, "u_visual"); - s_tex_uCamera = p_glGetUniformLocation(s_tex_prog, "u_camera"); - s_tex_uVisualOptions = p_glGetUniformLocation(s_tex_prog, "u_visual_options"); - s_tex_uPull = p_glGetUniformLocation(s_tex_prog, "u_pull"); - /* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL would - * otherwise zero them, collapsing all x to 0. */ - p_glUseProgram(s_geo_prog); - p_glUniform1f(s_geo_uXscale, 1.0f); p_glUniform1f(s_geo_uXcenter, 0.0f); - geometry_visual_uniforms(s_geo_uVisual, s_geo_uCamera, s_geo_uVisualOptions, 0); - p_glUseProgram(s_tex_prog); - p_glUniform1f(s_tex_uXscale, 1.0f); p_glUniform1f(s_tex_uXcenter, 0.0f); - geometry_visual_uniforms(s_tex_uVisual, s_tex_uCamera, s_tex_uVisualOptions, 0); - - /* Sample-grid alignment shift: half an HR pixel, set once (S is fixed - * for the lifetime of the pipeline). Backed off by 1/64 native px so - * primitive edges never land EXACTLY on sample centers — that float tie - * dropped 1px columns at quad seams (e.g. the 256px texture-page seam in - * Tomba's title background). The 1/64 bias keeps floor(uv) on the exact - * PS1 texel for |uv slope| < 64; mirrored (negative-slope) mappings can - * be off by one texel at exact-integer uv — accepted. */ - { - float shift = 0.5f / (float)s_scale - 1.0f / 64.0f; - p_glUseProgram(s_geo_prog); - p_glUniform1f(p_glGetUniformLocation(s_geo_prog, "u_shift"), shift); - /* Native-wide projection defaults: x translation 0, clip half-extent - * 512 — so the GEO_VS x term reduces to (x+u_shift)/512-1, bit-identical - * to the pre-native-wide projection. The wide passes set these, then - * restore these defaults. */ - p_glUniform1f(s_geo_uXoff, 0.0f); - p_glUniform1f(s_geo_uXhalf, 512.0f); - p_glUseProgram(s_tex_prog); - p_glUniform1f(p_glGetUniformLocation(s_tex_prog, "u_shift"), shift); - p_glUniform1f(s_tex_uXoff, 0.0f); - p_glUniform1f(s_tex_uXhalf, 512.0f); - p_glUseProgram(s_blit_prog); - p_glUniform1f(p_glGetUniformLocation(s_blit_prog, "u_shift"), shift); - p_glUseProgram(0); - } - - p_glGenVertexArrays(1, &s_geo_vao); - p_glBindVertexArray(s_geo_vao); - p_glGenBuffers(1, &s_geo_vbo); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_geo_vbo); - p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)0); - p_glEnableVertexAttribArray(0); - p_glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(2 * sizeof(float))); - p_glEnableVertexAttribArray(1); - p_glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(6 * sizeof(float))); - p_glEnableVertexAttribArray(2); - p_glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(7 * sizeof(float))); - p_glEnableVertexAttribArray(3); - p_glVertexAttribPointer(4, 1, GL_FLOAT, GL_FALSE, GEOV * sizeof(float), (void *)(9 * sizeof(float))); - p_glEnableVertexAttribArray(4); - - p_glGenVertexArrays(1, &s_tex_vao); - p_glBindVertexArray(s_tex_vao); - p_glGenBuffers(1, &s_tex_vbo); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_tex_vbo); - { - GLsizei st = TEXV * sizeof(float); - p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, st, (void*)0); p_glEnableVertexAttribArray(0); /* pos */ - p_glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, st, (void*)(2*sizeof(float))); p_glEnableVertexAttribArray(1); /* uv */ - p_glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, st, (void*)(4*sizeof(float))); p_glEnableVertexAttribArray(2); /* col */ - p_glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, st, (void*)(8*sizeof(float))); p_glEnableVertexAttribArray(3); /* tpage */ - p_glVertexAttribPointer(4, 2, GL_FLOAT, GL_FALSE, st, (void*)(10*sizeof(float))); p_glEnableVertexAttribArray(4); /* clut */ - p_glVertexAttribPointer(5, 1, GL_FLOAT, GL_FALSE, st, (void*)(12*sizeof(float))); p_glEnableVertexAttribArray(5); /* depth */ - p_glVertexAttribPointer(6, 1, GL_FLOAT, GL_FALSE, st, (void*)(13*sizeof(float))); p_glEnableVertexAttribArray(6); /* raw */ - p_glVertexAttribPointer(7, 4, GL_FLOAT, GL_FALSE, st, (void*)(14*sizeof(float))); p_glEnableVertexAttribArray(7); /* limits */ - p_glVertexAttribPointer(8, 1, GL_FLOAT, GL_FALSE, st, (void*)(18*sizeof(float))); p_glEnableVertexAttribArray(8); /* semi */ - p_glVertexAttribPointer(9, 1, GL_FLOAT, GL_FALSE, st, (void*)(19*sizeof(float))); p_glEnableVertexAttribArray(9); /* visual flags */ - p_glVertexAttribPointer(10, 2, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* visual pos */ - p_glVertexAttribPointer(11, 1, GL_FLOAT, GL_FALSE, st, (void*)(22*sizeof(float))); p_glEnableVertexAttribArray(11); /* reciprocal depth */ - p_glVertexAttribPointer(12, 1, GL_FLOAT, GL_FALSE, st, (void*)(23*sizeof(float))); p_glEnableVertexAttribArray(12); /* camera depth */ - } - - p_glGenVertexArrays(1, &s_blit_vao); - p_glBindVertexArray(s_blit_vao); - p_glGenBuffers(1, &s_blit_vbo); - p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_blit_vbo); - p_glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), (void*)0); - p_glEnableVertexAttribArray(0); - - p_glGenVertexArrays(1, &s_empty_vao); - p_glBindVertexArray(0); - - /* Clear the authoritative surface (color + stencil) and queue a full - * upload of whatever the CPU VRAM already holds (pre-context software - * draws, BIOS logo state, ...). */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_hr_fbo); - glDisable(GL_SCISSOR_TEST); - glClearColor(0, 0, 0, 0); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - rect_clear(&s_pack_dirty); - s_up_nrects = 0; - up_add(0, 0, VRAM_W - 1, VRAM_H - 1); - s_gpu_dirty = 0; - s_stencil_valid = 1; - for (int i = 0; i < PRES_ROWS; i++) s_present_dirty[i] = ~0ull; - s_last_present_path = -1; - - /* Native-wide compositor surfaces start unallocated (lazily created when a - * widescreen game calls wide_configure + wide_set_target). */ - for (int i = 0; i < WIDE_MAX_SURF; i++) { - s_wide_tex[i] = 0; s_wide_fbo[i] = 0; s_wide_base[i] = -1; - } - g_wide_w = 0; g_wide_off = 0; g_wide_cur = 0; g_wide_cur_base = 0; - - s_raster_ok = 1; - gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ - fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx, " - "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale); - if (s_geometry_full_yaw || s_geometry_coverage_tint) { - fprintf(stdout, "psxrecomp: geometry diagnostics: full X/Y yaw %s, " - "exact-coverage tint %s (presentation mirror only)\n", - s_geometry_full_yaw ? "enabled" : "disabled", - s_geometry_coverage_tint ? "enabled" : "disabled"); - } - return 1; -} - -int gl_renderer_init_context(SDL_Window *win) { - s_win = win; - s_present_w = 0; - s_present_h = 0; - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); - s_ctx = SDL_GL_CreateContext(win); - if (!s_ctx) { fprintf(stdout, "psxrecomp: GL context creation failed (%s)\n", SDL_GetError()); return 0; } - if (SDL_GL_MakeCurrent(win, s_ctx) != 0) { fprintf(stdout, "psxrecomp: MakeCurrent failed (%s)\n", SDL_GetError()); SDL_GL_DeleteContext(s_ctx); s_ctx=NULL; return 0; } - /* Swap interval: 1=vsync (tear-free, default), 0=immediate (lowest display - * latency, may tear; our wall-clock pacer still holds 59.94Hz), -1=adaptive. - * Adaptive falls back to vsync if the driver rejects it. */ - if (SDL_GL_SetSwapInterval(s_swap_interval) != 0 && s_swap_interval < 0) { - SDL_GL_SetSwapInterval(1); - s_swap_interval = 1; - } - glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); - const char *ver = (const char *)glGetString(GL_VERSION); - fprintf(stdout, "psxrecomp: OpenGL context created (%s)\n", ver ? ver : "?"); - - /* All-or-nothing: any missing entry point / failed shader / bad FBO means - * the whole GL renderer is unavailable and the runtime stays on the pure - * software path — no half-GL hybrid (that mixed mode is what produced - * the alternating-present menu jitter). */ - int ok = load_modern_gl(); - if (ok) { - glGenTextures(1, &s_present_tex); - glBindTexture(GL_TEXTURE_2D, s_present_tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - s_present_prog = build_program(PRESENT_VS, PRESENT_FS); - s_ssao_prog = build_program(PRESENT_VS, SSAO_FS); - s_ssao_mix_prog = build_program(PRESENT_VS, SSAO_MIX_FS); - { - const char *fill = getenv("PSX_GL_CRACK_FILL"), *under = getenv("PSX_GL_GROWN_UNDER"); - GLfloat widths[2] = {1.0f, 1.0f}; - glGetFloatv(0x846E /* GL_ALIASED_LINE_WIDTH_RANGE */, widths); - while (glGetError() != GL_NO_ERROR) {} - glLineWidth(2.0f); /* a forward compatible context refuses any width but 1 */ - s_line_width_max = glGetError() == GL_NO_ERROR && widths[1] > 1.0f ? widths[1] : 1.0f; - glLineWidth(1.0f); - if (fill) s_crack_fill = s_crack_width = (float)atof(fill); - if (under) s_grown_under = atoi(under) != 0; - if (getenv("PSX_GL_WORLD_DEPTH")) s_world_depth = atoi(getenv("PSX_GL_WORLD_DEPTH")) != 0; - if (getenv("PSX_GL_SHOWN_DUMP")) - gl_renderer_record_shown(getenv("PSX_GL_SHOWN_DUMP"), - getenv("PSX_GL_SHOWN_DUMP_SKIP") ? atoi(getenv("PSX_GL_SHOWN_DUMP_SKIP")) : 0, - getenv("PSX_GL_SHOWN_DUMP_COUNT") ? atoi(getenv("PSX_GL_SHOWN_DUMP_COUNT")) : 240, - getenv("PSX_GL_SHOWN_DUMP_WIDTH") ? atoi(getenv("PSX_GL_SHOWN_DUMP_WIDTH")) : 640); - if (getenv("PSX_GL_WORLD_DEPTH_MARGIN") && atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")) > 0.0) - s_world_margin = (float)atof(getenv("PSX_GL_WORLD_DEPTH_MARGIN")); - } - if (s_ssao_prog && s_ssao_mix_prog) { - const char *on = getenv("PSX_GL_SSAO"), *radius = getenv("PSX_GL_SSAO_RADIUS"), *strength = getenv("PSX_GL_SSAO_STRENGTH"); - s_ssao_mix_uTex = p_glGetUniformLocation(s_ssao_mix_prog, "u_tex"); - s_ssao_mix_uShade = p_glGetUniformLocation(s_ssao_mix_prog, "u_shade"); - s_ssao_mix_uUvRect = p_glGetUniformLocation(s_ssao_mix_prog, "u_uv_rect"); - s_ssao_mix_uSpan = p_glGetUniformLocation(s_ssao_mix_prog, "u_span"); - s_ssao_uDepth = p_glGetUniformLocation(s_ssao_prog, "u_depth"); - s_ssao_uTex = p_glGetUniformLocation(s_ssao_prog, "u_tex"); - s_ssao_uFade = p_glGetUniformLocation(s_ssao_prog, "u_fade"); - s_ssao_uUvRect = p_glGetUniformLocation(s_ssao_prog, "u_uv_rect"); - s_ssao_uTexel = p_glGetUniformLocation(s_ssao_prog, "u_span"); - s_ssao_uProj = p_glGetUniformLocation(s_ssao_prog, "u_proj"); - s_ssao_uAo = p_glGetUniformLocation(s_ssao_prog, "u_ao"); - if (on) { - s_ssao = atoi(on); - s_ssao_on = s_ssao == 1; - s_ssao_view = s_ssao > 1 && s_ssao <= 5 ? s_ssao - 1 : 0; - } - if (radius && atof(radius) > 0.0) s_ssao_radius = (float)atof(radius); - if (strength && atof(strength) > 0.0) s_ssao_strength = (float)atof(strength); - if (getenv("PSX_GL_SSAO_TILT") && atof(getenv("PSX_GL_SSAO_TILT")) >= 0.0 && atof(getenv("PSX_GL_SSAO_TILT")) < 0.95) - s_ssao_tilt = (float)atof(getenv("PSX_GL_SSAO_TILT")); - } -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_interp_prog = build_program(PRESENT_VS, INTERP_FS); - if (s_present_prog && s_interp_prog) { -#else - if (s_present_prog) { -#endif - p_glGenVertexArrays(1, &s_present_vao); - s_present_uTex = p_glGetUniformLocation(s_present_prog, "u_tex"); - s_present_uUvRect = p_glGetUniformLocation(s_present_prog, "u_uv_rect"); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); - s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); - s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); - s_interp_uUvRect = p_glGetUniformLocation(s_interp_prog, "u_uv_rect"); - s_interp_uBlendMode = - p_glGetUniformLocation(s_interp_prog, "u_blend_mode"); - glGenTextures(3, s_interp_tex); - for (int i = 0; i < 3; i++) { - glBindTexture(GL_TEXTURE_2D, s_interp_tex[i]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - } -#endif - } else ok = 0; - } - if (ok) ok = init_gpu_raster(); - if (!ok) { - fprintf(stdout, "psxrecomp: GL pipeline init failed — falling back to software renderer\n"); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - s_raster_ok = 0; - return 0; - } - return 1; -} - -static void apply_swap_interval(void) { - if (!s_ctx) return; - /* The interpolation thread owns host-refresh presentation and requires - * immediate swaps. Preserve the user's requested interval separately so - * disabling interpolation can restore it without a renderer restart. */ - int effective = s_interp_enabled ? 0 : s_swap_interval; - if (SDL_GL_SetSwapInterval(effective) != 0 && effective < 0) { - SDL_GL_SetSwapInterval(1); - s_swap_interval = 1; - } -} - -/* Set the requested GL swap interval (1=vsync, 0=immediate, -1=adaptive). - * Safe before or after context creation. While interpolation is active the - * request is remembered and physical swapping stays immediate. */ -void gl_renderer_set_swap_interval(int interval) { - s_swap_interval = interval; - apply_swap_interval(); -} - -void gl_renderer_shutdown(void) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (s_interp_thread) { - SDL_AtomicSet(&s_interp_thread_run, 0); - SDL_WaitThread(s_interp_thread, NULL); - s_interp_thread = NULL; - } - if (s_interp_ctx) { - SDL_GL_DeleteContext(s_interp_ctx); - s_interp_ctx = NULL; - } - if (s_ctx) { - SDL_GL_MakeCurrent(s_win, s_ctx); - if (s_interp_draw_fence) { - p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = NULL; - } - for (int i = 0; i < 3; i++) { - if (s_interp_fence[i]) { - p_glDeleteSync(s_interp_fence[i]); - s_interp_fence[i] = NULL; - } - } - ensure_cpu(); - temporal_release(); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - } - if (s_interp_mutex) { - SDL_DestroyMutex(s_interp_mutex); - s_interp_mutex = NULL; - } - s_interp_enabled = 0; - s_interp_suspended = 0; - s_interp_content_suspended = 0; - s_interp_host_ui_suspended = 0; - interp_reset_history_unlocked(); -#else - if (s_ctx) { - SDL_GL_MakeCurrent(s_win, s_ctx); - ensure_cpu(); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - } -#endif - free(s_conv); s_conv = NULL; - s_raster_ok = 0; - /* New context regenerates s_present_tex empty; a stale size makes - * upload_present_tex take glTexSubImage2D into an unallocated texture - * (rematch 24-bit FMV → black picture, audio still runs). */ - s_present_w = 0; - s_present_h = 0; - s_depth24_skip_up = 0; - rect_clear(&s_d24_skip_fb); -} - -/* Host UI callbacks are allowed to change live renderer settings. Those - * setters can flush queued geometry and consequently bind an internal FBO and - * its VRAM-sized viewport while ImGui is already building the current frame. - * Reassert the window target after the callback so the state cannot leak into - * this swap (or into ImGui's next-frame state backup). */ -static void render_host_ui_for_swap(int drawable_w, int drawable_h) { - if (!psx_host_ui_gl_visible()) return; - psx_host_ui_render_gl(drawable_w, drawable_h); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(0, 0, drawable_w, drawable_h); -} - -/* CPU-readout present (24-bit FMV frames and the PSX_GL_FORCE_CPU_PRESENT - * diagnostic): full-window clear, then a quad into the letterbox rect. - * force_4_3 pins the rect to native 4:3 regardless of the display aspect — - * FMVs are authored 4:3 and have no GTE squash to compensate a stretch, so - * widescreen presents them pillarboxed instead of distorted. */ -/* Rows of the window above the screen (host_borderless.h): the picture goes in the rest, which starts at the bottom row as OpenGL counts. */ -static int s_hidden_rows; -void gl_renderer_set_hidden_rows(int rows) { s_hidden_rows = rows > 0 ? rows : 0; } -static void gl_visible_size(int *w, int *h) { - SDL_GL_GetDrawableSize(s_win, w, h); - if (*h > s_hidden_rows) *h -= s_hidden_rows; -} - -void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linear, - int force_4_3, int content_w) { - if (!s_ctx) return; - interp_reset_history(); - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); - int lx, ly, lw, lh; - if (force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - /* Short GP1(07h) bands (MotK FMV is 128 lines) only fill a fraction of - * NTSC active height on hardware. Stretching them to the full letterbox - * doubles vertical scale vs horizontal and makes the frame look too wide - * with the right edge clipped. Letterbox within the present rect instead. - * Apply whenever the source is short — not only when force_4_3 — so a - * misclassified FMV frame still keeps correct pixel aspect. */ - /* Genuinely windowed video bands only (<80% of the 240-line field, e.g. - * MotK's 128-line FMV). A game's native short display mode (216/224) - * fills the rect as on hardware. */ - if (src_h > 0 && src_h < 192) { - int content_h = (lh * src_h) / 240; - if (content_h < 1) content_h = 1; - ly += (lh - content_h) / 2; - lh = content_h; - } - /* Optional trailing-column crop (depth24 margin): shrink the draw width - * left-aligned so cleared black remains on the right — never stretch. */ - float uv_x1 = 1.f; - int crop = (content_w > 0 && content_w < src_w && src_w > 0); - if (crop) { - uv_x1 = (float)content_w / (float)src_w; - lw = (lw * content_w) / src_w; - if (lw < 1) lw = 1; - } - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0); - upload_present_tex(pixels, src_w, src_h, linear); - p_glUseProgram(s_present_prog); p_glUniform1i(s_present_uTex, 0); - if (crop) { - p_glUniform4f(s_present_uUvRect, 0.f, 0.f, uv_x1, 1.f); - } else if (!linear && src_w > 0 && src_h > 0) { - /* Nearest: half-texel UV inset so UV=1.0 never grazes past the last - * column into undefined border samples on some drivers. */ - float u0 = 0.5f / (float)src_w, v0 = 0.5f / (float)src_h; - p_glUniform4f(s_present_uUvRect, u0, v0, 1.f - u0, 1.f - v0); - } else { - p_glUniform4f(s_present_uUvRect, 0.f, 0.f, 1.f, 1.f); - } - p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); p_glUseProgram(0); - pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - present_force_consumed(); - s_last_present_path = GL_PRES_CPU; -} - -void gl_renderer_present_blank(void) { - if (!s_ctx) return; - interp_reset_history(); - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); - pres_record(GL_PRES_BLANK, 0, 0, 0, 0, 0, 0, ww, wh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - present_force_consumed(); - s_last_present_path = GL_PRES_BLANK; -} - -/* Sync the authoritative FBO down into CPU VRAM (no-op when current). - * Screenshots / debug server. Not for 24-bit FMV scanout (see flush). */ -void gl_renderer_sync_cpu(void) { - ensure_cpu(); -} - -void gl_renderer_invalidate_present(void) { - for (int i = 0; i < PRES_ROWS; i++) s_present_dirty[i] = ~0ull; - s_last_present_path = -1; - s_force_present_remaining = 8; - interp_reset_history(); -} - -void gl_renderer_flush_cpu_uploads(void) { - if (!s_raster_ok) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); -} - -/* Diagnostic (debug server "gl_fbo_peek"): read a rect of the GPU-side - * authoritative VRAM (via the pack pass + raw mirror) WITHOUT writing CPU - * VRAM — lets a probe diff FBO truth against CPU truth. Returns 0 when the - * GL pipeline is inactive (software backend). */ -int gl_renderer_fbo_peek(int x, int y, int w, int h, uint16_t *out) { - if (!s_raster_ok || !s_ctx) return 0; - if (x < 0 || y < 0 || w < 1 || h < 1 || - x + w > VRAM_W || y + h > VRAM_H) return 0; - flush_cpu_upload(); - rect_add(&s_pack_dirty, x, y, x + w - 1, y + h - 1); - pack_flush(); - coh_record(GL_COH_PEEK, x, y, x + w - 1, y + h - 1); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 2); - glReadPixels(x, y, w, h, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, out); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - return 1; -} - -/* Diagnostic (debug server "gl_vram_diff"): full-VRAM comparison of the - * GPU-side truth (FBO via pack) against the CPU array, WITHOUT writing - * either. Reports mismatch count + bounding box + a few sample coords. - * Divergence is expected where the GPU is legitimately ahead (gpu_dirty); - * at upload-only scenes the two must match exactly. */ -int gl_renderer_vram_diff(uint32_t *count, int bbox[4], - int samples[8][2], uint16_t samples_px[8][2]) { - if (!s_raster_ok || !s_ctx) return 0; - uint16_t *tmp = (uint16_t *)malloc((size_t)VRAM_W * VRAM_H * 2); - if (!tmp) return 0; - flush_cpu_upload(); - /* Force a full pack: the diff must read FBO truth even where the - * raw-mirror invariant (raw == FBO outside s_pack_dirty) is broken — - * a broken invariant is exactly what this tool hunts. */ - rect_add(&s_pack_dirty, 0, 0, VRAM_W - 1, VRAM_H - 1); - pack_flush(); - coh_record(GL_COH_DIFF, 0, 0, VRAM_W - 1, VRAM_H - 1); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo); - glReadPixels(0, 0, VRAM_W, VRAM_H, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT, tmp); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - uint32_t n = 0; - int x0 = VRAM_W, y0 = VRAM_H, x1 = -1, y1 = -1, ns = 0; - for (int y = 0; y < VRAM_H; y++) { - for (int x = 0; x < VRAM_W; x++) { - uint16_t f = tmp[y * VRAM_W + x], c = s_vram[y * VRAM_W + x]; - if (f == c) continue; - n++; - if (x < x0) x0 = x; if (x > x1) x1 = x; - if (y < y0) y0 = y; if (y > y1) y1 = y; - if (ns < 8 && (n % 977) == 1) { /* spread samples */ - samples[ns][0] = x; samples[ns][1] = y; - samples_px[ns][0] = f; samples_px[ns][1] = c; - ns++; - } - } - } - free(tmp); - *count = n; - bbox[0] = x0; bbox[1] = y0; bbox[2] = x1; bbox[3] = y1; - return 1 + ns; /* >=1 means valid; ns = samples filled */ -} - -/* Diagnostic state for the debug server: coherency flags + dirty rects. */ -void gl_renderer_diag(int *gpu_dirty, int pending[5], int pack[5]) { - if (gpu_dirty) *gpu_dirty = s_gpu_dirty; - if (pending) { - /* [0] = pending rect count; [1..4] = union bbox (diagnostic only — - * the flush itself paints the exact rects, never this union). */ - pending[0] = s_up_nrects; - pending[1] = pending[2] = pending[3] = pending[4] = 0; - for (int i = 0; i < s_up_nrects; i++) { - if (i == 0) { - pending[1] = s_up_rects[i].x0; pending[2] = s_up_rects[i].y0; - pending[3] = s_up_rects[i].x1; pending[4] = s_up_rects[i].y1; - } else { - if (s_up_rects[i].x0 < pending[1]) pending[1] = s_up_rects[i].x0; - if (s_up_rects[i].y0 < pending[2]) pending[2] = s_up_rects[i].y0; - if (s_up_rects[i].x1 > pending[3]) pending[3] = s_up_rects[i].x1; - if (s_up_rects[i].y1 > pending[4]) pending[4] = s_up_rects[i].y1; - } - } - } - if (pack) { - pack[0] = s_pack_dirty.set; - pack[1] = s_pack_dirty.x0; pack[2] = s_pack_dirty.y0; - pack[3] = s_pack_dirty.x1; pack[4] = s_pack_dirty.y1; - } -} - -/* ------------------------------------------------------------------------- * - * Native-wide compositor (GL). Mirrors gpu_sw_renderer.c's wide functions: - * canonical VRAM (the hr FBO) is untouched; framebuffer draws are also mirrored - * into per-base_x wide FBOs (see the draw funcs' wide passes). Present reads the - * displayed buffer's wide FBO via glReadPixels into the CPU present buffer, then - * the existing CPU present path uploads/letterboxes it (Option B: reuse the CPU - * present path). Self-gates on the GL pipeline being live; never runs for - * 4:3 / non-opted games (gpu.c never calls wide_configure for those). - * ------------------------------------------------------------------------- */ - -static void wide_free_all(void) { - interp_reset_history(); - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } - if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } - if (s_wide_rb[i]) { p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; } - s_wide_base[i] = -1; - } - g_wide_cur = 0; -} - -/* Lazily allocate (or find) the wide FBO+tex for base_x. Returns the FBO id, or - * 0 on failure / more distinct buffers than WIDE_MAX_SURF. */ -static GLuint wide_fbo_for(int base_x) { - if (g_wide_w <= 0) return 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == base_x) return s_wide_fbo[i]; - for (int i = 0; i < WIDE_MAX_SURF; i++) { - if (!s_wide_fbo[i]) { - int w = g_wide_w * s_scale, h = VRAM_H * s_scale; - s_cw_fbo_creates++; - s_wide_tex[i] = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); - /* Depth-stencil RB, same as the hr FBO: the stencil carries the - * PSX mask-bit mirror for the wide surface, and (the hard lesson) - * a stencil-less FBO turns every stencil-enabled mirror draw into - * ~0.6ms of driver-side work — the 16:9 GL perf collapse. */ - p_glGenRenderbuffers(1, &s_wide_rb[i]); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_wide_rb[i]); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h); - if (!make_fbo(&s_wide_fbo[i], s_wide_tex[i], s_wide_rb[i])) { - glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; - p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; - return 0; - } - /* Clear to black so unwritten margins are clean (not stale). */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_wide_fbo[i]); - glDisable(GL_SCISSOR_TEST); - glClearColor(0, 0, 0, 0); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - s_wide_base[i] = base_x; - return s_wide_fbo[i]; - } - } - return 0; /* more distinct buffers than WIDE_MAX_SURF — shouldn't happen */ -} - -/* Enable native-wide with a wide width + centering offset (native px), or - * disable (wide_w <= 0). Re-allocates if the width changed. Mirrors - * sw_wide_configure. */ -static void glb_wide_configure(int wide_w, int offset) { - if (!s_raster_ok) return; - double t0 = cw_ms(); s_cw_wide_cfgs++; - flush_flat_batch(); - flush_tex_batch(); /* a queued batch's wide mirror targets the CURRENT surfaces */ - if (wide_w <= 0) { wide_free_all(); g_wide_w = 0; g_wide_off = 0; s_cw_wide_ms += cw_ms() - t0; return; } - if (wide_w != g_wide_w) wide_free_all(); - if (offset != g_wide_off) interp_reset_history(); - g_wide_w = wide_w; - g_wide_off = offset; - s_cw_wide_ms += cw_ms() - t0; -} - -/* Select the wide surface to mirror into for the back buffer at base_x. */ -static void glb_wide_set_target(int base_x) { - if (!s_raster_ok) { g_wide_cur = 0; return; } - double t0 = cw_ms(); s_cw_wide_sets++; - flush_flat_batch(); - flush_tex_batch(); /* drain into the OLD target before switching */ - g_wide_cur = wide_fbo_for(base_x); - g_wide_cur_base = base_x; - s_cw_wide_ms += cw_ms() - t0; -} - -/* Stop mirroring (offscreen draws that don't target a framebuffer). */ -static void glb_wide_disable_target(void) { flush_flat_batch(); flush_tex_batch(); g_wide_cur = 0; } - -/* Mirror a framebuffer clear: fill the full wide width over [y, y+h) of the - * surface for base_x, so the revealed margins are clean. Mirrors sw_wide_clear: - * a scissored glClear with the 1555 color converted to RGBA8 (alpha = bit15). */ -static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { - if (!s_raster_ok || s_ws_ablate == 1) return; - double t0 = cw_ms(); s_cw_wide_clears++; - flush_flat_batch(); - flush_tex_batch(); - GLuint fbo = wide_fbo_for(base_x); - if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } - temporal_begin(fbo, y, h, color); - gl_perf_mirror_begin(); - int H = VRAM_H * s_scale; - int y0 = y * s_scale, y1 = (y + h) * s_scale; - if (y0 < 0) y0 = 0; - if (y1 > H) y1 = H; - if (y1 <= y0) return; - float r = (color & 0x1F) / 31.0f; - float g = ((color >> 5) & 0x1F) / 31.0f; - float b = ((color >> 10) & 0x1F) / 31.0f; - float a = (color >> 15) & 1 ? 1.0f : 0.0f; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); - glViewport(0, 0, g_wide_w * s_scale, H); - glEnable(GL_SCISSOR_TEST); - glScissor(0, y0, g_wide_w * s_scale, y1 - y0); - glClearColor(r, g, b, a); - glClearStencil((color >> 15) & 1); /* stencil mirrors bit15, like the color alpha */ - glStencilMask(0xFF); - if (s_world_depth && mirror_depth()) { /* under the depth test a frame never shown must not leave its depth to the next */ - glDepthMask(GL_TRUE); - glClearDepth(1.0); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] &= ~wide_depth_rows(y, h); - } else - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - gl_perf_mirror_end(); - s_cw_wide_ms += cw_ms() - t0; -} - -/* Clear only the two synthetic reveal strips, preserving the centred canonical - * framebuffer. This is an opt-in transition cleanup driven by gpu.c. */ -static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { - if (!s_raster_ok || s_ws_ablate == 1 || g_wide_off <= 0) return; - double t0 = cw_ms(); s_cw_wide_clears++; - flush_flat_batch(); - flush_tex_batch(); - GLuint fbo = wide_fbo_for(base_x); - if (!fbo) { s_cw_wide_ms += cw_ms() - t0; return; } - temporal_invalidate_surface(fbo); - gl_perf_mirror_begin(); - int H = VRAM_H * s_scale; - int W = g_wide_w * s_scale; - int margin = g_wide_off * s_scale; - int y0 = y * s_scale, y1 = (y + h) * s_scale; - if (y0 < 0) y0 = 0; - if (y1 > H) y1 = H; - if (y1 <= y0 || margin * 2 >= W) { - gl_perf_mirror_end(); - s_cw_wide_ms += cw_ms() - t0; - return; - } - float r = (color & 0x1F) / 31.0f; - float g = ((color >> 5) & 0x1F) / 31.0f; - float b = ((color >> 10) & 0x1F) / 31.0f; - float a = (color >> 15) & 1 ? 1.0f : 0.0f; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); - glViewport(0, 0, W, H); - glEnable(GL_SCISSOR_TEST); - glClearColor(r, g, b, a); - glClearStencil((color >> 15) & 1); - glStencilMask(0xFF); - if (sides & 1) { - glScissor(0, y0, margin, y1 - y0); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - } - if (sides & 2) { - glScissor(W - margin, y0, margin, y1 - y0); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - } - glDisable(GL_SCISSOR_TEST); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - gl_perf_mirror_end(); - s_cw_wide_ms += cw_ms() - t0; -} - -/* Present source: read the wide FBO for the displayed buffer (base_x) into the - * CPU present buffer as ARGB8888, byte-identical to sw_render_wide_display's - * output so the shared CPU present path consumes it the same way. Output is - * (g_wide_w*scale) wide × (disp_h*scale) tall. Returns pixel count (>0), or 0 - * if no surface exists for base_x (caller falls back to the canonical present). - * - * PIXEL FORMAT: glReadPixels(GL_BGRA, GL_UNSIGNED_BYTE) yields, per pixel, the - * little-endian uint32 0xAARRGGBB == ARGB8888 — exactly what rgb555_to_argb - * produces and what upload_present_tex feeds to glTexImage2D(GL_BGRA,...). The - * SW path forces alpha to 0xFF; we OR it in to match (present ignores alpha, but - * we keep the two paths bit-identical). GL's read origin is bottom-left, so the - * block is read bottom-to-top and copied into `out` reversed (out row 0 = the - * display's top scanline, as the SW path and the PRESENT_VS V-flip expect). */ -static int glb_render_wide_display(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h) { - if (!s_raster_ok || !s_ctx || g_wide_w <= 0) return 0; - GLuint fbo = 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == base_x) { fbo = s_wide_fbo[i]; break; } - if (!fbo) return 0; - - /* Fold any pending CPU->VRAM uploads into the canonical FBO first (uploads - * are never mirrored to wide, but draws after them are; keep op order) and - * make sure all wide-FBO draws have completed before the readback. */ - flush_tex_batch(); - flush_cpu_upload(); - wide_blit_center(fbo, base_x, disp_y, disp_h); /* fast-path: authoritative centre before readback */ - glFinish(); - - int W = g_wide_w * s_scale; - int H = VRAM_H * s_scale; - int out_h = disp_h * s_scale; - int ry0 = disp_y * s_scale; - if (ry0 < 0) ry0 = 0; - if (ry0 + out_h > H) out_h = H - ry0; - if (out_h <= 0) return 0; - - uint32_t *tmp = (uint32_t *)malloc((size_t)W * out_h * sizeof(uint32_t)); - if (!tmp) return 0; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - glReadPixels(0, ry0, W, out_h, GL_BGRA, GL_UNSIGNED_BYTE, tmp); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - - /* Orientation: the wide FBO stores PS1 y inverted (geo shader maps vram_y=0 - * to NDC y=-1 = FBO BOTTOM), and glReadPixels reads bottom-up — the two - * inversions CANCEL, so glReadPixels row 0 already = PS1 top scanline. Copy - * straight (NO flip) so `out` is top-down, matching sw_render_wide_display - * (which the shared CPU present path + its PRESENT_VS V-flip expect). Force - * alpha = 0xFF (match SW). [An earlier reversal here made the frame - * upside-down.] */ - int count = 0; - for (int row = 0; row < out_h; row++) { - const uint32_t *src = tmp + (size_t)row * W; - uint32_t *dst = (uint32_t *)((uint8_t *)out + (size_t)row * pitch); - for (int col = 0; col < W; col++) { dst[col] = src[col] | 0xFF000000u; count++; } - } - free(tmp); - return count; -} - -/* Dump the ENTIRE wide compositor surface for base_x (all double-buffer bands + - * both reveal margins), g_wide_w x VRAM_H at scale. Debug/inspection tool (TCP - * wide_full) — the GL analog of sw_wide_dump_full, so native-wide can be - * inspected without touching the game window. Runs the same authoritative - * centre blit first so the dump matches what present shows. Top-down, alpha=FF - * (matches sw_wide_dump_full / render_wide_display orientation). */ -static int glb_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, - int base_x) { - if (!s_raster_ok || !s_ctx || g_wide_w <= 0) return 0; - GLuint fbo = 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == base_x) { fbo = s_wide_fbo[i]; break; } - if (!fbo) return 0; - flush_tex_batch(); - flush_cpu_upload(); - wide_blit_center(fbo, base_x, 0, VRAM_H); /* authoritative centre (full height) */ - glFinish(); - int W = g_wide_w * s_scale; - int H = VRAM_H * s_scale; - if (cap_pixels > 0 && (long)W * H > cap_pixels) { H = cap_pixels / W; if (H <= 0) return 0; } - uint32_t *tmp = (uint32_t *)malloc((size_t)W * H * sizeof(uint32_t)); - if (!tmp) return 0; - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - glReadPixels(0, 0, W, H, GL_BGRA, GL_UNSIGNED_BYTE, tmp); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - /* Same orientation reasoning as glb_render_wide_display: glReadPixels row 0 = - * PS1 top scanline, so copy straight (top-down). */ - int count = 0; - for (int i = 0; i < W * H; i++) { out[i] = tmp[i] | 0xFF000000u; count++; } - free(tmp); - if (ow) *ow = W; - if (oh) *oh = H; - return count; -} - -/* THE present path for 15-bit frames: blit the display region from the - * authoritative hr FBO into a letterboxed rect. Deterministic — runs - * every 15-bit frame regardless of what mix of ops produced it. - * force_4_3 pins to native 4:3 (15-bit MDEC FMV frames on a wide aspect). */ -/* ===================== frame_perf: per-frame GPU/CPU phase timing ============ - * Developer builds use two GL_TIME_ELAPSED queries per frame to bracket (a) the - * scene draws (all GP0 raster issued between two presents) and (b) the present - * clear+blit, giving TRUE GPU time per phase independent of CPU/GPU overlap - * (glFinish would only catch the non-overlapped tail and mislead). CPU wall time - * (present-to-present total, and the present call) comes from SDL perf counters. - * Results are read back GLPERF_NBUF frames late (no pipeline stall) into a ring - * the debug server's frame_perf command aggregates. Release builds compile out - * the debug server, so they also leave this instrumentation disabled: a native- - * wide frame can otherwise issue hundreds of unused mirror timestamp queries. - * One question this answers in a diagnostics build: - * where does a 16:9 frame go vs 4:3 — scene fill, wide composite, or CPU. */ -#define GLPERF_NBUF 4 -#define GLPERF_RING 256 -typedef struct { - double total_ms; /* present-entry to present-entry (full frame) */ - double present_wall_ms; /* CPU wall time inside the present call */ - double scene_gpu_ms; /* GPU: all scene draws this frame */ - double present_gpu_ms; /* GPU: the present clear+blit */ - double mirror_gpu_ms; /* GPU: of scene_gpu, the native-wide mirror passes */ - double prims; /* scene primitives submitted this frame */ - double mirror_passes; /* mirror passes this frame (measured + overflow) */ - double cw_flush_ms; /* CPU wall inside flush_tex_batch this frame */ - double cw_wide_ms; /* CPU wall inside glb_wide_* this frame */ - double batches; /* flush_tex_batch draws this frame */ - double wide_sets; /* glb_wide_set_target calls this frame */ - double fbo_creates; /* wide FBO+tex creations this frame */ - int wide; /* 1 = native-wide (16:9) present, 0 = 4:3 */ - uint64_t frame; -} GlPerfSample; - -/* Native-wide mirror-pass GPU attribution: the scene TIME_ELAPSED query spans - * the whole frame (queries of one target cannot nest), so each mirror pass is - * bracketed with a GL_TIMESTAMP pair instead (glQueryCounter does not conflict - * with an active TIME_ELAPSED query). Pairs are pooled per buffered frame and - * summed at readback, splitting scene_gpu into canonical vs mirror cost. */ -#define GLPERF_MIRQ 1024 /* measured mirror passes per frame */ -static GLuint s_mq_q[GLPERF_NBUF][GLPERF_MIRQ * 2]; -static int s_mq_n[GLPERF_NBUF]; /* pairs recorded this frame */ -static int s_mq_over[GLPERF_NBUF]; /* passes beyond the pool (counted, untimed) */ -static int s_mq_open = 0; /* begin issued, end pending */ -static int s_mq_ok = 0; /* glQueryCounter available */ - -static int s_pf_on = 0; -static GLuint s_pf_scene_q[GLPERF_NBUF]; -static GLuint s_pf_present_q[GLPERF_NBUF]; -static int s_pf_b = 0; /* buffer for the CURRENT frame */ -static int s_pf_scene_active = 0; -static uint64_t s_pf_count = 0; -static uint64_t s_pf_freq = 1; -static uint64_t s_pf_last_enter = 0; -static uint64_t s_pf_enter = 0; -static double s_pf_total_pending = 0.0; -static double s_pf_buf_total[GLPERF_NBUF]; -static double s_pf_buf_pwall[GLPERF_NBUF]; -static double s_pf_buf_prims[GLPERF_NBUF]; -static int s_pf_buf_wide[GLPERF_NBUF]; -static uint64_t s_pf_buf_frame[GLPERF_NBUF]; -static uint64_t s_pf_prims_last = 0; -static double s_pf_prims_pending = 0.0; -static double s_pf_cw_pending[5]; /* flush_ms, wide_ms, batches, wide_sets, fbo_creates */ -static double s_pf_buf_cw[GLPERF_NBUF][5]; -static GlPerfSample s_pf_ring[GLPERF_RING]; -static uint64_t s_pf_ring_seq = 0; - -static void gl_perf_init(void) { -#ifdef PSX_NO_DEBUG_TOOLS - return; -#else - /* Timer queries are intentionally available in diagnostics builds, but a - * driver may serialize command submission while collecting them. Keep an - * escape hatch so frame cadence can be A/B tested without rebuilding or - * losing the rest of the debug server telemetry. */ - { - const char *enabled = getenv("PSX_GL_PERF"); - if (enabled && enabled[0] == '0') return; - } - if (!p_glGenQueries || !p_glBeginQuery || !p_glEndQuery || !p_glGetQueryObjectui64v) return; - p_glGenQueries(GLPERF_NBUF, s_pf_scene_q); - p_glGenQueries(GLPERF_NBUF, s_pf_present_q); - s_mq_ok = (p_glQueryCounter != NULL); - if (s_mq_ok) - for (int i = 0; i < GLPERF_NBUF; i++) { - p_glGenQueries(GLPERF_MIRQ * 2, s_mq_q[i]); - s_mq_n[i] = 0; s_mq_over[i] = 0; - } - s_mq_open = 0; - s_pf_freq = SDL_GetPerformanceFrequency(); - if (!s_pf_freq) s_pf_freq = 1; - s_pf_b = 0; s_pf_scene_active = 0; s_pf_count = 0; s_pf_ring_seq = 0; - s_pf_last_enter = 0; - s_pf_on = 1; -#endif -} - -/* Bracket ONE native-wide mirror pass (called from the wide-mirror draw sites). - * Timestamp pairs, not TIME_ELAPSED — see the pool comment above. */ -static void gl_perf_mirror_begin(void) { - if (!s_pf_on || !s_mq_ok) return; - int b = s_pf_b; - if (s_mq_n[b] >= GLPERF_MIRQ) { s_mq_over[b]++; return; } - p_glQueryCounter(s_mq_q[b][s_mq_n[b] * 2], GL_TIMESTAMP); - s_mq_open = 1; -} -static void gl_perf_mirror_end(void) { - if (!s_pf_on || !s_mq_ok || !s_mq_open) return; - int b = s_pf_b; - p_glQueryCounter(s_mq_q[b][s_mq_n[b] * 2 + 1], GL_TIMESTAMP); - s_mq_n[b]++; - s_mq_open = 0; -} - -/* Top of present (after flush_cpu_upload, before clear/blit). */ -static void gl_perf_present_enter(void) { - /* Per-frame boundary for the 2D-backdrop stretch — runs from BOTH present - * paths (4:3 present_vram AND native-wide present_wide_fbo), before the - * perf-on gate so native-wide frames reset too. Snapshot this frame's - * backdrop-stretch diagnostics, then reset the per-frame counters. (The gate - * is per-prim now — no draw-order phase to reset.) Final batch already flushed - * by the caller. */ - g_bdg_applied = s_bdg_applied; g_bdg_prims = s_bdg_prims; g_bdg_clearx = s_bdg_clearx; - g_bdg_cur = (g_wide_cur != 0); g_bdg_base = g_wide_cur_base; g_bdg_w = g_wide_w; g_bdg_off = g_wide_off; - s_bdg_applied = 0; s_bdg_prims = 0; s_bdg_clearx = -999999; - { extern void psx_ws_dbg_gate_frame_snapshot(void); psx_ws_dbg_gate_frame_snapshot(); } - if (!s_pf_on) return; - uint64_t now = SDL_GetPerformanceCounter(); - s_pf_enter = now; - s_pf_total_pending = s_pf_last_enter - ? (double)(now - s_pf_last_enter) * 1000.0 / (double)s_pf_freq : 0.0; - s_pf_last_enter = now; - s_pf_prims_pending = (double)(s_scene_prims - s_pf_prims_last); /* prims drawn this frame */ - s_pf_prims_last = s_scene_prims; - s_pf_cw_pending[0] = s_cw_flush_ms; s_pf_cw_pending[1] = s_cw_wide_ms; - s_pf_cw_pending[2] = (double)s_cw_batches; - s_pf_cw_pending[3] = (double)s_cw_wide_sets; - s_pf_cw_pending[4] = (double)s_cw_fbo_creates; - s_cw_flush_ms = 0.0; s_cw_wide_ms = 0.0; - s_cw_batches = 0; s_cw_wide_sets = 0; s_cw_wide_cfgs = 0; - s_cw_wide_clears = 0; s_cw_fbo_creates = 0; - if (s_pf_scene_active) { p_glEndQuery(GL_TIME_ELAPSED); s_pf_scene_active = 0; } /* end frame b's scene draws */ - p_glBeginQuery(GL_TIME_ELAPSED, s_pf_present_q[s_pf_b]); /* time frame b's present */ -} - -/* End of present (after SwapWindow). wide = native-wide path. */ -static void gl_perf_present_exit(int wide) { - if (!s_pf_on) return; - uint64_t now = SDL_GetPerformanceCounter(); - p_glEndQuery(GL_TIME_ELAPSED); /* end present_q[b] */ - s_pf_buf_total[s_pf_b] = s_pf_total_pending; - s_pf_buf_pwall[s_pf_b] = (double)(now - s_pf_enter) * 1000.0 / (double)s_pf_freq; - s_pf_buf_prims[s_pf_b] = s_pf_prims_pending; - for (int ci = 0; ci < 5; ci++) s_pf_buf_cw[s_pf_b][ci] = s_pf_cw_pending[ci]; - s_pf_buf_wide[s_pf_b] = wide; - s_pf_buf_frame[s_pf_b] = s_pf_count; - int rd = (s_pf_b + 1) % GLPERF_NBUF; /* oldest buffer (frame count+1-NBUF), now done */ - if (s_pf_count >= (uint64_t)GLPERF_NBUF) { - GLuint64 sc = 0, pr = 0; - p_glGetQueryObjectui64v(s_pf_scene_q[rd], GL_QUERY_RESULT, &sc); - p_glGetQueryObjectui64v(s_pf_present_q[rd], GL_QUERY_RESULT, &pr); - double mir = 0.0; - for (int i = 0; i < s_mq_n[rd]; i++) { /* sum that frame's mirror pairs */ - GLuint64 t0 = 0, t1 = 0; - p_glGetQueryObjectui64v(s_mq_q[rd][i * 2], GL_QUERY_RESULT, &t0); - p_glGetQueryObjectui64v(s_mq_q[rd][i * 2 + 1], GL_QUERY_RESULT, &t1); - if (t1 > t0) mir += (double)(t1 - t0); - } - GlPerfSample *s = &s_pf_ring[s_pf_ring_seq % GLPERF_RING]; - s->total_ms = s_pf_buf_total[rd]; - s->present_wall_ms = s_pf_buf_pwall[rd]; - s->scene_gpu_ms = (double)sc / 1.0e6; - s->present_gpu_ms = (double)pr / 1.0e6; - s->mirror_gpu_ms = mir / 1.0e6; - s->prims = s_pf_buf_prims[rd]; - s->mirror_passes = (double)(s_mq_n[rd] + s_mq_over[rd]); - s->cw_flush_ms = s_pf_buf_cw[rd][0]; - s->cw_wide_ms = s_pf_buf_cw[rd][1]; - s->batches = s_pf_buf_cw[rd][2]; - s->wide_sets = s_pf_buf_cw[rd][3]; - s->fbo_creates = s_pf_buf_cw[rd][4]; - s->wide = s_pf_buf_wide[rd]; - s->frame = s_pf_buf_frame[rd]; - s_pf_ring_seq++; - } - s_mq_n[rd] = 0; s_mq_over[rd] = 0; /* rd becomes the next frame's buffer */ - s_pf_count++; - s_pf_b = rd; /* reuse oldest for next frame */ - p_glBeginQuery(GL_TIME_ELAPSED, s_pf_scene_q[s_pf_b]); /* open next frame's scene draws */ - s_pf_scene_active = 1; -} - -/* Aggregate the ring for the debug server. wide_filter: -1 all, 0 = 4:3, 1 = wide. - * out[0]=count, [1]=total_avg, [2]=total_max, [3]=emu_cpu_avg (total-present_wall), - * [4]=present_wall_avg, [5]=scene_gpu_avg, [6]=scene_gpu_max, [7]=present_gpu_avg, - * [8]=present_gpu_max. Returns the sample count. */ -/* Cumulative textured fraction of scene prims (decides flat vs textured batching - * priority). out_tex_frac = textured/total since boot; returns total prim count. */ -uint64_t gl_renderer_perf_prim_split(double *out_tex_frac) { - if (out_tex_frac) *out_tex_frac = s_scene_prims ? (double)s_scene_prims_tex / (double)s_scene_prims : 0.0; - return s_scene_prims; -} - -int gl_renderer_perf_aggregate(int wide_filter, double out[18]) { - for (int i = 0; i < 18; i++) out[i] = 0.0; - if (!s_pf_on) return 0; - int navail = (int)(s_pf_ring_seq < (uint64_t)GLPERF_RING ? s_pf_ring_seq : GLPERF_RING); - uint64_t start = s_pf_ring_seq - (uint64_t)navail; - int n = 0; - for (int i = 0; i < navail; i++) { - const GlPerfSample *s = &s_pf_ring[(start + i) % GLPERF_RING]; - if (wide_filter >= 0 && s->wide != wide_filter) continue; - double emu = s->total_ms - s->present_wall_ms; if (emu < 0) emu = 0; - out[1] += s->total_ms; if (s->total_ms > out[2]) out[2] = s->total_ms; - out[3] += emu; - out[4] += s->present_wall_ms; - out[5] += s->scene_gpu_ms; if (s->scene_gpu_ms > out[6]) out[6] = s->scene_gpu_ms; - out[7] += s->present_gpu_ms; if (s->present_gpu_ms > out[8]) out[8] = s->present_gpu_ms; - out[9] += s->prims; - out[10] += s->mirror_gpu_ms; if (s->mirror_gpu_ms > out[11]) out[11] = s->mirror_gpu_ms; - out[12] += s->mirror_passes; - out[13] += s->cw_flush_ms; - out[14] += s->cw_wide_ms; - out[15] += s->batches; - out[16] += s->wide_sets; - out[17] += s->fbo_creates; - n++; - } - if (n) { - out[1]/=n; out[3]/=n; out[4]/=n; out[5]/=n; out[7]/=n; out[9]/=n; out[10]/=n; out[12]/=n; - out[13]/=n; out[14]/=n; out[15]/=n; out[16]/=n; out[17]/=n; - } - out[0] = (double)n; - return n; -} - -/* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ -void gl_renderer_set_ws_ablate(int mode) { interp_reset_history(); s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } -int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } - -extern void gte_ws_x_squash(int32_t *num, int32_t *den); - -/* What the shading draws through. Frame buffer objects belong to one GL context: the frame loop's and the in-between presenter's. */ -typedef struct { - GLuint depth_fbo, depth, shade_fbo, shade; - int w, h, shade_w, shade_h; -} SsaoTargets; -static SsaoTargets s_ssao_main, s_ssao_thread; - -static GLuint ssao_depth_texture(int w, int h) { - GLuint made = 0; - glGenTextures(1, &made); - glBindTexture(GL_TEXTURE_2D, made); - glTexImage2D(GL_TEXTURE_2D, 0, PSXGL_DEPTH24_STENCIL8, w, h, 0, 0x84F9 /* GL_DEPTH_STENCIL */, 0x84FA /* GL_UNSIGNED_INT_24_8 */, NULL); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - return made; -} - -static int ssao_targets(SsaoTargets *t, int w, int h, int lw, int lh, int copies) { - if (copies && (!t->depth_fbo || t->w != w || t->h != h)) { - if (t->depth_fbo) p_glDeleteFramebuffers(1, &t->depth_fbo); - if (t->depth) glDeleteTextures(1, &t->depth); - t->depth_fbo = 0; - t->depth = ssao_depth_texture(w, h); - p_glGenFramebuffers(1, &t->depth_fbo); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->depth_fbo); - p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, t->depth, 0); - glDrawBuffer(GL_NONE); - glReadBuffer(GL_NONE); - if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) return 0; - t->w = w; - t->h = h; - } - if (!t->shade_fbo || t->shade_w != lw || t->shade_h != lh) { - if (t->shade_fbo) p_glDeleteFramebuffers(1, &t->shade_fbo); - if (t->shade) glDeleteTextures(1, &t->shade); - t->shade_fbo = 0; - t->shade = make_tex(GL_RGBA8, lw, lh, GL_RGBA, GL_UNSIGNED_BYTE); - if (!t->shade || !make_fbo(&t->shade_fbo, t->shade, 0)) return 0; - t->shade_w = lw; - t->shade_h = lh; - } - return 1; -} - -/* Shades `color` by a depth of the same size and puts it on screen. The depth is `depth`, or with 0 is first copied from the - * frame buffer `from`. uv is the part shown. 0: nothing drawn, the caller presents plainly. */ -static int ssao_draw(SsaoTargets *t, GLuint vao, GLuint from, GLuint depth, GLuint color, int w, int h, - float u0, float v0, float u1, float v1, int scale, int linear, int lx, int ly, int lw, int lh) { - int32_t num = 1, den = 1; - float focal_x, focal_y; - if (!s_ssao || !s_geometry_correction || !s_ssao_prog || !s_ssao_mix_prog || lw <= 0 || lh <= 0 || w <= 0 || h <= 0) return 0; - if (!ssao_targets(t, w, h, lw, lh, depth == 0)) { - fprintf(stdout, "psxrecomp: GL ambient occlusion has no targets, switched off\n"); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - s_ssao = s_ssao_on = s_ssao_view = 0; - return 0; - } - glDisable(GL_SCISSOR_TEST); - if (!depth) { - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, from); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, t->depth_fbo); - p_glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, GL_DEPTH_BUFFER_BIT, GL_NEAREST); - depth = t->depth; - } - gte_ws_x_squash(&num, &den); - focal_x = s_yaw_focal_x * (float)scale; - focal_y = num > 0 && den > 0 ? focal_x * (float)den / (float)num : focal_x; - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, t->shade_fbo); - glViewport(0, 0, lw, lh); - glDisable(GL_BLEND); - glDisable(GL_STENCIL_TEST); - glDisable(GL_DEPTH_TEST); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - p_glActiveTexture(PSXGL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, color); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, depth); - p_glUseProgram(s_ssao_prog); - p_glUniform1i(s_ssao_uDepth, 0); - p_glUniform1i(s_ssao_uTex, 1); - p_glUniform4f(s_ssao_uUvRect, u0, v0, u1, v1); - p_glUniform4f(s_ssao_uTexel, 1.0f / (float)w, 1.0f / (float)h, (float)s_ssao, 0.0f); - p_glUniform4f(s_ssao_uProj, focal_x, focal_y, (u0 + u1) * 0.5f * (float)w, (v0 + v1) * 0.5f * (float)h); - p_glUniform4f(s_ssao_uAo, s_ssao_radius, s_ssao_strength, 24.0f * (float)scale, (float)scale); - p_glUniform4f(s_ssao_uFade, s_ssao_fade_from, s_ssao_fade_to, s_ssao_tilt, 0.0f); - p_glBindVertexArray(vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, t->shade); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, color); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - p_glUseProgram(s_ssao_mix_prog); - p_glUniform1i(s_ssao_mix_uTex, 0); - p_glUniform1i(s_ssao_mix_uShade, 1); - p_glUniform4f(s_ssao_mix_uUvRect, u0, v0, u1, v1); - p_glUniform4f(s_ssao_mix_uSpan, (float)lx, (float)ly, (float)s_ssao, 0.0f); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); - return 1; -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -/* The depth of each captured real frame, for the presenter's thread to shade it by. */ -static GLuint s_interp_depth[3], s_interp_depth_fbo; -static int s_interp_depth_w, s_interp_depth_h; -static int s_temp_scene_depth; /* the replay is drawing its scene, whose opaque fragments alone leave depth while the shading is on */ - -static void interp_capture_depth(GLuint fbo, int x, int y, int h, int pw, int ph, int dst) { - if (!s_ssao || !s_geometry_correction) return; - if (!s_interp_depth[0] || s_interp_depth_w != pw || s_interp_depth_h != ph) { - for (int i = 0; i < 3; i++) { - if (s_interp_depth[i]) glDeleteTextures(1, &s_interp_depth[i]); - s_interp_depth[i] = ssao_depth_texture(pw, ph); - } - s_interp_depth_w = pw; - s_interp_depth_h = ph; - } - if (!s_interp_depth_fbo) p_glGenFramebuffers(1, &s_interp_depth_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, s_interp_depth_fbo); - p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, s_interp_depth[dst], 0); - glDrawBuffer(GL_NONE); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(x * s_scale, y * s_scale, x * s_scale + pw, y * s_scale + ph, 0, 0, pw, ph, GL_DEPTH_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); -} -#endif - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -#include "gpu_gl_temporal.c.inc" - -static void interp_reset_history_unlocked(void) { - temporal_reset(); - s_interp_valid = 0; - s_interp_w = s_interp_h = 0; - s_interp_start = s_interp_last_capture = 0; - s_interp_duration = 1; - s_interp_source_path = -1; -} - -static void interp_reset_history(void) { - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - interp_reset_history_unlocked(); - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode) { - double effective_hz = target_hz < 0.0 - ? -1.0 - : (target_hz >= 30.0 ? target_hz : host_hz); - int active = (enabled && - (blend_mode != 2 || s_geometry_correction) && - (effective_hz < 0.0 || effective_hz >= 30.0)) ? 1 : 0; - const char *diag = getenv("PSX_GL_INTERP_DIAG"); - s_interp_diag = diag && diag[0] && diag[0] != '0'; - if (active && !s_interp_ctx && s_ctx) { - if (!s_interp_mutex) s_interp_mutex = SDL_CreateMutex(); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); - s_interp_ctx = SDL_GL_CreateContext(s_win); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); - SDL_GL_MakeCurrent(s_win, s_ctx); - /* The wall-clock pacer and interpolation scheduler own cadence. Keeping - * driver vsync on the main context adds a 6-11 ms block whenever an - * FMV temporarily suspends interpolation and the main context presents. */ - SDL_GL_SetSwapInterval(0); - if (s_interp_ctx && s_interp_mutex) { - SDL_AtomicSet(&s_temp_worker_ready, 0); - SDL_AtomicSet(&s_interp_thread_run, 1); - s_interp_thread = SDL_CreateThread(interp_thread_main, - "psx-gl-interp", NULL); - } - if (!s_interp_thread) { - SDL_AtomicSet(&s_interp_thread_run, 0); - if (s_interp_ctx) SDL_GL_DeleteContext(s_interp_ctx); - s_interp_ctx = NULL; - active = 0; - } - } - if (active && s_interp_thread) { - while (!SDL_AtomicGet(&s_temp_worker_ready)) SDL_Delay(1); - if (blend_mode == 2 && SDL_AtomicGet(&s_temp_worker_ready) < 0) active = 0; - } - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (active != s_interp_enabled || blend_mode != s_interp_blend_mode) interp_reset_history_unlocked(); - s_interp_enabled = active; - s_interp_host_hz = host_hz; - s_interp_target_hz = active ? effective_hz : 0.0; - s_interp_blend_mode = blend_mode == 2 ? 2 : blend_mode == 1 ? 1 : 0; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); - /* Enabling interpolation forces immediate swaps; disabling restores the - * last user-requested interval kept in s_swap_interval. */ - apply_swap_interval(); - if (active && effective_hz < 0.0) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " - "target on %.1f Hz display (%s)\n", host_hz, - s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); - else if (active) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " - "target on %.1f Hz display (%s)\n", effective_hz, host_hz, - s_interp_blend_mode == 2 ? "geometry inter-frame" : s_interp_blend_mode ? "motion-adaptive" : "linear"); - else - fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); -} - -static void interp_set_suspended(int *reason, int suspended) { - suspended = suspended ? 1 : 0; - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - const int was_suspended = s_interp_suspended; - *reason = suspended; - s_interp_suspended = - s_interp_content_suspended || s_interp_host_ui_suspended; - if (was_suspended != s_interp_suspended) interp_reset_history_unlocked(); - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_set_interpolation_suspended(int suspended) { - interp_set_suspended(&s_interp_content_suspended, suspended); -} - -/* Host UI suspension is independent of content/FMV suspension. Keeping two - * reasons prevents closing a menu during an FMV from briefly waking the - * interpolation thread on the shared window. */ -void gl_renderer_set_host_ui_suspended(int suspended) { - interp_set_suspended(&s_interp_host_ui_suspended, suspended); -} - -void gl_renderer_interpolation_diag(int *enabled, int *suspended, - int *history_frames, - double *host_hz, double *target_hz, - uint64_t *swaps) { - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (enabled) *enabled = s_interp_enabled; - if (suspended) *suspended = s_interp_suspended; - if (history_frames) *history_frames = s_interp_valid; - if (host_hz) *host_hz = s_interp_host_hz; - if (target_hz) *target_hz = s_interp_target_hz; - if (swaps) *swaps = s_interp_swaps; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { - if (!out) return; - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - out[0] = s_temp_published; - out[1] = s_temp_replays; - out[2] = s_temp_fallbacks; - out[3] = (uint64_t)s_temp_last_matched; - out[4] = (uint64_t)s_temp_last_world; - out[5] = (uint64_t)s_temp_last_ready; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -/* PSX_GL_INTERP_DIAG: spacing of presents in ms, and of the phase they were - * drawn at (captures plus the alpha drawn) in game frames per present. A step - * near 0 drew one phase twice, a step near 1 skipped most of an interval. - * Pictures are not compared: a replay that moves nothing still advances. */ -typedef struct { double sum, squares, low, high; int count; } InterpSpread; -static InterpSpread s_interp_gap, s_interp_step, s_interp_hold, s_interp_work; -static InterpSpread s_interp_wake, s_interp_wait, s_interp_draw; -static int s_interp_holds, s_interp_jumps, s_interp_late, s_interp_settle; -static uint64_t s_interp_last_swap; -static double s_interp_last_phase; -static void interp_spread_add(InterpSpread *s, double value) { - if (!s->count || value < s->low) s->low = value; - if (!s->count || value > s->high) s->high = value; - s->sum += value; s->squares += value * value; s->count++; -} -static double interp_spread_deviation(const InterpSpread *s) { - const double mean = s->count ? s->sum / s->count : 0.0; - const double variance = s->count ? s->squares / s->count - mean * mean : 0.0; - return variance > 0.0 ? sqrt(variance) : 0.0; -} - -/* Copy a stable display image out of the mutable VRAM/wide render target. - * Returns true once both previous and current images are available. */ -static int interp_capture(GLuint fbo, int x, int y, int w, int h, - int linear, int force_4_3, int source_path) { - if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; - uint64_t began = 0; - int under_lock = 0; -#ifndef PSX_NO_DEBUG_TOOLS - { /* A/B: PSX_GL_TEMPORAL_MATCH_LOCKED=1 matches under the lock as before. */ - static int cached = -1; - if (cached < 0) { const char *e = getenv("PSX_GL_TEMPORAL_MATCH_LOCKED"); cached = e && e[0] == '1'; } - under_lock = cached; - } -#endif - if (s_interp_blend_mode == 2 && !under_lock) { - began = SDL_GetPerformanceCounter(); - temporal_prepare(fbo, y, w, h); - if (s_interp_diag) - began = SDL_GetPerformanceCounter() - began; - } - SDL_LockMutex(s_interp_mutex); - const uint64_t locked = SDL_GetPerformanceCounter(); - if (s_interp_blend_mode == 2 && under_lock) { - began = SDL_GetPerformanceCounter(); - temporal_prepare(fbo, y, w, h); - began = SDL_GetPerformanceCounter() - began; - } - if (s_interp_diag && s_interp_blend_mode == 2) - interp_spread_add(&s_interp_work, (double)began * 1000.0 / - (double)SDL_GetPerformanceFrequency()); - /* The presentation context may still have a draw queued which samples one - * of the shared history textures. Order this context's next allocation or - * copy after that draw before recycling a texture. glWaitSync keeps the - * dependency on the GPU; unlike glFinish it does not stall the guest CPU. */ - if (s_interp_draw_fence) { - p_glWaitSync(s_interp_draw_fence, 0, PSXGL_TIMEOUT_IGNORED); - p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = NULL; - } - int pw = w * s_scale, ph = h * s_scale; - if (pw != s_interp_w || ph != s_interp_h || - source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { - temporal_reset(); - s_interp_valid = 0; - s_interp_w = pw; s_interp_h = ph; - s_interp_prev = s_interp_cur = 0; - for (int i = 0; i < 3; i++) { - glBindTexture(GL_TEXTURE_2D, s_interp_tex[i]); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, pw, ph, 0, - GL_RGBA, GL_UNSIGNED_BYTE, NULL); - } - } - - int dst = 0; - if (s_interp_valid == 1) dst = s_interp_cur == 0 ? 1 : 0; - else if (s_interp_valid >= 2) dst = s_interp_prev; - if (s_interp_fence[dst]) { - p_glDeleteSync(s_interp_fence[dst]); - s_interp_fence[dst] = NULL; - } - - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); - glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, - x * s_scale, y * s_scale, pw, ph); - wide_depth_shown(fbo, y, h); - if (s_interp_blend_mode == 2) interp_capture_depth(fbo, x, y, h, pw, ph, dst); - if (s_interp_blend_mode == 2) temporal_publish(fbo, y, h); -#ifndef PSX_NO_DEBUG_TOOLS - if (s_interp_blend_mode == 2 && s_temp_curr.active) - temporal_dump_frame(x * s_scale, y * s_scale, pw, ph, -1.0f, - s_temp_curr.mesh_stats.departed_faces); - if (s_interp_blend_mode == 2 && temporal_edge_diag() && s_temp_curr.active) { - const int left = temporal_edge_gap((x + 1) * s_scale, y * s_scale, ph, s_temp_curr.clear); - const int right = temporal_edge_gap((x + w - 2) * s_scale, y * s_scale, ph, s_temp_curr.clear); - fprintf(stdout, "psxrecomp: edge real %llu %d %d %d\n", - (unsigned long long)s_temp_published, left, right, ph); - if ((left > right ? left : right) * 33 > ph) - temporal_dump_edge(x * s_scale, y * s_scale, pw, ph); - } -#endif - s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); - glFlush(); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - - uint64_t now = SDL_GetPerformanceCounter(); - uint64_t freq = SDL_GetPerformanceFrequency(); - if (s_interp_valid > 0 && s_interp_last_capture && now > s_interp_last_capture) { - uint64_t d = now - s_interp_last_capture; - uint64_t lo = freq / 240u, hi = freq / 10u; - if (d < lo) d = lo; - if (d > hi) d = hi; - s_interp_duration = d; - } - s_interp_last_capture = now; - s_interp_start = now; - if (s_interp_valid == 0) { - s_interp_cur = dst; - s_interp_valid = 1; - } else { - s_interp_prev = s_interp_cur; - s_interp_cur = dst; - s_interp_valid = 2; - } - s_interp_scale = s_scale; - s_interp_linear = linear; - s_interp_force_4_3 = force_4_3; - s_interp_source_path = source_path; - s_interp_captures++; - int ready = s_interp_valid >= 2; - if (s_interp_diag) - interp_spread_add(&s_interp_hold, (double)(SDL_GetPerformanceCounter() - locked) * 1000.0 / - (double)SDL_GetPerformanceFrequency()); - SDL_UnlockMutex(s_interp_mutex); - return ready; -} - -/* Returns the alpha of the picture it drew, which is 1 when the replay fell back. */ -static float interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { - if (s_interp_blend_mode == 2) { - temporal_verify_endpoint(lx, ly, lw, lh); -#ifndef PSX_NO_DEBUG_TOOLS - alpha = temporal_dump_alpha(alpha); -#endif - if (alpha < 1.0f && temporal_present(alpha, lx, ly, lw, lh)) return alpha; - alpha = 1.0f; /* Unsafe/incomplete geometry: current frame, never a crossfade. */ - } - int prev = s_interp_prev, curr = s_interp_cur; - if (s_interp_fence[prev]) - p_glWaitSync(s_interp_fence[prev], 0, PSXGL_TIMEOUT_IGNORED); - if (s_interp_fence[curr]) - p_glWaitSync(s_interp_fence[curr], 0, PSXGL_TIMEOUT_IGNORED); - if (s_interp_blend_mode == 2 && alpha >= 1.0f && s_interp_depth[curr] && - s_interp_depth_w == s_interp_w && s_interp_depth_h == s_interp_h) { - const float ix = 0.5f * s_interp_scale / s_interp_w, iy = 0.5f * s_interp_scale / s_interp_h; - if (ssao_draw(&s_ssao_thread, s_interp_thread_vao, 0, s_interp_depth[curr], s_interp_tex[curr], s_interp_w, s_interp_h, - ix, iy, 1 - ix, 1 - iy, s_interp_scale, s_interp_linear, lx, ly, lw, lh)) - return alpha; - } - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, s_interp_tex[prev]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - p_glActiveTexture(PSXGL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, s_interp_tex[curr]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST); - p_glUseProgram(s_interp_prog); - p_glUniform1i(s_interp_uPrev, 0); - p_glUniform1i(s_interp_uCurr, 1); - p_glUniform1f(s_interp_uAlpha, alpha); - p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); - if (s_interp_blend_mode == 2) { - const float ix=0.5f*s_interp_scale/s_interp_w, iy=0.5f*s_interp_scale/s_interp_h; - p_glUniform4f(s_interp_uUvRect,ix,iy,1-ix,1-iy); - } else p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); - p_glBindVertexArray(s_interp_thread_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); - p_glActiveTexture(PSXGL_TEXTURE0); - return alpha; -} - -/* What reaches the screen, for a report: each swapped picture is read back at the asked width (the window's at most) with - * its time and kept in memory, then written as PPM files and a list when the count is reached. Returns 0 when refused. */ -static char s_shown_dir[512]; -static int s_shown_skip = 0, s_shown_left = 0, s_shown_count = 0, s_shown_w = 0, s_shown_h = 0, s_shown_width = 640; -static uint8_t *s_shown_pixels = NULL; -static struct ShownMeta { uint64_t tick; float alpha; int kind; int placed[7]; } *s_shown_meta = NULL; - -int gl_renderer_record_shown(const char *folder, int skip, int count, int width) { - if (!folder || !folder[0] || strlen(folder) >= sizeof s_shown_dir || count <= 0 || count > 2000 || width < 160 || - width > 3840 || s_shown_left > 0) return 0; - snprintf(s_shown_dir, sizeof s_shown_dir, "%s", folder); - s_shown_width = width; - s_shown_skip = skip > 0 ? skip : 0; - s_shown_count = 0; - s_shown_w = s_shown_h = 0; - s_shown_left = count; - return 1; -} -int gl_renderer_recording_shown(void) { return s_shown_left > 0; } - -static void shown_write(void) { - char path[640]; - FILE *list; - snprintf(path, sizeof path, "%s/shown.csv", s_shown_dir); - list = fopen(path, "wb"); - if (list) fprintf(list, "index,kind,alpha,microseconds,faces,camera_samples,camera_inliers,carried,held,conflicting,departed\n"); - for (int i = 0; i < s_shown_count; ++i) { - FILE *file; - snprintf(path, sizeof path, "%s/shown_%04d.ppm", s_shown_dir, i); - file = fopen(path, "wb"); - if (file) { - fprintf(file, "P6\n%d %d\n255\n", s_shown_w, s_shown_h); - for (int y = s_shown_h - 1; y >= 0; --y) - fwrite(s_shown_pixels + ((size_t)i * s_shown_h + y) * s_shown_w * 3, 1, (size_t)s_shown_w * 3, file); - fclose(file); - } - if (list) - fprintf(list, "%d,%s,%.3f,%.0f,%d,%d,%d,%d,%d,%d,%d\n", i, s_shown_meta[i].kind ? "between" : "frame", s_shown_meta[i].alpha, - (double)(s_shown_meta[i].tick - s_shown_meta[0].tick) * 1e6 / (double)SDL_GetPerformanceFrequency(), - s_shown_meta[i].placed[0], s_shown_meta[i].placed[1], s_shown_meta[i].placed[2], s_shown_meta[i].placed[3], - s_shown_meta[i].placed[4], s_shown_meta[i].placed[5], s_shown_meta[i].placed[6]); - } - if (list) fclose(list); - free(s_shown_pixels); - free(s_shown_meta); - s_shown_pixels = NULL; - s_shown_meta = NULL; -} - -/* Called with the finished picture in the back buffer, right before its swap. kind: 0 a game frame's own swap, 1 the - * in-between presenter's, whose alpha is the picture's place between the last two game frames (1 is the later one). */ -static void shown_capture(int kind, float alpha) { - int ww = 0, wh = 0; - GLint bound = 0; - GLuint fbo = 0, texture = 0; - const GLboolean scissor = glIsEnabled(GL_SCISSOR_TEST); - if (s_shown_left <= 0) return; - if (s_shown_skip > 0) { --s_shown_skip; return; } - gl_visible_size(&ww, &wh); - if (ww <= 0 || wh <= 0) return; - if (!s_shown_pixels) { - s_shown_w = s_shown_width < ww ? s_shown_width : ww; - s_shown_h = s_shown_w * wh / ww > 0 ? s_shown_w * wh / ww : 1; - if (s_shown_h > 0) { - s_shown_pixels = (uint8_t *)malloc((size_t)s_shown_left * s_shown_w * s_shown_h * 3); - s_shown_meta = (struct ShownMeta *)malloc((size_t)s_shown_left * sizeof *s_shown_meta); - } - if (!s_shown_pixels || !s_shown_meta) { - free(s_shown_pixels); - free(s_shown_meta); - s_shown_pixels = NULL; - s_shown_meta = NULL; - s_shown_left = 0; - return; - } - } - glGetIntegerv(GL_TEXTURE_BINDING_2D, &bound); - glGenTextures(1, &texture); - glBindTexture(GL_TEXTURE_2D, texture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s_shown_w, s_shown_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - p_glGenFramebuffers(1, &fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, fbo); - p_glFramebufferTexture2D(PSXGL_DRAW_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - glReadBuffer(GL_BACK); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(0, 0, ww, wh, 0, 0, s_shown_w, s_shown_h, GL_COLOR_BUFFER_BIT, GL_LINEAR); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); - glPixelStorei(GL_PACK_ALIGNMENT, 1); - glReadPixels(0, 0, s_shown_w, s_shown_h, GL_RGB, GL_UNSIGNED_BYTE, - s_shown_pixels + (size_t)s_shown_count * s_shown_w * s_shown_h * 3); - glPixelStorei(GL_PACK_ALIGNMENT, 4); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - p_glDeleteFramebuffers(1, &fbo); - glBindTexture(GL_TEXTURE_2D, (GLuint)bound); - glDeleteTextures(1, &texture); - if (scissor) glEnable(GL_SCISSOR_TEST); - s_shown_meta[s_shown_count].tick = SDL_GetPerformanceCounter(); - s_shown_meta[s_shown_count].alpha = alpha; - s_shown_meta[s_shown_count].kind = kind; - memset(s_shown_meta[s_shown_count].placed, 0, sizeof s_shown_meta[s_shown_count].placed); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (kind) { - int *placed = s_shown_meta[s_shown_count].placed; - placed[0] = s_temp_shown_faces; - placed[1] = s_temp_shown_stats.camera_samples; - placed[2] = s_temp_shown_stats.camera_inliers; - placed[3] = s_temp_shown_stats.reprojected_vertices; - placed[4] = s_temp_shown_stats.held_vertices; - placed[5] = s_temp_shown_stats.conflicting_vertices; - placed[6] = s_temp_shown_stats.departed_faces; - } -#endif - ++s_shown_count; - if (--s_shown_left == 0) shown_write(); -} - -static int interp_present(void) { - if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 2) return 0; - uint64_t now = SDL_GetPerformanceCounter(); - if (now <= s_interp_start || !s_interp_duration) return 0; - double a = (double)(now - s_interp_start) / (double)s_interp_duration; - /* Keep swapping at the host cadence after the blend completes. Holding - * alpha at one is visually identical to leaving the current image on the - * front buffer, but avoids an irregular 2/3-swap pattern on 120/144/165 Hz - * displays while the next 59.94 Hz guest frame is being produced. */ - if (a > 1.0) a = 1.0; - if (a < 0.0) a = 0.0; - - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - int lx, ly, lw, lh; - if (s_interp_force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f, 0.f, 0.f, 1.f); - glClear(GL_COLOR_BUFFER_BIT); - } - const float shown = interp_draw_quad((float)a, lx, ly, lw, lh); - if (s_interp_draw_fence) p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); - glFlush(); - pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, - lx, ly, lw, lh); - shown_capture(1, shown); - SDL_GL_SwapWindow(s_win); - s_interp_swaps++; - if (s_interp_diag) { - const uint64_t swapped = SDL_GetPerformanceCounter(); - const double phase = (double)s_interp_captures + shown; - if (s_interp_settle > 0) --s_interp_settle; - else if (s_interp_last_swap) { - const double step = phase - s_interp_last_phase; - interp_spread_add(&s_interp_gap, (double)(swapped - s_interp_last_swap) * 1000.0 / - (double)SDL_GetPerformanceFrequency()); - interp_spread_add(&s_interp_step, step); - s_interp_late += (double)(swapped - s_interp_last_swap) * s_interp_target_hz > - 1.25 * (double)SDL_GetPerformanceFrequency(); - s_interp_holds += step < 0.05; - s_interp_jumps += step > 0.9; - } - s_interp_last_swap = swapped; - s_interp_last_phase = phase; - } - return 1; -} - -static int interp_thread_main(void *opaque) { - (void)opaque; - if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) { - SDL_AtomicSet(&s_temp_worker_ready, -1); - return -1; - } - SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ - p_glGenVertexArrays(1, &s_interp_thread_vao); - SDL_AtomicSet(&s_temp_worker_ready, temporal_worker_init() ? 1 : -1); - uint64_t freq = SDL_GetPerformanceFrequency(); - uint64_t deadline = SDL_GetPerformanceCounter(); - uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; - - while (SDL_AtomicGet(&s_interp_thread_run)) { - SDL_LockMutex(s_interp_mutex); - double hz = s_interp_target_hz; - SDL_UnlockMutex(s_interp_mutex); - int uncapped = hz < 0.0; - uint64_t now = SDL_GetPerformanceCounter(); - if (!uncapped) { - if (hz < 30.0) hz = 60.0; - uint64_t period = (uint64_t)((double)freq / hz); - if (!period) period = 1; - deadline += period; - if (now > deadline + period * 4u) deadline = now + period; - for (;;) { - now = SDL_GetPerformanceCounter(); - if (now >= deadline) break; - uint64_t remain = deadline - now; - uint32_t ms = (uint32_t)((remain * 1000u) / - (freq ? freq : 1u)); - if (ms > 1) SDL_Delay(ms - 1); - } - while (SDL_GetPerformanceCounter() < deadline) {} - now = SDL_GetPerformanceCounter(); - if (s_interp_diag && !s_interp_settle) - interp_spread_add(&s_interp_wake, (double)(now - deadline) * 1000.0 / (double)freq); - } else { - deadline = now; - } - - SDL_LockMutex(s_interp_mutex); - int presented = 0; - const uint64_t entered = SDL_GetPerformanceCounter(); - if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) - presented = interp_present(); - if (s_interp_diag) { - interp_spread_add(&s_interp_wait, (double)(entered - now) * 1000.0 / (double)freq); - interp_spread_add(&s_interp_draw, (double)(SDL_GetPerformanceCounter() - entered) * 1000.0 / - (double)freq); - } - if (s_interp_diag && now - diag_start >= freq * 5u) { - double seconds = (double)(now - diag_start) / (double)freq; - fprintf(stdout, "psxrecomp: GL interpolation cadence: " - "%.2f captures/s, %.2f presents/s\n", - (double)(s_interp_captures - diag_captures) / seconds, - (double)(s_interp_swaps - diag_swaps) / seconds); - if (s_interp_gap.count) { - fprintf(stdout, "psxrecomp: GL interpolation pacing: present gap %.3f ms " - "(deviation %.3f, %.3f to %.3f), phase step %.3f frames " - "(deviation %.3f), %d repeated and %d skipped phases, %d late in %d presents\n", - s_interp_gap.sum / s_interp_gap.count, interp_spread_deviation(&s_interp_gap), - s_interp_gap.low, s_interp_gap.high, - s_interp_step.sum / s_interp_step.count, interp_spread_deviation(&s_interp_step), - s_interp_holds, s_interp_jumps, s_interp_late, s_interp_gap.count); - if (s_interp_hold.count) - fprintf(stdout, "psxrecomp: GL interpolation lock: a capture holds it %.3f ms " - "(up to %.3f), matching takes %.3f ms (up to %.3f)\n", - s_interp_hold.sum / s_interp_hold.count, s_interp_hold.high, - s_interp_work.count ? s_interp_work.sum / s_interp_work.count : 0.0, - s_interp_work.high); - fprintf(stdout, "psxrecomp: GL interpolation presenter: wakes %.3f ms late (up to %.3f), " - "waits %.3f ms for the lock (up to %.3f), draws and swaps in %.3f ms (up to %.3f)\n", - s_interp_wake.count ? s_interp_wake.sum / s_interp_wake.count : 0.0, s_interp_wake.high, - s_interp_wait.count ? s_interp_wait.sum / s_interp_wait.count : 0.0, s_interp_wait.high, - s_interp_draw.count ? s_interp_draw.sum / s_interp_draw.count : 0.0, s_interp_draw.high); - memset(&s_interp_wake, 0, sizeof(s_interp_wake)); - memset(&s_interp_wait, 0, sizeof(s_interp_wait)); - memset(&s_interp_draw, 0, sizeof(s_interp_draw)); - memset(&s_interp_hold, 0, sizeof(s_interp_hold)); - memset(&s_interp_work, 0, sizeof(s_interp_work)); - memset(&s_interp_gap, 0, sizeof(s_interp_gap)); - memset(&s_interp_step, 0, sizeof(s_interp_step)); - s_interp_holds = s_interp_jumps = s_interp_late = 0; - s_interp_settle = 8; /* the report itself delays the next presents */ - } - if (s_interp_blend_mode == 2) { - fprintf(stdout, "psxrecomp: GL geometry interpolation: %llu scenes, " - "%llu redraws, %llu fallback captures, %d/%d matched world triangles\n", - (unsigned long long)s_temp_published, (unsigned long long)s_temp_replays, - (unsigned long long)s_temp_fallbacks, s_temp_curr.matched, s_temp_curr.world); - fprintf(stdout, "psxrecomp: GL geometry mesh: %d moving triangles, " - "%d seeded / %d shared vertices, %d propagated corners, " - "%d conflicts, %d held vertices, %d anchored junctions, " - "camera %d of %d corners, %d reprojected vertices, %d departed faces, " - "%d welded corners, %d twin faces\n", - s_temp_curr.moving, s_temp_curr.mesh_stats.seeded_vertices, - s_temp_curr.mesh_stats.shared_vertices, - s_temp_curr.mesh_stats.propagated_vertices, - s_temp_curr.mesh_stats.conflicting_vertices, - s_temp_curr.mesh_stats.held_vertices, - s_temp_curr.mesh_stats.anchored_junctions, - s_temp_curr.mesh_stats.camera_inliers, - s_temp_curr.mesh_stats.camera_samples, - s_temp_curr.mesh_stats.reprojected_vertices, - s_temp_curr.mesh_stats.departed_faces, - s_temp_curr.mesh_stats.welded_corners, - s_temp_curr.mesh_stats.twin_faces); - } - fflush(stdout); - diag_start = now; - diag_captures = s_interp_captures; - diag_swaps = s_interp_swaps; - } - SDL_UnlockMutex(s_interp_mutex); - if (uncapped && !presented) SDL_Delay(1); - } - p_glBindVertexArray(0); - SDL_GL_MakeCurrent(s_win, NULL); - return 0; -} - -#else -/* Retain the framework ABI while the experimental presenter is compiled out. - * No history, capture, shared context, worker or diagnostic output is created. */ -void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode) { - (void)enabled; (void)target_hz; (void)blend_mode; - fprintf(stdout, "psxrecomp: GL frame interpolation disabled (build; host %.1f Hz)\n", - host_hz); -} -void gl_renderer_set_interpolation_suspended(int suspended) { (void)suspended; } -void gl_renderer_set_hidden_rows(int rows) { (void)rows; } -void gl_renderer_set_host_ui_suspended(int suspended) { (void)suspended; } -void gl_renderer_interpolation_diag(int *enabled, int *suspended, - int *history_frames, double *host_hz, - double *target_hz, uint64_t *swaps) { - if (enabled) *enabled = 0; - if (suspended) *suspended = 0; - if (history_frames) *history_frames = 0; - if (host_hz) *host_hz = 0.0; - if (target_hz) *target_hz = 0.0; - if (swaps) *swaps = 0; -} -void gl_renderer_geometry_interpolation_diag(uint64_t out[6]) { - if (out) memset(out, 0, 6 * sizeof(*out)); -} -#endif - -static void present_target_quad(GLuint tex, int tex_w, int tex_h, - int x, int y, int w, int h, int linear, - int lx, int ly, int lw, int lh) { - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); - glViewport(lx, ly, lw, lh); - p_glActiveTexture(PSXGL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - p_glUseProgram(s_present_prog); - p_glUniform1i(s_present_uTex, 0); - /* Half-texel inset: with GL_LINEAR, corner-mapped UVs make the outermost - * dest pixels blend the border texel with VRAM outside the content rect - * (visible edge stripe with AA on). Center-mapped UVs keep edge samples - * inside the rect; interior sampling is unchanged. */ - p_glUniform4f(s_present_uUvRect, - ((float)x + 0.5f) / (float)tex_w, ((float)y + 0.5f) / (float)tex_h, - ((float)(x + w) - 0.5f) / (float)tex_w, ((float)(y + h) - 0.5f) / (float)tex_h); - p_glBindVertexArray(s_present_vao); - glDrawArrays(GL_TRIANGLES, 0, 3); - p_glBindVertexArray(0); - p_glUseProgram(0); -} - -void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, - int force_4_3) { - if (!s_ctx || !s_raster_ok) return; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - if (s_force_present_remaining <= 0 && - s_last_present_path == GL_PRES_VRAM && - s_last_dx == disp_x && s_last_dy == disp_y && - s_last_dw == w && s_last_dh == h && - !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && - !psx_host_ui_gl_visible()) { - gl_perf_present_enter(); - gl_perf_present_exit(0); - return; - } - gl_perf_present_enter(); /* per-frame backdrop-phase reset + dbg snapshot live in here */ - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - int lx, ly, lw, lh; - if (force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - /* No short-band adjustment on the 15-bit FBO path: a game's native short - * display mode (e.g. 216/224-line menus) must fill the rect as before. - * The FMV band fix lives in the depth24/CPU present paths only. */ - - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); - } - int interp_pair = interp_capture(s_hr_fbo, disp_x, disp_y, w, h, - linear, force_4_3, GL_PRES_VRAM); - if (interp_pair) { - gl_perf_present_exit(0); - present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_VRAM; - s_last_dx = disp_x; s_last_dy = disp_y; s_last_dw = w; s_last_dh = h; - return; - } - present_target_quad(s_hr_tex, VRAM_W, VRAM_H, - disp_x, disp_y, w, h, linear, lx, ly, lw, lh); - pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - gl_perf_present_exit(0); - present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_VRAM; - s_last_dx = disp_x; s_last_dy = disp_y; s_last_dw = w; s_last_dh = h; - coh_record(GL_COH_PRESENT, disp_x, disp_y, disp_x + w - 1, disp_y + h - 1); -} - -/* Native-wide fast path: authoritatively copy the canonical 4:3 framebuffer - * into the wide surface's CENTRE columns [g_wide_off, g_wide_off+native_w) for - * the displayed Y band, so the per-prim mirror could skip every centre-only - * prim (the dominant native-wide GPU saving). The reveal margins were already - * produced by the mirror; this leaves them untouched. One FBO->FBO blit, - * x-translated by the reveal offset. No-op when s_wide_fast is off (then the - * mirror drew the full surface, as before). Shared by both present paths. */ -static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { - /* The correction surface is already a complete full mirror. Copying the - * canonical centre here would erase its subpixel/yaw-corrected gameplay. */ - if (!s_wide_fast || s_geometry_correction || g_wide_w <= 0) return; - int native_w = g_wide_w - 2 * g_wide_off; - if (native_w <= 0) return; - int S = s_scale; - (void)disp_y; (void)disp_h; - /* Copy the canonical framebuffer column into the wide surface CENTRE over the - * FULL VRAM height, not just the current display band [disp_y, disp_y+disp_h]. - * The wide surface holds BOTH vertical double-buffer bands (Ape flips - * display_y 0<->256), and the game's draw area / display band can differ per - * scene (the cityscape intro exposed rows outside disp_h). Copying the whole - * column makes the wide CENTRE bit-identical to what the full mirror would - * have drawn there for every band, so nothing the present reads is ever left - * black. The margins (x outside the centre) are untouched. */ - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, wide_fbo); - glDisable(GL_SCISSOR_TEST); - p_glBlitFramebuffer(base_x * S, 0, - (base_x + native_w) * S, VRAM_H * S, - g_wide_off * S, 0, - (g_wide_off + native_w) * S, VRAM_H * S, - GL_COLOR_BUFFER_BIT, GL_NEAREST); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); -} - -/* GPU-direct native-wide present: blit the displayed buffer's wide FBO straight - * to the window (no glReadPixels / glFinish CPU round-trip). The wide surface is - * g_wide_w wide × VRAM_H tall (at scale S); present its [0,g_wide_w] × [disp_y, - * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y - * bottom-origin → window top). Returns 0 if there's no wide surface for base_x - * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ -/* The wide surface shaded by its own depth. 0: nothing drawn, the plain present follows. */ -static int ssao_present(GLuint fbo, GLuint tex, int y, int h, int linear, int lx, int ly, int lw, int lh) { - if (!ssao_draw(&s_ssao_main, s_present_vao, fbo, 0, tex, g_wide_w * s_scale, VRAM_H * s_scale, - 0.5f / (float)g_wide_w, ((float)y + 0.5f) / (float)VRAM_H, - ((float)g_wide_w - 0.5f) / (float)g_wide_w, ((float)(y + h) - 0.5f) / (float)VRAM_H, - s_scale, linear, lx, ly, lw, lh)) - return 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] == fbo) s_wide_depth_shown[i] |= wide_depth_rows(y, h); - return 1; -} - -void gl_renderer_set_mesh_view(int enabled) { s_mesh_view = enabled != 0; } -int gl_renderer_mesh_view(void) { return s_mesh_view; } -void gl_renderer_set_full_yaw(int enabled) { - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - if ((enabled != 0) != s_geometry_full_yaw) interp_reset_history(); - s_geometry_full_yaw = enabled != 0; -} -void gl_renderer_set_gap_fill(int enabled) { - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - const float width = enabled ? s_crack_width : 0.0f; - if ((enabled != 0) != s_grown_under || width != s_crack_fill) interp_reset_history(); - if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - s_grown_under = enabled != 0; - s_crack_fill = width; -} -int gl_renderer_gap_fill(void) { return s_grown_under || s_crack_fill > 0.0f; } - -void gl_renderer_set_world_depth(int enabled) { - if (s_raster_ok) { flush_flat_batch(); flush_tex_batch(); } - if ((enabled != 0) != s_world_depth) interp_reset_history(); - if (enabled && !mirror_depth()) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - s_world_depth = enabled != 0; -} -int gl_renderer_world_depth(void) { return s_world_depth; } - -static void ssao_mode(void) { - const int mode = s_ssao_view ? s_ssao_view + 1 : s_ssao_on; - if (mode && !s_ssao) memset(s_wide_depth_shown, 0xFF, sizeof s_wide_depth_shown); - s_ssao = mode; -} -void gl_renderer_set_ssao(int enabled) { - s_ssao_on = enabled != 0; - ssao_mode(); -} -void gl_renderer_set_ssao_view(int view) { - s_ssao_view = view >= 1 && view <= 4 ? view : 0; - ssao_mode(); -} -int gl_renderer_ssao_view(void) { return s_ssao_view; } -float gl_renderer_ssao_tilt(void) { return s_ssao_tilt; } -void gl_renderer_set_ssao_tilt(float tilt) { - if (tilt >= 0.0f && tilt < 0.95f) s_ssao_tilt = tilt; -} -void gl_renderer_set_ssao_shape(float radius, float strength) { - if (radius > 0.0f) s_ssao_radius = radius; - if (strength >= 0.0f) s_ssao_strength = strength; -} -float gl_renderer_ssao_radius(void) { return s_ssao_radius; } -/* The camera depths between which the game fades its world out: the shading goes with it. */ -void gl_renderer_set_ssao_fade(float from, float to) { s_ssao_fade_from = from; s_ssao_fade_to = to; } -float gl_renderer_ssao_strength(void) { return s_ssao_strength; } -int gl_renderer_ssao(void) { return s_ssao_on; } - -int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { - if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; - GLuint fbo = 0, tex = 0; - for (int i = 0; i < WIDE_MAX_SURF; i++) - if (s_wide_fbo[i] && s_wide_base[i] == disp_x) { - fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; break; - } - if (!fbo) return 0; - flush_flat_batch(); - flush_tex_batch(); - flush_cpu_upload(); - if (s_force_present_remaining <= 0 && - s_last_present_path == GL_PRES_WIDE && - s_last_dx == disp_x && s_last_dy == disp_y && - s_last_dw == g_wide_w && s_last_dh == disp_h && - !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && - !psx_host_ui_gl_visible()) { - gl_perf_present_enter(); - gl_perf_present_exit(1); - return 1; - } - gl_perf_present_enter(); - int ww = 0, wh = 0; gl_visible_size(&ww, &wh); - int lx, ly, lw, lh; - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ - - p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); - } - int interp_pair = interp_capture(fbo, 0, disp_y, g_wide_w, disp_h, - linear, 0, GL_PRES_WIDE); - if (interp_pair) { - gl_perf_present_exit(1); - present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_WIDE; - s_last_dx = disp_x; s_last_dy = disp_y; - s_last_dw = g_wide_w; s_last_dh = disp_h; - return 1; - } - wide_depth_shown(fbo, disp_y, disp_h); - if (!ssao_present(fbo, tex, disp_y, disp_h, linear, lx, ly, lw, lh)) - present_target_quad(tex, g_wide_w, VRAM_H, - 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh); - pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); - render_host_ui_for_swap(ww, wh); - shown_capture(0, 1.0f); - latency_ring_mark(LAT_SWAP_BEGIN); - SDL_GL_SwapWindow(s_win); - latency_ring_mark(LAT_SWAP_END); - gl_perf_present_exit(1); - present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); - present_force_consumed(); - s_last_present_path = GL_PRES_WIDE; - s_last_dx = disp_x; s_last_dy = disp_y; s_last_dw = g_wide_w; s_last_dh = disp_h; - coh_record(GL_COH_PRESENT, 0, disp_y, g_wide_w - 1, disp_y + disp_h - 1); - return 1; -} - -static const GpuRenderBackend GL_BACKEND = { - .name = "opengl", - .init = glb_init, .set_scale = glb_set_scale, .scale = glb_scale, - .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, - .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, - .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, - .set_geometry_correction = glb_set_geometry_correction, - .set_world_triangle = glb_set_world_triangle, - .set_precise_triangle = glb_set_precise_triangle, - .set_unpinned_triangle = glb_set_unpinned_triangle, - .set_perspective_triangle = glb_set_perspective_triangle, -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - .set_temporal_depth_triangle = glb_set_temporal_depth_triangle, - .set_surface_depth_triangle = glb_set_surface_depth_triangle, - .set_temporal_identity_triangle = glb_set_temporal_identity_triangle, - .set_temporal_sprite = glb_set_temporal_sprite, -#else - .set_temporal_depth_triangle = NULL, - .set_temporal_identity_triangle = NULL, -#endif - .set_presentation_yaw = glb_set_presentation_yaw, - .fill_rect = glb_fill_rect, .copy_rect = glb_copy_rect, - .draw_flat_triangle = glb_draw_flat_triangle, .draw_gouraud_triangle = glb_draw_gouraud_triangle, - .draw_textured_triangle = glb_draw_textured_triangle, - .draw_shaded_textured_triangle = glb_draw_shaded_textured_triangle, - .draw_flat_rect = glb_draw_flat_rect, .draw_textured_rect = glb_draw_textured_rect, - .draw_textured_rect_scaled = glb_draw_textured_rect_scaled, - .draw_line = glb_draw_line, .draw_shaded_line = glb_draw_shaded_line, - .render_display = glb_render_display, .render_display_hires = glb_render_display_hires, - .vram_write = glb_vram_write, .vram_read = glb_vram_read, - .vram_transfer_in = glb_vram_transfer_in, .vram_transfer_out = glb_vram_transfer_out, - .set_draw_area = glb_set_draw_area, .get_draw_area = glb_get_draw_area, - .set_draw_offset = glb_set_draw_offset, - .wide_configure = glb_wide_configure, - .wide_set_target = glb_wide_set_target, - .wide_disable_target = glb_wide_disable_target, - .wide_clear = glb_wide_clear, - .wide_clear_margins = glb_wide_clear_margins, - .render_wide_display = glb_render_wide_display, - .wide_dump_full = glb_wide_dump_full, -}; - -const GpuRenderBackend *gl_backend_get(void) { return &GL_BACKEND; } diff --git a/psxrecomp-overlay/runtime/src/gpu_render.c b/psxrecomp-overlay/runtime/src/gpu_render.c deleted file mode 100644 index a6b96fe..0000000 --- a/psxrecomp-overlay/runtime/src/gpu_render.c +++ /dev/null @@ -1,232 +0,0 @@ -/* gpu_render.c — renderer facade / backend dispatch. - * - * gpu.c and main.cpp call the gr_* functions; this file forwards each to the - * selected backend's vtable. The software rasterizer is the default and the - * fallback. The OpenGL backend (gpu_gl_renderer.c) is selected via - * gr_set_backend(GR_BACKEND_OPENGL) and supplies its table through - * gl_backend_get(); if that returns NULL (GL unavailable / init failed) we - * stay on software so a misconfigured [video] renderer never bricks boot. - * - * The software backend table points straight at the existing sw_* functions, - * so the software path is byte-for-byte unchanged. */ - -#include "gpu_render.h" -#include "gpu_sw_renderer.h" -#include - -static const GpuRenderBackend SW_BACKEND = { - .name = "software", - .init = sw_renderer_init, - .set_scale = sw_renderer_set_scale, - .scale = sw_renderer_scale, - .set_texture_filter = sw_set_texture_filter, - .texture_filter = sw_texture_filter, - .set_semi_transparency = sw_set_semi_transparency, - .set_mask_bits = sw_set_mask_bits, - .set_texture_window = sw_set_texture_window, - .set_color_modulation = sw_set_color_modulation, - .set_geometry_correction = NULL, - .set_world_triangle = NULL, - .set_precise_triangle = sw_set_precise_triangle, - .set_perspective_triangle = NULL, - .set_temporal_depth_triangle = NULL, - .set_temporal_identity_triangle = NULL, - .set_presentation_yaw = NULL, - .fill_rect = sw_fill_rect, - .copy_rect = sw_copy_rect, - .draw_flat_triangle = sw_draw_flat_triangle, - .draw_gouraud_triangle = sw_draw_gouraud_triangle, - .draw_textured_triangle = sw_draw_textured_triangle, - .draw_shaded_textured_triangle = sw_draw_shaded_textured_triangle, - .draw_flat_rect = sw_draw_flat_rect, - .draw_textured_rect = sw_draw_textured_rect, - .draw_textured_rect_scaled = sw_draw_textured_rect_scaled, - .draw_line = sw_draw_line, - .draw_shaded_line = sw_draw_shaded_line, - .render_display = sw_render_display, - .render_display_hires = sw_render_display_hires, - .vram_write = sw_vram_write, - .vram_read = sw_vram_read, - .vram_transfer_in = sw_vram_transfer_in, - .vram_transfer_out = sw_vram_transfer_out, - .set_draw_area = sw_set_draw_area, - .get_draw_area = sw_get_draw_area, - .set_draw_offset = sw_set_draw_offset, - .wide_configure = sw_wide_configure, - .wide_set_target = sw_wide_set_target, - .wide_disable_target = sw_wide_disable_target, - .wide_clear = sw_wide_clear, - .wide_clear_margins = sw_wide_clear_margins, - .render_wide_display = sw_render_wide_display, - .wide_dump_full = sw_wide_dump_full, -}; - -/* Supplied by gpu_gl_renderer.c; returns NULL until the GL backend is ready. */ -extern const GpuRenderBackend *gl_backend_get(void); -/* Supplied by gpu_vk_renderer.c; returns NULL until the Vulkan backend is - * ready (init succeeded). When Vulkan isn't compiled in it returns NULL. */ -extern const GpuRenderBackend *vk_backend_get(void); - -static const GpuRenderBackend *g_b = &SW_BACKEND; -static GrBackend g_effective = GR_BACKEND_SOFTWARE; - -void gr_set_backend(GrBackend backend) { - if (backend == GR_BACKEND_OPENGL) { - const GpuRenderBackend *gl = gl_backend_get(); - if (gl) { - g_b = gl; - g_effective = GR_BACKEND_OPENGL; - fprintf(stdout, "psxrecomp: renderer = opengl (%s)\n", gl->name); - return; - } - fprintf(stdout, "psxrecomp: renderer = opengl requested but unavailable " - "— falling back to software\n"); - } else if (backend == GR_BACKEND_VULKAN) { - const GpuRenderBackend *vk = vk_backend_get(); - if (vk) { - g_b = vk; - g_effective = GR_BACKEND_VULKAN; - fprintf(stdout, "psxrecomp: renderer = vulkan (%s)\n", vk->name); - return; - } - fprintf(stdout, "psxrecomp: renderer = vulkan requested but unavailable " - "— falling back to software\n"); - } - g_b = &SW_BACKEND; - g_effective = GR_BACKEND_SOFTWARE; -} - -GrBackend gr_backend(void) { return g_effective; } - -/* ---- Dispatch wrappers (one line each; forward to the active backend) ---- */ -void gr_init(uint16_t *vram) { g_b->init(vram); } -void gr_set_scale(int scale) { g_b->set_scale(scale); } -int gr_scale(void) { return g_b->scale(); } -void gr_set_texture_filter(int bilinear) { g_b->set_texture_filter(bilinear); } -int gr_texture_filter(void) { return g_b->texture_filter(); } -void gr_set_semi_transparency(int e, int m) { g_b->set_semi_transparency(e, m); } -void gr_set_mask_bits(int s, int c) { g_b->set_mask_bits(s, c); } -void gr_set_texture_window(uint32_t raw) { g_b->set_texture_window(raw); } -void gr_set_color_modulation(int r, int g, int b, int raw) { g_b->set_color_modulation(r, g, b, raw); } -void gr_set_geometry_correction(int enabled) { - if (g_b->set_geometry_correction) g_b->set_geometry_correction(enabled); -} -void gr_set_world_triangle(int world) { - if (g_b->set_world_triangle) g_b->set_world_triangle(world); -} -void gr_set_precise_triangle(int enabled, - int32_t x0, int32_t y0, - int32_t x1, int32_t y1, - int32_t x2, int32_t y2) { - if (g_b->set_precise_triangle) - g_b->set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); -} -void gr_set_unpinned_triangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1, int32_t x2, int32_t y2) { - if (g_b->set_unpinned_triangle) g_b->set_unpinned_triangle(x0, y0, x1, y1, x2, y2); -} -void gr_set_perspective_triangle(int enabled, - float q0, float q1, float q2) { - if (g_b->set_perspective_triangle) - g_b->set_perspective_triangle(enabled, q0, q1, q2); -} -void gr_set_temporal_depth_triangle(int enabled, - float z0, float z1, float z2) { - if (g_b->set_temporal_depth_triangle) - g_b->set_temporal_depth_triangle(enabled, z0, z1, z2); -} -void gr_set_surface_depth_triangle(float z0, float z1, float z2) { - if (g_b->set_surface_depth_triangle) g_b->set_surface_depth_triangle(z0, z1, z2); -} -void gr_set_temporal_identity_triangle(uint64_t id0, uint64_t id1, uint64_t id2) { - if (g_b->set_temporal_identity_triangle) - g_b->set_temporal_identity_triangle(id0, id1, id2); -} -void gr_set_temporal_sprite(float depth, const float sides[4]) { - if (g_b->set_temporal_sprite) g_b->set_temporal_sprite(depth, sides); -} -void gr_set_presentation_yaw(double yaw_units, double full_turn, - double center_x, double center_y, - double focal_x) { - if (g_b->set_presentation_yaw) - g_b->set_presentation_yaw(yaw_units, full_turn, - center_x, center_y, focal_x); -} -void gr_fill_rect(int x, int y, int w, int h, uint16_t c) { g_b->fill_rect(x, y, w, h, c); } -void gr_copy_rect(int sx, int sy, int dx, int dy, int w, int h) { g_b->copy_rect(sx, sy, dx, dy, w, h); } -void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, uint16_t c) { - g_b->draw_flat_triangle(x0, y0, x1, y1, x2, y2, c); -} -void gr_draw_gouraud_triangle(int x0, int y0, uint16_t c0, int x1, int y1, uint16_t c1, - int x2, int y2, uint16_t c2) { - g_b->draw_gouraud_triangle(x0, y0, c0, x1, y1, c1, x2, y2, c2); -} -void gr_draw_textured_triangle(int x0, int y0, int u0, int v0, int x1, int y1, int u1, int v1, - int x2, int y2, int u2, int v2, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { - g_b->draw_textured_triangle(x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, - clut_x, clut_y, texpage); -} -void gr_draw_shaded_textured_triangle(int x0, int y0, int u0, int v0, uint32_t c0, - int x1, int y1, int u1, int v1, uint32_t c1, - int x2, int y2, int u2, int v2, uint32_t c2, - uint16_t clut_x, uint16_t clut_y, - uint16_t texpage, int raw) { - g_b->draw_shaded_textured_triangle(x0, y0, u0, v0, c0, x1, y1, u1, v1, c1, - x2, y2, u2, v2, c2, clut_x, clut_y, texpage, raw); -} -void gr_draw_flat_rect(int x, int y, int w, int h, uint16_t c) { g_b->draw_flat_rect(x, y, w, h, c); } -void gr_draw_textured_rect(int x, int y, int w, int h, int u, int v, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { - g_b->draw_textured_rect(x, y, w, h, u, v, clut_x, clut_y, texpage); -} -void gr_draw_textured_rect_scaled(int x, int y, int w, int h, int u0, int v0, int u1, int v1, - uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { - g_b->draw_textured_rect_scaled(x, y, w, h, u0, v0, u1, v1, clut_x, clut_y, texpage); -} -void gr_draw_line(int x0, int y0, int x1, int y1, uint16_t c) { g_b->draw_line(x0, y0, x1, y1, c); } -void gr_draw_shaded_line(int x0, int y0, uint16_t c0, int x1, int y1, uint16_t c1) { - g_b->draw_shaded_line(x0, y0, c0, x1, y1, c1); -} -int gr_render_display(uint32_t *o, int p, int dx, int dy, int dw, int dh) { - return g_b->render_display(o, p, dx, dy, dw, dh); -} -int gr_render_display_hires(uint32_t *o, int p, int dx, int dy, int dw, int dh) { - return g_b->render_display_hires(o, p, dx, dy, dw, dh); -} -void gr_vram_write(int x, int y, uint16_t pixel) { g_b->vram_write(x, y, pixel); } -uint16_t gr_vram_read(int x, int y) { return g_b->vram_read(x, y); } -void gr_vram_transfer_in(int x, int y, int w, int h, const uint16_t *d) { g_b->vram_transfer_in(x, y, w, h, d); } -void gr_vram_transfer_out(int x, int y, int w, int h, uint16_t *d) { g_b->vram_transfer_out(x, y, w, h, d); } -void gr_set_draw_area(int x1, int y1, int x2, int y2){ g_b->set_draw_area(x1, y1, x2, y2); } -void gr_get_draw_area(int *x1, int *y1, int *x2, int *y2) { g_b->get_draw_area(x1, y1, x2, y2); } -void gr_set_draw_offset(int x, int y) { g_b->set_draw_offset(x, y); } - -/* Native-wide compositor — present only on backends that supply it. */ -int gr_wide_supported(void) { return g_b->render_wide_display != 0; } -void gr_wide_configure(int wide_w, int offset) { - if (g_b->wide_configure) g_b->wide_configure(wide_w, offset); -} -void gr_wide_set_target(int base_x) { - if (g_b->wide_set_target) g_b->wide_set_target(base_x); -} -void gr_wide_disable_target(void) { - if (g_b->wide_disable_target) g_b->wide_disable_target(); -} -void gr_wide_clear(int base_x, int y, int h, uint16_t color) { - if (g_b->wide_clear) g_b->wide_clear(base_x, y, h, color); -} -void gr_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { - if (g_b->wide_clear_margins) - g_b->wide_clear_margins(base_x, y, h, color, sides); -} -int gr_render_wide_display(uint32_t *out, int pitch, int base_x, - int disp_y, int disp_h) { - if (g_b->render_wide_display) - return g_b->render_wide_display(out, pitch, base_x, disp_y, disp_h); - return 0; -} -int gr_wide_dump_full(uint32_t *out, int cap_pixels, int *ow, int *oh, int base_x) { - if (g_b->wide_dump_full) - return g_b->wide_dump_full(out, cap_pixels, ow, oh, base_x); - return 0; -} diff --git a/psxrecomp-overlay/runtime/src/gte.cpp b/psxrecomp-overlay/runtime/src/gte.cpp deleted file mode 100644 index 163280c..0000000 --- a/psxrecomp-overlay/runtime/src/gte.cpp +++ /dev/null @@ -1,2841 +0,0 @@ -#include "gte.h" -#include "cpu_state.h" -#include "gte_precision.h" -#include "mod_memory.h" -#include -#include -#include -#include - -namespace PSXRecomp { -namespace GTE { - -// --------------------------------------------------------------------------- -// Common helpers -// --------------------------------------------------------------------------- - -// Perspective division: H / SZ3, scaled, saturated to 17 bits. -// -// The real PS1 GTE does NOT compute an exact H*0x20000/SZ3. It uses an -// Unsigned Newton-Raphson (UNR) reciprocal approximation driven by a 257-entry -// seed table (documented in PSX-SPX "GTE Division Inaccuracy"; identical to the -// mednafen/Beetle oracle in beetle-psx/mednafen/psx/gte.cpp). Exact division -// diverges from hardware by +/-1 (occasionally up to a few units) on ~25% of -// inputs, which is enough to flip games' distance/intensity threshold branches -// (e.g. Ape Escape's additive-glow CLUT semi-transparency bit). This is the -// faithful hardware algorithm, shared by every RTPS/RTPT caller. -static uint8_t s_gte_div_table[0x101]; -static bool s_gte_div_table_init = false; -static void gte_init_div_table() { - for (uint32_t divisor = 0x8000; divisor < 0x10000; divisor += 0x80) { - uint32_t xa = 512; - for (unsigned i = 1; i < 5; i++) - xa = (xa * (1024u * 512u - ((divisor >> 7) * xa))) >> 18; - s_gte_div_table[(divisor >> 7) & 0xFF] = - (uint8_t)(((xa + 1) >> 1) - 0x101); - } - s_gte_div_table[0x100] = s_gte_div_table[0xFF]; - s_gte_div_table_init = true; -} -static int32_t gte_calc_recip(uint16_t divisor) { - int32_t x = 0x101 + s_gte_div_table[(((divisor & 0x7FFF) + 0x40) >> 7)]; - int32_t tmp = (((int32_t)divisor * -x) + 0x80) >> 8; - int32_t tmp2 = ((x * (131072 + tmp)) + 0x80) >> 8; - return tmp2; -} -// count leading zeros of a 16-bit value (0 -> 16) -static inline unsigned gte_clz16(uint16_t v) { - unsigned n = 0; - for (int b = 15; b >= 0; --b) { if (v & (1u << b)) break; ++n; } - return n; -} -static int32_t gte_divide(uint16_t H, uint16_t SZ3, uint32_t& FLAG) { - if (!s_gte_div_table_init) gte_init_div_table(); - // Hardware: overflow flag + saturate when 2*SZ3 <= H (includes SZ3 == 0). - if ((uint32_t)SZ3 * 2 <= (uint32_t)H) { - FLAG |= FLAG_DIV_OVF; - return 0x1FFFF; - } - unsigned shift_bias = gte_clz16(SZ3); - uint32_t dividend = (uint32_t)H << shift_bias; - uint32_t divisor = (uint32_t)SZ3 << shift_bias; - uint32_t result = (uint32_t)(((uint64_t)dividend * - (uint32_t)gte_calc_recip((uint16_t)(divisor | 0x8000)) - + 32768) >> 16); - if (result > 0x1FFFF) result = 0x1FFFF; // 17-bit saturate (no flag; matches hw path) - return (int32_t)result; -} - -// lm (bit 10) selects the lower clamp bound of every IR write in the -// lighting/depth-cue ops: 0 → -0x8000, 1 → 0. sf (bit 19) selects the -// accumulator shift. Hardware takes BOTH from the instruction word; these -// helpers used to hardcode lm=1, which zeroed every negative component — -// Crash Bash lerps signed VERTICES through INTPL (lm=0), so its characters -// collapsed onto their anchor point on every tween frame (menu strobe bug). -static inline bool gte_instr_lm(uint32_t instr) { return (instr & (1u << 10)) != 0; } -static inline int gte_instr_sf(uint32_t instr) { return (instr & (1u << 19)) ? 12 : 0; } - -// Transform a vertex by Light matrix → IR1/IR2/IR3 (step 1 of lighting) -static void light_transform(GTEState* gte, int16_t* V, uint32_t instr) { - const bool lm = gte_instr_lm(instr); - int64_t mac1 = ((int64_t)gte->L[0][0] * V[0] + - (int64_t)gte->L[0][1] * V[1] + - (int64_t)gte->L[0][2] * V[2]) >> 12; - int64_t mac2 = ((int64_t)gte->L[1][0] * V[0] + - (int64_t)gte->L[1][1] * V[1] + - (int64_t)gte->L[1][2] * V[2]) >> 12; - int64_t mac3 = ((int64_t)gte->L[2][0] * V[0] + - (int64_t)gte->L[2][1] * V[1] + - (int64_t)gte->L[2][2] * V[2]) >> 12; - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); -} - -// Apply light color matrix + background color to IR → IR (step 2 of lighting) -static void light_color(GTEState* gte, uint32_t instr) { - const bool lm = gte_instr_lm(instr); - int64_t mac1 = (int64_t)gte->BK[0] * 4096 + - (int64_t)gte->LC[0][0] * gte->IR1 + - (int64_t)gte->LC[0][1] * gte->IR2 + - (int64_t)gte->LC[0][2] * gte->IR3; - int64_t mac2 = (int64_t)gte->BK[1] * 4096 + - (int64_t)gte->LC[1][0] * gte->IR1 + - (int64_t)gte->LC[1][1] * gte->IR2 + - (int64_t)gte->LC[1][2] * gte->IR3; - int64_t mac3 = (int64_t)gte->BK[2] * 4096 + - (int64_t)gte->LC[2][0] * gte->IR1 + - (int64_t)gte->LC[2][1] * gte->IR2 + - (int64_t)gte->LC[2][2] * gte->IR3; - gte->MAC1 = static_cast(mac1 >> 12); - gte->MAC2 = static_cast(mac2 >> 12); - gte->MAC3 = static_cast(mac3 >> 12); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); -} - -// Multiply IR by RGBC color and push to RGB FIFO (step 3 of color output) -static void color_output(GTEState* gte, uint32_t instr) { - const bool lm = gte_instr_lm(instr); - uint8_t r0 = (gte->RGBC >> 0) & 0xFF; - uint8_t g0 = (gte->RGBC >> 8) & 0xFF; - uint8_t b0 = (gte->RGBC >> 16) & 0xFF; - int64_t mac1 = (int64_t)r0 * gte->IR1 * 16; - int64_t mac2 = (int64_t)g0 * gte->IR2 * 16; - int64_t mac3 = (int64_t)b0 * gte->IR3 * 16; - gte->MAC1 = static_cast(mac1 >> 12); - gte->MAC2 = static_cast(mac2 >> 12); - gte->MAC3 = static_cast(mac3 >> 12); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); -} - -// Depth cue / interpolate current IR toward the far color using IR0 -// (common tail of DPCS/DPCT/DPCL/INTPL/NCDS/NCDT/CDP). Hardware: -// base = IR << 12 -// step = lim(((FC << 12) - base) >> (sf*12)) ; ±0x8000 clamp, lm FORCED off -// MAC = (base + IR0 * step) >> (sf*12) -// IR = lim(MAC, lm) ; lm from the instruction -static void depth_cue_from_ir(GTEState* gte, uint32_t instr) { - const int shift = gte_instr_sf(instr); - const bool lm = gte_instr_lm(instr); - int64_t base1 = (int64_t)gte->IR1 * 4096; - int64_t base2 = (int64_t)gte->IR2 * 4096; - int64_t base3 = (int64_t)gte->IR3 * 4096; - int16_t step1 = gte->saturate_ir( - (int32_t)((((int64_t)gte->FC[0] * 4096 - base1) >> shift)), 1, false); - int16_t step2 = gte->saturate_ir( - (int32_t)((((int64_t)gte->FC[1] * 4096 - base2) >> shift)), 2, false); - int16_t step3 = gte->saturate_ir( - (int32_t)((((int64_t)gte->FC[2] * 4096 - base3) >> shift)), 3, false); - int64_t mac1 = (base1 + (int64_t)gte->IR0 * step1) >> shift; - int64_t mac2 = (base2 + (int64_t)gte->IR0 * step2) >> shift; - int64_t mac3 = (base3 + (int64_t)gte->IR0 * step3) >> shift; - gte->check_mac_overflow(mac1, 1); - gte->check_mac_overflow(mac2, 2); - gte->check_mac_overflow(mac3, 3); - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); -} - -// --------------------------------------------------------------------------- -// Widescreen X-squash (verified-enhancement, default off = identity). -// -// For a display aspect wider than the native 4:3, screen-space X is scaled by -// (4*den)/(3*num) around the projection centre OFX — e.g. 3/4 for 16:9 — and -// the present path stretches the 4:3 frame to the wide aspect, netting a wider -// horizontal field of view (the DuckStation/Beetle "widescreen hack", but -// applied in our GTE library so every RTPS/RTPT caller — generated code, -// interpreter, overlay DLLs — sees it). Only the IR1*h/sz term is scaled, NOT -// OFX, so the squash is centred on the game's own projection centre and -// games' post-projection screen-bounds culls (which read SXY back from us) -// stay aligned with the visible frame. -// --------------------------------------------------------------------------- -static int32_t s_ws_xnum = 1, s_ws_xden = 1; -extern "C" int gpu_ws_present_native_43(void); /* gpu.c — suppress on 4:3 frames */ -static bool ws_x_squashed() { return s_ws_xnum != s_ws_xden && !gpu_ws_present_native_43(); } -/* The ratio RTPS scales a projected X by on this frame, 1/1 when it leaves X alone. */ -extern "C" void gte_ws_x_squash(int32_t* num, int32_t* den) { - const bool squashed = ws_x_squashed(); - *num = squashed ? s_ws_xnum : 1; - *den = squashed ? s_ws_xden : 1; -} -extern "C" void psx_ws_note_gte_project(int nverts); /* gpu.c — gte_game_mode stamp */ -static int s_gte_replay_sandbox = 0; - -/* Geometry presentation is enabled here, but vertex recovery is deliberately - * owned by the exact address-keyed precision store below. Test 11 also kept a - * cache keyed only by rounded SXY; that key is ambiguous and is permanently - * disabled. Keep a generation counter solely for state/test ABI compatibility - * with checkpoints that predate the removal of that cache. */ -static uint32_t s_geom_generation = 1; -static int s_geom_enabled = 0; - -/* The pinned framework's register test predates Test 12 and explicitly seeds - * the removed cache to verify generation wrap. Preserve that fixture only in - * the test binary; no production build contains or writes this storage. */ -#ifdef PSX_GTE_REGISTER_TEST -#define GEOM_TEST_CACHE_SIZE 8192u -struct GeomTestVertex { - uint32_t packed; - int32_t x16, y16; - uint32_t generation; -}; -static GeomTestVertex s_geom_test_cache[GEOM_TEST_CACHE_SIZE]; -static uint32_t s_geom_test_hits = 0; - -static inline uint32_t geom_test_hash(uint32_t packed) { - packed ^= packed >> 16; - return (packed * 2654435761u) & (GEOM_TEST_CACHE_SIZE - 1u); -} -#endif - -/* Exact GTE projection provenance for perspective texture correction. SWC2 - * stores are observed directly; reviewed game-specific MFC2 + ordinary-SW - * routes use the same committed-word gate. GPU DMA later supplies that exact - * RAM address, so depth lookup follows the packet rather than guessing from - * rounded X/Y. */ -struct PreciseProjection { - uint32_t packed; - int32_t x16, y16; - uint16_t z; - uint8_t valid; - uint8_t saturated; - uint8_t perspective_valid; - uint8_t clamped; /* packed is what the game pinned the projection to */ - uint8_t unbounded; /* the GTE's divide or screen stopped short, and exact_x16 and exact_y16 hold what an exact divide gives */ - int32_t exact_x16, exact_y16; - uint64_t identity; -}; -static PreciseProjection s_precise_sxy[4] = {}; -/* PSX_GEOMETRY_UNPIN=0 in a build with debug tools leaves pinned corners as the game put them, for comparison. */ -static int s_precision_unpin = -1; -static int precision_unpin() { - if (s_precision_unpin < 0) { - s_precision_unpin = 1; -#ifndef PSX_NO_DEBUG_TOOLS - const char *value = getenv("PSX_GEOMETRY_UNPIN"); - if (value && value[0] == '0') s_precision_unpin = 0; -#endif - } - return s_precision_unpin; -} -/* Half of what a 16.16 coordinate holds: an exact place stays under it with the screen centre in, the drawing offset comes later. */ -constexpr int64_t kUnboundedLimit16 = INT64_C(1) << 30; - -#define PRECISION_STORE_SIZE 65536u -struct PrecisionStoreEntry { - uint32_t addr; - PreciseProjection projection; - uint32_t generation; -}; -static PrecisionStoreEntry s_precision_store[PRECISION_STORE_SIZE]; -static uint32_t s_precision_generation = 1; -static int s_precision_tracking = 0; -static GtePrecisionDiagnostics s_precision_diagnostics = {}; -struct PrecisionWordCommitToken { - uint32_t physical; - uint8_t domain; - uint8_t pending; -}; -static PrecisionWordCommitToken s_precision_word_commit = {}; -enum PrecisionWordDomain : uint8_t { - PRECISION_WORD_DOMAIN_NONE = 0, - PRECISION_WORD_DOMAIN_MAIN_RAM = 1, - PRECISION_WORD_DOMAIN_SCRATCHPAD = 2, - PRECISION_WORD_DOMAIN_GPU_DMA = 3, -}; -#define PRECISION_STORE_PC_ROUTE_MAX 32u -struct PrecisionStorePcRoute { - uint32_t physical_pc; - uint32_t instruction; - uint8_t gte_reg; -}; -static PrecisionStorePcRoute - s_precision_store_pc_routes[PRECISION_STORE_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_store_pc_route_count = 0; -#define PRECISION_SCRATCH_STORE_PC_ROUTE_MAX 8u -struct PrecisionScratchStorePcRoute { - uint32_t physical_pc; - uint32_t instruction; - uint32_t scratch_first; - uint32_t scratch_stride; - uint32_t scratch_count; - uint8_t gte_reg; - uint8_t clamps; - int16_t low_x, high_x, low_y, high_y; -}; -static PrecisionScratchStorePcRoute - s_precision_scratch_store_pc_routes[ - PRECISION_SCRATCH_STORE_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_scratch_store_pc_route_count = 0; -#define PRECISION_MFC2_PC_ROUTE_MAX 8u -struct PrecisionMfc2PcRoute { - uint32_t physical_pc; - uint32_t instruction; - uint8_t gte_reg; -}; -static PrecisionMfc2PcRoute - s_precision_mfc2_pc_routes[PRECISION_MFC2_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_mfc2_pc_route_count = 0; -static PreciseProjection s_precision_mfc2_capture[4] = {}; -#define PRECISION_COPY_PC_ROUTE_MAX 32u -struct PrecisionCopyPcRoute { - uint32_t load_physical_pc; - uint32_t load_instruction; - uint32_t store_physical_pc; - uint32_t store_instruction; - uint8_t gpr; - PreciseProjection capture; -}; -static PrecisionCopyPcRoute - s_precision_copy_pc_routes[PRECISION_COPY_PC_ROUTE_MAX] = {}; -static uint32_t s_precision_copy_pc_route_count = 0; -static PreciseProjection s_speculative_saved_sxy[4]; -static uint32_t s_speculative_depth = 0; -static int s_speculative_timeline_invalidated = 0; - -static void precision_copy_captures_clear(void) { - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) - s_precision_copy_pc_routes[i].capture.valid = 0; -} - -static void gte_precision_generation_advance(void) { - if (++s_precision_generation == 0) { - std::memset(s_precision_store, 0, sizeof(s_precision_store)); - s_precision_generation = 1; - } -} - -static void gte_geom_generation_advance(void) { - if (++s_geom_generation == 0) { -#ifdef PSX_GTE_REGISTER_TEST - std::memset(s_geom_test_cache, 0, sizeof(s_geom_test_cache)); -#endif - s_geom_generation = 1; - } -} - -extern "C" void gte_precision_timeline_invalidate(void) { - for (int i = 0; i < 4; ++i) s_precise_sxy[i].valid = 0; - std::memset(s_precision_mfc2_capture, 0, - sizeof(s_precision_mfc2_capture)); - precision_copy_captures_clear(); - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - /* Raw machine-state restore is authoritative even if host polling reached - * it during a speculative validation pass. Defer the generation advance - * until the outer transaction ends so old provenance cannot be restored. */ - if (s_speculative_depth != 0) { - s_speculative_timeline_invalidated = 1; - return; - } - gte_precision_generation_advance(); - gte_geom_generation_advance(); -} - -extern "C" void gte_precision_speculative_begin(void) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - if (s_speculative_depth++ == 0) { - std::memcpy(s_speculative_saved_sxy, s_precise_sxy, - sizeof(s_precise_sxy)); - s_speculative_timeline_invalidated = 0; - } -} - -extern "C" void gte_precision_speculative_end(void) { - if (s_speculative_depth == 0) return; - if (--s_speculative_depth == 0) { - if (s_speculative_timeline_invalidated) { - gte_precision_generation_advance(); - gte_geom_generation_advance(); - s_speculative_timeline_invalidated = 0; - } else { - std::memcpy(s_precise_sxy, s_speculative_saved_sxy, - sizeof(s_precise_sxy)); - } - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - } -} - -static inline uint32_t precision_hash(uint32_t addr) { - addr >>= 2; - addr ^= addr >> 11; - return (addr * 2654435761u) & (PRECISION_STORE_SIZE - 1u); -} - -static inline int precision_ram_address(uint32_t addr, uint32_t *physical) { - /* Match memory.c: KSEG2/KSEG3 are not translated through the ordinary - * 0x1fffffff mask. Apart from cache control, those accesses are unmapped - * and must never alias a low-RAM provenance entry. */ - if (addr >= 0xC0000000u) return 0; - uint32_t mapped = addr & 0x1FFFFFFFu; - if (mapped >= 0x00800000u) { - if (mapped < PSX_MOD_GPU_DMA_APERTURE_BASE || - mapped > PSX_MOD_GPU_DMA_APERTURE_BASE + - PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u) return 0; - *physical = mapped & ~3u; - return 1; - } - *physical = mapped & 0x1FFFFCu; - return 1; -} - -static inline int precision_scratch_address(uint32_t addr, - uint32_t *physical) { - /* Use the same KUSEG/KSEG0/KSEG1 translation as memory.c, while keeping - * KSEG2/KSEG3 from aliasing a reviewed scratchpad slot. */ - if (addr >= 0xC0000000u) return 0; - const uint32_t mapped = addr & 0x1FFFFFFFu; - if (mapped < 0x1F800000u || mapped > 0x1F8003FFu) return 0; - *physical = mapped & ~3u; - return 1; -} - -static inline int precision_copy_source_address(uint32_t addr, - uint32_t *physical) { - return precision_ram_address(addr, physical) || - precision_scratch_address(addr, physical); -} - -static inline int precision_projection_index(uint8_t reg) { - if (reg < 12 || reg > 15) return -1; - return reg == 15 ? 2 : (int)reg - 12; -} - -static void precision_store_projection( - uint32_t physical, const PreciseProjection &projection) { - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation != 0 && entry.addr != physical) { - ++s_precision_diagnostics.store_collisions; - if (entry.generation == s_precision_generation) - ++s_precision_diagnostics.store_evictions; - } - entry.addr = physical; - entry.projection = projection; - entry.generation = s_precision_generation; -} - -static const PrecisionStorePcRoute *precision_store_pc_route( - uint32_t store_pc, uint32_t instruction) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { - if (s_precision_store_pc_routes[i].physical_pc == physical_pc && - s_precision_store_pc_routes[i].instruction == instruction) - return &s_precision_store_pc_routes[i]; - } - return nullptr; -} - -static const PrecisionScratchStorePcRoute *precision_scratch_store_pc_route( - uint32_t store_pc, uint32_t instruction) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; - i < s_precision_scratch_store_pc_route_count; ++i) { - const PrecisionScratchStorePcRoute &route = - s_precision_scratch_store_pc_routes[i]; - if (route.physical_pc == physical_pc && - route.instruction == instruction) - return &route; - } - return nullptr; -} - -static inline int precision_scratch_route_contains( - const PrecisionScratchStorePcRoute &route, uint32_t physical) { - if (physical < route.scratch_first) return 0; - const uint32_t delta = physical - route.scratch_first; - if ((delta % route.scratch_stride) != 0u) return 0; - return (delta / route.scratch_stride) < route.scratch_count; -} - -static const PrecisionMfc2PcRoute *precision_mfc2_pc_route( - uint32_t mfc2_pc, uint32_t instruction) { - const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { - if (s_precision_mfc2_pc_routes[i].physical_pc == physical_pc && - s_precision_mfc2_pc_routes[i].instruction == instruction) - return &s_precision_mfc2_pc_routes[i]; - } - return nullptr; -} - -static PrecisionCopyPcRoute *precision_copy_load_pc_route( - uint32_t load_pc, uint32_t instruction, uint8_t gpr) { - const uint32_t physical_pc = load_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { - PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; - if (route.load_physical_pc == physical_pc && - route.load_instruction == instruction && route.gpr == gpr) - return &route; - } - return nullptr; -} - -static PrecisionCopyPcRoute *precision_copy_store_pc_route( - uint32_t store_pc, uint32_t instruction, uint8_t gpr) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { - PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; - if (route.store_physical_pc == physical_pc && - route.store_instruction == instruction && route.gpr == gpr) - return &route; - } - return nullptr; -} - -extern "C" void gte_precision_word_write_begin(void) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; -} - -extern "C" void gte_precision_main_ram_word_committed(uint32_t physical) { - /* memory.c passes its already-folded main-RAM address only after all four - * bytes have been written. Do not turn an unaligned host write into an - * adjacent aligned provenance token. */ - if (physical >= 0x00200000u || (physical & 3u) != 0u) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - return; - } - s_precision_word_commit.physical = physical; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_MAIN_RAM; - s_precision_word_commit.pending = 1; -} - -/* Only memory.c arms this domain, after an allocated aperture word commits. - * A renderer lookup cannot alias this address with a folded main-RAM word. */ -extern "C" void gte_precision_gpu_dma_word_committed(uint32_t physical) { - if (physical < PSX_MOD_GPU_DMA_APERTURE_BASE || - physical > PSX_MOD_GPU_DMA_APERTURE_BASE + - PSX_MOD_GPU_DMA_APERTURE_SIZE - 4u || (physical & 3u)) { - gte_precision_word_write_begin(); - return; - } - s_precision_word_commit.physical = physical; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_GPU_DMA; - s_precision_word_commit.pending = 1; -} - -static int precision_memory_commit_matches(uint8_t domain, uint32_t physical) { - return domain == (physical < 0x00200000u ? - PRECISION_WORD_DOMAIN_MAIN_RAM : PRECISION_WORD_DOMAIN_GPU_DMA); -} - -extern "C" void gte_precision_scratch_word_committed(uint32_t physical) { - /* memory.c supplies an already-translated physical address after all four - * scratchpad bytes commit. Slot eligibility is checked independently by - * the reviewed post-write route. */ - if (physical < 0x1F800000u || physical > 0x1F8003FCu || - (physical & 3u) != 0u) { - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - return; - } - s_precision_word_commit.physical = physical; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_SCRATCHPAD; - s_precision_word_commit.pending = 1; -} - -extern "C" void gte_precision_tracking_set(int enabled) { - s_precision_tracking = enabled ? 1 : 0; - std::memset(s_precision_mfc2_capture, 0, - sizeof(s_precision_mfc2_capture)); - precision_copy_captures_clear(); - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - gte_precision_generation_advance(); -} - -extern "C" void gte_precision_store_pc_routes_reset(void) { - s_precision_store_pc_route_count = 0; - s_precision_scratch_store_pc_route_count = 0; - s_precision_mfc2_pc_route_count = 0; - s_precision_copy_pc_route_count = 0; - std::memset(s_precision_store_pc_routes, 0, - sizeof(s_precision_store_pc_routes)); - std::memset(s_precision_scratch_store_pc_routes, 0, - sizeof(s_precision_scratch_store_pc_routes)); - std::memset(s_precision_mfc2_pc_routes, 0, - sizeof(s_precision_mfc2_pc_routes)); - std::memset(s_precision_copy_pc_routes, 0, - sizeof(s_precision_copy_pc_routes)); - std::memset(s_precision_mfc2_capture, 0, - sizeof(s_precision_mfc2_capture)); - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; -} - -extern "C" int gte_precision_mfc2_pc_route_add( - uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg) { - if ((mfc2_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || - ((instruction >> 26) & 0x3Fu) != 0x12u || - ((instruction >> 21) & 0x1Fu) != 0u || - ((instruction >> 11) & 0x1Fu) != gte_reg) - return 0; - const uint32_t physical_pc = mfc2_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_mfc2_pc_route_count; ++i) { - PrecisionMfc2PcRoute &route = s_precision_mfc2_pc_routes[i]; - if (route.physical_pc != physical_pc) continue; - return route.instruction == instruction && route.gte_reg == gte_reg; - } - if (s_precision_mfc2_pc_route_count >= PRECISION_MFC2_PC_ROUTE_MAX) - return 0; - PrecisionMfc2PcRoute &route = - s_precision_mfc2_pc_routes[s_precision_mfc2_pc_route_count++]; - route.physical_pc = physical_pc; - route.instruction = instruction; - route.gte_reg = gte_reg; - return 1; -} - -extern "C" int gte_precision_store_pc_route_add( - uint32_t store_pc, uint32_t instruction, uint8_t gte_reg) { - if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || - ((instruction >> 26) & 0x3Fu) != 0x2Bu) - return 0; - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_store_pc_route_count; ++i) { - PrecisionStorePcRoute &route = s_precision_store_pc_routes[i]; - if (route.physical_pc != physical_pc) continue; - return route.instruction == instruction && route.gte_reg == gte_reg; - } - if (s_precision_store_pc_route_count >= PRECISION_STORE_PC_ROUTE_MAX) - return 0; - PrecisionStorePcRoute &route = - s_precision_store_pc_routes[s_precision_store_pc_route_count++]; - route.physical_pc = physical_pc; - route.instruction = instruction; - route.gte_reg = gte_reg; - return 1; -} - -extern "C" int gte_precision_scratch_store_pc_route_add( - uint32_t store_pc, uint32_t instruction, uint8_t gte_reg, - uint32_t scratch_first, uint32_t scratch_stride, - uint32_t scratch_count) { - if ((store_pc & 3u) != 0u || precision_projection_index(gte_reg) < 0 || - ((instruction >> 26) & 0x3Fu) != 0x2Bu || - scratch_count == 0u || scratch_stride == 0u || - (scratch_first & 3u) != 0u || (scratch_stride & 3u) != 0u || - scratch_first < 0x1F800000u || scratch_first > 0x1F8003FCu) - return 0; - - /* Prove the final registered slot without overflow, and keep every slot - * wholly within the physical 1 KiB scratchpad. */ - const uint64_t scratch_last = - (uint64_t)scratch_first + - (uint64_t)scratch_stride * (uint64_t)(scratch_count - 1u); - if (scratch_last > 0x1F8003FCull) return 0; - - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; - i < s_precision_scratch_store_pc_route_count; ++i) { - PrecisionScratchStorePcRoute &route = - s_precision_scratch_store_pc_routes[i]; - if (route.physical_pc != physical_pc) continue; - return route.instruction == instruction && - route.gte_reg == gte_reg && - route.scratch_first == scratch_first && - route.scratch_stride == scratch_stride && - route.scratch_count == scratch_count; - } - if (s_precision_scratch_store_pc_route_count >= - PRECISION_SCRATCH_STORE_PC_ROUTE_MAX) - return 0; - PrecisionScratchStorePcRoute &route = - s_precision_scratch_store_pc_routes[ - s_precision_scratch_store_pc_route_count++]; - route.physical_pc = physical_pc; - route.instruction = instruction; - route.scratch_first = scratch_first; - route.scratch_stride = scratch_stride; - route.scratch_count = scratch_count; - route.gte_reg = gte_reg; - return 1; -} - -extern "C" int gte_precision_scratch_store_pc_route_clamp( - uint32_t store_pc, uint32_t instruction, - int16_t low_x, int16_t high_x, int16_t low_y, int16_t high_y) { - const uint32_t physical_pc = store_pc & 0x1FFFFFFFu; - if (low_x > high_x || low_y > high_y) return 0; - for (uint32_t i = 0; i < s_precision_scratch_store_pc_route_count; ++i) { - PrecisionScratchStorePcRoute &route = s_precision_scratch_store_pc_routes[i]; - if (route.physical_pc != physical_pc || route.instruction != instruction) continue; - route.clamps = 1; - route.low_x = low_x; route.high_x = high_x; - route.low_y = low_y; route.high_y = high_y; - return 1; - } - return 0; -} - -static uint32_t precision_scratch_route_clamp( - const PrecisionScratchStorePcRoute &route, uint32_t packed) { - int32_t x = (int16_t)(packed & 0xFFFFu), y = (int16_t)(packed >> 16); - if (x < route.low_x) x = route.low_x; - if (x > route.high_x) x = route.high_x; - if (y < route.low_y) y = route.low_y; - if (y > route.high_y) y = route.high_y; - return ((uint32_t)x & 0xFFFFu) | ((uint32_t)y << 16); -} - -extern "C" int gte_precision_copy_pc_route_add( - uint32_t load_pc, uint32_t load_instruction, - uint32_t store_pc, uint32_t store_instruction, uint8_t gpr) { - if ((load_pc & 3u) != 0u || (store_pc & 3u) != 0u || gpr == 0u || - gpr >= 32u || ((load_instruction >> 26) & 0x3Fu) != 0x23u || - ((store_instruction >> 26) & 0x3Fu) != 0x2Bu || - ((load_instruction >> 16) & 0x1Fu) != gpr || - ((store_instruction >> 16) & 0x1Fu) != gpr) - return 0; - const uint32_t load_physical_pc = load_pc & 0x1FFFFFFFu; - const uint32_t store_physical_pc = store_pc & 0x1FFFFFFFu; - for (uint32_t i = 0; i < s_precision_copy_pc_route_count; ++i) { - PrecisionCopyPcRoute &route = s_precision_copy_pc_routes[i]; - if (route.load_physical_pc != load_physical_pc && - route.store_physical_pc != store_physical_pc) - continue; - return route.load_physical_pc == load_physical_pc && - route.load_instruction == load_instruction && - route.store_physical_pc == store_physical_pc && - route.store_instruction == store_instruction && - route.gpr == gpr; - } - if (s_precision_copy_pc_route_count >= PRECISION_COPY_PC_ROUTE_MAX) - return 0; - PrecisionCopyPcRoute &route = - s_precision_copy_pc_routes[s_precision_copy_pc_route_count++]; - route.load_physical_pc = load_physical_pc; - route.load_instruction = load_instruction; - route.store_physical_pc = store_physical_pc; - route.store_instruction = store_instruction; - route.gpr = gpr; - route.capture.valid = 0; - return 1; -} - -extern "C" uint32_t gte_precision_mfc2_pc_read( - uint32_t mfc2_pc, uint32_t instruction, uint8_t gte_reg, - uint32_t packed) { - const PrecisionMfc2PcRoute *route = - precision_mfc2_pc_route(mfc2_pc, instruction); - if (!route || route->gte_reg != gte_reg) - return packed; - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking) - return packed; - - const int index = precision_projection_index(gte_reg); - if (index < 0) return packed; - PreciseProjection &capture = s_precision_mfc2_capture[index]; - capture.valid = 0; - const PreciseProjection &projection = s_precise_sxy[index]; - if (projection.valid && projection.packed == packed) - capture = projection; - return packed; -} - -extern "C" uint32_t gte_precision_copy_pc_read( - uint32_t load_pc, uint32_t instruction, uint8_t gpr, - uint32_t addr, uint32_t packed) { - PrecisionCopyPcRoute *route = - precision_copy_load_pc_route(load_pc, instruction, gpr); - if (!route) return packed; - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking) - return packed; - ++s_precision_diagnostics.copy_load_attempts; - - /* Every authoritative execution replaces the prior capture, including a - * source miss. An older equal packed value must never survive this load. */ - route->capture.valid = 0; - if ((addr & 3u) != 0u) return packed; - uint32_t physical; - if (!precision_copy_source_address(addr, &physical)) return packed; - const PrecisionStoreEntry &entry = - s_precision_store[precision_hash(physical)]; - if (entry.generation != s_precision_generation || entry.addr != physical || - !entry.projection.valid || entry.projection.packed != packed) - return packed; - route->capture = entry.projection; - ++s_precision_diagnostics.copy_load_accepts; - return packed; -} - -extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { - /* Consume before every early return. Speculative validation, disabled - * tracking and ineligible GTE registers must not leave a real or replayed - * write token available to a later SWC2 callback. */ - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || - reg < 12 || reg > 15 || (addr & 3u) != 0u) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - int commit_matches = committed && - precision_memory_commit_matches(committed_domain, physical) && - committed_physical == physical; -#ifdef PSX_GTE_REGISTER_TEST - /* The pinned standalone register fixture links gte.cpp without memory.c - * and directly seeds then stores precision entries. Preserve that - * historical test seam only when no producer token exists; an explicitly - * armed mismatched token still exercises the production rejection. */ - if (!committed) commit_matches = 1; -#endif - if (!commit_matches) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - int index = precision_projection_index(reg); - const PreciseProjection &projection = s_precise_sxy[index]; - if (!projection.valid) return; - precision_store_projection(physical, projection); -} - -extern "C" void gte_precision_store_pc_word( - uint32_t store_pc, uint32_t instruction, uint32_t addr, - uint32_t packed) { - /* Most ordinary SWs are not reviewed projection stores and must leave the - * token available for an immediately-following SWC2 callback. */ - const PrecisionStorePcRoute *route = - precision_store_pc_route(store_pc, instruction); - if (!route) return; - - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking || (addr & 3u) != 0u) - return; - ++s_precision_diagnostics.registered_store_attempts; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - if (!committed || !precision_memory_commit_matches(committed_domain, physical) || - committed_physical != physical) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - - const int index = precision_projection_index(route->gte_reg); - if (index < 0) return; - const PreciseProjection &projection = s_precision_mfc2_capture[index]; - if (!projection.valid) return; - if (projection.packed != packed) { - ++s_precision_diagnostics.registered_store_packed_rejections; - return; - } - precision_store_projection(physical, projection); - ++s_precision_diagnostics.registered_store_accepts; -} - -extern "C" void gte_precision_scratch_store_pc_word( - uint32_t store_pc, uint32_t instruction, - uint32_t addr, uint32_t packed) { - /* An unrelated SW callback must not steal the token from the matching - * reviewed helper that immediately follows it. */ - const PrecisionScratchStorePcRoute *route = - precision_scratch_store_pc_route(store_pc, instruction); - if (!route) return; - - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking || (addr & 3u) != 0u) - return; - ++s_precision_diagnostics.registered_store_attempts; - - uint32_t physical; - if (!precision_scratch_address(addr, &physical) || - !precision_scratch_route_contains(*route, physical)) - return; - if (!committed || - committed_domain != PRECISION_WORD_DOMAIN_SCRATCHPAD || - committed_physical != physical) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - - const int index = precision_projection_index(route->gte_reg); - if (index < 0) return; - const PreciseProjection &projection = s_precision_mfc2_capture[index]; - if (!projection.valid) return; - if (projection.packed != packed) { - /* A corner the game pinned to its guard band keeps the projection it was pinned from. */ - if (!route->clamps || precision_scratch_route_clamp(*route, projection.packed) != packed) { - ++s_precision_diagnostics.registered_store_packed_rejections; - return; - } - PreciseProjection pinned = projection; - pinned.packed = packed; - pinned.clamped = 1; - precision_store_projection(physical, pinned); - ++s_precision_diagnostics.registered_store_accepts; - return; - } - precision_store_projection(physical, projection); - ++s_precision_diagnostics.registered_store_accepts; -} - -extern "C" void gte_precision_copy_pc_word( - uint32_t store_pc, uint32_t instruction, uint8_t gpr, - uint32_t addr, uint32_t packed) { - PrecisionCopyPcRoute *route = - precision_copy_store_pc_route(store_pc, instruction, gpr); - if (!route) return; - - const uint32_t committed_physical = s_precision_word_commit.physical; - const uint8_t committed_domain = s_precision_word_commit.domain; - const int committed = s_precision_word_commit.pending != 0; - s_precision_word_commit.domain = PRECISION_WORD_DOMAIN_NONE; - s_precision_word_commit.pending = 0; - - /* Replay/speculative execution may consume only its transient memory token; - * it must neither replace nor erase an authoritative load capture. */ - if (s_speculative_depth != 0 || s_gte_replay_sandbox || - !s_precision_tracking) - return; - ++s_precision_diagnostics.copy_store_attempts; - - const PreciseProjection projection = route->capture; - route->capture.valid = 0; - if ((addr & 3u) != 0u) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - if (!committed || !precision_memory_commit_matches(committed_domain, physical) || - committed_physical != physical) { - ++s_precision_diagnostics.store_uncommitted_rejections; - return; - } - if (!projection.valid) return; - if (projection.packed != packed) { - ++s_precision_diagnostics.copy_store_packed_rejections; - return; - } - precision_store_projection(physical, projection); - ++s_precision_diagnostics.copy_store_accepts; -} - -extern "C" void gte_precision_invalidate_word(uint32_t addr) { - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking) return; - uint32_t physical; - if (!precision_copy_source_address(addr, &physical)) return; - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation == s_precision_generation && entry.addr == physical) - entry.generation = 0; -} - -static GtePrecisionLookupResult precision_lookup_projection( - uint32_t addr, uint32_t packed, - const PreciseProjection **projection) { - if (projection) *projection = nullptr; - if (s_speculative_depth != 0) - return GTE_PRECISION_LOOKUP_SPECULATIVE; - if (!s_precision_tracking) - return GTE_PRECISION_LOOKUP_TRACKING_DISABLED; - if ((addr & 3u) != 0u) - return GTE_PRECISION_LOOKUP_SOURCE_UNALIGNED; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) - return GTE_PRECISION_LOOKUP_SOURCE_NOT_RAM; - const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation != s_precision_generation) - return GTE_PRECISION_LOOKUP_STORE_MISS; - if (entry.addr != physical) { - ++s_precision_diagnostics.lookup_collisions; - return GTE_PRECISION_LOOKUP_STORE_COLLISION; - } - if (!entry.projection.valid) - return GTE_PRECISION_LOOKUP_PROJECTION_INVALID; - if (entry.projection.packed != packed) - return GTE_PRECISION_LOOKUP_PACKED_MISMATCH; - if (entry.projection.z == 0) - return GTE_PRECISION_LOOKUP_ZERO_DEPTH; - /* A corner the GTE saturated and the game then pinned to its guard band is drawn where it projects. */ - if (entry.projection.saturated && !(entry.projection.clamped && entry.projection.unbounded && precision_unpin())) - return GTE_PRECISION_LOOKUP_SATURATED; - if (projection) *projection = &entry.projection; - return GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" GtePrecisionLookupResult gte_precision_load_word_ex( - uint32_t addr, uint32_t packed, - int32_t *x16, int32_t *y16, uint16_t *z) { - const PreciseProjection *projection = nullptr; - const GtePrecisionLookupResult result = - precision_lookup_projection(addr, packed, &projection); - if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; - /* Only a corner the game pinned leaves its packed pixel: it is drawn where an exact divide puts it. */ - const bool exact = projection->clamped && projection->unbounded && precision_unpin(); - if (x16) *x16 = exact ? projection->exact_x16 : projection->x16; - if (y16) *y16 = exact ? projection->exact_y16 : projection->y16; - if (z) *z = projection->z; - return GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" GtePrecisionLookupResult gte_precision_load_perspective_word_ex( - uint32_t addr, uint32_t packed, uint16_t *z) { - const PreciseProjection *projection = nullptr; - const GtePrecisionLookupResult result = - precision_lookup_projection(addr, packed, &projection); - if (result != GTE_PRECISION_LOOKUP_ACCEPTED) return result; - if (!projection->perspective_valid) - return GTE_PRECISION_LOOKUP_PERSPECTIVE_INVALID; - if (z) *z = projection->z; - return GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" GtePrecisionLookupResult gte_precision_load_identity( - uint32_t addr, uint32_t packed, uint64_t *identity) { - const PreciseProjection *projection = nullptr; - const GtePrecisionLookupResult result = - precision_lookup_projection(addr, packed, &projection); - if (identity) - *identity = result == GTE_PRECISION_LOOKUP_ACCEPTED ? projection->identity : 0u; - return result; -} - -extern "C" int gte_precision_unpin_enabled(void) { return precision_unpin(); } -extern "C" void gte_precision_unpin_set(int enabled) { s_precision_unpin = enabled ? 1 : 0; } - -extern "C" int gte_precision_word_clamped(uint32_t addr, uint32_t packed) { - const PreciseProjection *projection = nullptr; - return precision_lookup_projection(addr, packed, &projection) == GTE_PRECISION_LOOKUP_ACCEPTED && - projection->clamped; -} - -extern "C" int gte_precision_load_word(uint32_t addr, uint32_t packed, - int32_t *x16, int32_t *y16, - uint16_t *z) { - return gte_precision_load_word_ex(addr, packed, x16, y16, z) == - GTE_PRECISION_LOOKUP_ACCEPTED; -} - -extern "C" void gte_precision_diagnostics(GtePrecisionDiagnostics *out) { - if (out) *out = s_precision_diagnostics; -} - -extern "C" void gte_precision_diagnostics_reset(void) { - s_precision_diagnostics = {}; -} - -extern "C" void gte_geometry_correction_set(int enabled) { - s_geom_enabled = enabled ? 1 : 0; -#ifdef PSX_GTE_REGISTER_TEST - s_geom_test_hits = 0; -#endif - gte_geom_generation_advance(); -} - -extern "C" int gte_geometry_correction_enabled(void) { - return s_geom_enabled; -} - -extern "C" uint32_t gte_geometry_correction_hits(void) { -#ifdef PSX_GTE_REGISTER_TEST - return s_geom_test_hits; -#else - return 0; -#endif -} - -/* Retained as a hard-disabled ABI stub for older generated/native test code. - * Geometry must never recover provenance from a rounded coordinate alone. */ -extern "C" int gte_geometry_correction_lookup(uint32_t packed, - int32_t *x16, int32_t *y16) { -#ifdef PSX_GTE_REGISTER_TEST - if (s_speculative_depth != 0 || !s_geom_enabled) return 0; - const GeomTestVertex &entry = s_geom_test_cache[geom_test_hash(packed)]; - if (entry.generation != s_geom_generation || entry.packed != packed) - return 0; - if (x16) *x16 = entry.x16; - if (y16) *y16 = entry.y16; - s_geom_test_hits++; - return 1; -#else - (void)packed; - (void)x16; - (void)y16; - return 0; -#endif -} - -// Per-scope diagnostic suppression of the classic widescreen X squash for -// candidate finite far backdrops. The recompiler brackets each reviewed -// [widescreen.backdrop].unsquash_funcs driver with -// gte_ws_set_suppress(1)/(0). Those drivers may draw a mixture of backdrop and -// ordinary room geometry, so the scope remains DEPTH-GATED: only vertices with -// SZ >= s_ws_far_threshold retain their authored 4:3 projection before the -// final wide stretch. The counter makes nested/recursive tagged renderers safe: -// an inner return must not clear its caller's active scope. Live testing has -// not linked this path to the skyline plane, so it must remain opt-in. -static unsigned s_ws_suppress_depth = 0; -static int s_ws_backdrop_repair_enabled = 0; -static int32_t s_ws_far_threshold = 900; /* SZ split: near props squashed, far backdrop un-squashed (8C, tunable via ws_far_threshold) */ -extern "C" void gte_ws_set_suppress(int on) { - if (on) { - if (s_ws_suppress_depth != 0xFFFFFFFFu) ++s_ws_suppress_depth; - } else if (s_ws_suppress_depth != 0) { - --s_ws_suppress_depth; - } -} -extern "C" void gte_ws_set_backdrop_repair_enabled(int on) { - s_ws_backdrop_repair_enabled = on ? 1 : 0; -} -extern "C" int gte_ws_get_backdrop_repair_enabled(void) { - return s_ws_backdrop_repair_enabled; -} -extern "C" void gte_ws_set_far_threshold(int t) { s_ws_far_threshold = t; } -extern "C" int gte_ws_get_far_threshold(void) { return (int)s_ws_far_threshold; } -/* SZ stats observed while suppress is active (one frame window, reset on read). */ -static int32_t s_ws_sz_min = 0x7FFFFFFF, s_ws_sz_max = 0; -static uint32_t s_ws_sz_n = 0, s_ws_sz_far = 0; -extern "C" void gte_ws_get_sz_stats(int* mn, int* mx, unsigned* n, unsigned* far_n) { - if (mn) *mn = (s_ws_sz_n ? s_ws_sz_min : 0); - if (mx) *mx = s_ws_sz_max; - if (n) *n = s_ws_sz_n; - if (far_n) *far_n = s_ws_sz_far; - s_ws_sz_min = 0x7FFFFFFF; s_ws_sz_max = 0; s_ws_sz_n = 0; s_ws_sz_far = 0; -} - -// ---- Native-wide sky-DOME expand ------------------------------------------ -// In native-wide (mode 2) the GTE is fed identity (no squash) so the world -// fills the wider FOV via the cull widening. But a sky DOME is a FINITE mesh -// authored to fill 4:3 — its curved edge falls short of the wider frame corners -// (black corners). Fix: scale the FAR-depth vertices' projected X OUTWARD from -// the projection centre (OFX) by the frame-widening ratio (3*num)/(4*den) — the -// inverse of the squash — so the dome grows to cover the wider FOV. DEPTH-GATED -// (SZ >= s_ws_far_threshold) so only the farthest layer (the sky) expands while -// nearer world geometry stays put. On + ratio set from main.cpp when native- -// wide engages; threshold tuned live via ws_far_threshold. Off => identity. -static int s_ws_dome_on = 0; -static int32_t s_ws_dome_num = 1, s_ws_dome_den = 1; // expand = num/den (>1) -extern "C" void gte_ws_set_dome_expand(int on, int aspect_num, int aspect_den) { - s_ws_dome_on = on ? 1 : 0; - // widen ratio = wide_w / disp_w = (aspect/(4:3)) = (3*num)/(4*den). - int32_t n = 3 * aspect_num, d = 4 * aspect_den; - if (n <= 0 || d <= 0 || n <= d) { s_ws_dome_num = s_ws_dome_den = 1; return; } - s_ws_dome_num = n; s_ws_dome_den = d; -} - -// ---- Dome-locate probe ----------------------------------------------------- -// Tally which guest function (g_debug_current_func_addr, set at dispatch) -// projects FAR (SZ >= threshold) vertices, so the sky-dome draw function can be -// identified as the top far-vertex emitter — the target for the per-function -// dome-expand bracket (the clean alternative to the scene-dependent depth gate). -extern "C" { extern uint32_t g_debug_current_func_addr; } -static uint32_t s_gte_caller_ra = 0; /* guest ra at gte_execute = return into the GAME fn that issued the projection (not the libgte leaf) */ -#define WS_DOME_CALL_SITES_MAX 32 -static uint32_t s_ws_dome_call_sites[WS_DOME_CALL_SITES_MAX]; -static int s_ws_dome_call_sites_n = 0; -extern "C" void gte_ws_configure_dome_sites(const uint32_t* sites, int count) { - if (count < 0) count = 0; - if (count > WS_DOME_CALL_SITES_MAX) count = WS_DOME_CALL_SITES_MAX; - s_ws_dome_call_sites_n = count; - for (int i = 0; i < count; i++) - s_ws_dome_call_sites[i] = sites[i] & 0x1FFFFFFFu; -} -static inline bool ws_dome_call_matches(void) { - // JAL writes pc+8 to RA; config records the call instruction itself. - const uint32_t call_pc = (s_gte_caller_ra - 8u) & 0x1FFFFFFFu; - for (int i = 0; i < s_ws_dome_call_sites_n; i++) - if (s_ws_dome_call_sites[i] == call_pc) return true; - return false; -} -#define DOME_PROBE_SLOTS 48 -static struct { uint32_t func; uint32_t count; int32_t max_sz; } s_dome_probe[DOME_PROBE_SLOTS]; -static int s_dome_probe_on = 0; -static int32_t s_dome_probe_thr = 4000; -extern "C" void gte_dome_probe(int on, int thr) { - s_dome_probe_on = on ? 1 : 0; - if (thr > 0) s_dome_probe_thr = thr; - if (on) for (int i = 0; i < DOME_PROBE_SLOTS; i++) { s_dome_probe[i].func = 0; s_dome_probe[i].count = 0; s_dome_probe[i].max_sz = 0; } -} -static inline void dome_probe_note(int32_t sz) { - if (s_gte_replay_sandbox) return; - if (!s_dome_probe_on || sz < s_dome_probe_thr) return; - /* Tally the guest RA (the GAME fn that jal'd to libgte for this projection), - * NOT g_debug_current_func_addr (which is the libgte leaf 0x80000F40). */ - uint32_t f = s_gte_caller_ra; - int slot = -1, empty = -1; - for (int i = 0; i < DOME_PROBE_SLOTS; i++) { - if (s_dome_probe[i].count && s_dome_probe[i].func == f) { slot = i; break; } - if (empty < 0 && s_dome_probe[i].count == 0) empty = i; - } - if (slot < 0) slot = empty; - if (slot < 0) return; - s_dome_probe[slot].func = f; - s_dome_probe[slot].count++; - if (sz > s_dome_probe[slot].max_sz) s_dome_probe[slot].max_sz = sz; -} -extern "C" int gte_dome_probe_dump(uint32_t* funcs, uint32_t* counts, int32_t* maxsz, int cap) { - int n = 0; - for (int i = 0; i < DOME_PROBE_SLOTS && n < cap; i++) - if (s_dome_probe[i].count) { funcs[n] = s_dome_probe[i].func; counts[n] = s_dome_probe[i].count; maxsz[n] = s_dome_probe[i].max_sz; n++; } - return n; -} - -// --------------------------------------------------------------------------- -// GTE projection ring (ALWAYS-ON) — records each RTPS/RTPT's inputs (vertex, -// rotation matrix, translation, projection config) and outputs (screen XY / Z / -// FLAG). A post-hoc probe queries this to find flattened/degenerate character -// projections and decide whether the fault is in the INPUTS supplied by the -// recompiled game code (bad matrix/vertex) or the GTE math itself. -// --------------------------------------------------------------------------- -// NOTE: this block is already inside namespace PSXRecomp::GTE (opened line 8), -// so types are referred to unqualified. -extern "C" { extern uint64_t s_frame_count; } -struct GteRtpRec { - uint32_t seq, frame, caller_ra, cmd; - int16_t V0[3], V1[3], V2[3]; - int16_t RT[9]; - int32_t TR[3]; - uint16_t H; int32_t OFX, OFY; - int32_t SXY0, SXY1, SXY2; - uint16_t SZ1, SZ2, SZ3; - uint32_t FLAG; -}; -#define GTE_RTP_RING_CAP (1u << 18) /* 256K raw entries (~a few seconds) */ -static GteRtpRec* s_gte_rtp_ring = nullptr; -static uint64_t s_gte_rtp_seq = 0; - -/* Per-frame aggregate stats (retained ring over recent distinct frames). Lets a - * probe see the alternating flat/normal render pattern without shipping 84k - * projections/sec over JSON. nsat = projections whose newest output vertex - * saturated off-screen; nflat = RTP whose last-3 output verts are collinear - * (area 0) = a flattened triangle. */ -#define GTE_FSTAT_CAP 512 -struct GteFStat { uint32_t frame, nproj, nsat, nflat, nintpl, nintpl_tiny; }; -static GteFStat s_gte_fstat[GTE_FSTAT_CAP]; -static uint32_t s_gte_fstat_head = 0; /* index of most-recent frame slot */ -static int s_gte_fstat_valid = 0; - -/* Degenerate-latch: full records of flat/saturated projections, retained (small - * ring) so intermittent flat frames are caught regardless of raw-ring roll. */ -#define GTE_LATCH_CAP (1u << 13) /* 8192 */ -static GteRtpRec* s_gte_latch = nullptr; -static uint64_t s_gte_latch_seq = 0; - -static inline int gte_sxx(int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x10000:v; } -static inline int gte_syy(int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; } - -static void gte_rtp_record(const GTEState* g, uint32_t cmd) { - if (s_gte_replay_sandbox) return; - if (!s_gte_rtp_ring) { - s_gte_rtp_ring = (GteRtpRec*)calloc(GTE_RTP_RING_CAP, sizeof(GteRtpRec)); - if (!s_gte_rtp_ring) return; - } - GteRtpRec* e = &s_gte_rtp_ring[s_gte_rtp_seq % GTE_RTP_RING_CAP]; - e->seq = (uint32_t)s_gte_rtp_seq; - e->frame = (uint32_t)s_frame_count; - e->caller_ra = s_gte_caller_ra; - e->cmd = cmd; - for (int i = 0; i < 3; i++) { e->V0[i]=g->V0[i]; e->V1[i]=g->V1[i]; e->V2[i]=g->V2[i]; } - for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) e->RT[r*3+c]=g->RT[r][c]; - for (int i = 0; i < 3; i++) e->TR[i]=g->TR[i]; - e->H=g->H; e->OFX=g->OFX; e->OFY=g->OFY; - e->SXY0=g->SXY[0]; e->SXY1=g->SXY[1]; e->SXY2=g->SXY[2]; - e->SZ1=g->SZ[1]; e->SZ2=g->SZ[2]; e->SZ3=g->SZ[3]; - e->FLAG=g->FLAG; - s_gte_rtp_seq++; - - /* classify */ - int x0=gte_sxx(e->SXY0),y0=gte_syy(e->SXY0); - int x1=gte_sxx(e->SXY1),y1=gte_syy(e->SXY1); - int x2=gte_sxx(e->SXY2),y2=gte_syy(e->SXY2); - int sat = (x2<=-1023||x2>=1023||y2<=-1023||y2>=1023) ? 1 : 0; - long area2 = (long)(x1-x0)*(y2-y0) - (long)(x2-x0)*(y1-y0); - int flat = (area2==0) ? 1 : 0; /* three output verts exactly collinear */ - - /* per-frame stats */ - uint32_t f=(uint32_t)s_frame_count; - if (!s_gte_fstat_valid || s_gte_fstat[s_gte_fstat_head].frame != f) { - s_gte_fstat_head = s_gte_fstat_valid ? (s_gte_fstat_head+1)%GTE_FSTAT_CAP : 0; - s_gte_fstat_valid = 1; - s_gte_fstat[s_gte_fstat_head].frame=f; - s_gte_fstat[s_gte_fstat_head].nproj=0; - s_gte_fstat[s_gte_fstat_head].nsat=0; - s_gte_fstat[s_gte_fstat_head].nflat=0; - s_gte_fstat[s_gte_fstat_head].nintpl=0; - s_gte_fstat[s_gte_fstat_head].nintpl_tiny=0; - } - s_gte_fstat[s_gte_fstat_head].nproj++; - s_gte_fstat[s_gte_fstat_head].nsat += sat; - s_gte_fstat[s_gte_fstat_head].nflat += flat; - - /* latch degenerate (saturated) projections — the "flat/absent" character */ - if (sat) { - if (!s_gte_latch) { - s_gte_latch = (GteRtpRec*)calloc(GTE_LATCH_CAP, sizeof(GteRtpRec)); - if (!s_gte_latch) return; - } - s_gte_latch[s_gte_latch_seq % GTE_LATCH_CAP] = *e; - s_gte_latch_seq++; - } -} - -extern "C" uint64_t gte_rtp_ring_total(void) { return s_gte_rtp_seq; } -extern "C" uint64_t gte_latch_total(void) { return s_gte_latch_seq; } - -// --------------------------------------------------------------------------- -// GTE INTPL ring (ALWAYS-ON) — records each INTPL (0x11) vertex-lerp: inputs -// (IR0 blend, IR1-3 pose-A vertex, FC pose-B vertex) snapshotted BEFORE the op -// and outputs (MAC1-3 / IR1-3 / FLAG) after. Crash Bash tweens character -// vertex animation through INTPL; this ring decides whether collapsed tween -// output comes from already-tiny inputs (upstream CPU/game-state divergence) -// or from the INTPL math itself. -// --------------------------------------------------------------------------- -struct GteIntplRec { - uint32_t seq, frame, caller_ra; - int16_t ir0, in1, in2, in3; - int32_t fc[3]; - int32_t mac[3]; - int16_t out1, out2, out3; - uint32_t flag; -}; -#define GTE_INTPL_RING_CAP (1u << 17) /* 128K entries (~4s at menu rates) */ -static GteIntplRec* s_gte_intpl_ring = nullptr; -static uint64_t s_gte_intpl_seq = 0; - -static void gte_intpl_record(const GTEState* g, - const int16_t pre_ir[4], const int32_t pre_fc[3]) { - if (s_gte_replay_sandbox) return; - if (!s_gte_intpl_ring) { - s_gte_intpl_ring = (GteIntplRec*)calloc(GTE_INTPL_RING_CAP, sizeof(GteIntplRec)); - if (!s_gte_intpl_ring) return; - } - GteIntplRec* e = &s_gte_intpl_ring[s_gte_intpl_seq % GTE_INTPL_RING_CAP]; - e->seq = (uint32_t)s_gte_intpl_seq; - e->frame = (uint32_t)s_frame_count; - e->caller_ra = s_gte_caller_ra; - e->ir0 = pre_ir[0]; e->in1 = pre_ir[1]; e->in2 = pre_ir[2]; e->in3 = pre_ir[3]; - for (int i = 0; i < 3; i++) e->fc[i] = pre_fc[i]; - e->mac[0]=g->MAC1; e->mac[1]=g->MAC2; e->mac[2]=g->MAC3; - e->out1=g->IR1; e->out2=g->IR2; e->out3=g->IR3; - e->flag=g->FLAG; - s_gte_intpl_seq++; - - /* per-frame stats (shares the fstat ring; nintpl_tiny = all outputs small - * while at least one input was not — the collapse signature) */ - uint32_t f=(uint32_t)s_frame_count; - if (!s_gte_fstat_valid || s_gte_fstat[s_gte_fstat_head].frame != f) { - s_gte_fstat_head = s_gte_fstat_valid ? (s_gte_fstat_head+1)%GTE_FSTAT_CAP : 0; - s_gte_fstat_valid = 1; - s_gte_fstat[s_gte_fstat_head].frame=f; - s_gte_fstat[s_gte_fstat_head].nproj=0; - s_gte_fstat[s_gte_fstat_head].nsat=0; - s_gte_fstat[s_gte_fstat_head].nflat=0; - s_gte_fstat[s_gte_fstat_head].nintpl=0; - s_gte_fstat[s_gte_fstat_head].nintpl_tiny=0; - } - s_gte_fstat[s_gte_fstat_head].nintpl++; - { - int out_tiny = (e->out1>-48 && e->out1<48 && e->out2>-48 && e->out2<48 - && e->out3>-48 && e->out3<48); - int in_big = (e->in1<=-48 || e->in1>=48 || e->in2<=-48 || e->in2>=48 - || e->in3<=-48 || e->in3>=48 - || e->fc[0]<=-48 || e->fc[0]>=48 - || e->fc[1]<=-48 || e->fc[1]>=48 - || e->fc[2]<=-48 || e->fc[2]>=48); - if (out_tiny && in_big) s_gte_fstat[s_gte_fstat_head].nintpl_tiny++; - } -} - -extern "C" uint64_t gte_intpl_ring_total(void) { return s_gte_intpl_seq; } - -/* Dump INTPL ring entries as JSON array body. offset skips the first N - * MATCHING entries (after frame filtering), so a probe can page through a - * whole frame regardless of where it sits in the ring. */ -extern "C" int gte_intpl_ring_dump_json(char* out, int outsz, int max_count, - int newest_first, long frame_filter, - int offset) { - if (!s_gte_intpl_ring || outsz < 64) { if (out && outsz) out[0]=0; return 0; } - uint64_t total = s_gte_intpl_seq; - if (total == 0) { out[0]=0; return 0; } - uint64_t oldest = total > GTE_INTPL_RING_CAP ? total - GTE_INTPL_RING_CAP : 0; - int pos = 0, emitted = 0, matched = 0; - for (uint64_t k = 0; k < (total - oldest) && emitted < max_count; k++) { - uint64_t seq = newest_first ? (total - 1 - k) : (oldest + k); - if (seq < oldest || seq >= total) break; - const GteIntplRec* e = &s_gte_intpl_ring[seq % GTE_INTPL_RING_CAP]; - if (frame_filter >= 0 && e->frame != (uint32_t)frame_filter) continue; - if (matched++ < offset) continue; - if (pos > outsz - 400) break; - pos += snprintf(out+pos, outsz-pos, - "%s{\"seq\":%u,\"frame\":%u,\"ra\":\"0x%08X\"," - "\"ir0\":%d,\"in\":[%d,%d,%d],\"fc\":[%d,%d,%d]," - "\"mac\":[%d,%d,%d],\"out\":[%d,%d,%d],\"flag\":\"0x%08X\"}", - emitted?",":"", e->seq, e->frame, e->caller_ra, - e->ir0, e->in1, e->in2, e->in3, e->fc[0], e->fc[1], e->fc[2], - e->mac[0], e->mac[1], e->mac[2], e->out1, e->out2, e->out3, - e->flag); - emitted++; - } - out[pos] = 0; - return emitted; -} - -/* Emit per-frame stats (newest first) as JSON array body. */ -extern "C" int gte_fstat_dump_json(char* out, int outsz, int max_frames) { - if (!s_gte_fstat_valid) { if(out&&outsz) out[0]=0; return 0; } - int pos=0, emitted=0; - for (int k=0; knproj==0 && !(k==0)) continue; - if (pos>outsz-96) break; - pos+=snprintf(out+pos,outsz-pos, - "%s{\"frame\":%u,\"nproj\":%u,\"nsat\":%u,\"nflat\":%u," - "\"nintpl\":%u,\"nintpl_tiny\":%u}", - emitted?",":"", s->frame,s->nproj,s->nsat,s->nflat, - s->nintpl,s->nintpl_tiny); - emitted++; - } - out[pos]=0; return emitted; -} - -/* Dump latched degenerate projections (newest first). */ -extern "C" int gte_latch_dump_json(char* out, int outsz, int max_count) { - if (!s_gte_latch || s_gte_latch_seq==0){ if(out&&outsz) out[0]=0; return 0; } - uint64_t total=s_gte_latch_seq; - uint64_t oldest = total>GTE_LATCH_CAP ? total-GTE_LATCH_CAP : 0; - int pos=0, emitted=0; - for (uint64_t k=0; k<(total-oldest) && emittedoutsz-700) break; - pos+=snprintf(out+pos,outsz-pos, - "%s{\"frame\":%u,\"ra\":\"0x%08X\",\"cmd\":\"0x%08X\"," - "\"RT\":[%d,%d,%d,%d,%d,%d,%d,%d,%d],\"TR\":[%d,%d,%d],\"H\":%u," - "\"V0\":[%d,%d,%d],\"V1\":[%d,%d,%d],\"V2\":[%d,%d,%d]," - "\"S0\":[%d,%d],\"S1\":[%d,%d],\"S2\":[%d,%d],\"SZ\":[%u,%u,%u],\"FLAG\":\"0x%08X\"}", - emitted?",":"", e->frame, e->caller_ra, e->cmd, - e->RT[0],e->RT[1],e->RT[2],e->RT[3],e->RT[4],e->RT[5],e->RT[6],e->RT[7],e->RT[8], - e->TR[0],e->TR[1],e->TR[2],(unsigned)e->H, - e->V0[0],e->V0[1],e->V0[2], e->V1[0],e->V1[1],e->V1[2], e->V2[0],e->V2[1],e->V2[2], - gte_sxx(e->SXY0),gte_syy(e->SXY0),gte_sxx(e->SXY1),gte_syy(e->SXY1), - gte_sxx(e->SXY2),gte_syy(e->SXY2),(unsigned)e->SZ1,(unsigned)e->SZ2,(unsigned)e->SZ3,e->FLAG); - emitted++; - } - out[pos]=0; return emitted; -} - -/* Format up to max_count recent entries as a JSON array body (no outer braces). - * If frame_filter >= 0, only entries with that frame are emitted. Returns count. */ -extern "C" int gte_rtp_ring_dump_json(char* out, int outsz, int max_count, - int newest_first, long frame_filter) { - if (!s_gte_rtp_ring || outsz < 64) { if (out && outsz) out[0]=0; return 0; } - uint64_t total = s_gte_rtp_seq; - if (total == 0) { out[0]=0; return 0; } - uint64_t oldest = total > GTE_RTP_RING_CAP ? total - GTE_RTP_RING_CAP : 0; - int pos = 0, emitted = 0; - for (uint64_t k = 0; k < (total - oldest) && emitted < max_count; k++) { - uint64_t seq = newest_first ? (total - 1 - k) : (oldest + k); - if (seq < oldest || seq >= total) break; - const GteRtpRec* e = &s_gte_rtp_ring[seq % GTE_RTP_RING_CAP]; - if (frame_filter >= 0 && e->frame != (uint32_t)frame_filter) continue; - auto sxx = [](int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x10000:v; }; - auto syy = [](int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; }; - if (pos > outsz - 700) break; - pos += snprintf(out+pos, outsz-pos, - "%s{\"seq\":%u,\"frame\":%u,\"ra\":\"0x%08X\",\"cmd\":\"0x%08X\"," - "\"V0\":[%d,%d,%d],\"V1\":[%d,%d,%d],\"V2\":[%d,%d,%d]," - "\"RT\":[%d,%d,%d,%d,%d,%d,%d,%d,%d],\"TR\":[%d,%d,%d]," - "\"H\":%u,\"OFX\":%d,\"OFY\":%d," - "\"S0\":[%d,%d],\"S1\":[%d,%d],\"S2\":[%d,%d]," - "\"SZ\":[%u,%u,%u],\"FLAG\":\"0x%08X\"}", - emitted?",":"", e->seq, e->frame, e->caller_ra, e->cmd, - e->V0[0],e->V0[1],e->V0[2], e->V1[0],e->V1[1],e->V1[2], e->V2[0],e->V2[1],e->V2[2], - e->RT[0],e->RT[1],e->RT[2],e->RT[3],e->RT[4],e->RT[5],e->RT[6],e->RT[7],e->RT[8], - e->TR[0],e->TR[1],e->TR[2], (unsigned)e->H, e->OFX, e->OFY, - sxx(e->SXY0),syy(e->SXY0), sxx(e->SXY1),syy(e->SXY1), sxx(e->SXY2),syy(e->SXY2), - (unsigned)e->SZ1,(unsigned)e->SZ2,(unsigned)e->SZ3, e->FLAG); - emitted++; - } - out[pos] = 0; - return emitted; -} - -extern "C" void gte_set_display_aspect(int num, int den) { - if (num <= 0 || den <= 0) { s_ws_xnum = s_ws_xden = 1; return; } - // squash = (4/3) / (num/den) = (4*den) / (3*num); identity for 4:3. - int32_t n = 4 * den, d = 3 * num; - int32_t a = n, b = d; - while (b) { int32_t t = a % b; a = b; b = t; } // gcd - s_ws_xnum = n / a; - s_ws_xden = d / a; -} - -// --------------------------------------------------------------------------- -// RTPS — Perspective Transformation (internal, operates on given vertex V) -// --------------------------------------------------------------------------- -void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0) { - // Step 1: Matrix multiplication + translation - int64_t mac1 = (int64_t)gte->TR[0] * 4096 + - (int64_t)gte->RT[0][0] * V[0] + - (int64_t)gte->RT[0][1] * V[1] + - (int64_t)gte->RT[0][2] * V[2]; - int64_t mac2 = (int64_t)gte->TR[1] * 4096 + - (int64_t)gte->RT[1][0] * V[0] + - (int64_t)gte->RT[1][1] * V[1] + - (int64_t)gte->RT[1][2] * V[2]; - int64_t mac3 = (int64_t)gte->TR[2] * 4096 + - (int64_t)gte->RT[2][0] * V[0] + - (int64_t)gte->RT[2][1] * V[1] + - (int64_t)gte->RT[2][2] * V[2]; - - gte->check_mac_overflow(mac1 >> 12, 1); - gte->check_mac_overflow(mac2 >> 12, 2); - gte->check_mac_overflow(mac3 >> 12, 3); - gte->MAC1 = static_cast(mac1 >> 12); - gte->MAC2 = static_cast(mac2 >> 12); - gte->MAC3 = static_cast(mac3 >> 12); - - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, false); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, false); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, false); - - // Step 2: Push SZ FIFO - gte->push_sz(gte->MAC3); - - // Step 3: Perspective division - int32_t h_div_sz = gte_divide(gte->H, gte->SZ[3], gte->FLAG); - - // Step 4: Project to screen coordinates. Squash X only when configured AND - // this frame is being stretched — never on a 4:3-presented frame (FMV / - // full-2D screen), so content and present stay locked. - int64_t xterm = (int64_t)gte->IR1 * h_div_sz; - bool do_squash = ws_x_squashed(); - const bool dome_call = ws_dome_call_matches(); - // Curved backdrops are authored to cover the original 4:3 projection. - // In classic widescreen, leave that projection intact and let the normal - // final-frame stretch cover the wide output instead of shrinking it first. - if (dome_call) do_squash = false; - if (do_squash && s_ws_suppress_depth != 0) { - /* Depth-gated: un-squash only FAR geometry (the backdrop); keep near - * props squashed so they stay aligned. Record SZ stats for tuning. */ - int32_t sz = gte->SZ[3]; - if (!s_gte_replay_sandbox) { - if (sz < s_ws_sz_min) s_ws_sz_min = sz; - if (sz > s_ws_sz_max) s_ws_sz_max = sz; - s_ws_sz_n++; - } - if (s_ws_backdrop_repair_enabled && sz >= s_ws_far_threshold) { - do_squash = false; - if (!s_gte_replay_sandbox) s_ws_sz_far++; - } - } - if (do_squash) - xterm = xterm * s_ws_xnum / s_ws_xden; - // Native-wide dome expansion remains a diagnostic-only depth probe. - else if (s_ws_dome_on && s_ws_dome_num != s_ws_dome_den && - !gpu_ws_present_native_43()) { - int32_t sz = gte->SZ[3]; - if (!s_gte_replay_sandbox) { - if (sz < s_ws_sz_min) s_ws_sz_min = sz; - if (sz > s_ws_sz_max) s_ws_sz_max = sz; - s_ws_sz_n++; - } - if (sz >= s_ws_far_threshold) { - xterm = xterm * s_ws_dome_num / s_ws_dome_den; - if (!s_gte_replay_sandbox) s_ws_sz_far++; - } - } - dome_probe_note(gte->SZ[3]); /* locate the dome draw fn (far-vertex tally) */ - int64_t sx16 = gte->OFX + xterm; - int64_t sy16 = gte->OFY + (int64_t)gte->IR2 * h_div_sz; - int64_t sx = sx16 >> 16; - int64_t sy = sy16 >> 16; - gte->push_sxy(sx, sy); - if (!s_gte_replay_sandbox) { - s_precise_sxy[0] = s_precise_sxy[1]; - s_precise_sxy[1] = s_precise_sxy[2]; - s_precise_sxy[2].packed = (uint32_t)gte->SXY[2]; - s_precise_sxy[2].x16 = (int32_t)sx16; - s_precise_sxy[2].y16 = (int32_t)sy16; - s_precise_sxy[2].z = gte->SZ[3]; - s_precise_sxy[2].valid = 1; - s_precise_sxy[2].identity = UINT64_C(0x8000000000000000) | - static_cast(static_cast(V[0])) | - static_cast(static_cast(V[1])) << 16 | - static_cast(static_cast(V[2])) << 32; - s_precise_sxy[2].saturated = - static_cast(gte->SXY[2] & 0xFFFF) != sx || - static_cast((uint32_t)gte->SXY[2] >> 16) != sy; - /* The GTE's divide stops at twice the focal length and its screen at 1023: an exact divide does not. */ - const bool stopped = s_precise_sxy[2].saturated || (uint32_t)gte->SZ[3] * 2 <= (uint32_t)gte->H; - s_precise_sxy[2].unbounded = 0; - if (stopped && gte->SZ[3] != 0 && (do_squash || xterm == (int64_t)gte->IR1 * h_div_sz)) { - const int64_t exact = ((int64_t)gte->H << 16) / gte->SZ[3]; - int64_t exact_x = (int64_t)gte->IR1 * exact; - if (do_squash) exact_x = exact_x * s_ws_xnum / s_ws_xden; - int64_t exact_y = (int64_t)gte->IR2 * exact; - /* Too far for a 16.16 coordinate once the screen centre is added: nearer along the same ray from that centre. */ - const int64_t room_x = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFX); - const int64_t room_y = kUnboundedLimit16 - 1 - std::abs((int64_t)gte->OFY); - double nearer = 1.0; - if (std::abs(exact_x) > room_x) nearer = (double)room_x / (double)std::abs(exact_x); - if (std::abs(exact_y) > room_y) nearer = std::min(nearer, (double)room_y / (double)std::abs(exact_y)); - if (room_x > 0 && room_y > 0) { - if (nearer < 1.0) { - exact_x = (int64_t)((double)exact_x * nearer); - exact_y = (int64_t)((double)exact_y * nearer); - } - s_precise_sxy[2].exact_x16 = (int32_t)(gte->OFX + exact_x); - s_precise_sxy[2].exact_y16 = (int32_t)(gte->OFY + exact_y); - s_precise_sxy[2].unbounded = 1; - } - } - /* Perspective textures need the original camera depth, not a value - * clamped into SZ3, and must reject the GTE's near divide-overflow - * region. Geometry can still use the retained X/Y when this bit is 0. */ - s_precise_sxy[2].perspective_valid = - gte->H != 0 && gte->MAC3 > 0 && gte->MAC3 <= 0xFFFF && - (uint32_t)gte->SZ[3] * 2u > (uint32_t)gte->H; - s_precise_sxy[3] = s_precise_sxy[2]; - } - // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS - if (setMac0) { - int64_t mac0 = (int64_t)gte->DQA * h_div_sz + gte->DQB; - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0); - gte->IR0 = gte->saturate_ir0(static_cast(mac0 >> 12)); - } -} - -// RTPS (0x01) — single vertex, always sets MAC0 -void gte_rtps(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_rtps_internal(gte, gte->V0, true); - gte->set_error_flag(); -} - -// RTPT (0x30) — triple vertex, only last sets MAC0 -void gte_rtpt(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_rtps_internal(gte, gte->V0, false); - gte_rtps_internal(gte, gte->V1, false); - gte_rtps_internal(gte, gte->V2, true); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCLIP (0x06) — Normal Clipping (2D cross product for backface culling) -// --------------------------------------------------------------------------- -void gte_nclip(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int16_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); - int16_t sy0 = static_cast(gte->SXY[0] >> 16); - int16_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); - int16_t sy1 = static_cast(gte->SXY[1] >> 16); - int16_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); - int16_t sy2 = static_cast(gte->SXY[2] >> 16); - int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + - (int64_t)sx1 * (sy2 - sy0) + - (int64_t)sx2 * (sy0 - sy1); - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// AVSZ3 (0x2D) — Average Z (3 points) -// --------------------------------------------------------------------------- -void gte_avsz3(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int64_t mac0 = (int64_t)gte->ZSF3 * (gte->SZ[1] + gte->SZ[2] + gte->SZ[3]); - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0 >> 12); - gte->OTZ = gte->saturate_sz(gte->MAC0); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// AVSZ4 (0x2E) — Average Z (4 points) -// --------------------------------------------------------------------------- -void gte_avsz4(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int64_t mac0 = (int64_t)gte->ZSF4 * (gte->SZ[0] + gte->SZ[1] + gte->SZ[2] + gte->SZ[3]); - gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0 >> 12); - gte->OTZ = gte->saturate_sz(gte->MAC0); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCCS (0x1B) — Normal Color Color Single -// --------------------------------------------------------------------------- -void gte_nccs_internal(GTEState* gte, int16_t* V, uint32_t instr) { - light_transform(gte, V, instr); - light_color(gte, instr); - color_output(gte, instr); -} - -void gte_nccs(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_nccs_internal(gte, gte->V0, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCCT (0x3F) — Normal Color Color Triple -// --------------------------------------------------------------------------- -void gte_ncct(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_nccs_internal(gte, gte->V0, instr); - gte_nccs_internal(gte, gte->V1, instr); - gte_nccs_internal(gte, gte->V2, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCDS (0x13) — Normal Color Depth Cue Single -// --------------------------------------------------------------------------- -void gte_ncds_internal(GTEState* gte, int16_t* V, uint32_t instr) { - light_transform(gte, V, instr); - light_color(gte, instr); - depth_cue_from_ir(gte, instr); - color_output(gte, instr); -} - -void gte_ncds(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncds_internal(gte, gte->V0, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCDT (0x16) — Normal Color Depth Cue Triple -// --------------------------------------------------------------------------- -void gte_ncdt(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncds_internal(gte, gte->V0, instr); - gte_ncds_internal(gte, gte->V1, instr); - gte_ncds_internal(gte, gte->V2, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCS (0x1E) — Normal Color Single (no vertex color multiply) -// --------------------------------------------------------------------------- -void gte_ncs_internal(GTEState* gte, int16_t* V, uint32_t instr) { - light_transform(gte, V, instr); - light_color(gte, instr); - // Output directly (no RGBC multiply) - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); -} - -void gte_ncs(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncs_internal(gte, gte->V0, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// NCT (0x20) — Normal Color Triple -// --------------------------------------------------------------------------- -void gte_nct(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - gte_ncs_internal(gte, gte->V0, instr); - gte_ncs_internal(gte, gte->V1, instr); - gte_ncs_internal(gte, gte->V2, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// DPCS (0x10) — Depth Cueing Single (from RGBC color) -// --------------------------------------------------------------------------- -void gte_dpcs(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - uint8_t r = (gte->RGBC >> 0) & 0xFF; - uint8_t g = (gte->RGBC >> 8) & 0xFF; - uint8_t b = (gte->RGBC >> 16) & 0xFF; - // MAC = RGBC << 4 - gte->IR1 = gte->saturate_ir(r << 4, 1, false); - gte->IR2 = gte->saturate_ir(g << 4, 2, false); - gte->IR3 = gte->saturate_ir(b << 4, 3, false); - // Interpolate toward far color using IR0 - depth_cue_from_ir(gte, instr); - // Output - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// DPCT (0x2A) — Depth Cueing Triple (from RGB FIFO entries) -// --------------------------------------------------------------------------- -void gte_dpct(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - for (int i = 0; i < 3; i++) { - uint8_t r = (gte->RGB[0] >> 0) & 0xFF; - uint8_t g = (gte->RGB[0] >> 8) & 0xFF; - uint8_t b = (gte->RGB[0] >> 16) & 0xFF; - gte->IR1 = gte->saturate_ir(r << 4, 1, false); - gte->IR2 = gte->saturate_ir(g << 4, 2, false); - gte->IR3 = gte->saturate_ir(b << 4, 3, false); - depth_cue_from_ir(gte, instr); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - } - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// DPCL (0x29) — Depth Cueing Light (from IR, uses existing lighting result) -// --------------------------------------------------------------------------- -void gte_dpcl(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - // IR already contains lighting result, multiply by RGBC - uint8_t r = (gte->RGBC >> 0) & 0xFF; - uint8_t g = (gte->RGBC >> 8) & 0xFF; - uint8_t b = (gte->RGBC >> 16) & 0xFF; - gte->MAC1 = (r * gte->IR1) >> 8; - gte->MAC2 = (g * gte->IR2) >> 8; - gte->MAC3 = (b * gte->IR3) >> 8; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, gte_instr_lm(instr)); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, gte_instr_lm(instr)); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, gte_instr_lm(instr)); - // Depth cue toward far color - depth_cue_from_ir(gte, instr); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// INTPL (0x11) — Interpolation (IR toward far color using IR0) -// --------------------------------------------------------------------------- -void gte_intpl(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - depth_cue_from_ir(gte, instr); - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// CDP (0x14) — Color Depth Cue (light color + depth cue, no normal transform) -// --------------------------------------------------------------------------- -void gte_cdp(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - // IR1/IR2/IR3 already set (from previous NCS or similar) - light_color(gte, instr); - depth_cue_from_ir(gte, instr); - color_output(gte, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// CC (0x1C) — Color Color (light color + vertex color, no depth cue) -// --------------------------------------------------------------------------- -void gte_cc(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - light_color(gte, instr); - color_output(gte, instr); - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// MVMVA (0x12) — Matrix Vector Multiply Add -// Highly configurable: matrix, vector, and translation selected by bits -// --------------------------------------------------------------------------- -void gte_mvmva(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - uint32_t mx = (instr >> 17) & 3; // Matrix: 0=RT, 1=Light, 2=LightColor, 3=reserved - uint32_t vv = (instr >> 15) & 3; // Vector: 0=V0, 1=V1, 2=V2, 3=IR - uint32_t tv = (instr >> 13) & 3; // Translation: 0=TR, 1=BK, 2=FC/bugged, 3=none - int sf = (instr >> 19) & 1; // Shift: 0=no shift, 1=shift right 12 - - // Select matrix - int16_t M[3][3]; - switch (mx) { - case 0: std::memcpy(M, gte->RT, sizeof(M)); break; - case 1: std::memcpy(M, gte->L, sizeof(M)); break; - case 2: std::memcpy(M, gte->LC, sizeof(M)); break; - default: std::memset(M, 0, sizeof(M)); break; // Garbage on real HW - } - - // Select vector - int16_t V[3]; - switch (vv) { - case 0: V[0] = gte->V0[0]; V[1] = gte->V0[1]; V[2] = gte->V0[2]; break; - case 1: V[0] = gte->V1[0]; V[1] = gte->V1[1]; V[2] = gte->V1[2]; break; - case 2: V[0] = gte->V2[0]; V[1] = gte->V2[1]; V[2] = gte->V2[2]; break; - case 3: V[0] = gte->IR1; V[1] = gte->IR2; V[2] = gte->IR3; break; - } - - // Select translation vector - int64_t T[3]; - switch (tv) { - case 0: T[0] = (int64_t)gte->TR[0] * 4096; T[1] = (int64_t)gte->TR[1] * 4096; T[2] = (int64_t)gte->TR[2] * 4096; break; - case 1: T[0] = (int64_t)gte->BK[0] * 4096; T[1] = (int64_t)gte->BK[1] * 4096; T[2] = (int64_t)gte->BK[2] * 4096; break; - case 2: T[0] = (int64_t)gte->FC[0] * 4096; T[1] = (int64_t)gte->FC[1] * 4096; T[2] = (int64_t)gte->FC[2] * 4096; break; - case 3: T[0] = 0; T[1] = 0; T[2] = 0; break; - } - - // Multiply - int64_t mac1 = T[0] + (int64_t)M[0][0] * V[0] + (int64_t)M[0][1] * V[1] + (int64_t)M[0][2] * V[2]; - int64_t mac2 = T[1] + (int64_t)M[1][0] * V[0] + (int64_t)M[1][1] * V[1] + (int64_t)M[1][2] * V[2]; - int64_t mac3 = T[2] + (int64_t)M[2][0] * V[0] + (int64_t)M[2][1] * V[1] + (int64_t)M[2][2] * V[2]; - - if (sf) { - mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; - } - - gte->check_mac_overflow(mac1, 1); - gte->check_mac_overflow(mac2, 2); - gte->check_mac_overflow(mac3, 3); - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// OP (0x0C) — Outer Product of two vectors (cross product) -// --------------------------------------------------------------------------- -void gte_op(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - // D1=RT[0][0], D2=RT[1][1], D3=RT[2][2] (rotation matrix diagonal) - int16_t d1 = gte->RT[0][0]; - int16_t d2 = gte->RT[1][1]; - int16_t d3 = gte->RT[2][2]; - - int64_t mac1 = (int64_t)d2 * gte->IR3 - (int64_t)d3 * gte->IR2; - int64_t mac2 = (int64_t)d3 * gte->IR1 - (int64_t)d1 * gte->IR3; - int64_t mac3 = (int64_t)d1 * gte->IR2 - (int64_t)d2 * gte->IR1; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// SQR (0x28) — Square of IR vector -// --------------------------------------------------------------------------- -void gte_sqr(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - - int64_t mac1 = (int64_t)gte->IR1 * gte->IR1; - int64_t mac2 = (int64_t)gte->IR2 * gte->IR2; - int64_t mac3 = (int64_t)gte->IR3 * gte->IR3; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// GPF (0x3D) — General Purpose Interpolation (IR0 * IR) -// --------------------------------------------------------------------------- -void gte_gpf(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - - int64_t mac1 = (int64_t)gte->IR0 * gte->IR1; - int64_t mac2 = (int64_t)gte->IR0 * gte->IR2; - int64_t mac3 = (int64_t)gte->IR0 * gte->IR3; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// GPL (0x3E) — General Purpose Interpolation (MAC + IR0 * IR) -// --------------------------------------------------------------------------- -void gte_gpl(GTEState* gte, uint32_t instr) { - gte->FLAG = 0; - int sf = (instr >> 19) & 1; - - int64_t mac1 = (sf ? (int64_t)gte->MAC1 * 4096 : (int64_t)gte->MAC1) + (int64_t)gte->IR0 * gte->IR1; - int64_t mac2 = (sf ? (int64_t)gte->MAC2 * 4096 : (int64_t)gte->MAC2) + (int64_t)gte->IR0 * gte->IR2; - int64_t mac3 = (sf ? (int64_t)gte->MAC3 * 4096 : (int64_t)gte->MAC3) + (int64_t)gte->IR0 * gte->IR3; - - if (sf) { mac1 >>= 12; mac2 >>= 12; mac3 >>= 12; } - - gte->MAC1 = static_cast(mac1); - gte->MAC2 = static_cast(mac2); - gte->MAC3 = static_cast(mac3); - - bool lm = (instr >> 10) & 1; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); - - gte->push_rgb( - gte->saturate_color(gte->MAC1 >> 4, 0), - gte->saturate_color(gte->MAC2 >> 4, 1), - gte->saturate_color(gte->MAC3 >> 4, 2)); - - gte->set_error_flag(); -} - -// --------------------------------------------------------------------------- -// MTC2 — Move To Coprocessor 2 (write GTE data register) -// --------------------------------------------------------------------------- -void gte_mtc2(GTEState* gte, uint8_t reg, uint32_t value) { - switch (reg) { - case 0: gte->V0[0] = value & 0xFFFF; gte->V0[1] = value >> 16; break; - case 1: gte->V0[2] = value & 0xFFFF; break; - case 2: gte->V1[0] = value & 0xFFFF; gte->V1[1] = value >> 16; break; - case 3: gte->V1[2] = value & 0xFFFF; break; - case 4: gte->V2[0] = value & 0xFFFF; gte->V2[1] = value >> 16; break; - case 5: gte->V2[2] = value & 0xFFFF; break; - case 6: gte->RGBC = value; break; - case 7: gte->OTZ = value & 0xFFFF; break; - case 8: gte->IR0 = static_cast(value & 0xFFFF); break; - case 9: gte->IR1 = static_cast(value & 0xFFFF); break; - case 10: gte->IR2 = static_cast(value & 0xFFFF); break; - case 11: gte->IR3 = static_cast(value & 0xFFFF); break; - case 12: gte->SXY[0] = value; break; - case 13: gte->SXY[1] = value; break; - case 14: gte->SXY[2] = value; gte->SXY[3] = value; break; - case 15: // SXYP — push to FIFO - gte->SXY[0] = gte->SXY[1]; gte->SXY[1] = gte->SXY[2]; - gte->SXY[2] = value; gte->SXY[3] = value; break; - case 16: gte->SZ[0] = value & 0xFFFF; break; - case 17: gte->SZ[1] = value & 0xFFFF; break; - case 18: gte->SZ[2] = value & 0xFFFF; break; - case 19: gte->SZ[3] = value & 0xFFFF; break; - case 20: gte->RGB[0] = value; break; - case 21: gte->RGB[1] = value; break; - case 22: gte->RGB[2] = value; break; - case 23: break; // RES1 reserved - case 24: gte->MAC0 = value; break; - case 25: gte->MAC1 = value; break; - case 26: gte->MAC2 = value; break; - case 27: gte->MAC3 = value; break; - case 28: // IRGB — packed 5-bit RGB → IR1/IR2/IR3 - gte->IR1 = (value & 0x1F) << 7; - gte->IR2 = ((value >> 5) & 0x1F) << 7; - gte->IR3 = ((value >> 10) & 0x1F) << 7; - break; - case 29: break; // ORGB read-only - case 30: // LZCS — set source and compute LZCR - gte->LZCS = static_cast(value); - { - uint32_t v = value; - if ((int32_t)value < 0) v = ~v; - int cnt = 0; - if (v == 0) { cnt = 32; } - else { while (!(v & 0x80000000u)) { v <<= 1; cnt++; } } - gte->LZCR = cnt; - } - break; - case 31: break; // LZCR read-only - default: break; - } -} - -// --------------------------------------------------------------------------- -// MFC2 — Move From Coprocessor 2 (read GTE data register) -// --------------------------------------------------------------------------- -uint32_t gte_mfc2(GTEState* gte, uint8_t reg) { - switch (reg) { - case 0: return (static_cast(gte->V0[1]) << 16) | static_cast(gte->V0[0]); - case 1: return static_cast(gte->V0[2]); - case 2: return (static_cast(gte->V1[1]) << 16) | static_cast(gte->V1[0]); - case 3: return static_cast(gte->V1[2]); - case 4: return (static_cast(gte->V2[1]) << 16) | static_cast(gte->V2[0]); - case 5: return static_cast(gte->V2[2]); - case 6: return gte->RGBC; - case 7: return gte->OTZ; - case 8: return static_cast(gte->IR0); // sign-extend - case 9: return static_cast(gte->IR1); - case 10: return static_cast(gte->IR2); - case 11: return static_cast(gte->IR3); - case 12: return gte->SXY[0]; - case 13: return gte->SXY[1]; - case 14: return gte->SXY[2]; - case 15: return gte->SXY[3]; - case 16: return gte->SZ[0]; - case 17: return gte->SZ[1]; - case 18: return gte->SZ[2]; - case 19: return gte->SZ[3]; - case 20: return gte->RGB[0]; - case 21: return gte->RGB[1]; - case 22: return gte->RGB[2]; - case 23: return 0; // RES1 - case 24: return gte->MAC0; - case 25: return gte->MAC1; - case 26: return gte->MAC2; - case 27: return gte->MAC3; - case 28: case 29: { // IRGB/ORGB - uint32_t r = std::clamp((int)(gte->IR1 >> 7), 0, 0x1F); - uint32_t g = std::clamp((int)(gte->IR2 >> 7), 0, 0x1F); - uint32_t b = std::clamp((int)(gte->IR3 >> 7), 0, 0x1F); - return (b << 10) | (g << 5) | r; - } - case 30: return gte->LZCS; - case 31: return gte->LZCR; - default: return 0; - } -} - -// --------------------------------------------------------------------------- -// CTC2 — Move To Coprocessor 2 Control (write GTE control register) -// --------------------------------------------------------------------------- -void gte_ctc2(GTEState* gte, uint8_t reg, uint32_t value) { - switch (reg) { - case 0: gte->RT[0][0] = value & 0xFFFF; gte->RT[0][1] = value >> 16; break; - case 1: gte->RT[0][2] = value & 0xFFFF; gte->RT[1][0] = value >> 16; break; - case 2: gte->RT[1][1] = value & 0xFFFF; gte->RT[1][2] = value >> 16; break; - case 3: gte->RT[2][0] = value & 0xFFFF; gte->RT[2][1] = value >> 16; break; - case 4: gte->RT[2][2] = value & 0xFFFF; break; - case 5: gte->TR[0] = value; break; - case 6: gte->TR[1] = value; break; - case 7: gte->TR[2] = value; break; - case 8: gte->L[0][0] = value & 0xFFFF; gte->L[0][1] = value >> 16; break; - case 9: gte->L[0][2] = value & 0xFFFF; gte->L[1][0] = value >> 16; break; - case 10: gte->L[1][1] = value & 0xFFFF; gte->L[1][2] = value >> 16; break; - case 11: gte->L[2][0] = value & 0xFFFF; gte->L[2][1] = value >> 16; break; - case 12: gte->L[2][2] = value & 0xFFFF; break; - case 13: gte->BK[0] = value; break; - case 14: gte->BK[1] = value; break; - case 15: gte->BK[2] = value; break; - case 16: gte->LC[0][0] = value & 0xFFFF; gte->LC[0][1] = value >> 16; break; - case 17: gte->LC[0][2] = value & 0xFFFF; gte->LC[1][0] = value >> 16; break; - case 18: gte->LC[1][1] = value & 0xFFFF; gte->LC[1][2] = value >> 16; break; - case 19: gte->LC[2][0] = value & 0xFFFF; gte->LC[2][1] = value >> 16; break; - case 20: gte->LC[2][2] = value & 0xFFFF; break; - case 21: gte->FC[0] = value; break; - case 22: gte->FC[1] = value; break; - case 23: gte->FC[2] = value; break; - case 24: gte->OFX = value; break; - case 25: gte->OFY = value; break; - case 26: gte->H = value & 0xFFFF; break; - case 27: gte->DQA = static_cast(value & 0xFFFF); break; - case 28: gte->DQB = value; break; - case 29: gte->ZSF3 = static_cast(value & 0xFFFF); break; - case 30: gte->ZSF4 = static_cast(value & 0xFFFF); break; - case 31: gte->FLAG = value & 0x7FFFF000u; break; - default: break; - } -} - -// --------------------------------------------------------------------------- -// CFC2 — Move From Coprocessor 2 Control (read GTE control register) -// --------------------------------------------------------------------------- -uint32_t gte_cfc2(GTEState* gte, uint8_t reg) { - switch (reg) { - case 0: return (static_cast(gte->RT[0][1]) << 16) | static_cast(gte->RT[0][0]); - case 1: return (static_cast(gte->RT[1][0]) << 16) | static_cast(gte->RT[0][2]); - case 2: return (static_cast(gte->RT[1][2]) << 16) | static_cast(gte->RT[1][1]); - case 3: return (static_cast(gte->RT[2][1]) << 16) | static_cast(gte->RT[2][0]); - case 4: return static_cast(gte->RT[2][2]); - case 5: return gte->TR[0]; - case 6: return gte->TR[1]; - case 7: return gte->TR[2]; - case 8: return (static_cast(gte->L[0][1]) << 16) | static_cast(gte->L[0][0]); - case 9: return (static_cast(gte->L[1][0]) << 16) | static_cast(gte->L[0][2]); - case 10: return (static_cast(gte->L[1][2]) << 16) | static_cast(gte->L[1][1]); - case 11: return (static_cast(gte->L[2][1]) << 16) | static_cast(gte->L[2][0]); - case 12: return static_cast(gte->L[2][2]); - case 13: return gte->BK[0]; - case 14: return gte->BK[1]; - case 15: return gte->BK[2]; - case 16: return (static_cast(gte->LC[0][1]) << 16) | static_cast(gte->LC[0][0]); - case 17: return (static_cast(gte->LC[1][0]) << 16) | static_cast(gte->LC[0][2]); - case 18: return (static_cast(gte->LC[1][2]) << 16) | static_cast(gte->LC[1][1]); - case 19: return (static_cast(gte->LC[2][1]) << 16) | static_cast(gte->LC[2][0]); - case 20: return static_cast(gte->LC[2][2]); - case 21: return gte->FC[0]; - case 22: return gte->FC[1]; - case 23: return gte->FC[2]; - case 24: return gte->OFX; - case 25: return gte->OFY; - case 26: return gte->H; - case 27: return static_cast(gte->DQA); // sign-extend - case 28: return gte->DQB; - case 29: return static_cast(gte->ZSF3); - case 30: return static_cast(gte->ZSF4); - case 31: return gte->FLAG; - default: return 0; - } -} - -} // namespace GTE -} // namespace PSXRecomp - -// --------------------------------------------------------------------------- -// gte_execute — C-linkage bridge called from generated code -// --------------------------------------------------------------------------- -static uint64_t s_gte_exec_count = 0; -extern "C" uint64_t gte_get_exec_count(void) { return s_gte_exec_count; } -static uint32_t s_gte_replay_saved_caller_ra = 0; -extern "C" int gte_replay_side_effects_begin(void) { - using namespace PSXRecomp::GTE; - if (s_gte_replay_sandbox) return 0; - gte_precision_word_write_begin(); - s_gte_replay_saved_caller_ra = s_gte_caller_ra; - s_gte_replay_sandbox = 1; - return 1; -} -extern "C" void gte_replay_side_effects_end(void) { - using namespace PSXRecomp::GTE; - if (!s_gte_replay_sandbox) return; - s_gte_caller_ra = s_gte_replay_saved_caller_ra; - s_gte_replay_sandbox = 0; - gte_precision_word_write_begin(); -} - -static uint32_t gte_cpu_lzcr(uint32_t value); - -static inline int16_t gte_unpack_s16(uint32_t value) { - return static_cast(value & 0xFFFFu); -} - -static inline uint32_t gte_pack_s16_pair(int16_t lo, int16_t hi) { - return static_cast(static_cast(lo)) | - (static_cast(static_cast(hi)) << 16); -} - -/* Exact full marshaling for GTE commands. Keep this explicit: GTEState has - * mixed-width fields and padding, while CPUState exposes architectural 32-bit - * register words. Whole-struct copies would be layout/endian dependent. */ -static void gte_import_cpu_state(PSXRecomp::GTE::GTEState* gte, - const CPUState* cpu) { - const uint32_t* d = cpu->gte_data; - const uint32_t* c = cpu->gte_ctrl; - - gte->V0[0] = gte_unpack_s16(d[0]); - gte->V0[1] = gte_unpack_s16(d[0] >> 16); - gte->V0[2] = gte_unpack_s16(d[1]); - gte->V1[0] = gte_unpack_s16(d[2]); - gte->V1[1] = gte_unpack_s16(d[2] >> 16); - gte->V1[2] = gte_unpack_s16(d[3]); - gte->V2[0] = gte_unpack_s16(d[4]); - gte->V2[1] = gte_unpack_s16(d[4] >> 16); - gte->V2[2] = gte_unpack_s16(d[5]); - gte->RGBC = d[6]; - gte->OTZ = static_cast(d[7]); - gte->IR0 = gte_unpack_s16(d[8]); - gte->IR1 = gte_unpack_s16(d[9]); - gte->IR2 = gte_unpack_s16(d[10]); - gte->IR3 = gte_unpack_s16(d[11]); - gte->SXY[0] = static_cast(d[12]); - gte->SXY[1] = static_cast(d[13]); - gte->SXY[2] = static_cast(d[14]); - gte->SXY[3] = static_cast(d[14]); - for (int i = 0; i < 4; ++i) gte->SZ[i] = static_cast(d[16 + i]); - for (int i = 0; i < 3; ++i) gte->RGB[i] = d[20 + i]; - gte->MAC0 = static_cast(d[24]); - gte->MAC1 = static_cast(d[25]); - gte->MAC2 = static_cast(d[26]); - gte->MAC3 = static_cast(d[27]); - /* Skip IRGB (28): importing it would quantize and overwrite IR1..3. */ - gte->LZCS = static_cast(d[30]); - gte->LZCR = static_cast(gte_cpu_lzcr(d[30])); - - gte->RT[0][0] = gte_unpack_s16(c[0]); - gte->RT[0][1] = gte_unpack_s16(c[0] >> 16); - gte->RT[0][2] = gte_unpack_s16(c[1]); - gte->RT[1][0] = gte_unpack_s16(c[1] >> 16); - gte->RT[1][1] = gte_unpack_s16(c[2]); - gte->RT[1][2] = gte_unpack_s16(c[2] >> 16); - gte->RT[2][0] = gte_unpack_s16(c[3]); - gte->RT[2][1] = gte_unpack_s16(c[3] >> 16); - gte->RT[2][2] = gte_unpack_s16(c[4]); - for (int i = 0; i < 3; ++i) gte->TR[i] = static_cast(c[5 + i]); - - gte->L[0][0] = gte_unpack_s16(c[8]); - gte->L[0][1] = gte_unpack_s16(c[8] >> 16); - gte->L[0][2] = gte_unpack_s16(c[9]); - gte->L[1][0] = gte_unpack_s16(c[9] >> 16); - gte->L[1][1] = gte_unpack_s16(c[10]); - gte->L[1][2] = gte_unpack_s16(c[10] >> 16); - gte->L[2][0] = gte_unpack_s16(c[11]); - gte->L[2][1] = gte_unpack_s16(c[11] >> 16); - gte->L[2][2] = gte_unpack_s16(c[12]); - for (int i = 0; i < 3; ++i) gte->BK[i] = static_cast(c[13 + i]); - - gte->LC[0][0] = gte_unpack_s16(c[16]); - gte->LC[0][1] = gte_unpack_s16(c[16] >> 16); - gte->LC[0][2] = gte_unpack_s16(c[17]); - gte->LC[1][0] = gte_unpack_s16(c[17] >> 16); - gte->LC[1][1] = gte_unpack_s16(c[18]); - gte->LC[1][2] = gte_unpack_s16(c[18] >> 16); - gte->LC[2][0] = gte_unpack_s16(c[19]); - gte->LC[2][1] = gte_unpack_s16(c[19] >> 16); - gte->LC[2][2] = gte_unpack_s16(c[20]); - for (int i = 0; i < 3; ++i) gte->FC[i] = static_cast(c[21 + i]); - gte->OFX = static_cast(c[24]); - gte->OFY = static_cast(c[25]); - gte->H = static_cast(c[26]); - gte->DQA = gte_unpack_s16(c[27]); - gte->DQB = static_cast(c[28]); - gte->ZSF3 = gte_unpack_s16(c[29]); - gte->ZSF4 = gte_unpack_s16(c[30]); - gte->FLAG = c[31] & 0x7FFFF000u; -} - -static uint32_t gte_state_pack_irgb(const PSXRecomp::GTE::GTEState* gte) { - const uint32_t r = static_cast(std::clamp( - static_cast(gte->IR1 >> 7), 0, 0x1F)); - const uint32_t g = static_cast(std::clamp( - static_cast(gte->IR2 >> 7), 0, 0x1F)); - const uint32_t b = static_cast(std::clamp( - static_cast(gte->IR3 >> 7), 0, 0x1F)); - return (b << 10) | (g << 5) | r; -} - -static void gte_export_cpu_state(CPUState* cpu, - const PSXRecomp::GTE::GTEState* gte) { - uint32_t* d = cpu->gte_data; - uint32_t* c = cpu->gte_ctrl; - - d[0] = gte_pack_s16_pair(gte->V0[0], gte->V0[1]); - d[1] = static_cast(gte->V0[2]); - d[2] = gte_pack_s16_pair(gte->V1[0], gte->V1[1]); - d[3] = static_cast(gte->V1[2]); - d[4] = gte_pack_s16_pair(gte->V2[0], gte->V2[1]); - d[5] = static_cast(gte->V2[2]); - d[6] = gte->RGBC; - d[7] = gte->OTZ; - d[8] = static_cast(static_cast(gte->IR0)); - d[9] = static_cast(static_cast(gte->IR1)); - d[10] = static_cast(static_cast(gte->IR2)); - d[11] = static_cast(static_cast(gte->IR3)); - d[12] = static_cast(gte->SXY[0]); - d[13] = static_cast(gte->SXY[1]); - d[14] = static_cast(gte->SXY[2]); - d[15] = static_cast(gte->SXY[3]); - for (int i = 0; i < 4; ++i) d[16 + i] = gte->SZ[i]; - for (int i = 0; i < 3; ++i) d[20 + i] = gte->RGB[i]; - d[23] = 0; - d[24] = static_cast(gte->MAC0); - d[25] = static_cast(gte->MAC1); - d[26] = static_cast(gte->MAC2); - d[27] = static_cast(gte->MAC3); - d[28] = d[29] = gte_state_pack_irgb(gte); - d[30] = static_cast(gte->LZCS); - d[31] = static_cast(gte->LZCR); - - c[0] = gte_pack_s16_pair(gte->RT[0][0], gte->RT[0][1]); - c[1] = gte_pack_s16_pair(gte->RT[0][2], gte->RT[1][0]); - c[2] = gte_pack_s16_pair(gte->RT[1][1], gte->RT[1][2]); - c[3] = gte_pack_s16_pair(gte->RT[2][0], gte->RT[2][1]); - c[4] = static_cast(gte->RT[2][2]); - for (int i = 0; i < 3; ++i) c[5 + i] = static_cast(gte->TR[i]); - c[8] = gte_pack_s16_pair(gte->L[0][0], gte->L[0][1]); - c[9] = gte_pack_s16_pair(gte->L[0][2], gte->L[1][0]); - c[10] = gte_pack_s16_pair(gte->L[1][1], gte->L[1][2]); - c[11] = gte_pack_s16_pair(gte->L[2][0], gte->L[2][1]); - c[12] = static_cast(gte->L[2][2]); - for (int i = 0; i < 3; ++i) c[13 + i] = static_cast(gte->BK[i]); - c[16] = gte_pack_s16_pair(gte->LC[0][0], gte->LC[0][1]); - c[17] = gte_pack_s16_pair(gte->LC[0][2], gte->LC[1][0]); - c[18] = gte_pack_s16_pair(gte->LC[1][1], gte->LC[1][2]); - c[19] = gte_pack_s16_pair(gte->LC[2][0], gte->LC[2][1]); - c[20] = static_cast(gte->LC[2][2]); - for (int i = 0; i < 3; ++i) c[21 + i] = static_cast(gte->FC[i]); - c[24] = static_cast(gte->OFX); - c[25] = static_cast(gte->OFY); - c[26] = gte->H; - c[27] = static_cast(static_cast(gte->DQA)); - c[28] = static_cast(gte->DQB); - c[29] = static_cast(static_cast(gte->ZSF3)); - c[30] = static_cast(static_cast(gte->ZSF4)); - c[31] = gte->FLAG; -} - -static void gte_run_command(PSXRecomp::GTE::GTEState* gte, uint32_t cmd) { - using namespace PSXRecomp::GTE; - switch (cmd & 0x3Fu) { - case 0x01: gte_rtps(gte, cmd); break; - case 0x06: gte_nclip(gte, cmd); break; - case 0x0C: gte_op(gte, cmd); break; - case 0x10: gte_dpcs(gte, cmd); break; - case 0x11: gte_intpl(gte, cmd); break; - case 0x12: gte_mvmva(gte, cmd); break; - case 0x13: gte_ncds(gte, cmd); break; - case 0x14: gte_cdp(gte, cmd); break; - case 0x16: gte_ncdt(gte, cmd); break; - case 0x1B: gte_nccs(gte, cmd); break; - case 0x1C: gte_cc(gte, cmd); break; - case 0x1E: gte_ncs(gte, cmd); break; - case 0x20: gte_nct(gte, cmd); break; - case 0x28: gte_sqr(gte, cmd); break; - case 0x29: gte_dpcl(gte, cmd); break; - case 0x2A: gte_dpct(gte, cmd); break; - case 0x2D: gte_avsz3(gte, cmd); break; - case 0x2E: gte_avsz4(gte, cmd); break; - case 0x30: gte_rtpt(gte, cmd); break; - case 0x3D: gte_gpf(gte, cmd); break; - case 0x3E: gte_gpl(gte, cmd); break; - case 0x3F: gte_ncct(gte, cmd); break; - default: std::exit(1); - } -} - -extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { - using namespace PSXRecomp::GTE; -#ifndef PSX_NO_DEBUG_TOOLS - if (!s_gte_replay_sandbox) s_gte_exec_count++; - s_gte_caller_ra = cpu->gpr[31]; /* dome-locate probe: game fn that issued this projection */ -#endif - - GTEState gte; - gte_import_cpu_state(>e, cpu); - - uint8_t func = cmd & 0x3F; - /* GTE-activity gameplay detector ([widescreen] gte_game_mode): note every - * perspective projection so gpu.c can stamp real 3D frames as gameplay - * (no-op unless the game opts in — early-out on the config flag). */ - if (!s_gte_replay_sandbox && (func == 0x01 || func == 0x30)) - psx_ws_note_gte_project(func == 0x30 ? 3 : 1); -#ifndef PSX_NO_DEBUG_TOOLS - /* INTPL ring: snapshot inputs before the op mutates IR (outputs recorded - * after the switch). Debug tooling only; production does no probe copies. */ - int16_t intpl_pre_ir[4] = {0,0,0,0}; - int32_t intpl_pre_fc[3] = {0,0,0}; - if (func == 0x11) { - intpl_pre_ir[0]=gte.IR0; intpl_pre_ir[1]=gte.IR1; - intpl_pre_ir[2]=gte.IR2; intpl_pre_ir[3]=gte.IR3; - intpl_pre_fc[0]=gte.FC[0]; intpl_pre_fc[1]=gte.FC[1]; intpl_pre_fc[2]=gte.FC[2]; - } -#endif - gte_run_command(>e, cmd); - -#ifndef PSX_NO_DEBUG_TOOLS - if (func == 0x01 || func == 0x30) gte_rtp_record(>e, cmd); - if (func == 0x11) gte_intpl_record(>e, intpl_pre_ir, intpl_pre_fc); -#endif - - gte_export_cpu_state(cpu, >e); - -#ifdef PSX_ENABLE_BLOCK_CYCLES - /* Faithful GTE command completion-stall: arm the per-command deadline - * (serializing back-to-back ops). Any later COP2 register access stalls to - * it. Single shared site for BOTH backends (compiled + dirty interp both - * route GTE commands through gte_execute). */ - psx_gte_set(cpu, psx_gte_cmd_latency(cmd)); -#endif -} - -#ifdef PSX_GTE_REGISTER_TEST -/* Preserved pre-optimization bridge: test oracle for full command marshaling. */ -extern "C" void gte_test_execute_reference(CPUState* cpu, uint32_t cmd) { - using namespace PSXRecomp::GTE; - GTEState gte; - for (uint8_t reg = 0; reg < 32; ++reg) { - if (reg == 15 || reg == 28) continue; - gte_mtc2(>e, reg, cpu->gte_data[reg]); - } - for (uint8_t reg = 0; reg < 32; ++reg) - gte_ctc2(>e, reg, cpu->gte_ctrl[reg]); - switch (cmd & 0x3Fu) { - case 0x01: gte_rtps(>e, cmd); break; - case 0x06: gte_nclip(>e, cmd); break; - case 0x0C: gte_op(>e, cmd); break; - case 0x10: gte_dpcs(>e, cmd); break; - case 0x11: gte_intpl(>e, cmd); break; - case 0x12: gte_mvmva(>e, cmd); break; - case 0x13: gte_ncds(>e, cmd); break; - case 0x14: gte_cdp(>e, cmd); break; - case 0x16: gte_ncdt(>e, cmd); break; - case 0x1B: gte_nccs(>e, cmd); break; - case 0x1C: gte_cc(>e, cmd); break; - case 0x1E: gte_ncs(>e, cmd); break; - case 0x20: gte_nct(>e, cmd); break; - case 0x28: gte_sqr(>e, cmd); break; - case 0x29: gte_dpcl(>e, cmd); break; - case 0x2A: gte_dpct(>e, cmd); break; - case 0x2D: gte_avsz3(>e, cmd); break; - case 0x2E: gte_avsz4(>e, cmd); break; - case 0x30: gte_rtpt(>e, cmd); break; - case 0x3D: gte_gpf(>e, cmd); break; - case 0x3E: gte_gpl(>e, cmd); break; - case 0x3F: gte_ncct(>e, cmd); break; - default: std::exit(1); - } - for (uint8_t reg = 0; reg < 32; ++reg) - cpu->gte_data[reg] = gte_mfc2(>e, reg); - for (uint8_t reg = 0; reg < 32; ++reg) - cpu->gte_ctrl[reg] = gte_cfc2(>e, reg); -} -#endif - -/* C-callable wrappers for GTE register transfers. - * - * CPUState's arrays are the canonical GTE backing store between commands. - * The old wrappers rebuilt a full GTEState by replaying 62 register writes - * for every read, and rebuilt plus exported 64 registers for every write. - * Transfers are common in vertex loops, so that exact-but-accidental work was - * a severe interpreter-only tax. These switches implement the same guest - * register semantics in O(1), without changing the command implementation. */ - -static uint32_t gte_cpu_lzcr(uint32_t value) { - uint32_t bits = (value & 0x80000000u) ? ~value : value; - if (bits == 0) return 32; - uint32_t count = 0; - while ((bits & 0x80000000u) == 0) { - bits <<= 1; - ++count; - } - return count; -} - -static uint32_t gte_cpu_irgb_component(uint32_t value) { - const int32_t ir = static_cast(value & 0xFFFFu); - if (ir <= 0) return 0; - const uint32_t scaled = static_cast(ir) >> 7; - return scaled > 0x1Fu ? 0x1Fu : scaled; -} - -static uint32_t gte_cpu_pack_irgb(const CPUState* cpu) { - const uint32_t r = gte_cpu_irgb_component(cpu->gte_data[9]); - const uint32_t g = gte_cpu_irgb_component(cpu->gte_data[10]); - const uint32_t b = gte_cpu_irgb_component(cpu->gte_data[11]); - return (b << 10) | (g << 5) | r; -} - -static uint32_t gte_sign_extend_16(uint32_t value) { - return static_cast(static_cast( - static_cast(value & 0xFFFFu))); -} - -/* Normalize only the guest-visible backing words. This deliberately does not - * invalidate host precision caches: it is also the compatibility boundary for - * committed AOT C emitted before all masked/aliased writes used helpers. */ -static void gte_cpu_canonicalize_backing(CPUState* cpu) { - static const uint8_t data_u16[] = {1, 3, 5, 7, 16, 17, 18, 19}; - static const uint8_t data_s16[] = {8, 9, 10, 11}; - static const uint8_t ctrl_u16[] = {4, 12, 20, 26}; - static const uint8_t ctrl_s16[] = {27, 29, 30}; - - for (uint8_t reg : data_u16) cpu->gte_data[reg] &= 0xFFFFu; - for (uint8_t reg : data_s16) - cpu->gte_data[reg] = gte_sign_extend_16(cpu->gte_data[reg]); - cpu->gte_data[15] = cpu->gte_data[14]; - cpu->gte_data[23] = 0; - cpu->gte_data[28] = cpu->gte_data[29] = gte_cpu_pack_irgb(cpu); - cpu->gte_data[31] = gte_cpu_lzcr(cpu->gte_data[30]); - - for (uint8_t reg : ctrl_u16) cpu->gte_ctrl[reg] &= 0xFFFFu; - for (uint8_t reg : ctrl_s16) - cpu->gte_ctrl[reg] = gte_sign_extend_16(cpu->gte_ctrl[reg]); -} - -extern "C" uint32_t gte_read_data(CPUState* cpu, uint8_t reg) { - if (reg >= 32) return 0; - switch (reg) { - case 1: case 3: case 5: case 7: - case 16: case 17: case 18: case 19: - return cpu->gte_data[reg] & 0xFFFFu; - case 8: case 9: case 10: case 11: - return gte_sign_extend_16(cpu->gte_data[reg]); - case 15: - return cpu->gte_data[14]; - case 23: - return 0; - case 28: case 29: - return gte_cpu_pack_irgb(cpu); - case 31: - return gte_cpu_lzcr(cpu->gte_data[30]); - default: - return cpu->gte_data[reg]; - } -} - -extern "C" uint32_t gte_read_ctrl(CPUState* cpu, uint8_t reg) { - if (reg >= 32) return 0; - switch (reg) { - case 4: case 12: case 20: case 26: - return cpu->gte_ctrl[reg] & 0xFFFFu; - case 27: case 29: case 30: - return gte_sign_extend_16(cpu->gte_ctrl[reg]); - default: - return cpu->gte_ctrl[reg]; - } -} - -extern "C" void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val) { - if (reg >= 32) return; - gte_cpu_canonicalize_backing(cpu); - switch (reg) { - case 1: case 3: case 5: case 7: - case 16: case 17: case 18: case 19: - cpu->gte_data[reg] = val & 0xFFFFu; - break; - case 8: case 9: case 10: case 11: - cpu->gte_data[reg] = gte_sign_extend_16(val); - if (reg >= 9) { - const uint32_t packed = gte_cpu_pack_irgb(cpu); - cpu->gte_data[28] = packed; - cpu->gte_data[29] = packed; - } - break; - case 12: case 13: - cpu->gte_data[reg] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; - break; - case 14: - cpu->gte_data[14] = val; - cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; - break; - case 15: - cpu->gte_data[12] = cpu->gte_data[13]; - cpu->gte_data[13] = cpu->gte_data[14]; - cpu->gte_data[14] = val; - cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; - break; - case 23: - cpu->gte_data[23] = 0; - break; - case 28: { - cpu->gte_data[9] = (val & 0x1Fu) << 7; - cpu->gte_data[10] = ((val >> 5) & 0x1Fu) << 7; - cpu->gte_data[11] = ((val >> 10) & 0x1Fu) << 7; - const uint32_t packed = val & 0x7FFFu; - cpu->gte_data[28] = packed; - cpu->gte_data[29] = packed; - break; - } - case 29: { - const uint32_t packed = gte_cpu_pack_irgb(cpu); - cpu->gte_data[28] = packed; - cpu->gte_data[29] = packed; - break; - } - case 30: - cpu->gte_data[30] = val; - cpu->gte_data[31] = gte_cpu_lzcr(val); - break; - case 31: - cpu->gte_data[31] = gte_cpu_lzcr(cpu->gte_data[30]); - break; - default: - cpu->gte_data[reg] = val; - break; - } -} - -extern "C" void gte_write_ctrl(CPUState* cpu, uint8_t reg, uint32_t val) { - if (reg >= 32) return; - gte_cpu_canonicalize_backing(cpu); - switch (reg) { - case 4: case 12: case 20: case 26: - cpu->gte_ctrl[reg] = val & 0xFFFFu; - break; - case 27: case 29: case 30: - cpu->gte_ctrl[reg] = gte_sign_extend_16(val); - break; - case 31: - cpu->gte_ctrl[31] = val & 0x7FFFF000u; - break; - default: - cpu->gte_ctrl[reg] = val; - break; - } -} - -extern "C" void gte_canonicalize_cpu_state(CPUState* cpu) { - gte_cpu_canonicalize_backing(cpu); - /* ctrl[31] is emulator state, not a guest CTC2 write. Preserve the - * command-computed summary bit 31 exactly. */ - /* Raw restore replaced RAM and SXY0..2. Drop every host-only provenance - * cache from the previous timeline, including address-keyed packet depth - * and geometry correction entries. */ - gte_precision_timeline_invalidate(); -} - -#ifdef PSX_GTE_REGISTER_TEST -extern "C" void gte_test_set_precise_valid_mask(uint32_t mask) { - for (uint32_t i = 0; i < 4; ++i) - PSXRecomp::GTE::s_precise_sxy[i].valid = (mask >> i) & 1u; -} - -extern "C" uint32_t gte_test_get_precise_valid_mask(void) { - uint32_t mask = 0; - for (uint32_t i = 0; i < 4; ++i) - mask |= (PSXRecomp::GTE::s_precise_sxy[i].valid ? 1u : 0u) << i; - return mask; -} - -extern "C" void gte_test_set_timeline_generations(uint32_t precision, - uint32_t geometry) { - PSXRecomp::GTE::s_precision_generation = precision; - PSXRecomp::GTE::s_geom_generation = geometry; -} - -extern "C" uint32_t gte_test_get_precision_generation(void) { - return PSXRecomp::GTE::s_precision_generation; -} - -extern "C" uint32_t gte_test_get_geometry_generation(void) { - return PSXRecomp::GTE::s_geom_generation; -} - -extern "C" void gte_test_seed_precise_projection(uint32_t index, - uint32_t packed, - int32_t x16, - int32_t y16, - uint16_t z) { - if (index >= 4) return; - auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - p.packed = packed; - p.x16 = x16; - p.y16 = y16; - p.z = z; - p.valid = 1; - p.saturated = 0; - p.unbounded = 0; - p.perspective_valid = 1; -} - -extern "C" void gte_test_set_precise_exact(uint32_t index, int saturated, int unbounded, int32_t exact_x16, int32_t exact_y16) { - if (index >= 4) return; - auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - p.saturated = saturated ? 1 : 0; - p.unbounded = unbounded ? 1 : 0; - p.exact_x16 = exact_x16; - p.exact_y16 = exact_y16; -} - -extern "C" void gte_test_get_precise_exact(uint32_t index, int *saturated, int *unbounded, int32_t *exact_x16, int32_t *exact_y16) { - if (index >= 4) return; - const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - if (saturated) *saturated = p.saturated; - if (unbounded) *unbounded = p.unbounded; - if (exact_x16) *exact_x16 = p.exact_x16; - if (exact_y16) *exact_y16 = p.exact_y16; -} - -extern "C" void gte_test_set_precise_perspective_valid(uint32_t index, - int valid) { - if (index >= 4) return; - PSXRecomp::GTE::s_precise_sxy[index].perspective_valid = valid ? 1 : 0; -} - -extern "C" void gte_test_get_precise_projection(uint32_t index, - uint32_t* packed, - int32_t* x16, - int32_t* y16, - uint16_t* z, - uint8_t* valid) { - if (index >= 4) return; - const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - if (packed) *packed = p.packed; - if (x16) *x16 = p.x16; - if (y16) *y16 = p.y16; - if (z) *z = p.z; - if (valid) *valid = p.valid; -} - -extern "C" void gte_test_seed_geometry(uint32_t packed, int32_t x16, - int32_t y16) { - auto &entry = PSXRecomp::GTE::s_geom_test_cache[ - PSXRecomp::GTE::geom_test_hash(packed)]; - entry.packed = packed; - entry.x16 = x16; - entry.y16 = y16; - entry.generation = PSXRecomp::GTE::s_geom_generation; -} -#endif diff --git a/psxrecomp-overlay/runtime/src/interrupts.c b/psxrecomp-overlay/runtime/src/interrupts.c deleted file mode 100644 index f105a56..0000000 --- a/psxrecomp-overlay/runtime/src/interrupts.c +++ /dev/null @@ -1,1669 +0,0 @@ -/* - * interrupts.c — v4 interrupt delivery for recompiled BIOS. - * - * Pure hardware simulation. No BIOS state, no HLE, no interpreter. - * - * Since recompiled code runs as native C (no per-instruction stepping), - * interrupt delivery happens at dispatch loop boundaries. The dispatch - * loop calls psx_check_interrupts() after each function returns. - * - * Vblank is fired on a dispatch-count schedule to approximate 60 Hz. - * - * ReturnFromException handling: - * On real hardware, ReturnFromException (B0:0x17 or SYSCALL(3)) - * restores the full register context from the TCB and jumps to the - * saved EPC. This effectively "longjmps" out of the exception - * handler, bypassing any remaining chain-walk code. - * - * In our recompiled model the exception handler runs as a nested - * psx_dispatch call. A normal function return would unwind only one - * frame, leaving the chain walker running with corrupted registers. - * We use setjmp/longjmp to model the real hardware behaviour: - * psx_check_interrupts sets a jump point before dispatching the - * handler, and psx_exception_longjmp() (called by the runtime's - * ReturnFromException implementation) longjmps back, unwinding the - * entire handler call tree in one step. - */ - -#include "interrupts.h" -#include "sio.h" -#include "timers.h" -#include "gpu.h" -#include "cdrom.h" -#include "dma.h" -#include "cpu_state.h" -#include "debug_server.h" -#include "event_ring.h" -#include "lockstep.h" -#include "psx_cycles.h" -#include "psx_scheduler.h" -#include "save_point.h" -#include -#include -#include -#include - -/* Event-timeline ring: execution-mode flag owned by dirty_ram_interp.c. The - * static BIOS exception handler is NOT interp code, so we clear it around the - * handler dispatch (see psx_check_interrupts). */ -extern int g_dirty_interp_active; -extern uint32_t g_dirty_safe_resume_pc; - -/* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt; dumped via `irqctx_ring`). */ -#define IRQCTX_RING_CAP 4096u -typedef struct { - uint64_t seq, cycle; - uint32_t frame, istat, imask, sr, d44, cdrom_active, is_vblank; - int dma_depth; - /* Exception-EXIT half (Tomba menu IRQ-resume wedge): filled when this - * delivery returns to the interrupted code, so the ring shows for EVERY - * delivery which escape path fired and whether the interrupted GPRs were - * restored. take_pc/real_epc are the entry-side resume selection. */ - uint32_t take_pc; /* resume PC selected at entry (0 = boundary/sentinel) */ - uint32_t real_epc; /* g_exception_real_epc installed at entry */ - uint32_t exit_pc; /* cpu->pc at the restore decision */ - uint32_t exit_reason; /* g_exc_escape_reason at exit */ - uint32_t same_thread; /* same_thread_resume discriminator result */ - uint32_t restored; /* saved_gpr restore fired */ - uint32_t v1_exit; /* cpu->gpr[3] at exit before restore decision */ - uint32_t v1_saved; /* saved_gpr[3] (interrupted code's v1) */ - uint32_t ra_exit; /* cpu->gpr[31] at exit before restore decision */ - uint32_t ra_saved; /* saved_gpr[31] */ - uint32_t redirects; /* jmp_val==2 RestoreState redirects in this delivery */ - /* Ape memcard native<->interp resume-desync probe: the live sp and the - * dirty pump-site at IRQ ENTRY. The interrupted-thread TCB save captures - * exactly this (resume_pc=take_pc, sp=entry_sp); a pair where take_pc is a - * dirty return point but entry_sp belongs to a native callee's frame is the - * corrupt snapshot (0x801384AC paired with 0x801EFE80). */ - uint32_t entry_sp; /* cpu->gpr[29] at exception entry */ - uint32_t pump_site; /* g_cosim_dirty_pump_site at entry (which delivery path) */ -} IrqCtxEntry; -IrqCtxEntry g_irqctx_ring[IRQCTX_RING_CAP]; -uint64_t g_irqctx_seq = 0; -extern uint64_t psx_get_cycle_count(void); - -/* Record an interrupt-delivery decision into the event ring. GATE outcomes are - * edge-suppressed (they repeat every block while blocked); DELIVER is always - * recorded (once per interrupt); the not-pending (idle) case emits nothing but - * still updates the edge key so the next gate/deliver is captured. */ -static void irq_record_outcome(uint8_t kind, uint8_t detail, uint32_t aux) { - static uint16_t s_last = 0xFFFFu; - uint16_t key = ((uint16_t)kind << 8) | detail; - int repeat = (key == s_last); - s_last = key; - if (kind == EV_NONE) return; /* idle: update key only */ - /* IRQ_DELIVER: aux carries the architectural TAKE-PC (the resume PC), so the - * interrupt-take point can be compared across backends at the SAME guest - * cycle — native takes the IRQ at a basic-block boundary, the dirty-interp - * per-instruction. A take-PC that diverges at identical cycle is the - * take-point-granularity signature (PRINCIPLES "Control Flow Semantics": - * model the interrupt frame explicitly; the class fix is precise event - * slicing). The independent Beetle oracle records the same field. */ - if (kind == EV_IRQ_DELIVER) { event_ring_record_aux(EV_IRQ_DELIVER, detail, aux); return; } - if (!repeat) event_ring_record(kind, detail);/* GATE: edge only */ -} - -/* COP0 register indices */ -#define COP0_SR 12 -#define COP0_CAUSE 13 -#define COP0_EPC 14 - -/* I_STAT and I_MASK are owned by memory.c */ -extern uint32_t i_stat; -extern uint32_t i_mask; - -/* Device-event cycle ring (device_trace.c): every hardware IRQ-raise edge is - * recorded here with its guest cycle. */ -#include "device_trace.h" - -/* Central IRQ-raise choke point. All device sources call this to set their - * I_STAT bit so the device-event ring sees every raise from one place with the - * exact guest cycle. Pure addition over `i_stat |= (1<seq = s_cosim_irq_seq++; - e->cycle = psx_get_cycle_count(); - e->kind = kind; - e->cpu_pc = cpu ? cpu->pc : 0; - e->take_pc = take_pc; - e->dirty_resume_pc = dirty_resume_pc; - e->compiled_resume_pc = compiled_resume_pc; - e->epc = cpu ? cpu->cop0[COP0_EPC] : 0; - e->sr_before = sr_before; - e->sr_after = cpu ? cpu->cop0[COP0_SR] : 0; - e->cause = cpu ? cpu->cop0[COP0_CAUSE] : 0; - e->istat = i_stat; - e->imask = i_mask; - e->func = g_debug_current_func_addr; - e->block = cosim_last_block(); - e->native = overlay_loader_get_inprogress(); - { /* remaining cooldown cycles (0 = inactive) for the cosim ring display */ - uint64_t now = psx_get_cycle_count(); - e->cooldown = (post_exception_cooldown_until > now) - ? (int32_t)(post_exception_cooldown_until - now) : 0; - } - e->dirty_site = g_cosim_dirty_pump_site; -} - -void interrupts_cosim_irq_dump(char *out, int cap) -{ - if (!out || cap <= 0) return; - char *p = out; - size_t rem = (size_t)cap; - uint64_t total = s_cosim_irq_seq < COSIM_IRQ_RING_CAP ? s_cosim_irq_seq : COSIM_IRQ_RING_CAP; - uint64_t count = total; - if (count > 16u) count = 16u; - int w = snprintf(p, rem, "irqtrace count %llu", - (unsigned long long)count); - if (w < 0 || (size_t)w >= rem) { out[cap - 1] = 0; return; } - p += w; rem -= (size_t)w; - for (uint64_t i = 0; i < count && rem > 1; i++) { - uint64_t seq = s_cosim_irq_seq - count + i; - CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; - w = snprintf(p, rem, - " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", - (unsigned long long)e->seq, - e->kind, - (unsigned long long)e->cycle, - e->cpu_pc, e->take_pc, e->dirty_resume_pc, - e->compiled_resume_pc, e->epc, e->sr_before, - e->sr_after, e->cause, e->istat, e->imask, - e->func, e->block, e->native, - e->cooldown, e->dirty_site); - if (w < 0 || (size_t)w >= rem) { break; } - p += w; rem -= (size_t)w; - } - w = snprintf(p, rem, " | deliveries"); - if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } - uint64_t emitted = 0; - for (uint64_t scanned = 0; scanned < total && emitted < 16u && rem > 1; scanned++) { - uint64_t seq = s_cosim_irq_seq - 1u - scanned; - CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; - if (e->kind != 1u) continue; - w = snprintf(p, rem, - " ; seq %llu cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", - (unsigned long long)e->seq, - (unsigned long long)e->cycle, - e->cpu_pc, e->take_pc, e->dirty_resume_pc, - e->compiled_resume_pc, e->epc, e->sr_before, - e->sr_after, e->cause, e->istat, e->imask, - e->func, e->block, e->native, - e->cooldown, e->dirty_site); - if (w < 0 || (size_t)w >= rem) { break; } - p += w; rem -= (size_t)w; - emitted++; - } - uint64_t latest_delivery = UINT64_MAX; - for (uint64_t scanned = 0; scanned < total; scanned++) { - uint64_t seq = s_cosim_irq_seq - 1u - scanned; - CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; - if (e->kind == 1u) { latest_delivery = e->seq; break; } - } - if (latest_delivery != UINT64_MAX && rem > 1) { - w = snprintf(p, rem, " | pre_delivery"); - if (w >= 0 && (size_t)w < rem) { p += w; rem -= (size_t)w; } - uint64_t first = (latest_delivery >= 15u) ? (latest_delivery - 15u) : 0u; - for (uint64_t seq = first; seq <= latest_delivery && rem > 1; seq++) { - CosimIrqEntry *e = &s_cosim_irq_ring[seq & (COSIM_IRQ_RING_CAP - 1u)]; - w = snprintf(p, rem, - " ; seq %llu kind %u cyc %llu pc %08x take %08x dirty %08x compiled %08x epc %08x sr0 %08x sr1 %08x cause %08x istat %08x imask %08x func %08x block %08x native %08x cool %d site %d", - (unsigned long long)e->seq, - e->kind, - (unsigned long long)e->cycle, - e->cpu_pc, e->take_pc, e->dirty_resume_pc, - e->compiled_resume_pc, e->epc, e->sr_before, - e->sr_after, e->cause, e->istat, e->imask, - e->func, e->block, e->native, - e->cooldown, e->dirty_site); - if (w < 0 || (size_t)w >= rem) { break; } - p += w; rem -= (size_t)w; - } - } - if (rem >= 2) { - p[0] = '\n'; - p[1] = 0; - } else if (cap == 1) { - out[0] = 0; - } else { - out[cap - 2] = '\n'; - out[cap - 1] = 0; - } -} -#endif - -static void note_sio_progress_cycle(void) { - uint32_t cur_sio_seq = sio_get_seq(); - if (cur_sio_seq != last_sio_seq_seen) { - last_sio_seq_seen = cur_sio_seq; - last_sio_progress_cycle = psx_get_cycle_count(); - } -} - -static int should_defer_vblank_for_sio(void) { - if (!sio_card_protocol_active()) return 0; - uint64_t now = psx_get_cycle_count(); - uint64_t since_progress = now >= last_sio_progress_cycle - ? now - last_sio_progress_cycle - : 0; - return since_progress < VBLANK_DEFER_STALE_CYCLES; -} - -static void fire_vblank_edge(void) { - /* Subtract one VBlank period rather than reset to 0 so cycle overshoot - * carries forward. Prevents long-running blocks from rounding multiple - * VBlanks together. */ - cycles_since_vblank -= VBLANK_CYCLES; - dispatch_count = 0; - /* DEQUEUE: this VBlank fired. ENQUEUE: next VBlank scheduled one period out. */ - event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, - (uint32_t)psx_get_cycle_count()); - event_ring_record_aux(EV_ENQ, (uint8_t)SRC_VBLANK, - (uint32_t)(psx_get_cycle_count() + VBLANK_CYCLES)); - psx_irq_raise(IRQ_VBLANK, 0); - g_vblank_raise_count++; - event_ring_record(EV_ISTAT_RAISE, IRQ_VBLANK); - gpu_vblank_tick(); /* Toggle LCF (GPUSTAT bit 31) */ -#ifndef PSX_ENABLE_BLOCK_CYCLES - timers_tick(33868); /* ~1 NTSC frame worth of cycles */ - cdrom_tick(); /* Process pending CDROM responses */ -#endif -} - -void interrupts_service_scheduled_events(void) { - note_sio_progress_cycle(); - if (in_exception) return; - while (cycles_since_vblank >= VBLANK_CYCLES) { - if (should_defer_vblank_for_sio()) return; - fire_vblank_edge(); - } -} - -uint32_t interrupts_cycles_to_vblank(void) { - if (cycles_since_vblank >= VBLANK_CYCLES) return 0; - return VBLANK_CYCLES - cycles_since_vblank; -} - -void interrupts_advance_cycles(uint32_t cycles) { - cycles_since_vblank += cycles; - interrupts_service_scheduled_events(); -} - -/* Diagnostic: total times the exception handler was entered (in_exception - * transitioned 0->1). Diff this between two snapshots to measure handler - * dispatch rate. */ -static uint64_t exception_entries_total; - -/* Diagnostic: psx_check_interrupts called while already in_exception (the - * `return` path at line ~159). Real hardware can't double-fault into the - * same handler; on our model this should stay near zero — non-zero means - * something is calling psx_check_interrupts from inside the recompiled - * exception handler tree. */ -static uint64_t exception_reentry_blocks; -/* Nested-exception depth (0 = not in exception). in_exception stays the - * boolean the rest of the runtime reads; this tracks how deep the - * synchronous-handler recursion is so the nested-delivery path (hardware - * IEc-re-enabled semantics, see the gate below) can cap host-stack use. */ -static int exception_nest_depth; -/* Nested-delivery gate veto attribution (freeze_check). One counter per guard - * in the refinement gate below; first-true-wins ordering matches the gate. */ -uint64_t g_nestgate_depth, g_nestgate_rfepend, g_nestgate_escreason, g_nestgate_iec; - -/* After the exception handler returns, suppress the next interrupt delivery - * to give the interrupted code at least one block of execution — matching - * real hardware where at least one instruction runs after RFE before the - * pending interrupt can re-fire. Without this, unhandled interrupts cause - * a livelock: the handler runs, doesn't clear I_STAT, returns, and the - * very next psx_check_interrupts re-enters immediately. */ -/* IRQ raise/deliver/ack telemetry (Tomba 2 exception-reentry-storm diagnosis). - * Always-on, surfaced in the freeze heartbeat. Distinguishes: - * raise≈deliver≈ack≈1/frame → healthy - * deliver≫ack → handler runs but doesn't clear i_stat (re-delivered) - * raise≫1/frame → something keeps RE-RAISING the bit (cycle/condition bug) - * g_vblank_ack_count is incremented in memory.c at the I_STAT write that clears - * the VBLANK bit. The others are incremented here. */ -uint64_t g_vblank_raise_count = 0; /* bit0 set at the cycle-paced raise site */ -uint64_t g_vblank_deliver_count = 0; /* VBLANK delivered to the guest (exception taken) */ -uint64_t g_irq_deliver_count = 0; /* ANY hardware interrupt delivered */ -uint64_t g_cdrom_deliver_count = 0; /* CD IRQ (IRQ_CDROM) delivered to the guest — FMV dispatch probe */ -extern uint64_t g_vblank_ack_count; /* defined in memory.c */ - -/* Blocks of guaranteed main-code forward progress imposed after a CLAIMED - * non-SIO interrupt (DMA/VBLANK/timer/...). With cooldown 0 (the prior blanket - * policy, commit 6d2cb65) the block leader at the interrupted PC re-takes the - * exception before the block body executes, so under a fast-disc DMA flood the - * main code is pinned at one PC forever (reentry-storm freeze). A few blocks - * guarantee the interrupted block — and the field loop — advance between - * deliveries. SIO is exempt (card reads need immediate back-to-back IRQs). - * - * FAITHFULNESS (MMX5 cp-3259 fork, 2026-07-04): the cooldown window is counted - * in GUEST CYCLES, not psx_check_interrupts CALLS. A call is a backend-dependent - * unit — the compiled path checks at block edges, the dirty-RAM interp checks - * per-instruction, and compiled has extra check sites (e.g. at a `jr ra` return - * transition) the interp lacks. A per-call countdown therefore reached zero at a - * DIFFERENT guest cycle in each backend → IRQ delivered at a different cycle/EPC - * → the two backends forked (cosim first-divergence cp 3259 @ cyc ~213.5M). Guest - * cycles are advanced identically by both backends (shared cycle-cost model, ruler- - * validated), so a guest-cycle deadline makes the delivery decision backend- - * independent. Magnitudes below preserve the old behavior's breathing room: the - * claimed window ~= the old "8 blocks", the unclaimed ~= the old "500 blocks" - * (a compiled block edge ~= one psx_check_interrupts call ~= ~15 guest cycles). */ -#define POST_EXC_CLAIMED_COOLDOWN_CYCLES 128u /* claimed non-SIO: a few blocks */ -#define POST_EXC_UNCLAIMED_COOLDOWN_CYCLES 8192u /* unclaimed: generous boot window */ - -/* setjmp target for ReturnFromException during handler dispatch. - * - * IMPORTANT: longjmp(exception_jmpbuf, ...) MUST execute on the same - * Windows fiber that called setjmp; otherwise RSP is restored to that - * fiber's stack while the OS still tracks a different fiber as current, - * corrupting fiber state and eventually deadlocking SwitchToFiber. - * - * s_exception_owner_fiber records which fiber called setjmp. If a - * longjmp request originates on a different fiber, the caller must: - * (1) set s_pending_exception_longjmp = code - * (2) SwitchToFiber back to s_exception_owner_fiber - * That fiber's wrapped SwitchToFiber call will observe the flag on - * return and execute the longjmp on the correct stack. */ -jmp_buf exception_jmpbuf; /* non-static so traps.c can deferred-longjmp */ -/* Monotonic epoch of the exception setjmp frame: bumped each time the setjmp - * loop is (re)armed at exception entry. Lets an observer holding a frame on - * the C stack (the overlay shadow-diff, run_shadow_diff) decide whether a - * longjmp targets a frame BELOW it (armed before it — the unwind escapes its - * frame and skips its epilogue) or a frame armed after it (contained). The - * single global exception_jmpbuf always holds the latest-armed frame, so the - * current epoch identifies every longjmp's target. */ -uint64_t g_exc_setjmp_epoch = 0; -uint64_t psx_exception_setjmp_epoch(void) { return g_exc_setjmp_epoch; } -/* The fiber that owns the current exception setjmp. A longjmp must run on - * that same fiber/stack, so a non-owner defers by switching back to it - * first (see deferred_exception_longjmp). Used on all platforms now that - * the thread scheduler is fiber-based everywhere. */ -void* g_exception_owner_fiber = NULL; -int g_pending_exception_longjmp = 0; -extern int g_psx_dispatch_depth; - -/* Set by psx_check_interrupts_at while a compiled block-leader interrupt check - * is in progress. If the delivered handler later RFEs to the sentinel outside - * the synchronous host window, dirty_ram_dispatch can resume at this guest PC - * instead of treating the sentinel as pc=0 termination. */ -static uint32_t s_compiled_interrupt_resume_pc = 0; -/* Set by psx_check_interrupts_at for the one call it makes: in any other call the latch above is an earlier call's. */ -static int s_resume_pc_given = 0; -static int s_save_poll_owed = 0; -extern void savestate_poll(CPUState* cpu, uint32_t resume_pc); -static uint32_t s_last_interrupt_check_pc = 0; -static uint64_t s_last_interrupt_check_cycle = UINT64_MAX; - -/* Deferred cooperative thread switch from nested exception delivery. - * - * A genuine in-exception ChangeThread (kind-30, escape site below) must be - * honored at the OUTERMOST dispatch boundary, never mid-nested-host-dispatch. - * Only at the outermost boundary is the outgoing thread's guest state fully - * materialized in CPUState (its true block PC is live, GPRs current); nested - * inside a call unit / dirty pump the PC lives only in the resume-PC latch, - * which desyncs from the live GPRs when the IRQ was taken deep inside a nested - * call (the async-poll callback), so switching there saves a poisoned context - * (latched PC ahead of the callback's registers) → the resumed thread dispatches - * a jumptable with a smeared index → misaligned DISPATCH FATAL. When a switch is - * detected nested, we record it here, let the outgoing thread resume - * transparently, and honor it at the next outermost block-leader boundary (top - * of psx_check_interrupts) where the thread can be re-saved cleanly. */ -static int s_defer_switch_pending = 0; -static uint32_t s_defer_switch_target = 0; /* TCB PCB[0] should name after the switch */ -static uint32_t s_defer_switch_from = 0; /* the interrupted thread to re-save cleanly */ - -/* A/B toggle: PSX_DEFER_SWITCH=0 forces the legacy immediate-switch behavior - * (longjmp the instant a mid-exception ChangeThread is detected, regardless of - * nesting) so the deferred-switch fix can be compared against the baseline - * without a rebuild. Default ON. Latched at first query. */ -static int defer_switch_enabled(void) { - static int s = -1; - if (s < 0) { const char *e = getenv("PSX_DEFER_SWITCH"); s = (e && e[0] == '0') ? 0 : 1; } - return s; -} - -static int same_guest_pc(uint32_t a, uint32_t b) { - return (((a ^ b) & 0x1FFFFFFFu) == 0); -} - -int psx_get_in_exception(void) { return in_exception; } - -/* Co-sim (COSIM_ORACLE.md): fold the GENUINE guest-timing interrupt statics into the - * state hash. Deliberately EXCLUDES total_checks / dispatch_count / post_exception_ - * cooldown / s_compiled_interrupt_resume_pc — those are counted in psx_check_interrupts - * CALLS or are backend-internal, so they legitimately differ between the compiled and - * interp backends by call frequency and would be a false first-divergence. The one - * quantity that is a real guest-cycle timing value is cycles_since_vblank (drives the - * VBLANK deadline); in_exception mirrors the architectural handler-active state. */ -uint64_t interrupts_cosim_hash(uint64_t h) { - const uint64_t P = 1099511628211ULL; - uint32_t v = cycles_since_vblank; - for (int i = 0; i < 4; i++) { h ^= (uint8_t)(v >> (i*8)); h *= P; } - uint64_t sp = last_sio_progress_cycle; - for (int i = 0; i < 8; i++) { h ^= (uint8_t)(sp >> (i*8)); h *= P; } - h ^= (uint8_t)in_exception; h *= P; - return h; -} -/* Co-sim field dump: expose the exact irqctl-timing fields so a divergence can be - * field-diffed (is it cycles_since_vblank = VBLANK-raise-timing, or in_exception, ...). */ -void interrupts_cosim_dump(uint32_t *csv, int *inexc) { - if (csv) *csv = cycles_since_vblank; - if (inexc) *inexc = in_exception; -} - -/* ===== Fix B: faithful exception-return escape state (see psx_runtime.h) ===== */ -int g_rfe_escape_pending = 0; -int g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; -uint32_t g_exception_real_epc = 0; -extern void psx_exception_longjmp(void); - -/* Called by the recompiled `rfe` opcode (after it pops the COP0 SR stack). When we - * are inside the SYNCHRONOUS exception-handler window (in_exception) AND the handler - * is returning to a REAL EPC (not the legacy boundary-sentinel fallback), arm the - * host escape so the trampoline unwinds back to psx_check_interrupts after the jr - * commits cpu->pc = real EPC. On a fiber/thread resume (in_exception==0) this is a - * no-op and the real EPC is dispatched directly — that is how a suspended thread - * resumes at its own PC. */ -void psx_rfe_mark_escape(void) { - if (!in_exception) return; - /* Arm on EVERY handler-window RFE, including LEGACY_SENTINEL deliveries. - * The old blanket decline assumed a sentinel delivery always resumes AT - * the sentinel — but a handler that switches threads (the guest's own - * cooperative scheduler moving PCB[0] in its VBlank callback) RFEs to the - * NEW thread's real EPC instead; with nothing armed, no exit path ever - * fires and in_exception wedges the VBlank authority forever (Tomba 2 - * splash livelock). The take site keeps the legacy contract intact: a - * resume that DOES land on the sentinel still declines there and exits - * through the sentinel detector's saved-GPR restore. */ - g_rfe_escape_pending = 1; - if (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) - g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; -} - -/* Called in the dispatch trampoline after a function returns (cpu->pc holds the - * real resume EPC). If an RFE armed the escape inside the synchronous handler, - * longjmp back to psx_check_interrupts (cpu->pc preserved across the longjmp). */ -void psx_rfe_escape_check(CPUState* cpu) { - /* Escape ONLY when this RFE is the synchronous handler completing on the SAME - * fiber that set up the exception (the owner of exception_jmpbuf). in_exception - * is a single global, so a thread RESUMED on a different fiber (its own real EPC - * was restored and it later RFEs) must NOT longjmp here — that would be a - * cross-fiber jump to a stale jmpbuf (crash). For that case we fall through and - * the trampoline keeps dispatching cpu->pc = the real resume EPC, which is - * exactly how a suspended thread continues at its own PC. */ - extern void* psx_fiber_current(void); /* psx_fiber.h is included further down */ - if (g_rfe_escape_pending && in_exception && - cpu->pc != PSX_EXC_SENTINEL_PC && - psx_fiber_current() == g_exception_owner_fiber) { - g_rfe_escape_pending = 0; - /* A LEGACY_SENTINEL delivery escaping to a NON-sentinel PC means the - * handler switched threads and RFE'd to the new thread's real EPC. - * Record the completion truthfully as an RFE return so the epilogue's - * restore discriminator does NOT overwrite the kernel's freshly - * restored TCB registers with the ENTERING thread's saved_gpr (the - * cross-thread corruption Fix B exists to prevent). A resume that - * lands ON the sentinel never reaches this longjmp (declined above): - * it exits through the legacy sentinel detector, whose saved-GPR - * restore contract stays keyed on the untouched LEGACY reason. */ - if (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL) - g_exc_escape_reason = PSX_EXC_ESCAPE_RFE_RETURN; - /* Same shadow-frame hardening as deferred_exception_longjmp. */ - extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch); - overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch); - longjmp(exception_jmpbuf, 1); /* same fiber: safe; lands in psx_check_interrupts */ - } -} - -void psx_get_freeze_diag(uint64_t *out_total_checks, - uint32_t *out_dispatch_count, - int *out_in_exception, - int *out_post_exc_cooldown, - uint64_t *out_exc_entries, - uint64_t *out_exc_reentry_blocks) { - if (out_total_checks) *out_total_checks = total_checks; - if (out_dispatch_count) *out_dispatch_count = dispatch_count; - if (out_in_exception) *out_in_exception = in_exception; - if (out_post_exc_cooldown) { - uint64_t now = psx_get_cycle_count(); - *out_post_exc_cooldown = (post_exception_cooldown_until > now) - ? (int)(post_exception_cooldown_until - now) : 0; - } - if (out_exc_entries) *out_exc_entries = exception_entries_total; - if (out_exc_reentry_blocks) *out_exc_reentry_blocks = exception_reentry_blocks; -} - -void interrupts_init(void) { - dispatch_count = 0; - in_exception = 0; - exception_nest_depth = 0; - g_psx_dispatch_depth = 0; - total_checks = 0; - post_exception_cooldown_until = 0; - exception_entries_total = 0; - exception_reentry_blocks = 0; - cycles_since_vblank = 0; - /* Rematch / session_reboot re-enters bring-up without process exit. - * Stale I_STAT/I_MASK from the prior match (e.g. VBlank pending + game - * mask) makes boot take a phantom IRQ at ~cycle 4 and wedge under - * netplay lockstep. Cold boot also wants zeros (BSS already is). */ - i_stat = 0; - i_mask = 0; - g_vblank_raise_count = 0; - last_sio_seq_seen = sio_get_seq(); - last_sio_progress_cycle = psx_get_cycle_count(); -} - -/* - * Called by the runtime's ReturnFromException implementation (traps.c) - * when the recompiled BIOS handler or a chain callback invokes - * B0:0x17 or SYSCALL(3) during exception handling. - * - * At this point the caller has already: - * - Restored all GPRs from the TCB save area - * - Done RFE on the saved SR - * - Set cpu->pc = saved EPC - * - * We longjmp back to psx_check_interrupts, which will clear - * in_exception and return, effectively letting the interrupted - * code resume at the saved EPC through normal dispatch. - */ -#include "psx_fiber.h" -/* Defer a longjmp to the fiber that owns the current exception setjmp. - * If we're on the owning fiber already, longjmp immediately. Otherwise - * record the requested code, switch back to the owner, and the post-switch - * check in traps.c psx_change_thread_fiber executes the longjmp on the - * correct stack. */ -static void deferred_exception_longjmp(int code) { - /* If this unwind blows through a live shadow-diff frame (a frame armed - * BEFORE the shadow started), restore the shadow-scoped globals its - * skipped epilogue would have restored — otherwise the diff instrument - * silently dies for the rest of the run (s_in_shadow stuck). No-op when - * no shadow is live or the target frame was armed inside the shadow. */ - extern void overlay_loader_shadow_escape_fixup(uint64_t target_epoch); - overlay_loader_shadow_escape_fixup(g_exc_setjmp_epoch); - if (!g_exception_owner_fiber || psx_fiber_current() == g_exception_owner_fiber) { - longjmp(exception_jmpbuf, code); - } - g_pending_exception_longjmp = code; - psx_fiber_switch(g_exception_owner_fiber); - /* If we end up back here, the owner didn't honor the flag (bug). - * Fall through to direct longjmp as a last resort — even though - * the stack is wrong, the alternative is hanging silently. */ - longjmp(exception_jmpbuf, code); -} - -void psx_exception_longjmp(void) { - debug_server_log_restore_event(2, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 1); - deferred_exception_longjmp(1); -} - -void psx_restore_state_escape(void) { - if (in_exception) { - debug_server_log_restore_event(1, debug_cpu_ptr ? debug_cpu_ptr->pc : 0, 2); - deferred_exception_longjmp(2); - } - /* Not in exception context — return normally, let caller's `return;` handle it. */ -} - -/* Cycle-budgeted precise event slicing — single source of truth. - * - * Returns the minimum guest-CPU-cycle distance to the next DELIVERABLE hardware - * interrupt: a source raises its I_STAT bit AND that bit is unmasked in i_mask - * (CPU-side SR/IE gating is checked separately at take time). UINT32_MAX means - * no maskable hardware event is currently scheduled. - * - * The two-tier block executor uses this at each block leader: if a whole block - * fits inside this distance (and the block has no IRQ-visibility side effects), - * it runs as fast compiled C; otherwise it runs through the per-instruction - * interpreter so the interrupt is taken at its exact architectural cycle (see - * PRECISE_IRQ_SLICE.md). Each peripheral owns its own next-IRQ query; this just - * takes the min. Every query is a conservative UNDER-estimate (smaller distance - * => slice more => never run past an event), so this is too. */ -uint32_t cycles_to_next_event(void) { - uint32_t best = 0xFFFFFFFFu; - /* VBLANK is paced here (interrupts.c owns cycles_since_vblank). The deferred - * card-SIO case only pushes VBlank LATER, so this estimate stays a safe - * under-estimate. */ - if (i_mask & (1u << IRQ_VBLANK)) { - uint32_t d = (cycles_since_vblank >= VBLANK_CYCLES) - ? 0u : (VBLANK_CYCLES - cycles_since_vblank); - if (d < best) best = d; - } - uint32_t t = timers_cycles_to_irq(i_mask); if (t < best) best = t; - uint32_t c = cdrom_cycles_to_irq(i_mask); if (c < best) best = c; - uint32_t d = dma_cycles_to_irq(i_mask); if (d < best) best = d; - uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s; - return best; -} - -static uint64_t s_need_defer, s_need_irq, s_skip_none, s_skip_sr; -static uint64_t s_skip_cooldown, s_skip_nested; - -void psx_interrupt_delivery_diag(uint64_t *need_defer, uint64_t *need_irq, - uint64_t *skip_none, uint64_t *skip_sr, - uint64_t *skip_cooldown, uint64_t *skip_nested) { - if (need_defer) *need_defer = s_need_defer; - if (need_irq) *need_irq = s_need_irq; - if (skip_none) *skip_none = s_skip_none; - if (skip_sr) *skip_sr = s_skip_sr; - if (skip_cooldown) *skip_cooldown = s_skip_cooldown; - if (skip_nested) *skip_nested = s_skip_nested; -} - -int psx_interrupt_delivery_needed(const CPUState* cpu) { - if (s_defer_switch_pending) { s_need_defer++; return 1; } - if ((i_stat & i_mask) == 0) { s_skip_none++; return 0; } - - uint32_t sr = cpu->cop0[COP0_SR]; - if (!(sr & 0x01u) || !(sr & (1u << 10))) { s_skip_sr++; return 0; } - - if (post_exception_cooldown_until != 0 && - psx_get_cycle_count() < post_exception_cooldown_until) { - s_skip_cooldown++; - return 0; - } - - if (in_exception && - (exception_nest_depth >= 2 || - g_rfe_escape_pending || - g_exc_escape_reason != PSX_EXC_ESCAPE_NONE)) { - s_skip_nested++; - return 0; - } - s_need_irq++; - return 1; -} - -void psx_check_interrupts(CPUState* cpu) { - const int save_point = s_resume_pc_given; - s_resume_pc_given = 0; - psx_cyc_batch_flush(); - extern int g_ls_suppress_record; -#define PSX_CHECK_INTERRUPTS_RETURN() do { if (g_ls_suppress_record > 0) g_ls_suppress_record--; return; } while (0) -#ifdef PSX_COSIM - extern uint32_t g_dirty_safe_resume_pc; -#define COSIM_IRQ_TAKE_PC() (g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc) -#define COSIM_IRQ_NOTE(kind_) cosim_irq_note(cpu, (kind_), COSIM_IRQ_TAKE_PC(), g_dirty_safe_resume_pc, s_compiled_interrupt_resume_pc, cpu->cop0[COP0_SR]) -#endif - - /* MotK VLC / FMV hot edge: sticky unmasked I_STAT (CD/VBlank) while - * IEc or IM2 is clear — no architectural delivery possible. Skip the - * mid-path bookkeeping / irq_deliver_eval that used to run every BB. - * Guest-visible timing unchanged (same non-delivery). LTO can collapse - * this into the VLC call sites. */ - static uint32_t s_fast_maintenance; - if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { - uint32_t sr = cpu->cop0[COP0_SR]; - if (!(sr & 0x01u) || !(sr & (1u << 10))) { - const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; - if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) - savestate_poll(cpu, s_compiled_interrupt_resume_pc); - if (periodic) debug_server_poll(); - return; - } - } - - /* Genuine entry fast paths. Generated resident code calls this at every - * basic-block edge, which can mean tens of millions of calls inside an FMV - * polling loop. psx_advance_cycles() has already serviced every device - * deadline before this point, so no-pending and IEc-clear checks have no - * architectural work to do. The old fast paths came after diagnostics and - * savestate_poll, making a harmless callback expensive enough to reduce - * Tomba 2's Whoopee FMV to ~1 guest fps. - * - * Idle-skip used to force the slow path on every edge so - * psx_idle_note_check could observe poll PCs. That made MotK's VLC - * (branchy, changing resume PCs, idle_skip=true) pay full IRQ bookkeeping - * millions of times per second for zero skips. Keep the fast path; run the - * idle note here when enabled, then re-check I_STAT in case a skip raised - * an event. Guest timing is unchanged — skip still advances via - * psx_advance_cycles. - * - * Poll host maintenance periodically so save/load and the debug socket stay - * responsive even when the guest spends a long time in this path. At the - * observed 1M+ block edges/s this is sub-frame latency. */ - if (g_idle_skip_enabled < 0) (void)psx_idle_skip_is_enabled(); - /* COP0 software interrupts (CAUSE.IP0/IP1 vs SR.IM0/IM1, bits 8-9): raised - * purely by guest mtc0 to CAUSE, with no INTC involvement. Games use this - * as a re-entrant exception-dispatch mechanism (Jackie Chan Stuntmaster's - * handler saves SR/EPC, sets CAUSE=0x100 + SR=0x101, and relies on the - * immediate sw-int to chain into its second stage; without it the CD INT3 - * ack never runs and the machine deadlocks with SR.IM2 stripped). */ - uint32_t sw_pending = cpu->cop0[COP0_CAUSE] & cpu->cop0[COP0_SR] & 0x0300u; - if (!in_exception && (i_stat & i_mask) == 0 && sw_pending == 0 && - !s_defer_switch_pending) { - if (g_idle_skip_enabled > 0) { - uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc - : s_compiled_interrupt_resume_pc; - s_last_interrupt_check_pc = check_pc; - s_last_interrupt_check_cycle = psx_get_cycle_count(); - psx_idle_note_check(cpu, check_pc); - } - if ((i_stat & i_mask) == 0 && sw_pending == 0) { - const int periodic = (++s_fast_maintenance & 0x3FFFu) == 0; - if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) - savestate_poll(cpu, s_compiled_interrupt_resume_pc); - if (periodic) debug_server_poll(); - return; - } - /* Idle skip advanced time and a device raised I_STAT — deliver below. */ - } - if (in_exception && !(cpu->cop0[COP0_SR] & 0x01u)) return; - - /* Mid path: unmasked IRQ already pending, ordinary compiled edge. - * MotK VLC / FMV spend most BB edges here (sticky CD/VBlank bits). - * Devices are already caught up via psx_advance_cycles, so skip - * service/idle/savestate housekeeping and go straight to delivery. */ - if (!in_exception && !s_defer_switch_pending && (i_stat & i_mask) != 0) { - uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc - : s_compiled_interrupt_resume_pc; - s_last_interrupt_check_pc = check_pc; - s_last_interrupt_check_cycle = psx_get_cycle_count(); - g_ls_suppress_record++; - total_checks++; - const int periodic = (total_checks & 0x3FFFu) == 0; - if (psx_save_poll_due(periodic, save_point, &s_save_poll_owed)) - savestate_poll(cpu, s_compiled_interrupt_resume_pc); - if (periodic) debug_server_poll(); - goto irq_deliver_eval; - } - - { - uint32_t check_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc - : s_compiled_interrupt_resume_pc; - s_last_interrupt_check_pc = check_pc; - s_last_interrupt_check_cycle = psx_get_cycle_count(); - } - g_ls_suppress_record++; - total_checks++; - if ((total_checks & 0x3FFFu) == 0) { - debug_server_poll(); - } - - /* SIO delayed IRQ delivery removed from here. - * sio_tick() is now called only from SIO register accesses - * (sio_read/sio_write) and I_STAT reads (memory.c). The BIOS - * pad detection sequence clears I_STAT bit 7 then polls I_STAT - * waiting for it to re-appear. If we tick here, the IRQ fires - * during the delay loop BEFORE the clear, and the BIOS never - * sees it. */ - - interrupts_service_scheduled_events(); - - /* Idle-loop cycle skip (psx_cycles.c): detect a side-effect-free poll - * loop by its repeated same-PC checks and fast-forward guest time to the - * next internal device event in whole loop quanta. Runs BEFORE the - * deliverability evaluation below so an event raised by the skip is - * delivered in this same check, exactly at the boundary real execution - * would have taken it. Skip when an unmasked IRQ is already pending — - * that is not an idle poll (FMV/VLC edges with sticky CD/VBlank bits), - * and the fast path already noted when I_STAT was clear. */ - if ((i_stat & i_mask) == 0) - psx_idle_note_check(cpu, s_last_interrupt_check_pc); - - /* User save states: this is a block-leader boundary with a known resume PC; - * only act outside the exception handler so a restore's stack-unwind can't - * strand a half-finished handler. Near-free when nothing is staged. A load - * longjmps to the scheduler and never returns here. */ - if (!in_exception && psx_save_poll_due(1, save_point, &s_save_poll_owed)) - savestate_poll(cpu, s_compiled_interrupt_resume_pc); - - /* Deferred cooperative thread switch: honor at the next real thread-save - * boundary (see s_defer_switch_* above). A - * genuine in-exception ChangeThread was deferred because it was detected at a - * poisoned point (the IRQ fired deep inside a nested call, e.g. the - * async-poll callback, so the resume-PC latch desynced from the live GPRs). - * - * The dirty-RAM interpreter also pumps interrupts at synthetic transfer and - * call-return sites. Those sites are precise enough to deliver hardware IRQs, - * but not necessarily safe to snapshot a cooperative thread: the interpreter - * may have committed a candidate resume PC while the live CPUState stack/GPRs - * still describe the previous local frame. Keep the deferral pending there and - * honor it only once execution reaches a normal interrupt poll with site 0. - * Near-free when nothing is pending. */ - if (s_defer_switch_pending && !in_exception) { - if (psx_hle_scheduler_enabled()) { - uint32_t from_tcb = s_defer_switch_from; - uint32_t to_tcb = s_defer_switch_target; - uint32_t resume_pc = s_last_interrupt_check_pc; /* materialized block PC */ - if (from_tcb == 0u || to_tcb == 0u || - psx_sched_current_tcb(cpu) != from_tcb) { - /* STALE: the guest already re-scheduled cooperatively (PCB[0] no - * longer names the deferred thread) - abandon the deferral. This is - * the ONLY case we clear pending without honoring; dropping when a - * clean resume PC merely isn't available yet would LOSE the guest's - * ChangeThread and wedge (the bug this replaces). */ - s_defer_switch_pending = 0; - s_defer_switch_target = 0; - s_defer_switch_from = 0; - debug_server_log_thread_event(33, cpu, from_tcb, to_tcb, resume_pc); - } else if (g_cosim_dirty_pump_site != 0) { - /* Not a real suspend boundary: dirty interpreter pump sites expose - * committed PCs for IRQ timing, but the matching CPUState may not - * be materialized yet. */ - } else if (resume_pc != 0u && (resume_pc & 0x3u) == 0u && - psx_is_dispatchable(resume_pc)) { - /* Materialized clean boundary: re-save the deferred thread cleanly - * (this OVERWRITES any poisoned/sentinel EPC the guest handler wrote - * for it while nested), re-point PCB[0], and honor the switch. */ - extern int overlay_loader_shadow_native_thread_switch_bail(void); - if (overlay_loader_shadow_native_thread_switch_bail()) - PSX_CHECK_INTERRUPTS_RETURN(); - s_defer_switch_pending = 0; - s_defer_switch_target = 0; - s_defer_switch_from = 0; - psx_sched_save_context(cpu, from_tcb, resume_pc); - psx_sched_set_current_tcb(cpu, to_tcb); - debug_server_log_thread_event(32, cpu, from_tcb, to_tcb, resume_pc); - g_dirty_interp_active = 0; - s_compiled_interrupt_resume_pc = 0; - g_sched_escape.target_tcb = to_tcb; - g_sched_escape.resume_pc = 0; - g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB; - longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run */ - } - /* else: KEEP pending - this outermost boundary has no materialized - * resume PC (both latches 0, e.g. a scheduled-event check not tied to a - * block leader). Wait for the next block-leader boundary that does. */ - } - } - - /* Dispatch-loop maintenance only when NOT inside the exception handler. */ - if (!in_exception) { -#if SIO_MODEL_CYCLE_PACED - /* Builds without block-cycle accounting need a small dispatch-loop - * SIO quantum. With PSX_ENABLE_BLOCK_CYCLES, psx_advance_cycles() - * already drives SIO timing, so the fixed quantum would double-count. - * arms shift/ack), so this gate never opens — per-call cost on - * this hot path is one volatile load + one branch. */ -#ifndef PSX_ENABLE_BLOCK_CYCLES - if (g_sio_timing_active) { - sio_tick_quantum(); - } -#endif -#endif - dispatch_count++; - } - - /* Event ring: generic i_stat-edge backstop. Catches raises from sites we - * don't instrument precisely (memory.c MMIO acks, SIO, SPU). Bounded by - * actual transitions, not by check frequency. */ - { - static uint32_t s_last_istat = 0; - if (i_stat != s_last_istat) { - s_last_istat = i_stat; - event_ring_record(EV_ISTAT_CHANGE, 0); - } - } - -irq_deliver_eval: - /* Check if any interrupts are pending (INTC hardware or COP0 software). */ - if ((i_stat & i_mask) == 0 && sw_pending == 0) { irq_record_outcome(EV_NONE, 0, 0); PSX_CHECK_INTERRUPTS_RETURN(); } - /* Nested delivery (hardware semantics). Real R3000A has no 'in exception' - * gate — delivery is governed by SR alone. The handler normally runs with - * IEc=0 (hardware bit-shift on entry), so the SR gates below block - * re-entry faithfully on their own. A game that RE-ENABLES IEc inside the - * handler is asking for a nested interrupt and real hardware delivers it: - * Tomba2's lava attract demo parks in the kernel event-poll INSIDE the - * VBlank handler waiting for the NEXT VBlank — the old unconditional - * in_exception block starved it to ~1 fps (VBlank pending+unmasked, - * ~1100 reentry blocks/s, 1 delivery/s). The synchronous-handler - * machinery below is nesting-safe: the interrupted-context save - * (saved_gpr/hi/lo) and owner state (prev_owner_fiber, prev_pending, - * dispatch depth) are host-stack locals restored on unwind, and - * in_exception is restored to its pre-delivery value at the epilogue. - * Depth cap: a pathological IEc-re-enable loop would recurse the host - * stack; real hardware would wedge the guest instead — we stop nesting - * there (counted) so the rings expose it. */ - if (in_exception) { - /* Refinements over plain SR gating (first attempt wedged BOOT at - * f436): (1) never nest while an RFE/escape unwind is in flight — - * the guest's RFE pops SR (IEc back to 1) BEFORE the host escape - * completes, and a delivery in that window lands in half-unwound - * machinery; (2) one nesting level only — enough for the - * wait-inside-handler idiom, no host-stack pyramids. */ - if (exception_nest_depth >= 2 || - g_rfe_escape_pending || - g_exc_escape_reason != PSX_EXC_ESCAPE_NONE || - !(cpu->cop0[COP0_SR] & 0x01)) { - /* Per-guard veto attribution (always-on counters, freeze_check): - * which refinement is actually vetoing nested delivery. A wedge - * where ONE guard dominates for thousands of frames names the - * false veto (Tomba2 Whoopee-logo forensics 2026-07-03). */ - if (exception_nest_depth >= 2) g_nestgate_depth++; - else if (g_rfe_escape_pending) g_nestgate_rfepend++; - else if (g_exc_escape_reason != PSX_EXC_ESCAPE_NONE) g_nestgate_escreason++; - else g_nestgate_iec++; - exception_reentry_blocks++; - irq_record_outcome(EV_IRQ_GATE, GATE_IN_EXCEPTION, 0); -#ifdef PSX_COSIM - COSIM_IRQ_NOTE(2u); -#endif - PSX_CHECK_INTERRUPTS_RETURN(); - } - /* IEc re-enabled inside the handler: fall through to the SR gates - * and deliver a nested exception, as hardware would. */ - } - - /* Post-exception cooldown: let at least one block of guest time elapse after - * RFE before the next delivery. Gated on the guest-cycle deadline (not a - * per-call countdown) so compiled and interp agree on the delivery cycle. */ - if (post_exception_cooldown_until != 0) { - if (psx_get_cycle_count() < post_exception_cooldown_until) { - irq_record_outcome(EV_IRQ_GATE, GATE_COOLDOWN, 0); -#ifdef PSX_COSIM - COSIM_IRQ_NOTE(3u); -#endif - PSX_CHECK_INTERRUPTS_RETURN(); - } - post_exception_cooldown_until = 0; /* window elapsed */ - } - - /* Check COP0 SR: IEc (bit 0) must be set, and IM2 (bit 10) must be set. */ - uint32_t sr = cpu->cop0[COP0_SR]; - if (!(sr & 0x01)) { - irq_record_outcome(EV_IRQ_GATE, GATE_SR_IE, 0); -#ifdef PSX_COSIM - COSIM_IRQ_NOTE(4u); -#endif - PSX_CHECK_INTERRUPTS_RETURN(); - } /* Interrupts globally disabled */ - /* Deliverability per source: the INTC line needs SR.IM2; software - * interrupts need only their own IM bit (already folded into sw_pending, - * recomputed here against the CURRENT sr — the fast-path snapshot above - * may predate an SR write earlier in this same check). */ - sw_pending = cpu->cop0[COP0_CAUSE] & sr & 0x0300u; - int hw_deliverable = ((i_stat & i_mask) != 0) && ((sr & (1 << 10)) != 0); - if (!hw_deliverable && sw_pending == 0) { - irq_record_outcome(EV_IRQ_GATE, GATE_SR_IM2, 0); -#ifdef PSX_COSIM - COSIM_IRQ_NOTE(5u); -#endif - PSX_CHECK_INTERRUPTS_RETURN(); - } /* No deliverable interrupt source (hw masked and no sw pending) */ - - /* Architectural take-PC = the resume PC (same selection the async-RFE block - * below uses): the dirty-interp commits the exact interrupted instruction in - * g_dirty_safe_resume_pc; compiled code passes its block-entry PC via - * psx_check_interrupts_at -> s_compiled_interrupt_resume_pc. Combined with the - * event's `mode`, this is the take-point for the cross-backend diff. */ - { - extern uint32_t g_dirty_safe_resume_pc; - uint32_t take_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc - : s_compiled_interrupt_resume_pc; - irq_record_outcome(EV_IRQ_DELIVER, 0, take_pc); - } - g_irq_deliver_count++; - if ((i_stat & i_mask) & (1u << IRQ_VBLANK)) g_vblank_deliver_count++; - if ((i_stat & i_mask) & (1u << IRQ_CDROM)) g_cdrom_deliver_count++; - /* IRQ-delivery context ring (MMX6 VSync-vs-CD-DMA hunt). Capture, at every IRQ - * delivery, whether the kernel VSync callback-block word at 0x80079D44 is the - * clobbered game value AND whether a CD DMA (ch3) is mid-transfer / a DMA is - * executing. If VBlank gets delivered with d44 already==0x016F0110 while the CD - * DMA is active, that is ChatGPT's class (c)+(#2): IRQ delivered in the DMA - * clobber window because the CPU isn't stalled / the event wasn't torn down. */ - { - extern int dma_cdrom_transfer_active(void); - extern int g_dma_exec_depth; - extern uint64_t s_frame_count; - g_irqctx_ring[g_irqctx_seq & (IRQCTX_RING_CAP - 1u)] = (IrqCtxEntry){ - .seq = g_irqctx_seq, .cycle = psx_get_cycle_count(), - .frame = (uint32_t)s_frame_count, .istat = i_stat, .imask = i_mask, - .sr = cpu->cop0[COP0_SR], .d44 = cpu->read_word(0x80079D44u), - .cdrom_active = (uint32_t)dma_cdrom_transfer_active(), - .dma_depth = g_dma_exec_depth, - .is_vblank = (uint32_t)(((i_stat & i_mask) & (1u << IRQ_VBLANK)) != 0), - }; - g_irqctx_seq++; - } - ls_note_exception_entry(); - /* Nested delivery: remember whether we interrupted an OUTER handler so - * the epilogue restores in_exception to the pre-delivery value instead - * of clearing it (a nested level-2 exit must leave level-1 flagged). */ - int prev_in_exception = in_exception; - in_exception = 1; - exception_nest_depth++; - exception_entries_total++; - uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */ - - /* Set COP0 Cause: ExcCode=0 (interrupt). IP2 reflects the INTC line, so - * set it only when the hardware source is what's being delivered; a pure - * software interrupt must present the guest-written IP0/IP1 bits - * unmodified (the guest's dispatcher discriminates stages by exactly - * these bits — see sw_pending rationale at the top of this function). */ - cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2); - if (hw_deliverable) - cpu->cop0[COP0_CAUSE] |= (1 << 10); - - /* Push SR exception stack: shift bits [5:0] left by 2. */ - cpu->cop0[COP0_SR] = (sr & ~0x3F) | ((sr & 0x0F) << 2); - - /* EPC: set to a sentinel value. The recompiled exception handler reads - * memory at [EPC] to check for COP2 branch delay. We use a dedicated - * address in the kernel scratch area. Address 0x80000048 is chosen - * because it's between the exception vectors (0x80-0xBF) and the - * kernel data pointer area (0x100+), and not used by the BIOS. - * - * The sentinel is the HOST escape token for the SYNCHRONOUS handler: when the - * recompiled handler RFEs to it while in_exception, psx_unknown_dispatch / - * dirty_ram_dispatch longjmp back here and resume the interrupted code via the - * host-GPR-restore below. This is unchanged (Tomba 1 / MMX6 / Ape rely on it). */ - /* Fix B: COP0.EPC = the REAL interrupted resume PC, NOT a sentinel. The recompiled - * BIOS exception handler saves COP0.EPC into the interrupted thread's TCB EPC slot, - * so a thread suspended here (ChangeThread) resumes at its OWN real PC — never via a - * single global that the next thread's IRQs overwrite (the MMX6 cooperative-thread - * freeze). The host escape out of the nested synchronous handler is keyed on the - * RFE-pending flag (psx_rfe_mark_escape / psx_rfe_escape_check), not on pc==sentinel. - * - * real_pc = the committed interrupted PC, latched by the delivery site (the dirty - * pump exposes g_dirty_safe_resume_pc; a compiled block-leader check exposes - * s_compiled_interrupt_resume_pc). When BOTH are 0 the IRQ was taken at a clean - * trampoline boundary (the interrupted function already returned, pc was 0) — there - * is no mid-function resume PC, so fall back to the legacy sentinel + saved_gpr path - * for THIS delivery only (reason = LEGACY_SENTINEL gates the saved_gpr restore and - * disarms the RFE-flag escape). */ - g_rfe_escape_pending = 0; - { - uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc - : s_compiled_interrupt_resume_pc; - /* Accept the real resume PC from guest RAM (<2MB) OR the BIOS ROM - * window. The old RAM-only guard rejected ROM-space block leaders - * (e.g. OpenBIOS mcWaitForStatus spinning at 0xBFC076xx during a - * card op), forcing EVERY such delivery onto the legacy sentinel. - * A compiled handler escapes the sentinel host-side, so SCPH1001 - * never noticed — but an INTERPRETED handler (OpenBIOS: the patch - * slots unbless the exception handler) saves/restores EPC - * architecturally through the TCB, churning the sentinel through - * guest state thousands of times per frame until one interleaving - * leaves it unrepaired (observed as a torn IntRP walk and a wild - * dispatch to 0x54000000 during the first memcard write at boot). - * ROM acceptance is gated on psx_is_dispatchable so a non-leader - * ROM pc still falls back to the sentinel (pre-fix behavior); - * RAM acceptance is unchanged byte-for-byte. */ - uint32_t real_phys = real_pc & 0x1FFFFFFFu; - int resume_in_ram = real_phys < 0x00200000u; - int resume_in_rom = real_phys >= 0x1FC00000u && real_phys < 0x1FC80000u && - psx_is_dispatchable(real_pc); - if (real_pc != 0u && (real_pc & 0x3u) == 0u && - (resume_in_ram || resume_in_rom)) { - cpu->cop0[COP0_EPC] = real_pc; /* architectural: the real resume PC */ - g_exception_real_epc = real_pc; - g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; /* set at the actual RFE/SYSCALL return */ - } else { - uint32_t sentinel = PSX_EXC_SENTINEL_PC; - cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */ - cpu->cop0[COP0_EPC] = sentinel; - g_exception_real_epc = sentinel; - g_exc_escape_reason = PSX_EXC_ESCAPE_LEGACY_SENTINEL; - } -#ifdef PSX_COSIM - cosim_irq_note(cpu, 1u, real_pc, g_dirty_safe_resume_pc, - s_compiled_interrupt_resume_pc, sr); -#endif - /* Ring exit-half init: record the entry-side resume selection now; the - * exit fields are filled at the restore decision below. No nesting is - * possible between here and there (in_exception blocks re-entry), so - * (g_irqctx_seq - 1) is this delivery's entry. */ - { - IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)]; - e->take_pc = real_pc; - e->real_epc = g_exception_real_epc; - e->exit_pc = 0; e->exit_reason = 0; e->same_thread = 0; - e->restored = 0; e->v1_exit = 0; e->v1_saved = 0; - e->ra_exit = 0; e->ra_saved = 0; e->redirects = 0; - e->entry_sp = cpu->gpr[29]; - { extern int g_cosim_dirty_pump_site; - e->pump_site = (uint32_t)g_cosim_dirty_pump_site; } - } - } - - /* Save the interrupted code's full register state. - * - * On real hardware, the exception handler saves all GPRs to the - * TCB save area at entry, and ReturnFromException restores them - * before jumping back to EPC. The interrupted code always gets - * its exact pre-exception register values back. - * - * In our model, the recompiled handler runs as a C function and - * its `return;` goes back here — not to EPC. The handler's - * normal exit path (0xBFC10944) restores registers from the - * kernel jmpbuf (intended for longjmp to WaitEvent's caller), - * which corrupts the interrupted code's registers. - * - * We save all GPRs/HI/LO before the handler and restore them - * after, so the interrupted code resumes with its original state - * — matching real hardware behaviour. */ - uint32_t saved_gpr[32]; - uint32_t saved_hi, saved_lo; - for (int i = 0; i < 32; i++) saved_gpr[i] = cpu->gpr[i]; - saved_hi = cpu->hi; - saved_lo = cpu->lo; - - /* Same-thread discriminator input (mmx6_card_load_regression_state): the - * kernel's CURRENT-TCB pointer at exception ENTRY. PCB ptr lives at kernel - * 0x108; PCB[0] = running thread's TCB. ChangeThread inside the handler - * moves PCB[0] to the new thread, so entry-vs-exit TCB equality is the - * STRUCTURAL "no thread switch happened" test — unlike PC equality, it is - * immune to two threads parked at the SAME guest PC (shared spin loops: - * MMX6's card poll), where the PC heuristic force-restores the OLD thread's - * GPRs into the NEW thread. */ - extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ - uint32_t entry_pcb = psx_read_word(0x108u); - uint32_t entry_tcb = entry_pcb ? psx_read_word(entry_pcb & 0x1FFFFFFFu) : 0u; - - /* Dispatch the BIOS exception handler. - * BEV (SR bit 22) selects between 0x80000080 and 0xBFC00180. - * - * setjmp is placed here so ReturnFromException (longjmp code 1) - * and RestoreState (longjmp code 2) can escape the handler call - * tree. - * - * The loop handles the PSX VSync mechanism (SaveState/RestoreState): - * - Code 0: normal entry — dispatch the handler. - * - Code 2: RestoreState redirect — re-dispatch to cpu->pc - * (e.g. VSync callback loop at 0xBFC421D8), still in exception - * context. The redirected code eventually calls ReturnFromException. - * - Code 1: ReturnFromException — exit the loop entirely. */ - uint32_t target_pc; - if (sr & 0x00400000u) { - target_pc = 0xBFC00180u; - } else { - uint32_t w0 = cpu->read_word(0x80000080u); - uint32_t w1 = cpu->read_word(0x80000084u); - uint32_t hi_val = (w0 & 0xFFFF) << 16; - int16_t lo_val = (int16_t)(w1 & 0xFFFF); - target_pc = hi_val + (uint32_t)(int32_t)lo_val; - /* The RAM exception vector is a LUI/ADDIU pair that materializes the - * installed handler address in $k0 before transferring to it. The - * host-side fast path decodes that pair and dispatches straight to the - * target, so it must also commit the pair's architectural register - * result. Vigilante 8's handler uses $k0 as its table base on its very - * first instruction; leaving the interrupted value in $k0 made it load - * a BIOS instruction word (0xAD400000) as a jump target. */ - uint32_t vector_reg = (w1 >> 16) & 31u; - if (vector_reg != 0u) cpu->gpr[vector_reg] = target_pc; - } - - /* Record which fiber owns this setjmp. Any subsequent longjmp must - * happen on this same fiber; if a non-owner fiber needs to longjmp - * it must switch back here first (see deferred_exception_longjmp). */ - void *prev_owner_fiber = g_exception_owner_fiber; - int prev_pending = g_pending_exception_longjmp; - g_exception_owner_fiber = psx_fiber_current(); - g_pending_exception_longjmp = 0; - /* A bail unwind can never be in flight at exception entry: bail-mode - * returns skip every block leader, so psx_check_interrupts is never - * reached while g_psx_call_bail is set. If it ever is, count the - * anomaly and clear so the handler dispatch isn't poisoned. */ - if (g_psx_call_bail) { - g_psx_bail_anomaly++; - g_psx_call_bail = 0; - } - /* The static BIOS exception handler is not dirty-RAM-interp code. Clear the - * interp mode flag across the handler dispatch so events recorded inside it - * are tagged STATIC. The restore sits after the loop the longjmp lands in, - * so the EPC-sentinel longjmp can't leave the flag wrong. */ - int prev_interp_active = g_dirty_interp_active; - g_dirty_interp_active = 0; - /* Ape memcard fix #3 (2026-07-07): clear the dirty-interp resume-PC latch - * across the handler dispatch, exactly like g_dirty_interp_active above. - * - * The interrupted dirty block set g_dirty_safe_resume_pc = its committed PC - * and left it LIVE across its own psx_check_interrupts call (the dirty pump - * only restores it AFTER that call returns). Without clearing it here, a - * NESTED interrupt taken inside the handler — e.g. a VBLANK delivered while - * the kernel's async-poll memory-card callback runs (in_exception, IEc - * re-enabled) — inherits the OUTER interrupted block's stale resume PC as its - * EPC. same-thread-restore then matches (cpu->pc == that stale EPC) and - * resumes the OUTER block with the CALLBACK's registers: the Ape memcard - * consumer at 0x801382CC gets v1 (jumptable index) = the callback's pointer - * 0x800B4E30, bounds-check already passed, `jr jumptable[garbage]` faults - * (DISPATCH FATAL misaligned 0x3) — or, in the soft variant, drives a wrong - * branch that skips DeliverEvent(0xF0000011,4) and the card scene wedges. - * (irqctx_ring proof: exit_pc=0x801382CC, ra_saved=0x80010094 [callback], - * v1_saved=0x800B4E30, restored=1.) - * - * Cleared here so a nested delivery latches the ACTUALLY-running code's PC: - * the callback's own dirty pump re-sets g_dirty_safe_resume_pc for its - * blocks, and compiled handler code uses s_compiled_interrupt_resume_pc. - * Restored after the dispatch loop (below), which the RFE/escape longjmp - * lands in — so the escape can't leave the latch wrong. A/B off: - * PSX_EXC_CLEAR_RESUME_LATCH=0. */ - static int s_clear_resume_latch = -1; - if (s_clear_resume_latch < 0) { - const char *e = getenv("PSX_EXC_CLEAR_RESUME_LATCH"); - s_clear_resume_latch = (e && *e) ? atoi(e) : 1; - } - uint32_t saved_dirty_resume_pc = g_dirty_safe_resume_pc; - if (s_clear_resume_latch) g_dirty_safe_resume_pc = 0; - /* Wall-time sampler phase: the handler dispatch runs recompiled static - * BIOS text. Same save/restore contract as g_dirty_interp_active above — - * the restore sits after the setjmp loop, covering longjmp landings. */ - extern int g_exec_phase; - int prev_exec_phase = g_exec_phase; - g_exec_phase = 3; - /* Dispatch-depth contract across the async handler (Tomba 2 splash, post - * overlay-floor fix). The interrupt can be delivered from a NESTED dispatch - * (the local-flow pump runs inside dirty_ram_dispatch at depth > 0). The - * handler must run as its OWN outermost context (so its tail-call trampoline - * flattens), and a ReturnFromException longjmp skips the handler frames' - * `--g_psx_dispatch_depth` decrements — so we cannot leave the counter at 0. - * Save the interrupted code's nesting here and RESTORE it after the handler; - * otherwise the outer frames unwind below zero (dispatch_depth -> -1), the - * `outermost` test misfires, and the top-level dispatch returns to PC=0 - * ("execution completed" abnormal exit). */ - int saved_dispatch_depth = g_psx_dispatch_depth; - g_psx_dispatch_depth = 0; - /* Nested delivery clobber guard: exception_jmpbuf is a single global, so a - * nested exception's setjmp overwrites the OUTER frame's context. The outer - * handler's later RFE would then longjmp into the dead inner frame (host - * stack corruption — first hit by Jackie Chan Stuntmaster's software- - * interrupt dispatcher, which nests by design). Save the armed frame here - * and restore it in the epilogue so each nesting level's RFE lands in its - * own live frame. */ - jmp_buf saved_exception_jmpbuf; - memcpy(&saved_exception_jmpbuf, &exception_jmpbuf, sizeof(jmp_buf)); - g_exc_setjmp_epoch++; /* new setjmp frame armed (see decl above) */ - for (;;) { - int jmp_val = setjmp(exception_jmpbuf); - if (jmp_val == 2) { - /* RestoreState redirect: re-dispatch to cpu->pc. - * GPRs were already set by RestoreState — do NOT restore. - * Stay in exception context so ReturnFromException works. */ - g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)].redirects++; - g_psx_dispatch_depth = 0; - debug_server_log_restore_event(3, cpu->pc, (uint32_t)jmp_val); - target_pc = cpu->pc; - continue; - } - if (jmp_val == 1) { - g_psx_dispatch_depth = 0; - debug_server_log_restore_event(4, cpu->pc, (uint32_t)jmp_val); - } - if (jmp_val == 0) { - /* Normal entry (or after RestoreState redirect): dispatch. */ - psx_dispatch(cpu, target_pc); - } - /* jmp_val 0 (normal return) or 1 (ReturnFromException): done. */ - break; - } - /* Restore previous exception-owner state. Supports nested exceptions - * if they ever arise (uncommon but harmless). */ - g_exception_owner_fiber = prev_owner_fiber; - g_pending_exception_longjmp = prev_pending; - g_dirty_interp_active = prev_interp_active; - /* Re-arm the OUTER frame's jmpbuf (see save at entry): after a nested - * delivery returns, the outer handler is live again and its RFE must land - * in the outer setjmp frame, not this exited one. */ - memcpy(&exception_jmpbuf, &saved_exception_jmpbuf, sizeof(jmp_buf)); - /* Ape memcard fix #3: restore the resume-PC latch cleared at handler entry - * (see above). Sits with the other post-dispatch-loop restores so an - * RFE/RestoreState longjmp — which lands in the setjmp loop above — can't - * strand it at 0. */ - if (s_clear_resume_latch) g_dirty_safe_resume_pc = saved_dirty_resume_pc; - g_exec_phase = prev_exec_phase; - /* Restore the interrupted code's dispatch nesting (see save above). The - * handler's frames were unwound by longjmp without decrementing, so the - * live counter is meaningless here — overwrite, don't decrement. */ - g_psx_dispatch_depth = saved_dispatch_depth; - - /* Restore the interrupted code's registers. - * - * The handler has done its work (acknowledged i_stat, delivered - * events, etc.) via MMIO writes and RAM writes — those side - * effects are in memory. We restore GPRs so the interrupted - * code continues with its pre-exception state. - * - * For SR: if the handler did ReturnFromException (RFE already - * applied, IEc restored), we keep that. If the handler exited - * normally (jmpbuf path, no RFE), IEc is still 0 from the - * exception push — we do RFE to pop the SR stack. - * - * Fix B: only restore saved_gpr on the LEGACY (jmpbuf / boundary-sentinel) - * exit. On a real RFE/ReturnFromException the recompiled BIOS handler already - * restored the RESUMED thread's GPRs from its TCB; overwriting them with our - * saved_gpr would clobber the resumed thread with the state of the thread that - * ENTERED the exception (the cross-thread corruption fix B exists to prevent). */ - /* Fix B refinement (2026-07-01, Tomba pause-menu wedge): restore the - * interrupted GPRs whenever this resume returns to the SAME thread it - * interrupted — either the LEGACY sentinel exit, OR a real-EPC RFE that - * resumes at exactly the PC we installed as EPC (no ChangeThread happened). - * - * Real hardware saves and restores ALL GPRs across every exception, so a - * transparent same-thread interrupt must give the interrupted code its exact - * pre-exception registers back. The original Fix B skipped this for all - * real-EPC resumes, trusting the recompiled BIOS handler's TCB restore — but - * for a same-thread compiled resume that restore is incomplete, so a live - * value held in a register across a block-leader IRQ check gets clobbered by - * the handler. Concretely: Tomba's pause-menu frame-wait spin holds its loop - * bound (256) in gpr[3] across 0x80016588; a VBLANK there returned gpr[3]=1, - * so the wait exited early every frame and the menu never opened. - * - * A GENUINE ChangeThread resumes a DIFFERENT thread at its own PC (!= our - * EPC); there the BIOS handler restored the NEW thread's GPRs from its TCB - * and saved_gpr holds the thread that ENTERED the exception — restoring it - * would reintroduce the MMX6 cooperative-thread corruption Fix B prevents. */ - /* Discriminator selector (verification + candidate fix, - * mmx6_card_load_regression_state — the PC heuristic below was BISECTED as - * the "A game data could not be found" regression B on MMX6 while being - * the fix for Tomba's pause menu): - * PSX_SAME_THREAD_RESTORE=0 original Fix B (legacy-sentinel-only restore) - * PSX_SAME_THREAD_RESTORE=1 PC-equality heuristic (13c5e0c behavior) - * PSX_SAME_THREAD_RESTORE=2 kernel current-TCB equality (default; - * structural same-thread test — a genuine - * ChangeThread moves PCB[0] even when the new - * thread resumes at the SAME guest PC) */ - static int s_str_mode = -1; - if (s_str_mode < 0) { - const char *e = getenv("PSX_SAME_THREAD_RESTORE"); - s_str_mode = (e && *e) ? atoi(e) : 1; /* default = 13c5e0c behavior; - mode 2 (TCB check) is the hardening candidate, pending a Tomba - pause-menu gate. The MMX6 "not found" this selector was built to - verify turned out to be a STALE GENERATED IMAGE artifact. */ - if (s_str_mode < 0 || s_str_mode > 2) s_str_mode = 1; - } - extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ - uint32_t exit_pcb = psx_read_word(0x108u); - uint32_t exit_tcb = exit_pcb ? psx_read_word(exit_pcb & 0x1FFFFFFFu) : 0u; - int same_thread_resume = 0; - if (s_str_mode == 1) { - same_thread_resume = - (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) && - g_exception_real_epc != 0u && - same_guest_pc(cpu->pc, g_exception_real_epc); - } else if (s_str_mode == 2) { - same_thread_resume = - (g_exc_escape_reason != PSX_EXC_ESCAPE_LEGACY_SENTINEL) && - g_exception_real_epc != 0u && - same_guest_pc(cpu->pc, g_exception_real_epc) && - entry_tcb != 0u && entry_tcb == exit_tcb; - } - /* Same-thread completion of a SENTINEL (legacy) delivery via an explicit - * guest RFE / ReturnFromException. The guest TCB never holds the host - * sentinel (psx_assert_no_sentinel_pc), so the kernel's restore resumed - * cpu->pc at an APPROXIMATE saved PC (typically a stale earlier dirty - * boundary) — but with PCB[0] unmoved the TRUE continuation of the - * interrupted code is the LIVE host chain below this frame. Exit exactly - * like the legacy sentinel detector: restore the interrupted GPRs and - * return pc=0 so the interrupted compiled leader continues natively. - * Without this the approximate PC surfaces up the trampoline, severs the - * live chain, and one hop later lands on an un-re-enterable mid-function - * PC — the top-level "execution completed, PC=0" abnormal exit (Tomba 2 - * splash, frame 1385). A GENUINE in-handler switch (PCB[0] moved) skips - * this and is honored/deferred by the scheduler block below. */ - if (g_exception_real_epc == (uint32_t)PSX_EXC_SENTINEL_PC && - entry_tcb != 0u && entry_tcb == exit_tcb && - (g_exc_escape_reason == PSX_EXC_ESCAPE_RFE_RETURN || - g_exc_escape_reason == PSX_EXC_ESCAPE_SYSCALL_RETURN)) { - same_thread_resume = 1; - cpu->pc = 0; /* continue the interrupted live chain */ - } - int do_restore = - (g_exc_escape_reason == PSX_EXC_ESCAPE_LEGACY_SENTINEL || same_thread_resume); - /* Ring exit-half: every delivery records which escape path it took and - * whether the interrupted GPRs were restored (Tomba menu wedge evidence). */ - { - IrqCtxEntry *e = &g_irqctx_ring[(g_irqctx_seq - 1u) & (IRQCTX_RING_CAP - 1u)]; - e->exit_pc = cpu->pc; - e->exit_reason = (uint32_t)g_exc_escape_reason; - e->same_thread = (uint32_t)same_thread_resume; - e->restored = (uint32_t)do_restore; - e->v1_exit = cpu->gpr[3]; - e->v1_saved = saved_gpr[3]; - e->ra_exit = cpu->gpr[31]; - e->ra_saved = saved_gpr[31]; - } - if (do_restore) { - for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i]; - cpu->hi = saved_hi; - cpu->lo = saved_lo; - } - g_rfe_escape_pending = 0; - g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; - - if (!(cpu->cop0[COP0_SR] & 0x01)) { - uint32_t sr2 = cpu->cop0[COP0_SR]; - cpu->cop0[COP0_SR] = (sr2 & 0xFFFFFFC0u) | ((sr2 >> 2) & 0x0Fu); - } - - /* Nested delivery: a level-2 exit must leave level-1 flagged. */ - in_exception = prev_in_exception; - if (exception_nest_depth > 0) exception_nest_depth--; - - /* Adaptive cooldown: if the handler acknowledged the interrupt (cleared - * some I_STAT bits), the interrupt won't immediately re-fire and we need - * no cooldown. If I_STAT is unchanged (no handler claimed the interrupt), - * give the main code a generous window to make progress — e.g. to let - * the shell finish installing handlers. On real hardware, the CPU - * executes at least one instruction between exceptions; in our model - * each "block" is many instructions, but the handler also consumes - * hundreds of sub-dispatches per invocation. */ - if ((i_stat & i_mask) != 0 && i_stat == pre_handler_istat) { - /* unclaimed: give main code guest-time to install handlers */ - post_exception_cooldown_until = psx_get_cycle_count() + POST_EXC_UNCLAIMED_COOLDOWN_CYCLES; - } else { - /* Claimed: the handler acknowledged at least one I_STAT bit. Per-source - * policy (this used to be a blanket cooldown=0 — commit 6d2cb65 — which - * pins main code under a fast-disc DMA flood: the interrupted block's - * leader re-takes the exception before its body runs): - * - SIO (card reads): 128 consecutive SIO IRQs must fire within one - * blocking wait; any gap stalls the card protocol → re-fire now. - * - DMA/VBLANK/timer/etc: guarantee a few blocks of main-code - * progress between deliveries so a flood can't starve the loop. */ - uint32_t claimed = pre_handler_istat & ~i_stat; /* bits handler cleared */ - uint32_t sio_active = (claimed | (i_stat & i_mask)) & (1u << IRQ_SIO0); - post_exception_cooldown_until = sio_active - ? 0 /* SIO: no cooldown — card reads need immediate back-to-back IRQs */ - : psx_get_cycle_count() + POST_EXC_CLAIMED_COOLDOWN_CYCLES; - } - if (g_ls_suppress_record > 0) g_ls_suppress_record--; - - /* In-exception thread switch (Ape Escape NEW GAME memcard scene). - * - * A game can run its OWN cooperative scheduler inside the kernel exception - * handler: its VBlank/exception callback moves dword_108->entry (PCB[0], the - * current-thread pointer) to the next thread, and ReturnFromException (0xF40) - * then restores THAT thread. On real hardware the RFE atomically switches - * execution to the new thread. (docs/psx_bios_disasm.txt: both ExceptionHandler - * 0xC80 and ReturnFromException 0xF40 key off dword_108->entry.) - * - * Under the HLE TCB scheduler the interrupted thread is running via a - * structured dispatch on the host stack. Returning normally here does NOT - * transfer to the new thread — the generated block leader just falls through - * to the interrupted thread's next block (it never re-reads cpu->pc). The - * interrupted thread therefore keeps physically executing while PCB[0] already - * names a different thread; a subsequent exception then saves the running - * (old) thread's EPC into the NEW thread's TCB, corrupting it. That is the - * Ape "Checking… MEMORY CARD" wedge: the transition thread's ec8 PC is written - * into main's TCB EPC, main is later RFE'd there with main's regs, and its - * store lands on the transition thread's TCB status word (kill → target_missing - * forever). See ape_thread_smear_rootcause.md. - * - * Honor the switch exactly as a cooperative ChangeThread is honored: unwind to - * psx_scheduler_run and dispatch whatever PCB[0] now points at, from its - * committed TCB context (the guest handler already saved the interrupted - * thread into ITS own TCB before the switch). Gated to the OUTERMOST exception - * (prev_in_exception==0 → no live outer exception_jmpbuf frame to skip) and to - * the HLE scheduler (the legacy fiber bridge has no g_scheduler_jmpbuf and - * switches threads by fiber). */ - if (prev_in_exception == 0 && psx_hle_scheduler_enabled() && - entry_tcb != 0u && exit_tcb != 0u && entry_tcb != exit_tcb) { - extern uint32_t psx_read_word(uint32_t addr); /* memory.c (plain RAM read) */ - uint32_t new_state = psx_read_word(exit_tcb & 0x1FFFFFFFu); - if (new_state == 0x4000u) { /* the new current thread must be runnable */ - /* A native-only shadow pass must never commit the handler's TCB/RAM - * changes or escape past the authoritative restore. Bail before - * configuring either immediate or deferred scheduler state. */ - extern int overlay_loader_shadow_native_thread_switch_bail(void); - if (overlay_loader_shadow_native_thread_switch_bail()) - PSX_CHECK_INTERRUPTS_RETURN(); - /* Fix #2: honor the switch ONLY at the outermost dispatch boundary, - * where the outgoing thread's guest state is fully materialized in - * CPUState. Nested inside a host call unit / dirty pump, the outgoing - * thread's PC lives only in the resume-PC latch, which desyncs from the - * live GPRs when the IRQ was taken deep inside a nested call (the - * async-poll callback) — switching there commits a poisoned TCB context - * and the resumed thread faults on a smeared jumptable index. */ - extern int g_call_unit_depth; - int at_outermost = (g_psx_dispatch_depth == 0 && g_call_unit_depth == 0); - /* Defer only when nested AND the outgoing thread has a valid resume PC - * of its OWN (g_exception_real_epc — the architectural EPC latched for - * THIS delivery). The guest handler's in-exception ReturnFromException - * (traps.c syscall 3, in_exception path) has already restored the TARGET - * thread's GPRs and set cpu->pc to the target's EPC; to resume the - * OUTGOING thread transparently we must put cpu->pc back to ITS resume PC - * as well, not leave it pointing at the target. If we have no clean - * outgoing resume PC (sentinel / clean-trampoline boundary), or the - * toggle is off, or we are already outermost, switch immediately. */ - /* Low BIOS/kernel code is already scheduler code; deferring it can - * starve a target thread by re-entering the same VBlank EPC forever. */ - uint32_t epc_phys = g_exception_real_epc & 0x1FFFFFFFu; - int low_kernel_epc = (epc_phys < 0x00010000u); - int can_defer = defer_switch_enabled() && !low_kernel_epc && !at_outermost && - g_exception_real_epc != 0u && - (g_exception_real_epc & 0x3u) == 0u && - g_exception_real_epc != (uint32_t)PSX_EXC_SENTINEL_PC && - psx_is_dispatchable(g_exception_real_epc); - if (!can_defer) { - /* Outermost / no clean deferral: honor immediately — the guest - * handler's save of the outgoing thread is consistent here (or this - * is the pre-existing baseline path). */ - debug_server_log_thread_event(30, cpu, entry_tcb, exit_tcb, cpu->pc); - /* The longjmp skips psx_check_interrupts_at's restore of the compiled - * resume-PC latch; clear it so the next thread's block-leader delivery - * recomputes real_pc cleanly rather than inheriting a stale value. */ - s_compiled_interrupt_resume_pc = 0; - /* This escape uses g_scheduler_jmpbuf (unwinding to psx_scheduler_run), - * NOT exception_jmpbuf — so it BYPASSES the landing above that restores - * g_dirty_interp_active. From the dirty-RAM entry poll the flag is 1 - * and would LEAK across the unwind; we abandon the dirty-interp - * dispatch here, so it MUST be 0 (dirty_ram_interp.c ~205-210 contract; - * no-op from compiled code where the flag was already 0). */ - g_dirty_interp_active = 0; - g_sched_escape.target_tcb = exit_tcb; - g_sched_escape.resume_pc = 0; - g_sched_escape.reason = PSX_RUN_YIELD_TO_TCB; - longjmp(g_scheduler_jmpbuf, 1); /* unwind to psx_scheduler_run; never returns */ - } - /* Nested in host dispatch: DEFER. The guest handler has already saved a - * POISONED context for the outgoing thread (latched PC ahead of the live - * registers), but we do NOT switch off it. Instead resume the outgoing - * thread transparently — restoring its clean pre-exception GPRs/HI/LO/SR - * (do_restore above ran false for this genuine cross-thread case) — so it - * finishes its nested calls; record the switch as pending and honor it at - * the next outermost boundary (top of this function), re-saving the thread - * cleanly there. Keep PCB[0] naming the STILL-RUNNING thread during the - * interim so any interim exception saves into the right TCB (closes the - * mis-save window the immediate switch used to avoid); the guest's intent - * to run exit_tcb is preserved as the pending switch. Coalesces: the newest - * guest ChangeThread wins the target. */ - for (int i = 0; i < 32; i++) cpu->gpr[i] = saved_gpr[i]; - cpu->hi = saved_hi; - cpu->lo = saved_lo; - cpu->cop0[COP0_SR] = sr; /* pre-exception SR (IEc was set — gate above) */ - cpu->pc = g_exception_real_epc; /* resume the OUTGOING thread at ITS own PC, - * not the target PC the in-exception RFE - * left in cpu->pc — else it runs the target's - * code under the outgoing thread and never - * reaches its own block leaders to be honored. */ - psx_sched_set_current_tcb(cpu, entry_tcb); - s_defer_switch_from = entry_tcb; - s_defer_switch_target = exit_tcb; - s_defer_switch_pending = 1; - s_compiled_interrupt_resume_pc = 0; - debug_server_log_thread_event(31, cpu, entry_tcb, exit_tcb, cpu->pc); - /* fall through: return normally — the outgoing thread resumes. */ - } - } -#ifdef PSX_COSIM -#undef COSIM_IRQ_NOTE -#undef COSIM_IRQ_TAKE_PC -#endif -#undef PSX_CHECK_INTERRUPTS_RETURN -} - -/* Compatibility shim: the ape-flavored generated code calls - * psx_check_interrupts_at(cpu, resume_pc); the mmx6-fw baseline runtime delivers - * interrupts via psx_check_interrupts (cpu->pc / scratch sentinel). Forwarding - * here gives the mmx6 baseline interrupt behavior — sufficient to build+run the - * current generated code on the good baseline for instrumented comparison. */ -void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc) { - uint32_t prev = s_compiled_interrupt_resume_pc; - s_compiled_interrupt_resume_pc = resume_pc; - s_resume_pc_given = 1; - psx_check_interrupts(cpu); /* flushes load-charge batch on entry */ - s_compiled_interrupt_resume_pc = prev; -} - -int psx_interrupts_checked_at_current_cycle(uint32_t resume_pc) { - return s_last_interrupt_check_cycle == psx_get_cycle_count() && - same_guest_pc(s_last_interrupt_check_pc, resume_pc); -} - -void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc) { - if (psx_interrupts_checked_at_current_cycle(resume_pc)) { - return; - } - psx_check_interrupts_at(cpu, resume_pc); -} diff --git a/psxrecomp-overlay/runtime/src/iso_reader_c.cpp b/psxrecomp-overlay/runtime/src/iso_reader_c.cpp deleted file mode 100644 index a3fd420..0000000 --- a/psxrecomp-overlay/runtime/src/iso_reader_c.cpp +++ /dev/null @@ -1,119 +0,0 @@ -/* - * iso_reader_c.cpp — C wrapper for the C++ ISOReader class - * - * Provides iso_open() / iso_read_sector() / iso_close() for use by cdrom.c - */ - -#include "iso_overlay.h" -#include "iso_reader.h" -#include "mod_runtime.h" -#include -#include -#include -#include - -namespace { - -const PsxIsoOverlay* g_overlay = nullptr; -std::vector g_plain; -void* g_game_disc = nullptr; - -bool overlaid(void* handle) { - return g_overlay && std::find(g_plain.begin(), g_plain.end(), handle) == g_plain.end(); -} - -} // namespace - -extern "C" { - -void* iso_open(const char* path) { - auto* reader = new PS1::ISOReader(); - if (!reader->Open(path)) { - delete reader; - return nullptr; - } - g_game_disc = reader; - return reader; -} - -void iso_set_overlay(const PsxIsoOverlay* overlay) { g_overlay = overlay; } - -void* iso_open_plain(const char* path) { - void* const game = g_game_disc; - void* handle = iso_open(path); - g_game_disc = game; - if (handle) g_plain.push_back(handle); - return handle; -} - -void* iso_game_disc(void) { return g_game_disc; } - -int iso_read_raw_sector_plain(void* handle, uint32_t lba, uint8_t* buffer) { - return handle && static_cast(handle)->ReadRawSector(lba, buffer) ? 1 : 0; -} - -uint32_t iso_sector_count_plain(void* handle) { - return handle ? static_cast(handle)->GetSectorCount() : 0; -} - -int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { - if (!handle) return 0; - auto* reader = static_cast(handle); - (void)size; /* ReadSector always reads 2048 bytes */ - uint8_t raw[2352]; - const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, raw) : 0; - if (stood_in < 0) return 0; - if (stood_in > 0) std::memcpy(buffer, raw + 24, 2048); - else if (!reader->ReadSector(lba, buffer)) return 0; - mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); - return 1; -} - -int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { - if (!handle || size < 2352) return 0; - auto* reader = static_cast(handle); - const int stood_in = overlaid(handle) && g_overlay->read ? g_overlay->read(handle, lba, buffer) : 0; - if (stood_in < 0) return 0; - if (stood_in == 0 && !reader->ReadRawSector(lba, buffer)) return 0; - mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); - return 1; -} - -uint32_t iso_sector_count(void* handle) { - if (!handle) return 0; - const uint32_t stood_in = overlaid(handle) && g_overlay->sectors ? g_overlay->sectors(handle) : 0; - return stood_in ? stood_in : iso_sector_count_plain(handle); -} - -/* CD-track TOC accessors (multi-track / CD-DA support). track is 1-based. */ -int iso_track_count(void* handle) { - if (!handle) return 1; - return static_cast(handle)->TrackCount(); -} - -uint32_t iso_track_start_lba(void* handle, int track) { - if (!handle) return 0; - return static_cast(handle)->TrackStartLBA(track); -} - -uint32_t iso_track_pregap_lba(void* handle, int track) { - auto* reader = static_cast(handle); - return reader ? reader->TrackPregapLBA(track) : 0; -} - -int iso_track_is_audio(void* handle, int track) { - if (!handle) return 0; - return static_cast(handle)->TrackIsAudio(track) ? 1 : 0; -} - -void iso_close(void* handle) { - if (!handle) return; - if (overlaid(handle) && g_overlay->closed) g_overlay->closed(handle); - if (handle == g_game_disc) g_game_disc = nullptr; - g_plain.erase(std::remove(g_plain.begin(), g_plain.end(), handle), g_plain.end()); - auto* reader = static_cast(handle); - reader->Close(); - delete reader; -} - -} /* extern "C" */ diff --git a/psxrecomp-overlay/runtime/src/main.cpp b/psxrecomp-overlay/runtime/src/main.cpp deleted file mode 100644 index 975e230..0000000 --- a/psxrecomp-overlay/runtime/src/main.cpp +++ /dev/null @@ -1,8526 +0,0 @@ -/* main.cpp — Phase 3 runtime entry point. - * - * Loads BIOS ROM, initializes CPU state + SDL display, calls into - * the recompiled reset vector. BIOS drives execution; SDL presents - * VRAM at each vblank via callback from gpu_vblank_tick(). - */ - -/* Keep the Windows min/max macros out of this C++ translation unit. SDL may - * include windows.h before the explicit platform includes below, so this must - * be defined before any runtime header is included. */ -#if defined(_WIN32) && !defined(NOMINMAX) -#define NOMINMAX -#endif - -#include "cpu_state.h" -#include "psx_bios_image.h" /* the active BIOS's self-description */ -#include "psx_bios_backend.h" /* routing to whichever BIOS was selected */ -#include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ -#include "parity_trace.h" /* general two-process control-flow parity ring */ -#include "device_trace.h" /* general two-process device-event cycle ring */ -#include "psx_interpreter.h" -#include "cdrom.h" -#include "fntrace.h" -#include "text_xlate.h" -#include "boot_state.h" -#include "bios_hle.h" -#include "bios_hle_plan.h" -#include "psx_cycles.h" -#include "starvation_ring.h" -#include "load_accel.h" -#include "savestate.h" -#include "overlay_capture.h" -#include "overlay_loader.h" -#include "autocompile.h" -#include "code_provider.h" -extern "C" void psx_event_step_conservative_env_init(void); -#include "overlay_backend.h" -#include "gpu.h" -#include "present_ring.h" -#include "load_transition_ring.h" -#include "gpu_sw_renderer.h" -#include "gpu_render.h" -#include "gpu_gl_renderer.h" -#include "gpu_vk_renderer.h" -#include "host_borderless.h" -#include "host_pick_slot.h" -#include "host_ui.h" -extern "C" void gl_renderer_geometry_interpolation_diag(uint64_t out[6]); -#include "gte_precision.h" -#include "frame_pacing.h" -#include "latency_ring.h" -#include "sio.h" -#include "psx_netplay.h" -#include "psx_lobby_client.h" -#include "spu.h" -#include "audio_trace.h" -#include "spu_shadow.h" - -/* Shared clock-domain bridge: band-limited polyphase resampler + P-only DRC. - * The SPU renders at a fixed 44100/60 per wall-clock frame while the host - * consumes on its own crystal; with no resampling/DRC the queue slowly drifts - * to underrun (silence gaps). The bridge resamples ~1:1 with a <=+/-0.5% ratio - * trim to hold the ring near target -- no gaps. See recomp_audio_drc.h. */ -#define RECOMP_AUDIO_DRC_IMPL -#include "recomp_audio_drc.h" -#include "memcard.h" -#include "debug_server.h" -#include "crash_trace.h" -#include "freeze_heartbeat.h" -#include "config_loader.h" -#include "game_options.h" -#include "mod_plugins.h" -#include "mod_runtime.h" -#ifdef PSX_HAS_DISRUPTOR_CAPSULE -extern "C" void disruptor_capsule_before_input(void); -extern "C" void disruptor_capsule_after_input(void); -extern "C" void disruptor_capsule_state_loaded(void); -#endif -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -extern "C" void disruptor_far_rendering_abandon_metrics(void); -#ifdef PSX_HAS_DISRUPTOR_LANGUAGE -extern "C" void disruptor_language_set_disc(const char* path); -extern "C" void disruptor_language_settle(void); -#endif -#endif -#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY -extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); -#endif -#include "dirty_ram_interp.h" -#include "crc32.h" -#include "disc_identity.h" -#include "disc_path.h" -#include "bios_rom_alias.h" -#include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ -#include "launcher_device.h" /* recomp-ui controller-source round-trip */ -#include "psx_keybinds.h" /* configurable keyboard->DualShock keybinds (keybinds.ini) */ -#include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ -#if defined(RECOMP_LAUNCHER) -#include "recomp_launcher.h" /* shared recomp-ui Dear ImGui launcher */ -#include "launcher_profile.h" /* per-system variant profile (theme/caps bundle) */ -#endif -#include "psx_sdl.h" -#if defined(PSX_SDL3) -/* - * SDL_main.h is a single-header implementation in SDL3. Keep it in the one - * translation unit that defines main(); including it through psx_sdl.h makes - * every SDL-using source emit WinMain under MinGW. - */ -#include -#endif -#include "psx_sdl_audio.h" -#if defined(PSX_WEB) -#include -#endif -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/* Internal GL seam: host-menu suspension is a separate reason from FMV/ - * content suspension, so either owner can release independently. */ -extern "C" void gl_renderer_set_host_ui_suspended(int suspended); -extern "C" void gl_renderer_set_hidden_rows(int rows); - -#ifdef _WIN32 -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include -#include -#include -#include -#else -#include -#include -#include -#include -#include -#include -#include -#endif - -#ifndef PSX_DEFAULT_BIOS_PATH -#define PSX_DEFAULT_BIOS_PATH "bios/SCPH1001.BIN" -#endif -#ifndef PSX_DEFAULT_GAME_CONFIG_PATH -#define PSX_DEFAULT_GAME_CONFIG_PATH "" -#endif -#ifndef PSX_WINDOW_TITLE -#define PSX_WINDOW_TITLE "psxrecomp" -#endif -#ifndef DEFAULT_DEBUG_PORT -#error DEFAULT_DEBUG_PORT must be defined by the runtime target. -#endif - -/* SDL_RenderSetVSync was added in SDL 2.0.18. Keep the explicitly supported - * SDL2 fallback buildable against older distro/toolchain copies; its OpenGL - * renderer can still release the swap interval through the public GL API. - * Use a project-local name because SDL3's psx_sdl.h already defines its own - * psx_sdl_render_set_vsync compatibility helper. */ -static int disruptor_sdl_render_set_vsync_compat(SDL_Renderer *renderer, int enabled) { -#if defined(PSX_SDL3) || SDL_VERSION_ATLEAST(2, 0, 18) - return SDL_RenderSetVSync(renderer, enabled); -#else - (void)renderer; - return SDL_GL_SetSwapInterval(enabled ? 1 : 0); -#endif -} - -extern "C" uint64_t gte_get_exec_count(void); - -/* Disruptor does not use SWC2 for its resident world-projection loops. These - * two reviewed MFC2 sites snapshot SXY2. Six ordinary SW sites publish into - * main RAM and one publishes into nine exact scratchpad slots, each only after - * an exact instruction and committed-word check. Twenty-four reviewed LW/SW - * pairs then carry those proven words into GP0 packets. Keeping every route - * behind the game serial prevents another title at the same PCs from - * inheriting it. */ -static int configure_disruptor_precision_store_routes( - const std::string& game_id) { - gte_precision_store_pc_routes_reset(); - if (game_id != "SLUS-00224") return 1; - static const uint32_t mfc2_pcs[] = { - 0x8004633Cu, - 0x8004795Cu, - }; - static const uint32_t store_pcs[] = { - 0x8004641Cu, - 0x800464BCu, - 0x80046524u, - 0x80046540u, - 0x80046594u, - 0x800465D8u, - }; - struct PrecisionCopyRoute { - uint32_t load_pc; - uint32_t load_instruction; - uint32_t store_pc; - uint32_t store_instruction; - uint8_t gpr; - }; - static const PrecisionCopyRoute copy_routes[] = { - {0x80046C4Cu, 0x8D0C0004u, 0x80046D04u, 0xAF2C0008u, 12u}, - {0x80046C50u, 0x8D2D0004u, 0x80046D08u, 0xAF2D0014u, 13u}, - {0x80046C54u, 0x8D4E0004u, 0x80046D10u, 0xAF2E002Cu, 14u}, - {0x80046C60u, 0x8D6F0004u, 0x80046D0Cu, 0xAF2F0020u, 15u}, - {0x80047A80u, 0x8CA80084u, 0x80047A90u, 0xAF280008u, 8u}, - {0x80047A84u, 0x8CA900A4u, 0x80047A94u, 0xAF290014u, 9u}, - {0x80047A88u, 0x8CAA00C4u, 0x80047A9Cu, 0xAF2A002Cu, 10u}, - {0x80047A8Cu, 0x8CAB00BCu, 0x80047A98u, 0xAF2B0020u, 11u}, - {0x80047B40u, 0x8CA800A4u, 0x80047B50u, 0xAF280008u, 8u}, - {0x80047B44u, 0x8CA9008Cu, 0x80047B54u, 0xAF290014u, 9u}, - {0x80047B48u, 0x8CAA00ACu, 0x80047B5Cu, 0xAF2A002Cu, 10u}, - {0x80047B4Cu, 0x8CAB00C4u, 0x80047B58u, 0xAF2B0020u, 11u}, - {0x80047C00u, 0x8CA800C4u, 0x80047C10u, 0xAF280008u, 8u}, - {0x80047C04u, 0x8CA900ACu, 0x80047C14u, 0xAF290014u, 9u}, - {0x80047C08u, 0x8CAA0094u, 0x80047C1Cu, 0xAF2A002Cu, 10u}, - {0x80047C0Cu, 0x8CAB00B4u, 0x80047C18u, 0xAF2B0020u, 11u}, - {0x80047CC4u, 0x8CA800BCu, 0x80047CD4u, 0xAF280008u, 8u}, - {0x80047CC8u, 0x8CA900C4u, 0x80047CD8u, 0xAF290014u, 9u}, - {0x80047CCCu, 0x8CAA00B4u, 0x80047CE0u, 0xAF2A002Cu, 10u}, - {0x80047CD0u, 0x8CAB009Cu, 0x80047CDCu, 0xAF2B0020u, 11u}, - {0x80047D6Cu, 0x8CA80084u, 0x80047D7Cu, 0xAF280008u, 8u}, - {0x80047D70u, 0x8CA9008Cu, 0x80047D80u, 0xAF290014u, 9u}, - {0x80047D74u, 0x8CAA0094u, 0x80047D88u, 0xAF2A002Cu, 10u}, - {0x80047D78u, 0x8CAB009Cu, 0x80047D84u, 0xAF2B0020u, 11u}, - }; - for (uint32_t pc : mfc2_pcs) { - if (!gte_precision_mfc2_pc_route_add( - pc, 0x480A7000u, 14u)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor SXY2 MFC2 PC " - "0x%08X\n", pc); - return 0; - } - } - for (uint32_t pc : store_pcs) { - if (!gte_precision_store_pc_route_add( - pc, 0xACAA0004u, 14u)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor SXY2 store PC " - "0x%08X\n", pc); - return 0; - } - } - if (!gte_precision_scratch_store_pc_route_add( - 0x80047A0Cu, 0xACAA0004u, 14u, - 0x1F800084u, 8u, 9u)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor scratchpad SXY2 " - "store route 0x80047A0C\n"); - return 0; - } - /* 0x80047980..0x800479E0 pin x to -256..576 and y to -256..496 before that store. */ - if (!gte_precision_scratch_store_pc_route_clamp( - 0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) { - std::fprintf(stderr, - "psxrecomp: failed to register the guard band of Disruptor's " - "scratchpad SXY2 store route 0x80047A0C\n"); - return 0; - } - for (const PrecisionCopyRoute& route : copy_routes) { - if (!gte_precision_copy_pc_route_add( - route.load_pc, route.load_instruction, - route.store_pc, route.store_instruction, route.gpr)) { - std::fprintf(stderr, - "psxrecomp: failed to register Disruptor projection-copy " - "route 0x%08X -> 0x%08X\n", - route.load_pc, route.store_pc); - return 0; - } - } - std::fprintf(stdout, - "psxrecomp: registered %zu MFC2 captures, %zu main-RAM projection " - "stores, 1 scratchpad projection store and %zu packet-copy routes\n", - sizeof(mfc2_pcs) / sizeof(mfc2_pcs[0]), - sizeof(store_pcs) / sizeof(store_pcs[0]), - sizeof(copy_routes) / sizeof(copy_routes[0])); - return 1; -} - -/* Cross-language globals defined in C translation units. Declared extern "C" at - * file scope so MSVC gives them C linkage (matching the C definitions); without - * this MSVC name-mangles the C++ references and they fail to link. GCC/Clang do - * not mangle namespace-scope variables, so this is a no-op there. The existing - * block-scope `extern` redeclarations inside functions inherit this C linkage. */ -extern "C" { - extern uint64_t psx_cycle_count; - extern uint64_t s_frame_count; - extern uint32_t g_overlay_region_floor; - extern int g_psx_cps_mode; - extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run; - extern uint64_t g_dirty_window_dispatches; - extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; - extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; -} - -/* memory.c */ -extern "C" void memory_init(const char* bios_path); -extern "C" void memory_set_sr_ptr(const uint32_t *p); -extern "C" uint32_t memory_get_bios_checksum(void); -extern "C" void dirty_ram_register_text_image(uint32_t phys_lo, - const uint8_t *bytes, - uint32_t len); - -/* Arm the dirty-RAM text-image guard with the boot EXE bytes. The guard is - * load-bearing: dispatch native-safety (dirty_ram_text_native_ok) and the - * fntrace alternate game-start latch both key off the registered image, so - * every install must arm it — not just repo checkouts that happen to carry a - * loose EXE copy next to game.toml. Source order: - * 1. The local EXE file from game.toml (dev checkouts; recompiler input). - * 2. The configured native range sliced from the boot EXE on the mounted - * disc (end-user installs may intentionally omit a derived code-only EXE). - * Registration passes ownership of the malloc'd buffer to memory.c. */ -static void arm_text_image_guard(const std::string &exe_path, - uint32_t load_address, - uint32_t text_size, - const std::string &disc_path) { - const uint32_t phys_lo = load_address & 0x1FFFFFFFu; - /* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */ - if (!exe_path.empty()) { - std::ifstream ef(exe_path, std::ios::binary | std::ios::ate); - if (ef) { - std::streamsize sz = ef.tellg(); - if (sz > 2048) { - uint32_t available = (uint32_t)(sz - 2048); - uint32_t img_len = text_size ? text_size : available; - if (img_len > available) img_len = 0; - uint8_t *img = img_len - ? (uint8_t *)std::malloc(img_len) : nullptr; - if (img && img_len) { - ef.seekg(2048, std::ios::beg); - if (ef.read((char *)img, img_len)) { - dirty_ram_register_text_image(phys_lo, img, img_len); - std::fprintf(stdout, - "psxrecomp: text image guard armed (0x%08X..0x%08X, local EXE)\n", - load_address, load_address + img_len); - return; - } - std::free(img); - } - } - } - } - /* 2. Extract the boot EXE from the disc image. Its declared load address - * can precede the code-only image consumed by the recompiler, so slice by - * the configured physical load and text size rather than blindly copying - * from payload byte zero. */ - if (!disc_path.empty()) { - PS1::ISOReader iso; - if (iso.Open(disc_path)) { - /* The boot filename: basename of the game.toml exe field (it names - * the same file the recompiler consumed, which came off this disc); - * fall back to the SYSTEM.CNF BOOT token for configs whose local - * name differs from the disc name. */ - std::string boot_name; - if (!exe_path.empty()) { - const size_t slash = exe_path.find_last_of("/\\"); - boot_name = (slash == std::string::npos) - ? exe_path : exe_path.substr(slash + 1); - } - PS1::ISOFileEntry ent; - if (boot_name.empty() || !iso.FindFile(boot_name, ent)) { - /* SYSTEM.CNF: `BOOT = cdrom:\SCUS_944.23;1` */ - uint8_t cnf[2048] = {0}; - size_t n = iso.ReadFile("SYSTEM.CNF", cnf, sizeof(cnf) - 1); - if (n > 0) { - std::string text((const char *)cnf, n); - std::string lower = text; - for (char &c : lower) c = (char)std::tolower((unsigned char)c); - const size_t key = lower.find("cdrom:"); - if (key != std::string::npos) { - size_t j = key + 6; - while (j < text.size() && (text[j] == '\\' || text[j] == '/')) j++; - std::string tok; - while (j < text.size()) { - char c = text[j]; - if (c == ';' || c == '\r' || c == '\n' || c == ' ' || - c == '\t' || c == '\0') break; - tok += c; j++; - if (tok.size() > 64) break; - } - const size_t s2 = tok.find_last_of("\\/"); - if (s2 != std::string::npos) tok = tok.substr(s2 + 1); - if (!tok.empty() && iso.FindFile(tok, ent)) boot_name = tok; - } - } - } - if (!boot_name.empty() && iso.FindFile(boot_name, ent) && - ent.size > 2048) { - uint8_t *file = (uint8_t *)std::malloc(ent.size); - if (file) { - size_t got = iso.ReadFile(boot_name, file, ent.size); - if (got > 2048 && got >= 0x20 && - std::memcmp(file, "PS-X EXE", 8) == 0) { - const auto read_le32 = [](const uint8_t *p) { - return (uint32_t)p[0] | - ((uint32_t)p[1] << 8) | - ((uint32_t)p[2] << 16) | - ((uint32_t)p[3] << 24); - }; - uint32_t disc_load = - read_le32(file + 0x18) & 0x1FFFFFFFu; - uint32_t declared_len = read_le32(file + 0x1C); - uint32_t payload_len = (uint32_t)(got - 2048); - if (declared_len < payload_len) payload_len = declared_len; - uint32_t payload_off = phys_lo >= disc_load - ? phys_lo - disc_load : UINT32_MAX; - uint32_t img_len = text_size ? text_size - : (payload_off <= payload_len - ? payload_len - payload_off : 0u); - if (payload_off > payload_len || - img_len > payload_len - payload_off) - img_len = 0; - uint8_t *img = img_len - ? (uint8_t *)std::malloc(img_len) : nullptr; - if (img && img_len) { - memcpy(img, file + 2048 + payload_off, img_len); - std::free(file); - dirty_ram_register_text_image(phys_lo, img, img_len); - std::fprintf(stdout, - "psxrecomp: text image guard armed (0x%08X..0x%08X, " - "disc %s payload +0x%X)\n", - load_address, load_address + img_len, - boot_name.c_str(), payload_off); - return; - } - } - std::free(file); - } - } - } - } - std::fprintf(stdout, - "psxrecomp: WARNING: text image guard NOT armed (no local EXE, no disc " - "boot EXE) — native text dispatch will be conservative\n"); -} - -/* dma.c */ -extern "C" void dma_init(void); - -/* mdec.c */ -extern "C" void mdec_init(void); -extern "C" int mdec_recently_active(uint32_t within_frames); - -/* timers.c */ -extern "C" void timers_init(void); - -/* interrupts.c */ -extern "C" void interrupts_init(void); -#ifdef PSX_COSIM -extern "C" void cosim_init(void); /* first-divergence oracle server (cosim.c) */ -#endif -extern "C" uint32_t psx_read_word(uint32_t addr); -extern "C" void psx_write_word(uint32_t addr, uint32_t val); -extern "C" uint16_t psx_read_half(uint32_t addr); -extern "C" void psx_write_half(uint32_t addr, uint16_t val); -extern "C" uint8_t psx_read_byte(uint32_t addr); -extern "C" void psx_write_byte(uint32_t addr, uint8_t val); -/* Guest-side data-read wrappers: same as psx_read_* but charge PS1 main-RAM - * read wait states (R3000A has no D-cache). Wired to cpu->read_* below so the - * timing applies to recompiled + interpreted guest loads, not debug/device reads. */ -extern "C" uint32_t psx_guest_read_word(uint32_t addr); -extern "C" uint16_t psx_guest_read_half(uint32_t addr); -extern "C" uint8_t psx_guest_read_byte(uint32_t addr); - -/* ---- SDL state ---- */ -extern "C" { -SDL_Window* sdl_window = nullptr; -} -static SDL_Renderer* sdl_renderer; -static SDL_Texture* sdl_texture; -/* Per-player input device routing (PSX ports 1 & 2). Seeded from the - * [controller] settings the launcher writes; the runtime opens the matching - * SDL controller (or uses the keyboard) and feeds each PSX pad slot. */ -struct PlayerInput { - int kind = 0; /* 0=none, 1=keyboard, 2=controller */ - char guid[40] = {0}; /* SDL joystick GUID string when kind==controller */ - /* Pad input mode (PSXRecompV4::PadMode): 0=hybrid (default), 1=analog, - * 2=digital. hybrid_analog is the per-frame auto-switch latch used only in - * hybrid mode: true => currently presenting DualShock (stick was the last - * input), false => currently presenting a digital pad (D-pad was last). */ - int mode = PSXRecompV4::PAD_MODE_HYBRID; - bool hybrid_analog = false; - SDL_GameController* handle = nullptr; - SDL_JoystickID instance = -1; -}; -static PlayerInput g_players[2]; -/* ARGB8888 staging buffer. Sized for the active internal resolution: - * 640*scale x 512*scale. Allocated once the supersampling scale is known - * (sized for the native 640x512 when supersampling is off). */ -static uint32_t* sdl_pixel_buf = nullptr; -/* Live OpenGL internal-scale changes may grow the CPU fallback/readback path. - * Never shrink this allocation during a session: if a GL target rebuild fails, - * the previous (larger) renderer scale must remain safe to present. */ -static int sdl_pixel_buf_scale_capacity = 0; - -typedef void (*ModFrameHook)(void); - -static std::vector& mod_frame_hooks() { - static std::vector hooks; - return hooks; -} - -/* Statically linked mod code can register per-frame callbacks here. Hooks run - * after normal input sampling and before presentation/widescreen frame work. */ -extern "C" void mod_register_frame_hook(ModFrameHook hook) { - if (hook) mod_frame_hooks().push_back(hook); -} - -static void mod_call_frame_hooks() { - auto& hooks = mod_frame_hooks(); - const size_t count = hooks.size(); - for (size_t i = 0; i < count; i++) hooks[i](); -} - -/* Optional game-owned in-game UI. Keep the registration state here rather - * than in a renderer so input routing and renderer lifetime remain coordinated - * and a build with no provider has no extra source or link dependency. */ -struct HostUiState { - PsxHostUiHooks hooks{}; - bool registered = false; - bool session_live = false; - SDL_Window *window = nullptr; - int backend = PSX_HOST_UI_BACKEND_NONE; -}; - -static HostUiState& host_ui_state() { - static HostUiState state; - return state; -} - -static std::string g_host_user_settings_path_utf8; - -extern "C" const char *psx_host_user_settings_path(void) { - return g_host_user_settings_path_utf8.empty() - ? nullptr : g_host_user_settings_path_utf8.c_str(); -} - -extern "C" int psx_host_ui_register(const PsxHostUiHooks *hooks) { - if (!hooks || hooks->abi_version != PSX_HOST_UI_ABI_VERSION || - hooks->struct_size < sizeof(PsxHostUiHooks)) - return 0; - - HostUiState& state = host_ui_state(); - if (state.registered) return 0; - state.hooks = *hooks; - state.registered = true; - if (state.session_live && state.hooks.on_runtime_ready) - state.hooks.on_runtime_ready(state.hooks.userdata, state.window, - state.backend); - return 1; -} - -static void host_ui_runtime_ready(SDL_Window *window, int backend) { - HostUiState& state = host_ui_state(); - /* A lobby soft-return may create another game session in the same process. - * Close the prior provider lifetime defensively before opening a new one. */ - if (state.session_live && state.registered && - state.hooks.on_runtime_shutdown) - state.hooks.on_runtime_shutdown(state.hooks.userdata); - state.window = window; - state.backend = backend; - state.session_live = true; - if (state.registered && state.hooks.on_runtime_ready) - state.hooks.on_runtime_ready(state.hooks.userdata, window, backend); -} - -static void host_ui_runtime_shutdown() { - HostUiState& state = host_ui_state(); - if (!state.session_live) return; - if (state.registered && state.hooks.on_runtime_shutdown) - state.hooks.on_runtime_shutdown(state.hooks.userdata); - state.session_live = false; - state.window = nullptr; - state.backend = PSX_HOST_UI_BACKEND_NONE; -} - -extern "C" int psx_host_ui_handle_event(const SDL_Event *event) { - HostUiState& state = host_ui_state(); - if (!event || !state.session_live || !state.registered || - !state.hooks.on_sdl_event) - return 0; - return state.hooks.on_sdl_event(state.hooks.userdata, event) ? 1 : 0; -} - -extern "C" uint32_t psx_host_ui_capture_flags(void) { - HostUiState& state = host_ui_state(); - if (!state.session_live || !state.registered || !state.hooks.flags) - return 0; - return state.hooks.flags(state.hooks.userdata); -} - -static int (*g_fast_forward_query)(void) = nullptr; - -extern "C" void psx_host_set_fast_forward_query(int (*query)(void)) { - g_fast_forward_query = query; -} - -extern "C" int psx_host_ui_game_input_captured(void) { - const uint32_t input = PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_MOUSE | - PSX_HOST_UI_CAPTURE_GAMEPAD; - return (psx_host_ui_capture_flags() & input) != 0; -} - -extern "C" int psx_host_ui_gl_visible(void) { - HostUiState& state = host_ui_state(); - return state.session_live && state.registered && - state.backend == PSX_HOST_UI_BACKEND_OPENGL && - state.hooks.render_gl && - (psx_host_ui_capture_flags() & PSX_HOST_UI_VISIBLE) != 0; -} - -extern "C" void psx_host_ui_render_gl(int drawable_width, - int drawable_height) { - HostUiState& state = host_ui_state(); - if (!state.session_live || !state.registered || - state.backend != PSX_HOST_UI_BACKEND_OPENGL || - !state.hooks.render_gl) - return; - state.hooks.render_gl(state.hooks.userdata, drawable_width, - drawable_height); -} - -/* Presentation-only interpolation for software-rendered content that repeats - * each guest image for two vblanks. This never changes guest or audio timing. */ -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static std::atomic g_smooth_60fps{0}; - -struct Smooth60State { - std::vector previous_source; - uint64_t source_hash = 0; - uint32_t width = 0; - uint32_t height = 0; - bool have_source = false; - bool previous_was_duplicate = false; - bool announced = false; -}; - -static Smooth60State g_smooth_60_state; -#endif - -/* Present-path session state — must reset on rematch (function-local statics - * survive soft-return and poison FMV/FPS after session_reboot). */ -static bool s_disabled_frame_presented = false; -static bool s_force_present_after_load = false; -static Uint64 s_fps_last_time = 0; -static uint64_t s_fps_last_frame = 0; -static std::string s_fps_base_title; -static FramePacer s_frame_pacer = { 0 }; -static int s_turbo_present_skip = 0; -static int s_fmv_skip_present_skip = 0; -/* Netplay + depth24: present every other vblank (admit still every tick). */ -static int s_netplay_depth24_present_skip = 0; -static uint32_t s_fmv_skip_last_mdec = 0; -static int s_fmv_skip_hold = 0; - -/* Lightweight title/stderr FPS telemetry is developer-facing and opt-in. - * Keep release window titles clean unless explicitly requested. */ -static bool fps_telemetry_enabled() { - static const bool enabled = [] { - const char* value = std::getenv("PSX_FPS_TELEMETRY"); - return value && value[0] && value[0] != '0'; - }(); - return enabled; -} - -static void smooth_60_reset(void) { -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - g_smooth_60_state.previous_source.clear(); - g_smooth_60_state.source_hash = 0; - g_smooth_60_state.width = 0; - g_smooth_60_state.height = 0; - g_smooth_60_state.have_source = false; - g_smooth_60_state.previous_was_duplicate = false; -#endif -} - -static void present_session_reset(void) { - s_disabled_frame_presented = false; - s_force_present_after_load = false; - s_fps_last_time = 0; - s_fps_last_frame = 0; - s_fps_base_title.clear(); - s_frame_pacer = FramePacer{ 0 }; - s_turbo_present_skip = 0; - s_fmv_skip_present_skip = 0; - s_netplay_depth24_present_skip = 0; - s_fmv_skip_last_mdec = 0; - s_fmv_skip_hold = 0; - smooth_60_reset(); -} - -extern "C" int dirty_ram_text_image_registered(void); -extern "C" int psx_game_text_native_ok(uint32_t addr); -extern "C" void memory_keep_low_boot_scratch(void); -static void take_loaded_state_for_started(void); - -/* Called from savestate_poll after a successful restore (before scheduler - * longjmp). Clears present latches and forces the next vblank to show the - * restored VRAM — including a blank if display was disabled in the snapshot. */ -extern "C" void psx_frontend_on_savestate_loaded(void) { -#ifdef PSX_HAS_DISRUPTOR_CAPSULE - disruptor_capsule_state_loaded(); -#endif -#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING - disruptor_far_rendering_abandon_metrics(); -#endif - s_disabled_frame_presented = false; - s_force_present_after_load = true; - smooth_60_reset(); - /* Re-anchor wall pacing + FPS baseline: admit may have blocked for seconds - * in the load barrier with next_deadline in the past. */ - s_frame_pacer = FramePacer{ 0 }; - s_fps_last_time = 0; - s_fps_last_frame = 0; - /* GL present-dirty early-out can skip SwapWindow when the restored frame - * matches the last swap (typical on 2nd+ load of the same slot). Invalidate - * tiles + force several swaps so the window actually updates. Safe no-op - * when the GL pipeline was never brought up. */ - gl_renderer_invalidate_present(); - take_loaded_state_for_started(); -} - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION -static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { - uint64_t hash = 1469598103934665603ull; - const size_t step = std::max(1, count / 4096u); - for (size_t i = 0; i < count; i += step) { - hash ^= pixels[i]; - hash *= 1099511628211ull; - } - hash ^= count; - return hash * 1099511628211ull; -} - -static bool smooth_60_scene_cut(const uint32_t* current, - const uint32_t* previous, - size_t count) { - const size_t step = std::max(1, count / 4096u); - uint64_t difference = 0; - size_t samples = 0; - size_t large_changes = 0; - for (size_t i = 0; i < count; i += step) { - const uint32_t a = current[i]; - const uint32_t b = previous[i]; - const unsigned dr = (unsigned)std::abs((int)((a >> 16) & 0xFFu) - - (int)((b >> 16) & 0xFFu)); - const unsigned dg = (unsigned)std::abs((int)((a >> 8) & 0xFFu) - - (int)((b >> 8) & 0xFFu)); - const unsigned db = (unsigned)std::abs((int)(a & 0xFFu) - - (int)(b & 0xFFu)); - const unsigned pixel_difference = dr + dg + db; - difference += pixel_difference; - if (pixel_difference > 320u) large_changes++; - samples++; - } - return samples && large_changes * 4u > samples * 3u && - difference > (uint64_t)samples * 360u; -} - -static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height, - bool eligible) { - if (!eligible || !pixels || !g_smooth_60fps.load(std::memory_order_acquire)) { - if (g_smooth_60_state.have_source) smooth_60_reset(); - return; - } - Smooth60State& state = g_smooth_60_state; - const size_t count = (size_t)width * height; - if (!count) { smooth_60_reset(); return; } - const uint64_t hash = smooth_60_frame_hash(pixels, count); - if (!state.have_source || state.width != width || state.height != height) { - state.previous_source.assign(pixels, pixels + count); - state.source_hash = hash; - state.width = width; - state.height = height; - state.have_source = true; - state.previous_was_duplicate = false; - return; - } - const bool duplicate = hash == state.source_hash && - std::memcmp(pixels, state.previous_source.data(), count * sizeof(uint32_t)) == 0; - if (duplicate) { state.previous_was_duplicate = true; return; } - const bool interpolate = state.previous_was_duplicate && - !smooth_60_scene_cut(pixels, state.previous_source.data(), count); - state.previous_was_duplicate = false; - state.source_hash = hash; - if (!interpolate) { - std::copy(pixels, pixels + count, state.previous_source.begin()); - return; - } - if (!state.announced) { - std::fprintf(stdout, - "psxrecomp: 60 FPS smoothing active (guest timing preserved)\n"); - state.announced = true; - } - for (size_t i = 0; i < count; i++) { - const uint32_t current = pixels[i]; - const uint32_t previous = state.previous_source[i]; - state.previous_source[i] = current; - pixels[i] = 0xFF000000u | - (((current & 0x00FEFEFEu) >> 1) + ((previous & 0x00FEFEFEu) >> 1)); - } -} - -extern "C" void psx_smooth_60fps_set(int enabled) { - g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release); -} -#else -static void smooth_60_present(uint32_t* pixels, uint32_t width, uint32_t height, - bool eligible) { - (void)pixels; (void)width; (void)height; (void)eligible; -} -extern "C" void psx_smooth_60fps_set(int enabled) { (void)enabled; } -#endif -#if defined(PSX_WEB) -extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { - psx_smooth_60fps_set(enabled); -} -#endif - -/* [video] options, resolved from the game config (defaults: native + AA). */ -static int g_video_scale = 1; /* internal-resolution SSAA factor */ -static bool g_video_aa = true; /* linear present filtering */ -static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ -static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; -static bool g_geometry_correction = false; /* visual-only subpixel mirror */ -static bool g_texture_correction = false; /* visual-only perspective UVs */ -static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) - * fullscreen, 2 exclusive fullscreen */ -static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ -static int g_video_win_w = 1280; /* window width (height follows aspect) */ -static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ -static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ -static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ - -static void parappa_apply_timing_setting(const std::string& mode_in, - int extra_early_override, - int extra_late_override, - const char *source) { - std::string mode = mode_in; - for (char& c : mode) c = (char)std::tolower((unsigned char)c); - - int next_mode = 0; - int next_early = 0; - int next_late = 0; - if (mode == "stock" || mode == "off" || mode.empty()) { - next_mode = 0; - } else if (mode == "medium") { - next_mode = 1; - next_early = 2; - next_late = 2; - } else if (mode == "permissive") { - next_mode = 2; - next_early = 6; - next_late = 6; - } else if (mode == "easy") { - next_mode = 4; - next_early = 10; - next_late = 10; - } else if (mode == "custom") { - next_mode = 3; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring unknown PaRappa timing mode '%s' from %s\n", - mode_in.c_str(), source ? source : "config"); - return; - } - - if (extra_early_override >= 0) next_early = std::min(extra_early_override, 60); - if (extra_late_override >= 0) next_late = std::min(extra_late_override, 60); - if ((extra_early_override >= 0 || extra_late_override >= 0) && next_mode == 0) - next_mode = 3; - - g_parappa_timing_mode = next_mode; - g_parappa_timing_extra_early = next_early; - g_parappa_timing_extra_late = next_late; - parappa_timing_window_reset(); - std::fprintf(stdout, - "psxrecomp: PaRappa timing mode %s (early=%d late=%d, source=%s)\n", - next_mode == 1 ? "medium" : next_mode == 2 ? "permissive" : - next_mode == 3 ? "custom" : next_mode == 4 ? "easy" : "stock", - g_parappa_timing_extra_early, g_parappa_timing_extra_late, - source ? source : "config"); -} -/* FMV instant-skip via the game's OWN end-of-movie path. Tomba's MDEC player - * (FUN_8001efe8) tears a movie down when the streamed frame number reaches that - * movie's per-movie total minus 3; writing the current movie's total down to - * g_fmv_skip_end_total makes the player end it on the next frame — a natural end - * that reaches EVERY movie. g_fmv_skip_total_table = base of the per-movie u16 - * total table; g_fmv_skip_movie_id = guest addr of the current-movie-id byte. - * 0 table => fall back to START injection. See [video] fmv_skip_* in game.toml. */ -static uint32_t g_fmv_skip_total_table = 0; -static uint32_t g_fmv_skip_movie_id = 0; -static int g_fmv_skip_end_total = 3; -/* fmv_skip_no_xa: detect FMVs on MDEC activity alone (silent RAM-preloaded - * movies never stream XA, so the default MDEC+XA detector misses them — - * Tomba2's Whoopee Camp logo). Presentation-side fast-forward only. */ -static int g_fmv_skip_no_xa = 0; -static int g_fmv_skip_no_xa_hold = 4; -/* Low-latency present options (see [video] in config_loader). Measured on a - * 60Hz box the dominant input->photon cost is NOT vsync at the swap (that - * blocks ~tens of us) but that input is sampled ~one pacer-wait (~13.6ms) - * before the frame the CPU then renders from it. g_low_latency_input re-samples - * the pad AFTER the wall-clock pacer (just before present) so the next CPU frame - * reads near-fresh input. g_video_vsync controls the GL swap interval - * (1=vsync/tear-free, 0=immediate/lowest display latency+tearing, -1=adaptive); - * it trims the display-side scanout latency the CPU-side ring can't see. */ -static int g_low_latency_input = 1; -static int g_video_vsync = 1; -static int g_frame_interpolation = 0; -static int g_frame_interpolation_fps = 0; -#if defined(DISRUPTOR_DEV_MENU) -static int g_frame_interpolation_blend = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; -static int g_frame_interpolation_blend_default = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; -#else -static int g_frame_interpolation_blend = - PSX_MOD_FRAME_INTERPOLATION_LINEAR; -static int g_frame_interpolation_blend_default = - PSX_MOD_FRAME_INTERPOLATION_LINEAR; -#endif -static int g_mod_controller_mode_override[2] = { -1, -1 }; -static_assert((int)PSX_MOD_CONTROLLER_HYBRID == - (int)PSXRecompV4::PAD_MODE_HYBRID); -static_assert((int)PSX_MOD_CONTROLLER_ANALOG == - (int)PSXRecompV4::PAD_MODE_ANALOG); -static_assert((int)PSX_MOD_CONTROLLER_DIGITAL == - (int)PSXRecompV4::PAD_MODE_DIGITAL); -static double g_host_refresh_hz = 0.0; -static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; -static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; -static bool g_mod_native_vblank_rate = false; -static uint32_t g_mod_native_vblank_fps = 0; -/* Activation-time request. -1 means no enabled mod owns load acceleration. */ -static int g_mod_load_wall_multiplier = -1; -static int g_mod_load_release_frames = -1; -static int g_mod_disc_speed_divisor = -1; -static int g_mod_disc_instant_rate = -1; - -/* Map the configured tri-state fullscreen mode (g_fullscreen) to the SDL - * window-fullscreen flag: used both to open the window in that mode and to - * pick the hotkey's fullscreen target. */ -static Uint32 psx_fullscreen_flag_for_mode(int mode) { - if (mode == 2) return SDL_WINDOW_FULLSCREEN; /* exclusive */ - if (mode == 1) return SDL_WINDOW_FULLSCREEN_DESKTOP; /* borderless */ - return 0; /* windowed */ -} - -/* SDL2 selects borderless vs exclusive through SDL_SetWindowFullscreen's - * flag. SDL3 moved that distinction into the window's optional fullscreen - * display mode: NULL means borderless desktop and a concrete display mode - * means exclusive. Keep that difference behind one helper so startup, the - * host UI, and the fullscreen hotkey all agree. */ -static int psx_window_fullscreen_mode(SDL_Window *window) { - if (!window) return g_fullscreen; - const Uint32 flags = (Uint32)SDL_GetWindowFlags(window); - if ((flags & SDL_WINDOW_FULLSCREEN) == 0) return 0; -#if defined(PSX_SDL3) - return SDL_GetWindowFullscreenMode(window) ? 2 : 1; -#else - return ((flags & SDL_WINDOW_FULLSCREEN_DESKTOP) == - SDL_WINDOW_FULLSCREEN_DESKTOP) ? 1 : 2; -#endif -} - -#if defined(_WIN32) && defined(PSX_SDL3) -/* Keeps a borderless OpenGL window from covering its screen exactly (host_borderless.h). SDL fits it to the screen again when it is restored, so this is asked every frame. */ -static void psx_borderless_keep_windowed(void) { - int hidden = 0; - if (sdl_window && g_video_renderer == 1 && psx_window_fullscreen_mode(sdl_window) == 1) { - const HWND handle = (HWND)SDL_GetPointerProperty(SDL_GetWindowProperties(sdl_window), SDL_PROP_WINDOW_WIN32_HWND_POINTER, nullptr); - MONITORINFO info = {}; - info.cbSize = sizeof(info); - RECT now = {}; - if (handle && GetMonitorInfoW(MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST), &info) && GetWindowRect(handle, &now)) { - const PsxHostRect screen = {info.rcMonitor.left, info.rcMonitor.top, info.rcMonitor.right, info.rcMonitor.bottom}; - PsxHostRect window = {now.left, now.top, now.right, now.bottom}, wanted = {}; - if (psx_borderless_wanted(window, screen, &wanted) && - SetWindowPos(handle, nullptr, wanted.left, wanted.top, wanted.right - wanted.left, wanted.bottom - wanted.top, - SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER)) { - window = wanted; - std::fprintf(stderr, "psxrecomp: borderless window kept one row taller than its screen\n"); - } - int pixels_wide = 0, pixels_high = 0; - SDL_GetWindowSizeInPixels(sdl_window, &pixels_wide, &pixels_high); - hidden = psx_borderless_hidden_rows(window, screen, pixels_high); - } - } - gl_renderer_set_hidden_rows(hidden); -} -#else -static void psx_borderless_keep_windowed(void) {} -#endif - -static int psx_apply_window_fullscreen_mode(SDL_Window *window, int mode) { - if (!window || mode < 0 || mode > 2) return 0; -#if defined(PSX_SDL3) - if (mode == 0) { - if (!SDL_SetWindowFullscreen(window, false)) return 0; - } else if (mode == 1) { - if (!SDL_SetWindowFullscreenMode(window, nullptr)) return 0; - if (!SDL_SetWindowFullscreen(window, true)) return 0; - } else { - const SDL_DisplayID display = SDL_GetDisplayForWindow(window); - const SDL_DisplayMode *desktop = - display ? SDL_GetDesktopDisplayMode(display) : nullptr; - if (!display || !desktop) return 0; - - int width = 0; - int height = 0; - if (!SDL_GetWindowSizeInPixels(window, &width, &height) || - width <= 0 || height <= 0) { - width = desktop->w; - height = desktop->h; - } - SDL_DisplayMode closest = {}; - if (!SDL_GetClosestFullscreenDisplayMode( - display, width, height, desktop->refresh_rate, true, - &closest)) { - return 0; - } - if (!SDL_SetWindowFullscreenMode(window, &closest)) return 0; - if (!SDL_SetWindowFullscreen(window, true)) return 0; - } - /* Fullscreen transitions may be asynchronous. Settle the request before - * reporting success so the live getter and menu cannot disagree. */ - if (!SDL_SyncWindow(window)) return 0; -#else - if (SDL_SetWindowFullscreen( - window, psx_fullscreen_flag_for_mode(mode)) != 0) { - return 0; - } -#endif - psx_borderless_keep_windowed(); /* at once: a present may come before the next vblank */ - return psx_window_fullscreen_mode(window) == mode ? 1 : 0; -} - -extern "C" int psx_host_video_get_fullscreen_mode(void) { - return sdl_window ? psx_window_fullscreen_mode(sdl_window) : g_fullscreen; -} - -extern "C" int psx_host_video_set_fullscreen_mode(int mode) { - if (mode < 0 || mode > 2) return 0; - if (sdl_window && !psx_apply_window_fullscreen_mode(sdl_window, mode)) { - std::fprintf(stderr, - "psxrecomp: failed to apply fullscreen mode %d: %s\n", - mode, SDL_GetError()); - return 0; - } - g_fullscreen = mode; - return 1; -} - -/* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ -extern "C" int cdrom_xa_stream_active(void); -extern "C" uint32_t mdec_get_decode_count(void); - -/* Debug observability: expose the RESOLVED FMV-skip + config state so the TCP - * debug server can report the runtime truth (which game.toml path actually loaded, - * and what auto_skip_fmv / the frame-count-table params resolved to) instead of us - * inferring it from the .toml on disk. Set once in main() after the config path is - * resolved. */ -const char *g_active_config_path = nullptr; -extern "C" void debug_get_fmv_config(int *auto_skip, uint32_t *total_table, - uint32_t *movie_id, int *no_xa_hold, - const char **cfg_path) { - if (auto_skip) *auto_skip = g_auto_skip_fmv; - if (total_table) *total_table = g_fmv_skip_total_table; - if (movie_id) *movie_id = g_fmv_skip_movie_id; - if (no_xa_hold) *no_xa_hold = g_fmv_skip_no_xa_hold; - if (cfg_path) *cfg_path = g_active_config_path ? g_active_config_path : "(null)"; -} - -/* Display aspect W:H (default 4:3 = native). Wider aspects enable the - * widescreen hack: GTE X-squash + stretched present (see [video] aspect_ratio - * in config_loader.h). */ -static int g_video_aspect_num = 4; -static int g_video_aspect_den = 3; -/* Host-UI aspect requests may arrive while the current frame is being - * composed. Keep the renderer/GTE/GPU transition at the next presentation - * boundary instead of changing projection state halfway through a frame. - * Fixed requests are packed as numerator:denominator; the two reserved values - * select adaptive mode. Zero means no pending request. A single atomic makes - * the most recent fixed/adaptive selection win without a mixed transition. */ -static std::atomic g_pending_video_aspect{0}; -static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_ON = 0xFFFFFFFFu; -static constexpr uint32_t PENDING_VIDEO_ASPECT_ADAPTIVE_OFF = 0xFFFFFFFEu; -/* Resize-driven widescreen. The user's fixed aspect is still used to shape the - * initial window; after the game window exists these values follow its live - * aspect, clamped to 4:3..the widest mode offered by the title. */ -static bool g_ws_adaptive_view = false; -static int g_ws_adaptive_max_num = 16; -static int g_ws_adaptive_max_den = 9; - -static uint32_t pack_display_aspect(int numerator, int denominator) { - return ((uint32_t)numerator << 16) | (uint32_t)denominator; -} - -static bool is_host_fixed_display_aspect(int numerator, int denominator) { - return (numerator == 4 && denominator == 3) || - (numerator == 16 && denominator == 9) || - (numerator == 21 && denominator == 9) || - (numerator == 32 && denominator == 9); -} - -extern "C" void psx_host_video_get_display_aspect( - int *numerator, int *denominator) { - /* Report an accepted pending selection immediately so a host UI does not - * visibly snap back during the one-frame handoff to the present thread. */ - uint32_t packed = g_pending_video_aspect.load(std::memory_order_acquire); - const bool fixed_pending = - packed != 0 && - packed != PENDING_VIDEO_ASPECT_ADAPTIVE_ON && - packed != PENDING_VIDEO_ASPECT_ADAPTIVE_OFF; - int num = fixed_pending ? (int)(packed >> 16) : g_video_aspect_num; - int den = fixed_pending ? (int)(packed & 0xFFFFu) : g_video_aspect_den; - if (numerator) *numerator = num; - if (denominator) *denominator = den; -} - -extern "C" int psx_host_video_set_display_aspect( - int numerator, int denominator) { - if (!is_host_fixed_display_aspect(numerator, denominator)) return 0; - g_pending_video_aspect.store( - pack_display_aspect(numerator, denominator), - std::memory_order_release); - return 1; -} - -extern "C" int psx_host_video_get_adaptive_view(void) { - uint32_t pending = - g_pending_video_aspect.load(std::memory_order_acquire); - if (pending == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) return 1; - if (pending != 0) return 0; /* Explicit off or a pending fixed aspect. */ - return g_ws_adaptive_view ? 1 : 0; -} - -extern "C" int psx_host_video_set_adaptive_view(int enabled) { - if (enabled != 0 && enabled != 1) return 0; - g_pending_video_aspect.store( - enabled ? PENDING_VIDEO_ASPECT_ADAPTIVE_ON - : PENDING_VIDEO_ASPECT_ADAPTIVE_OFF, - std::memory_order_release); - return 1; -} - -extern "C" int psx_host_video_get_internal_scale(void) { - return g_video_scale; -} - -extern "C" int psx_host_video_set_internal_scale(int scale) { - if (scale < 1 || scale > SW_MAX_INTERNAL_SCALE) return 0; - /* The developer UI itself is OpenGL-backed. Software and Vulkan keep - * startup-only allocation rules until their presentation resources gain - * an equivalent transactional rebuild. */ - if (!host_ui_state().session_live || - host_ui_state().backend != PSX_HOST_UI_BACKEND_OPENGL) { - return 0; - } - if (scale == g_video_scale) return 1; - - if (scale > sdl_pixel_buf_scale_capacity) { - uint32_t *grown = static_cast(std::realloc( - sdl_pixel_buf, - (size_t)640 * scale * 512 * scale * sizeof(uint32_t))); - if (!grown) return 0; - sdl_pixel_buf = grown; - sdl_pixel_buf_scale_capacity = scale; - } - - gr_set_scale(scale); - const int applied = gr_scale(); - if (applied != scale) return 0; - g_video_scale = applied; - gl_renderer_invalidate_present(); - return 1; -} - -extern "C" int psx_mod_set_fixed_display_aspect( - uint32_t numerator, uint32_t denominator) { - if (numerator == 0 || denominator == 0 || - numerator > 99 || denominator > 99 || - 3u * numerator < 4u * denominator || - 9u * numerator > 32u * denominator) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid display aspect %u:%u\n", - (unsigned)numerator, (unsigned)denominator); - return 0; - } - g_video_aspect_num = (int)numerator; - g_video_aspect_den = (int)denominator; - g_ws_adaptive_view = false; - std::fprintf(stdout, "psxrecomp: mod selected fixed display aspect %u:%u\n", - (unsigned)numerator, (unsigned)denominator); - return 1; -} - -extern "C" int psx_mod_set_adaptive_display_aspect( - uint32_t max_numerator, uint32_t max_denominator) { - if (max_numerator == 0 || max_denominator == 0 || - max_numerator > 99 || max_denominator > 99 || - 3u * max_numerator < 4u * max_denominator || - 9u * max_numerator > 32u * max_denominator) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid adaptive display aspect %u:%u\n", - (unsigned)max_numerator, (unsigned)max_denominator); - return 0; - } - g_ws_adaptive_view = true; - g_ws_adaptive_max_num = (int)max_numerator; - g_ws_adaptive_max_den = (int)max_denominator; - std::fprintf(stdout, - "psxrecomp: mod selected adaptive display aspect " - "(initial %d:%d, range 4:3 through %u:%u)\n", - g_video_aspect_num, g_video_aspect_den, - (unsigned)max_numerator, (unsigned)max_denominator); - return 1; -} - -extern "C" int psx_mod_set_native_vblank_rate( - uint32_t frames_per_second) { - if (frames_per_second != 0 && - (frames_per_second < 60 || frames_per_second > 1000)) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid native VBlank rate %u FPS\n", - (unsigned)frames_per_second); - return 0; - } - g_mod_native_vblank_rate = true; - g_mod_native_vblank_fps = frames_per_second; - g_frame_period_ms = frames_per_second - ? 1000.0 / (double)frames_per_second - : 0.0; - /* - * Above the physical panel rate (and in uncapped mode), swap-interval - * blocking would silently replace the requested guest cadence with the - * display cadence. The frame pacer owns fixed-rate timing here. - */ - if (frames_per_second == 0 || frames_per_second > 60) - g_video_vsync = 0; - if (frames_per_second) { - std::fprintf(stdout, - "psxrecomp: mod selected native guest VBlank pacing at %u FPS " - "(%.4f ms/frame)\n", - (unsigned)frames_per_second, g_frame_period_ms); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected uncapped native guest VBlank pacing\n"); - } - return 1; -} - -extern "C" int psx_mod_set_frame_interpolation( - uint32_t frames_per_second) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - (void)frames_per_second; - return 0; -#else - if (frames_per_second != 0 && - (frames_per_second < 60 || frames_per_second > 1000)) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid interpolated frame rate %u FPS\n", - (unsigned)frames_per_second); - return 0; - } - - /* - * Interpolation is a presentation feature of the OpenGL renderer. Mods are - * activated before the game window and renderer are created, so selecting - * it here is deterministic and does not require a live backend switch. - */ - g_video_renderer = 1; - g_video_vsync = 0; - g_frame_interpolation = 1; - g_frame_interpolation_fps = - frames_per_second ? (int)frames_per_second : 0; - - if (frames_per_second) { - std::fprintf(stdout, - "psxrecomp: mod selected presentation-only interpolation at " - "%u FPS; guest timing remains stock\n", - (unsigned)frames_per_second); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected display-refresh presentation-only " - "interpolation; guest timing remains stock\n"); - } - return 1; -#endif -} - -extern "C" int psx_mod_set_frame_interpolation_blend( - uint32_t blend_mode) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - (void)blend_mode; - return 0; -#else - if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && - blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid frame-interpolation blend %u\n", - (unsigned)blend_mode); - return 0; - } - g_frame_interpolation_blend = (int)blend_mode; - std::fprintf(stdout, "psxrecomp: frame-interpolation blend = %s\n", - blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE - ? "motion-adaptive clarity" : "linear crossfade"); - return 1; -#endif -} - -extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { - if (enabled != 0 && enabled != 1) { - std::fprintf(stderr, - "psxrecomp: mod rejected invalid auto-skip-FMV value %d\n", - enabled); - return 0; - } - g_auto_skip_fmv = enabled; - std::fprintf(stdout, "psxrecomp: mod %s automatic FMV skipping\n", - enabled ? "enabled" : "disabled"); - return 1; -} - -extern "C" int psx_mod_set_load_acceleration( - uint32_t wall_clock_multiplier, uint32_t release_frames) { - if ((wall_clock_multiplier != 0 && - (wall_clock_multiplier < 2 || wall_clock_multiplier > 16)) || - release_frames > 60) { - std::fprintf(stderr, - "psxrecomp: mod rejected load acceleration " - "(multiplier %u, release frames %u)\n", - (unsigned)wall_clock_multiplier, (unsigned)release_frames); - return 0; - } - g_mod_load_wall_multiplier = (int)wall_clock_multiplier; - g_mod_load_release_frames = (int)release_frames; - return 1; -} - -extern "C" int psx_mod_set_disc_speed( - uint32_t divisor, uint32_t instant_max_per_frame) { - if ((divisor != 0 && divisor != 2 && divisor != 4) || - (divisor == 0 && - (instant_max_per_frame < 1 || instant_max_per_frame > 256))) { - std::fprintf(stderr, - "psxrecomp: mod rejected disc speed " - "(divisor %u, instant budget %u)\n", - (unsigned)divisor, (unsigned)instant_max_per_frame); - return 0; - } - g_mod_disc_speed_divisor = (int)divisor; - g_mod_disc_instant_rate = - divisor == 0 ? (int)instant_max_per_frame : -1; - return 1; -} - -extern "C" int psx_mod_set_controller_mode_override( - uint32_t player, uint32_t controller_mode) { - if (player >= 2 || - controller_mode > (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL) { - std::fprintf(stderr, - "psxrecomp: mod rejected controller-mode override " - "(player %u, mode %u)\n", - (unsigned)player, (unsigned)controller_mode); - return 0; - } - g_mod_controller_mode_override[player] = (int)controller_mode; - return 1; -} - -/* [widescreen] per-game hooks (see config_loader.h): anchor scratch addr for - * tagged sprite prims + HUD SPRT center-squash. Inert at 0/false. */ -static uint32_t g_ws_anchor_addr = 0; -static bool g_ws_hud_sprt = false; -/* Runtime-only transition cleanup; kept out of gpu.h because generated game - * units include that ABI header and do not need this frontend-only setter. */ -extern "C" void gpu_ws_set_clear_reveal(int on); -extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct); -extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int); -/* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot - * — Sony logo, PS logo, shell — presents authentic 4:3 with no GTE squash. - * Starts true when the configured aspect is already 4:3 (nothing to engage). */ -static bool g_ws_engaged = true; -/* Wide-aspect strategy: native-wide (render the wider FOV into a wider frame, - * present 1:1 — the GTE is NOT squashed) vs. the legacy squash hack. Default - * native-wide; toggle live via the ws_nw TCP command for A/B comparison. */ -static int g_ws_native_wide = 1; -/* Logical present width for the SDL_Renderer (software) path; 640*scale at - * 4:3, wider for wide aspects. Height is always 480*scale. Set at window - * creation alongside SDL_RenderSetLogicalSize. */ -static int g_logical_w = 640; -static bool g_gl_active = false; /* GL context live -> GL present path */ -static bool g_vk_active = false; /* Vulkan context live -> VK present path */ - -/* Clamp a requested window width to the primary display's usable area so an - * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps - * the given aspect: height = width*den/num. */ -static void clamp_window_aspect(int* w, int* h, int num, int den) { - int width = *w; - if (width < 640) width = 640; - SDL_Rect bounds; - if (SDL_GetDisplayUsableBounds(0, &bounds) == 0 && bounds.w > 0 && bounds.h > 0) { - if (width > bounds.w) width = bounds.w; - if (width * den / num > bounds.h) width = bounds.h * num / den; - } - *w = width; - *h = width * den / num; -} - -static int aspect_gcd(int a, int b) { - while (b) { int t = a % b; a = b; b = t; } - return a > 0 ? a : 1; -} - -/* Resize only an ordinary window. Fullscreen owns the display mode/desktop - * extent; its compositor instead follows the logical/present aspect below. */ -static void reshape_window_for_fixed_aspect(int num, int den) { - if (!sdl_window || - (SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN) != 0) - return; - - int width = 0, height = 0; - SDL_GetWindowSize(sdl_window, &width, &height); - if (width <= 0) return; - height = width * den / num; - clamp_window_aspect(&width, &height, num, den); - SDL_SetWindowSize(sdl_window, width, height); -} - -/* One transition funnel for adaptive resizing and explicit host-UI choices. - * It runs only from sdl_vblank_present, after game hooks have completed, so a - * frame cannot mix old projection state with a new presentation shape. */ -static void apply_live_display_aspect(int num, int den, - bool reshape_fixed_window) { - const bool changed = - num != g_video_aspect_num || den != g_video_aspect_den; - g_video_aspect_num = num; - g_video_aspect_den = den; - - gl_renderer_set_display_aspect(num, den); - if (sdl_renderer) { - g_logical_w = 480 * num * g_video_scale / den; - SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, - 480 * g_video_scale); - } - if (reshape_fixed_window) reshape_window_for_fixed_aspect(num, den); - - const bool wide = num * 3 != den * 4; - const bool game_started = fntrace_is_game_started() != 0; - if (game_started) { - g_ws_engaged = true; - const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - gte_set_display_aspect(mode == 1 ? num : 4, - mode == 1 ? den : 3); - gpu_ws_configure(num, den, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, mode); - } else { - /* Preserve authentic 4:3 boot rendering even when a host UI selects a - * wide output before game entry. The normal engagement path will pick - * up the already-updated num:den once gameplay begins. */ - g_ws_engaged = !wide; - gte_set_display_aspect(4, 3); - gpu_ws_configure(4, 3, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, 0); - } - - if (changed && g_gl_active) { - /* Do not crossfade or reuse a stale destination rectangle across an - * aspect transition. This also forces an otherwise-identical next - * frame through the GL present path. */ - gl_renderer_invalidate_present(); - } -} - -static void update_adaptive_widescreen(); - -static void apply_pending_display_aspect() { - uint32_t packed = - g_pending_video_aspect.exchange(0, std::memory_order_acq_rel); - if (!packed) return; - - if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_ON) { - g_ws_adaptive_view = true; - /* The developer-facing match-window mode supports every intermediate - * window ratio, but never exposes more than the audited 32:9 view. */ - g_ws_adaptive_max_num = 32; - g_ws_adaptive_max_den = 9; - update_adaptive_widescreen(); - std::fprintf(stdout, - "psxrecomp: live adaptive display aspect enabled " - "(match window, maximum 32:9)\n"); - return; - } - if (packed == PENDING_VIDEO_ASPECT_ADAPTIVE_OFF) { - /* Leave the current effective aspect in place. A subsequent fixed - * selection can choose and reshape a canonical fixed output. */ - g_ws_adaptive_view = false; - std::fprintf(stdout, - "psxrecomp: live adaptive display aspect disabled\n"); - return; - } - - int num = (int)(packed >> 16); - int den = (int)(packed & 0xFFFFu); - g_ws_adaptive_view = false; - apply_live_display_aspect(num, den, true); - std::fprintf(stdout, - "psxrecomp: live fixed display aspect = %d:%d (adaptive view off)\n", - num, den); -} - -/* Follow the host window without feeding its absolute pixel size into guest - * rendering. Only the ratio matters: gpu_ws_configure derives the PSX-native - * sidecar width from it, just as the fixed 16:9/21:9 modes do. */ -static void update_adaptive_widescreen() { - if (!g_ws_adaptive_view || !sdl_window) return; - - int width = 0, height = 0; - SDL_GetWindowSize(sdl_window, &width, &height); - if (width <= 0 || height <= 0) return; - - int num = width, den = height; - if ((int64_t)width * 3 <= (int64_t)height * 4) { - num = 4; den = 3; - } else if ((int64_t)width * g_ws_adaptive_max_den >= - (int64_t)height * g_ws_adaptive_max_num) { - num = g_ws_adaptive_max_num; - den = g_ws_adaptive_max_den; - } else { - int divisor = aspect_gcd(num, den); - num /= divisor; - den /= divisor; - } - if (num == g_video_aspect_num && den == g_video_aspect_den) return; - apply_live_display_aspect(num, den, false); -} - -/* SDL GL attributes are global inputs to the next context creation. Set the - * runtime requirements immediately before every GL window, because launcher - * teardown resets them and macOS otherwise supplies a legacy 2.1 context. */ -static void configure_core_gl_context_attributes() { - SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); -} - -/* Live native-wide vs squash toggle (ws_nw TCP command) for A/B comparison. - * Re-engages with the chosen mode in place if widescreen is already running. */ -extern "C" void psx_ws_set_native_wide(int on) { - g_ws_native_wide = on ? 1 : 0; - if (g_ws_engaged && g_video_aspect_num * 3 != g_video_aspect_den * 4) { - int mode = g_ws_native_wide ? 2 : 1; - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); - } -} -extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } - -extern "C" int psx_host_video_get_vsync(void) { - return g_video_vsync; -} - -extern "C" int psx_host_video_set_vsync(int mode) { - if (mode < -1 || mode > 1) return 0; - g_video_vsync = mode; -#ifndef PSX_SDL_NO_RENDER - /* gl_renderer_set_swap_interval remembers this request but keeps the - * physical interval at zero while interpolation owns presentation. */ - if (g_gl_active) gl_renderer_set_swap_interval(mode); - if (g_vk_active) vk_renderer_set_present_mode(mode); -#endif - latency_ring_set_present_mode(mode); - return 1; -} - -extern "C" void psx_host_video_get_interpolation(int *enabled, - int *target_fps, - int *blend_mode) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - if (enabled) *enabled = 0; - if (target_fps) *target_fps = 0; - if (blend_mode) *blend_mode = PSX_HOST_FRAME_INTERPOLATION_LINEAR; -#else - int actual_enabled = g_frame_interpolation ? 1 : 0; - if (g_gl_active) - gl_renderer_interpolation_diag(&actual_enabled, nullptr, nullptr, - nullptr, nullptr, nullptr); - if (enabled) *enabled = actual_enabled; - if (target_fps) *target_fps = g_frame_interpolation_fps; - if (blend_mode) *blend_mode = g_frame_interpolation_blend; -#endif -} - -extern "C" void psx_host_video_get_geometry_interpolation_stats( - uint64_t out[6]) { - if (!out) return; - for (int i = 0; i < 6; ++i) out[i] = 0; - if (g_gl_active) gl_renderer_geometry_interpolation_diag(out); -} - -extern "C" int psx_host_video_set_interpolation(int enabled, - int target_fps, - int blend_mode) { -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - (void)target_fps; (void)blend_mode; - /* A disable request is harmless; activation is unavailable in this build. */ - return enabled ? 0 : 1; -#else - enabled = enabled ? 1 : 0; - if (target_fps != -1 && target_fps != 0 && - (target_fps < 30 || target_fps > 1000)) - return 0; - if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && - blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE && - blend_mode != PSX_HOST_FRAME_INTERPOLATION_GEOMETRY) - return 0; - /* Interpolation is implemented only by the GL backend and renderer changes - * are restart-owned. Do not let an in-game control advertise success on a - * software/Vulkan session. */ - if (enabled && !g_gl_active) return 0; - if (enabled && blend_mode == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && - !gpu_geometry_correction_enabled()) - return 0; - - if (g_gl_active) { - int previous_actual = 0; - gl_renderer_interpolation_diag(&previous_actual, nullptr, nullptr, - nullptr, nullptr, nullptr); - gl_renderer_set_interpolation(enabled, g_host_refresh_hz, - (double)target_fps, blend_mode); - int actual = 0; - gl_renderer_interpolation_diag(&actual, nullptr, nullptr, - nullptr, nullptr, nullptr); - if (actual != enabled) { - /* Context/thread creation and display-rate validation live in the - * GL backend. Do not advertise a requested state which the - * renderer could not actually enter. Restore a previously-live - * interpolator when a target change was rejected. */ - if (actual != previous_actual) { - gl_renderer_set_interpolation( - previous_actual, g_host_refresh_hz, - (double)g_frame_interpolation_fps, - g_frame_interpolation_blend); - } - g_frame_interpolation = previous_actual; - gl_renderer_set_swap_interval(g_video_vsync); - return 0; - } - /* Re-assert the requested user interval. The GL backend defers it - * while interpolation is enabled and restores it on disable. */ - gl_renderer_set_swap_interval(g_video_vsync); - } - g_frame_interpolation = enabled; - g_frame_interpolation_fps = target_fps; - g_frame_interpolation_blend = blend_mode; - return 1; -#endif -} - -/* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 - * to force the software readout path instead — a diagnostic/fallback that also - * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ -static int g_gl_fbo_present = 1; - -/* Vsync self-heal state (see SDL_RenderPresent wrapper in - * sdl_vblank_present). C linkage: freeze_heartbeat.c includes both in - * the freeze dump so a slow-frames wedge can be attributed to driver - * present backpressure from the dump alone. */ -extern "C" { -uint32_t g_present_slow_count = 0; /* presents that blocked >250ms */ -int g_present_vsync_disabled = 0; /* 1 once self-heal tripped */ -} - -/* Turbo-through-loads (step 4). C linkage: debug_server.c reads/toggles the - * enable and reports the frame counter. Enabled by game.toml [runtime] - * turbo_loads (opt-in per game) or the turbo_loads TCP command. */ -extern "C" { -int g_turbo_loads_enabled = 0; -uint64_t g_turbo_loads_frames = 0; /* vblanks run unpaced (observability) */ -int g_turbo_audio_sink_enabled = 0; -int g_turbo_audio_sink_active = 0; -uint64_t g_turbo_audio_sink_frames = 0; /* guest SPU frames advanced, not queued */ -} -/* The CD predicate already excludes XA and holds across ordinary inter-file - * gaps. A short engage debounce rejects a one-frame controller blip without - * leaving a visible authentic-paced prefix on every real load. Once engaged, - * a symmetric release debounce rides through tiny late-sector/IRQ gaps. This - * changes host presentation/pacing only; guest cycles and input sampling keep - * advancing normally. */ -#define TURBO_LOADS_ENGAGE_FRAMES 4 -#define TURBO_LOADS_RELEASE_FRAMES 6 -/* Zero multiplier retains the historical uncapped turbo behavior. */ -static int g_turbo_load_wall_multiplier = 0; -static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; -static SDL_AudioDeviceID sdl_audio_device; -static int16_t sdl_audio_buf[2048 * 2]; -static std::atomic g_audio_master_volume_percent{100}; -static std::atomic g_audio_master_muted{0}; -/* Legacy queued audio has two producer sites (ordinary pump and turbo-mute - * tail); both share the same applied gain so either path transitions smoothly. */ -static float g_audio_legacy_applied_master_gain = -1.0f; -/* Callback-owned; reset only after the audio device has stopped. */ -static float g_audio_drc_applied_master_gain = -1.0f; - -static void sdl_audio_apply_master_gain(int16_t* buf, int frames, - float* applied_gain); - -/* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under - * SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio - * thread. */ -static rab_bridge s_drc; -static bool s_drc_ready = false; -/* Per-game bridge fill target. 180 ms remains the compatibility default; - * [audio].buffer_ms may lower it for titles whose production cadence has been - * validated with less reserve. */ -static double g_audio_buffer_ms = 180.0; - -/* Observability + A/B. PSXRECOMP_AUDIO_LEGACY=1 keeps the historical push model - * (SDL_QueueAudio, no bridge) so the underrun baseline can be measured against - * the bridge from a single build; default (unset) = bridge/pull model. Output - * health is surfaced through the audio_stats TCP command (no stderr probe). */ -static bool audio_legacy_mode(void) { - static int v = -1; - if (v < 0) { const char* e = getenv("PSXRECOMP_AUDIO_LEGACY"); v = (e && *e && *e != '0') ? 1 : 0; } - return v != 0; -} -/* Legacy-mode underrun counter: incremented when the SDL queue is found empty - * at pump time (the device was silence-filling = an audible gap). */ -static uint64_t g_legacy_underruns = 0; -/* Set at unmute so the pump resyncs its bridge-underrun baseline past the - * intentional mute-drain instead of reporting it as gaps. */ -int g_audio_unmute_resync = 0; -/* Actual device rate the host opened at (bridge mode may differ from 44100; - * the T3 tap ring runs at this rate and its WAV dump must say so). */ -extern "C" { -int g_audio_host_rate = 44100; -} - -extern "C" int psx_host_audio_get_master_volume(void) { - return g_audio_master_volume_percent.load(std::memory_order_relaxed); -} - -extern "C" int psx_host_audio_set_master_volume(int percent) { - if (percent < 0 || percent > 100) return 0; - g_audio_master_volume_percent.store(percent, std::memory_order_relaxed); - return 1; -} - -extern "C" int psx_host_audio_get_muted(void) { - return g_audio_master_muted.load(std::memory_order_relaxed); -} - -extern "C" int psx_host_audio_set_muted(int muted) { - if (muted != 0 && muted != 1) return 0; - g_audio_master_muted.store(muted, std::memory_order_relaxed); - return 1; -} - -static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) { - if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; } - int frames = len / (int)(2 * sizeof(int16_t)); /* stereo S16 */ - rab_pull(&s_drc, reinterpret_cast(stream), frames); - /* Apply user gain at the final pull boundary so the default bridge path - * responds on the next device callback instead of waiting for the DRC - * ring's buffered audio to drain. */ - sdl_audio_apply_master_gain( - reinterpret_cast(stream), frames, - &g_audio_drc_applied_master_gain); - /* T3 tap in bridge mode: the exact device-rate bytes the host consumes. - * This callback is the tap's single writer while the bridge is active. */ - audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast(stream), - frames); -} - -static std::filesystem::path find_upward(std::filesystem::path start, - const std::filesystem::path& marker) { - std::error_code ec; - start = std::filesystem::absolute(start, ec); - if (ec) start = std::filesystem::current_path(); - if (!std::filesystem::is_directory(start, ec)) start = start.parent_path(); - - for (;;) { - if (std::filesystem::exists(start / marker, ec)) return start; - if (!start.has_parent_path() || start.parent_path() == start) break; - start = start.parent_path(); - } - return {}; -} - -// The directory the running executable lives in — the SINGLE anchor for every -// runtime file (game.toml, disc, BIOS, settings.toml, caches, memcards, sidecar -// .cfg). Deliberately NEVER the current working directory: a game must launch -// identically whether started from its own folder, a shortcut, a shell in some -// other directory, or a debugger. On Windows GetModuleFileNameW is the -// authoritative source (independent of argv[0], which can be relative/bare and -// would otherwise get resolved against cwd by fs::absolute). $APPIMAGE and -// argv[0] are non-Windows / fallback sources only. -static std::filesystem::path exe_dir_from_argv(const char* argv0) { - namespace fs = std::filesystem; - std::error_code ec; - fs::path exe_dir; -#ifdef _WIN32 - // Authoritative: the real image path, regardless of how we were invoked. - { - wchar_t buf[MAX_PATH * 4]; - DWORD n = GetModuleFileNameW(NULL, buf, (DWORD)(sizeof(buf) / sizeof(buf[0]))); - if (n > 0 && n < (DWORD)(sizeof(buf) / sizeof(buf[0]))) { - exe_dir = fs::path(std::wstring(buf, buf + n)).parent_path(); - } - } -#endif - // Inside an AppImage the binary lives in a read-only mount and argv[0] can - // be a bare name or symlink (launched via PATH / a .desktop file). $APPIMAGE - // is the .AppImage's own path, so settings.toml anchors next to it. - if (exe_dir.empty()) { - if (const char* appimg = std::getenv("APPIMAGE"); appimg && appimg[0]) { - exe_dir = fs::absolute(appimg, ec).parent_path(); - if (ec) exe_dir.clear(); - } - } - if (exe_dir.empty() && argv0 && argv0[0]) { - exe_dir = fs::absolute(argv0, ec).parent_path(); - if (ec) exe_dir.clear(); - } - // Last-ditch only (should be unreachable on a normal launch); a bare "." so - // we never silently resolve against an unrelated cwd deeper in the tree. - if (exe_dir.empty()) exe_dir = fs::path("."); - return exe_dir; -} - -static std::filesystem::path resolve_existing_runtime_path(const char* requested, - const char* argv0) { - namespace fs = std::filesystem; - if (!requested || !requested[0]) return {}; - - std::error_code ec; - fs::path p(requested); - if (fs::exists(p, ec)) return fs::absolute(p, ec); - if (p.is_absolute()) return {}; - - // Anchor exclusively on the exe directory — never cwd (see exe_dir_from_argv). - const fs::path root = exe_dir_from_argv(argv0); - fs::path direct = root / p; - if (fs::exists(direct, ec)) return fs::absolute(direct, ec); - fs::path found = find_upward(root, p); - if (!found.empty()) return fs::absolute(found / p, ec); - return {}; -} - -static std::filesystem::path sidecar_cfg_path(const char* argv0, const char* filename) { - return exe_dir_from_argv(argv0) / filename; -} - -static std::filesystem::path read_cached_path(const char* argv0, const char* filename) { - std::ifstream f(sidecar_cfg_path(argv0, filename)); - if (!f.is_open()) return {}; - std::string line; - std::getline(f, line); - while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) { - line.pop_back(); - } - return line.empty() ? std::filesystem::path{} : std::filesystem::path(line); -} - -static void write_cached_path(const char* argv0, const char* filename, - const std::filesystem::path& path) { - std::ofstream f(sidecar_cfg_path(argv0, filename), std::ios::trunc); - if (f.is_open()) f << path.string() << "\n"; -} - -static void launcher_warning(const char* title, const std::string& msg) { - std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); -#ifdef _WIN32 - // Headless (--headless / PSX_HEADLESS): NEVER pop a blocking modal — it would - // hang an unattended/CI/scripted run forever waiting for a click. - if (!g_headless) MessageBoxA(NULL, msg.c_str(), title, MB_OK | MB_ICONWARNING); -#endif -} - -static void launcher_info(const char* title, const std::string& msg) { - std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); -#ifdef _WIN32 - if (!g_headless) MessageBoxA(NULL, msg.c_str(), title, MB_OK | MB_ICONINFORMATION); -#endif -} - -/* Game display name for picker dialogs ("Tomba!"); set after the game - * config loads, before any interactive file resolution. */ -static std::string s_picker_game_name = "PSXRecomp"; - -/* BIOS selection state (docs/BIOS_SELECTION.md). s_openbios_allowed is the - * game's [runtime] openbios; s_bundled_bios_rel is where the shipped - * redistributable image lives relative to the executable. */ -static bool s_openbios_allowed = true; -#ifdef PSX_BUNDLED_BIOS_PATH -static std::string s_bundled_bios_rel = PSX_BUNDLED_BIOS_PATH; -#else -static std::string s_bundled_bios_rel = "bios/openbios.bin"; -#endif - -static bool pick_runtime_file(const char* title, const char* filter, - std::filesystem::path& out, const char* cli_flag) { - // Headless: never open an interactive file dialog (it blocks). Fail the - // resolve so boot aborts cleanly with the stderr message the caller printed. - if (g_headless) { - std::fprintf(stderr, - "psxrecomp: headless — cannot prompt for '%s'.\n" - " Supply it on the command line: %s (or set it in game.toml).\n", - title, cli_flag); - return false; - } -#ifdef _WIN32 - /* IFileOpenDialog, not GetOpenFileName: that one moves the process's working directory into every folder the - * player opens (measured, OFN_NOCHANGEDIR or not), and the freeze heartbeat writes its files there. */ - const auto wide = [](const char* text) { - std::wstring out(static_cast(MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0)), L'\0'); - MultiByteToWideChar(CP_UTF8, 0, text, -1, out.data(), static_cast(out.size())); - return out; - }; - std::vector texts; - for (const char* at = filter; at && *at; at += std::strlen(at) + 1) texts.push_back(wide(at)); - std::vector kinds; - for (size_t pair = 0; pair + 1 < texts.size(); pair += 2) kinds.push_back({texts[pair].c_str(), texts[pair + 1].c_str()}); - - const HRESULT initialised = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); - IFileOpenDialog* dialog = nullptr; - bool chosen = false; - if (SUCCEEDED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) { - DWORD options = 0; - dialog->GetOptions(&options); - dialog->SetOptions(options | FOS_FILEMUSTEXIST | FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR); - if (!kinds.empty()) dialog->SetFileTypes(static_cast(kinds.size()), kinds.data()); - dialog->SetTitle(wide(title).c_str()); - IShellItem* item = nullptr; - wchar_t* path = nullptr; - if (SUCCEEDED(dialog->Show(nullptr)) && SUCCEEDED(dialog->GetResult(&item)) && - SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) { - out = path; - chosen = true; - } - CoTaskMemFree(path); - if (item) item->Release(); - dialog->Release(); - } - if (SUCCEEDED(initialised)) CoUninitialize(); - return chosen; -#else - (void)filter; - (void)out; - // No native GUI file picker on this platform (macOS/Linux): the file must be - // named on the command line. Tell the user the exact flag + a full example - // instead of a dead-end "requires a command-line path". - std::fprintf(stderr, - "psxrecomp: no graphical file picker on this platform — '%s'\n" - " must be supplied on the command line: %s \n" - " Example: ./ --game game.toml " - "--bios /path/to/SCPH1001.BIN --disc /path/to/game.cue\n" - " (or set the path in game.toml). See the game's README, \"Building From Source\".\n", - title, cli_flag); - return false; -#endif -} - -/* The dialog runs on a thread of its own: on the game's thread it would stop the frames, and the freeze watchdog - * takes two seconds without a frame for a hang and writes a dump. */ -/* Never destroyed: the dialog's thread may still be in the dialog when the program ends. */ -static PsxHostPickSlot& s_disc_pick = *new PsxHostPickSlot; -static std::thread& s_disc_worker = *new std::thread; - -extern "C" int psx_host_pick_disc_image_begin(void) { -#ifdef _WIN32 - if (g_headless || !s_disc_pick.open()) return 0; - try { - /* The worker before this one closed its pick as its last act, and no pick opens before that one is taken. */ - if (s_disc_worker.joinable()) s_disc_worker.join(); - s_disc_worker = std::thread([] { - std::string path; - try { - std::filesystem::path picked; - if (pick_runtime_file("Select a disc image", - "Disc image (*.cue;*.bin;*.chd;*.iso)\0*.cue;*.bin;*.chd;*.iso\0All files\0*.*\0", - picked, "--disc")) - path = picked.string(); - } catch (...) { - std::fprintf(stderr, "psxrecomp: the chosen disc image could not be taken, its path may have letters this system's code page lacks\n"); - path.clear(); - } - s_disc_pick.close(std::move(path)); - }); - } catch (const std::system_error&) { - s_disc_pick.abandon(); - return 0; - } - return 1; -#else - return 0; -#endif -} - -extern "C" int psx_host_pick_disc_image_poll(char* out, int size) { - return s_disc_pick.take(out, size); -} - -static std::string uppercase_ascii(std::string s) { - for (char& c : s) { - c = (char)std::toupper((unsigned char)c); - } - return s; -} - -// Region display label for the launcher, derived from the game-id serial -// prefix (e.g. "SCUS-94423" -> "(USA)"). Used only when game.toml [game] -// leaves `region` unset. Unknown/empty serials yield "" (no badge) rather -// than guessing. -static std::string region_label_from_serial(const std::string& serial) { - if (serial.size() < 3) return std::string(); - const std::string up = uppercase_ascii(serial); - const std::string p4 = up.substr(0, std::min(4, up.size())); - const std::string p3 = up.substr(0, 3); - if (p4 == "SCUS" || p4 == "SLUS" || p3 == "LSP") return "(USA)"; - if (p4 == "SCES" || p4 == "SLES") return "(Europe)"; - if (p4 == "SCPS" || p4 == "SLPS" || p4 == "SLPM") return "(Japan)"; - return std::string(); -} - -static bool read_at(std::ifstream& f, uint64_t offset, uint8_t* out, size_t len) { - f.clear(); - f.seekg((std::streamoff)offset, std::ios::beg); - if (!f.good()) return false; - f.read(reinterpret_cast(out), (std::streamsize)len); - return f.gcount() == (std::streamsize)len; -} - -struct DiscValidation { - bool opened = false; - bool has_header = false; - bool id_matches = false; - std::string detail; -}; - -static DiscValidation validate_disc_image(const std::filesystem::path& selected_path, - const std::string& game_id) { - // Delegate to the shared disc-identity module so the launch-time check and - // the launcher badge can never drift apart. No CRC here — the launch check - // only cares about openability / header / serial; the launcher does the - // (slower) CRC pass when an expected CRC is configured. - const PSXRecompV4::DiscIdentity id = - PSXRecompV4::identify_disc(selected_path, game_id, /*expected_crc*/0, - /*has_expected_crc*/false, /*compute_crc*/false); - DiscValidation v; - v.opened = id.opened; - v.has_header = id.has_header; - v.id_matches = game_id.empty() ? true : id.serial_matches; - v.detail = id.detail; - if (id.opened && id.has_header && !v.id_matches && v.detail.empty()) { - v.detail = "The disc header is readable, but it does not contain the expected game ID " + - uppercase_ascii(game_id) + " in the early disc metadata."; - } - return v; -} - -static bool validate_disc_for_launch(const std::filesystem::path& path, - const std::string& game_id) { - const DiscValidation v = validate_disc_image(path, game_id); - if (!v.opened) { - launcher_warning("Disc Image Not Found", v.detail + "\n\nSelected path:\n" + path.string()); - return false; - } - if (!v.has_header || !v.id_matches) { - launcher_warning("Disc Image Warning", - v.detail + "\n\nThis may be the wrong game or a corrupt image. The runtime will try to run it anyway."); - } - return true; -} - -// Canonicalize whichever member of a dump the user pointed us at. Picking the -// .cue and picking the .bin must both mount and both verify — see -// runtime/include/disc_path.h for the preference rules. This used to swap a -// .cue for its same-named .bin unconditionally, which silently discarded the -// cue's table of contents (and with it every CD-DA track and pregap) on -// single-file multi-track dumps. -// Pure: no dialog here. Every caller feeds the result to -// validate_disc_for_launch(), whose detail text already carries the resolver's -// note (identify_disc surfaces it), so a broken dump is reported exactly once -// instead of popping a modal on every boot. -static std::filesystem::path normalize_disc_path_for_launch(const std::filesystem::path& path) { - return PSXRecompV4::resolve_disc_path(path).mount; -} - -/* Which compiled-in backend, if any, was recompiled from THIS file? - * - * The chosen file is only ever DATA: the code that executes is the statically - * recompiled C of one specific image, so running it against another image's - * bytes is a guaranteed wild jump. Identity therefore decides WHICH backend - * may run, not merely whether to warn. Null = no linked image matches. - */ -static const PsxBiosBackend* bios_backend_for_file(const std::filesystem::path& path, - uint32_t* out_crc, - uint64_t* out_size) { - std::ifstream f(path, std::ios::binary | std::ios::ate); - if (!f.is_open()) return nullptr; - const std::streamoff size = f.tellg(); - if (out_size) *out_size = (uint64_t)size; - if (size <= 0) return nullptr; - std::vector data((size_t)size); - if (!read_at(f, 0, data.data(), data.size())) return nullptr; - const uint32_t crc = crc32_compute(data.data(), data.size()); - if (out_crc) *out_crc = crc; - for (uint32_t i = 0; i < psx_bios_registry_count; i++) { - const PsxBiosBackend* b = psx_bios_registry[i]; - if (!b || !b->image) continue; - if ((uint64_t)size == (uint64_t)b->image->image_size && - crc == b->image->image_crc32) - return b; - } - return nullptr; -} - -/* What a player may supply, for mismatch and picker copy. The bundled image is - * excluded: it is never something to go and find. */ -static std::string bios_accepted_images() { - std::string s; - for (uint32_t i = 0; i < psx_bios_registry_count; i++) { - const PsxBiosBackend* b = psx_bios_registry[i]; - if (!b || !b->image || b->image->image_bundled) continue; - if (!s.empty()) s += ", "; - s += b->image->image_id; - s += " (" + std::to_string(b->image->image_size / 1024u) + " KB)"; - } - return s.empty() ? std::string("(this build ships its own BIOS)") : s; -} - -/* Identity-gate a player-chosen BIOS and activate its backend on success. */ -static bool validate_bios_for_launch(const std::filesystem::path& path) { - uint32_t crc = 0; uint64_t size = 0; - const PsxBiosBackend* b = bios_backend_for_file(path, &crc, &size); - if (b) return psx_bios_activate(b) != 0; - - char buf[512]; - std::snprintf(buf, sizeof(buf), - "That BIOS (%llu bytes, CRC32 %08X) is not an image this build was " - "compiled from.\n\nThis build accepts: %s\n\nThe compiled-in code " - "would execute against mismatched data and crash.", - (unsigned long long)size, crc, bios_accepted_images().c_str()); - launcher_warning("BIOS Mismatch", buf); - return false; -} - -static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0); - -/* Resolve which BIOS image file to load, and activate its backend. - * - * Policy (docs/BIOS_SELECTION.md): - * no explicit player choice -> bundled OpenBIOS, silently - * explicit choice, matching -> that image - * explicit choice, mismatched-> explained; falls back to OpenBIOS if allowed - * openbios disabled for this title -> a retail image is required - * - * "Explicit" means --bios or a remembered launcher/settings pick. Finding a - * file on disk deliberately does NOT count: discovery used to adopt whatever - * happened to sit near the executable, so two players with the same build - * could end up on different BIOSes. - */ -static std::filesystem::path resolve_bios_for_runtime(const char* requested, - const char* argv0, - bool requested_is_explicit) { - const bool openbios_allowed = s_openbios_allowed; - const PsxBiosBackend* bundled = psx_bios_bundled(); - - /* 1. An explicit choice: --bios, else a remembered pick. */ - std::filesystem::path chosen; - if (requested_is_explicit && requested && requested[0]) { - /* --bios, or a BIOS named in settings.toml. NOT the compile-time - * default: that is a build constant, not something the player asked - * for, and treating it as a choice made a stray file near the exe - * produce a "BIOS Mismatch" dialog before the bundled image was even - * considered. */ - chosen = resolve_bios_path(requested, argv0); - } else { - std::filesystem::path cached = read_cached_path(argv0, "bios.cfg"); - if (!cached.empty() && std::filesystem::exists(cached)) chosen = cached; - } - if (!chosen.empty() && std::filesystem::exists(chosen)) { - if (validate_bios_for_launch(chosen)) return chosen; /* activates it */ - /* Mismatched. With OpenBIOS available this is recoverable, so say so - * and carry on rather than dead-ending on a file they chose once. */ - if (!(openbios_allowed && bundled)) return {}; - } - - /* 2. No usable explicit choice -> bundled OpenBIOS, if this title allows it. */ - if (openbios_allowed && bundled) { - std::filesystem::path img = resolve_bios_path( - s_bundled_bios_rel.empty() ? nullptr : s_bundled_bios_rel.c_str(), argv0); - if (!img.empty() && std::filesystem::exists(img) && - bios_backend_for_file(img, nullptr, nullptr) == bundled && - psx_bios_activate(bundled)) { - return img; - } - launcher_warning("Bundled BIOS Missing", - std::string("This build ships its own BIOS (") + - bundled->image->image_id + "), but the bundled image is missing " - "or does not match.\n\nExpected next to the executable:\n" + - (s_bundled_bios_rel.empty() ? "(default path)" : s_bundled_bios_rel) + - "\n\nReinstall or rebuild."); - return {}; - } - - /* 3. This title requires a retail BIOS: ask for one. */ - const std::string accepted = bios_accepted_images(); - launcher_info((s_picker_game_name + " — PlayStation BIOS needed").c_str(), - s_picker_game_name + " requires a PlayStation BIOS.\n\n" - "Step 1 of 2 — PlayStation BIOS\n\n" - "In the next window, select your PlayStation BIOS dump. This build " - "requires the exact image it was compiled from: " + accepted + ". " - "You must dump it from your own console or otherwise legally obtain " - "it.\n\n(This is NOT the game disc — that is asked for next.)"); - std::string bios_title = - s_picker_game_name + " — Step 1 of 2: select PlayStation BIOS (" + accepted + ")"; - for (;;) { - std::filesystem::path picked; - if (!pick_runtime_file( - bios_title.c_str(), - "PlayStation BIOS (*.bin)\0*.bin\0All Files (*.*)\0*.*\0", - picked, "--bios")) { - return {}; - } - if (validate_bios_for_launch(picked)) { - write_cached_path(argv0, "bios.cfg", picked); - return picked; - } - } -} - -static std::filesystem::path resolve_disc_for_runtime(const std::filesystem::path& config_disc, - const char* disc_override, - const std::string& game_id, - const char* argv0) { - if (disc_override && disc_override[0]) { - std::filesystem::path p = normalize_disc_path_for_launch(disc_override); - return validate_disc_for_launch(p, game_id) ? p : std::filesystem::path{}; - } - - // A relative disc path in game.toml resolves against the exe dir (never cwd); - // resolve_existing_runtime_path also walks up from there (build/ -> game dir), - // so an out-of-tree build layout still finds the bundled disc. - if (!config_disc.empty()) { - std::filesystem::path cd = config_disc; - if (cd.is_relative()) { - std::filesystem::path r = resolve_existing_runtime_path(config_disc.string().c_str(), argv0); - if (!r.empty()) cd = r; - } - cd = normalize_disc_path_for_launch(cd); - if (std::filesystem::exists(cd) && validate_disc_for_launch(cd, game_id)) { - return cd; - } - } - - std::filesystem::path cached = read_cached_path(argv0, "disc.cfg"); - if (!cached.empty()) { - cached = normalize_disc_path_for_launch(cached); - } - if (!cached.empty() && std::filesystem::exists(cached) && - validate_disc_for_launch(cached, game_id)) { - return cached; - } - - launcher_info((s_picker_game_name + " — game disc image needed").c_str(), - "Step 2 of 2 — game disc image\n\n" - "In the next window, select your " + s_picker_game_name + - (game_id.empty() ? std::string() : " (" + game_id + ")") + - " disc image ripped from your own disc.\n\n" - "Accepted formats: .cue (preferred, with its .bin next to it), " - ".bin, .iso, or .chd.\n\n" - "(This is NOT the BIOS — the BIOS was already chosen.)"); - std::string disc_title = - s_picker_game_name + " — Step 2 of 2: select " + s_picker_game_name + - " disc image (.cue / .bin / .iso / .chd)"; - for (;;) { - std::filesystem::path picked; - if (!pick_runtime_file( - disc_title.c_str(), - "PS1 Disc Images (*.cue;*.bin;*.iso;*.chd)\0*.cue;*.bin;*.iso;*.chd\0All Files (*.*)\0*.*\0", - picked, "--disc")) { - return {}; - } - picked = normalize_disc_path_for_launch(picked); - if (validate_disc_for_launch(picked, game_id)) { - write_cached_path(argv0, "disc.cfg", picked); - return picked; - } - } -} - -static std::filesystem::path resolve_bios_path(const char* requested, const char* argv0) { - namespace fs = std::filesystem; - std::error_code ec; - fs::path p(requested); - if (fs::exists(p, ec)) { - fs::path abs = fs::absolute(p, ec); - return ec ? p : abs; - } - // Either BIOS filename convention is acceptable: a dump folder holding - // "US-PSX-SCPH1001.BIN" satisfies a request for "SCPH1001.BIN" and vice - // versa (see recompiler/include/bios_rom_alias.h). - if (fs::path aliased = PSXRecompV4::resolve_bios_rom(p); aliased != p) { - fs::path abs = fs::absolute(aliased, ec); - return ec ? aliased : abs; - } - if (p.is_absolute()) return p; - - // Anchor on the exe directory — never cwd (see exe_dir_from_argv). - fs::path found = find_upward(exe_dir_from_argv(argv0), p); - if (!found.empty()) return found / p; - // Same walk, accepting the other naming convention at each rung: the - // literal name is absent but a region-qualified sibling may be present. - for (fs::path dir = fs::absolute(exe_dir_from_argv(argv0), ec); - !dir.empty(); dir = dir.parent_path()) { - const fs::path aliased = PSXRecompV4::resolve_bios_rom(dir / p); - if (aliased != dir / p && fs::exists(aliased, ec)) return aliased; - if (!dir.has_parent_path() || dir.parent_path() == dir) break; - } - - // Dev-checkout rung: game projects keep the framework at - // /psxrecomp-v4 (junction/worktree), so a relative default like - // "bios/SCPH1001.BIN" lives under that prefix rather than at the game root. - // A user install has no psxrecomp-v4 directory, so this rung cannot - // resolve a build-machine BIOS there — it falls through to bios.cfg or the - // interactive picker. - const fs::path dev_marker = fs::path("psxrecomp-v4") / p; - found = find_upward(exe_dir_from_argv(argv0), dev_marker); - if (!found.empty()) return found / dev_marker; - return p; -} - -// Fallback memcard directory used when no game config (or its [runtime] -// block) specifies one: the executable's directory (authoritative, never cwd — -// see exe_dir_from_argv), so saves always live next to the binary. -static std::filesystem::path default_memcard_dir(const char* argv0) { - return exe_dir_from_argv(argv0); -} - -static void close_controller(void); -static void shutdown_runtime(void); - -/* Set when this process started netplay from the lobby room — soft-exit returns - * there instead of killing the process. */ -static int g_netplay_from_lobby = 0; -static int g_netplay_vsync_forced_off = 0; - -/* Host-only: lockstep already couples peers. Driver vsync on top of the - * wall-clock pacer double-blocks the vblank callback (present is before the - * guest resumes), which shows up as MotK FMV ~30–40 FPS in netplay vs ~50+ - * offline. Force immediate swaps for the session; restore on soft-exit. */ -static void netplay_host_present_uncap(void) { -#ifndef PSX_SDL_NO_RENDER - if (g_gl_active) gl_renderer_set_swap_interval(0); - if (g_vk_active) vk_renderer_set_present_mode(0); -#endif - g_netplay_vsync_forced_off = 1; -} - -static void netplay_host_present_restore(void) { - if (!g_netplay_vsync_forced_off) return; -#ifndef PSX_SDL_NO_RENDER - if (g_gl_active) gl_renderer_set_swap_interval(g_video_vsync); - if (g_vk_active) vk_renderer_set_present_mode(g_video_vsync); -#endif - g_netplay_vsync_forced_off = 0; -} - -static void netplay_soft_exit(const char *origin) { - psx_crash_trace_set_exit_origin(origin); - netplay_host_present_restore(); - psx_netplay_shutdown(); /* sends BYE so the peer soft-exits too */ - if (g_netplay_from_lobby) { - std::printf("psxrecomp: netplay ended (%s) — returning to lobby\n", - origin ? origin : "?"); - std::fflush(stdout); - psx_request_return_to_lobby(); - return; - } - shutdown_runtime(); - std::exit(0); -} - -static void shutdown_runtime(void) { - /* UI teardown may destroy GL resources; it must run while the game window - * and main renderer context are still alive. The helper is idempotent. */ - host_ui_runtime_shutdown(); - /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the - * off-thread JIT worker here; the worker no longer exists.) */ - psx_netplay_shutdown(); - memcard_flush_all(); - /* Stop and join the active external compiler before capture/debug teardown. - * Otherwise closing the window can leave cmd/python/gcc running against - * cache and capture files while the main thread performs synchronous - * shutdown work, making the window appear frozen until that tree exits. */ - autocompile_shutdown(); - overlay_autocapture_shutdown(); - overlay_capture_wait_pending(); - overlay_capture_write_json(); - if (sdl_audio_device) { - psx_sdl_audio_clear(sdl_audio_device); - psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ - sdl_audio_device = 0; - } - g_audio_drc_applied_master_gain = -1.0f; - g_audio_legacy_applied_master_gain = -1.0f; - if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } - close_controller(); - debug_server_shutdown(); -} - -/* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL - * subsystems and the lobby WebSocket intact for the next launcher session. */ -static void teardown_game_session_keep_lobby(void) { - host_ui_runtime_shutdown(); - netplay_host_present_restore(); - psx_netplay_shutdown(); - memcard_flush_all(); - if (sdl_audio_device) { - psx_sdl_audio_clear(sdl_audio_device); - psx_sdl_audio_close(sdl_audio_device); - sdl_audio_device = 0; - } - g_audio_drc_applied_master_gain = -1.0f; - g_audio_legacy_applied_master_gain = -1.0f; - if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } - close_controller(); - if (g_vk_active) { - vk_renderer_shutdown(); - g_vk_active = false; - } - if (g_gl_active) { - gl_renderer_shutdown(); - g_gl_active = false; - } - if (sdl_texture) { SDL_DestroyTexture(sdl_texture); sdl_texture = nullptr; } - if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } - if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } - if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } - sdl_pixel_buf_scale_capacity = 0; - psx_lobby_set_ready(0); - psx_lobby_clear_launch_pending(); - psx_clear_return_to_lobby(); -} - -/* Linear gain ramp g0 -> g1 across a block of interleaved stereo frames. */ -static void sdl_audio_gain_ramp(int16_t* buf, int frames, float g0, float g1) { - if (frames <= 0) return; - const float step = (g1 - g0) / (float)frames; - float g = g0; - for (int f = 0; f < frames; f++, g += step) { - buf[f * 2 + 0] = (int16_t)((float)buf[f * 2 + 0] * g); - buf[f * 2 + 1] = (int16_t)((float)buf[f * 2 + 1] * g); - } -} - -/* Smooth live master-volume changes over one produced/device block. The DRC - * path invokes this on its final audio callback; legacy queued audio invokes - * it immediately before queueing. Separate per-path applied gains are owned by - * their respective threads, while the requested controls are atomic. */ -static void sdl_audio_apply_master_gain(int16_t* buf, int frames, - float* applied_gain) { - if (!buf || frames <= 0 || !applied_gain) return; - const int percent = - g_audio_master_volume_percent.load(std::memory_order_relaxed); - const bool muted = - g_audio_master_muted.load(std::memory_order_relaxed) != 0; - const float target_gain = muted ? 0.0f : (float)percent / 100.0f; - if (*applied_gain < 0.0f) *applied_gain = target_gain; - if (*applied_gain == 1.0f && target_gain == 1.0f) return; - sdl_audio_gain_ramp(buf, frames, *applied_gain, target_gain); - *applied_gain = target_gain; -} - -/* Fade-in state: samples of rising ramp still to apply after an unmute. - * Consumed by sdl_audio_pump across however many pump calls it spans. - * MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many - * frames in one spu_render call. 1764 frames = 40 ms. */ -static const int sdl_audio_fade_samples = 44100 * 40 / 1000; /* 40 ms */ -static int sdl_audio_fadein_left = 0; - -static void sdl_audio_pump(bool discard_output = false) { - if (!sdl_audio_device) discard_output = true; /* a game reads the SPU back: it runs on with nothing to play it */ - - const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; - const bool legacy = audio_legacy_mode(); - static int had_audio = 0; - uint32_t queued = 0; /* RENDER event b: bytes (legacy) / fill ms (bridge) */ - if (discard_output) { - /* Host-only sink: skip all queue/bridge interaction, but continue to - * the guest-cycle sample budget and spu_render below. */ - } else if (legacy) { - /* Historical push model + baseline measurement: a drained (==0) queue - * means the device was silence-filling since the last pump = a gap. - * Only meaningful after the first audio has been queued. */ - const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u; - queued = psx_sdl_audio_queued_size(sdl_audio_device); - if (queued == 0 && had_audio) { - g_legacy_underruns++; - audio_trace_event(AUDIO_EV_UNDERRUN, 0, 0); - } - if (queued > max_queue_bytes) { - audio_trace_event(AUDIO_EV_PUMP_SKIP, queued, 0); - return; - } - } else { - /* No host-queue backpressure check: the bridge's ring + DRC hold the - * fill near target, so we always render this frame and push it. */ - if (!s_drc_ready) return; - /* Surface bridge underruns (counted on the SDL audio thread) into the - * event ring from this thread — the event ring is single-writer. - * Across a turbo mute the ring intentionally runs dry (the pump stops - * while the callback keeps pulling); those dry pulls are the mute, - * not gaps — resync past them instead of reporting them. */ - rab_stats st; - rab_get_stats(&s_drc, &st); - static uint64_t prev_underruns = 0; - extern int g_audio_unmute_resync; - if (g_audio_unmute_resync) { - prev_underruns = st.underrun_events; - g_audio_unmute_resync = 0; - } else if (st.underrun_events > prev_underruns) { - audio_trace_event(AUDIO_EV_UNDERRUN, - (uint32_t)(st.underrun_events - prev_underruns), 1); - prev_underruns = st.underrun_events; - } - queued = (uint32_t)st.last_fill_ms; - } - - /* Faithful sample budget: the SPU is clocked by the GUEST, not by host - * presents. 33.8688 MHz / 44100 Hz = exactly 768 guest cycles per output - * frame, so production tracks guest time precisely — including the real - * NTSC 59.94 Hz vblank — instead of assuming 60.00 Hz per present, which - * built in a systematic -0.1% production deficit (measured: 43950/s - * produced vs 44100/s consumed = recurring ring underruns no +/-0.5% - * DRC trim could absorb during jitter spikes). */ - extern uint64_t psx_cycle_count; - static uint64_t last_cycles = 0; - static uint64_t cycle_carry = 0; - const uint64_t now_cycles = psx_cycle_count; - if (last_cycles == 0) last_cycles = now_cycles; - uint64_t delta = (now_cycles - last_cycles) + cycle_carry; - last_cycles = now_cycles; - int frames = (int)(delta / 768u); - cycle_carry = delta % 768u; - if (frames <= 0) return; - if (frames > 2048) { - /* A burst beyond one buffer (e.g. right after an unmute or a long - * stall): render one full buffer and DROP the remainder of the debt — - * same semantic as the mute model (voice positions freeze across the - * gap) rather than time-compressing a backlog into garble. */ - frames = 2048; - cycle_carry = 0; - } - - audio_trace_event(AUDIO_EV_RENDER, (uint32_t)frames, queued); - spu_render(sdl_audio_buf, frames); - if (discard_output) { - g_turbo_audio_sink_frames += (uint64_t)frames; - audio_trace_event(AUDIO_EV_SINK_DROP, (uint32_t)frames, 0); - return; - } - if (sdl_audio_fadein_left > 0) { - const float g0 = 1.0f - (float)sdl_audio_fadein_left - / (float)sdl_audio_fade_samples; - int ramp = sdl_audio_fadein_left < frames ? sdl_audio_fadein_left : frames; - const float g1 = 1.0f - (float)(sdl_audio_fadein_left - ramp) - / (float)sdl_audio_fade_samples; - sdl_audio_gain_ramp(sdl_audio_buf, ramp, g0, g1); - sdl_audio_fadein_left -= ramp; - } - if (legacy) { - sdl_audio_apply_master_gain( - sdl_audio_buf, frames, &g_audio_legacy_applied_master_gain); - /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ - audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); - psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, - (uint32_t)frames * bytes_per_frame); - had_audio = 1; - } else { - /* Hand to the bridge (band-limited resample + DRC) instead of - * SDL_QueueAudio. Lock guards the SPSC ring against the pull callback. - * The T3 tap moves to sdl_drc_callback: what the device actually - * receives is the bridge's device-rate output, not this buffer. */ - psx_sdl_audio_lock(sdl_audio_device); - rab_push(&s_drc, sdl_audio_buf, frames); - psx_sdl_audio_unlock(sdl_audio_device); - } -} - -/* Audio gating across turbo-loads transitions. - * - * The mute model stays: during turbo the guest runs at host speed, so - * rendered SPU audio is time-compressed garble — we stop pumping, the queue - * drains, voice positions freeze, and music resumes in place afterward. - * What changes is the EDGES: - * - entering turbo: render one short tail of the current voice state, - * ramp it to silence, and queue it — the drain ends in a fade instead - * of a hard cut; - * - leaving turbo: hold the mute for a short hangover first (loads often - * re-trigger within a few frames; without the debounce the mute would - * flicker audibly), then resume pumping with a rising ramp applied - * across the first ~50 ms of samples (sdl_audio_pump above). */ -/* Bridge/legacy output health, surfaced through the audio_stats TCP command - * (debug_server.c) — no stderr probe; rule 3. */ -extern "C" int psx_audio_out_stats(double *fill_ms, double *target_ms, - uint64_t *underruns, - uint64_t *overflow_drops, double *correction, - int *legacy, int *host_rate) -{ - *legacy = audio_legacy_mode() ? 1 : 0; - *host_rate = g_audio_host_rate; - *target_ms = g_audio_buffer_ms; - if (*legacy || !s_drc_ready) { - *fill_ms = sdl_audio_device - ? (double)psx_sdl_audio_queued_size(sdl_audio_device) - / (44100.0 * 4.0) * 1000.0 - : 0.0; - *underruns = g_legacy_underruns; - *overflow_drops = 0; - *correction = 0.0; - return sdl_audio_device != 0; - } - rab_stats st; - rab_get_stats(&s_drc, &st); - *fill_ms = st.last_fill_ms; - *underruns = st.underrun_events; - *overflow_drops = st.overflow_drops; - *correction = st.last_correction; - return 1; -} - -/* Lightweight production-safe cadence probe. Unlike the TCP debug build this - * adds no per-block or per-instruction recording. All counter/timer work below - * is opt-in via PSX_RUNTIME_PERF_DIAG; the normal Release path pays only the - * existing once-per-vblank disabled checks. */ -struct RuntimePerfSnapshot { - uint64_t counter = 0; - uint64_t frame = 0; - uint64_t guest_work_ticks = 0; - uint64_t pacer_ticks = 0; - uint64_t autocapture_ticks = 0; - uint64_t provider_poll_ticks = 0; - uint64_t dirty_insns = 0; - uint64_t dirty_dispatches = 0; - uint32_t overlay_loads = 0; - uint32_t overlay_invalidations = 0; - uint32_t overlay_unregistered = 0; - uint64_t overlay_native = 0; - uint64_t overlay_interp = 0; - uint64_t overlay_stale = 0; - uint32_t overlay_revalidations = 0; - uint32_t overlay_hot_native_pc = 0; - uint64_t overlay_hot_native_calls = 0; - uint64_t overlay_shadow_calls = 0; - uint64_t overlay_shadow_divergences = 0; - uint32_t overlay_first_divergence_pc = 0; - uint32_t capture_triggers = 0; - uint64_t capture_last_dispatch_delta = 0; - int capture_overlays = 0; -}; - -struct RuntimePerfState { - bool initialized = false; - bool enabled = false; - uint32_t interval_ms = 5000; - uint64_t frequency = 0; - uint64_t last_frame_exit_counter = 0; - uint64_t guest_work_ticks = 0; - uint64_t pacer_ticks = 0; - uint64_t autocapture_ticks = 0; - uint64_t provider_poll_ticks = 0; - bool bench_configured = false; - bool bench_started = false; - bool bench_reported = false; - uint64_t bench_start_frame = 0; - uint64_t bench_end_frame = 0; - RuntimePerfSnapshot bench_start; -}; - -static RuntimePerfState g_runtime_perf; - -static bool runtime_perf_parse_window(const char *text, uint64_t *start, - uint64_t *end) { - if (!text || !text[0]) return false; - char *middle = nullptr; - unsigned long long first = std::strtoull(text, &middle, 10); - if (middle == text || *middle != ':') return false; - char *tail = nullptr; - unsigned long long last = std::strtoull(middle + 1, &tail, 10); - if (tail == middle + 1 || *tail != '\0' || first == 0 || last <= first) - return false; - *start = (uint64_t)first; - *end = (uint64_t)last; - return true; -} - -static void runtime_perf_init() { - if (g_runtime_perf.initialized) return; - g_runtime_perf.initialized = true; - const char *enabled = std::getenv("PSX_RUNTIME_PERF_DIAG"); - g_runtime_perf.enabled = enabled && enabled[0] && enabled[0] != '0'; - if (!g_runtime_perf.enabled) return; - - g_runtime_perf.frequency = SDL_GetPerformanceFrequency(); - if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1; - dirty_ram_perf_hot_enable(1); - if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) { - char *tail = nullptr; - long requested = std::strtol(interval, &tail, 10); - if (tail != interval && *tail == '\0') { - if (requested < 250) requested = 250; - if (requested > 600000) requested = 600000; - g_runtime_perf.interval_ms = (uint32_t)requested; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring invalid PSX_RUNTIME_PERF_DIAG_MS='%s'\n", - interval); - } - } - if (const char *window = std::getenv("PSX_BENCH_WINDOW")) { - if (runtime_perf_parse_window(window, - &g_runtime_perf.bench_start_frame, - &g_runtime_perf.bench_end_frame)) { - g_runtime_perf.bench_configured = true; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring invalid PSX_BENCH_WINDOW='%s' " - "(expected start:end, start >= 1, end > start)\n", window); - } - } - std::fprintf(stdout, "psxrecomp: runtime perf diagnostics enabled: interval=%u ms", - g_runtime_perf.interval_ms); - if (g_runtime_perf.bench_configured) - std::fprintf(stdout, ", bench=%llu:%llu", - (unsigned long long)g_runtime_perf.bench_start_frame, - (unsigned long long)g_runtime_perf.bench_end_frame); - std::fprintf(stdout, "\n"); - std::fflush(stdout); -} - -static RuntimePerfSnapshot runtime_perf_snapshot(uint64_t now) { - RuntimePerfSnapshot s; - s.counter = now; - s.frame = s_frame_count; - s.guest_work_ticks = g_runtime_perf.guest_work_ticks; - s.pacer_ticks = g_runtime_perf.pacer_ticks; - s.autocapture_ticks = g_runtime_perf.autocapture_ticks; - s.provider_poll_ticks = g_runtime_perf.provider_poll_ticks; - s.dirty_insns = g_dirty_ram_insns_run; - s.dirty_dispatches = g_dirty_window_dispatches; - overlay_loader_get_counters(&s.overlay_loads, &s.overlay_invalidations, - &s.overlay_unregistered, &s.overlay_native, - &s.overlay_interp, &s.overlay_stale, - nullptr, nullptr, nullptr, nullptr, - &s.overlay_revalidations); - overlay_loader_take_hot_native(&s.overlay_hot_native_pc, - &s.overlay_hot_native_calls); - overlay_loader_get_shadow_summary(&s.overlay_shadow_calls, - &s.overlay_shadow_divergences, - &s.overlay_first_divergence_pc); - int capture_enabled = 0; - overlay_autocapture_get_status(&capture_enabled, &s.capture_triggers, - &s.capture_last_dispatch_delta); - s.capture_overlays = overlay_capture_count(); - return s; -} - -static double runtime_perf_ticks_ms(uint64_t ticks) { - return (double)ticks * 1000.0 / (double)g_runtime_perf.frequency; -} - -static void runtime_perf_bench_tick(uint64_t now) { - if (!g_runtime_perf.bench_configured || g_runtime_perf.bench_reported) return; - if (!g_runtime_perf.bench_started) { - if (s_frame_count == g_runtime_perf.bench_start_frame) { - g_runtime_perf.bench_start = runtime_perf_snapshot(now); - g_runtime_perf.bench_started = true; - } - return; - } - if (s_frame_count != g_runtime_perf.bench_end_frame) return; - - RuntimePerfSnapshot end = runtime_perf_snapshot(now); - const RuntimePerfSnapshot &start = g_runtime_perf.bench_start; - std::fprintf(stdout, - "[BENCH] window=%llu:%llu frames=%llu wall_ms=%.3f " - "guest_work_ms=%.3f pacer_ms=%.3f autocapture_main_ms=%.3f " - "provider_poll_ms=%.3f " - "dirty_insns=+%llu dirty_dispatches=+%llu " - "overlay_native=+%llu overlay_interp=+%llu overlay_loads=+%u " - "overlay_invalidations=+%u overlay_unregistered=+%u " - "overlay_stale=+%llu overlay_revalidations=+%u " - "capture_triggers=+%u capture_overlays=+%d " - "capture_last_dispatch_delta=%llu\n", - (unsigned long long)start.frame, (unsigned long long)end.frame, - (unsigned long long)(end.frame - start.frame), - runtime_perf_ticks_ms(end.counter - start.counter), - runtime_perf_ticks_ms(end.guest_work_ticks - start.guest_work_ticks), - runtime_perf_ticks_ms(end.pacer_ticks - start.pacer_ticks), - runtime_perf_ticks_ms(end.autocapture_ticks - start.autocapture_ticks), - runtime_perf_ticks_ms(end.provider_poll_ticks - start.provider_poll_ticks), - (unsigned long long)(end.dirty_insns - start.dirty_insns), - (unsigned long long)(end.dirty_dispatches - start.dirty_dispatches), - (unsigned long long)(end.overlay_native - start.overlay_native), - (unsigned long long)(end.overlay_interp - start.overlay_interp), - end.overlay_loads - start.overlay_loads, - end.overlay_invalidations - start.overlay_invalidations, - end.overlay_unregistered - start.overlay_unregistered, - (unsigned long long)(end.overlay_stale - start.overlay_stale), - end.overlay_revalidations - start.overlay_revalidations, - end.capture_triggers - start.capture_triggers, - end.capture_overlays - start.capture_overlays, - (unsigned long long)end.capture_last_dispatch_delta); - std::fflush(stdout); - g_runtime_perf.bench_reported = true; -} - -static void runtime_perf_frame_begin() { - runtime_perf_init(); - if (!g_runtime_perf.enabled) return; - uint64_t now = SDL_GetPerformanceCounter(); - if (g_runtime_perf.last_frame_exit_counter && - now >= g_runtime_perf.last_frame_exit_counter) { - g_runtime_perf.guest_work_ticks += - now - g_runtime_perf.last_frame_exit_counter; - } - runtime_perf_bench_tick(now); -} - -static void runtime_perf_frame_end() { - if (!g_runtime_perf.enabled) return; - g_runtime_perf.last_frame_exit_counter = SDL_GetPerformanceCounter(); -} - -class RuntimePerfFrameScope { -public: - ~RuntimePerfFrameScope() { runtime_perf_frame_end(); } -}; - -static uint64_t runtime_perf_section_begin() { - return g_runtime_perf.enabled ? SDL_GetPerformanceCounter() : 0; -} - -static void runtime_perf_section_end(uint64_t start, uint64_t *total) { - if (!start) return; - uint64_t end = SDL_GetPerformanceCounter(); - if (end >= start) *total += end - start; -} - -extern "C" uint8_t *memory_get_ram_ptr(void); -#ifdef PSX_HAS_OVERLAY_DISPATCH -extern "C" void psx_overlay_static_get_stats(uint64_t *, uint64_t *, - uint64_t *, uint64_t *); -extern "C" void overlay_loader_static_match_stats(uint64_t *, uint64_t *, - uint64_t *); -#endif -static uint32_t runtime_perf_live_word(uint32_t phys, uint32_t samples) { - if (samples == 0u || phys > 2u * 1024u * 1024u - 4u) return 0u; - const uint8_t *ram = memory_get_ram_ptr(); - if (!ram) return 0u; - return (uint32_t)ram[phys] | - ((uint32_t)ram[phys + 1u] << 8) | - ((uint32_t)ram[phys + 2u] << 16) | - ((uint32_t)ram[phys + 3u] << 24); -} - -static void runtime_perf_diag_tick() { - static bool have_last = false; - static RuntimePerfSnapshot last; - static uint64_t last_spu = 0, last_underruns = 0, last_overflows = 0; - static uint64_t last_up[6] = {0}; - static uint64_t last_overlay_load_us = 0; -#ifdef PSX_HAS_OVERLAY_DISPATCH - static uint64_t last_static[7] = {0}; -#endif - if (!g_runtime_perf.enabled) return; - - uint64_t now = SDL_GetPerformanceCounter(); - const uint64_t interval_ticks = - g_runtime_perf.frequency * (uint64_t)g_runtime_perf.interval_ms / 1000u; - if (have_last && now - last.counter < interval_ticks) return; - - RuntimePerfSnapshot current = runtime_perf_snapshot(now); - AudioTraceStats audio; - audio_trace_get_stats(&audio); - double fill_ms = 0.0, target_ms = 0.0, correction = 0.0; - uint64_t underruns = 0, overflows = 0; - int legacy = 0, host_rate = 0; - psx_audio_out_stats(&fill_ms, &target_ms, &underruns, &overflows, - &correction, &legacy, &host_rate); - uint64_t up[6] = {0}; - gl_renderer_runtime_diag(up); - uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; - overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us, - &overlay_load_last_us); - uint32_t dirty_hot_pc[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - uint32_t dirty_hot_n[DIRTY_RAM_PERF_HOT_COUNT] = {0}; - uint32_t dirty_hot_stride = 0; - dirty_ram_perf_take_hot(dirty_hot_pc, dirty_hot_n, &dirty_hot_stride); - uint64_t kbless[6] = {0}; - uint64_t kbless_mismatch_count = 0; - uint32_t kbless_last[6] = {0}; - psx_kernel_bless_stats(kbless); - psx_kernel_bless_mismatch_detail(&kbless_mismatch_count, kbless_last); -#ifdef PSX_HAS_OVERLAY_DISPATCH - uint64_t static_now[7] = {0}; - psx_overlay_static_get_stats(&static_now[0], &static_now[1], - &static_now[2], &static_now[3]); - overlay_loader_static_match_stats(&static_now[4], &static_now[5], - &static_now[6]); -#endif - if (!have_last) { - last = current; - last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; - last_underruns = underruns; - last_overflows = overflows; - last_overlay_load_us = overlay_load_us; -#ifdef PSX_HAS_OVERLAY_DISPATCH - for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; -#endif - for (int i = 0; i < 6; i++) last_up[i] = up[i]; - have_last = true; - return; - } - - const double dt = (double)(current.counter - last.counter) / - (double)g_runtime_perf.frequency; - std::fprintf(stdout, - "psxrecomp: runtime cadence: guest=%.2f Hz, spu=%.1f Hz, " - "audio_fill=%.1f/%.1f ms, underruns=+%llu, overflows=+%llu, corr=%+.5f; " - "GL upload=%.1f calls/s %.1f rect/s %.2f Mpix/s, " - "cpu=%.1f tex=%.1f draw=%.1f ms/s; " - "work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, " - "dirty=%.0f insn/s %.0f dispatch/s; " - "dirty_hot=0x%08X:%08X/%u,0x%08X:%08X/%u," - "0x%08X:%08X/%u sample=1/%u; " - "kbless clean=%llu mismatch=%llu hits=%llu last=0x%08X " - "body=0x%08X-0x%08X diff=0x%08X:%02X/%02X; " -#ifdef PSX_HAS_OVERLAY_DISPATCH - "static checks=+%llu hits=+%llu variant_miss=+%llu " - "address_miss=+%llu rehash=+%llu crc_miss=+%llu fast=+%llu; " -#endif - "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " - "shadow=+%llu div=+%llu first_div=0x%08X " - "loads=+%u revalidations=+%u " - "load_wall=%.1f ms max=%.1f last=%.1f ms; " - "capture triggers=+%u overlays=+%d last_dispatch_delta=%llu\n", - (double)(current.frame - last.frame) / dt, - (double)(audio.tap_frames[AUDIO_TAP_SPU_OUT] - last_spu) / dt, - fill_ms, target_ms, (unsigned long long)(underruns - last_underruns), - (unsigned long long)(overflows - last_overflows), correction, - (double)(up[0] - last_up[0]) / dt, - (double)(up[1] - last_up[1]) / dt, - (double)(up[2] - last_up[2]) / dt / 1.0e6, - (double)(up[3] - last_up[3]) * 1000.0 / - (double)g_runtime_perf.frequency / dt, - (double)(up[4] - last_up[4]) * 1000.0 / - (double)g_runtime_perf.frequency / dt, - (double)(up[5] - last_up[5]) * 1000.0 / - (double)g_runtime_perf.frequency / dt, - runtime_perf_ticks_ms(current.guest_work_ticks - last.guest_work_ticks) / dt, - runtime_perf_ticks_ms(current.pacer_ticks - last.pacer_ticks) / dt, - runtime_perf_ticks_ms(current.autocapture_ticks - last.autocapture_ticks) / dt, - runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt, - (double)(current.dirty_insns - last.dirty_insns) / dt, - (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, - dirty_hot_pc[0], runtime_perf_live_word(dirty_hot_pc[0], dirty_hot_n[0]), - dirty_hot_n[0], - dirty_hot_pc[1], runtime_perf_live_word(dirty_hot_pc[1], dirty_hot_n[1]), - dirty_hot_n[1], - dirty_hot_pc[2], runtime_perf_live_word(dirty_hot_pc[2], dirty_hot_n[2]), - dirty_hot_n[2], dirty_hot_stride, - (unsigned long long)kbless[1], - (unsigned long long)kbless_mismatch_count, - (unsigned long long)kbless[3], - kbless_last[0], kbless_last[1], kbless_last[2], kbless_last[3], - kbless_last[4] & 0xFFu, kbless_last[5] & 0xFFu, -#ifdef PSX_HAS_OVERLAY_DISPATCH - (unsigned long long)(static_now[0] - last_static[0]), - (unsigned long long)(static_now[1] - last_static[1]), - (unsigned long long)(static_now[2] - last_static[2]), - (unsigned long long)(static_now[3] - last_static[3]), - (unsigned long long)(static_now[4] - last_static[4]), - (unsigned long long)(static_now[5] - last_static[5]), - (unsigned long long)(static_now[6] - last_static[6]), -#endif - (unsigned long long)(current.overlay_native - last.overlay_native), - (unsigned long long)(current.overlay_interp - last.overlay_interp), - current.overlay_hot_native_pc, - (unsigned long long)current.overlay_hot_native_calls, - (unsigned long long)(current.overlay_shadow_calls - last.overlay_shadow_calls), - (unsigned long long)(current.overlay_shadow_divergences - - last.overlay_shadow_divergences), - current.overlay_first_divergence_pc, - current.overlay_loads - last.overlay_loads, - current.overlay_revalidations - last.overlay_revalidations, - (double)(overlay_load_us - last_overlay_load_us) / 1000.0, - (double)overlay_load_max_us / 1000.0, - (double)overlay_load_last_us / 1000.0, - current.capture_triggers - last.capture_triggers, - current.capture_overlays - last.capture_overlays, - (unsigned long long)current.capture_last_dispatch_delta); - std::fflush(stdout); - last = current; - last_spu = audio.tap_frames[AUDIO_TAP_SPU_OUT]; - last_underruns = underruns; - last_overflows = overflows; - last_overlay_load_us = overlay_load_us; -#ifdef PSX_HAS_OVERLAY_DISPATCH - for (int i = 0; i < 7; i++) last_static[i] = static_now[i]; -#endif - for (int i = 0; i < 6; i++) last_up[i] = up[i]; -} - -static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) { - { /* Tag audio events with the vblank frame counter. */ - extern uint64_t s_frame_count; - audio_trace_note_frame((uint32_t)s_frame_count); - } - const int HANGOVER_FRAMES = 8; /* ~133 ms at 60 fps */ - static int muted = 0; - static int hangover = 0; - static int sink_was_active = 0; - - if (hard_mute_active) { - if (!muted) { - int tail = sdl_audio_fade_samples; - const int buf_cap = (int)(sizeof(sdl_audio_buf) / (2 * sizeof(int16_t))); - if (tail > buf_cap) tail = buf_cap; - /* An unmute ramp may still be in progress; start the down-ramp - * from its current gain so the edge stays continuous. */ - const float g0 = 1.0f - (float)sdl_audio_fadein_left - / (float)sdl_audio_fade_samples; - sdl_audio_fadein_left = 0; - spu_render(sdl_audio_buf, tail); - sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); - audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); - if (sdl_audio_device && audio_legacy_mode()) { - sdl_audio_apply_master_gain( - sdl_audio_buf, tail, - &g_audio_legacy_applied_master_gain); - audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail); - psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, - (uint32_t)tail * sizeof(int16_t) * 2u); - } else if (sdl_audio_device && s_drc_ready) { - psx_sdl_audio_lock(sdl_audio_device); - rab_push(&s_drc, sdl_audio_buf, tail); - psx_sdl_audio_unlock(sdl_audio_device); - } - muted = 1; - } - hangover = HANGOVER_FRAMES; - return; - } - if (muted) { - if (hangover > 0) { hangover--; return; } - muted = 0; - sdl_audio_fadein_left = sdl_audio_fade_samples; - audio_trace_event(AUDIO_EV_UNMUTE, (uint32_t)sdl_audio_fadein_left, 0); - extern int g_audio_unmute_resync; - g_audio_unmute_resync = 1; - } - if (turbo_sink_active) { - if (!sink_was_active) { - sink_was_active = 1; - audio_trace_event(AUDIO_EV_MUTE, 0, 2); /* b=2: discard-only sink */ - } - g_turbo_audio_sink_active = 1; - sdl_audio_pump(true); - return; - } - g_turbo_audio_sink_active = 0; - if (sink_was_active) { - sink_was_active = 0; - sdl_audio_fadein_left = sdl_audio_fade_samples; - audio_trace_event(AUDIO_EV_UNMUTE, - (uint32_t)sdl_audio_fadein_left, 2); - g_audio_unmute_resync = 1; - } - sdl_audio_pump(false); -} - -/* PS1 digital pad button bits (active-low: 0=pressed, 1=released). - * Bit 0 = SELECT, Bit 1 = L3, Bit 2 = R3, Bit 3 = START, - * Bit 4 = UP, Bit 5 = RIGHT, Bit 6 = DOWN, Bit 7 = LEFT, - * Bit 8 = L2, Bit 9 = R2, Bit 10 = L1, Bit 11 = R1, - * Bit 12 = TRIANGLE, Bit 13 = CIRCLE, Bit 14 = CROSS, Bit 15 = SQUARE. - * L3/R3 (stick clicks) exist on a DualShock only; the wire reports them like - * Beetle's dualshock.cpp does — straight from the button word, no mode mask. */ -#define PAD_SELECT (1 << 0) -#define PAD_L3 (1 << 1) -#define PAD_R3 (1 << 2) -#define PAD_START (1 << 3) -#define PAD_UP (1 << 4) -#define PAD_RIGHT (1 << 5) -#define PAD_DOWN (1 << 6) -#define PAD_LEFT (1 << 7) -#define PAD_L2 (1 << 8) -#define PAD_R2 (1 << 9) -#define PAD_L1 (1 << 10) -#define PAD_R1 (1 << 11) -#define PAD_TRIANGLE (1 << 12) -#define PAD_CIRCLE (1 << 13) -#define PAD_CROSS (1 << 14) -#define PAD_SQUARE (1 << 15) - -struct ControllerSource { - enum class Kind { - None, - Button, - AxisPositive, - AxisNegative, - }; - - Kind kind = Kind::None; - int id = -1; -}; - -struct PsxButtonMap { - uint16_t bit; - const char* ini_name; - std::vector sources; -}; - -static int controller_device_index = 0; -static int controller_deadzone = 12000; -static int controller_anti_deadzone = 0; -static std::array controller_map = {{ - { PAD_UP, "up", {} }, - { PAD_DOWN, "down", {} }, - { PAD_LEFT, "left", {} }, - { PAD_RIGHT, "right", {} }, - { PAD_CROSS, "cross", {} }, - { PAD_CIRCLE, "circle", {} }, - { PAD_SQUARE, "square", {} }, - { PAD_TRIANGLE, "triangle", {} }, - { PAD_L1, "l1", {} }, - { PAD_R1, "r1", {} }, - { PAD_L2, "l2", {} }, - { PAD_R2, "r2", {} }, - { PAD_L3, "l3", {} }, - { PAD_R3, "r3", {} }, - { PAD_START, "start", {} }, - { PAD_SELECT, "select", {} }, -}}; - -static std::string trim_copy(const std::string& s) { - size_t first = 0; - while (first < s.size() && std::isspace((unsigned char)s[first])) first++; - size_t last = s.size(); - while (last > first && std::isspace((unsigned char)s[last - 1])) last--; - return s.substr(first, last - first); -} - -/* Resolve the durable additive overlay-capture store. Explicit process env is - * authoritative. For local development, parse only the two known capture keys - * from a gitignored root .psxrecomp.env (or .env); do not import unrelated - * variables/secrets into the game process. Relative paths are root-relative. */ -static std::filesystem::path resolve_overlay_capture_path( - const std::filesystem::path& project_root, - const std::filesystem::path& exe_dir, - const std::string& game_id) { - std::string direct; - std::string store_root; - std::filesystem::path value_base = project_root; - if (const char* e = std::getenv("PSX_OVERLAY_CAPTURES")) direct = e; - if (const char* e = std::getenv("PSX_OVERLAY_CAPTURE_ROOT")) store_root = e; - - if (direct.empty() && store_root.empty()) { - std::vector configs = { - project_root / ".psxrecomp.env", project_root / ".env" - }; - /* Framework developers commonly build a game whose config root is a - * sibling repository. Walk upward from the executable as well so the - * framework worktree's private config remains the durable authority. */ - std::filesystem::path cur = exe_dir; - for (int i = 0; i < 8; ++i) { - configs.push_back(cur / ".psxrecomp.env"); - const auto parent = cur.parent_path(); - if (parent == cur) break; - cur = parent; - } - for (const auto& config : configs) { - std::ifstream in(config); - if (!in) continue; - std::string line; - while (std::getline(in, line)) { - line = trim_copy(line); - if (line.empty() || line[0] == '#') continue; - const size_t eq = line.find('='); - if (eq == std::string::npos) continue; - std::string key = trim_copy(line.substr(0, eq)); - std::string value = trim_copy(line.substr(eq + 1)); - if (value.size() >= 2 && - ((value.front() == '"' && value.back() == '"') || - (value.front() == '\'' && value.back() == '\''))) - value = value.substr(1, value.size() - 2); - if (key == "PSX_OVERLAY_CAPTURES") { - direct = value; - value_base = config.parent_path(); - } else if (key == "PSX_OVERLAY_CAPTURE_ROOT") { - store_root = value; - value_base = config.parent_path(); - } - } - if (!direct.empty() || !store_root.empty()) break; - } - } - - auto root_relative = [&](const std::string& raw) { - std::filesystem::path p(raw); - return p.is_absolute() ? p : value_base / p; - }; - std::filesystem::path result; - if (!direct.empty()) result = root_relative(direct); - else if (!store_root.empty()) - result = root_relative(store_root) / game_id / "overlay_captures.json"; - else result = exe_dir / "overlay_captures.json"; - - std::error_code ec; - std::filesystem::create_directories(result.parent_path(), ec); - if (ec) { - std::fprintf(stderr, "psxrecomp: cannot create overlay capture store %s: %s\n", - result.parent_path().string().c_str(), ec.message().c_str()); - return exe_dir / "overlay_captures.json"; - } - return result.lexically_normal(); -} - -static std::string lower_copy(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), - [](unsigned char c) { return (char)std::tolower(c); }); - return s; -} - -static bool parse_bool_value(const std::string& value, bool fallback) { - std::string v = lower_copy(trim_copy(value)); - if (v == "1" || v == "true" || v == "yes" || v == "on") return true; - if (v == "0" || v == "false" || v == "no" || v == "off") return false; - return fallback; -} - -static ControllerSource parse_controller_source(const std::string& raw) { - std::string s = lower_copy(trim_copy(raw)); - ControllerSource out; - if (s.empty() || s == "none" || s == "disabled") return out; - - if (s == "a") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_A; return out; } - if (s == "b") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_B; return out; } - if (s == "x") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_X; return out; } - if (s == "y") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_Y; return out; } - if (s == "back" || s == "view" || s == "select") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_BACK; return out; - } - if (s == "start" || s == "menu") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_START; return out; - } - if (s == "guide") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_GUIDE; return out; } - if (s == "leftstick") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_LEFTSTICK; return out; } - if (s == "rightstick") { out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_RIGHTSTICK; return out; } - if (s == "leftshoulder" || s == "lb" || s == "l1") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_LEFTSHOULDER; return out; - } - if (s == "rightshoulder" || s == "rb" || s == "r1") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_RIGHTSHOULDER; return out; - } - if (s == "dpup" || s == "dpadup") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_UP; return out; - } - if (s == "dpdown" || s == "dpaddown") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_DOWN; return out; - } - if (s == "dpleft" || s == "dpadleft") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_LEFT; return out; - } - if (s == "dpright" || s == "dpadright") { - out.kind = ControllerSource::Kind::Button; out.id = SDL_CONTROLLER_BUTTON_DPAD_RIGHT; return out; - } - if (s == "lefttrigger" || s == "lt" || s == "l2") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_TRIGGERLEFT; return out; - } - if (s == "righttrigger" || s == "rt" || s == "r2") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_TRIGGERRIGHT; return out; - } - if (s == "leftx+" || s == "lsright") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_LEFTX; return out; - } - if (s == "leftx-" || s == "lsleft") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_LEFTX; return out; - } - if (s == "lefty+" || s == "lsdown") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_LEFTY; return out; - } - if (s == "lefty-" || s == "lsup") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_LEFTY; return out; - } - if (s == "rightx+" || s == "rsright") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_RIGHTX; return out; - } - if (s == "rightx-" || s == "rsleft") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_RIGHTX; return out; - } - if (s == "righty+" || s == "rsdown") { - out.kind = ControllerSource::Kind::AxisPositive; out.id = SDL_CONTROLLER_AXIS_RIGHTY; return out; - } - if (s == "righty-" || s == "rsup") { - out.kind = ControllerSource::Kind::AxisNegative; out.id = SDL_CONTROLLER_AXIS_RIGHTY; return out; - } - - SDL_GameControllerButton button = SDL_GameControllerGetButtonFromString(s.c_str()); - if (button != SDL_CONTROLLER_BUTTON_INVALID) { - out.kind = ControllerSource::Kind::Button; - out.id = button; - return out; - } - SDL_GameControllerAxis axis = SDL_GameControllerGetAxisFromString(s.c_str()); - if (axis != SDL_CONTROLLER_AXIS_INVALID) { - out.kind = ControllerSource::Kind::AxisPositive; - out.id = axis; - return out; - } - return out; -} - -static std::vector parse_source_list(const std::string& value) { - std::vector sources; - std::stringstream ss(value); - std::string item; - while (std::getline(ss, item, ',')) { - ControllerSource source = parse_controller_source(item); - if (source.kind != ControllerSource::Kind::None) { - sources.push_back(source); - } - } - return sources; -} - -static void set_default_controller_mapping(void) { - for (auto& entry : controller_map) entry.sources.clear(); - - auto set_sources = [](const char* name, const char* sources) { - for (auto& entry : controller_map) { - if (std::strcmp(entry.ini_name, name) == 0) { - entry.sources = parse_source_list(sources); - return; - } - } - }; - - set_sources("up", "dpup,lefty-"); - set_sources("down", "dpdown,lefty+"); - set_sources("left", "dpleft,leftx-"); - set_sources("right", "dpright,leftx+"); - set_sources("cross", "a"); - set_sources("circle", "b"); - set_sources("square", "x"); - set_sources("triangle", "y"); - set_sources("l1", "leftshoulder"); - set_sources("r1", "rightshoulder"); - set_sources("l2", "lefttrigger"); - set_sources("r2", "righttrigger"); - set_sources("l3", "leftstick"); - set_sources("r3", "rightstick"); - set_sources("start", "start"); - set_sources("select", "back"); -} - -static std::string default_input_ini_text(void) { - return - "; PSXRecomp input mapping. PSX buttons are active when any listed source is pressed.\n" - "; Sources use SDL/Xbox names: a,b,x,y,back,start,leftshoulder,rightshoulder,\n" - "; lefttrigger,righttrigger,leftstick,rightstick (stick clicks -> L3/R3),\n" - "; dpup,dpdown,dpleft,dpright,leftx-/leftx+/lefty-/lefty+.\n" - "\n" - "[controller]\n" - "enabled = true\n" - "device = 0\n" - "deadzone = 12000\n" - "\n" - "[mapping]\n" - "up = dpup,lefty-\n" - "down = dpdown,lefty+\n" - "left = dpleft,leftx-\n" - "right = dpright,leftx+\n" - "cross = a\n" - "circle = b\n" - "square = x\n" - "triangle = y\n" - "l1 = leftshoulder\n" - "r1 = rightshoulder\n" - "l2 = lefttrigger\n" - "r2 = righttrigger\n" - "l3 = leftstick\n" - "r3 = rightstick\n" - "start = start\n" - "select = back\n"; -} - -static void load_input_config(const char* argv0) { - set_default_controller_mapping(); - - /* Keyboard bindings are independent of the gamepad input.ini. Load them - * before the first-run input.ini creation branch: that branch returns - * after writing the default controller file, and previously left the - * compiled legacy keyboard defaults active for the entire first session - * even when a packaged keybinds.ini was already present. */ - psx_keybinds_init(argv0); - - namespace fs = std::filesystem; - fs::path config_path = exe_dir_from_argv(argv0) / "input.ini"; - std::error_code ec; - if (!fs::exists(config_path, ec)) { - std::ofstream out(config_path, std::ios::binary); - if (out) out << default_input_ini_text(); - return; - } - - std::ifstream in(config_path); - if (!in) return; - - bool controller_enabled = true; - std::string section; - std::string line; - while (std::getline(in, line)) { - size_t comment = line.find_first_of(";#"); - if (comment != std::string::npos) line.resize(comment); - line = trim_copy(line); - if (line.empty()) continue; - if (line.front() == '[' && line.back() == ']') { - section = lower_copy(trim_copy(line.substr(1, line.size() - 2))); - continue; - } - size_t eq = line.find('='); - if (eq == std::string::npos) continue; - - std::string key = lower_copy(trim_copy(line.substr(0, eq))); - std::string value = trim_copy(line.substr(eq + 1)); - if (section == "controller") { - if (key == "enabled") { - controller_enabled = parse_bool_value(value, controller_enabled); - } else if (key == "device") { - controller_device_index = std::max(0, std::atoi(value.c_str())); - } else if (key == "deadzone") { - controller_deadzone = std::max(0, std::min(32767, std::atoi(value.c_str()))); - } - } else if (section == "mapping") { - for (auto& entry : controller_map) { - if (key == entry.ini_name) { - entry.sources = parse_source_list(value); - break; - } - } - } - } - - if (!controller_enabled) { - for (auto& entry : controller_map) entry.sources.clear(); - } - -} - -static void close_player(PlayerInput& p) { - if (p.handle) { - SDL_GameControllerClose(p.handle); - p.handle = nullptr; - p.instance = -1; - } -} - -static void close_controller(void) { - close_player(g_players[0]); - close_player(g_players[1]); -} - -/* Open the SDL controller whose GUID matches p.guid. If no exact GUID match - * exists (e.g. a different physical unit of the same model, or Steam's virtual - * pad at an unpredictable slot), fall back to the first controller not already - * claimed by the other player. */ -static void open_player(PlayerInput& p, const PlayerInput& other) { - if (p.kind != 2 || p.handle) return; - - int chosen = -1, fallback = -1; - const int joysticks = SDL_NumJoysticks(); - for (int i = 0; i < joysticks; i++) { - if (!SDL_IsGameController(i)) continue; - SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i); - char buf[40] = {0}; - SDL_JoystickGetGUIDString(g, buf, sizeof(buf)); - /* Skip a device already opened by the other player. */ - SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - if (other.handle && other.instance == inst) continue; - if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; } - if (fallback < 0) fallback = i; - } - if (chosen < 0) chosen = fallback; - if (chosen < 0) return; - - p.handle = SDL_GameControllerOpen(chosen); - if (p.handle) { - SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle); - p.instance = joy ? SDL_JoystickInstanceID(joy) : -1; - const char* name = SDL_GameControllerName(p.handle); - std::fprintf(stdout, "psxrecomp runtime: opened controller for slot: %s\n", - name ? name : "(unnamed)"); - } -} - -/* The pad type a mode reports before any input has been sampled (boot / - * hotplug). analog pins DualShock; digital and hybrid both start as a digital - * pad (hybrid only flips to DualShock once the player nudges the stick). */ -static int pad_mode_boot_analog(int mode) { - /* Hybrid boots ANALOG-on (matches a DualShock powered up with the analog LED - * lit): the seamless auto-switch then drops to digital only if the player - * reaches for the d-pad. Pinned-analog also boots analog; digital boots off. */ - return (mode == PSXRecompV4::PAD_MODE_ANALOG || - mode == PSXRecompV4::PAD_MODE_HYBRID) ? 1 : 0; -} - -/* Open/close SDL handles so they match g_players, and (re)assert each slot's - * PSX connection + pad type. Safe to call repeatedly (hotplug, boot). - * While delay-sync netplay is active, SIO connection/type are owned by - * psx_netplay (session slots stay plugged); only refresh host SDL handles. */ -static void refresh_player_devices(void) { - const int netplay = psx_netplay_active(); - for (int s = 0; s < 2; s++) { - PlayerInput& p = g_players[s]; - if (p.kind != 2) close_player(p); /* keyboard/none: no handle */ - else open_player(p, g_players[s ^ 1]); - if (netplay) continue; - sio_set_pad_connected(s, p.kind != 0 ? 1 : 0); - sio_set_pad_analog(s, pad_mode_boot_analog(p.mode), 0x80, 0x80, 0x80, 0x80); - /* DIGITAL mode == a plain digital controller that ignores the DualShock - * config-mode commands (real SCPH-1080 behaviour); ANALOG/HYBRID == a - * config-capable DualShock. A digital pad that wrongly answered 0x43 - * sent Tomba 2's pad driver down the config path -> phantom 0x00 reads. */ - sio_set_pad_config_capable(s, p.mode != PSXRecompV4::PAD_MODE_DIGITAL); - } -} - -/* Parse a [controller] device string into a player slot: - * "none" -> no pad; "keyboard" -> keyboard map; otherwise an SDL GUID. */ -static void set_player_device(PlayerInput& p, const std::string& dev, int mode) { - p.mode = mode; - /* Hybrid starts in ANALOG (analog LED on); the auto-switch drops to digital - * only when the player uses the d-pad. */ - p.hybrid_analog = true; - p.guid[0] = '\0'; - std::string d = lower_copy(trim_copy(dev)); - if (d.empty() || d == "none") { p.kind = 0; } - else if (d == "keyboard") { p.kind = 1; } - else if (d == "auto" || d == "gamepad" || d == "controller") { - /* First available SDL game controller (guid empty -> open_player falls - * back to the first connected pad). Lets a user default to "my - * controller" without pinning a specific GUID. */ - p.kind = 2; /* p.guid already cleared above */ - } - else { - p.kind = 2; - std::snprintf(p.guid, sizeof(p.guid), "%s", trim_copy(dev).c_str()); - } -} - -/* Stick→button (Digital D-Pad fold): per-axis deadzone. A strong left/right - * push with a few units of Y drift must NOT fire Up/Down (jump/crouch). Pure - * radial+sign did that — once mag cleared the circle, every non-zero axis - * component became a D-Pad bit. Analog stick bytes still use radial rescale - * in axes_to_pad_pair; this path is digital buttons only. Triggers share the - * same per-axis threshold. */ -static bool controller_source_pressed_h(SDL_GameController* h, const ControllerSource& source) { - if (!h) return false; - - switch (source.kind) { - case ControllerSource::Kind::Button: - return SDL_GameControllerGetButton( - h, (SDL_GameControllerButton)source.id) != 0; - case ControllerSource::Kind::AxisPositive: - case ControllerSource::Kind::AxisNegative: { - const int16_t v = SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)source.id); - if (source.kind == ControllerSource::Kind::AxisPositive) - return v > controller_deadzone; - return v < -controller_deadzone; - } - case ControllerSource::Kind::None: - default: - return false; - } -} - -/* Keyboard -> PSX button word for `player` (1 or 2), via the configurable - * keybinds.ini map (psx_keybinds). Defaults reproduce the old hardcoded layout - * (arrows=d-pad, X/S/Z/A=Cross/Circle/Square/Triangle, Q/W/E/R=L1/R1/L2/R2, - * Return=Start, RShift=Select) plus T/Y=L3/R3 stick clicks. */ -static uint16_t pad_from_keyboard(int player) { - const Uint8* keys = SDL_GetKeyboardState(NULL); - return psx_keybinds_pad_word(keys, player); -} - -/* A left/right ANALOG-STICK axis source (LEFTX/LEFTY/RIGHTX/RIGHTY), as opposed - * to a trigger axis (L2/R2) or a button. The default map folds the left stick - * onto the D-pad bits (up=dpup,lefty- … right=dpright,leftx+) so the stick works - * as a D-pad for DIGITAL games. In ANALOG mode that fold is WRONG: the stick - * already drives the analog axes, and on a real DualShock the stick and D-pad - * are independent — so a dual-analog game that uses the D-pad as its own control - * (e.g. Ape Escape's camera rotate) would see phantom D-pad presses from every - * stick movement, and constant rotation from centre drift. controller_pad_buttons - * suppresses these sources when the pad presents as analog. Trigger axes and - * button sources are never suppressed. */ -static bool source_is_stick_axis(const ControllerSource& s) { - if (s.kind != ControllerSource::Kind::AxisPositive && - s.kind != ControllerSource::Kind::AxisNegative) return false; - return s.id == SDL_CONTROLLER_AXIS_LEFTX || s.id == SDL_CONTROLLER_AXIS_LEFTY || - s.id == SDL_CONTROLLER_AXIS_RIGHTX || s.id == SDL_CONTROLLER_AXIS_RIGHTY; -} - -/* Build the active-low PSX button word from a controller's input.ini map. When - * suppress_stick_axes is set (the pad is presenting as analog this frame), the - * left/right analog-stick axes do NOT contribute button bits — see - * source_is_stick_axis above. Digital mode passes false, so the stick still - * folds onto the D-pad (its only outlet there). */ -static uint16_t controller_pad_buttons(SDL_GameController* h, bool suppress_stick_axes) { - uint16_t buttons = 0xFFFF; /* all released */ - if (!h) return buttons; - for (const auto& entry : controller_map) { - for (const auto& source : entry.sources) { - if (suppress_stick_axes && source_is_stick_axis(source)) continue; - if (controller_source_pressed_h(h, source)) { - buttons &= (uint16_t)~entry.bit; - break; - } - } - } - return buttons; -} - -static void axes_to_pad_pair(int16_t vx, int16_t vy, uint8_t* obx, uint8_t* oby) { - psx_stick_to_dualshock(vx, vy, - controller_deadzone, controller_anti_deadzone, - obx, oby); -} - -/* Buttons for a player's selected device (0xFFFF = none pressed). `player` is - * 1 or 2 — selects which keybinds.ini section drives a keyboard port. */ -static uint16_t pad_buttons_for(const PlayerInput& p, int player, bool suppress_stick_axes) { - if (p.kind == 1) return pad_from_keyboard(player); - if (p.kind == 2) return controller_pad_buttons(p.handle, suppress_stick_axes); - return 0xFFFF; -} - -/* Analog stick bytes (lx,ly,rx,ry) for a player; centred if no live source. - * - * The host left stick maps to the LEFT analog axes (variable). When fold_dpad - * is set, the physical D-pad (and, for a keyboard player, the arrow keys) is - * ALSO folded onto the left axes at full deflection, so an analog-mode game - * whose movement reads only the stick magnitude (e.g. Tomba) still responds to - * the D-pad/keyboard — the faithful "seamless" behaviour: the stick gives - * variable speed, the D-pad gives full speed, both at once. Only the PHYSICAL - * D-pad is folded in (not the stick->d-pad button mapping), so the stick keeps - * its true variable magnitude. fold_dpad is false in HYBRID mode (there the - * D-pad instead flips the pad to digital, so the game runs its own d-pad path) - * and true in pinned-ANALOG mode. The keyboard branch always folds the arrows - * — they are that player's only stick source. */ -static void pad_sticks_for(const PlayerInput& p, int player, uint8_t out[4], bool fold_dpad) { - out[0] = out[1] = out[2] = out[3] = 0x80; - if (p.kind == 1) { - /* Keyboard analog: the configurable left/right stick-direction binds - * (default = arrow keys on the LEFT stick; RIGHT stick unbound), so the - * old keyboard analog behaviour is preserved unless the user rebinds. */ - const Uint8* keys = SDL_GetKeyboardState(NULL); - psx_keybinds_sticks(keys, player, out); - return; - } - if (p.kind == 2 && p.handle) { - axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTX), - SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_LEFTY), - &out[0], &out[1]); - axes_to_pad_pair(SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTX), - SDL_GameControllerGetAxis(p.handle, SDL_CONTROLLER_AXIS_RIGHTY), - &out[2], &out[3]); - if (fold_dpad) { - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT)) out[0] = 0x00; - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)) out[0] = 0xFF; - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_UP)) out[1] = 0x00; - if (SDL_GameControllerGetButton(p.handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)) out[1] = 0xFF; - } - } -} - -/* HYBRID-mode auto-switch detectors (mirror the DualShock analog LED toggling). - * hybrid_stick_active: the LEFT stick is outside the radial deadzone — the - * player has reached for analog. hybrid_dpad_active: any D-pad direction (or, - * for the keyboard, an arrow key) is held — the player wants classic digital. - * The keyboard has no analog stick, so a keyboard player stays digital. */ -static bool controller_stick_active(SDL_GameController* handle) { - if (!handle) return false; - const double lx = - SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_LEFTX); - const double ly = - SDL_GameControllerGetAxis(handle, SDL_CONTROLLER_AXIS_LEFTY); - return std::sqrt(lx * lx + ly * ly) > (double)controller_deadzone; -} -static bool controller_dpad_active(SDL_GameController* handle) { - return handle && - (SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_LEFT) || - SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) || - SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_UP) || - SDL_GameControllerGetButton(handle, SDL_CONTROLLER_BUTTON_DPAD_DOWN)); -} -static bool hybrid_stick_active(const PlayerInput& p, bool dev_any) { - if (p.kind == 2 && controller_stick_active(p.handle)) return true; - if (dev_any) { - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - const SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* handle = - SDL_GameControllerFromInstanceID(inst); - if (!handle) handle = SDL_GameControllerOpen(i); - if (controller_stick_active(handle)) return true; - } - } - return false; -} -static bool hybrid_dpad_active(const PlayerInput& p, int player, bool dev_any) { - if (p.kind == 2 && controller_dpad_active(p.handle)) return true; - if (dev_any) { - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - const SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* handle = - SDL_GameControllerFromInstanceID(inst); - if (!handle) handle = SDL_GameControllerOpen(i); - if (controller_dpad_active(handle)) return true; - } - } - if (p.kind == 1 || dev_any) { - const Uint8* keys = SDL_GetKeyboardState(NULL); - if (psx_keybinds_dpad_active(keys, player)) return true; - } - return false; -} - -/* Sample each player's live device state into the matching SIO pad slot. - * override >= 0 forces port-1 buttons (debug-server input injection). Called - * once at cycle start (covers the turbo/FMV-skip paths that present nothing) - * and, when g_low_latency_input is set, AGAIN after the pacer wait so the next - * CPU frame reads near-fresh input instead of input ~one frame stale. - * - * HYBRID mode auto-switches DualShock<->digital from the most-recent input - * (stick -> analog, D-pad -> digital) so the game runs its own analog or - * digital input path exactly as on hardware (mirrors the inline block this - * helper was extracted from for the low-latency re-sample). */ -/* Dev input mode: while enabled, Player 1 is driven by the keyboard AND EVERY - * connected game controller, all merged together (active-low AND) onto the P1 pad - * word — so a tester can navigate P1 from whatever is plugged in (keyboard, the - * launcher-assigned pad, or any other controller) with no reconfiguration. The P1 - * pad TYPE is left unchanged: a launcher-assigned analog DualShock still presents - * as analog (so the game's analog input path / SIO handshake cadence is preserved - * exactly), and merged sources only contribute button/stick STATE, never a type - * downgrade. This diagnostic convenience must never alter normal player input: - * strict single-device-per-port routing is the default, and PSX_DEV_INPUT must - * be explicitly set to 1/true/yes/on to enable the merge. */ -static bool dev_any_input_enabled() { - static int cached = -1; - if (cached < 0) { - const char* e = std::getenv("PSX_DEV_INPUT"); - const std::string value = lower_copy(trim_copy(e ? e : "")); - cached = (value == "1" || value == "true" || - value == "yes" || value == "on") ? 1 : 0; - } - return cached != 0; -} - -/* Merge (active-low AND) the button words of every connected game controller. - * Handles are resolved from SDL's already-open set when possible and opened once - * on first sight otherwise (kept open for the process lifetime; if a slot later - * closes a shared device this self-heals by reopening next frame). This does NOT - * disturb per-slot routing — SDL returns the same handle for an already-open - * device, so reads are shared and harmless. */ -static uint16_t dev_all_controllers_buttons(bool suppress_stick_axes) { - uint16_t btn = 0xFFFF; - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* h = SDL_GameControllerFromInstanceID(inst); - if (!h) h = SDL_GameControllerOpen(i); /* open once; SDL keeps it */ - if (h) btn &= controller_pad_buttons(h, suppress_stick_axes); - } - return btn; -} - -/* Fold the first live (non-neutral) left/right stick from ANY connected - * controller into st[] (lx,ly,rx,ry). Lets an analog-mode P1 steer from - * whatever pad is plugged in, not only the launcher-assigned one. Only axes - * that read past the deadzone override; a neutral pad leaves st[] untouched. */ -static void dev_any_controller_sticks(uint8_t st[4]) { - const int n = SDL_NumJoysticks(); - for (int i = 0; i < n; i++) { - if (!SDL_IsGameController(i)) continue; - SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - SDL_GameController* h = SDL_GameControllerFromInstanceID(inst); - if (!h) h = SDL_GameControllerOpen(i); - if (!h) continue; - uint8_t lx, ly, rx, ry; - axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTX), - SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_LEFTY), &lx, &ly); - axes_to_pad_pair(SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTX), - SDL_GameControllerGetAxis(h, SDL_CONTROLLER_AXIS_RIGHTY), &rx, &ry); - if (lx != 0x80 || ly != 0x80) { st[0] = lx; st[1] = ly; } - if (rx != 0x80 || ry != 0x80) { st[2] = rx; st[3] = ry; } - } -} - -/* Debug-server input injection: drive the SAME pad model as a physical - * device. The old path set only the button word and returned, which left the - * pad type/sticks at whatever the real device last was — a hybrid P1 stayed - * analog, so injected d-pad bits never moved games that read the stick in - * analog mode (menus reacted, walking didn't). Injected d-pad reads as d-pad - * activity (hybrid drops to digital), an injected stick override reads as - * stick activity (hybrid rises to analog), and the resolved type goes through - * the same coherent request channel as real sampling — never slammed mid- - * handshake (the v0.5.0 phantom-input lesson). */ -static void apply_input_override_to_sio(int override_word) { - PlayerInput& p = g_players[0]; - const uint16_t w = (uint16_t)override_word; - sio_set_pad_state_slot(0, w); - - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - int axes = 0; -#ifndef PSX_NO_DEBUG_TOOLS - axes = debug_server_get_axis_override(st); -#endif - const bool stick_live = axes != 0 && (st[0] != 0x80 || st[1] != 0x80 || - st[2] != 0x80 || st[3] != 0x80); - const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; /* up/right/down/left */ - - /* Debug injection follows the game's resolved mode even when P1 has no - * assigned device. This keeps dev-any-input from manufacturing Hybrid for - * a title that deliberately exposes only Analog / D-Pad. */ - const int mode = p.mode; - - int eff_analog; - if (mode == (int)PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == (int)PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { /* HYBRID: most-recent input source wins, like the real sampler */ - if (stick_live) p.hybrid_analog = true; - else if (dpad_live) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; } - sio_set_pad_sticks(0, st[0], st[1], st[2], st[3]); - sio_request_pad_type(0, eff_analog); -} - -/* Capture one SIO slot's host pad into a netplay/local blob. Returns 1 if a - * device (or dev-any P1) is present; 0 leaves *out as released/disconnected. */ -static int capture_pad_slot(int s, PsxNetPad* out) { - if (!out) return 0; - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 0; - out->connected = 0; - - PlayerInput& p = g_players[s]; - const int player = s + 1; /* keybinds.ini section (1|2) */ - /* Dev input: P1 is driven by the keyboard AND every connected controller, - * so a tester can navigate from whatever is plugged in (P2 keeps strict - * per-port routing). */ - const bool dev_here = (dev_any_input_enabled() && s == 0); - if (p.kind == 0 && !dev_here) return 0; /* no device in this port */ - - /* Resolve the pad type this frame FIRST — the effective analog/digital - * state gates how the left stick is read for BOTH the button word and the - * analog axes below. An assigned device keeps its configured mode (a - * launcher-selected analog DualShock stays analog, so its input path / SIO - * handshake cadence is preserved exactly). A P1 with no assigned device - * keeps the game's resolved mode while dev-any-input merges the keyboard and - * all connected controllers. This is important for games that deliberately - * do not support Hybrid. */ - const int mode = p.mode; - int eff_analog; - if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { /* HYBRID */ - if (hybrid_stick_active(p, dev_here)) p.hybrid_analog = true; - else if (hybrid_dpad_active(p, player, dev_here)) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - - /* Buttons: merge the assigned device with the keyboard (PSX pad word is - * active-low, so AND combines "pressed on either source"). In dev mode P1 - * also folds in the keyboard binds and EVERY connected controller. When the - * pad presents as ANALOG this frame (eff_analog), the left/right analog- - * stick axes are suppressed as button sources so the stick drives ONLY the - * analog axes — the D-pad bits then come solely from the physical D-pad, - * exactly as on a real DualShock. This is what stops a dual-analog game's - * D-pad control (Ape Escape's camera rotate) from being spun by stick - * movement or centre drift. Digital mode keeps the stick->D-pad fold. */ - const bool suppress_stick = (eff_analog != 0); - uint16_t btn = (p.kind != 0) ? pad_buttons_for(p, player, suppress_stick) - : (uint16_t)0xFFFF; - if (dev_here) { - btn &= pad_from_keyboard(1); /* keyboard P1 binds */ - btn &= dev_all_controllers_buttons(suppress_stick); /* any plugged-in pad */ - } - - /* Analog axes. Pinned-ANALOG folds the physical D-pad onto the left axes - * (fold_dpad) so the D-pad still moves stick-only games; HYBRID feeds the - * raw stick when currently analog (no fold — the D-pad drives its own - * digital path there); DIGITAL leaves the axes centred. */ - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - pad_sticks_for(p, player, st, /*fold_dpad=*/true); - } else if (eff_analog) { /* HYBRID, currently presenting analog */ - pad_sticks_for(p, player, st, /*fold_dpad=*/false); - } - /* Dev mode: fold the keyboard's stick binds AND any connected controller's - * sticks onto the analog stick, so an analog-mode P1 steers from whatever - * is plugged in (P1 binds). */ - if (dev_here && eff_analog) { - const Uint8* keys = SDL_GetKeyboardState(NULL); - psx_keybinds_sticks(keys, 1, st); - dev_any_controller_sticks(st); - } - - out->buttons = btn; - out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; - out->analog = eff_analog ? 1u : 0u; - out->connected = 1; - return 1; -} - -/* Netplay-only capture: assigned PlayerInput for this slot only. Never merges - * keyboard-all / all-controllers (dev_any_input). Same pad-mode / stick rules - * as capture_pad_slot otherwise. */ -static int capture_pad_slot_exclusive(int s, PsxNetPad* out) { - if (!out) return 0; - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 0; - out->connected = 0; - - PlayerInput& p = g_players[s]; - const int player = s + 1; /* keybinds.ini section (1|2) */ - const bool dev_here = false; - if (p.kind == 0) return 0; /* no device in this port */ - - int mode = p.mode; - int eff_analog; - if (mode == PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { /* HYBRID */ - if (hybrid_stick_active(p, dev_here)) p.hybrid_analog = true; - else if (hybrid_dpad_active(p, player, dev_here)) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - - const bool suppress_stick = (eff_analog != 0); - uint16_t btn = pad_buttons_for(p, player, suppress_stick); - - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - if (mode == PSXRecompV4::PAD_MODE_ANALOG) { - pad_sticks_for(p, player, st, /*fold_dpad=*/true); - } else if (eff_analog) { /* HYBRID, currently presenting analog */ - pad_sticks_for(p, player, st, /*fold_dpad=*/false); - } - - out->buttons = btn; - out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; - out->analog = eff_analog ? 1u : 0u; - out->connected = 1; - return 1; -} - -static void apply_pad_slot_to_sio(int s, const PsxNetPad& pad) { - sio_set_pad_state_slot(s, pad.buttons); - sio_set_pad_sticks(s, pad.lx, pad.ly, pad.rx, pad.ry); - sio_request_pad_type(s, pad.analog ? 1 : 0); -} - -/* Local human pad for delay-sync: sample the host PlayerInput selected for - * this peer (see --net-input-player / auto), then recomp-net maps that blob - * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive - * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ -static void capture_local_human_pad(PsxNetPad* out) { - const uint32_t ui_flags = psx_host_ui_capture_flags(); - if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_GAMEPAD)) { - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 1; - out->connected = 1; - psx_netplay_normalize_pad(out); - return; - } - int idx = psx_netplay_input_player(); - if (idx < 0 || idx > 1) idx = 0; - if (!capture_pad_slot_exclusive(idx, out)) { - /* Fallback: if auto picked empty P2, try P1 (two-machine guest). */ - if (idx != 0 && capture_pad_slot_exclusive(0, out)) { - out->connected = 1; - psx_netplay_normalize_pad(out); - return; - } - out->buttons = 0xFFFFu; - out->lx = out->ly = out->rx = out->ry = 0x80u; - out->analog = 1; - } - out->connected = 1; - psx_netplay_normalize_pad(out); -} - -/* Build a netplay pad blob from a debug-server override without writing SIO. */ -static void capture_override_pad(int override_word, PsxNetPad* out) { - PlayerInput& p = g_players[0]; - const uint16_t w = (uint16_t)override_word; - uint8_t st[4] = { 0x80, 0x80, 0x80, 0x80 }; - int axes = 0; -#ifndef PSX_NO_DEBUG_TOOLS - axes = debug_server_get_axis_override(st); -#endif - const bool stick_live = axes != 0 && (st[0] != 0x80 || st[1] != 0x80 || - st[2] != 0x80 || st[3] != 0x80); - const bool dpad_live = ((uint16_t)~w & 0x00F0u) != 0; - - const int mode = p.mode; - - int eff_analog; - if (mode == (int)PSXRecompV4::PAD_MODE_DIGITAL) { - eff_analog = 0; - } else if (mode == (int)PSXRecompV4::PAD_MODE_ANALOG) { - eff_analog = 1; - } else { - if (stick_live) p.hybrid_analog = true; - else if (dpad_live) p.hybrid_analog = false; - eff_analog = p.hybrid_analog ? 1 : 0; - } - if (!eff_analog) { st[0] = st[1] = st[2] = st[3] = 0x80; } - - out->buttons = w; - out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; - out->analog = eff_analog ? 1u : 0u; - out->connected = 1; -} - -/* Opt-in netplay barrier split: guest quantum vs admit wait (PSX_NETPLAY_TIMING=1). - * Printed on the existing [FPS] line — no separate log files. */ -static int s_np_timing_enabled = -1; -static uint64_t s_np_admit_ticks = 0; -static uint64_t s_np_guest_ticks = 0; -static uint64_t s_np_last_admit_end = 0; -static uint64_t s_np_timing_frames = 0; - -static int netplay_timing_on(void) { - if (s_np_timing_enabled < 0) { - const char *e = std::getenv("PSX_NETPLAY_TIMING"); - s_np_timing_enabled = (e && e[0] && e[0] != '0') ? 1 : 0; - } - return s_np_timing_enabled; -} - -/* Stage local pad + poll admit until published. Parks the guest fiber when - * called from the vblank callback (or before scheduler entry). Latches one - * sample per sim tick; stalls on INPUT_CONFIRM desync. */ -static void netplay_barrier_admit(int override) { - if (!psx_netplay_active()) return; - static int desync_logged = 0; - const uint64_t admit_t0 = - netplay_timing_on() ? SDL_GetPerformanceCounter() : 0; - /* Guest quantum = time since previous admit returned (fiber ran). */ - if (admit_t0 && s_np_last_admit_end && admit_t0 >= s_np_last_admit_end) { - s_np_guest_ticks += admit_t0 - s_np_last_admit_end; - s_np_timing_frames++; - } - for (;;) { - uint32_t dt = 0, lh = 0, rh = 0; - /* Load apply/ready suppresses INPUT (and can sit silent for seconds on - * a hash-match .pst). timeout=0 still honors BYE (peer_gone) but does - * not treat rx silence as disconnect — that was kicking both peers to - * the lobby mid-load. */ - if (psx_netplay_peer_disconnected( - psx_netplay_in_load_barrier() ? 0u : 1500u)) { - netplay_soft_exit("netplay_peer_disconnect"); - if (psx_return_to_lobby_requested()) return; - } - psx_lobby_pump(); - if (psx_netplay_input_desync(&dt, &lh, &rh)) { - if (!desync_logged) { - std::printf("psxrecomp: netplay INPUT desync tick=%u local=%08x remote=%08x — stalled\n", - (unsigned)dt, (unsigned)lh, (unsigned)rh); - std::fflush(stdout); - desync_logged = 1; - } - SDL_Delay(16); -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_poll(); -#endif - continue; - } - if (psx_netplay_needs_local_sample()) { - PsxNetPad local{}; - if (override >= 0 && !g_headless) { - capture_override_pad(override, &local); - } else if (g_headless) { - local.buttons = 0xFFFFu; - local.lx = local.ly = local.rx = local.ry = 0x80u; - local.analog = 1; - local.connected = 1; - } else { - capture_local_human_pad(&local); - } - psx_netplay_stage_local(&local); - } - if (psx_netplay_poll_admit()) { - desync_logged = 0; - if (admit_t0) { - const uint64_t t1 = SDL_GetPerformanceCounter(); - if (t1 >= admit_t0) s_np_admit_ticks += t1 - admit_t0; - s_np_last_admit_end = t1; - } - return; - } -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_poll(); -#endif - if (!g_headless) { - SDL_Event ev; - while (SDL_PollEvent(&ev)) { - const int ui_consumed = psx_host_ui_handle_event(&ev); - if (ev.type == SDL_QUIT) { - netplay_soft_exit("sdl_window_close"); - if (psx_return_to_lobby_requested()) return; - } - if (!ui_consumed && ev.type == SDL_KEYDOWN && -#if defined(PSX_SDL3) - ev.key.key == SDLK_ESCAPE) { -#else - ev.key.keysym.sym == SDLK_ESCAPE) { -#endif - netplay_soft_exit("netplay_barrier_escape"); - if (psx_return_to_lobby_requested()) return; - } - if (ev.type == SDL_CONTROLLERDEVICEADDED || - ev.type == SDL_CONTROLLERDEVICEREMOVED) { - refresh_player_devices(); - } - } - SDL_GameControllerUpdate(); - } - if (psx_return_to_lobby_requested()) return; - /* Wake on peer UDP (or 1ms timeout). SDL_Delay(1) under dual FMV load - * often stretches multi-ms and cut MotK netplay intro ~59→~36; Delay(0) - * busy-spins and can starve the peer (tick-0 hang). */ - psx_netplay_wait_recv(1); - /* Admit barriers (save/load sync) can last seconds without guest cycles. */ - starvation_watchdog_heartbeat(); - } -} - -static void sample_pad_into_sio(int override) { - if (override >= 0) { - apply_input_override_to_sio(override); - return; - } - const uint32_t ui_flags = psx_host_ui_capture_flags(); - if (ui_flags & (PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_GAMEPAD)) { - /* Poll-based input bypasses SDL event consumption. Publish a fully - * neutral sample (buttons and sticks) while preserving each port's - * current digital/analog presentation mode. */ - for (int s = 0; s < 2; s++) { - PsxNetPad pad{}; - pad.buttons = 0xFFFFu; - pad.lx = pad.ly = pad.rx = pad.ry = 0x80u; - pad.analog = (g_players[s].mode == PSXRecompV4::PAD_MODE_ANALOG || - (g_players[s].mode == PSXRecompV4::PAD_MODE_HYBRID && - g_players[s].hybrid_analog)) ? 1u : 0u; - pad.connected = g_players[s].kind != 0 ? 1u : 0u; - apply_pad_slot_to_sio(s, pad); - } - return; - } - for (int s = 0; s < 2; s++) { - PsxNetPad pad; - if (!capture_pad_slot(s, &pad)) continue; /* no device in this port */ - /* Push sticks every frame; request the pad type (digital/analog) through - * the coherent channel so a hybrid stick<->d-pad flip is applied only at - * an idle, non-config bus boundary (never mid-poll / mid-handshake). This - * is the fix for the v0.5.0 phantom-input regression: slamming the type - * each frame raced Tomba's DualShock config handshake -> garbage button - * reads. eff_analog still reflects this frame's mode (digital / analog / - * hybrid auto-switch). */ - apply_pad_slot_to_sio(s, pad); - } -} - -static void sample_headless_pad_into_sio(int override) { - if (override >= 0) { - apply_input_override_to_sio(override); - return; - } -#ifdef PSX_COSIM - /* Input-driven cosim (EXPERIMENTAL): the coordinator sets a HELD pad state via the - * `setpad` TCP command; the headless sampler applies it every frame so the same - * button input is fed to BOTH lockstep instances at the same guest cycle. If the - * two instances desync under input, this path is proven nondeterministic and gets - * stubbed out (see cosim.c). Default 0xFFFF = all released (PSX pad is active-low). */ - { extern volatile int g_cosim_pad_hold[2]; - sio_set_pad_state_slot(0, (uint16_t)g_cosim_pad_hold[0]); - sio_set_pad_state_slot(1, (uint16_t)g_cosim_pad_hold[1]); - return; } -#endif - sio_set_pad_state_slot(0, 0xFFFFu); - sio_set_pad_state_slot(1, 0xFFFFu); -} - -/* PSX native vblank cadence: NTSC ≈ 59.94 Hz. Wall-clock target keeps - * audio sample generation (735 samples/vblank * 60 = 44100/sec) matched - * to the SDL audio device drain rate, eliminating queue overflow drops - * and underruns. Uncapped, the host runs the simulation at whatever - * speed it can — typically several × realtime — and audio glitches. - * - * The wall-clock pacer target; nudged to the host display refresh at - * window-creation time (sync-to-host-refresh) so the pacer and SDL PRESENTVSYNC - * do not fight — a fixed 59.94 pacer against a 60.00 Hz panel makes rendered - * frames land on an uneven vblank count (a 2/3/1 beat) that reads as - * moving-object judder/flicker. See g_frame_period_ms. */ -/* Live pacer period (ms). Defaults to the PSX rate; set to the host refresh - * period when the panel is within ~2% of 60 Hz so 30fps content pads evenly to - * two host refreshes. Left at the PSX rate on non-~60Hz panels to avoid running - * the sim at the wrong speed. */ - -/* ── Host-stack-usage profile (RECURSION_BUG.md §17) ────────────────────────── - * The decisive instrument for the long-run freeze. The guest call graph mirrors - * onto the 1MB guest fiber stack (traps.c), and the native-stack guard trips at - * ~768KB used. The open question is the SHAPE of host-stack usage over frames: - * - LINEAR climb from gameplay start => a per-frame interp<->compiled boundary - * leak: every game tick leaks one un-unwound host call chain (~13 frames), - * overflowing the fiber after ~50k frames. 50k is a CAPACITY limit, not a - * trigger. (Model A — predicted.) - * - FLAT, then a cliff at one frame => a within-one-frame runaway re-entry at - * a real ~frame-50k trigger. (Model W — the old §15 premise.) - * (TEB StackBase - rsp), sampled on the guest fiber each vblank, IS the leaked - * depth. Sampled in sdl_vblank_present — a gpu_vblank_tick callback off - * psx_check_interrupts, i.e. ON the guest fiber, every vblank, in both builds. - * Decimated 1-per-STRIDE frames so the ring spans the whole run; "now" carries - * the exact latest sample. ALWAYS-ON: read live via the `stack_profile` TCP - * command while the game is still responsive (no need to reach the overflow), - * and dumped in the crash report. Query the ring; never arm-and-capture. */ -#if defined(_WIN32) -#include /* __readgsqword — fiber TEB StackBase */ -static size_t host_stack_used(void) { - char probe; - uintptr_t base = (uintptr_t)__readgsqword(0x08); /* TEB StackBase (high) */ - uintptr_t sp = (uintptr_t)&probe; - return (base > sp) ? (size_t)(base - sp) : 0; -} -#else -static size_t host_stack_used(void) { return 0; } -#endif - -#define STACK_PROFILE_CAP 512u -#define STACK_PROFILE_STRIDE 128u /* 512 * 128 = 65536 frames of coverage */ -typedef struct { uint32_t frame; uint32_t used_kb; } StackSample; -static StackSample g_stack_prof[STACK_PROFILE_CAP]; -static uint32_t g_stack_prof_seq = 0; -static uint32_t g_stack_used_now_kb = 0; -static uint32_t g_stack_frame_now = 0; -static uint32_t g_stack_used_max_kb = 0; - -static void stack_profile_sample(void) { - extern uint64_t s_frame_count; - uint32_t f = (uint32_t)s_frame_count; - uint32_t kb = (uint32_t)(host_stack_used() >> 10); - g_stack_frame_now = f; - g_stack_used_now_kb = kb; - if (kb > g_stack_used_max_kb) g_stack_used_max_kb = kb; - if ((f % STACK_PROFILE_STRIDE) == 0) { - StackSample *e = &g_stack_prof[g_stack_prof_seq++ & (STACK_PROFILE_CAP - 1u)]; - e->frame = f; e->used_kb = kb; - } -} - -/* JSON dump of the profile (callable from C: crash report + TCP command). */ -extern "C" int stack_profile_json(char *out, int cap) { - uint32_t total = g_stack_prof_seq; - uint32_t avail = total < STACK_PROFILE_CAP ? total : STACK_PROFILE_CAP; - uint32_t start = total - avail; - int n = snprintf(out, cap, - "{\"now\":{\"f\":%u,\"kb\":%u},\"max_kb\":%u,\"stride\":%u,\"count\":%u,\"samples\":[", - g_stack_frame_now, g_stack_used_now_kb, g_stack_used_max_kb, - STACK_PROFILE_STRIDE, avail); - for (uint32_t i = 0; i < avail && n < cap - 48; i++) { - StackSample *e = &g_stack_prof[(start + i) & (STACK_PROFILE_CAP - 1u)]; - n += snprintf(out + n, cap - n, "%s{\"f\":%u,\"kb\":%u}", - i ? "," : "", e->frame, e->used_kb); - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -/* ---- Present-classification ring (see present_ring.h) ----------------- - * Always-on record of how each present was classified, for every backend. - * A native-wide present that silently falls back to the canonical width is - * indistinguishable on screen from a deliberate stretch, so the decision - * has to live in a queryable ring rather than be re-derived after the fact. */ -#define PRES_RING_CAP 4096u /* power of two; ~68 s at 60 Hz */ -static PresRingEntry s_pres_ring[PRES_RING_CAP]; -static uint64_t s_pres_ring_total = 0; - -extern "C" uint64_t present_ring_total(void) { return s_pres_ring_total; } -extern "C" int present_ring_get(uint64_t seq, PresRingEntry* out) { - if (seq >= s_pres_ring_total) return 0; - if (s_pres_ring_total - seq > PRES_RING_CAP) return 0; - *out = s_pres_ring[seq & (PRES_RING_CAP - 1u)]; - return 1; -} - -/* Commit this present's classification. Returns the stored entry so the - * software wide-fallback (the only post-decision mutation) can amend it. */ -static PresRingEntry* present_ring_commit(uint8_t path, uint16_t disp_w, - uint16_t disp_h, uint16_t present_w) { - GpuWsDebug ws; - gpu_ws_get_debug(&ws); - PresRingEntry* e = &s_pres_ring[s_pres_ring_total & (PRES_RING_CAP - 1u)]; - s_pres_ring_total++; - e->frame = (uint32_t)ws.cur_frame; - e->disp_w = disp_w; - e->disp_h = disp_h; - e->present_w = present_w; - e->nw_extra = (uint16_t)(ws.nw_extra < 0 ? 0 : ws.nw_extra); - e->path = path; - e->wide_fellback = 0; - e->game_mode = (uint8_t)ws.game_mode; - e->native_43 = (uint8_t)ws.present_native_43; - int64_t d = (int64_t)ws.cur_frame - (int64_t)ws.last_tag_frame; - e->tag_delta = (d > INT32_MAX || d < INT32_MIN) ? INT32_MAX : (int32_t)d; - e->gte_verts = (uint16_t)(ws.gte_verts > 0xFFFF ? 0xFFFF : ws.gte_verts); - e->ovh_prims = (uint16_t)(ws.ovh_prims > 0xFFFF ? 0xFFFF : ws.ovh_prims); - return e; -} - -/* ---- Load-transition ring --------------------------------------------- - * State edges only, rather than one record per frame. This makes the exact - * CD-read -> bridged-load -> turbo -> paced transitions cheap and queryable - * without changing any runtime behavior. */ -#define LOAD_TRANSITION_CAP 512u -static LoadTransitionEntry s_load_transitions[LOAD_TRANSITION_CAP]; -static uint64_t s_load_transition_total = 0; - -extern "C" uint64_t load_transition_total(void) { - return s_load_transition_total; -} -extern "C" int load_transition_get(uint64_t seq, LoadTransitionEntry *out) { - if (seq >= s_load_transition_total) return 0; - if (s_load_transition_total - seq > LOAD_TRANSITION_CAP) return 0; - *out = s_load_transitions[seq & (LOAD_TRANSITION_CAP - 1u)]; - return 1; -} - -static void load_transition_note(int read_active, int load_active, - int turbo_active, int load_run) { - static int prev_read = -1, prev_load = -1, prev_turbo = -1; - if (read_active == prev_read && load_active == prev_load && - turbo_active == prev_turbo) return; - extern uint64_t s_frame_count; - LoadTransitionEntry *e = - &s_load_transitions[s_load_transition_total & (LOAD_TRANSITION_CAP - 1u)]; - s_load_transition_total++; - e->frame = (uint32_t)s_frame_count; - e->host_ms = (uint32_t)SDL_GetTicks(); - e->load_run = (uint16_t)(load_run > 0xFFFF ? 0xFFFF : load_run); - e->read_active = (uint8_t)(read_active != 0); - e->load_active = (uint8_t)(load_active != 0); - e->turbo_active = (uint8_t)(turbo_active != 0); - prev_read = read_active; - prev_load = load_active; - prev_turbo = turbo_active; -} - -/* Depth24 FMV: trailing RGB columns can be stale while CRTC width stays full - * (MotK 512). Present width is never shrunk — cropping the GL/SDL rect left a - * flickering black pillar when upload coverage varied. - * - * Always black-fill the last 8 RGB cols. Also blank beyond the tracked FB A0 - * span when it falls short of the CRTC (movie cut / partial cover). Do NOT - * replicate the last good column — that smeared MotK's starfield into an - * 8-wide streak. */ -static void depth24_fix_trailing_margin(uint32_t *buf, uint32_t w, uint32_t h, - uint32_t display_x) { - if (!buf || w < 8u || h == 0u) return; - uint32_t start = w - 8u; - uint32_t lim = gpu_depth24_rgb_limit(display_x, w); - if (lim > 0u && lim < w && lim < start) - start = lim; - for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = start; x < w; x++) - buf[y * w + x] = 0xFF000000u; - } -} - -static void engage_widescreen(void) { - g_ws_engaged = true; - const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; - int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio - * (identity squash). Squash mode: feed the real wide aspect. */ - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); -} - -/* Engage widescreen at game entry: BIOS boot stays authentic 4:3. */ -static void engage_widescreen_at_game_entry(void) { - extern int fntrace_is_game_started(void); - if (!g_ws_engaged && fntrace_is_game_started()) engage_widescreen(); -} - -/* A state of a started game is no game entry. Told by the game's code in memory, as the entry latch tells it. */ -static void take_loaded_state_for_started(void) { - uint32_t bios = 0, entry = 0; - savestate_get_integrity(&bios, &entry); - if (!dirty_ram_text_image_registered() || !psx_game_text_native_ok(entry)) return; - memory_keep_low_boot_scratch(); /* the latch of a fresh process would clear what the game has written there since */ - if (!g_ws_engaged) engage_widescreen(); /* at the next VBlank the frame the state draws first would be a 4:3 one */ -} - -/* Called from gpu_vblank_tick() at each simulated vblank. */ -static void sdl_vblank_present(void) { - psx_borderless_keep_windowed(); - /* A host UI normally submits its request from the GL render callback near - * the end of this function. Apply on scope exit, after the old-aspect frame - * has been presented, so the next guest frame and its eventual present use - * one coherent aspect. The scope also covers every skipped/early-return - * vblank path. */ - struct PendingAspectBoundary { - ~PendingAspectBoundary() { - apply_pending_display_aspect(); - /* SDL resize events were consumed during this vblank. Commit - * their ratio only after the old-aspect frame has presented so - * projection, packet correction and destination sizing advance - * together for the next guest frame. */ - update_adaptive_widescreen(); - } - } pending_aspect_boundary; -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_set_fmv_quiet(mdec_recently_active(2)); - /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ - debug_server_wait_if_paused(); - debug_server_poll(); - debug_server_record_frame(); - debug_server_check_watchpoints(); - - /* Check debug server input override. */ - int override = debug_server_get_input_override(); -#else - /* Production: skip debug server. Still need to advance frame counter - * locally so anything else that reads it continues to work. */ - extern uint64_t s_frame_count; - s_frame_count++; - int override = -1; -#endif - - runtime_perf_frame_begin(); - RuntimePerfFrameScope runtime_perf_frame_scope; - runtime_perf_diag_tick(); - - /* Lightweight frontend telemetry from PR #13. Count simulated vblanks rather - * than presents so turbo and skipped-frame modes still report game speed. - * Skip during netplay post-load barrier — admit is stalled and the window - * is not updating, so a climbing FPS line is misleading. */ - if (fps_telemetry_enabled() && !psx_netplay_in_load_barrier()) { - extern uint64_t s_frame_count; - const Uint64 now = SDL_GetPerformanceCounter(); - const Uint64 frequency = SDL_GetPerformanceFrequency(); - if (!s_fps_last_time) { - s_fps_last_time = now; - s_fps_last_frame = s_frame_count; - if (sdl_window && s_fps_base_title.empty()) { - const char *title = SDL_GetWindowTitle(sdl_window); - if (title) s_fps_base_title = title; - } - } else if (frequency && now - s_fps_last_time >= frequency) { - const double seconds = (double)(now - s_fps_last_time) / (double)frequency; - const double fps = (double)(s_frame_count - s_fps_last_frame) / seconds; - const double speed = fps / 59.94; - if (!g_headless && sdl_window) { - char title[256]; - snprintf(title, sizeof(title), "%s [%.0f fps %.2fx]", - s_fps_base_title.c_str(), fps, speed); - SDL_SetWindowTitle(sdl_window, title); - } - if (netplay_timing_on() && s_np_timing_frames > 0) { - const double invf = 1000.0 / (double)frequency; - const double admit_ms = - (double)s_np_admit_ticks * invf / (double)s_np_timing_frames; - const double guest_ms = - (double)s_np_guest_ticks * invf / (double)s_np_timing_frames; - std::fprintf(stderr, - "[FPS] game: %.1f fps (%.2fx) | frames: %llu | " - "guest=%.2f ms/f admit=%.2f ms/f (n=%llu)\n", - fps, speed, (unsigned long long)s_frame_count, - guest_ms, admit_ms, (unsigned long long)s_np_timing_frames); - s_np_admit_ticks = 0; - s_np_guest_ticks = 0; - s_np_timing_frames = 0; - } else { - std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu\n", - fps, speed, (unsigned long long)s_frame_count); - } - std::fflush(stderr); - s_fps_last_time = now; - s_fps_last_frame = s_frame_count; - } - } - - /* Host-stack-usage profile sample — frame counter is now current, and we are - * on the guest fiber (see §17 block above). BEFORE the turbo/fast-boot early - * returns so the curve is captured even when presents are skipped. */ - stack_profile_sample(); - - /* Step 2.8 automation ticks — BEFORE the turbo/fast-boot early returns - * below, so capture detection and compile pickup keep running while the - * frontend is skipping presents. Both are cheap and emu-thread-only. */ - { - uint64_t perf_start = runtime_perf_section_begin(); - overlay_autocapture_tick(); - runtime_perf_section_end(perf_start, - &g_runtime_perf.autocapture_ticks); - } - { /* Apply a finished batch compile via the active code provider (gcc: - * cache rescan on done). */ - uint64_t perf_start = runtime_perf_section_begin(); - const CodeProvider *cp = code_provider_active(); - if (cp->poll_main) cp->poll_main(); - runtime_perf_section_end(perf_start, - &g_runtime_perf.provider_poll_ticks); - } - - if (!g_headless) { - /* Pump SDL events to prevent window freeze. */ - SDL_Event ev; - while (SDL_PollEvent(&ev)) { - const int ui_consumed = psx_host_ui_handle_event(&ev); - if (ev.type == SDL_QUIT) { - if (psx_netplay_active()) { - netplay_soft_exit("sdl_window_close"); - return; - } - psx_crash_trace_set_exit_origin("sdl_window_close"); - shutdown_runtime(); - std::exit(0); - } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { - refresh_player_devices(); - } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { -#if defined(PSX_SDL3) - if (ev.gdevice.which == g_players[0].instance || - ev.gdevice.which == g_players[1].instance) { -#else - if (ev.cdevice.which == g_players[0].instance || - ev.cdevice.which == g_players[1].instance) { -#endif - close_controller(); - refresh_player_devices(); - } -#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY - } else if (ev.type == SDL_KEYUP) { -#if defined(PSX_SDL3) - (void)disruptor_name_entry_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1); -#else - (void)disruptor_name_entry_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1); -#endif -#endif - } else if (!ui_consumed && ev.type == SDL_KEYDOWN) { -#if defined(PSX_SDL3) - const SDL_Keymod mod = ev.key.mod; - const SDL_Keycode key = ev.key.key; - const SDL_Scancode scancode = ev.key.scancode; -#else - const Uint16 mod = ev.key.keysym.mod; - const SDL_Keycode key = ev.key.keysym.sym; - const SDL_Scancode scancode = ev.key.keysym.scancode; -#endif - if (key == SDLK_ESCAPE && psx_netplay_active()) { - netplay_soft_exit("netplay_escape"); - return; - } -#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY - if (disruptor_name_entry_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI)))) - continue; /* the key is a letter of a save's name */ -#else - (void)scancode; -#endif - /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. - * (F11 is a save slot per the user's spec, so fullscreen is - * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ - if (key >= SDLK_F1 && key <= SDLK_F12) { - int slot = (int)(key - SDLK_F1); /* 0..11 */ - if (psx_netplay_active()) { - /* Match host only — guest F-keys must not initiate. */ - if (!psx_netplay_is_host()) { - /* ignore */ - } else if (mod & KMOD_SHIFT) { - (void)psx_netplay_request_save(slot); - } else { - (void)psx_netplay_request_load(slot); - } - } else if (mod & KMOD_SHIFT) { - savestate_request_save(slot); - } else { - savestate_request_load(slot); - } - } - else if (key == SDLK_c && (mod & KMOD_CTRL)) { - std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); - debug_force_cd_reinsert(); - } - /* Fullscreen toggle: Alt+Enter or Cmd/Ctrl+F. Toggles between - * windowed and the CONFIGURED tri-state mode (g_fullscreen: 1 - * borderless desktop fullscreen keeping the desktop resolution - * and letterboxing the image, or 2 exclusive fullscreen — a - * real display-mode change). SDL_WINDOW_FULLSCREEN's bit is - * set in both SDL_WINDOW_FULLSCREEN and - * SDL_WINDOW_FULLSCREEN_DESKTOP, so testing just that bit - * detects "currently fullscreen, either mode". */ - else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || - (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { - const int current = - psx_window_fullscreen_mode(sdl_window); - /* If the configured mode is "off", the hotkey still - * needs a mode to switch INTO — default to borderless, - * matching the historical (pre-tri-state) behaviour. - * Deliberately do not update g_fullscreen here: hotkey - * state is temporary and is not an implicit preference. */ - const int target = current ? 0 : - (g_fullscreen ? g_fullscreen : 1); - if (!psx_apply_window_fullscreen_mode( - sdl_window, target)) { - std::fprintf(stderr, - "psxrecomp: fullscreen hotkey failed: %s\n", - SDL_GetError()); - } - } - } - } - } - - if (g_gl_active) { - const uint32_t ui_flags = psx_host_ui_capture_flags(); - /* Visible UI must stay on the main GL context even if a provider omits - * the explicit suspension flag. The interpolation thread never calls - * the host render hook. */ - gl_renderer_set_host_ui_suspended( - (ui_flags & (PSX_HOST_UI_VISIBLE | - PSX_HOST_UI_SUSPEND_INTERPOLATION)) != 0); - } - - /* Sample each player's device and feed the matching SIO pad slot. - * Debug server input override (when active) drives port 1 only. With - * g_low_latency_input this early sample is re-done after the pacer wait - * (below) for the interactive present path; it still covers the turbo / - * FMV-skip paths that early-return before pacing. - * - * Delay-sync netplay host FPS (MotK FMV): finish → present → admit/pace. - * Local Swap overlaps the peer's guest quantum; admit still runs every - * tick via the RAII tail (including early returns that skip present). - * Offline keeps pace-before-present. Barrier wait stays UDP poll — do - * not pair this order with SDL_Delay(0) busy-spin (tick-0 hang). */ - struct NetplayVblankTail { - int override_ = -1; - bool active_ = false; - bool skip_pace_ = false; - explicit NetplayVblankTail(int ov) - : override_(ov), active_(psx_netplay_active() != 0) {} - void skip_pace() { skip_pace_ = true; } - ~NetplayVblankTail() { - if (!active_) return; - if (psx_return_to_lobby_requested()) return; - netplay_barrier_admit(override_); - if (skip_pace_ || psx_return_to_lobby_requested()) return; - uint64_t perf_start = runtime_perf_section_begin(); - if (g_frame_period_ms > 0.0) - frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); - runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); - latency_ring_mark(LAT_PACED); - } - } netplay_tail(override); - -#ifdef PSX_HAS_DISRUPTOR_CAPSULE - if (!psx_netplay_active()) disruptor_capsule_before_input(); -#endif - if (psx_netplay_active()) { - psx_netplay_finish_frame(); - } else if (g_headless) { - sample_headless_pad_into_sio(override); - } else { - sample_pad_into_sio(override); - } -#ifdef PSX_HAS_DISRUPTOR_CAPSULE - if (!psx_netplay_active()) disruptor_capsule_after_input(); -#endif - - /* Latency ring: open this present cycle's slot, stamping when input was - * sampled into SIO. Always-on; queried via the debug server "latency". */ - latency_ring_frame_begin(); - - /* Turbo-active test shared by the pacing/present gate below. */ - int turbo_loads_active = 0; - extern int fntrace_is_game_started(void); - int logical_load_active = fntrace_is_game_started() && cdrom_load_in_progress(); - int load_run_value = 0; - static int load_run = 0; - static int release_run = 0; - if (g_turbo_loads_enabled && !psx_netplay_active()) { - /* Require a short sustained predicate on entry, then retain turbo over - * a short false gap after it has engaged. Netplay keeps wall pacing. */ - if (logical_load_active) { - if (load_run < (1 << 20)) load_run++; - if (load_run >= TURBO_LOADS_ENGAGE_FRAMES || release_run > 0) { - turbo_loads_active = 1; - release_run = g_turbo_load_release_frames; - } - } else { - load_run = 0; - if (release_run > 0) { - turbo_loads_active = 1; - release_run--; - } - } - load_run_value = load_run; - } else { - load_run = 0; - release_run = 0; - } - /* HLE boot-skip window: from reset until the game entry PC first - * dispatches, run unpaced so the (shell-skipped) BIOS kernel init + - * SYSTEM.CNF + game EXE load compress to host speed. This replaces the - * old fast_boot snapshot restore; all guest timing is authentic. */ - if (psx_bios_hle_boot_turbo_active()) - turbo_loads_active = 1; - - load_transition_note(cdrom_data_read_active(), logical_load_active, - turbo_loads_active, load_run_value); - - /* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC - * video together. Detect "MDEC produced a frame since the last vblank AND XA - * is streaming"; a short hold rides out brief inter-frame gaps. The present/ - * pacing safety-net + audio mute below hide the one or two transition frames. - * - * End the movie the GAME's own way, chosen per game: - * - fmv_skip_total_table set (Tomba): write the CURRENT movie's per-movie - * frame-total down to fmv_skip_end_total, so the player's MDEC loop - * (FUN_8001efe8: teardown when streamed frame# >= total - 3) terminates on - * its next frame — a NATURAL end that reaches EVERY movie, including ones - * whose caller never polls the skip button. Only the active movie's table - * entry is touched (via the movie-id byte), so nothing else is disturbed. - * - no table configured (generic): hold START so a movie whose handler polls - * the pad aborts itself (can't reach unskippable movies). */ - int fmv_skip_active = 0; - if (g_auto_skip_fmv) { - uint32_t mc = mdec_get_decode_count(); - int mdec_decoding = (mc != s_fmv_skip_last_mdec); - s_fmv_skip_last_mdec = mc; - int xa = cdrom_xa_stream_active(); - /* fmv_skip_no_xa: silent RAM-preloaded movies (no XA stream) never - * satisfy the MDEC+XA detector; with the per-game opt-in, MDEC - * activity alone qualifies. */ - int detected = mdec_decoding && (xa || g_fmv_skip_no_xa); - if (detected) s_fmv_skip_hold = (!xa && g_fmv_skip_no_xa) - ? g_fmv_skip_no_xa_hold : 4; - else if (s_fmv_skip_hold > 0) s_fmv_skip_hold--; - fmv_skip_active = (s_fmv_skip_hold > 0) && (xa || g_fmv_skip_no_xa); - if (fmv_skip_active) { - if (g_fmv_skip_total_table) { - /* End the active movie via its own frame-count teardown. */ - uint8_t mid = psx_read_byte(g_fmv_skip_movie_id); - psx_write_half(g_fmv_skip_total_table + (uint32_t)mid * 2u, - (uint16_t)g_fmv_skip_end_total); - } else { - /* Generic fallback: hold START (PSX pad word is active-low; START - * is bit 3) so the game's FMV handler aborts the movie itself. */ - sio_set_pad_state_slot(0, (uint16_t)~(1u << 3)); - } - } - } - - const bool module_fast_forward = g_fast_forward_query && - !psx_netplay_active() && g_fast_forward_query() != 0; - -#ifndef PSX_SDL_NO_AUDIO - /* Optional turbo host sink advances the canonical SPU on the exact guest - * sample budget but drops accelerated output before SDL. This is distinct - * from the old mute/freeze model: voice and CD state never pause. FMV skip - * retains the hard mute because it deliberately fast-forwards a movie. */ - sdl_audio_update(fmv_skip_active, - module_fast_forward || - (turbo_loads_active && g_turbo_audio_sink_enabled)); -#endif - - if (g_headless) engage_widescreen_at_game_entry(); /* a run without a window is the same game */ - if (g_headless || module_fast_forward) { - netplay_tail.skip_pace(); - return; - } - - /* TCP turbo is for automated validation and trace capture. It keeps the - * simulation advancing and the debug server polling, but removes frontend - * presentation and wall-clock pacing. */ -#ifndef PSX_NO_DEBUG_TOOLS - if (debug_server_turbo_enabled()) { - netplay_tail.skip_pace(); - return; - } -#endif - - /* Turbo mode: while ] is held, skip both VRAM->ARGB conversion and - * SDL_RenderPresent. The recompiled BIOS still advances simulated - * cycles every vblank, so the BIOS proceeds at whatever rate the host - * CPU sustains without graphics-driver vsync overhead. Present once - * every TURBO_PRESENT_EVERY frames so the user sees visual progress. */ - { - const Uint8* keys = SDL_GetKeyboardState(NULL); - static int turbo_skip = 0; - const int TURBO_PRESENT_EVERY = 30; - if (!(psx_host_ui_capture_flags() & PSX_HOST_UI_CAPTURE_KEYBOARD) && - keys[SDL_SCANCODE_RIGHTBRACKET]) { - turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; - if (turbo_skip != 0) { - netplay_tail.skip_pace(); - return; /* skip render this frame */ - } - } else { - turbo_skip = 0; - } - } - - /* Turbo-through-loads (step 4, OPT-IN via game.toml [runtime] - * turbo_loads): while the game is loading — CD data stream active, - * XA/FMV excluded, post-BIOS-handoff only — skip wall-clock pacing and - * most presents so the guest runs at host speed. Step-3 measurement - * showed loads are paced by the game's own per-sector processing at - * real time (2.2-4.8 sectors/frame against a 32-256 IRQ budget), so - * host-speed execution is the lever that compresses load wall-time. - * Presents 1-in-30 so visual progress stays visible. */ - bool turbo_load_paced = false; - if (turbo_loads_active) { - g_turbo_loads_frames++; - /* A mod may request a bounded wall-clock multiplier instead of the - * legacy unpaced host-speed path. Pace every simulated guest frame at - * the divided period; the presentation skip below remains independent - * so rendering cost does not distort the selected multiplier. */ - if (g_turbo_load_wall_multiplier >= 2 && - g_frame_period_ms > 0.0) { - uint64_t perf_start = runtime_perf_section_begin(); - frame_pacer_wait( - &s_frame_pacer, - g_frame_period_ms / (double)g_turbo_load_wall_multiplier); - runtime_perf_section_end( - perf_start, &g_runtime_perf.pacer_ticks); - latency_ring_mark(LAT_PACED); - turbo_load_paced = true; - } - const int TL_PRESENT_EVERY = 30; - s_turbo_present_skip = (s_turbo_present_skip + 1) % TL_PRESENT_EVERY; - if (s_turbo_present_skip != 0) { - netplay_tail.skip_pace(); - return; - } - } - - /* FMV auto-skip: run uncapped (no wall-clock pacing) and suppress nearly all - * presents so the fast-forwarded movie is invisible. Present 1-in-30 only so - * the window keeps pumping and never looks hung during the brief skip. */ - if (fmv_skip_active) { - const int FMV_PRESENT_EVERY = 30; - s_fmv_skip_present_skip = (s_fmv_skip_present_skip + 1) % FMV_PRESENT_EVERY; - if (s_fmv_skip_present_skip != 0) { - netplay_tail.skip_pace(); - return; - } - } - - /* Netplay FMV: skip present every other depth24 vblank (admit still runs - * in the RAII tail). Present-first frame, then alternate. */ - if (psx_netplay_active() && gpu_display_is_depth24()) { - if (s_netplay_depth24_present_skip) { - s_netplay_depth24_present_skip = 0; - netplay_tail.skip_pace(); - return; - } - s_netplay_depth24_present_skip = 1; - } else { - s_netplay_depth24_present_skip = 0; - } - - /* Offline wall-clock pacing before present. Netplay paces in NetplayVblankTail - * AFTER present so Swap overlaps the peer's guest quantum. */ - if (!psx_netplay_active()) { - uint64_t perf_start = runtime_perf_section_begin(); - if (!turbo_load_paced && g_frame_period_ms > 0.0) - frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); - runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); - latency_ring_mark(LAT_PACED); - - /* Low-latency input: the early sample above is now ~one pacer-wait old. - * Refresh the device state and re-sample right before present so the next - * CPU frame reads near-fresh input (the dominant input->photon cost on a - * vsync-light box). Re-stamp the ring's input mark to measure from here. */ - if (g_low_latency_input) { - SDL_GameControllerUpdate(); /* refresh pad state after the wait */ - SDL_PumpEvents(); /* refresh keyboard state */ - sample_pad_into_sio(override); - latency_ring_restamp_input(); - } - } - - /* Mod hooks. Run after all normal input sampling. */ - mod_call_frame_hooks(); - - /* Depth24 GP1(07h) retarget (MotK intro→crawl): keep the prior Swap for a - * few vblanks so stale trailing VRAM never flashes on the right edge. */ - if (gpu_depth24_present_hold_tick()) - return; - - engage_widescreen_at_game_entry(); - - /* ---- Display from our VRAM ---- */ - uint32_t w = 0, h = 0; - uint32_t present_w = 0; /* display width actually presented (w + native-wide EXTRA) */ - int active_scale = 1; /* hi-res mirror used only for 15-bit display */ - bool fmv_frame = false; /* FMV/boot — present pillarboxed 4:3 in widescreen */ - bool pin_43 = false; /* pillarbox this present (FMV, or a native-wide - game frame that could not present wide) */ - bool depth24_frame = false; - if (s_force_present_after_load && g_gl_active) - gl_renderer_flush_cpu_uploads(); - { - GpuDisplayInfo di; - gpu_get_display_info(&di); - depth24_frame = di.depth24 != 0; - if (di.disabled || di.width == 0 || di.height == 0) { - smooth_60_present(nullptr, 0, 0, false); - present_ring_commit(PRES_PATH_BLANK, (uint16_t)di.width, - (uint16_t)di.height, 0); -#ifndef PSX_SDL_NO_RENDER - /* After savestate load, always restage once even if we blanked - * earlier in the session — otherwise the window keeps a stale - * pre-load frame while vblanks (and FPS) keep advancing. */ - if (!s_disabled_frame_presented || s_force_present_after_load || - psx_host_ui_gl_visible()) { - s_disabled_frame_presented = true; - s_force_present_after_load = false; - if (g_gl_active) { - gl_renderer_present_blank(); - } else if (g_vk_active) { - vk_renderer_present_blank(); - } else { - SDL_SetRenderDrawColor(sdl_renderer, 0, 0, 0, 255); - SDL_RenderClear(sdl_renderer); - SDL_RenderPresent(sdl_renderer); - } - } -#endif - return; - } - s_disabled_frame_presented = false; - s_force_present_after_load = false; - w = di.width; h = di.height; - /* 4:3-pinned frames: the pre-game BIOS boot, plus (once engaged) every - * frame the widescreen layer presents native — FMV video and full-2D - * menu/title screens. gpu_ws_present_native_43() is the single source - * of truth, shared with the GTE/GPU squash so content and present stay - * locked: we squash IFF we stretch. depth24 always classifies as FMV - * even if the ws layer is not engaged yet (4:3 titles). */ - fmv_frame = di.depth24 || !g_ws_engaged || gpu_ws_displayed_native_43() != 0; - /* MDEC movies are already decoded at their authored cadence and are - * CPU/upload heavy. High-refresh crossfades only contend with decoding - * and can starve audio, so present native-4:3/MDEC phases directly. - * The classification catches the transition frame before the first - * decode; the activity stamp also covers authentic 4:3 configurations. */ - if (g_gl_active) - gl_renderer_set_interpolation_suspended( - fmv_frame || mdec_recently_active(2) || gpu_ws_displayed_menu() != 0); - - /* Canonical present width. Native-wide does NOT widen the canonical read - * (that bled across adjacent framebuffers); it composites into a separate - * wide surface and presents from there instead (see gpu wide compositor). */ - present_w = w; - - /* A native-wide frame and the same-width high-precision experiment both - * present from an independent compositor surface. Native-wide adds - * reveal columns; classic squash mode keeps `present_w == w` and uses - * the second surface solely to isolate visual correction from VRAM. */ - const bool corrected_present = - (!fmv_frame && !di.depth24 && g_ws_engaged && - gpu_geometry_correction_enabled() && gr_wide_supported()); - bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && - gr_wide_supported() && !gpu_ws_displayed_flat() && - (ws_native_wide_active() || corrected_present)); - if (wide_present && ws_native_wide_active()) - present_w = w + (uint32_t)ws_nw_extra(); - - /* Native-wide invariant: canonical (320-wide) content is NEVER - * stretched across the wide window — a game frame that cannot present - * wide (compositor unsupported, or the surface fallback below) - * pillarboxes 4:3 like FMV/menus instead. Only squash mode (1) may - * stretch: its canonical content is pre-squashed FOR the stretch. */ - const bool nw_pin = g_ws_engaged && g_ws_native_wide; - - /* Ring the classification now that it's final (only the software/CPU - * wide path below can still fall back — it amends this entry). A - * CANONICAL game frame on a widescreen window IS the stretch. */ - PresRingEntry* pres_entry = present_ring_commit( - fmv_frame ? PRES_PATH_NATIVE_43 - : (wide_present ? PRES_PATH_WIDE : PRES_PATH_CANONICAL), - (uint16_t)w, (uint16_t)h, (uint16_t)present_w); - - /* OpenGL: 15-bit frames ALWAYS present straight from the authoritative - * VRAM FBO — one deterministic path (the old per-frame FBO-vs-CPU - * alternation caused the menu seam/jitter). 24-bit (FMV) frames and - * the PSX_GL_FORCE_CPU_PRESENT diagnostic use the CPU readout below. - * depth24: do NOT sync_cpu (FBO readback can clobber packed RGB888) and - * do NOT flush_cpu_uploads (MDEC already wrote the CPU mirror; forcing - * FBO uploads every frame cut MotK intro from ~50 to ~30 FPS). */ -#ifndef PSX_SDL_NO_RENDER - if (g_gl_active && g_gl_fbo_present && !di.depth24) { - if (wide_present) { - /* GPU-direct native-wide present: blit the displayed buffer's - * wide FBO straight to the window (GPU-side, like the canonical - * present_vram). This avoids the glFinish + glReadPixels CPU - * round-trip the old path did EVERY frame — that readback was the - * GL-only slowdown (SW's wide path is pure CPU, no GPU sync, so - * SW stayed smooth). Falls through to the CPU readout path only if - * the wide surface for this buffer doesn't exist yet. */ - if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) - return; - /* A correction/native-wide surface can be unavailable for its - * first frame (allocation failure, an as-yet unknown display - * base, or a live mode transition). The GL renderer's CPU - * mirror is intentionally native-scale even when the FBO uses - * SSAA, so falling through to the generic CPU-hires path would - * upload a 1x image as though it were Nx and show a tiny box. - * Present the authoritative canonical FBO for this frame; the - * compositor can retry on the next one. */ - pres_entry->path = PRES_PATH_CANONICAL; - pres_entry->wide_fellback = 1; - pres_entry->present_w = (uint16_t)w; - gl_renderer_present_vram((int)di.display_x, (int)di.display_y, - (int)w, (int)h, - g_video_aa ? 1 : 0, - nw_pin ? 1 : 0); - return; - } else { - gl_renderer_present_vram((int)di.display_x, (int)di.display_y, - (int)present_w, (int)h, g_video_aa ? 1 : 0, - (fmv_frame || nw_pin) ? 1 : 0); - return; - } - } - if (g_gl_active && !di.depth24) gl_renderer_sync_cpu(); - /* Vulkan owns every frame: 15-bit frames present straight from the GPU - * VRAM image (deterministic blit, no readback), mirroring the GL path; - * 24-bit (FMV) frames go through the CPU present (Phase 3). The Vulkan - * window has no SDL_Renderer, so we must never fall through below. */ - if (g_vk_active) { - if (di.depth24) { - /* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT - * sync_cpu (FBO readback clobbers RGB888). */ - for (uint32_t y = 0; y < h; y++) - for (uint32_t x = 0; x < present_w; x++) - sdl_pixel_buf[y * present_w + x] = - gpu_display_pixel_argb(&di, x, y); - /* Trailing margin: blank junk cols inside the full-width - * buffer — never shrink present width. */ - depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, - di.display_x); - vk_renderer_present_cpu(sdl_pixel_buf, (int)present_w, (int)h, - 0 /* nearest */, fmv_frame ? 1 : 0); - } else if (wide_present && - vk_renderer_present_wide((int)di.display_x, (int)di.display_y, - (int)h, g_video_aa ? 1 : 0)) { - /* presented wide */ - } else { - vk_renderer_present_vram((int)di.display_x, (int)di.display_y, - (int)present_w, (int)h, g_video_aa ? 1 : 0, - (fmv_frame || nw_pin) ? 1 : 0); - } - return; - } -#endif - - /* The hi-res mirror is a 15-bit copy of VRAM; 24-bit display (FMV) - * reads packed bytes the mirror can't represent, so fall back to the - * native path for those frames (the present filter still upscales). */ - active_scale = (g_video_scale > 1 && !di.depth24) ? g_video_scale : 1; - - if (wide_present) { - /* The wide compositor surface is at the renderer's REAL internal - * scale, which gr_render_wide_display uses for its readback pitch. - * Use gr_scale() (== that internal scale) rather than g_video_scale: - * on the GL backend g_video_scale can be stale (1) while the renderer - * supersamples at 2, and a mismatch makes the present read the wide - * buffer at the wrong width (a magnified top-left slice). gr_scale() - * is guaranteed to match the readback. Falls back to the canonical - * hires path if the displayed buffer has no surface yet. */ - int s = gr_scale(); - int sw = (int)present_w * s; - int n = gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - (int)di.display_x, (int)di.display_y, (int)h); - if (n > 0) { - active_scale = s; - } else { - wide_present = false; - present_w = w; - pres_entry->path = PRES_PATH_CANONICAL; - pres_entry->wide_fellback = 1; - pres_entry->present_w = (uint16_t)present_w; - } - } - /* depth24 (MotK crawl is 128 lines): pin short bands for letterbox even - * when fmv_frame is false on a plain 4:3 window (ws layer not engaged). */ - pin_43 = fmv_frame || di.depth24 || (nw_pin && !wide_present); - if (!wide_present) { - if (active_scale > 1) { - int sw = (int)present_w * active_scale; - gr_render_display_hires(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - (int)di.display_x, (int)di.display_y, - (int)present_w, (int)h); - } else { - for (uint32_t y = 0; y < h; y++) { - for (uint32_t x = 0; x < present_w; x++) { - sdl_pixel_buf[y * present_w + x] = gpu_display_pixel_argb(&di, x, y); - } - } - } - } - - /* Depth24 trailing margin: MotK CRTC is 512 RGB but trailing cols - * can be stale. Fix pixels in-place at full present_w — never crop the - * GL/SDL draw width (that caused a flickering black pillar). */ - if (di.depth24 && active_scale == 1 && !wide_present) - depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, - di.display_x); - - smooth_60_present(sdl_pixel_buf, - present_w * (uint32_t)active_scale, - h * (uint32_t)active_scale, - !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - /* Frame blending (CRT-persistence masker for 30fps double-buffered - * content). Some games (e.g. Crash Bash menus/characters) leave a - * dynamic object in only one of the two display buffers per 30fps cycle, - * so it strobes on/off as the display alternates buffers — the static - * background, present in both, stays put. Real CRTs hid this via phosphor - * persistence; accurate emulators either time it away (cycle accuracy) or - * offer a "blend frames" option that averages the last two presented - * frames. This is the latter: presentation only, game logic untouched. - * Guarded to the plain software present path (no GL/VK/wide/hires/FMV) so - * the buffer is a simple present_w*h ARGB grid. PSX_FRAME_BLEND=0 off. */ - { - static int blend_cfg = -1; /* -1 unresolved, 0 off, 1 on */ - if (blend_cfg < 0) { - const char* e = getenv("PSX_FRAME_BLEND"); - blend_cfg = (e && e[0] == '1') ? 1 : 0; /* default off (masker only) */ - } - const bool simple_sw = !g_gl_active && !g_vk_active && !wide_present && - !di.depth24 && active_scale == 1; - if (blend_cfg && simple_sw && - !g_smooth_60fps.load(std::memory_order_acquire)) { - static uint32_t prev_buf[640 * 512]; - static uint32_t prev_px = 0; - const uint32_t npx = present_w * h; - if (npx <= (uint32_t)(640 * 512)) { - if (prev_px == npx) { - for (uint32_t i = 0; i < npx; i++) { - const uint32_t a = sdl_pixel_buf[i]; - const uint32_t b = prev_buf[i]; - prev_buf[i] = a; /* store this frame (pre-blend) */ - sdl_pixel_buf[i] = - 0xFF000000u | - (((a & 0xFEFEFEu) >> 1) + ((b & 0xFEFEFEu) >> 1)); - } - } else { - /* Resolution changed / first frame: seed, no blend. */ - for (uint32_t i = 0; i < npx; i++) prev_buf[i] = sdl_pixel_buf[i]; - prev_px = npx; - } - } - } - } -#endif - } - - /* Update only the active display rectangle. The backing texture is sized - * 640x512 (times the supersampling factor), while games can switch to - * smaller modes such as 320x224 for FMV; presenting the full texture would - * leave the active image stuck in the upper-left portion of the window. */ -#ifndef PSX_SDL_NO_RENDER - int src_w = (int)present_w * active_scale; - int src_h = (int)h * active_scale; - if (g_gl_active) { - /* OpenGL present: upload the active display rect and draw a full-screen - * quad. SDL_GL_SwapWindow handles vsync; the wall-clock pacer above - * still owns timing. 24-bit (FMV) frames pin to native 4:3. */ - /* FMV: nearest present — linear filtering fringes the right edge of - * low-res 24-bit scanouts into adjacent (often garbage) texels. */ - gl_renderer_present(sdl_pixel_buf, src_w, src_h, - (g_video_aa && !depth24_frame) ? 1 : 0, - pin_43 ? 1 : 0, 0 /* full width */); - } else { - SDL_Rect src = { 0, 0, src_w, src_h }; - SDL_UpdateTexture(sdl_texture, &src, sdl_pixel_buf, - (int)(src_w * sizeof(uint32_t))); - - /* FMV (24-bit) frames are authored 4:3 with no GTE squash to compensate - * the widescreen stretch — pillarbox them at native 4:3 instead. Same for - * native-wide game frames that could not present wide (pin_43): canonical - * content is never stretched across the wide window. */ - int dst_w = pin_43 ? 640 * g_video_scale : g_logical_w; - int dst_h = 480 * g_video_scale; - SDL_Rect dst = { (g_logical_w - dst_w) / 2, 0, dst_w, dst_h }; - /* Match GL: short display bands letterbox inside the 4:3 rect — FMV only - * (depth24), and only genuinely windowed bands (<80% of the field; a - * native short display mode like 216/224 fills the rect). */ - if (pin_43 && depth24_frame && h > 0 && h < 192) { - int content_h = (dst_h * (int)h) / 240; - if (content_h < 1) content_h = 1; - dst.y = (dst_h - content_h) / 2; - dst.h = content_h; - } - SDL_RenderClear(sdl_renderer); - SDL_RenderCopy(sdl_renderer, sdl_texture, &src, &dst); - - /* Vsync self-heal. The renderer is created with PRESENTVSYNC for - * tear-free output, but the wall-clock pacer above already holds - * 59.94 Hz, so driver vsync is redundant for timing. Under some - * driver states (observed: NVIDIA GL with the swap queue wedged) - * SwapBuffers blocks ~1.5 s per present, dragging the whole - * emulation to ~0.7 fps for minutes (freeze dump 1781045865: - * 8/8 main-thread samples inside wglSwapBuffers). If presents - * block pathologically several times in a row, drop driver vsync - * for the rest of the session; our own pacing keeps the rate. */ - { - latency_ring_mark(LAT_SWAP_BEGIN); - const Uint64 t0 = SDL_GetPerformanceCounter(); - SDL_RenderPresent(sdl_renderer); - const Uint64 t1 = SDL_GetPerformanceCounter(); - latency_ring_mark(LAT_SWAP_END); - const Uint64 freq = SDL_GetPerformanceFrequency(); - const Uint64 present_ms = (t1 >= t0 && freq) ? ((t1 - t0) * 1000u) / freq : 0; - if (!g_present_vsync_disabled && present_ms > 250) { - g_present_slow_count++; - if (g_present_slow_count >= 3 && - disruptor_sdl_render_set_vsync_compat(sdl_renderer, 0) == 0) { - g_present_vsync_disabled = 1; - } - } - } - } -#endif -} - -#if defined(RECOMP_LAUNCHER) -// Host verification/inspection callbacks for the shared recomp-ui launcher. -// The launcher re-runs these on every disc/memory-card change so the "Disc -// verified" verdict + memcard block grids reflect the real images. The expected -// serial/CRC are passed via these file-scope statics (set just before -// recomp_launcher_run_window) because the C-ABI callback can't capture locals. -namespace { - std::string g_lnch_expected_serial; - uint32_t g_lnch_expected_crc = 0; - bool g_lnch_has_crc = false; - - int ae_disc_verify(const char* disc_path, RecompLauncherCDiscVerify* out) { - if (!disc_path || !disc_path[0] || !out) return 0; - PSXRecompV4::DiscIdentity id = PSXRecompV4::identify_disc( - disc_path, g_lnch_expected_serial, g_lnch_expected_crc, - g_lnch_has_crc, /*compute_crc*/ g_lnch_has_crc); - const std::string& serial = !id.detected_serial.empty() - ? id.detected_serial : g_lnch_expected_serial; - std::snprintf(out->serial, sizeof(out->serial), "%s", serial.c_str()); - std::snprintf(out->region, sizeof(out->region), "%s", id.region.c_str()); - out->iso_ok = id.has_header ? 1 : 0; - // Verdict shown by the launcher. - if (!id.opened || !id.has_header) out->verdict = 3; // bad - else if (id.expected_serial_given && !id.serial_matches) out->verdict = 3; // wrong disc - else if (id.expected_crc_given && id.crc_computed && !id.crc_matches) out->verdict = 2; // warn - else out->verdict = 1; // ok - return 1; - } - - int ae_memcard_inspect(const char* card_path, RecompLauncherCMemcard* out) { - if (!card_path || !card_path[0] || !out) return 0; - MemcardSummary s; - if (memcard_summary_path(card_path, &s) != 0) return 0; - out->valid = s.valid; - out->used_blocks = s.used_blocks; - for (int i = 0; i < 15; ++i) out->block_used[i] = s.block_used[i]; - return 1; - } - - std::string g_lnch_netplay_game_name; - std::filesystem::path g_lnch_settings_path; - std::string g_lnch_lobby_url; - RecompLauncherCNetplayLaunch g_lnch_pending_direct_launch{}; - bool g_lnch_hosting_lan = false; - bool g_lnch_joined_lan = false; - std::string g_lnch_lan_endpoint; - std::string g_lnch_lan_guest_bind; - - struct AeLanLobbyState { - std::string name; - std::string game; - std::string endpoint; - std::string host_name; - std::string joiner_name; - std::string password; - bool started = false; - int host_slot = 0; - }; - - std::filesystem::path ae_np_lan_file() { - return std::filesystem::current_path() / "netplay_lan_lobby.txt"; - } - - bool ae_np_read_lan_state(AeLanLobbyState* state) { - if (!state) return false; - std::ifstream f(ae_np_lan_file()); - if (!f) return false; - std::string started, host_slot; - std::getline(f, state->name); - std::getline(f, state->game); - std::getline(f, state->endpoint); - std::getline(f, state->host_name); - std::getline(f, state->joiner_name); - std::getline(f, started); - std::getline(f, host_slot); - std::getline(f, state->password); - state->started = started == "1"; - state->host_slot = host_slot == "1" ? 1 : 0; - return !state->endpoint.empty(); - } - - bool ae_np_write_lan_state(const AeLanLobbyState& state) { - std::ofstream f(ae_np_lan_file(), std::ios::trunc); - if (!f) return false; - f << state.name << "\n" - << state.game << "\n" - << state.endpoint << "\n" - << state.host_name << "\n" - << state.joiner_name << "\n" - << (state.started ? "1" : "0") << "\n" - << state.host_slot << "\n" - << state.password << "\n"; - return (bool)f; - } - - void ae_np_write_lan_lobby(const char* name, const char* endpoint, - const char* password) { - AeLanLobbyState state; - state.name = name && name[0] ? name : "LAN Lobby"; - state.game = g_lnch_netplay_game_name.empty() ? "PSX" : g_lnch_netplay_game_name; - state.endpoint = endpoint && endpoint[0] ? endpoint : "127.0.0.1:7777"; - state.host_name = psx_lobby_display_name(); - if (state.host_name.empty()) state.host_name = "Host"; - state.password = password ? password : ""; - ae_np_write_lan_state(state); - g_lnch_hosting_lan = true; - g_lnch_joined_lan = false; - g_lnch_lan_endpoint = state.endpoint; - g_lnch_lan_guest_bind.clear(); - } - - int ae_np_read_lan_lobby(RecompLauncherCNetplayLobby* out) { - if (!out) return 0; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return 0; - std::snprintf(out->lobby_id, sizeof(out->lobby_id), "lan:%s", state.endpoint.c_str()); - std::snprintf(out->name, sizeof(out->name), "LAN - %s", - state.name.empty() ? "Lobby" : state.name.c_str()); - std::snprintf(out->game_name, sizeof(out->game_name), "%s", - state.game.empty() ? "PSX" : state.game.c_str()); - out->player_count = state.joiner_name.empty() ? 1 : 2; - out->max_slots = 2; - out->has_password = state.password.empty() ? 0 : 1; - return 1; - } - - PsxLobbyMatchCaps ae_netplay_caps_from_settings(const RecompLauncherCSettings* s) { - PsxLobbyMatchCaps caps{}; - caps.valid = 1; - switch (s ? s->aspect_index : 0) { - case 2: caps.aspect_num = 21; caps.aspect_den = 9; break; - case 1: caps.aspect_num = 16; caps.aspect_den = 9; break; - default: caps.aspect_num = 4; caps.aspect_den = 3; break; - } - caps.turbo_loads = s ? (s->turbo_loads != 0) : 0; - caps.auto_skip_fmv = s ? (s->auto_skip_fmv != 0) : 0; - caps.input_delay = 2; - return caps; - } - - const char* ae_np_default_url(void*) { - return g_lnch_lobby_url.empty() ? psx_lobby_default_url() : g_lnch_lobby_url.c_str(); - } - - void ae_np_save_identity(const char* player_name, const char* lobby_url) { - if (g_lnch_settings_path.empty()) return; - PSXRecompV4::UserSettings settings = - PSXRecompV4::load_user_settings(g_lnch_settings_path); - if (settings.parse_error) return; - if (player_name && player_name[0]) { - settings.netplay_player_name = player_name; - settings.has_netplay_player_name = true; - } - if (lobby_url && lobby_url[0]) { - settings.netplay_lobby_url = lobby_url; - settings.has_netplay_lobby_url = true; - } - PSXRecompV4::save_user_settings(g_lnch_settings_path, settings); - } - - void ae_np_set_lobby_url(void*, const char* url) { - g_lnch_lobby_url = url && url[0] ? url : psx_lobby_default_url(); - ae_np_save_identity(nullptr, g_lnch_lobby_url.c_str()); - } - - int ae_np_connect(void*) { - psx_lobby_set_game_identity(g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION); - return psx_lobby_connect(ae_np_default_url(nullptr)); - } - - int ae_np_connected(void*) { - return psx_lobby_connected(); - } - - void ae_np_pump(void*) { - psx_lobby_pump(); - } - - void ae_np_set_player_name(void*, const char* name) { - psx_lobby_set_display_name(name && name[0] ? name : "Player"); - if (name && name[0]) ae_np_save_identity(name, nullptr); - } - - const char* ae_np_player_name(void*) { - return psx_lobby_display_name(); - } - - void ae_np_request_list(void*) { - psx_lobby_request_list(); - } - - int ae_np_list_count(void*) { - RecompLauncherCNetplayLobby lan{}; - return psx_lobby_list_count() + (ae_np_read_lan_lobby(&lan) ? 1 : 0); - } - - int ae_np_list_get(void*, int index, RecompLauncherCNetplayLobby* out) { - if (!out) return 0; - const int remote_count = psx_lobby_list_count(); - if (index >= remote_count) - return ae_np_read_lan_lobby(out); - PsxLobbyRow row{}; - if (!psx_lobby_list_get(index, &row)) return 0; - std::snprintf(out->lobby_id, sizeof(out->lobby_id), "%s", row.lobby_id); - std::snprintf(out->name, sizeof(out->name), "%s", row.name); - std::snprintf(out->game_name, sizeof(out->game_name), "%s", row.game_name); - std::snprintf(out->game_version, sizeof(out->game_version), "%s", row.game_version); - out->player_count = row.player_count; - out->max_slots = row.max_slots; - out->has_password = row.has_password; - return 1; - } - - /* Collect non-loopback IPv4 addresses for Host Lobby IP dropdown. */ - static void ae_np_collect_local_addresses( - std::vector* out) { - if (!out) return; - out->clear(); - auto push = [&](const char* ip, const char* label) { - if (!ip || !ip[0]) return; - if (std::strcmp(ip, "0.0.0.0") == 0 || - std::strcmp(ip, "127.0.0.1") == 0) - return; - for (const auto& existing : *out) { - if (std::strcmp(existing.address, ip) == 0) return; - } - RecompLauncherCNetplayLocalAddress entry{}; - std::snprintf(entry.address, sizeof(entry.address), "%s", ip); - if (label && label[0]) - std::snprintf(entry.label, sizeof(entry.label), "%s", label); - out->push_back(entry); - }; -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); - ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | - GAA_FLAG_SKIP_DNS_SERVER; - ULONG buf_len = 16 * 1024; - std::vector buf(buf_len); - IP_ADAPTER_ADDRESSES* addrs = - reinterpret_cast(buf.data()); - ULONG rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); - if (rc == ERROR_BUFFER_OVERFLOW) { - buf.resize(buf_len); - addrs = reinterpret_cast(buf.data()); - rc = GetAdaptersAddresses(AF_INET, flags, nullptr, addrs, &buf_len); - } - if (rc != NO_ERROR) return; - for (IP_ADAPTER_ADDRESSES* a = addrs; a; a = a->Next) { - if (a->OperStatus != IfOperStatusUp) continue; - if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; - char label[64] = {}; - if (a->FriendlyName) { - WideCharToMultiByte(CP_UTF8, 0, a->FriendlyName, -1, label, - (int)sizeof(label), nullptr, nullptr); - } - for (IP_ADAPTER_UNICAST_ADDRESS* u = a->FirstUnicastAddress; u; - u = u->Next) { - if (!u->Address.lpSockaddr || - u->Address.lpSockaddr->sa_family != AF_INET) - continue; - auto* sin = reinterpret_cast(u->Address.lpSockaddr); - char ip[64] = {}; - if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; - push(ip, label); - } - } -#else - struct ifaddrs* ifa = nullptr; - if (getifaddrs(&ifa) != 0 || !ifa) return; - for (struct ifaddrs* i = ifa; i; i = i->ifa_next) { - if (!i->ifa_addr || i->ifa_addr->sa_family != AF_INET) continue; - if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & IFF_LOOPBACK)) continue; - auto* sin = reinterpret_cast(i->ifa_addr); - char ip[64] = {}; - if (!inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip))) continue; - push(ip, i->ifa_name ? i->ifa_name : ""); - } - freeifaddrs(ifa); -#endif - } - - int ae_np_local_address_get(void*, int index, - RecompLauncherCNetplayLocalAddress* out) { - if (!out || index < 0) return 0; - std::memset(out, 0, sizeof(*out)); - std::vector addrs; - ae_np_collect_local_addresses(&addrs); - if (index >= (int)addrs.size()) return 0; - *out = addrs[(size_t)index]; - return out->address[0] ? 1 : 0; - } - - int ae_np_local_ip(void*, char* out, size_t out_len) { - if (!out || out_len == 0) return 0; - std::vector addrs; - ae_np_collect_local_addresses(&addrs); - if (addrs.empty()) { - std::snprintf(out, out_len, "Unavailable"); - return 0; - } - std::snprintf(out, out_len, "%s", addrs[0].address); - return 1; - } - - int ae_np_external_ip(void*, char* out, size_t out_len) { - if (!out || out_len == 0) return 0; -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); - addrinfo hints{}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - addrinfo* res = nullptr; - if (getaddrinfo("api.ipify.org", "80", &hints, &res) != 0 || !res) - return 0; - SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (s == INVALID_SOCKET) { - freeaddrinfo(res); - return 0; - } - int ok = 0; - if (connect(s, res->ai_addr, (int)res->ai_addrlen) != SOCKET_ERROR) { - const char req[] = - "GET / HTTP/1.1\r\n" - "Host: api.ipify.org\r\n" - "Connection: close\r\n\r\n"; - (void)send(s, req, (int)strlen(req), 0); - char resp[1024]; - int n = recv(s, resp, sizeof(resp) - 1, 0); - if (n > 0) { - resp[n] = '\0'; - char* body = strstr(resp, "\r\n\r\n"); - body = body ? body + 4 : resp; - char ip[64] = {}; - int j = 0; - for (int i = 0; body[i] && j < (int)sizeof(ip) - 1; ++i) { - if ((body[i] >= '0' && body[i] <= '9') || body[i] == '.') - ip[j++] = body[i]; - else if (j > 0) - break; - } - if (j > 0) { - std::snprintf(out, out_len, "%s", ip); - ok = 1; - } - } - } - closesocket(s); - freeaddrinfo(res); - return ok; -#else - std::snprintf(out, out_len, "Unavailable"); - return 0; -#endif - } - - /* host_endpoint is in/out (capacity >= 64). recomp-ui applies UDP port - * policy before calling. lan_only: local registry only; else lobby server. */ - int ae_np_create(void*, const char* lobby_name, char* host_endpoint, - const char* password, - const RecompLauncherCSettings* settings, - int lan_only, int max_slots) { - /* The restored two-peer PSX transport does not consume the launcher's - * newer seat-count hint yet. The game profile still caps PSX sessions - * to the supported player count. */ - (void)max_slots; - PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); - char endpoint[96]; - if (host_endpoint && host_endpoint[0]) - std::snprintf(endpoint, sizeof(endpoint), "%s", host_endpoint); - else - std::snprintf(endpoint, sizeof(endpoint), "0.0.0.0:7777"); - - if (lan_only) { - if (psx_lobby_in_lobby()) - (void)psx_lobby_leave(); - ae_np_write_lan_lobby(lobby_name, endpoint, password); - if (host_endpoint) - std::snprintf(host_endpoint, 96, "%s", endpoint); - return 0; - } - - if (g_lnch_hosting_lan) { - std::error_code ec; - std::filesystem::remove(ae_np_lan_file(), ec); - g_lnch_hosting_lan = false; - } - g_lnch_joined_lan = false; - g_lnch_lan_endpoint.clear(); - g_lnch_lan_guest_bind.clear(); - if (host_endpoint) - std::snprintf(host_endpoint, 96, "%s", endpoint); - return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", - g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, - password ? password : "", endpoint, &caps); - } - - /* guest_bind is in/out (capacity >= 64). recomp-ui fills a real UDP port - * (prefer 7778); never advertise :0 to the lobby. */ - int ae_np_join(void*, const char* lobby_id, const char* password, - char* guest_bind) { - char bind_buf[64]; - const char* bind = guest_bind; - const char* colon = (bind && bind[0]) ? std::strrchr(bind, ':') : nullptr; - const unsigned port = (colon && colon[1]) - ? static_cast(std::strtoul(colon + 1, nullptr, 10)) : 0u; - if (!bind || !bind[0] || port == 0u) { - std::snprintf(bind_buf, sizeof(bind_buf), "0.0.0.0:7778"); - bind = bind_buf; - if (guest_bind) - std::snprintf(guest_bind, 64, "%s", bind_buf); - } - if (lobby_id && strncmp(lobby_id, "lan:", 4) == 0) { - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || !state.joiner_name.empty()) return -1; - if (state.password != (password ? password : "")) return -2; - state.joiner_name = psx_lobby_display_name(); - if (state.joiner_name.empty()) state.joiner_name = "Player"; - state.started = false; - if (!ae_np_write_lan_state(state)) return -1; - g_lnch_hosting_lan = false; - g_lnch_joined_lan = true; - g_lnch_lan_endpoint = state.endpoint; - g_lnch_lan_guest_bind = bind; - return 0; - } - return psx_lobby_join(lobby_id, password ? password : "", bind); - } - - int ae_np_leave(void*) { - if (g_lnch_hosting_lan) { - std::error_code ec; - std::filesystem::remove(ae_np_lan_file(), ec); - g_lnch_hosting_lan = false; - } else if (g_lnch_joined_lan) { - AeLanLobbyState state; - if (ae_np_read_lan_state(&state)) { - state.joiner_name.clear(); - state.started = false; - ae_np_write_lan_state(state); - } - } - g_lnch_joined_lan = false; - g_lnch_lan_endpoint.clear(); - g_lnch_lan_guest_bind.clear(); - g_lnch_pending_direct_launch = {}; - return psx_lobby_leave(); - } - int ae_np_in_lobby(void*) { - return g_lnch_hosting_lan || g_lnch_joined_lan || psx_lobby_in_lobby(); - } - int ae_np_is_host(void*) { - if (g_lnch_hosting_lan || g_lnch_joined_lan) return g_lnch_hosting_lan ? 1 : 0; - return psx_lobby_is_host(); - } - int ae_np_member_count(void*) { - if (g_lnch_hosting_lan || g_lnch_joined_lan) return 2; - return psx_lobby_member_count(); - } - - int ae_np_member_get(void*, int index, RecompLauncherCNetplayMember* out) { - if (!out) return 0; - if (g_lnch_hosting_lan || g_lnch_joined_lan) { - if (index < 0 || index > 1) return 0; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return 0; - const bool host = index == 0; - out->slot = host ? state.host_slot : 1 - state.host_slot; - const std::string& name = host ? state.host_name : state.joiner_name; - std::snprintf(out->display_name, sizeof(out->display_name), "%s", name.c_str()); - out->ready = !name.empty(); - out->is_host = host ? 1 : 0; - return 1; - } - PsxLobbyMember mem{}; - if (!psx_lobby_member_get(index, &mem)) return 0; - out->slot = mem.slot; - std::snprintf(out->display_name, sizeof(out->display_name), "%s", mem.display_name); - out->ready = mem.ready; - out->is_host = mem.slot == 0; - return 1; - } - - int ae_np_move_member(void*, int from_slot, int to_slot) { - if (!g_lnch_hosting_lan || from_slot < 0 || from_slot > 1 || - to_slot < 0 || to_slot > 1 || from_slot == to_slot) return -1; - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state)) return -1; - state.host_slot = 1 - state.host_slot; - state.started = false; - return ae_np_write_lan_state(state) ? 0 : -1; - } - - int ae_np_local_ready(void*) { return psx_lobby_local_ready(); } - int ae_np_all_ready(void*) { return psx_lobby_all_ready(); } - int ae_np_set_ready(void*, int ready) { return psx_lobby_set_ready(ready); } - - int ae_np_request_start(void*, const RecompLauncherCSettings* settings) { - if (g_lnch_hosting_lan) { - AeLanLobbyState state; - if (!ae_np_read_lan_state(&state) || state.joiner_name.empty()) return -1; - state.started = true; - return ae_np_write_lan_state(state) ? 0 : -1; - } - PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); - return psx_lobby_request_start(&caps); - } - - int ae_np_launch_pending(void*) { - if ((g_lnch_hosting_lan || g_lnch_joined_lan) && - !g_lnch_pending_direct_launch.enabled) { - AeLanLobbyState state; - if (ae_np_read_lan_state(&state) && state.started) { - g_lnch_pending_direct_launch = {}; - g_lnch_pending_direct_launch.enabled = 1; - g_lnch_pending_direct_launch.local_slot = g_lnch_hosting_lan - ? state.host_slot : 1 - state.host_slot; - g_lnch_pending_direct_launch.input_player = 0; - g_lnch_pending_direct_launch.session_id = 1; - g_lnch_pending_direct_launch.input_delay = 2; - if (g_lnch_hosting_lan) { - const size_t colon = state.endpoint.rfind(':'); - const char* port = colon == std::string::npos - ? "7777" : state.endpoint.c_str() + colon + 1; - std::snprintf(g_lnch_pending_direct_launch.bind_hostport, - sizeof(g_lnch_pending_direct_launch.bind_hostport), - "0.0.0.0:%s", port); - } else { - std::snprintf(g_lnch_pending_direct_launch.bind_hostport, - sizeof(g_lnch_pending_direct_launch.bind_hostport), "%s", - g_lnch_lan_guest_bind.empty() - ? "0.0.0.0:0" : g_lnch_lan_guest_bind.c_str()); - std::snprintf(g_lnch_pending_direct_launch.peer_hostport, - sizeof(g_lnch_pending_direct_launch.peer_hostport), "%s", - state.endpoint.c_str()); - } - } - } - return g_lnch_pending_direct_launch.enabled || psx_lobby_launch_pending(); - } - void ae_np_clear_launch_pending(void*) { - g_lnch_pending_direct_launch = {}; - psx_lobby_clear_launch_pending(); - } - - int ae_np_fill_launch(void*, RecompLauncherCNetplayLaunch* out) { - if (!out) return 0; - if (g_lnch_pending_direct_launch.enabled) { - *out = g_lnch_pending_direct_launch; - return 1; - } - const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); - if (!ji || !ji->ok) return 0; - const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); - out->enabled = 1; - out->local_slot = ji->local_slot; - out->input_player = 0; - std::snprintf(out->bind_hostport, sizeof(out->bind_hostport), "%s", ji->bind_hostport); - std::snprintf(out->peer_hostport, sizeof(out->peer_hostport), "%s", ji->peer_hostport); - out->session_id = ji->session_id; - out->input_delay = (caps && caps->valid) ? caps->input_delay : 2; - return 1; - } - - const char* ae_np_last_error(void*) { - const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); - return (ji && ji->last_error[0]) ? ji->last_error : nullptr; - } - - void ae_np_clear_last_error(void*) { - PsxLobbyJoinInfo* ji = const_cast(psx_lobby_join_info()); - if (ji) ji->last_error[0] = '\0'; - } - - RecompLauncherCNetplayCallbacks g_lnch_netplay_callbacks = { - nullptr, - ae_np_default_url, - ae_np_set_lobby_url, - ae_np_connect, - ae_np_connected, - ae_np_pump, - ae_np_set_player_name, - ae_np_player_name, - ae_np_request_list, - ae_np_list_count, - ae_np_list_get, - ae_np_local_ip, - ae_np_external_ip, - ae_np_create, - ae_np_join, - ae_np_leave, - ae_np_in_lobby, - ae_np_is_host, - ae_np_member_count, - ae_np_member_get, - ae_np_move_member, - ae_np_local_ready, - ae_np_all_ready, - ae_np_set_ready, - ae_np_request_start, - ae_np_launch_pending, - ae_np_clear_launch_pending, - ae_np_fill_launch, - ae_np_local_address_get, - nullptr, /* kick_member — optional */ - ae_np_last_error, - ae_np_clear_last_error, - }; -} // namespace -#endif - -int main(int argc, char** argv) { - /* Keep startup diagnostics visible even if the process is killed. MSVC's - * CRT rejects a zero-sized _IOLBF buffer via its invalid-parameter handler, - * so use the portable zero-sized _IONBF form for both standard streams. */ - std::setvbuf(stdout, nullptr, _IONBF, 0); - std::setvbuf(stderr, nullptr, _IONBF, 0); - std::fprintf(stderr, "psxrecomp: main() entered\n"); - std::fflush(stderr); - - /* Install crash handlers early so they catch issues during init too. - * Writes psx_last_run_report.json on signal/SEH/atexit/fail-fast. */ - psx_crash_trace_install_handlers(); - - const char* bios_path = PSX_DEFAULT_BIOS_PATH; - const char* game_config_path = nullptr; - const char* disc_override_path = nullptr; - bool bios_from_cli = false; /* CLI --bios/positional wins over settings.toml */ - /* Did the PLAYER choose this BIOS (CLI or settings), as opposed to it - * being the compile-time default? Only a real choice overrides the - * bundled OpenBIOS — see docs/BIOS_SELECTION.md. */ - bool bios_explicit = false; - /* Launcher overrides (mirrors snesrecomp): --launcher forces the GUI back on - * even when [launcher] skip_launcher = true is set; --no-launcher (and the - * PSX_NO_LAUNCHER env) forces it off. --launcher wins if both are given. */ - bool force_launcher = false; - bool force_no_launcher = false; - /* CLI overrides for running several instances side by side (soak fleet). - * These win over any game-config value and, crucially, work for the BIOS - * (which has no [game]-block config schema, so debug_port/renderer can't be - * supplied via --game). -1 = "not set on the CLI". */ - int cli_debug_port = -1; - int cli_renderer = -1; /* 0=software 1=opengl 2=vulkan */ - const char* cli_window_title = nullptr; /* label windows in a fleet */ - const char* cli_memcard_dir = nullptr; /* isolate writable state in a fleet */ - PsxNetplayConfig net_cfg; - psx_netplay_config_defaults(&net_cfg); - psx_netplay_apply_env(&net_cfg); /* CLI flags below win over env */ - /* Parse args. - * --bios override the compile-time BIOS path - * --game load a game config (single source of truth for - * disc / memcard / window title / debug port) - * --disc override the game config disc path - * --debug-port override the TCP debug-server port (multi-instance) - * --memcard-dir override card/save/options state (multi-instance) - * --renderer override the renderer: software|opengl|vulkan - * --launcher force the GUI launcher (overrides skip_launcher) - * --no-launcher skip the GUI launcher (boot straight in) - * --headless skip SDL window/audio; use TCP screenshots/state - * --netplay enable delay-sync LAN (also PSX_NETPLAY=1) - * --net-slot N local player slot (0|1) - * --net-input-player N host device to sample (0=P1, 1=P2; default auto) - * --net-bind H:P local UDP bind (default 0.0.0.0:7777) - * --net-peer H:P peer host:port - * --net-delay N input delay in sim ticks (default 2) - * --net-session-id N must match peer (default 1) - * deprecated alias for --bios - * No --game-root flag: that remains config-driven. */ - for (int i = 1; i < argc; i++) { - if (std::strcmp(argv[i], "--bios") == 0 && i + 1 < argc) { - bios_path = argv[++i]; - bios_from_cli = true; - bios_explicit = true; - } else if (std::strcmp(argv[i], "--game") == 0 && i + 1 < argc) { - game_config_path = argv[++i]; - } else if (std::strcmp(argv[i], "--disc") == 0 && i + 1 < argc) { - disc_override_path = argv[++i]; - } else if (std::strcmp(argv[i], "--debug-port") == 0 && i + 1 < argc) { - cli_debug_port = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--memcard-dir") == 0 && i + 1 < argc) { - cli_memcard_dir = argv[++i]; - } else if (std::strcmp(argv[i], "--renderer") == 0 && i + 1 < argc) { - const char* r = argv[++i]; - if (std::strcmp(r, "software") == 0) cli_renderer = 0; - else if (std::strcmp(r, "opengl") == 0) cli_renderer = 1; - else if (std::strcmp(r, "vulkan") == 0) cli_renderer = 2; - } else if (std::strcmp(argv[i], "--window-title") == 0 && i + 1 < argc) { - cli_window_title = argv[++i]; - } else if (std::strcmp(argv[i], "--launcher") == 0) { - force_launcher = true; - } else if (std::strcmp(argv[i], "--no-launcher") == 0) { - force_no_launcher = true; - } else if (std::strcmp(argv[i], "--headless") == 0) { - g_headless = 1; - force_no_launcher = true; - } else if (std::strcmp(argv[i], "--netplay") == 0) { - net_cfg.enabled = 1; - } else if (std::strcmp(argv[i], "--net-slot") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.local_slot = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--net-input-player") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.input_player = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--net-bind") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", argv[++i]); - } else if (std::strcmp(argv[i], "--net-peer") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", argv[++i]); - } else if (std::strcmp(argv[i], "--net-delay") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.input_delay = std::atoi(argv[++i]); - } else if (std::strcmp(argv[i], "--net-session-id") == 0 && i + 1 < argc) { - net_cfg.enabled = 1; - net_cfg.session_id = (uint32_t)std::strtoul(argv[++i], nullptr, 10); - } else if (argv[i][0] != '-') { - /* The positional BIOS alias predates the named CLI. Consume it at - * most once. In particular, never let a stray split argument - * replace an explicit --bios path (for example when a Windows - * launcher fails to quote a multi-word --window-title value). */ - if (!bios_from_cli) { - bios_path = argv[i]; - bios_from_cli = true; - bios_explicit = true; - bios_explicit = true; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring unexpected positional argument after BIOS selection: %s\n", - argv[i]); - } - } - } - if (const char *e = std::getenv("PSX_HEADLESS")) { - if (e[0] && e[0] != '0') { - g_headless = 1; - force_no_launcher = true; - } - } - - std::string default_game_config_storage; - if (!game_config_path) { - std::filesystem::path default_game_config = - resolve_existing_runtime_path(PSX_DEFAULT_GAME_CONFIG_PATH, argv[0]); - if (!default_game_config.empty()) { - default_game_config_storage = default_game_config.string(); - game_config_path = default_game_config_storage.c_str(); - } - } else if (!std::filesystem::path(game_config_path).is_absolute()) { - // An explicit --game with a relative path must ALSO anchor on the exe - // dir, never cwd — otherwise the disc / memcard_dir / game_options.toml - // that resolve against this file's parent silently point at cwd. Resolve - // it the same way the default path is resolved. - std::filesystem::path resolved = - resolve_existing_runtime_path(game_config_path, argv[0]); - if (!resolved.empty()) { - default_game_config_storage = resolved.string(); - game_config_path = default_game_config_storage.c_str(); - } - } - /* Record the resolved config path for the TCP fmv_state report (observability: - * proves which game.toml actually loaded — CLI --game vs the baked default). */ - g_active_config_path = game_config_path; - - std::filesystem::path memcard_dir; - std::filesystem::path memcard1_path; /* explicit slot-1 .mcd (empty => dir/card1.mcd) */ - std::filesystem::path memcard2_path; /* explicit slot-2 .mcd (empty => dir/card2.mcd) */ - bool memcard1_enabled = true; - bool memcard2_enabled = true; - /* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */ - /* Dev builds default Player 1 to the first connected controller ("auto"). - * PSX_DEV_INPUT=1 additionally merges every other controller and the - * keyboard for diagnostic convenience; normal routing stays exclusive. - * Release keeps "keyboard" as its pre-launch default (the launcher assigns - * the selected physical device). */ -#if defined(PSX_DEBUG_TOOLS) - std::string p1_device = "auto"; -#else - std::string p1_device = "keyboard"; -#endif - std::string p2_device = "none"; - int p1_mode = PSXRecompV4::PAD_MODE_HYBRID; - int p2_mode = PSXRecompV4::PAD_MODE_HYBRID; - bool ctrl_allow_hybrid = true; /* game.toml [controller] allow_hybrid; false hides Hybrid in the launcher */ - bool ctrl_lock_mode = false; /* game.toml [controller] lock_mode; true hides the whole pad-mode selector */ - bool ctrl_lock_device = false; /* game.toml [controller] lock_device; true hides the Player controller cards entirely */ - /* The game-DECLARED port modes, captured at game.toml load and immune to the - * settings.toml overrides below. Under lock_mode these are the only valid - * modes (the game supports exactly one pad type), so they are what the - * runtime clamps to and what the launcher locks its selector to. */ - int ctrl_locked_p1_mode = PSXRecompV4::PAD_MODE_HYBRID; - int ctrl_locked_p2_mode = PSXRecompV4::PAD_MODE_HYBRID; - bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ - bool ws_ultrawide_offered = false; - bool ws_adaptive_view_supported = false; -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - bool frame_interpolation_offered = false; -#else - bool frame_interpolation_offered = true; -#endif - bool skip_fmv_offered = true; - bool turbo_loads_offered = true; - bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ - int resolved_deadzone = -1; /* <0 => keep input.ini/runtime default (12000) */ - /* Localization: the effective language (game.toml default -> settings.toml -> - * launcher choice), applied to the translation layer AFTER the launcher runs. - * lang_menu_options drives the launcher's "Localization" dropdown (empty => - * no dropdown; only games that declare [localization].languages get one). */ - std::string resolved_language = "en"; - std::vector lang_menu_options; - std::filesystem::path resolved_disc; - std::string window_title = PSX_WINDOW_TITLE; - uint16_t debug_port = (uint16_t)DEFAULT_DEBUG_PORT; - std::string game_name; - std::string game_id; - std::string game_region; /* game.toml [game] region; empty => derive from game_id serial */ - int game_players = 1; /* game.toml [game] players; launcher hides the P2 row when 1 */ - bool game_has_disc_crc = false; - uint32_t game_disc_crc = 0; - std::string disc_speed; /* "1x" | "2x" | "4x" | "instant" */ - int instant_rate = 0; /* 0 = cdrom.c built-in default */ - std::vector warm_cd_routes; - uint32_t game_entry_pc = 0; - bool fast_boot = false; /* DEPRECATED alias: HLE boot-skip only */ - /* REQUESTED intent, never the granted result: "run the HLE tier" as asked - * for by game.toml / settings.toml / a netplay seed / env. What each of the - * tier's two axes actually gets is derived per BIOS image by - * psx_bios_hle_plan() at bring-up and must NOT be written back here — the - * session_reboot path re-enters that block, and folding a refusal back into - * the request would make a rematch differ from the first launch. */ - bool bios_hle_requested = false; /* [runtime] bios_hle (bios_hle.c) */ - bool bios_hle_keep_intro = false; - /* Text-image guard source, captured at config load; armed after the disc - * path is resolved (arm_text_image_guard). */ - std::string text_guard_exe_path; - uint32_t text_guard_load_addr = 0; - uint32_t text_guard_text_size = 0; - - if (game_config_path) { - try { - const auto gc = PSXRecompV4::load_game_config(game_config_path); - game_name = gc.name; - game_id = gc.id; - game_region = gc.region; - game_players = gc.players; - game_has_disc_crc = gc.has_disc_crc; - game_disc_crc = gc.disc_crc; - if (!gc.discs.empty()) resolved_disc = gc.discs.front(); - if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; - if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; - if (gc.runtime.has_debug_port) debug_port = gc.runtime.debug_port; - /* On-the-fly string translation / localization (framework feature — - * text_xlate.cpp): load translations/ *.toml under the project root - * and select the language. Capture inventory is always-on; APPLY is - * gated by language + table presence. See docs/STRING_TRANSLATION.md. */ - text_xlate_init(gc.project_root.string().c_str(), - gc.runtime.language.c_str()); - resolved_language = gc.runtime.language; /* launcher/settings may override */ - lang_menu_options = gc.runtime.languages; /* launcher localization dropdown */ - if (gc.runtime.has_disc_speed) disc_speed = gc.runtime.disc_speed; - if (gc.runtime.has_instant_max_per_frame) - instant_rate = gc.runtime.instant_max_per_frame; - warm_cd_routes = gc.runtime.warm_cd_routes; - if (gc.runtime.turbo_loads) { - g_turbo_loads_enabled = 1; - std::fprintf(stdout, "psxrecomp: turbo_loads enabled (opt-in)\n"); - } - if (gc.runtime.turbo_audio_sink) { - g_turbo_audio_sink_enabled = 1; - std::fprintf(stdout, - "psxrecomp: turbo_audio_sink enabled (opt-in)\n"); - } - { - extern int g_idle_skip_enabled; - const char *idle_env = std::getenv("PSX_IDLE_SKIP"); - g_idle_skip_enabled = idle_env - ? (idle_env[0] == '1' ? 1 : 0) - : (gc.runtime.idle_skip ? 1 : 0); - std::fprintf(stdout, "psxrecomp: idle_skip %s%s\n", - g_idle_skip_enabled ? "enabled" : "disabled", - idle_env ? " (environment override)" : ""); - psx_precise_slice_init_from_env(); - } - for (uint32_t site : gc.vsync_event_horizon_sites) - psx_vsync_query_hle_add_event_horizon_site(site); - for (uint32_t site : gc.vsync_event_horizon_extra_sites) - psx_vsync_query_hle_add_extra_event_horizon_site(site); - g_video_scale = gc.runtime.video_supersampling; - g_video_aa = gc.runtime.video_antialiasing; - g_video_texfilter = gc.runtime.video_texture_filter; - g_video_renderer = gc.runtime.video_renderer; - g_video_screen = gc.runtime.video_screen_kind; - g_video_aspect_num = gc.runtime.video_aspect_num; - g_video_aspect_den = gc.runtime.video_aspect_den; - g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; - g_video_vsync = gc.runtime.video_vsync; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; - g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; -#endif - g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; - g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; - if (gc.runtime.video_fmv_skip_end_total) - g_fmv_skip_end_total = gc.runtime.video_fmv_skip_end_total; - g_fmv_skip_no_xa = gc.runtime.video_fmv_skip_no_xa ? 1 : 0; - g_fmv_skip_no_xa_hold = gc.runtime.video_fmv_skip_no_xa_hold; - g_ws_anchor_addr = gc.ws_sprite_anchor_addr; - g_ws_hud_sprt = gc.ws_hud_sprt_squash; - gpu_ws_set_auto_ui_squash(gc.ws_auto_ui_squash ? 1 : 0); - /* [widescreen] full_2d — opt a pure-2D sprite game (MMX6) into the - * widescreen present path. Applied to the GPU layer up front so the - * ws engage at game entry classifies every frame as gameplay. */ - gpu_ws_set_full_2d(gc.ws_full_2d ? 1 : 0); - /* [widescreen.bg2d] engine tile-ring layout for the freshness - * refill shared by the MMX5/MMX6 2D background hook. */ - gpu_ws_bg2d_configure(gc.ws_bg2d_layer_base, - gc.ws_bg2d_ring_base, - gc.ws_bg2d_map_size_addr, - gc.ws_bg2d_layer_stride_addr, - gc.ws_bg2d_ring_cols, - gc.ws_bg2d_layer_count, - gc.ws_bg2d_layer_struct_stride, - gc.ws_bg2d_packet_cap); - /* [widescreen] gte_game_mode — 3D-title gameplay detector (Ape). */ - gpu_ws_set_gte_game_mode(gc.ws_gte_game_mode ? 1 : 0); - gpu_ws_set_gameplay_state_gate( - gc.ws_gameplay_state_addr, - gc.ws_gameplay_state_values.data(), - (int)gc.ws_gameplay_state_values.size()); - /* Keep titles with known native-wide regressions on the original - * projection-squash + stretched-present widescreen path. */ - g_ws_native_wide = gc.ws_native_wide ? 1 : 0; - /* [widescreen] nw_hud_corners — push HUD to the true wide corners. */ - gpu_ws_set_nw_hud_corners(gc.ws_nw_hud_corners ? 1 : 0); - /* Targeted left-HUD packet range — avoids shifting 2D scenery. */ - gpu_ws_set_nw_left_hud_packet_range(gc.ws_nw_left_hud_packet_lo, - gc.ws_nw_left_hud_packet_hi); - /* [widescreen] nw_backdrop — stretch full-frame 2D sky backdrop. */ - gpu_ws_set_nw_backdrop(gc.ws_nw_backdrop ? 1 : 0); - /* [widescreen] nw_flat_backdrop — stretch flat sky/backdrop prims - * in the native-wide mirror, preserving the canonical 4:3 image. */ - gpu_ws_set_nw_flat_backdrop(gc.ws_nw_flat_backdrop ? 1 : 0); - /* [widescreen] nw_phase_backdrop — stretch only the textured - * backdrop phase emitted before shaded 3D foreground geometry. */ - gpu_ws_set_nw_phase_backdrop(gc.ws_nw_phase_backdrop ? 1 : 0); - gpu_ws_set_nw_textured_edges(gc.ws_nw_textured_edges ? 1 : 0, - gc.ws_nw_textured_edge_scale); - gl_renderer_set_wide_fast(gc.ws_nw_full_mirror ? 0 : 1); - { - std::vector addresses, expected; - addresses.reserve(gc.ws_signed_x_bound_sites.size()); - expected.reserve(gc.ws_signed_x_bound_sites.size()); - for (const auto& site : gc.ws_signed_x_bound_sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - } - gpu_ws_set_signed_x_bound_sites(addresses.data(), expected.data(), - (int)addresses.size()); - } - /* [widescreen] clear_reveal — enable opted-in scene/map-boundary - * cleanup of synthetic native-wide margins. */ - gpu_ws_set_clear_reveal(gc.ws_clear_reveal ? 1 : 0); - gpu_ws_set_cull_guard_pixels(gc.ws_cull_guard_pixels); - gpu_ws_set_activation_guard_pixels( - gc.ws_cull_activation_guard_pixels); - gpu_ws_set_explicit_cull_sites( - gc.ws_cull_bias_sites.data(), (int)gc.ws_cull_bias_sites.size(), - gc.ws_cull_slti_sites.data(), (int)gc.ws_cull_slti_sites.size(), - gc.ws_cull_range_sites.data(), (int)gc.ws_cull_range_sites.size()); - gpu_ws_set_slti_lower_cull_sites( - gc.ws_cull_slti_lower_sites.data(), - (int)gc.ws_cull_slti_lower_sites.size()); - gpu_ws_set_negsub_cull_sites( - gc.ws_cull_negsub_sites.data(), (int)gc.ws_cull_negsub_sites.size()); - gpu_ws_set_vxrange_cull_sites( - gc.ws_cull_vxrange_sites.data(), (int)gc.ws_cull_vxrange_sites.size()); - gpu_ws_set_depth_cull_sites( - gc.ws_cull_depth_sites.data(), (int)gc.ws_cull_depth_sites.size()); - gpu_ws_set_plane_nx_sites( - gc.ws_cull_plane_nx_sites.data(), (int)gc.ws_cull_plane_nx_sites.size()); - gpu_ws_set_xclip_load_sites( - gc.ws_cull_xclip_load_sites.data(), (int)gc.ws_cull_xclip_load_sites.size()); - { - std::vector addresses, expected, results; - addresses.reserve(gc.ws_cull_keep_sites.size()); - expected.reserve(gc.ws_cull_keep_sites.size()); - results.reserve(gc.ws_cull_keep_sites.size()); - for (const auto& site : gc.ws_cull_keep_sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - results.push_back(site.result); - } - gpu_ws_set_cull_keep_sites( - addresses.data(), expected.data(), results.data(), - (int)addresses.size()); - } - { - std::vector addresses, expected; - addresses.reserve(gc.ws_cull_angle_sites.size()); - expected.reserve(gc.ws_cull_angle_sites.size()); - for (const auto& site : gc.ws_cull_angle_sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - } - gpu_ws_set_angle_sites( - addresses.data(), expected.data(), (int)addresses.size()); - } - { - std::vector addresses, expected, thresholds; - std::vector object_regs, x_regs, z_regs, y_regs; - std::vector queue_guards; - addresses.reserve(gc.ws_aspect_cone.sites.size()); - expected.reserve(gc.ws_aspect_cone.sites.size()); - thresholds.reserve(gc.ws_aspect_cone.sites.size()); - object_regs.reserve(gc.ws_aspect_cone.sites.size()); - x_regs.reserve(gc.ws_aspect_cone.sites.size()); - z_regs.reserve(gc.ws_aspect_cone.sites.size()); - y_regs.reserve(gc.ws_aspect_cone.sites.size()); - queue_guards.reserve(gc.ws_aspect_cone.sites.size()); - const auto effective_reg = [](uint32_t site_reg, - uint32_t default_reg) { - return site_reg == 0xFFFFFFFFu - ? default_reg : site_reg; - }; - for (const auto& site : gc.ws_aspect_cone.sites) { - addresses.push_back(site.address); - expected.push_back(site.expected); - thresholds.push_back(site.cosine_threshold); - object_regs.push_back(effective_reg( - site.object_reg, gc.ws_aspect_cone.object_reg)); - x_regs.push_back(effective_reg( - site.x_reg, gc.ws_aspect_cone.x_reg)); - z_regs.push_back(effective_reg( - site.z_reg, gc.ws_aspect_cone.z_reg)); - y_regs.push_back(effective_reg( - site.y_reg, gc.ws_aspect_cone.y_reg)); - queue_guards.push_back(site.queue_guard ? 1u : 0u); - } - gpu_ws_set_aspect_cone( - addresses.data(), expected.data(), thresholds.data(), - object_regs.data(), x_regs.data(), z_regs.data(), - y_regs.data(), queue_guards.data(), (int)addresses.size(), - gc.ws_aspect_cone.forward_addr, - gc.ws_aspect_cone.object_type_offset, - gc.ws_aspect_cone.hysteresis_pixels, - gc.ws_aspect_cone.queue_reserve, - gc.ws_aspect_cone.queue_count_addrs.data(), - gc.ws_aspect_cone.queue_capacities.data(), - gc.ws_aspect_cone.queue_type_masks.data()); - } - gte_ws_configure_dome_sites( - gc.ws_dome_call_sites.data(), (int)gc.ws_dome_call_sites.size()); - /* [widescreen.cull] per-game gates + signature immediates for the - * pattern-scanned interp widen hooks. A title that never opted in - * must never have its live code scanned and rewritten. */ - gpu_ws_set_auto_hooks(gc.ws_auto_screen_x_cull ? 1 : 0, - gc.ws_auto_backdrop_preload ? 1 : 0); - if (!gc.ws_cull_w_imms.empty() || !gc.ws_cull_h_imms.empty()) - gpu_ws_set_cull_imms(gc.ws_cull_w_imms.data(), (int)gc.ws_cull_w_imms.size(), - gc.ws_cull_h_imms.data(), (int)gc.ws_cull_h_imms.size()); - ws_offered = gc.ws_offered; - ws_ultrawide_offered = gc.ws_ultrawide_offered; - ws_adaptive_view_supported = gc.ws_adaptive_view; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - frame_interpolation_offered = - gc.runtime.video_offer_frame_interpolation; -#endif - skip_fmv_offered = gc.runtime.video_offer_skip_fmv; - turbo_loads_offered = gc.runtime.offer_turbo_loads; - vulkan_offered = gc.vulkan_offered; - /* Register the [widescreen.backdrop] store PCs so the dirty-RAM - * interpreter applies the backdrop screenX squash on the interp - * path (overlay backdrop handlers run interpreted when no cache - * DLL is loaded — the recompiler emit only covers native). */ - if (!gc.ws_backdrop_x_sites.empty()) - psx_ws_set_backdrop_sites(gc.ws_backdrop_x_sites.data(), - (int)gc.ws_backdrop_x_sites.size()); - g_audio_spu_hq = gc.runtime.audio_spu_hq; - g_audio_buffer_ms = (double)gc.runtime.audio_buffer_ms; - g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0; - if (gc.runtime.has_parappa_timing) { - parappa_apply_timing_setting( - gc.runtime.parappa_timing_mode, - gc.runtime.parappa_timing_extra_early, - gc.runtime.parappa_timing_extra_late, - "game.toml"); - } - /* [controller] game-declared input defaults (settings.toml/launcher - * still override below). */ - if (gc.runtime.has_default_mode) { - p1_mode = gc.runtime.default_p1_mode; - p2_mode = gc.runtime.default_p2_mode; - } - ctrl_locked_p1_mode = gc.runtime.default_p1_mode; - ctrl_locked_p2_mode = gc.runtime.default_p2_mode; - ctrl_allow_hybrid = gc.runtime.controller_allow_hybrid; - ctrl_lock_mode = gc.runtime.controller_lock_mode; - ctrl_lock_device = gc.runtime.controller_lock_device; - if (gc.runtime.has_deadzone) resolved_deadzone = gc.runtime.deadzone; - if (gc.runtime.has_anti_deadzone) - controller_anti_deadzone = gc.runtime.anti_deadzone; - { const char *e = std::getenv("PSX_GL_FORCE_CPU_PRESENT"); - if (e && e[0] && e[0] != '0') g_gl_fbo_present = 0; } - game_entry_pc = gc.entry_pc; - fast_boot = gc.runtime.fast_boot; - bios_hle_requested = gc.runtime.bios_hle; - bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro; - /* Developer compatibility finding, applied before BIOS selection. - * Not exposed to settings.toml on purpose — see BIOS_SELECTION.md. */ - s_openbios_allowed = gc.runtime.openbios; - /* Let the dispatch layer distinguish "dirty because text was - * loaded" from "diverged because runtime wrote different code over - * the original EXE image". Packed/self-modifying games can rewrite - * their own text; those pages must execute from live RAM, not stale - * static native code. Arming is DEFERRED until the disc path is - * resolved (arm_text_image_guard below): release installs have no - * local EXE file, so the guard extracts the boot EXE from the - * user's disc image instead. */ - text_guard_exe_path = gc.exe_path.string(); - text_guard_load_addr = gc.load_address; - text_guard_text_size = gc.text_size; - /* HLE-tier scheduler subsystem replacement default (env - * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ - psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); - /* Pin the overlay-region floor to THIS game's main-EXE text end so - * runtime-loaded overlays (which load just above it) are dispatched - * via in-interpreter local-flow chaining, NOT the slow block-by-block - * + bail-prone non-local-call path. Hardcoding the floor to Tomba 1's - * text end (0x98000) wedged Tomba 2 (text ends 0x38800, overlays at - * 0x85000+) at the Whoopee-Camp splash. See dirty_ram_interp.h. */ - { - extern uint32_t g_overlay_region_floor; - uint32_t text_end = (gc.load_address + gc.text_size) & 0x1FFFFFFFu; - if (text_end > 0x00010000u /* DIRTY_RAM_KERNEL_WINDOW_END */) - g_overlay_region_floor = text_end; - std::fprintf(stdout, - "psxrecomp: overlay_region_floor = 0x%05X (game text end)\n", - g_overlay_region_floor); - } - /* Overlay DLL cache (Layer A). Off unless enabled in [runtime]; - * when on, capture overlay bytes and scan cache// for - * precompiled overlay DLLs. */ - if (gc.runtime.overlay_cache) { - std::filesystem::path exe_dir = exe_dir_from_argv(argv[0]); - std::string cache_dir = (exe_dir / "cache").string(); - std::filesystem::path captures_path = - resolve_overlay_capture_path(gc.project_root, exe_dir, game_id); - if (!overlay_capture_set_path(captures_path.string().c_str())) { - captures_path = exe_dir / "overlay_captures.json"; - if (!overlay_capture_set_path(captures_path.string().c_str())) - throw std::runtime_error("overlay capture path exceeds runtime limit"); - } - overlay_capture_set_enabled(1); - std::fprintf(stdout, - "psxrecomp: additive overlay capture store = %s (+ .d history)\n", - captures_path.string().c_str()); - std::string capture_persist_dir; - if (gc.runtime.overlay_capture_history && - !gc.runtime.overlay_capture_persist_dir.empty()) { - std::filesystem::path persist = - gc.project_root / gc.runtime.overlay_capture_persist_dir; - std::error_code persist_ec; - std::filesystem::create_directories(persist, persist_ec); - if (persist_ec) { - std::fprintf(stderr, - "psxrecomp: cannot create overlay capture history %s: %s\n", - persist.string().c_str(), persist_ec.message().c_str()); - } else { - capture_persist_dir = persist.string(); - } - } - overlay_capture_configure_history( - gc.runtime.overlay_capture_history ? 1 : 0, - capture_persist_dir.empty() ? nullptr : - capture_persist_dir.c_str(), - game_id.c_str()); - const uint32_t overlay_config_hash = - PSXRecompV4::overlay_codegen_config_hash(gc); - overlay_loader_init(cache_dir.c_str(), game_id.c_str(), - overlay_config_hash); - std::fprintf(stdout, - "psxrecomp: overlay codegen config hash = %08x\n", - (unsigned)overlay_config_hash); - for (uint32_t addr : gc.runtime.overlay_native_block) { - overlay_loader_native_block_add(addr); - } - if (!gc.runtime.overlay_native_block.empty()) { - std::fprintf(stdout, - "psxrecomp: overlay native blocklist seeded with %zu entr%s\n", - gc.runtime.overlay_native_block.size(), - gc.runtime.overlay_native_block.size() == 1 ? "y" : "ies"); - } - /* Scoped pre-DMA journaling and the shutdown snapshot remain - * active whenever the cache is on, including toolchain-less - * production machines. The periodic pressure trigger exists to - * feed a live compiler; leave it off when no provider command - * exists, otherwise it rewrites manifests every cooldown while - * being unable to reduce interpreter residency. */ - overlay_autocapture_set_enabled(0); - /* Resolve the overlay tier first so we wire the RIGHT compiler's - * autocompile command. gcc is "available" only when a gcc cmd is - * configured AND a gcc toolchain is actually reachable (a real - * dev/production box). auto => gcc if so, else tcc; auto-no-gcc => - * tcc even with gcc present (simulate a toolchain-less user box). - * env PSX_OVERLAY_BACKEND overrides. Tiers: static > gcc > tcc > - * interp. */ - const char *cfg_backend = gc.runtime.overlay_backend.empty() - ? nullptr : gc.runtime.overlay_backend.c_str(); - int gcc_avail = gc.runtime.has_overlay_autocompile_cmd - && autocompile_toolchain_available(); - OverlayBackend eff = overlay_backend_resolve(cfg_backend, gcc_avail); - /* gcc and tcc run the IDENTICAL recompiler->C->DLL->load pipeline; - * only the compiler binary differs. Wire the autocompile spawn with - * the command for the resolved tier (tcc cmd for the tcc tier, gcc - * cmd otherwise). gcc shards already on disk still LOAD either way - * (the loader is compiler-blind), so a tcc box uses shipped gcc - * shards first and fills the rest with tcc. */ - std::string built_tcc_cmd; /* runtime-constructed bundled tcc cmd */ - std::string env_ac_cmd; - const std::string *ac_cmd = nullptr; - if (eff == OVERLAY_BACKEND_TCC) { - if (gc.runtime.has_overlay_autocompile_cmd_tcc) { - ac_cmd = &gc.runtime.overlay_autocompile_cmd_tcc; /* explicit override (dev) */ - } else { - /* PRODUCTION: construct the tcc autocompile cmd from the - * self-contained toolchain bundled beside the exe - * (/overlay_toolchain/ = embedded python + tcc + - * recompiler + compile_overlays.py + runtime headers). No - * system python or gcc required. */ - extern int g_psx_cps_mode; - std::filesystem::path xd = exe_dir_from_argv(argv[0]); - std::filesystem::path tk = xd / "overlay_toolchain"; - std::filesystem::path py = tk / "python" / "python.exe"; - if (std::filesystem::exists(py)) { - auto cmd_quote = [](const std::string& s) { - return std::string("\"") + s + "\""; - }; - built_tcc_cmd = - cmd_quote(py.string()) + " " + - cmd_quote((tk / "compile_overlays.py").string()) + - " --captures " + cmd_quote(captures_path.string()) + - " --game-toml " + cmd_quote(std::string( - game_config_path ? game_config_path : "game.toml")) + - " --recompiler " + cmd_quote((tk / "psxrecomp-game.exe").string()) + - " --runtime-include " + cmd_quote((tk / "include").string()) + - " --out-dir " + cmd_quote((xd / "cache").string()) + - (g_psx_cps_mode ? " --cps" : "") + - " --compiler tcc --tcc " + - cmd_quote((tk / "tcc" / "tcc.exe").string()); - ac_cmd = &built_tcc_cmd; - std::fprintf(stdout, - "psxrecomp: tcc tier using bundled toolchain (%s)\n", - tk.string().c_str()); - } else { - std::fprintf(stdout, - "psxrecomp: tcc tier active but no bundled toolchain at %s " - "(overlay gaps -> interpreter)\n", tk.string().c_str()); - } - } - } else { - if (gc.runtime.has_overlay_autocompile_cmd) - ac_cmd = &gc.runtime.overlay_autocompile_cmd; - } - /* A developer may run a game config from one checkout against a - * runtime/recompiler built in another worktree. Let the launch - * pin the producer command to that exact worktree so the baked - * codegen hash, additive-capture reader, and runtime headers - * cannot silently drift through a game-repo junction. */ - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CMD")) { - if (e[0]) { - env_ac_cmd = e; - ac_cmd = &env_ac_cmd; - std::fprintf(stdout, - "psxrecomp: overlay autocompile command overridden by environment\n"); - } - } - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_OFF")) { - if (e[0] && e[0] != '0') { - ac_cmd = nullptr; - std::fprintf(stdout, - "psxrecomp: overlay autocompile disabled by environment\n"); - } - } - if (ac_cmd) { - /* Pin the compile's WRITE cache + READ captures to the SAME - * canonical locations the loader uses (cache_dir = /cache, - * /overlay_captures.json — set above). The framework owns - * the cache location; no game.toml --out-dir/--captures can make - * the write drift from the read. Single source of truth, all - * games, dev or prod. */ - autocompile_set_cache_paths(cache_dir.c_str(), - captures_path.string().c_str()); - std::string ac_cwd = gc.project_root.string(); - if (const char *e = std::getenv("PSX_OVERLAY_AUTOCOMPILE_CWD")) { - if (e[0]) ac_cwd = e; - } - autocompile_configure(ac_cmd->c_str(), ac_cwd.c_str()); - overlay_autocapture_set_enabled(1); - /* cwd is printed because every relative path in - * overlay_autocompile_cmd resolves against it, and it is - * derived from game.toml's detected project root. Staging a - * game.toml somewhere that is not the real project root - * therefore repoints the whole compile command, and the only - * symptom is that nothing ever goes native and frame times - * are 10-30x worse. Printing the root makes that a one-line - * diagnosis instead of a TCP query against - * autocompile_status. */ - std::fprintf(stdout, - "psxrecomp: overlay autocompile enabled (%s); cache=%s; captures=%s; cwd=%s\n", - overlay_backend_name(eff), cache_dir.c_str(), - captures_path.string().c_str(), ac_cwd.c_str()); - } - code_provider_init(cfg_backend, gcc_avail); - /* (sljit removed 2026-07-15: overlay_loader_apply_live_policy was - * called here once the backend resolved.) */ - } - std::fprintf(stdout, "psxrecomp: loaded game config %s (%s, %s)\n", - game_config_path, game_name.c_str(), game_id.c_str()); - } catch (const std::exception& ex) { - std::fprintf(stderr, "psxrecomp: failed to load --game %s: %s\n", - game_config_path, ex.what()); - return 1; - } - } - - if (!configure_disruptor_precision_store_routes(game_id)) return 1; - - if (!game_name.empty()) s_picker_game_name = game_name; - - /* Layer the launcher-written settings.toml (next to the exe) over the - * bundled game.toml. Any field present there overrides the config value; - * the command line (--bios/--disc) still wins over the file. Absent => - * fall through to game.toml. The file is the launcher's persistence. */ - bool skip_launcher_setting = false; /* [launcher] skip_launcher from settings.toml */ - std::string settings_bios_storage; /* must outlive resolve_bios_for_runtime */ - std::string netplay_player_name; /* [netplay] player_name from settings.toml */ - bool has_netplay_player_name = false; - { - std::filesystem::path settings_path = - exe_dir_from_argv(argv[0]) / "settings.toml"; - const auto settings_utf8 = settings_path.u8string(); - g_host_user_settings_path_utf8.assign(settings_utf8.begin(), - settings_utf8.end()); -#if defined(RECOMP_LAUNCHER) - g_lnch_settings_path = settings_path; -#endif - const PSXRecompV4::UserSettings us = - PSXRecompV4::load_user_settings(settings_path); - if (us.parse_error) { - /* The file exists but is not valid TOML: every setting in it (the - * user's renderer choice, BIOS/disc paths, ...) is being ignored, - * and any later launcher save would overwrite it with defaults. - * Preserve their file and say so loudly instead of both failing - * silently (GH Tomba2Recomp#1 triage: a stray character disabled a - * user's whole settings file with no indication anywhere). */ - std::error_code rn_ec; - std::filesystem::path bad = settings_path; - bad += ".bad"; - std::filesystem::remove(bad, rn_ec); - rn_ec.clear(); - std::filesystem::rename(settings_path, bad, rn_ec); - launcher_warning("Settings file ignored", - "settings.toml has a TOML syntax error, so ALL settings in it were " - "ignored this run (renderer, BIOS/disc paths, everything).\n\n" + - (rn_ec ? "The broken file was left at:\n" + settings_path.string() - : "The broken file was preserved as:\n" + bad.string()) + - "\n\nFix the syntax error and restore the file, or set your options " - "again from the launcher (a fresh settings.toml will be written)."); - } - if (us.has_skip_launcher) skip_launcher_setting = us.skip_launcher; - if (us.has_renderer) { - if (us.renderer == 2 && !vulkan_offered) { - g_video_renderer = 1; - std::fprintf(stdout, - "psxrecomp: settings requested Vulkan, but this game does not " - "offer Vulkan in the launcher; using OpenGL.\n"); - } else { - g_video_renderer = us.renderer; - } - } - if (us.has_netplay_player_name && !us.netplay_player_name.empty()) { - netplay_player_name = us.netplay_player_name; - has_netplay_player_name = true; - } -#if defined(RECOMP_LAUNCHER) - if (us.has_netplay_lobby_url && !us.netplay_lobby_url.empty()) - g_lnch_lobby_url = us.netplay_lobby_url; -#endif - if (us.has_supersampling) g_video_scale = us.supersampling; - if (us.has_window_width) g_video_win_w = us.window_width; - if (us.has_antialiasing) g_video_aa = us.antialiasing; - if (us.has_texture_filter) g_video_texfilter = us.texture_filter; - if (us.has_screen_kind) g_video_screen = us.screen_kind; - if (us.has_auto_skip_fmv) g_auto_skip_fmv = us.auto_skip_fmv ? 1 : 0; - if (us.has_turbo_loads) g_turbo_loads_enabled = us.turbo_loads ? 1 : 0; - /* fast_boot is deliberately NOT restored from settings.toml: the - * launcher exposes no control for it, so the persisted row can only - * echo whatever game.toml said on some EARLIER run — a write-only - * latch that silently overrode a later config change (flipping - * game.toml fast_boot=false still skipped the boot animation). - * Config + env own it until a launcher toggle exists; if one is - * added, restore this line together with the UI. The seed write - * below still persists the effective value for inspection. */ -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP - /* Disruptor's intro switch has a control and covers the BIOS animation too. */ - if (us.has_skip_intro && us.skip_intro && - !std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) - fast_boot = true; -#endif -#ifdef PSX_HAS_DISRUPTOR_LANGUAGE - /* Named before the drive first reads the game disc. */ - if (us.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) - disruptor_language_set_disc(us.language_disc.c_str()); -#endif - if (us.has_bios_hle) bios_hle_requested = us.bios_hle; - if (us.has_fullscreen) g_fullscreen = us.fullscreen; - if (us.has_aspect_ratio) { - g_video_aspect_num = us.aspect_num; - g_video_aspect_den = us.aspect_den; - } - if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; - if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; - if (us.has_master_volume) - (void)psx_host_audio_set_master_volume(us.master_volume); - if (us.has_audio_muted) - (void)psx_host_audio_set_muted(us.audio_muted ? 1 : 0); - /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- - * as-hide treatment as lock_mode / ws_offered below) so a stale - * settings.toml or launcher-less build can never point a bundled - * build at a foreign image. The identity gate is the backstop. */ - /* A non-empty BIOS in settings.toml is the player's choice, made in - * the launcher's SYSTEM row, and is honoured on every build — that row - * is the only way to opt into a retail image now that bundled builds - * no longer prompt. An EMPTY value is equally meaningful: it is the - * launcher's Clear, i.e. "go back to the bundled BIOS", so it must NOT - * be adopted as a path. - * - * The identity gate is what protects a bundled build from a stale or - * foreign path here — previously this was guarded by refusing to read - * the setting at all, which would now silently ignore the player. */ - if (us.has_bios_path && !bios_from_cli && !us.bios_path.empty()) { - settings_bios_storage = us.bios_path.string(); - bios_path = settings_bios_storage.c_str(); - bios_explicit = true; - } - if (us.has_disc_path && !disc_override_path) - resolved_disc = normalize_disc_path_for_launch(us.disc_path); - if (us.has_memcard_dir) memcard_dir = us.memcard_dir; - if (us.has_memcard1_path) memcard1_path = us.memcard1_path; - if (us.has_memcard2_path) memcard2_path = us.memcard2_path; - if (us.has_memcard1_enabled) memcard1_enabled = us.memcard1_enabled; - if (us.has_memcard2_enabled) memcard2_enabled = us.memcard2_enabled; - if (us.has_language) resolved_language = us.language; - if (us.has_p1_device) p1_device = us.p1_device; - if (us.has_p2_device) p2_device = us.p2_device; - if (us.has_p1_mode) p1_mode = us.p1_mode; - if (us.has_p2_mode) p2_mode = us.p2_mode; - if (us.has_deadzone) resolved_deadzone = us.deadzone; - if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; - if (us.has_vsync) g_video_vsync = us.vsync; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (us.has_frame_interpolation) - g_frame_interpolation = us.frame_interpolation ? 1 : 0; - if (us.has_frame_interpolation_fps) - g_frame_interpolation_fps = us.frame_interpolation_fps; -#if defined(DISRUPTOR_DEV_MENU) - /* Migrate legacy image-blend preferences to geometry at startup. - * Explicit environment selection below can still opt into legacy - * modes for one-run comparisons. */ - g_frame_interpolation_blend_default = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; - g_frame_interpolation_blend = g_frame_interpolation_blend_default; -#else - if (us.has_frame_interpolation_blend) { - g_frame_interpolation_blend_default = - us.frame_interpolation_blend; - g_frame_interpolation_blend = us.frame_interpolation_blend; - } -#endif -#endif - if (us.has_geometry_correction) - g_geometry_correction = us.geometry_correction; - if (us.has_perspective_textures) - g_texture_correction = us.perspective_textures; - if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || - us.has_parappa_timing_extra_late) { - parappa_apply_timing_setting( - us.has_parappa_timing_mode ? us.parappa_timing_mode : "custom", - us.has_parappa_timing_extra_early ? us.parappa_timing_extra_early : -1, - us.has_parappa_timing_extra_late ? us.parappa_timing_extra_late : -1, - "settings.toml"); - } - } - - /* The launcher must inspect the same explicit disc override that the - * runtime will validate and mount. Applying --disc only in the later - * resolve_disc_for_runtime() pass left the launcher showing game.toml's - * path, which may not exist in a build/worktree even though the CLI path - * is valid. */ - if (disc_override_path && disc_override_path[0]) - resolved_disc = normalize_disc_path_for_launch(disc_override_path); - - /* lock_mode: the game supports exactly ONE pad type (e.g. X4 / Tomba 2 are - * digital-only — X4's pre-DualShock libpad silently discards input from a - * pad answering id 0x73). The launcher hides its selector for such games, - * but that alone left two holes: (a) launcher-less builds still honoured a - * settings.toml p1_mode/p2_mode, and (b) a settings.toml persisted BEFORE - * the game declared lock_mode fed the stale mode back as the launcher's - * locked_mode. Clamp to the game-declared modes here, after every - * config/settings source has been applied, so a locked game can never boot - * a pad type it doesn't support. */ - if (ctrl_lock_mode) { - p1_mode = ctrl_locked_p1_mode; - p2_mode = ctrl_locked_p2_mode; - } - /* allow_hybrid=false removes Hybrid from the game's supported controller - * modes. Clamp an old persisted Hybrid value here as well as hiding it in - * recomp-ui, so launcher-less builds cannot revive an unsupported mode. - * Prefer each port's game-declared default; malformed/legacy configs that - * also default to Hybrid fall back to Analog, matching recomp-ui. */ - if (!ctrl_allow_hybrid) { - const int fallback_p1 = - ctrl_locked_p1_mode == PSXRecompV4::PAD_MODE_HYBRID - ? PSXRecompV4::PAD_MODE_ANALOG : ctrl_locked_p1_mode; - const int fallback_p2 = - ctrl_locked_p2_mode == PSXRecompV4::PAD_MODE_HYBRID - ? PSXRecompV4::PAD_MODE_ANALOG : ctrl_locked_p2_mode; - if (p1_mode == PSXRecompV4::PAD_MODE_HYBRID) p1_mode = fallback_p1; - if (p2_mode == PSXRecompV4::PAD_MODE_HYBRID) p2_mode = fallback_p2; - } - - /* A game may migrate Skip FMVs from generic Settings into its mod catalog. - * Clamp stale settings before seeding recomp-ui; an enabled activation - * plugin applies the feature after the final mod-plan commit. */ - if (!skip_fmv_offered && g_auto_skip_fmv) { - std::fprintf(stdout, - "psxrecomp: Skip FMVs is mod-owned for this title; " - "ignoring the legacy Settings value\n"); - g_auto_skip_fmv = 0; - } - /* A game may likewise move load acceleration into its mod catalog. The - * activation plugin runs after final plan commit, so clear both the - * game.toml default and stale Settings value before the launcher is seeded. */ - if (!turbo_loads_offered && g_turbo_loads_enabled) { - std::fprintf(stdout, - "psxrecomp: Turbo loads is mod-owned for this title; " - "ignoring the legacy Settings value\n"); - g_turbo_loads_enabled = 0; - } - /* Treat presentation interpolation the same way when a title moves it to - * Mods. Both the boolean and target rate are cleared so launcher-less - * starts cannot revive an old generic Settings selection. */ - if (!frame_interpolation_offered && - (g_frame_interpolation || g_frame_interpolation_fps)) { - std::fprintf(stdout, - "psxrecomp: Frame interpolation is mod-owned for this title; " - "ignoring the legacy Settings value\n"); - g_frame_interpolation = 0; - g_frame_interpolation_fps = 0; - } - - /* [widescreen] offer=false: this title's widescreen is unported/unvalidated, - * so the launcher hides its toggle — and, same completeness treatment as - * lock_mode above, the runtime clamps the display aspect to native 4:3 here - * so a stale persisted 16:9 in settings.toml can't engage the hack in - * launcher-less builds either. */ - if (!ws_offered && (g_video_aspect_num != 4 || g_video_aspect_den != 3)) { - std::fprintf(stdout, "psxrecomp: widescreen not offered for this title; " - "clamping display aspect %d:%d -> 4:3\n", - g_video_aspect_num, g_video_aspect_den); - g_video_aspect_num = 4; - g_video_aspect_den = 3; - } - if (!ws_ultrawide_offered && g_video_aspect_num * 9 == g_video_aspect_den * 21) { - std::fprintf(stdout, "psxrecomp: 21:9 is not offered for this title; clamping to %s\n", - ws_offered ? "16:9" : "4:3"); - g_video_aspect_num = ws_offered ? 16 : 4; - g_video_aspect_den = ws_offered ? 9 : 3; - } - if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; - /* A title that explicitly opts into resize-driven presentation has - * accepted the runtime's full validated aspect range. Launcher-only - * ultrawide support remains capped at its historical 21:9 choice. */ - g_ws_adaptive_max_num = ws_adaptive_view_supported - ? 32 : (ws_ultrawide_offered ? 21 : 16); - g_ws_adaptive_max_den = 9; - - /* Explicit process overrides win over saved/configured presentation. - * PSX_VIDEO_ASPECT=4:3|16:9|21:9|32:9; - * PSX_LOW_LATENCY_INPUT=0/1 ; PSX_VSYNC=1(vsync)/0(immediate)/-1(adaptive); - * PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|60+; - * PSX_FRAME_INTERPOLATION_BLEND=geometry|linear|adaptive. */ - if (const char *e = std::getenv("PSX_VIDEO_ASPECT")) { - int numerator = 0; - int denominator = 0; - if (std::strcmp(e, "4:3") == 0) { - numerator = 4; denominator = 3; - } else if (std::strcmp(e, "16:9") == 0) { - numerator = 16; denominator = 9; - } else if (std::strcmp(e, "21:9") == 0) { - numerator = 21; denominator = 9; - } else if (std::strcmp(e, "32:9") == 0) { - numerator = 32; denominator = 9; - } - const bool parsed = numerator != 0; - const bool wide = numerator * 3 != denominator * 4; - const bool ultrawide = numerator * 9 > denominator * 16; - const bool offered = - !wide || (ws_offered && (!ultrawide || ws_ultrawide_offered)); - if (parsed && is_host_fixed_display_aspect(numerator, denominator) && - offered) { - g_video_aspect_num = numerator; - g_video_aspect_den = denominator; - g_ws_adaptive_view = false; - } else { - std::fprintf(stderr, - "psxrecomp: ignoring unsupported PSX_VIDEO_ASPECT=%s\n", e); - } - } - if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0; - if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e); - if (const char *e = std::getenv("PSX_SMOOTH_60FPS")) - psx_smooth_60fps_set(atoi(e) ? 1 : 0); - if (const char *e = std::getenv("PSX_GEOMETRY_CORRECTION")) - g_geometry_correction = atoi(e) ? true : false; - if (const char *e = std::getenv("PSX_TEXTURE_CORRECTION")) - g_texture_correction = atoi(e) ? true : false; -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION")) - g_frame_interpolation = atoi(e) ? 1 : 0; - if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_FPS")) { - int fps = atoi(e); - if (fps == 0 || (fps >= 30 && fps <= 1000)) g_frame_interpolation_fps = fps; - } - if (const char *e = std::getenv("PSX_FRAME_INTERPOLATION_BLEND")) { - if (strcmp(e, "geometry") == 0 || strcmp(e, "2") == 0) { - g_frame_interpolation_blend_default = - PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; - } else if (strcmp(e, "adaptive") == 0 || strcmp(e, "clarity") == 0 || - strcmp(e, "1") == 0) { - g_frame_interpolation_blend_default = - PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; - } else if (strcmp(e, "linear") == 0 || strcmp(e, "0") == 0) { - g_frame_interpolation_blend_default = - PSX_MOD_FRAME_INTERPOLATION_LINEAR; - } - g_frame_interpolation_blend = g_frame_interpolation_blend_default; - } - /* Geometry in-between frames need exact geometry, as the live switch insists. */ - if (g_frame_interpolation_blend == PSX_HOST_FRAME_INTERPOLATION_GEOMETRY && - !g_geometry_correction) - g_frame_interpolation = 0; -#endif - { - const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); - const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); - const char *late_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_LATE"); - if ((mode_env && mode_env[0]) || early_env || late_env) { - parappa_apply_timing_setting( - (mode_env && mode_env[0]) ? std::string(mode_env) : std::string("custom"), - early_env ? atoi(early_env) : -1, - late_env ? atoi(late_env) : -1, - "environment"); - } - } - - /* Apply writable-state isolation before game-options and launcher setup, - * not with the later renderer/port overrides. Explicit slot paths from a - * shared settings.toml must not escape the isolated directory. */ - if (cli_memcard_dir) { - memcard_dir = std::filesystem::path(cli_memcard_dir); - if (memcard_dir.is_relative()) - memcard_dir = exe_dir_from_argv(argv[0]) / memcard_dir; - memcard_dir = memcard_dir.lexically_normal(); - memcard1_path.clear(); - memcard2_path.clear(); - std::error_code memcard_ec; - std::filesystem::create_directories(memcard_dir, memcard_ec); - if (memcard_ec) { - std::fprintf(stderr, - "psxrecomp: cannot create --memcard-dir %s: %s\n", - memcard_dir.string().c_str(), memcard_ec.message().c_str()); - return 1; - } - std::fprintf(stdout, "psxrecomp: CLI writable-state directory = %s\n", - memcard_dir.string().c_str()); - } - - /* Resolve the effective memory-card directory now (before the launcher) so - * the launcher can introspect the real card files. The same default is used - * by the runtime below. */ - if (memcard_dir.empty()) memcard_dir = default_memcard_dir(argv[0]); - - /* The game's OWN native OPTION settings (game_options.toml, next to - * game.toml) — persisted across launches, kept separate from game.toml - * (recomp config) and settings.toml (launcher). Values are saved to - * /.options. Best-effort: a malformed file disables - * the feature, it never blocks boot. */ - if (game_config_path) { - try { - std::filesystem::path go_path = - std::filesystem::path(game_config_path).parent_path() / "game_options.toml"; - const PSXRecompV4::GameOptions go = PSXRecompV4::load_game_options(go_path); - if (!go.options.empty()) { - std::vector go_addrs; - std::vector go_sizes; - std::vector go_names; - std::vector go_vmins; - std::vector go_vmaxs; - for (const auto& o : go.options) { - go_addrs.push_back(o.addr); - go_sizes.push_back((uint8_t)o.size); - go_names.push_back(o.name.c_str()); - /* Declared range validates the persisted value at restore; no - * range => full int32 span (accept anything that fits). */ - go_vmins.push_back(o.has_range ? (int32_t)o.vmin : (int32_t)0x80000000); - go_vmaxs.push_back(o.has_range ? (int32_t)o.vmax : (int32_t)0x7FFFFFFF); - } - std::string go_state = (memcard_dir / - ((game_id.empty() ? std::string("game") : game_id) + ".options")).string(); - game_options_configure(go_state.c_str(), go_addrs.data(), go_sizes.data(), - go_names.data(), go_vmins.data(), go_vmaxs.data(), - (int)go_addrs.size()); - std::fprintf(stdout, - "psxrecomp: game options persistence armed (%d field(s)) -> %s\n", - (int)go_addrs.size(), go_state.c_str()); - } - } catch (const std::exception& ex) { - std::fprintf(stderr, "psxrecomp: game_options.toml ignored: %s\n", ex.what()); - } - } - - { - std::string mod_error; - if (!PSXRecompV4::mod_runtime_initialize( - exe_dir_from_argv(argv[0]) / "mods", game_id, - game_entry_pc, text_guard_exe_path, &mod_error)) { - std::fprintf(stderr, "psxrecomp: mods unavailable: %s\n", - mod_error.c_str()); - } - } - -#if defined(RECOMP_LAUNCHER) - /* Integrated recomp-ui launcher: shown in its own GL window before the emulator - * boots. Seeded with the effective settings (game.toml ∪ settings.toml); - * on LAUNCH the user's choices are persisted to settings.toml and applied. - * The launcher window/context is fully torn down before the emulator's own - * window is created, so the emulator boot path below is untouched. - * - * Skip the GUI (boot straight in) when ANY of: PSX_NO_LAUNCHER=1 env, - * --no-launcher, or the persisted [launcher] skip_launcher setting — unless - * --launcher forces it back on (mirrors snesrecomp's SkipLauncher / --launcher). - * This removes the dismiss-the-launcher round-trip for scripted/debug runs. */ - const bool want_launcher = - force_launcher || - (!std::getenv("PSX_NO_LAUNCHER") && !force_no_launcher && !skip_launcher_setting); - if (want_launcher) { - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { - int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ - /* Merge launcher edits into the canonical file instead of - * constructing a fresh snapshot. Game-owned sections (including - * [disruptor]) and settings not exposed by recomp-ui must survive - * a later launcher save. */ - PSXRecompV4::UserSettings seed = - PSXRecompV4::load_user_settings( - exe_dir_from_argv(argv[0]) / "settings.toml"); - if (seed.parse_error) seed = PSXRecompV4::UserSettings{}; - seed.renderer = g_video_renderer; seed.has_renderer = true; - seed.supersampling = g_video_scale; seed.has_supersampling = true; - seed.antialiasing = g_video_aa; seed.has_antialiasing = true; - seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; - seed.screen_kind = g_video_screen; seed.has_screen_kind = true; - seed.auto_skip_fmv = (g_auto_skip_fmv != 0); - seed.has_auto_skip_fmv = skip_fmv_offered; - seed.turbo_loads = (g_turbo_loads_enabled != 0); - seed.has_turbo_loads = turbo_loads_offered; - seed.fast_boot = fast_boot; seed.has_fast_boot = true; - seed.bios_hle = bios_hle_requested; seed.has_bios_hle = true; - seed.fullscreen = g_fullscreen; seed.has_fullscreen = true; - seed.frame_interpolation = (g_frame_interpolation != 0); - seed.has_frame_interpolation = frame_interpolation_offered; - seed.frame_interpolation_fps = g_frame_interpolation_fps; - seed.has_frame_interpolation_fps = frame_interpolation_offered; - seed.aspect_num = g_video_aspect_num; - seed.aspect_den = g_video_aspect_den; - seed.has_aspect_ratio = ws_offered; - seed.adaptive_view = g_ws_adaptive_view; - seed.has_adaptive_view = ws_offered; - seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; - seed.skip_launcher = skip_launcher_setting; seed.has_skip_launcher = true; - if (has_netplay_player_name) { - seed.netplay_player_name = netplay_player_name; - seed.has_netplay_player_name = true; - } - if (!g_lnch_lobby_url.empty()) { - seed.netplay_lobby_url = g_lnch_lobby_url; - seed.has_netplay_lobby_url = true; - } - if (bios_path && bios_path[0]) { seed.bios_path = bios_path; seed.has_bios_path = true; } - if (!resolved_disc.empty()) { seed.disc_path = resolved_disc; seed.has_disc_path = true; } - seed.memcard_dir = memcard_dir; seed.has_memcard_dir = true; - seed.memcard1_enabled = memcard1_enabled; seed.has_memcard1_enabled = true; - seed.memcard2_enabled = memcard2_enabled; seed.has_memcard2_enabled = true; - if (!memcard1_path.empty()) { seed.memcard1_path = memcard1_path; seed.has_memcard1_path = true; } - if (!memcard2_path.empty()) { seed.memcard2_path = memcard2_path; seed.has_memcard2_path = true; } - seed.language = resolved_language; seed.has_language = true; - seed.p1_device = p1_device; seed.has_p1_device = true; - seed.p2_device = p2_device; seed.has_p2_device = true; - seed.p1_mode = p1_mode; seed.has_p1_mode = true; - seed.p2_mode = p2_mode; seed.has_p2_mode = true; - seed.deadzone = resolved_deadzone >= 0 ? resolved_deadzone : 12000; - seed.has_deadzone = true; - seed.window_width = g_video_win_w; seed.has_window_width = true; - seed.parappa_timing_mode = - g_parappa_timing_mode == 1 ? "medium" : - g_parappa_timing_mode == 2 ? "permissive" : - g_parappa_timing_mode == 3 ? "custom" : - g_parappa_timing_mode == 4 ? "easy" : "stock"; - seed.has_parappa_timing_mode = true; - seed.parappa_timing_extra_early = g_parappa_timing_extra_early; - seed.has_parappa_timing_extra_early = true; - seed.parappa_timing_extra_late = g_parappa_timing_extra_late; - seed.has_parappa_timing_extra_late = true; - - /* recomp-ui creates + owns its SDL/GL window internally, so there - * is no launcher window/context to manage here. */ - std::string assets_dir_str = exe_dir_from_argv(argv[0]).string(); - std::string rui_initial_disc = resolved_disc.string(); - std::string rui_title = (game_name.empty() ? std::string("PSX") : game_name) - + " - Launcher"; - - RecompLauncherCSettings ls{}; - ls.output_method = 2; /* OpenGL */ - ls.window_scale = std::max(1, std::min(4, g_video_win_w / 320)); - ls.fullscreen = seed.fullscreen; - ls.ignore_aspect = 0; - ls.linear_filter = (seed.texture_filter != 0) ? 1 : 0; - ls.widescreen = (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; - ls.widescreen_hud = ls.widescreen; - ls.enable_audio = 1; - ls.audio_freq = 44100; - ls.volume = 100; - ls.player_src[0] = PSXRecompV4::launcher_source_from_device(p1_device); - ls.player_src[1] = PSXRecompV4::launcher_source_from_device(p2_device); -#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_GUID) - if (ls.player_src[0] == 2) { - std::snprintf(ls.player_gamepad_guid[0], - sizeof(ls.player_gamepad_guid[0]), "%s", - p1_device.c_str()); - } - if (ls.player_src[1] == 2) { - std::snprintf(ls.player_gamepad_guid[1], - sizeof(ls.player_gamepad_guid[1]), "%s", - p2_device.c_str()); - } -#endif - { - int rui_deadzone_pct = seed.deadzone * 100 / 32767; - ls.deadzone[0] = rui_deadzone_pct; - ls.deadzone[1] = rui_deadzone_pct; - } - ls.skip_launcher = seed.skip_launcher ? 1 : 0; - ls.msu1_enabled = 0; - ls.msu1_dir[0] = '\0'; - std::snprintf(ls.netplay_player_name, sizeof(ls.netplay_player_name), "%s", - has_netplay_player_name ? netplay_player_name.c_str() : ""); - ls.pad_mode[0] = seed.p1_mode; - ls.pad_mode[1] = seed.p2_mode; - /* Adaptive is part of the aspect vocabulary, not a second toggle. - * It follows 21:9 when ultrawide is offered, otherwise 16:9. */ - const int adaptive_aspect_index = ws_ultrawide_offered ? 3 : 2; - ls.aspect_index = seed.adaptive_view ? adaptive_aspect_index : - (seed.aspect_num * 9 == seed.aspect_den * 21) ? 2 : - (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; - ls.adaptive_view = seed.adaptive_view ? 1 : 0; - - /* ---- deeper PSX-style settings (capability-gated via launcher_profile - * below). Sourced 1:1 from PSXRecompV4::UserSettings (config_loader.h). */ - ls.window_width = seed.window_width; - ls.renderer = seed.renderer; - if (ls.renderer < 0 || ls.renderer > (vulkan_offered ? 2 : 1)) - ls.renderer = 1; - ls.supersampling = seed.supersampling; - ls.antialiasing = seed.antialiasing ? 1 : 0; - ls.texture_filter = seed.texture_filter; - ls.screen_kind = seed.screen_kind; - ls.frame_interp = seed.frame_interpolation ? 1 : 0; - ls.frame_interp_fps = seed.frame_interpolation_fps; - ls.spu_hq = seed.spu_hq ? 1 : 0; - ls.auto_skip_fmv = seed.auto_skip_fmv ? 1 : 0; - ls.turbo_loads = seed.turbo_loads ? 1 : 0; - /* Localization: index of resolved_language within lang_menu_options - * (match by code; games with no [runtime].languages list leave - * lang_menu_options empty and this stays 0 / unused). */ - ls.language_index = 0; - for (size_t li = 0; li < lang_menu_options.size(); li++) { - if (lang_menu_options[li].code == resolved_language) { - ls.language_index = (int)li; - break; - } - } - { - std::string bios_str = seed.bios_path.string(); - std::snprintf(ls.bios_path, sizeof(ls.bios_path), "%s", bios_str.c_str()); - } - - /* Memory-card slots: point each slot at the REAL card the runtime - * will use — an explicit settings.toml path if set, else the default - * /cardN.mcd the runtime derives — so the launcher's - * block grids reflect the actual on-disk saves (memcard_inspect). */ - { - std::string mc1 = seed.has_memcard1_path ? seed.memcard1_path.string() - : (memcard_dir / "card1.mcd").string(); - std::string mc2 = seed.has_memcard2_path ? seed.memcard2_path.string() - : (memcard_dir / "card2.mcd").string(); - std::snprintf(ls.memcard_path[0], sizeof(ls.memcard_path[0]), "%s", mc1.c_str()); - std::snprintf(ls.memcard_path[1], sizeof(ls.memcard_path[1]), "%s", mc2.c_str()); - } - ls.memcard_enabled[0] = seed.memcard1_enabled ? 1 : 0; - ls.memcard_enabled[1] = seed.memcard2_enabled ? 1 : 0; - - /* Region badge: game.toml [game] region wins verbatim; otherwise derive - * from the game_id serial prefix (SCUS/SLUS/LSP -> USA, SCES/SLES -> - * Europe, SCPS/SLPS/SLPM -> Japan). Empty/unknown -> no badge (gi.region - * left null; the launcher model treats that as "" and hides the row). */ - const std::string rui_region = - !game_region.empty() ? game_region : region_label_from_serial(game_id); - - /* Localization: one C-string array pointing at lang_menu_options' - * own storage (that vector outlives this call, it's a main()-scope - * local), so no separate copy is needed. Empty when the game declares - * no [runtime].languages -> Localization stays hidden (num_languages=0). */ - std::vector rui_lang_labels; - rui_lang_labels.reserve(lang_menu_options.size()); - for (const auto& lo : lang_menu_options) rui_lang_labels.push_back(lo.label.c_str()); - - RecompLauncherCGameInfo gi{}; - static const char* const kPsxRendererLabels[] = { - "Software", - "OpenGL (Recommended)", - "Vulkan" - }; - static const char* const kAdaptiveAspectLabels[] = { - "4:3 (Native)", - "16:9 (Widescreen)", - "21:9 (Ultrawide)", - "Adaptive" - }; - static const char* const kAdaptiveAspectLabelsNoUltrawide[] = { - "4:3 (Native)", - "16:9 (Widescreen)", - "Adaptive" - }; - /* System identity + full PS1 settings-surface capability set (theme, - * platform label, rom_noun, pad-mode support, aspect mask base, and - * all has_* deep-settings flags) — one profile call keeps them from - * drifting apart across PSX titles. Per-game specifics below override - * only what the profile can't know. */ - launcher_profile_apply("psx", &gi); - /* Show the SYSTEM module's BIOS row whenever this build can - * actually use a player-supplied image — i.e. some linked backend - * is not the bundled one. Bundled builds used to hide the row - * entirely, which under the current model would strand the player: - * with OpenBIOS AND a retail image linked, choosing retail (and - * clearing back to bundled) is exactly what the row is for. - * Asks the registry rather than psx_bios_image, which is not - * populated until a backend is activated. */ - gi.has_bios = psx_bios_has_selectable(); - gi.name = game_name.empty() ? nullptr : game_name.c_str(); - gi.region = rui_region.empty() ? nullptr : rui_region.c_str(); - gi.has_expected_crc = 0; /* the launcher's simple file-CRC doesn't fit - PSX multi-track discs — skip verification */ - gi.num_known_sha256 = 0; - gi.widescreen_supported = ws_offered ? 1 : 0; - gi.num_players = game_players; - gi.msu1_supported = 0; - gi.sram_path = nullptr; /* PSX uses memory cards, not SRAM -> hide SAVES */ - /* Pad-mode + aspect capabilities sourced from game.toml - * [controller]/[widescreen] via GameConfig. PSX always has pad modes. */ - gi.pad_mode_selectable = ctrl_lock_mode ? 0 : 1; - gi.allow_hybrid = ctrl_allow_hybrid ? 1 : 0; - gi.locked_pad_mode = p1_mode; /* force the game's declared mode (default_mode) */ - gi.lock_device = ctrl_lock_device ? 1 : 0; - /* No aspect capability means recomp-ui omits View mode entirely. - * Games still using the legacy Settings path advertise Native plus - * their offered wide modes; games migrating widescreen into Mods - * set [widescreen] offer=false and let a plugin own activation. */ - gi.aspect_mask = ws_offered - ? (0x1 | 0x2 | (ws_ultrawide_offered ? 0x4 : 0)) - : 0; - gi.has_frame_interp = frame_interpolation_offered ? 1 : 0; - if (ws_offered && ws_adaptive_view_supported) { - gi.aspect_labels = ws_ultrawide_offered - ? kAdaptiveAspectLabels : kAdaptiveAspectLabelsNoUltrawide; - gi.num_aspect_labels = ws_ultrawide_offered ? 4 : 3; - gi.aspect_experimental = 1; - } - gi.renderer_labels = kPsxRendererLabels; - gi.num_renderers = vulkan_offered ? 3 : 2; - gi.has_skip_fmv = skip_fmv_offered ? 1 : 0; - gi.has_turbo_loads = turbo_loads_offered ? 1 : 0; - /* Localization menu: shown only when the game declares languages. */ - if (!rui_lang_labels.empty()) { - gi.language_labels = rui_lang_labels.data(); - gi.num_languages = (int)rui_lang_labels.size(); - } - - /* Real disc-verify + memcard-inspect (identify_disc / memcard_summary_path - * via the ae_* callbacks). The launcher re-runs these whenever the - * user changes the disc or a memory card, so the verdict + block - * grids reflect the actual images. - * The expected serial/CRC ride on file-scope statics (the C-ABI - * callback can't capture these locals). */ - g_lnch_expected_serial = game_id; - g_lnch_expected_crc = game_disc_crc; - g_lnch_has_crc = game_has_disc_crc; - gi.disc_verify = ae_disc_verify; - gi.memcard_inspect = ae_memcard_inspect; - gi.mods = PSXRecompV4::mod_runtime_launcher_provider(); -#if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) - g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; - gi.netplay_supported = (game_players == 2) ? 1 : 0; - gi.netplay = &g_lnch_netplay_callbacks; -#endif - - char rui_out_disc[1024] = {0}; - int rui_rc = recomp_launcher_run_window( - rui_title.c_str(), &ls, &gi, assets_dir_str.c_str(), - rui_initial_disc.c_str(), rui_out_disc, sizeof(rui_out_disc)); - - lr = rui_rc; - - if (lr == 0) { - seed.netplay_player_name = ls.netplay_player_name; - seed.has_netplay_player_name = true; - if (rui_out_disc[0]) { - seed.disc_path = rui_out_disc; - seed.has_disc_path = true; - } - seed.fullscreen = ls.fullscreen; seed.has_fullscreen = true; - seed.skip_launcher = ls.skip_launcher != 0; seed.has_skip_launcher = true; - /* Adaptive preserves the previous fixed aspect as the initial - * window shape. Selecting a fixed entry turns adaptation off. */ - if (ws_offered) { - if (ws_adaptive_view_supported && - ls.aspect_index == adaptive_aspect_index) { - seed.adaptive_view = true; - } else { - seed.adaptive_view = false; - switch (ls.aspect_index) { - case 2: seed.aspect_num = 21; seed.aspect_den = 9; break; - case 1: seed.aspect_num = 16; seed.aspect_den = 9; break; - default: seed.aspect_num = 4; seed.aspect_den = 3; break; - } - } - seed.has_aspect_ratio = true; - seed.has_adaptive_view = true; - } else { - /* A mod-owned widescreen path starts from an unpersisted - * 4:3 baseline. Its activation plugin selects the actual - * fixed/adaptive aspect after the launcher commits Mods. */ - seed.adaptive_view = false; - seed.aspect_num = 4; - seed.aspect_den = 3; - seed.has_aspect_ratio = false; - seed.has_adaptive_view = false; - } - /* has_texture_filter is on (PSX profile) so the launcher edits - * ls.texture_filter directly (0=nearest,1=bilinear); ls.linear_filter - * is the legacy fallback field for consoles without the cap and is - * left unused here. */ - seed.texture_filter = ls.texture_filter ? 1 : 0; seed.has_texture_filter = true; - std::string p1_launcher_device = p1_device; - std::string p2_launcher_device = p2_device; -#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_GUID) - if (ls.player_gamepad_guid[0][0]) - p1_launcher_device = ls.player_gamepad_guid[0]; - if (ls.player_gamepad_guid[1][0]) - p2_launcher_device = ls.player_gamepad_guid[1]; -#endif - p1_device = PSXRecompV4::launcher_device_from_source( - ls.player_src[0], p1_launcher_device); - p2_device = PSXRecompV4::launcher_device_from_source( - ls.player_src[1], p2_launcher_device); - seed.p1_device = p1_device; seed.has_p1_device = true; - seed.p2_device = p2_device; seed.has_p2_device = true; - seed.deadzone = ls.deadzone[0] * 32767 / 100; seed.has_deadzone = true; - seed.p1_mode = ls.pad_mode[0]; seed.has_p1_mode = true; - seed.p2_mode = ls.pad_mode[1]; seed.has_p2_mode = true; - seed.parappa_timing_mode = - g_parappa_timing_mode == 1 ? "medium" : - g_parappa_timing_mode == 2 ? "permissive" : - g_parappa_timing_mode == 3 ? "custom" : - g_parappa_timing_mode == 4 ? "easy" : "stock"; - seed.has_parappa_timing_mode = true; - seed.parappa_timing_extra_early = g_parappa_timing_extra_early; - seed.has_parappa_timing_extra_early = true; - seed.parappa_timing_extra_late = g_parappa_timing_extra_late; - seed.has_parappa_timing_extra_late = true; - - /* ---- deeper PSX-style settings write-back (mirrors the seed - * fields above), all gated on by the "psx" launcher_profile caps. */ - seed.window_width = ls.window_width; seed.has_window_width = true; - seed.renderer = ls.renderer; seed.has_renderer = true; - seed.supersampling = ls.supersampling; seed.has_supersampling = true; - seed.antialiasing = ls.antialiasing != 0; seed.has_antialiasing = true; - seed.screen_kind = ls.screen_kind; seed.has_screen_kind = true; - seed.frame_interpolation = ls.frame_interp != 0; - seed.has_frame_interpolation = frame_interpolation_offered; - seed.frame_interpolation_fps = ls.frame_interp_fps; - seed.has_frame_interpolation_fps = frame_interpolation_offered; - seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; - seed.auto_skip_fmv = ls.auto_skip_fmv != 0; - seed.has_auto_skip_fmv = skip_fmv_offered; - seed.turbo_loads = ls.turbo_loads != 0; - seed.has_turbo_loads = turbo_loads_offered; - /* Bundled-BIOS builds ignore any launcher-supplied path (the - * picker is hidden, but a stale settings file could still - * carry one) — resolve_bios_for_runtime would reject it and - * the identity gate makes it meaningless. */ - if (ls.bios_path[0] && !psx_bios_image.image_bundled) { - seed.bios_path = ls.bios_path; - seed.has_bios_path = true; - } - /* Memory-card slots: enable flags + any Browse/New paths. */ - seed.memcard1_enabled = ls.memcard_enabled[0] != 0; seed.has_memcard1_enabled = true; - seed.memcard2_enabled = ls.memcard_enabled[1] != 0; seed.has_memcard2_enabled = true; - if (ls.memcard_path[0][0]) { seed.memcard1_path = ls.memcard_path[0]; seed.has_memcard1_path = true; } - if (ls.memcard_path[1][0]) { seed.memcard2_path = ls.memcard_path[1]; seed.has_memcard2_path = true; } - - /* Localization: persist the chosen language code (only meaningful - * when the game declared a menu; otherwise leave seed.language - * untouched. */ - if (!lang_menu_options.empty() && ls.language_index >= 0 && - ls.language_index < (int)lang_menu_options.size()) { - seed.language = lang_menu_options[ls.language_index].code; - seed.has_language = true; - } -#if defined(PSX_HAS_LOBBY_CLIENT) - if (ls.netplay_launch.enabled) { - const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); - if (caps && caps->valid) { - seed.aspect_num = caps->aspect_num ? caps->aspect_num : seed.aspect_num; - seed.aspect_den = caps->aspect_den ? caps->aspect_den : seed.aspect_den; - seed.has_aspect_ratio = true; - if (turbo_loads_offered) { - seed.turbo_loads = caps->turbo_loads != 0; - seed.has_turbo_loads = true; - } - if (skip_fmv_offered) { - seed.auto_skip_fmv = caps->auto_skip_fmv != 0; - seed.has_auto_skip_fmv = true; - } - if (caps->language[0]) { - seed.language = caps->language; - seed.has_language = true; - } - } - } -#endif - } - - if (lr == 1) { - std::fprintf(stdout, "psxrecomp: launcher closed; exiting.\n"); - return 0; - } - if (lr == 0) { - if (ls.netplay_launch.enabled) { - net_cfg.enabled = 1; - net_cfg.local_slot = ls.netplay_launch.local_slot; - net_cfg.input_player = ls.netplay_launch.input_player; - net_cfg.session_id = ls.netplay_launch.session_id; - net_cfg.input_delay = ls.netplay_launch.input_delay; - std::snprintf(net_cfg.bind_hostport, sizeof(net_cfg.bind_hostport), "%s", - ls.netplay_launch.bind_hostport); - std::snprintf(net_cfg.peer_hostport, sizeof(net_cfg.peer_hostport), "%s", - ls.netplay_launch.peer_hostport); - g_netplay_from_lobby = 1; - std::fprintf(stdout, - "psxrecomp: launcher netplay slot=%d bind=%s peer=%s session=%u\n", - net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); - std::fflush(stdout); - } else { - g_netplay_from_lobby = 0; - } - g_video_renderer = seed.renderer; - g_video_scale = seed.supersampling; - g_video_aa = seed.antialiasing; - g_video_texfilter = seed.texture_filter; - g_video_screen = seed.screen_kind; - g_auto_skip_fmv = skip_fmv_offered && seed.auto_skip_fmv ? 1 : 0; - g_turbo_loads_enabled = - turbo_loads_offered && seed.turbo_loads ? 1 : 0; - fast_boot = seed.fast_boot; - bios_hle_requested = seed.bios_hle; - g_fullscreen = seed.fullscreen; - g_frame_interpolation = - frame_interpolation_offered && seed.frame_interpolation ? 1 : 0; - g_frame_interpolation_fps = - frame_interpolation_offered - ? seed.frame_interpolation_fps : 0; - g_video_aspect_num = ws_offered ? seed.aspect_num : 4; - g_video_aspect_den = ws_offered ? seed.aspect_den : 3; - g_ws_adaptive_view = ws_offered && seed.adaptive_view; - g_audio_spu_hq = seed.spu_hq; - skip_launcher_setting = seed.skip_launcher; - if (seed.has_bios_path) { - settings_bios_storage = seed.bios_path.string(); - bios_path = settings_bios_storage.c_str(); - } - if (seed.has_disc_path) { - seed.disc_path = normalize_disc_path_for_launch(seed.disc_path); - resolved_disc = seed.disc_path; - } - memcard1_enabled = seed.memcard1_enabled; - memcard2_enabled = seed.memcard2_enabled; - if (seed.has_memcard1_path) memcard1_path = seed.memcard1_path; - if (seed.has_memcard2_path) memcard2_path = seed.memcard2_path; - if (seed.has_language) resolved_language = seed.language; - p1_device = seed.p1_device; p2_device = seed.p2_device; - p1_mode = seed.p1_mode; p2_mode = seed.p2_mode; - if (seed.has_deadzone) resolved_deadzone = seed.deadzone; - g_video_win_w = seed.window_width; - /* Persist the user's choices next to the exe. */ - PSXRecompV4::save_user_settings( - exe_dir_from_argv(argv[0]) / "settings.toml", seed); - } - } - } -#endif - - /* Resolve the actual stock image before mod resolution. In particular, - * --disc is a late CLI override and must participate in target hashing; - * the launcher already stores its imported stock path in resolved_disc. */ - if (game_config_path || disc_override_path || !resolved_disc.empty()) { - resolved_disc = - resolve_disc_for_runtime(resolved_disc, disc_override_path, game_id, argv[0]); - if (game_config_path && resolved_disc.empty()) { - std::fprintf(stderr, "psxrecomp: no disc image selected; exiting.\n"); - return 1; - } - } - - { - std::string mod_error; - if (!PSXRecompV4::mod_runtime_commit(resolved_disc, &mod_error)) { - std::fprintf(stderr, "psxrecomp: cannot launch with selected mods: %s\n", - mod_error.c_str()); - return 1; - } - } - /* Activation callbacks are re-run after every launcher session. Clear - * game-owned controller overrides first so disabling a package cannot - * leave its prior mode latched across a soft return to the launcher. */ - g_mod_controller_mode_override[0] = -1; - g_mod_controller_mode_override[1] = -1; - g_mod_load_wall_multiplier = -1; - g_mod_load_release_frames = -1; - g_mod_disc_speed_divisor = -1; - g_mod_disc_instant_rate = -1; - g_turbo_load_wall_multiplier = 0; - g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; - if (!turbo_loads_offered) - g_turbo_loads_enabled = 0; -#ifdef PSX_DISABLE_FRAME_INTERPOLATION - /* Clamp each runtime session after configuration and launcher selection. - * Legacy settings and environment variables cannot revive this feature. */ - g_frame_interpolation = 0; - g_frame_interpolation_fps = 0; -#endif - g_frame_interpolation_blend = g_frame_interpolation_blend_default; - mod_runtime_activate_plugins(); - if (g_mod_controller_mode_override[0] >= 0) - p1_mode = g_mod_controller_mode_override[0]; - if (g_mod_controller_mode_override[1] >= 0) - p2_mode = g_mod_controller_mode_override[1]; - if (g_mod_load_wall_multiplier >= 0) { - g_turbo_loads_enabled = 1; - g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier; - g_turbo_load_release_frames = g_mod_load_release_frames; - if (g_turbo_load_wall_multiplier) { - std::fprintf(stdout, - "psxrecomp: mod selected %dx load acceleration " - "(%d release frames)\n", - g_turbo_load_wall_multiplier, g_turbo_load_release_frames); - } else { - std::fprintf(stdout, - "psxrecomp: mod selected uncapped load acceleration " - "(%d release frames)\n", - g_turbo_load_release_frames); - } - } - - /* Re-apply the resolved language to the translation layer. text_xlate_init - * (at config load) only saw the game.toml default; this folds in the - * settings.toml override and the launcher's choice. No-op when unchanged. */ - text_xlate_set_language(resolved_language.c_str()); - - /* CLI overrides win over config — applied last, before backend/port init. - * Enables a soak fleet: several instances on distinct ports + renderers, - * including BIOS instances that have no [game]-block config. */ - if (cli_debug_port >= 0) debug_port = (uint16_t)cli_debug_port; - if (cli_renderer >= 0) g_video_renderer = cli_renderer; - if (cli_window_title) window_title = cli_window_title; - if (cli_memcard_dir) { - memcard_dir = std::filesystem::path(cli_memcard_dir); - if (memcard_dir.is_relative()) - memcard_dir = exe_dir_from_argv(argv[0]) / memcard_dir; - memcard_dir = memcard_dir.lexically_normal(); - memcard1_path.clear(); - memcard2_path.clear(); - } - - std::filesystem::path resolved_bios = - resolve_bios_for_runtime(bios_path, argv[0], bios_explicit); - if (resolved_bios.empty()) { - std::fprintf(stderr, "psxrecomp: no BIOS selected; exiting.\n"); - return 1; - } - /* memcard_dir was resolved to its default before the launcher (above). */ - - std::string bios_path_str = resolved_bios.string(); - std::string memcard_dir_str = memcard_dir.string(); - const std::filesystem::path& mod_disc = - PSXRecompV4::mod_runtime_effective_disc_path(); - std::string disc_path_str = - (mod_disc.empty() ? resolved_disc : mod_disc).string(); - if (!mod_disc.empty()) { - std::fprintf(stdout, - "psxrecomp: stock disc remains %s; mounting private mod cache %s\n", - resolved_disc.string().c_str(), mod_disc.string().c_str()); - } - -session_reboot: - /* Rematch after lobby soft-return re-enters here with updated net_cfg. */ - static int s_emu_session = 0; - const bool rematch_session = (++s_emu_session > 1); - std::fprintf(stdout, "psxrecomp runtime: loading BIOS from %s%s\n", - bios_path_str.c_str(), - rematch_session ? " (rematch)" : ""); - /* Soft-exit longjmps out of device service with a leftover guest clock; - * rematch must start from cycle 0 or vblanks never fire again. */ - psx_cycles_reset_for_boot(); - starvation_ring_reset(); - present_session_reset(); - { - extern uint64_t s_frame_count; - extern uint32_t g_debug_current_func_addr; - extern uint32_t g_debug_last_store_pc; - s_frame_count = 0; - g_debug_current_func_addr = 0; - g_debug_last_store_pc = 0; - } - memory_init(bios_path_str.c_str()); -#ifndef PSX_HAVE_VULKAN - /* Vulkan was not compiled in (PSX_ENABLE_VULKAN=OFF or no SDK found). - * Refuse a vulkan request from ANY source (config / CLI / launcher seed) - * and fall back to OpenGL, so the window is never created with - * SDL_WINDOW_VULKAN against an inert backend stub. */ - if (g_video_renderer == 2) { - std::fprintf(stdout, "psxrecomp: Vulkan renderer is not available in this build " - "(PSX_ENABLE_VULKAN=OFF); using OpenGL instead.\n"); - g_video_renderer = 1; - } -#endif - /* Select the renderer backend BEFORE gpu_init() (which runs gr_init -> - * the backend's init on the VRAM buffer). Software is the default and the - * fallback; an unavailable OpenGL backend reverts to software. */ - gr_set_backend(g_video_renderer == 2 ? GR_BACKEND_VULKAN : - g_video_renderer == 1 ? GR_BACKEND_OPENGL : GR_BACKEND_SOFTWARE); - std::fprintf(stdout, "psxrecomp: renderer backend requested: %s\n", - g_video_renderer == 2 ? "vulkan" : - g_video_renderer == 1 ? "opengl" : "software"); - gpu_init(); - /* Internal-resolution supersampling (SSAA). Must follow gpu_init (which - * runs sw_renderer_init). scale==1 is a no-op; >1 allocates the hi-res - * VRAM mirror. */ - if (g_video_scale < 1) g_video_scale = 1; - if (g_video_scale > SW_MAX_INTERNAL_SCALE) g_video_scale = SW_MAX_INTERNAL_SCALE; - gr_set_scale(g_video_scale); - g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ - gr_set_texture_filter(g_video_texfilter); - gpu_geometry_correction_set(g_geometry_correction ? 1 : 0); - if (g_geometry_correction) - std::fprintf(stdout, - "psxrecomp: exact-provenance presentation geometry enabled " - "(ambiguous SXY fallback disabled; guest GP0/GTE coordinates preserved)\n"); - if (g_texture_correction && !g_geometry_correction) { - std::fprintf(stdout, - "psxrecomp: perspective texture correction requested but exact " - "geometry is disabled; keeping affine textures\n"); - g_texture_correction = false; - } - gpu_texture_correction_set(g_texture_correction ? 1 : 0); - if (g_texture_correction) - std::fprintf(stdout, - "psxrecomp: exact-depth perspective textures enabled on the " - "presentation mirror (canonical VRAM remains affine)\n"); - /* Display aspect. Identity at the default 4:3. The present letterbox uses - * this aspect; native-wide fills it with a genuinely wider frame (no - * stretch), squash mode stretches the 4:3 frame into it. */ - gl_renderer_set_display_aspect(g_video_aspect_num, g_video_aspect_den); - if (g_video_aspect_num * 3 != g_video_aspect_den * 4 || g_ws_adaptive_view) { - /* Hold widescreen off through the BIOS boot (authentic 4:3 logos); - * the per-frame present path engages it at game entry. */ - g_ws_engaged = false; - std::fprintf(stdout, - "psxrecomp: widescreen %d:%d (%s%s%s%s; engages at game entry)\n", - g_video_aspect_num, g_video_aspect_den, - g_ws_native_wide ? "native-wide, present 1:1" - : "GTE X-squash + stretched present", - g_ws_anchor_addr ? " + sprite tags" : "", - g_ws_hud_sprt ? " + HUD squash" : "", - g_ws_adaptive_view ? " + adaptive view" : ""); - } - /* Present-time screen-colour model (verified-enhancement LUT). Default raw - * is byte-identical; PSX_SCREEN env overrides this at scanout. */ - gpu_set_screen_kind(g_video_screen); - if (g_video_scale > 1 || g_video_texfilter) - std::fprintf(stdout, - "psxrecomp: supersampling %dx (antialiasing %s, texture filter %s)\n", - g_video_scale, g_video_aa ? "on" : "off", - g_video_texfilter ? "bilinear" : "nearest"); - if (g_video_screen != 0) - std::fprintf(stdout, "psxrecomp: screen-colour model %s\n", - g_video_screen == 1 ? "crt" : g_video_screen == 2 ? "composite" - : "trinitron"); - dma_init(); - mdec_init(); - timers_init(); - interrupts_init(); - sio_init(); - psx_event_step_conservative_env_init(); - /* Seed per-player device routing from the resolved [controller] config. - * SDL controller handles are opened later (after SDL_Init); here we only - * set the PSX-visible connection + pad type so the BIOS sees the right - * ports during early boot. */ - set_player_device(g_players[0], p1_device, p1_mode); - set_player_device(g_players[1], p2_device, p2_mode); - for (int s = 0; s < 2; s++) { - /* Dev-any-input keeps P1 connected even with no assigned controller so the - * keyboard / any plugged-in controller can drive port 1 standalone. */ - const bool dev_p1 = (dev_any_input_enabled() && s == 0); - sio_set_pad_connected(s, (g_players[s].kind != 0 || dev_p1) ? 1 : 0); - sio_set_pad_analog(s, pad_mode_boot_analog(g_players[s].mode), 0x80, 0x80, 0x80, 0x80); - } - /* SPU float-shadow gate must be set before spu_init() (which runs - * spu_shadow_reset()). Default OFF; PSX_AUDIO_SHADOW env overrides. */ - spu_shadow_set_enabled(g_audio_spu_hq ? 1 : 0); - if (g_audio_spu_hq) - std::fprintf(stdout, "psxrecomp: SPU float-shadow enabled (verified-enhancement)\n"); - spu_init(); - cdrom_init(disc_path_str.empty() ? NULL : disc_path_str.c_str()); - - /* A disc was requested but nothing mounted. cdrom_init() is non-fatal here - * (BIOS-only targets run with an empty drive on purpose), so without this - * check the game would boot into an empty drive and render NOTHING -- the - * "black screen on a .cue that works as a .bin" symptom. The earlier - * validate_disc_for_launch() pass cannot catch it: identify_disc() reads - * the data track FILE directly, so a cue whose sheet the mounting reader - * rejects still shows a green "Disc verified" badge. Report the actual - * failure instead of leaving the player staring at black. */ - if (!disc_path_str.empty() && !cdrom_has_disc()) { - const PSXRecompV4::DiscPathResolution r = - PSXRecompV4::resolve_disc_path(disc_path_str); - std::string detail = - "The disc image was found and verified, but the CD-ROM drive could " - "not mount it, so the game would boot with an empty drive.\n\n" - "Selected:\n" + disc_path_str; - if (r.mount != r.picked) detail += "\nMounted as:\n" + r.mount.string(); - if (!r.note.empty()) detail += "\n\n" + r.note; - detail += "\n\nIf this is a .cue, check that every FILE line it names " - "exists next to it; selecting the .bin directly also works."; - launcher_warning("Disc Could Not Be Mounted", detail); - return 1; - } - for (const auto& route : warm_cd_routes) { - cdrom_register_warm_route(route.arm_lba, route.lbas.data(), - (int)route.lbas.size(), - route.instant_max_per_frame); - std::fprintf(stdout, - "psxrecomp: warm CD route armed at LBA %d (%zu entries, " - "%d sectors/frame)\n", - route.arm_lba, route.lbas.size(), - route.instant_max_per_frame); - } - if (!disc_path_str.empty()) { - /* GetID must report the inserted disc's license region (the BIOS CD - * driver revalidates it mid-game). Derive it from the disc's boot - * serial via the same disc_identity module the launch check uses. */ - const auto ident = PSXRecompV4::identify_disc( - disc_path_str, /*expected_serial*/"", /*expected_crc*/0, - /*has_expected_crc*/false, /*compute_crc*/false); - if (ident.region == "PAL") cdrom_set_disc_scex("SCEE"); - else if (ident.region == "NTSC-J") cdrom_set_disc_scex("SCEI"); - else if (ident.region == "NTSC-U") cdrom_set_disc_scex("SCEA"); - if (!ident.region.empty()) - std::fprintf(stdout, "psxrecomp: disc region %s (serial %s)\n", - ident.region.c_str(), ident.detected_serial.c_str()); - } - /* Arm the text-image guard now that both possible sources are resolved: - * the local EXE file (dev checkouts) and the disc image (every install). */ - if (game_config_path) - arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, - text_guard_text_size, - disc_path_str); - mod_runtime_enable_disc_patches(); - { - int divisor = 1; /* default: authentic 1x timing */ - if (disc_speed == "instant") divisor = 0; - else if (disc_speed == "4x") divisor = 4; - else if (disc_speed == "2x") divisor = 2; - if (g_mod_disc_speed_divisor >= 0) - divisor = g_mod_disc_speed_divisor; - /* Store for post-BIOS application; boot always runs at 1x so the - * BIOS disc-init sequence sees correct timing. */ - cdrom_set_game_speed(divisor); - if (g_mod_disc_instant_rate > 0) - cdrom_set_instant_rate(g_mod_disc_instant_rate); - else if (instant_rate > 0) - cdrom_set_instant_rate(instant_rate); - if (divisor != 1) - std::fprintf(stdout, "psxrecomp: disc speed divisor=%d (applied post-BIOS, " - "instant budget %d/frame)\n", - divisor, cdrom_get_instant_rate()); - } - { - std::string mc1 = memcard1_path.string(); - std::string mc2 = memcard2_path.string(); - const MemcardSlotConfig slots[2] = { - { mc1.empty() ? nullptr : mc1.c_str(), memcard1_enabled ? 1 : 0 }, - { mc2.empty() ? nullptr : mc2.c_str(), memcard2_enabled ? 1 : 0 }, - }; - memcard_init_slots(memcard_dir_str.c_str(), slots); - } - if (!rematch_session) { - std::atexit(memcard_flush_all); - /* Persist the game's native OPTION settings on any exit path (belt-and- - * suspenders; save-on-change already persists them live). No-op if not yet - * armed, so it can't overwrite the saved file with boot defaults. */ - std::atexit(game_options_save_now); -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_init(debug_port); -#else - (void)debug_port; -#endif -#ifdef PSX_COSIM - cosim_init(); /* first-divergence oracle server */ -#endif - /* Heartbeat always on — see freeze_heartbeat.c rationale. */ - freeze_heartbeat_start("psx-runtime"); - } else { -#ifndef PSX_NO_DEBUG_TOOLS - (void)debug_port; -#endif - } - /* Register game entry_pc for post-BIOS disc speed switch. Fires once when - * the BIOS hands control to the game EXE — not on the BIOS shell. */ - if (game_entry_pc != 0) - fntrace_set_game_range(game_entry_pc, 0); - - if (g_headless) { - std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); - host_ui_runtime_ready(nullptr, PSX_HOST_UI_BACKEND_NONE); /* a game module sets its session up there, saved settings included */ - } else { - /* ---- SDL init ---- */ - /* Scale quality governs SDL's logical-size -> window scaling. Linear when - * antialiasing is on so the (super)sampled frame stays smooth when the - * window is resized; nearest preserves crisp pixels otherwise. */ - SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, g_video_aa ? "1" : "0"); - SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); - /* Prefer SDL's own HIDAPI driver over platform-native so Steam's virtual - * Xbox controller (injected by Steam Input / Remote Play) is enumerated - * as a game controller rather than a raw HID device. */ - SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1"); - SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0"); - /* ...but HIDAPI's Xbox sub-driver is OFF by default on Windows (Xbox pads are - * normally RAWINPUT/XInput there). With RAWINPUT disabled above, a PHYSICAL - * Xbox One/Series controller would be claimed by nobody -> not a GameController - * -> zero input (PS5 DualSense works regardless: its HIDAPI driver is on by - * default). Enable the HIDAPI Xbox driver so HIDAPI handles Xbox pads too. */ - SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX, "1"); - if (!SDL_WasInit(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER)) { - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { - std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); - return 1; - } - } - load_input_config(argv[0]); - /* The launcher / settings.toml / game.toml deadzone (when set) is the - * user-facing authority; apply it over the input.ini value here, after - * load_input_config has read input.ini. */ - if (resolved_deadzone >= 0) - controller_deadzone = std::max(0, std::min(32767, resolved_deadzone)); - refresh_player_devices(); /* open SDL handles to match the player config */ -#ifndef PSX_SDL_NO_AUDIO - audio_trace_init(); - if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) { - PsxSdlAudioSpec want = {}; - PsxSdlAudioSpec have = {}; - want.freq = 44100; - want.format = AUDIO_S16SYS; - want.channels = 2; - want.samples = 1024; - const bool legacy = audio_legacy_mode(); - want.allow_frequency_change = legacy ? 0 : 1; - if (!legacy) - want.callback = sdl_drc_callback; /* pull model: bridge resamples + DRC */ - sdl_audio_device = psx_sdl_audio_open(&want, &have); - if (sdl_audio_device) { - if (!legacy) { - rab_config cfg; rab_config_defaults(&cfg); - cfg.channels = 2; - cfg.source_rate = 44100.0; /* SPU render rate */ - cfg.host_rate = (double)have.freq; /* actual device rate */ - cfg.target_ms = g_audio_buffer_ms; /* per-game cushion */ - if (rab_init(&s_drc, &cfg) == 0) s_drc_ready = true; - } - g_audio_host_rate = have.freq; - audio_trace_set_tap_rate(AUDIO_TAP_HOST, (uint32_t)have.freq); - (void)psx_sdl_audio_resume(sdl_audio_device); - } - } -#endif - - Uint32 win_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; - if (g_video_renderer == 1) { - configure_core_gl_context_attributes(); - win_flags |= SDL_WINDOW_OPENGL; - } - if (g_video_renderer == 2) win_flags |= SDL_WINDOW_VULKAN; - /* Fullscreen on launch (launcher's tri-state Fullscreen control): 1 = - * borderless desktop fullscreen (keeps the desktop resolution, letterboxes - * the image), 2 = exclusive fullscreen (real display-mode change), 0 = - * windowed. Matches the in-game Alt+Enter / Cmd+Ctrl+F hotkey behaviour. */ - win_flags |= psx_fullscreen_flag_for_mode(g_fullscreen); - /* Open at the user-chosen window size (default 1280 wide) instead of the - * old hardcoded 640x480, so the game doesn't boot into a tiny window. The - * height follows the configured display aspect (4:3 native, wider for the - * widescreen hack); the present path letterboxes to the same aspect, so - * the image scales to fill the larger window with no further distortion. */ - int game_w = g_video_win_w, game_h = 0; - clamp_window_aspect(&game_w, &game_h, g_video_aspect_num, g_video_aspect_den); - sdl_window = SDL_CreateWindow( - window_title.c_str(), - SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, - game_w, game_h, - win_flags - ); - if (!sdl_window) { - std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); - return 1; - } -#if defined(PSX_SDL3) - /* SDL3's creation flag can request fullscreen but cannot select its - * exclusive display mode. Complete a configured exclusive startup now, - * before renderer/context creation, so mode 2 is not silently borderless. */ - if (g_fullscreen == 2 && - !psx_apply_window_fullscreen_mode(sdl_window, g_fullscreen)) { - std::fprintf(stderr, - "psxrecomp: failed to enter configured exclusive fullscreen: %s\n", - SDL_GetError()); - } -#endif - - /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock - * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank - * count (2/3/1 beat) that reads as moving-object judder. If the panel is - * within ~2% of 60 Hz, nudge the pacer to the exact panel period so the pacer - * and vsync agree and 30fps content pads to a steady 2 refreshes each. - * Non-~60Hz panels keep the PSX rate (vsync then governs; wrong-speed sim is - * worse than a benign slow beat). */ - { - SDL_DisplayMode dm; - int disp_idx = SDL_GetWindowDisplayIndex(sdl_window); - if (disp_idx >= 0 && SDL_GetCurrentDisplayMode(disp_idx, &dm) == 0 && dm.refresh_rate > 0) { - double host_hz = (double)dm.refresh_rate; - g_host_refresh_hz = host_hz; - if (g_mod_native_vblank_rate) { - if (g_mod_native_vblank_fps) { - std::printf("psxrecomp: native VBlank mod owns pacing at " - "%u FPS; ignoring %.2f Hz panel sync\n", - (unsigned)g_mod_native_vblank_fps, - host_hz); - } else { - std::printf("psxrecomp: native VBlank mod owns uncapped " - "pacing; ignoring %.2f Hz panel sync\n", - host_hz); - } - } else if (host_hz >= 58.8 && host_hz <= 61.2) { - g_frame_period_ms = 1000.0 / host_hz; - std::printf("psxrecomp: sync-to-host-refresh: pacing to %.2f Hz panel " - "(%.4f ms/frame)\n", host_hz, g_frame_period_ms); - } else { - std::printf("psxrecomp: host panel %.2f Hz not ~60 Hz; keeping PSX " - "59.94 Hz pacing\n", host_hz); - } - } - } - - /* OpenGL backend: create the GL context now. On failure, relabel the - * facade back to software (rasterization already runs through software in - * this phase) and fall through to the SDL_Renderer present path below. */ - if (g_video_renderer == 1) { - gl_renderer_set_swap_interval(g_video_vsync); /* applied at context init */ - g_gl_active = (gl_renderer_init_context(sdl_window) != 0); - if (!g_gl_active) gr_set_backend(GR_BACKEND_SOFTWARE); - /* The GL backend establishes its real internal scale HERE (raster init), - * which is AFTER the earlier `g_video_scale = gr_scale()` sync (that ran - * before this and so saw the default scale 1). Re-sync now so the staging - * buffer below is sized for the true scale and the native-wide present - * (which uses gr_scale()) matches it — otherwise sdl_pixel_buf is - * undersized and the wide readback overflows it. */ - g_video_scale = gr_scale(); - gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, - (double)g_frame_interpolation_fps, - g_frame_interpolation_blend); - } - /* Vulkan backend: create the instance/device/swapchain on the - * SDL_WINDOW_VULKAN window. On failure, fall back to software (vkb_init - * already initialized the software renderer on the shared VRAM array). */ - if (g_video_renderer == 2) { - vk_renderer_set_present_mode(g_video_vsync); - g_vk_active = (vk_renderer_init_context(sdl_window) != 0); - if (!g_vk_active) gr_set_backend(GR_BACKEND_SOFTWARE); - g_video_scale = gr_scale(); - } - latency_ring_set_backend(g_vk_active ? "vulkan" : g_gl_active ? "opengl" : "software"); - latency_ring_set_present_mode(g_video_vsync); - /* Title bar shows the clean game title (set at window creation); the active - * renderer is reported via the debug server / config, not appended here. */ - - /* Force OpenGL renderer. - * - * History (TombaRecomp/ISSUES.md #6): - * 1. Originally SDL_RENDERER_ACCELERATED with fallback to software. - * Froze "Not Responding" after extended uptime — was thought to - * be GPU-driver-side hangs. - * 2. Switched to SDL_RENDERER_SOFTWARE only. Still froze. Software - * renderer goes through Windows GDI; the GDI path hangs the SDL - * main thread under heavy emulation load. - * 3. Bisection: NO_AUDIO+NO_RENDER ran indefinitely (~7+ min, 40k+ - * frames) but the game never progressed past BIOS boot because - * it depends on the renderer being present. NO_AUDIO alone with - * software renderer froze at frame 3084 — same as full debug - * build. So audio is innocent; software renderer (GDI path) is - * the culprit. - * 4. SDL_HINT_RENDER_DRIVER=opengl + SDL_RENDERER_ACCELERATED. - * Ran indefinitely past every prior freeze point. OpenGL driver - * uses a different presentation path that doesn't hit the GDI - * hang. This is now the default. - * - * Note: the freeze became prevalent only after the FMV-speed fix - * (commit b486c13) raised cycle throughput. Before that, the slower - * MDEC/DMA workload was below whatever GDI threshold trips the bug. */ - /* The OpenGL force above is a Windows-only workaround for the GDI - * presentation hang; macOS/Linux have no GDI path, so let SDL choose its - * native backend (Metal on Apple Silicon). PRESENTVSYNC removes tearing; - * fall back progressively if a driver can't provide vsync/accel. */ - if (!g_gl_active && !g_vk_active) { -#ifdef _WIN32 - SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl"); -#endif - /* Vsync off (g_video_vsync==0) drops PRESENTVSYNC for lowest display - * latency; the wall-clock pacer still holds 59.94Hz (may tear). */ - Uint32 rflags = SDL_RENDERER_ACCELERATED | - (g_video_vsync != 0 ? SDL_RENDERER_PRESENTVSYNC : 0u); - sdl_renderer = SDL_CreateRenderer(sdl_window, -1, rflags); - if (!sdl_renderer) - sdl_renderer = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_ACCELERATED); - if (!sdl_renderer) { - std::fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError()); - return 1; - } - - /* Present in a logical space of the configured aspect (640x480 at native - * 4:3, wider at widescreen aspects; scaled by the supersampling factor so - * the full internal resolution reaches a large/fullscreen window; SDL - * still scales and letterboxes to the real output). Identity in the - * default window when supersampling is off, so native rendering is - * unchanged. */ - g_logical_w = 480 * g_video_aspect_num * g_video_scale / g_video_aspect_den; - SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); - } - - /* Staging buffer + backing texture are sized for the internal resolution - * (640x512 native, times the supersampling factor). */ - sdl_pixel_buf = (uint32_t*)std::malloc( - (size_t)640 * g_video_scale * 512 * g_video_scale * sizeof(uint32_t)); - if (!sdl_pixel_buf) { - std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); - return 1; - } - sdl_pixel_buf_scale_capacity = g_video_scale; - - if (!g_gl_active && !g_vk_active) { - sdl_texture = SDL_CreateTexture( - sdl_renderer, - SDL_PIXELFORMAT_ARGB8888, - SDL_TEXTUREACCESS_STREAMING, - 640 * g_video_scale, 512 * g_video_scale - ); - if (!sdl_texture) { - std::fprintf(stderr, "SDL_CreateTexture failed: %s\n", SDL_GetError()); - return 1; - } - SDL_SetTextureScaleMode(sdl_texture, - g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); - } - - host_ui_runtime_ready( - sdl_window, - g_gl_active ? PSX_HOST_UI_BACKEND_OPENGL : - g_vk_active ? PSX_HOST_UI_BACKEND_VULKAN : - PSX_HOST_UI_BACKEND_SOFTWARE); - } - - /* Register vblank presentation callback. */ - gpu_set_vblank_callback(sdl_vblank_present); - - /* Delay-sync LAN (recomp-net). Menu/lobby UI is later work — CLI/env only. - * Must start after SDL so local pad capture has devices; before the guest - * fiber so the first vblanks already pump HELLO/START. */ - if (net_cfg.enabled) { - /* Transport role and gameplay slot are independent. An empty peer - * means this process listens for the first peer, even when the lobby - * owner was reordered into gameplay slot 1 (Player 2). */ - if (!net_cfg.bind_hostport[0]) { - std::fprintf(stderr, - "psxrecomp: netplay refused — empty bind " - "(bind='%s' peer='%s' session=%u)\n", - net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); - return 1; - } - /* Resolve which host PlayerInput feeds this peer's net sample. - * Auto: prefer g_players[local_slot] when assigned (same-PC: host - * C40 on P1 + guest keyboard on P2); else player 0 (two-machine). */ - if (net_cfg.input_player != 0 && net_cfg.input_player != 1) { - const int prefer = (net_cfg.local_slot == 1) ? 1 : 0; - if (prefer == 1 && g_players[1].kind != 0) - net_cfg.input_player = 1; - else - net_cfg.input_player = 0; - } - const int nrc = psx_netplay_start(&net_cfg); - if (nrc != 0) { - std::fprintf(stderr, - "psxrecomp: netplay start failed (%d) — built without recomp-net, " - "or bind/peer invalid (slot=%d bind=%s peer=%s)\n", - nrc, net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport); - return 1; - } - std::printf("psxrecomp: netplay LAN slot=%d input_player=%d delay=%d " - "bind=%s peer=%s session=%u\n", - net_cfg.local_slot, net_cfg.input_player, net_cfg.input_delay, - net_cfg.bind_hostport, net_cfg.peer_hostport, - (unsigned)net_cfg.session_id); - } - - /* Initialize CPU state. */ - CPUState cpu; - std::memset(&cpu, 0, sizeof(cpu)); - /* R3000A load-delay interlock init (Beetle: BACKED_LDWhich=0x20 = no pending - * load; ReadFudge=0 so the first load gets no fudge). Rest is correctly 0. */ - cpu.ld_which_t = 0x20; - psx_icache_reset(); /* all I-cache lines cold at reset */ - - /* Wire memory function pointers. Guest data loads now route their CPU cycle - * cost through the faithful load-delay interlock (psx_cyc_load_* in memory.c), - * so these VALUE accessors are the UNCHARGED psx_read_* — the data-access - * wait-state is charged once, inside the interlock, never here. */ - cpu.read_word = psx_read_word; - cpu.write_word = psx_write_word; - cpu.read_half = psx_read_half; - cpu.write_half = psx_write_half; - cpu.read_byte = psx_read_byte; - cpu.write_byte = psx_write_byte; - /* (sljit removed 2026-07-15: overlay_sljit_init_helpers wired the JIT-shard - * host-helper table into cpu here; no shards consume it now.) */ - - /* BIOS backend select (CLAUDE.md §0 amendment 2026-07-02): LLE (the - * recompiled BIOS — default, reference implementation, oracle) vs the - * opt-in HLE tier (implemented kernel services computed in-runtime, LLE - * fallback for everything else). Boot always starts at the real reset - * vector; with the HLE boot-skip on, the tier's dispatch hook intercepts - * the shell entry one-shot (the boot animation is skipped; kernel init + - * game EXE load still run in the real recompiled BIOS, unpaced) — this - * REPLACES the old fast_boot snapshot restore, and fast_boot=true now - * aliases the boot-skip alone. Env overrides: PSX_BIOS_HLE / - * PSX_BIOS_HLE_KEEP_INTRO ('0' = off, anything else = on). - * - * The two axes are decided in ONE pure place — psx_bios_hle_plan(), - * runtime/src/bios_hle_plan.c — because they have DIFFERENT per-image - * requirements and conflating them broke boot-skip on OpenBIOS: call-HLE - * needs deliver_event_ret, the boot-skip needs only shell_entry_phys, so - * refusing the former must not silently cancel the latter. See that - * header for the full rationale and the bug it fixes. */ - { - if (const char* e = std::getenv("PSX_BIOS_HLE")) - bios_hle_requested = (e[0] && e[0] != '0'); - if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) - bios_hle_keep_intro = (e[0] && e[0] != '0'); -#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP - if (const char* e = std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) - if (e[0] && e[0] != '0') fast_boot = true; -#endif - - PsxBiosHleRequest req; - req.bios_hle = bios_hle_requested ? 1 : 0; - req.keep_intro = bios_hle_keep_intro ? 1 : 0; - req.fast_boot = fast_boot ? 1 : 0; - req.have_deliver_event_ret = (psx_bios_image.deliver_event_ret != 0); - req.have_shell_entry = (psx_bios_image.shell_entry_phys != 0); - req.have_game_entry = (game_entry_pc != 0); - const PsxBiosHlePlan plan = psx_bios_hle_plan(req); - - /* Call-HLE is a per-image capability, not just a preference: its kernel - * service semantics were validated against the SCPH1001 kernel, and an - * image that exports no DeliverEvent anchor (psx_bios_image - * deliver_event_ret == 0, e.g. OpenBIOS until validated) declares that - * axis STRUCTURALLY UNAVAILABLE. Config/env cannot turn it on for such - * a BIOS — servicing B0 events with mismatched semantics wedges the - * guest in event waits (OpenBIOS bring-up: the shell menu hung in a - * CD-event wait under HLE, booted the game under LLE). */ - if (plan.call_hle_denied) { - std::fprintf(stdout, - "psxrecomp: bios_hle kernel-call tier unavailable on %s (no " - "DeliverEvent anchor); kernel calls stay LLE\n", - psx_bios_image.image_id); - } - /* Boot-skip refusal is only possible on an image with no shell entry at - * all. Say so rather than quietly playing the intro. */ - if (plan.boot_skip_denied) { - std::fprintf(stdout, - "psxrecomp: BIOS boot-skip unavailable on %s (no shell entry " - "anchor); playing the real intro\n", - psx_bios_image.image_id); - } - psx_bios_hle_configure(plan.call_hle, plan.boot_skip); - std::fprintf(stdout, "psxrecomp: bios_backend=%s bios_boot=%s\n", - psx_bios_hle_backend_name(), - psx_bios_hle_boot_skip_enabled() - ? "skip to game (shell skipped)" - : "real intro"); - } - - /* R3000A reset state. */ - cpu.pc = 0xBFC00000u; - cpu.cop0[12] = 0x00400000u; /* SR: BEV=1 (boot exception vectors) */ - - /* User save states (F1-F12 / Shift+F1-F12): slots live in the per-game - * memcard/save dir, keyed by entry_pc + guarded by the boot_state integrity - * key (codegen hash/abi/ver) so a stale/foreign state can never load. */ - if (game_entry_pc != 0) { - savestate_configure(memcard_dir.string().c_str(), - memory_get_bios_checksum(), game_entry_pc); -#ifdef PSX_HAS_DISRUPTOR_LANGUAGE - /* Before any state can be asked for: a language disc's states have a folder of their own. */ - disruptor_language_settle(); -#endif - /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) - * at the next safe block boundary after boot. */ - if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { - int slot = atoi(ls); - if (slot >= 0 && slot < 12) { - if (psx_netplay_active()) { - std::fprintf(stdout, - "psxrecomp: PSX_LOAD_SLOT=%d ignored (use host F1–F12 after netplay starts)\n", - slot); - } else if (savestate_request_load(slot)) { - std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot); - } - } - } - } - /* Guest netplay: sandbox .pst/.mcd under saves/netplay/ after personal - * roots are known (must follow savestate_configure + memcard_init). */ - psx_netplay_bind_guest_saves(); - - /* Let memory subsystem see SR for cache-isolation checks. */ - memory_set_sr_ptr(&cpu.cop0[12]); - - /* Wire debug server to CPU state for register queries. */ - debug_server_set_cpu(&cpu); - - /* Execute. */ - std::fprintf(stdout, "psxrecomp runtime: executing from PC=0x%08X\n", cpu.pc); - -#if defined(PSX_ORACLE_BUILD) - std::fprintf(stdout, "psxrecomp ORACLE: interpreter mode (port %d)\n", DEFAULT_DEBUG_PORT); - interp_init(&cpu); - interp_trace_enable(1); - - /* Breakpoints focused on VSync diagnostics. - * Only break on the VSync incrementer — count how many times it's hit. */ - interp_break_add(0x8005A5BCu); /* VSync counter incrementer — KSEG0 */ - - std::fprintf(stderr, "ORACLE: running with BP at 0x8005A5BC (VSync incrementer)...\n"); - std::fflush(stderr); - - uint64_t total_executed = 0; - const uint64_t max_total = 100000000ULL; /* 100M instructions */ - uint32_t vsync_hits = 0; - for (;;) { - uint32_t ran = interp_step(&cpu, 1000000); - total_executed += ran; - if (interp_hit_breakpoint()) { - vsync_hits++; - if (vsync_hits <= 3 || (vsync_hits % 100 == 0)) { - std::fprintf(stderr, "ORACLE: VSync hit #%u at %llu instructions, ra=0x%08X, gte_exec=%llu\n", - vsync_hits, (unsigned long long)total_executed, cpu.gpr[31], - (unsigned long long)gte_get_exec_count()); - /* Dump last 20 trace entries for first 3 hits. */ - if (vsync_hits <= 3) { - uint64_t tseq = interp_trace_count(); - uint32_t tavail = (tseq < 1048576ULL) ? (uint32_t)tseq : 1048576u; - uint32_t tstart = (tavail > 20) ? tavail - 20 : 0; - for (uint32_t ti = tstart; ti < tavail; ti++) { - const InterpTraceEntry* e = interp_trace_get(ti); - if (e) std::fprintf(stderr, " [%llu] PC=0x%08X insn=0x%08X ra=0x%08X v0=0x%08X\n", - (unsigned long long)e->seq, e->pc, e->insn, - e->gpr[31], e->gpr[2]); - } - } - std::fflush(stderr); - } - /* Step past the breakpoint: temporarily remove, step 1, re-add. */ - uint32_t bp_pc = cpu.pc; - interp_break_remove(bp_pc); - interp_step(&cpu, 1); - total_executed++; - interp_break_add(bp_pc); - } - if (ran == 0) { - std::fprintf(stderr, "ORACLE: halted at PC=0x%08X after %llu total instructions\n", - cpu.pc, (unsigned long long)total_executed); - break; - } - if (total_executed >= max_total) { - std::fprintf(stderr, "ORACLE: reached %llu instructions, stopping. PC=0x%08X\n", - (unsigned long long)total_executed, cpu.pc); - /* Dump last 30 trace entries (ring-relative). */ - uint64_t tseq2 = interp_trace_count(); - uint32_t tavail2 = (tseq2 < 1048576ULL) ? (uint32_t)tseq2 : 1048576u; - uint32_t tstart2 = (tavail2 > 30) ? tavail2 - 30 : 0; - for (uint32_t ti = tstart2; ti < tavail2; ti++) { - const InterpTraceEntry* e = interp_trace_get(ti); - if (e) std::fprintf(stderr, " [%llu] PC=0x%08X insn=0x%08X ra=0x%08X\n", - (unsigned long long)e->seq, e->pc, e->insn, e->gpr[31]); - } - std::fflush(stderr); - break; - } - /* Poll debug server. */ - debug_server_poll(); - } - /* Read VSync counter from RAM. */ - uint32_t vsync_counter = cpu.read_word(0x80079D9Cu); - uint32_t init_flag_48 = cpu.read_word(0x80079D48u); - uint32_t init_flag_4C = cpu.read_word(0x80079D4Cu); - std::fprintf(stderr, "ORACLE: VSync counter = 0x%08X (%u), init_flag@48 = 0x%08X, init_flag@4C = 0x%08X\n", - vsync_counter, vsync_counter, init_flag_48, init_flag_4C); - std::fprintf(stderr, "ORACLE: VSync incrementer hit count = %u\n", vsync_hits); - std::fflush(stderr); -#else - /* Deterministic TCB-scheduler trampoline (carve-out): wraps the top-level - * dispatch so a cooperative thread switch unwinds here and re-dispatches the - * target thread, instead of the old per-frame host-fiber recreate. Returns - * only on the abnormal top-level pc==0 exit; the diagnostic dump below runs - * exactly as it did for the bare psx_dispatch. (HLE_SCHEDULER_CARVEOUT_PLAN.md) - * - * Hidden toggle: PSX_HLE_SCHEDULER=0 reverts to the legacy LLE host-fiber - * bridge (default 1 = HLE). The trampoline is transparent in LLE mode (the - * fiber path never longjmps to it). */ - std::fprintf(stdout, "psxrecomp: thread scheduler = %s (PSX_HLE_SCHEDULER)\n", - psx_hle_scheduler_enabled() ? "HLE (deterministic TCB)" - : "LLE (host fibers)"); - std::fflush(stdout); - - /* General control-flow parity trace (parity_trace.h). Armed from boot via - * PSX_PARITY_TRACE=1 so the pre-divergence window is always covered (never - * arm-then-time). Watched TCB / freeze trigger / watch words are env-tunable; - * defaults target the MMX6 cutscene→gameplay wedge (thread1 0xA000E35C, - * trigger dispatch 0x800CD3F8, watch the handshake flag + state struct). */ - if (const char* pt = std::getenv("PSX_PARITY_TRACE"); pt && pt[0] && pt[0] != '0') { - auto envhex = [](const char* k, uint32_t dflt) -> uint32_t { - const char* v = std::getenv(k); - return (v && v[0]) ? (uint32_t)std::strtoul(v, nullptr, 0) : dflt; - }; - uint32_t tcb = envhex("PSX_PARITY_TCB", 0xA000E35Cu); - uint32_t trigger = envhex("PSX_PARITY_TRIGGER", 0x800CD3F8u); - uint32_t watch[PARITY_WATCH_MAX] = { - 0x801FEB78u, /* thread1 stack saved-ra slot */ - 0x8006D9ACu, /* handshake flag (setter target) */ - 0x800CD3F8u, /* cutscene state struct: outer/sub */ - 0x800CD3FCu, /* countdown */ - 0x800CD404u, /* done flag region */ - 0x800200F4u, /* func_8002000C resume point */ - }; - int wc = PARITY_WATCH_MAX; - if (const char* wl = std::getenv("PSX_PARITY_WATCH"); wl && wl[0]) { - wc = 0; std::string s(wl); size_t p = 0; - while (p < s.size() && wc < PARITY_WATCH_MAX) { - size_t c = s.find(',', p); - std::string tok = s.substr(p, c == std::string::npos ? c : c - p); - if (!tok.empty()) watch[wc++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 0); - if (c == std::string::npos) break; p = c + 1; - } - } - parity_trace_config(tcb, trigger, 0x88u, 0x00u, watch, wc); - parity_trace_arm(1); - std::fprintf(stdout, "psxrecomp: parity trace ARMED tcb=0x%08X trigger=0x%08X (%d watch)\n", - tcb, trigger, wc); - std::fflush(stdout); - } - - /* Device-event cycle ring (device_trace.h): armed from boot under the same - * PSX_PARITY_TRACE gate, or PSX_DEVTRACE=1 standalone. Captures every - * CD/DMA/timer/VBlank/SIO/SPU IRQ raise with its guest cycle for the - * cross-process device-timing diff (tools/devtrace_diff.py). */ - { - const char* pt = std::getenv("PSX_PARITY_TRACE"); - const char* dt = std::getenv("PSX_DEVTRACE"); - if ((pt && pt[0] && pt[0] != '0') || (dt && dt[0] && dt[0] != '0')) { - device_trace_arm(1); - std::fprintf(stdout, "psxrecomp: device-event trace ARMED\n"); - std::fflush(stdout); - } - } - - /* Delay-sync: do not free-run boot while HELLO/START is in flight. - * Park until tick 0 pads are published, then enter the guest. */ - if (psx_netplay_active()) { - std::printf("psxrecomp: netplay waiting for peer START + tick-0 admit…\n"); - std::fflush(stdout); - netplay_barrier_admit(-1); - if (psx_return_to_lobby_requested()) - goto soft_return_lobby; - netplay_host_present_uncap(); - std::printf("psxrecomp: netplay lockstep armed (sim_tick=%u, vsync off)\n", - (unsigned)psx_netplay_sim_tick()); - std::fflush(stdout); - } - - psx_scheduler_run(&cpu); - if (psx_return_to_lobby_requested() && g_netplay_from_lobby) - goto soft_return_lobby; -#endif - - /* If we reach here, all execution completed without MMIO abort. - * During normal operation the guest runs an infinite main loop and the - * top-level psx_dispatch NEVER returns. Reaching this point means the - * outermost trampoline loop saw cpu->pc == 0 (some jr/tail-transfer - * published a null PC) — an abnormal boot exit. Dump the always-on - * fntrace ring tail (last dispatch chain) to a JSON artifact so we can - * see exactly which targets led to the null PC. (CLAUDE.md ring-buffer - * model: consume the always-on ring after the fact, not arm-and-time.) */ - { - FILE* tf = std::fopen("psx_cps_exit_trace.json", "wb"); - if (tf) { - std::fprintf(tf, "{\n \"final_pc\": \"0x%08X\",\n \"final_ra\": \"0x%08X\",\n" - " \"final_sp\": \"0x%08X\",\n \"fntrace_seq\": %llu,\n \"tail\": [\n", - cpu.pc, cpu.gpr[31], cpu.gpr[29], - (unsigned long long)g_fntrace_seq); - uint64_t seq = g_fntrace_seq; - uint32_t n = seq < 128u ? (uint32_t)seq : 128u; - for (uint32_t i = 0; i < n; i++) { - uint64_t idx = seq - n + i; - const FntraceEntry* e = &g_fntrace_ring[idx % FNTRACE_RING_CAP]; - std::fprintf(tf, - " {\"seq\":%llu,\"frame\":%u,\"target\":\"0x%08X\",\"ra\":\"0x%08X\"," - "\"sp\":\"0x%08X\",\"a0\":\"0x%08X\",\"a1\":\"0x%08X\"}%s\n", - (unsigned long long)idx, e->frame, e->target, e->ra, e->sp, - e->a0, e->a1, (i + 1 < n) ? "," : ""); - } - std::fprintf(tf, " ],\n \"stack\": [\n"); - uint32_t sbase = (cpu.gpr[29] - 0x40u) & ~3u; - for (int w = 0; w < 40; w++) { - uint32_t a = sbase + (uint32_t)w * 4u; - uint32_t v = cpu.read_word(a); - std::fprintf(tf, " {\"addr\":\"0x%08X\",\"val\":\"0x%08X\"}%s\n", - a, v, (w < 39) ? "," : ""); - } - std::fprintf(tf, " ]\n}\n"); - std::fclose(tf); - } - } - - /* Diagnostic: the guest published a null PC at the top level (abnormal). With - * PSX_EXIT_HALT set, halt-and-serve here instead of shutting down so the - * still-loaded overlays + full guest state are live-inspectable over TCP. */ - { const char *e = std::getenv("PSX_EXIT_HALT"); - if (e && e[0] && e[0] != '0') { - extern void psx_fatal_halt(const char *reason); - psx_fatal_halt("top-level dispatch returned PC=0 (abnormal boot exit — inspect live)"); - } - } - - std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc); - { extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run; - extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; - extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; - std::fprintf(stdout, "psxrecomp runtime: [slice diag] slice_fired=%llu slice_irq_taken=%llu dirty_insns=%llu exit_pc=0x%08X reason=%u iter=%u dispatchable=%u dirty=%u in_text=%u want=%u\n", - (unsigned long long)g_slice_fired, (unsigned long long)g_slice_irq_taken, - (unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason, - g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty, - g_slice_exit_in_text, g_slice_exit_want); } - - shutdown_runtime(); - if (g_gl_active) gl_renderer_shutdown(); - if (g_vk_active) vk_renderer_shutdown(); - SDL_DestroyTexture(sdl_texture); /* NULL-safe in GL mode */ - SDL_DestroyRenderer(sdl_renderer); /* NULL-safe in GL mode */ - SDL_DestroyWindow(sdl_window); - SDL_Quit(); - - return 0; - -soft_return_lobby: - /* Netplay soft-exit: tear down the match window. Lobby resume will be - * restored through recomp-ui. */ - teardown_game_session_keep_lobby(); - psx_lobby_disconnect(); - SDL_Quit(); - return 0; -} diff --git a/psxrecomp-overlay/runtime/src/memory.c b/psxrecomp-overlay/runtime/src/memory.c deleted file mode 100644 index 86e933d..0000000 --- a/psxrecomp-overlay/runtime/src/memory.c +++ /dev/null @@ -1,2106 +0,0 @@ -/* memory.c — Phase 2 PS1 memory system. - * - * Physical address routing: - * 0x00000000..0x001FFFFF — 2 MB RAM - * 0x1F800000..0x1F8003FF — 1 KB scratchpad - * 0x1F801000..0x1F803FFF — MMIO (fatal abort) - * 0x1FC00000..0x1FC7FFFF — 512 KB BIOS ROM (read-only) - * Everything else — fatal abort (unmapped) - */ - -#include "cpu_state.h" -#include "cdrom.h" -#include "crash_trace.h" -#include "dma.h" -#include "fntrace.h" -#include "gpu.h" -#include "gte_precision.h" -#include "mdec.h" -#include "mod_memory.h" -#include "sio.h" -#include "spu.h" -#include "timers.h" -#include "lockstep.h" -#include "data_shards.h" -#include "dirty_ram_interp.h" -#include "psx_cycles.h" -#include "starvation_ring.h" -#include -#include -#include -#include - -#define RAM_SIZE (2 * 1024 * 1024) -#define SCRATCHPAD_SIZE 1024 -#define BIOS_ROM_SIZE (512 * 1024) -#define MOD_MEMORY_BASE 0x1F000000u -#define MOD_MEMORY_SIZE (1u * 1024u * 1024u) - -static uint8_t ram[RAM_SIZE]; -static uint8_t scratchpad[SCRATCHPAD_SIZE]; -static uint8_t bios_rom[BIOS_ROM_SIZE]; -static uint8_t mod_memory[MOD_MEMORY_SIZE]; -static uint32_t mod_memory_used; -static uint8_t mod_gpu_dma_memory[PSX_MOD_GPU_DMA_APERTURE_SIZE]; -static uint32_t mod_gpu_dma_memory_used; - -/* - * Trusted mods may opt into host-backed guest memory in Expansion 1. Before - * the first allocation this entire region retains hardware open-bus behavior. - */ -uint32_t psx_mod_memory_alloc(uint32_t size, uint32_t alignment) { - uint32_t start; - if (size == 0u) return 0u; - if (alignment == 0u) alignment = 1u; - if ((alignment & (alignment - 1u)) != 0u || alignment > 4096u) return 0u; - start = (mod_memory_used + alignment - 1u) & ~(alignment - 1u); - if (start > MOD_MEMORY_SIZE || size > MOD_MEMORY_SIZE - start) return 0u; - memset(mod_memory + start, 0, size); - mod_memory_used = start + size; - return 0x9F000000u + start; -} - -static int mod_memory_offset(uint32_t phys, uint32_t width, uint32_t *offset) { - uint32_t off; - if (phys < MOD_MEMORY_BASE) return 0; - off = phys - MOD_MEMORY_BASE; - if (off > mod_memory_used || width > mod_memory_used - off) return 0; - if (offset) *offset = off; - return 1; -} - -uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment) { - uint32_t start; - if (size == 0u) return 0u; - if (alignment == 0u) alignment = 1u; - if ((alignment & (alignment - 1u)) != 0u || alignment > 4096u) return 0u; - start = (mod_gpu_dma_memory_used + alignment - 1u) & ~(alignment - 1u); - if (start > PSX_MOD_GPU_DMA_APERTURE_SIZE || - size > PSX_MOD_GPU_DMA_APERTURE_SIZE - start) - return 0u; - memset(mod_gpu_dma_memory + start, 0, size); - mod_gpu_dma_memory_used = start + size; - return PSX_MOD_GPU_DMA_GUEST_BASE + start; -} - -uint32_t psx_mod_gpu_dma_memory_bytes(void) { return mod_gpu_dma_memory_used; } -const uint8_t *psx_mod_gpu_dma_memory_data(void) { return mod_gpu_dma_memory; } -int psx_mod_gpu_dma_memory_restore(const uint8_t *data, uint32_t size) { - if (size > PSX_MOD_GPU_DMA_APERTURE_SIZE || (size && !data)) return 0; - if (size) memcpy(mod_gpu_dma_memory, data, size); - memset(mod_gpu_dma_memory + size, 0, PSX_MOD_GPU_DMA_APERTURE_SIZE - size); - mod_gpu_dma_memory_used = size; - return 1; -} - -static int mod_gpu_dma_memory_offset(uint32_t phys, uint32_t width, - uint32_t *offset) { - return psx_mod_gpu_dma_aperture_offset_for( - phys, width, mod_gpu_dma_memory_used, offset); -} - -uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { - return psx_mod_gpu_dma_resolve_address_for( - address, mod_gpu_dma_memory_used); -} - -/* Exposed for inlined main-RAM load helpers in psx_cyc.h (VLC/decode hot path). */ -uint8_t *g_psx_ram = ram; -/* PSX_LOAD_DELAY gate (default on). −1 = unread; 0/1 after first resolve. */ -int g_psx_load_delay = -1; - -/* Physical address translation for guest accesses. The 2 MB main RAM is - * mirrored 4x across the first 8 MB of each segment (mem-ctrl RAM_SIZE - * register, default 0x0B88) and games rely on it — Kula World's crt0 - * parks $sp in the 4th mirror (0x807FFFF8). Fold the mirrors here so the - * RAM bounds checks below see canonical offsets; without this, mirror - * writes fell into the open-bus no-op and mirror reads returned 0 (the - * guest's stack silently vanished and $ra came back as 0). */ -static inline uint32_t psx_phys_addr(uint32_t addr) { - uint32_t phys = addr & 0x1FFFFFFFu; - if (phys < 0x00800000u) phys &= (uint32_t)(RAM_SIZE - 1); - return phys; -} - -/* Expose RAM pointer for oracle comparison (find_first_divergence). */ -uint8_t *memory_get_ram_ptr(void) { return ram; } -uint8_t *memory_get_scratchpad_ptr(void) { return scratchpad; } - -static int s_low_scratch_is_the_games; /* a loaded state of a started game: what is there is no longer the boot's */ -void memory_keep_low_boot_scratch(void) { s_low_scratch_is_the_games = 1; } - -void memory_clear_low_boot_scratch(void) { - if (s_low_scratch_is_the_games) return; - memset(ram, 0, 0x10u); -} - -/* ---- Dirty-page tracking for install-at-runtime code (CLAUDE.md Rule 18) ---- - * - * The PS1 BIOS writes 4-instruction dispatch stubs into kernel RAM at runtime - * (e.g. RAM 0xCF0 for the SIO data-byte handler). A static recompiler can't - * see those instructions because they don't exist at compile time. We track - * which RAM pages have been written-to since boot and route any psx_dispatch - * landing in such a page through a small MIPS interpreter (dirty_ram_interp.c). - * - * Granularity: 4 KB pages. Ordinary CPU writes only mark the kernel-code - * region (RAM 0..0xFFFF) where BIOS install stubs live. CD-ROM DMA can also - * mark arbitrary RAM ranges as executable candidates, which covers game - * overlays loaded from disc without treating every data write as code. - * - * Future option (Option B, see docs/dynamic_handler_install.md): when a page - * goes dirty, JIT-compile its bytes via StrictTranslator instead of running - * an interpreter. Pros: one source of MIPS semantics, hot install stubs run - * as native compiled C. Cons: gcc-at-runtime build dep, ~200 ms compile latency - * stall on first dispatch, file I/O on hot path, cache-invalidation complexity, - * Windows MinGW + dlopen friction. Revisit only if install stubs become a - * measurable hot path; today they're cold-path glue (~4k instructions per - * directory-load is sub-microsecond to interpret). */ -#define DIRTY_RAM_KERNEL_TRACK_BYTES 0x10000u -#define DIRTY_RAM_PAGE_SHIFT 12 /* 4 KB pages */ -#define DIRTY_RAM_PAGE_COUNT (RAM_SIZE >> DIRTY_RAM_PAGE_SHIFT) -#define DIRTY_RAM_BITMAP_WORDS ((DIRTY_RAM_PAGE_COUNT + 31u) / 32u) -static uint32_t dirty_ram_bitmap[DIRTY_RAM_BITMAP_WORDS]; - -/* Monotonic generation for RAM-resident CODE changes (kernel install-stub - * writes + DMA/EXE loads that mark executable ranges). Consumers that cache - * per-PC classifications of RAM instructions (the interp's widescreen - * cull/backdrop site caches) compare against this and re-derive after any - * code change — a cached kind must never survive an overlay reload. */ -uint32_t g_dirty_ram_code_gen = 1; - -static inline void dirty_ram_mark_page(uint32_t phys) { - if (phys >= RAM_SIZE) return; - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t bit = 1u << (page & 31u); - /* Generation bumps on the clean->dirty TRANSITION only: kernel-window data - * writes land here on every guest store, and bumping per write would - * invalidate the per-PC site caches continuously. Overlay (re)loads always - * bump via dirty_ram_mark_executable_range. */ - if (!(dirty_ram_bitmap[page >> 5] & bit)) g_dirty_ram_code_gen++; - dirty_ram_bitmap[page >> 5] |= bit; -} - -/* ---- Kernel-image bless ------------------------------------------------- - * Kernel Part 2 (RAM [0x500,0x8500)) is the BIOS's boot-time copy of ROM - * [0x1FC10000,0x1FC18000); the statically-recompiled kernel functions in the - * generated dispatch table were compiled FROM those ROM bytes. Those pages - * are permanently dirty (TCB saves, event tables, install stubs share the - * window), which diverted EVERY kernel dispatch to the interpreter — the - * dominant cost of event-heavy scenes (a Tomba village frame samples ~97% - * in kernel PCs) and CD-load drains. - * - * A dispatch key may run its static native function IFF the live RAM bytes - * of everything that function can execute (its body extent, emitter-supplied - * via psx_bios_kernel_bodies[]) still byte-match the ROM source. Per-entry, - * lazily verified, cached; a guest write landing INSIDE an entry's body - * drops that entry back to unverified (next dispatch re-compares). Entries - * whose bodies were runtime-patched (the BIOS installs pad/SIO handlers into - * ROM-zero gaps of the pad driver) simply never verify and stay on the - * faithful interpreter path — their behavior is unchanged by design. This is - * the runtime half of the relocation-manifest contract - * (docs/RELOCATION_MANIFEST_FORMAT.md: "runtime verifies against live RAM - * before dispatching the AOT function"). */ -/* The window/offset constants come from the LINKED recompiled BIOS itself - * (psx_bios_image, emitted into _dispatch.c from the BIOS profile) — - * they cannot disagree with the code they describe. Snapshotted into - * file-statics on the lazy init latch so the two hot paths (dispatch query, - * guest-store invalidation) keep a two-load range test instead of reading - * through the extern each time. A BIOS with no bless window exports all - * zeros: the span-0 range test then rejects every address. */ -#include "psx_bios_image.h" - -static uint32_t s_kb_lo = 0, s_kb_span = 0, s_kb_rom_off = 0; - -#define KBLESS_UNKNOWN 0u -#define KBLESS_CLEAN 1u -#define KBLESS_MISMATCH 2u -#define KBLESS_MAX_ENTRIES 4096u -static uint8_t kbless_state[KBLESS_MAX_ENTRIES]; /* parallel to bodies[] */ -static int kbless_enabled = -1; /* env PSX_KERNEL_BLESS=0 disables */ -/* Always-on counters (TCP kernel_bless). */ -static uint64_t kbless_native_hits = 0; -static uint64_t kbless_verifies = 0; -static uint64_t kbless_mismatches = 0; -static uint64_t kbless_invalidations = 0; -static uint32_t kbless_last_mismatch[6] = {0}; - -static int kbless_on(void) { - if (kbless_enabled < 0) { - const char* e = getenv("PSX_KERNEL_BLESS"); - kbless_enabled = (e && e[0] == '0') ? 0 : 1; - if (psx_bios_kernel_body_count > KBLESS_MAX_ENTRIES) kbless_enabled = 0; - s_kb_lo = psx_bios_image.kbless_ram_lo; - s_kb_span = psx_bios_image.kbless_ram_hi - psx_bios_image.kbless_ram_lo; - s_kb_rom_off = psx_bios_image.kbless_rom_off; - if (s_kb_span == 0) kbless_enabled = 0; /* BIOS with no bless window */ - /* The emitted constants must agree with each other and the ROM - * array: a window whose ROM source exceeds the image is a build - * defect, not a runtime condition. */ - if (s_kb_span > 0 && - (uint64_t)s_kb_rom_off + s_kb_span > (uint64_t)BIOS_ROM_SIZE) { - fprintf(stderr, "FATAL: psx_bios_image kbless window [0x%X,+0x%X) " - "exceeds the %u-byte ROM\n", s_kb_rom_off, s_kb_span, - (unsigned)BIOS_ROM_SIZE); - exit(1); - } - } - return kbless_enabled; -} - -/* Binary search the (key-sorted) body table. -1 if absent. */ -static int kbless_find(uint32_t phys) { - uint32_t lo = 0, hi = psx_bios_kernel_body_count; - while (lo < hi) { - uint32_t mid = (lo + hi) >> 1; - uint32_t k = psx_bios_kernel_bodies[mid].key; - if (k == phys) return (int)mid; - if (k < phys) lo = mid + 1; else hi = mid; - } - return -1; -} - -/* Dispatch-time query: may `phys` run its static native function? - * Verifies lazily; every failure mode falls back to the interpreter. */ -int psx_kernel_bless_dispatchable(uint32_t phys) { - if (!kbless_on()) return 0; - if (phys - s_kb_lo >= s_kb_span) return 0; - int i = kbless_find(phys); - if (i < 0) return 0; - uint8_t st = kbless_state[i]; - if (st == KBLESS_CLEAN) { kbless_native_hits++; return 1; } - if (st == KBLESS_MISMATCH) return 0; - const PsxKernelBody* b = &psx_bios_kernel_bodies[i]; - kbless_verifies++; - if (memcmp(ram + b->body_lo, - bios_rom + s_kb_rom_off + (b->body_lo - s_kb_lo), - b->body_hi - b->body_lo) == 0) { - kbless_state[i] = KBLESS_CLEAN; - kbless_native_hits++; - return 1; - } - kbless_state[i] = KBLESS_MISMATCH; - kbless_mismatches++; - kbless_last_mismatch[0] = phys; - kbless_last_mismatch[1] = b->body_lo; - kbless_last_mismatch[2] = b->body_hi; - { - const uint8_t *live = ram + b->body_lo; - const uint8_t *ref = bios_rom + s_kb_rom_off + - (b->body_lo - s_kb_lo); - uint32_t len = b->body_hi - b->body_lo; - uint32_t off = 0; - while (off < len && live[off] == ref[off]) off++; - kbless_last_mismatch[3] = b->body_lo + off; - kbless_last_mismatch[4] = off < len ? live[off] : 0u; - kbless_last_mismatch[5] = off < len ? ref[off] : 0u; - } - return 0; -} - -/* A write landed in the kernel window: any entry whose body contains the - * written byte must re-verify before its next native dispatch. Kernel DATA - * writes (TCB saves, event tables — the frequent case) lie outside every - * body and fall through the loop without invalidating anything; genuine - * code-window writes (install-time, patches) are rare. */ -static void kbless_note_write(uint32_t phys) { - if (kbless_enabled <= 0) return; /* also pre-init: nothing verified yet */ - if (phys - s_kb_lo >= s_kb_span) return; - uint32_t n = psx_bios_kernel_body_count; - if (n > KBLESS_MAX_ENTRIES) return; - for (uint32_t i = 0; i < n; i++) { - /* No early exit: a continuation key can sort ABOVE the written - * address while its parent body contains it (bodies overlap). n is - * a few hundred; this path fires only on kernel-window writes. */ - const PsxKernelBody* b = &psx_bios_kernel_bodies[i]; - if (phys >= b->body_lo && phys < b->body_hi && - kbless_state[i] != KBLESS_UNKNOWN) { - kbless_state[i] = KBLESS_UNKNOWN; - kbless_invalidations++; - } - } -} - -/* Range write (DMA / EXE load / savestate restore) overlapping the window: - * bulk, rare events — reset every entry rather than per-byte scanning. */ -void psx_kernel_bless_note_range(uint32_t phys, uint32_t len) { - if (kbless_enabled <= 0) return; /* also pre-init: nothing verified yet */ - if (len == 0) return; - uint32_t end = phys + len; - if (end < phys) end = 0xFFFFFFFFu; - if (end <= s_kb_lo || phys >= s_kb_lo + s_kb_span) return; - uint32_t n = psx_bios_kernel_body_count; - if (n > KBLESS_MAX_ENTRIES) return; - for (uint32_t i = 0; i < n; i++) { - if (kbless_state[i] != KBLESS_UNKNOWN) { - kbless_state[i] = KBLESS_UNKNOWN; - kbless_invalidations++; - } - } -} - -void psx_kernel_bless_stats(uint64_t out[6]) { - uint32_t n = psx_bios_kernel_body_count; - uint32_t clean = 0, mism = 0; - if (n > KBLESS_MAX_ENTRIES) n = KBLESS_MAX_ENTRIES; - for (uint32_t i = 0; i < n; i++) { - if (kbless_state[i] == KBLESS_CLEAN) clean++; - if (kbless_state[i] == KBLESS_MISMATCH) mism++; - } - out[0] = psx_bios_kernel_body_count; - out[1] = clean; - out[2] = mism; - out[3] = kbless_native_hits; - out[4] = kbless_verifies; - out[5] = kbless_invalidations; -} - -void psx_kernel_bless_mismatch_detail(uint64_t *count, uint32_t out[6]) { - if (count) *count = kbless_mismatches; - if (out) memcpy(out, kbless_last_mismatch, sizeof(kbless_last_mismatch)); -} - -static inline void dirty_ram_mark_kernel_write(uint32_t phys) { - if (phys >= DIRTY_RAM_KERNEL_TRACK_BYTES) return; - dirty_ram_mark_page(phys); - kbless_note_write(phys); -} - -int dirty_ram_is_dirty(uint32_t phys); - -/* Establish the clean compiled-image baseline for the game-EXE text region. - * Called ONCE when the game entry is first reached (fntrace game-start): by then - * the BIOS has fully loaded the boot EXE into [0x10000, FLOOR) — which IS the - * compiled image — but no gameplay overlay has run yet. The EXE load (CD DMA via - * dirty_ram_mark_executable_range) marks the whole text dirty as a FALSE POSITIVE - * (RAM == compiled image); clearing it here means dirty_ram_is_dirty() afterwards - * is true ONLY for pages a later overlay actually overwrote. The dispatch can then - * trust a clean text page to run its compiled function and divert only truly- - * overlaid pages to the interpreter (Tomba 2 loads a loader overlay over - * 0x8001Dxxx). The kernel window [0,0x10000) (BIOS install stubs) and the overlay - * region [FLOOR, RAM) are left untouched. */ -extern uint32_t g_overlay_region_floor; -void dirty_ram_clear_image_baseline(void) { - uint32_t floor = g_overlay_region_floor; - if (floor <= DIRTY_RAM_KERNEL_TRACK_BYTES) return; - if (floor > RAM_SIZE) floor = RAM_SIZE; - uint32_t first_page = DIRTY_RAM_KERNEL_TRACK_BYTES >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = (floor - 1u) >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) - dirty_ram_bitmap[page >> 5] &= ~(1u << (page & 31u)); -} - -/* Text-image divergence guard. - * - * dirty_ram_is_dirty() is intentionally coarse: it says a page was touched by - * runtime code loading. For deciding whether the original static game dispatch - * is still safe, a dirty page is not enough information: data in a code page can - * be written without changing the instructions at a target, while packed games - * can also rewrite their own text with completely different instructions. - * - * main.cpp registers the boot EXE bytes as the reference image. Writes inside - * that range mark pages whose bytes differ from the reference. At dispatch time, - * a modified page is still allowed to use the static native entry if the code - * bytes at the target match the original image; otherwise it is sticky-diverged - * and must execute from live RAM through the dirty interpreter. */ -/* Mutable: the runtime owns this heap buffer (main.cpp mallocs the EXE image and - * never frees it). Intentional data patches by the translation layer are blessed - * into it via dirty_ram_text_bless so they are not mistaken for self-modifying - * code — see dirty_ram_text_native_ok / dirty_ram_text_bless. */ -static uint8_t *text_ref_image = NULL; -static uint32_t text_ref_lo = 0, text_ref_hi = 0; -static uint32_t text_modified_bitmap[DIRTY_RAM_BITMAP_WORDS]; -static uint32_t text_diverged_bitmap[DIRTY_RAM_BITMAP_WORDS]; -static uint64_t g_text_native_blocked = 0; -static uint32_t g_text_diverged_pages = 0; -static uint64_t g_text_exact_mismatches = 0; -static uint32_t g_text_exact_last_range_lo = 0; -static uint32_t g_text_exact_last_range_len = 0; -static uint32_t g_text_exact_last_mismatch = 0; -static uint32_t g_text_exact_last_live = 0; -static uint32_t g_text_exact_last_ref = 0; - -void dirty_ram_register_text_image(uint32_t phys_lo, const uint8_t *bytes, - uint32_t len) { - if (!bytes || len == 0 || phys_lo >= RAM_SIZE) return; - if (len > RAM_SIZE - phys_lo) len = RAM_SIZE - phys_lo; - text_ref_image = (uint8_t *)bytes; /* runtime-owned mutable heap buffer */ - text_ref_lo = phys_lo; - text_ref_hi = phys_lo + len; - memset(text_modified_bitmap, 0, sizeof(text_modified_bitmap)); - memset(text_diverged_bitmap, 0, sizeof(text_diverged_bitmap)); - g_text_native_blocked = 0; - g_text_diverged_pages = 0; - g_text_exact_mismatches = 0; - g_text_exact_last_range_lo = 0; - g_text_exact_last_range_len = 0; - g_text_exact_last_mismatch = 0; - g_text_exact_last_live = 0; - g_text_exact_last_ref = 0; -} - -int dirty_ram_text_image_registered(void) { return text_ref_image != NULL; } - -static inline void text_guard_note_write(uint32_t phys, uint32_t val, int size) { - if (!text_ref_image) return; - if (phys < text_ref_lo || phys + (uint32_t)size > text_ref_hi) return; - const uint8_t *ref = text_ref_image + (phys - text_ref_lo); - uint8_t buf[4] = { (uint8_t)val, (uint8_t)(val >> 8), - (uint8_t)(val >> 16), (uint8_t)(val >> 24) }; - if (memcmp(ref, buf, (size_t)size) != 0) { - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - text_modified_bitmap[page >> 5] |= (1u << (page & 31u)); - } -} - -int dirty_ram_text_native_ok(uint32_t phys) { - if (!text_ref_image || phys < text_ref_lo || phys >= text_ref_hi) - return !dirty_ram_is_dirty(phys); - - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t bit = 1u << (page & 31u); - if (text_diverged_bitmap[page >> 5] & bit) { - g_text_native_blocked++; - return 0; - } - /* Fast path: a page never touched by a guarded write AND not runtime-dirty - * still holds the pristine compiled image — native is safe with no compare. */ - if (!(text_modified_bitmap[page >> 5] & bit) && !dirty_ram_is_dirty(phys)) - return 1; - - /* Flagged (a guarded write that differed, an overlay-dirty page, or an - * intentional runtime data patch): the compiled native code is valid IFF the - * bytes at the entry still match the reference image. Decide by the ACTUAL - * bytes, not the page-dirty heuristic — so a data-only change in a page that - * also holds code (e.g. a translation string table blessed into the ref via - * dirty_ram_text_bless) does not needlessly block a still-valid function and - * route it to psx_unknown_dispatch. A genuine code overwrite still diverges. */ - uint32_t n = 256; - if (n > text_ref_hi - phys) n = text_ref_hi - phys; - if (memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), n) == 0) - return 1; - - text_diverged_bitmap[page >> 5] |= bit; - g_text_diverged_pages++; - g_text_native_blocked++; - return 0; -} - -/* Validate the exact instruction ranges emitted for a static game function. - * Each pair is {virtual/physical lo, byte len}; non-code gaps and mutable data - * on the same page are intentionally absent. Unlike the legacy 256-byte probe, - * a mismatch never poisons an unrelated 4 KB page forever: every decision is - * made from the live bytes the native body will actually execute. - * - * exec_pc is the dispatch/resume address. Ranges that end at or before that PC - * are skipped, and a range that straddles it is clipped to [exec_pc, end). A - * runtime patch of a function prologue must not block a compiled continuation - * that never fetches the patched bytes. */ -int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, - uint32_t count, - uint32_t exec_pc) { - if (!text_ref_image || !lo_len_pairs || count == 0) return 0; - uint32_t at = exec_pc & 0x1FFFFFFFu; - int any = 0; - for (uint32_t i = 0; i < count; i++) { - uint32_t phys = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; - uint32_t len = lo_len_pairs[i * 2u + 1u]; - if (len == 0 || phys < text_ref_lo || phys >= text_ref_hi || - len > text_ref_hi - phys) { - g_text_native_blocked++; - return 0; - } - if (phys + len <= at) continue; - if (phys < at) { - len -= at - phys; - phys = at; - } - any = 1; - if (memcmp(ram + phys, text_ref_image + (phys - text_ref_lo), len) != 0) { - uint32_t off = 0; - const uint8_t *live = ram + phys; - const uint8_t *ref = text_ref_image + (phys - text_ref_lo); - while (off < len && live[off] == ref[off]) off++; - g_text_exact_mismatches++; - g_text_exact_last_range_lo = phys; - g_text_exact_last_range_len = len; - g_text_exact_last_mismatch = phys + off; - g_text_exact_last_live = off < len ? live[off] : 0; - g_text_exact_last_ref = off < len ? ref[off] : 0; - /* Do not sticky-poison the page. A continuation on the same page - * may still match its clipped ranges. */ - g_text_native_blocked++; - return 0; - } - } - if (!any) { - g_text_native_blocked++; - return 0; - } - return 1; -} - -/* Preserve the generated-code ABI used by existing game projects. */ -int dirty_ram_text_native_ok_ranges(const uint32_t *lo_len_pairs, - uint32_t count) { - return dirty_ram_text_native_ok_ranges_from(lo_len_pairs, count, 0u); -} - -void dirty_ram_text_exact_mismatch_stats(uint64_t *count, - uint32_t out[5]) { - if (count) *count = g_text_exact_mismatches; - if (!out) return; - out[0] = g_text_exact_last_range_lo; - out[1] = g_text_exact_last_range_len; - out[2] = g_text_exact_last_mismatch; - out[3] = g_text_exact_last_live; - out[4] = g_text_exact_last_ref; -} - -/* Bless an INTENTIONAL data patch into the reference image so the text-divergence - * guard does not mistake it for self-modifying code. The runtime's own - * translation layer (text_xlate) patches string/glyph tables that share 4 KB - * pages — and the guard's 256-byte compare window — with real compiled functions. - * Without this, patching a data table diverges the page and blocks native - * dispatch of every function in it (a real function fatally routes to - * psx_unknown_dispatch). By updating text_ref_image to match the patched bytes, - * the compare passes for the intentional change while a genuine game write of - * DIFFERENT bytes still diverges and is still caught. Clears the sticky diverged - * bit for affected pages so the guard re-evaluates against the updated reference. - * Byte-for-byte identical writes are a no-op. */ -void dirty_ram_text_bless(uint32_t phys, const uint8_t *bytes, uint32_t len) { - if (!text_ref_image || !bytes || len == 0) return; - if (phys >= text_ref_hi || phys + len <= text_ref_lo) return; /* out of range */ - uint32_t lo = phys < text_ref_lo ? text_ref_lo : phys; - uint32_t hi = phys + len > text_ref_hi ? text_ref_hi : phys + len; - if (hi <= lo) return; - uint8_t *ref = text_ref_image + (lo - text_ref_lo); - const uint8_t *src = bytes + (lo - phys); - if (memcmp(ref, src, hi - lo) == 0) return; /* already in sync */ - memcpy(ref, src, hi - lo); - /* Re-open the affected pages: clear the sticky diverged bit so the next - * dispatch re-runs the compare against the now-updated reference. */ - uint32_t first_page = lo >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = (hi - 1u) >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) - text_diverged_bitmap[page >> 5] &= ~(1u << (page & 31u)); -} - -uint64_t dirty_ram_text_native_blocked(void) { return g_text_native_blocked; } -uint32_t dirty_ram_text_diverged_pages(void) { return g_text_diverged_pages; } -uint32_t dirty_ram_text_modified_bitmap_word(uint32_t word_index) { - if (word_index >= DIRTY_RAM_BITMAP_WORDS) return 0; - return text_modified_bitmap[word_index]; -} -uint32_t dirty_ram_text_diverged_bitmap_word(uint32_t word_index) { - if (word_index >= DIRTY_RAM_BITMAP_WORDS) return 0; - return text_diverged_bitmap[word_index]; -} - -void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return; - psx_kernel_bless_note_range(phys, len); - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - - uint32_t first_page = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t last_page = end >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t page = first_page; page <= last_page; page++) { - dirty_ram_bitmap[page >> 5] |= (1u << (page & 31u)); - } - g_dirty_ram_code_gen++; -} - -/* Force-interp mode (tooling): PSX_FORCE_INTERP=1 makes ALL RAM above the kernel - * window report dirty, so every dispatch into game/overlay text routes to the - * dirty-RAM interpreter (the SAME path overlays take) instead of the compiled - * image. Interp-path Δ-ruler enabler: lets the cyctest isolation loops be measured - * native-INTERP vs Beetle. Consulted by every routing site that already calls - * dirty_ram_is_dirty (top dispatch, dirty_ram_dispatch_inner gates, the - * psx_dispatch_game_compiled gates) — no emitter/dispatch change needed. */ -static int dirty_ram_force_interp(void) { - static int s = -1; - if (s < 0) { const char* e = getenv("PSX_FORCE_INTERP"); s = (e && e[0] && e[0] != '0'); } - return s; -} - -/* DIAGNOSTIC ONLY (class-B reproduction, NOT a fix — Rule -1): PSX_SHELLWIN_INTERP=1 - * reports the BIOS shell-copy relocated RAM window [0x30000, 0x5AFFF] as dirty, so - * the compiled dispatch (full_function_emitter.cpp:1380) routes those addresses - * through the recovering dirty-RAM interp instead of normalize()->shell ROM. This - * peels the class-A shell-window pc=0 wedge (func_1FC42090) so the *general* - * class-B compiled exception-return pc=0 (in [0x5B000, 0x8F000)) can surface and be - * captured. Must be reverted before any merge; it is a probe, not the fix. */ -static int dirty_ram_shellwin_interp(void) { - static int s = -1; - if (s < 0) { -#ifdef PSX_SHELLWIN_INTERP_DEFAULT - s = 1; -#else - const char* e = getenv("PSX_SHELLWIN_INTERP"); - s = (e && e[0] && e[0] != '0'); -#endif - } - return s; -} - -int dirty_ram_is_dirty(uint32_t phys) { - if (phys >= RAM_SIZE) return 0; - if (dirty_ram_force_interp() && phys >= DIRTY_RAM_KERNEL_TRACK_BYTES) return 1; - if (dirty_ram_shellwin_interp() && phys >= 0x00030000u && phys <= 0x0005AFFFu) return 1; - /* Experimental fallback for overlays copied into their final location by - * ordinary guest CPU stores rather than CD DMA. Dispatch above the static - * image floor is treated as dynamic and validated by the interpreter. */ - if (phys >= g_overlay_region_floor) return 1; - uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; - return (dirty_ram_bitmap[page >> 5] >> (page & 31u)) & 1u; -} - -uint32_t dirty_ram_get_bitmap(void) { return dirty_ram_bitmap[0]; } - -uint32_t dirty_ram_get_bitmap_word(uint32_t word_index) { - if (word_index >= DIRTY_RAM_BITMAP_WORDS) return 0; - return dirty_ram_bitmap[word_index]; -} - -uint32_t dirty_ram_get_bitmap_word_count(void) { - return DIRTY_RAM_BITMAP_WORDS; -} - -void dirty_ram_set_bitmap_words(const uint32_t* words, uint32_t count) { - if (count > DIRTY_RAM_BITMAP_WORDS) count = DIRTY_RAM_BITMAP_WORDS; - for (uint32_t i = 0; i < count; i++) - dirty_ram_bitmap[i] = words[i]; -} - -/* ---- Inc3: watched overlay pages + per-page generation counters --------- - * Pages covered by a registered overlay function's code range. The store path - * (the single, audited RAM-write chokepoint — all CPU + DMA stores funnel - * here) tests the watch bitmap and, on a hit, bumps that page's generation - * counter. It does NOT eagerly invalidate (Inc1-D did): validity is now decided - * lazily, per compiled entry, at dispatch time (overlay_loader.c §8). The - * generation counter lets the loader cheaply detect "did any page covering this - * entry's code change since I last validated it?" without hashing on the store - * path. Monotonic: gen only increases, so a sum over an entry's pages is a - * perfect change detector (no aliasing). - * - * The bitmap is almost always empty, so the per-store cost on the common path - * is a single bitmap lookup. - */ -static uint32_t overlay_watch_bitmap[DIRTY_RAM_BITMAP_WORDS]; -static uint32_t overlay_page_gen[DIRTY_RAM_PAGE_COUNT]; - -void overlay_watch_set_range(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return; - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t lp = end >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t pg = fp; pg <= lp; pg++) - overlay_watch_bitmap[pg >> 5] |= (1u << (pg & 31u)); -} - -void overlay_watch_clear_range(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return; - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t lp = end >> DIRTY_RAM_PAGE_SHIFT; - for (uint32_t pg = fp; pg <= lp; pg++) - overlay_watch_bitmap[pg >> 5] &= ~(1u << (pg & 31u)); -} - -/* Sum of generation counters over the pages spanning [phys, phys+len). The - * loader stores this at validation time and compares on dispatch; any change - * means a watched page in the range was written. */ -uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len) { - if (len == 0 || phys >= RAM_SIZE) return 0; - uint32_t end = phys + len - 1u; - if (end >= RAM_SIZE || end < phys) end = RAM_SIZE - 1u; - uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; - uint32_t lp = end >> DIRTY_RAM_PAGE_SHIFT; - uint32_t sum = 0; - for (uint32_t pg = fp; pg <= lp; pg++) sum += overlay_page_gen[pg]; - return sum; -} - -static inline void overlay_watch_note_write(uint32_t phys, uint32_t size) { - uint32_t pg = phys >> DIRTY_RAM_PAGE_SHIFT; - if (pg >= DIRTY_RAM_PAGE_COUNT) return; - /* Never attach pre-write PC evidence to post-write bytes, including for - * completely unknown/self-modifying code. This is deliberately a compact - * page clear, not a capture: serializing snapshots from this universal - * guest-store hook caused unbounded queues and multi-second stalls. CD DMA - * and periodic coherent capture remain the durable variant boundaries. */ - if ((g_dirty_ram_exec_page_bitmap[pg >> 5] >> (pg & 31u)) & 1u) { - uint32_t bitmap_word = pg * (4096u / 4u / 32u); - memset(&g_dirty_ram_exec_pc_bitmap[bitmap_word], 0, - (4096u / 4u / 32u) * sizeof(uint32_t)); - memset(&g_dirty_ram_dispatch_pc_bitmap[bitmap_word], 0, - (4096u / 4u / 32u) * sizeof(uint32_t)); - g_dirty_ram_exec_page_bitmap[pg >> 5] &= ~(1u << (pg & 31u)); - } - if ((overlay_watch_bitmap[pg >> 5] >> (pg & 31u)) & 1u) { - overlay_page_gen[pg]++; - /* Also invalidate generation-aware negative overlay lookups: bytes in - * a manifested code page may now match a previously absent variant. - * Keep this separate from g_dirty_ram_code_gen: interpreter widescreen - * classifiers use that epoch and must not churn on every watched-page - * data write. */ - extern void overlay_loader_note_code_write(void); - overlay_loader_note_code_write(); - /* Self-modification of a currently-executing native entry cannot be - * recovered lazily (the next dispatch is too late) — the loader - * blacklists that entry. Everything else is handled at dispatch. */ - extern void overlay_loader_active_write_check(uint32_t phys, uint32_t size); - overlay_loader_active_write_check(phys, size); - } -} - -/* Memory control registers: 0x1F801000..0x1F80103F (16 words) + 0x1F801060 (RAM size). - * Includes expansion base/size, COM_DELAY, SPU_DELAY, CDROM_DELAY etc. */ -static uint32_t mem_ctrl[16]; /* indices 0..15 → addresses 0x1F801000..0x1F80103C */ -static uint32_t ram_size_reg; /* 0x1F801060 */ - -/* KSEG2 no-op access telemetry (see the guards in the accessors). */ -uint64_t g_kseg2_ignored_reads; -uint64_t g_kseg2_ignored_writes; - -/* Cache control register (KSEG2: 0xFFFE0130). */ -static uint32_t cache_ctrl; - -/* Pointer to cpu->cop0[12] (SR). Set once at init. - * Used by write functions to check the IsC (Isolate Cache) bit. - * When IsC is set, RAM/scratchpad writes are silently dropped — the - * real R3000A sends them to the data cache only. */ -static const uint32_t *sr_ptr; - -/* Interrupt controller — non-static so hardware subsystems can set I_STAT bits. */ -uint32_t i_stat; /* 0x1F801070 — interrupt status (AND-acknowledge semantics) */ -uint32_t i_mask; /* 0x1F801074 — interrupt enable mask */ - -/* Shadow-diff device-access detector. run_shadow_diff arms g_shadow_mmio_watch - * around its single authoritative (interpreter) probe pass; ANY MMIO touch bumps - * g_shadow_mmio_hits, so the harness can detect a device-touching function and - * SKIP the validation (native) pass — device I/O must never be double-executed - * (one spurious card/SIO/DMA write corrupts hardware state). Zero cost when not - * watching (adds 0). */ -int g_shadow_mmio_watch = 0; -uint64_t g_shadow_mmio_hits = 0; -/* Always-on MMIO access counter (reads AND writes of the device page). The - * idle-loop cycle skip (psx_cycles.c) requires "no MMIO touched since the - * last interrupt check" before fast-forwarding time: an MMIO read can return - * time-varying or side-effecting values (timer counters, CD response FIFO), - * so any touch disqualifies the window. One increment per access. */ -uint64_t g_mmio_access_count = 0; -#define SHADOW_NOTE_MMIO() do { g_mmio_access_count++; \ - g_shadow_mmio_hits += (uint64_t)g_shadow_mmio_watch; } while (0) - -/* Always-on guest store counter, bumped at the top of every raw store - * (RAM, scratchpad, MMIO, even discarded KSEG2/IsC stores — over-counting - * is conservative). Second gate of the idle-loop cycle skip: a window with - * ANY store is not a pure poll loop. CPU stores and in-flight DMA writes - * both funnel through the psx_write_*_raw chokepoints. */ -uint64_t g_guest_store_count = 0; - -/* ---- Card protocol trace: tracks I_MASK bit 7 transitions ---- */ -#define IMASK_TRACE_CAP 4096 -typedef struct { - uint32_t old_mask; - uint32_t new_mask; - uint32_t caller; /* g_debug_current_func_addr */ - uint32_t store_pc; /* g_debug_last_store_pc — exact PC of the SW/SH */ - uint8_t width; /* 8, 16, or 32 */ - uint8_t bit7_set; /* 1 if this write SET bit 7 */ - uint8_t bit7_clear; /* 1 if this write CLEARED bit 7 */ - uint8_t in_exc; -} ImaskTraceEntry; -static ImaskTraceEntry imask_trace[IMASK_TRACE_CAP]; -static int imask_trace_idx = 0; -static int imask_trace_count = 0; -static int imask_bit7_set_count = 0; -static int imask_bit7_clear_count = 0; - -static void imask_trace_record(uint32_t old_val, uint32_t new_val, uint8_t width) { - extern uint32_t g_debug_current_func_addr; - extern uint32_t g_debug_last_store_pc; - extern int psx_get_in_exception(void); - ImaskTraceEntry *e = &imask_trace[imask_trace_idx]; - e->old_mask = old_val; - e->new_mask = new_val; - e->caller = g_debug_current_func_addr; - e->store_pc = g_debug_last_store_pc; - e->width = width; - e->bit7_set = (!(old_val & 0x80) && (new_val & 0x80)) ? 1 : 0; - e->bit7_clear = ((old_val & 0x80) && !(new_val & 0x80)) ? 1 : 0; - e->in_exc = (uint8_t)psx_get_in_exception(); - if (e->bit7_set) imask_bit7_set_count++; - if (e->bit7_clear) imask_bit7_clear_count++; - imask_trace_idx = (imask_trace_idx + 1) % IMASK_TRACE_CAP; - imask_trace_count++; -} - -/* VBLANK-ack telemetry (Tomba 2 exception-reentry-storm diagnosis): counts how - * many times an I_STAT write clears the VBLANK bit (the handler's ack). Read in - * the freeze heartbeat against g_vblank_raise/deliver counts. */ -uint64_t g_vblank_ack_count = 0; - -static void interrupt_write_stat_masked(uint32_t val, uint32_t mask) { - uint32_t ack_mask = mask & 0x7FFu; - uint32_t before = i_stat; - i_stat = (i_stat & ~ack_mask) | (i_stat & val & ack_mask); - if ((before & 1u) && !(i_stat & 1u)) g_vblank_ack_count++; /* VBLANK bit 1->0 */ -} - -static void interrupt_write_mask_masked(uint32_t val, uint32_t mask, uint8_t width) { - uint32_t old = i_mask; - i_mask = ((i_mask & ~mask) | (val & mask)) & 0x7FFu; - imask_trace_record(old, i_mask, width); -} - -/* Getters for debug server */ -int memory_get_imask_bit7_set_count(void) { return imask_bit7_set_count; } -int memory_get_imask_bit7_clear_count(void) { return imask_bit7_clear_count; } -const ImaskTraceEntry *memory_get_imask_trace(int *idx_out, int *count_out) { - if (idx_out) *idx_out = imask_trace_idx; - if (count_out) *count_out = imask_trace_count; - return imask_trace; -} - -/* Tier 1 write-trace hooks (implemented in debug_server.c). */ -extern void debug_server_trace_write_check(uint32_t phys, uint32_t old_val, - uint32_t new_val, uint8_t width); -extern void debug_server_trace_mmio_write(uint32_t addr, uint32_t val, uint8_t width); -extern void debug_server_trace_mmio_read(uint32_t addr, uint32_t val, uint8_t width); -/* Targeted main-RAM read watch (debug_server.c). Flag gates the hot read path. */ -extern int g_ram_read_watch_active; -extern void debug_server_trace_ram_read_watch(uint32_t phys, uint32_t val); -extern void debug_server_trace_entryint_write(uint32_t phys, uint32_t old_val, - uint32_t new_val, uint8_t width); -extern CPUState *debug_cpu_ptr; -extern uint32_t g_debug_last_store_pc; - -/* Parity last-writer provenance (parity_trace.c): note every main-RAM write so - * the watch-word last-writer table tracks the exact producing store. No-op - * unless the parity ring is armed. */ -extern void parity_trace_note_write(uint32_t addr, uint32_t width, uint32_t writer_pc); - -/* Effective writer PC for provenance: during a DMA transfer the last CPU-store - * PC is stale/unrelated, so attribute DMA-sourced RAM writes to the PC that - * kicked the DMA (dma.c). Matches the wtrace recorder's DMA attribution. */ -extern int g_dma_exec_depth; -extern uint32_t g_dma_initiator_pc; -static inline uint32_t effective_store_pc(void) { - return (g_dma_exec_depth > 0 && g_dma_initiator_pc) ? g_dma_initiator_pc - : g_debug_last_store_pc; -} - -/* Card-byte destination capture (Phase 3 audit). Always-on. */ -extern int card_data_writes_check(uint32_t phys, uint32_t value, uint8_t width); - -static inline uint32_t read_ram_word(uint32_t phys) { - return (uint32_t)ram[phys] - | ((uint32_t)ram[phys + 1] << 8) - | ((uint32_t)ram[phys + 2] << 16) - | ((uint32_t)ram[phys + 3] << 24); -} -static inline uint16_t read_ram_half(uint32_t phys) { - return (uint16_t)ram[phys] | ((uint16_t)ram[phys + 1] << 8); -} - -/* SPU registers are now handled by spu.c */ - -void memory_set_sr_ptr(const uint32_t *p) { sr_ptr = p; } -uint32_t memory_get_sr(void) { return sr_ptr ? *sr_ptr : 0; } - -static uint32_t s_bios_checksum = 0; -uint32_t memory_get_bios_checksum(void) { return s_bios_checksum; } - -void memory_init(const char* bios_path) { - gte_precision_word_write_begin(); - s_low_scratch_is_the_games = 0; - memset(ram, 0, sizeof(ram)); - memset(scratchpad, 0, sizeof(scratchpad)); - /* Rematch re-enters without process exit — wipe sticky I/O regs that - * live outside device *_init (I_STAT/I_MASK cleared in interrupts_init). */ - memset(mem_ctrl, 0, sizeof(mem_ctrl)); - ram_size_reg = 0; - i_stat = 0; - i_mask = 0; - - FILE* f = fopen(bios_path, "rb"); - if (!f) { - fprintf(stderr, "FATAL: cannot open BIOS file: %s\n", bios_path); - exit(1); - } - size_t n = fread(bios_rom, 1, BIOS_ROM_SIZE, f); - fclose(f); - if (n != BIOS_ROM_SIZE) { - fprintf(stderr, "FATAL: BIOS file %s is %zu bytes (expected %d)\n", - bios_path, n, BIOS_ROM_SIZE); - exit(1); - } - s_bios_checksum = 0; - for (uint32_t i = 0; i < BIOS_ROM_SIZE / 4; i++) - s_bios_checksum += ((const uint32_t*)bios_rom)[i]; -} - -/* Unmapped-register access INSIDE the I/O window (0x1F801000..0x1F803FFF): - * real hardware open-buses these (reads return garbage, writes vanish; no - * fault) and games genuinely hit them — Tomba2's late attract sweeps a wild - * byte loop across the whole window (bzero/read over a 0xDF80xxxx pointer). - * Beetle returns 0 / ignores. Match it: count + (already ring-traced by the - * callers' mmio trace hooks) + open-bus. Genuinely unknown-DEVICE reads are - * still observable via the always-on MMIO rings and these counters — probes - * query the rings, per the ring-buffer doctrine. mmio_fatal is retired. */ -uint64_t g_io_openbus_reads; -uint64_t g_io_openbus_writes; - -static void mmio_fatal(uint32_t vaddr, uint32_t phys, const char* op) { - static char reason[96]; - snprintf(reason, sizeof(reason), "MMIO %s @ 0x%08X (phys 0x%08X)", op, vaddr, phys); - fprintf(stderr, "%s\n", reason); - fflush(stderr); - FILE* cf = fopen("psx_crash.txt", "w"); - if (cf) { fprintf(cf, "%s\n", reason); fclose(cf); } - psx_fatal_halt(reason); -} - -static void mmio_unimplemented(uint32_t addr, const char* op) { - static char reason[96]; - snprintf(reason, sizeof(reason), "UNIMPLEMENTED MMIO %s @ 0x%08X", op, addr); - fprintf(stderr, "%s\n", reason); - fflush(stderr); - /* Also write to a crash file for capture when stderr is lost. */ - FILE* cf = fopen("psx_crash.txt", "w"); - if (cf) { fprintf(cf, "%s\n", reason); fclose(cf); } - psx_fatal_halt(reason); -} - -static void unmapped_fatal(uint32_t vaddr, uint32_t phys, const char* op) { - /* On real PS1 hardware, reads from unmapped addresses return open bus - * (typically the last value on the data bus, or 0xFFFFFFFF). - * The BIOS intentionally probes unmapped regions (RAM size detection, - * expansion hardware detection). Fatal abort would prevent normal boot. */ - (void)vaddr; (void)phys; (void)op; -} - -/* --- MMIO read/write helpers --- */ - -static uint32_t mmio_read32_impl(uint32_t addr) { - SHADOW_NOTE_MMIO(); - /* Memory control: 0x1F801000..0x1F801020 */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Cu) { - return mem_ctrl[(addr - 0x1F801000u) >> 2]; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - return sio_read(addr); - } - /* RAM size: 0x1F801060 */ - if (addr == 0x1F801060u) { - return ram_size_reg; - } - /* Interrupts: 0x1F801070, 0x1F801074 */ - if (addr == 0x1F801070u) { sio_tick(0); return i_stat; } - if (addr == 0x1F801074u) return i_mask; - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - return dma_read(addr); - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - return timers_read(addr); - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - return cdrom_read(addr); - } - /* GPU: 0x1F801810 (GPUREAD), 0x1F801814 (GPUSTAT) */ - if (addr == 0x1F801810u) return gpu_read_gpuread(); - if (addr == 0x1F801814u) return gpu_read_gpustat(); - /* MDEC: 0x1F801820, 0x1F801824 */ - if (addr == 0x1F801820u || addr == 0x1F801824u) return mdec_read(addr); - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - return spu_read(addr); - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return 0; - } - { /* open-bus (Beetle parity) */ g_io_openbus_reads++; return 0;; } - return 0; -} - -/* Thin wrapper: record the loaded value into the MMIO-READ trace ring AFTER the - * single (side-effecting) read. Callers use mmio_read32; the body is _impl, so - * the device read executes exactly once. */ -static uint32_t mmio_read32(uint32_t addr) { - psx_devices_mmio_sync(); - uint32_t v = mmio_read32_impl(addr); - debug_server_trace_mmio_read(addr, v, 4); - return v; -} - -static void mmio_write32(uint32_t addr, uint32_t val) { - psx_devices_mmio_sync(); - SHADOW_NOTE_MMIO(); - debug_server_trace_mmio_write(addr, val, 4); - /* Memory control: 0x1F801000..0x1F801020 */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Cu) { - mem_ctrl[(addr - 0x1F801000u) >> 2] = val; - return; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - sio_write(addr, val); - return; - } - /* RAM size: 0x1F801060 */ - if (addr == 0x1F801060u) { - ram_size_reg = val; - return; - } - /* Interrupts: 0x1F801070, 0x1F801074 */ - if (addr == 0x1F801070u) { interrupt_write_stat_masked(val, 0xFFFFFFFFu); return; } - if (addr == 0x1F801074u) { interrupt_write_mask_masked(val, 0xFFFFFFFFu, 32); return; } - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - dma_write(addr, val); - return; - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - timers_write(addr, val); - return; - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - cdrom_write(addr, val); - return; - } - /* GPU GP0: 0x1F801810, GP1: 0x1F801814 */ - if (addr == 0x1F801810u) { - uint32_t src = addr; - if (g_debug_last_store_pc == 0xBFC38B1Cu && debug_cpu_ptr) { - src = (debug_cpu_ptr->gpr[4] - 4u) & 0x1FFFFCu; - } - gpu_set_gp0_source(src); - gpu_write_gp0(val); - return; - } - if (addr == 0x1F801814u) { gpu_write_gp1(val); return; } - /* MDEC: 0x1F801820, 0x1F801824 */ - if (addr == 0x1F801820u || addr == 0x1F801824u) { mdec_write(addr, val); return; } - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - spu_write(addr, val); - return; - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return; /* POST port — ignore */ - } - { /* open-bus (Beetle parity) */ g_io_openbus_writes++; return;; } -} - -static uint16_t mmio_read16_impl(uint32_t addr) { - SHADOW_NOTE_MMIO(); - /* Memory control: 0x1F801000..0x1F80103C — halfword lane of the 32-bit - * register (games touch EXP1/EXP2 config with sub-word accesses; Tomba2's - * late attract byte-writes 0x1F801000). */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t v = mem_ctrl[(addr - 0x1F801000u) >> 2]; - return (uint16_t)(v >> (8u * (addr & 2u))); - } - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - return (uint16_t)(ram_size_reg >> (8u * (addr & 2u))); - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - return (uint16_t)sio_read(addr); - } - /* Interrupts */ - if (addr >= 0x1F801070u && addr <= 0x1F801072u) { - sio_tick(0); - uint32_t shift = (addr & 2u) ? 16u : 0u; - return (uint16_t)(i_stat >> shift); - } - if (addr >= 0x1F801074u && addr <= 0x1F801076u) { - uint32_t shift = (addr & 2u) ? 16u : 0u; - return (uint16_t)(i_mask >> shift); - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - return (uint16_t)timers_read(addr); - } - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t val = dma_read(addr & ~3u); - return (addr & 2) ? (uint16_t)(val >> 16) : (uint16_t)val; - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t val = mdec_read(addr & ~3u); - return (addr & 2) ? (uint16_t)(val >> 16) : (uint16_t)val; - } - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - return (uint16_t)spu_read(addr); - } - { /* open-bus (Beetle parity) */ g_io_openbus_reads++; return 0;; } - return 0; -} - -static uint16_t mmio_read16(uint32_t addr) { - psx_devices_mmio_sync(); - uint16_t v = mmio_read16_impl(addr); - debug_server_trace_mmio_read(addr, (uint32_t)v, 2); - return v; -} - -static void mmio_write16(uint32_t addr, uint16_t val) { - psx_devices_mmio_sync(); - SHADOW_NOTE_MMIO(); - debug_server_trace_mmio_write(addr, (uint32_t)val, 2); - /* Memory control: 0x1F801000..0x1F80103C — halfword lane RMW. */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t idx = (addr - 0x1F801000u) >> 2; - uint32_t shift = 8u * (addr & 2u); - mem_ctrl[idx] = (mem_ctrl[idx] & ~(0xFFFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* RAM size register: 0x1F801060 — halfword lane RMW. */ - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - uint32_t shift = 8u * (addr & 2u); - ram_size_reg = (ram_size_reg & ~(0xFFFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - sio_write(addr, val); - return; - } - /* Interrupts */ - if (addr >= 0x1F801070u && addr <= 0x1F801072u) { - uint32_t shift = (addr & 2u) ? 16u : 0u; - interrupt_write_stat_masked((uint32_t)val << shift, 0xFFFFu << shift); - return; - } - if (addr >= 0x1F801074u && addr <= 0x1F801076u) { - uint32_t shift = (addr & 2u) ? 16u : 0u; - interrupt_write_mask_masked((uint32_t)val << shift, 0xFFFFu << shift, 16); - return; - } - /* Timers: 0x1F801100..0x1F80112F */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - timers_write(addr, val); - return; - } - /* DMA: 0x1F801080..0x1F8010FF */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t aligned = addr & ~3u; - uint32_t shift = (addr & 2) ? 16u : 0u; - uint32_t mask = 0xFFFFu << shift; - dma_write_masked(aligned, (uint32_t)val << shift, mask); - return; - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t aligned = addr & ~3u; - uint32_t cur = mdec_read(aligned); - if (addr & 2) - cur = (cur & 0x0000FFFFu) | ((uint32_t)val << 16); - else - cur = (cur & 0xFFFF0000u) | (uint32_t)val; - mdec_write(aligned, cur); - return; - } - /* SPU: 0x1F801C00..0x1F801FFF */ - if (addr >= 0x1F801C00u && addr <= 0x1F801FFFu) { - spu_write(addr, val); - return; - } - { /* open-bus (Beetle parity) */ g_io_openbus_writes++; return;; } -} - -static uint8_t mmio_read8_impl(uint32_t addr) { - SHADOW_NOTE_MMIO(); - /* Memory control: 0x1F801000..0x1F80103C — byte lane of the 32-bit reg. */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t v = mem_ctrl[(addr - 0x1F801000u) >> 2]; - return (uint8_t)(v >> (8u * (addr & 3u))); - } - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - return (uint8_t)(ram_size_reg >> (8u * (addr & 3u))); - } - /* Interrupts: 0x1F801070..0x1F801077 (I_STAT, I_MASK) */ - if (addr >= 0x1F801070u && addr <= 0x1F801077u) { - if (addr < 0x1F801074u) sio_tick(0); - uint32_t val = (addr < 0x1F801074u) ? i_stat : i_mask; - return (uint8_t)(val >> (8 * (addr & 3))); - } - /* SIO: 0x1F801040..0x1F80104F */ - if (addr >= 0x1F801040u && addr <= 0x1F80104Fu) { - return (uint8_t)sio_read(addr & ~3u); - } - /* DMA: 0x1F801080..0x1F8010FF — byte reads return the corresponding - * byte of the 32-bit register. The BIOS shell reads DICR (0x1F8010F4) - * as individual bytes during interrupt handling. */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t aligned = addr & ~3u; - uint32_t val = dma_read(aligned); - return (uint8_t)(val >> (8 * (addr & 3))); - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t val = mdec_read(addr & ~3u); - return (uint8_t)(val >> (8 * (addr & 3))); - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - return (uint8_t)cdrom_read(addr); - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return 0; - } - { /* open-bus (Beetle parity) */ g_io_openbus_reads++; return 0;; } - return 0; -} - -static uint8_t mmio_read8(uint32_t addr) { - psx_devices_mmio_sync(); - uint8_t v = mmio_read8_impl(addr); - debug_server_trace_mmio_read(addr, (uint32_t)v, 1); - return v; -} - -static void mmio_write8(uint32_t addr, uint8_t val) { - psx_devices_mmio_sync(); - SHADOW_NOTE_MMIO(); - debug_server_trace_mmio_write(addr, (uint32_t)val, 1); - /* Memory control: 0x1F801000..0x1F80103C — byte lane RMW. Tomba2's late - * attract byte-writes the EXP1 base register at 0x1F801000; only the - * 32-bit path covered the block and the byte path fatal'd. */ - if (addr >= 0x1F801000u && addr <= 0x1F80103Fu) { - uint32_t idx = (addr - 0x1F801000u) >> 2; - uint32_t shift = 8u * (addr & 3u); - mem_ctrl[idx] = (mem_ctrl[idx] & ~(0xFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* RAM size register: 0x1F801060 — byte lane RMW (Tomba2 late attract). */ - if (addr >= 0x1F801060u && addr <= 0x1F801063u) { - uint32_t shift = 8u * (addr & 3u); - ram_size_reg = (ram_size_reg & ~(0xFFu << shift)) - | ((uint32_t)val << shift); - return; - } - /* Interrupts: partial stores affect only the addressed byte lane. */ - if (addr >= 0x1F801070u && addr <= 0x1F801073u) { - uint32_t shift = 8u * (addr & 3u); - interrupt_write_stat_masked((uint32_t)val << shift, 0xFFu << shift); - return; - } - if (addr >= 0x1F801074u && addr <= 0x1F801077u) { - uint32_t shift = 8u * (addr & 3u); - interrupt_write_mask_masked((uint32_t)val << shift, 0xFFu << shift, 8); - return; - } - /* SIO: 0x1F801040..0x1F80105F */ - if (addr >= 0x1F801040u && addr <= 0x1F80105Fu) { - sio_write(addr & ~3u, (uint32_t)val); - return; - } - /* DMA: 0x1F801080..0x1F8010FF — byte writes update the corresponding - * byte of the 32-bit register. Needed for DICR byte-level access. */ - if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { - uint32_t aligned = addr & ~3u; - uint32_t shift = 8 * (addr & 3); - uint32_t mask = 0xFFu << shift; - dma_write_masked(aligned, (uint32_t)val << shift, mask); - return; - } - /* Timers: 0x1F801100..0x1F80112F — byte writes update the addressed byte - * lane of the 32-bit register. Byte stores to timer registers are valid - * hardware accesses; mmio_write16/32 already route here via timers_write, so - * write8 must too (otherwise a guest `sb` to a timer fails loud). */ - if (addr >= 0x1F801100u && addr <= 0x1F80112Fu) { - uint32_t aligned = addr & ~3u; - uint32_t cur = timers_read(aligned); - uint32_t shift = 8 * (addr & 3); - cur = (cur & ~(0xFFu << shift)) | ((uint32_t)val << shift); - timers_write(aligned, cur); - return; - } - /* MDEC: 0x1F801820..0x1F801827 */ - if (addr >= 0x1F801820u && addr <= 0x1F801827u) { - uint32_t aligned = addr & ~3u; - uint32_t cur = mdec_read(aligned); - uint32_t shift = 8 * (addr & 3); - cur = (cur & ~(0xFFu << shift)) | ((uint32_t)val << shift); - mdec_write(aligned, cur); - return; - } - /* CDROM: 0x1F801800..0x1F801803 */ - if (addr >= 0x1F801800u && addr <= 0x1F801803u) { - cdrom_write(addr, val); - return; - } - /* Expansion 2 / POST: 0x1F802000..0x1F802FFF */ - if (addr >= 0x1F802000u && addr <= 0x1F802FFFu) { - return; - } - { /* open-bus (Beetle parity) */ g_io_openbus_writes++; return;; } -} - -/* --- Read functions --- */ - -/* lockstep: 1 while a guest-direct memory op's REAL body runs, so nested ops - * (device-emulation reads triggered by an MMIO write, etc.) are not recorded — - * the shadow replay verifies writes without performing them, so it never sees - * those device-internal accesses. Interrupt-handler ops are excluded too - * (psx_get_in_exception): the replay runs only the block, never the handler. */ -static int s_ls_op_active = 0; -extern int g_ls_suppress_record; -extern int g_dma_exec_depth; -extern int psx_get_in_exception(void); -static uint32_t psx_read_word_raw(uint32_t addr); -/* data-shard capture feed (data_shards.c): record guest reads/writes while a - * capture window is armed. Same exclusions as the lockstep hooks: never in - * exception context (ISR work replays live), never under DMA (DMA writes - * poison the window via ds_note_dma_write instead). */ -uint32_t psx_read_word(uint32_t addr) { - if (g_ls_mode == 2) return ls_read_hook(addr, 4, 0u); - if (g_ds_recording && g_dma_exec_depth == 0 && !psx_get_in_exception()) - ds_note_read(addr, 4); - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_word_raw(addr); - s_ls_op_active = 1; - uint32_t v = psx_read_word_raw(addr); - s_ls_op_active = 0; - if (!psx_get_in_exception()) ls_read_hook(addr, 4, v); - return v; -} -/* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first - * (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the - * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (Beetle libretro.cpp:874 - * `A < 0x00800000` routes to main RAM; psx-spx "2048K RAM ... mirrored 4x"). A game - * may legitimately place its stack at the top of that window — Tsumu Light computes - * sp = (ramtop-8)|0x80000000 with its ramtop constant 0x00800000, giving sp=0x807FFFF8 - * (top of the 4th mirror). Without this fold those accesses miss DRAM (`< RAM_SIZE` - * fails) and hit open bus: stores drop, loads return 0, so the first saved return - * address reads back 0 and `jr ra` derails to PC=0. Identity for phys < RAM_SIZE, so - * normal-range RAM is byte-identical; MMIO/scratchpad/BIOS (>= 0x800000) untouched. */ -static uint32_t psx_read_word_raw(uint32_t addr) { - /* KSEG2 cache control — before physical translation. */ - if (addr == 0xFFFE0130u) return cache_ctrl; - /* KSEG2 (0xC0000000+): only cache control (0xFFFE0130, above where - * applicable) exists there. Real hardware maps NOTHING else — Beetle - * (cpu.cpp addr_mask[6..7]=0xFFFFFFFF) leaves KSEG2 addresses unmasked - * so they fall to unmapped space and the access is a no-op. Our flat - * 0x1FFFFFFF masking routed KSEG2 garbage onto LIVE registers: Tomba2's - * attract runs a BIOS bzero over a wild 0xDF80xxxx pointer, which zeroed - * the SIO/memctrl I/O block byte-by-byte and then hit the unmapped-MMIO - * fatal (frame 9705). Ignore writes, read as 0, count for telemetry. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; } - - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - /* Host LE load — same bytes as the shift-or form; VLC/decode hot paths - * issue millions of aligned main-RAM LWs per second. */ - uint32_t v; - memcpy(&v, &ram[phys], sizeof(v)); - /* Targeted main-RAM read watch (debug). Flag is 0 in normal runs, so the - * hot read path pays only a predictable branch. */ - if (g_ram_read_watch_active) debug_server_trace_ram_read_watch(phys, v); - return v; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 4u, &off)) { - uint32_t v; - memcpy(&v, mod_gpu_dma_memory + off, sizeof(v)); - return v; - } - } - /* Expansion 1: 0x1F000000..0x1F7FFFFF — no device, open bus */ - { - uint32_t off; - if (mod_memory_offset(phys, 4u, &off)) { - uint32_t v; - memcpy(&v, mod_memory + off, sizeof(v)); - return v; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) { - return 0xFFFFFFFFu; - } - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - return (uint32_t)scratchpad[off] - | ((uint32_t)scratchpad[off + 1] << 8) - | ((uint32_t)scratchpad[off + 2] << 16) - | ((uint32_t)scratchpad[off + 3] << 24); - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - return mmio_read32(phys); - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - uint32_t off = phys - 0x1FC00000u; - return (uint32_t)bios_rom[off] - | ((uint32_t)bios_rom[off + 1] << 8) - | ((uint32_t)bios_rom[off + 2] << 16) - | ((uint32_t)bios_rom[off + 3] << 24); - } - unmapped_fatal(addr, phys, "READ"); - return 0; -} - -/* ---- VSync callback-pointer provenance probe (MMX6 boot wedge) ------------- - * The kernel VSync/RCnt callback-block pointer at phys 0x79D44 (KSEG0 0x80079D44) - * is corrupted to 0x016F0110 at frame ~1188 by a write whose g_debug_last_store_pc - * is STALE (so it is not a compiled/dirty-interp store — a DMA or runtime mem-op - * with a wrong destination). This always-on ring records EVERY write to that word - * at the unified RAM-write chokepoint, tagging CPU-vs-DMA via the dma.c exec flags, - * so the real corrupting writer (and, if DMA, its channel + madr) is captured even - * though the per-instruction store-PC tracker can't see it. Dump via `d44_ring`. */ -#define D44_PHYS 0x00079D44u -#define D44_RING_CAP 32u -typedef struct { - uint64_t seq; - uint32_t val, old, store_pc; - int32_t dma_depth, dma_ch; - uint32_t dma_madr, dma_bcr, frame; -} D44Entry; -D44Entry g_d44_ring[D44_RING_CAP]; -uint64_t g_d44_seq = 0; -extern uint32_t g_debug_last_store_pc; -extern int g_dma_exec_depth; /* >0 while a DMA is moving data (dma.c) */ -extern int g_dma_cur_ch; /* channel of the in-flight DMA, else -1 */ -extern uint32_t g_dma_cur_madr; /* current MADR of the in-flight DMA */ -extern uint32_t g_dma_cur_bcr; /* BCR of the in-flight DMA */ -extern uint64_t s_frame_count; -static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { - if (phys != D44_PHYS) return; - uint64_t i = g_d44_seq++; - D44Entry *e = &g_d44_ring[i & (D44_RING_CAP - 1u)]; - e->seq = i; e->val = val; e->old = old; e->store_pc = g_debug_last_store_pc; - e->dma_depth = g_dma_exec_depth; e->dma_ch = g_dma_cur_ch; - e->dma_madr = g_dma_cur_madr; e->dma_bcr = g_dma_cur_bcr; - e->frame = (uint32_t)s_frame_count; -} - -static void psx_write_word_raw(uint32_t addr, uint32_t val); -void psx_write_word(uint32_t addr, uint32_t val) { - extern void (*g_overlay_flush_pending_cycles)(void); - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - /* The immediately following provenance callback may trust only a commit - * from this write attempt. Clear after the cycle flush because flushing - * can itself perform guest writes. */ - gte_precision_word_write_begin(); - if (g_ls_mode == 2) { ls_write_hook(addr, 4, val); return; } - if (g_ds_recording) { - if (g_dma_exec_depth > 0) ds_note_dma_write(); - else if (!psx_get_in_exception()) ds_note_write(addr, 4); - } - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_word_raw(addr, val); return; } - if (!psx_get_in_exception()) ls_write_hook(addr, 4, val); - s_ls_op_active = 1; - psx_write_word_raw(addr, val); - s_ls_op_active = 0; -} -static void psx_write_word_raw(uint32_t addr, uint32_t val) { - g_guest_store_count++; - /* KSEG2 cache control — before physical translation. */ - if (addr == 0xFFFE0130u) { cache_ctrl = val; return; } - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; } - - /* IsC (Isolate Cache): when set, writes go to D-cache only. - * We have no cache model, so silently discard RAM/scratchpad writes. */ - if (sr_ptr && (*sr_ptr & 0x10000u)) return; - - uint32_t phys = psx_phys_addr(addr); - - /* The generated BIOS mirrors its exception trampoline to 0x80000000 during - * boot. On hardware this mirror copy is not visible in RAM; only the real - * exception vector at 0x80000080 is. Tomba 2 later passes buffer=0 to the - * BIOS card write routine, so stale mirror bytes at 0 corrupt the sector 63 - * management write and leave the load menu stuck checking the card. */ - if (fntrace_is_game_started() && - phys < 0x10u && g_debug_last_store_pc == 0xBFC10A00u) return; - - /* BIOS helpers use RAM address zero as a tiny delay-loop scratch between - * device-register polls. Treat these two dummy stores as non-visible; real - * hardware / Beetle preserve Tomba 2's sector-63 card-management payload - * when it passes buffer=0 to _card_write. */ - if (fntrace_is_game_started() && phys == 0u) { - switch (g_debug_last_store_pc) { - case 0xBFC04E90u: - case 0xBFC04EF0u: - case 0xBFC05164u: - case 0xBFC0D634u: - case 0xBFC3EEB4u: - case 0xBFC405E4u: - case 0xBFC40788u: - case 0xBFC41C50u: - case 0x80012434u: - case 0x800125ACu: - return; - default: - break; - } - } - - if (phys < RAM_SIZE) { - /* Tomba 2 load-game card check: the BIOS card-manager cleanup helper - * at kernel RAM 0x1C5C scans MARK events and re-arms any READY event - * matching F0000011/{4,8000,100,200,2000}. In the recomp timing path it - * can run after the card completion DeliverEvent but before the game's - * TestEvent poll, consuming the public card event and leaving the UI - * stuck at "Checking MEMORY CARD...". Let the real TestEvent consume - * public MARK card events instead; keep this narrowly keyed to the - * helper's status store and the EvCB layout. */ - if (fntrace_is_game_started() && - g_debug_last_store_pc == 0xBFC117E4u && - val == 0x2000u && - phys >= 4u && (phys + 8u) < RAM_SIZE && - read_ram_word(phys) == 0x4000u && - read_ram_word(phys - 4u) == 0xF0000011u && - read_ram_word(phys + 8u) == 0x2000u) { - uint32_t spec = read_ram_word(phys + 4u); - if (spec == 0x00000004u || spec == 0x00008000u || - spec == 0x00000100u || spec == 0x00000200u || - spec == 0x00002000u) { - return; - } - } - /* All discard/suppression paths are now behind us. Only a write that - * actually changes main RAM may invalidate prior provenance. */ - gte_precision_invalidate_word(addr); - if (phys == D44_PHYS) d44_note(phys, read_ram_word(phys), val); - debug_server_trace_write_check(phys, read_ram_word(phys), val, 4); - parity_trace_note_write(phys, 4, effective_store_pc()); - card_data_writes_check(phys, val, 4); - dirty_ram_mark_kernel_write(phys); - text_guard_note_write(phys, val, 4); - overlay_watch_note_write(phys, 4); -#ifdef PSX_COSIM - { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 4); } -#endif - ram[phys] = (uint8_t)(val); - ram[phys + 1] = (uint8_t)(val >> 8); - ram[phys + 2] = (uint8_t)(val >> 16); - ram[phys + 3] = (uint8_t)(val >> 24); - gte_precision_main_ram_word_committed(phys); - /* The matching emitted/interpreted guest-store callback consumes this - * token after write_word returns. DMA, HLE and mod writes deliberately - * have no such callback, so a sticky debug PC cannot grant provenance. */ - return; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 4u, &off)) { - gte_precision_invalidate_word(addr); - memcpy(mod_gpu_dma_memory + off, &val, sizeof(val)); - gte_precision_gpu_dma_word_committed(phys); - return; - } - } - /* Expansion 1: 0x1F000000..0x1F7FFFFF — ignore writes */ - { - uint32_t off; - if (mod_memory_offset(phys, 4u, &off)) { - memcpy(mod_memory + off, &val, sizeof(val)); - return; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - /* All discard/suppression paths are behind us. Invalidate any - * reviewed scratch provenance before the replacement bytes land. */ - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, - (uint32_t)scratchpad[off] - | ((uint32_t)scratchpad[off + 1] << 8) - | ((uint32_t)scratchpad[off + 2] << 16) - | ((uint32_t)scratchpad[off + 3] << 24), - val, 4); - scratchpad[off] = (uint8_t)(val); - scratchpad[off + 1] = (uint8_t)(val >> 8); - scratchpad[off + 2] = (uint8_t)(val >> 16); - scratchpad[off + 3] = (uint8_t)(val >> 24); - gte_precision_scratch_word_committed(phys); - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - mmio_write32(phys, val); - return; - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - /* ROM: silently ignore writes */ - return; - } - unmapped_fatal(addr, phys, "WRITE"); -} - -static uint16_t psx_read_half_raw(uint32_t addr); -uint16_t psx_read_half(uint32_t addr) { - if (g_ls_mode == 2) return (uint16_t)ls_read_hook(addr, 2, 0u); - if (g_ds_recording && g_dma_exec_depth == 0 && !psx_get_in_exception()) - ds_note_read(addr, 2); - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_half_raw(addr); - s_ls_op_active = 1; - uint16_t v = psx_read_half_raw(addr); - s_ls_op_active = 0; - if (!psx_get_in_exception()) ls_read_hook(addr, 2, v); - return v; -} -static uint16_t psx_read_half_raw(uint32_t addr) { - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - return (uint16_t)ram[phys] | ((uint16_t)ram[phys + 1] << 8); - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 2u, &off)) - return (uint16_t)mod_gpu_dma_memory[off] | - ((uint16_t)mod_gpu_dma_memory[off + 1u] << 8); - } - { - uint32_t off; - if (mod_memory_offset(phys, 2u, &off)) - return (uint16_t)mod_memory[off] | - ((uint16_t)mod_memory[off + 1u] << 8); - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0xFFFFu; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - return (uint16_t)scratchpad[off] | ((uint16_t)scratchpad[off + 1] << 8); - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - return mmio_read16(phys); - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - uint32_t off = phys - 0x1FC00000u; - return (uint16_t)bios_rom[off] | ((uint16_t)bios_rom[off + 1] << 8); - } - unmapped_fatal(addr, phys, "READ"); - return 0; -} - -static void psx_write_half_raw(uint32_t addr, uint16_t val); -void psx_write_half(uint32_t addr, uint16_t val) { - extern void (*g_overlay_flush_pending_cycles)(void); - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - if (g_ls_mode == 2) { ls_write_hook(addr, 2, val); return; } - if (g_ds_recording) { - if (g_dma_exec_depth > 0) ds_note_dma_write(); - else if (!psx_get_in_exception()) ds_note_write(addr, 2); - } - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_half_raw(addr, val); return; } - if (!psx_get_in_exception()) ls_write_hook(addr, 2, val); - s_ls_op_active = 1; - psx_write_half_raw(addr, val); - s_ls_op_active = 0; -} -static void psx_write_half_raw(uint32_t addr, uint16_t val) { - g_guest_store_count++; - if (sr_ptr && (*sr_ptr & 0x10000u)) return; - - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, (uint32_t)read_ram_half(phys), (uint32_t)val, 2); - parity_trace_note_write(phys, 2, effective_store_pc()); - card_data_writes_check(phys, (uint32_t)val, 2); - dirty_ram_mark_kernel_write(phys); - text_guard_note_write(phys, (uint32_t)val, 2); - overlay_watch_note_write(phys, 2); -#ifdef PSX_COSIM - { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 2); } -#endif - ram[phys] = (uint8_t)(val); - ram[phys + 1] = (uint8_t)(val >> 8); - return; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 2u, &off)) { - gte_precision_invalidate_word(addr); - mod_gpu_dma_memory[off] = (uint8_t)val; - mod_gpu_dma_memory[off + 1u] = (uint8_t)(val >> 8); - return; - } - } - { - uint32_t off; - if (mod_memory_offset(phys, 2u, &off)) { - mod_memory[off] = (uint8_t)val; - mod_memory[off + 1u] = (uint8_t)(val >> 8); - return; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, - (uint32_t)scratchpad[off] | ((uint32_t)scratchpad[off + 1] << 8), - (uint32_t)val, 2); - scratchpad[off] = (uint8_t)(val); - scratchpad[off + 1] = (uint8_t)(val >> 8); - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - mmio_write16(phys, val); - return; - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - return; /* ROM: ignore */ - } - unmapped_fatal(addr, phys, "WRITE"); -} - -static uint8_t psx_read_byte_raw(uint32_t addr); -uint8_t psx_read_byte(uint32_t addr) { - if (g_ls_mode == 2) return (uint8_t)ls_read_hook(addr, 1, 0u); - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) return psx_read_byte_raw(addr); - s_ls_op_active = 1; - uint8_t v = psx_read_byte_raw(addr); - s_ls_op_active = 0; - if (!psx_get_in_exception()) ls_read_hook(addr, 1, v); - return v; -} -static uint8_t psx_read_byte_raw(uint32_t addr) { - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_reads++; return 0; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - return ram[phys]; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 1u, &off)) - return mod_gpu_dma_memory[off]; - } - { - uint32_t off; - if (mod_memory_offset(phys, 1u, &off)) return mod_memory[off]; - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0xFFu; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - return scratchpad[phys - 0x1F800000u]; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - return mmio_read8(phys); - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - return bios_rom[phys - 0x1FC00000u]; - } - unmapped_fatal(addr, phys, "READ"); - return 0; -} - -/* ---- CPU guest-side data loads: faithful R3000A load-delay pipeline interlock ---- - * The R3000A has no usable D-cache (it is repurposed as the 1 KB scratchpad), so a - * CPU data load from main DRAM stalls the pipeline. Beetle (cpu.cpp ReadMemory, - * 364-451) models this as: a +2 "fudge" iff the predecessor committed no load, the - * region wait (main RAM = +3, libretro.cpp:884), and a completion cost (+2 CPU / - * +1 LWC2); the (region+completion) becomes a per-register LDAbsorb "give-back" that - * following instructions consume instead of their own +1 base (pipeline write-back - * overlap). The §1 base + GPR_DEPRES + DO_LDS that bracket this run in psx_cyc.h. - * - * These functions own the WHOLE per-instruction interlock for a CPU load (they call - * psx_cyc_base/deps/lds), so the emitters/interp invoke them in place of the prior - * cpu->read_* call and emit NO separate psx_cyc_step for the load. They return the - * raw value via the UNCHARGED psx_read_* (cpu->read_* is now uncharged too — the - * data-access cost is charged exactly once, here). Keyed on the runtime effective - * physical address (KUSEG/KSEG0/KSEG1 alias the same DRAM). - * - * DMACycleSteal residual: Beetle adds the (dynamic) DMACycleSteal to EVERY read - * (libretro.cpp:868-869). That is non-zero only while a DMA channel is actively - * stealing the bus; modeling it needs the live steal count threaded out of the DMA - * controller, and it can't be isolated by a static ruler. It remains an unmodeled - * dynamic axis; the per-region device waits below are the static, validatable piece. */ - -/* Runtime-only production cycle charge for data-load timing. Overlay DLLs - * flush their local pending-cycle accumulator before entering these host - * helpers, so this stays on the host side of that ABI boundary. */ -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) && !STARVATION_RING_ENABLED -extern uint64_t g_psx_cycle_fast_limit; -extern int g_event_step_conservative; -extern int g_ls_replay_active; -static inline void psx_load_charge_cycles(uint32_t cycles) { - if (g_ls_replay_active || cycles == 0u) return; - uint64_t next = psx_cycle_count + (uint64_t)cycles; - if (!g_event_step_conservative && g_psx_cycle_fast_limit != 0u && - next >= psx_cycle_count && next <= g_psx_cycle_fast_limit) { - psx_cycle_count = next; - return; - } - psx_advance_cycles(cycles); -} -#else -static inline void psx_load_charge_cycles(uint32_t cycles) { - psx_advance_cycles(cycles); -} -#endif - -/* Beetle MemRW device-region READ wait (libretro.cpp:859-1131), the device-dependent - * part of a load's access cost (added to the timestamp before the +completion). `size` - * is the access width in bytes (1/2/4) — the SPU and CDC waits are width-dependent. - * Reads only; writes are posted (~free) in MemRW. phys is the masked physical address. */ -static inline uint32_t psx_mmio_read_wait(uint32_t phys, uint32_t size) { - /* Main RAM, mirrored across phys 0..0x7FFFFF (libretro.cpp:874 `A < 0x00800000`). */ - if (phys < 0x00800000u) return 3u; - /* BIOS ROM (905) and Expansion 1 / PIO (1134): no extra device wait. */ - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) return 0u; - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return 0u; - /* Hardware MMIO window 0x1F801000..0x1F802FFF (921). */ - if (phys >= 0x1F801000u && phys <= 0x1F802FFFu) { - /* Bisect gate (PSX_MMIO_WAIT=0): disable the device-region read waits - * (the 9ae534d feature) to test whether they move the MMX6 cutscene - * ordering. Read once. */ - static int s_mw = -1; - if (s_mw < 0) { const char* e = getenv("PSX_MMIO_WAIT"); s_mw = (e && e[0] == '0') ? 0 : 1; } - if (!s_mw) return 0u; - if (phys >= 0x1F801C00u && phys <= 0x1F801FFFu) /* SPU (929) */ - return (size == 4u) ? 36u : 16u; - if (phys >= 0x1F801800u && phys <= 0x1F80180Fu) /* CDC (979) */ - return 6u * size; - if (phys >= 0x1F801810u && phys <= 0x1F801817u) return 1u; /* GPU (994) */ - if (phys >= 0x1F801820u && phys <= 0x1F801827u) return 1u; /* MDEC (1007) */ - if (phys >= 0x1F801000u && phys <= 0x1F801023u) return 1u; /* SysControl (1020) */ - if (phys >= 0x1F801040u && phys <= 0x1F80104Fu) return 1u; /* FrontIO/pad (1043) */ - if (phys >= 0x1F801050u && phys <= 0x1F80105Fu) return 1u; /* SIO (1055) */ - if (phys >= 0x1F801070u && phys <= 0x1F801077u) return 1u; /* IRQ (1094) */ - if (phys >= 0x1F801080u && phys <= 0x1F8010FFu) return 1u; /* DMA (1106) */ - if (phys >= 0x1F801100u && phys <= 0x1F80113Fu) return 1u; /* Timers (1119) */ - return 0u; /* unmatched MMIO: Beetle adds no device wait */ - } - return 0u; /* unknown / open-bus region */ -} - -/* Beetle ReadMemory data-access timing (cpu.cpp:369-448), after §1/deps/DO_LDS. - * compl_cost = 2 (CPU load) / 1 (LWC2); arm_rt = GPR to arm as pending load, or - * 0x20 = none (LWC2, dest is a GTE reg). size = access width in bytes (1/2/4). */ -static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys, uint32_t size, - uint32_t compl_cost, uint32_t arm_rt) { - /* ReadMemory start (369-370): clear the current give-back slot. */ - cpu->read_absorb[cpu->read_absorb_which] = 0u; - cpu->read_absorb_which = 0u; - /* Scratchpad (the D-cache): no wait, no give-back (414-422). */ - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - cpu->ld_absorb = 0u; - cpu->ld_which_t = (uint8_t)arm_rt; - return; - } - /* fudge (424): +2 iff the predecessor committed no load (read_fudge==0x20). - * Combined with region+completion into one advance — deadline catch-up - * replays exact event boundaries, so splitting the charge is only host cost. */ - uint32_t region = psx_mmio_read_wait(phys, size); /* device-region wait */ - uint32_t cost = region + compl_cost; /* LDAbsorb = region + completion */ - uint32_t fudge = (uint32_t)((cpu->read_fudge >> 4) & 2u); - cpu->ld_absorb = cost; - psx_advance_cycles(fudge + cost); - cpu->ld_which_t = (uint8_t)arm_rt; - /* PROOF GATE (PSX_POLL_PROOF=N, default 0/off): a FLAT, non-absorbed extra N - * cycles per main-RAM data read — replicates the historical "+6 cyc/main-RAM - * read" fix (mmx6_memcard_invalid_rootcause) that lengthened MMX6's card - * busy-poll so it outlasts the VBlank-paced async card op. Unlike the - * region+completion above (which arms ld_absorb and gets given back in a - * tight loop), this is pure added cost. If setting this makes the MMX6 save - * load, the card regression is confirmed as poll-vs-op timing. TEMPORARY — - * the faithful fix models the uncached data-read cost properly. */ - if (phys < 0x00800000u) { - static int s_pp = -1; - if (s_pp < 0) { const char* e = getenv("PSX_POLL_PROOF"); s_pp = (e && e[0]) ? atoi(e) : 0; } - if (s_pp > 0) psx_load_charge_cycles((uint32_t)s_pp); - } -} - -/* Resolve PSX_LOAD_DELAY once (shared with inlined psx_cyc.h helpers). */ -int psx_load_delay_enabled(void) { - if (g_psx_load_delay < 0) { - const char* e = getenv("PSX_LOAD_DELAY"); - g_psx_load_delay = (e && e[0] == '0') ? 0 : 1; - } - return g_psx_load_delay; -} - -/* The interlock half of a load (§1+deps+(cancel)+DO_LDS+ReadMemory). Gated on - * PSX_ENABLE_BLOCK_CYCLES so the Beetle-oracle build (cycles off) does a plain read. */ -static inline void psx_cyc_load_timing(CPUState* cpu, uint32_t addr, uint32_t size, - uint32_t rt, uint32_t reg_mask) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - /* Bisect gate (PSX_LOAD_DELAY=0): disable the R3000A load-delay interlock - * timing (the d8c4a8e/fade560/d597797 feature) to test whether it moves the - * MMX6 cutscene ordering. Read once; default on. */ - if (!psx_load_delay_enabled()) { - (void)addr; (void)size; (void)rt; (void)reg_mask; - return; - } - psx_cyc_base(cpu); - psx_cyc_deps(cpu, reg_mask); - if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; /* cancel pending load to same dest */ - psx_cyc_lds(cpu); - psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, size, 2u, rt); -#else - (void)cpu; (void)addr; (void)size; (void)rt; (void)reg_mask; -#endif -} - -/* Production value-read fast path for the overwhelmingly common main-RAM - * load. Timing has already run before this helper is consulted, so a device - * deadline (and any DMA it services) remains ordered before the value read. - * - * Keep this host-local: generated overlay DLLs flush their pending cycles - * before entering psx_cyc_load_* and must not depend on runtime globals. Every - * mode with observable read-side instrumentation falls back to psx_read_*. - * The physical-range test is done before the 2 MiB mirror fold so MMIO, BIOS, - * scratchpad, KSEG2/cache-control and open-bus behavior remain canonical. */ -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) -static inline int psx_cyc_main_ram_fast_addr(uint32_t addr, uint32_t width, - uint32_t *phys_out) { - if (g_ls_mode != 0 || g_ls_replay_active || g_ds_recording || - g_ram_read_watch_active || - g_dma_exec_depth > 0 || addr >= 0xC0000000u) - return 0; - uint32_t phys = addr & 0x1FFFFFFFu; - if (phys >= 0x00800000u) return 0; - phys &= (uint32_t)(RAM_SIZE - 1); - /* Aligned guest loads cannot cross this boundary, but fail closed for a - * malformed/unaligned caller instead of introducing a host OOB read. */ - if (phys > (uint32_t)RAM_SIZE - width) return 0; - *phys_out = phys; - return 1; -} -#endif - -/* Slow paths for the inlined helpers in psx_cyc.h (MMIO / lockstep / shards). */ -uint32_t psx_cyc_load_word_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask); - return psx_read_word(addr); -} -uint16_t psx_cyc_load_half_slow(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 2u, rt, reg_mask); - return psx_read_half(addr); -} -void psx_cyc_load_word_timing_only(CPUState* cpu, uint32_t addr, - uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 4u, rt, reg_mask); -} -uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) { - psx_cyc_load_timing(cpu, addr, 1u, rt, reg_mask); -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) - uint32_t phys; - if (psx_cyc_main_ram_fast_addr(addr, 1u, &phys)) return ram[phys]; -#endif - return psx_read_byte(addr); -} - -/* LWC2 (GTE load): §1/DO_LDS done by psx_cyc_step(cpu,0); the GTE deadline stall by - * psx_gte_stall — both emitted before this call. 32-bit access, completion +1, no - * LDWhich arm. */ -uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr) { -#ifdef PSX_ENABLE_BLOCK_CYCLES - psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 4u, 1u, 0x20u); -#else - (void)cpu; -#endif -#if defined(PSX_NO_DEBUG_TOOLS) && !defined(PSX_COSIM) - uint32_t phys; - if (psx_cyc_main_ram_fast_addr(addr, 4u, &phys)) { - return (uint32_t)ram[phys] - | ((uint32_t)ram[phys + 1] << 8) - | ((uint32_t)ram[phys + 2] << 16) - | ((uint32_t)ram[phys + 3] << 24); - } -#endif - return psx_read_word(addr); -} - -/* Deprecated uncharged passthroughs (the +4 flat wait-state model is gone; load - * timing now lives in psx_cyc_load_*). Kept so any stray reference stays valid. */ -uint32_t psx_guest_read_word(uint32_t addr) { return psx_read_word(addr); } -uint16_t psx_guest_read_half(uint32_t addr) { return psx_read_half(addr); } -uint8_t psx_guest_read_byte(uint32_t addr) { return psx_read_byte(addr); } - -static void psx_write_byte_raw(uint32_t addr, uint8_t val); -void psx_write_byte(uint32_t addr, uint8_t val) { - extern void (*g_overlay_flush_pending_cycles)(void); - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - if (g_ls_mode == 2) { ls_write_hook(addr, 1, val); return; } - if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_byte_raw(addr, val); return; } - if (!psx_get_in_exception()) ls_write_hook(addr, 1, val); - s_ls_op_active = 1; - psx_write_byte_raw(addr, val); - s_ls_op_active = 0; -} -static void psx_write_byte_raw(uint32_t addr, uint8_t val) { - g_guest_store_count++; - if (sr_ptr && (*sr_ptr & 0x10000u)) return; - - /* KSEG2 guard — see psx_read_word_raw. */ - if (addr >= 0xC0000000u) { g_kseg2_ignored_writes++; return; } - uint32_t phys = psx_phys_addr(addr); - - if (phys < RAM_SIZE) { - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, (uint32_t)ram[phys], (uint32_t)val, 1); - parity_trace_note_write(phys, 1, effective_store_pc()); - card_data_writes_check(phys, (uint32_t)val, 1); - dirty_ram_mark_kernel_write(phys); - text_guard_note_write(phys, (uint32_t)val, 1); - overlay_watch_note_write(phys, 1); -#ifdef PSX_COSIM - { extern void cosim_note_ram_write(uint32_t,uint32_t); cosim_note_ram_write(phys, 1); } -#endif - ram[phys] = val; - return; - } - { - uint32_t off; - if (mod_gpu_dma_memory_offset(phys, 1u, &off)) { - gte_precision_invalidate_word(addr); - mod_gpu_dma_memory[off] = val; - return; - } - } - { - uint32_t off; - if (mod_memory_offset(phys, 1u, &off)) { - mod_memory[off] = val; - return; - } - } - if (phys >= 0x1F000000u && phys <= 0x1F7FFFFFu) return; - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - gte_precision_invalidate_word(addr); - debug_server_trace_write_check(phys, (uint32_t)scratchpad[phys - 0x1F800000u], - (uint32_t)val, 1); - scratchpad[phys - 0x1F800000u] = val; - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - mmio_write8(phys, val); - return; - } - if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFFu) { - return; /* ROM: ignore */ - } - unmapped_fatal(addr, phys, "WRITE"); -} diff --git a/psxrecomp-overlay/runtime/src/overlay_loader.c b/psxrecomp-overlay/runtime/src/overlay_loader.c deleted file mode 100644 index f05e691..0000000 --- a/psxrecomp-overlay/runtime/src/overlay_loader.c +++ /dev/null @@ -1,4715 +0,0 @@ -#include "overlay_loader.h" -#include "overlay_api.h" -#include "overlay_path_canon.h" -#include "code_provider.h" -#include "overlay_backend.h" -#include "crc32.h" -#include "dirty_ram_interp.h" -#include "interrupts.h" -#include "debug_server.h" -#include "psx_cycles.h" -#include "lockstep.h" -#include "overlay_posix.h" -#include -#include -#include -#include -#include -#include - -extern void overlay_watch_set_range(uint32_t phys, uint32_t len); - -#ifdef _WIN32 -# define WIN32_LEAN_AND_MEAN -# include -#else -# include -#endif - -#ifdef _WIN32 -# define OVERLAY_SHARED_EXT ".dll" -# define OVERLAY_SHARED_EXT_LEN 4 -#else -# define OVERLAY_SHARED_EXT ".so" -# define OVERLAY_SHARED_EXT_LEN 3 -#endif - -/* ============================================================================ - * Inc3 — Per-entry validity + multi-candidate dispatch (design doc §8) - * - * Validity is tracked per *compiled entry*, not per dirty region. Each entry - * carries its tight code byte-ranges (from the recompiler's per-function walk, - * emitted as a {base}_{crc}.ranges manifest beside the DLL), a content hash of - * those ranges, and a page-generation snapshot. A write to a watched page bumps - * that page's generation (memory.c); dispatch lazily re-hashes an entry only - * when the generation over its ranges changed. This: - * - kills the false-invalidation churn of the old [fn_lo,fn_hi) region flag - * (data interleaved between functions no longer nukes the whole region), and - * - makes reload-on-return gradual and automatic — each entry flips back to - * native the moment its own code bytes reappear (hash matches), with no - * region-wide atomic threshold. - * - * PC -> candidate list: different overlays can reuse the same RAM address - * (Tomba loads village and overworld both at 0x800E7xxx). A single-entry table - * would let the later DLL clobber the earlier candidate, making reload-on-return - * impossible. So each PC maps to a chain of candidates; dispatch picks the one - * whose code hash matches live RAM. - * - * §8.3 RESOLVED: jump tables compile to `switch (live register)` with a - * call_by_address default, so table contents are an optimization, not a - * correctness dependency. The dependency set is the entry's code bytes only. - * ==========================================================================*/ - -typedef void (*OverlayFn)(CPUState *); -typedef void (*OverlayFlushFn)(void); - -#define MAX_CODE_RANGES 16 /* code ranges per function (coalesced; usually 1) */ - -enum { ENTRY_VALID = 0, ENTRY_INVALID = 1, ENTRY_BLACKLIST = 2 }; - -typedef struct { - uint32_t addr; /* phys entry address */ - OverlayFn fn; - uint32_t range_lo[MAX_CODE_RANGES]; /* phys code-range starts */ - uint32_t range_len[MAX_CODE_RANGES]; - int nranges; - uint32_t crc_code; /* hash of code ranges at registration*/ - uint32_t val_gen; /* pagegen sum when last (in)validated*/ - int state; /* ENTRY_VALID/INVALID/BLACKLIST */ - int dll; /* source DLL index */ - uint8_t tier; /* gcc=2, tcc=1, unknown=0 */ - int next; /* next candidate at same addr, -1 end*/ - uint32_t diff_passes; /* clean same-state diffs (verify budget)*/ - int device_touch; /* 1 = touches MMIO; never run its shard, - * always interp (shadow diff can't safely - * double-execute device I/O). */ -#ifndef PSX_NO_DEBUG_TOOLS - uint32_t native_rank; /* first-seen identity rank for bisection */ -#endif -} Candidate; - -/* Same-state differential verify budget: diff a candidate this many times with - * 0 divergence, then trust it (stop diffing — run it normally). Bounds the - * differential's cost to ~(distinct functions × budget) instead of every - * dispatch forever, making a validation playtest playable; a DIVERGING - * candidate never reaches the budget, so it stays diff-gated (interp result - * kept) and never executes native live. */ -#define OVERLAY_DIFF_BUDGET 32u - -/* MMX6's clean play-free cache now carries 17,506 on-disk manifest identities - * across observed byte variants, above the old 16,384 lifetime ceiling. Lazy - * loading means this is a potential long-session population, not proof that one - * observed run loaded all of them; every encountered variant does remain loaded. - * Keep enough headroom that coverage cannot silently become interpreter-only. */ -/* Executable loader tests need a tiny table to prove alias-at-cap behavior. - * Keep the override private to this translation unit: changing overlay_api.h - * would roll the production shard codegen hash and its publisher contract. */ -#ifdef PSX_OVERLAY_TEST_CANDIDATE_CAP -#define CAND_CAP PSX_OVERLAY_TEST_CANDIDATE_CAP -#else -#define CAND_CAP PSX_OVERLAY_CANDIDATE_CAP -#endif -static Candidate s_cand[CAND_CAP]; -static int s_cand_n = 0; - -/* CPS continuation lookup. A full candidate-table scan at every tail transfer - * scales catastrophically once a warmed cache contains hundreds of variant - * DLLs. Index candidates by the 4 KiB RAM pages touched by their code ranges; - * a continuation then examines only candidates that could contain its PC. */ -#define RANGE_PAGE_COUNT (2u * 1024u * 1024u / 4096u) -#define RANGE_LINK_CAP (CAND_CAP * 8) -typedef struct { int cand, next; } RangeLink; -static int s_range_page_head[RANGE_PAGE_COUNT]; -static int s_range_page_tail[RANGE_PAGE_COUNT]; -static RangeLink s_range_links[RANGE_LINK_CAP]; -static int s_range_link_n = 0; -static int s_range_index_overflow = 0; - -/* Cache the selected owner of hot CPS continuation PCs. The cached candidate - * is still generation/CRC validated on every lookup; this only avoids walking - * hundreds of historical, currently-invalid owners in a reused region. */ -#define RANGE_PC_CACHE_CAP 16384u -#define RANGE_PC_CACHE_MASK (RANGE_PC_CACHE_CAP - 1u) -typedef struct { uint32_t phys, generation; int cand; } RangePcCache; -static RangePcCache s_range_pc_cache[RANGE_PC_CACHE_CAP]; -static uint32_t s_range_candidate_generation = 1u; - -static void range_index_add_candidate(int ci) { - Candidate *c = &s_cand[ci]; - uint8_t seen[RANGE_PAGE_COUNT / 8] = {0}; - for (int r = 0; r < c->nranges; r++) { - uint32_t lo = c->range_lo[r]; - uint32_t hi = lo + c->range_len[r] - 1u; - uint32_t p0 = lo >> 12, p1 = hi >> 12; - if (p0 >= RANGE_PAGE_COUNT) continue; - if (p1 >= RANGE_PAGE_COUNT) p1 = RANGE_PAGE_COUNT - 1u; - for (uint32_t p = p0; p <= p1; p++) { - uint8_t bit = (uint8_t)(1u << (p & 7u)); - if (seen[p >> 3] & bit) continue; - seen[p >> 3] |= bit; - if (s_range_link_n >= RANGE_LINK_CAP) { - /* Never leave lookup correctness dependent on a partial index. - * overlay_find_by_range() falls back to the original ordered - * candidate scan if a future title exceeds this sizing guess. */ - s_range_index_overflow = 1; - return; - } - int li = s_range_link_n++; - s_range_links[li].cand = ci; - s_range_links[li].next = -1; - if (s_range_page_head[p] < 0) s_range_page_head[p] = li; - else s_range_links[s_range_page_tail[p]].next = li; - s_range_page_tail[p] = li; - } - } -} - -/* RECURSION_BUG.md §25 — 1 when the build is continuation-passing (set by a - * constructor in the generated CPS dispatch). The overlay dispatch reads it to - * emit/route the CPS tail-transfer contract. Defined here (before - * overlay_loader_dispatch) so both can see it. 0 = legacy unit-model. */ -int g_psx_cps_mode = 0; - -/* Open-addressed index: phys entry addr -> head candidate index (-1 sentinel - * stored as chain terminator on each Candidate). addr 0 = empty slot. */ -#define IDX_CAP (CAND_CAP * 2u) -#define IDX_MASK (IDX_CAP - 1u) -typedef struct { uint32_t addr; int head; } IdxSlot; -static IdxSlot s_idx[IDX_CAP]; -/* Presence-only exact-entry index for the interpreter's common local-transfer - * negative check. Full byte/bundle validation remains in dispatch. */ -static uint32_t s_exact_entry_bitmap[DIRTY_RAM_EXEC_BITMAP_WORDS]; - -static void exact_entry_set(uint32_t phys) { - phys &= 0x1FFFFFFFu; - if (phys < 2u * 1024u * 1024u && (phys & 3u) == 0u) { - uint32_t word = phys >> 2; - s_exact_entry_bitmap[word >> 5] |= 1u << (word & 31u); - } -} - -static int exact_entry_has(uint32_t phys) { - phys &= 0x1FFFFFFFu; - if (phys >= 2u * 1024u * 1024u || (phys & 3u) != 0u) return 0; - uint32_t word = phys >> 2; - return (s_exact_entry_bitmap[word >> 5] >> (word & 31u)) & 1u; -} - -static int idx_head(uint32_t phys) { - uint32_t h = (phys * 2654435761u) & IDX_MASK; - for (uint32_t i = 0; i < IDX_CAP; i++) { - uint32_t k = (h + i) & IDX_MASK; - if (s_idx[k].addr == 0) return -1; - if (s_idx[k].addr == phys) return s_idx[k].head; - } - return -1; -} -static void idx_set_head(uint32_t phys, int head) { - uint32_t h = (phys * 2654435761u) & IDX_MASK; - for (uint32_t i = 0; i < IDX_CAP; i++) { - uint32_t k = (h + i) & IDX_MASK; - if (s_idx[k].addr == 0 || s_idx[k].addr == phys) { - s_idx[k].addr = phys; - s_idx[k].head = head; - return; - } - } -} - -/* Active native-entry stack (self-modification detection, §8.5). */ -static int s_active_stack[64]; -static int s_active_depth = 0; - -/* ---- Observability (recomp-debug: measure, don't eyeball) -------------- */ -/* Always-on ring of native overlay calls, so the FIRST divergence / last - * function executed before a corruption can be read back from the window of - * interest — never "arm a trace then hope". s_native_inprogress holds the entry - * currently executing (nonzero at dump => a native fn was entered and never - * returned: a freeze INSIDE native code, the strongest single suspect). */ -#define NRING_CAP 16384 -typedef struct { uint32_t addr; uint32_t crc; uint32_t frame; uint64_t seq; int returned; } NRingEnt; -static NRingEnt s_nring[NRING_CAP]; -static uint32_t s_nring_pos = 0; -static uint64_t s_nring_seq = 0; -static uint32_t s_native_inprogress = 0; /* addr of fn currently in native, 0 = none */ -/* Sampler accessor (phase_profile hot-function histogram): the innermost native - * candidate's registered entry. Read off-thread; a torn read is harmless. */ -uint32_t overlay_loader_native_inprogress(void) { return s_native_inprogress; } -static uint64_t s_native_calls_total = 0; -extern uint64_t s_frame_count; - -/* Runtime A/B: when 0, candidates are still hashed/validated and recorded, but - * NEVER executed native — execution falls to the interpreter. Lets us prove - * whether native EXECUTION is the cause without a rebuild or losing candidate - * visibility. Default on. */ -static int s_native_exec = 1; -static uint64_t s_would_run_native = 0; /* matched but skipped (exec off) */ - -void overlay_loader_set_native_exec(int on) { s_native_exec = on ? 1 : 0; } -int overlay_loader_get_native_exec(void) { return s_native_exec; } -/* Fail-closed native entry guard (root-cause fix for the Tomba/Tomba2 native- - * overlay "blue screen" wedge). A native overlay function's generated CPS entry- - * switch calls psx_native_bad_entry when it is entered at a PC that is NOT one of - * its legal entries (true prologue or a known continuation) -- e.g. when range - * ownership wrongly resolved a foreign interior PC to this function. Instead of - * the old `default: break` (which fell through and ran the function from its - * TOP, corrupting shared CPU/RAM state -- the wedge), the function records the - * illegal entry and returns WITHOUT executing; overlay_loader_dispatch then - * routes the requested PC to the sanctioned dirty-RAM interpreter (the bytes at - * that PC run correctly). NOT a stub/HLE -- the code still runs, via interp. */ -int g_native_bad_entry = 0; /* set by psx_native_bad_entry, consumed by dispatch */ -static uint32_t s_bad_entry_owner = 0; /* the function unit that rejected the PC */ -static uint32_t s_bad_entry_pc = 0; /* the illegal entry PC */ -static uint64_t s_bad_entry_count = 0; -void psx_native_bad_entry(CPUState *cpu, uint32_t owner, uint32_t pc) { - (void)cpu; - g_native_bad_entry = 1; - s_bad_entry_owner = owner; - s_bad_entry_pc = pc; - s_bad_entry_count++; -} -void overlay_loader_bad_entry_stats(uint32_t *owner, uint32_t *pc, uint64_t *count) { - if (owner) *owner = s_bad_entry_owner; - if (pc) *pc = s_bad_entry_pc; - if (count) *count = s_bad_entry_count; -} - -/* Per-function native-disable (bisection). Functions whose ENTRY phys address is - * listed here are forced through the sanctioned dirty-RAM interpreter instead of - * their native shard — exactly as if s_native_exec were off, but for that one - * function only. This is a DIAGNOSTIC localization knob (not skip/stub/HLE): the - * function still runs, just via the interpreter. Used to binary-search which - * compiled overlay function's native execution causes a native-vs-interp - * divergence. Keyed by phys (addr & 0x1FFFFFFF) so KSEG bits don't matter. */ -#define NATIVE_BLOCK_CAP 64 -static uint32_t s_native_block[NATIVE_BLOCK_CAP]; -static int s_native_block_n = 0; -static uint64_t s_native_block_hits = 0; -static int overlay_native_blocked(uint32_t addr) { - if (s_native_block_n == 0) return 0; - uint32_t p = addr & 0x1FFFFFFFu; - for (int i = 0; i < s_native_block_n; i++) - if (s_native_block[i] == p) { s_native_block_hits++; return 1; } - return 0; -} -int overlay_loader_native_block_add(uint32_t addr) { - uint32_t p = addr & 0x1FFFFFFFu; - for (int i = 0; i < s_native_block_n; i++) if (s_native_block[i] == p) return s_native_block_n; - if (s_native_block_n >= NATIVE_BLOCK_CAP) return -1; - s_native_block[s_native_block_n++] = p; - return s_native_block_n; -} -void overlay_loader_native_block_clear(void) { s_native_block_n = 0; s_native_block_hits = 0; } -int overlay_loader_native_block_list(uint32_t *out, int cap) { - int n = s_native_block_n < cap ? s_native_block_n : cap; - for (int i = 0; i < n; i++) out[i] = s_native_block[i]; - return s_native_block_n; -} -uint64_t overlay_loader_native_block_hits(void) { return s_native_block_hits; } - -/* CPS interior-continuation dispatch probe (diagnostic, always-on when armed). - * Records, for a watched interior PC, what the CPS continuation re-entry path - * (overlay_find_by_range + validate) decides: chosen candidate entry, range - * count, crc match, and outcome. Used to confirm the wrong-candidate / escape - * hypothesis for the Tomba 2 FMV freeze (pc=0x50B30). */ -static uint32_t s_cps_probe_pc = 0; /* phys, 0 = disarmed */ -static uint64_t s_cps_probe_count = 0; -static uint32_t s_cps_probe_found = 0; /* chosen c->addr (0 if none) */ -static int s_cps_probe_ci = -2; /* overlay_find_by_range result */ -static int s_cps_probe_nrange = -1; -static int s_cps_probe_matched = -1; /* crc match */ -static int s_cps_probe_outcome = -1; /* 0=find<0,1=crc-miss->interp,2=native,3=device,4=blocked */ -static int s_cps_probe_ncand_inrange = 0; /* how many candidates' range contains the PC */ -void overlay_loader_cps_probe_set(uint32_t pc) { - s_cps_probe_pc = pc & 0x1FFFFFFFu; s_cps_probe_count = 0; - s_cps_probe_found = 0; s_cps_probe_ci = -2; s_cps_probe_nrange = -1; - s_cps_probe_matched = -1; s_cps_probe_outcome = -1; s_cps_probe_ncand_inrange = 0; -} -void overlay_loader_cps_probe_get(uint32_t *pc, uint64_t *cnt, uint32_t *found, - int *ci, int *nrange, int *matched, int *outcome, - int *ncand) { - if (pc) *pc = s_cps_probe_pc; if (cnt) *cnt = s_cps_probe_count; - if (found) *found = s_cps_probe_found; if (ci) *ci = s_cps_probe_ci; - if (nrange) *nrange = s_cps_probe_nrange; if (matched) *matched = s_cps_probe_matched; - if (outcome) *outcome = s_cps_probe_outcome; if (ncand) *ncand = s_cps_probe_ncand_inrange; -} -/* count how many live candidates have a range containing phys (multi-variant check) */ -static int overlay_count_by_range(uint32_t phys) { - int n = 0; - for (int i = 0; i < s_cand_n; i++) { - const Candidate *c = &s_cand[i]; - if (c->state == ENTRY_BLACKLIST) continue; - for (int r = 0; r < c->nranges; r++) - if (phys >= c->range_lo[r] && phys < c->range_lo[r] + c->range_len[r]) { n++; break; } - } - return n; -} -/* Address of the native overlay function currently executing (0 if none). - * Used by the event-timeline ring to tag an event's execution mode. */ -uint32_t overlay_loader_get_inprogress(void) { return s_native_inprogress; } - -/* Same-state differential — defined fully below; declared here for dispatch. */ -static int s_diff_mode = 0; -/* Optional diagnostic filter: when non-zero, shadow-diff only this candidate - * entry while every other validated candidate follows its normal live route. */ -static uint32_t s_diff_addr = 0; -static int s_in_shadow = 0; -/* Candidate whose shadow NATIVE pass is currently executing (NULL outside it). - * Set ONLY around run_shadow_diff's native pass — never during the interp pass, - * which must stay pure interp. Lets the CPS continuation re-entry path below - * run THIS candidate's own interiors natively inside its shadow run (so the - * diff exercises the whole function, not just the first CPS segment), while - * every other dispatch inside the shadow stays on the interpreter. */ -static const void *s_shadow_cand = NULL; -/* Longjmp-escape hardening. run_shadow_diff saves the pre-shadow values of - * s_native_exec / s_suppress_irq here (in addition to its locals) so that - * overlay_loader_shadow_escape_fixup() can restore them if an exception - * longjmp ever unwinds through a live shadow frame (should be impossible — - * the dispatch gate refuses to start a shadow while in an exception dispatch — - * but an escape must fail LOUD and self-heal, not silently kill the - * instrument). s_shadow_epoch is the exception-setjmp epoch at shadow start: - * a longjmp targeting a frame with epoch <= s_shadow_epoch escapes the shadow; - * a larger epoch is an exception armed INSIDE the shadow (contained). */ -static int s_shadow_saved_native_exec = 0; -static int s_shadow_saved_supp = 0; -static int s_shadow_saved_mmio_watch = 0; -static int s_shadow_saved_exec_phase = 0; -static int s_shadow_scheduler_bail = 0; -static int s_shadow_saved_call_bail = 0; -static uint32_t s_shadow_saved_debug_func = 0; -static uint32_t s_shadow_saved_irq_calls = 0; -static uint64_t s_shadow_saved_irq_supp = 0; -static uint64_t s_shadow_epoch = 0; -static uint32_t s_shadow_escapes = 0; -static uint32_t s_shadow_escapes_native = 0; -/* (sljit removed 2026-07-15: the deprecated in-process JIT tier and its - * live-execution mode are gone; overlay gaps fall to the interpreter.) */ -static void run_shadow_diff(CPUState *cpu, Candidate *c, uint32_t addr); - -/* A real interpreter-pass ChangeThread is authoritative and escapes before a - * native validation pass starts. Therefore a scheduler switch attempted while - * s_shadow_cand is armed is necessarily native-only divergence. Traps calls - * this before mutating TCB/RAM or longjmping; force the speculative call to - * unwind normally so run_shadow_diff can restore the interpreter snapshot. */ -int overlay_loader_shadow_native_thread_switch_bail(void) { - if (!s_in_shadow || s_shadow_cand == NULL) return 0; - s_shadow_scheduler_bail = 1; - g_psx_call_bail = 1; - return 1; -} - -static int cand_range_contains(const Candidate *c, uint32_t phys) { - for (int r = 0; r < c->nranges; r++) - if (phys >= c->range_lo[r] && phys < c->range_lo[r] + c->range_len[r]) - return 1; - return 0; -} - -static int cand_ranges_overlap(const Candidate *a, const Candidate *b) { - for (int ar = 0; ar < a->nranges; ar++) { - uint32_t alo = a->range_lo[ar], ahi = alo + a->range_len[ar]; - for (int br = 0; br < b->nranges; br++) { - uint32_t blo = b->range_lo[br], bhi = blo + b->range_len[br]; - if (alo < bhi && blo < ahi) return 1; - } - } - return 0; -} - -/* An explicit entry filter owns its whole code envelope, including separately - * seeded continuation candidates. This prevents an IRQ resume at a mid-function - * PC from bypassing the diagnostic quarantine and running native live. The scan - * is intentionally diagnostic-only (s_diff_addr != 0). */ -static int cand_selected_for_diff(const Candidate *c) { - if (!s_diff_addr) return 1; - uint32_t target = s_diff_addr & 0x1FFFFFFFu; - if (c->addr == target || cand_range_contains(c, target)) return 1; - for (int i = 0; i < s_cand_n; i++) { - const Candidate *owner = &s_cand[i]; - if (owner->addr == target && cand_ranges_overlap(c, owner)) return 1; - } - return 0; -} - -/* ---- Counters (surfaced via overlay_loader_status) --------------------- */ -static int s_ndlls = 0; /* DLLs LoadLibrary'd */ -static uint64_t s_load_total_us = 0; -static uint64_t s_load_max_us = 0; -static uint64_t s_load_last_us = 0; -static int s_valid_count = 0; /* candidates currently VALID */ -static uint64_t s_disp_native = 0; -static uint64_t s_disp_interp = 0; -/* Small diagnostic-only native-owner sampler. A logical softlock can continue - * presenting at 60 Hz while executing a tiny bad native loop, so FPS alone is - * not correctness evidence. This is enabled only with the existing runtime - * perf environment switch and is drained once per telemetry interval. */ -#define NATIVE_HOT_CAP 256u -typedef struct { uint32_t pc; uint64_t calls; } NativeHot; -static NativeHot s_native_hot[NATIVE_HOT_CAP]; -static int s_native_hot_enabled = 0; - -#ifndef PSX_NO_DEBUG_TOOLS -/* Correctness localization without speculative/double guest execution. When - * PSX_NATIVE_RANK_LIMIT is present, each concrete loaded candidate is assigned - * a first-seen rank. Candidates through the limit execute normally; later ones - * fall back to the authoritative interpreter. Binary-searching the limit finds - * the first candidate identity required for a deterministic softlock while - * preserving exactly one guest execution path per run. Debug builds only. */ -static uint32_t s_native_rank_limit = UINT32_MAX; -static uint32_t s_native_rank_next = 0; -static uint64_t s_native_rank_blocked = 0; -#endif - -static void native_hot_note(uint32_t pc) { - if (!s_native_hot_enabled) return; - uint32_t slot = ((pc >> 2) * 2654435761u) & (NATIVE_HOT_CAP - 1u); - NativeHot *h = &s_native_hot[slot]; - if (h->pc != pc) { h->pc = pc; h->calls = 0; } - h->calls++; -} -static uint64_t s_stale_blocked = 0; /* dispatches skipped (candidate !valid)*/ -static uint32_t s_invalidations = 0; /* VALID -> INVALID transitions */ -static uint32_t s_revalidations = 0; /* INVALID -> VALID (reload-on-return) */ -static uint32_t s_rehashes = 0; /* code-range hashes computed */ -static uint32_t s_rehash_miss = 0; /* hashes that didn't match crc_code */ -static uint64_t s_gen_fastpath = 0; /* dispatches that skipped the crc32 */ - /* via the unchanged page-generation */ - /* fast path (overlay-cache v2 P2) */ -static uint64_t s_diffgate_interp = 0; /* CPS interior re-entries sent to the */ - /* interp because their candidate is */ - /* still inside the diff verify budget */ -static uint32_t s_last_crc = 0; -static uint32_t s_no_manifest = 0; /* exports skipped (no manifest range)*/ -static uint64_t s_cand_overflow = 0; /* registrations dropped at CAND_CAP */ -static uint64_t s_pair_aliases = 0; /* validated complete pairs deduped */ -static uint32_t s_selfmod = 0; /* entries blacklisted (self-mod) */ -static uint32_t s_last_write_pc = 0; -static uint32_t s_last_write_addr = 0; -static uint32_t s_last_write_size = 0; - -/* ABI v11 cycle batching. Each DLL owns a small pending-cycle accumulator. - * While its code is active, memory stores ask this export to commit cycles - * before the guest-visible write; block/device boundaries flush through the - * DLL glue directly. */ -static OverlayFlushFn s_dll_flush[4096]; -OverlayFlushFn g_overlay_flush_pending_cycles = NULL; - -static OverlayFlushFn overlay_flush_enter(const Candidate *c) { - OverlayFlushFn prev = g_overlay_flush_pending_cycles; - if (prev) prev(); - g_overlay_flush_pending_cycles = - (c->dll >= 0 && c->dll < (int)(sizeof(s_dll_flush) / sizeof(s_dll_flush[0]))) - ? s_dll_flush[c->dll] : NULL; - return prev; -} - -static void overlay_flush_leave(OverlayFlushFn prev) { - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - g_overlay_flush_pending_cycles = prev; -} - -extern uint8_t *memory_get_ram_ptr(void); -extern uint32_t overlay_watch_pagegen_sum(uint32_t phys, uint32_t len); - -/* ---- Per-candidate hash / generation over its code ranges -------------- */ - -static uint32_t cand_crc(const Candidate *c) { - const uint8_t *ram = memory_get_ram_ptr(); - uint32_t crc = 0xFFFFFFFFu; - for (int i = 0; i < c->nranges; i++) - crc = crc32_update(crc, ram + c->range_lo[i], c->range_len[i]); - return crc ^ 0xFFFFFFFFu; -} - -static uint32_t cand_gensum(const Candidate *c) { - uint32_t s = 0; - for (int i = 0; i < c->nranges; i++) - s += overlay_watch_pagegen_sum(c->range_lo[i], c->range_len[i]); - return s; -} - -#ifdef PSX_HAS_OVERLAY_DISPATCH -/* Static-overlay validation uses the same exact-code-range contract as the - * dynamic DLL loader. Generated code passes immutable {phys_lo, len} pairs and - * the CRC of the bytes it was compiled from. A page-generation cache keeps the - * hot path O(number of ranges), without re-hashing unchanged code each call. */ -#define STATIC_MATCH_CACHE_CAP 4096u -typedef struct { - const uint32_t *ranges; - uint32_t count; - uint32_t expected_crc; - uint32_t gen_sum; - int matches; -} StaticMatchCache; - -static StaticMatchCache s_static_match_cache[STATIC_MATCH_CACHE_CAP]; -static uint64_t s_static_match_rehashes = 0; -static uint64_t s_static_match_crc_misses = 0; -static uint64_t s_static_match_gen_fastpath = 0; - -int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs, - uint32_t count, - uint32_t expected_crc) { - const uint8_t *ram = memory_get_ram_ptr(); - if (!ram || !lo_len_pairs || count == 0u || count > 4096u) { - s_static_match_crc_misses++; - return 0; - } - - uint32_t gen_sum = 0; - for (uint32_t i = 0; i < count; i++) { - uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; - uint32_t len = lo_len_pairs[i * 2u + 1u]; - if (len == 0u || lo >= 2u * 1024u * 1024u || - len > 2u * 1024u * 1024u - lo) { - s_static_match_crc_misses++; - return 0; - } - gen_sum += overlay_watch_pagegen_sum(lo, len); - } - - uintptr_t raw = (uintptr_t)lo_len_pairs; - uint32_t slot = (uint32_t)(((raw >> 4) ^ (raw >> 19) ^ expected_crc ^ - (count * 0x9E3779B9u)) & - (STATIC_MATCH_CACHE_CAP - 1u)); - StaticMatchCache *entry = NULL; - for (uint32_t probe = 0; probe < STATIC_MATCH_CACHE_CAP; probe++) { - StaticMatchCache *candidate = - &s_static_match_cache[(slot + probe) & (STATIC_MATCH_CACHE_CAP - 1u)]; - if (!candidate->ranges || - (candidate->ranges == lo_len_pairs && - candidate->count == count && - candidate->expected_crc == expected_crc)) { - entry = candidate; - break; - } - } - - if (entry && entry->ranges && entry->gen_sum == gen_sum) { - s_static_match_gen_fastpath++; - return entry->matches; - } - - uint32_t crc = 0xFFFFFFFFu; - for (uint32_t i = 0; i < count; i++) { - uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; - uint32_t len = lo_len_pairs[i * 2u + 1u]; - crc = crc32_update(crc, ram + lo, len); - } - crc ^= 0xFFFFFFFFu; - int matches = (crc == expected_crc) ? 1 : 0; - s_static_match_rehashes++; - if (!matches) s_static_match_crc_misses++; - - if (entry) { - entry->ranges = lo_len_pairs; - entry->count = count; - entry->expected_crc = expected_crc; - entry->gen_sum = gen_sum; - entry->matches = matches; - } - return matches; -} - -void overlay_loader_static_match_stats(uint64_t *rehashes, - uint64_t *crc_misses, - uint64_t *gen_fastpath) { - if (rehashes) *rehashes = s_static_match_rehashes; - if (crc_misses) *crc_misses = s_static_match_crc_misses; - if (gen_fastpath) *gen_fastpath = s_static_match_gen_fastpath; -} -#endif - -/* ---- Per-DLL code-range manifest --------------------------------------- */ -/* Strict v2 line format emitted by tools/compile_overlays.py beside each DLL: - * P optional DLL/manifest publication identity - * F start a function (entry = virtual export addr) - * R a code byte-range (virtual addr, byte length) - * The recompiler's per-function instruction walk yields exactly the executed - * code bytes — interleaved jump tables / constant pools are excluded, which is - * what makes the hash stable across reloads. */ -typedef struct { - uint32_t entry; - uint32_t crc; /* expected hash of the compiled-from code bytes */ - int has_crc; /* parser validity bit; authoritative CRC is required */ - uint32_t lo[MAX_CODE_RANGES]; - uint32_t len[MAX_CODE_RANGES]; - int n; -} ManFn; - -#define OVERLAY_RAM_SIZE (2u * 1024u * 1024u) -#define MANIFEST_LINE_MAX 128u -#define MANIFEST_PHYSICAL_LINE_MAX 159u -#define MANIFEST_PROVENANCE_PREFIX "# psxrecomp overlay provenance " -enum { - MANIFEST_PROVENANCE_AMBIGUOUS = -1, - MANIFEST_PROVENANCE_AUTHORITY = 0, - MANIFEST_PROVENANCE_HOSTED = 1, - MANIFEST_PROVENANCE_ORPHAN = 2 -}; - -static int man_structurally_valid(const ManFn *m) { - if (!m || !m->has_crc || m->n < 1 || m->n > MAX_CODE_RANGES) - return 0; - if ((m->entry & 0xFFE00000u) != 0x80000000u) return 0; - uint32_t entry = m->entry & 0x1FFFFFFFu; - if ((entry & 3u) != 0u || entry >= OVERLAY_RAM_SIZE) return 0; - int entry_covered = 0; - for (int r = 0; r < m->n; r++) { - if ((m->lo[r] & 0xFFE00000u) != 0x80000000u) return 0; - uint32_t lo = m->lo[r] & 0x1FFFFFFFu; - uint32_t len = m->len[r]; - if ((lo & 3u) != 0u || (len & 3u) != 0u || len < 4u || - lo >= OVERLAY_RAM_SIZE || len > OVERLAY_RAM_SIZE - lo) - return 0; - if (entry >= lo && entry - lo <= len - 4u) entry_covered = 1; - for (int p = 0; p < r; p++) { - uint32_t prev_lo = m->lo[p] & 0x1FFFFFFFu; - uint32_t prev_hi = prev_lo + m->len[p]; - uint32_t hi = lo + len; - if (lo < prev_hi && prev_lo < hi) return 0; - } - } - return entry_covered; -} - -static int manifest_is_space(unsigned char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\v' || c == '\f'; -} - -static const char *manifest_skip_space(const char *p) { - while (*p && manifest_is_space((unsigned char)*p)) p++; - return p; -} - -static int manifest_hex_field(const char **cursor, int max_digits, - uint64_t *out) { - const char *p = manifest_skip_space(*cursor); - uint64_t value = 0; - int digits = 0; - while (isxdigit((unsigned char)*p)) { - int nibble; - if (*p >= '0' && *p <= '9') nibble = *p - '0'; - else if (*p >= 'a' && *p <= 'f') nibble = *p - 'a' + 10; - else nibble = *p - 'A' + 10; - if (++digits > max_digits) return 0; - value = (value << 4) | (uint64_t)nibble; - p++; - } - if (digits == 0 || (*p && !manifest_is_space((unsigned char)*p))) return 0; - *cursor = p; - *out = value; - return 1; -} - -static int manifest_record_end(const char *cursor) { - return *manifest_skip_space(cursor) == '\0'; -} - -static ManFn *parse_manifest(const char *path, int *out_n, - uint64_t *out_pair_id, - int *out_has_pair_id, - int *out_provenance) { - *out_n = 0; - if (out_pair_id) *out_pair_id = 0; - if (out_has_pair_id) *out_has_pair_id = 0; - if (out_provenance) *out_provenance = MANIFEST_PROVENANCE_AUTHORITY; - FILE *f = fopen(path, "rb"); - if (!f) return NULL; - int cap = 1024, n = 0; - int invalid = 0, pair_seen = 0, provenance_seen = 0; - int provenance = MANIFEST_PROVENANCE_AUTHORITY; - ManFn *arr = (ManFn *)malloc(sizeof(ManFn) * cap); - if (!arr) { fclose(f); return NULL; } - char line[MANIFEST_PHYSICAL_LINE_MAX + 1u]; - ManFn *cur = NULL; - for (;;) { - size_t line_len = 0; - int ch = 0, too_long = 0; - while ((ch = fgetc(f)) != '\n' && ch != EOF) { - if (line_len + 1 < sizeof(line)) line[line_len++] = (char)ch; - else too_long = 1; - } - if (ch == EOF && line_len == 0 && !too_long) break; - line[line_len] = '\0'; - size_t semantic_len = line_len; - if (semantic_len > 0 && line[semantic_len - 1] == '\r') - semantic_len--; - size_t provenance_prefix_len = strlen(MANIFEST_PROVENANCE_PREFIX); - if (semantic_len >= provenance_prefix_len && - memcmp(line, MANIFEST_PROVENANCE_PREFIX, - provenance_prefix_len) == 0) { - const char *value = line + provenance_prefix_len; - size_t value_len = semantic_len - provenance_prefix_len; - int parsed_provenance = MANIFEST_PROVENANCE_AMBIGUOUS; - if (value_len == strlen("hosted-v1") && - memcmp(value, "hosted-v1", value_len) == 0) - parsed_provenance = MANIFEST_PROVENANCE_HOSTED; - else if (value_len == strlen("orphan-v1") && - memcmp(value, "orphan-v1", value_len) == 0) - parsed_provenance = MANIFEST_PROVENANCE_ORPHAN; - provenance_seen++; - provenance = provenance_seen == 1 - ? parsed_provenance : MANIFEST_PROVENANCE_AMBIGUOUS; - } - const char *record = manifest_skip_space(line); - int recognized = ((*record == 'P' || *record == 'F' || *record == 'R') && - (record[1] == '\0' || - manifest_is_space((unsigned char)record[1]))); - int embedded_nul = memchr(line, '\0', line_len) != NULL; - int non_ascii = 0; - for (size_t i = 0; i < line_len; i++) - if ((unsigned char)line[i] >= 0x80u) { non_ascii = 1; break; } - size_t record_width = 0; - while (record_width < line_len && line[record_width] != '\r') - record_width++; - if (too_long || embedded_nul || non_ascii || - record_width > MANIFEST_LINE_MAX) { - invalid = 1; - continue; - } - if (!recognized) continue; - if (*record == 'P') { - const char *cursor = record + 1; - uint64_t parsed = 0; - if (manifest_hex_field(&cursor, 16, &parsed) && - manifest_record_end(cursor)) { - if (pair_seen) invalid = 1; - pair_seen = 1; - if (out_pair_id) *out_pair_id = parsed; - if (out_has_pair_id) *out_has_pair_id = 1; - } else invalid = 1; - } else if (*record == 'F') { - const char *cursor = record + 1; - uint64_t parsed_e = 0, parsed_crc = 0; - if (manifest_hex_field(&cursor, 8, &parsed_e) && - manifest_hex_field(&cursor, 8, &parsed_crc) && - manifest_record_end(cursor)) { - if (n >= cap) { - cap *= 2; - ManFn *na = (ManFn *)realloc(arr, sizeof(ManFn) * cap); - if (!na) { invalid = 1; break; } - arr = na; - } - cur = &arr[n++]; - cur->entry = ((uint32_t)parsed_e & 0x1FFFFFFFu) | 0x80000000u; - cur->crc = (uint32_t)parsed_crc; - cur->has_crc = 1; - cur->n = 0; - } else invalid = 1; - } else if (*record == 'R' && cur) { - const char *cursor = record + 1; - uint64_t parsed_lo = 0, parsed_len = 0; - if (manifest_hex_field(&cursor, 8, &parsed_lo) && - manifest_hex_field(&cursor, 8, &parsed_len) && - manifest_record_end(cursor) && - cur->n < MAX_CODE_RANGES && parsed_len > 0) { - cur->lo[cur->n] = (uint32_t)parsed_lo; - cur->len[cur->n] = (uint32_t)parsed_len; - cur->n++; - } else invalid = 1; - } - } - if (!invalid && (n == 0 || !cur || !man_structurally_valid(cur))) invalid = 1; - fclose(f); - for (int i = 0; i < n; i++) { - uint32_t entry = arr[i].entry & 0x1FFFFFFFu; - int contains_entry = 0; - if (!arr[i].has_crc || arr[i].n <= 0) invalid = 1; - for (int r = 0; r < arr[i].n; r++) { - uint32_t lo = arr[i].lo[r] & 0x1FFFFFFFu; - uint64_t hi = (uint64_t)lo + arr[i].len[r]; - if (lo >= OVERLAY_RAM_SIZE || - arr[i].len[r] > OVERLAY_RAM_SIZE - lo) - invalid = 1; - if ((uint64_t)entry >= lo && (uint64_t)entry < hi) - contains_entry = 1; - } - if (!contains_entry) invalid = 1; - for (int j = 0; j < i; j++) - if ((arr[j].entry & 0x1FFFFFFFu) == entry) invalid = 1; - } - if (invalid || n == 0) { - free(arr); - return NULL; - } - if (out_provenance) *out_provenance = provenance; - *out_n = n; - return arr; -} - -static ManFn *man_find(ManFn *arr, int n, uint32_t entry) { - for (int i = 0; i < n; i++) - if (arr[i].entry == entry) return &arr[i]; - return NULL; -} - -/* 1 = supported R3000A control transfer with a delay slot, 0 = ordinary - * instruction, -1 = reserved/unsupported branch encoding that native shards - * must never claim. */ -static int mips_control_kind(uint32_t instr) { - uint32_t op = instr >> 26; - if (op == 0u) { - uint32_t funct = instr & 0x3Fu; - return funct == 0x08u || funct == 0x09u; /* JR / JALR */ - } - if (op == 0x01u) { /* REGIMM */ - uint32_t rt = (instr >> 16) & 0x1Fu; - return (rt == 0x00u || rt == 0x01u || - rt == 0x10u || rt == 0x11u) ? 1 : -1; - } - if (op == 0x02u || op == 0x03u || - (op >= 0x04u && op <= 0x07u)) /* J/JAL/branches */ - return 1; - if (op >= 0x14u && op <= 0x17u) return -1; /* branch-likely: reserved */ - if (op >= 0x10u && op <= 0x13u && - ((instr >> 21) & 0x1Fu) == 0x08u) return -1; /* unsupported BCz* */ - return 0; -} - -static int ranges_contain_word(const uint32_t *lo_list, - const uint32_t *len_list, int n, - uint32_t phys) { - if ((phys & 3u) != 0u || phys > (2u * 1024u * 1024u) - 4u) return 0; - for (int r = 0; r < n; r++) { - uint32_t lo = lo_list[r] & 0x1FFFFFFFu; - uint32_t len = len_list[r]; - if (len >= 4u && phys >= lo && phys - lo <= len - 4u) return 1; - } - return 0; -} - -/* Defense in depth for caches generated before guarded capture/codegen. Called - * only after the range CRC matches. Scan every hashed instruction so a missing - * slot at any range edge, an unsupported branch form, or a nested transfer in a - * delay slot rejects the whole native identity. */ -static int ranges_delay_slots_hashed(const uint32_t *lo_list, - const uint32_t *len_list, int n) { - const uint8_t *ram = memory_get_ram_ptr(); - const uint32_t ram_size = 2u * 1024u * 1024u; - if (!ram || n < 1 || n > MAX_CODE_RANGES) return 0; - for (int r = 0; r < n; r++) { - uint32_t lo = lo_list[r] & 0x1FFFFFFFu; - uint32_t len = len_list[r]; - if ((lo & 3u) != 0u || (len & 3u) != 0u || len < 4u || - lo >= ram_size || len > ram_size - lo) - return 0; - for (uint32_t pc = lo; pc < lo + len; pc += 4u) { - uint32_t instr = (uint32_t)ram[pc] | - ((uint32_t)ram[pc + 1u] << 8) | - ((uint32_t)ram[pc + 2u] << 16) | - ((uint32_t)ram[pc + 3u] << 24); - int kind = mips_control_kind(instr); - if (kind < 0) return 0; - if (kind > 0) { - uint32_t slot = pc + 4u; - if (!ranges_contain_word(lo_list, len_list, n, slot)) return 0; - uint32_t slot_instr = (uint32_t)ram[slot] | - ((uint32_t)ram[slot + 1u] << 8) | - ((uint32_t)ram[slot + 2u] << 16) | - ((uint32_t)ram[slot + 3u] << 24); - if (mips_control_kind(slot_instr) != 0) return 0; - } - } - } - return 1; -} - -/* Complete-pair deduplication. A generated P record binds the DLL source, - * manifest provenance, and normalized F/R records. The pair id alone is not - * enough for aliasing: retain the canonical parsed manifest and require exact - * semantic equality as collision defense. Compiler tiers remain separate so - * a gcc image never silently stands in for a tcc image (or vice versa). - * - * Only a fully registered pair enters this table. Its DLL handle, function - * pointers, and cycle-flush callback remain the sole owners for process life; - * a later identical physical cache pair is preflighted, closed, and recorded - * as a satisfied path without consuming Candidate slots. */ -#define LOADED_PAIR_CAP 4096 -#define LOAD_PAIR_ALIAS (-1) -typedef struct { - uint64_t pair_id; - ManFn *man; - int man_n; - int tier; - int provenance; -} LoadedPair; -static LoadedPair s_loaded_pairs[LOADED_PAIR_CAP]; -static int s_loaded_pair_n = 0; - -static int manifest_pair_equal(const ManFn *a, int a_n, - const ManFn *b, int b_n) { - if (!a || !b || a_n != b_n) return 0; - for (int i = 0; i < a_n; i++) { - if (a[i].entry != b[i].entry || a[i].crc != b[i].crc || - a[i].has_crc != b[i].has_crc || a[i].n != b[i].n) - return 0; - for (int r = 0; r < a[i].n; r++) - if ((a[i].lo[r] & 0x1FFFFFFFu) != - (b[i].lo[r] & 0x1FFFFFFFu) || - a[i].len[r] != b[i].len[r]) - return 0; - } - return 1; -} - -static int man_delay_slots_hashed(const ManFn *m) { - return ranges_delay_slots_hashed(m->lo, m->len, m->n); -} - -static int cand_delay_slots_hashed(const Candidate *c) { - return ranges_delay_slots_hashed(c->range_lo, c->range_len, c->nranges); -} - -static int loaded_pair_find(int tier, int provenance, uint64_t pair_id, - const ManFn *man, int man_n) { - if (provenance == MANIFEST_PROVENANCE_AMBIGUOUS) return -1; - for (int i = 0; i < s_loaded_pair_n; i++) { - const LoadedPair *p = &s_loaded_pairs[i]; - if (p->tier == tier && p->provenance == provenance && - p->pair_id == pair_id && - manifest_pair_equal(p->man, p->man_n, man, man_n)) - return i; - } - return -1; -} - -static void loaded_pair_commit(int tier, int provenance, uint64_t pair_id, - const ManFn *man, int man_n) { - if (!man || man_n <= 0 || - provenance == MANIFEST_PROVENANCE_AMBIGUOUS || - s_loaded_pair_n >= LOADED_PAIR_CAP || - loaded_pair_find(tier, provenance, pair_id, man, man_n) >= 0) - return; - ManFn *copy = (ManFn *)malloc(sizeof(*copy) * (size_t)man_n); - if (!copy) return; /* Safe fallback: later twins register independently. */ - memcpy(copy, man, sizeof(*copy) * (size_t)man_n); - LoadedPair *p = &s_loaded_pairs[s_loaded_pair_n++]; - p->pair_id = pair_id; - p->man = copy; - p->man_n = man_n; - p->tier = tier; - p->provenance = provenance; -} - -/* ---- Candidate registration -------------------------------------------- */ - -static void loader_log(const char *fmt, ...); /* defined below */ -static int cand_register(uint32_t phys, OverlayFn fn, const ManFn *m, int dll, - int tier) { - if (s_cand_n >= CAND_CAP) { - s_cand_overflow++; - return 0; - } - if (!man_structurally_valid(m)) return 0; - int idx = s_cand_n++; - Candidate *c = &s_cand[idx]; - c->addr = phys; - c->fn = fn; - c->dll = dll; - c->tier = (uint8_t)tier; - c->state = ENTRY_VALID; - c->nranges = 0; - for (int i = 0; i < m->n && c->nranges < MAX_CODE_RANGES; i++) { - c->range_lo[c->nranges] = m->lo[i] & 0x1FFFFFFFu; - c->range_len[c->nranges] = m->len[i]; - c->nranges++; - } - /* Watch the code pages so future writes bump their generation. */ - extern void overlay_watch_set_range(uint32_t phys, uint32_t len); - for (int i = 0; i < c->nranges; i++) - overlay_watch_set_range(c->range_lo[i], c->range_len[i]); - - /* crc_code is the AUTHORITATIVE hash of the bytes the recompiler compiled - * from (supplied by the manifest) — NOT a sample of live RAM at this instant - * (registration is a single transient moment; the overlay may not be fully - * present yet, and other overlays sharing the merged DLL are not present at - * all). A candidate is callable iff live RAM matches this compiled-from hash, - * which makes validity timing-independent and reload-on-return work. */ - c->crc_code = m->crc; - c->val_gen = cand_gensum(c); - c->state = (cand_crc(c) == c->crc_code && cand_delay_slots_hashed(c)) - ? ENTRY_VALID : ENTRY_INVALID; - /* Keep higher-priority compiler tiers first without discarding additive - * artifacts. New same-tier repairs precede older ones; lower-tier TCC - * remains available if every GCC candidate fails live-byte validation. */ - int head = idx_head(phys); - if (head < 0 || s_cand[head].tier <= c->tier) { - c->next = head; - idx_set_head(phys, idx); - } else { - int prev = head; - while (s_cand[prev].next >= 0 && - s_cand[s_cand[prev].next].tier > c->tier) - prev = s_cand[prev].next; - c->next = s_cand[prev].next; - s_cand[prev].next = idx; - } - exact_entry_set(phys); - range_index_add_candidate(idx); - s_range_candidate_generation++; - if (s_range_candidate_generation == 0u) s_range_candidate_generation = 1u; - if (c->state == ENTRY_VALID) s_valid_count++; - /* (sljit removed 2026-07-15: the gcc-DLL-obsoletes-older-sljit-shard block - * lived here. With no sljit shards ever registered (dll == -1), there is - * nothing to obsolete; the normal content-keyed chain dispatch stands.) */ - return 1; -} - -/* (sljit removed 2026-07-15: register_sljit_candidate, the JIT-on-miss memo, - * the async compile-worker glue, the master tier gate, and try_sljit_region - * lived here. Overlay misses now fall to the interpreter.) */ - -/* ---- Global state ------------------------------------------------------ */ - -static char s_cache_dir[512]; -static char s_game_id[64]; -static uint32_t s_config_hash; -static int s_active = 0; - -/* Canonical (filesystem-resolved) form of s_cache_dir, computed lazily on the - * first live publication — the root is guaranteed to exist by then (the - * compiler just wrote a DLL into it). cache_path_has_root() is sound ONLY on - * canonical inputs: raw pipe text can smuggle `..` segments or a junction - * past a lexical prefix compare. */ -static char s_cache_root_canon[768]; -static int s_cache_root_canon_ok = 0; - -/* Rule 3: no stderr logging. Most recent loader event recorded here and - * surfaced through overlay_loader_status. */ -static char s_last_msg[256] = {0}; - -static void loader_log(const char *fmt, ...) { - va_list ap; - va_start(ap, fmt); - vsnprintf(s_last_msg, sizeof(s_last_msg), fmt, ap); - va_end(ap); -} - -const char *overlay_loader_last_msg(void) { return s_last_msg; } - -/* ---- Cache index: region_start -> dll path ----------------------------- */ - -/* 256 -> 4096 (2026-07-03): Tomba2's cache crossed 256 DLLs and the index - * TRUNCATED SILENTLY — every entry past the cap was invisible to the loader - * (region ran interpreted forever, no diagnostic). Found by the ABI-sweep - * negative test. scan_one_cache_dir now shouts if even 4096 is hit. */ -#define CACHE_IDX_CAP 4096 -enum { CACHE_TIER_UNKNOWN = 0, CACHE_TIER_TCC = 1, CACHE_TIER_GCC = 2 }; -typedef struct { - uint32_t region_start; - uint32_t logical_crc; - uint64_t mtime; - int func_count; - int indexed_func_count; - uint8_t tier; - uint8_t manifest_ok; - uint8_t load_failed; - int resident; - int capacity_suppressed; - char path[768]; -} CacheEntry; -static CacheEntry s_cache_idx[CACHE_IDX_CAP]; -static int s_cache_idx_count = 0; -static int s_capacity_warm_dropped = 0; -static void overlay_image_warm_cancel(int ci); -static void overlay_image_warm_release(int ci); -static void overlay_image_warm_drop_all(void); - -static int cache_entry_suppress_for_shortfall(int ci, int needed) { - if (ci < 0 || ci >= s_cache_idx_count || needed <= 0) return 0; - CacheEntry *e = &s_cache_idx[ci]; - if (e->capacity_suppressed) return 1; - if (needed <= CAND_CAP - s_cand_n) return 0; - if (s_cand_n >= CAND_CAP && !s_capacity_warm_dropped) { - /* No further DLL can ever register in this process. Release every - * speculative image-map reference, including bundles never dispatched. */ - overlay_image_warm_drop_all(); - s_capacity_warm_dropped = 1; - } else { - /* Smaller bundles may still fit. Release only this impossible image - * and leave warming enabled for candidates within the remaining cap. */ - overlay_image_warm_cancel(ci); - overlay_image_warm_release(ci); - } - e->capacity_suppressed = 1; - s_cand_overflow += (uint64_t)needed; - loader_log("*** CANDIDATE TABLE SHORTFALL (%d): suppressing %s (%d " - "manifest candidates, %d slots remain); native coverage is " - "falling back to the interpreter", CAND_CAP, e->path, needed, - CAND_CAP - s_cand_n); - return 1; -} - -/* Candidates are process-lifetime objects, so reaching CAND_CAP is permanent. - * Suppress each not-yet-loaded bundle once instead of repeatedly mapping and - * unmapping it on every dispatch miss. The durable overflow count includes the - * manifest identities that this short-circuit prevents from registering. */ -static int cache_entry_suppress_at_capacity(int ci) { - if (ci < 0 || ci >= s_cache_idx_count) return 0; - CacheEntry *e = &s_cache_idx[ci]; - if (e->capacity_suppressed) return 1; - if (s_cand_n < CAND_CAP) return 0; - int dropped = e->indexed_func_count > 0 - ? e->indexed_func_count : e->func_count; - if (dropped == 0) dropped = 1; - return cache_entry_suppress_for_shortfall(ci, dropped); -} - -static int path_component_eq(const char *path, const char *wanted) { - size_t wanted_len = strlen(wanted); - const char *p = path; - while (*p) { - while (*p == '/' || *p == '\\') p++; - const char *start = p; - while (*p && *p != '/' && *p != '\\') p++; - size_t len = (size_t)(p - start); - if (len == wanted_len) { - size_t i = 0; - for (; i < len; i++) { - unsigned char a = (unsigned char)start[i]; - unsigned char b = (unsigned char)wanted[i]; - if (tolower(a) != tolower(b)) break; - } - if (i == len) return 1; - } - } - return 0; -} - -static int cache_tier_from_path(const char *path) { - if (path_component_eq(path, "gcc")) return CACHE_TIER_GCC; - if (path_component_eq(path, "tcc")) return CACHE_TIER_TCC; - return CACHE_TIER_UNKNOWN; -} - -static int cache_name_is_immutable(const char *name) { - if (!name) return 0; - size_t len = strlen(name); - /* Current publishers commit a recoverable region/CRC pair. Retain support - * for additive artifact-suffixed pairs produced by earlier cache tools. */ - if (len == 17u + OVERLAY_SHARED_EXT_LEN) return name[8] == '_'; - return len == 26u + OVERLAY_SHARED_EXT_LEN && - name[8] == '_' && name[17] == '_'; -} - -static int cache_path_char_equal(unsigned char a, unsigned char b) { -#ifdef _WIN32 - if ((a == '/' || a == '\\') && (b == '/' || b == '\\')) return 1; - return tolower(a) == tolower(b); -#else - return a == b; -#endif -} - -static int cache_path_equal(const char *a, const char *b) { - if (!a || !b) return 0; - while (*a && *b) { - if (!cache_path_char_equal((unsigned char)*a, (unsigned char)*b)) - return 0; - a++; - b++; - } - return *a == '\0' && *b == '\0'; -} - -static int cache_path_has_root(const char *path, const char *root) { - if (!path || !root) return 0; - size_t root_len = strlen(root); - while (root_len > 0u && - (root[root_len - 1u] == '/' || root[root_len - 1u] == '\\')) - root_len--; - for (size_t i = 0; i < root_len; i++) { - if (!path[i] || - !cache_path_char_equal((unsigned char)path[i], - (unsigned char)root[i])) - return 0; - } - return path[root_len] == '/' || path[root_len] == '\\'; -} - -#ifdef _WIN32 -/* Image-only, bounded mapping for small recovery/fragment DLLs. Windows can - * spend ~100 ms in its loader/security path on the first LoadLibrary, enough to - * drain audio even though guest execution is otherwise at full speed. Map the - * dynamic-text-base fragments on a worker before streamed code first uses them. - * The emulation thread still performs overlay_init, export registration, and - * live-byte validation. - * - * This deliberately is NOT a boot-time preload: Tomba's mature vault contains - * hundreds of historical fragments and retaining all of them consumed ~1.8 GB. - * Only one cache region's small fragments are mapped. */ -typedef struct { - volatile LONG state; /* 0 idle, 1 mapping, 2 ready, 3 cancelled, -1 failed */ - HMODULE handle; -} ImageWarm; -typedef struct { - int cache_idx; - char path[768]; -} ImageWarmJob; -typedef struct { - int count; - ImageWarmJob jobs[1]; -} ImageWarmBatch; -static ImageWarm s_image_warm[CACHE_IDX_CAP]; -volatile LONG g_overlay_image_warm_loaded = 0; -volatile LONG g_overlay_image_warm_pending = 0; -static int s_image_warm_enabled = 1; -static ULONGLONG s_image_warm_not_before_ms = 0; - -static DWORD WINAPI overlay_image_warm_main(LPVOID unused) { - ImageWarmBatch *batch = (ImageWarmBatch *)unused; - ULONGLONG now = GetTickCount64(); - if (now < s_image_warm_not_before_ms) - Sleep((DWORD)(s_image_warm_not_before_ms - now)); - for (int i = 0; i < batch->count; i++) { - ImageWarmJob *job = &batch->jobs[i]; - int ci = job->cache_idx; - if (InterlockedCompareExchange(&s_image_warm[ci].state, 0, 0) != 1) { - InterlockedDecrement(&g_overlay_image_warm_pending); - continue; - } - HMODULE h = LoadLibraryA(job->path); - s_image_warm[ci].handle = h; - if (InterlockedCompareExchange(&s_image_warm[ci].state, - h ? 2 : -1, 1) == 1) { - if (h) InterlockedIncrement(&g_overlay_image_warm_loaded); - } else { - /* The emulation thread needed this image before the batch reached - * it and cancelled the speculative reference. */ - s_image_warm[ci].handle = NULL; - if (h) FreeLibrary(h); - InterlockedExchange(&s_image_warm[ci].state, 0); - } - InterlockedDecrement(&g_overlay_image_warm_pending); - } - free(batch); - return 0; -} - -static void overlay_image_warm_init(void) { - const char *e = getenv("PSX_OVERLAY_IMAGE_WARM"); - if (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N')) - s_image_warm_enabled = 0; - /* Leave BIOS/game initialization alone, but finish before the first - * streamed replacement of the dynamic text base. */ - s_image_warm_not_before_ms = GetTickCount64() + 5000u; -} - -static void overlay_image_warm_queue(const int *indices, int count) { - /* Candidate storage is process-lifetime. Once full, no warmed image can - * ever be registered, including a queue attempted later during init. */ - if (!s_image_warm_enabled || s_capacity_warm_dropped || - s_cand_n >= CAND_CAP || count <= 0) return; - size_t bytes = sizeof(ImageWarmBatch) + - (size_t)(count - 1) * sizeof(ImageWarmJob); - ImageWarmBatch *batch = (ImageWarmBatch *)malloc(bytes); - if (!batch) return; - batch->count = 0; - for (int i = 0; i < count; i++) { - int ci = indices[i]; - if (ci < 0 || ci >= s_cache_idx_count) continue; - if (s_cache_idx[ci].capacity_suppressed) continue; - if (InterlockedCompareExchange(&s_image_warm[ci].state, 1, 0) != 0) - continue; - ImageWarmJob *job = &batch->jobs[batch->count++]; - job->cache_idx = ci; - snprintf(job->path, sizeof(job->path), "%s", s_cache_idx[ci].path); - InterlockedIncrement(&g_overlay_image_warm_pending); - } - if (batch->count == 0) { free(batch); return; } - HANDLE h = CreateThread(NULL, 0, overlay_image_warm_main, batch, 0, NULL); - if (!h) { - for (int i = 0; i < batch->count; i++) { - InterlockedDecrement(&g_overlay_image_warm_pending); - InterlockedExchange(&s_image_warm[batch->jobs[i].cache_idx].state, 0); - } - free(batch); - return; - } - SetThreadPriority(h, THREAD_PRIORITY_BELOW_NORMAL); - CloseHandle(h); -} - -static void overlay_image_warm_cancel(int ci) { - if (ci < 0 || ci >= CACHE_IDX_CAP) return; - InterlockedCompareExchange(&s_image_warm[ci].state, 3, 1); -} - -static void overlay_image_warm_release(int ci) { - if (ci < 0 || ci >= CACHE_IDX_CAP) return; - if (InterlockedCompareExchange(&s_image_warm[ci].state, 0, 0) != 2) return; - HMODULE h = s_image_warm[ci].handle; - s_image_warm[ci].handle = NULL; - InterlockedExchange(&s_image_warm[ci].state, 0); - if (h) FreeLibrary(h); /* load_overlay_dll retained its own reference */ -} -static void overlay_image_warm_drop_all(void) { - for (int ci = 0; ci < s_cache_idx_count; ci++) { - overlay_image_warm_cancel(ci); - overlay_image_warm_release(ci); - } -} -#else -long g_overlay_image_warm_loaded = 0; -long g_overlay_image_warm_pending = 0; -static void overlay_image_warm_init(void) {} -static void overlay_image_warm_queue(const int *indices, int count) - { (void)indices; (void)count; } -static void overlay_image_warm_cancel(int ci) { (void)ci; } -static void overlay_image_warm_release(int ci) { (void)ci; } -static void overlay_image_warm_drop_all(void) {} -#endif - -static void overlay_image_warm_seed_boot_text(void); - -/* Manifest-only cache index. Parsing the small .ranges sidecars up front lets - * a dispatch hash the live code bytes and LoadLibrary only the exact matching - * variant. This is the scalable alternative to trial-loading hundreds of DLLs - * that share one reused RAM region. */ -#define LAZY_MAN_CAP (CAND_CAP * 2) -#define LAZY_ENTRY_CAP (CAND_CAP * 2u) -#define LAZY_ENTRY_MASK (LAZY_ENTRY_CAP - 1u) -#define LAZY_RANGE_LINK_CAP (LAZY_MAN_CAP * 8) -typedef struct { - int cache_idx; - ManFn fn; - uint32_t val_gen; - uint8_t state; /* 0xFF unknown, else ENTRY_VALID/INVALID */ - int next_entry; - int next_bundle; -} LazyMan; -static LazyMan s_lazy_man[LAZY_MAN_CAP]; -static int s_lazy_man_n = 0; -static int s_lazy_man_overflow = 0; -static int s_lazy_entry_head[LAZY_ENTRY_CAP]; -static int s_lazy_entry_tail[LAZY_ENTRY_CAP]; -static int s_lazy_bundle_head[CACHE_IDX_CAP]; -static int s_lazy_page_head[RANGE_PAGE_COUNT]; -static int s_lazy_page_tail[RANGE_PAGE_COUNT]; -static RangeLink s_lazy_range_links[LAZY_RANGE_LINK_CAP]; -static int s_lazy_range_link_n = 0; - -/* A direct-mapped negative cache for the final "no live native owner" result. - * Hot uncovered PCs otherwise repeat manifest/range-chain discovery on every - * branch into the interpreter. The entry is valid only for the current RAM - * code generation and loader generation: executable writes, DLL publication, - * and cache rescans all make old misses disappear immediately. */ -#define LAZY_MISS_CACHE_CAP 16384u -#define LAZY_MISS_CACHE_MASK (LAZY_MISS_CACHE_CAP - 1u) -typedef struct { - uint32_t phys; - uint32_t code_gen; - uint32_t watched_code_gen; - uint32_t loader_gen; -} LazyMissEntry; -static LazyMissEntry s_lazy_miss_cache[LAZY_MISS_CACHE_CAP]; -static uint32_t s_lazy_loader_gen = 1; -static uint32_t s_lazy_watched_code_gen = 1; -extern uint32_t g_dirty_ram_code_gen; - -static void lazy_miss_invalidate_loader(void) { - if (++s_lazy_loader_gen == 0) { - memset(s_lazy_miss_cache, 0, sizeof(s_lazy_miss_cache)); - s_lazy_loader_gen = 1; - } -} - -static int lazy_miss_cached(uint32_t phys) { - LazyMissEntry *e = &s_lazy_miss_cache[(phys >> 2) & LAZY_MISS_CACHE_MASK]; - return e->phys == phys && e->code_gen == g_dirty_ram_code_gen && - e->watched_code_gen == s_lazy_watched_code_gen && - e->loader_gen == s_lazy_loader_gen; -} - -static void lazy_miss_record(uint32_t phys) { - LazyMissEntry *e = &s_lazy_miss_cache[(phys >> 2) & LAZY_MISS_CACHE_MASK]; - e->phys = phys; - e->code_gen = g_dirty_ram_code_gen; - e->watched_code_gen = s_lazy_watched_code_gen; - e->loader_gen = s_lazy_loader_gen; -} - -void overlay_loader_note_code_write(void) { - if (++s_lazy_watched_code_gen == 0) { - memset(s_lazy_miss_cache, 0, sizeof(s_lazy_miss_cache)); - s_lazy_watched_code_gen = 1; - } -} - -static void rebuild_lazy_manifest_index(void) { - memset(s_exact_entry_bitmap, 0, sizeof(s_exact_entry_bitmap)); - s_lazy_man_n = 0; - s_lazy_man_overflow = 0; - s_lazy_range_link_n = 0; - for (uint32_t i = 0; i < LAZY_ENTRY_CAP; i++) { - s_lazy_entry_head[i] = -1; - s_lazy_entry_tail[i] = -1; - } - for (int i = 0; i < CACHE_IDX_CAP; i++) s_lazy_bundle_head[i] = -1; - for (uint32_t i = 0; i < RANGE_PAGE_COUNT; i++) { - s_lazy_page_head[i] = -1; - s_lazy_page_tail[i] = -1; - } - for (int ci = 0; ci < s_cache_idx_count; ci++) { - s_cache_idx[ci].func_count = 0; - s_cache_idx[ci].indexed_func_count = 0; - s_cache_idx[ci].manifest_ok = 0; - char path[800]; - snprintf(path, sizeof(path), "%s", s_cache_idx[ci].path); - size_t n = strlen(path); - if (n < OVERLAY_SHARED_EXT_LEN || - strcmp(path + n - OVERLAY_SHARED_EXT_LEN, OVERLAY_SHARED_EXT) != 0) - continue; - snprintf(path + n - OVERLAY_SHARED_EXT_LEN, - sizeof(path) - (n - OVERLAY_SHARED_EXT_LEN), ".ranges"); - int man_n = 0; - ManFn *man = parse_manifest(path, &man_n, NULL, NULL, NULL); - if (!man) continue; - s_cache_idx[ci].manifest_ok = 1; - s_cache_idx[ci].func_count = man_n; - for (int mi = 0; mi < man_n; mi++) { - if (!man[mi].has_crc || man[mi].n <= 0) continue; - uint32_t entry = man[mi].entry & 0x1FFFFFFFu; - exact_entry_set(entry); - uint32_t bucket = (entry * 2654435761u) & LAZY_ENTRY_MASK; - - if (s_lazy_man_n >= LAZY_MAN_CAP) { - s_lazy_man_overflow = 1; - break; - } - int li = s_lazy_man_n++; - LazyMan *lm = &s_lazy_man[li]; - lm->cache_idx = ci; - lm->fn = man[mi]; - lm->val_gen = 0; - lm->state = 0xFFu; - lm->next_bundle = s_lazy_bundle_head[ci]; - s_lazy_bundle_head[ci] = li; - s_cache_idx[ci].indexed_func_count++; - /* Exact-entry chains historically insert at the head, so later - * cache entries win. Keep that order distinct from range ownership - * below, which is oldest-first. */ - lm->next_entry = s_lazy_entry_head[bucket]; - s_lazy_entry_head[bucket] = li; - if (s_lazy_entry_tail[bucket] < 0) s_lazy_entry_tail[bucket] = li; - - uint8_t seen[RANGE_PAGE_COUNT / 8] = {0}; - for (int r = 0; r < lm->fn.n; r++) { - uint32_t lo = lm->fn.lo[r] & 0x1FFFFFFFu; - uint32_t hi = lo + lm->fn.len[r] - 1u; - /* Unloaded manifests must participate in the same page - * generations as registered DLL candidates. Otherwise a CPU - * copy can turn INVALID bytes into a match while both the - * LazyMan state and final-miss cache remain stale forever. */ - overlay_watch_set_range(lo, lm->fn.len[r]); - uint32_t p0 = lo >> 12, p1 = hi >> 12; - if (p0 >= RANGE_PAGE_COUNT) continue; - if (p1 >= RANGE_PAGE_COUNT) p1 = RANGE_PAGE_COUNT - 1u; - for (uint32_t p = p0; p <= p1; p++) { - uint8_t bit = (uint8_t)(1u << (p & 7u)); - if (seen[p >> 3] & bit) continue; - seen[p >> 3] |= bit; - if (s_lazy_range_link_n >= LAZY_RANGE_LINK_CAP) { - s_lazy_man_overflow = 1; - break; - } - int ri = s_lazy_range_link_n++; - s_lazy_range_links[ri].cand = li; - s_lazy_range_links[ri].next = -1; - if (s_lazy_page_head[p] < 0) s_lazy_page_head[p] = ri; - else s_lazy_range_links[s_lazy_page_tail[p]].next = ri; - s_lazy_page_tail[p] = ri; - } - if (s_lazy_man_overflow) break; - } - if (s_lazy_man_overflow) break; - } - free(man); - if (s_lazy_man_overflow) break; - } - for (int i = 0; i < s_cand_n; i++) exact_entry_set(s_cand[i].addr); -} - -static int cache_idx_has_path(const char *path) { - for (int i = 0; i < s_cache_idx_count; i++) - if (cache_path_equal(s_cache_idx[i].path, path)) return 1; - return 0; -} - -/* Cache discovery is idempotent only by full path. Logical identities may have - * multiple immutable artifacts; validation and tier-aware selection happen - * after every artifact is indexed. */ -static int cache_filename_equal(const char *a, const char *b) { -#ifdef _WIN32 - return _stricmp(a, b) == 0; -#else - return strcmp(a, b) == 0; -#endif -} - -/* A BIOS-resident shard is generated from an exact-BIOS-hash manifest and has - * a synthetic page envelope rather than a runtime dirty-run envelope. Its - * region filename therefore cannot participate in the ordinary region-start - * lookup. compile_overlays.py publishes this explicit sidecar only for those - * shards. Loading it early merely registers candidates: every dispatch still - * passes the ordinary per-function live-byte CRC guard, so a different BIOS or - * boot-time patch fails closed to the interpreter. */ -#define BIOS_RESIDENT_MARKER_SCHEMA "psxrecomp bios resident shard v1" -static int cache_path_is_bios_resident(const char *dll_path) { - char path[800]; - snprintf(path, sizeof(path), "%s", dll_path); - size_t n = strlen(path); - if (n < OVERLAY_SHARED_EXT_LEN || - strcmp(path + n - OVERLAY_SHARED_EXT_LEN, OVERLAY_SHARED_EXT) != 0) - return 0; - snprintf(path + n - OVERLAY_SHARED_EXT_LEN, - sizeof(path) - (n - OVERLAY_SHARED_EXT_LEN), ".resident"); - FILE *f = fopen(path, "rb"); - if (!f) return 0; - char buf[256] = {0}; - size_t got = fread(buf, 1, sizeof(buf) - 1, f); - fclose(f); - buf[got] = '\0'; - return strstr(buf, BIOS_RESIDENT_MARKER_SCHEMA) != NULL; -} - -static void refresh_bios_resident_flags(void) { - for (int i = 0; i < s_cache_idx_count; i++) - s_cache_idx[i].resident = - cache_path_is_bios_resident(s_cache_idx[i].path); -} - -/* Canonical cache arch-abi tag (caches are namespaced per backend AND per - * target so a Windows-x64 gcc DLL and, later, a same-OS arm64 build for the - * same fragment never comingle). compile_overlays.py - * computes the IDENTICAL string from platform.system()/machine(); keep the two - * mappings in lockstep ("-": win|linux|macos + x64|arm64|x86). */ -#if defined(_WIN32) -# define PSX_OL_OS "win" -#elif defined(__APPLE__) -# define PSX_OL_OS "macos" -#else -# define PSX_OL_OS "linux" -#endif -#if defined(__aarch64__) || defined(_M_ARM64) -# define PSX_OL_ARCH "arm64" -#elif defined(__x86_64__) || defined(_M_X64) -# define PSX_OL_ARCH "x64" -#elif defined(__i386__) || defined(_M_IX86) -# define PSX_OL_ARCH "x86" -#else -# define PSX_OL_ARCH "unknown" -#endif -#define PSX_OVERLAY_ARCH_ABI PSX_OL_OS "-" PSX_OL_ARCH - -const char *overlay_loader_arch_abi(void) { return PSX_OVERLAY_ARCH_ABI; } - -#ifndef _WIN32 -static int add_posix_cache_file(const PsxOverlayCacheFile *file, void *opaque) { - int tier = opaque ? *(const int *)opaque : CACHE_TIER_UNKNOWN; - /* Legacy 8_8 pairs were published by a replace protocol that could leave a - * new manifest beside an old DLL. Keep them on disk for Python seed - * migration, but never let them suppress or execute instead of a bound - * immutable 8_8_8 repair. */ - if (!cache_name_is_immutable(file->name)) return 0; - if (s_cache_idx_count >= CACHE_IDX_CAP) { - loader_log("*** CACHE INDEX FULL (%d): further DLLs near %s are being " - "IGNORED — their regions will run interpreted. Raise " - "CACHE_IDX_CAP.", CACHE_IDX_CAP, file->path); - return 1; - } - if (cache_idx_has_path(file->path)) return 0; - CacheEntry *e = &s_cache_idx[s_cache_idx_count++]; - e->region_start = file->region_start; - e->logical_crc = file->content_crc; - e->mtime = file->mtime; - e->func_count = 0; - e->indexed_func_count = 0; - e->tier = (uint8_t)tier; - e->manifest_ok = 0; - e->load_failed = 0; - e->resident = cache_path_is_bios_resident(file->path); - e->capacity_suppressed = 0; - snprintf(e->path, sizeof(e->path), "%s", file->path); - return 0; -} -#endif - -/* Scan one directory for _.dll cache entries into the index. - * `dir` is a full directory path. Idempotent (skips already-indexed paths). */ -static void scan_one_cache_dir(const char *dir, int tier) { -#ifdef _WIN32 - char pattern[768]; - snprintf(pattern, sizeof(pattern), "%s/*_*.dll", dir); - WIN32_FIND_DATAA fd; - HANDLE h = FindFirstFileA(pattern, &fd); - if (h == INVALID_HANDLE_VALUE) return; - do { - uint32_t addr = 0, crc = 0; - if (!psx_overlay_cache_name_parse(fd.cFileName, &addr, &crc)) continue; - if (!cache_name_is_immutable(fd.cFileName)) continue; - (void)crc; - if (s_cache_idx_count >= CACHE_IDX_CAP) { - /* Never-again (the silent-256 truncation): overflowing the index - * means real native coverage is being IGNORED. Shout once. */ - loader_log("*** CACHE INDEX FULL (%d): further DLLs in %s are being " - "IGNORED — their regions will run interpreted. Raise " - "CACHE_IDX_CAP.", CACHE_IDX_CAP, dir); - break; - } - char full[768]; - snprintf(full, sizeof(full), "%s/%s", dir, fd.cFileName); - /* Same full path means a rescan duplicate. Same logical name with a - * different artifact suffix is an additive repair and is retained. */ - if (cache_idx_has_path(full)) continue; - CacheEntry *e = &s_cache_idx[s_cache_idx_count++]; - e->region_start = addr; - e->logical_crc = crc; - e->mtime = ((uint64_t)fd.ftLastWriteTime.dwHighDateTime << 32) | - (uint64_t)fd.ftLastWriteTime.dwLowDateTime; - e->func_count = 0; - e->indexed_func_count = 0; - e->tier = (uint8_t)tier; - e->manifest_ok = 0; - e->load_failed = 0; - e->capacity_suppressed = 0; - snprintf(e->path, sizeof(e->path), "%s", full); - e->resident = cache_path_is_bios_resident(full); - } while (FindNextFileA(h, &fd)); - FindClose(h); -#else - psx_overlay_posix_scan_cache_dir(dir, add_posix_cache_file, &tier); -#endif -} - -/* Scan the namespaced gcc cache: gcc//cg/. The codegen - * version segment means a build with new emitter output reads a FRESH directory - * and never picks up a stale DLL (old versions coexist on disk, no migration). - * (Pre-1.0: no legacy fallback — older flat / unversioned caches are simply - * ignored and regenerated.) */ -/* Hardening (never-again for the silent cg-tag read≠write drift): when the loader - * finds ZERO shards under its OWN cg-tag, check whether a SIBLING cg<...> folder in - * the same tier holds shards. If so, the autocompile wrote to a different codegen - * hash than this build reads — every overlay will silently fall to the interpreter - * ("why is it slow"). This is USUALLY a mismatched overlay_autocompile_cmd - * --recompiler / --runtime-include (e.g. a cross-build: runtime from one framework - * checkout, autocompile pointed at another). Shout it once, loudly, with both tags, - * so it can never again be diagnosed as generic slowness. */ -static void warn_on_cgtag_mismatch(const char *tier) { -#ifdef _WIN32 - char base[768], pattern[900]; - snprintf(base, sizeof base, "%s/%s/%s/%s", - s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); - char expect[64]; - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", - PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); - snprintf(pattern, sizeof pattern, "%s/cg*", base); - WIN32_FIND_DATAA fd; - HANDLE h = FindFirstFileA(pattern, &fd); - if (h == INVALID_HANDLE_VALUE) return; - do { - if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; - if (strcmp(fd.cFileName, expect) == 0) continue; /* our own tag */ - char dllpat[900]; WIN32_FIND_DATAA fd2; - snprintf(dllpat, sizeof dllpat, "%s/%s/*_*.dll", base, fd.cFileName); - HANDLE h2 = FindFirstFileA(dllpat, &fd2); - if (h2 != INVALID_HANDLE_VALUE) { /* sibling tag HAS shards */ - FindClose(h2); - loader_log("*** OVERLAY CACHE HASH MISMATCH: this build reads %s/%s but " - "shards exist under %s/%s. The autocompile is writing to a " - "DIFFERENT codegen/config hash than this runtime reads -> ALL overlays " - "run INTERPRETED (slow). Fix overlay_autocompile_cmd's " - "--recompiler/--runtime-include to match THIS build's framework.", - tier, expect, tier, fd.cFileName); - } - } while (FindNextFileA(h, &fd)); - FindClose(h); -#else - char base[768], expect[64], found[256]; - snprintf(base, sizeof base, "%s/%s/%s/%s", - s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", - PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); - if (psx_overlay_posix_find_other_cache_tag(base, expect, found, - sizeof(found))) { - loader_log("*** OVERLAY CACHE HASH MISMATCH: this build reads %s/%s but " - "shards exist under %s/%s. The autocompile is writing to a " - "DIFFERENT codegen/config hash than this runtime reads -> ALL overlays " - "run INTERPRETED (slow). Fix overlay_autocompile_cmd's " - "--recompiler/--runtime-include to match THIS build's framework.", - tier, expect, tier, found); - } -#endif -} - -static void cache_idx_remove_path(const char *path) { - for (int i = 0; i < s_cache_idx_count; i++) { - if (cache_path_equal(s_cache_idx[i].path, path)) { - s_cache_idx[i] = s_cache_idx[--s_cache_idx_count]; - return; - } - } -} - -#ifndef _WIN32 -typedef struct PosixAbiSweep { - int purged; - int kept; -} PosixAbiSweep; - -static int posix_abi_sweep_file(const PsxOverlayCacheFile *file, void *opaque) { - PosixAbiSweep *sweep = (PosixAbiSweep *)opaque; - if (!cache_name_is_immutable(file->name)) return 0; - char error[256] = {0}; - void *handle = psx_overlay_posix_library_open(file->path, error, sizeof(error)); - int abi = 0; - if (handle) { - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)psx_overlay_posix_library_symbol(handle, "overlay_abi"); - abi = abi_fn ? abi_fn() : 0; - psx_overlay_posix_library_close(handle); - } - if (handle && abi == PSX_OVERLAY_ABI_TAG) { - sweep->kept++; - return 0; - } - - if (handle) - loader_log("ABI preflight rejecting %s: dll=0x%X runtime=0x%X", - file->path, abi, PSX_OVERLAY_ABI_TAG); - else - loader_log("ABI preflight rejecting unloadable %s: %s", - file->path, error); - /* Do not delete by canonical pathname after inspecting a loaded handle: - * an atomic publisher may already have replaced that path with a new file. */ - cache_idx_remove_path(file->path); - sweep->purged++; - return 0; -} -#endif - -/* ---- ABI pre-flight sweep (batch purge) ---------------------------------- - * A contract-ABI bump (e.g. v9 -> v10) invalidates EVERY cached DLL at once. - * The lazy path handled that per-dispatch: try_load_region -> LoadLibrary -> - * ABI reject -> DeleteFile -> retry next dispatch — thousands of file ops on - * the EMULATION thread (which owns the window pump). On a large cache this - * presented as a "(Not Responding)" black window for minutes (MMX6, 136-DLL - * cache, the v10 migration, 2026-07-03 regression gate). - * - * Instead, sweep at scan time: load each indexed DLL, check overlay_abi, and - * drop mismatches from the in-memory index. Never delete by canonical path - * after loading: a concurrent atomic publisher may have replaced that name. A marker - * file (.abi_.ok) per cache dir records a completed sweep, so healthy - * caches skip the sweep entirely on later boots — steady-state cost is one - * stat per dir. Autocompile only ever writes current-ABI DLLs, so the marker - * stays truthful; the per-load ABI gate in load_overlay_dll remains as - * defense in depth. */ -static void abi_preflight_sweep(const char *dir) { -#ifdef _WIN32 - char marker[900]; - snprintf(marker, sizeof marker, "%s/.abi_%08x.ok", dir, (unsigned)PSX_OVERLAY_ABI_TAG); - if (GetFileAttributesA(marker) != INVALID_FILE_ATTRIBUTES) return; /* swept */ - - /* Enumerate the DIRECTORY, not the index: the index is capacity-bounded - * and dedup-filtered, so sweeping it alone can leave stale files behind - * while the marker claims a complete sweep (found by the negative test — - * the planted v6 DLL survived behind the old 256-entry index cap). */ - int purged = 0, kept = 0; - char pattern[900]; - snprintf(pattern, sizeof pattern, "%s/*_*.dll", dir); - WIN32_FIND_DATAA fd; - HANDLE hf = FindFirstFileA(pattern, &fd); - if (hf != INVALID_HANDLE_VALUE) { - do { - if (!cache_name_is_immutable(fd.cFileName)) continue; - char full[900]; - snprintf(full, sizeof full, "%s/%s", dir, fd.cFileName); - HMODULE h = LoadLibraryA(full); - if (h) { - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)GetProcAddress(h, "overlay_abi"); - int abi = abi_fn ? abi_fn() : 0; - FreeLibrary(h); - if (abi == PSX_OVERLAY_ABI_TAG) { kept++; continue; } - } - /* Drop only the index entry. Deleting `full` here can delete a new - * shard atomically swapped in after LoadLibrary returned the old - * handle. The compiler owns on-disk replacement. */ - purged++; - for (int i = 0; i < s_cache_idx_count; i++) { - if (cache_path_equal(s_cache_idx[i].path, full)) { - s_cache_idx[i] = s_cache_idx[--s_cache_idx_count]; - break; - } - } - } while (FindNextFileA(hf, &fd)); - FindClose(hf); - } - if (purged) - loader_log("abi preflight: rejected %d stale DLL(s), kept %d in %s", - purged, kept, dir); - /* Rejected files remain until the compiler replaces them, so do not create - * a marker that would skip their in-memory rejection next boot. */ - if (!purged) { - HANDLE m = CreateFileA(marker, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, NULL); - if (m != INVALID_HANDLE_VALUE) CloseHandle(m); - } -#else - char marker[900]; - snprintf(marker, sizeof marker, "%s/.abi_%08x.ok", dir, - (unsigned)PSX_OVERLAY_ABI_TAG); - if (access(marker, F_OK) == 0) return; - - PosixAbiSweep sweep = {0}; - psx_overlay_posix_scan_cache_dir(dir, posix_abi_sweep_file, &sweep); - if (sweep.purged) - loader_log("abi preflight: rejected %d stale DLL(s), kept %d in %s", - sweep.purged, sweep.kept, dir); - if (!sweep.purged) { - FILE *m = fopen(marker, "wb"); - if (m) fclose(m); - } -#endif -} - -static void scan_cache_dir(void) { - char dir[768]; - /* Index both tiers and every immutable artifact. Runtime selection prefers - * usable GCC over TCC; an invalid GCC artifact cannot suppress a valid TCC - * fallback merely because its filename was enumerated first. */ - snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x_gc%08x", - s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); - scan_one_cache_dir(dir, CACHE_TIER_GCC); - abi_preflight_sweep(dir); - snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x_gc%08x", - s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); - scan_one_cache_dir(dir, CACHE_TIER_TCC); - abi_preflight_sweep(dir); - - refresh_bios_resident_flags(); - rebuild_lazy_manifest_index(); - - /* Never-again guard: if we loaded NOTHING but wrong-hash shards exist, shout. */ - if (s_cache_idx_count == 0) { - warn_on_cgtag_mismatch("gcc"); - warn_on_cgtag_mismatch("tcc"); - } - - /* Opt-in startup/rescan inventory from PR #13. Current master deliberately - * keeps routine loader events off stderr, so retain that policy unless the - * operator explicitly requests the diagnostic. */ - const char *inventory = getenv("PSX_OVERLAY_CACHE_INVENTORY"); - if (inventory && *inventory && *inventory != '0') { - fprintf(stderr, "overlay cache scan: %d shared library file(s) indexed " - "[arch=%s]\n", s_cache_idx_count, PSX_OVERLAY_ARCH_ABI); - for (int i = 0; i < s_cache_idx_count; i++) { - const char *base = strrchr(s_cache_idx[i].path, '/'); - base = base ? base + 1 : s_cache_idx[i].path; - fprintf(stderr, " [%d] %s (region=0x%08X, functions=%d%s)\n", - i, base, s_cache_idx[i].region_start, - s_cache_idx[i].indexed_func_count, - s_cache_idx[i].resident ? ", BIOS-resident" : ""); - } - fflush(stderr); - } -} - -/* (sljit removed 2026-07-15: the persisted sljit shard cache — blob header, - * persist/publish writers, on-disk reload scan, and their debug accessors — - * lived here. No shards are produced or reloaded anymore.) */ - -static uint32_t lazy_man_crc(const ManFn *m); /* defined with lazy matching */ - -/* True if the cache holds a usable DLL for this region/logical image CRC. - * Filename identity alone is insufficient: a structurally valid but stale or - * unsafe manifest must not suppress additive recapture of its own repair. */ -int overlay_loader_has_cached_crc(uint32_t region_start, uint32_t crc) { - for (int i = 0; i < s_cache_idx_count; i++) { - if (s_cache_idx[i].manifest_ok && - !s_cache_idx[i].load_failed && - s_cache_idx[i].region_start == region_start && - s_cache_idx[i].logical_crc == crc && - s_cache_idx[i].func_count > 0 && - s_cache_idx[i].indexed_func_count == s_cache_idx[i].func_count) { - int seen = 0, usable = 1; - for (int li = s_lazy_bundle_head[i]; li >= 0; - li = s_lazy_man[li].next_bundle) { - seen++; - if (lazy_man_crc(&s_lazy_man[li].fn) != s_lazy_man[li].fn.crc || - !man_delay_slots_hashed(&s_lazy_man[li].fn)) { - usable = 0; - break; - } - } - if (usable && seen == s_cache_idx[i].func_count) return 1; - } - } - return 0; -} - -/* ---- Runtime callbacks wired into overlay DLLs via overlay_init() ------ */ - -extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t ra); -extern void psx_check_interrupts(CPUState *cpu); -extern void psx_check_interrupts_at(CPUState *cpu, uint32_t resume_pc); -extern int psx_interrupt_delivery_needed(const CPUState *cpu); -extern void gte_execute(CPUState *cpu, uint32_t cmd); -extern int psx_syscall(CPUState *cpu, uint32_t code); -extern void psx_unknown_dispatch(CPUState *cpu, uint32_t addr, uint32_t phys); -extern void debug_server_log_call_entry_fn(uint32_t func_addr); - -static OverlayCallbacks s_callbacks; - -/* Timing-hypothesis probe: native overlay code calls psx_check_interrupts at - * EVERY block (up to ~100x/function), whereas the dirty-RAM interpreter checks - * only ~every 4096 instructions + at function exit. That cadence gap can deliver - * an interrupt at a different point in native vs interp -> divergence with no - * mistranslation. When s_suppress_irq is set we drop native's per-block checks - * (cadence ~ interp). If the blue screen vanishes, the cause is interrupt timing. - * Two modes: full suppress, or rate-limit (call the real check every Nth time). - * - * NOTE: this probe rests on the cross-game finding that overlay code never - * installs its own IRQ/DMA/callback handlers (PsyQ convention — all timing- - * critical handlers live in resident static code; holds for every sampled - * title). That makes interrupt-check *cadence* the only native-vs-interp - * difference. If a future title violates the convention (an overlay installs a - * handler), this probe is no longer sufficient and a discriminator is needed. */ -static int s_suppress_irq = 0; -static uint32_t s_irq_ratelimit = 0; /* 0 = full suppress; N = every Nth call */ -static uint32_t s_irq_callcount = 0; -static uint64_t s_irq_suppressed = 0; -static uint32_t s_irq_budget_cycles = 0; /* 0 = off; otherwise guest cycles/check */ -static uint64_t s_irq_last_check_cycle = UINT64_MAX; -static int s_irq_suppress_cdrom_only = 0; -static int s_irq_post_dispatch_pump = 0; -static int s_irq_defer_cdrom = 0; - -extern uint32_t i_stat; -extern uint32_t i_mask; - -/* Nested call-unit depth (Ape memcard native<->interp resume-desync fix). - * - * When the dirty interpreter (or a shard) issues a guest jal/jalr to a callee - * run as a UNIT — an overlay-native shard (overlay_loader_call_native) OR a - * non-local dirty/kernel routine (dispatch_nonlocal_call -> psx_dispatch_call) — - * that callee must be ATOMIC w.r.t. the cooperative thread switch, exactly like - * statically-compiled code (psx_dispatch_impl checks interrupts ONLY at the - * outermost dispatch return; a nested callee never interrupts before its caller - * runs the post-call continuation). Both the overlay CI wrappers AND the dirty - * per-transfer IRQ pumps, however, checked interrupts at EVERY block/transfer - * regardless of nesting; an IRQ + cooperative ChangeThread landing inside such a - * nested unit suspended the interrupted thread with an INCONSISTENT snapshot - * (resume PC at the caller's post-call point, sp still mid-callee) -> a leaked - * stack frame that compounded across cooperative cycles into a smeared jumptable - * index (the Ape "Checking MEMORY CARD" softlock/fatal). While this depth is >0 - * both backends defer the IRQ check; the callee runs to completion, then the - * enclosing top-level dirty pump / outermost dispatch return delivers the IRQ at - * a consistent (pc, sp) boundary. The TOP-LEVEL flow (depth 0) still pumps IRQs, - * so the consumer's poll loop and long overlay game-loops keep their - * responsiveness. Incremented only when the A/B toggle is on (below). */ -int g_call_unit_depth = 0; - -/* A/B toggle for the nested-unit IRQ deferral (PSX_OVERLAY_UNIT_DEFER, default - * ON). Gates the depth INCREMENTS (so =0 leaves g_call_unit_depth at 0 and - * nothing defers — the pre-fix behavior), letting us attribute a behavior change - * to this fix without a rebuild. */ -int overlay_unit_defer_enabled(void) { - static int cached = -1; - if (cached < 0) { - const char *e = getenv("PSX_OVERLAY_UNIT_DEFER"); - cached = (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N')) ? 0 : 1; - } - return cached; -} - -void overlay_loader_set_irq_suppress(int mode, uint32_t ratelimit) { - s_suppress_irq = mode ? 1 : 0; - s_irq_ratelimit = ratelimit; - /* Reset counters on every (re)arm so an A/B run reports a clean, isolated - * suppressed-count for that arming rather than a cumulative total. */ - s_irq_callcount = 0; - s_irq_suppressed = 0; -} -void overlay_loader_get_irq_suppress(int *mode, uint32_t *rl, uint64_t *supp) { - if (mode) *mode = s_suppress_irq; - if (rl) *rl = s_irq_ratelimit; - if (supp) *supp = s_irq_suppressed; -} - -static int overlay_irq_suppressed_now(void) { - /* Differential replay (and its authoritative interpreter pass) is atomic. - * Never let a previously armed rate-limit punch a real IRQ into a shadow. */ - if (s_in_shadow) { s_irq_suppressed++; return 1; } - if (s_suppress_irq) { - if (s_irq_ratelimit == 0) { s_irq_suppressed++; return 1; } - if ((++s_irq_callcount % s_irq_ratelimit) != 0) { s_irq_suppressed++; return 1; } - } - if (s_irq_suppress_cdrom_only) { - uint32_t pending = i_stat & i_mask; - if (pending == (1u << IRQ_CDROM)) { - s_irq_suppressed++; - return 1; - } - } - if (s_irq_budget_cycles != 0) { - uint64_t now = psx_get_cycle_count(); - if (s_irq_last_check_cycle != UINT64_MAX && - now - s_irq_last_check_cycle < (uint64_t)s_irq_budget_cycles) { - s_irq_suppressed++; - return 1; - } - s_irq_last_check_cycle = now; - } - return 0; -} - -static void overlay_ci_wrapper(CPUState *cpu) { - /* Defer while inside a nested call unit — a callee must not interrupt - * mid-call (static-call atomicity). See g_call_unit_depth. */ - if (g_call_unit_depth > 0) return; - if (overlay_irq_suppressed_now()) return; - /* psx_advance_cycles() has already raised every device edge due at this - * block. Avoid entering the full scheduler/diagnostic path when COP0 could - * not take the IRQ anyway. FMV polling loops can execute this edge millions - * of times while an INTC bit is pending but IEc is deliberately clear. */ - if ((i_stat & i_mask) == 0) return; - if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) return; - if (!psx_interrupt_delivery_needed(cpu)) return; - if (s_irq_defer_cdrom && (i_stat & (1u << IRQ_CDROM))) { - uint32_t saved_cd = i_stat & (1u << IRQ_CDROM); - i_stat &= ~(1u << IRQ_CDROM); - psx_check_interrupts(cpu); - i_stat |= saved_cd; - return; - } - psx_check_interrupts(cpu); -} - -static int overlay_idle_note_is_internal_or_return(const CPUState *cpu, - uint32_t resume_pc) { - uint32_t phys = resume_pc & 0x1FFFFFFFu; - if ((resume_pc & 0x1FFFFFFFu) == (cpu->gpr[31] & 0x1FFFFFFFu)) return 1; - if (s_active_depth <= 0) return 0; - Candidate *c = &s_cand[s_active_stack[s_active_depth - 1]]; - for (int r = 0; r < c->nranges; r++) { - uint32_t lo = c->range_lo[r]; - if (phys >= lo && phys < lo + c->range_len[r]) return 1; - } - return 0; -} - -static void overlay_ci_at_wrapper(CPUState *cpu, uint32_t resume_pc) { - /* Defer while inside a nested call unit (see g_call_unit_depth): suspending - * here would save resume_pc at the callee's block leader while the enclosing - * dirty caller expects an atomic unit — the resume-desync bug. */ - if (g_call_unit_depth > 0) return; - if (overlay_irq_suppressed_now()) return; - if ((i_stat & i_mask) == 0) return; - if ((cpu->cop0[12] & ((1u << 10) | 1u)) != ((1u << 10) | 1u)) return; - if (!psx_interrupt_delivery_needed(cpu)) return; - extern int g_idle_note_suppress; - int suppress_idle_note = overlay_idle_note_is_internal_or_return(cpu, resume_pc); - if (suppress_idle_note) g_idle_note_suppress++; - if (s_irq_defer_cdrom && (i_stat & (1u << IRQ_CDROM))) { - uint32_t saved_cd = i_stat & (1u << IRQ_CDROM); - i_stat &= ~(1u << IRQ_CDROM); - psx_check_interrupts_at(cpu, resume_pc); - i_stat |= saved_cd; - if (suppress_idle_note) g_idle_note_suppress--; - return; - } - psx_check_interrupts_at(cpu, resume_pc); - if (suppress_idle_note) g_idle_note_suppress--; -} - -static int overlay_irq_budget_blocks_now(void) { - if (s_irq_budget_cycles == 0) return 0; - uint64_t now = psx_get_cycle_count(); - if (s_irq_last_check_cycle != UINT64_MAX && - now - s_irq_last_check_cycle < (uint64_t)s_irq_budget_cycles) { - s_irq_suppressed++; - return 1; - } - s_irq_last_check_cycle = now; - return 0; -} - -static void overlay_post_dispatch_irq_pump(CPUState *cpu) { - if (!s_irq_post_dispatch_pump) return; - /* Never deliver an IRQ inside a shadow run: the handler would ack device - * state (I_STAT) that survives the sandbox while the guest state it ran on - * is rolled back — a lost interrupt. Shadow runs are IRQ-suppressed by - * design (s_suppress_irq in run_shadow_diff); this pump must match. */ - if (s_in_shadow) return; - if (overlay_irq_budget_blocks_now()) return; - if (cpu->pc != 0u) psx_check_interrupts_at(cpu, cpu->pc); - else psx_check_interrupts(cpu); -} - -/* Probe wrapper (mmx6_card_load_regression_state): record shard-side calls to - * xprobe-watched targets (before + after) so overlay-DLL call-outs are visible - * in the xprobe `watched` dump the way interp JAL/JALR sites already are. */ -extern void dirty_ram_xprobe_call_note(CPUState *cpu, uint32_t target, uint32_t ra, uint8_t phase); -extern int g_exec_phase; /* wall-time sampler phase (dirty_ram_interp.c) */ -static void overlay_dispatch_call_probed(CPUState *cpu, uint32_t addr, uint32_t ra) { - dirty_ram_xprobe_call_note(cpu, addr, ra, 0); - int prev_phase = g_exec_phase; - g_exec_phase = 3; /* compiled route; dirty/native callees re-tag inside */ - psx_dispatch_call(cpu, addr, ra); - g_exec_phase = prev_phase; - dirty_ram_xprobe_call_note(cpu, addr, ra, 1); -} - -static void init_callbacks(void) { - extern void psx_restore_state_escape(void); - extern void psx_rfe_mark_escape(void); - s_callbacks.dispatch_call = overlay_dispatch_call_probed; - s_callbacks.check_interrupts = overlay_ci_wrapper; - s_callbacks.check_interrupts_at = overlay_ci_at_wrapper; - /* Address of the header inline → out-of-line copy in this TU (host side). */ - s_callbacks.advance_cycles = psx_advance_cycles; - s_callbacks.gte_execute = gte_execute; - s_callbacks.psx_syscall = psx_syscall; - s_callbacks.psx_native_bad_entry = psx_native_bad_entry; - s_callbacks.psx_unknown_dispatch = psx_unknown_dispatch; -#ifdef PSX_NO_DEBUG_TOOLS - /* Generated DLLs NULL-check this callback. Avoid a production call/return - * at every guest function entry when the callee would immediately no-op. */ - s_callbacks.log_call_entry = NULL; -#else - s_callbacks.log_call_entry = debug_server_log_call_entry_fn; -#endif - s_callbacks.psx_restore_state_escape = psx_restore_state_escape; - /* Return-from-exception mark (ABI v12): overlay `rfe` ops forward here. */ - s_callbacks.rfe_mark_escape = psx_rfe_mark_escape; - /* Call-contract state (ABI v2): DLL code shares the runtime's bail - * flag and counters through these pointers. */ - s_callbacks.call_bail_flag = &g_psx_call_bail; - s_callbacks.bail_first = &g_psx_bail_first; - s_callbacks.bail_resolved = &g_psx_bail_resolved; - /* Widescreen hooks (ABI v3): overlay-emitted psx_ws_* calls forward to - * the runtime's live widescreen state (gpu.c). */ - { - extern int psx_ws_backdrop_x(int x); - extern int psx_ws_x_margin(void); - extern void psx_ws_sprite_tag(CPUState *cpu); - extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ABI v4 */ - s_callbacks.ws_backdrop_x = psx_ws_backdrop_x; - s_callbacks.ws_x_margin = psx_ws_x_margin; - s_callbacks.ws_sprite_tag = psx_ws_sprite_tag; - s_callbacks.ws_backdrop_value = psx_ws_backdrop_value; - } - /* Faithful-timing functions (ABI v9): overlay code built with - * PSX_ENABLE_BLOCK_CYCLES emits these; forward to the runtime's real impls so - * native overlays charge cycles on the SAME timeline as the interp/BIOS. */ - { - /* Word/half fast paths are static inline in psx_cyc.h; overlays get the - * out-of-line slow entry points (same guest timing, MMIO-safe). */ - extern uint32_t psx_cyc_load_word_slow(CPUState*, uint32_t, uint32_t, uint32_t); - extern uint16_t psx_cyc_load_half_slow(CPUState*, uint32_t, uint32_t, uint32_t); - extern uint8_t psx_cyc_load_byte(CPUState*, uint32_t, uint32_t, uint32_t); - extern uint32_t psx_cyc_lwc2_read(CPUState*, uint32_t); - extern void psx_icache_fetch_fn(CPUState*, uint32_t); - extern void psx_muldiv_set(CPUState*, uint32_t); - extern void psx_muldiv_stall(CPUState*); - extern uint32_t psx_mult_latency_s(uint32_t); - extern uint32_t psx_mult_latency_u(uint32_t); - extern void psx_gte_stall(CPUState*); - extern void psx_gte_read(CPUState*, uint32_t); - extern int psx_slice_block_impl(CPUState*, uint32_t, uint32_t, int); - s_callbacks.cyc_load_word = psx_cyc_load_word_slow; - s_callbacks.cyc_load_half = psx_cyc_load_half_slow; - s_callbacks.cyc_load_byte = psx_cyc_load_byte; - s_callbacks.cyc_lwc2_read = psx_cyc_lwc2_read; - s_callbacks.icache_fetch = psx_icache_fetch_fn; - s_callbacks.muldiv_set = psx_muldiv_set; - s_callbacks.muldiv_stall = psx_muldiv_stall; - s_callbacks.mult_latency_s = psx_mult_latency_s; - s_callbacks.mult_latency_u = psx_mult_latency_u; - s_callbacks.gte_stall = psx_gte_stall; - s_callbacks.gte_read = psx_gte_read; - s_callbacks.slice_block = psx_slice_block_impl; - /* ABI v10: GTE special-register accessors — the emitter emits direct - * calls for flag/IR/derived GTE regs (mfc2/cfc2/mtc2/ctc2); a GTE-heavy - * overlay DLL cannot link without these forwarded. */ - { - extern uint32_t gte_read_data(CPUState*, uint8_t); - extern uint32_t gte_read_ctrl(CPUState*, uint8_t); - extern void gte_write_data(CPUState*, uint8_t, uint32_t); - extern void gte_write_ctrl(CPUState*, uint8_t, uint32_t); - s_callbacks.gte_read_data = gte_read_data; - s_callbacks.gte_read_ctrl = gte_read_ctrl; - s_callbacks.gte_write_data = gte_write_data; - s_callbacks.gte_write_ctrl = gte_write_ctrl; - } - /* ABI v12: rfe escape marker — emitted at every `rfe`, including in - * overlay-resident exception-context code; mutates the runtime's - * shared RFE-pending state so it must forward. */ - { - extern void psx_rfe_mark_escape(void); - s_callbacks.rfe_mark_escape = psx_rfe_mark_escape; - } - /* ABI v13: typed native-wide signed player-X bound — the emitter - * rewrites configured signed_x_bound LUI sites (Einhander player - * bounds) into psx_ws_player_x_bound() calls, including in - * overlay-resident code; the clamp reads the host's live widescreen - * state (gpu.c) so the DLL must forward. */ - { - extern int32_t psx_ws_player_x_bound(int32_t vanilla); - s_callbacks.ws_player_x_bound = psx_ws_player_x_bound; - } - /* ABI v14: GTE precision-store tracker — the emitter emits a direct - * gte_precision_store_word() call for every swc2 (GTE store-word), - * including in overlay-resident code; it mutates the host's stateful - * sub-pixel projection cache (gte.cpp) so the DLL must forward. */ - { - extern void gte_precision_store_word(uint32_t addr, uint8_t reg); - s_callbacks.gte_precision_store_word = gte_precision_store_word; - } - { - extern int32_t psx_ws_depth_bound(int32_t imm); - s_callbacks.ws_depth_bound = psx_ws_depth_bound; - } - { - extern int32_t psx_ws_plane_nx(int32_t nx); - s_callbacks.ws_plane_nx = psx_ws_plane_nx; - } - { - extern uint32_t psx_ws_xclip_bound(uint32_t vanilla); - s_callbacks.ws_xclip_bound = psx_ws_xclip_bound; - } - { - extern uint32_t psx_ws_cull_keep_result(uint32_t vanilla, - uint32_t forced); - s_callbacks.ws_cull_keep_result = psx_ws_cull_keep_result; - } - { - extern uint32_t psx_ws_aspect_cone_result( - uint32_t site, uint32_t vanilla, uint32_t object, - int32_t x, int32_t z, int32_t y); - s_callbacks.ws_aspect_cone_result = - psx_ws_aspect_cone_result; - } - { - extern uint32_t psx_ws_angle_widen(uint32_t vanilla); - s_callbacks.ws_angle_widen = psx_ws_angle_widen; - } - } -} - -/* ---- DLL loading and export enumeration -------------------------------- */ - -#ifdef _WIN32 -typedef HMODULE OverlayLibraryHandle; -#else -typedef void *OverlayLibraryHandle; -#endif - -struct OverlayPreparedImage { - OverlayLibraryHandle handle; - char path[768]; -}; - -#ifdef _WIN32 -static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll, - OverlayLibraryHandle prepared, - uint64_t manifest_pair_id, - int manifest_has_pair_id, - int manifest_provenance, int tier) { - HMODULE h = prepared ? prepared : LoadLibraryA(dll_path); - if (!h) { - loader_log("LoadLibrary(%s) failed: %lu", dll_path, GetLastError()); - return 0; - } - /* ABI gate: reject any DLL whose contract ABI doesn't match this runtime - * (pre-versioning DLLs lack the export entirely). The compiler, not this - * loaded handle, owns safe on-disk replacement. */ - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)GetProcAddress(h, "overlay_abi"); - int abi = abi_fn ? abi_fn() : 0; - /* Tag = ABI version (low 16) | codegen flavor (high 16). Mismatch on either - * (wrong ABI, or a different-flavor cache e.g. widescreen vs base) is - * rejected so autocompile can replace it for THIS build. */ - if (abi != PSX_OVERLAY_ABI_TAG) { - loader_log("ABI/flavor mismatch in %s: dll=0x%X runtime=0x%X — rejecting " - "without pathname deletion", dll_path, abi, - PSX_OVERLAY_ABI_TAG); - FreeLibrary(h); - return 0; - } - typedef uint64_t (*PairIdFn)(void); - PairIdFn pair_fn = (PairIdFn)GetProcAddress(h, "overlay_pair_id"); - int dll_has_pair_id = pair_fn != NULL; - uint64_t dll_pair_id = pair_fn ? pair_fn() : 0; - if (dll_has_pair_id != manifest_has_pair_id || - (dll_has_pair_id && dll_pair_id != manifest_pair_id)) { - loader_log("DLL/manifest pair mismatch in %s -- rejecting without " - "deleting (publication may be in progress)", dll_path); - FreeLibrary(h); - return 0; - } - typedef void (*InitFn)(const OverlayCallbacks *); - InitFn init_fn = (InitFn)GetProcAddress(h, "overlay_init"); - if (!init_fn) { - loader_log("no overlay_init in %s", dll_path); - FreeLibrary(h); - return 0; - } - /* Match the offline pair validator atomically: every manifest F must have - * its exact callable export before any Candidate slot is consumed. */ - for (int i = 0; i < man_n; i++) { - char name[32]; - snprintf(name, sizeof(name), "func_%08X", man[i].entry); - if (!GetProcAddress(h, name)) { - loader_log("manifest/export mismatch in %s: missing %s -- " - "rejecting whole DLL", dll_path, name); - FreeLibrary(h); - return 0; - } - } - OverlayFlushFn flush_fn = - (OverlayFlushFn)GetProcAddress(h, "overlay_flush_cycles"); - if (!flush_fn) { - loader_log("no ABI-v11 cycle flush export in %s", dll_path); - FreeLibrary(h); - return 0; - } - - /* Validate the whole physical pair before treating it as an alias. The - * canonical pair already owns its initialized handle and callbacks, so the - * redundant image must never run overlay_init or publish function pointers. */ - if (manifest_has_pair_id && loaded_pair_find( - tier, manifest_provenance, manifest_pair_id, man, man_n) >= 0) { - s_pair_aliases++; - loader_log("validated duplicate overlay pair in %s -- reusing canonical " - "candidate bundle", dll_path); - FreeLibrary(h); - return LOAD_PAIR_ALIAS; - } - if (dll < 0 || dll >= (int)(sizeof(s_dll_flush) / sizeof(s_dll_flush[0]))) { - loader_log("overlay DLL owner table full for %s", dll_path); - FreeLibrary(h); - return 0; - } - - /* Candidate publication is all-or-nothing for pair dedup authority. The - * offline publisher normally makes this impossible; fail closed if an - * externally modified cache would leave only part of a bundle resident. */ - if (man_n > CAND_CAP - s_cand_n) { - s_cand_overflow += (uint64_t)man_n; - loader_log("*** CANDIDATE TABLE SHORTFALL (%d): rejecting complete %s " - "pair (%d manifest candidates); native coverage is falling " - "back to the interpreter", CAND_CAP, dll_path, man_n); - FreeLibrary(h); - return 0; - } - - BYTE *base = (BYTE *)h; - IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base; - IMAGE_NT_HEADERS *nt = (IMAGE_NT_HEADERS *)(base + dos->e_lfanew); - IMAGE_DATA_DIRECTORY *exp_dd = - &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; - if (!exp_dd->VirtualAddress) { - loader_log("no export dir in %s", dll_path); - FreeLibrary(h); - return 0; - } - init_fn(&s_callbacks); - s_dll_flush[dll] = flush_fn; - IMAGE_EXPORT_DIRECTORY *exp = - (IMAGE_EXPORT_DIRECTORY *)(base + exp_dd->VirtualAddress); - DWORD *names = (DWORD *)(base + exp->AddressOfNames); - WORD *ordinals = (WORD *)(base + exp->AddressOfNameOrdinals); - DWORD *funcs = (DWORD *)(base + exp->AddressOfFunctions); - - int registered = 0; - uint64_t overflow_before = s_cand_overflow; - for (DWORD i = 0; i < exp->NumberOfNames; i++) { - const char *name = (const char *)(base + names[i]); - if (strncmp(name, "func_", 5) != 0) continue; - if (strlen(name) != 13) continue; - uint32_t addr = (uint32_t)strtoul(name + 5, NULL, 16); - if (addr == 0) continue; - - WORD ord = ordinals[i]; - OverlayFn fn = (OverlayFn)(base + funcs[ord]); - - /* Precision-first (§1): only register a candidate when the recompiler - * gave us its code ranges. A function with no manifest entry is left to - * the interpreter rather than registered with a guessed extent. */ - ManFn *m = man_find(man, man_n, addr); - if (!m || m->n == 0) { s_no_manifest++; continue; } - registered += cand_register(addr & 0x1FFFFFFFu, fn, m, dll, tier); - } - if (registered == man_n && manifest_has_pair_id) - loaded_pair_commit(tier, manifest_provenance, - manifest_pair_id, man, man_n); - if (s_cand_overflow != overflow_before) - loader_log("*** CANDIDATE TABLE FULL (%d): loaded %s -> %d registered, " - "%llu dropped; native coverage is falling back to the " - "interpreter. Raise CAND_CAP.", CAND_CAP, dll_path, - registered, - (unsigned long long)(s_cand_overflow - overflow_before)); - else - loader_log("loaded %s -> %d candidates (%u no-manifest)", - dll_path, registered, s_no_manifest); - if (registered == 0) { - /* load_one_dll deliberately leaves a zero-candidate path retryable. Do - * not leak a LoadLibrary reference on every retry after CAND_CAP is - * exhausted (or when every export lacks a usable manifest record). */ - s_dll_flush[dll] = NULL; - FreeLibrary(h); - } - return registered; -} -#else -static int load_overlay_dll(const char *dll_path, ManFn *man, int man_n, int dll, - OverlayLibraryHandle prepared, - uint64_t manifest_pair_id, - int manifest_has_pair_id, - int manifest_provenance, int tier) { - char error[256] = {0}; - void *h = prepared ? prepared - : psx_overlay_posix_library_open(dll_path, error, - sizeof(error)); - if (!h) { loader_log("dlopen(%s) failed: %s", dll_path, error); return 0; } - /* ABI gate (see the _WIN32 branch). */ - typedef int (*AbiFn)(void); - AbiFn abi_fn = (AbiFn)psx_overlay_posix_library_symbol(h, "overlay_abi"); - int abi = abi_fn ? abi_fn() : 0; - if (abi != PSX_OVERLAY_ABI_TAG) { - loader_log("ABI/flavor mismatch in %s: dll=0x%X runtime=0x%X — rejecting " - "without pathname deletion", dll_path, abi, - PSX_OVERLAY_ABI_TAG); - psx_overlay_posix_library_close(h); - return 0; - } - typedef uint64_t (*PairIdFn)(void); - PairIdFn pair_fn = (PairIdFn)psx_overlay_posix_library_symbol( - h, "overlay_pair_id"); - int dll_has_pair_id = pair_fn != NULL; - uint64_t dll_pair_id = pair_fn ? pair_fn() : 0; - if (dll_has_pair_id != manifest_has_pair_id || - (dll_has_pair_id && dll_pair_id != manifest_pair_id)) { - loader_log("DLL/manifest pair mismatch in %s -- rejecting without " - "deleting (publication may be in progress)", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - typedef void (*InitFn)(const OverlayCallbacks *); - InitFn init_fn = (InitFn)psx_overlay_posix_library_symbol(h, "overlay_init"); - if (!init_fn) { - loader_log("no overlay_init in %s", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - for (int i = 0; i < man_n; i++) { - if (!psx_overlay_posix_library_entry(h, man[i].entry)) { - loader_log("manifest/export mismatch in %s: missing func_%08X -- " - "rejecting whole DLL", dll_path, man[i].entry); - psx_overlay_posix_library_close(h); - return 0; - } - } - OverlayFlushFn flush_fn = (OverlayFlushFn)psx_overlay_posix_library_symbol( - h, "overlay_flush_cycles"); - if (!flush_fn) { - loader_log("no ABI-v11 cycle flush export in %s", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - if (manifest_has_pair_id && loaded_pair_find( - tier, manifest_provenance, manifest_pair_id, man, man_n) >= 0) { - s_pair_aliases++; - loader_log("validated duplicate overlay pair in %s -- reusing canonical " - "candidate bundle", dll_path); - psx_overlay_posix_library_close(h); - return LOAD_PAIR_ALIAS; - } - if (dll < 0 || dll >= (int)(sizeof(s_dll_flush) / sizeof(s_dll_flush[0]))) { - loader_log("overlay DLL owner table full for %s", dll_path); - psx_overlay_posix_library_close(h); - return 0; - } - if (man_n > CAND_CAP - s_cand_n) { - s_cand_overflow += (uint64_t)man_n; - loader_log("*** CANDIDATE TABLE SHORTFALL (%d): rejecting complete %s " - "pair (%d manifest candidates); native coverage is falling " - "back to the interpreter", CAND_CAP, dll_path, man_n); - psx_overlay_posix_library_close(h); - return 0; - } - init_fn(&s_callbacks); - s_dll_flush[dll] = flush_fn; - - /* ELF/Mach-O do not expose a portable export-table walker. The range - * manifest is already the loader's authority, so resolve only its exact - * callable entries. Missing symbols stay interpreted rather than acquiring - * a guessed extent. */ - int registered = 0; - uint64_t overflow_before = s_cand_overflow; - for (int i = 0; i < man_n; i++) { - ManFn *m = &man[i]; - if (m->entry == 0 || m->n == 0) continue; - OverlayFn fn = (OverlayFn)psx_overlay_posix_library_entry(h, m->entry); - if (!fn) continue; - registered += cand_register(m->entry & 0x1FFFFFFFu, fn, m, dll, tier); - } - if (registered == man_n && manifest_has_pair_id) - loaded_pair_commit(tier, manifest_provenance, - manifest_pair_id, man, man_n); - if (s_cand_overflow != overflow_before) - loader_log("*** CANDIDATE TABLE FULL (%d): loaded %s -> %d registered, " - "%llu dropped; native coverage is falling back to the " - "interpreter. Raise CAND_CAP.", CAND_CAP, dll_path, - registered, - (unsigned long long)(s_cand_overflow - overflow_before)); - else - loader_log("loaded %s -> %d manifest candidates", dll_path, registered); - if (registered == 0) { - s_dll_flush[dll] = NULL; - psx_overlay_posix_library_close(h); - } - /* A successful handle intentionally stays open for process lifetime: the - * registered function and flush pointers refer into it. Rescans are - * idempotent through s_loaded_paths and never acquire a second reference. */ - return registered; -} -#endif - -/* ---- Public API -------------------------------------------------------- */ - -static int dll_already_loaded(const char *path); -static int load_one_dll(const char *dll_path, OverlayLibraryHandle prepared); - -static int load_bios_resident_shards(void) { - int bundles = 0, functions = 0; - for (int i = 0; i < s_cache_idx_count; i++) { - CacheEntry *e = &s_cache_idx[i]; - if (!e->resident || e->func_count <= 0 || - e->indexed_func_count != e->func_count || - e->capacity_suppressed || dll_already_loaded(e->path)) - continue; - int loaded = load_one_dll(e->path, NULL); - if (loaded > 0) { bundles++; functions += loaded; } - } - if (bundles > 0) - loader_log("preloaded %d exact-guarded BIOS resident shard(s), %d candidates", - bundles, functions); - return functions; -} - -void overlay_loader_init(const char *cache_dir, const char *game_id, - uint32_t config_hash) { - { - const char *perf = getenv("PSX_RUNTIME_PERF_DIAG"); - s_native_hot_enabled = perf && perf[0] && perf[0] != '0'; - } - for (uint32_t p = 0; p < RANGE_PAGE_COUNT; p++) { - s_range_page_head[p] = -1; - s_range_page_tail[p] = -1; - } - s_range_link_n = 0; - s_range_index_overflow = 0; - for (uint32_t i = 0; i < RANGE_PC_CACHE_CAP; i++) - s_range_pc_cache[i].cand = -1; - strncpy(s_cache_dir, cache_dir, sizeof(s_cache_dir) - 1); - strncpy(s_game_id, game_id, sizeof(s_game_id) - 1); - s_config_hash = config_hash; - s_cache_root_canon_ok = 0; /* re-resolve for the (re)assigned root */ - /* data shards persist under the same unified cache root (data_shards.c) */ - { extern void ds_init(const char*, const char*); ds_init(cache_dir, game_id); } - init_callbacks(); - scan_cache_dir(); - load_bios_resident_shards(); - overlay_image_warm_init(); - overlay_image_warm_seed_boot_text(); - /* (sljit removed 2026-07-15: the persisted-shard reload + off-thread compile - * worker were started here. Overlay misses fall to the interpreter.) */ - /* Pre-seed native-block bisection list from PSX_NATIVE_BLOCK (comma/space - * separated hex addrs). Lets us route init-time overlay functions through the - * sanctioned dirty-RAM interpreter from the FIRST boot instruction — the - * runtime overlay_native_block cmd can only be set post-boot, too late for - * once-only init functions. Same diagnostic knob, just seedable at launch. */ - { - const char *nb = getenv("PSX_NATIVE_BLOCK"); - if (nb && *nb) { - const char *p = nb; - while (*p) { - while (*p == ' ' || *p == ',' || *p == '\t') p++; - if (!*p) break; - uint32_t a = (uint32_t)strtoul(p, NULL, 0); - if (a) overlay_loader_native_block_add(a); - while (*p && *p != ' ' && *p != ',' && *p != '\t') p++; - } - loader_log("PSX_NATIVE_BLOCK seeded %d native-block entr%s from '%s'", - s_native_block_n, s_native_block_n == 1 ? "y" : "ies", nb); - } - } -#ifndef PSX_NO_DEBUG_TOOLS - { - const char *limit = getenv("PSX_NATIVE_RANK_LIMIT"); - if (limit && *limit) { - unsigned long long n = strtoull(limit, NULL, 0); - s_native_rank_limit = n >= UINT32_MAX ? UINT32_MAX - 1u : (uint32_t)n; - s_native_rank_next = 0; - s_native_rank_blocked = 0; - fprintf(stdout, - "psxrecomp: PSX_NATIVE_RANK_LIMIT=%u: " - "first-seen candidate bisection ON\n", - s_native_rank_limit); - } - } -#endif - /* Boot-time full-interp override (diagnostic): PSX_OVERLAY_NATIVE_OFF=1 forces - * native overlay execution off from the first instruction, so a pristine - * interpreter reference can be captured without racing post-boot cmds. Same - * effect as the overlay_native_off cmd, but seeded at launch. */ - { - const char *no = getenv("PSX_OVERLAY_NATIVE_OFF"); - if (no && *no && *no != '0') { - s_native_exec = 0; - loader_log("PSX_OVERLAY_NATIVE_OFF set: native overlay exec OFF from boot"); - } - } - { - const char *sup = getenv("PSX_OVERLAY_IRQ_SUPPRESS"); - const char *rl = getenv("PSX_OVERLAY_IRQ_RATELIMIT"); - if (sup && *sup && *sup != '0') { - overlay_loader_set_irq_suppress(1, 0); - loader_log("PSX_OVERLAY_IRQ_SUPPRESS set: native overlay IRQ checks suppressed from boot"); - } else if (rl && *rl) { - uint32_t n = (uint32_t)strtoul(rl, NULL, 0); - if (n == 0) n = 1; - overlay_loader_set_irq_suppress(1, n); - loader_log("PSX_OVERLAY_IRQ_RATELIMIT set: native overlay IRQ checks every %u call(s)", n); - } - } - { - const char *budget = getenv("PSX_OVERLAY_IRQ_BUDGET"); - if (budget && *budget) { - uint32_t n = (uint32_t)strtoul(budget, NULL, 0); - s_irq_budget_cycles = n; - s_irq_last_check_cycle = UINT64_MAX; - loader_log("PSX_OVERLAY_IRQ_BUDGET set: native overlay IRQ checks every %u guest cycle(s)", n); - } - } - { - const char *no_cd = getenv("PSX_OVERLAY_IRQ_NO_CDROM"); - if (no_cd && *no_cd && *no_cd != '0') { - s_irq_suppress_cdrom_only = 1; - loader_log("PSX_OVERLAY_IRQ_NO_CDROM set: native overlay CDROM-only IRQ checks suppressed"); - } - } - { - const char *defer_cd = getenv("PSX_OVERLAY_IRQ_DEFER_CDROM"); - if (defer_cd && *defer_cd && *defer_cd != '0') { - s_irq_defer_cdrom = 1; - loader_log("PSX_OVERLAY_IRQ_DEFER_CDROM set: native overlay CDROM IRQ delivery deferred"); - } - } - { - const char *post = getenv("PSX_OVERLAY_IRQ_POST_PUMP"); - if (post && *post && *post != '0') { - s_irq_post_dispatch_pump = 1; - s_irq_last_check_cycle = UINT64_MAX; - loader_log("PSX_OVERLAY_IRQ_POST_PUMP set: native overlay dispatch-return IRQ pump enabled"); - } - } - { - const char *diff = getenv("PSX_OVERLAY_DIFF"); - if (diff && *diff && *diff != '0') { - s_diff_mode = 1; - loader_log("PSX_OVERLAY_DIFF set: native/interp shadow diff ON from boot"); - } - const char *diff_addr = getenv("PSX_OVERLAY_DIFF_ADDR"); - if (diff_addr && *diff_addr) { - s_diff_addr = (uint32_t)strtoul(diff_addr, NULL, 0); - loader_log("PSX_OVERLAY_DIFF_ADDR set: shadow diff restricted to 0x%08X", - s_diff_addr); - } - } - s_active = 1; -} - -/* (sljit removed 2026-07-15: overlay_loader_apply_live_policy resolved the - * sljit live-execution policy once the Tier-2 backend was known. With the tier - * gone there is no live policy to apply; the interpreter is the overlay-miss - * floor unconditionally.) */ - -void overlay_loader_check_cache(uint32_t load_addr, uint32_t size, - const uint8_t *bytes) { - /* DLL loading is deferred to the first dispatch miss (try_load_region). */ - (void)load_addr; (void)size; (void)bytes; -} - -/* ---- Lazy region cache check (first dispatch miss for a region) -------- */ - -/* 64 -> 256 (2026-07-06): the dirty boot-text window adds per-variant regions - * below FLOOR; a full memo silently stops caching new region checks. */ -#define MAX_CHECKED 256 -static uint32_t s_checked[MAX_CHECKED]; -static int s_nchecked = 0; -static int s_last_file_found = 0; - -static int already_checked(uint32_t region_start) { - for (int i = 0; i < s_nchecked; i++) - if (s_checked[i] == region_start) return 1; - return 0; -} -static void mark_checked(uint32_t region_start) { - if (s_nchecked < MAX_CHECKED) s_checked[s_nchecked++] = region_start; -} - -/* Re-scan the cache dir for DLLs compiled after init (step 2.8 autocompile) - * and clear the checked-regions memo so the next dispatch into a window - * region reconsiders the cache. Already-loaded DLLs stay loaded; - * dll_already_loaded() makes the re-walk idempotent. */ -void overlay_loader_rescan(void) { - if (!s_active) return; - scan_cache_dir(); - load_bios_resident_shards(); - s_nchecked = 0; - lazy_miss_invalidate_loader(); -} - -/* Loaded-DLL set — the cache is ADDITIVE: a memory slot reused by several - * overlays (Tomba's village and overworld both at 0x800E7xxx) has one cached - * DLL per distinct overlay (keyed by content crc in the filename). We load - * ALL of them; each contributes its functions as separate candidates, and - * per-entry validity (the live-RAM hash) decides which candidate is callable - * at any moment. Nothing is ever clobbered — discoveries accumulate. */ -/* The warmed vault and runtime-discovered variants can exceed the old 512-DLL - * ceiling; once dll_already_loaded() lost track, a rescan could double-register - * candidates. Match the cache-index capacity used by the loader. */ -#define MAX_LOADED_DLLS 4096 -static char s_loaded_paths[MAX_LOADED_DLLS][768]; -static int s_nloaded_paths = 0; - -#ifndef PSX_NO_DEBUG_TOOLS -static int native_rank_allows(Candidate *c, uint32_t pc) { - if (s_native_rank_limit == UINT32_MAX) return 1; - if (c->native_rank == 0) { - c->native_rank = ++s_native_rank_next; - const char *path = (c->dll >= 0 && c->dll < s_nloaded_paths) - ? s_loaded_paths[c->dll] : ""; - fprintf(stdout, - "psxrecomp: native-rank rank=%u candidate=%d owner=0x%08X " - "pc=0x%08X crc=0x%08X dll=%s frame=%llu cycle=%llu allow=%d\n", - c->native_rank, (int)(c - s_cand), c->addr, pc, - c->crc_code, path, (unsigned long long)s_frame_count, - (unsigned long long)psx_get_cycle_count(), - c->native_rank <= s_native_rank_limit); - } - if (c->native_rank <= s_native_rank_limit) return 1; - s_native_rank_blocked++; - return 0; -} -#endif - -static int dll_already_loaded(const char *path) { - for (int i = 0; i < s_nloaded_paths; i++) - if (cache_path_equal(s_loaded_paths[i], path)) return 1; - return 0; -} - -static void overlay_library_close(OverlayLibraryHandle handle) { - if (!handle) return; -#ifdef _WIN32 - FreeLibrary(handle); -#else - psx_overlay_posix_library_close(handle); -#endif -} - -/* Consumes prepared on every path. A successful registration intentionally - * retains that reference for process lifetime, matching the ordinary loader. */ -static int load_one_dll(const char *dll_path, - OverlayLibraryHandle prepared) { - /* Fail CLOSED at the tracking cap, BEFORE registering anything: past this - * point dll_already_loaded() would lose track of the DLL and a later - * rescan could double-register its candidates (stale-chain execution). - * Refusing keeps the region interpreted -- same contract as CACHE_IDX_CAP. */ - if (s_nloaded_paths >= MAX_LOADED_DLLS) { - loader_log("*** LOADED-DLL TABLE FULL (%d): refusing %s -- its region " - "stays interpreted. Raise MAX_LOADED_DLLS.", - MAX_LOADED_DLLS, dll_path); - overlay_library_close(prepared); - return 0; - } - int cache_idx = -1; - int tier = cache_tier_from_path(dll_path); - for (int ci = 0; ci < s_cache_idx_count; ci++) { - if (cache_path_equal(s_cache_idx[ci].path, dll_path)) { - cache_idx = ci; - tier = s_cache_idx[ci].tier; - break; - } - } -#ifdef _WIN32 - LARGE_INTEGER q0, q1, qf; - QueryPerformanceCounter(&q0); -#endif - /* Sibling code-range manifest: {base}_{crc}.ranges next to the DLL. */ - char ranges_path[800]; - snprintf(ranges_path, sizeof(ranges_path), "%s", dll_path); - size_t plen = strlen(ranges_path); - if (plen >= OVERLAY_SHARED_EXT_LEN && - strcmp(ranges_path + plen - OVERLAY_SHARED_EXT_LEN, OVERLAY_SHARED_EXT) == 0) - snprintf(ranges_path + plen - OVERLAY_SHARED_EXT_LEN, - sizeof(ranges_path) - (plen - OVERLAY_SHARED_EXT_LEN), ".ranges"); - - int man_n = 0; - uint64_t manifest_pair_id = 0; - int manifest_has_pair_id = 0; - int manifest_provenance = MANIFEST_PROVENANCE_AUTHORITY; - ManFn *man = parse_manifest(ranges_path, &man_n, - &manifest_pair_id, - &manifest_has_pair_id, - &manifest_provenance); - if (!man || man_n == 0) { - loader_log("no/empty manifest %s — DLL left to interpreter", ranges_path); - free(man); - overlay_library_close(prepared); - return 0; - } - /* A complete known pair remains a zero-slot operation even when no new - * bundle of this size can fit. Parse and compare before durably suppressing - * the cache entry; otherwise safe aliases become unreachable at the cap. */ - int known_alias = manifest_has_pair_id && loaded_pair_find( - tier, manifest_provenance, manifest_pair_id, man, man_n) >= 0; - if (cache_idx >= 0 && !known_alias) { - int suppressed = s_cand_n >= CAND_CAP - ? cache_entry_suppress_at_capacity(cache_idx) - : cache_entry_suppress_for_shortfall(cache_idx, man_n); - if (suppressed) { - free(man); - overlay_library_close(prepared); - return 0; - } - } - int registered = load_overlay_dll(dll_path, man, man_n, s_ndlls, prepared, - manifest_pair_id, - manifest_has_pair_id, - manifest_provenance, tier); -#ifdef _WIN32 - QueryPerformanceCounter(&q1); - QueryPerformanceFrequency(&qf); - uint64_t elapsed_us = qf.QuadPart > 0 - ? (uint64_t)((q1.QuadPart - q0.QuadPart) * 1000000LL / qf.QuadPart) : 0; - s_load_last_us = elapsed_us; - s_load_total_us += elapsed_us; - if (elapsed_us > s_load_max_us) s_load_max_us = elapsed_us; -#endif - free(man); - if (registered == 0) return 0; - - /* The DLL may publish other PCs that were previously negative-cached. */ - lazy_miss_invalidate_loader(); - - /* Unconditional: the fail-closed gate at entry guarantees room, so the - * tracking table can never silently lose a loaded DLL again. */ - strncpy(s_loaded_paths[s_nloaded_paths], dll_path, 767); - s_loaded_paths[s_nloaded_paths][767] = '\0'; - s_nloaded_paths++; - if (registered > 0) s_ndlls++; - return registered; -} - -static int load_one_dll_prepared(const char *dll_path, - OverlayLibraryHandle prepared) { - /* load_one_dll performs lazy_miss_invalidate_loader(); keep this wrapper - * name for the prepared-publication contract. */ - return load_one_dll(dll_path, prepared); -} - -static int published_root_canonical(void) { - if (s_cache_root_canon_ok) return 1; - if (!overlay_path_canonicalize(s_cache_dir, s_cache_root_canon, - sizeof(s_cache_root_canon))) { - /* Not cached as a failure: the root may simply not exist yet; the - * next publication retries. Rejecting until it resolves is the - * fail-closed direction. */ - loader_log("publication rejected: cache root %s does not " - "canonicalize", s_cache_dir); - return 0; - } - return s_cache_root_canon_ok = 1; -} - -/* Validates dll_path and writes its CANONICAL form into canon; every later - * consumer (LoadLibrary, .ranges derivation, loaded-path tracking) uses the - * canonical string, never the raw pipe text. */ -static int published_path_valid(const char *dll_path, char *canon, - size_t canon_cap) { - if (!s_active || !dll_path || !dll_path[0]) return 0; - /* The compiler command is trusted local code, but keep its native-library - * output confined to the cache root the framework injected — resolved - * through the filesystem on BOTH sides, exactly as the OS loader will - * resolve it, so `..`/short-name/reparse forms cannot slip a lexical - * prefix check. */ - if (!published_root_canonical()) return 0; - if (!overlay_path_canonicalize(dll_path, canon, canon_cap)) return 0; - if (!cache_path_has_root(canon, s_cache_root_canon)) return 0; - const char *base = strrchr(canon, '/'); - const char *win_base = strrchr(canon, '\\'); - if (!base || (win_base && win_base > base)) base = win_base; - base = base ? base + 1 : canon; - uint32_t addr = 0, crc = 0; - if (!psx_overlay_cache_name_parse(base, &addr, &crc)) return 0; - if (!cache_name_is_immutable(base)) return 0; - (void)addr; - (void)crc; - return 1; -} - -OverlayPreparedImage *overlay_loader_prepare_published(const char *dll_path) { - char canon[768]; - if (!published_path_valid(dll_path, canon, sizeof(canon))) return NULL; - OverlayPreparedImage *image = - (OverlayPreparedImage *)calloc(1, sizeof(*image)); - if (!image) return NULL; -#ifdef _WIN32 - /* Guard handle: GENERIC_READ with only READ shared blocks rename/replace/ - * delete of the validated file until LoadLibrary has mapped it, and the - * canonical identity is re-derived from the object actually held — so a - * swap between validation and mapping cannot survive the re-check. - * (Anything already writable inside the cache root itself is outside this - * boundary by design; see the threat-model note above cand_register.) */ - HANDLE guard = CreateFileA(canon, GENERIC_READ, FILE_SHARE_READ, NULL, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); - if (guard == INVALID_HANDLE_VALUE) { - free(image); - return NULL; - } - if (!overlay_path_canonicalize_handle(guard, canon, sizeof(canon)) || - !cache_path_has_root(canon, s_cache_root_canon)) { - CloseHandle(guard); - free(image); - return NULL; - } - snprintf(image->path, sizeof(image->path), "%s", canon); - image->handle = LoadLibraryA(canon); - CloseHandle(guard); -#else - snprintf(image->path, sizeof(image->path), "%s", canon); - char error[256] = {0}; - image->handle = psx_overlay_posix_library_open(canon, error, - sizeof(error)); -#endif - if (!image->handle) { - free(image); - return NULL; - } - return image; -} - -int overlay_loader_commit_published(OverlayPreparedImage *image) { - if (!image) return 0; - OverlayLibraryHandle handle = image->handle; - image->handle = NULL; - int loaded = 0; - char canon[768]; - /* image->path is already canonical (prepare stored the handle-derived - * form); re-validating is an idempotent freshness check. */ - if (published_path_valid(image->path, canon, sizeof(canon)) && - !dll_already_loaded(canon)) - loaded = load_one_dll_prepared(canon, handle); - else - overlay_library_close(handle); - free(image); - return loaded; -} - -void overlay_loader_discard_prepared(OverlayPreparedImage *image) { - if (!image) return; - overlay_library_close(image->handle); - image->handle = NULL; - free(image); -} - -static int lazy_man_contains(const ManFn *m, uint32_t phys) { - if ((m->entry & 0x1FFFFFFFu) == phys) return 1; - for (int r = 0; r < m->n; r++) { - uint32_t lo = m->lo[r] & 0x1FFFFFFFu; - if (phys >= lo && phys < lo + m->len[r]) return 1; - } - return 0; -} - -static uint32_t lazy_man_crc(const ManFn *m) { - const uint8_t *ram = memory_get_ram_ptr(); - uint32_t crc = 0xFFFFFFFFu; - for (int r = 0; r < m->n; r++) - crc = crc32_update(crc, ram + (m->lo[r] & 0x1FFFFFFFu), m->len[r]); - return crc ^ 0xFFFFFFFFu; -} - -static uint32_t lazy_man_gensum(const ManFn *m) { - uint32_t sum = 0; - for (int r = 0; r < m->n; r++) - sum += overlay_watch_pagegen_sum(m->lo[r] & 0x1FFFFFFFu, m->len[r]); - return sum; -} - -static int lazy_man_matches(LazyMan *lm) { - if (!man_structurally_valid(&lm->fn)) { - lm->state = ENTRY_INVALID; - return 0; - } - uint32_t gen = lazy_man_gensum(&lm->fn); - if (lm->state == ENTRY_VALID && lm->val_gen == gen) return 1; - if (lm->state == ENTRY_INVALID && lm->val_gen == gen) return 0; - uint32_t live = lazy_man_crc(&lm->fn); - lm->val_gen = gen; - s_last_crc = live; - lm->state = (live == lm->fn.crc && man_delay_slots_hashed(&lm->fn)) - ? ENTRY_VALID : ENTRY_INVALID; - return lm->state == ENTRY_VALID; -} - -/* A CPS DLL can directly own internal tail targets, so one matching entry does - * not prove the rest of that captured snapshot is current. Validate every - * manifested function before preferring a broad bundle. This permits one - * coherent snapshot to replace many synchronous incremental LoadLibrary calls - * at a stage transition without substituting a stale CPS owner. */ -static int lazy_bundle_matches(int ci) { - if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP) return 0; - if (s_cache_idx[ci].func_count <= 0 || - s_cache_idx[ci].indexed_func_count != s_cache_idx[ci].func_count) - return 0; - int seen = 0; - for (int li = s_lazy_bundle_head[ci]; li >= 0; - li = s_lazy_man[li].next_bundle) { - seen++; - if (!lazy_man_matches(&s_lazy_man[li])) return 0; - } - return seen == s_cache_idx[ci].func_count; -} - -static int lazy_candidate_preferred(int li, int current) { - if (current < 0) return 1; - const CacheEntry *a = &s_cache_idx[s_lazy_man[li].cache_idx]; - const CacheEntry *b = &s_cache_idx[s_lazy_man[current].cache_idx]; - if (a->tier != b->tier) return a->tier > b->tier; - if (a->mtime != b->mtime) return a->mtime > b->mtime; - return strcmp(a->path, b->path) > 0; -} - -/* Prefer GCC over TCC even when the TCC option happens to be a broader bundle. - * Within one tier, a fully coherent bundle remains stronger than a partial - * region-local function match. */ -static int lazy_choose_complete_or_fallback(int complete, int fallback) { - if (complete < 0) return fallback; - if (fallback < 0) return complete; - const CacheEntry *a = &s_cache_idx[s_lazy_man[complete].cache_idx]; - const CacheEntry *b = &s_cache_idx[s_lazy_man[fallback].cache_idx]; - if (a->tier != b->tier) return a->tier > b->tier ? complete : fallback; - return complete; -} - -/* A streamed variant is not byte-coherent until immediately before its first - * call, too late to hide Windows' first image-map cost. Map every small bundle - * for this ONE base after boot. Tomba's mature vault has 141 here versus 712 in - * the rejected whole-cache preload. Mapping never registers a candidate; the - * normal manifest/live-byte gate still decides whether it can execute. */ -static void overlay_image_warm_seed_boot_text(void) { - int indices[CACHE_IDX_CAP]; - int count = 0; - for (int ci = 0; ci < s_cache_idx_count; ci++) { - if (s_cache_idx[ci].region_start != DIRTY_RAM_KERNEL_WINDOW_END || - s_cache_idx[ci].capacity_suppressed || - s_cache_idx[ci].func_count <= 0 || - s_cache_idx[ci].func_count > 8) - continue; - indices[count++] = ci; - } - overlay_image_warm_queue(indices, count); -} - -static int lazy_is_loadable(int li, uint32_t region_start, uint32_t phys, - int require_region_start) { - if (li < 0 || li >= s_lazy_man_n) return 0; - LazyMan *lm = &s_lazy_man[li]; - int ci = lm->cache_idx; - if (ci < 0 || ci >= s_cache_idx_count) return 0; - return (!require_region_start || - s_cache_idx[ci].region_start == region_start) && - !s_cache_idx[ci].load_failed && - !s_cache_idx[ci].capacity_suppressed && - !dll_already_loaded(s_cache_idx[ci].path) && - lazy_man_contains(&lm->fn, phys) && lazy_man_matches(lm); -} - -static int lazy_better_bundle(int li, int best) { - (void)li; - /* Candidate order is semantic for continuation-passing bundles: two DLLs - * can contain a byte-identical entry while resolving its internal tail - * targets against different region snapshots. Preserve the manifest index's - * established order and publish only its first live match. Comparing bundle - * size or timestamp can silently substitute an incompatible owner. */ - return best < 0; -} - -static int lazy_load_selected(int li) { - if (li < 0 || li >= s_lazy_man_n) return 0; - int ci = s_lazy_man[li].cache_idx; - if (ci < 0 || ci >= s_cache_idx_count || ci >= CACHE_IDX_CAP || - s_cache_idx[ci].load_failed || - s_cache_idx[ci].capacity_suppressed || - dll_already_loaded(s_cache_idx[ci].path) || - s_cache_idx[ci].func_count <= 0) return 0; - /* If proactive warming has not reached this fragment yet, prefer the - * historical synchronous path over running a potentially hot function in - * the interpreter. The worker drops its speculative reference safely. */ - overlay_image_warm_cancel(ci); - s_last_file_found = 1; - int loaded = load_one_dll(s_cache_idx[ci].path, NULL) > 0; - if (!loaded) s_cache_idx[ci].load_failed = 1; - overlay_image_warm_release(ci); - return loaded; -} - -static int try_load_region(uint32_t phys) { - extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); - - uint32_t page_sz = 4096u; - - /* Walk back over the contiguous dirty run to recover region_start — cache - * DLLs are keyed by this start address in their filename. - * - * CRITICAL: the capture clamps each region to its WINDOW — kernel - * [0, KERNEL_WINDOW_END), dirty boot-text [KERNEL_WINDOW_END, FLOOR), or - * overlay [FLOOR, end) — so a DLL's region_start is the first dirty page - * AT OR ABOVE the window floor. The walkback must apply the SAME clamp, - * chosen by PAGE (the capture windows are page-granular and FLOOR need - * not be page-aligned): an overlay-region walk stopping anywhere below - * FLOOR's page, or a boot-text walk crossing into the kernel window, - * yields a region_start that NO DLL filename matches — the overlay's - * DLLs never load and its functions interpret forever. */ - uint32_t kern_pg = DIRTY_RAM_KERNEL_WINDOW_END / page_sz; - uint32_t ovl_pg = OVERLAY_REGION_FLOOR / page_sz; - uint32_t pg = phys / page_sz; - uint32_t floor_pg = (pg < kern_pg) ? 0u - : (pg < ovl_pg) ? kern_pg - : ovl_pg; - while (pg > floor_pg) { - uint32_t pp = pg - 1; - if (!((dirty_ram_get_bitmap_word(pp >> 5) >> (pp & 31u)) & 1u)) break; - pg = pp; - } - uint32_t region_start = pg * page_sz; - - if (!already_checked(region_start)) mark_checked(region_start); - - int select_attempts = 0; -retry_artifact: - ; - /* Prefer the broadest fully coherent snapshot. If none has every function - * live, retain the historical first matching-function fallback. */ - int best = -1; - int fallback = -1; - uint32_t bucket = (phys * 2654435761u) & LAZY_ENTRY_MASK; - for (int li = s_lazy_entry_head[bucket]; li >= 0; - li = s_lazy_man[li].next_entry) { - if ((s_lazy_man[li].fn.entry & 0x1FFFFFFFu) != phys) continue; - /* This function is reached only for a real runtime dispatch at phys - * (external entry or CPS continuation). The manifest supplies exact - * code identity, and cross-region recovery below additionally requires - * every directly-owned function in the CPS bundle to match. The dirty - * bitmap is sticky across streamed - * variants, so walkback can recover an older adjacent run's base - * (e.g. 0x106000 instead of an exact live 0x108000 shard). Rejecting - * the exact entry on that heuristic strands valid DLLs forever. */ - if (!lazy_is_loadable(li, region_start, phys, 0)) continue; - int ci = s_lazy_man[li].cache_idx; - /* Cross-region recovery is safe only for a fully coherent CPS bundle. - * A partial exact-function match can have snapshot-specific internal - * tails, so retain it as fallback only when the heuristic base agrees. */ - if (s_cache_idx[ci].region_start == region_start && - lazy_candidate_preferred(li, fallback)) - fallback = li; - /* Entry chains are newest-first/semantic. Preserve their established - * order instead of substituting a larger historical bundle. */ - if (lazy_bundle_matches(ci) && lazy_candidate_preferred(li, best)) - best = li; - } - - uint32_t page = phys >> 12; - /* An exact live manifest is sufficient and semantically stronger than an - * enclosing range. Scanning every range link after finding one revalidated - * hundreds of broad historical bundles at streamed-stage transitions. */ - if (fallback < 0 && page < RANGE_PAGE_COUNT) { - for (int ri = s_lazy_page_head[page]; ri >= 0; - ri = s_lazy_range_links[ri].next) { - int li = s_lazy_range_links[ri].cand; - if ((s_lazy_man[li].fn.entry & 0x1FFFFFFFu) == phys) continue; - /* Non-exact range ownership remains region-narrowed: unlike an - * exact manifested entry, an interior PC alone is not a unique - * identity for a reused-address CPS bundle. */ - if (!lazy_is_loadable(li, region_start, phys, 1)) continue; - if (lazy_candidate_preferred(li, fallback)) fallback = li; - int ci = s_lazy_man[li].cache_idx; - if (lazy_bundle_matches(ci) && lazy_candidate_preferred(li, best)) - best = li; - } - } - int selected = lazy_choose_complete_or_fallback(best, fallback); - if (selected < 0) return 0; - if (lazy_load_selected(selected)) return 1; - /* An unloadable/corrupt GCC artifact must not suppress an older immutable - * repair or the TCC fallback. lazy_load_selected marks the failed cache - * entry, so a bounded re-selection chooses the next live candidate. */ - if (++select_attempts < s_cache_idx_count) goto retry_artifact; - return 0; -} - -/* O(1)-bucket discriminator between a real manifested function entry and an - * interior CPS continuation. The latter must use its already-loaded range owner - * first (the Whoopee 0x107624 fix); the former must be allowed to publish its - * exact DLL even when a broader conservative owner contains the same PC. */ -static int lazy_has_exact_entry(uint32_t phys) { - /* Same overlay-off hazard as overlay_find_by_range: the lazy entry index - * (s_lazy_entry_head) is -1-initialized only when overlay_cache is enabled; - * with overlay off it is zero-initialized and the link traversal below - * spins forever. Fail closed (no lazy entry) when the loader is inactive. */ - if (!s_active) return 0; - uint32_t bucket = (phys * 2654435761u) & LAZY_ENTRY_MASK; - for (int li = s_lazy_entry_head[bucket]; li >= 0; - li = s_lazy_man[li].next_entry) { - if ((s_lazy_man[li].fn.entry & 0x1FFFFFFFu) == phys) - return 1; - } - return 0; -} - -/* ---- Dispatch ---------------------------------------------------------- */ - -/* CPS (§25): find a non-blacklisted candidate whose code range CONTAINS phys — - * i.e. phys is a continuation / return point INSIDE an overlay function, not its - * registered entry. Under continuation-passing, an overlay function tail- - * transfers after each block, so a callee returns to a mid-function address that - * idx_head() (entry-keyed) misses; we re-enter the owning function with - * cpu->pc = that address so its entry-switch routes to the right block. Returns - * the candidate index, or -1. */ -static int range_candidate_matches(int i, uint32_t phys) { - Candidate *c = &s_cand[i]; - if (c->state == ENTRY_BLACKLIST) return 0; - int contains = 0; - uint32_t first_range_lo = UINT32_MAX; - for (int r = 0; r < c->nranges; r++) { - uint32_t lo = c->range_lo[r]; - if (lo < first_range_lo) first_range_lo = lo; - if (phys >= lo && phys < lo + c->range_len[r]) contains = 1; - } - /* Interior hosted aliases intentionally share their canonical host's full - * CRC/range identity, but their generated wrapper may contain only the - * blocks reachable from the alias. They are valid exact dispatch entries, - * never authoritative owners for an arbitrary CPS continuation elsewhere - * in the host range. Prefer the rooted host; if it is unavailable, fail - * closed to the interpreter instead of restarting through an alias. */ - if (!contains || c->addr != first_range_lo) return 0; - - /* A reused address can have several range-owning variants. Select the - * one whose compiled code bytes match live RAM instead of returning the - * first range hit and letting one stale variant mask every later match. */ - uint32_t gen = cand_gensum(c); - if (c->state == ENTRY_VALID && gen == c->val_gen) { - s_gen_fastpath++; - return 1; - } - if (c->state == ENTRY_INVALID && gen == c->val_gen) - return 0; /* known mismatch, no watched write */ - uint32_t live = cand_crc(c); - s_rehashes++; - s_last_crc = live; - c->val_gen = gen; - if (live == c->crc_code && cand_delay_slots_hashed(c)) { - if (c->state != ENTRY_VALID) { - c->state = ENTRY_VALID; - s_valid_count++; - } - return 1; - } - s_rehash_miss++; - if (c->state == ENTRY_VALID) { - c->state = ENTRY_INVALID; - s_invalidations++; - if (s_valid_count > 0) s_valid_count--; - } else { - c->state = ENTRY_INVALID; - } - s_stale_blocked++; - return 0; -} - -static int range_candidate_preferred(int candidate, int current) { - if (current < 0) return 1; - if (s_cand[candidate].tier != s_cand[current].tier) - return s_cand[candidate].tier > s_cand[current].tier; - return candidate > current; /* newest same-tier repair */ -} - -static int overlay_find_by_range(uint32_t phys) { - /* Definitive guard for the overlay-off case: the range page index - * (s_range_page_head) is only initialized to -1 by overlay_loader_init, - * which runs solely when overlay_cache is enabled. With overlay off it is - * zero-initialized (0 = a valid index, not the -1 sentinel), so the link - * traversal below never terminates. Fail closed (no candidate) when the - * loader is inactive — there are no candidates to find anyway. */ - if (!s_active) return -1; - uint32_t page = phys >> 12; - if (page >= RANGE_PAGE_COUNT) return -1; - - RangePcCache *pc = &s_range_pc_cache[ - (phys * 2654435761u) & RANGE_PC_CACHE_MASK]; - if (pc->generation == s_range_candidate_generation && - pc->cand >= 0 && pc->phys == phys && - range_candidate_matches(pc->cand, phys)) - return pc->cand; - - int best = -1; - if (s_range_index_overflow) { - for (int i = 0; i < s_cand_n; i++) - if (range_candidate_matches(i, phys) && - range_candidate_preferred(i, best)) - best = i; - } else { - for (int li = s_range_page_head[page]; li >= 0; - li = s_range_links[li].next) { - int i = s_range_links[li].cand; - if (range_candidate_matches(i, phys) && - range_candidate_preferred(i, best)) - best = i; - } - } - pc->phys = phys; - pc->generation = s_range_candidate_generation; - pc->cand = best; - return best; -} - -int overlay_loader_dispatch(CPUState *cpu, uint32_t addr) { - uint32_t phys = addr & 0x1FFFFFFFu; - /* Overlay dispatch is a no-op when the overlay loader is inactive - * (overlay_cache=false): there are no candidates to match, so this must - * return 0 (dispatch to interp). This guard is also a HARD SAFETY: - * overlay_loader_init() — the ONLY place the range index s_range_page_head[] - * is initialized to -1 (empty) — runs only when overlay_cache is enabled - * (main.cpp). With overlay off, that array stays zero-initialized (0 is a - * VALID index, not the -1 sentinel), so the CPS continuation range lookup - * below (gated on g_psx_cps_mode, independent of s_active) would traverse a - * garbage index: s_range_page_head[page]=0 -> s_range_links[0].next=0 -> the - * loop never terminates (infinite spin in range_candidate_matches). Every - * overlay-off + CPS game hit this and wedged at boot before any game code ran - * (found via Ape Escape, the only overlay-off title). Fail closed here. */ - if (!s_active) return 0; - if (overlay_cache_window_contains(phys) && lazy_miss_cached(phys)) { - s_disp_interp++; - return 0; - } - int lazy_loaded = 0; -retry_candidates: ; - int head = idx_head(phys); - int loaded_range_ci = -1; - int lazy_exact = 0; - int exact_needs_load = 0; - if (head < 0 && s_active && overlay_cache_window_contains(phys)) { - lazy_exact = head < 0 && lazy_has_exact_entry(phys); - /* A CPS continuation is normally not a registered function ENTRY. Its - * already-loaded range owner must be checked before lazy discovery: - * try_load_region otherwise scans the 12K+ manifest index and may load - * another duplicate bundle on every hot continuation edge. Whoopee's - * 0x80107624 path paid that cost thousands of times per frame. Exact - * manifested entries are different: a broader range owner must not mask - * their valid cached DLL (Tomba FMV's 0x80106424/0x80106688 case). */ - if (head < 0 && g_psx_cps_mode) - loaded_range_ci = overlay_find_by_range(phys); - exact_needs_load = lazy_exact && - (loaded_range_ci < 0 || s_cand[loaded_range_ci].device_touch); - if (head < 0 && (loaded_range_ci < 0 || exact_needs_load) && - !lazy_loaded && try_load_region(phys)) { - lazy_loaded = 1; - goto retry_candidates; - } - /* (sljit removed 2026-07-15: the JIT-on-miss gap-fill — off-thread - * enqueue or synchronous compile — ran here. Misses fall to interp.) */ - } - - /* CPS (§25) continuation re-entry: phys is not an overlay ENTRY, but it may - * be a return point inside an already-registered overlay function (the - * caller's continuation after a tail-transferred call). Re-enter that - * function natively with cpu->pc = addr so its entry-switch routes to the - * continuation block — without this the continuation falls to the interp and - * native coverage stops at the first call. gcc-DLL candidates are the trusted - * tier (diff-validated at their entry; the continuation is the same DLL code), - * so they run native directly here; device-touch funcs still go to interp. */ - if (head < 0 && g_psx_cps_mode) { - int ci = loaded_range_ci >= 0 ? loaded_range_ci - : overlay_find_by_range(phys); - int _probe = (s_cps_probe_pc && phys == s_cps_probe_pc); - if (_probe) { - s_cps_probe_count++; - s_cps_probe_ci = ci; - s_cps_probe_ncand_inrange = overlay_count_by_range(phys); - s_cps_probe_found = (ci >= 0) ? s_cand[ci].addr : 0; - s_cps_probe_nrange = (ci >= 0) ? s_cand[ci].nranges : -1; - s_cps_probe_matched = -1; - s_cps_probe_outcome = (ci < 0) ? 0 : -1; - } - if (ci >= 0) { - Candidate *c = &s_cand[ci]; - /* Generation-gated validation (overlay-cache v2 P2) — same contract - * as the main chain: skip the crc32 when no watched code page changed - * since this entry was last confirmed VALID. */ - uint32_t gen = cand_gensum(c); - int matched; - if (c->state == ENTRY_VALID && gen == c->val_gen) { - matched = 1; - s_gen_fastpath++; - } else { - uint32_t live = cand_crc(c); - s_rehashes++; - s_last_crc = live; - c->val_gen = gen; - matched = (live == c->crc_code && cand_delay_slots_hashed(c)); - } - if (_probe) s_cps_probe_matched = matched; - if (matched) { - if (c->state != ENTRY_VALID) { c->state = ENTRY_VALID; s_valid_count++; } - if (c->device_touch) { if (_probe) s_cps_probe_outcome = 3; s_disp_interp++; return 0; } - /* Diff instrument — same contract as the entry chain's want_diff - * gate below. A continuation re-entry must NOT run native blind - * while its candidate is still inside the verify budget: CPS - * interiors dominate dispatch, so without this gate ~all native - * execution bypassed the diff harness (shadow_calls=1 over 51K - * frames while gen_fastpath took ~600M hits). Interiors are - * validated transitively: the entry-level shadow diff runs the - * WHOLE function natively (in_own_shadow below), so until the - * candidate passes its budget its interiors take the interpreter - * (the authority). Inside the candidate's OWN shadow native pass - * interiors DO run native — that IS the diff exercising the - * continuation blocks; the blocklist is not consulted there, - * matching the entry gate (which shadow-diffs blocked candidates - * too — the blocklist blocks LIVE native, not the sandbox). */ - int in_own_shadow = (s_in_shadow && (const void *)c == s_shadow_cand); - if (!in_own_shadow) { - int want_diff = s_diff_mode && cand_selected_for_diff(c); - if (want_diff && addr < 0x10000u) want_diff = 0; - if (want_diff && (s_diff_addr || c->diff_passes < OVERLAY_DIFF_BUDGET)) { - if (_probe) s_cps_probe_outcome = 5; - s_diffgate_interp++; - s_disp_interp++; - return 0; - } - if (!s_native_exec || overlay_native_blocked(c->addr) || overlay_native_blocked(addr)) - { if (_probe) s_cps_probe_outcome = 4; s_would_run_native++; s_disp_interp++; return 0; } -#ifndef PSX_NO_DEBUG_TOOLS - if (!native_rank_allows(c, addr)) - { if (_probe) s_cps_probe_outcome = 7; s_would_run_native++; s_disp_interp++; return 0; } -#endif - } - if (_probe) s_cps_probe_outcome = 2; - /* Native-call ring: ALWAYS-ON (production too). Each entry names - * the requested PC and the candidate CRC that claimed it — the - * only record that can attribute a wrong-variant native run on - * a production binary (Tomba 2 splash reload loop). */ - uint32_t slot = s_nring_pos++ & (NRING_CAP - 1u); - s_nring[slot].addr = addr; - s_nring[slot].crc = c->crc_code; - s_nring[slot].frame = (uint32_t)s_frame_count; - s_nring[slot].seq = ++s_nring_seq; - s_nring[slot].returned = 0; - uint32_t prev_inprogress = s_native_inprogress; - s_native_inprogress = c->addr; - s_native_calls_total++; - native_hot_note(c->addr); - if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) - s_active_stack[s_active_depth++] = ci; - s_disp_native++; - cpu->pc = addr; /* route the func's entry-switch to the block */ - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(cpu); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - overlay_post_dispatch_irq_pump(cpu); - if (s_active_depth > 0) s_active_depth--; - s_nring[slot].returned = 1; - s_native_inprogress = prev_inprogress; - if (g_native_bad_entry) { /* foreign interior entry: fail closed to interp */ - g_native_bad_entry = 0; - s_disp_native--; s_disp_interp++; - return 0; /* cpu->pc was restored to the requested PC */ - } - return 1; - } - if (_probe) s_cps_probe_outcome = 1; - /* stale code bytes: fall through to the interpreter */ - } - } - - for (int i = head; i >= 0; i = s_cand[i].next) { - Candidate *c = &s_cand[i]; - if (c->state == ENTRY_BLACKLIST) continue; - - /* Generation-gated validation (overlay-cache v2 P2). The ONLY way this - * entry's compiled code bytes can change is a write to one of its watched - * code pages — and every CPU/DMA store funnels through the single store - * chokepoint, which bumps overlay_page_gen for watched pages (memory.c). - * So if the page-generation sum is unchanged since we last confirmed this - * entry VALID, the bytes are PROVABLY unchanged and we run native without - * re-hashing. We fall back to the full crc32 only when the generation - * moved (or the entry isn't known-valid) — which also covers - * reload-on-return: a write-away-then-back bumps the gen, forcing a - * re-hash that re-confirms the byte-exact match before any native call. - * This removes the per-dispatch crc32 of the whole function body from the - * hot path for stable code (the warm-cache common case). Correctness is - * identical to the old unconditional hash; only redundant hashing is cut. */ - uint32_t gen = cand_gensum(c); - int matched; - if (c->state == ENTRY_VALID && gen == c->val_gen) { - matched = 1; /* no watched write since validation */ - s_gen_fastpath++; - } else if (c->state == ENTRY_INVALID && gen == c->val_gen) { - /* This exact byte identity already failed after the most recent - * watched write. Re-hashing it on every dispatch makes reused - * variant chains scale with cache history instead of live code. */ - continue; - } else { - uint32_t live = cand_crc(c); - s_rehashes++; - s_last_crc = live; - c->val_gen = gen; - matched = (live == c->crc_code && cand_delay_slots_hashed(c)); - } - if (matched) { - if (c->state != ENTRY_VALID) { - c->state = ENTRY_VALID; - s_revalidations++; /* reload-on-return */ - s_valid_count++; - } - /* Device-touching functions never run their shard: the shadow diff - * can't safely double-execute MMIO/SIO/DMA to validate them, so they - * always fall to the interpreter (the authoritative single path). */ - if (c->device_touch) { s_disp_interp++; return 0; } - /* Same-state differential: run native+interp from identical state, - * compare, keep the interp result. Takes precedence over the A/B - * toggle. Verify-budget: once a candidate has passed cleanly enough - * times it's trusted and falls through to normal execution, so the - * diff cost stays bounded (a diverging candidate never reaches the - * budget — it keeps being diff-gated and never runs native live). - * gcc/DLL candidates are the trusted tier (validated at dev time) and - * run native directly; they are diffed only in explicit dev diff mode - * (PSX_OVERLAY_DIFF / overlay_diff cmd). */ - int want_diff = s_diff_mode && cand_selected_for_diff(c); - /* Kernel-window candidates (call gates 0xA0/0xB0/0xC0 and the RAM - * kernel) are NOT shadow-diffable: the gates tail-jump via RAM - * tables into kernel SERVICES whose behavior depends on scheduler/ - * event state and can block (TestEvent/WaitEvent) — the two passes - * legitimately run different instruction paths and every call - * "diverges" (observed: 2016/2017 calls, all 0xB0, flooding the - * ring). The diff harness validates OVERLAY shard codegen against - * the interp oracle; kernel gates stay on their normal route. */ - if (want_diff && addr < 0x10000u) want_diff = 0; - if (want_diff && !s_in_shadow && - (s_diff_addr || c->diff_passes < OVERLAY_DIFF_BUDGET)) { - /* NEVER start a shadow inside an exception dispatch: the guest's - * ReturnFromException longjmps to the setjmp frame BELOW us - * (psx_check_interrupts), unwinding run_shadow_diff WITHOUT its - * epilogue — s_in_shadow/s_native_exec/s_suppress_irq stay stuck - * and the diff instrument is dead for the rest of the run - * (observed: shadow_calls=1 over 51K frames). Exceptions ENTERED - * during a shadow are contained — their setjmp is armed inside - * the shadow frame — so gating the START is structurally - * sufficient (every psx_exception_longjmp site is guarded by - * psx_get_in_exception()). The candidate still must not run - * native unvalidated: route to the interpreter (the authority); - * it gets diffed at its next non-exception dispatch. */ - extern int psx_get_in_exception(void); - if (psx_get_in_exception()) { - s_diffgate_interp++; - s_disp_interp++; - return 0; - } - run_shadow_diff(cpu, c, addr); - return 1; - } - - /* A/B: prove whether native EXECUTION is the cause. When off, the - * candidate matched (byte-exact) but we DON'T run native — interp - * handles it. The per-function blocklist forces the same interp - * routing for one function only (bisection localization). */ - if (!s_native_exec || overlay_native_blocked(c->addr)) - { s_would_run_native++; s_disp_interp++; return 0; } -#ifndef PSX_NO_DEBUG_TOOLS - if (!native_rank_allows(c, addr)) - { s_would_run_native++; s_disp_interp++; return 0; } -#endif - - /* Record into the always-on ring BEFORE the call; mark in-progress - * so a freeze inside this fn is visible at dump time. */ - /* Native-call ring: ALWAYS-ON (see the range-chain site). */ - uint32_t slot = s_nring_pos++ & (NRING_CAP - 1u); - s_nring[slot].addr = c->addr; - s_nring[slot].crc = c->crc_code; - s_nring[slot].frame = (uint32_t)s_frame_count; - s_nring[slot].seq = ++s_nring_seq; - s_nring[slot].returned = 0; - uint32_t prev_inprogress = s_native_inprogress; - s_native_inprogress = c->addr; - s_native_calls_total++; - native_hot_note(c->addr); - - if (s_active_depth < (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0]))) - s_active_stack[s_active_depth++] = i; - s_disp_native++; - /* Delimit this native execution in the interp insn ring (native code - * emits no per-insn entries; markers keep the timeline alignable). */ -#ifndef PSX_NO_DEBUG_TOOLS - extern void dirty_ram_log_marker(uint32_t addr, uint32_t tag, int kind); - uint32_t mtag = (uint32_t)s_nring[slot].seq; /* stable across nesting */ - dirty_ram_log_marker(c->addr, mtag, 0); -#endif - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(cpu); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - overlay_post_dispatch_irq_pump(cpu); -#ifndef PSX_NO_DEBUG_TOOLS - dirty_ram_log_marker(c->addr, mtag, 1); -#endif - if (s_active_depth > 0) s_active_depth--; - - s_nring[slot].returned = 1; - s_native_inprogress = prev_inprogress; /* restore (nested calls) */ - if (g_native_bad_entry) { /* foreign interior entry: fail closed to interp */ - g_native_bad_entry = 0; - s_disp_native--; s_disp_interp++; - return 0; /* cpu->pc was restored to the requested PC */ - } - return 1; - } else { - s_rehash_miss++; - if (c->state == ENTRY_VALID) { - c->state = ENTRY_INVALID; - s_invalidations++; - if (s_valid_count > 0) s_valid_count--; - } else { - c->state = ENTRY_INVALID; - } - s_stale_blocked++; - } - } - - /* Publish at most one cached DLL and retry this same dispatch once. This - * preserves additive variant coverage without turning one guest transition - * into an unbounded synchronous LoadLibrary loop. */ - if (!lazy_loaded && s_active && overlay_cache_window_contains(phys) && - try_load_region(phys)) { - lazy_loaded = 1; - goto retry_candidates; - } - - if (overlay_cache_window_contains(phys)) lazy_miss_record(phys); - s_disp_interp++; - return 0; -} - -/* ---- Self-modification of an actively-executing entry (§8.5) ------------ */ -/* Lazy re-hash on the NEXT dispatch is too late if a native function modifies - * its own code and continues executing the modified bytes within the same - * activation (native runs the originally-compiled semantics). We can't recover - * the current activation, so we permanently demote that entry to interp. Called - * from memory.c only when the written page is watched. */ -void overlay_loader_active_write_check(uint32_t phys, uint32_t size) { - extern uint32_t g_debug_last_store_pc; - uint32_t p = phys & 0x1FFFFFFFu; - for (int d = 0; d < s_active_depth; d++) { - Candidate *c = &s_cand[s_active_stack[d]]; - for (int i = 0; i < c->nranges; i++) { - uint32_t lo = c->range_lo[i]; - uint32_t hi = lo + c->range_len[i]; - if (p < hi && p + size > lo) { - if (c->state != ENTRY_BLACKLIST) { - c->state = ENTRY_BLACKLIST; - s_selfmod++; - if (s_valid_count > 0) s_valid_count--; - s_last_write_pc = g_debug_last_store_pc; - s_last_write_addr = phys; - s_last_write_size = size; - loader_log("blacklist self-mod entry 0x%08X (write 0x%08X)", - c->addr, phys); - } - break; - } - } - } -} - -/* ---- Status getters (signatures preserved for debug_server.c) ---------- */ - -void overlay_loader_get_counters(uint32_t *loads, uint32_t *invalidations, - uint32_t *unregistered, - uint64_t *disp_native, uint64_t *disp_interp, - uint64_t *stale_blocked, - uint32_t *last_write_pc, - uint32_t *last_write_addr, - uint32_t *last_write_size, - int *regions, uint32_t *revalidations) { - if (loads) *loads = (uint32_t)s_ndlls; - if (invalidations) *invalidations = s_invalidations; - if (unregistered) *unregistered = s_no_manifest; - if (disp_native) *disp_native = s_disp_native; - if (disp_interp) *disp_interp = s_disp_interp; - if (stale_blocked) *stale_blocked = s_stale_blocked; - if (last_write_pc) *last_write_pc = s_last_write_pc; - if (last_write_addr) *last_write_addr = s_last_write_addr; - if (last_write_size) *last_write_size = s_last_write_size; - if (regions) *regions = s_ndlls; - if (revalidations) *revalidations = s_revalidations; -} - -void overlay_loader_get_load_timing(uint64_t *total_us, uint64_t *max_us, - uint64_t *last_us) { - if (total_us) *total_us = s_load_total_us; - if (max_us) *max_us = s_load_max_us; - if (last_us) *last_us = s_load_last_us; -} - -void overlay_loader_take_hot_native(uint32_t *pc, uint64_t *calls) { - uint32_t best_pc = 0; - uint64_t best_calls = 0; - if (s_native_hot_enabled) { - for (uint32_t i = 0; i < NATIVE_HOT_CAP; i++) { - if (s_native_hot[i].calls > best_calls) { - best_pc = s_native_hot[i].pc; - best_calls = s_native_hot[i].calls; - } - s_native_hot[i].calls = 0; - } - } - if (pc) *pc = best_pc; - if (calls) *calls = best_calls; -} - -/* Reload diagnostics. Repurposed for the per-entry model: - * r0_valid -> candidates currently VALID - * r0_writes... -> entries blacklisted (self-mod) - * r0_fn_lo -> total candidates registered - * r0_fn_hi -> DLLs loaded - * r0_crc_live -> last computed code-range crc - * reval_attempts -> code-range hashes computed - * reval_crc_miss -> hashes that did not match - * last_reval_crc -> last computed crc */ -void overlay_loader_get_reload_debug(int *r0_valid, uint32_t *r0_writes, - uint32_t *r0_fn_lo, uint32_t *r0_fn_hi, - uint32_t *r0_crc_live, - uint32_t *reval_attempts, - uint32_t *reval_crc_miss, - uint32_t *last_reval_crc) { - if (r0_valid) *r0_valid = s_valid_count; - if (r0_writes) *r0_writes = s_selfmod; - if (r0_fn_lo) *r0_fn_lo = (uint32_t)s_cand_n; - if (r0_fn_hi) *r0_fn_hi = (uint32_t)s_ndlls; - if (r0_crc_live) *r0_crc_live = s_last_crc; - if (reval_attempts) *reval_attempts = s_rehashes; - if (reval_crc_miss) *reval_crc_miss = s_rehash_miss; - if (last_reval_crc) *last_reval_crc = s_last_crc; -} - -int overlay_loader_registered_count(void) { return s_valid_count; } - -/* Dispatches that ran native via the unchanged-page-generation fast path, - * i.e. skipped the per-dispatch code-range crc32 (overlay-cache v2 P2). High - * values relative to reval_attempts mean the warm-cache hot path is cheap. */ -uint64_t overlay_loader_gen_fastpath(void) { return s_gen_fastpath; } -int overlay_loader_range_link_count(void) { return s_range_link_n; } -int overlay_loader_range_index_overflow(void) { return s_range_index_overflow; } -int overlay_loader_lazy_manifest_count(void) { return s_lazy_man_n; } -int overlay_loader_lazy_manifest_overflow(void) { return s_lazy_man_overflow; } -uint64_t overlay_loader_candidate_overflow(void) { return s_cand_overflow; } -uint64_t overlay_loader_pair_aliases(void) { return s_pair_aliases; } - -/* (sljit removed 2026-07-15: overlay_loader_sljit_obsoleted and the - * overlay_loader_sljit_probe one-shot JIT helper lived here.) */ - -/* ---- Native↔interp execution fingerprint differential (§5-E) ----------- */ -/* For each CANDIDATE function execution we record the FULL register file at - * entry and exit (plus the guest cycle), tagged native vs interp. Run once - * native-OFF (all candidates interpreted = oracle) and once native-ON; diff by - * sequence — the first entry whose in-state differs names the exact register - * AND value where the two trajectories part ways, and the cycle field - * quantifies native↔interp cycle-accounting skew over the aligned prefix. - * Logging is purely additive (no control-flow change), driven from the single - * dirty_ram_dispatch chokepoint. */ -typedef struct { - uint64_t seq; - uint64_t cycle; /* guest cycle at exit (log time) */ - uint32_t addr; - uint32_t in_crc, out_crc; - int native; - uint32_t in_regs[34]; /* r0..r31, hi, lo at entry */ - uint32_t out_regs[34]; /* r0..r31, hi, lo at exit */ -} FpEnt; -#define FP_CAP (1u << 16) /* ~19 MB with full reg files; ~65K executions */ -static FpEnt s_fp[FP_CAP]; -static uint64_t s_fp_seq = 0; - -int overlay_loader_is_candidate(uint32_t phys) { - if (!s_active) return 0; - return exact_entry_has(phys); -} - -int overlay_fp_enabled(void) { - static int enabled = -1; - if (enabled < 0) { - const char *e = getenv("PSX_OVERLAY_FP_LOG"); - enabled = (e && e[0] && e[0] != '0') ? 1 : 0; - } - return enabled; -} - -/* ---- Same-state native↔interp differential (confident measurement) ------ */ -/* At a matched dispatch: snapshot CPU+RAM, run native (discard), restore, run - * interpreter (KEEP — game stays correct), compare under IDENTICAL input state. - * Eliminates manual-nav desync. Interrupts suppressed during both shadow runs - * so the comparison isolates COMPUTATION (and is longjmp-safe). A divergence - * here = a real codegen bug (function + exact register/RAM). Zero divergence = - * computation is correct and the fault is timing/interrupt-ordering. */ -#define SHADOW_RAM_SIZE (2u * 1024u * 1024u) -#define SHADOW_SPAD_SIZE 1024u -static uint8_t s_ram0[SHADOW_RAM_SIZE], s_ramN[SHADOW_RAM_SIZE], s_ramI[SHADOW_RAM_SIZE]; -static uint8_t s_spad0[SHADOW_SPAD_SIZE], s_spadI[SHADOW_SPAD_SIZE]; -static uint64_t s_shadow_skipped_dev = 0; /* unsafe/incomplete traces skipped */ -/* s_diff_mode / s_in_shadow declared above (before dispatch). */ - -typedef struct { - uint64_t seq; uint32_t addr; - int kind; /* architectural/trace difference class */ - int index; /* register/array index, or -1 */ - uint64_t cycle_native, cycle_interp; - uint32_t trace_ops, replay_ops; - int trace_kind; - uint32_t trace_pc, trace_addr, trace_expected, trace_actual; - int reg; /* first differing gpr index, -1 none */ - uint32_t reg_native, reg_interp; - int hi_diff, lo_diff; - int64_t ram_off; /* first differing RAM byte, -1 none */ - uint32_t ram_native, ram_interp; /* the differing word */ -} ShadowDiv; -#define SDIV_CAP 512 -static ShadowDiv s_sdiv[SDIV_CAP]; -static int s_sdiv_n = 0; -static uint64_t s_shadow_calls = 0; -static uint64_t s_shadow_divs = 0; - -/* One-shot full-state capture of the FIRST divergence: complete native and - * interp register files so the diverging path can be localized. */ -static int s_detail_captured = 0; -static uint32_t s_detail_addr = 0; -static uint32_t s_detail_nat_gpr[32], s_detail_int_gpr[32]; -static uint32_t s_detail_nat_hi, s_detail_nat_lo, s_detail_int_hi, s_detail_int_lo; - -void overlay_loader_get_shadow_summary(uint64_t *calls, uint64_t *divergences, - uint32_t *first_divergence_pc) { - if (calls) *calls = s_shadow_calls; - if (divergences) *divergences = s_shadow_divs; - if (first_divergence_pc) - *first_divergence_pc = s_detail_captured ? s_detail_addr : 0; -} - -void overlay_loader_set_diff_mode(int on) { s_diff_mode = on ? 1 : 0; } - -static void run_shadow_diff_legacy(CPUState *cpu, Candidate *c, uint32_t addr) { - extern uint8_t *memory_get_ram_ptr(void); - extern uint8_t *memory_get_scratchpad_ptr(void); - extern int dirty_ram_dispatch(CPUState *cpu, uint32_t addr, uint32_t stop_addr); - extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr); - extern int g_shadow_mmio_watch; /* memory.c — device-access detector */ - extern uint64_t g_shadow_mmio_hits; - uint8_t *ram = memory_get_ram_ptr(); - uint8_t *spad = memory_get_scratchpad_ptr(); - - extern uint64_t psx_exception_setjmp_epoch(void); - s_in_shadow = 1; - s_shadow_epoch = psx_exception_setjmp_epoch(); - int saved_supp = s_suppress_irq; - s_suppress_irq = 1; /* isolate computation; longjmp-safe */ - s_shadow_saved_supp = saved_supp; /* escape-fixup mirror (see decl) */ - s_shadow_saved_mmio_watch = g_shadow_mmio_watch; - s_shadow_saved_exec_phase = g_exec_phase; - s_shadow_scheduler_bail = 0; - /* Validate ONE function at a time: nested OVERLAY calls run via the - * INTERPRETER on BOTH passes (s_native_exec=0). Otherwise the native pass - * dispatches each callee as its own native shard while the interp pass runs - * it interp, so the diff compares the whole call TREE and a callee that bails - * or diverges is misattributed to this candidate (e.g. a contract bail that - * skips THIS function's epilogue -> $sp off by the frame size). Per-function - * isolation proves exactly this shard's codegen vs the interp oracle; whole- - * tree soundness then follows by induction. */ - int sv = s_native_exec; - s_native_exec = 0; - s_shadow_saved_native_exec = sv; /* escape-fixup mirror (see decl) */ - - CPUState cpu0 = *cpu; - memcpy(s_ram0, ram, SHADOW_RAM_SIZE); - memcpy(s_spad0, spad, SHADOW_SPAD_SIZE); - - /* PASS 1 — INTERPRETER, the authoritative single execution, with the device - * detector armed. Running interp FIRST (not native) guarantees device I/O - * happens AT MOST ONCE and only via the trusted path: if this function (or a - * callee) touches ANY MMIO we abandon the native pass entirely — device I/O - * must never be double-executed (one spurious card/SIO/DMA write corrupts - * hardware state and wedges the guest, e.g. the save-load crash). */ - uint64_t mmio0 = g_shadow_mmio_hits; - g_shadow_mmio_watch++; - dirty_ram_dispatch(cpu, addr, cpu->gpr[31]); - g_shadow_mmio_watch--; - s_shadow_calls++; - - if (g_shadow_mmio_hits != mmio0) { - /* Device-touching: keep the interp result live (already in *cpu/ram/spad), - * mark the candidate so it ALWAYS runs via the interpreter (never its - * shard, never re-diffed). Device functions stay on the interpreter — - * safe by construction, no double I/O. */ - c->device_touch = 1; - s_shadow_skipped_dev++; - g_psx_call_bail = 0; - s_native_exec = sv; - s_suppress_irq = saved_supp; - s_in_shadow = 0; - return; - } - - /* Device-free: preserve the interp result, then run the NATIVE shard from the - * same input and compare. No device I/O on either pass (proven clean above). */ - CPUState cpuI = *cpu; - memcpy(s_ramI, ram, SHADOW_RAM_SIZE); - memcpy(s_spadI, spad, SHADOW_SPAD_SIZE); - - *cpu = cpu0; - memcpy(ram, s_ram0, SHADOW_RAM_SIZE); - memcpy(spad, s_spad0, SHADOW_SPAD_SIZE); - uint32_t stop_ra = cpu->gpr[31]; /* entry $ra = the function's return point */ - /* Arm the own-interior native route for the NATIVE pass only (see - * s_shadow_cand decl): the candidate's CPS continuation re-entries run - * native so the diff exercises every block of the function, not just the - * first segment (nested CALLS still run interp on both passes — - * s_native_exec=0 above). Never armed during the interp pass, which must - * stay pure interp. */ - s_shadow_cand = c; - /* Preserve the authoritative interpreter's projection provenance while - * making every host-only cache read miss and every cache write a no-op for - * the speculative native pass. */ - gte_precision_speculative_begin(); - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(cpu); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - /* CPS shards exit with cpu->pc set to the next tail target. Chain through - * the normal dispatcher to the original caller return, with s_native_exec=0 - * above so nested overlay calls still run through the interpreter on both - * passes. dirty_ram_dispatch alone cannot follow clean/static BIOS targets - * and creates false shadow divergences for tail-transfer-heavy functions. */ - { - int guard = 0; - while (cpu->pc != 0 && !g_psx_call_bail && guard++ < 8192) { - uint32_t tv = cpu->pc; - if ((tv & 0x1FFFFFFFu) == (stop_ra & 0x1FFFFFFFu)) break; /* returned */ - cpu->pc = 0; - int prev_phase = g_exec_phase; - g_exec_phase = 3; /* compiled route; dirty/native callees re-tag inside */ - psx_dispatch_call(cpu, tv, stop_ra); - g_exec_phase = prev_phase; - } - } - s_shadow_cand = NULL; - gte_precision_speculative_end(); - CPUState cpuN = *cpu; - memcpy(s_ramN, ram, SHADOW_RAM_SIZE); - int scheduler_bail = s_shadow_scheduler_bail; - - /* Compare native (cpuN/s_ramN) vs interp (cpuI/s_ramI) under identical input. */ - int reg = -1; - for (int r = 1; r < 32; r++) if (cpuN.gpr[r] != cpuI.gpr[r]) { reg = r; break; } - int hidiff = (cpuN.hi != cpuI.hi), lodiff = (cpuN.lo != cpuI.lo); - int64_t ramoff = -1; - if (memcmp(s_ramN, s_ramI, SHADOW_RAM_SIZE) != 0) { - for (uint32_t a = 0; a < SHADOW_RAM_SIZE; a++) - if (s_ramN[a] != s_ramI[a]) { ramoff = (int64_t)a; break; } - } - if (!scheduler_bail && reg < 0 && !hidiff && !lodiff && ramoff < 0) { - /* Clean pass: credit the verify budget. */ - if (c->diff_passes < OVERLAY_DIFF_BUDGET) c->diff_passes++; - } else { - /* Divergence: reset the budget. Promotion to live requires N CONSECUTIVE - * clean passes (the spec's "0 divergences over the budget"), so an - * intermittently-wrong shard can never accumulate enough lucky passes to be - * trusted — it stays diff-gated (interp result kept) and never runs live. */ - c->diff_passes = 0; - s_shadow_divs++; - if (scheduler_bail && c->state != ENTRY_BLACKLIST) { - /* The interpreter pass completed without switching threads, so a - * native-only scheduler attempt proves control-flow divergence. - * Permanently keep this shard off the live path. */ - c->state = ENTRY_BLACKLIST; - if (s_valid_count > 0) s_valid_count--; - loader_log("blacklist shadow scheduler divergence 0x%08X", c->addr); - } - if (!s_detail_captured) { - s_detail_captured = 1; - s_detail_addr = c->addr; - for (int r = 0; r < 32; r++) { - s_detail_nat_gpr[r] = cpuN.gpr[r]; - s_detail_int_gpr[r] = cpuI.gpr[r]; - } - s_detail_nat_hi = cpuN.hi; s_detail_nat_lo = cpuN.lo; - s_detail_int_hi = cpuI.hi; s_detail_int_lo = cpuI.lo; - } - /* Ring-flood guard: one persistently-diverging site must not evict - * every other site's records (512x 0xB0 drowned the 0xF514 hunt). - * Cap records per address; the divergence COUNTER still increments. */ - int addr_recs = 0; - for (int i = 0; i < s_sdiv_n; i++) - if (s_sdiv[i].addr == c->addr && ++addr_recs >= 16) break; - if (s_sdiv_n < SDIV_CAP && addr_recs < 16) { - ShadowDiv *d = &s_sdiv[s_sdiv_n++]; - d->seq = s_shadow_calls; d->addr = c->addr; - d->reg = reg; - d->reg_native = (reg >= 0) ? cpuN.gpr[reg] : 0; - d->reg_interp = (reg >= 0) ? cpuI.gpr[reg] : 0; - d->hi_diff = hidiff; d->lo_diff = lodiff; - d->ram_off = ramoff; - if (ramoff >= 0) { - uint32_t a = (uint32_t)ramoff & ~3u; - d->ram_native = *(uint32_t *)&s_ramN[a]; - d->ram_interp = *(uint32_t *)&s_ramI[a]; - } - } - } - /* Restore the interp result as the authoritative live state (native discarded). - * A bail raised by the shadow run must never leak into live execution (a - * spurious in-progress unwind wedges the guest). */ - *cpu = cpuI; - memcpy(ram, s_ramI, SHADOW_RAM_SIZE); - memcpy(spad, s_spadI, SHADOW_SPAD_SIZE); - g_psx_call_bail = 0; - s_native_exec = sv; - s_suppress_irq = saved_supp; - s_in_shadow = 0; -} - -enum { - SHDIFF_NONE = 0, SHDIFF_PC, SHDIFF_GPR, SHDIFF_HI, SHDIFF_LO, - SHDIFF_COP0, SHDIFF_GTE_DATA, SHDIFF_GTE_CTRL, - SHDIFF_MULDIV_TS, SHDIFF_GTE_TS, SHDIFF_READ_ABSORB, - SHDIFF_READ_WHICH, SHDIFF_READ_FUDGE, SHDIFF_LD_WHICH, - SHDIFF_LD_ABSORB, SHDIFF_CYCLE, SHDIFF_TRACE, SHDIFF_SETUP, - SHDIFF_SCHED -}; - -static int shadow_cpu_diff(const CPUState *native, const CPUState *interp, - int *index, uint32_t *nv, uint32_t *iv) { -#define SHCMP_SCALAR(kind_, field_) do { \ - if (native->field_ != interp->field_) { \ - *index = -1; *nv = (uint32_t)native->field_; *iv = (uint32_t)interp->field_; \ - return (kind_); \ - } \ -} while (0) -#define SHCMP_ARRAY(kind_, field_, count_) do { \ - for (int _i = 0; _i < (count_); _i++) { \ - if (native->field_[_i] != interp->field_[_i]) { \ - *index = _i; *nv = (uint32_t)native->field_[_i]; \ - *iv = (uint32_t)interp->field_[_i]; return (kind_); \ - } \ - } \ -} while (0) - if (native->pc != interp->pc) { - *index = -1; *nv = native->pc; *iv = interp->pc; return SHDIFF_PC; - } - SHCMP_ARRAY(SHDIFF_GPR, gpr, 32); - SHCMP_SCALAR(SHDIFF_HI, hi); - SHCMP_SCALAR(SHDIFF_LO, lo); - SHCMP_ARRAY(SHDIFF_COP0, cop0, 32); - SHCMP_ARRAY(SHDIFF_GTE_DATA, gte_data, 32); - SHCMP_ARRAY(SHDIFF_GTE_CTRL, gte_ctrl, 32); - SHCMP_SCALAR(SHDIFF_MULDIV_TS, muldiv_ts_done); - SHCMP_SCALAR(SHDIFF_GTE_TS, gte_ts_done); - SHCMP_ARRAY(SHDIFF_READ_ABSORB, read_absorb, 33); - SHCMP_SCALAR(SHDIFF_READ_WHICH, read_absorb_which); - SHCMP_SCALAR(SHDIFF_READ_FUDGE, read_fudge); - SHCMP_SCALAR(SHDIFF_LD_WHICH, ld_which_t); - SHCMP_SCALAR(SHDIFF_LD_ABSORB, ld_absorb); -#undef SHCMP_ARRAY -#undef SHCMP_SCALAR - *index = -1; *nv = 0; *iv = 0; - return SHDIFF_NONE; -} - -/* Authoritative interpreter RECORD + side-effect-free native REPLAY. - * Memory reads are replayed and writes verified/no-op, the replay clock advances - * without servicing devices, and GTE visual/diagnostic sidecars are sandboxed. - * Thus RAM/MMIO/devices mutate exactly once while the comparison covers the full - * CPU architectural and timing state plus the ordered memory-operation trace. */ -static void run_shadow_diff(CPUState *cpu, Candidate *c, uint32_t addr) { - extern int dirty_ram_dispatch(CPUState *cpu, uint32_t addr, uint32_t stop_addr); - extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr); - extern int gte_replay_side_effects_begin(void); - extern void gte_replay_side_effects_end(void); - extern uint32_t g_debug_current_func_addr; - extern uint64_t psx_exception_setjmp_epoch(void); - extern int g_shadow_mmio_watch; - - s_in_shadow = 1; - s_shadow_epoch = psx_exception_setjmp_epoch(); - int saved_supp = s_suppress_irq; - int saved_native = s_native_exec; - s_suppress_irq = 1; - s_native_exec = 0; - s_shadow_saved_supp = saved_supp; - s_shadow_saved_native_exec = saved_native; - s_shadow_saved_mmio_watch = g_shadow_mmio_watch; - s_shadow_saved_exec_phase = g_exec_phase; - s_shadow_saved_call_bail = g_psx_call_bail; - s_shadow_saved_debug_func = g_debug_current_func_addr; - s_shadow_saved_irq_calls = s_irq_callcount; - s_shadow_saved_irq_supp = s_irq_suppressed; - s_shadow_scheduler_bail = 0; - - CPUState cpu0 = *cpu; - uint64_t cycle0 = psx_get_cycle_count(); - uint32_t stop_ra = cpu0.gpr[31]; - uint32_t record_ops = 0, replay_ops = 0; - int saw_exception = 0; - int icache_record_ok = psx_icache_shadow_record_begin(); - int record_owned = 0; - int record_ok = icache_record_ok && ls_shadow_record_begin(); - if (record_ok) { - record_owned = 1; - dirty_ram_dispatch(cpu, addr, stop_ra); - record_ok = ls_shadow_record_end(&record_ops, &saw_exception); - record_owned = 0; - } else { - dirty_ram_dispatch(cpu, addr, stop_ra); - } - s_shadow_calls++; - - CPUState cpuI = *cpu; - uint64_t cycleI = psx_get_cycle_count(); - if (!record_ok || saw_exception) { - s_shadow_skipped_dev++; - if (record_owned) ls_shadow_abort(); - psx_icache_shadow_abort(); - s_native_exec = saved_native; - s_suppress_irq = saved_supp; - s_in_shadow = 0; - return; - } - - CPUState cpuN = cpu0; - int trace_kind = 0; - uint32_t trace_pc = 0, trace_addr = 0, trace_expected = 0, trace_actual = 0; - int replay_owned = 0; - int replay_ok = ls_shadow_replay_begin(); - if (replay_ok) replay_owned = 1; - int icache_ok = replay_ok && psx_icache_shadow_replay_begin(); - int clock_ok = icache_ok && psx_cycle_replay_begin(cycle0); - int gte_ok = clock_ok && gte_replay_side_effects_begin(); - uint32_t saved_debug_func = g_debug_current_func_addr; - uint32_t saved_irq_callcount = s_irq_callcount; - uint64_t saved_irq_suppressed = s_irq_suppressed; - int saved_bail = g_psx_call_bail; - - if (gte_ok) { - s_shadow_cand = c; - { - int prev_phase = g_exec_phase; - OverlayFlushFn prev_flush = overlay_flush_enter(c); - g_exec_phase = 2; - c->fn(&cpuN); - overlay_flush_leave(prev_flush); - g_exec_phase = prev_phase; - } - { - int guard = 0; - while (cpuN.pc != 0 && !g_psx_call_bail && guard++ < 8192) { - uint32_t tv = cpuN.pc; - if ((tv & 0x1FFFFFFFu) == (stop_ra & 0x1FFFFFFFu)) break; - cpuN.pc = 0; - int prev_phase = g_exec_phase; - g_exec_phase = 3; - psx_dispatch_call(&cpuN, tv, stop_ra); - g_exec_phase = prev_phase; - } - } - s_shadow_cand = NULL; - } - int scheduler_bail = s_shadow_scheduler_bail; - - if (gte_ok) gte_replay_side_effects_end(); - uint64_t cycleN = clock_ok ? psx_cycle_replay_end() : cycle0; - psx_icache_shadow_replay_end(); - if (replay_ok) { - replay_ok = ls_shadow_replay_end(&replay_ops, &trace_kind, &trace_pc, - &trace_addr, &trace_expected, &trace_actual); - replay_owned = 0; - } else if (replay_owned) { - ls_shadow_abort(); - replay_owned = 0; - } - psx_icache_shadow_abort(); - g_debug_current_func_addr = saved_debug_func; - s_irq_callcount = saved_irq_callcount; - s_irq_suppressed = saved_irq_suppressed; - g_psx_call_bail = saved_bail; - - int index = -1; - uint32_t native_value = 0, interp_value = 0; - int kind = (!gte_ok || !replay_ok) ? SHDIFF_TRACE : - shadow_cpu_diff(&cpuN, &cpuI, &index, &native_value, &interp_value); - if (kind == SHDIFF_NONE && cycleN - cycle0 != cycleI - cycle0) { - kind = SHDIFF_CYCLE; - native_value = (uint32_t)(cycleN - cycle0); - interp_value = (uint32_t)(cycleI - cycle0); - } - if (kind == SHDIFF_NONE && scheduler_bail) kind = SHDIFF_SCHED; - - if (kind == SHDIFF_NONE) { - if (c->diff_passes < OVERLAY_DIFF_BUDGET) c->diff_passes++; - } else { - c->diff_passes = 0; - c->device_touch = 1; /* fail closed: all later calls stay interpreted */ - s_shadow_divs++; - if (scheduler_bail && c->state != ENTRY_BLACKLIST) { - /* The interpreter pass completed without switching threads, so a - * native-only scheduler attempt proves control-flow divergence. - * Permanently keep this shard off the live path. */ - c->state = ENTRY_BLACKLIST; - if (s_valid_count > 0) s_valid_count--; - loader_log("blacklist shadow scheduler divergence 0x%08X", c->addr); - } - if (!s_detail_captured) { - s_detail_captured = 1; - s_detail_addr = c->addr; - memcpy(s_detail_nat_gpr, cpuN.gpr, sizeof s_detail_nat_gpr); - memcpy(s_detail_int_gpr, cpuI.gpr, sizeof s_detail_int_gpr); - s_detail_nat_hi = cpuN.hi; s_detail_nat_lo = cpuN.lo; - s_detail_int_hi = cpuI.hi; s_detail_int_lo = cpuI.lo; - } - int addr_recs = 0; - for (int i = 0; i < s_sdiv_n; i++) - if (s_sdiv[i].addr == c->addr && ++addr_recs >= 16) break; - if (s_sdiv_n < SDIV_CAP && addr_recs < 16) { - ShadowDiv *d = &s_sdiv[s_sdiv_n++]; - memset(d, 0, sizeof *d); - d->seq = s_shadow_calls; d->addr = c->addr; - d->kind = kind; d->index = index; - d->cycle_native = cycleN - cycle0; - d->cycle_interp = cycleI - cycle0; - d->trace_ops = record_ops; d->replay_ops = replay_ops; - d->trace_kind = trace_kind; d->trace_pc = trace_pc; - d->trace_addr = trace_addr; d->trace_expected = trace_expected; - d->trace_actual = trace_actual; - d->reg = kind == SHDIFF_GPR ? index : -1; - d->reg_native = native_value; d->reg_interp = interp_value; - d->hi_diff = kind == SHDIFF_HI; d->lo_diff = kind == SHDIFF_LO; - d->ram_off = -1; - } - } - - s_native_exec = saved_native; - s_suppress_irq = saved_supp; - s_in_shadow = 0; -} - -/* A nonlocal scheduler bail during the speculative native replay pass (see - * overlay_loader_shadow_native_thread_switch_bail) means the interpreter pass - * — authoritative and already complete by the time the native replay runs — - * did NOT itself switch threads at this point, so a native-only switch attempt - * proves the native shard's control flow diverges. This is stronger than an - * ordinary register/RAM mismatch: permanently blacklist the shard rather than - * merely resetting its verify budget, mirroring the legacy comparator's - * handling of the same signal. */ -static void shadow_escape_cleanup(void) { - extern int g_shadow_mmio_watch; - if (s_shadow_cand != NULL) { - Candidate *escaped = (Candidate *)s_shadow_cand; - escaped->diff_passes = 0; - escaped->device_touch = 1; /* native escape is unverified: fail closed */ - } - { extern void gte_replay_side_effects_end(void); gte_replay_side_effects_end(); } - (void)psx_cycle_replay_end(); - psx_icache_shadow_abort(); - ls_shadow_abort(); - s_shadow_escapes++; - if (s_shadow_cand != NULL) { - s_shadow_escapes_native++; - gte_precision_speculative_end(); - } - /* The interpreter pass arms the MMIO detector around dirty dispatch. A - * nonlocal scheduler/exception escape can skip the matching decrement. */ - g_shadow_mmio_watch = s_shadow_saved_mmio_watch; - s_in_shadow = 0; - s_shadow_cand = NULL; - s_native_exec = s_shadow_saved_native_exec; - s_suppress_irq = s_shadow_saved_supp; - g_exec_phase = s_shadow_saved_exec_phase; - g_psx_call_bail = s_shadow_saved_call_bail; - { extern uint32_t g_debug_current_func_addr; - g_debug_current_func_addr = s_shadow_saved_debug_func; } - s_irq_callcount = s_shadow_saved_irq_calls; - s_irq_suppressed = s_shadow_saved_irq_supp; -} - -static void shadow_escape_flush_cycles(void) { - /* A host unwind can skip a candidate call's normal epilogue. Commit any - * cycles accumulated before the callback and clear the active DLL store - * hook so later static/interpreted writes cannot address stale context. */ - if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); - g_overlay_flush_pending_cycles = NULL; -} - -/* Called by deferred_exception_longjmp() (interrupts.c) before it unwinds. - * If the longjmp target frame predates a live shadow run (target_epoch <= - * s_shadow_epoch), the unwind blows through run_shadow_diff and its epilogue - * never runs — restore the shadow-scoped globals here and count the escape. - * The dispatch gate (no shadow start while in an exception dispatch) makes - * this structurally unreachable; if the counters ever move, that invariant is - * broken and must be investigated. An escape during the NATIVE pass - * (s_shadow_cand set) additionally means speculative native state leaked into - * the live timeline — counted separately; it must stay 0. A longjmp to a - * frame armed AFTER shadow start (target_epoch > s_shadow_epoch) is an - * exception contained inside the shadow: it lands inside the shadow frame and - * the shadow continues — the flags must NOT be touched. */ -void overlay_loader_shadow_escape_fixup(uint64_t target_epoch) { - shadow_escape_flush_cycles(); - if (!s_in_shadow) return; - if (target_epoch > s_shadow_epoch) return; /* contained: leave armed */ - shadow_escape_cleanup(); -} - -/* The deterministic HLE scheduler uses a different jmp_buf and therefore has - * no exception-setjmp epoch. Its landing calls this after every structured - * escape; a live shadow frame was necessarily abandoned in full. */ -void overlay_loader_shadow_scheduler_escape_fixup(void) { - shadow_escape_flush_cycles(); - if (s_in_shadow) shadow_escape_cleanup(); -} - -int overlay_loader_dump_shadow_detail(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, - "{\"captured\":%d,\"addr\":\"0x%08X\",\"regs\":[", s_detail_captured, s_detail_addr); - static const char *rn[32] = {"zero","at","v0","v1","a0","a1","a2","a3", - "t0","t1","t2","t3","t4","t5","t6","t7","s0","s1","s2","s3","s4","s5", - "s6","s7","t8","t9","k0","k1","gp","sp","fp","ra"}; - int first = 1; - for (int r = 0; r < 32; r++) { - if (s_detail_nat_gpr[r] == s_detail_int_gpr[r]) continue; - n += snprintf(out + n, cap - n, - "%s{\"r\":%d,\"name\":\"%s\",\"native\":\"0x%08X\",\"interp\":\"0x%08X\"}", - first ? "" : ",", r, rn[r], s_detail_nat_gpr[r], s_detail_int_gpr[r]); - first = 0; - } - n += snprintf(out + n, cap - n, "],\"hi\":{\"native\":\"0x%08X\",\"interp\":\"0x%08X\"}," - "\"lo\":{\"native\":\"0x%08X\",\"interp\":\"0x%08X\"}}", - s_detail_nat_hi, s_detail_int_hi, s_detail_nat_lo, s_detail_int_lo); - return n; -} - -int overlay_loader_dump_shadow(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, - "{\"diff_mode\":%d,\"shadow_calls\":%llu,\"divergences\":%llu," - "\"skipped_device\":%llu,\"interior_gated\":%llu," - "\"in_shadow\":%d,\"native_exec\":%d," - "\"escapes\":%u,\"escapes_native\":%u,\"records\":[", - s_diff_mode, (unsigned long long)s_shadow_calls, - (unsigned long long)s_shadow_divs, - (unsigned long long)s_shadow_skipped_dev, - (unsigned long long)s_diffgate_interp, - s_in_shadow, s_native_exec, - s_shadow_escapes, s_shadow_escapes_native); - for (int i = 0; i < s_sdiv_n && n < cap - 200; i++) { - ShadowDiv *d = &s_sdiv[i]; - n += snprintf(out + n, cap - n, - "%s{\"seq\":%llu,\"addr\":\"0x%08X\",\"kind\":%d,\"index\":%d,\"reg\":%d," - "\"reg_native\":\"0x%08X\",\"reg_interp\":\"0x%08X\"," - "\"hi\":%d,\"lo\":%d,\"ram_off\":%lld," - "\"ram_native\":\"0x%08X\",\"ram_interp\":\"0x%08X\"," - "\"cycle_native\":%llu,\"cycle_interp\":%llu," - "\"trace_ops\":%u,\"replay_ops\":%u,\"trace_kind\":%d," - "\"trace_pc\":\"0x%08X\",\"trace_addr\":\"0x%08X\"," - "\"trace_expected\":\"0x%08X\",\"trace_actual\":\"0x%08X\"}", - i ? "," : "", (unsigned long long)d->seq, d->addr, - d->kind, d->index, d->reg, - d->reg_native, d->reg_interp, d->hi_diff, d->lo_diff, - (long long)d->ram_off, d->ram_native, d->ram_interp, - (unsigned long long)d->cycle_native, - (unsigned long long)d->cycle_interp, - d->trace_ops, d->replay_ops, d->trace_kind, - d->trace_pc, d->trace_addr, d->trace_expected, d->trace_actual); - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -/* Fingerprint over the general registers (r1..r31) + hi/lo. r0 excluded - * (always 0). pc excluded (the return target is trivially equal at exit). */ -uint32_t overlay_regs_crc(const CPUState *cpu) { - uint32_t crc = 0xFFFFFFFFu; - crc = crc32_update(crc, (const uint8_t *)&cpu->gpr[1], sizeof(uint32_t) * 31); - crc = crc32_update(crc, (const uint8_t *)&cpu->hi, sizeof(uint32_t)); - crc = crc32_update(crc, (const uint8_t *)&cpu->lo, sizeof(uint32_t)); - return crc ^ 0xFFFFFFFFu; -} - -/* Snapshot r0..r31 + hi/lo into a 34-word buffer (entry-state capture). */ -void overlay_regs_snap(uint32_t out[34], const CPUState *cpu) { - memcpy(out, cpu->gpr, sizeof(uint32_t) * 32); - out[32] = cpu->hi; - out[33] = cpu->lo; -} - -/* CRC over words 1..33 (r0 excluded — always 0), matching overlay_regs_crc. */ -static uint32_t regs34_crc(const uint32_t *r) { - uint32_t crc = 0xFFFFFFFFu; - crc = crc32_update(crc, (const uint8_t *)&r[1], sizeof(uint32_t) * 33); - return crc ^ 0xFFFFFFFFu; -} - -void overlay_fp_log(uint32_t addr, const uint32_t *in_regs, - const CPUState *cpu, int native) { - extern uint64_t psx_get_cycle_count(void); - uint64_t s = s_fp_seq++; - FpEnt *e = &s_fp[s & (FP_CAP - 1u)]; - e->seq = s; e->cycle = psx_get_cycle_count(); - e->addr = addr & 0x1FFFFFFFu; e->native = native; - memcpy(e->in_regs, in_regs, sizeof(e->in_regs)); - overlay_regs_snap(e->out_regs, cpu); - e->in_crc = regs34_crc(e->in_regs); - e->out_crc = regs34_crc(e->out_regs); -} - -/* Execute `addr` natively if a validated overlay candidate exists, keeping the - * §5-E fingerprint record (same as the dirty_ram_dispatch chokepoint). Called - * from the dirty-RAM interpreter's jal/jalr handlers so native overlay callees - * get the SAME call contract as statically-compiled callees: execute as a - * unit, then the interpreter resumes at the call's return address. Without - * this, the call surfaces to the dispatch loop as a bare pc value; the native - * callee's C-style return (pc==0) then unwinds the loop past the suspended - * caller continuation — the caller's epilogue never runs and its stack frame - * leaks (root cause of the dwarf->overworld native blue screen). - * Returns 1 iff a native candidate ran. */ -int overlay_loader_call_native(CPUState *cpu, uint32_t addr) { - if (!s_active || !s_native_exec) - return 0; /* inactive/interp mode: keep the legacy inline path */ - uint32_t phys = addr & 0x1FFFFFFFu; - if (idx_head(phys) < 0 && !lazy_has_exact_entry(phys)) - return 0; /* neither a registered nor an exact cached entry */ - uint32_t in_regs[34]; - int fp = overlay_fp_enabled(); - if (fp) overlay_regs_snap(in_regs, cpu); - /* Run the callee as an atomic UNIT: IRQ delivery is deferred inside it (both - * the overlay CI wrappers and the dirty IRQ pumps check g_call_unit_depth) so - * a cooperative ChangeThread cannot suspend the interrupted thread mid-callee - * with an inconsistent (resume_pc, sp) snapshot. Restore (not just decrement) - * so a bail/longjmp-out unwinds the depth correctly; the scheduler landing - * also resets it to 0 as a backstop. Gated by the A/B toggle. */ - int prev_unit_depth = g_call_unit_depth; - if (overlay_unit_defer_enabled()) g_call_unit_depth = prev_unit_depth + 1; - int ran = overlay_loader_dispatch(cpu, addr); - g_call_unit_depth = prev_unit_depth; - if (!ran) return 0; - if (fp) overlay_fp_log(addr, in_regs, cpu, 1); - return 1; -} - -/* (sljit removed 2026-07-15: the JIT-shard host helpers psx_sljit_call / - * psx_sljit_cop2 / psx_sljit_memx lived here. They were called only by sljit- - * emitted shards via the cpu->sljit_helpers table, which is no longer wired.) */ - -/* Write the whole fingerprint log to a file (no TCP size limit). Returns the - * number of entries written, or -1 on open failure. */ -int overlay_loader_write_fp_file(const char *path) { - FILE *f = fopen(path, "w"); - if (!f) return -1; - uint64_t total = s_fp_seq; - uint64_t start = (total > FP_CAP) ? (total - FP_CAP) : 0; - fputc('[', f); - int first = 1, count = 0; - for (uint64_t s = start; s < total; s++) { - FpEnt *e = &s_fp[s & (FP_CAP - 1u)]; - fprintf(f, - "%s{\"seq\":%llu,\"cycle\":%llu,\"addr\":\"0x%08X\",\"in\":\"0x%08X\"," - "\"out\":\"0x%08X\",\"native\":%d,\"in_regs\":[", - first ? "" : ",\n", (unsigned long long)e->seq, - (unsigned long long)e->cycle, e->addr, - e->in_crc, e->out_crc, e->native); - for (int r = 0; r < 34; r++) - fprintf(f, "%s\"0x%08X\"", r ? "," : "", e->in_regs[r]); - fputs("],\"out_regs\":[", f); - for (int r = 0; r < 34; r++) - fprintf(f, "%s\"0x%08X\"", r ? "," : "", e->out_regs[r]); - fputs("]}", f); - first = 0; count++; - } - fputs("]\n", f); - fclose(f); - return count; -} - -/* Dump the native-call ring (most-recent first) + the in-progress entry. The - * in_progress field being nonzero means a native function was entered and never - * returned — a freeze INSIDE native code, pointing straight at the suspect. */ -int overlay_loader_dump_native_ring(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, - "{\"native_exec\":%d,\"calls_total\":%llu,\"would_run\":%llu," - "\"in_progress\":\"0x%08X\",\"recent\":[", - s_native_exec, (unsigned long long)s_native_calls_total, - (unsigned long long)s_would_run_native, s_native_inprogress); - /* Walk backward from the most recent entries kept in the diagnostic ring. */ - int shown = 0; - for (uint32_t k = 0; k < NRING_CAP && n < cap - 140; k++) { - uint32_t idx = (s_nring_pos - 1u - k) & (NRING_CAP - 1u); - if (s_nring[idx].seq == 0) break; - n += snprintf(out + n, cap - n, - "%s{\"addr\":\"0x%08X\",\"crc\":\"0x%08X\",\"frame\":%u,\"seq\":%llu,\"returned\":%d}", - shown ? "," : "", s_nring[idx].addr, s_nring[idx].crc, s_nring[idx].frame, - (unsigned long long)s_nring[idx].seq, s_nring[idx].returned); - shown++; - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -/* Diagnostic: dump every candidate with its stored vs live hash and generation - * state, so reload behaviour can be inspected directly (Rule 3 — visibility via - * the debug server, not logs). Writes a JSON array into `out`; returns bytes - * written. */ -int overlay_loader_dump_candidates(char *out, int cap) { - int n = 0; - n += snprintf(out + n, cap - n, "["); - for (int i = 0; i < s_cand_n && n < cap - 160; i++) { - Candidate *c = &s_cand[i]; - uint32_t live = cand_crc(c); - uint32_t sum = cand_gensum(c); - n += snprintf(out + n, cap - n, - "%s{\"addr\":\"0x%08X\",\"state\":%d,\"nranges\":%d," - "\"crc\":\"0x%08X\",\"live\":\"0x%08X\",\"match\":%d," - "\"val_gen\":%u,\"gen\":%u,\"dll\":%d,\"diff_passes\":%u}", - i ? "," : "", c->addr, c->state, c->nranges, - c->crc_code, live, (live == c->crc_code) ? 1 : 0, - c->val_gen, sum, c->dll, c->diff_passes); - } - n += snprintf(out + n, cap - n, "]"); - return n; -} - -/* Focused form for live miss diagnosis. The full candidate table can exceed the - * debug command's response buffer once a game has accumulated many variants; - * filtering by entry keeps every candidate at a reused PC visible. */ -int overlay_loader_dump_candidates_at(uint32_t addr, char *out, int cap) { - uint32_t phys = addr & 0x1FFFFFFFu; - int n = 0; - int first = 1; - n += snprintf(out + n, cap - n, "["); - for (int i = 0; i < s_cand_n && n < cap - 180; i++) { - Candidate *c = &s_cand[i]; - if (c->addr != phys) continue; - uint32_t live = cand_crc(c); - uint32_t sum = cand_gensum(c); - n += snprintf(out + n, cap - n, - "%s{\"index\":%d,\"addr\":\"0x%08X\",\"state\":%d,\"nranges\":%d," - "\"crc\":\"0x%08X\",\"live\":\"0x%08X\",\"match\":%d," - "\"val_gen\":%u,\"gen\":%u,\"dll\":%d,\"diff_passes\":%u," - "\"device_touch\":%d}", - first ? "" : ",", i, c->addr, c->state, c->nranges, - c->crc_code, live, (live == c->crc_code) ? 1 : 0, - c->val_gen, sum, c->dll, c->diff_passes, c->device_touch); - first = 0; - } - n += snprintf(out + n, cap - n, "]"); - return n; -} - -/* Focused view of manifest-only candidates that have not been loaded yet. */ -int overlay_loader_dump_lazy_at(uint32_t addr, char *out, int cap) { - uint32_t phys = addr & 0x1FFFFFFFu; - uint32_t pg = phys >> 12; - uint32_t kern_pg = DIRTY_RAM_KERNEL_WINDOW_END >> 12; - uint32_t ovl_pg = OVERLAY_REGION_FLOOR >> 12; - uint32_t floor_pg = pg < kern_pg ? 0u : (pg < ovl_pg ? kern_pg : ovl_pg); - extern uint32_t dirty_ram_get_bitmap_word(uint32_t word_index); - while (pg > floor_pg) { - uint32_t pp = pg - 1; - if (!((dirty_ram_get_bitmap_word(pp >> 5) >> (pp & 31u)) & 1u)) break; - pg = pp; - } - uint32_t recovered = pg << 12; - uint32_t bucket = (phys * 2654435761u) & LAZY_ENTRY_MASK; - int n = snprintf(out, cap, "{\"recovered_region\":\"0x%08X\",\"entries\":[", - recovered); - int first = 1; - for (int li = s_lazy_entry_head[bucket]; li >= 0 && n < cap - 320; - li = s_lazy_man[li].next_entry) { - LazyMan *lm = &s_lazy_man[li]; - if ((lm->fn.entry & 0x1FFFFFFFu) != phys) continue; - int ci = lm->cache_idx; - const char *base = strrchr(s_cache_idx[ci].path, '/'); - base = base ? base + 1 : s_cache_idx[ci].path; - uint32_t live = lazy_man_crc(&lm->fn); - n += snprintf(out + n, cap - n, - "%s{\"li\":%d,\"ci\":%d,\"region\":\"0x%08X\"," - "\"file\":\"%s\",\"funcs\":%d,\"indexed\":%d," - "\"loaded\":%d,\"crc\":\"0x%08X\",\"live\":\"0x%08X\"," - "\"match\":%d,\"contains\":%d}", - first ? "" : ",", li, ci, s_cache_idx[ci].region_start, - base, s_cache_idx[ci].func_count, s_cache_idx[ci].indexed_func_count, - dll_already_loaded(s_cache_idx[ci].path), lm->fn.crc, live, - live == lm->fn.crc, lazy_man_contains(&lm->fn, phys)); - first = 0; - } - n += snprintf(out + n, cap - n, "]}"); - return n; -} - -void overlay_loader_get_status(int *active, int *registered, - int *regions_checked, - char *cache_dir_out, int cache_dir_len, - char *game_id_out, int game_id_len, - uint32_t *checked_out, int checked_max, - int *checked_written, - uint32_t *last_crc_out, int *last_file_found_out) { - if (active) *active = s_active; - if (registered) *registered = s_valid_count; - if (regions_checked) *regions_checked = s_nchecked; - if (cache_dir_out) strncpy(cache_dir_out, s_cache_dir, (size_t)cache_dir_len - 1); - if (game_id_out) strncpy(game_id_out, s_game_id, (size_t)game_id_len - 1); - if (checked_out && checked_written) { - int n = s_nchecked < checked_max ? s_nchecked : checked_max; - for (int i = 0; i < n; i++) checked_out[i] = s_checked[i]; - *checked_written = n; - } - if (last_crc_out) *last_crc_out = s_last_crc; - if (last_file_found_out) *last_file_found_out = s_last_file_found; -} diff --git a/psxrecomp-overlay/runtime/src/psx_cycles.c b/psxrecomp-overlay/runtime/src/psx_cycles.c deleted file mode 100644 index dfd3b33..0000000 --- a/psxrecomp-overlay/runtime/src/psx_cycles.c +++ /dev/null @@ -1,684 +0,0 @@ -/* psx_cycles.c — PSX guest CPU cycle clock. */ - -#include "psx_cycles.h" -#include "cpu_state.h" -#include -#if defined(_MSC_VER) -#include /* MSVC intrinsics: _BitScanReverse (no __builtin_clz) */ -#endif -#include "cdrom.h" -#include "dma.h" -#include "interrupts.h" -#include "sio.h" -#include "starvation_ring.h" -#include "timers.h" -#ifdef PSX_COSIM -#include "cosim_state.h" -#endif - -uint64_t psx_cycle_count = 0; -uint32_t g_psx_cyc_batch = 0; -uint32_t g_psx_cyc_batch_limit = 0; -int g_psx_cyc_bb_defer = 0; -uint32_t g_psx_cyc_overclock_shift = 0; -uint32_t g_psx_cyc_overclock_carry = 0; -static int s_cycle_replay_active = 0; -static uint64_t s_cycle_replay_live = 0; - -int psx_cycle_replay_begin(uint64_t start_cycle) { - extern int g_ls_replay_active; - if (!g_ls_replay_active || s_cycle_replay_active) return 0; - s_cycle_replay_live = psx_cycle_count; - psx_cycle_count = start_cycle; - s_cycle_replay_active = 1; - return 1; -} - -uint64_t psx_cycle_replay_end(void) { - uint64_t replay_cycle = psx_cycle_count; - if (s_cycle_replay_active) { - psx_cycle_count = s_cycle_replay_live; - s_cycle_replay_active = 0; - } - return replay_cycle; -} - -/* Throttle watchdog check to once per ~64K cycles (header hot path). */ -uint32_t psx_watchdog_throttle = 0; -uint32_t psx_pc_sample_throttle = 0; - -/* Conservative event-granularity diagnostic (set via debug cmd - * overlay_native_event_granularity). Normally psx_advance_cycles charges a - * whole basic block's cycles in ONE step, so every device advances N cycles at - * once and any events that came due at sub-block cycles all fire together, in - * the fixed device order below (sio,cdrom,dma,timers,interrupts) — NOT in true - * due-cycle order. The dirty-RAM interpreter avoids this only because it calls - * us with N=1 per instruction. When this flag is set, a batched (N>1) advance - * is split into N single-cycle steps, so device events fire at their true - * due-cycle in order — i.e. native execution gets the same event timeline the - * interpreter produces. Diagnostic: if the village->overworld blue screen - * clears with this on, the root cause is per-block event-ordering, and the - * real fix is a due-cycle event scheduler (run-to-next-event), not this. */ -int g_event_step_conservative = 0; - -/* Env opt-in (PSX_EVENT_STEP_CONSERVATIVE=1) so the diagnostic is reachable in - * production builds too, where the TCP setter doesn't exist — the determinism - * A/B ("do the fast-limit and conservative paths present the same guest event - * timeline?") needs the production binary to run BOTH ways. */ -void psx_event_step_conservative_env_init(void) { - const char *e = getenv("PSX_EVENT_STEP_CONSERVATIVE"); - if (e && e[0] == '1') g_event_step_conservative = 1; -} - -static void advance_devices(uint32_t c) { - psx_cycle_count += (uint64_t)c; - sio_advance(c); - cdrom_advance(c); - dma_advance(c); - timers_advance(c); - interrupts_advance_cycles(c); -} - -/* ===== Event-deadline device servicing (production fast path) ================= - * - * The faithful core charges guest cycles PER INSTRUCTION (psx_cyc.h §1), and the - * legacy path below walked EVERY device on EVERY charge — ~10 host calls per - * guest instruction, ~300M calls/s: the whole prod build pegged a core inside - * psx_advance_cycles (gdb-sampled 14/14, leaves = timers/cdrom/dma/interrupts). - * - * FAITHFUL_TIMING_PLAN.md's target architecture is the fix: cycles accumulate - * into the ONE global counter cheaply; devices are only SERVICED when the next - * event deadline is reached (cycles_to_next_event(): min over vblank / timer / - * cdrom / dma / sio IRQ distances), or when guest code touches device MMIO - * (memory.c calls psx_devices_mmio_sync() so reads see current state and writes - * that re-arm a device force a deadline recompute). - * - * Guest-visible semantics are IDENTICAL: servicing rewinds psx_cycle_count to - * the devices' synced position and re-plays the gap through the SAME - * vblank-bounded chunk loop the legacy path used, so device code observes the - * exact same cycle values it always did, and no event can fire late because the - * deadline is by construction <= the earliest device event (hard-capped at - * DEADLINE_HARD_CAP for IRQ-masked device progress + frame pacing). - * - * The exact per-charge path is kept verbatim for PSX_COSIM builds (oracle - * determinism / checkpoint alignment) and for the g_event_step_conservative - * diagnostic toggle. */ -#define PSX_DEADLINE_HARD_CAP 16384u - -static uint64_t s_devices_synced_cycle = 0; /* devices are advanced up to here */ -/* Exported for the inlined psx_advance_cycles / psx_cyc_charge hot path. */ -uint64_t psx_next_service_cycle = 0; /* absolute; 0 = dirty, recompute */ -int psx_in_device_service = 0; /* re-entrancy guard */ -#define s_next_service_cycle psx_next_service_cycle -#define s_in_device_service psx_in_device_service -static uint64_t s_next_watchdog = 0; -static uint64_t s_next_pc_sample = 0; - -/* Absolute inclusive limit for dirty_ram_interp.c's exact one-cycle path. - * Zero means that the next charge must visit psx_advance_cycles(). */ -uint64_t g_psx_cycle_fast_limit = 0; - -/* Distance to the nearest INTERNAL device event, mask-blind (i_mask = - * all-unmasked). This is the chunking bound for catch-up: the sio/cdrom/dma - * *_advance() functions fire at most ONE event boundary per call (their - * legacy caller stepped 1 cycle at a time), so every catch-up chunk must land - * exactly ON the nearest event so chained sequences (SIO byte -> ack -> next - * byte; CD sector trains) replay event-by-event, and so events armed while - * their IRQ is masked in I_MASK still process on time (games poll I_STAT). */ -static uint32_t devices_cycles_to_next_internal_event(void) { - uint32_t best = interrupts_cycles_to_vblank(); /* frame pacing always */ - uint32_t t = timers_cycles_to_irq(0xFFFFFFFFu); if (t < best) best = t; - uint32_t c = cdrom_cycles_to_irq(0xFFFFFFFFu); if (c < best) best = c; - uint32_t d = dma_cycles_to_internal_event(); if (d < best) best = d; - uint32_t s = sio_cycles_to_irq(0xFFFFFFFFu); if (s < best) best = s; - if (best == 0) best = 1; /* due/overdue: process within one cycle */ - return best; -} - -/* Wait-loop observation boundary. Device catch-up remains mask-blind and - * replays every intermediate timer/DMA/SIO transition, but a CPU loop with no - * stores or MMIO cannot observe a masked IRQ until it becomes deliverable. - * Stopping at every masked timer target turned a safe VBlank/CD wait skip into - * tens of thousands of host-side re-detections per second. VBlank remains an - * unconditional ceiling so frame pacing is never crossed in one jump. */ -static uint32_t devices_cycles_to_next_idle_event(void) { - extern uint32_t i_mask; - uint32_t best = interrupts_cycles_to_vblank(); - uint32_t t = timers_cycles_to_irq(i_mask); if (t < best) best = t; - uint32_t c = cdrom_cycles_to_irq(i_mask); if (c < best) best = c; - uint32_t d = dma_cycles_to_deliverable_irq(i_mask); if (d < best) best = d; - uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s; - if (best == 0) best = 1; - return best; -} - -static void psx_devices_recompute_deadline(void) { - uint32_t next = devices_cycles_to_next_internal_event(); - if (next > PSX_DEADLINE_HARD_CAP) next = PSX_DEADLINE_HARD_CAP; - psx_next_service_cycle = psx_cycle_count + (uint64_t)next; -} - -void psx_devices_service_to_now(void) { - if (s_in_device_service) return; /* device code charged cycles: absorb */ - g_psx_cycle_fast_limit = 0; - s_in_device_service = 1; - uint64_t target = psx_cycle_count; - if (s_devices_synced_cycle < target) { - /* Rewind and re-play the gap in event-bounded chunks so device - * callbacks see the same incremental psx_cycle_count they always did - * and no chunk ever skips OVER a device event boundary. */ - psx_cycle_count = s_devices_synced_cycle; - interrupts_service_scheduled_events(); - while (psx_cycle_count < target) { - uint32_t remaining = (uint32_t)(target - psx_cycle_count); - uint32_t step = remaining; - uint32_t to_ev = devices_cycles_to_next_internal_event(); - if (to_ev > 0 && to_ev < step) step = to_ev; - if (step == 0) step = 1; - /* Preserve interval causality at an advertised event boundary. - * Devices are advanced in a fixed dependency order (CD before DMA, - * MDEC input before output). Passing the whole D-cycle interval to - * each device lets a state created by an earlier device at cycle D - * be treated by a later device as though it existed for all D - * cycles. Advance the quiet prefix first, then the boundary cycle: - * this is exactly the conservative timeline with O(events), not - * O(cycles), host calls. */ - if (step > 1u && to_ev == step) { - advance_devices(step - 1u); - step = 1u; - } - advance_devices(step); - interrupts_service_scheduled_events(); - } - s_devices_synced_cycle = target; - } - psx_devices_recompute_deadline(); - psx_in_device_service = 0; - - /* Sparse host maintenance (was on every psx_advance_cycles). HARD_CAP - * guarantees we land here at least every 16K guest cycles. */ - if (target >= s_next_watchdog) { - s_next_watchdog = target + 65536ull; - psx_cycles_watchdog_fire(); - } - if (target >= s_next_pc_sample) { - s_next_pc_sample = target + 1048576ull; - psx_cycles_pc_sample_fire(); - } -} - -/* memory.c hook: called at the top of every device-MMIO read/write wrapper. - * Catch devices up so the handler sees current state, then recompute the - * deadline immediately (a write may re-arm a shorter event). Previously this - * set psx_next_service_cycle=0 ("dirty"), forcing the *next* per-instruction - * advance through service_to_now even when already synced — MotK FMV pays - * that on every GPU/CD/MDEC MMIO touch. Recompute here instead. */ -void psx_devices_mmio_sync(void) { - psx_cyc_batch_flush(); - if (s_devices_synced_cycle != psx_cycle_count) { - psx_devices_service_to_now(); - } else { - psx_devices_recompute_deadline(); - } -} - -/* Exact per-charge path (legacy semantics). Used by PSX_COSIM builds and the - * g_event_step_conservative diagnostic; also keeps the deadline-path state - * coherent so the two can interleave (the runtime toggle flips mid-run). */ -static void psx_advance_cycles_exact(uint32_t cycles) { -#ifdef PSX_COSIM - /* First-divergence oracle: the guest-cycle counter is the ONLY clock both backends - * share identically, so it is the alignment point for the full-state hash. */ - cosim_tick(); -#endif - interrupts_service_scheduled_events(); - while (cycles > 0) { - uint32_t step = cycles; - if (g_event_step_conservative && step > 1u) { - step = 1u; - } else { - uint32_t to_vblank = interrupts_cycles_to_vblank(); - if (to_vblank > 0 && to_vblank < step) step = to_vblank; -#ifdef PSX_COSIM - uint32_t to_cp = cosim_cycles_to_next_checkpoint(); - if (to_cp > 0 && to_cp < step) step = to_cp; -#endif - } - if (step == 0) step = cycles; - advance_devices(step); - cycles -= step; - interrupts_service_scheduled_events(); -#ifdef PSX_COSIM - cosim_tick(); -#endif - } - s_devices_synced_cycle = psx_cycle_count; - psx_next_service_cycle = 0; -} - -void psx_cycles_watchdog_fire(void) { - starvation_watchdog_check(); -} - -void psx_cycles_pc_sample_fire(void) { - starvation_ring_pc_sample(); -} - -/* Slow path for the inlined psx_advance_cycles (COSIM / lockstep / conservative). */ -void psx_advance_cycles_slow(uint32_t cycles) { - { extern int g_ls_replay_active; - if (g_ls_replay_active) { - /* Replay owns a private-in-time view of the global clock. Advance it - * so GTE/muldiv deadlines and memory wait states see the same time as - * the authoritative pass, but never service devices or observers. */ - if (s_cycle_replay_active) psx_cycle_count += (uint64_t)cycles; - return; - } - } - if (cycles == 0) return; -#ifdef PSX_COSIM - psx_advance_cycles_exact(cycles); -#else - if (g_event_step_conservative) { - psx_advance_cycles_exact(cycles); - } else if (psx_in_device_service) { - psx_cycle_count += (uint64_t)cycles; - } else { - psx_cycle_count += (uint64_t)cycles; - if (psx_next_service_cycle == 0 || psx_cycle_count >= psx_next_service_cycle) { - psx_devices_service_to_now(); - } - } -#endif -#if STARVATION_RING_ENABLED - psx_watchdog_throttle += cycles; - if (psx_watchdog_throttle >= 65536u) { - psx_watchdog_throttle = 0; - starvation_watchdog_check(); - } - psx_pc_sample_throttle += cycles; - if (psx_pc_sample_throttle >= 1048576u) { - psx_pc_sample_throttle = 0; - psx_cycles_pc_sample_fire(); - } -#endif -} - -uint64_t psx_get_cycle_count(void) { - return psx_cycle_count + (uint64_t)g_psx_cyc_batch; -} - -/* ===== Idle-loop cycle skip (wait-loop elision, 2026-07-06) ================== - * - * A pure poll loop (Tomba2 main loop: `do {} while (vbl_count < target)`) - * burns its full guest-cycle budget at real emulation cost even though every - * iteration is a provable no-op: no stores, no MMIO, and register state at - * the loop's interrupt-check boundary identical each time. Nothing such a - * loop can observe changes except via device servicing, which happens at - * deterministic cycle deadlines. So time is fast-forwarded to the next - * INTERNAL device event (mask-blind min over vblank pacing / timers / cdrom / - * dma / sio — the full set of async guest-visible mutators) in whole loop - * quanta, bit-exact vs executing the iterations: - * - real execution takes a deliverable IRQ at the first check boundary at - * or after the event cycle; the skip lands on exactly that boundary - * (k = ceil(dist / quantum) whole iterations). - * - device RAM writes (DMA) replay at their exact cycles inside - * psx_advance_cycles' deadline-servicing path either way. - * - if the event does not release the loop, the detector re-arms and - * skips to the next internal event. - * - * Detection is evidence-based; ALL gates must hold across IDLE_STREAK_MIN - * consecutive interrupt checks at the SAME resume PC with a STABLE quantum: - * 1. zero guest stores (g_guest_store_count unchanged — covers RAM, - * scratchpad, MMIO and DMA writes; memory.c chokepoints) - * 2. zero MMIO accesses (g_mmio_access_count unchanged — an MMIO read can - * be side-effecting or time-varying, strictly disqualifying. I_STAT- - * polling kernel loops therefore never skip; whitelisting side-effect- - * free I_STAT reads is a possible later refinement.) - * 3. identical GPR/hi/lo fingerprint at the check boundary — rules out - * progressing pure-load loops (strlen/memcmp-style scans) whose exit - * depends on register progress, not time. (COP0/GTE-held loop state is - * not fingerprinted; no realistic poll loop keys its exit on those.) - * - * Excluded contexts: exception delivery, bail unwinds, precise slicing, - * lockstep record/replay, and PSX_COSIM builds entirely (the cosim oracle - * compares per-instruction streams; elided iterations would false-diverge). - * - * Opt in: game config, PSX_IDLE_SKIP=1 (env), or the `idle_skip` TCP command. - * The always-on counters below are the observability surface. */ - -enum { - IDLE_STREAK_MIN = 4, /* stable same-PC windows before a skip */ - IDLE_QUANTUM_MAX = 32768, /* interp pump gap fits; junk deltas don't */ - IDLE_SKIP_MAX_CYCLES = 1200000 /* defensive cap (> one vblank interval) */ -}; - -int g_idle_skip_enabled = -1; /* -1 = read PSX_IDLE_SKIP once; TCP-togglable */ -uint64_t g_idle_skip_count = 0; /* skips performed */ -uint64_t g_idle_skip_cycles = 0; /* guest cycles fast-forwarded */ -uint32_t g_idle_skip_last_pc = 0; /* loop PC of the last skip */ -uint32_t g_idle_skip_last_quantum = 0; -/* Overlay CPS code reports several interrupt-safe PCs per logical loop - * iteration. The loader suppresses internal/return observations so they do not - * erase evidence collected at the repeated external poll boundary; interrupt - * delivery still runs normally. */ -int g_idle_note_suppress = 0; - -static uint32_t s_idle_pc = 0; -static uint32_t s_idle_quantum = 0; -static uint32_t s_idle_streak = 0; -static int s_idle_have_snap = 0; /* GPR snapshot valid for s_idle_pc */ -static uint32_t s_idle_gpr[32]; -static uint32_t s_idle_hi = 0, s_idle_lo = 0; -static int s_idle_progress_reg = -2; /* -2 unknown, -1 stable, 1..31 countdown */ -static int32_t s_idle_progress_delta = 0; -static uint64_t s_idle_last_cycle = 0; -static uint64_t s_idle_last_stores = 0; -static uint64_t s_idle_last_mmio = 0; - -static void idle_snapshot_regs(const CPUState *cpu) { - for (int i = 1; i < 32; i++) s_idle_gpr[i] = cpu->gpr[i]; - s_idle_hi = cpu->hi; - s_idle_lo = cpu->lo; -} - -static int idle_skip_on(void) { - if (g_idle_skip_enabled < 0) { - /* No game config reached this process (for example a BIOS-only - * runtime). Keep the enhancement inert unless the environment opts in; - * normal game launches set g_idle_skip_enabled from RuntimeConfig. */ - const char *e = getenv("PSX_IDLE_SKIP"); - g_idle_skip_enabled = e ? (e[0] == '1') : 0; - } - return g_idle_skip_enabled; -} - -int psx_idle_skip_is_enabled(void) { return idle_skip_on(); } - -void psx_idle_note_check(CPUState *cpu, uint32_t check_pc) { -#ifdef PSX_COSIM - (void)cpu; (void)check_pc; - return; -#else - extern int g_psx_call_bail, g_precise_mode, g_ls_mode, g_ls_replay_active; - extern int psx_get_in_exception(void); - extern uint64_t g_guest_store_count, g_mmio_access_count; - - /* A speculative replay must not clear or train the authoritative live idle - * detector. Its clock/device view is transactional and cannot be skipped. */ - if (g_ls_replay_active) return; - if (!idle_skip_on() || g_idle_note_suppress) return; - if (check_pc == 0 || psx_get_in_exception() || g_psx_call_bail || - g_precise_mode || g_ls_mode != 0) { - s_idle_pc = 0; - s_idle_streak = 0; - s_idle_have_snap = 0; - return; - } - - uint64_t cyc = psx_cycle_count; - if (check_pc == s_idle_pc && s_idle_have_snap && - g_guest_store_count == s_idle_last_stores && - g_mmio_access_count == s_idle_last_mmio) { - int changed = -1, changed_count = 0; - for (int i = 1; i < 32; i++) { - if (cpu->gpr[i] != s_idle_gpr[i]) { - changed = i; - changed_count++; - if (changed_count > 1) break; - } - } - if (cpu->hi != s_idle_hi || cpu->lo != s_idle_lo) changed_count = 2; - int32_t progress_delta = changed_count == 1 - ? (int32_t)(cpu->gpr[changed] - s_idle_gpr[changed]) : 0; - /* Besides the original invariant-register loop, accept exactly one - * monotonically decrementing timeout register. Skipped iterations apply - * the same decrements and stop before zero so the real exit branch still - * executes. No stores/MMIO are allowed, so there is no hidden work. */ - int progress_reg = changed_count == 0 ? -1 - : (changed_count == 1 && progress_delta == -1 ? changed : -2); - uint32_t quantum = (uint32_t)(cyc - s_idle_last_cycle); - if (progress_reg != -2 && quantum > 0 && quantum <= IDLE_QUANTUM_MAX) { - if (quantum == s_idle_quantum && - progress_reg == s_idle_progress_reg && - progress_delta == s_idle_progress_delta) { - if (s_idle_streak < 1000000u) s_idle_streak++; - } else { - s_idle_quantum = quantum; - s_idle_progress_reg = progress_reg; - s_idle_progress_delta = progress_delta; - s_idle_streak = 1; - } - } else { - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; - } - idle_snapshot_regs(cpu); - } else if (check_pc == s_idle_pc && - g_guest_store_count == s_idle_last_stores && - g_mmio_access_count == s_idle_last_mmio) { - /* Second consecutive observation of this PC with no stores/MMIO — - * take the baseline snapshot; the next hit can compare. Defers the - * 31-GPR copy away from branchy VLC/decode edges that change resume - * PC every check (pure host cost, never skipped). */ - idle_snapshot_regs(cpu); - s_idle_have_snap = 1; - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; - } else { - s_idle_pc = check_pc; - s_idle_streak = 0; - s_idle_quantum = 0; - s_idle_progress_reg = -2; - s_idle_progress_delta = 0; - s_idle_have_snap = 0; - } - s_idle_last_cycle = cyc; - s_idle_last_stores = g_guest_store_count; - s_idle_last_mmio = g_mmio_access_count; - - if (s_idle_streak < IDLE_STREAK_MIN) return; - uint32_t q = s_idle_quantum; - uint32_t dist = devices_cycles_to_next_idle_event(); - if (dist <= q) return; /* one real iteration reaches it */ - uint32_t k = (dist + q - 1u) / q; /* first check boundary >= event */ - if (s_idle_progress_reg > 0) { - uint32_t value = cpu->gpr[s_idle_progress_reg]; - uint32_t max_k = value > 1u ? value - 1u : 0u; - if (k > max_k) k = max_k; - if (k == 0) return; - } - uint64_t skip = (uint64_t)k * q; - if (skip > IDLE_SKIP_MAX_CYCLES) return; - - if (s_idle_progress_reg > 0) - cpu->gpr[s_idle_progress_reg] -= k; - - g_idle_skip_count++; - g_idle_skip_cycles += skip; - g_idle_skip_last_pc = check_pc; - g_idle_skip_last_quantum = q; - psx_advance_cycles((uint32_t)skip); /* replays device events exactly */ - - /* Device servicing may have written RAM / raised I_STAT — the caller - * (psx_check_interrupts) evaluates deliverability right after this with - * the post-skip state. Re-detect from scratch either way. */ - s_idle_streak = 0; - idle_snapshot_regs(cpu); - s_idle_have_snap = 1; - s_idle_last_cycle = psx_cycle_count; - s_idle_last_stores = g_guest_store_count; - s_idle_last_mmio = g_mmio_access_count; -#endif -} - -/* Save-state restore: psx_cycle_count was just overwritten from the snapshot, so - * the deadline-model bookkeeping (synced position + next deadline) is stale and - * would try to replay a bogus gap. Re-anchor devices at the restored cycle and - * force a fresh deadline on the next charge. */ -void psx_cycles_resync_after_restore(void) { - g_psx_cyc_batch = 0; - g_psx_cyc_batch_limit = 0; - g_psx_cyc_bb_defer = 0; - s_devices_synced_cycle = psx_cycle_count; - psx_next_service_cycle = 0; /* recompute on next charge */ - psx_in_device_service = 0; -} - -void psx_cycles_reset_for_boot(void) { - g_psx_cyc_batch = 0; - g_psx_cyc_batch_limit = 0; - g_psx_cyc_bb_defer = 0; - psx_cycle_count = 0; - s_devices_synced_cycle = 0; - s_next_service_cycle = 0; - s_in_device_service = 0; - g_psx_cycle_fast_limit = 0; - s_next_watchdog = 0; - s_next_pc_sample = 0; -} - -/* ---- Mult/div completion-stall timing (faithful R3000A; Beetle muldiv_ts_done) ---- - * - * MULT/MULTU/DIV/DIVU don't stall at the op; they set a completion DEADLINE. - * A later MFLO/MFHI that reads HI/LO before the deadline STALLS (advances guest - * cycles) until it. Instructions executed in between absorb the latency — so the - * stall is (deadline - now), not a flat charge (this is why div+2filler+mflo costs - * the same as div+mflo: the fillers ran during the latency window). REQUIRES - * per-instruction cycle charging (PSX_CODEGEN_CYCLE_PER_INSN / the interp), so - * `now` is the true cycle position at the op — block-up-front charging breaks it. - * - * Latencies transcribed from Beetle cpu.cpp: DIV/DIVU = 37 (fixed). MULT/MULTU = - * MULT_Tab24 indexed by the leading-zero count of the (sign-folded, for signed) - * first operand | 0x400 — i.e. 14 for small magnitudes (<12 significant bits), - * 10 for medium, 7 for large. The | 0x400 caps the index at 21 (never l==0). */ - -static const uint8_t PSX_MULT_TAB24[24] = { - /* i<12: 7+4+3=14 */ 14,14,14,14,14,14,14,14,14,14,14,14, - /* 12<=i<21: 7+3=10 */ 10,10,10,10,10,10,10,10,10, - /* i>=21: 7 */ 7,7,7 -}; - -static inline uint32_t psx_clz32(uint32_t v) { - /* v is never 0 here (callers OR in 0x400). */ -#if defined(_MSC_VER) - unsigned long _idx; - _BitScanReverse(&_idx, v); /* index of highest set bit */ - return (uint32_t)(31u - _idx); -#else - return (uint32_t)__builtin_clz(v); -#endif -} - -uint32_t psx_mult_latency_s(uint32_t rs) { /* MULT (signed): sign-fold magnitude */ - return PSX_MULT_TAB24[psx_clz32((rs ^ (uint32_t)((int32_t)rs >> 31)) | 0x400u)]; -} -uint32_t psx_mult_latency_u(uint32_t rs) { /* MULTU (unsigned) */ - return PSX_MULT_TAB24[psx_clz32(rs | 0x400u)]; -} - -/* DIV/DIVU latency is the fixed constant 37 — emitted directly at the op site. */ - -void psx_muldiv_set(CPUState* cpu, uint32_t latency) { - cpu->muldiv_ts_done = psx_cycle_count + (uint64_t)latency; -} - -void psx_muldiv_stall(CPUState* cpu) { - /* MFLO/MFHI stall to the mult/div completion deadline (Beetle cpu.cpp:1723-1736). - * While stalling it CONSUMES a pending load-delay give-back (read_absorb) — each - * stalled cycle decrements read_absorb[read_absorb_which] — so cycles that would - * have been "free" for following instructions are spent here instead. Plus the - * off-by-one shortcut: a deadline exactly one cycle out just retracts (no stall). - * (No-load code has read_absorb==0, so this reduces to a plain advance.) */ - if (cpu->muldiv_ts_done > psx_cycle_count) { - if (cpu->muldiv_ts_done == psx_cycle_count + 1u) { - cpu->muldiv_ts_done--; /* off-by-one: retract the deadline, no advance */ - return; - } - uint32_t stall = (uint32_t)(cpu->muldiv_ts_done - psx_cycle_count); - uint8_t w = cpu->read_absorb_which; /* fixed during the stall */ - uint32_t give = cpu->read_absorb[w]; - cpu->read_absorb[w] = (uint8_t)(give > stall ? give - stall : 0u); /* consume */ - psx_advance_cycles(stall); - } -} - -/* MFC2/CFC2 (GTE register read → GPR): stall to the GTE command completion deadline - * AND hand the stall amount to the next instruction(s) as a load-delay give-back - * (Beetle cpu.cpp:1332-1341: LDAbsorb = gte_ts_done - timestamp, LDWhich = rt). The - * §1+DO_LDS that bracket this ran in the instruction's psx_cyc_step (COP2 is non-load). - * MTC2/CTC2 (writes) use psx_gte_stall (stall only, no give-back). */ -void psx_gte_read(CPUState* cpu, uint32_t rt) { - if (cpu->gte_ts_done > psx_cycle_count) { - uint32_t stall = (uint32_t)(cpu->gte_ts_done - psx_cycle_count); - cpu->ld_absorb = stall; - psx_advance_cycles(stall); - } else { - cpu->ld_absorb = 0u; - } - cpu->ld_which_t = (uint8_t)rt; -} - -/* ---- GTE (COP2) per-command completion-stall timing ---- - * - * Faithful R3000A/GTE model (Beetle cpu.cpp:1410-1412 + gte.cpp GTE_Instruction - * return(ret-1)). Each GTE command takes `cost` cycles (gte.cpp per-op returns, - * verified from source); the COP2 instruction's own +1 base is charged - * separately by per-instruction charging, so the *added* deadline latency is - * cost-1. A later COP2 register access (MFC2/CFC2/MTC2/CTC2/LWC2/SWC2) stalls - * until the deadline. Back-to-back commands serialize: psx_gte_set stalls to the - * prior deadline before arming the next (Beetle stalls timestamp to gte_ts_done - * at the command site before computing the new gte_ts_done). - * - * Cost table = (cost-1), indexed by the 6-bit GTE command (instr & 0x3F). - * cost values transcribed + verified from beetle-psx/mednafen/psx/gte.cpp op - * returns: RTPS15 RTPT23 MVMVA8 SQR5 OP6 AVSZ3/4=5 NCLIP8 NCDS19 NCDT44 NCCS17 - * NCCT39 NCS14 NCT30 CC11 CDP13 DPCS8 DPCT17 DCPL8 INTPL8 GPF5 GPL5. Unknown/ - * undefined commands = 1 cycle (Beetle default ret=1) -> 0 added. */ -static const uint8_t PSX_GTE_LAT_M1[64] = { - [0x00] = 14, [0x01] = 14, /* RTPS */ - [0x06] = 7, /* NCLIP */ - [0x0C] = 5, /* OP */ - [0x10] = 7, /* DPCS */ - [0x11] = 7, /* INTPL */ - [0x12] = 7, /* MVMVA */ - [0x13] = 18, /* NCDS */ - [0x14] = 12, /* CDP */ - [0x16] = 43, /* NCDT */ - [0x1A] = 7, /* DCPL (alt of 0x29) */ - [0x1B] = 16, /* NCCS */ - [0x1C] = 10, /* CC */ - [0x1E] = 13, /* NCS */ - [0x20] = 29, /* NCT */ - [0x28] = 4, /* SQR */ - [0x29] = 7, /* DCPL */ - [0x2A] = 16, /* DPCT */ - [0x2D] = 4, /* AVSZ3 */ - [0x2E] = 4, /* AVSZ4 */ - [0x30] = 22, /* RTPT */ - [0x3D] = 4, /* GPF */ - [0x3E] = 4, /* GPL */ - [0x3F] = 38, /* NCCT */ -}; - -uint32_t psx_gte_cmd_latency(uint32_t cmd) { - return (uint32_t)PSX_GTE_LAT_M1[cmd & 0x3Fu]; -} - -void psx_gte_set(CPUState* cpu, uint32_t latency) { - /* Back-to-back GTE ops serialize: finish the prior op first. */ - if (cpu->gte_ts_done > psx_cycle_count) { - psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); - } - cpu->gte_ts_done = psx_cycle_count + (uint64_t)latency; -} - -void psx_gte_stall(CPUState* cpu) { - if (cpu->gte_ts_done > psx_cycle_count) { - psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); - } -} diff --git a/psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c b/psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c deleted file mode 100644 index a97019e..0000000 --- a/psxrecomp-overlay/runtime/src/ws_aspect_cone_math.c +++ /dev/null @@ -1,70 +0,0 @@ -#include "ws_aspect_cone_math.h" - -#include - -int32_t psx_ws_widen_angle_units(int32_t vanilla, uint32_t full_turn, - int extent_pixels) { - if (extent_pixels <= 0 || vanilla == 0) return vanilla; - if (full_turn < 4u || (full_turn & 3u) != 0u) return vanilla; - - const int sign = vanilla < 0 ? -1 : 1; - int64_t magnitude = vanilla; - if (magnitude < 0) magnitude = -magnitude; - const int64_t maximum = (int64_t)full_turn / 4 - 1; - if (magnitude > maximum) magnitude = maximum; - - const double tau = 6.283185307179586476925286766559; - const double angle = (double)magnitude * tau / (double)full_turn; - const double scale = (160.0 + (double)extent_pixels) / 160.0; - const double widened = atan(tan(angle) * scale); - long result = lround(widened * (double)full_turn / tau); - if (result < 1) result = 1; - if ((int64_t)result > maximum) result = (long)maximum; - return (int32_t)(sign * result); -} - -uint32_t psx_ws_widen_angle_q12(uint32_t vanilla, int extent_pixels) { - if (vanilla >= 1024u) vanilla = 1023u; - return (uint32_t)psx_ws_widen_angle_units( - (int32_t)vanilla, 4096u, extent_pixels); -} - -int32_t psx_ws_scale_horizontal_320(int32_t value, int extent_pixels) { - if (extent_pixels <= 0 || value == 0) return value; - const int64_t denominator = 160 + (int64_t)extent_pixels; - int64_t numerator = (int64_t)value * 160; - numerator += numerator >= 0 ? denominator / 2 : -denominator / 2; - return (int32_t)(numerator / denominator); -} - -int psx_ws_aspect_cone_contains(int32_t x, int32_t z, int32_t y, - int32_t fx, int32_t fz, int32_t fy, - uint32_t threshold, int extent_pixels) { - if (threshold == 0 || threshold >= 1024 || extent_pixels < 0) - return 0; - - const double fhd = hypot((double)fx, (double)fz); - if (fhd < 1.0) return 0; - - /* Camera-local coordinates. The Q12 scale cancels from the comparisons. - * The right vector is horizontal in world space; the derived up vector is - * perpendicular to both right and the pitched camera-forward vector. */ - const double forward = - ((double)fx * x + (double)fz * z + (double)fy * y) / 4096.0; - if (forward <= 0.0) return 0; - const double horizontal = - ((double)fz * x - (double)fx * z) / fhd; - const double vertical_num = - (double)fx * fy * x - - fhd * fhd * y + - (double)fz * fy * z; - const double vertical = vertical_num / (fhd * 4096.0); - - const double cosine = (double)threshold / 1024.0; - const double tangent = - sqrt((1.0 - cosine * cosine) / (cosine * cosine)); - if (fabs(vertical) > forward * tangent) return 0; - - const double aspect_scale = (160.0 + extent_pixels) / 160.0; - return fabs(horizontal) <= forward * tangent * aspect_scale; -} diff --git a/psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c b/psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c deleted file mode 100644 index 5575a2d..0000000 --- a/psxrecomp-overlay/runtime/tests/test_ws_aspect_cone_math.c +++ /dev/null @@ -1,81 +0,0 @@ -#include "ws_aspect_cone_math.h" - -#include - -static int failures; - -static void check(int condition, const char *name) { - if (condition) { - printf("PASS %s\n", name); - } else { - fprintf(stderr, "FAIL %s\n", name); - failures++; - } -} - -int main(void) { - /* Camera looking along +X, with an approximately 30-degree vanilla cone. */ - const int32_t q12 = 4096; - const uint32_t cos_q10 = 880; - - check(psx_ws_aspect_cone_contains( - 1000, 500, 0, q12, 0, 0, cos_q10, 0), - "vanilla horizontal interior is accepted"); - check(!psx_ws_aspect_cone_contains( - 1000, 650, 0, q12, 0, 0, cos_q10, 0), - "vanilla horizontal exterior is rejected"); - check(psx_ws_aspect_cone_contains( - 1000, 650, 0, q12, 0, 0, cos_q10, 80), - "wide extent expands horizontal acceptance"); - check(!psx_ws_aspect_cone_contains( - 1000, 0, 650, q12, 0, 0, cos_q10, 80), - "wide extent preserves the vertical boundary"); - check(!psx_ws_aspect_cone_contains( - -1000, 0, 0, q12, 0, 0, cos_q10, 160), - "points behind the camera are rejected"); - - /* A 45-degree pitched forward vector still produces orthogonal forward, - * horizontal, and vertical tests. */ - check(psx_ws_aspect_cone_contains( - 1000, 0, 1000, 2896, 0, 2896, cos_q10, 0), - "pitched camera accepts a point on its forward axis"); - check(!psx_ws_aspect_cone_contains( - 1000, 0, -1000, 2896, 0, 2896, cos_q10, 160), - "horizontal expansion does not bypass pitched vertical rejection"); - - check(!psx_ws_aspect_cone_contains( - 1000, 0, 0, q12, 0, 0, 0, 0), - "invalid zero threshold is inert"); - check(!psx_ws_aspect_cone_contains( - 1000, 0, 0, 0, 0, q12, cos_q10, 0), - "degenerate horizontal camera basis is inert"); - - check(psx_ws_widen_angle_q12(341, 0) == 341, - "terrain angle is exact at 4:3"); - check(psx_ws_widen_angle_q12(341, 136) == 533, - "terrain angle follows 21:9 plus guard geometry"); - check(psx_ws_widen_angle_q12(455, 136) == 651, - "wider vanilla terrain angle scales geometrically"); - check(psx_ws_widen_angle_q12(300, 136) == 484, - "lava terrain angle uses the same projection geometry"); - check(psx_ws_widen_angle_q12(318, 80) < - psx_ws_widen_angle_q12(318, 136), - "terrain angle tracks dynamic horizontal extent"); - check(psx_ws_widen_angle_q12(1023, 256) <= 1023, - "terrain angle remains below a quarter turn"); - - check(psx_ws_widen_angle_units(32, 256, 0) == 32 && - psx_ws_widen_angle_units(-32, 256, 0) == -32, - "wrapping yaw offsets are exact at 4:3"); - check(psx_ws_widen_angle_units(32, 256, 53) == 38 && - psx_ws_widen_angle_units(-32, 256, 53) == -38, - "eight-bit yaw cone widens symmetrically for 16:9"); - check(psx_ws_scale_horizontal_320(213, 53) == 160 && - psx_ws_scale_horizontal_320(-213, 53) == -160, - "16:9 horizontal edges map to the original side plane"); - check(psx_ws_scale_horizontal_320(123, 0) == 123 && - psx_ws_scale_horizontal_320(-123, 0) == -123, - "horizontal side-plane scaling is exact at 4:3"); - - return failures ? 1 : 0; -} diff --git a/src/game/input/mouse_aim.cpp b/src/game/input/mouse_aim.cpp index 87b3343..cb46013 100644 --- a/src/game/input/mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -21,7 +21,7 @@ #include "game/input/mouse_aim.h" #include "game/session/capsule.h" #include "game/input/vertical_camera.h" -#include "../../../psxrecomp-overlay/runtime/include/gpu.h" +#include "../../../psxrecomp/runtime/include/gpu.h" #include #include diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 3710a88..1c06748 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -21,7 +21,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") @@ -36,8 +36,8 @@ def between(source: str, opening: str, closing: str) -> str: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") +loader = read("psxrecomp/recompiler/src/config_loader.cpp") +settings = read("psxrecomp/recompiler/src/config_loader.h") require(" disruptor_contract(ambient_occlusion)\n" in cmake, "this contract is registered") diff --git a/tests/contracts/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py index a011091..b28f305 100644 --- a/tests/contracts/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -54,10 +54,10 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") source = read("src/game/render/backdrop_tiles.cpp") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -gpu_header = read("psxrecomp-overlay/runtime/include/gpu.h") -codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") -interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +gpu_header = read("psxrecomp/runtime/include/gpu.h") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +interpreter = read("psxrecomp/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") require( @@ -75,7 +75,7 @@ def require(condition: bool, message: str) -> None: "the Disruptor target must opt into the dirty-interpreter backdrop seams", ) require( - "+runtime/include/gpu_ws_screen_tile.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines(), + "+runtime/include/gpu_ws_screen_tile.h" in read("framework/FILES.txt").splitlines(), "the tile header must be applied to the framework checkout", ) diff --git a/tests/contracts/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py index bfe1997..5a26ad7 100644 --- a/tests/contracts/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -21,11 +21,11 @@ def require(condition: bool, message: str) -> None: source = read("src/game/render/billboard_aspect.cpp") header = read("src/game/render/billboard_aspect.h") -codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") -dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -gpu_h = read("psxrecomp-overlay/runtime/include/gpu.h") -tag_match_h = read("psxrecomp-overlay/runtime/include/gpu_ws_tag_match.h") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +dirty = read("psxrecomp/runtime/src/dirty_ram_interp.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +gpu_h = read("psxrecomp/runtime/include/gpu.h") +tag_match_h = read("psxrecomp/runtime/include/gpu_ws_tag_match.h") audit = read("tools/audit_codegen.py") cmake = read("CMakeLists.txt") diff --git a/tests/contracts/test_borderless_contract.py b/tests/contracts/test_borderless_contract.py index e2202fa..475122e 100644 --- a/tests/contracts/test_borderless_contract.py +++ b/tests/contracts/test_borderless_contract.py @@ -15,8 +15,8 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -host = read("psxrecomp-overlay/runtime/src/main.cpp") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +host = read("psxrecomp/runtime/src/main.cpp") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") cmake = read("CMakeLists.txt") keep = host[ @@ -59,7 +59,7 @@ def require(condition: bool, message: str) -> None: "every present of the OpenGL renderer fits its picture to the rows that are on the screen", ) require( - "+runtime/include/host_borderless.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() + "+runtime/include/host_borderless.h" in read("framework/FILES.txt").splitlines() and " disruptor_unit_test(host_borderless " in cmake and " add_test(NAME host_borderless_contract\n" in cmake and '/tests/contracts/test_borderless_contract.py")' in cmake, diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index 63e2b09..24abd4e 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -15,7 +15,7 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -host = read("psxrecomp-overlay/runtime/src/main.cpp") +host = read("psxrecomp/runtime/src/main.cpp") capsule = read("src/game/session/capsule.cpp") mouse = read("src/game/input/mouse_aim.cpp") menu = read("src/game/ui/dev_menu.cpp") diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 060c45e..8bec0bf 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -21,10 +21,10 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") -settings = read("psxrecomp-overlay/recompiler/src/config_loader.h") +loader = read("psxrecomp/recompiler/src/config_loader.cpp") +settings = read("psxrecomp/recompiler/src/config_loader.h") require(" disruptor_contract(debug_tab)\n" in cmake, "this contract is registered") diff --git a/tests/contracts/test_depth24_sync_contract.py b/tests/contracts/test_depth24_sync_contract.py index 69d0d20..97e387c 100644 --- a/tests/contracts/test_depth24_sync_contract.py +++ b/tests/contracts/test_depth24_sync_contract.py @@ -35,8 +35,8 @@ def enclosing(source: str, block: str) -> list[str]: cmake = read("CMakeLists.txt") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") REGISTRATION = " disruptor_contract(depth24_sync)\n" require( diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 49a3a9a..d7dd4d2 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -53,11 +53,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -host_ui = read("psxrecomp-overlay/runtime/include/host_ui.h") -main_cpp = read("psxrecomp-overlay/runtime/src/main.cpp") -gl = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") +manifest = read("framework/FILES.txt") +host_ui = read("psxrecomp/runtime/include/host_ui.h") +main_cpp = read("psxrecomp/runtime/src/main.cpp") +gl = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +gte = read("psxrecomp/runtime/src/gte.cpp") menu = read("src/game/ui/dev_menu.cpp") options = read("src/game/settings/options.cpp") STAGED = ( @@ -75,8 +75,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: cheats = read("src/game/session/cheats.cpp") cheats_h = read("src/game/session/cheats.h") far_rendering_h = read("src/game/render/far_rendering.h") -config_h = read("psxrecomp-overlay/recompiler/src/config_loader.h") -config_cpp = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +config_h = read("psxrecomp/recompiler/src/config_loader.h") +config_cpp = read("psxrecomp/recompiler/src/config_loader.cpp") run_ps1 = read("run.ps1") run_sh = read("run.sh") game_toml = read("game.toml") @@ -217,7 +217,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: require(token in scale_setter, f"live internal-scale setter is missing {token}") -sw_header = read("psxrecomp-overlay/runtime/include/gpu_sw_renderer.h") +sw_header = read("psxrecomp/runtime/include/gpu_sw_renderer.h") require("#define SW_MAX_INTERNAL_SCALE 8" in sw_header and "#define GL_MAX_INTERNAL_SCALE SW_MAX_INTERNAL_SCALE" in gl, "renderers must share the 8x internal scale limit") diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index 78dae0a..bfdb868 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -21,9 +21,9 @@ def require(condition: bool, message: str) -> None: source = read("src/game/render/far_rendering.cpp") header = read("src/game/render/far_rendering.h") -codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") -dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") -main = read("psxrecomp-overlay/runtime/src/main.cpp") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +dirty = read("psxrecomp/runtime/src/dirty_ram_interp.c") +main = read("psxrecomp/runtime/src/main.cpp") audit = read("tools/audit_codegen.py") cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") diff --git a/tests/contracts/test_frame_kind_contract.py b/tests/contracts/test_frame_kind_contract.py index 01475e3..7a90aee 100644 --- a/tests/contracts/test_frame_kind_contract.py +++ b/tests/contracts/test_frame_kind_contract.py @@ -35,10 +35,10 @@ def enclosing(source: str, block: str) -> list[str]: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -header = read("psxrecomp-overlay/runtime/include/gpu.h") -runtime = read("psxrecomp-overlay/runtime/src/main.cpp") +manifest = read("framework/FILES.txt") +gpu = read("psxrecomp/runtime/src/gpu.c") +header = read("psxrecomp/runtime/include/gpu.h") +runtime = read("psxrecomp/runtime/src/main.cpp") require( "+runtime/include/gpu_ws_frame_kind.h" in manifest.splitlines(), diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index f975773..3882ef8 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -58,9 +58,9 @@ def require(condition: bool, message: str) -> None: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -cycle_header = read("psxrecomp-overlay/runtime/include/psx_cyc.h") -cycle_source = read("psxrecomp-overlay/runtime/src/psx_cycles.c") +manifest = read("framework/FILES.txt") +cycle_header = read("psxrecomp/runtime/include/psx_cyc.h") +cycle_source = read("psxrecomp/runtime/src/psx_cycles.c") require( " disruptor_contract(frame_rate RETAIL)\n" in cmake, @@ -150,7 +150,7 @@ def require(condition: bool, message: str) -> None: " disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0);" in menu, "a saved switch must be applied at start unless the environment overrides it", ) -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") require( 's.frame_unlock = toml::find(d, "frame_unlock");' in settings_loader and 'f << "frame_unlock = " << (s.frame_unlock ? "true" : "false")' in settings_loader @@ -177,8 +177,8 @@ def require(condition: bool, message: str) -> None: present_rate = read("src/game/frame/present_rate.h") present_control = read("src/game/frame/present_rate.cpp") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -host = read("psxrecomp-overlay/runtime/src/main.cpp") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +host = read("psxrecomp/runtime/src/main.cpp") require( " disruptor_unit_test(disruptor_present_rate " in cmake and "constexpr int kDisruptorPresentRateLowest = 30;" in present_rate diff --git a/tests/contracts/test_framework_overlay_new_file.py b/tests/contracts/test_framework_overlay_new_file.py deleted file mode 100644 index 660600a..0000000 --- a/tests/contracts/test_framework_overlay_new_file.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""Verify that reviewed overlays may add files inside known framework dirs.""" - -from __future__ import annotations - -import pathlib -import subprocess -import sys -import tempfile - - -ROOT = pathlib.Path(__file__).resolve().parents[2] -APPLIER = ROOT / "tools" / "apply_framework_overlay.py" -PROJECT_OVERLAY = ROOT / "psxrecomp-overlay" -PROJECT_MANIFEST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" - - -def run_applier( - framework: pathlib.Path, - overlay: pathlib.Path, - manifest: pathlib.Path, -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - str(APPLIER), - "--framework", - str(framework), - "--overlay", - str(overlay), - "--manifest", - str(manifest), - ], - check=False, - capture_output=True, - text=True, - ) - - -def main() -> int: - with tempfile.TemporaryDirectory() as temp_dir: - temp = pathlib.Path(temp_dir) - framework = temp / "framework" - overlay = temp / "overlay" - include_dir = framework / "runtime" / "include" - overlay_include_dir = overlay / "runtime" / "include" - include_dir.mkdir(parents=True) - overlay_include_dir.mkdir(parents=True) - (framework / "runtime" / "runtime.cmake").write_text( - "# fixture\n", encoding="utf-8" - ) - expected = "#pragma once\n" - (overlay_include_dir / "new_header.h").write_text( - expected, encoding="utf-8" - ) - manifest = temp / "manifest.txt" - manifest.write_text( - "+runtime/include/new_header.h\n", encoding="utf-8" - ) - - result = run_applier(framework, overlay, manifest) - if result.returncode != 0: - print(result.stdout, end="") - print(result.stderr, end="", file=sys.stderr) - return 1 - installed = include_dir / "new_header.h" - if installed.read_text(encoding="utf-8") != expected: - print("new manifest-listed overlay file was not copied", file=sys.stderr) - return 1 - - unexpected = overlay_include_dir / "unexpected.h" - unexpected.write_text(expected, encoding="utf-8") - manifest.write_text( - "runtime/include/unexpected.h\n", encoding="utf-8" - ) - rejected = run_applier(framework, overlay, manifest) - if rejected.returncode == 0 or (include_dir / "unexpected.h").exists(): - print("unmarked new overlay file was not rejected", file=sys.stderr) - return 1 - - # Exercise the real reviewed manifest as well. Existing entries get - # placeholder destinations; '+' entries deliberately start absent. - real_framework = temp / "real-framework" - (real_framework / "runtime").mkdir(parents=True) - (real_framework / "runtime" / "runtime.cmake").write_text( - "# fixture\n", encoding="utf-8" - ) - for raw_line in PROJECT_MANIFEST.read_text( - encoding="utf-8" - ).splitlines(): - entry = raw_line.strip() - if not entry or entry.startswith("#"): - continue - allow_new = entry.startswith("+") - relative = pathlib.Path(entry[1:] if allow_new else entry) - destination = real_framework / relative - destination.parent.mkdir(parents=True, exist_ok=True) - if not allow_new: - destination.write_text("fixture\n", encoding="utf-8") - - real_result = run_applier( - real_framework, PROJECT_OVERLAY, PROJECT_MANIFEST - ) - if real_result.returncode != 0: - print(real_result.stdout, end="") - print(real_result.stderr, end="", file=sys.stderr) - return 1 - installed_shared_header = ( - real_framework / "runtime" / "include" / "gte_precision.h" - ) - if not installed_shared_header.is_file(): - print("real manifest did not install gte_precision.h", file=sys.stderr) - return 1 - - print("framework overlay new-file test: PASS") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/contracts/test_framework_tool.py b/tests/contracts/test_framework_tool.py new file mode 100644 index 0000000..2e42b3d --- /dev/null +++ b/tests/contracts/test_framework_tool.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Test of tools/framework.py on a small framework made here: apply, refresh, check and what each refuses.""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TOOL = ROOT / "tools" / "framework.py" +PLAIN = b"int a;\nint b;\nint c;\nint d;\nint e;\n" +MIXED = b"one\r\ntwo\r\nthree\nfo\rur\r\nfive\r\n" +PLAIN_EDITED = b"int a;\nint b2;\nint c;\nint d;\nint e;\nint f;\n" +MIXED_EDITED = b"one\r\ntwo\r\nours\nfo\rur\r\nfive\r\n" +ADDED = b"#pragma once\n" +LISTED = "runtime/src/plain.c\nruntime/src/mixed.c\n+runtime/include/added.h\n" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def git(folder: Path, *arguments: str) -> str: + identity = ["-c", "user.name=test", "-c", "user.email=test@example.invalid", "-c", "core.autocrlf=false"] + done = subprocess.run(["git", "-C", str(folder), *identity, *arguments], capture_output=True, text=True, check=True) + return done.stdout.strip() + + +def tool(project: Path, checkout: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + command = [sys.executable, "-B", str(TOOL), *arguments, "--project", str(project), "--framework", str(checkout)] + return subprocess.run(command, capture_output=True, text=True, check=False) + + +def main() -> int: + with tempfile.TemporaryDirectory() as folder: + checkout, project = Path(folder) / "checkout", Path(folder) / "project" + (checkout / "runtime" / "src").mkdir(parents=True) + (checkout / "runtime" / "include").mkdir() + (checkout / "runtime" / "runtime.cmake").write_bytes(b"# fixture\n") + plain, mixed = checkout / "runtime" / "src" / "plain.c", checkout / "runtime" / "src" / "mixed.c" + added = checkout / "runtime" / "include" / "added.h" + plain.write_bytes(PLAIN) + mixed.write_bytes(MIXED) + git(checkout, "init", "--quiet") + git(checkout, "add", "--all") + git(checkout, "commit", "--quiet", "--message", "pinned") + (project / "framework").mkdir(parents=True) + pinned = git(checkout, "rev-parse", "HEAD") + "\n" + (project / "framework" / "PIN").write_text(pinned, encoding="utf-8") + manifest = project / "framework" / "FILES.txt" + manifest.write_text("# a comment\n" + LISTED, encoding="utf-8") + + plain.write_bytes(PLAIN_EDITED) + mixed.write_bytes(MIXED_EDITED) + added.write_bytes(ADDED) + saved = tool(project, checkout, "refresh") + patch = project / "framework" / "patches" / "runtime" / "src" / "mixed.c.patch" + require( + saved.returncode == 0 + and (project / "framework" / "files" / "runtime" / "include" / "added.h").read_bytes() == ADDED + and b"\n two\r\n-three\n+ours\n fo\rur\r\n five\r\n" in patch.read_bytes() + and tool(project, checkout, "check").returncode == 0, + "an edit made in the checkout is saved as a patch that keeps each line's own ending", + ) + + plain.write_bytes(PLAIN) + mixed.write_bytes(MIXED) + require( + git(checkout, "apply", "--check", str(patch)) == "" + and git(checkout, "apply", "--check", str(patch.with_name("plain.c.patch"))) == "", + "a saved patch is one that git applies to the pinned file", + ) + added.unlink() + require(tool(project, checkout, "check").returncode != 0, "a checkout with pinned files is not in step") + applied = tool(project, checkout, "apply") + require( + applied.returncode == 0 + and plain.read_bytes() == PLAIN_EDITED + and mixed.read_bytes() == MIXED_EDITED + and added.read_bytes() == ADDED + and tool(project, checkout, "check").returncode == 0, + "apply gives the saved state back byte for byte", + ) + written = [path.stat().st_mtime_ns for path in (plain, mixed, added)] + require( + tool(project, checkout, "apply").returncode == 0 + and written == [path.stat().st_mtime_ns for path in (plain, mixed, added)], + "apply leaves alone a file that already holds what it should, so a build does not compile it again", + ) + + plain.write_bytes(PLAIN_EDITED + b"int unsaved;\n") + refused = tool(project, checkout, "apply") + require( + refused.returncode != 0 + and "runtime/src/plain.c" in refused.stderr + and plain.read_bytes().endswith(b"int unsaved;\n") + and tool(project, checkout, "check").returncode != 0, + "apply does not overwrite an edit that refresh has not saved, and check reports it", + ) + require( + tool(project, checkout, "apply", "--discard").returncode == 0 and plain.read_bytes() == PLAIN_EDITED, + "apply --discard drops such an edit", + ) + + stray = project / "framework" / "patches" / "runtime" / "src" / "stray.c.patch" + stray.write_bytes(patch.read_bytes()) + require( + tool(project, checkout, "check").returncode != 0 and tool(project, checkout, "apply").returncode != 0, + "a patch that FILES.txt does not list is refused", + ) + stray.unlink() + + whole = patch.read_bytes() + patch.write_bytes(whole.replace(b"+ours\n", b"+theirs\n")) + mixed.write_bytes(MIXED) + altered = tool(project, checkout, "apply") + require( + altered.returncode != 0 + and "did not give the content it names" in altered.stderr + and mixed.read_bytes() == MIXED, + "a patch whose text was altered is not applied", + ) + patch.write_bytes(whole.replace(b" two\r\n", b" two\n")) + unmatched = tool(project, checkout, "apply") + require( + unmatched.returncode != 0 and "does not match at line 2" in unmatched.stderr, + "a hunk that does not match the pinned file is refused, and the line is named", + ) + patch.write_bytes(whole) + require(tool(project, checkout, "apply").returncode == 0, "the fixture is whole again") + + marker = checkout / "runtime" / "runtime.cmake" + marker.write_bytes(b"# changed\n") + unlisted = tool(project, checkout, "check") + require( + unlisted.returncode != 0 and "not in FILES.txt: runtime/runtime.cmake" in unlisted.stderr, + "a framework file changed in the checkout and not listed is reported", + ) + marker.write_bytes(b"# fixture\n") + require(tool(project, checkout, "check").returncode == 0, "the fixture is in step again") + + for lines, said in ( + (LISTED.replace("+runtime/include/added.h", "runtime/include/added.h"), "is not in the framework"), + (LISTED.replace("runtime/src/plain.c", "+runtime/src/plain.c"), "is marked '+' but the framework has it"), + (LISTED + "../outside.c\n", "unsafe path"), + (LISTED + "+..\\outside.h\n", "unsafe path"), + (LISTED + "+C:/outside.h\n", "unsafe path"), + (LISTED + "runtime/src/plain.c\n", "lists a file twice"), + ("# nothing\n", "lists no file"), + ): + manifest.write_text(lines, encoding="utf-8") + refused = tool(project, checkout, "check") + require(refused.returncode != 0 and said in refused.stderr, f"a list is refused when it {said}") + manifest.write_text(LISTED, encoding="utf-8") + + plain.write_bytes(PLAIN) + require( + tool(project, checkout, "refresh").returncode != 0, + "refresh refuses a listed file that is the pinned one again", + ) + + plain.write_bytes(b"int other;\n") + git(checkout, "commit", "--quiet", "--all", "--message", "moved") + (project / "framework" / "PIN").write_text(git(checkout, "rev-parse", "HEAD") + "\n", encoding="utf-8") + moved = tool(project, checkout, "check") + require( + moved.returncode != 0 and "made against other content" in moved.stderr, + "a patch is not applied to a pinned file it was not made against", + ) + (project / "framework" / "PIN").write_text(pinned, encoding="utf-8") + elsewhere = tool(project, checkout, "check") + require( + elsewhere.returncode != 0 and "not at the pinned" in elsewhere.stderr, + "a checkout that is at another revision than the pinned one is reported", + ) + + listed = [line for line in (ROOT / "framework" / "FILES.txt").read_text(encoding="utf-8").splitlines()] + entries = [line.strip() for line in listed if line.strip() and not line.startswith("#")] + patches = { + path.relative_to(ROOT / "framework" / "patches").as_posix() + for path in (ROOT / "framework" / "patches").rglob("*") + if path.is_file() + } + files = { + path.relative_to(ROOT / "framework" / "files").as_posix() + for path in (ROOT / "framework" / "files").rglob("*") + if path.is_file() + } + require( + len(entries) == 51 + and patches == {f"{line}.patch" for line in entries if not line.startswith("+")} + and files == {line[1:] for line in entries if line.startswith("+")} + and len(patches) == 32 + and len(files) == 19 + and "framework/patches/** -text\n" in (ROOT / ".gitattributes").read_text(encoding="utf-8"), + "the project lists 51 changed files, with a patch for each of 32 and 19 added whole, and stores patches byte for byte", + ) + for script, call in ( + ("build.ps1", "python (Join-Path $Root 'tools/framework.py') apply --framework $Framework"), + ("build.sh", 'python3 "$ROOT/tools/framework.py" apply --framework "$FRAMEWORK"'), + ("build.ps1", 'python -c "import sys; sys.exit(sys.version_info < (3, 10))"'), + ("build.sh", "python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' || {"), + ("CMakeLists.txt", " find_package(Python3 3.10 REQUIRED COMPONENTS Interpreter)\n"), + ): + require( + call in (ROOT / script).read_text(encoding="utf-8"), + f"{script} applies the framework changes with a Python the tool runs on", + ) + source = TOOL.read_text(encoding="utf-8") + require( + ' if self._git("cat-file", "-e", f"{pin}^0").returncode != 0:\n' in source and "^{" not in source, + "the tool asks git for the pinned commit without braces, which an MSYS git expands when a native Python starts it", + ) + + print("framework tool test: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 292cb94..d8d2b32 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -21,7 +21,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") @@ -36,7 +36,7 @@ def between(source: str, opening: str, closing: str) -> str: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +loader = read("psxrecomp/recompiler/src/config_loader.cpp") require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") @@ -179,7 +179,7 @@ def between(source: str, opening: str, closing: str) -> str: f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\\n"; """ in loader and "s.has_gap_filling" in between(loader, " if (s.has_mouse_aim", "{\n") - and " bool has_gap_filling = false; bool gap_filling = true;\n" in read("psxrecomp-overlay/recompiler/src/config_loader.h"), + and " bool has_gap_filling = false; bool gap_filling = true;\n" in read("psxrecomp/recompiler/src/config_loader.h"), "the settings file keeps the choice, which is on until changed", ) diff --git a/tests/contracts/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py index 6486009..7beb1a2 100644 --- a/tests/contracts/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -9,19 +9,19 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] -GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" -GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" -MEMORY = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "memory.c" +GPU = ROOT / "psxrecomp" / "runtime" / "src" / "gpu.c" +GTE = ROOT / "psxrecomp" / "runtime" / "src" / "gte.cpp" +MEMORY = ROOT / "psxrecomp" / "runtime" / "src" / "memory.c" DIRTY_INTERP = ( - ROOT / "psxrecomp-overlay" / "runtime" / "src" / "dirty_ram_interp.c" + ROOT / "psxrecomp" / "runtime" / "src" / "dirty_ram_interp.c" ) GTE_HEADER = ( - ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" + ROOT / "psxrecomp" / "runtime" / "include" / "gte_precision.h" ) MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" -MAIN = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "main.cpp" +MAIN = ROOT / "psxrecomp" / "runtime" / "src" / "main.cpp" CODEGEN = ( - ROOT / "psxrecomp-overlay" / "recompiler" / "src" / "code_generator.cpp" + ROOT / "psxrecomp" / "recompiler" / "src" / "code_generator.cpp" ) GAME_CODE = ROOT / "input" / "SLUS_002.24.code" diff --git a/tests/contracts/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py index d2f59d8..e1b908f 100644 --- a/tests/contracts/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -37,7 +37,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") source = read("src/game/render/grown_quad.cpp") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") +gpu = read("psxrecomp/runtime/src/gpu.c") require( '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 393c175..3fbd1b6 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -16,10 +16,10 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -scale_header = read("psxrecomp-overlay/runtime/include/gpu_ws_hud_scale.h") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -header = read("psxrecomp-overlay/runtime/include/gpu.h") +manifest = read("framework/FILES.txt") +scale_header = read("psxrecomp/runtime/include/gpu_ws_hud_scale.h") +gpu = read("psxrecomp/runtime/src/gpu.c") +header = read("psxrecomp/runtime/include/gpu.h") menu = read("src/game/ui/dev_menu.cpp") options = read("src/game/settings/options.cpp") STAGED = ( @@ -33,7 +33,7 @@ def require(condition: bool, message: str) -> None: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") require( "+runtime/include/gpu_ws_hud_scale.h" in manifest.splitlines(), diff --git a/tests/contracts/test_hud_widget_contract.py b/tests/contracts/test_hud_widget_contract.py index 9bacf9e..1b8c594 100644 --- a/tests/contracts/test_hud_widget_contract.py +++ b/tests/contracts/test_hud_widget_contract.py @@ -21,9 +21,9 @@ def body(source: str, signature: str) -> str: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -header = read("psxrecomp-overlay/runtime/include/gpu.h") +manifest = read("framework/FILES.txt") +gpu = read("psxrecomp/runtime/src/gpu.c") +header = read("psxrecomp/runtime/include/gpu.h") module = read("src/game/render/hud_widgets.cpp") require( diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 7399fae..d04d153 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -60,10 +60,10 @@ def require(condition: bool, message: str) -> None: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -generator = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") -interpreter = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") +generator = read("psxrecomp/recompiler/src/code_generator.cpp") +interpreter = read("psxrecomp/runtime/src/dirty_ram_interp.c") audit = read("tools/audit_codegen.py") -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") require( "src/game/session/intro_skip.cpp" in cmake @@ -128,8 +128,8 @@ def require(condition: bool, message: str) -> None: "disruptor_intro_skip_fast_forward"): require(token in header and token in source, f"intro-skip API is missing {token}") -host_header = read("psxrecomp-overlay/runtime/include/host_ui.h") -host = read("psxrecomp-overlay/runtime/src/main.cpp") +host_header = read("psxrecomp/runtime/include/host_ui.h") +host = read("psxrecomp/runtime/src/main.cpp") require( 'extern "C" int disruptor_intro_skip_fast_forward(void) {\n' " if (g_boot_over || !psx_mod_game_started()) return 0;\n" @@ -197,7 +197,7 @@ def require(condition: bool, message: str) -> None: "settings.toml must read and write [disruptor] skip_intro", ) -runtime_main = read("psxrecomp-overlay/runtime/src/main.cpp") +runtime_main = read("psxrecomp/runtime/src/main.cpp") require( " if (us.has_skip_intro && us.skip_intro &&\n" ' !std::getenv("PSX_DISRUPTOR_SKIP_INTRO"))\n' diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index cd55795..b02b962 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -64,20 +64,20 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") -overlay_files = read("PSXRECOMP_OVERLAY_FILES.txt") +overlay_files = read("framework/FILES.txt") disc = read("src/game/language/language_disc.cpp") disc_header = read("src/game/language/language_disc.h") rules = read("src/game/language/language_pack.h") source = read("src/game/language/language_source.cpp") -header = read("psxrecomp-overlay/runtime/include/iso_overlay.h") -host = read("psxrecomp-overlay/runtime/src/main.cpp") -host_header = read("psxrecomp-overlay/runtime/include/host_ui.h") -pick_slot = read("psxrecomp-overlay/runtime/include/host_pick_slot.h") +header = read("psxrecomp/runtime/include/iso_overlay.h") +host = read("psxrecomp/runtime/src/main.cpp") +host_header = read("psxrecomp/runtime/include/host_ui.h") +pick_slot = read("psxrecomp/runtime/include/host_pick_slot.h") restart = read("src/game/session/restart.cpp") -settings_header = read("psxrecomp-overlay/recompiler/src/config_loader.h") -settings_loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +settings_header = read("psxrecomp/recompiler/src/config_loader.h") +settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") menu = read("src/game/ui/dev_menu.cpp") -reader = read("psxrecomp-overlay/runtime/src/iso_reader_c.cpp") +reader = read("psxrecomp/runtime/src/iso_reader_c.cpp") require( '/src/game/language/language_disc.cpp"' in cmake diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 0108a2d..8e60be0 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -43,7 +43,7 @@ def plain_values(text: str) -> dict[str, str]: region = read("src/launcher/region_disc.cpp") launcher_cmake = read("src/launcher/CMakeLists.txt") cmake = read("CMakeLists.txt") -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +loader = read("psxrecomp/recompiler/src/config_loader.cpp") menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") ROW = r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", "([^"]+)", (-?\d+)' @@ -97,7 +97,7 @@ def plain_values(text: str) -> dict[str, str]: "the launcher must start from the game's mouse sensitivity", ) require( - initial["video.vsync"] == 2 and "static int g_video_vsync = 1;" in read("psxrecomp-overlay/runtime/src/main.cpp") + initial["video.vsync"] == 2 and "static int g_video_vsync = 1;" in read("psxrecomp/runtime/src/main.cpp") and initial["disruptor.hud_scale"] == 100 and initial["audio.master_volume"] == 100, "the launcher must start from the runtime's own defaults", ) diff --git a/tests/contracts/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py index 742b49e..30d8e82 100644 --- a/tests/contracts/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -19,10 +19,10 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -host = read("psxrecomp-overlay/runtime/src/main.cpp") -memory = read("psxrecomp-overlay/runtime/src/memory.c") -state = read("psxrecomp-overlay/runtime/src/boot_state.c") -cycles = read("psxrecomp-overlay/runtime/include/psx_cyc.h") +host = read("psxrecomp/runtime/src/main.cpp") +memory = read("psxrecomp/runtime/src/memory.c") +state = read("psxrecomp/runtime/src/boot_state.c") +cycles = read("psxrecomp/runtime/include/psx_cyc.h") latch = read("psxrecomp/runtime/src/fntrace.c") menu = read("src/game/ui/dev_menu.cpp") rate = read("src/game/frame/frame_rate.cpp") diff --git a/tests/contracts/test_menu_wide_contract.py b/tests/contracts/test_menu_wide_contract.py index 33bd4dd..71133a1 100644 --- a/tests/contracts/test_menu_wide_contract.py +++ b/tests/contracts/test_menu_wide_contract.py @@ -15,10 +15,10 @@ def require(condition: bool, message: str) -> None: raise AssertionError(message) -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -host = read("psxrecomp-overlay/runtime/src/main.cpp") -kinds = read("psxrecomp-overlay/runtime/include/gpu_ws_frame_kind.h") -state = read("psxrecomp-overlay/runtime/src/boot_state.c") +gpu = read("psxrecomp/runtime/src/gpu.c") +host = read("psxrecomp/runtime/src/main.cpp") +kinds = read("psxrecomp/runtime/include/gpu_ws_frame_kind.h") +state = read("psxrecomp/runtime/src/boot_state.c") cmake = read("CMakeLists.txt") require( @@ -189,11 +189,11 @@ def require(condition: bool, message: str) -> None: ) require( " fmv_frame || mdec_recently_active(2) || gpu_ws_displayed_menu() != 0);" in host - and "int gpu_ws_displayed_menu(void);" in read("psxrecomp-overlay/runtime/include/gpu.h"), + and "int gpu_ws_displayed_menu(void);" in read("psxrecomp/runtime/include/gpu.h"), "nothing is put in between the frames of a front-end screen", ) require( - "+runtime/include/gpu_ws_menu.h" in read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() + "+runtime/include/gpu_ws_menu.h" in read("framework/FILES.txt").splitlines() and " disruptor_unit_test(disruptor_gpu_ws_menu " in cmake and " disruptor_contract(menu_wide)\n" in cmake, "the header is part of the overlay and its tests remain registered", diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index ccb38e7..bb1ef87 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -94,7 +94,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") module = read("src/game/input/name_entry.cpp") -host = read("psxrecomp-overlay/runtime/src/main.cpp") +host = read("psxrecomp/runtime/src/main.cpp") require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake diff --git a/tests/contracts/test_perspective_texture_contract.py b/tests/contracts/test_perspective_texture_contract.py index 81af699..59d6078 100644 --- a/tests/contracts/test_perspective_texture_contract.py +++ b/tests/contracts/test_perspective_texture_contract.py @@ -14,7 +14,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] -OVERLAY = ROOT / "psxrecomp-overlay" / "runtime" +OVERLAY = ROOT / "psxrecomp" / "runtime" MAIN = OVERLAY / "src" / "main.cpp" GPU = OVERLAY / "src" / "gpu.c" GL = OVERLAY / "src" / "gpu_gl_renderer.c" diff --git a/tests/contracts/test_phase_a_contract.py b/tests/contracts/test_phase_a_contract.py index 94de2be..fd08f02 100644 --- a/tests/contracts/test_phase_a_contract.py +++ b/tests/contracts/test_phase_a_contract.py @@ -9,15 +9,15 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] -GPU = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu.c" -GTE = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gte.cpp" -GL = ROOT / "psxrecomp-overlay" / "runtime" / "src" / "gpu_gl_renderer.c" -GPU_HEADER = ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gpu.h" +GPU = ROOT / "psxrecomp" / "runtime" / "src" / "gpu.c" +GTE = ROOT / "psxrecomp" / "runtime" / "src" / "gte.cpp" +GL = ROOT / "psxrecomp" / "runtime" / "src" / "gpu_gl_renderer.c" +GPU_HEADER = ROOT / "psxrecomp" / "runtime" / "include" / "gpu.h" GTE_HEADER = ( - ROOT / "psxrecomp-overlay" / "runtime" / "include" / "gte_precision.h" + ROOT / "psxrecomp" / "runtime" / "include" / "gte_precision.h" ) MOUSE = ROOT / "src" / "game" / "input" / "mouse_aim.cpp" -OVERLAY_LIST = ROOT / "PSXRECOMP_OVERLAY_FILES.txt" +OVERLAY_LIST = ROOT / "framework/FILES.txt" def function_body(source: str, name: str) -> str: diff --git a/tests/contracts/test_pinned_corner_contract.py b/tests/contracts/test_pinned_corner_contract.py index 8defb02..f2ba9fb 100644 --- a/tests/contracts/test_pinned_corner_contract.py +++ b/tests/contracts/test_pinned_corner_contract.py @@ -41,14 +41,14 @@ def enclosing(source: str, block: str) -> list[str]: cmake = read("CMakeLists.txt") -overlay = read("PSXRECOMP_OVERLAY_FILES.txt") -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") -render_header = read("psxrecomp-overlay/runtime/include/gpu_render.h") -render = read("psxrecomp-overlay/runtime/src/gpu_render.c") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") -mesh = read("psxrecomp-overlay/runtime/include/gpu_temporal_mesh.h") +overlay = read("framework/FILES.txt") +gte = read("psxrecomp/runtime/src/gte.cpp") +gpu = read("psxrecomp/runtime/src/gpu.c") +render_header = read("psxrecomp/runtime/include/gpu_render.h") +render = read("psxrecomp/runtime/src/gpu_render.c") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +temporal = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") +mesh = read("psxrecomp/runtime/include/gpu_temporal_mesh.h") REGISTRATION = " disruptor_contract(pinned_corner)\n" UNIT_TEST = " disruptor_unit_test(disruptor_gpu_pinned_corner tests/unit/gpu_pinned_corner_test.cpp RUNTIME_HEADERS)\n" diff --git a/tests/contracts/test_save_point_contract.py b/tests/contracts/test_save_point_contract.py index a7cc45d..f9f8dc4 100644 --- a/tests/contracts/test_save_point_contract.py +++ b/tests/contracts/test_save_point_contract.py @@ -32,9 +32,9 @@ def polls_are_the_four(source: str) -> bool: cmake = read("CMakeLists.txt") -manifest = read("PSXRECOMP_OVERLAY_FILES.txt").splitlines() -interrupts = read("psxrecomp-overlay/runtime/src/interrupts.c") -rule = read("psxrecomp-overlay/runtime/include/save_point.h") +manifest = read("framework/FILES.txt").splitlines() +interrupts = read("psxrecomp/runtime/src/interrupts.c") +rule = read("psxrecomp/runtime/include/save_point.h") require( " disruptor_contract(save_point)\n" in cmake diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index f182d04..d5974ed 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -21,9 +21,9 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -overlay = read("PSXRECOMP_OVERLAY_FILES.txt") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") +overlay = read("framework/FILES.txt") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +gpu = read("psxrecomp/runtime/src/gpu.c") mouse = read("src/game/input/mouse_aim.cpp") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") diff --git a/tests/contracts/test_sprite_range_contract.py b/tests/contracts/test_sprite_range_contract.py index 997114e..1da26b8 100644 --- a/tests/contracts/test_sprite_range_contract.py +++ b/tests/contracts/test_sprite_range_contract.py @@ -184,7 +184,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") source = read("src/game/render/sprite_depth.cpp") -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") +gte = read("psxrecomp/runtime/src/gte.cpp") require( " disruptor_contract(sprite_range RETAIL)\n" in cmake diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 9f77a83..6ba69bb 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -36,9 +36,9 @@ def shader(source: str, name: str) -> str: cmake = read("CMakeLists.txt") -temporal = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") +temporal = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +gpu = read("psxrecomp/runtime/src/gpu.c") billboards = read("src/game/render/billboard_aspect.cpp") require( @@ -221,7 +221,7 @@ def shader(source: str, name: str) -> str: ) sides = function_body(gpu, "static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4])") sizer = function_body(gpu, "void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16,") -sprite_header = read("psxrecomp-overlay/runtime/include/gpu_temporal_sprite.h") +sprite_header = read("psxrecomp/runtime/include/gpu_temporal_sprite.h") require( " if (!t) return 0.0f;" in sides and " const GpuTemporalSprite sprite = gpu_temporal_sprite_from_fixed(\n t->placed, t->sized, t->shadow, t->whole, t->centre_x, t->offset_y, t->row, t->width, t->height);" in sides @@ -230,13 +230,13 @@ def shader(source: str, name: str) -> str: and '#include "gpu_temporal_sprite.h"' in gpu and "static inline void gpu_temporal_sprite_sides(const GpuTemporalSprite *sprite, float left, float top, int w, int h," in sprite_header and "tests/unit/gpu_temporal_sprite_test.cpp" in read("CMakeLists.txt") - and "+runtime/include/gpu_temporal_sprite.h" in read("PSXRECOMP_OVERLAY_FILES.txt") + and "+runtime/include/gpu_temporal_sprite.h" in read("framework/FILES.txt") and " if (!t || t->stamp != (uint32_t)s_frame_count || !t->placed || width16 <= 0 || height16 <= 0) return;" in sizer and " t->placed = t->sized = t->shadow = t->whole = 0;" in gpu, "the renderer must hand a sprite's own numbers, the squash and the shadow setting to the tested side arithmetic", ) -gte = read("psxrecomp-overlay/runtime/src/gte.cpp") -runtime_main = read("psxrecomp-overlay/runtime/src/main.cpp") +gte = read("psxrecomp/runtime/src/gte.cpp") +runtime_main = read("psxrecomp/runtime/src/main.cpp") audit = read("tools/audit_codegen.py") scratch_store = function_body(gte, "extern \"C\" void gte_precision_scratch_store_pc_word(") band = function_body(gte, "static uint32_t precision_scratch_route_clamp(") diff --git a/tests/contracts/test_test_registration_contract.py b/tests/contracts/test_test_registration_contract.py index 3687686..665dbf6 100644 --- a/tests/contracts/test_test_registration_contract.py +++ b/tests/contracts/test_test_registration_contract.py @@ -11,7 +11,7 @@ ARTIFACTS = "--require-" + "artifacts" EXPLICIT = { "test_borderless_contract.py": "host_borderless_contract", - "test_framework_overlay_new_file.py": "disruptor_framework_overlay_new_file", + "test_framework_tool.py": "disruptor_framework_tool", } CONTRACT_HELPER = """function(disruptor_contract name) cmake_parse_arguments(PARSE_ARGV 1 CONTRACT "RETAIL" "" "") @@ -114,7 +114,7 @@ def unit_faults(builds: str, cmake: str, sources: list[str]) -> list[str]: require( ' include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/DisruptorTests.cmake")\n' in cmake - and cmake.index("find_package(Python3 REQUIRED COMPONENTS Interpreter)") < cmake.index("cmake/DisruptorTests.cmake") + and cmake.index("find_package(Python3 3.10 REQUIRED COMPONENTS Interpreter)") < cmake.index("cmake/DisruptorTests.cmake") and cmake.index("cmake/DisruptorTests.cmake") < cmake.index("disruptor_contract("), "the helpers are loaded after Python is found and before the first registration", ) diff --git a/tests/contracts/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py index 66a3e8a..8508394 100644 --- a/tests/contracts/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -22,9 +22,9 @@ def require(condition: bool, message: str) -> None: camera = read("src/game/input/vertical_camera.cpp") camera_h = read("src/game/input/vertical_camera.h") mouse = read("src/game/input/mouse_aim.cpp") -codegen = read("psxrecomp-overlay/recompiler/src/code_generator.cpp") -dirty = read("psxrecomp-overlay/runtime/src/dirty_ram_interp.c") -gpu = read("psxrecomp-overlay/runtime/src/gpu.c") +codegen = read("psxrecomp/recompiler/src/code_generator.cpp") +dirty = read("psxrecomp/runtime/src/dirty_ram_interp.c") +gpu = read("psxrecomp/runtime/src/gpu.c") cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 58adbfc..d2b6ba7 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -21,10 +21,10 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") -overlay = read("PSXRECOMP_OVERLAY_FILES.txt") -header = read("psxrecomp-overlay/runtime/include/gpu_world_depth.h") -renderer = read("psxrecomp-overlay/runtime/src/gpu_gl_renderer.c") -presenter = read("psxrecomp-overlay/runtime/src/gpu_gl_temporal.c.inc") +overlay = read("framework/FILES.txt") +header = read("psxrecomp/runtime/include/gpu_world_depth.h") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +presenter = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") menu = read("src/game/ui/dev_menu.cpp") launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") @@ -39,7 +39,7 @@ def between(source: str, opening: str, closing: str) -> str: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -loader = read("psxrecomp-overlay/recompiler/src/config_loader.cpp") +loader = read("psxrecomp/recompiler/src/config_loader.cpp") require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 @@ -160,7 +160,7 @@ def between(source: str, opening: str, closing: str) -> str: """ in loader and "s.has_depth_sorted_world" in between(loader, " if (s.has_mouse_aim", "{\n") and " bool has_depth_sorted_world = false; bool depth_sorted_world = false;\n" - in read("psxrecomp-overlay/recompiler/src/config_loader.h"), + in read("psxrecomp/recompiler/src/config_loader.h"), "the settings file keeps the choice, which is off until made", ) diff --git a/tests/unit/disruptor_language_overlay_test.cpp b/tests/unit/disruptor_language_overlay_test.cpp index c5252d8..efe22ae 100644 --- a/tests/unit/disruptor_language_overlay_test.cpp +++ b/tests/unit/disruptor_language_overlay_test.cpp @@ -67,7 +67,7 @@ extern "C" void savestate_get_integrity(uint32_t *bios_checksum, uint32_t *entry *entry_pc = g_states_entry; } -#include "../../psxrecomp-overlay/runtime/src/iso_reader_c.cpp" +#include "../../psxrecomp/runtime/src/iso_reader_c.cpp" #include "../../src/game/language/language_source.cpp" namespace { diff --git a/tests/unit/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp index 5a3e40f..9d9589a 100644 --- a/tests/unit/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -1,5 +1,5 @@ #include "psx_sdl.h" -#include "../../psxrecomp-overlay/runtime/include/gpu.h" +#include "../../psxrecomp/runtime/include/gpu.h" #include #include diff --git a/tests/unit/host_borderless_test.cpp b/tests/unit/host_borderless_test.cpp index 65dd4e7..485b5c0 100644 --- a/tests/unit/host_borderless_test.cpp +++ b/tests/unit/host_borderless_test.cpp @@ -1,4 +1,4 @@ -#include "../../psxrecomp-overlay/runtime/include/host_borderless.h" +#include "../../psxrecomp/runtime/include/host_borderless.h" #include diff --git a/tests/unit/host_pick_slot_test.cpp b/tests/unit/host_pick_slot_test.cpp index af43edd..79e5ef1 100644 --- a/tests/unit/host_pick_slot_test.cpp +++ b/tests/unit/host_pick_slot_test.cpp @@ -1,4 +1,4 @@ -#include "../../psxrecomp-overlay/runtime/include/host_pick_slot.h" +#include "../../psxrecomp/runtime/include/host_pick_slot.h" #include #include diff --git a/tools/apply_framework_overlay.py b/tools/apply_framework_overlay.py deleted file mode 100755 index c353d77..0000000 --- a/tools/apply_framework_overlay.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -"""Apply this project's reviewed source overlay to a pinned PSXRecomp tree.""" - -from __future__ import annotations - -import argparse -import filecmp -import shutil -import sys -from pathlib import Path, PurePosixPath - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "--framework", - type=Path, - required=True, - help="path to the pinned PSXRecomp checkout", - ) - parser.add_argument( - "--overlay", - type=Path, - default=PROJECT_ROOT / "psxrecomp-overlay", - help="overlay root (default: this repository's psxrecomp-overlay)", - ) - parser.add_argument( - "--manifest", - type=Path, - default=PROJECT_ROOT / "PSXRECOMP_OVERLAY_FILES.txt", - help="reviewed overlay-file manifest", - ) - args = parser.parse_args() - - framework = args.framework.resolve() - overlay = args.overlay.resolve() - manifest = args.manifest.resolve() - if not overlay.is_dir(): - parser.error(f"framework overlay does not exist: {overlay}") - if not manifest.is_file(): - parser.error(f"framework overlay manifest does not exist: {manifest}") - if not (framework / "runtime" / "runtime.cmake").is_file(): - parser.error(f"not a PSXRecomp checkout: {framework}") - - relative_paths: list[Path] = [] - new_relative_paths: set[Path] = set() - for line_number, raw_line in enumerate( - manifest.read_text(encoding="utf-8").splitlines(), start=1 - ): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - allow_new = line.startswith("+") - if allow_new: - line = line[1:].strip() - if not line: - parser.error( - f"empty new-file entry in {manifest} at line {line_number}" - ) - posix_path = PurePosixPath(line) - if posix_path.is_absolute() or ".." in posix_path.parts: - parser.error( - f"unsafe path in {manifest} at line {line_number}: {line}" - ) - relative = Path(*posix_path.parts) - relative_paths.append(relative) - if allow_new: - new_relative_paths.add(relative) - - if not relative_paths: - parser.error(f"framework overlay manifest is empty: {manifest}") - if len(relative_paths) != len(set(relative_paths)): - parser.error(f"framework overlay manifest contains duplicates: {manifest}") - - sources = [overlay / relative for relative in relative_paths] - absent_sources = [ - relative - for source, relative in zip(sources, relative_paths) - if not source.is_file() - ] - if absent_sources: - print("Reviewed framework overlay files are missing:", file=sys.stderr) - for path in absent_sources: - print(f" missing {path}", file=sys.stderr) - return 1 - - destinations = [framework / relative for relative in relative_paths] - missing_existing = [ - relative - for destination, relative in zip(destinations, relative_paths) - if relative not in new_relative_paths and not destination.is_file() - ] - invalid_new = [ - relative - for destination, relative in zip(destinations, relative_paths) - if relative in new_relative_paths - and (not destination.parent.is_dir() - or (destination.exists() and not destination.is_file())) - ] - if missing_existing or invalid_new: - print( - "Refusing to apply the overlay because the pinned framework layout " - "does not match:", - file=sys.stderr, - ) - for path in missing_existing: - print(f" missing {path}", file=sys.stderr) - for path in invalid_new: - print(f" invalid destination for new file {path}", file=sys.stderr) - return 1 - - # Manifest membership is the review boundary. Only '+' entries may add a - # file inside an existing framework directory; unmarked entries retain the - # pinned-layout typo/removal guard. - for source, destination in zip(sources, destinations): - shutil.copy2(source, destination) - - mismatched = [ - relative - for source, destination, relative in zip( - sources, destinations, relative_paths - ) - if not filecmp.cmp( - source, - destination, - shallow=False, - ) - ] - if mismatched: - print("Framework overlay verification failed:", file=sys.stderr) - for path in mismatched: - print(f" mismatch {path}", file=sys.stderr) - return 1 - - print(f"Applied and verified {len(sources)} PSXRecomp overlay files.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/framework.py b/tools/framework.py new file mode 100644 index 0000000..000f8b1 --- /dev/null +++ b/tools/framework.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Carry this project's changes to the pinned PSXRecomp checkout, in both directions. + +The changes live under framework/. PIN names the framework revision. FILES.txt lists every file the project +changes there, one path a line, with "+" before a file the project adds. patches/.patch is the diff of a +changed file against the pinned revision, and files/ is an added file whole. + + apply write each changed file as its pinned content with its patch applied, copy the added files, verify + refresh rewrite patches/ and files/ from the checkout, after an edit made there + check verify that the checkout holds exactly what framework/ describes + +Files are read and written as bytes and a patch is applied here, not by git, so the result does not depend on +a machine's line-ending settings. A patch is still a unified diff that `git apply` and `patch` read. +""" + +import argparse +import difflib +import hashlib +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +INDEX_LINE = re.compile(rb"^index ([0-9a-f]{40})\.\.([0-9a-f]{40}) 100644\n", re.MULTILINE) +HUNK_LINE = re.compile(rb"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") + + +class FrameworkError(Exception): + """A state of the project's folder or of the checkout that the command cannot work with.""" + + +def blob_hash(content: bytes) -> str: + """Returns the hash git gives a file of this content.""" + return hashlib.sha1(b"blob %d\0" % len(content) + content, usedforsecurity=False).hexdigest() + + +def lines_of(content: bytes) -> list[bytes]: + """Splits at line feeds, each line keeping its ending, a carriage return before it included. + + Raises: + FrameworkError: The content does not end with a line feed. + """ + if content and not content.endswith(b"\n"): + raise FrameworkError("a file without a final line feed cannot be patched") + return [line + b"\n" for line in content.split(b"\n")[:-1]] + + +def make_patch(path: PurePosixPath, before: bytes, after: bytes) -> bytes: + """Returns the patch that turns `before` into `after`, empty when they are the same.""" + if before == after: + return b"" + name = str(path).encode() + hunks = difflib.diff_bytes(difflib.unified_diff, lines_of(before), lines_of(after), b"a/" + name, b"b/" + name) + head = b"diff --git a/%s b/%s\nindex %s..%s 100644\n" % ( + name, + name, + blob_hash(before).encode(), + blob_hash(after).encode(), + ) + return head + b"".join(hunks) + + +def images(patch: bytes) -> tuple[str, str]: + """Returns the hashes of the file before and after a patch, as its index line gives them. + + Raises: + FrameworkError: The patch does not carry exactly one index line with full hashes. + """ + found = INDEX_LINE.findall(patch) + if len(found) != 1: + raise FrameworkError("a patch must change one file and name its full hashes") + return found[0][0].decode(), found[0][1].decode() + + +def apply_patch(before: bytes, patch: bytes) -> bytes: + """Returns `before` with the patch applied, every line of every hunk matched exactly where the hunk says. + + Raises: + FrameworkError: The patch is not for this content, or the result is not the one it names. + """ + wanted_before, wanted_after = images(patch) + if blob_hash(before) != wanted_before: + raise FrameworkError("the patch was made against other content") + source, rows = lines_of(before), lines_of(patch) + result: list[bytes] = [] + taken = index = 0 + while index < len(rows): + hunk = HUNK_LINE.match(rows[index]) + index += 1 + if hunk is None: + continue + old_left, new_left = int(hunk[2] or 1), int(hunk[4] or 1) + first = int(hunk[1]) - (1 if old_left else 0) + result += source[taken:first] + taken = first + while old_left or new_left: + kind, text = rows[index][:1], rows[index][1:] + index += 1 + if kind in b" -": + if taken >= len(source) or source[taken] != text: + raise FrameworkError(f"a hunk does not match at line {taken + 1}") + taken += 1 + old_left -= 1 + if kind in b" +": + result.append(text) + new_left -= 1 + if kind not in b" -+" or old_left < 0 or new_left < 0: + raise FrameworkError("a hunk is malformed") + after = b"".join(result + source[taken:]) + if blob_hash(after) != wanted_after: + raise FrameworkError("the patch did not give the content it names") + return after + + +@dataclass(frozen=True) +class Change: + """One file of the framework that the project changes. + + Attributes: + path: The file's path inside the framework. + added: Whether the project adds the file, as opposed to changing one the framework has. + """ + + path: PurePosixPath + added: bool + + +class Project: + """The project's own record of its changes: the pin, the list, the patches and the added files.""" + + def __init__(self, root: Path) -> None: + self.folder = root / "framework" + self.manifest = self.folder / "FILES.txt" + + def pin(self) -> str: + """Returns the framework revision the patches are made against.""" + return (self.folder / "PIN").read_text(encoding="utf-8").strip() + + def changes(self) -> list[Change]: + """Reads FILES.txt. + + Raises: + FrameworkError: The list is empty, repeats a path or names one outside the framework. + """ + changes = [] + for number, raw in enumerate(self.manifest.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + path = PurePosixPath(line.removeprefix("+").strip()) + if not path.parts or path.is_absolute() or ".." in path.parts or "\\" in line or ":" in line: + raise FrameworkError(f"unsafe path in {self.manifest.name} at line {number}: {line}") + changes.append(Change(path, line.startswith("+"))) + if not changes: + raise FrameworkError(f"{self.manifest} lists no file") + if len({change.path for change in changes}) != len(changes): + raise FrameworkError(f"{self.manifest} lists a file twice") + return changes + + def record(self, change: Change) -> Path: + """Returns where the change is kept: the patch of a changed file, or an added file itself.""" + if change.added: + return self.folder / "files" / change.path + return self.folder / "patches" / f"{change.path}.patch" + + def strays(self, changes: list[Change]) -> list[str]: + """Returns the patches and added files on disk that FILES.txt does not list.""" + listed = {self.record(change) for change in changes} + kept = {path for name in ("patches", "files") for path in (self.folder / name).rglob("*") if path.is_file()} + return sorted(path.relative_to(self.folder).as_posix() for path in kept - listed) + + +class Checkout: + """A git checkout of the framework that has the pinned revision.""" + + def __init__(self, folder: Path, pin: str) -> None: + if not (folder / "runtime" / "runtime.cmake").is_file(): + raise FrameworkError(f"not a PSXRecomp checkout: {folder}") + self.folder = folder + self.pin = pin + # No braces in an argument: an MSYS git started by a native Python expands them. + if self._git("cat-file", "-e", f"{pin}^0").returncode != 0: + raise FrameworkError(f"the checkout does not have the pinned revision {pin}") + + def _git(self, *arguments: str) -> subprocess.CompletedProcess[bytes]: + return subprocess.run(["git", "-C", str(self.folder), *arguments], capture_output=True, check=False) + + def path(self, change: Change) -> Path: + """Returns the file's place in the checkout.""" + return self.folder / change.path + + def pinned(self, change: Change) -> bytes | None: + """Returns the file as the pinned revision has it, or None when the framework has no such file.""" + done = self._git("cat-file", "blob", f"{self.pin}:{change.path}") + return done.stdout if done.returncode == 0 else None + + def present(self, change: Change) -> bytes | None: + """Returns the file as it is in the checkout now, or None when it is not there.""" + path = self.path(change) + return path.read_bytes() if path.is_file() else None + + def head(self) -> str: + """Returns the revision the checkout is at.""" + return self._git("rev-parse", "HEAD").stdout.decode().strip() + + def changed(self) -> set[str]: + """Returns the files of the framework that git sees as changed in the checkout.""" + done = self._git("-c", "core.quotepath=off", "status", "--porcelain", "--untracked-files=no", "--no-renames") + return {line[3:] for line in done.stdout.decode().splitlines() if line} + + +class Framework: + """The three commands, over a project and a checkout.""" + + def __init__(self, project: Project, checkout: Checkout) -> None: + self.project = project + self.checkout = checkout + self.changes = project.changes() + self.pinned = {change: checkout.pinned(change) for change in self.changes} + for change, pinned in self.pinned.items(): + if (pinned is None) != change.added: + raise FrameworkError( + f"{change.path} is marked '+' but the framework has it" + if change.added + else f"{change.path} is not in the framework: mark it '+' if the project adds it" + ) + + def _described(self, change: Change) -> bytes: + """Returns the file as framework/ says the checkout should hold it.""" + record = self.project.record(change) + if not record.is_file(): + raise FrameworkError(f"framework/ has no record of {change.path}: run `python tools/framework.py refresh`") + pinned = self.pinned[change] + return record.read_bytes() if pinned is None else apply_patch(pinned, record.read_bytes()) + + def _differing(self, pinned_too: bool) -> list[str]: + """Lists the files of the checkout that are not what framework/ describes. + + Args: + pinned_too: Whether a file still as the pinned revision has it, an added file being absent, also passes. + """ + differing = [] + for change in self.changes: + present = self.checkout.present(change) + same = present == self._described(change) or (pinned_too and present == self.pinned[change]) + if not same: + differing.append(str(change.path)) + return differing + + def _refuse_strays(self) -> None: + strays = self.project.strays(self.changes) + if strays: + raise FrameworkError(f"not listed in {self.project.manifest.name}: {', '.join(strays)}") + + def check(self) -> str: + """Verifies that the checkout is the pinned revision with exactly the changes framework/ describes. + + Raises: + FrameworkError: The checkout is at another revision, a file differs, a framework file is changed + without being listed, or framework/ keeps a file that FILES.txt does not list. + """ + self._refuse_strays() + if self.checkout.head() != self.checkout.pin: + raise FrameworkError(f"the checkout is at {self.checkout.head()}, not at the pinned {self.checkout.pin}") + unlisted = sorted(self.checkout.changed() - {str(change.path) for change in self.changes}) + if unlisted: + raise FrameworkError( + f"changed in the checkout and not in {self.project.manifest.name}: {', '.join(unlisted)}" + ) + differing = self._differing(pinned_too=False) + if differing: + raise FrameworkError( + f"the checkout differs from framework/ in {', '.join(differing)}: " + "run `python tools/framework.py apply`, or `refresh` to keep an edit made there" + ) + return f"The checkout holds the {len(self.changes)} framework changes." + + def apply(self, discard: bool) -> str: + """Writes each changed file as its pinned content with its patch applied, and copies the added files. + + A file that already holds that content is left alone, so a build does not see it as changed. + + Args: + discard: Whether to overwrite files of the checkout that hold an edit framework/ does not have. + + Raises: + FrameworkError: The checkout holds such an edit and `discard` is off, or a patch does not apply. + """ + self._refuse_strays() + edited = self._differing(pinned_too=True) + if edited and not discard: + raise FrameworkError( + f"the checkout holds edits that framework/ does not have, in {', '.join(edited)}: " + "run `python tools/framework.py refresh` to keep them, or `apply --discard` to drop them" + ) + described = {change: self._described(change) for change in self.changes} + for change, content in described.items(): + destination = self.checkout.path(change) + if not destination.parent.is_dir(): + raise FrameworkError(f"the framework has no folder for {change.path}") + if self.checkout.present(change) != content: + destination.write_bytes(content) + self.check() + return f"Applied and verified {len(self.changes)} framework changes." + + def refresh(self) -> str: + """Rewrites patches/ and files/ from the checkout. + + Raises: + FrameworkError: A listed file is missing from the checkout or is the same as the pinned one. + """ + for change in self.changes: + present, pinned = self.checkout.present(change), self.pinned[change] + if present is None: + raise FrameworkError(f"the checkout has no {change.path}") + content = present if pinned is None else make_patch(change.path, pinned, present) + if not content and pinned is not None: + raise FrameworkError(f"{change.path} is the pinned file: take it out of {self.project.manifest.name}") + record = self.project.record(change) + record.parent.mkdir(parents=True, exist_ok=True) + record.write_bytes(content) + self._refuse_strays() + return f"Saved {len(self.changes)} framework changes." + + +def main() -> int: + """Runs one command and returns the process's exit status.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("command", choices=["apply", "refresh", "check"]) + parser.add_argument("--framework", type=Path, default=PROJECT_ROOT / "psxrecomp", help="the PSXRecomp checkout") + parser.add_argument("--project", type=Path, default=PROJECT_ROOT, help="the folder that holds framework/") + parser.add_argument("--discard", action="store_true", help="with apply: drop edits the checkout holds") + arguments = parser.parse_args() + try: + project = Project(arguments.project.resolve()) + framework = Framework(project, Checkout(arguments.framework.resolve(), project.pin())) + match arguments.command: + case "apply": + print(framework.apply(arguments.discard)) + case "refresh": + print(framework.refresh()) + case _: + print(framework.check()) + except (FrameworkError, OSError) as e: + print(f"framework.py: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/patch_openbios_seeds.py b/tools/patch_openbios_seeds.py deleted file mode 100644 index b5ff2cb..0000000 --- a/tools/patch_openbios_seeds.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the project-local OpenBIOS framework fixes. - -The pinned PSXRecomp revision's ELF seed list marks osDbgPrintf's entry but -not the verified first data byte after it. Whole-function discovery can then -follow an unconditional branch into the adjacent data and omit osDbgPrintf. - -The same revision also emits a const-qualified object as a file-scope C -initializer. That is not a C constant expression and MSVC rejects the generated -OpenBIOS dispatch table. Emit the count as an enum constant instead. - -Both patches are deliberately small, checked, and idempotent so a fresh -framework checkout produces the same BIOS translation used by this project. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - - -ADDRESS = "0xBFC095F8" -PATCH_SEED = { - "address": ADDRESS, - "label": "data_after_osDbgPrintf", - "rationale": ( - "verified exclusive end of osDbgPrintf; prevents unconditional-branch " - "fallthrough analysis from entering adjacent data" - ), -} -EMITTER_OLD = ( - ' out += fmt::format("static const uint32_t ' - '{}psx_bios_kernel_body_count = {}u;\\n\\n",\n' - ' g_sym_prefix, kb_count);' -) -EMITTER_NEW = ( - ' out += fmt::format("enum {{ ' - '{}psx_bios_kernel_body_count = {}u }};\\n\\n",\n' - ' g_sym_prefix, kb_count);' -) - - -def parse_args() -> argparse.Namespace: - project_root = Path(__file__).resolve().parents[1] - parser = argparse.ArgumentParser() - parser.add_argument( - "--framework", - type=Path, - default=project_root / "psxrecomp", - help="path to the pinned PSXRecomp checkout", - ) - return parser.parse_args() - - -def prepare_emitter_patch(framework: Path): - emitter_path = framework / "recompiler" / "src" / "full_function_emitter.cpp" - if not emitter_path.is_file(): - raise SystemExit(f"OpenBIOS emitter source not found: {emitter_path}") - - source = emitter_path.read_text(encoding="utf-8") - old_count = source.count(EMITTER_OLD) - new_count = source.count(EMITTER_NEW) - if old_count == 0 and new_count == 1: - return emitter_path, None - if old_count != 1 or new_count != 0: - raise SystemExit( - "Unexpected OpenBIOS kernel-body count emitter; refusing to patch" - ) - - return emitter_path, source.replace(EMITTER_OLD, EMITTER_NEW) - - -def main() -> int: - args = parse_args() - seed_path = ( - args.framework - / "recompiler" - / "seeds" - / "openbios_elf_seeds.json" - ) - if not seed_path.is_file(): - raise SystemExit(f"OpenBIOS seed file not found: {seed_path}") - - with seed_path.open("r", encoding="utf-8") as stream: - document = json.load(stream) - - seeds = document.get("seeds") - if document.get("schema") != "psxrecomp phase2 seeds" or not isinstance(seeds, list): - raise SystemExit(f"Unexpected OpenBIOS seed schema: {seed_path}") - if document.get("seed_count") != len(seeds): - raise SystemExit( - "OpenBIOS seed_count does not match the seed array; refusing to patch" - ) - - target = int(ADDRESS, 16) - matches = [ - seed for seed in seeds - if int(seed.get("address", "0"), 16) == target - ] - if matches: - if len(matches) != 1 or matches[0] != PATCH_SEED: - raise SystemExit(f"Conflicting OpenBIOS seed already exists at {ADDRESS}") - seed_update = False - else: - if len(seeds) != 646: - raise SystemExit( - f"Expected the pinned 646-seed baseline, found {len(seeds)}; " - "refusing to guess" - ) - dbg_printf = [ - seed for seed in seeds - if int(seed.get("address", "0"), 16) == 0xBFC091EC - ] - if len(dbg_printf) != 1 or dbg_printf[0].get("label") != "osDbgPrintf": - raise SystemExit("Pinned osDbgPrintf seed was not found at 0xBFC091EC") - - insert_at = next( - (index for index, seed in enumerate(seeds) - if int(seed["address"], 16) > int(ADDRESS, 16)), - len(seeds), - ) - seeds.insert(insert_at, PATCH_SEED) - document["seed_count"] = len(seeds) - seed_update = True - - # Validate both pinned inputs before writing either one. A layout mismatch - # must never leave a fresh checkout with only half of this patch applied. - emitter_path, emitter_update = prepare_emitter_patch(args.framework) - - if seed_update: - with seed_path.open("w", encoding="utf-8", newline="\n") as stream: - json.dump(document, stream, indent=2) - stream.write("\n") - print(f"Applied OpenBIOS boundary seed: {ADDRESS}") - else: - print(f"OpenBIOS boundary seed already present: {ADDRESS}") - - if emitter_update is not None: - with emitter_path.open("w", encoding="utf-8", newline="\n") as stream: - stream.write(emitter_update) - print("Applied OpenBIOS constant-expression emitter fix") - else: - print("OpenBIOS constant-expression emitter fix already present") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 0c35c2ad7aa8c2fe0f44f84e10679fba44b5035f Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 04:11:53 +0300 Subject: [PATCH 14/21] refactor: let the host loop call the game through one interface --- CMakeLists.txt | 6 +- framework/FILES.txt | 1 + framework/files/runtime/include/game_host.h | 34 ++ framework/patches/runtime/src/main.cpp.patch | 376 ++++++------------ src/game/host.cpp | 47 +++ src/game/render/precision_routes.cpp | 117 ++++++ src/game/render/precision_routes.h | 14 + tests/contracts/test_capsule_contract.py | 28 +- .../contracts/test_far_rendering_contract.py | 4 +- tests/contracts/test_framework_tool.py | 6 +- .../test_geometry_provenance_contract.py | 10 +- tests/contracts/test_intro_skip_contract.py | 13 +- .../contracts/test_language_disc_contract.py | 20 +- tests/contracts/test_name_entry_contract.py | 27 +- .../test_temporal_presenter_contract.py | 2 +- 15 files changed, 401 insertions(+), 304 deletions(-) create mode 100644 framework/files/runtime/include/game_host.h create mode 100644 src/game/host.cpp create mode 100644 src/game/render/precision_routes.cpp create mode 100644 src/game/render/precision_routes.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b8c36d..35af391 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ set(DISRUPTOR_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/control_probe.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/far_rendering.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/frame_rate.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/intro_skip.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_disc.cpp" @@ -40,6 +41,7 @@ set(DISRUPTOR_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/restart.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/mouse_aim.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/precision_routes.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/render_arena.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/hud_widgets.cpp" @@ -132,13 +134,11 @@ psxrecomp_add_runtime_target(psx-runtime EXE_NAME "DisruptorRecompiled" ) target_compile_definitions(psx-runtime PRIVATE + PSX_HAS_GAME_HOST=1 PSX_HAS_DISRUPTOR_BACKDROP_TILES=1 PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1 - PSX_HAS_DISRUPTOR_CAPSULE=1 PSX_HAS_DISRUPTOR_FAR_RENDERING=1 PSX_HAS_DISRUPTOR_INTRO_SKIP=1 - PSX_HAS_DISRUPTOR_LANGUAGE=1 - PSX_HAS_DISRUPTOR_NAME_ENTRY=1 PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1 PSX_HAS_DISRUPTOR_GROWN_QUAD=1 PSX_HAS_DISRUPTOR_VERTICAL_CAMERA=1) diff --git a/framework/FILES.txt b/framework/FILES.txt index ccc9916..dedcd28 100644 --- a/framework/FILES.txt +++ b/framework/FILES.txt @@ -9,6 +9,7 @@ recompiler/tests/recompiler_patch_test.cpp recompiler/tests/test_reachable_discovery_codegen.py runtime/CMakeLists.txt runtime/include/dirty_ram_interp.h ++runtime/include/game_host.h +runtime/include/gte_precision.h runtime/include/gpu.h +runtime/include/gpu_ws_hud_scale.h diff --git a/framework/files/runtime/include/game_host.h b/framework/files/runtime/include/game_host.h new file mode 100644 index 0000000..51739c2 --- /dev/null +++ b/framework/files/runtime/include/game_host.h @@ -0,0 +1,34 @@ +#ifndef PSX_GAME_HOST_H +#define PSX_GAME_HOST_H + +/* What the host asks of the game project it is built into. A project that defines PSX_HAS_GAME_HOST supplies every one. */ + +#include +#include + +#ifdef __cplusplus +namespace PSXRecompV4 { struct UserSettings; } +/* Called when the user's settings are read, before the drive first reads the disc. True asks for a boot without the BIOS animation. */ +bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings); +extern "C" { +#endif + +/* Called when the disc's serial is known, `length` characters of it. 0 stops the start. */ +int psx_game_configure(const char *game_id, size_t length); +/* Nonzero asks for a boot without the BIOS animation whatever the settings say. */ +int psx_game_fast_boot_forced(void); +/* Called when save states have their folder, before any can be asked for. */ +void psx_game_states_configured(void); +/* Called after a save state is restored. */ +void psx_game_state_loaded(void); +/* A key went down or up. `plain` is set for a press without Ctrl, Alt or the system key and for every release. Nonzero when the game took a press. */ +int psx_game_key(int scancode, uint32_t keycode, int down, int plain); +/* Called around the sampling of the pad, outside netplay. */ +void psx_game_before_input(void); +void psx_game_after_input(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch index af20c10..12628d2 100644 --- a/framework/patches/runtime/src/main.cpp.patch +++ b/framework/patches/runtime/src/main.cpp.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp -index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1fa9a3548 100644 +index 81e048307ec9dd9fc7234a1e6285042ffab16457..721138eab4af70a7a7a35b3235af0ba6235a527f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1,9 +1,16 @@ @@ -32,29 +32,17 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #include "frame_pacing.h" #include "latency_ring.h" #include "sio.h" -@@ -59,6 +71,21 @@ +@@ -59,6 +71,9 @@ #include "game_options.h" #include "mod_plugins.h" #include "mod_runtime.h" -+#ifdef PSX_HAS_DISRUPTOR_CAPSULE -+extern "C" void disruptor_capsule_before_input(void); -+extern "C" void disruptor_capsule_after_input(void); -+extern "C" void disruptor_capsule_state_loaded(void); -+#endif -+#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -+extern "C" void disruptor_far_rendering_abandon_metrics(void); -+#ifdef PSX_HAS_DISRUPTOR_LANGUAGE -+extern "C" void disruptor_language_set_disc(const char* path); -+extern "C" void disruptor_language_settle(void); -+#endif -+#endif -+#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY -+extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); ++#ifdef PSX_HAS_GAME_HOST ++#include "game_host.h" +#endif #include "dirty_ram_interp.h" #include "crc32.h" #include "disc_identity.h" -@@ -87,6 +114,9 @@ +@@ -87,6 +102,9 @@ #endif #include #include @@ -64,7 +52,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #include #include #include -@@ -100,6 +130,11 @@ +@@ -100,6 +118,11 @@ #include #include @@ -76,7 +64,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN -@@ -108,7 +143,7 @@ +@@ -108,7 +131,7 @@ #include #include #include @@ -85,16 +73,17 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #else #include #include -@@ -132,7 +167,131 @@ +@@ -131,6 +154,20 @@ + #ifndef DEFAULT_DEBUG_PORT #error DEFAULT_DEBUG_PORT must be defined by the runtime target. #endif - ++ +/* SDL_RenderSetVSync was added in SDL 2.0.18. Keep the explicitly supported + * SDL2 fallback buildable against older distro/toolchain copies; its OpenGL + * renderer can still release the swap interval through the public GL API. + * Use a project-local name because SDL3's psx_sdl.h already defines its own + * psx_sdl_render_set_vsync compatibility helper. */ -+static int disruptor_sdl_render_set_vsync_compat(SDL_Renderer *renderer, int enabled) { ++static int sdl_render_set_vsync_compat(SDL_Renderer *renderer, int enabled) { +#if defined(PSX_SDL3) || SDL_VERSION_ATLEAST(2, 0, 18) + return SDL_RenderSetVSync(renderer, enabled); +#else @@ -102,122 +91,10 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 + return SDL_GL_SetSwapInterval(enabled ? 1 : 0); +#endif +} -+ + extern "C" uint64_t gte_get_exec_count(void); -+ -+/* Disruptor does not use SWC2 for its resident world-projection loops. These -+ * two reviewed MFC2 sites snapshot SXY2. Six ordinary SW sites publish into -+ * main RAM and one publishes into nine exact scratchpad slots, each only after -+ * an exact instruction and committed-word check. Twenty-four reviewed LW/SW -+ * pairs then carry those proven words into GP0 packets. Keeping every route -+ * behind the game serial prevents another title at the same PCs from -+ * inheriting it. */ -+static int configure_disruptor_precision_store_routes( -+ const std::string& game_id) { -+ gte_precision_store_pc_routes_reset(); -+ if (game_id != "SLUS-00224") return 1; -+ static const uint32_t mfc2_pcs[] = { -+ 0x8004633Cu, -+ 0x8004795Cu, -+ }; -+ static const uint32_t store_pcs[] = { -+ 0x8004641Cu, -+ 0x800464BCu, -+ 0x80046524u, -+ 0x80046540u, -+ 0x80046594u, -+ 0x800465D8u, -+ }; -+ struct PrecisionCopyRoute { -+ uint32_t load_pc; -+ uint32_t load_instruction; -+ uint32_t store_pc; -+ uint32_t store_instruction; -+ uint8_t gpr; -+ }; -+ static const PrecisionCopyRoute copy_routes[] = { -+ {0x80046C4Cu, 0x8D0C0004u, 0x80046D04u, 0xAF2C0008u, 12u}, -+ {0x80046C50u, 0x8D2D0004u, 0x80046D08u, 0xAF2D0014u, 13u}, -+ {0x80046C54u, 0x8D4E0004u, 0x80046D10u, 0xAF2E002Cu, 14u}, -+ {0x80046C60u, 0x8D6F0004u, 0x80046D0Cu, 0xAF2F0020u, 15u}, -+ {0x80047A80u, 0x8CA80084u, 0x80047A90u, 0xAF280008u, 8u}, -+ {0x80047A84u, 0x8CA900A4u, 0x80047A94u, 0xAF290014u, 9u}, -+ {0x80047A88u, 0x8CAA00C4u, 0x80047A9Cu, 0xAF2A002Cu, 10u}, -+ {0x80047A8Cu, 0x8CAB00BCu, 0x80047A98u, 0xAF2B0020u, 11u}, -+ {0x80047B40u, 0x8CA800A4u, 0x80047B50u, 0xAF280008u, 8u}, -+ {0x80047B44u, 0x8CA9008Cu, 0x80047B54u, 0xAF290014u, 9u}, -+ {0x80047B48u, 0x8CAA00ACu, 0x80047B5Cu, 0xAF2A002Cu, 10u}, -+ {0x80047B4Cu, 0x8CAB00C4u, 0x80047B58u, 0xAF2B0020u, 11u}, -+ {0x80047C00u, 0x8CA800C4u, 0x80047C10u, 0xAF280008u, 8u}, -+ {0x80047C04u, 0x8CA900ACu, 0x80047C14u, 0xAF290014u, 9u}, -+ {0x80047C08u, 0x8CAA0094u, 0x80047C1Cu, 0xAF2A002Cu, 10u}, -+ {0x80047C0Cu, 0x8CAB00B4u, 0x80047C18u, 0xAF2B0020u, 11u}, -+ {0x80047CC4u, 0x8CA800BCu, 0x80047CD4u, 0xAF280008u, 8u}, -+ {0x80047CC8u, 0x8CA900C4u, 0x80047CD8u, 0xAF290014u, 9u}, -+ {0x80047CCCu, 0x8CAA00B4u, 0x80047CE0u, 0xAF2A002Cu, 10u}, -+ {0x80047CD0u, 0x8CAB009Cu, 0x80047CDCu, 0xAF2B0020u, 11u}, -+ {0x80047D6Cu, 0x8CA80084u, 0x80047D7Cu, 0xAF280008u, 8u}, -+ {0x80047D70u, 0x8CA9008Cu, 0x80047D80u, 0xAF290014u, 9u}, -+ {0x80047D74u, 0x8CAA0094u, 0x80047D88u, 0xAF2A002Cu, 10u}, -+ {0x80047D78u, 0x8CAB009Cu, 0x80047D84u, 0xAF2B0020u, 11u}, -+ }; -+ for (uint32_t pc : mfc2_pcs) { -+ if (!gte_precision_mfc2_pc_route_add( -+ pc, 0x480A7000u, 14u)) { -+ std::fprintf(stderr, -+ "psxrecomp: failed to register Disruptor SXY2 MFC2 PC " -+ "0x%08X\n", pc); -+ return 0; -+ } -+ } -+ for (uint32_t pc : store_pcs) { -+ if (!gte_precision_store_pc_route_add( -+ pc, 0xACAA0004u, 14u)) { -+ std::fprintf(stderr, -+ "psxrecomp: failed to register Disruptor SXY2 store PC " -+ "0x%08X\n", pc); -+ return 0; -+ } -+ } -+ if (!gte_precision_scratch_store_pc_route_add( -+ 0x80047A0Cu, 0xACAA0004u, 14u, -+ 0x1F800084u, 8u, 9u)) { -+ std::fprintf(stderr, -+ "psxrecomp: failed to register Disruptor scratchpad SXY2 " -+ "store route 0x80047A0C\n"); -+ return 0; -+ } -+ /* 0x80047980..0x800479E0 pin x to -256..576 and y to -256..496 before that store. */ -+ if (!gte_precision_scratch_store_pc_route_clamp( -+ 0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) { -+ std::fprintf(stderr, -+ "psxrecomp: failed to register the guard band of Disruptor's " -+ "scratchpad SXY2 store route 0x80047A0C\n"); -+ return 0; -+ } -+ for (const PrecisionCopyRoute& route : copy_routes) { -+ if (!gte_precision_copy_pc_route_add( -+ route.load_pc, route.load_instruction, -+ route.store_pc, route.store_instruction, route.gpr)) { -+ std::fprintf(stderr, -+ "psxrecomp: failed to register Disruptor projection-copy " -+ "route 0x%08X -> 0x%08X\n", -+ route.load_pc, route.store_pc); -+ return 0; -+ } -+ } -+ std::fprintf(stdout, -+ "psxrecomp: registered %zu MFC2 captures, %zu main-RAM projection " -+ "stores, 1 scratchpad projection store and %zu packet-copy routes\n", -+ sizeof(mfc2_pcs) / sizeof(mfc2_pcs[0]), -+ sizeof(store_pcs) / sizeof(store_pcs[0]), -+ sizeof(copy_routes) / sizeof(copy_routes[0])); -+ return 1; -+} - /* Cross-language globals defined in C translation units. Declared extern "C" at - * file scope so MSVC gives them C linkage (matching the C definitions); without -@@ -164,11 +323,12 @@ +@@ -164,11 +201,12 @@ * every install must arm it — not just repo checkouts that happen to carry a * loose EXE copy next to game.toml. Source order: * 1. The local EXE file from game.toml (dev checkouts; recompiler input). @@ -232,7 +109,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 const std::string &disc_path) { const uint32_t phys_lo = load_address & 0x1FFFFFFFu; /* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */ -@@ -177,9 +337,12 @@ +@@ -177,9 +215,12 @@ if (ef) { std::streamsize sz = ef.tellg(); if (sz > 2048) { @@ -248,7 +125,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 ef.seekg(2048, std::ios::beg); if (ef.read((char *)img, img_len)) { dirty_ram_register_text_image(phys_lo, img, img_len); -@@ -193,7 +356,10 @@ +@@ -193,7 +234,10 @@ } } } @@ -260,7 +137,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (!disc_path.empty()) { PS1::ISOReader iso; if (iso.Open(disc_path)) { -@@ -239,17 +405,38 @@ +@@ -239,17 +283,38 @@ uint8_t *file = (uint8_t *)std::malloc(ent.size); if (file) { size_t got = iso.ReadFile(boot_name, file, ent.size); @@ -306,7 +183,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 return; } } -@@ -317,6 +504,10 @@ +@@ -317,6 +382,10 @@ * 640*scale x 512*scale. Allocated once the supersampling scale is known * (sized for the native 640x512 when supersampling is off). */ static uint32_t* sdl_pixel_buf = nullptr; @@ -317,7 +194,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 typedef void (*ModFrameHook)(void); -@@ -337,8 +528,118 @@ +@@ -337,8 +406,118 @@ for (size_t i = 0; i < count; i++) hooks[i](); } @@ -436,7 +313,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 static std::atomic g_smooth_60fps{0}; struct Smooth60State { -@@ -352,6 +653,7 @@ +@@ -352,6 +531,7 @@ }; static Smooth60State g_smooth_60_state; @@ -444,7 +321,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Present-path session state — must reset on rematch (function-local statics * survive soft-return and poison FMV/FPS after session_reboot). */ -@@ -379,12 +681,14 @@ +@@ -379,12 +559,14 @@ } static void smooth_60_reset(void) { @@ -459,7 +336,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } static void present_session_reset(void) { -@@ -402,10 +706,21 @@ +@@ -402,10 +584,18 @@ smooth_60_reset(); } @@ -472,16 +349,13 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 * longjmp). Clears present latches and forces the next vblank to show the * restored VRAM — including a blank if display was disabled in the snapshot. */ extern "C" void psx_frontend_on_savestate_loaded(void) { -+#ifdef PSX_HAS_DISRUPTOR_CAPSULE -+ disruptor_capsule_state_loaded(); -+#endif -+#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -+ disruptor_far_rendering_abandon_metrics(); ++#ifdef PSX_HAS_GAME_HOST ++ psx_game_state_loaded(); +#endif s_disabled_frame_presented = false; s_force_present_after_load = true; smooth_60_reset(); -@@ -419,8 +734,10 @@ +@@ -419,8 +609,10 @@ * tiles + force several swaps so the window actually updates. Safe no-op * when the GL pipeline was never brought up. */ gl_renderer_invalidate_present(); @@ -494,7 +368,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { uint64_t hash = 1469598103934665603ull; const size_t step = std::max(1, count / 4096u); -@@ -504,6 +821,13 @@ +@@ -504,6 +696,13 @@ extern "C" void psx_smooth_60fps_set(int enabled) { g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release); } @@ -508,7 +382,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #if defined(PSX_WEB) extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { psx_smooth_60fps_set(enabled); -@@ -515,6 +839,8 @@ +@@ -515,6 +714,8 @@ static bool g_video_aa = true; /* linear present filtering */ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; @@ -517,7 +391,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ -@@ -599,10 +925,17 @@ +@@ -599,10 +800,17 @@ static int g_video_vsync = 1; static int g_frame_interpolation = 0; static int g_frame_interpolation_fps = 0; @@ -535,7 +409,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 static int g_mod_controller_mode_override[2] = { -1, -1 }; static_assert((int)PSX_MOD_CONTROLLER_HYBRID == (int)PSXRecompV4::PAD_MODE_HYBRID); -@@ -630,6 +963,111 @@ +@@ -630,6 +838,111 @@ return 0; /* windowed */ } @@ -647,7 +521,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ extern "C" int cdrom_xa_stream_active(void); extern "C" uint32_t mdec_get_decode_count(void); -@@ -655,12 +1093,105 @@ +@@ -655,12 +968,105 @@ * in config_loader.h). */ static int g_video_aspect_num = 4; static int g_video_aspect_den = 3; @@ -753,7 +627,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { -@@ -738,6 +1269,10 @@ +@@ -738,6 +1144,10 @@ extern "C" int psx_mod_set_frame_interpolation( uint32_t frames_per_second) { @@ -764,7 +638,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (frames_per_second != 0 && (frames_per_second < 60 || frames_per_second > 1000)) { std::fprintf(stderr, -@@ -768,10 +1303,15 @@ +@@ -768,10 +1178,15 @@ "interpolation; guest timing remains stock\n"); } return 1; @@ -780,7 +654,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) { std::fprintf(stderr, -@@ -784,6 +1324,7 @@ +@@ -784,6 +1199,7 @@ blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE ? "motion-adaptive clarity" : "linear crossfade"); return 1; @@ -788,7 +662,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { -@@ -867,6 +1408,8 @@ +@@ -867,6 +1283,8 @@ * 4:3, wider for wide aspects. Height is always 480*scale. Set at window * creation alongside SDL_RenderSetLogicalSize. */ static int g_logical_w = 640; @@ -797,7 +671,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Clamp a requested window width to the primary display's usable area so an * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps -@@ -886,6 +1429,103 @@ +@@ -886,6 +1304,103 @@ static int aspect_gcd(int a, int b) { while (b) { int t = a % b; a = b; b = t; } return a > 0 ? a : 1; @@ -901,7 +775,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } /* Follow the host window without feeding its absolute pixel size into guest -@@ -911,23 +1551,7 @@ +@@ -911,23 +1426,7 @@ den /= divisor; } if (num == g_video_aspect_num && den == g_video_aspect_den) return; @@ -926,7 +800,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } /* SDL GL attributes are global inputs to the next context creation. Set the -@@ -955,8 +1579,107 @@ +@@ -955,8 +1454,107 @@ } extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } @@ -1036,7 +910,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 * to force the software readout path instead — a diagnostic/fallback that also * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ -@@ -994,6 +1717,16 @@ +@@ -994,6 +1592,16 @@ static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; static SDL_AudioDeviceID sdl_audio_device; static int16_t sdl_audio_buf[2048 * 2]; @@ -1053,7 +927,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under * SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio -@@ -1026,10 +1759,36 @@ +@@ -1026,10 +1634,36 @@ int g_audio_host_rate = 44100; } @@ -1090,7 +964,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* T3 tap in bridge mode: the exact device-rate bytes the host consumes. * This callback is the tap's single writer while the bridge is active. */ audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast(stream), -@@ -1174,22 +1933,40 @@ +@@ -1174,22 +1808,40 @@ return false; } #ifdef _WIN32 @@ -1147,7 +1021,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #else (void)filter; (void)out; -@@ -1205,6 +1982,46 @@ +@@ -1205,6 +1857,46 @@ title, cli_flag); return false; #endif @@ -1194,7 +1068,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } static std::string uppercase_ascii(std::string s) { -@@ -1590,6 +2407,9 @@ +@@ -1590,6 +2282,9 @@ } static void shutdown_runtime(void) { @@ -1204,7 +1078,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the * off-thread JIT worker here; the worker no longer exists.) */ psx_netplay_shutdown(); -@@ -1607,6 +2427,8 @@ +@@ -1607,6 +2302,8 @@ psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ sdl_audio_device = 0; } @@ -1213,7 +1087,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } close_controller(); debug_server_shutdown(); -@@ -1615,6 +2437,7 @@ +@@ -1615,6 +2312,7 @@ /* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL * subsystems and the lobby WebSocket intact for the next launcher session. */ static void teardown_game_session_keep_lobby(void) { @@ -1221,7 +1095,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 netplay_host_present_restore(); psx_netplay_shutdown(); memcard_flush_all(); -@@ -1623,6 +2446,8 @@ +@@ -1623,6 +2321,8 @@ psx_sdl_audio_close(sdl_audio_device); sdl_audio_device = 0; } @@ -1230,7 +1104,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } close_controller(); if (g_vk_active) { -@@ -1637,6 +2462,7 @@ +@@ -1637,6 +2337,7 @@ if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } @@ -1238,7 +1112,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 psx_lobby_set_ready(0); psx_lobby_clear_launch_pending(); psx_clear_return_to_lobby(); -@@ -1653,6 +2479,24 @@ +@@ -1653,6 +2354,24 @@ } } @@ -1263,7 +1137,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Fade-in state: samples of rising ramp still to apply after an unmute. * Consumed by sdl_audio_pump across however many pump calls it spans. * MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many -@@ -1661,7 +2505,7 @@ +@@ -1661,7 +2380,7 @@ static int sdl_audio_fadein_left = 0; static void sdl_audio_pump(bool discard_output = false) { @@ -1272,7 +1146,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; const bool legacy = audio_legacy_mode(); -@@ -1751,6 +2595,8 @@ +@@ -1751,6 +2470,8 @@ sdl_audio_fadein_left -= ramp; } if (legacy) { @@ -1281,7 +1155,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, -@@ -1883,6 +2729,7 @@ +@@ -1883,6 +2604,7 @@ g_runtime_perf.frequency = SDL_GetPerformanceFrequency(); if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1; @@ -1289,7 +1163,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) { char *tail = nullptr; long requested = std::strtol(interval, &tail, 10); -@@ -2026,12 +2873,32 @@ +@@ -2026,12 +2748,32 @@ if (end >= start) *total += end - start; } @@ -1322,7 +1196,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (!g_runtime_perf.enabled) return; uint64_t now = SDL_GetPerformanceCounter(); -@@ -2052,12 +2919,31 @@ +@@ -2052,12 +2794,31 @@ uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us, &overlay_load_last_us); @@ -1354,7 +1228,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 for (int i = 0; i < 6; i++) last_up[i] = up[i]; have_last = true; return; -@@ -2072,6 +2958,14 @@ +@@ -2072,6 +2833,14 @@ "cpu=%.1f tex=%.1f draw=%.1f ms/s; " "work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, " "dirty=%.0f insn/s %.0f dispatch/s; " @@ -1369,7 +1243,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " "shadow=+%llu div=+%llu first_div=0x%08X " "loads=+%u revalidations=+%u " -@@ -2096,6 +2990,26 @@ +@@ -2096,6 +2865,26 @@ runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt, (double)(current.dirty_insns - last.dirty_insns) / dt, (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, @@ -1396,7 +1270,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 (unsigned long long)(current.overlay_native - last.overlay_native), (unsigned long long)(current.overlay_interp - last.overlay_interp), current.overlay_hot_native_pc, -@@ -2118,11 +3032,13 @@ +@@ -2118,11 +2907,13 @@ last_underruns = underruns; last_overflows = overflows; last_overlay_load_us = overlay_load_us; @@ -1411,7 +1285,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 { /* Tag audio events with the vblank frame counter. */ extern uint64_t s_frame_count; audio_trace_note_frame((uint32_t)s_frame_count); -@@ -2145,11 +3061,14 @@ +@@ -2145,11 +2936,14 @@ spu_render(sdl_audio_buf, tail); sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); @@ -1428,7 +1302,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 psx_sdl_audio_lock(sdl_audio_device); rab_push(&s_drc, sdl_audio_buf, tail); psx_sdl_audio_unlock(sdl_audio_device); -@@ -2506,6 +3425,13 @@ +@@ -2506,6 +3300,13 @@ static void load_input_config(const char* argv0) { set_default_controller_mapping(); @@ -1442,7 +1316,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 namespace fs = std::filesystem; fs::path config_path = exe_dir_from_argv(argv0) / "input.ini"; std::error_code ec; -@@ -2557,11 +3483,6 @@ +@@ -2557,11 +3358,6 @@ for (auto& entry : controller_map) entry.sources.clear(); } @@ -1454,7 +1328,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } static void close_player(PlayerInput& p) { -@@ -3095,6 +4016,16 @@ +@@ -3095,6 +3891,16 @@ * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ static void capture_local_human_pad(PsxNetPad* out) { @@ -1471,7 +1345,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 int idx = psx_netplay_input_player(); if (idx < 0 || idx > 1) idx = 0; if (!capture_pad_slot_exclusive(idx, out)) { -@@ -3228,11 +4159,12 @@ +@@ -3228,11 +4034,12 @@ if (!g_headless) { SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -1485,7 +1359,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #if defined(PSX_SDL3) ev.key.key == SDLK_ESCAPE) { #else -@@ -3261,6 +4193,24 @@ +@@ -3261,6 +4068,24 @@ static void sample_pad_into_sio(int override) { if (override >= 0) { apply_input_override_to_sio(override); @@ -1510,7 +1384,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 return; } for (int s = 0; s < 2; s++) { -@@ -3482,8 +4432,51 @@ +@@ -3482,8 +4307,51 @@ } } @@ -1562,7 +1436,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #ifndef PSX_NO_DEBUG_TOOLS debug_server_set_fmv_quiet(mdec_recently_active(2)); /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ -@@ -3582,6 +4575,7 @@ +@@ -3582,6 +4450,7 @@ /* Pump SDL events to prevent window freeze. */ SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -1570,17 +1444,17 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (ev.type == SDL_QUIT) { if (psx_netplay_active()) { netplay_soft_exit("sdl_window_close"); -@@ -3603,18 +4597,34 @@ +@@ -3603,18 +4472,34 @@ close_controller(); refresh_player_devices(); } - } else if (ev.type == SDL_KEYDOWN) { -+#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY ++#ifdef PSX_HAS_GAME_HOST + } else if (ev.type == SDL_KEYUP) { +#if defined(PSX_SDL3) -+ (void)disruptor_name_entry_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1); ++ (void)psx_game_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1); +#else -+ (void)disruptor_name_entry_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1); ++ (void)psx_game_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1); +#endif +#endif + } else if (!ui_consumed && ev.type == SDL_KEYDOWN) { @@ -1597,16 +1471,16 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 netplay_soft_exit("netplay_escape"); return; } -+#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY -+ if (disruptor_name_entry_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI)))) -+ continue; /* the key is a letter of a save's name */ ++#ifdef PSX_HAS_GAME_HOST ++ if (psx_game_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI)))) ++ continue; /* the game took the key */ +#else + (void)scancode; +#endif /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. * (F11 is a save slot per the user's spec, so fullscreen is * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ -@@ -3649,21 +4659,34 @@ +@@ -3649,21 +4534,34 @@ * detects "currently fullscreen, either mode". */ else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { @@ -1652,27 +1526,27 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } /* Sample each player's device and feed the matching SIO pad slot. -@@ -3697,6 +4720,9 @@ +@@ -3697,6 +4595,9 @@ } } netplay_tail(override); -+#ifdef PSX_HAS_DISRUPTOR_CAPSULE -+ if (!psx_netplay_active()) disruptor_capsule_before_input(); ++#ifdef PSX_HAS_GAME_HOST ++ if (!psx_netplay_active()) psx_game_before_input(); +#endif if (psx_netplay_active()) { psx_netplay_finish_frame(); } else if (g_headless) { -@@ -3704,6 +4730,9 @@ +@@ -3704,6 +4605,9 @@ } else { sample_pad_into_sio(override); } -+#ifdef PSX_HAS_DISRUPTOR_CAPSULE -+ if (!psx_netplay_active()) disruptor_capsule_after_input(); ++#ifdef PSX_HAS_GAME_HOST ++ if (!psx_netplay_active()) psx_game_after_input(); +#endif /* Latency ring: open this present cycle's slot, stamping when input was * sampled into SIO. Always-on; queried via the debug server "latency". */ -@@ -3789,16 +4818,21 @@ +@@ -3789,16 +4693,21 @@ } } @@ -1698,7 +1572,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 netplay_tail.skip_pace(); return; } -@@ -3813,7 +4847,7 @@ +@@ -3813,7 +4722,7 @@ } #endif @@ -1707,7 +1581,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 * SDL_RenderPresent. The recompiled BIOS still advances simulated * cycles every vblank, so the BIOS proceeds at whatever rate the host * CPU sustains without graphics-driver vsync overhead. Present once -@@ -3822,7 +4856,8 @@ +@@ -3822,7 +4731,8 @@ const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; const int TURBO_PRESENT_EVERY = 30; @@ -1717,7 +1591,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; if (turbo_skip != 0) { netplay_tail.skip_pace(); -@@ -3916,30 +4951,12 @@ +@@ -3916,30 +4826,12 @@ /* Mod hooks. Run after all normal input sampling. */ mod_call_frame_hooks(); @@ -1749,7 +1623,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* ---- Display from our VRAM ---- */ uint32_t w = 0, h = 0; -@@ -3963,7 +4980,8 @@ +@@ -3963,7 +4855,8 @@ /* After savestate load, always restage once even if we blanked * earlier in the session — otherwise the window keeps a stale * pre-load frame while vblanks (and FPS) keep advancing. */ @@ -1759,7 +1633,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 s_disabled_frame_presented = true; s_force_present_after_load = false; if (g_gl_active) { -@@ -3988,7 +5006,7 @@ +@@ -3988,7 +4881,7 @@ * of truth, shared with the GTE/GPU squash so content and present stay * locked: we squash IFF we stretch. depth24 always classifies as FMV * even if the ws layer is not engaged yet (4:3 titles). */ @@ -1768,7 +1642,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* MDEC movies are already decoded at their authored cadence and are * CPU/upload heavy. High-refresh crossfades only contend with decoding * and can starve audio, so present native-4:3/MDEC phases directly. -@@ -3996,19 +5014,25 @@ +@@ -3996,19 +4889,25 @@ * decode; the activity stamp also covers authentic 4:3 configurations. */ if (g_gl_active) gl_renderer_set_interpolation_suspended( @@ -1800,7 +1674,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Native-wide invariant: canonical (320-wide) content is NEVER * stretched across the wide window — a game frame that cannot present -@@ -4045,6 +5069,22 @@ +@@ -4045,6 +4944,22 @@ if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) return; @@ -1823,7 +1697,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } else { gl_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, -@@ -4142,6 +5182,7 @@ +@@ -4142,6 +5057,7 @@ h * (uint32_t)active_scale, !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); @@ -1831,7 +1705,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Frame blending (CRT-persistence masker for 30fps double-buffered * content). Some games (e.g. Crash Bash menus/characters) leave a * dynamic object in only one of the two display buffers per 30fps cycle, -@@ -4183,6 +5224,7 @@ +@@ -4183,6 +5099,7 @@ } } } @@ -1839,16 +1713,16 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } /* Update only the active display rectangle. The backing texture is sized -@@ -4245,7 +5287,7 @@ +@@ -4245,7 +5162,7 @@ if (!g_present_vsync_disabled && present_ms > 250) { g_present_slow_count++; if (g_present_slow_count >= 3 && - SDL_RenderSetVSync(sdl_renderer, 0) == 0) { -+ disruptor_sdl_render_set_vsync_compat(sdl_renderer, 0) == 0) { ++ sdl_render_set_vsync_compat(sdl_renderer, 0) == 0) { g_present_vsync_disabled = 1; } } -@@ -4867,9 +5909,11 @@ +@@ -4867,9 +5784,11 @@ #endif int main(int argc, char** argv) { @@ -1863,7 +1737,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 std::fprintf(stderr, "psxrecomp: main() entered\n"); std::fflush(stderr); -@@ -5047,7 +6091,11 @@ +@@ -5047,7 +5966,11 @@ bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ bool ws_ultrawide_offered = false; bool ws_adaptive_view_supported = false; @@ -1875,7 +1749,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 bool skip_fmv_offered = true; bool turbo_loads_offered = true; bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ -@@ -5084,6 +6132,7 @@ +@@ -5084,6 +6007,7 @@ * path is resolved (arm_text_image_guard). */ std::string text_guard_exe_path; uint32_t text_guard_load_addr = 0; @@ -1883,7 +1757,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (game_config_path) { try { -@@ -5143,8 +6192,10 @@ +@@ -5143,8 +6067,10 @@ g_video_aspect_den = gc.runtime.video_aspect_den; g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; @@ -1894,7 +1768,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; if (gc.runtime.video_fmv_skip_end_total) -@@ -5308,8 +6359,10 @@ +@@ -5308,8 +6234,10 @@ ws_offered = gc.ws_offered; ws_ultrawide_offered = gc.ws_ultrawide_offered; ws_adaptive_view_supported = gc.ws_adaptive_view; @@ -1905,7 +1779,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 skip_fmv_offered = gc.runtime.video_offer_skip_fmv; turbo_loads_offered = gc.runtime.offer_turbo_loads; vulkan_offered = gc.vulkan_offered; -@@ -5363,6 +6416,7 @@ +@@ -5363,6 +6291,7 @@ * user's disc image instead. */ text_guard_exe_path = gc.exe_path.string(); text_guard_load_addr = gc.load_address; @@ -1913,16 +1787,18 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* HLE-tier scheduler subsystem replacement default (env * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); -@@ -5567,6 +6621,8 @@ +@@ -5567,6 +6496,10 @@ } } -+ if (!configure_disruptor_precision_store_routes(game_id)) return 1; ++#ifdef PSX_HAS_GAME_HOST ++ if (!psx_game_configure(game_id.data(), game_id.size())) return 1; ++#endif + if (!game_name.empty()) s_picker_game_name = game_name; /* Layer the launcher-written settings.toml (next to the exe) over the -@@ -5580,6 +6636,9 @@ +@@ -5580,6 +6513,9 @@ { std::filesystem::path settings_path = exe_dir_from_argv(argv[0]) / "settings.toml"; @@ -1932,25 +1808,18 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 #if defined(RECOMP_LAUNCHER) g_lnch_settings_path = settings_path; #endif -@@ -5640,6 +6699,17 @@ +@@ -5640,6 +6576,10 @@ * Config + env own it until a launcher toggle exists; if one is * added, restore this line together with the UI. The seed write * below still persists the effective value for inspection. */ -+#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP -+ /* Disruptor's intro switch has a control and covers the BIOS animation too. */ -+ if (us.has_skip_intro && us.skip_intro && -+ !std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) -+ fast_boot = true; -+#endif -+#ifdef PSX_HAS_DISRUPTOR_LANGUAGE -+ /* Named before the drive first reads the game disc. */ -+ if (us.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) -+ disruptor_language_set_disc(us.language_disc.c_str()); ++#ifdef PSX_HAS_GAME_HOST ++ /* Before the drive first reads the game disc. */ ++ if (psx_game_settings_loaded(us)) fast_boot = true; +#endif if (us.has_bios_hle) bios_hle_requested = us.bios_hle; if (us.has_fullscreen) g_fullscreen = us.fullscreen; if (us.has_aspect_ratio) { -@@ -5648,6 +6718,10 @@ +@@ -5648,6 +6588,10 @@ } if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; @@ -1961,7 +1830,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- * as-hide treatment as lock_mode / ws_offered below) so a stale * settings.toml or launcher-less build can never point a bundled -@@ -5682,10 +6756,30 @@ +@@ -5682,10 +6626,30 @@ if (us.has_deadzone) resolved_deadzone = us.deadzone; if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; if (us.has_vsync) g_video_vsync = us.vsync; @@ -1992,7 +1861,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || us.has_parappa_timing_extra_late) { parappa_apply_timing_setting( -@@ -5782,25 +6876,65 @@ +@@ -5782,25 +6746,65 @@ g_video_aspect_den = ws_offered ? 9 : 3; } if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; @@ -2063,7 +1932,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 strcmp(e, "1") == 0) { g_frame_interpolation_blend_default = PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; -@@ -5810,6 +6944,11 @@ +@@ -5810,6 +6814,11 @@ } g_frame_interpolation_blend = g_frame_interpolation_blend_default; } @@ -2075,7 +1944,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 { const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); -@@ -5916,7 +7055,14 @@ +@@ -5916,7 +6925,14 @@ if (want_launcher) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ @@ -2091,7 +1960,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 seed.renderer = g_video_renderer; seed.has_renderer = true; seed.supersampling = g_video_scale; seed.has_supersampling = true; seed.antialiasing = g_video_aa; seed.has_antialiasing = true; -@@ -6415,6 +7561,12 @@ +@@ -6415,6 +7431,12 @@ g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; if (!turbo_loads_offered) g_turbo_loads_enabled = 0; @@ -2104,7 +1973,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); if (g_mod_controller_mode_override[0] >= 0) -@@ -6527,6 +7679,22 @@ +@@ -6527,6 +7549,22 @@ gr_set_scale(g_video_scale); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); @@ -2127,7 +1996,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Display aspect. Identity at the default 4:3. The present letterbox uses * this aspect; native-wide fills it with a genuinely wider frame (no * stretch), squash mode stretches the 4:3 frame into it. */ -@@ -6633,6 +7801,7 @@ +@@ -6633,6 +7671,7 @@ * the local EXE file (dev checkouts) and the disc image (every install). */ if (game_config_path) arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, @@ -2135,7 +2004,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 disc_path_str); mod_runtime_enable_disc_patches(); { -@@ -6691,6 +7860,7 @@ +@@ -6691,6 +7730,7 @@ if (g_headless) { std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); @@ -2143,7 +2012,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } else { /* ---- SDL init ---- */ /* Scale quality governs SDL's logical-size -> window scaling. Linear when -@@ -6780,6 +7950,17 @@ +@@ -6780,6 +7820,17 @@ std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); return 1; } @@ -2161,7 +2030,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank -@@ -6909,6 +8090,7 @@ +@@ -6909,6 +7960,7 @@ std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); return 1; } @@ -2169,7 +2038,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 if (!g_gl_active && !g_vk_active) { sdl_texture = SDL_CreateTexture( -@@ -6924,6 +8106,12 @@ +@@ -6924,6 +7976,12 @@ SDL_SetTextureScaleMode(sdl_texture, g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); } @@ -2182,24 +2051,23 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..975e230515281eaf1cbf5f878abc65e1 } /* Register vblank presentation callback. */ -@@ -7012,6 +8200,10 @@ +@@ -7012,6 +8070,9 @@ bios_hle_requested = (e[0] && e[0] != '0'); if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) bios_hle_keep_intro = (e[0] && e[0] != '0'); -+#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP -+ if (const char* e = std::getenv("PSX_DISRUPTOR_SKIP_INTRO")) -+ if (e[0] && e[0] != '0') fast_boot = true; ++#ifdef PSX_HAS_GAME_HOST ++ if (psx_game_fast_boot_forced()) fast_boot = true; +#endif PsxBiosHleRequest req; req.bios_hle = bios_hle_requested ? 1 : 0; -@@ -7062,6 +8254,10 @@ +@@ -7062,6 +8123,10 @@ if (game_entry_pc != 0) { savestate_configure(memcard_dir.string().c_str(), memory_get_bios_checksum(), game_entry_pc); -+#ifdef PSX_HAS_DISRUPTOR_LANGUAGE -+ /* Before any state can be asked for: a language disc's states have a folder of their own. */ -+ disruptor_language_settle(); ++#ifdef PSX_HAS_GAME_HOST ++ /* Before any state can be asked for. */ ++ psx_game_states_configured(); +#endif /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) * at the next safe block boundary after boot. */ diff --git a/src/game/host.cpp b/src/game/host.cpp new file mode 100644 index 0000000..e2d4a10 --- /dev/null +++ b/src/game/host.cpp @@ -0,0 +1,47 @@ +#include "game_host.h" + +#include "config_loader.h" +#include "game/language/language.h" +#include "game/render/far_rendering.h" +#include "game/render/precision_routes.h" +#include "game/session/capsule.h" + +#include +#include +#include + +extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); + +bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings) { + if (settings.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) + disruptor_language_set_disc(settings.language_disc.c_str()); + return settings.has_skip_intro && settings.skip_intro && !std::getenv("PSX_DISRUPTOR_SKIP_INTRO"); +} + +extern "C" { + +int psx_game_configure(const char *game_id, size_t length) { + return disruptor_precision_routes_configure(game_id, length); +} + +int psx_game_fast_boot_forced(void) { + const char *forced = std::getenv("PSX_DISRUPTOR_SKIP_INTRO"); + return forced && forced[0] && forced[0] != '0'; +} + +void psx_game_states_configured(void) { disruptor_language_settle(); } + +void psx_game_state_loaded(void) { + disruptor_capsule_state_loaded(); + disruptor_far_rendering_abandon_metrics(); +} + +int psx_game_key(int scancode, uint32_t keycode, int down, int plain) { + return disruptor_name_entry_key(scancode, keycode, down, plain); +} + +void psx_game_before_input(void) { disruptor_capsule_before_input(); } + +void psx_game_after_input(void) { disruptor_capsule_after_input(); } + +} // extern "C" diff --git a/src/game/render/precision_routes.cpp b/src/game/render/precision_routes.cpp new file mode 100644 index 0000000..3358ff9 --- /dev/null +++ b/src/game/render/precision_routes.cpp @@ -0,0 +1,117 @@ +#include "game/render/precision_routes.h" + +#include "gte_precision.h" + +#include +#include +#include +#include + +/* Disruptor does not use SWC2 for its resident world-projection loops. These + * two reviewed MFC2 sites snapshot SXY2. Six ordinary SW sites publish into + * main RAM and one publishes into nine exact scratchpad slots, each only after + * an exact instruction and committed-word check. Twenty-four reviewed LW/SW + * pairs then carry those proven words into GP0 packets. Keeping every route + * behind the game serial prevents another title at the same PCs from + * inheriting it. */ +int disruptor_precision_routes_configure(const char *game_id, size_t length) { + gte_precision_store_pc_routes_reset(); + if (std::string_view(game_id, length) != "SLUS-00224") return 1; + static const uint32_t mfc2_pcs[] = { + 0x8004633Cu, + 0x8004795Cu, + }; + static const uint32_t store_pcs[] = { + 0x8004641Cu, + 0x800464BCu, + 0x80046524u, + 0x80046540u, + 0x80046594u, + 0x800465D8u, + }; + struct PrecisionCopyRoute { + uint32_t load_pc; + uint32_t load_instruction; + uint32_t store_pc; + uint32_t store_instruction; + uint8_t gpr; + }; + static const PrecisionCopyRoute copy_routes[] = { + {0x80046C4Cu, 0x8D0C0004u, 0x80046D04u, 0xAF2C0008u, 12u}, + {0x80046C50u, 0x8D2D0004u, 0x80046D08u, 0xAF2D0014u, 13u}, + {0x80046C54u, 0x8D4E0004u, 0x80046D10u, 0xAF2E002Cu, 14u}, + {0x80046C60u, 0x8D6F0004u, 0x80046D0Cu, 0xAF2F0020u, 15u}, + {0x80047A80u, 0x8CA80084u, 0x80047A90u, 0xAF280008u, 8u}, + {0x80047A84u, 0x8CA900A4u, 0x80047A94u, 0xAF290014u, 9u}, + {0x80047A88u, 0x8CAA00C4u, 0x80047A9Cu, 0xAF2A002Cu, 10u}, + {0x80047A8Cu, 0x8CAB00BCu, 0x80047A98u, 0xAF2B0020u, 11u}, + {0x80047B40u, 0x8CA800A4u, 0x80047B50u, 0xAF280008u, 8u}, + {0x80047B44u, 0x8CA9008Cu, 0x80047B54u, 0xAF290014u, 9u}, + {0x80047B48u, 0x8CAA00ACu, 0x80047B5Cu, 0xAF2A002Cu, 10u}, + {0x80047B4Cu, 0x8CAB00C4u, 0x80047B58u, 0xAF2B0020u, 11u}, + {0x80047C00u, 0x8CA800C4u, 0x80047C10u, 0xAF280008u, 8u}, + {0x80047C04u, 0x8CA900ACu, 0x80047C14u, 0xAF290014u, 9u}, + {0x80047C08u, 0x8CAA0094u, 0x80047C1Cu, 0xAF2A002Cu, 10u}, + {0x80047C0Cu, 0x8CAB00B4u, 0x80047C18u, 0xAF2B0020u, 11u}, + {0x80047CC4u, 0x8CA800BCu, 0x80047CD4u, 0xAF280008u, 8u}, + {0x80047CC8u, 0x8CA900C4u, 0x80047CD8u, 0xAF290014u, 9u}, + {0x80047CCCu, 0x8CAA00B4u, 0x80047CE0u, 0xAF2A002Cu, 10u}, + {0x80047CD0u, 0x8CAB009Cu, 0x80047CDCu, 0xAF2B0020u, 11u}, + {0x80047D6Cu, 0x8CA80084u, 0x80047D7Cu, 0xAF280008u, 8u}, + {0x80047D70u, 0x8CA9008Cu, 0x80047D80u, 0xAF290014u, 9u}, + {0x80047D74u, 0x8CAA0094u, 0x80047D88u, 0xAF2A002Cu, 10u}, + {0x80047D78u, 0x8CAB009Cu, 0x80047D84u, 0xAF2B0020u, 11u}, + }; + for (uint32_t pc : mfc2_pcs) { + if (!gte_precision_mfc2_pc_route_add( + pc, 0x480A7000u, 14u)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor SXY2 MFC2 PC " + "0x%08X\n", pc); + return 0; + } + } + for (uint32_t pc : store_pcs) { + if (!gte_precision_store_pc_route_add( + pc, 0xACAA0004u, 14u)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor SXY2 store PC " + "0x%08X\n", pc); + return 0; + } + } + if (!gte_precision_scratch_store_pc_route_add( + 0x80047A0Cu, 0xACAA0004u, 14u, + 0x1F800084u, 8u, 9u)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor scratchpad SXY2 " + "store route 0x80047A0C\n"); + return 0; + } + /* 0x80047980..0x800479E0 pin x to -256..576 and y to -256..496 before that store. */ + if (!gte_precision_scratch_store_pc_route_clamp( + 0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) { + std::fprintf(stderr, + "psxrecomp: failed to register the guard band of Disruptor's " + "scratchpad SXY2 store route 0x80047A0C\n"); + return 0; + } + for (const PrecisionCopyRoute& route : copy_routes) { + if (!gte_precision_copy_pc_route_add( + route.load_pc, route.load_instruction, + route.store_pc, route.store_instruction, route.gpr)) { + std::fprintf(stderr, + "psxrecomp: failed to register Disruptor projection-copy " + "route 0x%08X -> 0x%08X\n", + route.load_pc, route.store_pc); + return 0; + } + } + std::fprintf(stdout, + "psxrecomp: registered %zu MFC2 captures, %zu main-RAM projection " + "stores, 1 scratchpad projection store and %zu packet-copy routes\n", + sizeof(mfc2_pcs) / sizeof(mfc2_pcs[0]), + sizeof(store_pcs) / sizeof(store_pcs[0]), + sizeof(copy_routes) / sizeof(copy_routes[0])); + return 1; +} diff --git a/src/game/render/precision_routes.h b/src/game/render/precision_routes.h new file mode 100644 index 0000000..ba7531d --- /dev/null +++ b/src/game/render/precision_routes.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Registers the game's projection routes for the disc with this serial of `length` characters. 0 when a route was refused. */ +int disruptor_precision_routes_configure(const char *game_id, size_t length); + +#ifdef __cplusplus +} +#endif diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index 24abd4e..99d9138 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -16,14 +16,15 @@ def require(condition: bool, message: str) -> None: host = read("psxrecomp/runtime/src/main.cpp") +game_side = read("src/game/host.cpp") capsule = read("src/game/session/capsule.cpp") mouse = read("src/game/input/mouse_aim.cpp") menu = read("src/game/ui/dev_menu.cpp") cmake = read("CMakeLists.txt") require( - "#ifdef PSX_HAS_DISRUPTOR_CAPSULE\n" - " if (!psx_netplay_active()) disruptor_capsule_before_input();\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " if (!psx_netplay_active()) psx_game_before_input();\n" "#endif\n" " if (psx_netplay_active()) {\n" " psx_netplay_finish_frame();\n" @@ -32,14 +33,19 @@ def require(condition: bool, message: str) -> None: " } else {\n" " sample_pad_into_sio(override);\n" " }\n" - "#ifdef PSX_HAS_DISRUPTOR_CAPSULE\n" - " if (!psx_netplay_active()) disruptor_capsule_after_input();\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " if (!psx_netplay_active()) psx_game_after_input();\n" "#endif\n" in host - and host.count("disruptor_capsule_before_input();") == host.count("disruptor_capsule_after_input();") == 1, + and host.count("psx_game_before_input();") == host.count("psx_game_after_input();") == 1 + and "void psx_game_before_input(void) { disruptor_capsule_before_input(); }\n" in game_side + and "void psx_game_after_input(void) { disruptor_capsule_after_input(); }\n" in game_side + and game_side.count("disruptor_capsule_before_input();") == game_side.count("disruptor_capsule_after_input();") == 1 + and game_side.count("disruptor_capsule_state_loaded();") == 1 + and "disruptor_capsule_" not in host, "the capsule is called once on either side of the pad sampling of every VBlank, and never in a network game", ) -sampling = host.index(" if (!psx_netplay_active()) disruptor_capsule_after_input();") +sampling = host.index(" if (!psx_netplay_active()) psx_game_after_input();") require( sampling < host.index(" if (g_headless || module_fast_forward) {") and sampling < host.index(" mod_call_frame_hooks();"), @@ -47,9 +53,10 @@ def require(condition: bool, message: str) -> None: ) require( 'extern "C" void psx_frontend_on_savestate_loaded(void) {\n' - "#ifdef PSX_HAS_DISRUPTOR_CAPSULE\n" - " disruptor_capsule_state_loaded();\n" - "#endif\n" in host, + "#ifdef PSX_HAS_GAME_HOST\n" + " psx_game_state_loaded();\n" + "#endif\n" in host + and "void psx_game_state_loaded(void) {\n disruptor_capsule_state_loaded();\n" in game_side, "the capsule must hear of a loaded state before the game runs on from it", ) require( @@ -129,7 +136,8 @@ def require(condition: bool, message: str) -> None: ) require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp"\n' in cmake - and " PSX_HAS_DISRUPTOR_CAPSULE=1\n" in cmake + and " PSX_HAS_GAME_HOST=1\n" in cmake + and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp"\n' in cmake and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' in read("tests/unit/disruptor_capsule_test.cpp") and " disruptor_unit_test(disruptor_capsule " in cmake diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index bfdb868..037c1aa 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -172,8 +172,8 @@ def require(condition: bool, message: str) -> None: require("disruptor_far_rendering_abandon_metrics" in header and "disruptor_far_rendering_abandon_metrics" in source, "the module must expose savestate-abandon cleanup") -require("#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING" in main and - "disruptor_far_rendering_abandon_metrics();" in main, +require("#ifdef PSX_HAS_GAME_HOST\n psx_game_state_loaded();\n#endif\n" in main and + " disruptor_far_rendering_abandon_metrics();\n}\n" in read("src/game/host.cpp"), "successful savestate loads must abandon host measurement spans") for token in ( diff --git a/tests/contracts/test_framework_tool.py b/tests/contracts/test_framework_tool.py index 2e42b3d..740af33 100644 --- a/tests/contracts/test_framework_tool.py +++ b/tests/contracts/test_framework_tool.py @@ -188,13 +188,13 @@ def main() -> int: if path.is_file() } require( - len(entries) == 51 + len(entries) == 52 and patches == {f"{line}.patch" for line in entries if not line.startswith("+")} and files == {line[1:] for line in entries if line.startswith("+")} and len(patches) == 32 - and len(files) == 19 + and len(files) == 20 and "framework/patches/** -text\n" in (ROOT / ".gitattributes").read_text(encoding="utf-8"), - "the project lists 51 changed files, with a patch for each of 32 and 19 added whole, and stores patches byte for byte", + "the project lists 52 changed files, with a patch for each of 32 and 20 added whole, and stores patches byte for byte", ) for script, call in ( ("build.ps1", "python (Join-Path $Root 'tools/framework.py') apply --framework $Framework"), diff --git a/tests/contracts/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py index 7beb1a2..d957b8d 100644 --- a/tests/contracts/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -374,8 +374,14 @@ def main() -> int: f"{replay_name} must sandbox replayed GTE projections") route_config = function_body( - main_source, "configure_disruptor_precision_store_routes") - require('game_id != "SLUS-00224"' in route_config, + (ROOT / "src/game/render/precision_routes.cpp").read_text(encoding="utf-8"), + "disruptor_precision_routes_configure") + require("#ifdef PSX_HAS_GAME_HOST\n if (!psx_game_configure(game_id.data(), game_id.size())) return 1;\n#endif\n" in main_source and + "int psx_game_configure(const char *game_id, size_t length) {\n" + " return disruptor_precision_routes_configure(game_id, length);\n}\n" in + (ROOT / "src/game/host.cpp").read_text(encoding="utf-8"), + "the host must ask the game for its routes once the serial is known, and stop when one is refused") + require('std::string_view(game_id, length) != "SLUS-00224"' in route_config, "reviewed resident routes must be gated to Disruptor's serial") mfc2_array = re.search( r"mfc2_pcs\[\]\s*=\s*\{(.*?)\};", route_config, re.S) diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index d04d153..39a30ca 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -199,13 +199,12 @@ def require(condition: bool, message: str) -> None: runtime_main = read("psxrecomp/runtime/src/main.cpp") require( - " if (us.has_skip_intro && us.skip_intro &&\n" - ' !std::getenv("PSX_DISRUPTOR_SKIP_INTRO"))\n' - " fast_boot = true;" - in runtime_main - and ' if (const char* e = std::getenv("PSX_DISRUPTOR_SKIP_INTRO"))\n' - " if (e[0] && e[0] != '0') fast_boot = true;" - in runtime_main, + " if (psx_game_settings_loaded(us)) fast_boot = true;\n" in runtime_main + and " if (psx_game_fast_boot_forced()) fast_boot = true;\n" in runtime_main + and ' return settings.has_skip_intro && settings.skip_intro && !std::getenv("PSX_DISRUPTOR_SKIP_INTRO");\n' + in read("src/game/host.cpp") + and ' const char *forced = std::getenv("PSX_DISRUPTOR_SKIP_INTRO");\n' + " return forced && forced[0] && forced[0] != '0';\n" in read("src/game/host.cpp"), "the saved switch, or the environment in its place, must also skip the BIOS animation", ) diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index b02b962..5e58b5e 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -296,11 +296,12 @@ def require(condition: bool, message: str) -> None: " if (void *game = iso_game_disc(); game && !g_path.empty()) (void)laid_out(game);" in source and " savestate_configure(memcard_dir.string().c_str(),\n" " memory_get_bios_checksum(), game_entry_pc);\n" - "#ifdef PSX_HAS_DISRUPTOR_LANGUAGE\n" - " /* Before any state can be asked for: a language disc's states have a folder of their own. */\n" - " disruptor_language_settle();\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " /* Before any state can be asked for. */\n" + " psx_game_states_configured();\n" "#endif" in host - and host.index(" cdrom_init(disc_path_str.empty()") < host.index(" disruptor_language_settle();") + and "void psx_game_states_configured(void) { disruptor_language_settle(); }\n" in read("src/game/host.cpp") + and host.index(" cdrom_init(disc_path_str.empty()") < host.index(" psx_game_states_configured();") < host.index(' if (const char *ls = std::getenv("PSX_LOAD_SLOT")) {') and " language_ = language->name;\n tall_hints_ = language->hints == Hints::kTall && hints != 0;\n return true;" in disc and ' tall_hints_ = false;\n language_ = "";' in disc, @@ -317,12 +318,11 @@ def require(condition: bool, message: str) -> None: "settings.toml must read and write [disruptor] language_disc whole: backslashes, quotes and control characters", ) require( - "PSX_HAS_DISRUPTOR_LANGUAGE=1" in cmake - and '#ifdef PSX_HAS_DISRUPTOR_LANGUAGE\nextern "C" void disruptor_language_set_disc(const char* path);\n' - 'extern "C" void disruptor_language_settle(void);\n#endif' in host - and ' if (us.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC"))\n' - " disruptor_language_set_disc(us.language_disc.c_str());" in host - and host.index("disruptor_language_set_disc(us.language_disc.c_str());") < host.index("if (us.has_bios_hle) bios_hle_requested = us.bios_hle;"), + "PSX_HAS_GAME_HOST=1" in cmake + and ' if (settings.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC"))\n' + " disruptor_language_set_disc(settings.language_disc.c_str());" in read("src/game/host.cpp") + and " if (psx_game_settings_loaded(us)) fast_boot = true;\n" in host + and host.index("if (psx_game_settings_loaded(us)) fast_boot = true;") < host.index("if (us.has_bios_hle) bios_hle_requested = us.bios_hle;"), "the saved language disc must be named while the settings are read, before anything boots, unless the environment names one", ) require( diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index bb1ef87..0416d54 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -95,10 +95,12 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") module = read("src/game/input/name_entry.cpp") host = read("psxrecomp/runtime/src/main.cpp") +game_side = read("src/game/host.cpp") require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake - and " PSX_HAS_DISRUPTOR_NAME_ENTRY=1\n" in cmake + and " PSX_HAS_GAME_HOST=1\n" in cmake + and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp"\n' in cmake and " disruptor_unit_test(disruptor_name_entry " in cmake and " disruptor_contract(name_entry RETAIL)\n" in cmake, "the module, its switch for the host, its unit test and this contract with its input must stay registered", @@ -134,28 +136,29 @@ def require(condition: bool, message: str) -> None: "and the name, the cursor and the row the only memory written", ) require( - "#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY\n" - 'extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain);\n#endif\n' + '#ifdef PSX_HAS_GAME_HOST\n#include "game_host.h"\n#endif\n' in host - and "#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY\n } else if (ev.type == SDL_KEYUP) {\n#if defined(PSX_SDL3)\n" - " (void)disruptor_name_entry_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1);\n#else\n" - " (void)disruptor_name_entry_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1);\n" + and "int psx_game_key(int scancode, uint32_t keycode, int down, int plain) {\n" + " return disruptor_name_entry_key(scancode, keycode, down, plain);\n}\n" in game_side + and "#ifdef PSX_HAS_GAME_HOST\n } else if (ev.type == SDL_KEYUP) {\n#if defined(PSX_SDL3)\n" + " (void)psx_game_key((int)ev.key.scancode, (uint32_t)ev.key.key, 0, 1);\n#else\n" + " (void)psx_game_key((int)ev.key.keysym.scancode, (uint32_t)ev.key.keysym.sym, 0, 1);\n" "#endif\n#endif\n } else if (!ui_consumed && ev.type == SDL_KEYDOWN) {\n" in host - and "#ifdef PSX_HAS_DISRUPTOR_NAME_ENTRY\n" - " if (disruptor_name_entry_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI))))\n" - " continue; /* the key is a letter of a save's name */\n" + and "#ifdef PSX_HAS_GAME_HOST\n" + " if (psx_game_key((int)scancode, (uint32_t)key, 1, !(mod & (KMOD_CTRL | KMOD_ALT | KMOD_GUI))))\n" + " continue; /* the game took the key */\n" in host - and host.count("disruptor_name_entry_key(") == 4, + and host.count("psx_game_key(") == 3, "the host must hand every key it did not use itself to the module, a release whoever used the press, and leave a taken key alone", ) keydown = host.index(" } else if (!ui_consumed && ev.type == SDL_KEYDOWN) {\n") require( keydown - < host.index("if (disruptor_name_entry_key((int)scancode") + < host.index("if (psx_game_key((int)scancode") < host.index("if (key >= SDLK_F1 && key <= SDLK_F12) {", keydown) and host.index('netplay_soft_exit("netplay_escape");', keydown) - < host.index("if (disruptor_name_entry_key((int)scancode"), + < host.index("if (psx_game_key((int)scancode"), "a taken key must be taken before the host's own keys are looked at, and after a net game's Escape", ) diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 6ba69bb..974c006 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -245,7 +245,7 @@ def shader(source: str, name: str) -> str: and "pinned.packed = packed;\n pinned.clamped = 1;\n precision_store_projection(physical, pinned);" in scratch_store and "if (x < route.low_x) x = route.low_x;" in band and "if (x > route.high_x) x = route.high_x;" in band and "if (y < route.low_y) y = route.low_y;" in band and "if (y > route.high_y) y = route.high_y;" in band - and "0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) {" in runtime_main + and "0x80047A0Cu, 0xACAA0004u, -0x100, 0x240, -0x100, 0x1F0)) {" in read("src/game/render/precision_routes.cpp") and '"cpu->gpr[11] = -256; /* 0x8004798C: 0x200BFF00 */", "cpu->gpr[11] = 576; /* 0x800479A8: 0x200B0240 */",' in audit and '"cpu->gpr[12] = -256; /* 0x800479C4: 0x200CFF00 */", "cpu->gpr[12] = 496; /* 0x800479E0: 0x200C01F0 */"):' in audit, "a corner the game pins to its guard band must keep its projection only when the stored word is that projection pinned to the retail band", From 7e28c709f662b945c0550b1505829ada2f9990ba Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 04:42:47 +0300 Subject: [PATCH 15/21] refactor: list the interpreter's instruction hooks in the game configuration --- CMakeLists.txt | 10 +- docs/BUILD.md | 11 + framework/files/runtime/include/game_host.h | 4 + .../recompiler/src/config_loader.cpp.patch | 84 +++++-- .../recompiler/src/config_loader.h.patch | 42 +++- .../patches/recompiler/src/main_psx.cpp.patch | 70 +++++- .../runtime/src/dirty_ram_interp.c.patch | 231 ++---------------- game-widescreen.toml | 147 +++++++++++ game.toml | 147 +++++++++++ .../contracts/test_backdrop_tiles_contract.py | 10 +- .../test_billboard_aspect_contract.py | 16 +- .../contracts/test_far_rendering_contract.py | 21 +- .../test_instruction_hooks_contract.py | 133 ++++++++++ tests/contracts/test_intro_skip_contract.py | 10 +- tests/contracts/test_state_key_contract.py | 77 ++++++ .../test_temporal_presenter_contract.py | 8 +- .../test_vertical_camera_contract.py | 12 +- tools/audit_codegen.py | 48 ++++ 18 files changed, 790 insertions(+), 291 deletions(-) create mode 100644 tests/contracts/test_instruction_hooks_contract.py create mode 100644 tests/contracts/test_state_key_contract.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 35af391..c13ad5f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -135,13 +135,7 @@ psxrecomp_add_runtime_target(psx-runtime ) target_compile_definitions(psx-runtime PRIVATE PSX_HAS_GAME_HOST=1 - PSX_HAS_DISRUPTOR_BACKDROP_TILES=1 - PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1 - PSX_HAS_DISRUPTOR_FAR_RENDERING=1 - PSX_HAS_DISRUPTOR_INTRO_SKIP=1 - PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1 - PSX_HAS_DISRUPTOR_GROWN_QUAD=1 - PSX_HAS_DISRUPTOR_VERTICAL_CAMERA=1) + PSX_HAS_DISRUPTOR_GROWN_QUAD=1) if(NOT DISRUPTOR_FRAME_INTERPOLATION) target_compile_definitions(psx-runtime PRIVATE # Keep the experimental sources available, but compile out frame @@ -214,6 +208,8 @@ if(BUILD_TESTING) disruptor_contract(smooth_turn) disruptor_contract(ambient_occlusion) disruptor_contract(debug_tab) + disruptor_contract(instruction_hooks) + disruptor_contract(state_key) disruptor_contract(test_registration) add_test(NAME disruptor_framework_tool diff --git a/docs/BUILD.md b/docs/BUILD.md index 369f26d..0617029 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -139,6 +139,17 @@ python tools/framework.py apply `settings.toml` beside the executable; game files, generated code, and build directories are ignored by Git. +The framework reaches the game through one header, `runtime/include/game_host.h`, +which `src/game/host.cpp` answers. The instructions the game hooks are listed in +`game.toml` under `[[recompiler.instruction_hook]]` for code that runs from RAM. +The code generator holds its own copy for native code, and a contract keeps the +two equal. + +A save state carries a key made of the code generator's sources, listed in +`psxrecomp/runtime/codegen_hash_sources.cmake`. Any edit to one of them, a +comment included, makes every earlier save state and capsule unloadable. Those +files change only together with the pin. + ## Layout | Folder | Holds | diff --git a/framework/files/runtime/include/game_host.h b/framework/files/runtime/include/game_host.h index 51739c2..d0bc7be 100644 --- a/framework/files/runtime/include/game_host.h +++ b/framework/files/runtime/include/game_host.h @@ -6,6 +6,8 @@ #include #include +typedef struct CPUState CPUState; + #ifdef __cplusplus namespace PSXRecompV4 { struct UserSettings; } /* Called when the user's settings are read, before the drive first reads the disc. True asks for a boot without the BIOS animation. */ @@ -26,6 +28,8 @@ int psx_game_key(int scancode, uint32_t keycode, int down, int plain); /* Called around the sampling of the pad, outside netplay. */ void psx_game_before_input(void); void psx_game_after_input(void); +/* Supplied by the generated code, from [[recompiler.instruction_hook]]: the game's calls for an instruction that ran from RAM. */ +void psx_game_instruction_ran(CPUState *cpu, uint32_t address, uint32_t instruction); #ifdef __cplusplus } diff --git a/framework/patches/recompiler/src/config_loader.cpp.patch b/framework/patches/recompiler/src/config_loader.cpp.patch index 1437c52..342a6c4 100644 --- a/framework/patches/recompiler/src/config_loader.cpp.patch +++ b/framework/patches/recompiler/src/config_loader.cpp.patch @@ -1,5 +1,5 @@ diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp -index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0b23a24d3 100644 +index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc1028726852 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -3,12 +3,26 @@ @@ -89,7 +89,49 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 } rt.video_supersampling = static_cast(n); } -@@ -1451,6 +1499,10 @@ +@@ -1264,6 +1312,41 @@ + for (const auto& a : arr) + mod_function_entry_funcs.push_back( + parse_hex(a, "recompiler.mod_function_entry_funcs")); ++ } ++ std::vector instruction_hooks; ++ if (recomp.contains("instruction_hook")) { ++ for (const auto& item : ++ toml::find(recomp, "instruction_hook")) { ++ InstructionHook hook; ++ hook.callback = toml::find(item, "callback"); ++ const bool named = !hook.callback.empty() && ++ !std::isdigit((unsigned char)hook.callback[0]) && ++ std::all_of(hook.callback.begin(), hook.callback.end(), ++ [](unsigned char c) { return std::isalnum(c) || c == '_'; }); ++ if (!named) ++ throw std::runtime_error(fmt::format( ++ "{}: [[recompiler.instruction_hook]] callback must be a " ++ "C identifier", config_path.string())); ++ for (const auto& pair : toml::find>>(item, "sites")) { ++ if (pair.size() != 2) ++ throw std::runtime_error(fmt::format( ++ "{}: [[recompiler.instruction_hook]] a site is an " ++ "address and a word", config_path.string())); ++ InstructionHookSite site; ++ site.address = parse_hex( ++ pair[0], "recompiler.instruction_hook.sites"); ++ site.expected = parse_hex( ++ pair[1], "recompiler.instruction_hook.sites"); ++ if ((site.address & 3u) != 0) ++ throw std::runtime_error(fmt::format( ++ "{}: instruction hook address 0x{:08X} is not " ++ "instruction-aligned", ++ config_path.string(), site.address)); ++ hook.sites.push_back(site); ++ } ++ instruction_hooks.push_back(std::move(hook)); ++ } + } + std::vector hot_funcs; + if (recomp.contains("hot_funcs")) { +@@ -1451,6 +1534,10 @@ std::vector ws_cull_screen_x_sites; std::vector ws_cull_slti_sites; std::vector ws_cull_slti_lower_sites; @@ -100,7 +142,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 std::vector ws_cull_bltz_sites; std::vector ws_cull_negsub_sites; std::vector ws_cull_vxrange_sites; -@@ -1459,6 +1511,9 @@ +@@ -1459,6 +1546,9 @@ std::vector ws_cull_xclip_load_sites; std::vector ws_cull_keep_sites; std::vector ws_cull_angle_sites; @@ -110,7 +152,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 WidescreenAspectConeConfig ws_aspect_cone; int ws_cull_guard_pixels = 0; int ws_cull_activation_guard_pixels = 0; -@@ -1484,6 +1539,10 @@ +@@ -1484,6 +1574,10 @@ load_sites("screen_x_sites", ws_cull_screen_x_sites); load_sites("slti_sites", ws_cull_slti_sites); load_sites("slti_lower_sites", ws_cull_slti_lower_sites); @@ -121,7 +163,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 load_sites("bltz_sites", ws_cull_bltz_sites); load_sites("negsub_sites", ws_cull_negsub_sites); load_sites("vxrange_sites", ws_cull_vxrange_sites); -@@ -1552,6 +1611,85 @@ +@@ -1552,6 +1646,85 @@ "{}: duplicate cull-angle address 0x{:08X}", config_path.string(), site.address)); ws_cull_angle_sites.push_back(site); @@ -207,7 +249,15 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 } } if (cull.contains("aspect_cone")) { -@@ -1901,6 +2039,10 @@ +@@ -1886,6 +2059,7 @@ + /*ws_auto_ui_squash*/ ws_auto_ui_squash, + /*data_shard_funcs*/ data_shard_funcs, + /*mod_function_entry_funcs*/ mod_function_entry_funcs, ++ /*instruction_hooks*/ instruction_hooks, + /*hot_funcs*/ hot_funcs, + /*vsync_query_func*/ vsync_query_func, + /*vsync_counter_addr*/ vsync_counter_addr, +@@ -1901,6 +2075,10 @@ /*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites, /*ws_cull_slti_sites*/ ws_cull_slti_sites, /*ws_cull_slti_lower_sites*/ ws_cull_slti_lower_sites, @@ -218,7 +268,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 /*ws_cull_bltz_sites*/ ws_cull_bltz_sites, /*ws_cull_negsub_sites*/ ws_cull_negsub_sites, /*ws_cull_vxrange_sites*/ ws_cull_vxrange_sites, -@@ -1909,6 +2051,8 @@ +@@ -1909,6 +2087,8 @@ /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, @@ -227,7 +277,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 /*ws_aspect_cone*/ ws_aspect_cone, /*ws_cull_guard_pixels*/ ws_cull_guard_pixels, /*ws_cull_activation_guard_pixels*/ ws_cull_activation_guard_pixels, -@@ -1995,7 +2139,7 @@ +@@ -1995,7 +2175,7 @@ return go; } @@ -236,7 +286,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 UserSettings load_user_settings(const fs::path& path) { UserSettings s; -@@ -2004,7 +2148,10 @@ +@@ -2004,7 +2184,10 @@ toml::value doc; try { @@ -248,7 +298,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 } catch (const std::exception&) { // Malformed file: fall back to all-defaults rather than refuse to boot, // but tell the caller so the user hears about it (and so the file can -@@ -2026,7 +2173,7 @@ +@@ -2026,7 +2209,7 @@ }); if (v.contains("supersampling")) try_get([&]{ const auto n = toml::find(v, "supersampling"); @@ -257,7 +307,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 }); if (v.contains("window_width")) try_get([&]{ const auto n = toml::find(v, "window_width"); -@@ -2087,8 +2234,16 @@ +@@ -2087,8 +2270,16 @@ }); if (v.contains("frame_interpolation_fps")) try_get([&]{ s.frame_interpolation_fps = toml::find(v, "frame_interpolation_fps"); @@ -275,7 +325,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 }); if (v.contains("aspect_ratio")) try_get([&]{ const auto m = toml::find(v, "aspect_ratio"); -@@ -2106,6 +2261,17 @@ +@@ -2106,6 +2297,17 @@ const toml::value& a = toml::find(doc, "audio"); if (a.contains("spu_hq")) try_get([&]{ s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; @@ -293,7 +343,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 }); } if (doc.contains("launcher")) { -@@ -2230,15 +2396,190 @@ +@@ -2230,15 +2432,190 @@ if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } }); } @@ -485,7 +535,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 if (!f.is_open()) return false; // TOML strings use forward slashes so backslash escaping is never an issue. -@@ -2247,7 +2588,21 @@ +@@ -2247,7 +2624,21 @@ return str; }; @@ -508,7 +558,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 f << "# Overrides the bundled game.toml; the command line overrides this file.\n\n"; f << "[video]\n"; -@@ -2287,6 +2642,11 @@ +@@ -2287,6 +2678,11 @@ f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n"; if (s.has_frame_interpolation_fps) f << "frame_interpolation_fps = " << s.frame_interpolation_fps << "\n"; @@ -520,7 +570,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 if (s.has_aspect_ratio) f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; if (s.has_adaptive_view) -@@ -2294,6 +2654,10 @@ +@@ -2294,6 +2690,10 @@ f << "\n[audio]\n"; if (s.has_spu_hq) f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; @@ -531,7 +581,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..5f99a3341d999703606af51594b351e0 if (s.has_skip_launcher) f << "\n[launcher]\nskip_launcher = " << (s.skip_launcher ? "true" : "false") << "\n"; if ((s.has_netplay_player_name && !s.netplay_player_name.empty()) || -@@ -2353,7 +2717,98 @@ +@@ -2353,7 +2753,98 @@ f << "extra_late = " << s.parappa_timing_extra_late << "\n"; } diff --git a/framework/patches/recompiler/src/config_loader.h.patch b/framework/patches/recompiler/src/config_loader.h.patch index 18ac7f7..1721c28 100644 --- a/framework/patches/recompiler/src/config_loader.h.patch +++ b/framework/patches/recompiler/src/config_loader.h.patch @@ -1,8 +1,8 @@ diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h -index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139ea621c6ed 100644 +index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569ea7563229 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h -@@ -71,6 +71,26 @@ +@@ -71,6 +71,36 @@ struct WidescreenAngleSite { uint32_t address = 0; uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero @@ -13,6 +13,16 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e +// camera yaw. The signed immediate is a half-FOV in `full_turn` units (for +// example +/-32 with full_turn=256). Only the offset is widened; the base yaw +// and its natural integer wrapping remain owned by the game. ++// A call the RAM interpreter makes after each listed instruction. ++struct InstructionHookSite { ++ uint32_t address = 0; ++ uint32_t expected = 0; // the whole instruction word ++}; ++struct InstructionHook { ++ std::string callback; ++ std::vector sites; ++}; ++ +struct WidescreenYawConeSite { + uint32_t address = 0; + uint32_t expected = 0; // guarded ADDI/ADDIU with rs != zero @@ -29,7 +39,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e }; // Aspect-aware horizontal participation cone. The exact compare sites are -@@ -309,7 +329,7 @@ +@@ -309,7 +339,7 @@ // ---- [video] block — visual enhancement options ---- // supersampling: internal-resolution SSAA factor (per axis). 1 = native @@ -38,7 +48,17 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. int video_supersampling = 1; -@@ -695,6 +715,20 @@ +@@ -646,6 +676,9 @@ + // default, so projects that do not opt in emit no callback overhead. + std::vector mod_function_entry_funcs; + ++ // [[recompiler.instruction_hook]]. Not part of the codegen hash: a save state stays loadable. ++ std::vector instruction_hooks; ++ + // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) + // on their generated C bodies (profile/host locality; no guest semantics). + std::vector hot_funcs; +@@ -695,6 +728,20 @@ // Explicit signed lower-bound sites (`slti rt, sx, -W`). The threshold is // moved left by the live reveal margin. Empty by default; regen required. std::vector ws_cull_slti_lower_sites; @@ -59,7 +79,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // [widescreen.cull] bltz_sites — explicit signed LEFT-edge widen sites // (`bltz rs, reject` -> psx_ws_cull_bltz), the counterpart to slti_sites. // detect_cull_bltz_sites only classifies left-edge bltz for functions -@@ -730,6 +764,10 @@ +@@ -730,6 +777,10 @@ std::vector ws_cull_keep_sites; // Exact 12-bit angular half-extents used by terrain-cell frusta. std::vector ws_cull_angle_sites; @@ -70,7 +90,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // Full-word-guarded model-participation cosine compares widened only in // the camera-horizontal plane. Empty/default is completely inert. WidescreenAspectConeConfig ws_aspect_cone; -@@ -944,12 +982,12 @@ +@@ -944,12 +995,12 @@ uint32_t ws_bg2d_packet_cap = 1000; }; @@ -85,7 +105,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // from game.toml defaults the first time it writes. // // Unlike game.toml, paths here are stored verbatim (the user picked them); they -@@ -963,7 +1001,7 @@ +@@ -963,7 +1014,7 @@ // [video] bool has_renderer = false; int renderer = 0; // 0=software,1=opengl @@ -94,7 +114,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to // both the launcher and the emulator window so they boot at the same size. bool has_window_width = false; int window_width = 1280; // -> 1280x960 -@@ -995,6 +1033,10 @@ +@@ -995,6 +1046,10 @@ bool has_vsync = false; int vsync = 1; bool has_frame_interpolation = false; bool frame_interpolation = false; bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; @@ -105,7 +125,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // [launcher] — when true, boot straight into the game and skip the GUI // launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run: // `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off. -@@ -1008,6 +1050,10 @@ +@@ -1008,6 +1063,10 @@ bool has_adaptive_view = false; bool adaptive_view = false; // [audio] bool has_spu_hq = false; bool spu_hq = false; @@ -116,7 +136,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // [bios] / [disc] / [memcard] bool has_bios_path = false; std::filesystem::path bios_path; bool has_disc_path = false; std::filesystem::path disc_path; -@@ -1043,7 +1089,36 @@ +@@ -1043,7 +1102,36 @@ bool has_parappa_timing_mode = false; std::string parappa_timing_mode = "stock"; bool has_parappa_timing_extra_early = false; int parappa_timing_extra_early = 0; bool has_parappa_timing_extra_late = false; int parappa_timing_extra_late = 0; @@ -154,7 +174,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..dbe869f40d4687949e35f2cbd719139e // GameOptions — the game's OWN native OPTION-screen settings, declared in a // dedicated, self-contained `game_options.toml` next to game.toml. Kept entirely -@@ -1084,7 +1159,9 @@ +@@ -1084,7 +1172,9 @@ // never throws. UserSettings load_user_settings(const std::filesystem::path& path); diff --git a/framework/patches/recompiler/src/main_psx.cpp.patch b/framework/patches/recompiler/src/main_psx.cpp.patch index 194a58c..61ede44 100644 --- a/framework/patches/recompiler/src/main_psx.cpp.patch +++ b/framework/patches/recompiler/src/main_psx.cpp.patch @@ -1,5 +1,5 @@ diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp -index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7bebbb4215a 100644 +index 9979abe18ed351391f95e5009189c84ce7cbec7e..f79e0dcc50a18d4cdb7e1f5c9c1770ff8b78cde7 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -169,6 +169,10 @@ @@ -13,17 +13,26 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be std::set ws_cull_bltz; // [widescreen.cull] bltz_sites std::set ws_cull_negsub; // [widescreen.cull] negsub_sites std::set ws_cull_vxrange; // [widescreen.cull] vxrange_sites -@@ -177,6 +181,9 @@ +@@ -177,6 +181,10 @@ std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites std::vector ws_cull_keep; std::vector ws_cull_angle; + std::vector ws_cull_yaw_cone; ++ std::vector instruction_hooks; + std::vector + ws_cull_horizontal_slt; PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; int ws_cull_activation_guard_pixels = 0; std::vector ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms -@@ -226,6 +233,14 @@ +@@ -214,6 +222,7 @@ + ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); + mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), + cfg.mod_function_entry_funcs.end()); ++ instruction_hooks = cfg.instruction_hooks; + hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); + if (cfg.vsync_query_func) + vsync_query_hle_funcs[cfg.vsync_query_func] = { +@@ -226,6 +235,14 @@ ws_cull_slti.insert(cfg.ws_cull_slti_sites.begin(), cfg.ws_cull_slti_sites.end()); ws_cull_slti_lower.insert(cfg.ws_cull_slti_lower_sites.begin(), cfg.ws_cull_slti_lower_sites.end()); @@ -38,7 +47,7 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be ws_cull_bltz.insert(cfg.ws_cull_bltz_sites.begin(), cfg.ws_cull_bltz_sites.end()); ws_cull_negsub.insert(cfg.ws_cull_negsub_sites.begin(), cfg.ws_cull_negsub_sites.end()); ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end()); -@@ -234,6 +249,8 @@ +@@ -234,6 +251,8 @@ ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); ws_cull_keep = cfg.ws_cull_keep_sites; ws_cull_angle = cfg.ws_cull_angle_sites; @@ -47,7 +56,16 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be ws_aspect_cone = cfg.ws_aspect_cone; ws_cull_activation_guard_pixels = cfg.ws_cull_activation_guard_pixels; -@@ -314,6 +331,14 @@ +@@ -307,6 +326,8 @@ + ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end()); + mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), + wscfg.mod_function_entry_funcs.end()); ++ if (instruction_hooks.empty()) ++ instruction_hooks = wscfg.instruction_hooks; + ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); + ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); + ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end()); +@@ -314,6 +335,14 @@ ws_cull_slti.insert(wscfg.ws_cull_slti_sites.begin(), wscfg.ws_cull_slti_sites.end()); ws_cull_slti_lower.insert(wscfg.ws_cull_slti_lower_sites.begin(), wscfg.ws_cull_slti_lower_sites.end()); @@ -62,7 +80,7 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be ws_cull_bltz.insert(wscfg.ws_cull_bltz_sites.begin(), wscfg.ws_cull_bltz_sites.end()); ws_cull_negsub.insert(wscfg.ws_cull_negsub_sites.begin(), wscfg.ws_cull_negsub_sites.end()); ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end()); -@@ -322,6 +347,11 @@ +@@ -322,6 +351,11 @@ ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; @@ -74,7 +92,7 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be if (ws_aspect_cone.sites.empty()) ws_aspect_cone = wscfg.ws_aspect_cone; ws_cull_activation_guard_pixels = -@@ -1152,6 +1182,10 @@ +@@ -1152,6 +1186,10 @@ ws_cull_activation_guard_pixels; codegen_config.ws_cull_slti_sites = ws_cull_slti; codegen_config.ws_cull_slti_lower_sites = ws_cull_slti_lower; @@ -85,7 +103,7 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be codegen_config.ws_cull_bltz_sites = ws_cull_bltz; codegen_config.ws_cull_negsub_sites = ws_cull_negsub; codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange; -@@ -1160,6 +1194,9 @@ +@@ -1160,6 +1198,9 @@ codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; codegen_config.ws_cull_keep_sites = ws_cull_keep; codegen_config.ws_cull_angle_sites = ws_cull_angle; @@ -95,3 +113,39 @@ index 9979abe18ed351391f95e5009189c84ce7cbec7e..b508c97cd3f4f737e960e10c3540b7be codegen_config.ws_aspect_cone = ws_aspect_cone; codegen_config.ws_cull_w_imms = ws_cull_w_imms; codegen_config.ws_cull_h_imms = ws_cull_h_imms; +@@ -1584,6 +1625,35 @@ + ds << "#endif\n"; + } + ++ { ++ // [[recompiler.instruction_hook]]: the calls the RAM interpreter makes ++ // after an instruction, the ones native code has from the generator. ++ // Written with no hook too: the interpreter of a host project calls it. ++ std::map>> hooked; ++ for (const auto& hook : instruction_hooks) ++ for (const auto& site : hook.sites) ++ hooked[site.address].emplace_back(site.expected, hook.callback); ++ ds << "\n/* The game's hooks after an instruction that ran from RAM. */\n"; ++ for (const auto& hook : instruction_hooks) ++ ds << "extern void " << hook.callback ++ << "(CPUState* cpu, uint32_t address, uint32_t instruction, int phase);\n"; ++ ds << "void psx_game_instruction_ran(CPUState* cpu, uint32_t address, uint32_t instruction) {\n"; ++ if (hooked.empty()) ++ ds << " (void)cpu; (void)address; (void)instruction;\n}\n"; ++ else ++ ds << " switch (address) {\n"; ++ for (const auto& [address, calls] : hooked) { ++ ds << fmt::format(" case 0x{:08X}u:\n", address); ++ for (const auto& [expected, callback] : calls) ++ ds << fmt::format( ++ " if (instruction == 0x{:08X}u) {}(cpu, address, instruction, 1);\n", ++ expected, callback); ++ ds << " break;\n"; ++ } ++ if (!hooked.empty()) ++ ds << " default:\n break;\n }\n}\n"; ++ } ++ + std::ofstream dispatch_file(dispatch_filename); + if (dispatch_file.is_open()) { + dispatch_file << ds.str(); diff --git a/framework/patches/runtime/src/dirty_ram_interp.c.patch b/framework/patches/runtime/src/dirty_ram_interp.c.patch index c5bb9cf..d0779db 100644 --- a/framework/patches/runtime/src/dirty_ram_interp.c.patch +++ b/framework/patches/runtime/src/dirty_ram_interp.c.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c -index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4ff1ac5452 100644 +index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..d503d93efe57bb5e2d23c9cb4f7b224097ef9140 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -24,6 +24,7 @@ @@ -10,7 +10,17 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f #include "cpu_state.h" #include "debug_server.h" #include "interrupts.h" -@@ -45,6 +46,55 @@ +@@ -34,6 +35,9 @@ + #include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */ + #include "lockstep.h" + #include "starvation_ring.h" ++#ifdef PSX_HAS_GAME_HOST ++#include "game_host.h" ++#endif + + #include + #include +@@ -45,6 +49,55 @@ uint64_t g_dirty_ram_aborts = 0; uint64_t g_dirty_ram_guard_yields = 0; uint64_t g_dirty_ram_native_handoffs = 0; @@ -66,7 +76,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f /* Scheduling-contract pump telemetry (see dirty_ram_dispatch). Always-on: * g_dirty_pump_max_gap_insns is the largest interpreted-insn gap ever seen * between two interrupt pumps — must stay bounded (~4096 + one block) once the -@@ -1449,6 +1499,7 @@ +@@ -1449,6 +1502,7 @@ uint32_t dummy_next = 0; (void)exec_one_fetched(cpu, pc, insn, &dummy_next); g_dirty_ram_insns_run++; @@ -74,209 +84,18 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f /* CYCLE MODEL: the delay-slot instruction is a real retired R3000A instruction * and is charged its own per-instruction interlock INSIDE exec_one (top-of-fn * §1+deps+DO_LDS, or psx_cyc_load_* for a load delay slot) — so a branch+slot -@@ -1461,6 +1512,158 @@ - * so the delay-slot instruction observes the architecturally-correct value. */ - static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out); -+ -+#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA -+extern void disruptor_vertical_camera_instruction_hook( -+ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); -+ -+static int disruptor_vertical_camera_site(uint32_t pc, uint32_t insn) { -+ switch (pc) { -+ case 0x8003AD54u: case 0x8003ADE4u: case 0x8003B140u: -+ case 0x8003B1ECu: case 0x8003B764u: case 0x8003B990u: -+ case 0x8003BDA4u: case 0x8003C4B4u: case 0x8003C99Cu: -+ case 0x8003D07Cu: case 0x8003F6B0u: -+ return insn == 0x34020078u; -+ case 0x8003D398u: -+ return insn == 0x34030078u; -+ case 0x8003B900u: -+ return insn == 0x0205102Au; -+ case 0x8003B90Cu: -+ return insn == 0x0202102Au; -+ case 0x8003BD14u: -+ return insn == 0x0206102Au; -+ case 0x8003BD20u: -+ return insn == 0x0202102Au; -+ case 0x8003CDB8u: -+ return insn == 0x0065102Au; -+ case 0x8003CDC4u: -+ return insn == 0x0062102Au; -+ case 0x8002E4B8u: -+ return insn == 0x92640022u; -+ case 0x8002EAF0u: -+ return insn == 0x92430020u; -+ case 0x8004279Cu: -+ return insn == 0x8FBF016Cu; -+ default: -+ return 0; -+ } -+} -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -+extern void disruptor_far_rendering_instruction_hook( -+ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); -+ -+static int disruptor_far_rendering_site(uint32_t pc, uint32_t insn) { -+ switch (pc) { -+ case 0x8003A0F8u: -+ return insn == 0x93B300C0u; -+ case 0x8003A390u: -+ return insn == 0xA082001Fu; -+ case 0x8003A92Cu: -+ return insn == 0x00005812u; -+ case 0x8003A964u: -+ return insn == 0x00681021u; -+ case 0x8003AA74u: -+ return insn == 0x00AC1021u; -+ case 0x8003A914u: -+ return insn == 0x8F8C04A0u; -+ case 0x8003B8C8u: case 0x8003C168u: case 0x8003CD64u: -+ return insn == 0x8F8304A0u; -+ case 0x8003BCE0u: -+ return insn == 0x8F8204A0u; -+ case 0x8003B8D8u: -+ return insn == 0x0072182Au; -+ case 0x8003BCECu: -+ return insn == 0x0053102Au; -+ case 0x8003C178u: case 0x8003CD70u: -+ return insn == 0x0077182Au; -+ case 0x8003B2CCu: case 0x8003BA30u: case 0x8003BE40u: -+ case 0x8003C628u: case 0x8003C878u: case 0x8003D28Cu: -+ case 0x80043184u: -+ return insn == 0x8F820494u; -+ case 0x8003FB0Cu: -+ return insn == 0x8F840494u; -+ case 0x800410E0u: -+ return insn == 0x93830310u; -+ case 0x800410F8u: -+ return insn == 0x8F820680u; -+ case 0x800411D8u: -+ return insn == 0x8F8301D4u; -+ case 0x8004279Cu: -+ return insn == 0x8FBF016Cu; -+ default: -+ return 0; -+ } -+} -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT -+extern void disruptor_billboard_aspect_instruction_hook( -+ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); -+ -+static int disruptor_billboard_aspect_site(uint32_t pc, uint32_t insn) { -+ switch (pc) { -+ case 0x8003BB88u: case 0x8003BFB0u: case 0x8003C848u: -+ return insn == 0xA6030016u; -+ case 0x8003CAD4u: -+ return insn == 0xA6020016u; -+ case 0x8003D488u: -+ return insn == 0xA2060025u; -+ case 0x800433A0u: -+ return insn == 0x02A02821u; -+ default: -+ return 0; -+ } -+} -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP -+extern void disruptor_intro_skip_instruction_hook( -+ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); -+ -+static int disruptor_intro_skip_site(uint32_t pc, uint32_t insn) { -+ return (pc == 0x800147B0u && insn == 0x2A820003u) || -+ (pc == 0x80020A80u && insn == 0x2A02005Eu) || -+ (pc == 0x80048678u && insn == 0x30420940u); -+} -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH -+extern void disruptor_sprite_depth_instruction_hook( -+ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); -+ -+static int disruptor_sprite_depth_site(uint32_t pc, uint32_t insn) { -+ switch (pc) { -+ case 0x8003B98Cu: case 0x8003BDA0u: case 0x8003C4B0u: case 0x8003D078u: -+ return insn == 0xAFA20010u; -+ case 0x8003D1B4u: -+ return insn == 0xA4379EA0u; -+ default: -+ return 0; -+ } -+} -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES -+extern void disruptor_backdrop_tiles_instruction_hook( -+ CPUState *cpu, uint32_t address, uint32_t instruction, int phase); -+ -+static int disruptor_backdrop_tile_site(uint32_t pc, uint32_t insn) { -+ switch (pc) { -+ case 0x8003B328u: case 0x8003B638u: -+ return insn == 0x8FB80040u; -+ case 0x8003B338u: -+ return insn == 0x00621821u; -+ case 0x8003B348u: -+ return insn == 0x8FB80028u; -+ case 0x8003B5A8u: -+ return insn == 0x8F8205B8u; -+ default: -+ return 0; -+ } -+} -+#endif - - static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, - uint32_t *next_pc_out) { -@@ -1516,6 +1719,42 @@ +@@ -1516,6 +1570,10 @@ const uint32_t ld_before = is_ld ? cpu->gpr[ld_rt] : 0u; const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out); + -+#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA -+ if (disruptor_vertical_camera_site(pc, insn)) { -+ disruptor_vertical_camera_instruction_hook(cpu, pc, insn, 1); -+ } -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING -+ if (disruptor_far_rendering_site(pc, insn)) { -+ disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1); -+ } -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT -+ if (disruptor_billboard_aspect_site(pc, insn)) { -+ disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1); -+ } -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_INTRO_SKIP -+ if (disruptor_intro_skip_site(pc, insn)) { -+ disruptor_intro_skip_instruction_hook(cpu, pc, insn, 1); -+ } -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_SPRITE_DEPTH -+ if (disruptor_sprite_depth_site(pc, insn)) { -+ disruptor_sprite_depth_instruction_hook(cpu, pc, insn, 1); -+ } -+#endif -+ -+#ifdef PSX_HAS_DISRUPTOR_BACKDROP_TILES -+ if (disruptor_backdrop_tile_site(pc, insn)) { -+ disruptor_backdrop_tiles_instruction_hook(cpu, pc, insn, 1); -+ } ++#ifdef PSX_HAS_GAME_HOST ++ psx_game_instruction_ran(cpu, pc, insn); +#endif if (is_ld) { const uint32_t loaded = cpu->gpr[ld_rt]; -@@ -2114,7 +2353,9 @@ +@@ -2114,7 +2172,9 @@ #ifdef PSX_ENABLE_BLOCK_CYCLES psx_gte_read(cpu, rt); #endif @@ -287,7 +106,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f cpu->gpr[0] = 0; return 0; } -@@ -2181,6 +2422,8 @@ +@@ -2181,6 +2241,8 @@ cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); else cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); @@ -296,7 +115,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f cpu->gpr[0] = 0; return 0; } -@@ -2236,6 +2479,11 @@ +@@ -2236,6 +2298,11 @@ g_debug_last_store_pc = pc; parappa_rhythm_log_store(cpu, pc, addr, cpu->gpr[rt], 4u); cpu->write_word(addr, cpu->gpr[rt]); @@ -308,7 +127,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f return 0; } case 0x2E: { /* SWR */ -@@ -2509,6 +2757,7 @@ +@@ -2509,6 +2576,7 @@ cosim_exec_one_begin(); int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */ g_dirty_ram_insns_run++; @@ -316,7 +135,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f #ifdef PSX_COSIM if (!cosim_exec_one_did_hook()) { extern void cosim_instr(uint32_t); cosim_instr(pc); } #endif -@@ -2981,6 +3230,7 @@ +@@ -2981,6 +3049,7 @@ OV_FPLOG_RET1(); } g_dirty_ram_insns_run++; @@ -324,7 +143,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f insns_executed++; /* COP0 software-interrupt latency: an MTC0 to CAUSE or SR that makes a * software interrupt deliverable (CAUSE.IP0/IP1 & SR.IM0/IM1 & IEc) -@@ -3433,6 +3683,7 @@ +@@ -3433,6 +3502,7 @@ uint32_t sav_unsup_pc = g_unsupported_pc; uint32_t sav_unsup_insn = g_unsupported_insn; const char *sav_unsup_reason = g_unsupported_reason; @@ -332,7 +151,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f g_ls_replay_active = 1; g_ls_mode = 2; uint32_t pc = rep.pc; -@@ -3456,6 +3707,7 @@ +@@ -3456,6 +3526,7 @@ } g_ls_mode = 0; g_ls_replay_active = 0; @@ -340,7 +159,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f g_unsupported_seen = sav_unsup_seen; /* undo any replay-side decode-state leak */ g_unsupported_pc = sav_unsup_pc; g_unsupported_insn = sav_unsup_insn; -@@ -3550,6 +3802,7 @@ +@@ -3550,6 +3621,7 @@ const char *sav_unsup_reason = g_unsupported_reason; uint32_t sav_store_pc = g_debug_last_store_pc; uint32_t sav_func_addr = g_debug_current_func_addr; @@ -348,7 +167,7 @@ index 0a324bcd9913b32b2bb2bcd7a673fd4aba39e4c4..e988e67fb713654ee6df3833cd231b4f g_ls_replay_active = 1; g_ls_mode = 2; -@@ -3585,6 +3838,7 @@ +@@ -3585,6 +3657,7 @@ g_ls_mode = 0; g_ls_replay_active = 0; diff --git a/game-widescreen.toml b/game-widescreen.toml index d4ee225..38c715e 100644 --- a/game-widescreen.toml +++ b/game-widescreen.toml @@ -23,6 +23,153 @@ address = "0x80043E90" expected = "0x28420002" replacement = "0x28420001" +# Calls made after an instruction of the game that ran from RAM. A site is an address and the whole +# instruction word expected there. Native code gets the same calls from the generator's own lists, which +# a contract keeps equal to these. + +# Disruptor has no architectural pitch state. The project implements its +# opt-in vertical camera at a deliberately finite set of resident instruction +# sites: CPU-projected screen-Y centres, proven vertical participation tests, +# the two projectile-construction convergence points, and the common renderer +# epilogue. Every complete instruction word is pinned so a different retail +# revision fails generation instead of silently modifying unrelated code. +# +# The generated callback is appended after the vanilla instruction. At zero +# pitch it is an exact no-op; the runtime callback decodes the pinned word to +# identify the destination register or the required post-instruction action. +[[recompiler.instruction_hook]] +callback = "disruptor_vertical_camera_instruction_hook" +sites = [ + # CPU world projection centres (ORI rt,zero,120). + ["0x8003AD54", "0x34020078"], + ["0x8003ADE4", "0x34020078"], + ["0x8003B140", "0x34020078"], + ["0x8003B1EC", "0x34020078"], + ["0x8003B764", "0x34020078"], + ["0x8003B990", "0x34020078"], + ["0x8003BDA4", "0x34020078"], + ["0x8003C4B4", "0x34020078"], + ["0x8003C99C", "0x34020078"], + ["0x8003D07C", "0x34020078"], + ["0x8003D398", "0x34030078"], + ["0x8003F6B0", "0x34020078"], + # Proven camera-vertical object participation pairs. + ["0x8003B900", "0x0205102A"], + ["0x8003B90C", "0x0202102A"], + ["0x8003BD14", "0x0206102A"], + ["0x8003BD20", "0x0202102A"], + ["0x8003CDB8", "0x0065102A"], + ["0x8003CDC4", "0x0062102A"], + # Normal-weapon and psionic construction convergence loads. + ["0x8002E4B8", "0x92640022"], + ["0x8002EAF0", "0x92430020"], + # Common renderer epilogue: restore the neutral projection horizon. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's experimental far-rendering override is host-only: reviewed +# loads of the retail far/fade globals are substituted after the original LW, +# leaving guest RAM untouched. Additional reviewed portal, object-decision, +# and packet-boundary instructions feed read-only diagnostics through the same +# callback. The renderer entry starts a measurement span and the reviewed +# common epilogue closes it. Keep this callback independent from the +# vertical-camera dispatcher so either enhancement can be built and tested in +# isolation. +[[recompiler.instruction_hook]] +callback = "disruptor_far_rendering_instruction_hook" +sites = [ + # Portal traversal diagnostics: recursion depth, unique-room completion, + # and the three candidate-distance values before their branch tests. + ["0x8003A0F8", "0x93B300C0"], + ["0x8003A390", "0xA082001F"], + ["0x8003A92C", "0x00005812"], + ["0x8003A964", "0x00681021"], + ["0x8003AA74", "0x00AC1021"], + # Far-distance loads from $gp + 0x4A0. + ["0x8003A914", "0x8F8C04A0"], + ["0x8003B8C8", "0x8F8304A0"], + ["0x8003BCE0", "0x8F8204A0"], + ["0x8003C168", "0x8F8304A0"], + ["0x8003CD64", "0x8F8304A0"], + # Object distance-decision results. + ["0x8003B8D8", "0x0072182A"], + ["0x8003BCEC", "0x0053102A"], + ["0x8003C178", "0x0077182A"], + ["0x8003CD70", "0x0077182A"], + # Fade-start loads from $gp + 0x494. + ["0x8003B2CC", "0x8F820494"], + ["0x8003BA30", "0x8F820494"], + ["0x8003BE40", "0x8F820494"], + ["0x8003C628", "0x8F820494"], + ["0x8003C878", "0x8F820494"], + ["0x8003D28C", "0x8F820494"], + ["0x8003FB0C", "0x8F840494"], + # Billboard post-pass CLUT-row selection. + ["0x80043184", "0x8F820494"], + # Main-renderer visible-list and packet boundaries. + ["0x800410E0", "0x93830310"], + ["0x800410F8", "0x8F820680"], + ["0x800411D8", "0x8F8301D4"], + # Common renderer epilogue. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. +# Their projected centre goes through the reviewed classic-wide X squash, but +# their retail pixel width does not. These are five final packet writes plus +# the deferred base-actor submission seam; the host callback tags only a +# completed, shape-validated packet for proportion repair. +[[recompiler.instruction_hook]] +callback = "disruptor_billboard_aspect_instruction_hook" +sites = [ + ["0x8003BB88", "0xA6030016"], + ["0x8003BFB0", "0xA6030016"], + ["0x8003C848", "0xA6030016"], + ["0x8003CAD4", "0xA6020016"], + ["0x8003D488", "0xA2060025"], + ["0x800433A0", "0x02A02821"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_backdrop_tiles_instruction_hook" +sites = [ + ["0x8003B328", "0x8FB80040"], + ["0x8003B338", "0x00621821"], + ["0x8003B348", "0x8FB80028"], + ["0x8003B5A8", "0x8F8205B8"], + ["0x8003B638", "0x8FB80040"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_sprite_depth_instruction_hook" +sites = [ + ["0x8003B98C", "0xAFA20010"], + ["0x8003BDA0", "0xAFA20010"], + ["0x8003C4B0", "0xAFA20010"], + ["0x8003D078", "0xAFA20010"], + ["0x8003D1B4", "0xA4379EA0"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_intro_skip_instruction_hook" +sites = [ + ["0x800147B0", "0x2A820003"], + ["0x80020A80", "0x2A02005E"], + ["0x80048678", "0x30420940"], +] + [runtime] debug_port = 4624 window_title = "Disruptor Recompiled" diff --git a/game.toml b/game.toml index e25a535..62f246b 100644 --- a/game.toml +++ b/game.toml @@ -26,6 +26,153 @@ address = "0x80043E90" expected = "0x28420002" replacement = "0x28420001" +# Calls made after an instruction of the game that ran from RAM. A site is an address and the whole +# instruction word expected there. Native code gets the same calls from the generator's own lists, which +# a contract keeps equal to these. + +# Disruptor has no architectural pitch state. The project implements its +# opt-in vertical camera at a deliberately finite set of resident instruction +# sites: CPU-projected screen-Y centres, proven vertical participation tests, +# the two projectile-construction convergence points, and the common renderer +# epilogue. Every complete instruction word is pinned so a different retail +# revision fails generation instead of silently modifying unrelated code. +# +# The generated callback is appended after the vanilla instruction. At zero +# pitch it is an exact no-op; the runtime callback decodes the pinned word to +# identify the destination register or the required post-instruction action. +[[recompiler.instruction_hook]] +callback = "disruptor_vertical_camera_instruction_hook" +sites = [ + # CPU world projection centres (ORI rt,zero,120). + ["0x8003AD54", "0x34020078"], + ["0x8003ADE4", "0x34020078"], + ["0x8003B140", "0x34020078"], + ["0x8003B1EC", "0x34020078"], + ["0x8003B764", "0x34020078"], + ["0x8003B990", "0x34020078"], + ["0x8003BDA4", "0x34020078"], + ["0x8003C4B4", "0x34020078"], + ["0x8003C99C", "0x34020078"], + ["0x8003D07C", "0x34020078"], + ["0x8003D398", "0x34030078"], + ["0x8003F6B0", "0x34020078"], + # Proven camera-vertical object participation pairs. + ["0x8003B900", "0x0205102A"], + ["0x8003B90C", "0x0202102A"], + ["0x8003BD14", "0x0206102A"], + ["0x8003BD20", "0x0202102A"], + ["0x8003CDB8", "0x0065102A"], + ["0x8003CDC4", "0x0062102A"], + # Normal-weapon and psionic construction convergence loads. + ["0x8002E4B8", "0x92640022"], + ["0x8002EAF0", "0x92430020"], + # Common renderer epilogue: restore the neutral projection horizon. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's experimental far-rendering override is host-only: reviewed +# loads of the retail far/fade globals are substituted after the original LW, +# leaving guest RAM untouched. Additional reviewed portal, object-decision, +# and packet-boundary instructions feed read-only diagnostics through the same +# callback. The renderer entry starts a measurement span and the reviewed +# common epilogue closes it. Keep this callback independent from the +# vertical-camera dispatcher so either enhancement can be built and tested in +# isolation. +[[recompiler.instruction_hook]] +callback = "disruptor_far_rendering_instruction_hook" +sites = [ + # Portal traversal diagnostics: recursion depth, unique-room completion, + # and the three candidate-distance values before their branch tests. + ["0x8003A0F8", "0x93B300C0"], + ["0x8003A390", "0xA082001F"], + ["0x8003A92C", "0x00005812"], + ["0x8003A964", "0x00681021"], + ["0x8003AA74", "0x00AC1021"], + # Far-distance loads from $gp + 0x4A0. + ["0x8003A914", "0x8F8C04A0"], + ["0x8003B8C8", "0x8F8304A0"], + ["0x8003BCE0", "0x8F8204A0"], + ["0x8003C168", "0x8F8304A0"], + ["0x8003CD64", "0x8F8304A0"], + # Object distance-decision results. + ["0x8003B8D8", "0x0072182A"], + ["0x8003BCEC", "0x0053102A"], + ["0x8003C178", "0x0077182A"], + ["0x8003CD70", "0x0077182A"], + # Fade-start loads from $gp + 0x494. + ["0x8003B2CC", "0x8F820494"], + ["0x8003BA30", "0x8F820494"], + ["0x8003BE40", "0x8F820494"], + ["0x8003C628", "0x8F820494"], + ["0x8003C878", "0x8F820494"], + ["0x8003D28C", "0x8F820494"], + ["0x8003FB0C", "0x8F840494"], + # Billboard post-pass CLUT-row selection. + ["0x80043184", "0x8F820494"], + # Main-renderer visible-list and packet boundaries. + ["0x800410E0", "0x93830310"], + ["0x800410F8", "0x8F820680"], + ["0x800411D8", "0x8F8301D4"], + # Common renderer epilogue. + ["0x8004279C", "0x8FBF016C"], +] + +# Disruptor's world actors and pickups are CPU-built POLY_FT4 billboards. +# Their projected centre goes through the reviewed classic-wide X squash, but +# their retail pixel width does not. These are five final packet writes plus +# the deferred base-actor submission seam; the host callback tags only a +# completed, shape-validated packet for proportion repair. +[[recompiler.instruction_hook]] +callback = "disruptor_billboard_aspect_instruction_hook" +sites = [ + ["0x8003BB88", "0xA6030016"], + ["0x8003BFB0", "0xA6030016"], + ["0x8003C848", "0xA6030016"], + ["0x8003CAD4", "0xA6020016"], + ["0x8003D488", "0xA2060025"], + ["0x800433A0", "0x02A02821"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_backdrop_tiles_instruction_hook" +sites = [ + ["0x8003B328", "0x8FB80040"], + ["0x8003B338", "0x00621821"], + ["0x8003B348", "0x8FB80028"], + ["0x8003B5A8", "0x8F8205B8"], + ["0x8003B638", "0x8FB80040"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_sprite_depth_instruction_hook" +sites = [ + ["0x8003B98C", "0xAFA20010"], + ["0x8003BDA0", "0xAFA20010"], + ["0x8003C4B0", "0xAFA20010"], + ["0x8003D078", "0xAFA20010"], + ["0x8003D1B4", "0xA4379EA0"], +] + +# Disruptor's sky backdrop routine lays SPRT tiles over the opening it has +# already clamped to the 320-pixel frame. These four seams hand the host the +# left edge, the panorama scroll and the right edge before the tile loop reads +# them, so classic widescreen can ask for the columns a wider view needs. +[[recompiler.instruction_hook]] +callback = "disruptor_intro_skip_instruction_hook" +sites = [ + ["0x800147B0", "0x2A820003"], + ["0x80020A80", "0x2A02005E"], + ["0x80048678", "0x30420940"], +] + [runtime] debug_port = 4624 window_title = "Disruptor Recompiled" diff --git a/tests/contracts/test_backdrop_tiles_contract.py b/tests/contracts/test_backdrop_tiles_contract.py index b28f305..8251778 100644 --- a/tests/contracts/test_backdrop_tiles_contract.py +++ b/tests/contracts/test_backdrop_tiles_contract.py @@ -57,7 +57,7 @@ def require(condition: bool, message: str) -> None: gpu = read("psxrecomp/runtime/src/gpu.c") gpu_header = read("psxrecomp/runtime/include/gpu.h") codegen = read("psxrecomp/recompiler/src/code_generator.cpp") -interpreter = read("psxrecomp/runtime/src/dirty_ram_interp.c") +configs = read("game.toml") + read("game-widescreen.toml") audit = read("tools/audit_codegen.py") require( @@ -70,16 +70,12 @@ def require(condition: bool, message: str) -> None: " disruptor_contract(backdrop_tiles RETAIL)\n" in cmake, "the build gate must run this contract with its inputs required", ) -require( - "PSX_HAS_DISRUPTOR_BACKDROP_TILES=1" in cmake, - "the Disruptor target must opt into the dirty-interpreter backdrop seams", -) require( "+runtime/include/gpu_ws_screen_tile.h" in read("framework/FILES.txt").splitlines(), "the tile header must be applied to the framework checkout", ) -for text, name in ((source, "host hook"), (codegen, "code generator"), (interpreter, "dirty interpreter"), (audit, "codegen audit")): +for text, name in ((source, "host hook"), (codegen, "code generator"), (configs, "game configuration"), (audit, "codegen audit")): upper = text.upper() require( all(f"0X{address:08X}" in upper and f"0X{word:08X}" in upper for address, word in TILE_SITES.items()), @@ -87,7 +83,7 @@ def require(condition: bool, message: str) -> None: ) require( "disruptor_backdrop_tiles_instruction_hook(cpu, " in codegen - and "disruptor_backdrop_tiles_instruction_hook(cpu, pc, insn, 1);" in interpreter, + and configs.count('callback = "disruptor_backdrop_tiles_instruction_hook"\n') == 2, "compiled and interpreted code must reach the same host hook", ) require( diff --git a/tests/contracts/test_billboard_aspect_contract.py b/tests/contracts/test_billboard_aspect_contract.py index 5a26ad7..6b8922a 100644 --- a/tests/contracts/test_billboard_aspect_contract.py +++ b/tests/contracts/test_billboard_aspect_contract.py @@ -22,7 +22,7 @@ def require(condition: bool, message: str) -> None: source = read("src/game/render/billboard_aspect.cpp") header = read("src/game/render/billboard_aspect.h") codegen = read("psxrecomp/recompiler/src/code_generator.cpp") -dirty = read("psxrecomp/runtime/src/dirty_ram_interp.c") +configs = read("game.toml") + read("game-widescreen.toml") gpu = read("psxrecomp/runtime/src/gpu.c") gpu_h = read("psxrecomp/runtime/include/gpu.h") tag_match_h = read("psxrecomp/runtime/include/gpu_ws_tag_match.h") @@ -59,19 +59,17 @@ def require(condition: bool, message: str) -> None: require(f"0x{pc:08X}u" in source_table and f"0x{word:08X}u" in source_table, f"host hook table is missing 0x{pc:08X}") - require(f"0x{pc:08X}u" in dirty and f"0x{word:08X}u" in dirty, - f"dirty interpreter is missing 0x{pc:08X}") + require(configs.count(f'["0x{pc:08X}", "0x{word:08X}"],') == 2, + f"code that runs from RAM is not hooked at 0x{pc:08X}") require("disruptor_billboard_aspect_instruction_hook(cpu" in codegen and '"\\n{}disruptor_billboard_aspect_instruction_hook(cpu, "' in codegen, "compiled hooks must be separate post-instruction statements") -require("#ifdef PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT" in dirty and - "disruptor_billboard_aspect_instruction_hook(cpu, pc, insn, 1)" in dirty, - "dirty-interpreter parity must be project-gated and post-instruction") -require("PSX_HAS_DISRUPTOR_BILLBOARD_ASPECT=1" in cmake and - "src/game/render/billboard_aspect.cpp" in cmake and +require(configs.count('callback = "disruptor_billboard_aspect_instruction_hook"\n') == 2, + "both game configurations must list the hook for code that runs from RAM") +require("src/game/render/billboard_aspect.cpp" in cmake and "tests/unit/disruptor_billboard_aspect_test.cpp" in cmake, - "runtime source, dirty seam, and focused behavior test must be enabled") + "runtime source and focused behavior test must be enabled") require("gpu_ws_tag_primitive" in gpu_h and "void gpu_ws_tag_primitive" in gpu and diff --git a/tests/contracts/test_far_rendering_contract.py b/tests/contracts/test_far_rendering_contract.py index 037c1aa..7e6195d 100644 --- a/tests/contracts/test_far_rendering_contract.py +++ b/tests/contracts/test_far_rendering_contract.py @@ -22,7 +22,6 @@ def require(condition: bool, message: str) -> None: source = read("src/game/render/far_rendering.cpp") header = read("src/game/render/far_rendering.h") codegen = read("psxrecomp/recompiler/src/code_generator.cpp") -dirty = read("psxrecomp/runtime/src/dirty_ram_interp.c") main = read("psxrecomp/runtime/src/main.cpp") audit = read("tools/audit_codegen.py") cmake = read("CMakeLists.txt") @@ -32,8 +31,8 @@ def require(condition: bool, message: str) -> None: "tests/unit/disruptor_far_rendering_test.cpp" in cmake and " disruptor_unit_test(disruptor_far_rendering " in cmake, "runtime source and focused behavior test must remain enabled") -require("PSX_HAS_DISRUPTOR_FAR_RENDERING=1" in cmake, - "the Disruptor target must opt into dirty-interpreter far hooks") +require(configs.count('callback = "disruptor_far_rendering_instruction_hook"\n') == 2, + "both game configurations must list the far hooks for code that runs from RAM") # The far renderer shares the already-generated renderer-entry fan-out. A # second config entry would emit a duplicate guest callback. @@ -116,22 +115,18 @@ def require(condition: bool, message: str) -> None: "config_.indent, addr, instr" in codegen, "compiled AFTER hooks must begin a separately indented statement line") -require("#ifdef PSX_HAS_DISRUPTOR_FAR_RENDERING" in dirty and - "disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1)" in dirty, - "dirty interpreter parity must be project-gated and opcode-pinned") +listed_start = configs.index('callback = "disruptor_far_rendering_instruction_hook"\n') +listed_table = configs[listed_start:configs.index("[[recompiler.instruction_hook]]", listed_start)] for pc, instruction in expected_sites.items(): - require(f"0x{pc:08X}u" in dirty and f"0x{instruction:08X}u" in dirty, - f"dirty interpreter is missing reviewed site 0x{pc:08X}") -require("disruptor_far_rendering_instruction_hook(cpu, pc, insn, 1)" in dirty and - '"0x{:08X}u, 0x{:08X}u, 1);"' in codegen, + require(f'["0x{pc:08X}", "0x{instruction:08X}"],' in listed_table, + f"code that runs from RAM is not hooked at reviewed site 0x{pc:08X}") +require('"0x{:08X}u, 0x{:08X}u, 1);"' in codegen, "all reviewed diagnostic sites must remain AFTER-instruction") -dirty_table_start = dirty.index("static int disruptor_far_rendering_site") -dirty_table = dirty[dirty_table_start:dirty.index("#endif", dirty_table_start)] audit_table_start = audit.index("FAR_RENDERING_SITES") audit_table = audit[audit_table_start:audit.index("# Tests 4-6", audit_table_start)] for pc in excluded_sites: token = f"0x{pc:08X}" - require(token not in codegen_table and token not in dirty_table and + require(token not in codegen_table and token not in listed_table and token not in audit_table and token not in source_table, f"excluded far-rendering site leaked into hook tables: {token}") require("FAR_RENDERING_SITES" in audit and diff --git a/tests/contracts/test_instruction_hooks_contract.py b/tests/contracts/test_instruction_hooks_contract.py new file mode 100644 index 0000000..9bf16d3 --- /dev/null +++ b/tests/contracts/test_instruction_hooks_contract.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Source contract for the game's hooks after an instruction: one list in the settings, and who reads it.""" + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +GENERATOR_TABLES = { + "disruptor_vertical_camera_instruction_hook": "kDisruptorVerticalCameraSites", + "disruptor_far_rendering_instruction_hook": "kDisruptorFarRenderingSites", + "disruptor_billboard_aspect_instruction_hook": "kDisruptorBillboardAspectSites", + "disruptor_backdrop_tiles_instruction_hook": "kDisruptorBackdropTileSites", + "disruptor_sprite_depth_instruction_hook": "kDisruptorSpriteDepthSites", + "disruptor_intro_skip_instruction_hook": "kDisruptorIntroSkipSites", +} +SITES = 66 +SHARED = {0x8004279C: ["disruptor_vertical_camera_instruction_hook", "disruptor_far_rendering_instruction_hook"]} + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def listed_hooks(path: str) -> dict[str, dict[int, int]]: + """The hooks of a game configuration, by callback, in the order they are written.""" + hooks: dict[str, dict[int, int]] = {} + for hook in tomllib.loads(read(path))["recompiler"]["instruction_hook"]: + require(set(hook) == {"callback", "sites"}, f"{path}: a hook is a callback and its sites") + sites = {int(address, 16): int(word, 16) for address, word in hook["sites"]} + require( + len(sites) == len(hook["sites"]) and hook["callback"] not in hooks, + f"{path}: {hook['callback']} is listed twice, or one of its addresses is", + ) + hooks[hook["callback"]] = sites + return hooks + + +def generator_table(generator: str, table: str) -> dict[int, int]: + """A site table of the framework's code generator.""" + rows = re.search(r"static const \w+ " + table + r"\[\] = \{\n(.*?)\n\};\n", generator, re.DOTALL) + if rows is None: + raise AssertionError(f"the generator has no table {table}") + found = re.findall(r"\{0x([0-9A-F]{8})u, 0x([0-9A-F]{8})u(?:, \d+u)?\},", rows.group(1)) + sites = {int(address, 16): int(word, 16) for address, word in found} + require(len(sites) == len(found), f"{table} lists an address twice") + return sites + + +generator = read("psxrecomp/recompiler/src/code_generator.cpp") +interpreter = read("psxrecomp/runtime/src/dirty_ram_interp.c") +emitter = read("psxrecomp/recompiler/src/main_psx.cpp") +settings = read("psxrecomp/recompiler/src/config_loader.cpp") +host = read("psxrecomp/runtime/include/game_host.h") +cmake = read("CMakeLists.txt") + +hooks = listed_hooks("game.toml") +require( + hooks == listed_hooks("game-widescreen.toml") and list(hooks) == list(listed_hooks("game-widescreen.toml")), + "both game configurations must list the same hooks in the same order", +) +require( + list(hooks) == list(GENERATOR_TABLES) and sum(len(sites) for sites in hooks.values()) == SITES, + "the hooks are the six the generator knows, in the order it writes them, at 66 sites", +) +for callback, table in GENERATOR_TABLES.items(): + require( + hooks[callback] == generator_table(generator, table), + f"{callback}: native code and code that runs from RAM must be hooked at the same instructions", + ) +shared = { + address: [callback for callback, sites in hooks.items() if address in sites] + for address in {address for sites in hooks.values() for address in sites} +} +require( + {address: callbacks for address, callbacks in shared.items() if len(callbacks) > 1} == SHARED, + "one instruction has two hooks, and the camera's runs first as it does in native code", +) +require( + "instruction_hooks" not in generator + and "instruction_hooks" not in read("psxrecomp/recompiler/include/code_generator.h"), + "the generator's sources make the key of a save state: its own site tables stay until the pin is raised", +) +require( + "disruptor" not in interpreter.lower() + and interpreter.count("psx_game_instruction_ran(") == 1 + and " const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out);\n" + "\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " psx_game_instruction_ran(cpu, pc, insn);\n" + "#endif\n" + in interpreter + and '#ifdef PSX_HAS_GAME_HOST\n#include "game_host.h"\n#endif\n' in interpreter + and "void psx_game_instruction_ran(CPUState *cpu, uint32_t address, uint32_t instruction);\n" in host + and " PSX_HAS_GAME_HOST=1" in cmake, + "the interpreter names no game: it makes one call after an instruction, and the generated code knows the sites", +) +require( + " instruction_hooks = cfg.instruction_hooks;\n" in emitter + and " if (instruction_hooks.empty())\n instruction_hooks = wscfg.instruction_hooks;\n" in emitter + and " hooked[site.address].emplace_back(site.expected, hook.callback);\n" in emitter + and " if (hooked.empty())\n" + ' ds << " (void)cpu; (void)address; (void)instruction;\\n}\\n";\n' + in emitter + and " if (!instruction_hooks.empty()) {\n" not in emitter + and ' ds << "void psx_game_instruction_ran(CPUState* cpu, uint32_t address, uint32_t instruction) {\\n";\n' + in emitter + and ' " if (instruction == 0x{:08X}u) {}(cpu, address, instruction, 1);\\n",\n' + in emitter, + "the dispatch file must carry the interpreter's calls: by address, the whole word checked, after the instruction, " + "and the function itself even when no hook is listed", +) +hashed = settings[settings.index("uint32_t overlay_codegen_config_hash(") : settings.index("// Pad mode <-> string.")] +require( + "instruction_hook" not in hashed + and ' for (const auto& item :\n toml::find(recomp, "instruction_hook")) {\n' + in settings + and " if ((site.address & 3u) != 0)\n" in settings + and " /*instruction_hooks*/ instruction_hooks,\n" in settings, + "the settings reader takes the list, refuses an address between instructions, and keeps it out of the state's key", +) +require( + " disruptor_contract(instruction_hooks)\n" in cmake, + "this contract must remain registered", +) + +print("Disruptor instruction hook source contract: PASS") diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 39a30ca..7406643 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -61,16 +61,16 @@ def require(condition: bool, message: str) -> None: " option->set(settings, option->get(pending));\n" ) generator = read("psxrecomp/recompiler/src/code_generator.cpp") -interpreter = read("psxrecomp/runtime/src/dirty_ram_interp.c") +configs = read("game.toml") + read("game-widescreen.toml") audit = read("tools/audit_codegen.py") settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") require( "src/game/session/intro_skip.cpp" in cmake - and "PSX_HAS_DISRUPTOR_INTRO_SKIP=1" in cmake + and configs.count('callback = "disruptor_intro_skip_instruction_hook"\n') == 2 and " disruptor_contract(intro_skip RETAIL)\n" in cmake and " disruptor_unit_test(disruptor_intro_skip " in cmake, - "intro-skip source, its interpreter switch and both tests must remain registered", + "intro-skip source, its hook for code that runs from RAM and both tests must remain registered", ) require( @@ -83,8 +83,8 @@ def require(condition: bool, message: str) -> None: f"the generator must pin the intro-skip seam at 0x{address:08X}", ) require( - f"(pc == 0x{address:08X}u && insn == 0x{word:08X}u)" in interpreter, - f"the dirty-RAM interpreter must pin the intro-skip seam at 0x{address:08X}", + configs.count(f'["0x{address:08X}", "0x{word:08X}"],') == 2, + f"code that runs from RAM must be hooked at the intro-skip seam 0x{address:08X}", ) require(f"0x{address:08X}: 0x{word:08X}," in audit, f"the codegen audit must expect the seam at 0x{address:08X}") require(f"0x{address:08X}u" in source and f"0x{word:08X}u" in source, f"the hook must check 0x{address:08X}") diff --git a/tests/contracts/test_state_key_contract.py b/tests/contracts/test_state_key_contract.py new file mode 100644 index 0000000..079a7cc --- /dev/null +++ b/tests/contracts/test_state_key_contract.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Source contract for the key a save state carries: the generator's sources it is made of. + +The key is pinned twice: over the files' bytes as the pin and the project's patches give them, and with CRLF read as +LF, which is how CMake reads a file on Windows before it hashes. +""" + +import hashlib +import importlib.util +import re +import sys +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[2] +KEY_OF_BYTES = "2d1942e0" +KEY_OF_TEXT = "2cece446" + +specification = importlib.util.spec_from_file_location("framework_tool", ROOT / "tools" / "framework.py") +if specification is None or specification.loader is None: + raise AssertionError("tools/framework.py cannot be loaded") +tool = importlib.util.module_from_spec(specification) +sys.modules[specification.name] = tool +specification.loader.exec_module(tool) + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def as_text(content: bytes) -> bytes: + return content.replace(b"\r\n", b"\n") + + +def key(content: bytes) -> str: + return hashlib.sha256(content).hexdigest()[:8] + + +listing = read("psxrecomp/runtime/codegen_hash_sources.cmake") +sources = re.findall(r"^\s*\$\{PSXRECOMP_CODEGEN_HASH_ROOT\}/(\S+?)\)?$", listing, re.MULTILINE) +require(len(sources) == 19, f"the key is made of 19 sources, the framework lists {len(sources)}") + +project = tool.Project(ROOT) +checkout = tool.Checkout(ROOT / "psxrecomp", project.pin()) +changed = {str(change.path): change for change in project.changes()} +stored, working = b"", b"" +for source in sources: + change = changed.get(source, tool.Change(PurePosixPath(source), False)) + pinned = checkout.pinned(change) + if pinned is None: + raise AssertionError(f"the pinned framework has no {source}") + stored += tool.apply_patch(pinned, project.record(change).read_bytes()) if source in changed else pinned + working += (ROOT / "psxrecomp" / source).read_bytes() +require( + key(stored) == KEY_OF_BYTES and key(as_text(stored)) == KEY_OF_TEXT, + f"the generator's sources give the keys {key(stored)} and {key(as_text(stored))}, not {KEY_OF_BYTES} and " + f"{KEY_OF_TEXT}: every save state and capsule made so far would be refused. Change them only with the pin, " + "and restate the keys here", +) +require( + as_text(working) == as_text(stored), + "the checkout's generator sources are not the pinned ones with the project's patches", +) +hashing = read("psxrecomp/runtime/hash_codegen.cmake") +require( + 'string(SHA256 _h "${_cat}")\nstring(SUBSTRING "${_h}" 0 8 _h8)\n' in hashing + and " h.codegen_hash != (uint32_t)PSX_OVERLAY_CODEGEN_HASH ||\n" + in read("psxrecomp/runtime/src/boot_state.c"), + "the key is the first eight digits of the sources' hash, and a state of another key is refused", +) +require(" disruptor_contract(state_key)\n" in read("CMakeLists.txt"), "this contract must remain registered") + +print("Disruptor save state key source contract: PASS") diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 974c006..7d0c12d 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -42,8 +42,12 @@ def shader(source: str, name: str) -> str: billboards = read("src/game/render/billboard_aspect.cpp") require( - " disruptor_contract(temporal_presenter)\n" in cmake and "PSX_HAS_DISRUPTOR_SPRITE_DEPTH=1" in cmake, - "the presenter contract and the sprite-depth interpreter switch must remain registered", + " disruptor_contract(temporal_presenter)\n" in cmake + and (read("game.toml") + read("game-widescreen.toml")).count( + 'callback = "disruptor_sprite_depth_instruction_hook"\n' + ) + == 2, + "the presenter contract and the sprite depth hook for code that runs from RAM must remain registered", ) underlay = function_body(temporal, "static void temporal_underlay(float alpha,int covered)") diff --git a/tests/contracts/test_vertical_camera_contract.py b/tests/contracts/test_vertical_camera_contract.py index 8508394..777490b 100644 --- a/tests/contracts/test_vertical_camera_contract.py +++ b/tests/contracts/test_vertical_camera_contract.py @@ -23,7 +23,6 @@ def require(condition: bool, message: str) -> None: camera_h = read("src/game/input/vertical_camera.h") mouse = read("src/game/input/mouse_aim.cpp") codegen = read("psxrecomp/recompiler/src/code_generator.cpp") -dirty = read("psxrecomp/runtime/src/dirty_ram_interp.c") gpu = read("psxrecomp/runtime/src/gpu.c") cmake = read("CMakeLists.txt") configs = read("game.toml") + read("game-widescreen.toml") @@ -63,11 +62,12 @@ def require(condition: bool, message: str) -> None: "addr, instr);" not in codegen_table, "generated sites must call the guarded camera dispatcher") -require("#ifdef PSX_HAS_DISRUPTOR_VERTICAL_CAMERA" in dirty and - "disruptor_vertical_camera_instruction_hook(cpu, pc, insn, 1)" in dirty, - "dirty interpreter parity must be project-gated and post-instruction") -require("PSX_HAS_DISRUPTOR_VERTICAL_CAMERA=1" in cmake, - "the Disruptor target must opt into dirty-interpreter camera hooks") +listed_start = configs.index('callback = "disruptor_vertical_camera_instruction_hook"\n') +listed_table = configs[listed_start:configs.index("[[recompiler.instruction_hook]]", listed_start)] +require(configs.count('callback = "disruptor_vertical_camera_instruction_hook"\n') == 2 and + all(f'["0x{pc:08X}", "0x{word:08X}"],' in listed_table + for pc, word in expected_sites.items()), + "both game configurations must list the camera hooks for code that runs from RAM") require("g_ls_mode != 0 || g_ls_replay_active != 0" in camera, "record and replay halves of the lockstep comparator must be neutral") diff --git a/tools/audit_codegen.py b/tools/audit_codegen.py index 73fc9ee..038105b 100644 --- a/tools/audit_codegen.py +++ b/tools/audit_codegen.py @@ -220,6 +220,23 @@ 0x800433A0: 0x02A02821, } +# Code that runs from RAM gets the same calls from one function of the dispatch file. +INTERPRETED_HOOKS = { + "disruptor_vertical_camera_instruction_hook": VERTICAL_CAMERA_SITES, + "disruptor_far_rendering_instruction_hook": FAR_RENDERING_SITES, + "disruptor_billboard_aspect_instruction_hook": BILLBOARD_ASPECT_SITES, + "disruptor_backdrop_tiles_instruction_hook": BACKDROP_TILE_SITES, + "disruptor_sprite_depth_instruction_hook": SPRITE_DEPTH_SITES, + "disruptor_intro_skip_instruction_hook": INTRO_SKIP_SITES, +} +INTERPRETED_CASE = re.compile( + r" case 0x([0-9A-F]{8})u:\n((?: if \(instruction == 0x[0-9A-F]{8}u\) \w+\(cpu, address, instruction, 1\);\n)+)" + r" break;\n" +) +INTERPRETED_CALL = re.compile( + r" if \(instruction == 0x([0-9A-F]{8})u\) (\w+)\(cpu, address, instruction, 1\);\n" +) + # Tests 4-6 widened Disruptor's portal spans into negative X. Its static-world # renderer was authored around unsigned 0..320 spans, and yaw-dependent portal # selection became unstable. Test 7 instead keeps every portal/outcode bound @@ -255,6 +272,31 @@ def billboard_seam_precedes_texture_stores(shard_text: str) -> bool: return seam < shard_text.index(late[0]) < shard_text.index(late[1]) +def interpreted_hooks(dispatch_text: str) -> dict[str, dict[int, int]] | None: + """The calls the dispatch file makes after an instruction that ran from RAM, or None when it has no such list.""" + opening = "void psx_game_instruction_ran(CPUState* cpu, uint32_t address, uint32_t instruction) {\n switch (address) {\n" + closing = " default:\n break;\n }\n}\n" + if dispatch_text.count(opening) != 1: + return None + body = dispatch_text[dispatch_text.index(opening) + len(opening) :] + if closing not in body: + return None + body = body[: body.index(closing)] + cases = INTERPRETED_CASE.findall(body) + if "".join(f" case 0x{address}u:\n{calls} break;\n" for address, calls in cases) != body: + return None + hooks: dict[str, dict[int, int]] = {} + for address, calls in cases: + called = [callback for _, callback in INTERPRETED_CALL.findall(calls)] + if called != [callback for callback in INTERPRETED_HOOKS if callback in called]: + return None + for word, callback in INTERPRETED_CALL.findall(calls): + if int(address, 16) in hooks.setdefault(callback, {}): + return None + hooks[callback][int(address, 16)] = int(word, 16) + return hooks + + def format_addresses(addresses: set[int]) -> str: values = sorted(addresses) shown = ", ".join(f"0x{value:08X}" for value in values[:12]) @@ -703,6 +745,12 @@ def main() -> int: + str(generated_precision_copy_stores) ) + if interpreted_hooks(dispatch_text) != INTERPRETED_HOOKS: + failures.append( + "the dispatch file must give code that runs from RAM the calls native code has: " + + str(interpreted_hooks(dispatch_text)) + ) + for site in VANILLA_PORTAL_AND_POINT_SITES: if site not in shard_text: failures.append( From d89a477f74d482387e9fb50783bfd6da9354bc78 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 04:50:42 +0300 Subject: [PATCH 16/21] refactor: ask the game about a grown quad through the host interface --- CMakeLists.txt | 3 +- framework/files/runtime/include/game_host.h | 4 + framework/patches/runtime/src/gpu.c.patch | 152 +++++++++--------- src/game/host.cpp | 9 ++ tests/contracts/test_capsule_contract.py | 2 +- .../test_geometry_provenance_contract.py | 3 +- tests/contracts/test_grown_quad_contract.py | 19 ++- tests/contracts/test_name_entry_contract.py | 2 +- .../test_temporal_presenter_contract.py | 21 +++ 9 files changed, 127 insertions(+), 88 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c13ad5f..144c7f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -134,8 +134,7 @@ psxrecomp_add_runtime_target(psx-runtime EXE_NAME "DisruptorRecompiled" ) target_compile_definitions(psx-runtime PRIVATE - PSX_HAS_GAME_HOST=1 - PSX_HAS_DISRUPTOR_GROWN_QUAD=1) + PSX_HAS_GAME_HOST=1) if(NOT DISRUPTOR_FRAME_INTERPOLATION) target_compile_definitions(psx-runtime PRIVATE # Keep the experimental sources available, but compile out frame diff --git a/framework/files/runtime/include/game_host.h b/framework/files/runtime/include/game_host.h index d0bc7be..e8580a0 100644 --- a/framework/files/runtime/include/game_host.h +++ b/framework/files/runtime/include/game_host.h @@ -28,6 +28,10 @@ int psx_game_key(int scancode, uint32_t keycode, int down, int plain); /* Called around the sampling of the pad, outside netplay. */ void psx_game_before_input(void); void psx_game_after_input(void); +/* Nonzero when the four-cornered polygon whose command sits at `command_address` is the game's second copy of the one + * before it, grown outwards. Then each corner's source address and source word are those of the first copy's corner. */ +int psx_game_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], + uint32_t source_word[4]); /* Supplied by the generated code, from [[recompiler.instruction_hook]]: the game's calls for an instruction that ran from RAM. */ void psx_game_instruction_ran(CPUState *cpu, uint32_t address, uint32_t instruction); diff --git a/framework/patches/runtime/src/gpu.c.patch b/framework/patches/runtime/src/gpu.c.patch index 45ac484..6068237 100644 --- a/framework/patches/runtime/src/gpu.c.patch +++ b/framework/patches/runtime/src/gpu.c.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c -index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867ebb08b0b 100644 +index 6d81477285f786cdd3658d14c424d4bfbab5d809..03a9bb50a3a0acf42c4593027554279e973793c3 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -11,6 +11,7 @@ @@ -10,7 +10,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 #include "mod_memory.h" #include "gpu_primitive_reject.h" #include "gpu_sw_renderer.h" -@@ -24,7 +25,16 @@ +@@ -24,7 +25,19 @@ #include "mod_runtime.h" #include "ws_cull_detect.h" #include "ws_aspect_cone_math.h" @@ -23,11 +23,14 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 +#include "gpu_ws_menu.h" +#include "gpu_temporal_sprite.h" #include "ws_ui_group.h" ++#ifdef PSX_HAS_GAME_HOST ++#include "game_host.h" ++#endif +#include #include #include #include -@@ -104,12 +114,36 @@ +@@ -104,12 +117,36 @@ * in native-wide mode. */ static int ws_mode = 0; static int ws_cfg_num = 4, ws_cfg_den = 3; @@ -65,7 +68,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 static WsTag ws_tags[WS_TAG_BUCKETS]; static uint32_t ws_last_tag_stamp = (uint32_t)-1000; /* frame of newest tag */ static uint32_t ws_last_3d_stamp = (uint32_t)-1000; /* frame of newest shaded prim (diagnostic) */ -@@ -177,6 +211,8 @@ +@@ -177,6 +214,8 @@ * intro-cutscene flicker). Only a genuine full-2D screen — no GTE projection for * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ #define WS_GTE_GAME_MODE_HYSTERESIS 45u @@ -74,7 +77,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } void gpu_ws_set_gameplay_state_gate(uint32_t addr, const uint32_t *values, int nvalues) { -@@ -240,6 +276,7 @@ +@@ -240,6 +279,7 @@ void psx_ws_note_gte_project(int nverts) { uint32_t f = (uint32_t)s_frame_count; @@ -82,7 +85,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 if (f != ws_gte_frame) { if (f == ws_gte_frame + 1u) ws_gte_prev_verts = ws_gte_count; else ws_gte_prev_verts = 0; -@@ -262,14 +299,22 @@ +@@ -262,14 +302,22 @@ if (env < 0) { const char *e = getenv("PSX_WS_FORCE_2D"); env = (e && e[0] == '1') ? 1 : 0; } return ws_full_2d || env; } @@ -108,7 +111,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* True when the current frame is presented at native 4:3 (NOT stretched), so * ALL squash must be suppressed and the content rendered pixel-native: -@@ -304,9 +349,19 @@ +@@ -304,9 +352,19 @@ static uint32_t s_ws_fmv_frame_cache = 0xFFFFFFFFu; static int s_ws_fmv_cached = 0; @@ -130,7 +133,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 if (ws_2d_only_scene()) return 1; /* 2D-only gameplay scene */ uint32_t f = (uint32_t)s_frame_count; if (f != s_ws_fmv_frame_cache) { -@@ -315,6 +370,14 @@ +@@ -315,6 +373,14 @@ s_ws_fmv_cached = di.depth24 || mdec_recently_active(WS_FMV_HYSTERESIS); } return s_ws_fmv_cached; @@ -145,7 +148,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Squash applies only when configured AND the frame is being stretched. */ -@@ -599,6 +662,38 @@ +@@ -599,6 +665,38 @@ if (widened > ws_angle_max_widened) ws_angle_max_widened = widened; return widened; @@ -184,7 +187,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out) { -@@ -1303,6 +1398,23 @@ +@@ -1303,6 +1401,23 @@ return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; } @@ -208,7 +211,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Signed left-edge widen for the funnel's `bltz maxSX, reject`: reject only * when the prim ends left of the REVEALED edge (maxSX < -margin). Returns the * branch predicate. Identity at margin 0 (4:3). */ -@@ -1403,6 +1515,30 @@ +@@ -1403,6 +1518,30 @@ if (!ws_active()) return (int16_t)x; int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ return ws_scale_about((int16_t)x, cx); @@ -239,7 +242,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Backdrop PRELOAD predicate + value substitution ([widescreen.cull] -@@ -1616,17 +1752,10 @@ +@@ -1616,17 +1755,10 @@ ws_nw_sync_target(); } @@ -260,7 +263,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 uint32_t now = (uint32_t)s_frame_count; uint32_t idx = (key >> 2) & (WS_TAG_BUCKETS - 1); uint32_t victim = idx; -@@ -1638,8 +1767,175 @@ +@@ -1638,8 +1770,169 @@ } ws_tags[victim].key = key; ws_tags[victim].stamp = now; @@ -340,15 +343,9 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 + t->height = height16; +} + -+static int s_shadow_shape = -1; /* not chosen yet: PSX_DISRUPTOR_IMPROVED_SHADOWS=1 of the launcher decides */ ++static int s_shadow_shape; +void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; } -+int gpu_shadow_shape(void) { -+ if (s_shadow_shape < 0) { -+ const char *asked = getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS"); -+ s_shadow_shape = asked && asked[0] == '1'; -+ } -+ return s_shadow_shape; -+} ++int gpu_shadow_shape(void) { return s_shadow_shape; } +int gpu_sprite_placement_available(void) { +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + return 1; @@ -437,7 +434,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Look up the executing GP0 command's prim in the tag table. The command's -@@ -1649,11 +1945,12 @@ +@@ -1649,11 +1942,12 @@ if (!ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; uint32_t now = (uint32_t)s_frame_count; for (int variant = 0; variant < 2; variant++) { @@ -452,7 +449,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 *out_ax = t->anchor_x; return 1; } -@@ -1786,11 +2083,12 @@ +@@ -1786,11 +2080,12 @@ if (!ws_engaged() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; uint32_t now = (uint32_t)s_frame_count; for (int variant = 0; variant < 2; variant++) { @@ -467,7 +464,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } } return 0; -@@ -1820,6 +2118,12 @@ +@@ -1820,6 +2115,12 @@ GpuDisplayInfo di; gpu_get_display_info(&di); return di.height ? (int32_t)di.height : 240; @@ -480,7 +477,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Full-screen fades and environmental filters are authored as 320x240 TILEs. -@@ -1842,9 +2146,10 @@ +@@ -1842,9 +2143,10 @@ * the middle third to centre. A composite (counter box + digits) sits inside * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ @@ -492,7 +489,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 if (3 * cx < 2 * W) return 0; if (3 * cx > 4 * W) return W; return W / 2; -@@ -1861,7 +2166,7 @@ +@@ -1861,7 +2163,7 @@ if (!ws_auto_ui_squash || !ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; @@ -501,7 +498,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { if (ws_ui_prepass[i].src_addr != src) continue; if (out_anchor) *out_anchor = ws_ui_prepass[i].group.anchor; -@@ -1946,20 +2251,102 @@ +@@ -1946,20 +2248,102 @@ return 1; } @@ -607,7 +604,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 *x0 = ws_scale_about(*x0, ws_hud_pivot(*x0, w)); return (int)ws_scale_len(w); } -@@ -2024,6 +2411,10 @@ +@@ -2024,6 +2408,10 @@ * the game's dedicated HUD packet arena reaches here, never world polygons. */ static void ws_nw_hud_shift_vertices(int32_t *vx, int count) { if (count <= 0) return; @@ -618,7 +615,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Sprite-tag titles: polygon/line prims are the GTE world and the tagged * character billboards, never HUD — only the rect-family sites (which * call ws_nw_hud_shift directly, with the untagged filter) re-anchor. */ -@@ -2103,6 +2494,7 @@ +@@ -2103,6 +2491,7 @@ * CPU-visible transfer remains ordered because GP0 accepts no next command * until this payload is complete. Maximum PS1 transfer = full VRAM (1 MiB). */ static uint16_t vram_write_pixels[1024 * 512]; @@ -626,7 +623,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Depth24 CPU→VRAM upload span (halfwords, exclusive end). See * gpu_depth24_rgb_limit — declared early so gpu_reset_state can clear it. */ -@@ -2110,6 +2502,58 @@ +@@ -2110,6 +2499,58 @@ static int s_d24_present_hold = 0; /* vblanks to skip Swap after GP1(07h) */ static uint32_t s_d24_prev_disp_h = 0; /* last GP1(07h) band height */ static void depth24_note_upload(uint32_t x, uint32_t w); @@ -685,7 +682,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 static void gp0_commit_cpu_to_vram(void) { for (uint32_t row = 0; row < vram_write_h; row++) -@@ -2120,6 +2564,11 @@ +@@ -2120,6 +2561,11 @@ gr_vram_transfer_in(vram_write_x, vram_write_y, vram_write_w, vram_write_h, vram_write_pixels); depth24_note_upload(vram_write_x, vram_write_w); @@ -697,7 +694,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gp0_state = GP0_IDLE; vram_write_remaining = 0; text_xlate_vram_upload(vram_write_x, vram_write_y, -@@ -2221,11 +2670,186 @@ +@@ -2221,11 +2667,186 @@ * when native-wide is active and that buffer is a known display buffer; else * disable mirroring for this draw. Called when the draw env changes. */ static void ws_nw_sync_target(void) { @@ -886,7 +883,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } static void ws_clear_all_reveal_margins(void) { -@@ -2239,18 +2863,23 @@ +@@ -2239,18 +2860,23 @@ * stage art. A stale reveal tile is safer than deleting submitted content. */ void gpu_ws_begin_linked_list(void) { gp0_ot_rank = 0xFFFFu; @@ -910,7 +907,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } -@@ -2362,6 +2991,16 @@ +@@ -2362,6 +2988,16 @@ s_d24_upload_x1 = 0; s_d24_present_hold = 0; s_d24_prev_disp_h = 0; @@ -927,7 +924,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } void gpu_init(void) { -@@ -2538,6 +3177,9 @@ +@@ -2538,6 +3174,9 @@ } g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; gpustat_poll_count = 0; @@ -937,7 +934,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Trusted package-selected plugins run on guest VBlank, independent of * host presentation, pacing, turbo, or skipped frames. */ mod_runtime_on_vblank(); -@@ -2757,73 +3399,527 @@ +@@ -2757,73 +3396,522 @@ *y = sign_extend((word >> 16) & 0x7FFu, 11); } @@ -1177,7 +1174,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 +static PsxPinnedPlaces s_pinned_places; +/* The polygon resolved last is the game's grown second copy of the one before it. */ +static int s_precise_grown; -+#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) ++#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) +/* A grown corner lies off its source corner, on the same plane: its depth is the plane's there, 1/z being linear on screen. */ +static void grown_corner_depths(const int32_t *source_x, const int32_t *source_y, const int32_t *raw_x, const int32_t *raw_y, + const int *indices) { @@ -1204,11 +1201,6 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 + memcpy(s_precise_vertex_depth, moved, sizeof(moved)); +} +#endif -+#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD -+extern int disruptor_grown_quad_sources( -+ uint32_t command_address, const uint32_t *packet, -+ uint32_t source_address[4], uint32_t source_word[4]); -+#endif +static int resolve_precise_vertices(const int *indices, int count, + int triangle_count, + const int32_t *vx, const int32_t *vy, @@ -1254,9 +1246,9 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 + int pinned[4] = {0}; + const int unpin = gte_precision_unpin_enabled(); + uint32_t grown_addr[4] = {0}, grown_word[4] = {0}; -+#ifdef PSX_HAS_DISRUPTOR_GROWN_QUAD -+ /* Disruptor's second, grown copy of a world quad stands on the first packet's corners. */ -+ const int grown = count == 4 && disruptor_grown_quad_sources( ++#ifdef PSX_HAS_GAME_HOST ++ /* The game's second, grown copy of a world quad stands on the first packet's corners. */ ++ const int grown = count == 4 && psx_game_grown_quad_sources( + gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word); +#else + const int grown = 0; @@ -1352,7 +1344,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 + (int64_t)(vy[i] - raw_y[i]) * 65536); + } + s_precise_grown = grown; -+#if defined(PSX_HAS_DISRUPTOR_GROWN_QUAD) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) ++#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION) + if (grown) { + grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices); + memcpy(s_precise_vertex_surface, s_precise_vertex_depth, sizeof(s_precise_vertex_surface)); @@ -1521,7 +1513,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Write a single pixel to VRAM with draw area clipping and mask bit handling */ -@@ -3009,8 +4105,7 @@ +@@ -3009,8 +4097,7 @@ } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); @@ -1531,7 +1523,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } -@@ -3046,6 +4141,7 @@ +@@ -3046,6 +4133,7 @@ gr_draw_flat_rect(x, y, w, h, color); return; } @@ -1539,7 +1531,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop stretch (no-op else) */ ws_nw_hud_shift_vertices(vx, 4); for (int i = 0; i < 4; i++) { -@@ -3054,18 +4150,17 @@ +@@ -3054,18 +4142,17 @@ } if (draw_area_out_bbox(vx, vy, 4)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); @@ -1565,7 +1557,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); } } -@@ -3088,8 +4183,7 @@ +@@ -3088,8 +4175,7 @@ } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); @@ -1575,7 +1567,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], vx[2], vy[2], c[2]); -@@ -3131,20 +4225,19 @@ +@@ -3131,20 +4217,19 @@ } } gr_set_semi_transparency(semi_trans, (int)semi_transparency); @@ -1603,7 +1595,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_gouraud_triangle(vx[2], vy[2], c[2], vx[1], vy[1], c[1], vx[3], vy[3], c[3]); -@@ -3215,9 +4308,8 @@ +@@ -3215,9 +4300,8 @@ if (draw_area_out_bbox(vx, vy, 3)) return; setup_textured_draw(color24, semi_trans, raw_texture); @@ -1615,7 +1607,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], vx[1], vy[1], u[1], v[1], vx[2], vy[2], u[2], v[2], -@@ -3252,11 +4344,16 @@ +@@ -3252,11 +4336,16 @@ /* Widescreen: tagged billboard quads carry CPU-computed pixel offsets the * GTE squash never saw — re-squash every X around the prim's anchor. */ @@ -1634,7 +1626,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 ws_auto_ui_transform_quad(vx, vy); ws_nw_backdrop_stretch_quad(vx, vy); /* full-frame 2D backdrop image stretch (no-op else) */ ws_nw_hud_shift_vertices(vx, 4); -@@ -3282,6 +4379,13 @@ +@@ -3282,6 +4371,13 @@ int top_v = vy[0] < vy[2] ? v[0] : v[2]; int bot_v = vy[0] < vy[2] ? v[2] : v[0]; if (w > 0 && h > 0) { @@ -1648,7 +1640,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 if (right_u - left_u == w && bot_v - top_v == h) { gr_draw_textured_rect(x, y, w, h, left_u, top_v, clut_x, clut_y, tpage); -@@ -3294,23 +4398,25 @@ +@@ -3294,23 +4390,25 @@ } } @@ -1684,7 +1676,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], vx[1], vy[1], u[1], v[1], vx[3], vy[3], u[3], v[3], -@@ -3351,9 +4457,8 @@ +@@ -3351,9 +4449,8 @@ if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); @@ -1696,7 +1688,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], vx[1], vy[1], u[1], v[1], c[1], vx[2], vy[2], u[2], v[2], c[2], -@@ -3399,23 +4504,25 @@ +@@ -3399,23 +4496,25 @@ if (draw_area_out_bbox(vx, vy, 4)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); @@ -1732,7 +1724,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], vx[1], vy[1], u[1], v[1], c[1], vx[3], vy[3], u[3], v[3], c[3], -@@ -3477,6 +4584,7 @@ +@@ -3477,6 +4576,7 @@ if (w > 1023) w = 1023; if (h > 511) h = 511; ws_expand_fullscreen_rect(&x0, y0, &w, h); @@ -1740,7 +1732,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ x0 += draw_offset_x; y0 += draw_offset_y; if (draw_area_out_rect(x0, y0, w, h)) return; -@@ -3504,35 +4612,76 @@ +@@ -3504,35 +4604,76 @@ /* Widescreen: tagged sprite parts squash around their projected anchor; * untagged SPRTs are screen-space 2D (HUD/menus) and squash around the * display centre. Texels keep full coverage via the scaled-rect path. */ @@ -1829,7 +1821,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Execute 1x1 dot (GP0 0x68-0x6B) */ -@@ -3541,6 +4690,7 @@ +@@ -3541,6 +4682,7 @@ uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t x, y; parse_vertex(gp0_cmd_buf[1], &x, &y); @@ -1837,7 +1829,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 x += ws_nw_hud_shift(x, 1); x += draw_offset_x; y += draw_offset_y; if (draw_area_out_point(x, y)) return; -@@ -3555,13 +4705,12 @@ +@@ -3555,13 +4697,12 @@ int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); @@ -1855,7 +1847,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 int u0 = gp0_cmd_buf[2] & 0xFF; int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); -@@ -3569,8 +4718,8 @@ +@@ -3569,8 +4710,8 @@ uint16_t clut_y = (clut >> 6) & 0x1FF; setup_textured_draw(color24, semi_trans, raw_texture); @@ -1866,7 +1858,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 clut_x, clut_y, current_texpage()); else gr_draw_textured_rect(x0, y0, 8, 8, u0, v0, clut_x, clut_y, current_texpage()); -@@ -3582,11 +4731,12 @@ +@@ -3582,11 +4723,12 @@ uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); @@ -1881,7 +1873,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } /* Execute 16x16 textured sprite (GP0 0x7C-0x7F) */ -@@ -3599,7 +4749,8 @@ +@@ -3599,7 +4741,8 @@ /* Never suppress MMX6 BG packets at a guessed finite-map boundary. The * classifier cannot distinguish an authored layer entering the reveal from * a stale ring slot; suppressing here caused the stage-start black flicker. */ @@ -1891,7 +1883,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 x0 += ws_nw_hud_shift(x0, 16); /* native-wide HUD corner re-anchor (no-op else) */ x0 += draw_offset_x; y0 += draw_offset_y; int u0 = gp0_cmd_buf[2] & 0xFF; -@@ -3608,13 +4759,11 @@ +@@ -3608,13 +4751,11 @@ uint16_t clut_x = (clut & 0x3F) * 16; uint16_t clut_y = (clut >> 6) & 0x1FF; @@ -1909,7 +1901,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 clut_x, clut_y, current_texpage()); else gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage()); -@@ -3642,12 +4791,15 @@ +@@ -3642,12 +4783,15 @@ /* Fill ignores draw area, mask bits, and draw offset — writes directly to * VRAM. Routed through the renderer so it also fills the hi-res * supersampling mirror (no-op cost when supersampling is off). */ @@ -1929,7 +1921,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); } } -@@ -3682,6 +4834,11 @@ +@@ -3682,6 +4826,11 @@ gr_set_texture_window(texture_window_value); } @@ -1941,7 +1933,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 static void gp0_exec_draw_area_tl(void) { /* 0xE3 — Set Drawing Area Top-Left * Bits 0-9: X -@@ -3691,7 +4848,12 @@ +@@ -3691,7 +4840,12 @@ draw_area_top = (param >> 10) & 0x3FF; gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); @@ -1954,7 +1946,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 } static void gp0_exec_draw_area_br(void) { -@@ -3703,6 +4865,7 @@ +@@ -3703,6 +4857,7 @@ draw_area_bottom = (param >> 10) & 0x3FF; gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); @@ -1962,7 +1954,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ } -@@ -3799,7 +4962,10 @@ +@@ -3799,7 +4954,10 @@ /* 0 means max dimension */ vram_write_w = (w == 0) ? 0x400 : (uint16_t)w; vram_write_h = (h == 0) ? 0x200 : (uint16_t)h; @@ -1974,7 +1966,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Record for debug */ if (a0_history_count < A0_HISTORY_CAP) { int slot = a0_history_count++; -@@ -3879,6 +5045,7 @@ +@@ -3879,6 +5037,7 @@ uint32_t h = (gp0_cmd_buf[2] >> 16) & 0x1FFu; vram_read_w = (w == 0) ? 0x400 : (uint16_t)w; vram_read_h = (h == 0) ? 0x200 : (uint16_t)h; @@ -1982,7 +1974,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Record for debug */ if (c0_history_count < C0_HISTORY_CAP) { -@@ -4047,7 +5214,7 @@ +@@ -4047,7 +5206,7 @@ item->group.x = min_x; item->group.width = width; item->group.anchor = 0; @@ -1991,7 +1983,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 item->ot_rank = rank; } -@@ -4397,6 +5564,10 @@ +@@ -4397,6 +5556,10 @@ extern void ws_bg_phase_note(uint32_t op); ws_bg_phase_note(opcode); /* native-wide 2D-backdrop stretch: background-phase latch */ @@ -2002,7 +1994,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 /* Draw-census: capture every drawing primitive's first vertex + camera. */ if (opcode >= 0x20 && opcode <= 0x7F) { int32_t cvx, cvy; -@@ -4499,7 +5670,8 @@ +@@ -4499,7 +5662,8 @@ int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); @@ -2012,7 +2004,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 x0 += draw_offset_x; y0 += draw_offset_y; int u0 = gp0_cmd_buf[2] & 0xFF; int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; -@@ -4522,9 +5694,10 @@ +@@ -4522,9 +5686,10 @@ uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); @@ -2024,7 +2016,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 break; } case 0x7C: case 0x7D: case 0x7E: case 0x7F: -@@ -4549,7 +5722,10 @@ +@@ -4549,7 +5714,10 @@ int h = (gp0_cmd_buf[3] >> 16) & 0x1FF; if (w == 0) w = 0x400; if (h == 0) h = 0x200; @@ -2035,20 +2027,20 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 break; } -@@ -4901,7 +6077,11 @@ +@@ -4901,7 +6069,11 @@ hres1 = val & 3; vres = (val >> 2) & 1; video_mode = (val >> 3) & 1; - display_depth = (val >> 4) & 1; + const uint32_t depth = (val >> 4) & 1; -+ /* A 24-bit scanout reads the CPU copy of VRAM, and a GPU backend leaves that copy without what it drew. Disruptor ++ /* A 24-bit scanout reads the CPU copy of VRAM, and a GPU backend leaves that copy without what it drew. The game + * blacks out its movie's area with a rectangle a few VBlanks before it switches, so sync once, at the switch. */ + if (depth && !display_depth) (void)gr_vram_read(0, 0); + display_depth = depth; vertical_interlace = (val >> 5) & 1; /* GPUSTAT.13 holds the legacy constant 0 in progressive (see the vblank * field flip); clear it on the switch so a title that toggles interlace -@@ -5087,11 +6267,16 @@ +@@ -5087,11 +6259,16 @@ } void gpu_snapshot_write(uint8_t *p) { PstW w; uint32_t n = gpu_snapshot_bytes(); @@ -2066,7 +2058,7 @@ index 6d81477285f786cdd3658d14c424d4bfbab5d809..2bd3df7060272e596dc884022e0e3867 void gpu_cosim_dump(char *out, int cap) { if (!out || cap <= 0) return; char *p = out; -@@ -5136,10 +6321,14 @@ +@@ -5136,10 +6313,14 @@ if (len != gpu_snapshot_bytes()) return 0; pst_r_init(&r, p, len); if (!gpu_snap_parse(&r)) return 0; diff --git a/src/game/host.cpp b/src/game/host.cpp index e2d4a10..5e33306 100644 --- a/src/game/host.cpp +++ b/src/game/host.cpp @@ -5,12 +5,15 @@ #include "game/render/far_rendering.h" #include "game/render/precision_routes.h" #include "game/session/capsule.h" +#include "gpu.h" #include #include #include extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); +extern "C" int disruptor_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], + uint32_t source_word[4]); bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings) { if (settings.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) @@ -21,6 +24,7 @@ bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings) { extern "C" { int psx_game_configure(const char *game_id, size_t length) { + if (const char *asked = std::getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS")) gpu_set_shadow_shape(asked[0] == '1'); return disruptor_precision_routes_configure(game_id, length); } @@ -40,6 +44,11 @@ int psx_game_key(int scancode, uint32_t keycode, int down, int plain) { return disruptor_name_entry_key(scancode, keycode, down, plain); } +int psx_game_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], + uint32_t source_word[4]) { + return disruptor_grown_quad_sources(command_address, packet, source_address, source_word); +} + void psx_game_before_input(void) { disruptor_capsule_before_input(); } void psx_game_after_input(void) { disruptor_capsule_after_input(); } diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index 99d9138..e80b4fd 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -136,7 +136,7 @@ def require(condition: bool, message: str) -> None: ) require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp"\n' in cmake - and " PSX_HAS_GAME_HOST=1\n" in cmake + and " PSX_HAS_GAME_HOST=1" in cmake and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp"\n' in cmake and '#define DISRUPTOR_CAPSULE_NO_HOST 1\n#include "../../src/game/session/capsule.cpp"\n' in read("tests/unit/disruptor_capsule_test.cpp") diff --git a/tests/contracts/test_geometry_provenance_contract.py b/tests/contracts/test_geometry_provenance_contract.py index d957b8d..bfd8b7a 100644 --- a/tests/contracts/test_geometry_provenance_contract.py +++ b/tests/contracts/test_geometry_provenance_contract.py @@ -377,8 +377,9 @@ def main() -> int: (ROOT / "src/game/render/precision_routes.cpp").read_text(encoding="utf-8"), "disruptor_precision_routes_configure") require("#ifdef PSX_HAS_GAME_HOST\n if (!psx_game_configure(game_id.data(), game_id.size())) return 1;\n#endif\n" in main_source and - "int psx_game_configure(const char *game_id, size_t length) {\n" " return disruptor_precision_routes_configure(game_id, length);\n}\n" in + function_body((ROOT / "src/game/host.cpp").read_text(encoding="utf-8"), "psx_game_configure") + "}\n" and + "int psx_game_configure(const char *game_id, size_t length) {\n" in (ROOT / "src/game/host.cpp").read_text(encoding="utf-8"), "the host must ask the game for its routes once the serial is known, and stop when one is refused") require('std::string_view(game_id, length) != "SLUS-00224"' in route_config, diff --git a/tests/contracts/test_grown_quad_contract.py b/tests/contracts/test_grown_quad_contract.py index e1b908f..1a27623 100644 --- a/tests/contracts/test_grown_quad_contract.py +++ b/tests/contracts/test_grown_quad_contract.py @@ -38,10 +38,12 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") source = read("src/game/render/grown_quad.cpp") gpu = read("psxrecomp/runtime/src/gpu.c") +host = read("src/game/host.cpp") require( '"${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/grown_quad.cpp"' in cmake - and "PSX_HAS_DISRUPTOR_GROWN_QUAD=1" in cmake + and " PSX_HAS_GAME_HOST=1)\n" in cmake + and " return disruptor_grown_quad_sources(command_address, packet, source_address, source_word);\n" in host and " disruptor_unit_test(disruptor_grown_quad " in cmake and " disruptor_contract(grown_quad RETAIL)\n" in cmake, "the grown quad source, its renderer switch, its unit test and this contract with its input must stay registered", @@ -57,8 +59,19 @@ def require(condition: bool, message: str) -> None: "recognising a grown quad must not write guest memory", ) require( - " const int grown = count == 4 && disruptor_grown_quad_sources(\n" - " gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word);\n" in gpu + "#ifdef PSX_HAS_GAME_HOST\n" + " /* The game's second, grown copy of a world quad stands on the first packet's corners. */\n" + " const int grown = count == 4 && psx_game_grown_quad_sources(\n" + " gp0_cmd_source_addr, gp0_cmd_buf, grown_addr, grown_word);\n" + "#else\n" + " const int grown = 0;\n" + "#endif\n" in gpu + and "#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION)\n" + " if (grown) {\n" + " grown_corner_depths(precise_x, precise_y, raw_x, raw_y, indices);\n" in gpu + and "#if defined(PSX_HAS_GAME_HOST) && !defined(PSX_DISABLE_FRAME_INTERPOLATION)\n" + "/* A grown corner lies off its source corner, on the same plane" in gpu + and "disruptor" not in gpu.lower() and " const uint32_t word = grown ? grown_word[i] : gp0_cmd_buf[indices[i]];\n" " uint32_t addr = grown_addr[i];\n" in gpu and " if (!grown && !gp0_source_word_address(indices[i], &addr)) {\n" in gpu, diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index 0416d54..5702691 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -99,7 +99,7 @@ def require(condition: bool, message: str) -> None: require( ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/name_entry.cpp"\n' in cmake - and " PSX_HAS_GAME_HOST=1\n" in cmake + and " PSX_HAS_GAME_HOST=1" in cmake and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp"\n' in cmake and " disruptor_unit_test(disruptor_name_entry " in cmake and " disruptor_contract(name_entry RETAIL)\n" in cmake, diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 7d0c12d..92b720c 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -223,6 +223,27 @@ def shader(source: str, name: str) -> str: and quad.index("const int raw_w = abs(vx[1] - vx[0]) + 1, raw_h = abs(vy[2] - vy[0]) + 1;") < quad.index("if (ws_tagged_anchor(&ws_ax)) {"), "the depth and the sides must be announced right before the rectangle they belong to, from the packet's size before the squash", ) +require( + "static int s_shadow_shape;\n" + "void gpu_set_shadow_shape(int improved) { s_shadow_shape = improved != 0; }\n" + "int gpu_shadow_shape(void) { return s_shadow_shape; }\n" in gpu + and "int psx_game_configure(const char *game_id, size_t length) {\n" + ' if (const char *asked = std::getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS")) gpu_set_shadow_shape(asked[0] == \'1\');\n' + in read("src/game/host.cpp") + and ' if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS"))\n' + " gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0);\n" in read("src/game/ui/dev_menu.cpp"), + "the renderer holds the shadow switch and reads no environment: the game sets it at the start, " + "and a saved setting gives way to the environment", +) +host_loop = read("psxrecomp/runtime/src/main.cpp") +require( + host_loop.count("psx_game_configure(") == host_loop.count(" gpu_init();\n") == 1 + and host_loop.index("int main(int argc, char** argv) {") + < host_loop.index("psx_game_configure(") + < host_loop.index(" gpu_init();\n") + < host_loop.index(" sdl_window = SDL_CreateWindow("), + "the game is configured before the GPU starts and a window opens: nothing can ask for the shadow switch sooner", +) sides = function_body(gpu, "static float temporal_sprite_sides(int x, int y, int w, int h, int raw_w, int raw_h, int squashed, float sides[4])") sizer = function_body(gpu, "void gpu_temporal_size_sprite(uint32_t primitive_addr, int32_t row16, int32_t width16, int32_t height16,") sprite_header = read("psxrecomp/runtime/include/gpu_temporal_sprite.h") From 8c81a96f3750a2cda52f74bf19210515227ea56a Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 04:55:32 +0300 Subject: [PATCH 17/21] refactor: give the host loop a neutral switch for the in-between frames' default --- CMakeLists.txt | 2 +- framework/patches/runtime/src/main.cpp.patch | 6 +++--- tests/contracts/test_dev_menu_contract.py | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 144c7f0..323648b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -142,7 +142,7 @@ if(NOT DISRUPTOR_FRAME_INTERPOLATION) PSX_DISABLE_FRAME_INTERPOLATION=1) endif() if(DISRUPTOR_DEV_MENU) - target_compile_definitions(psx-runtime PRIVATE DISRUPTOR_DEV_MENU=1) + target_compile_definitions(psx-runtime PRIVATE PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT=1) target_link_libraries(psx-runtime PRIVATE disruptor-imgui) endif() if(WIN32) diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch index 12628d2..6eef784 100644 --- a/framework/patches/runtime/src/main.cpp.patch +++ b/framework/patches/runtime/src/main.cpp.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp -index 81e048307ec9dd9fc7234a1e6285042ffab16457..721138eab4af70a7a7a35b3235af0ba6235a527f 100644 +index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca71f0f85f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1,9 +1,16 @@ @@ -395,7 +395,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..721138eab4af70a7a7a35b3235af0ba6 static int g_video_vsync = 1; static int g_frame_interpolation = 0; static int g_frame_interpolation_fps = 0; -+#if defined(DISRUPTOR_DEV_MENU) ++#if defined(PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT) +static int g_frame_interpolation_blend = + PSX_HOST_FRAME_INTERPOLATION_GEOMETRY; +static int g_frame_interpolation_blend_default = @@ -1839,7 +1839,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..721138eab4af70a7a7a35b3235af0ba6 g_frame_interpolation = us.frame_interpolation ? 1 : 0; if (us.has_frame_interpolation_fps) g_frame_interpolation_fps = us.frame_interpolation_fps; -+#if defined(DISRUPTOR_DEV_MENU) ++#if defined(PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT) + /* Migrate legacy image-blend preferences to geometry at startup. + * Explicit environment selection below can still opt into legacy + * modes for one-run comparisons. */ diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index d7dd4d2..340d870 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -82,8 +82,11 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: game_toml = read("game.toml") game_wide_toml = read("game-widescreen.toml") -require("option(DISRUPTOR_DEV_MENU" in cmake, - "developer menu must remain independently build-selectable") +require("option(DISRUPTOR_DEV_MENU" in cmake and + "if(DISRUPTOR_DEV_MENU)\n target_compile_definitions(psx-runtime PRIVATE PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT=1)\n" in cmake and + read("psxrecomp/runtime/src/main.cpp").count("#if defined(PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT)\n") == 2 and + "DISRUPTOR_DEV_MENU" not in read("psxrecomp/runtime/src/main.cpp"), + "developer menu must remain independently build-selectable, and with it in-between frames start in the geometry mode") require("v1.92.6.tar.gz" in cmake and "5b17c01f69545bde732b14936d89ce0f508adb83e8b56fa82448371845172bc3" in cmake.lower(), From da762583e7037cc8ceaabad90aed5f960a1c1f76 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 05:04:56 +0300 Subject: [PATCH 18/21] refactor: keep the game's settings section in the game's own files --- CMakeLists.txt | 5 + docs/BUILD.md | 4 +- .../recompiler/src/config_loader.cpp.patch | 282 +++--------------- .../recompiler/src/config_loader.h.patch | 78 +++-- framework/patches/runtime/src/main.cpp.patch | 27 +- src/game/settings/game_user_settings.h | 38 +++ src/game/settings/saved.cpp | 236 +++++++++++++++ src/launcher/CMakeLists.txt | 3 +- .../test_ambient_occlusion_contract.py | 4 +- tests/contracts/test_debug_tab_contract.py | 4 +- tests/contracts/test_dev_menu_contract.py | 4 +- tests/contracts/test_frame_rate_contract.py | 5 +- tests/contracts/test_gap_filling_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 2 +- tests/contracts/test_intro_skip_contract.py | 2 +- .../contracts/test_language_disc_contract.py | 4 +- tests/contracts/test_launcher_contract.py | 34 +++ tests/contracts/test_world_depth_contract.py | 4 +- 18 files changed, 430 insertions(+), 310 deletions(-) create mode 100644 src/game/settings/game_user_settings.h create mode 100644 src/game/settings/saved.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 323648b..e7bf69b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ set(DISRUPTOR_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/far_rendering.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/frame_rate.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/saved.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/intro_skip.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/language/language_disc.cpp" @@ -53,6 +54,9 @@ set(PSXRECOMP_BIOS_STEMS "OpenBIOS" CACHE STRING "" FORCE) set(PSX_RECOMP_UI OFF CACHE BOOL "" FORCE) include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src") +# The framework's settings reader takes the game's section of settings.toml from here, in every target alike. +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings") +add_compile_definitions(PSX_HAS_GAME_USER_SETTINGS=1) include("${PSXRECOMP_ROOT}/runtime/runtime.cmake") if(DISRUPTOR_DEV_MENU) @@ -231,6 +235,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) SOURCES "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/saved.cpp" INCLUDES "${PSXRECOMP_ROOT}/recompiler/src" "${PSXRECOMP_ROOT}/recompiler/include" diff --git a/docs/BUILD.md b/docs/BUILD.md index 0617029..70a9a5f 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -143,7 +143,9 @@ The framework reaches the game through one header, `runtime/include/game_host.h` which `src/game/host.cpp` answers. The instructions the game hooks are listed in `game.toml` under `[[recompiler.instruction_hook]]` for code that runs from RAM. The code generator holds its own copy for native code, and a contract keeps the -two equal. +two equal. The game's section of `settings.toml` belongs to the game as well: +its fields are in `src/game/settings/game_user_settings.h`, and +`src/game/settings/saved.cpp` reads and writes them for the framework's reader. A save state carries a key made of the code generator's sources, listed in `psxrecomp/runtime/codegen_hash_sources.cmake`. Any edit to one of them, a diff --git a/framework/patches/recompiler/src/config_loader.cpp.patch b/framework/patches/recompiler/src/config_loader.cpp.patch index 342a6c4..2f04bc4 100644 --- a/framework/patches/recompiler/src/config_loader.cpp.patch +++ b/framework/patches/recompiler/src/config_loader.cpp.patch @@ -1,8 +1,8 @@ diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp -index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc1028726852 100644 +index 246483fe90be378f8baf3e85671b692986823a8e..4fba08ff61a27a02f2f41e064aeba68d120e3f05 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp -@@ -3,12 +3,26 @@ +@@ -3,13 +3,27 @@ #include "config_loader.h" #include @@ -17,7 +17,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 #include #include #include -+ + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX @@ -26,10 +26,26 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 +#else +#include +#endif - ++ #include "bios_rom_alias.h" #include "fmt/format.h" -@@ -64,6 +78,10 @@ + #include "ps1_exe_parser.h" +@@ -17,6 +31,14 @@ + // toml11 is header-only. + #define TOML11_USE_UNRELEASED_TOML_FEATURES + #include "toml.hpp" ++ ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++namespace PSXRecompV4 { ++// Supplied by the game project: its own section of the user's settings file. ++void read_game_user_settings(const toml::value& doc, UserSettings& s); ++void write_game_user_settings(std::ostream& f, const UserSettings& s); ++} ++#endif + + namespace PSXRecompV4 { + +@@ -64,6 +86,10 @@ h.words("cull_screen_x", c.ws_cull_screen_x_sites); h.words("cull_slti", c.ws_cull_slti_sites); h.words("cull_slti_lower", c.ws_cull_slti_lower_sites); @@ -40,7 +56,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 h.words("cull_bltz", c.ws_cull_bltz_sites); h.words("cull_negsub", c.ws_cull_negsub_sites); h.words("cull_vxrange", c.ws_cull_vxrange_sites); -@@ -120,6 +138,36 @@ +@@ -120,6 +146,36 @@ h.tag("cull_angle"); h.u32((uint32_t)angle_sites.size()); for (const auto& site : angle_sites) { @@ -77,7 +93,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 h.u32(site.address); h.u32(site.expected); } -@@ -496,9 +544,9 @@ +@@ -496,9 +552,9 @@ const toml::value& video = toml::find(cfg, "video"); if (video.contains("supersampling")) { const auto n = toml::find(video, "supersampling"); @@ -89,7 +105,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 } rt.video_supersampling = static_cast(n); } -@@ -1264,6 +1312,41 @@ +@@ -1264,6 +1320,41 @@ for (const auto& a : arr) mod_function_entry_funcs.push_back( parse_hex(a, "recompiler.mod_function_entry_funcs")); @@ -131,7 +147,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 } std::vector hot_funcs; if (recomp.contains("hot_funcs")) { -@@ -1451,6 +1534,10 @@ +@@ -1451,6 +1542,10 @@ std::vector ws_cull_screen_x_sites; std::vector ws_cull_slti_sites; std::vector ws_cull_slti_lower_sites; @@ -142,7 +158,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 std::vector ws_cull_bltz_sites; std::vector ws_cull_negsub_sites; std::vector ws_cull_vxrange_sites; -@@ -1459,6 +1546,9 @@ +@@ -1459,6 +1554,9 @@ std::vector ws_cull_xclip_load_sites; std::vector ws_cull_keep_sites; std::vector ws_cull_angle_sites; @@ -152,7 +168,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 WidescreenAspectConeConfig ws_aspect_cone; int ws_cull_guard_pixels = 0; int ws_cull_activation_guard_pixels = 0; -@@ -1484,6 +1574,10 @@ +@@ -1484,6 +1582,10 @@ load_sites("screen_x_sites", ws_cull_screen_x_sites); load_sites("slti_sites", ws_cull_slti_sites); load_sites("slti_lower_sites", ws_cull_slti_lower_sites); @@ -163,7 +179,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 load_sites("bltz_sites", ws_cull_bltz_sites); load_sites("negsub_sites", ws_cull_negsub_sites); load_sites("vxrange_sites", ws_cull_vxrange_sites); -@@ -1552,6 +1646,85 @@ +@@ -1552,6 +1654,85 @@ "{}: duplicate cull-angle address 0x{:08X}", config_path.string(), site.address)); ws_cull_angle_sites.push_back(site); @@ -249,7 +265,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 } } if (cull.contains("aspect_cone")) { -@@ -1886,6 +2059,7 @@ +@@ -1886,6 +2067,7 @@ /*ws_auto_ui_squash*/ ws_auto_ui_squash, /*data_shard_funcs*/ data_shard_funcs, /*mod_function_entry_funcs*/ mod_function_entry_funcs, @@ -257,7 +273,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 /*hot_funcs*/ hot_funcs, /*vsync_query_func*/ vsync_query_func, /*vsync_counter_addr*/ vsync_counter_addr, -@@ -1901,6 +2075,10 @@ +@@ -1901,6 +2083,10 @@ /*ws_cull_screen_x_sites*/ ws_cull_screen_x_sites, /*ws_cull_slti_sites*/ ws_cull_slti_sites, /*ws_cull_slti_lower_sites*/ ws_cull_slti_lower_sites, @@ -268,7 +284,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 /*ws_cull_bltz_sites*/ ws_cull_bltz_sites, /*ws_cull_negsub_sites*/ ws_cull_negsub_sites, /*ws_cull_vxrange_sites*/ ws_cull_vxrange_sites, -@@ -1909,6 +2087,8 @@ +@@ -1909,6 +2095,8 @@ /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, @@ -277,7 +293,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 /*ws_aspect_cone*/ ws_aspect_cone, /*ws_cull_guard_pixels*/ ws_cull_guard_pixels, /*ws_cull_activation_guard_pixels*/ ws_cull_activation_guard_pixels, -@@ -1995,7 +2175,7 @@ +@@ -1995,7 +2183,7 @@ return go; } @@ -286,7 +302,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 UserSettings load_user_settings(const fs::path& path) { UserSettings s; -@@ -2004,7 +2184,10 @@ +@@ -2004,7 +2192,10 @@ toml::value doc; try { @@ -298,7 +314,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 } catch (const std::exception&) { // Malformed file: fall back to all-defaults rather than refuse to boot, // but tell the caller so the user hears about it (and so the file can -@@ -2026,7 +2209,7 @@ +@@ -2026,7 +2217,7 @@ }); if (v.contains("supersampling")) try_get([&]{ const auto n = toml::find(v, "supersampling"); @@ -307,7 +323,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 }); if (v.contains("window_width")) try_get([&]{ const auto n = toml::find(v, "window_width"); -@@ -2087,8 +2270,16 @@ +@@ -2087,8 +2278,16 @@ }); if (v.contains("frame_interpolation_fps")) try_get([&]{ s.frame_interpolation_fps = toml::find(v, "frame_interpolation_fps"); @@ -325,7 +341,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 }); if (v.contains("aspect_ratio")) try_get([&]{ const auto m = toml::find(v, "aspect_ratio"); -@@ -2106,6 +2297,17 @@ +@@ -2106,6 +2305,17 @@ const toml::value& a = toml::find(doc, "audio"); if (a.contains("spu_hq")) try_get([&]{ s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; @@ -343,133 +359,13 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 }); } if (doc.contains("launcher")) { -@@ -2230,15 +2432,190 @@ +@@ -2230,15 +2440,70 @@ if (n >= 0 && n <= 32767) { s.deadzone = (int)n; s.has_deadzone = true; } }); } -+ if (doc.contains("disruptor")) { -+ const toml::value& d = toml::find(doc, "disruptor"); -+ if (d.contains("mouse_aim")) try_get([&]{ -+ s.mouse_aim = toml::find(d, "mouse_aim"); -+ s.has_mouse_aim = true; -+ }); -+ if (d.contains("modern_controls")) try_get([&]{ -+ s.modern_controls = toml::find(d, "modern_controls"); -+ s.has_modern_controls = true; -+ }); -+ if (d.contains("horizontal_sensitivity")) try_get([&]{ -+ double n = 0.0; -+ try { -+ n = toml::find(d, "horizontal_sensitivity"); -+ } catch (const std::exception&) { -+ n = static_cast( -+ toml::find(d, "horizontal_sensitivity")); -+ } -+ if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { -+ s.horizontal_sensitivity = n; -+ s.has_horizontal_sensitivity = true; -+ } -+ }); -+ if (d.contains("invert_horizontal")) try_get([&]{ -+ s.invert_horizontal = toml::find(d, "invert_horizontal"); -+ s.has_invert_horizontal = true; -+ }); -+ if (d.contains("vertical_look")) try_get([&]{ -+ s.vertical_look = toml::find(d, "vertical_look"); -+ s.has_vertical_look = true; -+ }); -+ if (d.contains("vertical_sensitivity")) try_get([&]{ -+ double n = 0.0; -+ try { -+ n = toml::find(d, "vertical_sensitivity"); -+ } catch (const std::exception&) { -+ n = static_cast( -+ toml::find(d, "vertical_sensitivity")); -+ } -+ if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { -+ s.vertical_sensitivity = n; -+ s.has_vertical_sensitivity = true; -+ } -+ }); -+ if (d.contains("invert_vertical")) try_get([&]{ -+ s.invert_vertical = toml::find(d, "invert_vertical"); -+ s.has_invert_vertical = true; -+ }); -+ if (d.contains("high_precision_camera")) try_get([&]{ -+ s.high_precision_camera = toml::find(d, "high_precision_camera"); -+ s.has_high_precision_camera = true; -+ }); -+ if (d.contains("geometry_correction")) try_get([&]{ -+ s.geometry_correction = toml::find(d, "geometry_correction"); -+ s.has_geometry_correction = true; -+ }); -+ if (d.contains("perspective_textures")) try_get([&]{ -+ s.perspective_textures = toml::find(d, "perspective_textures"); -+ s.has_perspective_textures = true; -+ }); -+ if (d.contains("frame_unlock")) try_get([&]{ -+ s.frame_unlock = toml::find(d, "frame_unlock"); -+ s.has_frame_unlock = true; -+ }); -+ if (d.contains("skip_intro")) try_get([&]{ -+ s.skip_intro = toml::find(d, "skip_intro"); -+ s.has_skip_intro = true; -+ }); -+ if (d.contains("language_disc")) try_get([&]{ -+ s.language_disc = toml::find(d, "language_disc"); -+ s.has_language_disc = true; -+ }); -+ if (d.contains("language_discs")) try_get([&]{ -+ s.language_discs = toml::find>(d, "language_discs"); -+ s.has_language_discs = true; -+ }); -+ if (d.contains("hud_scale")) try_get([&]{ -+ const int percent = toml::find(d, "hud_scale"); -+ if (percent >= 50 && percent <= 100) { -+ s.hud_scale = percent; -+ s.has_hud_scale = true; -+ } -+ }); -+ if (d.contains("improved_shadows")) try_get([&]{ -+ s.improved_shadows = toml::find(d, "improved_shadows"); -+ s.has_improved_shadows = true; -+ }); -+ if (d.contains("ambient_occlusion")) try_get([&]{ -+ s.ambient_occlusion = toml::find(d, "ambient_occlusion"); -+ s.has_ambient_occlusion = true; -+ }); -+ if (d.contains("occlusion_reach")) try_get([&]{ -+ const int reach = toml::find(d, "occlusion_reach"); -+ if (reach >= 50 && reach <= 800) { -+ s.occlusion_reach = reach; -+ s.has_occlusion_reach = true; -+ } -+ }); -+ if (d.contains("occlusion_strength")) try_get([&]{ -+ const int percent = toml::find(d, "occlusion_strength"); -+ if (percent >= 0 && percent <= 300) { -+ s.occlusion_strength = percent; -+ s.has_occlusion_strength = true; -+ } -+ }); -+ if (d.contains("gap_filling")) try_get([&]{ -+ s.gap_filling = toml::find(d, "gap_filling"); -+ s.has_gap_filling = true; -+ }); -+ if (d.contains("depth_sorted_world")) try_get([&]{ -+ s.depth_sorted_world = toml::find(d, "depth_sorted_world"); -+ s.has_depth_sorted_world = true; -+ }); -+ if (d.contains("diagnostic_view")) try_get([&]{ -+ const std::string view = toml::find(d, "diagnostic_view"); -+ for (int index = 0; index < kDisruptorDiagnosticViewCount; ++index) { -+ if (view == kDisruptorDiagnosticViews[index]) { -+ s.diagnostic_view = index; -+ s.has_diagnostic_view = true; -+ } -+ } -+ }); -+ } ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++ read_game_user_settings(doc, s); ++#endif return s; } + @@ -535,30 +431,16 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 if (!f.is_open()) return false; // TOML strings use forward slashes so backslash escaping is never an issue. -@@ -2247,7 +2624,21 @@ +@@ -2247,7 +2512,7 @@ return str; }; - f << "# psxrecomp user settings - written by the launcher. Safe to hand-edit.\n"; -+ // A TOML basic string: a quote, a backslash and a control character are escaped. -+ auto quoted = [](const std::string& text) { -+ std::string out = "\""; -+ for (const char letter : text) { -+ const unsigned code = static_cast(letter); -+ char escape[8]; -+ if (letter == '\\' || letter == '"') std::snprintf(escape, sizeof(escape), "\\%c", letter); -+ else if (code < 0x20 || code == 0x7F) std::snprintf(escape, sizeof(escape), "\\u%04X", code); -+ else std::snprintf(escape, sizeof(escape), "%c", letter); -+ out += escape; -+ } -+ return out + "\""; -+ }; -+ + f << "# psxrecomp user settings - written by settings UIs. Safe to hand-edit.\n"; f << "# Overrides the bundled game.toml; the command line overrides this file.\n\n"; f << "[video]\n"; -@@ -2287,6 +2678,11 @@ +@@ -2287,6 +2552,11 @@ f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n"; if (s.has_frame_interpolation_fps) f << "frame_interpolation_fps = " << s.frame_interpolation_fps << "\n"; @@ -570,7 +452,7 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 if (s.has_aspect_ratio) f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; if (s.has_adaptive_view) -@@ -2294,6 +2690,10 @@ +@@ -2294,6 +2564,10 @@ f << "\n[audio]\n"; if (s.has_spu_hq) f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; @@ -581,84 +463,14 @@ index 246483fe90be378f8baf3e85671b692986823a8e..98a404f8bff2b3fc666007ae4a50cc10 if (s.has_skip_launcher) f << "\n[launcher]\nskip_launcher = " << (s.skip_launcher ? "true" : "false") << "\n"; if ((s.has_netplay_player_name && !s.netplay_player_name.empty()) || -@@ -2353,7 +2753,98 @@ +@@ -2353,7 +2627,28 @@ f << "extra_late = " << s.parappa_timing_extra_late << "\n"; } - return f.good(); -+ if (s.has_mouse_aim || s.has_modern_controls || -+ s.has_horizontal_sensitivity || s.has_invert_horizontal || -+ s.has_vertical_look || s.has_vertical_sensitivity || -+ s.has_invert_vertical || -+ s.has_high_precision_camera || s.has_geometry_correction || -+ s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || -+ s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || -+ s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || -+ s.has_language_discs || -+ s.has_language_disc || s.has_skip_intro || -+ s.has_hud_scale) { -+ f << "\n[disruptor]\n"; -+ if (s.has_mouse_aim) -+ f << "mouse_aim = " << (s.mouse_aim ? "true" : "false") << "\n"; -+ if (s.has_modern_controls) -+ f << "modern_controls = " << (s.modern_controls ? "true" : "false") << "\n"; -+ if (s.has_horizontal_sensitivity && -+ std::isfinite(s.horizontal_sensitivity) && -+ s.horizontal_sensitivity >= 0.005 && -+ s.horizontal_sensitivity <= 2.0) { -+ f << "horizontal_sensitivity = " -+ << std::setprecision(std::numeric_limits::max_digits10) -+ << s.horizontal_sensitivity << "\n"; -+ } -+ if (s.has_invert_horizontal) -+ f << "invert_horizontal = " << (s.invert_horizontal ? "true" : "false") << "\n"; -+ if (s.has_vertical_look) -+ f << "vertical_look = " << (s.vertical_look ? "true" : "false") << "\n"; -+ if (s.has_vertical_sensitivity && -+ std::isfinite(s.vertical_sensitivity) && -+ s.vertical_sensitivity >= 0.005 && -+ s.vertical_sensitivity <= 2.0) { -+ f << "vertical_sensitivity = " -+ << std::setprecision(std::numeric_limits::max_digits10) -+ << s.vertical_sensitivity << "\n"; -+ } -+ if (s.has_invert_vertical) -+ f << "invert_vertical = " << (s.invert_vertical ? "true" : "false") << "\n"; -+ if (s.has_high_precision_camera) -+ f << "high_precision_camera = " << (s.high_precision_camera ? "true" : "false") << "\n"; -+ if (s.has_geometry_correction) -+ f << "geometry_correction = " << (s.geometry_correction ? "true" : "false") << "\n"; -+ if (s.has_perspective_textures) -+ f << "perspective_textures = " << (s.perspective_textures ? "true" : "false") << "\n"; -+ if (s.has_frame_unlock) -+ f << "frame_unlock = " << (s.frame_unlock ? "true" : "false") << "\n"; -+ if (s.has_skip_intro) -+ f << "skip_intro = " << (s.skip_intro ? "true" : "false") << "\n"; -+ if (s.has_language_disc) -+ f << "language_disc = " << quoted(s.language_disc) << "\n"; -+ if (s.has_language_discs) { -+ f << "language_discs = ["; -+ for (size_t index = 0; index < s.language_discs.size(); ++index) -+ f << (index ? ", " : "") << quoted(s.language_discs[index]); -+ f << "]\n"; -+ } -+ if (s.has_hud_scale && s.hud_scale >= 50 && s.hud_scale <= 100) -+ f << "hud_scale = " << s.hud_scale << "\n"; -+ if (s.has_improved_shadows) -+ f << "improved_shadows = " << (s.improved_shadows ? "true" : "false") << "\n"; -+ if (s.has_ambient_occlusion) -+ f << "ambient_occlusion = " << (s.ambient_occlusion ? "true" : "false") << "\n"; -+ if (s.has_occlusion_reach && s.occlusion_reach >= 50 && s.occlusion_reach <= 800) -+ f << "occlusion_reach = " << s.occlusion_reach << "\n"; -+ if (s.has_occlusion_strength && s.occlusion_strength >= 0 && s.occlusion_strength <= 300) -+ f << "occlusion_strength = " << s.occlusion_strength << "\n"; -+ if (s.has_gap_filling) -+ f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\n"; -+ if (s.has_depth_sorted_world) -+ f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; -+ if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) -+ f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; -+ } ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++ write_game_user_settings(f, s); ++#endif + + f.flush(); + const bool write_ok = f.good(); diff --git a/framework/patches/recompiler/src/config_loader.h.patch b/framework/patches/recompiler/src/config_loader.h.patch index 1721c28..2b28c70 100644 --- a/framework/patches/recompiler/src/config_loader.h.patch +++ b/framework/patches/recompiler/src/config_loader.h.patch @@ -1,8 +1,18 @@ diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h -index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569ea7563229 100644 +index 4761b920f677e251b60550fdaf44427bdcd1659b..f9cbb12226f8d7bfb4b67180cdfaa0e642f23309 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h -@@ -71,6 +71,36 @@ +@@ -26,6 +26,9 @@ + + #include "bios_address_model.h" // BiosAddrCopy (BiosConfig::address_copies) + #include "recompiler_patch.h" ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++#include "game_user_settings.h" // the game project's: PSXRecompV4::GameUserSettings, its own fields of the settings file ++#endif + + namespace PSXRecompV4 { + +@@ -71,6 +74,36 @@ struct WidescreenAngleSite { uint32_t address = 0; uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero @@ -39,7 +49,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e }; // Aspect-aware horizontal participation cone. The exact compare sites are -@@ -309,7 +339,7 @@ +@@ -309,7 +342,7 @@ // ---- [video] block — visual enhancement options ---- // supersampling: internal-resolution SSAA factor (per axis). 1 = native @@ -48,7 +58,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // an N*-scaled mirror of VRAM and downsample on present — true ordered-grid // supersampling + edge anti-aliasing. Cost scales ~N^2 in fill rate. int video_supersampling = 1; -@@ -646,6 +676,9 @@ +@@ -646,6 +679,9 @@ // default, so projects that do not opt in emit no callback overhead. std::vector mod_function_entry_funcs; @@ -58,7 +68,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) // on their generated C bodies (profile/host locality; no guest semantics). std::vector hot_funcs; -@@ -695,6 +728,20 @@ +@@ -695,6 +731,20 @@ // Explicit signed lower-bound sites (`slti rt, sx, -W`). The threshold is // moved left by the live reveal margin. Empty by default; regen required. std::vector ws_cull_slti_lower_sites; @@ -79,7 +89,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // [widescreen.cull] bltz_sites — explicit signed LEFT-edge widen sites // (`bltz rs, reject` -> psx_ws_cull_bltz), the counterpart to slti_sites. // detect_cull_bltz_sites only classifies left-edge bltz for functions -@@ -730,6 +777,10 @@ +@@ -730,6 +780,10 @@ std::vector ws_cull_keep_sites; // Exact 12-bit angular half-extents used by terrain-cell frusta. std::vector ws_cull_angle_sites; @@ -90,7 +100,7 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // Full-word-guarded model-participation cosine compares widened only in // the camera-horizontal plane. Empty/default is completely inert. WidescreenAspectConeConfig ws_aspect_cone; -@@ -944,12 +995,12 @@ +@@ -944,17 +998,21 @@ uint32_t ws_bg2d_packet_cap = 1000; }; @@ -105,7 +115,16 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // from game.toml defaults the first time it writes. // // Unlike game.toml, paths here are stored verbatim (the user picked them); they -@@ -963,7 +1014,7 @@ + // are NOT resolved against a project root. ++#ifdef PSX_HAS_GAME_USER_SETTINGS ++struct UserSettings : GameUserSettings { ++#else + struct UserSettings { ++#endif + // Set when the file existed but failed to parse as TOML: every field below + // is defaults and the caller must warn loudly — silently dropping a user's + // hand-edited settings looks like the settings "don't work", and a later +@@ -963,7 +1021,7 @@ // [video] bool has_renderer = false; int renderer = 0; // 0=software,1=opengl @@ -114,18 +133,18 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // Window size: width in px; height is always width*3/4 (PSX 4:3). Applies to // both the launcher and the emulator window so they boot at the same size. bool has_window_width = false; int window_width = 1280; // -> 1280x960 -@@ -995,6 +1046,10 @@ +@@ -995,6 +1053,10 @@ bool has_vsync = false; int vsync = 1; bool has_frame_interpolation = false; bool frame_interpolation = false; bool has_frame_interpolation_fps = false; int frame_interpolation_fps = 0; + // Frame-interpolation mode: 0 = legacy linear, 1 = legacy adaptive, -+ // 2 = geometry inter-frame redraw. Activation remains session-only in -+ // Disruptor; its runtime migrates saved legacy modes to geometry. ++ // 2 = geometry inter-frame redraw. A host built with ++ // PSX_FRAME_INTERPOLATION_GEOMETRY_DEFAULT migrates saved legacy modes to geometry. + bool has_frame_interpolation_blend = false; int frame_interpolation_blend = 0; // [launcher] — when true, boot straight into the game and skip the GUI // launcher window (mirrors snesrecomp's SkipLauncher). Overridable per-run: // `--launcher` forces the GUI back on; `PSX_NO_LAUNCHER=1` forces it off. -@@ -1008,6 +1063,10 @@ +@@ -1008,6 +1070,10 @@ bool has_adaptive_view = false; bool adaptive_view = false; // [audio] bool has_spu_hq = false; bool spu_hq = false; @@ -136,45 +155,18 @@ index 4761b920f677e251b60550fdaf44427bdcd1659b..983bb5915cb5ac63d21160439e11569e // [bios] / [disc] / [memcard] bool has_bios_path = false; std::filesystem::path bios_path; bool has_disc_path = false; std::filesystem::path disc_path; -@@ -1043,7 +1102,36 @@ +@@ -1043,6 +1109,10 @@ bool has_parappa_timing_mode = false; std::string parappa_timing_mode = "stock"; bool has_parappa_timing_extra_early = false; int parappa_timing_extra_early = 0; bool has_parappa_timing_extra_late = false; int parappa_timing_extra_late = 0; --}; + -+ // [disruptor] — Disruptor-specific, live-safe enhancement settings. -+ // Kept optional so a missing key continues to inherit the launch-time -+ // default or an explicit per-run override. -+ bool has_mouse_aim = false; bool mouse_aim = false; -+ bool has_modern_controls = false; bool modern_controls = false; -+ bool has_horizontal_sensitivity = false; double horizontal_sensitivity = 0.1; -+ bool has_invert_horizontal = false; bool invert_horizontal = false; -+ bool has_vertical_look = false; bool vertical_look = false; -+ bool has_vertical_sensitivity = false; double vertical_sensitivity = 0.08; -+ bool has_invert_vertical = false; bool invert_vertical = false; -+ bool has_high_precision_camera = false; bool high_precision_camera = false; ++ // Corrections of the renderer. A game project that offers them keeps them in its own section of the file. + bool has_geometry_correction = false; bool geometry_correction = false; + bool has_perspective_textures = false; bool perspective_textures = false; -+ bool has_frame_unlock = false; bool frame_unlock = false; -+ bool has_skip_intro = false; bool skip_intro = false; -+ bool has_language_disc = false; std::string language_disc; // image of another region's disc, "" for none -+ bool has_language_discs = false; std::vector language_discs; // the ones named before, for a launcher to offer again -+ bool has_hud_scale = false; int hud_scale = 100; -+ bool has_improved_shadows = false; bool improved_shadows = false; -+ bool has_ambient_occlusion = false; bool ambient_occlusion = false; -+ bool has_occlusion_reach = false; int occlusion_reach = 160; // camera depth units, 50..800 -+ bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 -+ bool has_gap_filling = false; bool gap_filling = true; -+ bool has_depth_sorted_world = false; bool depth_sorted_world = false; -+ // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. -+ bool has_diagnostic_view = false; int diagnostic_view = 0; -+}; -+inline constexpr const char* kDisruptorDiagnosticViews[] = {"off", "shading", "depth", "normals", "fade", "mesh"}; -+inline constexpr int kDisruptorDiagnosticViewCount = 6; + }; // GameOptions — the game's OWN native OPTION-screen settings, declared in a - // dedicated, self-contained `game_options.toml` next to game.toml. Kept entirely -@@ -1084,7 +1172,9 @@ +@@ -1084,7 +1154,9 @@ // never throws. UserSettings load_user_settings(const std::filesystem::path& path); diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch index 6eef784..4d0b581 100644 --- a/framework/patches/runtime/src/main.cpp.patch +++ b/framework/patches/runtime/src/main.cpp.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp -index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca71f0f85f 100644 +index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8fc3648db 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1,9 +1,16 @@ @@ -1944,15 +1944,14 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca { const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); -@@ -5916,7 +6925,14 @@ +@@ -5916,7 +6925,13 @@ if (want_launcher) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ - PSXRecompV4::UserSettings seed; + /* Merge launcher edits into the canonical file instead of -+ * constructing a fresh snapshot. Game-owned sections (including -+ * [disruptor]) and settings not exposed by recomp-ui must survive -+ * a later launcher save. */ ++ * constructing a fresh snapshot. Game-owned sections and settings ++ * not exposed by recomp-ui must survive a later launcher save. */ + PSXRecompV4::UserSettings seed = + PSXRecompV4::load_user_settings( + exe_dir_from_argv(argv[0]) / "settings.toml"); @@ -1960,7 +1959,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca seed.renderer = g_video_renderer; seed.has_renderer = true; seed.supersampling = g_video_scale; seed.has_supersampling = true; seed.antialiasing = g_video_aa; seed.has_antialiasing = true; -@@ -6415,6 +7431,12 @@ +@@ -6415,6 +7430,12 @@ g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; if (!turbo_loads_offered) g_turbo_loads_enabled = 0; @@ -1973,7 +1972,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); if (g_mod_controller_mode_override[0] >= 0) -@@ -6527,6 +7549,22 @@ +@@ -6527,6 +7548,22 @@ gr_set_scale(g_video_scale); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); @@ -1996,7 +1995,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca /* Display aspect. Identity at the default 4:3. The present letterbox uses * this aspect; native-wide fills it with a genuinely wider frame (no * stretch), squash mode stretches the 4:3 frame into it. */ -@@ -6633,6 +7671,7 @@ +@@ -6633,6 +7670,7 @@ * the local EXE file (dev checkouts) and the disc image (every install). */ if (game_config_path) arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, @@ -2004,7 +2003,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca disc_path_str); mod_runtime_enable_disc_patches(); { -@@ -6691,6 +7730,7 @@ +@@ -6691,6 +7729,7 @@ if (g_headless) { std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); @@ -2012,7 +2011,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca } else { /* ---- SDL init ---- */ /* Scale quality governs SDL's logical-size -> window scaling. Linear when -@@ -6780,6 +7820,17 @@ +@@ -6780,6 +7819,17 @@ std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); return 1; } @@ -2030,7 +2029,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank -@@ -6909,6 +7960,7 @@ +@@ -6909,6 +7959,7 @@ std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); return 1; } @@ -2038,7 +2037,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca if (!g_gl_active && !g_vk_active) { sdl_texture = SDL_CreateTexture( -@@ -6924,6 +7976,12 @@ +@@ -6924,6 +7975,12 @@ SDL_SetTextureScaleMode(sdl_texture, g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); } @@ -2051,7 +2050,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca } /* Register vblank presentation callback. */ -@@ -7012,6 +8070,9 @@ +@@ -7012,6 +8069,9 @@ bios_hle_requested = (e[0] && e[0] != '0'); if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) bios_hle_keep_intro = (e[0] && e[0] != '0'); @@ -2061,7 +2060,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..bb8b7276d85ec6ed3ff60d86eb3e46ca PsxBiosHleRequest req; req.bios_hle = bios_hle_requested ? 1 : 0; -@@ -7062,6 +8123,10 @@ +@@ -7062,6 +8122,10 @@ if (game_entry_pc != 0) { savestate_configure(memcard_dir.string().c_str(), memory_get_bios_checksum(), game_entry_pc); diff --git a/src/game/settings/game_user_settings.h b/src/game/settings/game_user_settings.h new file mode 100644 index 0000000..29b0d0a --- /dev/null +++ b/src/game/settings/game_user_settings.h @@ -0,0 +1,38 @@ +#ifndef DISRUPTOR_GAME_USER_SETTINGS_H +#define DISRUPTOR_GAME_USER_SETTINGS_H + +#include +#include + +namespace PSXRecompV4 { + +/// The game's fields of the [disruptor] section of settings.toml, which the framework's UserSettings derives from. +struct GameUserSettings { + bool has_mouse_aim = false; bool mouse_aim = false; + bool has_modern_controls = false; bool modern_controls = false; + bool has_horizontal_sensitivity = false; double horizontal_sensitivity = 0.1; + bool has_invert_horizontal = false; bool invert_horizontal = false; + bool has_vertical_look = false; bool vertical_look = false; + bool has_vertical_sensitivity = false; double vertical_sensitivity = 0.08; + bool has_invert_vertical = false; bool invert_vertical = false; + bool has_high_precision_camera = false; bool high_precision_camera = false; + bool has_frame_unlock = false; bool frame_unlock = false; + bool has_skip_intro = false; bool skip_intro = false; + bool has_language_disc = false; std::string language_disc; // image of another region's disc, "" for none + bool has_language_discs = false; std::vector language_discs; // the ones named before, for a launcher to offer again + bool has_hud_scale = false; int hud_scale = 100; + bool has_improved_shadows = false; bool improved_shadows = false; + bool has_ambient_occlusion = false; bool ambient_occlusion = false; + bool has_occlusion_reach = false; int occlusion_reach = 160; // camera depth units, 50..800 + bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 + bool has_gap_filling = false; bool gap_filling = true; + bool has_depth_sorted_world = false; bool depth_sorted_world = false; + // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. + bool has_diagnostic_view = false; int diagnostic_view = 0; +}; +inline constexpr const char* kDisruptorDiagnosticViews[] = {"off", "shading", "depth", "normals", "fade", "mesh"}; +inline constexpr int kDisruptorDiagnosticViewCount = 6; + +} // namespace PSXRecompV4 + +#endif diff --git a/src/game/settings/saved.cpp b/src/game/settings/saved.cpp new file mode 100644 index 0000000..c829994 --- /dev/null +++ b/src/game/settings/saved.cpp @@ -0,0 +1,236 @@ +#include "config_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Defined as the framework's reader defines it: toml11's inline code must be one in a program. +#define TOML11_USE_UNRELEASED_TOML_FEATURES +#include "toml.hpp" + +namespace PSXRecompV4 { + +void read_game_user_settings(const toml::value& doc, UserSettings& s) { + auto try_get = [](auto&& fn) { try { fn(); } catch (const std::exception&) {} }; + if (doc.contains("disruptor")) { + const toml::value& d = toml::find(doc, "disruptor"); + if (d.contains("mouse_aim")) try_get([&]{ + s.mouse_aim = toml::find(d, "mouse_aim"); + s.has_mouse_aim = true; + }); + if (d.contains("modern_controls")) try_get([&]{ + s.modern_controls = toml::find(d, "modern_controls"); + s.has_modern_controls = true; + }); + if (d.contains("horizontal_sensitivity")) try_get([&]{ + double n = 0.0; + try { + n = toml::find(d, "horizontal_sensitivity"); + } catch (const std::exception&) { + n = static_cast( + toml::find(d, "horizontal_sensitivity")); + } + if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { + s.horizontal_sensitivity = n; + s.has_horizontal_sensitivity = true; + } + }); + if (d.contains("invert_horizontal")) try_get([&]{ + s.invert_horizontal = toml::find(d, "invert_horizontal"); + s.has_invert_horizontal = true; + }); + if (d.contains("vertical_look")) try_get([&]{ + s.vertical_look = toml::find(d, "vertical_look"); + s.has_vertical_look = true; + }); + if (d.contains("vertical_sensitivity")) try_get([&]{ + double n = 0.0; + try { + n = toml::find(d, "vertical_sensitivity"); + } catch (const std::exception&) { + n = static_cast( + toml::find(d, "vertical_sensitivity")); + } + if (std::isfinite(n) && n >= 0.005 && n <= 2.0) { + s.vertical_sensitivity = n; + s.has_vertical_sensitivity = true; + } + }); + if (d.contains("invert_vertical")) try_get([&]{ + s.invert_vertical = toml::find(d, "invert_vertical"); + s.has_invert_vertical = true; + }); + if (d.contains("high_precision_camera")) try_get([&]{ + s.high_precision_camera = toml::find(d, "high_precision_camera"); + s.has_high_precision_camera = true; + }); + if (d.contains("geometry_correction")) try_get([&]{ + s.geometry_correction = toml::find(d, "geometry_correction"); + s.has_geometry_correction = true; + }); + if (d.contains("perspective_textures")) try_get([&]{ + s.perspective_textures = toml::find(d, "perspective_textures"); + s.has_perspective_textures = true; + }); + if (d.contains("frame_unlock")) try_get([&]{ + s.frame_unlock = toml::find(d, "frame_unlock"); + s.has_frame_unlock = true; + }); + if (d.contains("skip_intro")) try_get([&]{ + s.skip_intro = toml::find(d, "skip_intro"); + s.has_skip_intro = true; + }); + if (d.contains("language_disc")) try_get([&]{ + s.language_disc = toml::find(d, "language_disc"); + s.has_language_disc = true; + }); + if (d.contains("language_discs")) try_get([&]{ + s.language_discs = toml::find>(d, "language_discs"); + s.has_language_discs = true; + }); + if (d.contains("hud_scale")) try_get([&]{ + const int percent = toml::find(d, "hud_scale"); + if (percent >= 50 && percent <= 100) { + s.hud_scale = percent; + s.has_hud_scale = true; + } + }); + if (d.contains("improved_shadows")) try_get([&]{ + s.improved_shadows = toml::find(d, "improved_shadows"); + s.has_improved_shadows = true; + }); + if (d.contains("ambient_occlusion")) try_get([&]{ + s.ambient_occlusion = toml::find(d, "ambient_occlusion"); + s.has_ambient_occlusion = true; + }); + if (d.contains("occlusion_reach")) try_get([&]{ + const int reach = toml::find(d, "occlusion_reach"); + if (reach >= 50 && reach <= 800) { + s.occlusion_reach = reach; + s.has_occlusion_reach = true; + } + }); + if (d.contains("occlusion_strength")) try_get([&]{ + const int percent = toml::find(d, "occlusion_strength"); + if (percent >= 0 && percent <= 300) { + s.occlusion_strength = percent; + s.has_occlusion_strength = true; + } + }); + if (d.contains("gap_filling")) try_get([&]{ + s.gap_filling = toml::find(d, "gap_filling"); + s.has_gap_filling = true; + }); + if (d.contains("depth_sorted_world")) try_get([&]{ + s.depth_sorted_world = toml::find(d, "depth_sorted_world"); + s.has_depth_sorted_world = true; + }); + if (d.contains("diagnostic_view")) try_get([&]{ + const std::string view = toml::find(d, "diagnostic_view"); + for (int index = 0; index < kDisruptorDiagnosticViewCount; ++index) { + if (view == kDisruptorDiagnosticViews[index]) { + s.diagnostic_view = index; + s.has_diagnostic_view = true; + } + } + }); + } +} + +void write_game_user_settings(std::ostream& f, const UserSettings& s) { + // A TOML basic string: a quote, a backslash and a control character are escaped. + auto quoted = [](const std::string& text) { + std::string out = "\""; + for (const char letter : text) { + const unsigned code = static_cast(letter); + char escape[8]; + if (letter == '\\' || letter == '"') std::snprintf(escape, sizeof(escape), "\\%c", letter); + else if (code < 0x20 || code == 0x7F) std::snprintf(escape, sizeof(escape), "\\u%04X", code); + else std::snprintf(escape, sizeof(escape), "%c", letter); + out += escape; + } + return out + "\""; + }; + + if (s.has_mouse_aim || s.has_modern_controls || + s.has_horizontal_sensitivity || s.has_invert_horizontal || + s.has_vertical_look || s.has_vertical_sensitivity || + s.has_invert_vertical || + s.has_high_precision_camera || s.has_geometry_correction || + s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || + s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || + s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || + s.has_language_discs || + s.has_language_disc || s.has_skip_intro || + s.has_hud_scale) { + f << "\n[disruptor]\n"; + if (s.has_mouse_aim) + f << "mouse_aim = " << (s.mouse_aim ? "true" : "false") << "\n"; + if (s.has_modern_controls) + f << "modern_controls = " << (s.modern_controls ? "true" : "false") << "\n"; + if (s.has_horizontal_sensitivity && + std::isfinite(s.horizontal_sensitivity) && + s.horizontal_sensitivity >= 0.005 && + s.horizontal_sensitivity <= 2.0) { + f << "horizontal_sensitivity = " + << std::setprecision(std::numeric_limits::max_digits10) + << s.horizontal_sensitivity << "\n"; + } + if (s.has_invert_horizontal) + f << "invert_horizontal = " << (s.invert_horizontal ? "true" : "false") << "\n"; + if (s.has_vertical_look) + f << "vertical_look = " << (s.vertical_look ? "true" : "false") << "\n"; + if (s.has_vertical_sensitivity && + std::isfinite(s.vertical_sensitivity) && + s.vertical_sensitivity >= 0.005 && + s.vertical_sensitivity <= 2.0) { + f << "vertical_sensitivity = " + << std::setprecision(std::numeric_limits::max_digits10) + << s.vertical_sensitivity << "\n"; + } + if (s.has_invert_vertical) + f << "invert_vertical = " << (s.invert_vertical ? "true" : "false") << "\n"; + if (s.has_high_precision_camera) + f << "high_precision_camera = " << (s.high_precision_camera ? "true" : "false") << "\n"; + if (s.has_geometry_correction) + f << "geometry_correction = " << (s.geometry_correction ? "true" : "false") << "\n"; + if (s.has_perspective_textures) + f << "perspective_textures = " << (s.perspective_textures ? "true" : "false") << "\n"; + if (s.has_frame_unlock) + f << "frame_unlock = " << (s.frame_unlock ? "true" : "false") << "\n"; + if (s.has_skip_intro) + f << "skip_intro = " << (s.skip_intro ? "true" : "false") << "\n"; + if (s.has_language_disc) + f << "language_disc = " << quoted(s.language_disc) << "\n"; + if (s.has_language_discs) { + f << "language_discs = ["; + for (size_t index = 0; index < s.language_discs.size(); ++index) + f << (index ? ", " : "") << quoted(s.language_discs[index]); + f << "]\n"; + } + if (s.has_hud_scale && s.hud_scale >= 50 && s.hud_scale <= 100) + f << "hud_scale = " << s.hud_scale << "\n"; + if (s.has_improved_shadows) + f << "improved_shadows = " << (s.improved_shadows ? "true" : "false") << "\n"; + if (s.has_ambient_occlusion) + f << "ambient_occlusion = " << (s.ambient_occlusion ? "true" : "false") << "\n"; + if (s.has_occlusion_reach && s.occlusion_reach >= 50 && s.occlusion_reach <= 800) + f << "occlusion_reach = " << s.occlusion_reach << "\n"; + if (s.has_occlusion_strength && s.occlusion_strength >= 0 && s.occlusion_strength <= 300) + f << "occlusion_strength = " << s.occlusion_strength << "\n"; + if (s.has_gap_filling) + f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\n"; + if (s.has_depth_sorted_world) + f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; + if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) + f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; + } +} + +} // namespace PSXRecompV4 diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index d5ccca3..2005277 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -8,7 +8,8 @@ target_link_libraries(disruptor-disc-import PUBLIC bcrypt) # settings.toml goes through the game's own reader and writer. add_library(disruptor-launcher-config STATIC "${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp" - "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp") + "${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp" + "${PROJECT_SOURCE_DIR}/src/game/settings/saved.cpp") target_compile_features(disruptor-launcher-config PUBLIC cxx_std_20) target_include_directories(disruptor-launcher-config PUBLIC "${PSXRECOMP_ROOT}/recompiler/src" diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 1c06748..207666d 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -36,8 +36,8 @@ def between(source: str, opening: str, closing: str) -> str: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -loader = read("psxrecomp/recompiler/src/config_loader.cpp") -settings = read("psxrecomp/recompiler/src/config_loader.h") +loader = read("src/game/settings/saved.cpp") +settings = read("src/game/settings/game_user_settings.h") require(" disruptor_contract(ambient_occlusion)\n" in cmake, "this contract is registered") diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 8bec0bf..866815c 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -23,8 +23,8 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") menu = read("src/game/ui/dev_menu.cpp") -loader = read("psxrecomp/recompiler/src/config_loader.cpp") -settings = read("psxrecomp/recompiler/src/config_loader.h") +loader = read("src/game/settings/saved.cpp") +settings = read("src/game/settings/game_user_settings.h") require(" disruptor_contract(debug_tab)\n" in cmake, "this contract is registered") diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index 340d870..e21ccac 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -75,8 +75,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: cheats = read("src/game/session/cheats.cpp") cheats_h = read("src/game/session/cheats.h") far_rendering_h = read("src/game/render/far_rendering.h") -config_h = read("psxrecomp/recompiler/src/config_loader.h") -config_cpp = read("psxrecomp/recompiler/src/config_loader.cpp") +config_h = read("psxrecomp/recompiler/src/config_loader.h") + read("src/game/settings/game_user_settings.h") +config_cpp = read("psxrecomp/recompiler/src/config_loader.cpp") + read("src/game/settings/saved.cpp") run_ps1 = read("run.ps1") run_sh = read("run.sh") game_toml = read("game.toml") diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index 3882ef8..da044ac 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -150,7 +150,7 @@ def require(condition: bool, message: str) -> None: " disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0);" in menu, "a saved switch must be applied at start unless the environment overrides it", ) -settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") +settings_loader = read("src/game/settings/saved.cpp") require( 's.frame_unlock = toml::find(d, "frame_unlock");' in settings_loader and 'f << "frame_unlock = " << (s.frame_unlock ? "true" : "false")' in settings_loader @@ -291,7 +291,8 @@ def require(condition: bool, message: str) -> None: "the presenter must run at any rate from 30 FPS", ) require( - "(s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000))" in settings_loader, + "(s.frame_interpolation_fps >= 30 && s.frame_interpolation_fps <= 1000))" + in read("psxrecomp/recompiler/src/config_loader.cpp"), "settings.toml must keep an in-between frame rate from 30 FPS", ) diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index d8d2b32..6d49255 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -36,7 +36,7 @@ def between(source: str, opening: str, closing: str) -> str: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -loader = read("psxrecomp/recompiler/src/config_loader.cpp") +loader = read("src/game/settings/saved.cpp") require(" disruptor_contract(gap_filling)\n" in cmake, "this contract is registered") @@ -179,7 +179,7 @@ def between(source: str, opening: str, closing: str) -> str: f << "gap_filling = " << (s.gap_filling ? "true" : "false") << "\\n"; """ in loader and "s.has_gap_filling" in between(loader, " if (s.has_mouse_aim", "{\n") - and " bool has_gap_filling = false; bool gap_filling = true;\n" in read("psxrecomp/recompiler/src/config_loader.h"), + and " bool has_gap_filling = false; bool gap_filling = true;\n" in read("src/game/settings/game_user_settings.h"), "the settings file keeps the choice, which is on until changed", ) diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index 3fbd1b6..e65c46e 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -33,7 +33,7 @@ def require(condition: bool, message: str) -> None: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") +settings_loader = read("src/game/settings/saved.cpp") require( "+runtime/include/gpu_ws_hud_scale.h" in manifest.splitlines(), diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 7406643..76da509 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -63,7 +63,7 @@ def require(condition: bool, message: str) -> None: generator = read("psxrecomp/recompiler/src/code_generator.cpp") configs = read("game.toml") + read("game-widescreen.toml") audit = read("tools/audit_codegen.py") -settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") +settings_loader = read("src/game/settings/saved.cpp") require( "src/game/session/intro_skip.cpp" in cmake diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index 5e58b5e..9c4e59b 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -74,8 +74,8 @@ def require(condition: bool, message: str) -> None: host_header = read("psxrecomp/runtime/include/host_ui.h") pick_slot = read("psxrecomp/runtime/include/host_pick_slot.h") restart = read("src/game/session/restart.cpp") -settings_header = read("psxrecomp/recompiler/src/config_loader.h") -settings_loader = read("psxrecomp/recompiler/src/config_loader.cpp") +settings_header = read("src/game/settings/game_user_settings.h") +settings_loader = read("src/game/settings/saved.cpp") menu = read("src/game/ui/dev_menu.cpp") reader = read("psxrecomp/runtime/src/iso_reader_c.cpp") diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 8e60be0..ff946e5 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -44,6 +44,7 @@ def plain_values(text: str) -> dict[str, str]: launcher_cmake = read("src/launcher/CMakeLists.txt") cmake = read("CMakeLists.txt") loader = read("psxrecomp/recompiler/src/config_loader.cpp") +game_section = read("src/game/settings/saved.cpp") menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") ROW = r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", "([^"]+)", (-?\d+)' @@ -67,6 +68,39 @@ def plain_values(text: str) -> dict[str, str]: ) writer = loader[loader.index("bool save_user_settings(") :] +require( + "#ifdef PSX_HAS_GAME_USER_SETTINGS\n write_game_user_settings(f, s);\n#endif\n\n f.flush();\n" in writer + and "#ifdef PSX_HAS_GAME_USER_SETTINGS\n read_game_user_settings(doc, s);\n#endif\n return s;\n" in loader + and "disruptor" not in loader.lower() + and ' if (doc.contains("disruptor")) {\n const toml::value& d = toml::find(doc, "disruptor");\n' in game_section + and ' f << "\\n[disruptor]\\n";\n' in game_section, + "the framework's writer and reader hand the game's section to the game, last, and name no game", +) +settings_struct = read("psxrecomp/recompiler/src/config_loader.h") +require( + "#ifdef PSX_HAS_GAME_USER_SETTINGS\nstruct UserSettings : GameUserSettings {\n#else\nstruct UserSettings {\n#endif\n" + in settings_struct + and '#ifdef PSX_HAS_GAME_USER_SETTINGS\n#include "game_user_settings.h"' in settings_struct + and "disruptor" not in settings_struct.lower() + and "struct GameUserSettings {\n" in read("src/game/settings/game_user_settings.h") + and "void read_game_user_settings(const toml::value& doc, UserSettings& s) {\n" in game_section + and "void write_game_user_settings(std::ostream& f, const UserSettings& s) {\n" in game_section + and "#define TOML11_USE_UNRELEASED_TOML_FEATURES\n" in game_section + and "#define TOML11_USE_UNRELEASED_TOML_FEATURES\n" in loader, + "the settings struct takes the game's fields from the game, which reads and writes its own section " + "with the reader's own build of the TOML library", +) +require( + cmake.index('include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings")\n') + < cmake.index("add_compile_definitions(PSX_HAS_GAME_USER_SETTINGS=1)\n") + < cmake.index("psxrecomp_add_runtime_target(psx-runtime") + < cmake.index(" add_subdirectory(src/launcher)") + and cmake.count('"${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/saved.cpp"\n') == 2 + and ' "${PROJECT_SOURCE_DIR}/src/game/settings/saved.cpp")\n' in launcher_cmake + and "PSX_HAS_GAME_USER_SETTINGS" not in launcher_cmake, + "every target of the build sees the same settings struct, and each that builds the reader builds the game's section", +) +writer += game_section[game_section.index("void write_game_user_settings(") :] saved, section = set(), "" for header, key in re.findall(r'f << "(?:\\n)?\[(\w+)\]\\n|f << "(\w+)\s*= ', writer): section = header or section diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index d2b6ba7..52af175 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -39,7 +39,7 @@ def between(source: str, opening: str, closing: str) -> str: " for (const Option *option : g_preferences.changed)\n" " option->set(settings, option->get(pending));\n" ) -loader = read("psxrecomp/recompiler/src/config_loader.cpp") +loader = read("src/game/settings/saved.cpp") require( overlay.count("+runtime/include/gpu_world_depth.h\n") == 1 @@ -160,7 +160,7 @@ def between(source: str, opening: str, closing: str) -> str: """ in loader and "s.has_depth_sorted_world" in between(loader, " if (s.has_mouse_aim", "{\n") and " bool has_depth_sorted_world = false; bool depth_sorted_world = false;\n" - in read("psxrecomp/recompiler/src/config_loader.h"), + in read("src/game/settings/game_user_settings.h"), "the settings file keeps the choice, which is off until made", ) From f2321b91908d1126343fbfa2c941f361d5fc973c Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 05:05:40 +0300 Subject: [PATCH 19/21] refactor: let only the code generator name the game among the framework files --- framework/files/runtime/include/gte_precision.h | 4 ++-- tests/contracts/test_instruction_hooks_contract.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/framework/files/runtime/include/gte_precision.h b/framework/files/runtime/include/gte_precision.h index ccef735..4bb4bba 100644 --- a/framework/files/runtime/include/gte_precision.h +++ b/framework/files/runtime/include/gte_precision.h @@ -1,7 +1,7 @@ /* Exact GTE projection provenance shared by the GTE and GPU paths. * - * SWC2 records the projection associated with a guest RAM word. Some games, - * including Disruptor, instead read SXY through MFC2 and later use an ordinary + * SWC2 records the projection associated with a guest RAM word. Some games + * instead read SXY through MFC2 and later use an ordinary * SW. Those routes are admitted only through opcode/dataflow-reviewed MFC2 * and store PCs registered by the game runtime; a separately reviewed route * may seed a finite set of scratchpad words for a later exact packet copy. diff --git a/tests/contracts/test_instruction_hooks_contract.py b/tests/contracts/test_instruction_hooks_contract.py index 9bf16d3..0669a4a 100644 --- a/tests/contracts/test_instruction_hooks_contract.py +++ b/tests/contracts/test_instruction_hooks_contract.py @@ -87,6 +87,16 @@ def generator_table(generator: str, table: str) -> dict[int, int]: and "instruction_hooks" not in read("psxrecomp/recompiler/include/code_generator.h"), "the generator's sources make the key of a save state: its own site tables stay until the pin is raised", ) +changed = [ + line.removeprefix("+").strip() + for line in read("framework/FILES.txt").splitlines() + if line.strip() and not line.startswith("#") +] +naming = sorted(path for path in changed if b"disruptor" in (ROOT / "psxrecomp" / path).read_bytes().lower()) +require( + naming == ["recompiler/src/code_generator.cpp"], + f"of the framework files the project changes only the code generator may name the game: {naming}", +) require( "disruptor" not in interpreter.lower() and interpreter.count("psx_game_instruction_ran(") == 1 From 2a9b6d39b7295b271547b1e9b776712e1a41ded8 Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 05:19:25 +0300 Subject: [PATCH 20/21] fix: keep a key that types a name from pressing a pad button --- framework/files/runtime/include/game_host.h | 3 + framework/patches/runtime/src/main.cpp.patch | 143 ++++++++++++------ src/game/host.cpp | 7 + src/game/input/mouse_aim.cpp | 8 + src/game/input/name_entry.cpp | 84 +++++++++-- tests/contracts/test_name_entry_contract.py | 82 ++++++++++- tests/unit/disruptor_mouse_aim_test.cpp | 18 +++ tests/unit/disruptor_name_entry_test.cpp | 145 ++++++++++++++++++- 8 files changed, 421 insertions(+), 69 deletions(-) diff --git a/framework/files/runtime/include/game_host.h b/framework/files/runtime/include/game_host.h index e8580a0..d4a2ed0 100644 --- a/framework/files/runtime/include/game_host.h +++ b/framework/files/runtime/include/game_host.h @@ -25,6 +25,9 @@ void psx_game_states_configured(void); void psx_game_state_loaded(void); /* A key went down or up. `plain` is set for a press without Ctrl, Alt or the system key and for every release. Nonzero when the game took a press. */ int psx_game_key(int scancode, uint32_t keycode, int down, int plain); +/* The keyboard's state as a pad may read it: `count` keys, nonzero for down, copied from `keys` to `kept` with the keys + * the game is typing with shown as up. Called at every reading, which can be several a frame. */ +void psx_game_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count); /* Called around the sampling of the pad, outside netplay. */ void psx_game_before_input(void); void psx_game_after_input(void); diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch index 4d0b581..8dd2fd4 100644 --- a/framework/patches/runtime/src/main.cpp.patch +++ b/framework/patches/runtime/src/main.cpp.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp -index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8fc3648db 100644 +index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e352b13313 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1,9 +1,16 @@ @@ -1328,7 +1328,62 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 } static void close_player(PlayerInput& p) { -@@ -3095,6 +3891,16 @@ +@@ -2691,12 +3487,26 @@ + } + } + ++/* The keyboard as a pad reads it: a game project may hold back the keys it is typing with. */ ++static const Uint8* pad_keys(void) { ++ int count = 0; ++ const Uint8* keys = SDL_GetKeyboardState(&count); ++#ifdef PSX_HAS_GAME_HOST ++ static Uint8 kept[512]; ++ if (!keys || count <= 0 || count > (int)sizeof(kept)) return keys; ++ psx_game_keys_for_pad(keys, kept, count); ++ return kept; ++#else ++ return keys; ++#endif ++} ++ + /* Keyboard -> PSX button word for `player` (1 or 2), via the configurable + * keybinds.ini map (psx_keybinds). Defaults reproduce the old hardcoded layout + * (arrows=d-pad, X/S/Z/A=Cross/Circle/Square/Triangle, Q/W/E/R=L1/R1/L2/R2, + * Return=Start, RShift=Select) plus T/Y=L3/R3 stick clicks. */ + static uint16_t pad_from_keyboard(int player) { +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + return psx_keybinds_pad_word(keys, player); + } + +@@ -2770,7 +3580,7 @@ + /* Keyboard analog: the configurable left/right stick-direction binds + * (default = arrow keys on the LEFT stick; RIGHT stick unbound), so the + * old keyboard analog behaviour is preserved unless the user rebinds. */ +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + psx_keybinds_sticks(keys, player, out); + return; + } +@@ -2839,7 +3649,7 @@ + } + } + if (p.kind == 1 || dev_any) { +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + if (psx_keybinds_dpad_active(keys, player)) return true; + } + return false; +@@ -3028,7 +3838,7 @@ + * sticks onto the analog stick, so an analog-mode P1 steers from whatever + * is plugged in (P1 binds). */ + if (dev_here && eff_analog) { +- const Uint8* keys = SDL_GetKeyboardState(NULL); ++ const Uint8* keys = pad_keys(); + psx_keybinds_sticks(keys, 1, st); + dev_any_controller_sticks(st); + } +@@ -3095,6 +3905,16 @@ * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ static void capture_local_human_pad(PsxNetPad* out) { @@ -1345,7 +1400,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 int idx = psx_netplay_input_player(); if (idx < 0 || idx > 1) idx = 0; if (!capture_pad_slot_exclusive(idx, out)) { -@@ -3228,11 +4034,12 @@ +@@ -3228,11 +4048,12 @@ if (!g_headless) { SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -1359,7 +1414,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 #if defined(PSX_SDL3) ev.key.key == SDLK_ESCAPE) { #else -@@ -3261,6 +4068,24 @@ +@@ -3261,6 +4082,24 @@ static void sample_pad_into_sio(int override) { if (override >= 0) { apply_input_override_to_sio(override); @@ -1384,7 +1439,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 return; } for (int s = 0; s < 2; s++) { -@@ -3482,8 +4307,51 @@ +@@ -3482,8 +4321,51 @@ } } @@ -1436,7 +1491,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 #ifndef PSX_NO_DEBUG_TOOLS debug_server_set_fmv_quiet(mdec_recently_active(2)); /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ -@@ -3582,6 +4450,7 @@ +@@ -3582,6 +4464,7 @@ /* Pump SDL events to prevent window freeze. */ SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -1444,7 +1499,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (ev.type == SDL_QUIT) { if (psx_netplay_active()) { netplay_soft_exit("sdl_window_close"); -@@ -3603,18 +4472,34 @@ +@@ -3603,18 +4486,34 @@ close_controller(); refresh_player_devices(); } @@ -1480,7 +1535,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. * (F11 is a save slot per the user's spec, so fullscreen is * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ -@@ -3649,21 +4534,34 @@ +@@ -3649,21 +4548,34 @@ * detects "currently fullscreen, either mode". */ else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { @@ -1526,7 +1581,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 } /* Sample each player's device and feed the matching SIO pad slot. -@@ -3697,6 +4595,9 @@ +@@ -3697,6 +4609,9 @@ } } netplay_tail(override); @@ -1536,7 +1591,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (psx_netplay_active()) { psx_netplay_finish_frame(); } else if (g_headless) { -@@ -3704,6 +4605,9 @@ +@@ -3704,6 +4619,9 @@ } else { sample_pad_into_sio(override); } @@ -1546,7 +1601,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Latency ring: open this present cycle's slot, stamping when input was * sampled into SIO. Always-on; queried via the debug server "latency". */ -@@ -3789,16 +4693,21 @@ +@@ -3789,16 +4707,21 @@ } } @@ -1572,7 +1627,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 netplay_tail.skip_pace(); return; } -@@ -3813,7 +4722,7 @@ +@@ -3813,7 +4736,7 @@ } #endif @@ -1581,7 +1636,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 * SDL_RenderPresent. The recompiled BIOS still advances simulated * cycles every vblank, so the BIOS proceeds at whatever rate the host * CPU sustains without graphics-driver vsync overhead. Present once -@@ -3822,7 +4731,8 @@ +@@ -3822,7 +4745,8 @@ const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; const int TURBO_PRESENT_EVERY = 30; @@ -1591,7 +1646,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; if (turbo_skip != 0) { netplay_tail.skip_pace(); -@@ -3916,30 +4826,12 @@ +@@ -3916,30 +4840,12 @@ /* Mod hooks. Run after all normal input sampling. */ mod_call_frame_hooks(); @@ -1623,7 +1678,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* ---- Display from our VRAM ---- */ uint32_t w = 0, h = 0; -@@ -3963,7 +4855,8 @@ +@@ -3963,7 +4869,8 @@ /* After savestate load, always restage once even if we blanked * earlier in the session — otherwise the window keeps a stale * pre-load frame while vblanks (and FPS) keep advancing. */ @@ -1633,7 +1688,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 s_disabled_frame_presented = true; s_force_present_after_load = false; if (g_gl_active) { -@@ -3988,7 +4881,7 @@ +@@ -3988,7 +4895,7 @@ * of truth, shared with the GTE/GPU squash so content and present stay * locked: we squash IFF we stretch. depth24 always classifies as FMV * even if the ws layer is not engaged yet (4:3 titles). */ @@ -1642,7 +1697,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* MDEC movies are already decoded at their authored cadence and are * CPU/upload heavy. High-refresh crossfades only contend with decoding * and can starve audio, so present native-4:3/MDEC phases directly. -@@ -3996,19 +4889,25 @@ +@@ -3996,19 +4903,25 @@ * decode; the activity stamp also covers authentic 4:3 configurations. */ if (g_gl_active) gl_renderer_set_interpolation_suspended( @@ -1674,7 +1729,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Native-wide invariant: canonical (320-wide) content is NEVER * stretched across the wide window — a game frame that cannot present -@@ -4045,6 +4944,22 @@ +@@ -4045,6 +4958,22 @@ if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) return; @@ -1697,7 +1752,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 } else { gl_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, -@@ -4142,6 +5057,7 @@ +@@ -4142,6 +5071,7 @@ h * (uint32_t)active_scale, !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); @@ -1705,7 +1760,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Frame blending (CRT-persistence masker for 30fps double-buffered * content). Some games (e.g. Crash Bash menus/characters) leave a * dynamic object in only one of the two display buffers per 30fps cycle, -@@ -4183,6 +5099,7 @@ +@@ -4183,6 +5113,7 @@ } } } @@ -1713,7 +1768,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 } /* Update only the active display rectangle. The backing texture is sized -@@ -4245,7 +5162,7 @@ +@@ -4245,7 +5176,7 @@ if (!g_present_vsync_disabled && present_ms > 250) { g_present_slow_count++; if (g_present_slow_count >= 3 && @@ -1722,7 +1777,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 g_present_vsync_disabled = 1; } } -@@ -4867,9 +5784,11 @@ +@@ -4867,9 +5798,11 @@ #endif int main(int argc, char** argv) { @@ -1737,7 +1792,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 std::fprintf(stderr, "psxrecomp: main() entered\n"); std::fflush(stderr); -@@ -5047,7 +5966,11 @@ +@@ -5047,7 +5980,11 @@ bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ bool ws_ultrawide_offered = false; bool ws_adaptive_view_supported = false; @@ -1749,7 +1804,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 bool skip_fmv_offered = true; bool turbo_loads_offered = true; bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ -@@ -5084,6 +6007,7 @@ +@@ -5084,6 +6021,7 @@ * path is resolved (arm_text_image_guard). */ std::string text_guard_exe_path; uint32_t text_guard_load_addr = 0; @@ -1757,7 +1812,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (game_config_path) { try { -@@ -5143,8 +6067,10 @@ +@@ -5143,8 +6081,10 @@ g_video_aspect_den = gc.runtime.video_aspect_den; g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; @@ -1768,7 +1823,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; if (gc.runtime.video_fmv_skip_end_total) -@@ -5308,8 +6234,10 @@ +@@ -5308,8 +6248,10 @@ ws_offered = gc.ws_offered; ws_ultrawide_offered = gc.ws_ultrawide_offered; ws_adaptive_view_supported = gc.ws_adaptive_view; @@ -1779,7 +1834,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 skip_fmv_offered = gc.runtime.video_offer_skip_fmv; turbo_loads_offered = gc.runtime.offer_turbo_loads; vulkan_offered = gc.vulkan_offered; -@@ -5363,6 +6291,7 @@ +@@ -5363,6 +6305,7 @@ * user's disc image instead. */ text_guard_exe_path = gc.exe_path.string(); text_guard_load_addr = gc.load_address; @@ -1787,7 +1842,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* HLE-tier scheduler subsystem replacement default (env * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); -@@ -5567,6 +6496,10 @@ +@@ -5567,6 +6510,10 @@ } } @@ -1798,7 +1853,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (!game_name.empty()) s_picker_game_name = game_name; /* Layer the launcher-written settings.toml (next to the exe) over the -@@ -5580,6 +6513,9 @@ +@@ -5580,6 +6527,9 @@ { std::filesystem::path settings_path = exe_dir_from_argv(argv[0]) / "settings.toml"; @@ -1808,7 +1863,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 #if defined(RECOMP_LAUNCHER) g_lnch_settings_path = settings_path; #endif -@@ -5640,6 +6576,10 @@ +@@ -5640,6 +6590,10 @@ * Config + env own it until a launcher toggle exists; if one is * added, restore this line together with the UI. The seed write * below still persists the effective value for inspection. */ @@ -1819,7 +1874,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (us.has_bios_hle) bios_hle_requested = us.bios_hle; if (us.has_fullscreen) g_fullscreen = us.fullscreen; if (us.has_aspect_ratio) { -@@ -5648,6 +6588,10 @@ +@@ -5648,6 +6602,10 @@ } if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; @@ -1830,7 +1885,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- * as-hide treatment as lock_mode / ws_offered below) so a stale * settings.toml or launcher-less build can never point a bundled -@@ -5682,10 +6626,30 @@ +@@ -5682,10 +6640,30 @@ if (us.has_deadzone) resolved_deadzone = us.deadzone; if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; if (us.has_vsync) g_video_vsync = us.vsync; @@ -1861,7 +1916,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || us.has_parappa_timing_extra_late) { parappa_apply_timing_setting( -@@ -5782,25 +6746,65 @@ +@@ -5782,25 +6760,65 @@ g_video_aspect_den = ws_offered ? 9 : 3; } if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; @@ -1932,7 +1987,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 strcmp(e, "1") == 0) { g_frame_interpolation_blend_default = PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; -@@ -5810,6 +6814,11 @@ +@@ -5810,6 +6828,11 @@ } g_frame_interpolation_blend = g_frame_interpolation_blend_default; } @@ -1944,7 +1999,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 { const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); -@@ -5916,7 +6925,13 @@ +@@ -5916,7 +6939,13 @@ if (want_launcher) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ @@ -1959,7 +2014,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 seed.renderer = g_video_renderer; seed.has_renderer = true; seed.supersampling = g_video_scale; seed.has_supersampling = true; seed.antialiasing = g_video_aa; seed.has_antialiasing = true; -@@ -6415,6 +7430,12 @@ +@@ -6415,6 +7444,12 @@ g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; if (!turbo_loads_offered) g_turbo_loads_enabled = 0; @@ -1972,7 +2027,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); if (g_mod_controller_mode_override[0] >= 0) -@@ -6527,6 +7548,22 @@ +@@ -6527,6 +7562,22 @@ gr_set_scale(g_video_scale); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); @@ -1995,7 +2050,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Display aspect. Identity at the default 4:3. The present letterbox uses * this aspect; native-wide fills it with a genuinely wider frame (no * stretch), squash mode stretches the 4:3 frame into it. */ -@@ -6633,6 +7670,7 @@ +@@ -6633,6 +7684,7 @@ * the local EXE file (dev checkouts) and the disc image (every install). */ if (game_config_path) arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, @@ -2003,7 +2058,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 disc_path_str); mod_runtime_enable_disc_patches(); { -@@ -6691,6 +7729,7 @@ +@@ -6691,6 +7743,7 @@ if (g_headless) { std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); @@ -2011,7 +2066,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 } else { /* ---- SDL init ---- */ /* Scale quality governs SDL's logical-size -> window scaling. Linear when -@@ -6780,6 +7819,17 @@ +@@ -6780,6 +7833,17 @@ std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); return 1; } @@ -2029,7 +2084,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank -@@ -6909,6 +7959,7 @@ +@@ -6909,6 +7973,7 @@ std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); return 1; } @@ -2037,7 +2092,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 if (!g_gl_active && !g_vk_active) { sdl_texture = SDL_CreateTexture( -@@ -6924,6 +7975,12 @@ +@@ -6924,6 +7989,12 @@ SDL_SetTextureScaleMode(sdl_texture, g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); } @@ -2050,7 +2105,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 } /* Register vblank presentation callback. */ -@@ -7012,6 +8069,9 @@ +@@ -7012,6 +8083,9 @@ bios_hle_requested = (e[0] && e[0] != '0'); if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) bios_hle_keep_intro = (e[0] && e[0] != '0'); @@ -2060,7 +2115,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..0ff3b64e04111d2c549b1192dc2ee8f8 PsxBiosHleRequest req; req.bios_hle = bios_hle_requested ? 1 : 0; -@@ -7062,6 +8122,10 @@ +@@ -7062,6 +8136,10 @@ if (game_entry_pc != 0) { savestate_configure(memcard_dir.string().c_str(), memory_get_bios_checksum(), game_entry_pc); diff --git a/src/game/host.cpp b/src/game/host.cpp index 5e33306..94da2ad 100644 --- a/src/game/host.cpp +++ b/src/game/host.cpp @@ -12,6 +12,8 @@ #include extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain); +extern "C" void disruptor_name_entry_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count); +extern "C" void disruptor_name_entry_state_loaded(void); extern "C" int disruptor_grown_quad_sources(uint32_t command_address, const uint32_t *packet, uint32_t source_address[4], uint32_t source_word[4]); @@ -37,6 +39,7 @@ void psx_game_states_configured(void) { disruptor_language_settle(); } void psx_game_state_loaded(void) { disruptor_capsule_state_loaded(); + disruptor_name_entry_state_loaded(); disruptor_far_rendering_abandon_metrics(); } @@ -49,6 +52,10 @@ int psx_game_grown_quad_sources(uint32_t command_address, const uint32_t *packet return disruptor_grown_quad_sources(command_address, packet, source_address, source_word); } +void psx_game_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count) { + disruptor_name_entry_keys_for_pad(keys, kept, count); +} + void psx_game_before_input(void) { disruptor_capsule_before_input(); } void psx_game_after_input(void) { disruptor_capsule_after_input(); } diff --git a/src/game/input/mouse_aim.cpp b/src/game/input/mouse_aim.cpp index cb46013..fda5dab 100644 --- a/src/game/input/mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -24,6 +24,7 @@ #include "../../../psxrecomp/runtime/include/gpu.h" #include +#include #include #include #include @@ -45,6 +46,7 @@ extern "C" void gpu_geometry_camera_yaw_residual_set(double yaw_units); extern "C" SDL_Window *sdl_window; extern "C" int psx_host_ui_game_input_captured(void); extern "C" int psx_netplay_active(void); +extern "C" void disruptor_name_entry_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count); namespace { @@ -342,6 +344,12 @@ bool key_down(const Uint8 *keys, int scancode) { void apply_modern_controls(const Uint8 *keys, uint32_t mouse_buttons) { if (!g_mouse.modern_controls_enabled) return; + std::array pad_keys{}; /* SDL's scancode count, in 2 and 3 alike */ + if (keys) { + disruptor_name_entry_keys_for_pad(keys, pad_keys.data(), static_cast(pad_keys.size())); + keys = pad_keys.data(); + } + uint16_t press = 0; if (key_down(keys, SDL_SCANCODE_W) || key_down(keys, SDL_SCANCODE_UP)) press |= kPadUp; diff --git a/src/game/input/name_entry.cpp b/src/game/input/name_entry.cpp index dced01e..308413e 100644 --- a/src/game/input/name_entry.cpp +++ b/src/game/input/name_entry.cpp @@ -6,6 +6,9 @@ * with 8 for DONE, the row a word at gp + 0x33C. Keys the host hands over are put there on the way into the * routine, and that frame's presses are dropped: a letter key is a pad button as well. Enter only moves the * cursor to DONE and stays a press: the game's own confirm then goes on to ACCEPT NAME. + * + * The pad is sampled from the keyboard's state, which shows a key up to a frame before its event comes, so on the + * name rows a key that types is also kept out of that state for the pad, until it is let go. */ #include "cpu_state.h" @@ -19,6 +22,8 @@ #include #include +extern "C" uint64_t psx_get_cycle_count(void); + namespace { constexpr uint32_t kSaveScreen = 0x8001C484u; @@ -29,11 +34,12 @@ constexpr uint32_t kLettersRow = 5u, kLastNameRow = 7u; constexpr uint32_t kLength = 8u; /* the cursor past the last letter stands on DONE */ constexpr uint8_t kDigits = 26u, kBlank = 36u; constexpr unsigned kLeftScreen = 8u; /* VBlanks without the routine: the menu runs it every frame */ +constexpr uint64_t kVBlankCycles = 564480u; /* the framework's VBLANK_CYCLES */ constexpr unsigned kLetGo = 120u; /* VBlanks without a key event: a held key repeats many times in that */ /* SDL scancodes, the same numbers in SDL 2 and SDL 3. */ constexpr int kKeyA = 4, kKeyZ = 29, kKey1 = 30, kKey9 = 38, kKey0 = 39, kBackspace = 42, kSpace = 44, kDelete = 76; -constexpr int kPad1 = 89, kPad9 = 97, kPad0 = 98, kEnter = 40, kPadEnter = 88; +constexpr int kPad1 = 89, kPad9 = 97, kPad0 = 98, kEnter = 40, kPadEnter = 88, kSlash = 56, kSecondBackslash = 100; enum class Act : uint8_t { Put, Back, Clear, Done }; @@ -45,8 +51,10 @@ struct Typed { std::array g_typed{}; std::size_t g_count = 0; std::bitset<512> g_held; +std::bitset<512> g_withheld; /* keys the pad does not see: a typing key found down on the name rows, until it is up */ std::size_t g_text = 0; /* how many of the typed are text: Enter is not */ -unsigned g_away = kLeftScreen, g_quiet = 0; +uint64_t g_entered = 0, g_keyed = 0; /* the game's clock at the routine's last entry and at the last key event */ +bool g_entered_once = false; bool g_on = false, g_letters = false; /* at the routine's last entry: the name rows took text, and the row was the letters' */ /* A key as the layout names it when that is a Latin letter, a digit or a space. A letter key the layout gives a @@ -65,6 +73,29 @@ bool g_on = false, g_letters = false; /* at the routine's last entry: the name return std::nullopt; } +/* A key of the typing block, which some layout types with: letters, digits and the signs beside them, space, the + * keypad's digits, both erasers. Enter, Tab and Escape are not of it. */ +[[nodiscard]] constexpr bool in_typing_block(int scancode) { + return (scancode >= kKeyA && scancode <= kKey0) || (scancode >= kSpace && scancode <= kSlash) || scancode == kSecondBackslash || + (scancode >= kPad1 && scancode <= kPad0) || scancode == kBackspace || scancode == kDelete; +} + +[[nodiscard]] bool run_in_step() { return g_ls_mode || g_ls_replay_active || psx_netplay_active() != 0; } + +/* The screen was left: the routine has not run for kLeftScreen VBlanks of the game's own clock. A VBlank plugin + * cannot count them: the framework calls one for a mod package only, never for this game. */ +[[nodiscard]] bool left_screen() { + return !g_entered_once || psx_get_cycle_count() - g_entered >= kLeftScreen * kVBlankCycles; +} + +/* Whether the name rows are up now, for the pad. The row is read as it is, not as the routine last found it: the + * routine itself moves it, and a key can go down between that and its next entry. */ +[[nodiscard]] bool taking_text() { + if (left_screen() || run_in_step()) return false; + const uint32_t row = psx_mod_read_word(kRow); + return row >= kLettersRow && row <= kLastNameRow; +} + void forget() { g_count = 0; g_text = 0; @@ -73,14 +104,17 @@ void forget() { void save_screen_entry(CPUState *cpu, uint32_t address) { if (!cpu || address != kSaveScreen) return; + if (left_screen()) forget(); + g_entered = psx_get_cycle_count(); + g_entered_once = true; const uint32_t row = psx_mod_read_word(kRow); - g_away = 0; g_on = row >= kLettersRow && row <= kLastNameRow && !g_ls_mode && !g_ls_replay_active && !psx_netplay_active(); g_letters = row == kLettersRow; if (!g_on) { forget(); return; } + if (g_held.any() && g_entered - g_keyed >= kLetGo * kVBlankCycles) g_held.reset(); if (g_count == 0 && g_held.none()) return; if (g_text != 0 || g_held.any()) cpu->gpr[4] = 0; uint32_t cursor = psx_mod_read_word(kCursor); @@ -107,34 +141,56 @@ void save_screen_entry(CPUState *cpu, uint32_t address) { psx_mod_write_word(kRow, kLettersRow); } -void vblank() { - if (g_away < kLeftScreen && ++g_away == kLeftScreen) { - g_on = g_letters = false; - forget(); - } - if (g_held.any() && ++g_quiet >= kLetGo) g_held.reset(); -} - PSX_MOD_CONSTRUCTOR(register_disruptor_name_entry) { psx_mod_register_function_entry_plugin("disruptor.name_entry", kSaveScreen, save_screen_entry); - psx_mod_register_vblank_plugin("disruptor.name_entry.vblank", vblank); } } // namespace +/* The keyboard's state as the pad may read it: `count` keys copied to `kept`, the withheld ones as up. A key held + * while the name rows are left stays withheld, or the next screen would take it for a fresh press. A run in step + * with another gets the keyboard whole. */ +extern "C" void disruptor_name_entry_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count) { + const bool taking = taking_text(); + if (run_in_step()) g_withheld.reset(); + for (int scancode = 0; scancode < count; ++scancode) { + const auto key = static_cast(scancode); + if (key >= g_withheld.size()) { + kept[scancode] = keys[scancode]; + continue; + } + if (keys[scancode] == 0) g_withheld.reset(key); + else if (taking && in_typing_block(scancode)) g_withheld.set(key); + kept[scancode] = g_withheld.test(key) ? uint8_t{0} : keys[scancode]; + } +} + +/* A save state was loaded: what was typed, and where the screen stood, belong to the time before it. The clock + * cannot tell: a state's clock may lie anywhere beside the one it replaces. */ +extern "C" void disruptor_name_entry_state_loaded(void) { + g_on = g_letters = g_entered_once = false; + forget(); +} + /* A key went down or up. `plain` is 0 with Ctrl, Alt or the system key held. Returns 1 when the key was taken for the name. */ extern "C" int disruptor_name_entry_key(int scancode, uint32_t keycode, int down, int plain) { + if (left_screen()) { + g_on = g_letters = false; + forget(); + } if (scancode == kEnter || scancode == kPadEnter) { if (down && plain && g_letters && g_count < g_typed.size()) g_typed[g_count++] = Typed{Act::Done, kBlank}; return 0; } const std::optional typed = typed_of(scancode, keycode); - if (!typed || scancode < 0 || static_cast(scancode) >= g_held.size()) return 0; - g_quiet = 0; + if (!typed || !in_typing_block(scancode)) return 0; /* text only from keys the pad never sees on these rows */ + g_keyed = psx_get_cycle_count(); if (!down) { g_held.reset(static_cast(scancode)); return 0; } + /* By the rows as the routine last found them, not as they are: the pad shows a key before its event comes, and a + * key that confirmed the row before can have brought these rows up by now. */ if (!g_on || !plain) return 0; g_held.set(static_cast(scancode)); if (g_count < g_typed.size()) { diff --git a/tests/contracts/test_name_entry_contract.py b/tests/contracts/test_name_entry_contract.py index 5702691..8ac5287 100644 --- a/tests/contracts/test_name_entry_contract.py +++ b/tests/contracts/test_name_entry_contract.py @@ -118,9 +118,8 @@ def require(condition: bool, message: str) -> None: and "constexpr uint32_t kLength = 8u;" in module and "constexpr uint8_t kDigits = 26u, kBlank = 36u;" in module and ' psx_mod_register_function_entry_plugin("disruptor.name_entry", kSaveScreen, save_screen_entry);\n' - in module - and ' psx_mod_register_vblank_plugin("disruptor.name_entry.vblank", vblank);\n' in module, - "the module must name the routine, the row, the cursor and the name as the game has them, and hook the routine and the VBlank", + in module, + "the module must name the routine, the row, the cursor and the name as the game has them, and hook the routine", ) require( " g_on = row >= kLettersRow && row <= kLastNameRow && !g_ls_mode && !g_ls_replay_active && !psx_netplay_active();\n" @@ -131,8 +130,15 @@ def require(condition: bool, message: str) -> None: and module.count("psx_mod_write_") == 3 and " psx_mod_write_byte(kName + cursor, typed.code);\n" in module and " psx_mod_write_word(kCursor, cursor);\n psx_mod_write_word(kRow, kLettersRow);\n" in module - and " if (!g_on || !plain) return 0;\n" in module, - "keys are text only on the name rows and outside lockstep and net games, the frame's presses are the one register changed, " + and " if (!g_on || !plain) return 0;\n" in module + and " if (!typed || !in_typing_block(scancode)) return 0;" in module + and " if (left_screen() || run_in_step()) return false;\n" + " const uint32_t row = psx_mod_read_word(kRow);\n" + " return row >= kLettersRow && row <= kLastNameRow;\n" in module + and "[[nodiscard]] bool run_in_step() { return g_ls_mode || g_ls_replay_active || psx_netplay_active() != 0; }\n" in module, + "keys are text only from the typing block, on the name rows as the routine last found them, and outside " + "lockstep and net games, " + "the frame's presses are the one register changed, " "and the name, the cursor and the row the only memory written", ) require( @@ -161,6 +167,72 @@ def require(condition: bool, message: str) -> None: < host.index("if (psx_game_key((int)scancode"), "a taken key must be taken before the host's own keys are looked at, and after a net game's Escape", ) +sources = [found for found in (ROOT / "src").rglob("*") if found.suffix in {".cpp", ".h"}] +require( + " return !g_entered_once || psx_get_cycle_count() - g_entered >= kLeftScreen * kVBlankCycles;\n" in module + and " if (left_screen()) forget();\n g_entered = psx_get_cycle_count();\n g_entered_once = true;\n" in module + and " if (left_screen()) {\n g_on = g_letters = false;\n forget();\n }\n" in module + and " if (g_held.any() && g_entered - g_keyed >= kLetGo * kVBlankCycles) g_held.reset();\n" in module + and " g_keyed = psx_get_cycle_count();\n" in module + and "constexpr uint64_t kVBlankCycles = 564480u;" in module + and "#define VBLANK_CYCLES 564480u" in read("psxrecomp/runtime/src/interrupts.c") + and "constexpr unsigned kLeftScreen = 8u;" in module + and "constexpr unsigned kLetGo = 120u;" in module + and 'extern "C" void disruptor_name_entry_state_loaded(void) {\n' + " g_on = g_letters = g_entered_once = false;\n forget();\n}\n" in module + and " disruptor_capsule_state_loaded();\n disruptor_name_entry_state_loaded();\n" in game_side, + "the screen counts as left, and a held key as let go, by the game's own clock in the framework's VBlanks, " + "and a loaded save state, whose clock may lie anywhere, resets it all", +) +require( + " for (const ModResolution::Plugin& plugin : s.plan.plugins)\n mod_invoke_vblank_plugin(plugin.id);\n" + in read("psxrecomp/runtime/src/mod_runtime.cpp") + and not [ + found.name + for found in sources + if "psx_mod_register_vblank_plugin" in found.read_text(encoding="utf-8") + or "psx_mod_register_activation_plugin" in found.read_text(encoding="utf-8") + ], + "the framework calls a VBlank or an activation plugin for a mod package only, never for the game itself: " + "no source of the game may count on one", +) +controls = read("src/game/input/mouse_aim.cpp") +require( + "static const Uint8* pad_keys(void) {\n" + " int count = 0;\n" + " const Uint8* keys = SDL_GetKeyboardState(&count);\n" + "#ifdef PSX_HAS_GAME_HOST\n" + " static Uint8 kept[512];\n" + " if (!keys || count <= 0 || count > (int)sizeof(kept)) return keys;\n" + " psx_game_keys_for_pad(keys, kept, count);\n" + " return kept;\n" in host + and host.count("const Uint8* keys = pad_keys();\n") == 4 + and all( + host.count(f"{reader}(keys, ") == uses + for reader, uses in (("psx_keybinds_pad_word", 1), ("psx_keybinds_sticks", 2), ("psx_keybinds_dpad_active", 1)) + ) + and "void psx_game_keys_for_pad(const uint8_t *keys, uint8_t *kept, int count) {\n" + " disruptor_name_entry_keys_for_pad(keys, kept, count);\n}\n" in game_side, + "every reading of the keyboard for a pad must go through the game, which holds back the keys it is typing with", +) +require( + " disruptor_name_entry_keys_for_pad(keys, pad_keys.data(), static_cast(pad_keys.size()));\n" + " keys = pad_keys.data();\n" in controls + and controls.index("disruptor_name_entry_keys_for_pad(keys, pad_keys.data()") + < controls.index(" if (key_down(keys, SDL_SCANCODE_W)") + and " const bool taking = taking_text();\n if (run_in_step()) g_withheld.reset();\n" in module + and " if (keys[scancode] == 0) g_withheld.reset(key);\n" + " else if (taking && in_typing_block(scancode)) g_withheld.set(key);\n" + " kept[scancode] = g_withheld.test(key) ? uint8_t{0} : keys[scancode];\n" in module + and module.count("g_withheld.reset(") == 2 + and " return (scancode >= kKeyA && scancode <= kKey0) || (scancode >= kSpace && scancode <= kSlash) || " + "scancode == kSecondBackslash ||\n" + " (scancode >= kPad1 && scancode <= kPad0) || scancode == kBackspace || scancode == kDelete;\n" in module + and "kEnter = 40, kPadEnter = 88, kSlash = 56, kSecondBackslash = 100;\n" in module, + "on the name rows a key of the typing block is no button, in the modern controls as well, from the moment the " + "keyboard shows it down, which can be a frame before its event, until it is let go and not merely until the rows " + "are left, and a run in step with another gets the keyboard whole", +) image_path = ROOT / "input" / "SLUS_002.24.code" if image_path.exists(): diff --git a/tests/unit/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp index 9d9589a..a6e60a9 100644 --- a/tests/unit/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -18,6 +18,7 @@ namespace { SDL_Window g_window; std::array g_ram{}; std::array g_keys{}; +std::array g_typed_with{}; /* keys the name entry holds back from the pad */ void (*g_registered_hook)(void) = nullptr; std::uint32_t g_noted_at = 0; std::uint8_t g_noted = 0; @@ -62,6 +63,10 @@ void disruptor_capsule_note_byte(std::uint32_t address, std::uint8_t value) { ++g_notes; } +void disruptor_name_entry_keys_for_pad(const std::uint8_t *keys, std::uint8_t *kept, int count) { + for (int key = 0; key < count; ++key) kept[key] = g_typed_with[static_cast(key)] ? std::uint8_t{0} : keys[key]; +} + int psx_host_ui_game_input_captured(void) { return g_host_ui_captured; } @@ -203,6 +208,7 @@ void reset_state() { g_mouse = MouseAimState{}; g_ram.fill(0); g_keys.fill(0); + g_typed_with.fill(false); g_buttons = 0; g_pad_buttons = 0xFFFFu; g_relative_x = 0.0f; @@ -710,6 +716,18 @@ void test_modern_keyboard_mapping_and_merge() { expect_pressed(kPadLeft, "Left arrow retains original turn/menu behavior"); expect_pressed(kPadRight, "Right arrow retains original turn/menu behavior"); expect_pressed(kPadSelect, "Tab maps to the automap"); + + reset_state(); + g_mouse.modern_controls_enabled = true; + g_typed_with[SDL_SCANCODE_E] = true; + g_keys[SDL_SCANCODE_E] = 1; + g_keys[SDL_SCANCODE_F] = 1; + g_keys[SDL_SCANCODE_RETURN] = 1; + apply_modern_controls(g_keys.data(), 0); + expect_released(kPadTriangle, "a key a name is being typed with is no button"); + expect_pressed(kPadSquare, "the keys beside it still are"); + expect_pressed(kPadCross, "and so is Return"); + expect(g_keys[SDL_SCANCODE_E] == 1, "the keyboard's own state is left as it was"); } void test_modern_mouse_buttons_require_capture() { diff --git a/tests/unit/disruptor_name_entry_test.cpp b/tests/unit/disruptor_name_entry_test.cpp index be1e3b1..f1c94f0 100644 --- a/tests/unit/disruptor_name_entry_test.cpp +++ b/tests/unit/disruptor_name_entry_test.cpp @@ -1,6 +1,8 @@ #include "cpu_state.h" #include "mod_plugins.h" +#include +#include #include #include #include @@ -16,7 +18,7 @@ constexpr char kTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 "; /* the game' std::map g_memory; std::uint32_t g_entry_address = 0; PSXModFunctionEntryCallback g_entry = nullptr; -PSXModVBlankCallback g_tick = nullptr; +std::uint64_t g_cycles = 1000; /* the game's clock */ int g_netplay = 0; int g_strays = 0; int g_failures = 0; @@ -27,6 +29,9 @@ void expect(bool condition, const char *message) { std::cerr << "FAIL: " << message << '\n'; } +/* A VBlank of the game passes. */ +void g_tick() { g_cycles += 564480u; } + bool ours(std::uint32_t address, std::uint32_t bytes) { return (address == kTestRow && bytes == 4u) || (address == kTestCursor && bytes == 4u) || (address >= kTestName && address < kTestName + 8u && bytes == 1u); @@ -56,10 +61,7 @@ int psx_mod_register_function_entry_plugin(const char *, std::uint32_t address, g_entry = callback; return 1; } -int psx_mod_register_vblank_plugin(const char *, PSXModVBlankCallback callback) { - g_tick = callback; - return 1; -} +std::uint64_t psx_get_cycle_count(void) { return g_cycles; } } #include "../../src/game/input/name_entry.cpp" @@ -107,7 +109,7 @@ void fresh(std::uint32_t at_row, std::uint32_t at_cursor, const std::string &tex } void test_keys_become_the_name() { - expect(g_entry_address == 0x8001C484u && g_entry && g_tick, "the module must hook the save screen's routine and the VBlank"); + expect(g_entry_address == 0x8001C484u && g_entry, "the module must hook the save screen's routine"); fresh(5, 0); expect(strike(4, 'a') && name() == "A " && cursor() == 1u && row() == 5u, "a letter goes where the cursor is and the cursor moves on"); expect(strike(5, 'B') && strike(38, '9') && strike(39, '0') && strike(44, 0xA0u) && strike(29, 'z'), @@ -268,6 +270,136 @@ void test_a_letter_key_is_no_button_that_frame() { expect(frame() == 0u && name() == "AAAAAAAA" && cursor() == 8u, "more keys than a frame can hold are dropped, not written past the name"); } +/* Whether the pad sees this key when it and the others named are down. */ +bool reaches_pad(int scancode, std::initializer_list others = {}) { + std::array keys{}, kept{}; + keys[static_cast(scancode)] = 1; + for (const int other : others) keys[static_cast(other)] = 1; + disruptor_name_entry_keys_for_pad(keys.data(), kept.data(), static_cast(keys.size())); + for (std::size_t key = 0; key < keys.size(); ++key) expect(kept[key] == 0 || keys[key] != 0, "the pad is never shown a key that is up"); + return kept[static_cast(scancode)] != 0; +} + +void let_go() { + const std::array keys{}; + std::array kept{}; + disruptor_name_entry_keys_for_pad(keys.data(), kept.data(), static_cast(keys.size())); +} + +void test_a_typing_key_stays_off_the_pad() { + fresh(0, 0); + expect(reaches_pad(8) && reaches_pad(42) && reaches_pad(44) && reaches_pad(30) && reaches_pad(51), "off the name rows every key is the pad's"); + let_go(); + + fresh(5, 0); + expect(!reaches_pad(8), "on the name rows a letter key is no button, and that before its event has come"); + expect(frame() == kPressed && name() == " ", "which leaves the pad's own presses alone and types nothing by itself"); + expect(!reaches_pad(4) && !reaches_pad(29) && !reaches_pad(30) && !reaches_pad(39) && !reaches_pad(89) && !reaches_pad(98), + "nor is any letter, a key of the digit row or a digit of the keypad"); + expect(!reaches_pad(44) && !reaches_pad(42) && !reaches_pad(76), "nor the space bar or either eraser"); + expect(!reaches_pad(45) && !reaches_pad(51) && !reaches_pad(56) && !reaches_pad(100), + "nor a sign's key: a French board has M on one, a Dvorak board most of its letters"); + expect(reaches_pad(40) && reaches_pad(88) && reaches_pad(82) && reaches_pad(81) && reaches_pad(43) && reaches_pad(41) && reaches_pad(58) && + reaches_pad(225), + "Enter, the arrows, Tab, Escape, a function key and Shift stay buttons"); + expect(reaches_pad(82, {8}) && !reaches_pad(8, {82}), "one key held back does not hold back another"); + let_go(); + for (std::uint32_t at = 6; at <= 7; ++at) { + fresh(at, 8); + expect(!reaches_pad(8), "the rows under the letters hold a letter key back as well"); + let_go(); + } + + fresh(0, 0); + screen(5, 0); + expect(!reaches_pad(8), "a key that goes down between the routine's putting the rows up and its next entry is held back already"); + expect(disruptor_name_entry_key(8, 'e', 1, 1) == 0, "and is no text yet"); + expect(frame() == kPressed && name() == " ", "so it does nothing at all"); + disruptor_name_entry_key(8, 'e', 0, 1); + let_go(); + + fresh(4, 0); + expect(reaches_pad(9), "on the row before, F is a button and can confirm it"); + screen(5, 0); + expect(disruptor_name_entry_key(9, 'f', 1, 1) == 0, "its event, which comes after the pad showed it, is not typed into the name it brought up"); + expect(!reaches_pad(9), "and the key, still down, is a button no more"); + expect(frame() == kPressed && name() == " ", "the name stays empty"); + disruptor_name_entry_key(9, 'f', 0, 1); + let_go(); + + fresh(5, 0); + screen(0, 0); + expect(reaches_pad(9), "a key that goes down once the routine has left the rows is a button"); + disruptor_name_entry_key(9, 'f', 1, 1); + expect(frame() == kPressed && name() == " ", "and what its event queued is dropped at the routine's next entry"); + disruptor_name_entry_key(9, 'f', 0, 1); + let_go(); + fresh(5, 0); + for (int tick = 0; tick < 8; ++tick) g_tick(); + expect(reaches_pad(9) && disruptor_name_entry_key(9, 'f', 1, 1) == 0, "and so is one that comes when the routine has stopped running"); + disruptor_name_entry_key(9, 'f', 0, 1); + let_go(); + + fresh(5, 0); + expect(!reaches_pad(8), "a key held on the name rows"); + screen(0, 0); + expect(!reaches_pad(8), "stays held back when the rows are left: the next screen must not take it for a fresh press"); + let_go(); + expect(reaches_pad(8), "and is a button again once it has been let go"); + let_go(); + + fresh(5, 0); + expect(disruptor_name_entry_key(99, 'x', 1, 1) == 0 && reaches_pad(99) && frame() == kPressed && name() == " ", + "a key outside the typing block types nothing whatever a layout calls it, and stays a button"); + disruptor_name_entry_key(99, 'x', 0, 1); + let_go(); + + fresh(5, 0); + expect(!reaches_pad(8), "a key held back on the name rows"); + g_netplay = 1; + expect(reaches_pad(8), "is given to a network game at once: nothing is typed there"); + g_netplay = 0; + let_go(); + fresh(5, 0); + expect(!reaches_pad(8), "and one held back"); + g_ls_mode = 1; + expect(reaches_pad(8), "to a lockstep run"); + g_ls_mode = 0; + let_go(); + + fresh(5, 0); + expect(disruptor_name_entry_key(4, 'a', 1, 1) == 1 && !reaches_pad(4), "a letter queued and its key held back"); + disruptor_name_entry_state_loaded(); + expect(disruptor_name_entry_key(5, 'b', 1, 1) == 0, "a save state loaded: no key is text until the routine runs again"); + expect(reaches_pad(9), "a key that goes down now is a button, though the clock has not moved"); + expect(frame() == kPressed && name() == " ", "and what was typed before the load is not put into the name it restored"); + disruptor_name_entry_key(4, 'a', 0, 1); + disruptor_name_entry_key(5, 'b', 0, 1); + let_go(); + + int typed_and_pressed = 0, typing = 0; + for (int scancode = 0; scancode < 512; ++scancode) { + for (const std::uint32_t keycode : {std::uint32_t{'a'}, std::uint32_t{'Z'}, std::uint32_t{'7'}, std::uint32_t{' '}, 0xE9u, 0x444u, 0x40000059u}) { + fresh(5, 0); + const bool on_pad = reaches_pad(scancode); + const bool text = disruptor_name_entry_key(scancode, keycode, 1, 1) == 1; + disruptor_name_entry_key(scancode, keycode, 0, 1); + let_go(); + typing += text; + typed_and_pressed += text && on_pad; + } + } + expect(typing > 300 && typed_and_pressed == 0, "no key of the board, whatever a layout calls it, both types on the name rows and presses a button"); + + fresh(5, 0); + std::array wide{}, kept{}; + wide[8] = wide[599] = 1; + kept[599] = 7; + disruptor_name_entry_keys_for_pad(wide.data(), kept.data(), 600); + expect(kept[8] == 0 && kept[599] == 1, "a key past the last one known is passed on as it is"); + let_go(); +} + } // namespace int main() { @@ -276,6 +408,7 @@ int main() { test_enter_ends_the_typing(); test_only_on_the_name_rows(); test_a_letter_key_is_no_button_that_frame(); + test_a_typing_key_stays_off_the_pad(); expect(g_strays == 0, "nothing but the row, the cursor and the name's eight codes may be read or written"); if (g_failures) return 1; std::cout << "Disruptor name entry tests passed\n"; From a04a049784f8ab27cc3d0c02f3dcdfbfaf455a0f Mon Sep 17 00:00:00 2001 From: Solganis Date: Mon, 12 Oct 2026 07:10:30 +0300 Subject: [PATCH 21/21] refactor: split the in-game menu into a folder, a file a tab --- CMakeLists.txt | 11 +- src/game/ui/dev_menu.cpp | 1439 ----------------- src/game/ui/menu/menu.cpp | 315 ++++ src/game/ui/menu/menu.h | 174 ++ src/game/ui/menu/preferences.cpp | 456 ++++++ src/game/ui/menu/rows.cpp | 62 + src/game/ui/menu/tab_cheats.cpp | 92 ++ src/game/ui/menu/tab_controls.cpp | 84 + src/game/ui/menu/tab_debug.cpp | 31 + src/game/ui/menu/tab_enhancements.cpp | 236 +++ src/game/ui/menu/tab_experimental.cpp | 37 + src/game/ui/menu/tab_language.cpp | 20 + src/game/ui/menu/tab_system.cpp | 87 + tests/contracts/menu_sources.py | 17 + .../test_ambient_occlusion_contract.py | 4 +- tests/contracts/test_capsule_contract.py | 4 +- tests/contracts/test_debug_tab_contract.py | 4 +- tests/contracts/test_dev_menu_contract.py | 40 +- tests/contracts/test_frame_rate_contract.py | 4 +- tests/contracts/test_gap_filling_contract.py | 4 +- tests/contracts/test_hud_scale_contract.py | 4 +- tests/contracts/test_intro_skip_contract.py | 4 +- .../contracts/test_language_disc_contract.py | 4 +- tests/contracts/test_launcher_contract.py | 4 +- tests/contracts/test_loaded_state_contract.py | 4 +- tests/contracts/test_smooth_turn_contract.py | 4 +- .../test_temporal_presenter_contract.py | 4 +- tests/contracts/test_world_depth_contract.py | 4 +- 28 files changed, 1694 insertions(+), 1459 deletions(-) delete mode 100644 src/game/ui/dev_menu.cpp create mode 100644 src/game/ui/menu/menu.cpp create mode 100644 src/game/ui/menu/menu.h create mode 100644 src/game/ui/menu/preferences.cpp create mode 100644 src/game/ui/menu/rows.cpp create mode 100644 src/game/ui/menu/tab_cheats.cpp create mode 100644 src/game/ui/menu/tab_controls.cpp create mode 100644 src/game/ui/menu/tab_debug.cpp create mode 100644 src/game/ui/menu/tab_enhancements.cpp create mode 100644 src/game/ui/menu/tab_experimental.cpp create mode 100644 src/game/ui/menu/tab_language.cpp create mode 100644 src/game/ui/menu/tab_system.cpp create mode 100644 tests/contracts/menu_sources.py diff --git a/CMakeLists.txt b/CMakeLists.txt index e7bf69b..5bfed12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,7 +96,16 @@ if(DISRUPTOR_DEV_MENU) list(APPEND DISRUPTOR_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/preferences.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/rows.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_controls.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_enhancements.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_experimental.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_debug.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_cheats.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_system.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/tab_language.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/menu.cpp") if(DISRUPTOR_FRAME_INTERPOLATION) list(APPEND DISRUPTOR_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/present_rate.cpp") diff --git a/src/game/ui/dev_menu.cpp b/src/game/ui/dev_menu.cpp deleted file mode 100644 index df034e0..0000000 --- a/src/game/ui/dev_menu.cpp +++ /dev/null @@ -1,1439 +0,0 @@ -/* - * Host-side in-game settings menu for Disruptor. - * - * This overlay is deliberately outside the emulated PlayStation. It draws - * after the completed host presentation. Normal settings never write guest - * VRAM/RAM; the Cheats tab calls only the narrow, version-pinned Disruptor - * gameplay APIs whose save consequences are stated in the UI. - */ - -#include "game/session/cheats.h" -#include "game/render/billboard_aspect.h" -#include "game/render/far_rendering.h" -#include "game/frame/frame_rate.h" -#include "game/session/capsule.h" -#include "game/session/intro_skip.h" -#include "game/input/mouse_aim.h" -#include "game/frame/present_rate.h" -#include "game/session/restart.h" -#include "game/settings/options.h" -#include "game/ui/hint.h" -#include "config_loader.h" -#include "gpu.h" -#include "host_ui.h" -#include "psx_keybinds.h" -#include "psx_netplay.h" - -#include "imgui.h" -#include "imgui_impl_opengl3.h" -#if defined(PSX_SDL3) -#include "imgui_impl_sdl3.h" -#else -#include "imgui_impl_sdl2.h" -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -extern "C" void gte_ws_set_far_threshold(int threshold); -extern "C" void gte_ws_set_backdrop_repair_enabled(int enabled); -extern "C" void gl_renderer_set_world_depth(int enabled); -extern "C" int gl_renderer_world_depth(void); -extern "C" void gl_renderer_set_full_yaw(int enabled); -extern "C" void gl_renderer_set_mesh_view(int enabled); -extern "C" int gl_renderer_mesh_view(void); -extern "C" int gl_renderer_record_shown(const char *folder, int skip, int count, int width); -extern "C" int gl_renderer_recording_shown(void); -extern "C" void gl_renderer_set_gap_fill(int enabled); -extern "C" int gl_renderer_gap_fill(void); -extern "C" void gl_renderer_set_ssao(int enabled); -extern "C" void gl_renderer_set_ssao_view(int view); -extern "C" int gl_renderer_ssao_view(void); -extern "C" int gl_renderer_ssao(void); -extern "C" void gl_renderer_set_ssao_shape(float radius, float strength); -extern "C" float gl_renderer_ssao_radius(void); -extern "C" float gl_renderer_ssao_strength(void); - -namespace { - -struct DevMenuState { - SDL_Window *window = nullptr; - int backend = PSX_HOST_UI_BACKEND_NONE; - bool imgui_ready = false; - bool open = false; - bool restore_mouse_capture = false; - bool aspect_apply_failed = false; - bool internal_scale_apply_failed = false; - bool fullscreen_apply_failed = false; - bool base_keybinds_valid = false; - bool record_shown = false; // armed in the menu, started when it closes - std::string shown_note; - int selected_wide_aspect = 0; - std::string cheat_status; - std::array base_keybinds{}; -}; - -DevMenuState g_menu; - -namespace fs = std::filesystem; -using disruptor::settings::find_option; -using disruptor::settings::Option; -using disruptor::settings::Unit; - -struct PreferenceState { - fs::path path; - PSXRecompV4::UserSettings pending; - std::set changed; - bool language_disc_changed = false; - bool diagnostic_view_changed = false; - bool loaded = false; - bool save_failed = false; - std::string status; - - bool dirty() const { - return !changed.empty() || language_disc_changed || - diagnostic_view_changed; - } - void clean() { - changed.clear(); - language_disc_changed = diagnostic_view_changed = false; - } -}; - -PreferenceState g_preferences; -std::string g_language_disc; - -std::string utf8(const fs::path &path) { - const std::u8string text = path.u8string(); - return {text.begin(), text.end()}; -} - -bool env_override_present(const char *name) { - return name && std::getenv(name) != nullptr; -} - -static constexpr std::array kModernKeybinds = { - SDL_SCANCODE_W, SDL_SCANCODE_S, - SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, - SDL_SCANCODE_RETURN, SDL_SCANCODE_SPACE, - SDL_SCANCODE_F, SDL_SCANCODE_E, - SDL_SCANCODE_Q, SDL_SCANCODE_R, - SDL_SCANCODE_A, SDL_SCANCODE_D, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_P, SDL_SCANCODE_TAB, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, -}; - -static constexpr std::array kOriginalKeybinds = { - SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, - SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, - SDL_SCANCODE_X, SDL_SCANCODE_S, - SDL_SCANCODE_Z, SDL_SCANCODE_A, - SDL_SCANCODE_Q, SDL_SCANCODE_W, - SDL_SCANCODE_E, SDL_SCANCODE_R, - SDL_SCANCODE_T, SDL_SCANCODE_Y, - SDL_SCANCODE_RETURN, SDL_SCANCODE_RSHIFT, - SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, - SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, - SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, -}; - -void capture_base_keybinds() { - if (g_menu.base_keybinds_valid) return; - bool is_packaged_modern_preset = true; - for (int i = 0; i < PSX_KB_COUNT; ++i) { - g_menu.base_keybinds[static_cast(i)] = - psx_keybinds_get_button(1, i); - if (g_menu.base_keybinds[static_cast(i)] != - kModernKeybinds[static_cast(i)]) - is_packaged_modern_preset = false; - } - /* run.ps1/run.sh install the packaged modern map for an explicit launch - * flag. Treat that exact map as an overlay, not the restore baseline, so - * turning Modern controls off live really returns to the packaged classic - * mapping. Any custom/non-exact map is preserved verbatim instead. */ - if (is_packaged_modern_preset) - g_menu.base_keybinds = kOriginalKeybinds; - g_menu.base_keybinds_valid = true; -} - -void apply_modern_keybinds(bool enabled) { - capture_base_keybinds(); - if (!enabled) { - for (int i = 0; i < PSX_KB_COUNT; ++i) - psx_keybinds_set_button( - 1, i, g_menu.base_keybinds[static_cast(i)]); - return; - } - - for (int i = 0; i < PSX_KB_COUNT; ++i) - psx_keybinds_set_button( - 1, i, kModernKeybinds[static_cast(i)]); -} - -void set_modern_controls_live(bool enabled) { - apply_modern_keybinds(enabled); - disruptor_modern_controls_set_enabled(enabled ? 1 : 0); -} - -void mark(const char *key, int value) { - const Option *option = find_option(key); - option->set(g_preferences.pending, value); - g_preferences.changed.insert(option); -} - -bool changed(const char *key) { - return g_preferences.changed.contains(find_option(key)); -} - -bool prerequisite_on(const Option &option) { - const std::string_view key(option.key); - if (key == "disruptor.geometry_correction") - return gpu_geometry_correction_enabled() != 0; - if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0; - if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0; - return false; -} - -bool applies(const char *key) { - return disruptor::settings::applies(*find_option(key), prerequisite_on); -} - -bool option_toggle(const char *key, bool *value) { - const Option &option = *find_option(key); - const bool moved = ImGui::Checkbox(option.label, value); - disruptor::ui::hint(option.hint); - return moved; -} - -bool option_slider(const char *key, int *value) { - const Option &option = *find_option(key); - const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; - const bool moved = - ImGui::SliderInt(option.label, value, option.lowest, option.highest, - format, ImGuiSliderFlags_AlwaysClamp); - disruptor::ui::hint(option.hint); - return moved; -} - -bool option_slider(const char *key, float *value) { - const Option &option = *find_option(key); - const bool moved = ImGui::SliderFloat( - option.label, value, option.lowest / 1000.0f, - option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic); - disruptor::ui::hint(option.hint); - return moved; -} - -bool option_list(const char *key, int *index, size_t skipped = 0) { - const Option &option = *find_option(key); - const auto shown = option.choices.subspan(skipped); - const bool moved = ImGui::Combo(option.label, index, shown.data(), - static_cast(shown.size())); - disruptor::ui::hint(option.hint); - return moved; -} - -void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } - -void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } - -void mark_sensitivity(double value) { - g_preferences.pending.has_horizontal_sensitivity = true; - g_preferences.pending.horizontal_sensitivity = value; - g_preferences.changed.insert( - find_option("disruptor.horizontal_sensitivity")); -} - -void mark_invert_x(bool value) { mark("disruptor.invert_horizontal", value); } - -void mark_vertical_look(bool value) { mark("disruptor.vertical_look", value); } - -void mark_vertical_sensitivity(double value) { - g_preferences.pending.has_vertical_sensitivity = true; - g_preferences.pending.vertical_sensitivity = value; - g_preferences.changed.insert( - find_option("disruptor.vertical_sensitivity")); -} - -void mark_invert_y(bool value) { mark("disruptor.invert_vertical", value); } - -void mark_geometry(bool value) { mark("disruptor.geometry_correction", value); } - -void mark_textures(bool value) { mark("disruptor.perspective_textures", value); } - -void mark_vsync(int value) { mark("video.vsync", value + 1); } - -void mark_aspect(int numerator, int denominator, bool adaptive) { - g_preferences.pending.has_aspect_ratio = true; - g_preferences.pending.aspect_num = numerator; - g_preferences.pending.aspect_den = denominator; - /* Keep the adaptive flag and its fixed 32:9 cap in the same atomic - * settings update as the fallback/initial aspect. */ - g_preferences.pending.has_adaptive_view = true; - g_preferences.pending.adaptive_view = adaptive; - g_preferences.changed.insert(find_option("video.aspect_ratio")); -} - -void mark_supersampling(int value) { mark("video.supersampling", value - 1); } - -void mark_fullscreen(int value) { mark("video.fullscreen", value); } - -void mark_master_volume(int value) { mark("audio.master_volume", value); } - -void mark_audio_muted(bool value) { mark("audio.muted", value); } - -void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); } - -void mark_language_disc(const std::string &path) { - g_language_disc = path; - g_preferences.pending.has_language_disc = true; - g_preferences.pending.language_disc = path; - g_preferences.language_disc_changed = true; -} - -void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); } - -void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); } - -void mark_shadow_shape(bool improved) { mark("disruptor.improved_shadows", improved); } - -void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } - -void mark_occlusion_shape(int reach, int strength) { - mark("disruptor.occlusion_reach", reach); - mark("disruptor.occlusion_strength", strength); -} - -void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } - -void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); } - -// Views 1 to 4 are layers of the occlusion pass, 5 is the mesh drawn over in-between frames. -void apply_diagnostic_view(int view) { - gl_renderer_set_ssao_view(view >= 1 && view <= 4 ? view : 0); - gl_renderer_set_mesh_view(view == 5 ? 1 : 0); -} - -void mark_diagnostic_view(int value) { - g_preferences.pending.has_diagnostic_view = true; - g_preferences.pending.diagnostic_view = value; - g_preferences.diagnostic_view_changed = true; -} - -// With the vertical divided too a renderer turn of one step misses the game's next step in a median of 8604 pixels of 307200, without in 24274. -void apply_smooth_turn(bool enabled) { - disruptor_high_precision_camera_set_enabled(enabled ? 1 : 0); - if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); -} - -void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); } - -void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); } - -void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); } - -void merge_dirty_preferences(PSXRecompV4::UserSettings &settings) { - const auto &pending = g_preferences.pending; - for (const Option *option : g_preferences.changed) - option->set(settings, option->get(pending)); - if (g_preferences.language_disc_changed) { - settings.has_language_disc = true; - settings.language_disc = pending.language_disc; - } - if (g_preferences.diagnostic_view_changed) { - settings.has_diagnostic_view = true; - settings.diagnostic_view = pending.diagnostic_view; - } -} - -bool flush_preferences() { - if (!g_preferences.dirty()) return true; - if (g_preferences.path.empty()) { - g_preferences.save_failed = true; - g_preferences.status = "Runtime settings path is unavailable."; - return false; - } - - PSXRecompV4::UserSettings settings = - PSXRecompV4::load_user_settings(g_preferences.path); - if (settings.parse_error) { - g_preferences.save_failed = true; - g_preferences.status = - "settings.toml is invalid; it was left untouched."; - return false; - } - merge_dirty_preferences(settings); - if (!PSXRecompV4::save_user_settings(g_preferences.path, settings)) { - g_preferences.save_failed = true; - g_preferences.status = - "Could not save settings; the previous file is intact."; - return false; - } - - g_preferences.clean(); - g_preferences.save_failed = false; - g_preferences.status = "Settings saved."; - std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", - utf8(g_preferences.path).c_str()); - return true; -} - -void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { - /* Force the legacy INI + environment fallback to load before the saved - * layer is applied. Presence (including an explicit zero) makes an - * environment value authoritative for this launch. */ - (void)disruptor_mouse_horizontal_sensitivity(); - (void)disruptor_mouse_invert_horizontal(); - - if (settings.has_mouse_aim && - !env_override_present("PSX_DISRUPTOR_MOUSE_AIM")) - disruptor_mouse_aim_set_enabled(settings.mouse_aim ? 1 : 0); - if (settings.has_modern_controls && - !env_override_present("PSX_DISRUPTOR_MODERN_CONTROLS")) - set_modern_controls_live(settings.modern_controls); - if (settings.has_horizontal_sensitivity && - !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY")) - (void)disruptor_mouse_set_horizontal_sensitivity( - settings.horizontal_sensitivity); - if (settings.has_invert_horizontal && - !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_X")) - disruptor_mouse_set_invert_horizontal( - settings.invert_horizontal ? 1 : 0); - if (settings.has_vertical_look && - !env_override_present("PSX_DISRUPTOR_VERTICAL_LOOK")) - disruptor_mouse_set_vertical_look_enabled( - settings.vertical_look ? 1 : 0); - if (settings.has_vertical_sensitivity && - !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY_Y")) - (void)disruptor_mouse_set_vertical_sensitivity( - settings.vertical_sensitivity); - if (settings.has_invert_vertical && - !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_Y")) - disruptor_mouse_set_invert_vertical( - settings.invert_vertical ? 1 : 0); - - /* An inherited explicit environment override can enable Modern controls - * without a run-script switch (and therefore without its preset copy). - * Always reconcile the live mode with the in-memory keyboard overlay so - * S/A do not retain conflicting classic face-button bindings. */ - apply_modern_keybinds(disruptor_modern_controls_enabled() != 0); - - /* main.cpp applies these fields before the environment layer. Repeat the - * live application here so a host-UI soft session also gets the saved - * state, while still respecting explicit per-launch overrides. */ - if (settings.has_geometry_correction && - !env_override_present("PSX_GEOMETRY_CORRECTION")) - gpu_geometry_correction_set(settings.geometry_correction ? 1 : 0); - if (settings.has_perspective_textures && - !env_override_present("PSX_TEXTURE_CORRECTION")) { - gpu_texture_correction_set( - settings.perspective_textures && - gpu_geometry_correction_enabled() - ? 1 : 0); - } - if (settings.has_supersampling) - (void)psx_host_video_set_internal_scale(settings.supersampling); - if (settings.has_fullscreen) - g_menu.fullscreen_apply_failed = - psx_host_video_set_fullscreen_mode(settings.fullscreen) == 0; - if (settings.has_master_volume) - (void)psx_host_audio_set_master_volume(settings.master_volume); - if (settings.has_audio_muted) - (void)psx_host_audio_set_muted(settings.audio_muted ? 1 : 0); - if (settings.has_hud_scale) gpu_ws_set_hud_scale(settings.hud_scale); - if (settings.has_frame_unlock && - !env_override_present("PSX_DISRUPTOR_FRAME_UNLOCK")) - disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0); - if (settings.has_skip_intro && - !env_override_present("PSX_DISRUPTOR_SKIP_INTRO")) - disruptor_intro_skip_set_enabled(settings.skip_intro ? 1 : 0); - if (settings.has_language_disc) g_language_disc = settings.language_disc; - if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS")) - gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0); - if (!env_override_present("PSX_GL_SSAO")) { - if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); - /* A layer is a session's own. Only a capsule's replay starts in the one its recording was made in. */ - apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") - ? settings.diagnostic_view : 0); - } - gl_renderer_set_ssao_shape( - settings.has_occlusion_reach && !env_override_present("PSX_GL_SSAO_RADIUS") - ? static_cast(settings.occlusion_reach) : -1.0f, - settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") - ? settings.occlusion_strength / 100.0f : -1.0f); - if (settings.has_gap_filling && !env_override_present("PSX_GL_CRACK_FILL") && !env_override_present("PSX_GL_GROWN_UNDER")) - gl_renderer_set_gap_fill(settings.gap_filling ? 1 : 0); - if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) - gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); - // The session's start switched it off after the mouse module read its variable, so the variable is applied here too. - if (const char *forced = std::getenv("PSX_DISRUPTOR_HIGH_PRECISION_CAMERA")) - apply_smooth_turn(forced[0] != '\0' && forced[0] != '0'); - else if (settings.has_high_precision_camera) - apply_smooth_turn(settings.high_precision_camera); -} - -void apply_pending_preferences() { - const auto &pending = g_preferences.pending; - if (changed("disruptor.mouse_aim")) - disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); - if (changed("disruptor.modern_controls")) - set_modern_controls_live(pending.modern_controls); - if (changed("disruptor.horizontal_sensitivity")) - (void)disruptor_mouse_set_horizontal_sensitivity( - pending.horizontal_sensitivity); - if (changed("disruptor.invert_horizontal")) - disruptor_mouse_set_invert_horizontal( - pending.invert_horizontal ? 1 : 0); - if (changed("disruptor.vertical_look")) - disruptor_mouse_set_vertical_look_enabled( - pending.vertical_look ? 1 : 0); - if (changed("disruptor.vertical_sensitivity")) - (void)disruptor_mouse_set_vertical_sensitivity( - pending.vertical_sensitivity); - if (changed("disruptor.invert_vertical")) - disruptor_mouse_set_invert_vertical( - pending.invert_vertical ? 1 : 0); - if (changed("disruptor.geometry_correction")) - gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); - if (changed("disruptor.perspective_textures")) - gpu_texture_correction_set( - pending.perspective_textures && - gpu_geometry_correction_enabled() - ? 1 : 0); - if (changed("video.vsync")) - (void)psx_host_video_set_vsync(pending.vsync); - if (changed("video.aspect_ratio")) { - if (pending.adaptive_view) - (void)psx_host_video_set_adaptive_view(1); - else - (void)psx_host_video_set_display_aspect( - pending.aspect_num, pending.aspect_den); - } - if (changed("video.supersampling")) - (void)psx_host_video_set_internal_scale(pending.supersampling); - if (changed("video.fullscreen")) - g_menu.fullscreen_apply_failed = - psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; - if (changed("audio.master_volume")) - (void)psx_host_audio_set_master_volume(pending.master_volume); - if (changed("audio.muted")) - (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); - if (changed("disruptor.hud_scale")) - gpu_ws_set_hud_scale(pending.hud_scale); - if (changed("disruptor.frame_unlock")) - disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); - if (changed("disruptor.skip_intro")) - disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); - if (changed("disruptor.improved_shadows")) - gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); - if (changed("disruptor.ambient_occlusion")) - gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); - if (changed("disruptor.occlusion_reach") || - changed("disruptor.occlusion_strength")) - gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); - if (changed("disruptor.gap_filling")) - gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); - if (changed("disruptor.depth_sorted_world")) - gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); - if (g_preferences.diagnostic_view_changed) - apply_diagnostic_view(pending.diagnostic_view); - if (changed("disruptor.high_precision_camera")) - apply_smooth_turn(pending.high_precision_camera); -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (changed("video.frame_interpolation")) - (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); - if (changed("video.frame_interpolation_fps")) - (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); -#endif -} - -void load_preferences_for_session() { - const char *path = psx_host_user_settings_path(); - if (!path || !path[0]) { - g_preferences.loaded = false; - g_preferences.status = "Runtime settings path is unavailable."; - return; - } - const std::string_view text(path); - g_preferences.path = fs::path(std::u8string(text.begin(), text.end())); - const PSXRecompV4::UserSettings settings = - PSXRecompV4::load_user_settings(g_preferences.path); - if (settings.parse_error) { - g_preferences.loaded = false; - g_preferences.status = - "settings.toml is invalid; saved menu values were not applied."; - return; - } - capture_base_keybinds(); - apply_saved_preferences(settings); - apply_pending_preferences(); - /* A capsule recorded later copies the file: a layer left there by the last session must not go into it. */ - if (settings.has_diagnostic_view && settings.diagnostic_view != 0 && !env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY")) { - mark_diagnostic_view(0); - (void)flush_preferences(); - } - g_preferences.loaded = true; - if (!g_preferences.save_failed) - g_preferences.status = "Settings loaded."; -} - -bool imgui_sdl_init(SDL_Window *window) { -#if defined(PSX_SDL3) - return ImGui_ImplSDL3_InitForOpenGL(window, SDL_GL_GetCurrentContext()); -#else - return ImGui_ImplSDL2_InitForOpenGL(window, SDL_GL_GetCurrentContext()); -#endif -} - -void imgui_sdl_shutdown() { -#if defined(PSX_SDL3) - ImGui_ImplSDL3_Shutdown(); -#else - ImGui_ImplSDL2_Shutdown(); -#endif -} - -void imgui_sdl_new_frame() { -#if defined(PSX_SDL3) - ImGui_ImplSDL3_NewFrame(); -#else - ImGui_ImplSDL2_NewFrame(); -#endif -} - -void imgui_sdl_process_event(const SDL_Event *event) { -#if defined(PSX_SDL3) - (void)ImGui_ImplSDL3_ProcessEvent(event); -#else - (void)ImGui_ImplSDL2_ProcessEvent(event); -#endif -} - -void apply_disruptor_style() { - ImGui::StyleColorsDark(); - ImGuiStyle &style = ImGui::GetStyle(); - style.WindowRounding = 5.0f; - style.ChildRounding = 4.0f; - style.FrameRounding = 3.0f; - style.PopupRounding = 4.0f; - style.ScrollbarRounding = 4.0f; - style.GrabRounding = 3.0f; - - const ImVec4 green(0.20f, 0.78f, 0.34f, 1.00f); - const ImVec4 green_hover(0.28f, 0.90f, 0.43f, 1.00f); - const ImVec4 green_active(0.14f, 0.62f, 0.27f, 1.00f); - style.Colors[ImGuiCol_CheckMark] = green_hover; - style.Colors[ImGuiCol_SliderGrab] = green; - style.Colors[ImGuiCol_SliderGrabActive] = green_hover; - style.Colors[ImGuiCol_Header] = ImVec4(0.10f, 0.38f, 0.18f, 1.00f); - style.Colors[ImGuiCol_HeaderHovered] = green_active; - style.Colors[ImGuiCol_HeaderActive] = green; - style.Colors[ImGuiCol_Button] = ImVec4(0.10f, 0.34f, 0.17f, 1.00f); - style.Colors[ImGuiCol_ButtonHovered] = green_active; - style.Colors[ImGuiCol_ButtonActive] = green; -} - -bool initialize_imgui() { - if (g_menu.imgui_ready) return true; - if (!g_menu.window || g_menu.backend != PSX_HOST_UI_BACKEND_OPENGL) - return false; - - IMGUI_CHECKVERSION(); - ImGui::CreateContext(); - ImGuiIO &io = ImGui::GetIO(); - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - /* Persist only the reviewed gameplay/presentation fields below. ImGui's - * ambient layout file is deliberately disabled: it is relative-path, - * independently written, and outside our atomic settings contract. */ - io.IniFilename = nullptr; - apply_disruptor_style(); - - if (!imgui_sdl_init(g_menu.window)) { - ImGui::DestroyContext(); - std::fprintf(stderr, - "disruptor: developer menu SDL backend initialization failed\n"); - return false; - } - if (!ImGui_ImplOpenGL3_Init("#version 330 core")) { - imgui_sdl_shutdown(); - ImGui::DestroyContext(); - std::fprintf(stderr, - "disruptor: developer menu OpenGL initialization failed\n"); - return false; - } - - g_menu.imgui_ready = true; - std::fprintf(stdout, - "disruptor: in-game settings menu ready (press ` to open)\n"); - return true; -} - -// A second after the menu closes, two seconds of what is swapped to the screen go to shown// beside settings.toml. -void start_shown_recording() { - g_menu.record_shown = false; - const auto now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); - const fs::path folder = g_preferences.path.parent_path() / "shown" / std::to_string(now); - std::error_code failed; - fs::create_directories(folder, failed); - if (failed) { - g_menu.shown_note = "The folder for the pictures could not be made."; - return; - } - g_menu.shown_note = gl_renderer_record_shown(utf8(folder).c_str(), 120, 240, 960) ? "Last recording: " + utf8(folder) - : "The recording could not start."; -} - -void set_menu_open(bool open) { - if (open == g_menu.open) return; - if (open) { - g_menu.restore_mouse_capture = disruptor_mouse_captured() != 0; - if (g_menu.restore_mouse_capture) - (void)disruptor_mouse_set_captured(0); - g_menu.open = true; - } else { - g_menu.open = false; - (void)flush_preferences(); - if (g_menu.record_shown) start_shown_recording(); - if (g_menu.restore_mouse_capture) - (void)disruptor_mouse_set_captured(1); - g_menu.restore_mouse_capture = false; - } -} - -bool scancode_event(const SDL_Event *event, SDL_Scancode scancode) { - if (!event || event->type != SDL_KEYDOWN || event->key.repeat) return false; -#if defined(PSX_SDL3) - return event->key.scancode == scancode; -#else - return event->key.keysym.scancode == scancode; -#endif -} - -void draw_status_badge(const char *label, const ImVec4 &colour) { - ImGui::SameLine(); - ImGui::TextColored(colour, "[%s]", label); -} - -void draw_controls_tab() { - bool mouse_aim = disruptor_mouse_aim_enabled() != 0; - if (option_toggle("disruptor.mouse_aim", &mouse_aim)) { - disruptor_mouse_aim_set_enabled(mouse_aim ? 1 : 0); - mark_mouse_aim(mouse_aim); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - bool modern = disruptor_modern_controls_enabled() != 0; - if (option_toggle("disruptor.modern_controls", &modern)) { - set_modern_controls_live(modern); - mark_modern_controls(modern); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - float sensitivity = - static_cast(disruptor_mouse_horizontal_sensitivity()); - if (option_slider("disruptor.horizontal_sensitivity", &sensitivity)) { - const double applied = - disruptor_mouse_set_horizontal_sensitivity(sensitivity); - mark_sensitivity(applied); - } - - bool invert_x = disruptor_mouse_invert_horizontal() != 0; - if (option_toggle("disruptor.invert_horizontal", &invert_x)) { - disruptor_mouse_set_invert_horizontal(invert_x ? 1 : 0); - mark_invert_x(invert_x); - } - - const bool turn_applies = applies("disruptor.high_precision_camera"); - bool smooth = disruptor_high_precision_camera_enabled() != 0; - if (!turn_applies) ImGui::BeginDisabled(); - if (option_toggle("disruptor.high_precision_camera", &smooth)) { - apply_smooth_turn(smooth); - mark_smooth_turn(smooth); - } - if (!turn_applies) ImGui::EndDisabled(); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - ImGui::SeparatorText("Vertical look"); - bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; - if (option_toggle("disruptor.vertical_look", &vertical_look)) { - disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); - mark_vertical_look(vertical_look); - } - draw_status_badge("EXPERIMENTAL", ImVec4(1.0f, 0.73f, 0.25f, 1.0f)); - - float vertical_sensitivity = - static_cast(disruptor_mouse_vertical_sensitivity()); - if (option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)) { - const double applied = - disruptor_mouse_set_vertical_sensitivity(vertical_sensitivity); - mark_vertical_sensitivity(applied); - } - - bool invert_y = disruptor_mouse_invert_vertical() != 0; - if (option_toggle("disruptor.invert_vertical", &invert_y)) { - disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); - mark_invert_y(invert_y); - } - - const double pitch_units = disruptor_mouse_vertical_pitch(); - if (pitch_units == 0.0) ImGui::BeginDisabled(); - if (ImGui::Button("Recenter vertical view")) - disruptor_mouse_recenter_vertical(); - if (pitch_units == 0.0) ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::Text("Requested pitch: %+.2f deg", pitch_units * (360.0 / 256.0)); - ImGui::TextDisabled( - "Range: +/-30.94 degrees. The original game has no pitch state."); - ImGui::Separator(); - ImGui::TextWrapped( - "The menu releases relative mouse capture while open and restores the " - "previous state when closed. Gameplay input is neutralised while you " - "interact with the menu."); - ImGui::Text("Capture before opening: %s", - g_menu.restore_mouse_capture ? "yes" : "no"); -} - -void apply_geometry_enabled(bool enabled) { - if (!enabled && gpu_texture_correction_enabled()) { - gpu_texture_correction_set(0); - mark_textures(false); - } -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - if (!enabled) { - (void)disruptor_present_rate_enable(0); - mark_present_enabled(false); - } -#endif - gpu_geometry_correction_set(enabled ? 1 : 0); - mark_geometry(enabled); -} - -void draw_aspect_controls() { - static constexpr int kAspectNumerators[] = {16, 21, 32, 32}; - static constexpr int kAspectDenominators[] = {9, 9, 9, 9}; - static constexpr int kFixedWideAspectCount = 3; - static constexpr int kAdaptiveAspectIndex = 3; - static constexpr int kAspectCount = 4; - - int numerator = 4; - int denominator = 3; - psx_host_video_get_display_aspect(&numerator, &denominator); - const bool adaptive = psx_host_video_get_adaptive_view() != 0; - bool widescreen = adaptive || numerator * 3 != denominator * 4; - if (adaptive) { - g_menu.selected_wide_aspect = kAdaptiveAspectIndex; - } else if (widescreen) { - for (int index = 0; index < kFixedWideAspectCount; ++index) { - if (numerator * kAspectDenominators[index] == - denominator * kAspectNumerators[index]) { - g_menu.selected_wide_aspect = index; - break; - } - } - } - g_menu.selected_wide_aspect = std::clamp( - g_menu.selected_wide_aspect, 0, kAspectCount - 1); - - ImGui::SeparatorText("Display aspect"); - if (ImGui::Checkbox("Widescreen", &widescreen)) { - if (widescreen && - g_menu.selected_wide_aspect == kAdaptiveAspectIndex) { - g_menu.aspect_apply_failed = - psx_host_video_set_adaptive_view(1) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(32, 9, true); - } else { - const int requested_num = widescreen - ? kAspectNumerators[g_menu.selected_wide_aspect] : 4; - const int requested_den = widescreen - ? kAspectDenominators[g_menu.selected_wide_aspect] : 3; - g_menu.aspect_apply_failed = - psx_host_video_set_display_aspect( - requested_num, requested_den) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(requested_num, requested_den, false); - } - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - int selected = g_menu.selected_wide_aspect; - if (option_list("video.aspect_ratio", &selected, 1)) { - g_menu.selected_wide_aspect = selected; - if (widescreen) { - if (selected == kAdaptiveAspectIndex) { - g_menu.aspect_apply_failed = - psx_host_video_set_adaptive_view(1) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(32, 9, true); - } else { - const int requested_num = kAspectNumerators[selected]; - const int requested_den = kAspectDenominators[selected]; - g_menu.aspect_apply_failed = - psx_host_video_set_display_aspect( - requested_num, requested_den) == 0; - if (!g_menu.aspect_apply_failed) - mark_aspect(requested_num, requested_den, false); - } - } - } - if (g_menu.aspect_apply_failed) { - ImGui::TextColored( - ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "The runtime rejected the requested display aspect."); - } - ImGui::TextDisabled( - "4:3 disables widescreen. Fixed 16:9, 21:9 and 32:9 choices or " - "live window matching (capped at 32:9) apply after the current " - "frame and are saved."); - - if (gpu_sprite_placement_available()) { - int shadow_shape = gpu_shadow_shape(); - ImGui::SetNextItemWidth(260.0f); - if (option_list("disruptor.improved_shadows", &shadow_shape)) { - gpu_set_shadow_shape(shadow_shape); - mark_shadow_shape(shadow_shape != 0); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - } - - int hud_scale = gpu_ws_hud_scale(); - ImGui::SetNextItemWidth(260.0f); - if (option_slider("disruptor.hud_scale", &hud_scale)) { - gpu_ws_set_hud_scale(hud_scale); - mark_hud_scale(hud_scale); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); -} - -void draw_internal_resolution_controls() { - static constexpr int kScaleCount = 8; - int selected = std::clamp( - psx_host_video_get_internal_scale(), 1, kScaleCount) - 1; - - ImGui::SeparatorText("Rendering resolution"); - if (option_list("video.supersampling", &selected)) { - const int requested = selected + 1; - g_menu.internal_scale_apply_failed = - psx_host_video_set_internal_scale(requested) == 0; - if (!g_menu.internal_scale_apply_failed) - mark_supersampling(requested); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (g_menu.internal_scale_apply_failed) { - ImGui::TextColored( - ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "The renderer could not allocate that internal resolution."); - } - ImGui::TextDisabled( - "Supersamples geometry and shading before presentation. GPU fill and " - "memory cost grow roughly with the square of this value; changes " - "apply immediately and are saved."); -} - -void draw_enhancements_tab() { - draw_aspect_controls(); - draw_internal_resolution_controls(); - - bool geometry = gpu_geometry_correction_enabled() != 0; - if (option_toggle("disruptor.geometry_correction", &geometry)) - apply_geometry_enabled(geometry); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - bool textures = gpu_texture_correction_enabled() != 0; - const bool textures_apply = applies("disruptor.perspective_textures"); - if (!textures_apply) ImGui::BeginDisabled(); - if (option_toggle("disruptor.perspective_textures", &textures)) { - gpu_texture_correction_set(textures ? 1 : 0); - mark_textures(textures); - } - if (!textures_apply) ImGui::EndDisabled(); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (!textures_apply) - ImGui::TextDisabled("Enable exact geometry before perspective textures."); - - bool gaps = gl_renderer_gap_fill() != 0; - const bool gaps_apply = applies("disruptor.gap_filling"); - if (!gaps_apply) ImGui::BeginDisabled(); - if (option_toggle("disruptor.gap_filling", &gaps)) { - gl_renderer_set_gap_fill(gaps ? 1 : 0); - mark_gap_filling(gaps); - } - if (!gaps_apply) ImGui::EndDisabled(); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - bool by_depth = gl_renderer_world_depth() != 0; - const bool depth_applies = applies("disruptor.depth_sorted_world"); - if (!depth_applies) ImGui::BeginDisabled(); - if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { - gl_renderer_set_world_depth(by_depth ? 1 : 0); - mark_world_depth(by_depth); - } - if (!depth_applies) ImGui::EndDisabled(); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - ImGui::SeparatorText("Synchronisation"); - int vsync = psx_host_video_get_vsync(); - int vsync_index = std::clamp(vsync + 1, 0, 2); - if (option_list("video.vsync", &vsync_index)) { - const int requested = vsync_index - 1; - if (psx_host_video_set_vsync(requested)) mark_vsync(requested); - } - - bool unlocked = disruptor_frame_rate_unlocked() != 0; - if (option_toggle("disruptor.frame_unlock", &unlocked)) { - disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); - mark_frame_unlock(unlocked); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - DisruptorFrameRateWindow window{}; - if (disruptor_frame_rate_last_window(&window) && window.vblanks != 0u) { - ImGui::TextDisabled( - "Game frames: %.1f per second. Frame work: %.2f VBlank average, " - "%.2f peak, %u of %u frames over one VBlank.", - 59.94 * window.frames / window.vblanks, - window.average_work_permille / 1000.0, - window.peak_work_permille / 1000.0, - window.late_frames, window.frames); - } - -#ifndef PSX_DISABLE_FRAME_INTERPOLATION - int present_enabled = 0; - if (disruptor_present_rate_switch( - find_option("video.frame_interpolation")->label, - find_option("video.frame_interpolation")->hint, - applies("video.frame_interpolation") ? 1 : 0, - &present_enabled)) - mark_present_enabled(present_enabled != 0); - int present_rate = 0; - if (disruptor_present_rate_control( - find_option("video.frame_interpolation_fps")->label, - find_option("video.frame_interpolation_fps")->hint, &present_rate)) - mark_present_rate(present_rate); -#endif - - ImGui::SeparatorText("Startup"); - bool skip_intro = disruptor_intro_skip_enabled() != 0; - if (option_toggle("disruptor.skip_intro", &skip_intro)) { - disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0); - mark_skip_intro(skip_intro); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); -} - -const char *cheat_result_message(int result, const char *success) { - switch (result) { - case DISRUPTOR_CHEAT_OK: - return success; - case DISRUPTOR_CHEAT_GAME_NOT_READY: - return "Enter live gameplay before using this cheat."; - case DISRUPTOR_CHEAT_NETPLAY_BLOCKED: - return "Cheats are disabled during netplay."; - case DISRUPTOR_CHEAT_UNVERIFIED_STATE: - return "The game state did not match the verified USA executable; no changes were made."; - default: - return "The cheat request was rejected."; - } -} - -void draw_experimental_tab() { - ImGui::TextDisabled( - "Unfinished features of the OpenGL renderer. All of them need geometry " - "correction on."); - ImGui::Spacing(); - - bool occlusion = gl_renderer_ssao() != 0; - const bool occlusion_applies = applies("disruptor.ambient_occlusion"); - if (!occlusion_applies) ImGui::BeginDisabled(); - if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { - gl_renderer_set_ssao(occlusion ? 1 : 0); - mark_occlusion(occlusion); - } - if (!occlusion_applies) ImGui::EndDisabled(); - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - int reach = static_cast(std::lround(gl_renderer_ssao_radius())); - int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); - const bool shape_applies = applies("disruptor.occlusion_reach") && - applies("disruptor.occlusion_strength"); - if (!shape_applies) ImGui::BeginDisabled(); - ImGui::SetNextItemWidth(260.0f); - const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); - ImGui::SetNextItemWidth(260.0f); - const bool strength_moved = - option_slider("disruptor.occlusion_strength", &strength); - if (!shape_applies) ImGui::EndDisabled(); - if (reach_moved || strength_moved) { - gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); - mark_occlusion_shape(reach, strength); - } -} - -void draw_debug_tab() { - ImGui::TextDisabled("Tools for a report of a fault in the picture."); - ImGui::Spacing(); - - if (ImGui::Button("Record the screen") && !gl_renderer_recording_shown()) g_menu.record_shown = true; - draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "For a report: two seconds of the pictures that reach the screen, with " - "their times. It starts a second after this menu closes."); - if (g_menu.record_shown) ImGui::TextDisabled("Armed. Close the menu and do what you want to show."); - else if (!g_menu.shown_note.empty()) ImGui::TextDisabled("%s", g_menu.shown_note.c_str()); - - ImGui::Separator(); - int view = gl_renderer_mesh_view() ? 5 : gl_renderer_ssao_view(); - ImGui::SetNextItemWidth(260.0f); - if (ImGui::Combo("Diagnostic view", &view, "Off\0Shading alone\0Depth\0Surface normals\0Fog fade\0Mesh\0")) { - apply_diagnostic_view(view); - mark_diagnostic_view(view); - } - draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Shows a layer of the renderer in the picture's place, for reports. A " - "replay capsule recorded with one on replays in it. Off again at the " - "next start."); -} - -void draw_cheats_tab() { - const bool netplay = disruptor_cheats_netplay_blocked() != 0; - const bool gameplay_ready = disruptor_cheats_gameplay_ready() != 0; - - ImGui::TextWrapped( - "These are version-pinned gameplay cheats for testing and casual " - "play. They are never saved as port settings and start off on every " - "launch."); - if (netplay) { - ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "Cheats are unavailable while netplay is active."); - } else if (!gameplay_ready) { - ImGui::TextDisabled("Enter live gameplay to enable cheats."); - } - - ImGui::SeparatorText("Player"); - bool god_mode = disruptor_cheats_god_mode_enabled() != 0; - const bool disable_god_control = !god_mode && (netplay || !gameplay_ready); - if (disable_god_control) ImGui::BeginDisabled(); - if (ImGui::Checkbox("God mode", &god_mode)) { - const int result = disruptor_cheats_set_god_mode(god_mode ? 1 : 0); - g_menu.cheat_status = cheat_result_message( - result, god_mode ? "God mode enabled for this session." - : "God mode disabled."); - } - if (disable_god_control) ImGui::EndDisabled(); - draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - ImGui::TextDisabled( - "Neutralises player damage at the game's central damage routine. " - "A light hit cue remains, but saved health is not altered and the " - "game is not marked as cheated."); - - ImGui::SeparatorText("Inventory"); - if (netplay || !gameplay_ready) ImGui::BeginDisabled(); - if (ImGui::Button("Grant all weapons + psionics")) - ImGui::OpenPopup("Confirm All Weapons"); - if (netplay || !gameplay_ready) ImGui::EndDisabled(); - ImGui::TextWrapped( - "This reproduces the retail All Weapons cheat: all weapons and " - "psionics are unlocked, ammunition and psionic energy are refilled, " - "and the current game is marked as cheated."); - ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), - "Important: the cheated marker affects the ending and is carried into " - "memory-card/password saves."); - - bool popup_open = true; - if (ImGui::BeginPopupModal("Confirm All Weapons", &popup_open, - ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextWrapped( - "Grant every weapon and psionic, refill their resources, and " - "mark this game (including later saves) as cheated?"); - if (ImGui::Button("Grant and mark cheated")) { - const int result = disruptor_cheats_grant_all_weapons(); - g_menu.cheat_status = cheat_result_message( - result, "All weapons, psionics and resources granted. Game marked as cheated."); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } - - if (!g_menu.cheat_status.empty()) { - ImGui::Separator(); - ImGui::TextWrapped("%s", g_menu.cheat_status.c_str()); - } -} - -void draw_fullscreen_controls() { - int mode = std::clamp(psx_host_video_get_fullscreen_mode(), 0, 2); - - ImGui::SeparatorText("Window presentation"); - if (option_list("video.fullscreen", &mode)) { - g_menu.fullscreen_apply_failed = - psx_host_video_set_fullscreen_mode(mode) == 0; - if (!g_menu.fullscreen_apply_failed) - mark_fullscreen(mode); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - if (g_menu.fullscreen_apply_failed) { - ImGui::TextColored( - ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "The runtime rejected the requested display mode."); - } - ImGui::TextDisabled( - "Menu selections apply immediately and are saved. Alt+Enter remains " - "a transient window/fullscreen toggle and does not overwrite the " - "saved mode."); -} - -void draw_audio_controls() { - ImGui::SeparatorText("Audio output"); - int volume = psx_host_audio_get_master_volume(); - ImGui::SetNextItemWidth(260.0f); - if (option_slider("audio.master_volume", &volume) && - psx_host_audio_set_master_volume(volume)) { - mark_master_volume(volume); - } - draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); - - bool muted = psx_host_audio_get_muted() != 0; - if (option_toggle("audio.muted", &muted) && - psx_host_audio_set_muted(muted ? 1 : 0)) { - mark_audio_muted(muted); - } - ImGui::TextDisabled( - "Host-side output only: game audio state and saves are unchanged. " - "Unmuting restores the selected volume."); -} - -void draw_system_tab() { - draw_fullscreen_controls(); - draw_audio_controls(); - ImGui::TextWrapped( - "Live menu choices are merged into the runtime's user-owned " - "settings.toml. Writes use an atomic replacement, so a failed save " - "leaves the previous file intact. Explicit launch flags still win " - "for that run."); - ImGui::SeparatorText("Settings persistence"); - ImGui::TextWrapped("Path: %s", - g_preferences.path.empty() - ? "unavailable" : utf8(g_preferences.path).c_str()); - if (g_preferences.save_failed) - ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), - "Not saved"); - else if (g_preferences.dirty()) - ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), - "Unsaved changes"); - else - ImGui::TextColored(ImVec4(0.35f, 0.90f, 0.45f, 1.0f), "Saved"); - if (!g_preferences.status.empty()) - ImGui::TextWrapped("%s", g_preferences.status.c_str()); - if (g_preferences.dirty() && ImGui::Button("Save now")) - (void)flush_preferences(); - ImGui::TextDisabled( - "Current vertical pitch, menu layout, " - "and mouse capture are not saved. Cheat controls start " - "off each launch."); - ImGui::SeparatorText("Restart required"); - ImGui::BulletText("Renderer backend"); - ImGui::BulletText("Audio backend and buffer configuration"); - ImGui::BulletText("BIOS, disc and memory-card wiring"); - ImGui::Separator(); - if (ImGui::Button("Close menu")) set_menu_open(false); -} - -void draw_language_tab() { - ImGui::TextWrapped("Disc of another region: %s", g_language_disc.empty() ? "none, the game is in English" : g_language_disc.c_str()); - if (ImGui::Button("Choose a disc image")) (void)psx_host_pick_disc_image_begin(); - ImGui::SameLine(); - if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string()); - ImGui::SameLine(); - if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) { - SDL_Event quit{}; - quit.type = SDL_QUIT; - SDL_PushEvent(&quit); - } - ImGui::TextDisabled("Movies, speech and words come from that disc at the next start. The French, German and Japanese discs are known."); - ImGui::TextDisabled("The Japanese disc gives movies, speech and hints: its menus are in English on the disc itself."); -} - -void draw_menu() { - char picked[4096]; - if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked); - bool keep_open = g_menu.open; - ImGui::SetNextWindowSize(ImVec2(760.0f, 560.0f), - ImGuiCond_FirstUseEver); - if (ImGui::Begin("Disruptor Settings", &keep_open, - ImGuiWindowFlags_NoCollapse)) { - ImGui::PushTextWrapPos(0.0f); // a long note wraps at the window's edge, where it used to be cut off - ImGui::TextDisabled("Press ` to toggle; Escape closes the menu."); - ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), - "The game is not paused while this menu is open."); - - if (ImGui::BeginTabBar("DisruptorDevTabs")) { - if (ImGui::BeginTabItem("Controls")) { - draw_controls_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Enhancements")) { - draw_enhancements_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Experimental")) { - draw_experimental_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Language")) { - draw_language_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("System")) { - draw_system_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Debug")) { - draw_debug_tab(); - ImGui::EndTabItem(); - } - if (ImGui::BeginTabItem("Cheats")) { - draw_cheats_tab(); - ImGui::EndTabItem(); - } - ImGui::EndTabBar(); - } - ImGui::PopTextWrapPos(); - } - ImGui::End(); - if (!keep_open && g_menu.open) set_menu_open(false); -} - -void on_runtime_ready(void *, SDL_Window *window, int backend) { - disruptor_cheats_reset_session(); - disruptor_far_rendering_reset_session(); - disruptor_billboard_aspect_set_site_mask( - disruptor_billboard_aspect_all_site_mask()); - disruptor_mouse_recenter_vertical(); - disruptor_high_precision_camera_set_enabled(0); - gte_ws_set_far_threshold(900); - gte_ws_set_backdrop_repair_enabled(0); - g_menu.window = window; - g_menu.backend = backend; - load_preferences_for_session(); - if (backend == PSX_HOST_UI_BACKEND_OPENGL) (void)initialize_imgui(); -} - -void on_runtime_shutdown(void *) { - disruptor_cheats_reset_session(); - disruptor_far_rendering_reset_session(); - disruptor_billboard_aspect_set_site_mask( - disruptor_billboard_aspect_all_site_mask()); - disruptor_mouse_recenter_vertical(); - disruptor_high_precision_camera_set_enabled(0); - gte_ws_set_far_threshold(900); - gte_ws_set_backdrop_repair_enabled(0); - (void)flush_preferences(); - if (disruptor_mouse_captured()) - (void)disruptor_mouse_set_captured(0); - if (g_menu.base_keybinds_valid) - apply_modern_keybinds(false); - g_menu.open = false; - g_menu.restore_mouse_capture = false; - if (g_menu.imgui_ready) { - ImGui_ImplOpenGL3_Shutdown(); - imgui_sdl_shutdown(); - ImGui::DestroyContext(); - } - g_menu = DevMenuState{}; -} - -int on_sdl_event(void *, const SDL_Event *event) { - const bool toggle = scancode_event(event, SDL_SCANCODE_GRAVE); - const bool close = - g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE); - if (toggle || close) { - set_menu_open(toggle ? !g_menu.open : false); - return 1; - } - if (!g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE)) - disruptor_intro_skip_request(); - if (!g_menu.open && scancode_event(event, SDL_SCANCODE_F12) && (SDL_GetModState() & KMOD_CTRL) != 0) { - disruptor_capsule_toggle(); - return 1; - } - if (!g_menu.open || !g_menu.imgui_ready) return 0; - imgui_sdl_process_event(event); - return 1; -} - -uint32_t current_flags(void *) { - if (!g_menu.open || !g_menu.imgui_ready) return disruptor_capsule_notice() ? PSX_HOST_UI_VISIBLE : 0u; - return PSX_HOST_UI_CAPTURE_KEYBOARD | - PSX_HOST_UI_CAPTURE_MOUSE | - PSX_HOST_UI_CAPTURE_GAMEPAD | - PSX_HOST_UI_VISIBLE; -} - -/* Drawn without the SDL backend's frame: that one reads and captures the mouse, which is the game's while the menu is closed. */ -void render_notice(const char *word, int width, int height) { - ImGui_ImplOpenGL3_NewFrame(); - ImGuiIO &io = ImGui::GetIO(); - io.DisplaySize = ImVec2(static_cast(width), static_cast(height)); - io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); - io.DeltaTime = 1.0f / 60.0f; - ImGui::NewFrame(); - ImGui::SetNextWindowPos(ImVec2(16.0f, 16.0f)); - ImGui::Begin("##capsule", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); - ImGui::SetWindowFontScale(std::max(1.0f, static_cast(height) / 360.0f)); - ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.2f, 1.0f), "%s", word); - ImGui::End(); - ImGui::Render(); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); -} - -void render_gl(void *, int width, int height) { - if (const char *word = g_menu.open ? nullptr : disruptor_capsule_notice()) { - if (initialize_imgui()) render_notice(word, width, height); - return; - } - if (!g_menu.open || !initialize_imgui()) return; - ImGui_ImplOpenGL3_NewFrame(); - imgui_sdl_new_frame(); - ImGui::NewFrame(); - draw_menu(); - ImGui::Render(); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); -} - -struct MenuRegistration { - MenuRegistration() { - PsxHostUiHooks hooks{}; - hooks.abi_version = PSX_HOST_UI_ABI_VERSION; - hooks.struct_size = static_cast(sizeof(hooks)); - hooks.on_runtime_ready = on_runtime_ready; - hooks.on_runtime_shutdown = on_runtime_shutdown; - hooks.on_sdl_event = on_sdl_event; - hooks.flags = current_flags; - hooks.render_gl = render_gl; - if (!psx_host_ui_register(&hooks)) { - std::fprintf(stderr, - "disruptor: settings menu registration failed\n"); - } - } -}; - -MenuRegistration g_registration; - -} // namespace diff --git a/src/game/ui/menu/menu.cpp b/src/game/ui/menu/menu.cpp new file mode 100644 index 0000000..0e86d90 --- /dev/null +++ b/src/game/ui/menu/menu.cpp @@ -0,0 +1,315 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +DevMenuState g_menu; + +namespace { + +bool imgui_sdl_init(SDL_Window *window) { +#if defined(PSX_SDL3) + return ImGui_ImplSDL3_InitForOpenGL(window, SDL_GL_GetCurrentContext()); +#else + return ImGui_ImplSDL2_InitForOpenGL(window, SDL_GL_GetCurrentContext()); +#endif +} + +void imgui_sdl_shutdown() { +#if defined(PSX_SDL3) + ImGui_ImplSDL3_Shutdown(); +#else + ImGui_ImplSDL2_Shutdown(); +#endif +} + +void imgui_sdl_new_frame() { +#if defined(PSX_SDL3) + ImGui_ImplSDL3_NewFrame(); +#else + ImGui_ImplSDL2_NewFrame(); +#endif +} + +void imgui_sdl_process_event(const SDL_Event *event) { +#if defined(PSX_SDL3) + (void)ImGui_ImplSDL3_ProcessEvent(event); +#else + (void)ImGui_ImplSDL2_ProcessEvent(event); +#endif +} + +void apply_disruptor_style() { + ImGui::StyleColorsDark(); + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 5.0f; + style.ChildRounding = 4.0f; + style.FrameRounding = 3.0f; + style.PopupRounding = 4.0f; + style.ScrollbarRounding = 4.0f; + style.GrabRounding = 3.0f; + + const ImVec4 green(0.20f, 0.78f, 0.34f, 1.00f); + const ImVec4 green_hover(0.28f, 0.90f, 0.43f, 1.00f); + const ImVec4 green_active(0.14f, 0.62f, 0.27f, 1.00f); + style.Colors[ImGuiCol_CheckMark] = green_hover; + style.Colors[ImGuiCol_SliderGrab] = green; + style.Colors[ImGuiCol_SliderGrabActive] = green_hover; + style.Colors[ImGuiCol_Header] = ImVec4(0.10f, 0.38f, 0.18f, 1.00f); + style.Colors[ImGuiCol_HeaderHovered] = green_active; + style.Colors[ImGuiCol_HeaderActive] = green; + style.Colors[ImGuiCol_Button] = ImVec4(0.10f, 0.34f, 0.17f, 1.00f); + style.Colors[ImGuiCol_ButtonHovered] = green_active; + style.Colors[ImGuiCol_ButtonActive] = green; +} + +bool initialize_imgui() { + if (g_menu.imgui_ready) return true; + if (!g_menu.window || g_menu.backend != PSX_HOST_UI_BACKEND_OPENGL) + return false; + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + /* Persist only the reviewed gameplay/presentation fields below. ImGui's + * ambient layout file is deliberately disabled: it is relative-path, + * independently written, and outside our atomic settings contract. */ + io.IniFilename = nullptr; + apply_disruptor_style(); + + if (!imgui_sdl_init(g_menu.window)) { + ImGui::DestroyContext(); + std::fprintf(stderr, + "disruptor: developer menu SDL backend initialization failed\n"); + return false; + } + if (!ImGui_ImplOpenGL3_Init("#version 330 core")) { + imgui_sdl_shutdown(); + ImGui::DestroyContext(); + std::fprintf(stderr, + "disruptor: developer menu OpenGL initialization failed\n"); + return false; + } + + g_menu.imgui_ready = true; + std::fprintf(stdout, + "disruptor: in-game settings menu ready (press ` to open)\n"); + return true; +} + +// A second after the menu closes, two seconds of what is swapped to the screen go to shown// beside settings.toml. +void start_shown_recording() { + g_menu.record_shown = false; + const auto now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + const fs::path folder = g_preferences.path.parent_path() / "shown" / std::to_string(now); + std::error_code failed; + fs::create_directories(folder, failed); + if (failed) { + g_menu.shown_note = "The folder for the pictures could not be made."; + return; + } + g_menu.shown_note = gl_renderer_record_shown(utf8(folder).c_str(), 120, 240, 960) ? "Last recording: " + utf8(folder) + : "The recording could not start."; +} + +} // namespace + +void set_menu_open(bool open) { + if (open == g_menu.open) return; + if (open) { + g_menu.restore_mouse_capture = disruptor_mouse_captured() != 0; + if (g_menu.restore_mouse_capture) + (void)disruptor_mouse_set_captured(0); + g_menu.open = true; + } else { + g_menu.open = false; + (void)flush_preferences(); + if (g_menu.record_shown) start_shown_recording(); + if (g_menu.restore_mouse_capture) + (void)disruptor_mouse_set_captured(1); + g_menu.restore_mouse_capture = false; + } +} + +namespace { + +bool scancode_event(const SDL_Event *event, SDL_Scancode scancode) { + if (!event || event->type != SDL_KEYDOWN || event->key.repeat) return false; +#if defined(PSX_SDL3) + return event->key.scancode == scancode; +#else + return event->key.keysym.scancode == scancode; +#endif +} + +void draw_menu() { + char picked[4096]; + if (psx_host_pick_disc_image_poll(picked, static_cast(sizeof picked)) == 1) mark_language_disc(picked); + bool keep_open = g_menu.open; + ImGui::SetNextWindowSize(ImVec2(760.0f, 560.0f), + ImGuiCond_FirstUseEver); + if (ImGui::Begin("Disruptor Settings", &keep_open, + ImGuiWindowFlags_NoCollapse)) { + ImGui::PushTextWrapPos(0.0f); // a long note wraps at the window's edge, where it used to be cut off + ImGui::TextDisabled("Press ` to toggle; Escape closes the menu."); + ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), + "The game is not paused while this menu is open."); + + if (ImGui::BeginTabBar("DisruptorDevTabs")) { + if (ImGui::BeginTabItem("Controls")) { + draw_controls_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Enhancements")) { + draw_enhancements_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Experimental")) { + draw_experimental_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Language")) { + draw_language_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("System")) { + draw_system_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Debug")) { + draw_debug_tab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Cheats")) { + draw_cheats_tab(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + ImGui::PopTextWrapPos(); + } + ImGui::End(); + if (!keep_open && g_menu.open) set_menu_open(false); +} + +void on_runtime_ready(void *, SDL_Window *window, int backend) { + disruptor_cheats_reset_session(); + disruptor_far_rendering_reset_session(); + disruptor_billboard_aspect_set_site_mask( + disruptor_billboard_aspect_all_site_mask()); + disruptor_mouse_recenter_vertical(); + disruptor_high_precision_camera_set_enabled(0); + gte_ws_set_far_threshold(900); + gte_ws_set_backdrop_repair_enabled(0); + g_menu.window = window; + g_menu.backend = backend; + load_preferences_for_session(); + if (backend == PSX_HOST_UI_BACKEND_OPENGL) (void)initialize_imgui(); +} + +void on_runtime_shutdown(void *) { + disruptor_cheats_reset_session(); + disruptor_far_rendering_reset_session(); + disruptor_billboard_aspect_set_site_mask( + disruptor_billboard_aspect_all_site_mask()); + disruptor_mouse_recenter_vertical(); + disruptor_high_precision_camera_set_enabled(0); + gte_ws_set_far_threshold(900); + gte_ws_set_backdrop_repair_enabled(0); + (void)flush_preferences(); + if (disruptor_mouse_captured()) + (void)disruptor_mouse_set_captured(0); + if (g_menu.base_keybinds_valid) + apply_modern_keybinds(false); + g_menu.open = false; + g_menu.restore_mouse_capture = false; + if (g_menu.imgui_ready) { + ImGui_ImplOpenGL3_Shutdown(); + imgui_sdl_shutdown(); + ImGui::DestroyContext(); + } + g_menu = DevMenuState{}; +} + +int on_sdl_event(void *, const SDL_Event *event) { + const bool toggle = scancode_event(event, SDL_SCANCODE_GRAVE); + const bool close = + g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE); + if (toggle || close) { + set_menu_open(toggle ? !g_menu.open : false); + return 1; + } + if (!g_menu.open && scancode_event(event, SDL_SCANCODE_ESCAPE)) + disruptor_intro_skip_request(); + if (!g_menu.open && scancode_event(event, SDL_SCANCODE_F12) && (SDL_GetModState() & KMOD_CTRL) != 0) { + disruptor_capsule_toggle(); + return 1; + } + if (!g_menu.open || !g_menu.imgui_ready) return 0; + imgui_sdl_process_event(event); + return 1; +} + +uint32_t current_flags(void *) { + if (!g_menu.open || !g_menu.imgui_ready) return disruptor_capsule_notice() ? PSX_HOST_UI_VISIBLE : 0u; + return PSX_HOST_UI_CAPTURE_KEYBOARD | + PSX_HOST_UI_CAPTURE_MOUSE | + PSX_HOST_UI_CAPTURE_GAMEPAD | + PSX_HOST_UI_VISIBLE; +} + +/* Drawn without the SDL backend's frame: that one reads and captures the mouse, which is the game's while the menu is closed. */ +void render_notice(const char *word, int width, int height) { + ImGui_ImplOpenGL3_NewFrame(); + ImGuiIO &io = ImGui::GetIO(); + io.DisplaySize = ImVec2(static_cast(width), static_cast(height)); + io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); + io.DeltaTime = 1.0f / 60.0f; + ImGui::NewFrame(); + ImGui::SetNextWindowPos(ImVec2(16.0f, 16.0f)); + ImGui::Begin("##capsule", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); + ImGui::SetWindowFontScale(std::max(1.0f, static_cast(height) / 360.0f)); + ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.2f, 1.0f), "%s", word); + ImGui::End(); + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); +} + +void render_gl(void *, int width, int height) { + if (const char *word = g_menu.open ? nullptr : disruptor_capsule_notice()) { + if (initialize_imgui()) render_notice(word, width, height); + return; + } + if (!g_menu.open || !initialize_imgui()) return; + ImGui_ImplOpenGL3_NewFrame(); + imgui_sdl_new_frame(); + ImGui::NewFrame(); + draw_menu(); + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); +} + +struct MenuRegistration { + MenuRegistration() { + PsxHostUiHooks hooks{}; + hooks.abi_version = PSX_HOST_UI_ABI_VERSION; + hooks.struct_size = static_cast(sizeof(hooks)); + hooks.on_runtime_ready = on_runtime_ready; + hooks.on_runtime_shutdown = on_runtime_shutdown; + hooks.on_sdl_event = on_sdl_event; + hooks.flags = current_flags; + hooks.render_gl = render_gl; + if (!psx_host_ui_register(&hooks)) { + std::fprintf(stderr, + "disruptor: settings menu registration failed\n"); + } + } +}; + +MenuRegistration g_registration; + +} // namespace + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/menu.h b/src/game/ui/menu/menu.h new file mode 100644 index 0000000..9263823 --- /dev/null +++ b/src/game/ui/menu/menu.h @@ -0,0 +1,174 @@ +/* + * Host-side in-game settings menu for Disruptor. + * + * This overlay is deliberately outside the emulated PlayStation. It draws + * after the completed host presentation. Normal settings never write guest + * VRAM/RAM; the Cheats tab calls only the narrow, version-pinned Disruptor + * gameplay APIs whose save consequences are stated in the UI. + */ + +#ifndef DISRUPTOR_UI_MENU_H +#define DISRUPTOR_UI_MENU_H + +#include "game/session/cheats.h" +#include "game/render/billboard_aspect.h" +#include "game/render/far_rendering.h" +#include "game/frame/frame_rate.h" +#include "game/session/capsule.h" +#include "game/session/intro_skip.h" +#include "game/input/mouse_aim.h" +#include "game/frame/present_rate.h" +#include "game/session/restart.h" +#include "game/settings/options.h" +#include "game/ui/hint.h" +#include "config_loader.h" +#include "gpu.h" +#include "host_ui.h" +#include "psx_keybinds.h" +#include "psx_netplay.h" + +#include "imgui.h" +#include "imgui_impl_opengl3.h" +#if defined(PSX_SDL3) +#include "imgui_impl_sdl3.h" +#else +#include "imgui_impl_sdl2.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void gte_ws_set_far_threshold(int threshold); +extern "C" void gte_ws_set_backdrop_repair_enabled(int enabled); +extern "C" void gl_renderer_set_world_depth(int enabled); +extern "C" int gl_renderer_world_depth(void); +extern "C" void gl_renderer_set_full_yaw(int enabled); +extern "C" void gl_renderer_set_mesh_view(int enabled); +extern "C" int gl_renderer_mesh_view(void); +extern "C" int gl_renderer_record_shown(const char *folder, int skip, int count, int width); +extern "C" int gl_renderer_recording_shown(void); +extern "C" void gl_renderer_set_gap_fill(int enabled); +extern "C" int gl_renderer_gap_fill(void); +extern "C" void gl_renderer_set_ssao(int enabled); +extern "C" void gl_renderer_set_ssao_view(int view); +extern "C" int gl_renderer_ssao_view(void); +extern "C" int gl_renderer_ssao(void); +extern "C" void gl_renderer_set_ssao_shape(float radius, float strength); +extern "C" float gl_renderer_ssao_radius(void); +extern "C" float gl_renderer_ssao_strength(void); + +namespace disruptor::menu { + +struct DevMenuState { + SDL_Window *window = nullptr; + int backend = PSX_HOST_UI_BACKEND_NONE; + bool imgui_ready = false; + bool open = false; + bool restore_mouse_capture = false; + bool aspect_apply_failed = false; + bool internal_scale_apply_failed = false; + bool fullscreen_apply_failed = false; + bool base_keybinds_valid = false; + bool record_shown = false; // armed in the menu, started when it closes + std::string shown_note; + int selected_wide_aspect = 0; + std::string cheat_status; + std::array base_keybinds{}; +}; + +extern DevMenuState g_menu; + +namespace fs = std::filesystem; +using disruptor::settings::find_option; +using disruptor::settings::Option; +using disruptor::settings::Unit; + +struct PreferenceState { + fs::path path; + PSXRecompV4::UserSettings pending; + std::set changed; + bool language_disc_changed = false; + bool diagnostic_view_changed = false; + bool loaded = false; + bool save_failed = false; + std::string status; + + bool dirty() const { + return !changed.empty() || language_disc_changed || + diagnostic_view_changed; + } + void clean() { + changed.clear(); + language_disc_changed = diagnostic_view_changed = false; + } +}; + +extern PreferenceState g_preferences; +extern std::string g_language_disc; + +std::string utf8(const fs::path &path); +void apply_modern_keybinds(bool enabled); +void set_modern_controls_live(bool enabled); +void mark(const char *key, int value); +void mark_mouse_aim(bool value); +void mark_modern_controls(bool value); +void mark_sensitivity(double value); +void mark_invert_x(bool value); +void mark_vertical_look(bool value); +void mark_vertical_sensitivity(double value); +void mark_invert_y(bool value); +void mark_geometry(bool value); +void mark_textures(bool value); +void mark_vsync(int value); +void mark_aspect(int numerator, int denominator, bool adaptive); +void mark_supersampling(int value); +void mark_fullscreen(int value); +void mark_master_volume(int value); +void mark_audio_muted(bool value); +void mark_skip_intro(bool value); +void mark_language_disc(const std::string &path); +void mark_hud_scale(int value); +void mark_frame_unlock(bool value); +void mark_shadow_shape(bool improved); +void mark_occlusion(bool value); +void mark_occlusion_shape(int reach, int strength); +void mark_gap_filling(bool value); +void mark_world_depth(bool value); +void apply_diagnostic_view(int view); +void mark_diagnostic_view(int value); +void apply_smooth_turn(bool enabled); +void mark_smooth_turn(bool value); +void mark_present_rate(int value); +void mark_present_enabled(bool value); +bool flush_preferences(); +void load_preferences_for_session(); + +bool applies(const char *key); +bool option_toggle(const char *key, bool *value); +bool option_slider(const char *key, int *value); +bool option_slider(const char *key, float *value); +bool option_list(const char *key, int *index, size_t skipped = 0); +void draw_status_badge(const char *label, const ImVec4 &colour); + +void draw_controls_tab(); +void draw_enhancements_tab(); +void draw_experimental_tab(); +void draw_debug_tab(); +void draw_cheats_tab(); +void draw_system_tab(); +void draw_language_tab(); + +void set_menu_open(bool open); + +} // namespace disruptor::menu + +#endif diff --git a/src/game/ui/menu/preferences.cpp b/src/game/ui/menu/preferences.cpp new file mode 100644 index 0000000..912e453 --- /dev/null +++ b/src/game/ui/menu/preferences.cpp @@ -0,0 +1,456 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +PreferenceState g_preferences; + +std::string g_language_disc; + +std::string utf8(const fs::path &path) { + const std::u8string text = path.u8string(); + return {text.begin(), text.end()}; +} + +namespace { + +bool env_override_present(const char *name) { + return name && std::getenv(name) != nullptr; +} + +static constexpr std::array kModernKeybinds = { + SDL_SCANCODE_W, SDL_SCANCODE_S, + SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + SDL_SCANCODE_RETURN, SDL_SCANCODE_SPACE, + SDL_SCANCODE_F, SDL_SCANCODE_E, + SDL_SCANCODE_Q, SDL_SCANCODE_R, + SDL_SCANCODE_A, SDL_SCANCODE_D, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_P, SDL_SCANCODE_TAB, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, +}; + +static constexpr std::array kOriginalKeybinds = { + SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, + SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + SDL_SCANCODE_X, SDL_SCANCODE_S, + SDL_SCANCODE_Z, SDL_SCANCODE_A, + SDL_SCANCODE_Q, SDL_SCANCODE_W, + SDL_SCANCODE_E, SDL_SCANCODE_R, + SDL_SCANCODE_T, SDL_SCANCODE_Y, + SDL_SCANCODE_RETURN, SDL_SCANCODE_RSHIFT, + SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, + SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, + SDL_SCANCODE_UNKNOWN, SDL_SCANCODE_UNKNOWN, +}; + +void capture_base_keybinds() { + if (g_menu.base_keybinds_valid) return; + bool is_packaged_modern_preset = true; + for (int i = 0; i < PSX_KB_COUNT; ++i) { + g_menu.base_keybinds[static_cast(i)] = + psx_keybinds_get_button(1, i); + if (g_menu.base_keybinds[static_cast(i)] != + kModernKeybinds[static_cast(i)]) + is_packaged_modern_preset = false; + } + /* run.ps1/run.sh install the packaged modern map for an explicit launch + * flag. Treat that exact map as an overlay, not the restore baseline, so + * turning Modern controls off live really returns to the packaged classic + * mapping. Any custom/non-exact map is preserved verbatim instead. */ + if (is_packaged_modern_preset) + g_menu.base_keybinds = kOriginalKeybinds; + g_menu.base_keybinds_valid = true; +} + +} // namespace + +void apply_modern_keybinds(bool enabled) { + capture_base_keybinds(); + if (!enabled) { + for (int i = 0; i < PSX_KB_COUNT; ++i) + psx_keybinds_set_button( + 1, i, g_menu.base_keybinds[static_cast(i)]); + return; + } + + for (int i = 0; i < PSX_KB_COUNT; ++i) + psx_keybinds_set_button( + 1, i, kModernKeybinds[static_cast(i)]); +} + +void set_modern_controls_live(bool enabled) { + apply_modern_keybinds(enabled); + disruptor_modern_controls_set_enabled(enabled ? 1 : 0); +} + +void mark(const char *key, int value) { + const Option *option = find_option(key); + option->set(g_preferences.pending, value); + g_preferences.changed.insert(option); +} + +namespace { + +bool changed(const char *key) { + return g_preferences.changed.contains(find_option(key)); +} + +} // namespace + +void mark_mouse_aim(bool value) { mark("disruptor.mouse_aim", value); } + +void mark_modern_controls(bool value) { mark("disruptor.modern_controls", value); } + +void mark_sensitivity(double value) { + g_preferences.pending.has_horizontal_sensitivity = true; + g_preferences.pending.horizontal_sensitivity = value; + g_preferences.changed.insert( + find_option("disruptor.horizontal_sensitivity")); +} + +void mark_invert_x(bool value) { mark("disruptor.invert_horizontal", value); } + +void mark_vertical_look(bool value) { mark("disruptor.vertical_look", value); } + +void mark_vertical_sensitivity(double value) { + g_preferences.pending.has_vertical_sensitivity = true; + g_preferences.pending.vertical_sensitivity = value; + g_preferences.changed.insert( + find_option("disruptor.vertical_sensitivity")); +} + +void mark_invert_y(bool value) { mark("disruptor.invert_vertical", value); } + +void mark_geometry(bool value) { mark("disruptor.geometry_correction", value); } + +void mark_textures(bool value) { mark("disruptor.perspective_textures", value); } + +void mark_vsync(int value) { mark("video.vsync", value + 1); } + +void mark_aspect(int numerator, int denominator, bool adaptive) { + g_preferences.pending.has_aspect_ratio = true; + g_preferences.pending.aspect_num = numerator; + g_preferences.pending.aspect_den = denominator; + /* Keep the adaptive flag and its fixed 32:9 cap in the same atomic + * settings update as the fallback/initial aspect. */ + g_preferences.pending.has_adaptive_view = true; + g_preferences.pending.adaptive_view = adaptive; + g_preferences.changed.insert(find_option("video.aspect_ratio")); +} + +void mark_supersampling(int value) { mark("video.supersampling", value - 1); } + +void mark_fullscreen(int value) { mark("video.fullscreen", value); } + +void mark_master_volume(int value) { mark("audio.master_volume", value); } + +void mark_audio_muted(bool value) { mark("audio.muted", value); } + +void mark_skip_intro(bool value) { mark("disruptor.skip_intro", value); } + +void mark_language_disc(const std::string &path) { + g_language_disc = path; + g_preferences.pending.has_language_disc = true; + g_preferences.pending.language_disc = path; + g_preferences.language_disc_changed = true; +} + +void mark_hud_scale(int value) { mark("disruptor.hud_scale", value); } + +void mark_frame_unlock(bool value) { mark("disruptor.frame_unlock", value); } + +void mark_shadow_shape(bool improved) { mark("disruptor.improved_shadows", improved); } + +void mark_occlusion(bool value) { mark("disruptor.ambient_occlusion", value); } + +void mark_occlusion_shape(int reach, int strength) { + mark("disruptor.occlusion_reach", reach); + mark("disruptor.occlusion_strength", strength); +} + +void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } + +void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); } + +// Views 1 to 4 are layers of the occlusion pass, 5 is the mesh drawn over in-between frames. +void apply_diagnostic_view(int view) { + gl_renderer_set_ssao_view(view >= 1 && view <= 4 ? view : 0); + gl_renderer_set_mesh_view(view == 5 ? 1 : 0); +} + +void mark_diagnostic_view(int value) { + g_preferences.pending.has_diagnostic_view = true; + g_preferences.pending.diagnostic_view = value; + g_preferences.diagnostic_view_changed = true; +} + +// With the vertical divided too a renderer turn of one step misses the game's next step in a median of 8604 pixels of 307200, without in 24274. +void apply_smooth_turn(bool enabled) { + disruptor_high_precision_camera_set_enabled(enabled ? 1 : 0); + if (!env_override_present("PSX_GEOMETRY_FULL_YAW")) gl_renderer_set_full_yaw(enabled ? 1 : 0); +} + +void mark_smooth_turn(bool value) { mark("disruptor.high_precision_camera", value); } + +void mark_present_rate(int value) { mark("video.frame_interpolation_fps", value); } + +void mark_present_enabled(bool value) { mark("video.frame_interpolation", value); } + +namespace { + +void merge_dirty_preferences(PSXRecompV4::UserSettings &settings) { + const auto &pending = g_preferences.pending; + for (const Option *option : g_preferences.changed) + option->set(settings, option->get(pending)); + if (g_preferences.language_disc_changed) { + settings.has_language_disc = true; + settings.language_disc = pending.language_disc; + } + if (g_preferences.diagnostic_view_changed) { + settings.has_diagnostic_view = true; + settings.diagnostic_view = pending.diagnostic_view; + } +} + +} // namespace + +bool flush_preferences() { + if (!g_preferences.dirty()) return true; + if (g_preferences.path.empty()) { + g_preferences.save_failed = true; + g_preferences.status = "Runtime settings path is unavailable."; + return false; + } + + PSXRecompV4::UserSettings settings = + PSXRecompV4::load_user_settings(g_preferences.path); + if (settings.parse_error) { + g_preferences.save_failed = true; + g_preferences.status = + "settings.toml is invalid; it was left untouched."; + return false; + } + merge_dirty_preferences(settings); + if (!PSXRecompV4::save_user_settings(g_preferences.path, settings)) { + g_preferences.save_failed = true; + g_preferences.status = + "Could not save settings; the previous file is intact."; + return false; + } + + g_preferences.clean(); + g_preferences.save_failed = false; + g_preferences.status = "Settings saved."; + std::fprintf(stdout, "disruptor: saved in-game settings to %s\n", + utf8(g_preferences.path).c_str()); + return true; +} + +namespace { + +void apply_saved_preferences(const PSXRecompV4::UserSettings &settings) { + /* Force the legacy INI + environment fallback to load before the saved + * layer is applied. Presence (including an explicit zero) makes an + * environment value authoritative for this launch. */ + (void)disruptor_mouse_horizontal_sensitivity(); + (void)disruptor_mouse_invert_horizontal(); + + if (settings.has_mouse_aim && + !env_override_present("PSX_DISRUPTOR_MOUSE_AIM")) + disruptor_mouse_aim_set_enabled(settings.mouse_aim ? 1 : 0); + if (settings.has_modern_controls && + !env_override_present("PSX_DISRUPTOR_MODERN_CONTROLS")) + set_modern_controls_live(settings.modern_controls); + if (settings.has_horizontal_sensitivity && + !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY")) + (void)disruptor_mouse_set_horizontal_sensitivity( + settings.horizontal_sensitivity); + if (settings.has_invert_horizontal && + !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_X")) + disruptor_mouse_set_invert_horizontal( + settings.invert_horizontal ? 1 : 0); + if (settings.has_vertical_look && + !env_override_present("PSX_DISRUPTOR_VERTICAL_LOOK")) + disruptor_mouse_set_vertical_look_enabled( + settings.vertical_look ? 1 : 0); + if (settings.has_vertical_sensitivity && + !env_override_present("PSX_DISRUPTOR_MOUSE_SENSITIVITY_Y")) + (void)disruptor_mouse_set_vertical_sensitivity( + settings.vertical_sensitivity); + if (settings.has_invert_vertical && + !env_override_present("PSX_DISRUPTOR_MOUSE_INVERT_Y")) + disruptor_mouse_set_invert_vertical( + settings.invert_vertical ? 1 : 0); + + /* An inherited explicit environment override can enable Modern controls + * without a run-script switch (and therefore without its preset copy). + * Always reconcile the live mode with the in-memory keyboard overlay so + * S/A do not retain conflicting classic face-button bindings. */ + apply_modern_keybinds(disruptor_modern_controls_enabled() != 0); + + /* main.cpp applies these fields before the environment layer. Repeat the + * live application here so a host-UI soft session also gets the saved + * state, while still respecting explicit per-launch overrides. */ + if (settings.has_geometry_correction && + !env_override_present("PSX_GEOMETRY_CORRECTION")) + gpu_geometry_correction_set(settings.geometry_correction ? 1 : 0); + if (settings.has_perspective_textures && + !env_override_present("PSX_TEXTURE_CORRECTION")) { + gpu_texture_correction_set( + settings.perspective_textures && + gpu_geometry_correction_enabled() + ? 1 : 0); + } + if (settings.has_supersampling) + (void)psx_host_video_set_internal_scale(settings.supersampling); + if (settings.has_fullscreen) + g_menu.fullscreen_apply_failed = + psx_host_video_set_fullscreen_mode(settings.fullscreen) == 0; + if (settings.has_master_volume) + (void)psx_host_audio_set_master_volume(settings.master_volume); + if (settings.has_audio_muted) + (void)psx_host_audio_set_muted(settings.audio_muted ? 1 : 0); + if (settings.has_hud_scale) gpu_ws_set_hud_scale(settings.hud_scale); + if (settings.has_frame_unlock && + !env_override_present("PSX_DISRUPTOR_FRAME_UNLOCK")) + disruptor_frame_rate_set_unlocked(settings.frame_unlock ? 1 : 0); + if (settings.has_skip_intro && + !env_override_present("PSX_DISRUPTOR_SKIP_INTRO")) + disruptor_intro_skip_set_enabled(settings.skip_intro ? 1 : 0); + if (settings.has_language_disc) g_language_disc = settings.language_disc; + if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS")) + gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0); + if (!env_override_present("PSX_GL_SSAO")) { + if (settings.has_ambient_occlusion) gl_renderer_set_ssao(settings.ambient_occlusion ? 1 : 0); + /* A layer is a session's own. Only a capsule's replay starts in the one its recording was made in. */ + apply_diagnostic_view(settings.has_diagnostic_view && env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY") + ? settings.diagnostic_view : 0); + } + gl_renderer_set_ssao_shape( + settings.has_occlusion_reach && !env_override_present("PSX_GL_SSAO_RADIUS") + ? static_cast(settings.occlusion_reach) : -1.0f, + settings.has_occlusion_strength && !env_override_present("PSX_GL_SSAO_STRENGTH") + ? settings.occlusion_strength / 100.0f : -1.0f); + if (settings.has_gap_filling && !env_override_present("PSX_GL_CRACK_FILL") && !env_override_present("PSX_GL_GROWN_UNDER")) + gl_renderer_set_gap_fill(settings.gap_filling ? 1 : 0); + if (settings.has_depth_sorted_world && !env_override_present("PSX_GL_WORLD_DEPTH")) + gl_renderer_set_world_depth(settings.depth_sorted_world ? 1 : 0); + // The session's start switched it off after the mouse module read its variable, so the variable is applied here too. + if (const char *forced = std::getenv("PSX_DISRUPTOR_HIGH_PRECISION_CAMERA")) + apply_smooth_turn(forced[0] != '\0' && forced[0] != '0'); + else if (settings.has_high_precision_camera) + apply_smooth_turn(settings.high_precision_camera); +} + +void apply_pending_preferences() { + const auto &pending = g_preferences.pending; + if (changed("disruptor.mouse_aim")) + disruptor_mouse_aim_set_enabled(pending.mouse_aim ? 1 : 0); + if (changed("disruptor.modern_controls")) + set_modern_controls_live(pending.modern_controls); + if (changed("disruptor.horizontal_sensitivity")) + (void)disruptor_mouse_set_horizontal_sensitivity( + pending.horizontal_sensitivity); + if (changed("disruptor.invert_horizontal")) + disruptor_mouse_set_invert_horizontal( + pending.invert_horizontal ? 1 : 0); + if (changed("disruptor.vertical_look")) + disruptor_mouse_set_vertical_look_enabled( + pending.vertical_look ? 1 : 0); + if (changed("disruptor.vertical_sensitivity")) + (void)disruptor_mouse_set_vertical_sensitivity( + pending.vertical_sensitivity); + if (changed("disruptor.invert_vertical")) + disruptor_mouse_set_invert_vertical( + pending.invert_vertical ? 1 : 0); + if (changed("disruptor.geometry_correction")) + gpu_geometry_correction_set(pending.geometry_correction ? 1 : 0); + if (changed("disruptor.perspective_textures")) + gpu_texture_correction_set( + pending.perspective_textures && + gpu_geometry_correction_enabled() + ? 1 : 0); + if (changed("video.vsync")) + (void)psx_host_video_set_vsync(pending.vsync); + if (changed("video.aspect_ratio")) { + if (pending.adaptive_view) + (void)psx_host_video_set_adaptive_view(1); + else + (void)psx_host_video_set_display_aspect( + pending.aspect_num, pending.aspect_den); + } + if (changed("video.supersampling")) + (void)psx_host_video_set_internal_scale(pending.supersampling); + if (changed("video.fullscreen")) + g_menu.fullscreen_apply_failed = + psx_host_video_set_fullscreen_mode(pending.fullscreen) == 0; + if (changed("audio.master_volume")) + (void)psx_host_audio_set_master_volume(pending.master_volume); + if (changed("audio.muted")) + (void)psx_host_audio_set_muted(pending.audio_muted ? 1 : 0); + if (changed("disruptor.hud_scale")) + gpu_ws_set_hud_scale(pending.hud_scale); + if (changed("disruptor.frame_unlock")) + disruptor_frame_rate_set_unlocked(pending.frame_unlock ? 1 : 0); + if (changed("disruptor.skip_intro")) + disruptor_intro_skip_set_enabled(pending.skip_intro ? 1 : 0); + if (changed("disruptor.improved_shadows")) + gpu_set_shadow_shape(pending.improved_shadows ? 1 : 0); + if (changed("disruptor.ambient_occlusion")) + gl_renderer_set_ssao(pending.ambient_occlusion ? 1 : 0); + if (changed("disruptor.occlusion_reach") || + changed("disruptor.occlusion_strength")) + gl_renderer_set_ssao_shape(static_cast(pending.occlusion_reach), pending.occlusion_strength / 100.0f); + if (changed("disruptor.gap_filling")) + gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); + if (changed("disruptor.depth_sorted_world")) + gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); + if (g_preferences.diagnostic_view_changed) + apply_diagnostic_view(pending.diagnostic_view); + if (changed("disruptor.high_precision_camera")) + apply_smooth_turn(pending.high_precision_camera); +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (changed("video.frame_interpolation")) + (void)disruptor_present_rate_enable(pending.frame_interpolation ? 1 : 0); + if (changed("video.frame_interpolation_fps")) + (void)disruptor_present_rate_apply(pending.frame_interpolation_fps); +#endif +} + +} // namespace + +void load_preferences_for_session() { + const char *path = psx_host_user_settings_path(); + if (!path || !path[0]) { + g_preferences.loaded = false; + g_preferences.status = "Runtime settings path is unavailable."; + return; + } + const std::string_view text(path); + g_preferences.path = fs::path(std::u8string(text.begin(), text.end())); + const PSXRecompV4::UserSettings settings = + PSXRecompV4::load_user_settings(g_preferences.path); + if (settings.parse_error) { + g_preferences.loaded = false; + g_preferences.status = + "settings.toml is invalid; saved menu values were not applied."; + return; + } + capture_base_keybinds(); + apply_saved_preferences(settings); + apply_pending_preferences(); + /* A capsule recorded later copies the file: a layer left there by the last session must not go into it. */ + if (settings.has_diagnostic_view && settings.diagnostic_view != 0 && !env_override_present("PSX_DISRUPTOR_CAPSULE_REPLAY")) { + mark_diagnostic_view(0); + (void)flush_preferences(); + } + g_preferences.loaded = true; + if (!g_preferences.save_failed) + g_preferences.status = "Settings loaded."; +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/rows.cpp b/src/game/ui/menu/rows.cpp new file mode 100644 index 0000000..146d831 --- /dev/null +++ b/src/game/ui/menu/rows.cpp @@ -0,0 +1,62 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +bool prerequisite_on(const Option &option) { + const std::string_view key(option.key); + if (key == "disruptor.geometry_correction") + return gpu_geometry_correction_enabled() != 0; + if (key == "disruptor.mouse_aim") return disruptor_mouse_aim_enabled() != 0; + if (key == "disruptor.ambient_occlusion") return gl_renderer_ssao() != 0; + return false; +} + +} // namespace + +bool applies(const char *key) { + return disruptor::settings::applies(*find_option(key), prerequisite_on); +} + +bool option_toggle(const char *key, bool *value) { + const Option &option = *find_option(key); + const bool moved = ImGui::Checkbox(option.label, value); + disruptor::ui::hint(option.hint); + return moved; +} + +bool option_slider(const char *key, int *value) { + const Option &option = *find_option(key); + const char *format = option.unit == Unit::percent ? "%d%%" : "%d"; + const bool moved = + ImGui::SliderInt(option.label, value, option.lowest, option.highest, + format, ImGuiSliderFlags_AlwaysClamp); + disruptor::ui::hint(option.hint); + return moved; +} + +bool option_slider(const char *key, float *value) { + const Option &option = *find_option(key); + const bool moved = ImGui::SliderFloat( + option.label, value, option.lowest / 1000.0f, + option.highest / 1000.0f, "%.3f", ImGuiSliderFlags_Logarithmic); + disruptor::ui::hint(option.hint); + return moved; +} + +bool option_list(const char *key, int *index, size_t skipped) { + const Option &option = *find_option(key); + const auto shown = option.choices.subspan(skipped); + const bool moved = ImGui::Combo(option.label, index, shown.data(), + static_cast(shown.size())); + disruptor::ui::hint(option.hint); + return moved; +} + +void draw_status_badge(const char *label, const ImVec4 &colour) { + ImGui::SameLine(); + ImGui::TextColored(colour, "[%s]", label); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_cheats.cpp b/src/game/ui/menu/tab_cheats.cpp new file mode 100644 index 0000000..cbaf04d --- /dev/null +++ b/src/game/ui/menu/tab_cheats.cpp @@ -0,0 +1,92 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +const char *cheat_result_message(int result, const char *success) { + switch (result) { + case DISRUPTOR_CHEAT_OK: + return success; + case DISRUPTOR_CHEAT_GAME_NOT_READY: + return "Enter live gameplay before using this cheat."; + case DISRUPTOR_CHEAT_NETPLAY_BLOCKED: + return "Cheats are disabled during netplay."; + case DISRUPTOR_CHEAT_UNVERIFIED_STATE: + return "The game state did not match the verified USA executable; no changes were made."; + default: + return "The cheat request was rejected."; + } +} + +} // namespace + +void draw_cheats_tab() { + const bool netplay = disruptor_cheats_netplay_blocked() != 0; + const bool gameplay_ready = disruptor_cheats_gameplay_ready() != 0; + + ImGui::TextWrapped( + "These are version-pinned gameplay cheats for testing and casual " + "play. They are never saved as port settings and start off on every " + "launch."); + if (netplay) { + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "Cheats are unavailable while netplay is active."); + } else if (!gameplay_ready) { + ImGui::TextDisabled("Enter live gameplay to enable cheats."); + } + + ImGui::SeparatorText("Player"); + bool god_mode = disruptor_cheats_god_mode_enabled() != 0; + const bool disable_god_control = !god_mode && (netplay || !gameplay_ready); + if (disable_god_control) ImGui::BeginDisabled(); + if (ImGui::Checkbox("God mode", &god_mode)) { + const int result = disruptor_cheats_set_god_mode(god_mode ? 1 : 0); + g_menu.cheat_status = cheat_result_message( + result, god_mode ? "God mode enabled for this session." + : "God mode disabled."); + } + if (disable_god_control) ImGui::EndDisabled(); + draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + ImGui::TextDisabled( + "Neutralises player damage at the game's central damage routine. " + "A light hit cue remains, but saved health is not altered and the " + "game is not marked as cheated."); + + ImGui::SeparatorText("Inventory"); + if (netplay || !gameplay_ready) ImGui::BeginDisabled(); + if (ImGui::Button("Grant all weapons + psionics")) + ImGui::OpenPopup("Confirm All Weapons"); + if (netplay || !gameplay_ready) ImGui::EndDisabled(); + ImGui::TextWrapped( + "This reproduces the retail All Weapons cheat: all weapons and " + "psionics are unlocked, ammunition and psionic energy are refilled, " + "and the current game is marked as cheated."); + ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), + "Important: the cheated marker affects the ending and is carried into " + "memory-card/password saves."); + + bool popup_open = true; + if (ImGui::BeginPopupModal("Confirm All Weapons", &popup_open, + ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped( + "Grant every weapon and psionic, refill their resources, and " + "mark this game (including later saves) as cheated?"); + if (ImGui::Button("Grant and mark cheated")) { + const int result = disruptor_cheats_grant_all_weapons(); + g_menu.cheat_status = cheat_result_message( + result, "All weapons, psionics and resources granted. Game marked as cheated."); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + + if (!g_menu.cheat_status.empty()) { + ImGui::Separator(); + ImGui::TextWrapped("%s", g_menu.cheat_status.c_str()); + } +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_controls.cpp b/src/game/ui/menu/tab_controls.cpp new file mode 100644 index 0000000..6525e9c --- /dev/null +++ b/src/game/ui/menu/tab_controls.cpp @@ -0,0 +1,84 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_controls_tab() { + bool mouse_aim = disruptor_mouse_aim_enabled() != 0; + if (option_toggle("disruptor.mouse_aim", &mouse_aim)) { + disruptor_mouse_aim_set_enabled(mouse_aim ? 1 : 0); + mark_mouse_aim(mouse_aim); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + bool modern = disruptor_modern_controls_enabled() != 0; + if (option_toggle("disruptor.modern_controls", &modern)) { + set_modern_controls_live(modern); + mark_modern_controls(modern); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + float sensitivity = + static_cast(disruptor_mouse_horizontal_sensitivity()); + if (option_slider("disruptor.horizontal_sensitivity", &sensitivity)) { + const double applied = + disruptor_mouse_set_horizontal_sensitivity(sensitivity); + mark_sensitivity(applied); + } + + bool invert_x = disruptor_mouse_invert_horizontal() != 0; + if (option_toggle("disruptor.invert_horizontal", &invert_x)) { + disruptor_mouse_set_invert_horizontal(invert_x ? 1 : 0); + mark_invert_x(invert_x); + } + + const bool turn_applies = applies("disruptor.high_precision_camera"); + bool smooth = disruptor_high_precision_camera_enabled() != 0; + if (!turn_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.high_precision_camera", &smooth)) { + apply_smooth_turn(smooth); + mark_smooth_turn(smooth); + } + if (!turn_applies) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + ImGui::SeparatorText("Vertical look"); + bool vertical_look = disruptor_mouse_vertical_look_enabled() != 0; + if (option_toggle("disruptor.vertical_look", &vertical_look)) { + disruptor_mouse_set_vertical_look_enabled(vertical_look ? 1 : 0); + mark_vertical_look(vertical_look); + } + draw_status_badge("EXPERIMENTAL", ImVec4(1.0f, 0.73f, 0.25f, 1.0f)); + + float vertical_sensitivity = + static_cast(disruptor_mouse_vertical_sensitivity()); + if (option_slider("disruptor.vertical_sensitivity", &vertical_sensitivity)) { + const double applied = + disruptor_mouse_set_vertical_sensitivity(vertical_sensitivity); + mark_vertical_sensitivity(applied); + } + + bool invert_y = disruptor_mouse_invert_vertical() != 0; + if (option_toggle("disruptor.invert_vertical", &invert_y)) { + disruptor_mouse_set_invert_vertical(invert_y ? 1 : 0); + mark_invert_y(invert_y); + } + + const double pitch_units = disruptor_mouse_vertical_pitch(); + if (pitch_units == 0.0) ImGui::BeginDisabled(); + if (ImGui::Button("Recenter vertical view")) + disruptor_mouse_recenter_vertical(); + if (pitch_units == 0.0) ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::Text("Requested pitch: %+.2f deg", pitch_units * (360.0 / 256.0)); + ImGui::TextDisabled( + "Range: +/-30.94 degrees. The original game has no pitch state."); + ImGui::Separator(); + ImGui::TextWrapped( + "The menu releases relative mouse capture while open and restores the " + "previous state when closed. Gameplay input is neutralised while you " + "interact with the menu."); + ImGui::Text("Capture before opening: %s", + g_menu.restore_mouse_capture ? "yes" : "no"); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_debug.cpp b/src/game/ui/menu/tab_debug.cpp new file mode 100644 index 0000000..e11febc --- /dev/null +++ b/src/game/ui/menu/tab_debug.cpp @@ -0,0 +1,31 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_debug_tab() { + ImGui::TextDisabled("Tools for a report of a fault in the picture."); + ImGui::Spacing(); + + if (ImGui::Button("Record the screen") && !gl_renderer_recording_shown()) g_menu.record_shown = true; + draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + ImGui::TextDisabled( + "For a report: two seconds of the pictures that reach the screen, with " + "their times. It starts a second after this menu closes."); + if (g_menu.record_shown) ImGui::TextDisabled("Armed. Close the menu and do what you want to show."); + else if (!g_menu.shown_note.empty()) ImGui::TextDisabled("%s", g_menu.shown_note.c_str()); + + ImGui::Separator(); + int view = gl_renderer_mesh_view() ? 5 : gl_renderer_ssao_view(); + ImGui::SetNextItemWidth(260.0f); + if (ImGui::Combo("Diagnostic view", &view, "Off\0Shading alone\0Depth\0Surface normals\0Fog fade\0Mesh\0")) { + apply_diagnostic_view(view); + mark_diagnostic_view(view); + } + draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + ImGui::TextDisabled( + "Shows a layer of the renderer in the picture's place, for reports. A " + "replay capsule recorded with one on replays in it. Off again at the " + "next start."); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_enhancements.cpp b/src/game/ui/menu/tab_enhancements.cpp new file mode 100644 index 0000000..aa194da --- /dev/null +++ b/src/game/ui/menu/tab_enhancements.cpp @@ -0,0 +1,236 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +void apply_geometry_enabled(bool enabled) { + if (!enabled && gpu_texture_correction_enabled()) { + gpu_texture_correction_set(0); + mark_textures(false); + } +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + if (!enabled) { + (void)disruptor_present_rate_enable(0); + mark_present_enabled(false); + } +#endif + gpu_geometry_correction_set(enabled ? 1 : 0); + mark_geometry(enabled); +} + +void draw_aspect_controls() { + static constexpr int kAspectNumerators[] = {16, 21, 32, 32}; + static constexpr int kAspectDenominators[] = {9, 9, 9, 9}; + static constexpr int kFixedWideAspectCount = 3; + static constexpr int kAdaptiveAspectIndex = 3; + static constexpr int kAspectCount = 4; + + int numerator = 4; + int denominator = 3; + psx_host_video_get_display_aspect(&numerator, &denominator); + const bool adaptive = psx_host_video_get_adaptive_view() != 0; + bool widescreen = adaptive || numerator * 3 != denominator * 4; + if (adaptive) { + g_menu.selected_wide_aspect = kAdaptiveAspectIndex; + } else if (widescreen) { + for (int index = 0; index < kFixedWideAspectCount; ++index) { + if (numerator * kAspectDenominators[index] == + denominator * kAspectNumerators[index]) { + g_menu.selected_wide_aspect = index; + break; + } + } + } + g_menu.selected_wide_aspect = std::clamp( + g_menu.selected_wide_aspect, 0, kAspectCount - 1); + + ImGui::SeparatorText("Display aspect"); + if (ImGui::Checkbox("Widescreen", &widescreen)) { + if (widescreen && + g_menu.selected_wide_aspect == kAdaptiveAspectIndex) { + g_menu.aspect_apply_failed = + psx_host_video_set_adaptive_view(1) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(32, 9, true); + } else { + const int requested_num = widescreen + ? kAspectNumerators[g_menu.selected_wide_aspect] : 4; + const int requested_den = widescreen + ? kAspectDenominators[g_menu.selected_wide_aspect] : 3; + g_menu.aspect_apply_failed = + psx_host_video_set_display_aspect( + requested_num, requested_den) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(requested_num, requested_den, false); + } + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + int selected = g_menu.selected_wide_aspect; + if (option_list("video.aspect_ratio", &selected, 1)) { + g_menu.selected_wide_aspect = selected; + if (widescreen) { + if (selected == kAdaptiveAspectIndex) { + g_menu.aspect_apply_failed = + psx_host_video_set_adaptive_view(1) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(32, 9, true); + } else { + const int requested_num = kAspectNumerators[selected]; + const int requested_den = kAspectDenominators[selected]; + g_menu.aspect_apply_failed = + psx_host_video_set_display_aspect( + requested_num, requested_den) == 0; + if (!g_menu.aspect_apply_failed) + mark_aspect(requested_num, requested_den, false); + } + } + } + if (g_menu.aspect_apply_failed) { + ImGui::TextColored( + ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "The runtime rejected the requested display aspect."); + } + ImGui::TextDisabled( + "4:3 disables widescreen. Fixed 16:9, 21:9 and 32:9 choices or " + "live window matching (capped at 32:9) apply after the current " + "frame and are saved."); + + if (gpu_sprite_placement_available()) { + int shadow_shape = gpu_shadow_shape(); + ImGui::SetNextItemWidth(260.0f); + if (option_list("disruptor.improved_shadows", &shadow_shape)) { + gpu_set_shadow_shape(shadow_shape); + mark_shadow_shape(shadow_shape != 0); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + } + + int hud_scale = gpu_ws_hud_scale(); + ImGui::SetNextItemWidth(260.0f); + if (option_slider("disruptor.hud_scale", &hud_scale)) { + gpu_ws_set_hud_scale(hud_scale); + mark_hud_scale(hud_scale); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); +} + +void draw_internal_resolution_controls() { + static constexpr int kScaleCount = 8; + int selected = std::clamp( + psx_host_video_get_internal_scale(), 1, kScaleCount) - 1; + + ImGui::SeparatorText("Rendering resolution"); + if (option_list("video.supersampling", &selected)) { + const int requested = selected + 1; + g_menu.internal_scale_apply_failed = + psx_host_video_set_internal_scale(requested) == 0; + if (!g_menu.internal_scale_apply_failed) + mark_supersampling(requested); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + if (g_menu.internal_scale_apply_failed) { + ImGui::TextColored( + ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "The renderer could not allocate that internal resolution."); + } + ImGui::TextDisabled( + "Supersamples geometry and shading before presentation. GPU fill and " + "memory cost grow roughly with the square of this value; changes " + "apply immediately and are saved."); +} + +} // namespace + +void draw_enhancements_tab() { + draw_aspect_controls(); + draw_internal_resolution_controls(); + + bool geometry = gpu_geometry_correction_enabled() != 0; + if (option_toggle("disruptor.geometry_correction", &geometry)) + apply_geometry_enabled(geometry); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + bool textures = gpu_texture_correction_enabled() != 0; + const bool textures_apply = applies("disruptor.perspective_textures"); + if (!textures_apply) ImGui::BeginDisabled(); + if (option_toggle("disruptor.perspective_textures", &textures)) { + gpu_texture_correction_set(textures ? 1 : 0); + mark_textures(textures); + } + if (!textures_apply) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + if (!textures_apply) + ImGui::TextDisabled("Enable exact geometry before perspective textures."); + + bool gaps = gl_renderer_gap_fill() != 0; + const bool gaps_apply = applies("disruptor.gap_filling"); + if (!gaps_apply) ImGui::BeginDisabled(); + if (option_toggle("disruptor.gap_filling", &gaps)) { + gl_renderer_set_gap_fill(gaps ? 1 : 0); + mark_gap_filling(gaps); + } + if (!gaps_apply) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + bool by_depth = gl_renderer_world_depth() != 0; + const bool depth_applies = applies("disruptor.depth_sorted_world"); + if (!depth_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.depth_sorted_world", &by_depth)) { + gl_renderer_set_world_depth(by_depth ? 1 : 0); + mark_world_depth(by_depth); + } + if (!depth_applies) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + ImGui::SeparatorText("Synchronisation"); + int vsync = psx_host_video_get_vsync(); + int vsync_index = std::clamp(vsync + 1, 0, 2); + if (option_list("video.vsync", &vsync_index)) { + const int requested = vsync_index - 1; + if (psx_host_video_set_vsync(requested)) mark_vsync(requested); + } + + bool unlocked = disruptor_frame_rate_unlocked() != 0; + if (option_toggle("disruptor.frame_unlock", &unlocked)) { + disruptor_frame_rate_set_unlocked(unlocked ? 1 : 0); + mark_frame_unlock(unlocked); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + DisruptorFrameRateWindow window{}; + if (disruptor_frame_rate_last_window(&window) && window.vblanks != 0u) { + ImGui::TextDisabled( + "Game frames: %.1f per second. Frame work: %.2f VBlank average, " + "%.2f peak, %u of %u frames over one VBlank.", + 59.94 * window.frames / window.vblanks, + window.average_work_permille / 1000.0, + window.peak_work_permille / 1000.0, + window.late_frames, window.frames); + } + +#ifndef PSX_DISABLE_FRAME_INTERPOLATION + int present_enabled = 0; + if (disruptor_present_rate_switch( + find_option("video.frame_interpolation")->label, + find_option("video.frame_interpolation")->hint, + applies("video.frame_interpolation") ? 1 : 0, + &present_enabled)) + mark_present_enabled(present_enabled != 0); + int present_rate = 0; + if (disruptor_present_rate_control( + find_option("video.frame_interpolation_fps")->label, + find_option("video.frame_interpolation_fps")->hint, &present_rate)) + mark_present_rate(present_rate); +#endif + + ImGui::SeparatorText("Startup"); + bool skip_intro = disruptor_intro_skip_enabled() != 0; + if (option_toggle("disruptor.skip_intro", &skip_intro)) { + disruptor_intro_skip_set_enabled(skip_intro ? 1 : 0); + mark_skip_intro(skip_intro); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_experimental.cpp b/src/game/ui/menu/tab_experimental.cpp new file mode 100644 index 0000000..5a101f0 --- /dev/null +++ b/src/game/ui/menu/tab_experimental.cpp @@ -0,0 +1,37 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_experimental_tab() { + ImGui::TextDisabled( + "Unfinished features of the OpenGL renderer. All of them need geometry " + "correction on."); + ImGui::Spacing(); + + bool occlusion = gl_renderer_ssao() != 0; + const bool occlusion_applies = applies("disruptor.ambient_occlusion"); + if (!occlusion_applies) ImGui::BeginDisabled(); + if (option_toggle("disruptor.ambient_occlusion", &occlusion)) { + gl_renderer_set_ssao(occlusion ? 1 : 0); + mark_occlusion(occlusion); + } + if (!occlusion_applies) ImGui::EndDisabled(); + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + int reach = static_cast(std::lround(gl_renderer_ssao_radius())); + int strength = static_cast(std::lround(gl_renderer_ssao_strength() * 100.0f)); + const bool shape_applies = applies("disruptor.occlusion_reach") && + applies("disruptor.occlusion_strength"); + if (!shape_applies) ImGui::BeginDisabled(); + ImGui::SetNextItemWidth(260.0f); + const bool reach_moved = option_slider("disruptor.occlusion_reach", &reach); + ImGui::SetNextItemWidth(260.0f); + const bool strength_moved = + option_slider("disruptor.occlusion_strength", &strength); + if (!shape_applies) ImGui::EndDisabled(); + if (reach_moved || strength_moved) { + gl_renderer_set_ssao_shape(static_cast(reach), strength / 100.0f); + mark_occlusion_shape(reach, strength); + } +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_language.cpp b/src/game/ui/menu/tab_language.cpp new file mode 100644 index 0000000..5d428bd --- /dev/null +++ b/src/game/ui/menu/tab_language.cpp @@ -0,0 +1,20 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +void draw_language_tab() { + ImGui::TextWrapped("Disc of another region: %s", g_language_disc.empty() ? "none, the game is in English" : g_language_disc.c_str()); + if (ImGui::Button("Choose a disc image")) (void)psx_host_pick_disc_image_begin(); + ImGui::SameLine(); + if (ImGui::Button("English") && !g_language_disc.empty()) mark_language_disc(std::string()); + ImGui::SameLine(); + if (ImGui::Button("Restart now") && !psx_netplay_active() && flush_preferences() && disruptor_restart_arm()) { + SDL_Event quit{}; + quit.type = SDL_QUIT; + SDL_PushEvent(&quit); + } + ImGui::TextDisabled("Movies, speech and words come from that disc at the next start. The French, German and Japanese discs are known."); + ImGui::TextDisabled("The Japanese disc gives movies, speech and hints: its menus are in English on the disc itself."); +} + +} // namespace disruptor::menu diff --git a/src/game/ui/menu/tab_system.cpp b/src/game/ui/menu/tab_system.cpp new file mode 100644 index 0000000..e2f0036 --- /dev/null +++ b/src/game/ui/menu/tab_system.cpp @@ -0,0 +1,87 @@ +#include "game/ui/menu/menu.h" + +namespace disruptor::menu { + +namespace { + +void draw_fullscreen_controls() { + int mode = std::clamp(psx_host_video_get_fullscreen_mode(), 0, 2); + + ImGui::SeparatorText("Window presentation"); + if (option_list("video.fullscreen", &mode)) { + g_menu.fullscreen_apply_failed = + psx_host_video_set_fullscreen_mode(mode) == 0; + if (!g_menu.fullscreen_apply_failed) + mark_fullscreen(mode); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + if (g_menu.fullscreen_apply_failed) { + ImGui::TextColored( + ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "The runtime rejected the requested display mode."); + } + ImGui::TextDisabled( + "Menu selections apply immediately and are saved. Alt+Enter remains " + "a transient window/fullscreen toggle and does not overwrite the " + "saved mode."); +} + +void draw_audio_controls() { + ImGui::SeparatorText("Audio output"); + int volume = psx_host_audio_get_master_volume(); + ImGui::SetNextItemWidth(260.0f); + if (option_slider("audio.master_volume", &volume) && + psx_host_audio_set_master_volume(volume)) { + mark_master_volume(volume); + } + draw_status_badge("LIVE", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); + + bool muted = psx_host_audio_get_muted() != 0; + if (option_toggle("audio.muted", &muted) && + psx_host_audio_set_muted(muted ? 1 : 0)) { + mark_audio_muted(muted); + } + ImGui::TextDisabled( + "Host-side output only: game audio state and saves are unchanged. " + "Unmuting restores the selected volume."); +} + +} // namespace + +void draw_system_tab() { + draw_fullscreen_controls(); + draw_audio_controls(); + ImGui::TextWrapped( + "Live menu choices are merged into the runtime's user-owned " + "settings.toml. Writes use an atomic replacement, so a failed save " + "leaves the previous file intact. Explicit launch flags still win " + "for that run."); + ImGui::SeparatorText("Settings persistence"); + ImGui::TextWrapped("Path: %s", + g_preferences.path.empty() + ? "unavailable" : utf8(g_preferences.path).c_str()); + if (g_preferences.save_failed) + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.25f, 1.0f), + "Not saved"); + else if (g_preferences.dirty()) + ImGui::TextColored(ImVec4(1.0f, 0.73f, 0.25f, 1.0f), + "Unsaved changes"); + else + ImGui::TextColored(ImVec4(0.35f, 0.90f, 0.45f, 1.0f), "Saved"); + if (!g_preferences.status.empty()) + ImGui::TextWrapped("%s", g_preferences.status.c_str()); + if (g_preferences.dirty() && ImGui::Button("Save now")) + (void)flush_preferences(); + ImGui::TextDisabled( + "Current vertical pitch, menu layout, " + "and mouse capture are not saved. Cheat controls start " + "off each launch."); + ImGui::SeparatorText("Restart required"); + ImGui::BulletText("Renderer backend"); + ImGui::BulletText("Audio backend and buffer configuration"); + ImGui::BulletText("BIOS, disc and memory-card wiring"); + ImGui::Separator(); + if (ImGui::Button("Close menu")) set_menu_open(false); +} + +} // namespace disruptor::menu diff --git a/tests/contracts/menu_sources.py b/tests/contracts/menu_sources.py new file mode 100644 index 0000000..85fa1ed --- /dev/null +++ b/tests/contracts/menu_sources.py @@ -0,0 +1,17 @@ +"""The in-game menu's sources as one text: the shared header, then each source in the order the build lists it.""" + +from pathlib import Path + +FOLDER = "src/game/ui/menu" + + +def menu_sources(root: Path) -> list[str]: + """Returns the menu's files as paths from the repository root.""" + build = (root / "CMakeLists.txt").read_text(encoding="utf-8") + listed = [line.strip().strip('")').rsplit("}/", 1)[1] for line in build.splitlines() if f"/{FOLDER}/" in line] + return [f"{FOLDER}/menu.h", *listed] + + +def menu_text(root: Path) -> str: + """Returns the text of all of them, joined.""" + return "".join((root / name).read_text(encoding="utf-8") for name in menu_sources(root)) diff --git a/tests/contracts/test_ambient_occlusion_contract.py b/tests/contracts/test_ambient_occlusion_contract.py index 207666d..226c1a2 100644 --- a/tests/contracts/test_ambient_occlusion_contract.py +++ b/tests/contracts/test_ambient_occlusion_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -22,7 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") STAGED = ( diff --git a/tests/contracts/test_capsule_contract.py b/tests/contracts/test_capsule_contract.py index e80b4fd..9b965cc 100644 --- a/tests/contracts/test_capsule_contract.py +++ b/tests/contracts/test_capsule_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -19,7 +21,7 @@ def require(condition: bool, message: str) -> None: game_side = read("src/game/host.cpp") capsule = read("src/game/session/capsule.cpp") mouse = read("src/game/input/mouse_aim.cpp") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) cmake = read("CMakeLists.txt") require( diff --git a/tests/contracts/test_debug_tab_contract.py b/tests/contracts/test_debug_tab_contract.py index 866815c..4a7863a 100644 --- a/tests/contracts/test_debug_tab_contract.py +++ b/tests/contracts/test_debug_tab_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -22,7 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) loader = read("src/game/settings/saved.cpp") settings = read("src/game/settings/game_user_settings.h") diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index e21ccac..a6d0471 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -6,10 +6,12 @@ import re from pathlib import Path +from menu_sources import menu_sources, menu_text ROOT = Path(__file__).resolve().parents[2] + def read(path: str) -> str: return (ROOT / path).read_text(encoding="utf-8") @@ -58,7 +60,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: main_cpp = read("psxrecomp/runtime/src/main.cpp") gl = read("psxrecomp/runtime/src/gpu_gl_renderer.c") gte = read("psxrecomp/runtime/src/gte.cpp") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) options = read("src/game/settings/options.cpp") STAGED = ( "void mark(const char *key, int value) {\n" @@ -91,9 +93,32 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "5b17c01f69545bde732b14936d89ce0f508adb83e8b56fa82448371845172bc3" in cmake.lower(), "Dear ImGui source must remain versioned and integrity-pinned") -require("src/game/ui/dev_menu.cpp" in cmake and +menu_files = menu_sources(ROOT) +require([name.rsplit("/", 1)[1] for name in menu_files] == [ + "menu.h", "preferences.cpp", "rows.cpp", "tab_controls.cpp", "tab_enhancements.cpp", + "tab_experimental.cpp", "tab_debug.cpp", "tab_cheats.cpp", "tab_system.cpp", "tab_language.cpp", + "menu.cpp"] and + sorted(menu_files) == sorted( + found.relative_to(ROOT).as_posix() for found in (ROOT / "src/game/ui/menu").iterdir()) and "target_link_libraries(psx-runtime PRIVATE disruptor-imgui)" in cmake, - "menu implementation must be linked only into the game runtime") + "every file of the menu's folder must be built, and linked only into the game runtime") +for name in menu_files[1:]: + source = read(name) + require(source.startswith('#include "game/ui/menu/menu.h"\n\nnamespace disruptor::menu {\n') and + source.endswith("} // namespace disruptor::menu\n") and + source.count("namespace disruptor::menu {") == 1 and "#include" not in source[10:], + f"{name} must hold its definitions in the menu's namespace, behind the menu's one header") +for tab in ("controls", "enhancements", "experimental", "debug", "cheats", "system", "language"): + source = read(f"src/game/ui/menu/tab_{tab}.cpp") + require(f"\nvoid draw_{tab}_tab() {{\n" in source and source.count("_tab() {") == 1 and + f"void draw_{tab}_tab();\n" in read("src/game/ui/menu/menu.h") and + f"draw_{tab}_tab();" in function_body(read("src/game/ui/menu/menu.cpp"), "draw_menu"), + f"the {tab} tab is drawn by its own file, and the menu draws it") +require("PreferenceState g_preferences;\n" in read("src/game/ui/menu/preferences.cpp") and + "DevMenuState g_menu;\n" in read("src/game/ui/menu/menu.cpp") and + "MenuRegistration g_registration;\n" in read("src/game/ui/menu/menu.cpp") and + "bool option_toggle(const char *key, bool *value) {\n" in read("src/game/ui/menu/rows.cpp"), + "the staged settings, the menu's own state and its registration, and the rows each have one file") require("src/game/session/cheats.cpp" in cmake and "tests/unit/disruptor_cheats_test.cpp" in cmake, "version-pinned cheats and their behavioral test must be built") @@ -769,7 +794,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: "PreferenceDirty" not in menu and "PREF_" not in menu and ' list(APPEND DISRUPTOR_EXTRA_SOURCES\n' ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/settings/options.cpp"\n' - ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/dev_menu.cpp")\n' in cmake, + ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/ui/menu/preferences.cpp"\n' in cmake, "a staged change must be an option of the shared table, and a save must write and then forget every one") HELPERS = ( @@ -799,7 +824,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: " return moved;\n" "}\n" "\n" - "bool option_list(const char *key, int *index, size_t skipped = 0) {\n" + "bool option_list(const char *key, int *index, size_t skipped) {\n" " const Option &option = *find_option(key);\n" " const auto shown = option.choices.subspan(skipped);\n" " const bool moved = ImGui::Combo(option.label, index, shown.data(),\n" @@ -813,7 +838,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: drawn_keys = set(re.findall(r'\boption_(?:toggle|slider|list)\("([\w.]+)"', menu)) | set( re.findall(r'find_option\("([\w.]+)"\)->label', menu)) present = read("src/game/frame/present_rate.cpp") -require(HELPERS in menu and len(shared_labels) == 27 and drawn_keys == shared_keys and +require(HELPERS in menu and "bool option_list(const char *key, int *index, size_t skipped = 0);\n" in menu and + len(shared_labels) == 27 and drawn_keys == shared_keys and not [text for text in [*shared_labels, *shared_choices] if f'"{text}"' in menu + present] and " static constexpr int kScaleCount = 8;\n" in menu and '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"' in options and @@ -843,6 +869,8 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: " return false;\n" "}\n" "\n" + "} // namespace\n" + "\n" "bool applies(const char *key) {\n" " return disruptor::settings::applies(*find_option(key), prerequisite_on);\n" "}\n" diff --git a/tests/contracts/test_frame_rate_contract.py b/tests/contracts/test_frame_rate_contract.py index da044ac..d517028 100644 --- a/tests/contracts/test_frame_rate_contract.py +++ b/tests/contracts/test_frame_rate_contract.py @@ -6,6 +6,8 @@ import sys from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -45,7 +47,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") source = read("src/game/frame/frame_rate.cpp") header = read("src/game/frame/frame_rate.h") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) options = read("src/game/settings/options.cpp") STAGED = ( "void mark(const char *key, int value) {\n" diff --git a/tests/contracts/test_gap_filling_contract.py b/tests/contracts/test_gap_filling_contract.py index 6d49255..372f41c 100644 --- a/tests/contracts/test_gap_filling_contract.py +++ b/tests/contracts/test_gap_filling_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -22,7 +24,7 @@ def between(source: str, opening: str, closing: str) -> str: cmake = read("CMakeLists.txt") renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") STAGED = ( diff --git a/tests/contracts/test_hud_scale_contract.py b/tests/contracts/test_hud_scale_contract.py index e65c46e..71609bc 100644 --- a/tests/contracts/test_hud_scale_contract.py +++ b/tests/contracts/test_hud_scale_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -20,7 +22,7 @@ def require(condition: bool, message: str) -> None: scale_header = read("psxrecomp/runtime/include/gpu_ws_hud_scale.h") gpu = read("psxrecomp/runtime/src/gpu.c") header = read("psxrecomp/runtime/include/gpu.h") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) options = read("src/game/settings/options.cpp") STAGED = ( "void mark(const char *key, int value) {\n" diff --git a/tests/contracts/test_intro_skip_contract.py b/tests/contracts/test_intro_skip_contract.py index 76da509..582efc5 100644 --- a/tests/contracts/test_intro_skip_contract.py +++ b/tests/contracts/test_intro_skip_contract.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -47,7 +49,7 @@ def require(condition: bool, message: str) -> None: cmake = read("CMakeLists.txt") source = read("src/game/session/intro_skip.cpp") header = read("src/game/session/intro_skip.h") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) options = read("src/game/settings/options.cpp") STAGED = ( "void mark(const char *key, int value) {\n" diff --git a/tests/contracts/test_language_disc_contract.py b/tests/contracts/test_language_disc_contract.py index 9c4e59b..9b5d15d 100644 --- a/tests/contracts/test_language_disc_contract.py +++ b/tests/contracts/test_language_disc_contract.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] LOAD_ADDRESS = 0x80011200 ROM_TEXT_OFFSET = 0x800 @@ -76,7 +78,7 @@ def require(condition: bool, message: str) -> None: restart = read("src/game/session/restart.cpp") settings_header = read("src/game/settings/game_user_settings.h") settings_loader = read("src/game/settings/saved.cpp") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) reader = read("psxrecomp/runtime/src/iso_reader_c.cpp") require( diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index ff946e5..fbdb419 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -4,6 +4,8 @@ import re from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] # Keys the game saves and the launcher leaves to the in-game menu, the command line or a hand edit. @@ -45,7 +47,7 @@ def plain_values(text: str) -> dict[str, str]: cmake = read("CMakeLists.txt") loader = read("psxrecomp/recompiler/src/config_loader.cpp") game_section = read("src/game/settings/saved.cpp") -menu = read("src/game/ui/dev_menu.cpp") + read("src/game/frame/present_rate.cpp") +menu = menu_text(ROOT) + read("src/game/frame/present_rate.cpp") ROW = r'\b(toggle|choice|slider)(?:<[^>]*>)?\(\s*"([\w.]+)", "([^"]+)", (-?\d+)' rows = re.findall(ROW, options) diff --git a/tests/contracts/test_loaded_state_contract.py b/tests/contracts/test_loaded_state_contract.py index 30d8e82..45bf4fd 100644 --- a/tests/contracts/test_loaded_state_contract.py +++ b/tests/contracts/test_loaded_state_contract.py @@ -7,6 +7,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -24,7 +26,7 @@ def require(condition: bool, message: str) -> None: state = read("psxrecomp/runtime/src/boot_state.c") cycles = read("psxrecomp/runtime/include/psx_cyc.h") latch = read("psxrecomp/runtime/src/fntrace.c") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) rate = read("src/game/frame/frame_rate.cpp") require( diff --git a/tests/contracts/test_smooth_turn_contract.py b/tests/contracts/test_smooth_turn_contract.py index d5974ed..ba24fb2 100644 --- a/tests/contracts/test_smooth_turn_contract.py +++ b/tests/contracts/test_smooth_turn_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -25,7 +27,7 @@ def between(source: str, opening: str, closing: str) -> str: renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") gpu = read("psxrecomp/runtime/src/gpu.c") mouse = read("src/game/input/mouse_aim.cpp") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") STAGED = ( diff --git a/tests/contracts/test_temporal_presenter_contract.py b/tests/contracts/test_temporal_presenter_contract.py index 92b720c..27ec92e 100644 --- a/tests/contracts/test_temporal_presenter_contract.py +++ b/tests/contracts/test_temporal_presenter_contract.py @@ -4,6 +4,8 @@ import re from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -231,7 +233,7 @@ def shader(source: str, name: str) -> str: ' if (const char *asked = std::getenv("PSX_DISRUPTOR_IMPROVED_SHADOWS")) gpu_set_shadow_shape(asked[0] == \'1\');\n' in read("src/game/host.cpp") and ' if (settings.has_improved_shadows && !env_override_present("PSX_DISRUPTOR_IMPROVED_SHADOWS"))\n' - " gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0);\n" in read("src/game/ui/dev_menu.cpp"), + " gpu_set_shadow_shape(settings.improved_shadows ? 1 : 0);\n" in menu_text(ROOT), "the renderer holds the shadow switch and reads no environment: the game sets it at the start, " "and a saved setting gives way to the environment", ) diff --git a/tests/contracts/test_world_depth_contract.py b/tests/contracts/test_world_depth_contract.py index 52af175..e82ae0c 100644 --- a/tests/contracts/test_world_depth_contract.py +++ b/tests/contracts/test_world_depth_contract.py @@ -3,6 +3,8 @@ from pathlib import Path +from menu_sources import menu_text + ROOT = Path(__file__).resolve().parents[2] @@ -25,7 +27,7 @@ def between(source: str, opening: str, closing: str) -> str: header = read("psxrecomp/runtime/include/gpu_world_depth.h") renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") presenter = read("psxrecomp/runtime/src/gpu_gl_temporal.c.inc") -menu = read("src/game/ui/dev_menu.cpp") +menu = menu_text(ROOT) launcher = read("src/launcher/launcher_settings.cpp") options = read("src/game/settings/options.cpp") STAGED = (