8qp4rNxmt6iW!XTSGGl
zOFsf1xbr(DVMT{Ce@}cLhYtq|
z=SH3j>S6abJMf6b2b684_z5>@`EiG4o;IkJCZ2)+zV96Ui>NGeB65HEgR%Ku0cH4h?cWyieMyq+HgEsN2L;
zp{vl*mAuMa-=yo>)ys01!aj&>N57Krar)kd3^yyRE{k)5re7{P7)`=oyY51Pz|v;c
zRCx~W7iV1xY7qq5m^k7-`8rzHRM5PJuo1~Kj)q*yXM#sewapiLS*d|HOL4^Qo+q7)
ziNQBBrtL)dH}K1RM~8pP7FXD%s4hH<>G*o)m3zfv*rIZ=M|f~~e5nN|>Sb7tEJ44v
zz^vxdj6l)S1)@9BrgO~0l;(dTz7@}8RtC*v7TT12tAB`f3edd!TqT?r$nAd2a|z))
z_1H_6{AJ`~-dB|GTOrUT{lIQ-slA0`VP6;K*EUzjSRzyQx1K5z$K&}CX}dn1`p<1_
zWBI<`?ppT?iuVqpTBz);FTz5_+n1?>rmdW7w%GK
zJ)0}YObNJzJwgJWxOzY=%-XY9CyW~@OlrusW8&Z0ewUe4e|(nCwOSo315PCPFsHP(C`#|VcXVCZ14Ac;&nx{>B+@EZ_EKlO-UEhu;j%W-P_?#HZ
zBbD%|*9f3b2245>oaNErk|RJuo&JW&<2uN2*eY&>_4ff1`
z7FYRHE1<_2gj9$RC$D^#IC9}^K&GlD1G;6<4m??^t%HBGJhg#)lM<_o5>Qex91Jd5
z1qi})WXG|mtqO|hG&Ry+1><+Y)WrM^5XKI1ntxHqd>vQa$DZlv6gX`kp>uCv4q_c+
z!W6w*cY5c8Mlv)ZFl)NH%qK&eaf;s|hMhQelDJfBW28_n)f1?k<^E
z$7wvX8
zb0N-;-3&4Od9D;QS}Ucw)oyB-ceJHLG;sJzQ#q~crD>#h(LuV$wgy4su>bN{>g4=q
zm)-d1SwzJV!rz2SaHWWwJX18n@9#V*x{84`$)F!i75G+G);J3Z+ddi=gQ1U
z>ViUjmO%ziF6Pb{lcH`6Y9b0hX`(B}r#-Hmqth~2YMa(%@?lmfr&}^YZ6Q5%_Hd_+
zM-HUxt~h*L?qnNtwx0LzZmj$)!$Z8;
z^jT#M!IT?bM13OTn3r(-05oQ;1VUv*#r7jTn?0Lj9Hf09P!6XoA8SvyE?+S`PNbjU
zmoUOPHN$5)SdR!WKk&Gf641G2bYz-X4MTu*_48UDCYNZN;8~;>a(ggxk`-_|2N^L9
z*!+_HwQUA7F9yEa<`)JYvrU#U{?ZEz`_zdo@K!|dS*X05bx*2AG3~T6l-BA==Tf11
z7X*Jq_J$^=P5Wc&44B$Jyw)GQOAZ1D4zL*r()bcjf8?~|>DDDL-IwcUYsEK5DsYk*
z*Ulxv>m*dW-v14o?N!-Mb_sYtscp@)t|yKEj!5Az+>{-2nXeG^BWQOu
zhx;xI@Od0|V?3Nm=0YZ8a}nqzFrvgSW2X6grMW18{%ucS`zKX$FZLiR$ELc=qt(1m0NFqziJb{!nf0Gr0*AD#!OF#Uz$(W~(*<%LF}Dv>?P%>w`lzsT
z-bXIE`V7}yIN!+&o2PFSbA4N0N_n~tI)IEjH3c!EDfTtprYbQ6Tjna%(yCbK3
O%Yf1`(*B@{j`|C43^SB=&IX!HqtFJsomQa@4iH%A
m(82>=473Y#>bUy=JG<{6ww5(a>vsY37K5j&pUXO@geCx8@zK`+
literal 0
HcmV?d00001
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_light.png
new file mode 100644
index 0000000000000000000000000000000000000000..134c0842ad7ff7bb14134a67c4ffb12d18943699
GIT binary patch
literal 4528
zcmd5=i8mD97iWec#>^nuhOw6HOCd$FCRECjCBh)
z27fm@h;ife%ea$?i95u^;F{%=iM8p_RQOT^c5`L=dnIyJmsKt`vm-A~z{h8ug(o>d
z#ilCUUm%{;OYrH^C6VJSNir}WDw*};sW^^%^SBrT3tYzo<+)Bczz0~b!Yr<9dn#D^
z*{9d8Av5$L1p-m}g0{!t&UQ(TZ0;}7etOaDMk{K667^Kd1poiM3UW9y|NR-v-i+M3Gzly&+AB8uoV(#&eGRJ~5TA
z0UPicvQ-5gYWe?j(t5g)t?_b%0?|?xW@VYn+*PO)JzK$>ybsv0lklY~ReY-q2Hgbu
z0V6pbz*Kizq*&>XdgcL8K5M?o$^J;DA&8K$zTk-p5Leb%Gr0jGa757&K>As(&J=qd
zD0@$ED$9ZzNkM8j0x5PQHspdbqLEXR6exhhQ*Kj=NrJjrOUkf9f-si@;L4KngzgLS
zADcqNt&V_N#=Z394b|}DAg#Pij11q5arEVALEYm2%CE8vu&tl7Z8ZlGET6D3wXqc0
zaR4oNo-(m`KjDYZlQmCfYrdVqG_oH+s_sHon|Nu{J8H`<$u(E#g(F<#CC@RRAwbYH
z;6RiXJm4Mn!Ccp?LKhCI^hy(Ra8e#HYtt9?eh(fSa2KmH5}SciXFx$S|16ek6pkTgjERY0;u
z{Kfv`X7)C=Lkuf-B>lnQh$+{jh%rP$k#%+guO_asggprb|~5
zax{K6X(g|%Q_2N?8?pB{sftL-NzgyvUc`S?!tz;0u^x)zcR6!Qpr|!92FSQ4@yOVh
zg?w!tQ)APA)SZ(2{n*XA^~GJ0Vl0YOM1KZc*xF{(IybF+tVTZ_9sj<-=9Qb65H9E|
zlx`@LFy4<7N!MvVO^IQeT(16?`wY(|)Mi@Zp{KiFZ9MQp7b&SqYV)KLwx!-MDgzxwM}k-m(4jVp*ZbPa!T?#
z<>wg={b>DW;5NE49#P?cvJ!na=iv7~D`czcBFqYH!=zzDTp-(Sc31Lj>r*5ZG(i(h
zWn_AM&3qbPYX>|B9k2j~-pBSqvb|SFrEJV+`=4Y;>v(B6;)Rg1V4}7
z2q)hgle6zov(HjmL1PJfXi6sLZ4vAI4Dy3W`N}WN`pe3>Bnw}wweGbt$#a(ejsYkk
ze|yQB--5f#n{%vsTB@f-9HxVvUp{6|{so~Mv!Vx3^Iv`=p{_uS@4_st*+IoK*_&{D
ztms6L!()d-k(xtyySq76jcbac10X`_Bfk;Q)MYtQAxZu^w6n`1!9;DrnU9XS)L?=b
z_jvaN!aXSdwbAOXdYjhz%=pKr%$^Btc-C`Ke^
z2Uy14_w#DEa>Dk52)#Q@Jv+~dJIPCCXsp#Pk5k{{&0a>H8NNXc%b}=25pd&0xT$rJ
z(qa#MVJ
z+k*NtUTQex%6EJn9iGs@GunZdSW&RS_fm`C
z5PzvJzCG#=1PgD?7@rUjqbLc1F48@~_}+i=)W-3?wMJMshwE>UjPmKl#(K*5O=VQe
zfglY?;oYC$Nk!=<{cZ;S4B%ka`#G}Tp3=7jb;~O!MIj8w+rsFei&Ys7(?Yn?Yp!6L
z$!DwJg>G-LV8H!jJx1!Fo=@6UHh=pSI$4Q%Yt2c1D3&>$o1E?EeF4U0utdg#_1!n_
zD@GMQkKaAu@_7#;y!}&~8+IWO+ZS}e^>#YcKoDn_vgGD_Q6u>47^}H`#B1yfIOWW}
zWQOCi{4E_;OFo*LBJ~gqcV;=&fSOa48i>%XEB)AFP&_SwD=qSv_ivKmeWc}6zh-Ay
z(IsPgdlaPFyCN=#^XkwAC$;fRKvuZ37I#w8q2(H|I*gGferq{a0Ef&UwDjvYQrCoG
zkAlDkR>NvPIpyL%xHa{zKW)jFt1eg&rvGF#MT?69rQ@bj!?Jpz+F9O=01|pnk%x2(
zZq|A2@P#9+QDOj!V^^Av*&
zj~TyWSWp-guTAW{$HSo7zssz50c6A+eZ$(kY
zXu)ykH73&rSut*6cgUU^Y=eLb6h-|3Y3Q=P4^tfaMgNSfQC!8AO^
zj>WeAbh8WMVl9K;z<+R>>Mfo!t)H9Q5-xr=%EfT3!aIa$1_>T>D4X!fAIZ;~xh4s$
ze!GpkR5_Jo6CBVKQc>Oza&TCx=zFkQ6CO!CP3&|W84k6<=?y*W6z
z*`Nx`2oTthB{t-9-(YdSr{3ECJk{;vv-3zzZ3{{0lh65>x72~&z@?ftrM^UDGjYPt
zb?MSkgtKGkUEULl|G2KD_n5Heh0lN)&|JL2Q!NauwD4OWXkn?w>JgSHa#$QY8m-iMFZz?Q
zq_-a1D|_{e^1Y2KJGE>x47gSA?2GNeDjW0z%b_~Xd776cxuiL9BX_<9F?Oz
zwKf>7{@y(?4_BR19wd3i8i~;`2b(eRt?fwYL*4fCXU0jSP?y9Ma#H`)!
z8AvR~+8lOOIXzQz?b}dRGy+NFeZLxa1iA%IEc?+kn-(;2C+R*_d^L4k{LxX1focHS
zvk;~1><1s-g%sl*`~_d7+bO2d2KC=G$#+N^j281hsrq^qesSwURaSa6Bd@Ae^2I{K
zmwUZtkn}#yoH<>qOThAxO>@|p_a5O
z?=MBOtC;a`w+iU0gy=xS)!QS*zb!iiJ#&_Tc0v4vMdvdyKVN4CO>!KZukwZ@$9s~@
za^n~krrzaE65l<5x74`a^1NvLtb52nhZo?g3dI6Ly1FYik`_lAik@k#(vCvRJN(Pw
zaOkbHyjhWYPXWd(wkqJqy6}czS#y80>0yt3IWB-yGcF0
zD8vz!+JRZcm_MQkgNvOHUi9l3LdY-EZB89P1V=1|8jJjNt^x5uLz>~wO*81WrQ{>b%-%m
zOCBkPmTyL=14}LfRPcG!Hn6~G%&rKVLrx_2%E+F`1N&UeH!V!#UL9ETRk%ai^h<^7
ziaGM1L(N5GO%;T2t9xF@B4di!-6_a{c#lGS=(`1ZXjI8Aa1%NhWnO5quTtEZIyJrN
z&-s1r5~V}*_zs$NequC`a)Tm(_f$ywaY_{Gz*YXR+O9$Z(ZJ5JVYzuTqf_87J26A+
z4+7gtXf4N55o<<7TKjQrXJ{YSrSwI;lmhiN@ytJ3DLyGjJ`EcuDABD(mwNJv(~-jH
zvhUtBiuL9`>@QbC#~>@Z@r8_|1RA0lBWrUm3nNq49ErE;$@_)?4yD4kjI6Lkd%=uIBVUVqt!+@!?I>;w+jll~hFZv$^3dK%alRLd
z+k;6bTp0=z>TzjcnCAO>nPf}K|DSjyC{HhN;5Gw>UNaY?4}Mdw#mZQG;XJFs%#Y7g
zdV!I#Apd?=gGr2@wS@@cWC_KX6Ba*m{C5T=*5&*ZL`E3>Ee!)dQoG#M(=R2U8%fFc6}1_>3W-4|-mw)X*+;g76xqfa#py)!j-~9UVh%EP62p|@
z4B2zxVQcdfmcU@UZw{7z`!|m>5~X
zVDR$&?;#%OpT8|o3NV=HC}4EOCUk6ZBC^`aBNDl?+{6bLFwu7g1r8rNjQz{t%g0l+
ztHq_St@%H_KB)He++F#D8P={mo?;2unz6g3HRX2+F{p#bjWSM1o`K8%;UC9+S0q;E
zIObl=QG-zGIJtYfLMXL@TW)T4lH9!$F*LT|`SCJmawoh?cWOq!&%7q1Hl%Z9rri6E0mCtNMO8MtQ3xN4?R#wm0!SF#YqMwVVr)}SCaro(1)-c2=5nrwSAb_IF&d+atn%-XMYe}VuK@q*z%Zu>-?C_DV+9~sog_e)m3?O1d0!?G
z$7m=j%XkFiwx3lm@=}VDrRKIntFa%y5FKE|IjNR7NaX1WHe<+>-zWbQ{WZ{y?3XZ%
zftM~OO{CDK&hc^ynDFPR><|#mWycSp&L}tXZ7~rpmgLFbAn#x&U(mJ?foWX(mwyDJ
zdf0qi0(a8tfn6EajJ-ajhWQ%dVjhrg1%aRtAccG&?W#(MkyjsOF&posWw
zAIvNZXv(1#VE#p!kEoL!wUmb}8E>ra7vZQEK)d}eAgSiS#*M=V#Lj#c#=%6~^DKVv
z++X&z3ODFdH~u?2Oo*U*i9k@^%n-Kg&!;HgdXre>_l<*lR%%|!$f%W;t#9twAuCa3
zfFma8wCz`U+6qA+je*F+@*EK3@?AiuxC8X?w0q_On+J9#ZRNCHSDnsr@0PdUGY57f
z>2rTPf*Hn;W1<-c21|2#wutfDPO9M+`KMpF@5v9ym00N=UsLn9{AW)T=W*D72Dk?>
zClRXmu7r~>QN43gauobRO8busP;b!C_{QDZiOzA)OS_W$Ud*4w|JR@6(J!NWb=7!z49QziSWZ2g#
zgc!YA#no~g2f5sq@Z(8t?DnFJ_rjM<5wUpK_)FTG2Zq75_g7;BL0_rZV7;4xu`r*t
zgb_ViwqWP`!RKZHQ^EqWOZoCwVrm|6Zh|@LuEW6qvok$mL@Tq`{`Xt_YRV2Y);1z~
zj4hmXFZ_tWO`4B-5NCPS=*+^;@C0486!|9l)`tD+cwuR-3}mbJb;99$dq>j7rz
z92FG25U{3dQ6%;9@rNcf^@R6a(kD?QJI2~BMuw8fs6aHQ(KsTj9iFO64M{dyg{c;I
zu%rI5&jYUl>8T2)=W`3*@E0c_=Qo>lS>=p(>Ewhqkdc9(#X}MD?!l5JdVxW@vJ2*@
zs%ZP^oo$l~^O6tq!J0229$7Js(jqQko3_?l9XIEH4vq;#74IYg!TzJ1U+oB_R?b04
zgLkeSc4qg=POO%TQn-hGR}mNn)cV{xJ%qHoq@kSW9C$Zq85Z3g1&|iDeF-ko>!OnT
zq3QF^jM<71@fE-MDttY$wK%PJ0J;Qwa)u}UNO{IHGNqU2`bZ~RlQj8~0Tr21Ye`=?ugH5*X2ufCwyIgzImZ(C+R;j!J?mpp%A>}=OhITO!+>Y)0T1W=uR$J?
z>->Oddx(ectkqX8GET3xVX0c!G*LlA?sVF!)vVi_T_lJHf7*>
zfIUCzz=->3Hm!4KR}Ssv$}UB?{8ii-Atq6o3L*b?rO~dGuoJ
zK#9TnmwPTs9KQ?7!|rtS^Iej;o7U2Fe{uNM`|f1oX;V!MTaz@auwYXs45nu0msOAea#m
z)%Bg3$Ej|>0w=5gDGxCXh#eBeq3-0FGi!8Njk>us5zN+JF-9f3;V;J?;zVRkzo*|X
ztH&}%w-2dtJI<_}0Ps!G-bMVAafAr+Wr`8LgwQYBa%5uvR$+T)%+A)^~D^;Ng<94cLs6KeIt=Go)0AKZ7pORwr
z5~GWC2N`=AP@(ee`DQO
z-`8w@Z`o(*J5$D
z;!Cfc@h*WUGoYLsfBwkVQ-OQ6jHwyX)MpZS-*GuDdl7!rd}}|Tn=wfma4OOhY@ze6vJ@~
zRkN6nLt6osmaT)FyAGJ~zs=g~~`MqrL1D8PM3F{L7A3O4#M$@j8K%^QHKUjp)aKnFFHO~K@?Q+6;znmaBv~~0^U|}9Ca0z?5~5iccL+5$#!+x`jt=vh
zeV}Ll{>V*p#W3R!(Kg+6P(n+TSC4r4Ju0X5@vWafe@tabgqno=VBtK??Tb!bQ>0>A
zQhP?cBhP|7B}xej=jz_dhpf-Dj68*gb1rW8?oRq_ysq3dYU1VbC4Oeyg!6=bc$I&P_&(FP{8%J^8lTXTB`BBN(
zqc~KGk2II>dpxc~K;}1!NeAhXADO@11lYhf3G{y@_lg-9#q|E>RBF^T?mS8^R;tbX
zG11%D|L&^CkWrH;kMD)t#F=h@-(h#$q7@0Ra7>%gttXjULRy_ICjW~d2s9!XFkRy*-^iH;Su
znZZ)g?ZZmYZ&C4iBX})Pau{XOEZuw~ll?0xRHy4YT58VQT!E6m2fxWz&NKN%#~1Ed
zTLO!lWBu{4*)bU=kt;P)-4!kqFMJeN{iPZa9?ULmbYk~ahf_ZI5q88sEal4>K@Wh_
z(;K%|;!!};o%M;g3YePTzB?!^C`}sV7QwBQNTx4|J}29;`%uio=?#Sj
zH-GsmCPw|-N7C~2pfMlnfSBLIJ9WzytDW^K)N8U|-KRb_dNgqtJ%Y|Sv1%}so)Wx
zdDop=9fwh3cVdz{zuMjwdtj9y)KMJ~_BWTW4mC&IHs)h`s9W7~Rf6Ta+;g(S^3!hx
zrK18niv|hkh+4^BN{jsqntZ7u<%~nTh6nS(zc)YmLnsJ6EeEZ0t^g=7EeM-
zaXyR`+Q>Z@oy!+o`uMZ1D@tXL_S2F=f((=d9Uo0z&f*fN9Rr-2nV;{zL*(7~WHvxc
z((8UYIP$idg^TQVvpFF4aApM25W@LBc%iB%P6de|rr-01uq4+sbL+Rwf)t4`LjjB_Ki59ReY%uCEqrX
zkUiFM2IKm5X|%
zp)OPm3X$wPaYAdkh>n_CSX@-72dFpi!JC}xT&hfcT45h)S<>mHgVecyoAod`d@C+~
zvx!8i%6$36jc8QYZTH~ThewYZ&%CWX8lc&1*45i)&Z
z9uqfn3Wvw_7;DR+OY1UdqhF%*-ex;28b%%}L$DvVwD{Do9K)r+ORc8XiUmtVZA6h;
zwpw)~^*NaZLPCl;!z*q(l|P>U*HDD(_CEqz@e{p_tFt0!J9Eu?9=Gn&=C|qD|MnAm
z+SFa|b;&dafu^fm#>*3TUmxV>RJ?&4qBVo<)|~PD-Agiekhp>Y`NgH20jA${0dKt2
zh|#v7KM-K87pxKDs#S`JcEXa)2c=*CBd#dBEa7xrnclCGY!Tqb-vsNntQ^tShw>PY
z-$j;hMQ;?t7iugRe}|GR()@_Od|QZCv%dz{N}?wIZZM|**@702nomZIYEuaot6~O^
z;aNGf$|y3@cn%w_r;$4>Yp3?aXKM|V9BW$<2bdwskWcCzL-|lIP1+Dz%`Vywf%Ytd
z`Gr6ed3O78MwpX^hH}{{r|UcKk{i%^tQFUOKMUqTqP2hw)VPT3ZEoKK%Qgdvfub3k
zg*4Uv7K2a96-_o;eym11Q+IiD5oj6$Rmrx6^_EYr#i)ITr+2wcC7V~i&C{N6a}8rx
zD?}4$=7wYGrmdLCI?pRLmE9b)0yPNg=Yi9c!j>*4p`I0}R-yXKv7?$pbi8h8sZhO&
zX&$GZd&h5PyvQcwor74gji@{H(Lc3o--_2gQ6R7op^K8^M1D<=gORyL&X1$2ugzCx&02>dc3zYI@)tDXv`)X
zgPR5Q!}uX0?b(7Cjeyogvu#J~M0hy0406PEFz?d8lZCewCer-BNk~OiyZc)T!8Faf
zWNXV(93{J(fK4;a%YM-@l_!6{%(Y$*Sg%ak43=bXUMRnbrGi$K4+GOAq;p-8Ebcp5nlKCArkVOXWz)DV
zJfI3|sy#&PL|T;zs6oRAWxpCHg0@Kr4zYa~OI5V?m{VQ^(%r)_>B)(-B_bwjYh;^V
z0TjIC~^JUQm%zkqUnudcMa<;|<
z9p+XB6!+@t7ncVU&rmQOb)7(0lFX{_bjozwWp$tMhGr}@Q7al&LX?rP`Bvi*fcfTs
z$L~UzVi7VZLWTNFeX+_J#1~GEq*Sl!A^)A%PvW}C8X9`m;kPOA{IJO;25K=_I6rW<
z$cw$>HaC}S1*<+MJC+&)8rO@Fzc
zMLIx1gq>lPPj{zrp;7tDQJMIsO4CoI#CpW_%*bX#_65*fa_3nYQp+k-2IA(}5#WrK
zY9GJa<3ksnfB;Vy9~Q|ZAe6|<
z1F=<&ZP3G^E`KpI{4Ormb3~IE^*qB_6pzza)JdZy4)8M%tFBXGZoXG%rA$X+9zjTW
zEDM{|*e26YAYSh~6TvIT?if(?je&9Z(F*b*s*#Q$er`cN^}*?{O+ftCdzn*^C9>(V
zVa9Z%9KR|ADl|D5@5n35jt>wv{9V7L)Pz}&$dC(j)PnqBe?MuV3TQf6b@(VwzXhF&
zzr1}FSh{%xI`+_3)-IKX-{<7aH**GtYe*RM6>dmyYcGWsEmyBZCGRtene>f)F4b>U
zNd=PWyA}B>HPu%^Q26%wPV-e0`XK7&+g$n3kh!T4dBH=dm%f@ovEEK#4RABq;I7>_
iGlBodoHPt(mb%9eTVQil+`0I4-w@!cg%QaB7yCcF>s71(
literal 0
HcmV?d00001
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 3381ca393..fcc1b2258 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -741,6 +741,9 @@
%1$d%% (افتراضي)
المظهر
مظهر المشغّل
+ أيقونة التطبيق
+ داكنة
+ فاتحة
استخدام كشاشة رئيسية
يمكن أن يكون DroidDeck تطبيق الشاشة الرئيسية
لا يُعرَض DroidDeck أبدًا كتطبيق للشاشة الرئيسية
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 3ff6b8521..877992f59 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -752,6 +752,9 @@
%1$d%% (predeterminada)
Tema
Apariencia del launcher
+ Ícono de la app
+ Oscuro
+ Claro
Usar como pantalla de inicio
DroidDeck puede ser la app de inicio
DroidDeck nunca se ofrece como app de inicio
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index f562b8bb8..4e14e9940 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (по подразбиране)
Тема
Облик на лаунчъра
+ Икона на приложението
+ Тъмна
+ Светла
Използване като начален екран
DroidDeck може да бъде приложението за начален екран
DroidDeck никога не се предлага като приложение за начален екран
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index df75b0192..366be9edc 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -735,6 +735,9 @@
%1$d %% (výchozí)
Téma
Vzhled spouštěče
+ Ikona aplikace
+ Tmavá
+ Světlá
Použít jako domovskou obrazovku
DroidDeck může být domovská aplikace
DroidDeck se nikdy nenabízí jako domovská aplikace
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 931534a37..46d435398 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (standard)
Tema
Launcherens udseende
+ Appikon
+ Mørk
+ Lys
Brug som startskærm
DroidDeck kan være Home-appen
DroidDeck tilbydes aldrig som Home-app
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index dfaa30fed..66b18fde8 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -718,6 +718,9 @@
%1$d %% (Standard)
Design
Aussehen des Launchers
+ App-Symbol
+ Dunkel
+ Hell
Als Startbildschirm verwenden
DroidDeck kann die Startbildschirm-App sein
DroidDeck wird nie als Startbildschirm-App angeboten
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 7efafb770..786b4ddb5 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (Προεπιλογή)
Θέμα
Εμφάνιση εκκινητή
+ Εικονίδιο εφαρμογής
+ Σκούρο
+ Ανοιχτό
Χρήση ως αρχική οθόνη
Το DroidDeck μπορεί να είναι η εφαρμογή αρχικής οθόνης
Το DroidDeck δεν προτείνεται ποτέ ως εφαρμογή αρχικής οθόνης
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index ccc1e9e37..7db317747 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (predeterminada)
Tema
Apariencia del launcher
+ Icono de la app
+ Oscuro
+ Claro
Usar como pantalla de inicio
DroidDeck puede ser la app de inicio
DroidDeck nunca se ofrece como app de inicio
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 9b156663c..de265dcab 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -729,6 +729,9 @@
%1$d %% (Oletus)
Teema
Käynnistimen ulkoasu
+ Sovelluskuvake
+ Tumma
+ Vaalea
Käytä Aloitusnäyttönä
DroidDeck voi olla kotisovellus
DroidDeckiä ei koskaan tarjota kotisovellukseksi
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index fc79c4e3c..1d09298d5 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (par défaut)
Thème
Apparence du lanceur
+ Icône de l\'app
+ Sombre
+ Clair
Utiliser comme écran d\'accueil
DroidDeck peut être utilisé comme application d\'écran d\'accueil
DroidDeck ne sera pas proposé comme application d\'écran d\'accueil
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index a9cbd38f3..90c47ff45 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (डिफ़ॉल्ट)
थीम
लॉन्चर की दिखावट
+ ऐप आइकन
+ गहरा
+ हल्का
होम स्क्रीन के तौर पर इस्तेमाल करें
DroidDeck होम ऐप हो सकता है
DroidDeck को होम ऐप के तौर पर कभी नहीं दिखाया जाता
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 747aac900..96cb5ed36 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (Alapértelmezett)
Téma
Az indítóalkalmazás megjelenése
+ Alkalmazásikon
+ Sötét
+ Világos
Használat kezdőképernyőként
A DroidDeck lehet a kezdőképernyő alkalmazás
A DroidDeck soha nem jelenik meg kezdőképernyő alkalmazásként
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 2c695930b..9ab984dce 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -726,6 +726,9 @@
%1$d%% (Default)
Tema
Tampilan peluncur
+ Ikon aplikasi
+ Gelap
+ Terang
Gunakan sebagai Layar Utama
DroidDeck dapat menjadi aplikasi Utama
DroidDeck tidak pernah ditawarkan sebagai aplikasi Utama
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 4d6cb901c..cd769f615 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -721,6 +721,9 @@
%1$d%% (predefinita)
Tema
Aspetto del launcher
+ Icona dell\'app
+ Scura
+ Chiara
Usa come schermata Home
DroidDeck può essere l\'app Home
DroidDeck non viene mai proposta come app Home
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 2df5383a9..b350ede00 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -726,6 +726,9 @@
%1$d%%(デフォルト)
テーマ
ランチャーの外観
+ アプリアイコン
+ ダーク
+ ライト
ホーム画面として使用
DroidDeckをホームアプリとして使用できます
DroidDeckはホームアプリとして提供されません
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 0705efab9..f076dc4fb 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -726,6 +726,9 @@
%1$d%%(기본값)
테마
런처 외관
+ 앱 아이콘
+ 다크
+ 라이트
홈 화면으로 사용
DroidDeck을 홈 앱으로 사용할 수 있습니다
DroidDeck은 홈 앱으로 제공되지 않습니다
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 6729d1e58..344e02520 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -726,6 +726,9 @@
%1$d%% (Lalai)
Tema
Rupa pelancar
+ Ikon apl
+ Gelap
+ Cerah
Gunakan sebagai skrin Utama
DroidDeck boleh menjadi apl Utama
DroidDeck tidak sekali-kali ditawarkan sebagai apl Utama
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 42962f25a..3b316f00e 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (Standard)
Tema
Launcherens utseende
+ Appikon
+ Mørk
+ Lys
Bruk som Hjem-skjerm
DroidDeck kan være Hjem-appen
DroidDeck tilbys aldri som en Hjem-app
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 46389acca..ec1b209ab 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -729,6 +729,9 @@
%1$d%% (Standaard)
Thema
Uiterlijk van de launcher
+ App-pictogram
+ Donker
+ Licht
Als startscherm gebruiken
DroidDeck kan de startschermapp zijn
DroidDeck wordt nooit aangeboden als startschermapp
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 32634d99b..35a3afaef 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -735,6 +735,9 @@
%1$d%% (domyślnie)
Motyw
Wygląd aplikacji
+ Ikona aplikacji
+ Ciemna
+ Jasna
Użyj jako ekranu głównego
DroidDeck może być aplikacją ekranu głównego
DroidDeck nigdy nie jest proponowany jako aplikacja ekranu głównego
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 13412341a..614ece612 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -721,6 +721,9 @@
%1$d%% (padrão)
Tema
Aspeto do iniciador
+ Ícone da app
+ Escuro
+ Claro
Utilizar como página inicial
O DroidDeck pode ser a app de página inicial
O DroidDeck nunca é proposto como app de página inicial
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index b3d775666..b5c4889e3 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -721,6 +721,9 @@
%1$d%% (padrão)
Tema
Aparência do inicializador
+ Ícone do app
+ Escuro
+ Claro
Usar como tela inicial
O DroidDeck pode ser o app de início
O DroidDeck nunca é oferecido como app de início
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 0453831e3..840d1dfb3 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -732,6 +732,9 @@
%1$d%% (Implicit)
Temă
Aspectul launcherului
+ Pictograma aplicației
+ Întunecată
+ Luminoasă
Folosește ca ecran Acasă
DroidDeck poate fi aplicația Acasă
DroidDeck nu este niciodată oferit ca aplicație Acasă
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 78d237c6c..f939fd97f 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -732,6 +732,9 @@
%1$d%% (по умолчанию)
Тема
Внешний вид лаунчера
+ Значок приложения
+ Тёмный
+ Светлый
Использовать как главный экран
DroidDeck может использоваться как приложение главного экрана
DroidDeck не будет доступен как приложение главного экрана
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 4ddc7eacc..58279d36b 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -729,6 +729,9 @@
%1$d %% (Standard)
Tema
Startprogrammets utseende
+ Appikon
+ Mörk
+ Ljus
Använd som startskärm
DroidDeck kan vara startskärmsappen
DroidDeck erbjuds aldrig som startskärmsapp
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 4bda3c786..f13ce19aa 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -726,6 +726,9 @@
%1$d%% (ค่าเริ่มต้น)
ธีม
รูปลักษณ์ของตัวเปิดแอป
+ ไอคอนแอป
+ มืด
+ สว่าง
ใช้เป็นหน้าจอหลัก
DroidDeck สามารถเป็นแอปหน้าจอหลักได้
DroidDeck จะไม่ถูกเสนอให้เป็นแอปหน้าจอหลัก
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 3322f1c92..7a7e40f23 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -718,6 +718,9 @@
%%%1$d (Varsayılan)
Tema
Başlatıcı görünümü
+ Uygulama simgesi
+ Koyu
+ Açık
Ana ekran olarak kullan
DroidDeck ana ekran uygulaması olabilir
DroidDeck hiçbir zaman ana ekran uygulaması olarak sunulmaz
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index fb30a8e4e..6a19b33c9 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -735,6 +735,9 @@
%1$d%% (стандартно)
Тема
Вигляд лаунчера
+ Значок застосунку
+ Темний
+ Світлий
Використовувати як головний екран
DroidDeck може бути додатком головного екрана
DroidDeck ніколи не пропонується як додаток головного екрана
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 64c63421b..e1f58f9e8 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -726,6 +726,9 @@
%1$d%% (Mặc định)
Giao diện
Giao diện launcher
+ Biểu tượng ứng dụng
+ Tối
+ Sáng
Dùng làm màn hình chính
DroidDeck có thể là ứng dụng Màn hình chính
DroidDeck không bao giờ được đề xuất làm ứng dụng Màn hình chính
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index c0acbc778..caf528050 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -726,6 +726,9 @@
%1$d%%(默认)
主题
启动器外观
+ 应用图标
+ 深色
+ 浅色
用作主屏幕
DroidDeck 可以设为主屏幕应用
系统不会把 DroidDeck 作为主屏幕应用提供
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index a8978f454..252446f85 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -726,6 +726,9 @@
%1$d%%(預設)
主題
啟動器外觀
+ 應用程式圖示
+ 深色
+ 淺色
用作主畫面
DroidDeck 可用作主畫面應用程式
DroidDeck 不會作為主畫面應用程式的選項
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 84195dcd7..10865ff6d 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -726,6 +726,9 @@
%1$d%%(預設)
佈景主題
啟動器外觀
+ 應用程式圖示
+ 深色
+ 淺色
用作主螢幕
DroidDeck 可以作為主螢幕應用程式
DroidDeck 不會作為主螢幕應用程式提供
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d1f8c0203..424d8e35b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -760,6 +760,9 @@
%1$d%% (Default)
Theme
Launcher appearance
+ App icon
+ Dark
+ Light
Use as Home screen
DroidDeck can be the Home app
DroidDeck is never offered as a Home app
diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt
index e23faaf69..9493a630b 100644
--- a/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/frontend/GameLaunchLinkTest.kt
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
import android.content.Intent
import android.net.Uri
import android.app.Activity
+import com.droiddeck.launcher.LauncherIcon
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.frontend.Library
import org.junit.Assert.assertEquals
@@ -68,12 +69,24 @@ class GameShortcutIntentTest {
val launch = requireNotNull(shortcut.intent)
assertEquals("game:8400", shortcut.id)
assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString())
- assertEquals(MainActivity::class.java.name, shortcut.activity?.className)
+ assertEquals("com.droiddeck.launcher.LauncherDark", shortcut.activity?.className)
+ assertEquals(MainActivity::class.java.name, launch.component?.className)
assertEquals(Intent.ACTION_VIEW, launch.action)
assertEquals(Uri.parse("droiddeck://game/8400"), launch.data)
assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags)
}
+ @Test fun pinnedShortcutFollowsTheLightLauncherIcon() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ LauncherIcon.set(activity, LauncherIcon.LIGHT)
+
+ GameShortcuts.pin(activity, Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L))
+
+ val shortcut = activity.getSystemService(android.content.pm.ShortcutManager::class.java).pinnedShortcuts.single()
+ assertEquals(LauncherIcon.LIGHT, LauncherIcon.current(activity))
+ assertEquals("com.droiddeck.launcher.LauncherLight", shortcut.activity?.className)
+ }
+
@Test fun actionCreateShortcutUsesAndroidsShortcutResultIntentApi() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)
diff --git a/artwork/README.md b/artwork/README.md
index 6b2f7de2e..7f4f24ec9 100644
--- a/artwork/README.md
+++ b/artwork/README.md
@@ -2,4 +2,4 @@
`droiddeck-mark.svg` is the editable source for the white split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette. Place it on black when a background is needed.
-Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. `drawable-nodpi/logo.png` composes the same mark on a black circular badge.
+Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. The `_light` variants recolor the mark black on a white background and back the light launcher icon users can pick in Settings. `drawable-nodpi/logo.png` composes the same mark on a black circular badge.
From 7370145f89d77b03badefb6f59c0e74710bf8d10 Mon Sep 17 00:00:00 2001
From: kingokksa <47888451+kingokksa@users.noreply.github.com>
Date: Mon, 19 Oct 2026 04:54:13 +0000
Subject: [PATCH 013/156] feat: itch.io store, library and installs for
DRM-free Windows builds (#1064)
Co-authored-by: xXJSONDeruloXx <136133082+xXJSONDeruloXx@users.noreply.github.com>
---
.../main/java/com/droiddeck/launcher/App.kt | 1 +
.../launcher/runtime/GuestCommand.kt | 15 +-
.../droiddeck/launcher/stores/CloudSaves.kt | 6 +-
.../droiddeck/launcher/stores/LoginFlows.kt | 1 +
.../com/droiddeck/launcher/stores/Store.kt | 3 +-
.../droiddeck/launcher/stores/StoreSizes.kt | 2 +
.../droiddeck/launcher/stores/StoresState.kt | 26 ++
.../launcher/stores/download/DownloadQueue.kt | 2 +-
.../droiddeck/launcher/stores/itch/ItchApi.kt | 34 ++
.../launcher/stores/itch/ItchAuth.kt | 36 ++
.../launcher/stores/itch/ItchBackend.kt | 71 ++++
.../launcher/stores/itch/ItchCatalog.kt | 90 +++++
.../launcher/stores/itch/ItchInstaller.kt | 372 ++++++++++++++++++
.../launcher/stores/itch/ItchLibrary.kt | 162 ++++++++
.../launcher/stores/itch/ItchPrefs.kt | 11 +
.../launcher/stores/itch/ItchShelves.kt | 199 ++++++++++
.../launcher/stores/itch/ItchUploads.kt | 36 ++
.../droiddeck/launcher/ui/ItchKeyDialog.kt | 115 ++++++
.../com/droiddeck/launcher/ui/StoreChips.kt | 2 +
.../droiddeck/launcher/ui/StoresDownloads.kt | 19 +-
.../com/droiddeck/launcher/ui/StoresPage.kt | 28 +-
app/src/main/res/values-ar/strings.xml | 10 +
app/src/main/res/values-b+es+419/strings.xml | 10 +
app/src/main/res/values-bg/strings.xml | 10 +
app/src/main/res/values-cs/strings.xml | 10 +
app/src/main/res/values-da/strings.xml | 10 +
app/src/main/res/values-de/strings.xml | 10 +
app/src/main/res/values-el/strings.xml | 10 +
app/src/main/res/values-es/strings.xml | 10 +
app/src/main/res/values-fi/strings.xml | 10 +
app/src/main/res/values-fr/strings.xml | 10 +
app/src/main/res/values-hi/strings.xml | 10 +
app/src/main/res/values-hu/strings.xml | 10 +
app/src/main/res/values-in/strings.xml | 10 +
app/src/main/res/values-it/strings.xml | 10 +
app/src/main/res/values-ja/strings.xml | 10 +
app/src/main/res/values-ko/strings.xml | 10 +
app/src/main/res/values-ms/strings.xml | 10 +
app/src/main/res/values-nb/strings.xml | 10 +
app/src/main/res/values-nl/strings.xml | 10 +
app/src/main/res/values-pl/strings.xml | 10 +
app/src/main/res/values-pt-rPT/strings.xml | 10 +
app/src/main/res/values-pt/strings.xml | 10 +
app/src/main/res/values-ro/strings.xml | 10 +
app/src/main/res/values-ru/strings.xml | 10 +
app/src/main/res/values-sv/strings.xml | 10 +
app/src/main/res/values-th/strings.xml | 10 +
app/src/main/res/values-tr/strings.xml | 10 +
app/src/main/res/values-uk/strings.xml | 10 +
app/src/main/res/values-vi/strings.xml | 10 +
app/src/main/res/values-zh-rCN/strings.xml | 10 +
app/src/main/res/values-zh-rHK/strings.xml | 10 +
app/src/main/res/values-zh-rTW/strings.xml | 10 +
app/src/main/res/values/strings.xml | 10 +
54 files changed, 1534 insertions(+), 27 deletions(-)
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index e0abe387d..9c7f928d7 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -35,5 +35,6 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
+ com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
index 9c65c426a..8b4f14e5a 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
@@ -11,7 +11,7 @@ import java.io.File
object GuestCommand {
fun run(context: Context, argv: List, fakeRoot: Boolean = false, logName: String? = null,
- linkDir: File? = null, onLine: (String) -> Unit): Int {
+ linkDir: File? = null, extraBinds: List? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
@@ -19,13 +19,14 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
- return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
+ return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
- private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
+ private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, extraBinds: List?,
+ fastPath: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -33,8 +34,10 @@ object GuestCommand {
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
if (linkDir != null) cmd.add(1, "--link2symlink")
- LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
- .forEach { cmd.add("-b"); cmd.add(it) }
+ val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
+ binds.forEach { cmd.add("-b"); cmd.add(it) }
+ // Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
+ val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
@@ -42,6 +45,7 @@ object GuestCommand {
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
+ fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
@@ -49,6 +53,7 @@ object GuestCommand {
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
+ fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
index 3c3d38d26..ccfd221ac 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
@@ -57,7 +57,7 @@ object CloudSaves {
val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
}
- Store.AMAZON -> ""
+ Store.AMAZON, Store.ITCH -> ""
} ?: return null
p.edit().putString("cloud_template_$id", fetched).apply()
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
@@ -95,7 +95,7 @@ object CloudSaves {
val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
- Store.AMAZON -> null
+ Store.AMAZON, Store.ITCH -> null
}
return dir to (if (dir == null) "unresolved" else "ok")
}
@@ -120,7 +120,7 @@ object CloudSaves {
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
}
- Store.AMAZON -> null
+ Store.AMAZON, Store.ITCH -> null
}
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
index 7a4f579ae..43517ebfe 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
@@ -12,6 +12,7 @@ internal object LoginFlows {
Store.GOG -> GogFlow(state)
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
+ Store.ITCH -> null
}
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
index 3288e1c6e..d7cc814cb 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
@@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores
enum class Store(val id: String, val label: String, val shortLabel: String) {
GOG("gog", "GOG", "GOG"),
EPIC("epic", "Epic Games", "Epic"),
- AMAZON("amazon", "Amazon Games", "Amazon");
+ AMAZON("amazon", "Amazon Games", "Amazon"),
+ ITCH("itch", "itch.io", "itch");
companion object {
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
index 9e37226f8..01014dbd7 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
@@ -54,6 +54,7 @@ object StoreSizes {
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
+ Store.ITCH -> {}
Store.GOG -> {}
}
}
@@ -108,5 +109,6 @@ object StoreSizes {
AmazonManifest.parse(bytes).totalInstallSize
}
Store.GOG -> 0L
+ Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
} }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
index 6b99210e9..63cc4a4cf 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
@@ -16,6 +16,7 @@ import java.util.Locale
object StoresState {
private const val TAG = "Stores"
+ private const val SEARCH_DELAY_MS = 400L
private val main = Handler(Looper.getMainLooper())
val accounts = mutableStateMapOf()
@@ -24,6 +25,8 @@ object StoresState {
val shelves = mutableStateMapOf()
+ val found = mutableStateMapOf>()
+
val status = mutableStateMapOf()
val problems = mutableStateMapOf()
@@ -64,6 +67,8 @@ object StoresState {
private val backends = HashMap()
+ private val searchSeq = java.util.concurrent.ConcurrentHashMap()
+
fun backend(store: Store): StoreBackend? = backends[store]
fun register(backend: StoreBackend) { backends[backend.store] = backend }
@@ -129,6 +134,26 @@ object StoresState {
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
}
+ fun search(context: Context, store: Store, query: String) {
+ val backend = backends[store] ?: return
+ val text = query.trim()
+ if (text.isEmpty()) {
+ searchSeq.remove(store)
+ found.remove(store)
+ return
+ }
+ val seq = (searchSeq[store] ?: 0) + 1
+ searchSeq[store] = seq
+ val app = context.applicationContext
+ Thread({
+ Thread.sleep(SEARCH_DELAY_MS)
+ if (searchSeq[store] != seq) return@Thread
+ val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList())
+ if (searchSeq[store] != seq) return@Thread
+ main.post { found[store] = items }
+ }, "stores-search").start()
+ }
+
fun signIn(context: Context, store: Store) {
expired.remove(store)
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
@@ -265,6 +290,7 @@ interface StoreBackend {
fun signOut(context: Context)
fun syncLibrary(context: Context, force: Boolean)
fun loadShelves(context: Context, force: Boolean)
+ fun search(context: Context, query: String): List = emptyList()
fun install(context: Context, item: CatalogItem, root: java.io.File)
fun uninstall(context: Context, game: InstalledStoreGame)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
index e2549a3be..cfb1324f0 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
@@ -335,7 +335,7 @@ object DownloadQueue {
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
StoresState.notifyLibraryChanged(context)
val games = runCatching {
- com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
+ com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
}.getOrNull()
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt
new file mode 100644
index 000000000..92635630d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchApi.kt
@@ -0,0 +1,34 @@
+package com.droiddeck.launcher.stores.itch
+
+import com.droiddeck.launcher.stores.StoreNet
+import org.json.JSONArray
+import org.json.JSONObject
+
+internal object ItchApi {
+ const val KEYS_PAGE = "https://itch.io/user/settings/api-keys"
+
+ private const val BASE = "https://api.itch.io"
+ private const val WEB = "https://itch.io"
+
+ fun get(path: String, key: String): JSONObject? {
+ val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null
+ return runCatching { JSONObject(body) }.getOrNull()
+ }
+
+ fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user")
+
+ fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
+
+ fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
+ get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads")
+
+ fun search(key: String, query: String, page: Int): JSONArray? {
+ val q = java.net.URLEncoder.encode(query, "UTF-8")
+ return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games")
+ }
+
+ fun downloadUrl(uploadId: String, downloadKeyId: String): String =
+ "$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId"
+
+ fun pageUrl(slug: String): String = "$WEB/$slug"
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt
new file mode 100644
index 000000000..622aa5383
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchAuth.kt
@@ -0,0 +1,36 @@
+package com.droiddeck.launcher.stores.itch
+
+import android.content.Context
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreAccounts
+import org.json.JSONObject
+
+internal object ItchAuth {
+ private val TOKEN = Regex("[A-Za-z0-9]{40,64}")
+
+ fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty()
+
+ fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty()
+
+ fun verify(key: String): JSONObject? = ItchApi.profile(key)
+
+ fun save(context: Context, key: String, user: JSONObject) {
+ StoreAccounts.write(
+ context,
+ Store.ITCH,
+ JSONObject()
+ .put("access_token", key)
+ .put("username", user.optString("username"))
+ .put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") })
+ .put("user_id", user.optLong("id")),
+ )
+ }
+
+ fun candidates(text: String): List {
+ if (text.isBlank()) return emptyList()
+ val out = LinkedHashSet()
+ text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) }
+ TOKEN.findAll(text).forEach { out.add(it.value) }
+ return out.take(8)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt
new file mode 100644
index 000000000..b1bf834c1
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchBackend.kt
@@ -0,0 +1,71 @@
+package com.droiddeck.launcher.stores.itch
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.InstalledStoreGame
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreBackend
+import com.droiddeck.launcher.stores.StoreInstallRoot
+import com.droiddeck.launcher.stores.StoreSizes
+import com.droiddeck.launcher.stores.StoresState
+import com.droiddeck.launcher.stores.download.DownloadEntry
+import com.droiddeck.launcher.stores.download.DownloadQueue
+import java.io.File
+
+object ItchBackend : StoreBackend {
+ override val store: Store = Store.ITCH
+
+ override fun signIn(context: Context) {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+
+ override fun signOut(context: Context) {
+ ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
+ StoresState.post {
+ StoresState.library.remove(store)
+ StoresState.shelves.remove(store)
+ StoresState.found.remove(store)
+ StoresState.problems.remove(store)
+ StoresState.status.remove(store)
+ }
+ }
+
+ override fun syncLibrary(context: Context, force: Boolean) {
+ val app = context.applicationContext
+ Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start()
+ }
+
+ override fun loadShelves(context: Context, force: Boolean) {
+ val app = context.applicationContext
+ ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } }
+ Thread({
+ val shelves = ItchShelves.featured(app, force)
+ if (shelves != null) {
+ StoresState.post { StoresState.shelves[store] = shelves }
+ } else {
+ StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) }
+ }
+ }, "itch-shelves").start()
+ }
+
+ override fun search(context: Context, query: String): List = ItchShelves.search(context, query)
+
+ override fun install(context: Context, item: CatalogItem, root: File) {
+ val app = context.applicationContext
+ if (item.extra["downloadKeyId"].isNullOrEmpty()) {
+ StoresState.logLine("itch.io: \"${item.title}\" is not in the library")
+ return
+ }
+ val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root)
+ val entry = DownloadEntry(
+ Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item),
+ location = StoreInstallRoot.labelFor(app, folder),
+ )
+ DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) }
+ }
+
+ override fun uninstall(context: Context, game: InstalledStoreGame) {}
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt
new file mode 100644
index 000000000..fc4f4a168
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchCatalog.kt
@@ -0,0 +1,90 @@
+package com.droiddeck.launcher.stores.itch
+
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreShelves
+import org.json.JSONArray
+import org.json.JSONObject
+
+internal object ItchCatalog {
+ fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/')
+
+ fun shelvesJson(shelves: StoreShelves): String = JSONObject()
+ .put("new", array(shelves.whatsNew))
+ .put("deals", array(shelves.deals))
+ .put("free", array(shelves.free))
+ .put("top", array(shelves.trending))
+ .toString()
+
+ fun shelves(raw: String?): StoreShelves? {
+ if (raw.isNullOrBlank()) return null
+ val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
+ val shelves = StoreShelves(
+ whatsNew = list(o.optJSONArray("new")),
+ deals = list(o.optJSONArray("deals")),
+ free = list(o.optJSONArray("free")),
+ trending = list(o.optJSONArray("top")),
+ )
+ return shelves.takeUnless { it.isEmpty }
+ }
+
+ fun array(items: List): JSONArray {
+ val out = JSONArray()
+ items.forEach { out.put(json(it)) }
+ return out
+ }
+
+ fun list(raw: JSONArray?): List {
+ if (raw == null) return emptyList()
+ return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) }
+ }
+
+ private fun json(i: CatalogItem): JSONObject = JSONObject()
+ .put("id", i.id)
+ .put("title", i.title)
+ .put("image", i.imageUrl ?: "")
+ .put("tall", i.tallImageUrl ?: "")
+ .put("tags", i.tags)
+ .put("free", i.isFree)
+ .put("hasPrice", i.hasPrice)
+ .put("finalPrice", i.finalPrice)
+ .put("originalPrice", i.originalPrice)
+ .put("discount", i.discountPercent)
+ .put("url", i.storeUrl)
+ .put("developer", i.developer)
+ .put("publisher", i.publisher)
+ .put("releaseDate", i.releaseDate)
+ .put("description", i.description)
+ .put("owned", i.owned)
+ .put("size", i.sizeBytes)
+ .put("extra", JSONObject(i.extra))
+ .put("mature", i.mature)
+
+ private fun item(o: JSONObject): CatalogItem {
+ val extra = o.optJSONObject("extra")
+ val map = HashMap()
+ extra?.keys()?.forEach { k -> map[k] = extra.optString(k) }
+ return CatalogItem(
+ store = Store.ITCH,
+ id = o.optString("id"),
+ title = o.optString("title"),
+ imageUrl = o.optString("image").ifBlank { null },
+ tallImageUrl = o.optString("tall").ifBlank { null },
+ tags = o.optString("tags"),
+ isFree = o.optBoolean("free"),
+ hasPrice = o.optBoolean("hasPrice"),
+ finalPrice = o.optString("finalPrice"),
+ originalPrice = o.optString("originalPrice"),
+ discountPercent = o.optInt("discount"),
+ storeUrl = o.optString("url"),
+ developer = o.optString("developer"),
+ publisher = o.optString("publisher"),
+ releaseDate = o.optString("releaseDate"),
+ description = o.optString("description"),
+ owned = o.optBoolean("owned"),
+ sizeBytes = o.optLong("size"),
+ extra = map,
+ mature = o.optBoolean("mature"),
+ )
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt
new file mode 100644
index 000000000..96ec0ae35
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchInstaller.kt
@@ -0,0 +1,372 @@
+package com.droiddeck.launcher.stores.itch
+
+import android.content.Context
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.runtime.GuestCommand
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreExe
+import com.droiddeck.launcher.stores.StoreGameSidecar
+import com.droiddeck.launcher.stores.StoreInstallRoot
+import com.droiddeck.launcher.stores.StoreInstalls
+import com.droiddeck.launcher.stores.StoreNet
+import com.droiddeck.launcher.stores.download.DownloadQueue
+import com.droiddeck.launcher.stores.download.DownloadStage
+import org.apache.commons.compress.archivers.sevenz.SevenZFile
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import org.apache.commons.compress.archivers.zip.ZipFile
+import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
+import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal class ItchInstaller(
+ private val app: Context,
+ private val item: CatalogItem,
+ private val folder: File,
+) : DownloadQueue.DownloadJob {
+ private val cancelled = AtomicBoolean(false)
+
+ override fun run(handle: DownloadQueue.JobHandle): String? {
+ val key = ItchAuth.key(app)
+ if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in))
+ val downloadKeyId = item.extra["downloadKeyId"].orEmpty()
+ if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned))
+ val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') }
+ StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl)
+ handle.stage(DownloadStage.MANIFEST)
+ val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId))
+ ?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
+ val uploadId = upload.optString("id")
+ val filename = ItchUploads.filename(upload)
+ val size = ItchUploads.size(upload)
+ val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
+ scratch.mkdirs()
+ val archive = File(scratch, filename.ifBlank { "download.bin" })
+ val exe = try {
+ handle.stage(DownloadStage.DOWNLOAD)
+ download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
+ if (cancelled.get()) return null
+ handle.stage(DownloadStage.INSTALL)
+ install(archive, filename, handle)
+ } catch (t: Throwable) {
+ if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch))
+ throw t
+ }
+ if (exe == null) {
+ if (cancelled.get()) return null
+ StoreInstalls.discard(app, folder, listOf(scratch))
+ throw IllegalStateException(app.getString(R.string.stores_itch_no_exe))
+ }
+ archive.delete()
+ StoreInstalls.deleteTree(scratch)
+ val sidecar = StoreGameSidecar(
+ store = Store.ITCH,
+ id = item.id,
+ title = item.title,
+ exe = exe,
+ installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
+ installedAt = System.currentTimeMillis(),
+ cover = item.imageUrl,
+ hero = item.imageUrl,
+ extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
+ cloud = false,
+ )
+ StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
+ return folder.path
+ }
+
+ override fun cancel(deleteFiles: Boolean) {
+ cancelled.set(true)
+ if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)))
+ }
+
+ private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long,
+ handle: DownloadQueue.JobHandle) {
+ // A partial file is only resumed when it came from this exact upload build.
+ val origin = File(target.path + ".from")
+ val stamp = "$uploadId $version $expected"
+ if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) {
+ target.delete()
+ origin.writeText(stamp)
+ }
+ if (expected > 0 && target.length() == expected) {
+ handle.progress(expected, expected)
+ return
+ }
+ for (attempt in 0..1) {
+ val have = target.length()
+ var conn: HttpURLConnection? = null
+ try {
+ conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 30_000
+ readTimeout = 60_000
+ instanceFollowRedirects = true
+ setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
+ setRequestProperty("Authorization", "Bearer $key")
+ setRequestProperty("Accept", "*/*")
+ if (have > 0) setRequestProperty("Range", "bytes=$have-")
+ }
+ val code = conn.responseCode
+ if (code == 416 && attempt == 0) {
+ target.delete()
+ continue
+ }
+ if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
+ val append = code == 206 && have > 0
+ if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over")
+ var done = if (append) have else 0L
+ val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected
+ // The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput.
+ handle.progress(done, total)
+ val buffer = ByteArray(128 * 1024)
+ conn.inputStream.use { input ->
+ FileOutputStream(target, append).use { out ->
+ while (true) {
+ if (cancelled.get()) return
+ val read = input.read(buffer)
+ if (read < 0) break
+ out.write(buffer, 0, read)
+ done += read
+ handle.progress(done, total)
+ }
+ }
+ }
+ if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
+ return
+ } finally {
+ runCatching { conn?.disconnect() }
+ }
+ }
+ }
+
+ private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? {
+ folder.mkdirs()
+ when (val kind = sniff(archive, filename)) {
+ "zip" -> unzip(archive, handle)
+ "7z" -> sevenZip(archive, handle)
+ "rar" -> unrar(archive, handle)
+ "exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" }))
+ "tar" -> untar(archive, null, handle)
+ "gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
+ else -> copy(archive, File(folder, filename.ifBlank { "game" }))
+ }
+ val hint = filename.substringBeforeLast('.')
+ val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title)
+ if (exe.isBlank()) {
+ handle.log("itch.io: no exe found in ${folder.name}")
+ return null
+ }
+ return exe
+ }
+
+ private fun sniff(file: File, filename: String): String {
+ val extension = filename.substringAfterLast('.', "").lowercase()
+ if (extension == "exe") return "exe"
+ val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull()
+ ?: return extension
+ fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() }
+ return when {
+ at(0, 0x50, 0x4B) -> "zip"
+ at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z"
+ at(0, 0x52, 0x61, 0x72, 0x21) -> "rar"
+ at(0, 0x1F, 0x8B) -> "gz"
+ at(0, 0x42, 0x5A, 0x68) -> "bz2"
+ at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz"
+ at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar"
+ else -> extension
+ }
+ }
+
+ private fun target(name: String): File? {
+ val file = File(folder, name.replace('\\', '/'))
+ val root = folder.canonicalPath
+ val path = file.canonicalPath
+ return if (path == root || path.startsWith(root + File.separator)) file else null
+ }
+
+ // No JVM library reads RAR5; the runtime's bsdtar (libarchive) does.
+ private fun unrar(archive: File, handle: DownloadQueue.JobHandle) {
+ if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar"))
+ val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null
+ else listOf(folder.absolutePath)
+ fun bsdtar(args: List, onLine: (String) -> Unit) =
+ GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line ->
+ if (!line.startsWith("proot ")) onLine(line)
+ }
+ var entries = 0L
+ bsdtar(listOf("-tf", archive.absolutePath)) { entries++ }
+ var done = 0L
+ val tail = ArrayDeque()
+ val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line ->
+ if (line.startsWith("x ")) handle.stageProgress(++done, entries) else {
+ if (tail.size == 5) tail.removeFirst()
+ tail.addLast(line)
+ }
+ }
+ if (cancelled.get()) return
+ if (code != 0) {
+ handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}")
+ throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code))
+ }
+ }
+
+ private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) {
+ val total = archive.length()
+ var written = 0L
+ val buffer = ByteArray(64 * 1024)
+ SevenZFile.builder().setFile(archive).get().use { seven ->
+ while (true) {
+ if (cancelled.get()) return
+ val entry = seven.nextEntry ?: break
+ val file = target(entry.name)
+ if (file == null) {
+ handle.log("itch.io: skipped an entry outside the game folder")
+ continue
+ }
+ if (entry.isDirectory) {
+ file.mkdirs()
+ continue
+ }
+ file.parentFile?.mkdirs()
+ FileOutputStream(file).use { out ->
+ while (true) {
+ if (cancelled.get()) return
+ val read = seven.read(buffer, 0, buffer.size)
+ if (read < 0) break
+ out.write(buffer, 0, read)
+ written += read
+ handle.stageProgress(written, total)
+ }
+ }
+ }
+ }
+ }
+
+ private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? {
+ val total = archive.length()
+ var written = 0L
+ var entries = 0
+ val buffer = ByteArray(64 * 1024)
+ val opened = runCatching {
+ FileInputStream(archive).use { raw ->
+ val stream = when (codec) {
+ "gz" -> GzipCompressorInputStream(BufferedInputStream(raw))
+ "bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
+ "xz" -> XZCompressorInputStream(BufferedInputStream(raw))
+ else -> BufferedInputStream(raw)
+ }
+ stream.use { input ->
+ TarArchiveInputStream(input).use { tar ->
+ while (true) {
+ if (cancelled.get()) return null
+ val entry = tar.nextEntry ?: break
+ entries++
+ val file = target(entry.name)
+ if (file == null) {
+ handle.log("itch.io: skipped an entry outside the game folder")
+ continue
+ }
+ if (entry.isDirectory) {
+ file.mkdirs()
+ continue
+ }
+ file.parentFile?.mkdirs()
+ FileOutputStream(file).use { out ->
+ while (true) {
+ if (cancelled.get()) return null
+ val read = tar.read(buffer, 0, buffer.size)
+ if (read < 0) break
+ out.write(buffer, 0, read)
+ written += read
+ handle.stageProgress(written, total)
+ }
+ }
+ }
+ }
+ }
+ }
+ true
+ }
+ if (opened.isSuccess && entries > 0) return Unit
+ if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar")
+ return null
+ }
+
+ private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) {
+ val name = archive.name.substringBeforeLast('.')
+ val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive")
+ file.parentFile?.mkdirs()
+ val total = archive.length()
+ var written = 0L
+ val buffer = ByteArray(64 * 1024)
+ FileInputStream(archive).use { raw ->
+ val stream = when (codec) {
+ "bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
+ "xz" -> XZCompressorInputStream(BufferedInputStream(raw))
+ else -> GzipCompressorInputStream(BufferedInputStream(raw))
+ }
+ stream.use { input ->
+ FileOutputStream(file).use { out ->
+ while (true) {
+ if (cancelled.get()) return
+ val read = input.read(buffer)
+ if (read < 0) break
+ out.write(buffer, 0, read)
+ written += read
+ handle.stageProgress(written, total)
+ }
+ }
+ }
+ }
+ }
+
+ private fun copy(source: File, target: File) {
+ target.parentFile?.mkdirs()
+ FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } }
+ }
+
+ // Streaming readers stop silently on zips whose data descriptors disagree with the local headers;
+ // the central directory is authoritative.
+ private fun unzip(archive: File, handle: DownloadQueue.JobHandle) {
+ val total = archive.length()
+ var written = 0L
+ val buffer = ByteArray(64 * 1024)
+ ZipFile.builder().setFile(archive).get().use { zip ->
+ for (entry in zip.entries) {
+ if (cancelled.get()) return
+ val target = target(entry.name)
+ if (target == null) {
+ handle.log("itch.io: skipped an entry outside the game folder")
+ continue
+ }
+ if (entry.isDirectory) {
+ target.mkdirs()
+ continue
+ }
+ target.parentFile?.mkdirs()
+ zip.getInputStream(entry).use { input ->
+ FileOutputStream(target).use { out ->
+ while (true) {
+ if (cancelled.get()) return
+ val read = input.read(buffer)
+ if (read < 0) break
+ out.write(buffer, 0, read)
+ written += read
+ handle.stageProgress(written, total)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private companion object
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt
new file mode 100644
index 000000000..e4b7cba83
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchLibrary.kt
@@ -0,0 +1,162 @@
+package com.droiddeck.launcher.stores.itch
+
+import android.content.Context
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoresState
+import com.droiddeck.launcher.stores.cleanStoreText
+import com.droiddeck.launcher.stores.mergeItems
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.math.roundToInt
+
+internal object ItchLibrary {
+ private const val KEY_CACHE = "library_cache"
+ private const val KEY_AT = "library_synced_at"
+ private const val MAX_PAGES = 20
+ private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
+
+ private val running = AtomicBoolean(false)
+
+ fun cached(context: Context): List {
+ val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
+ return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList())
+ }
+
+ fun sync(context: Context, force: Boolean) {
+ val app = context.applicationContext
+ if (!running.compareAndSet(false, true)) return
+ try {
+ val key = ItchAuth.key(app)
+ val cache = cached(app)
+ val prefs = ItchPrefs.get(app)
+ if (key.isBlank()) {
+ StoresState.post {
+ StoresState.status.remove(Store.ITCH)
+ if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
+ StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in)
+ }
+ return
+ }
+ val quiet = cache.isNotEmpty() && !force
+ if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) {
+ StoresState.post { StoresState.library[Store.ITCH] = cache }
+ return
+ }
+ if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) }
+ val items = fetch(key)
+ if (items == null) {
+ StoresState.post {
+ StoresState.status.remove(Store.ITCH)
+ if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
+ StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed)
+ }
+ return
+ }
+ prefs.edit()
+ .putString(KEY_CACHE, ItchCatalog.array(items).toString())
+ .putLong(KEY_AT, System.currentTimeMillis())
+ .apply()
+ StoresState.post {
+ StoresState.status.remove(Store.ITCH)
+ StoresState.problems.remove(Store.ITCH)
+ StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items)
+ }
+ StoresState.logLine("itch.io: ${items.size} owned games")
+ } finally {
+ running.set(false)
+ }
+ }
+
+ fun size(context: Context, item: CatalogItem): Long {
+ val key = ItchAuth.key(context.applicationContext)
+ if (key.isBlank()) return 0L
+ val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L }
+ val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L }
+ val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L
+ return ItchUploads.size(upload)
+ }
+
+ private fun fetch(key: String): List? {
+ val out = ArrayList()
+ var page = 1
+ var keys = 0
+ while (page <= MAX_PAGES) {
+ val body = ItchApi.ownedKeys(key, page) ?: return null
+ val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out
+ if (list.length() == 0) break
+ out.addAll(items(list))
+ keys += list.length()
+ page++
+ }
+ val games = out.distinctBy { it.id }
+ StoresState.logLine("itch.io: $keys download keys, ${games.size} games")
+ return games
+ }
+
+ internal fun items(keys: JSONArray): List {
+ val out = ArrayList(keys.length())
+ for (i in 0 until keys.length()) {
+ val entry = keys.optJSONObject(i) ?: continue
+ val game = entry.optJSONObject("game") ?: continue
+ item(game, entry.optString("id"))?.let { out.add(it) }
+ }
+ return out
+ }
+
+ private fun JSONObject.text(snake: String, camel: String): String =
+ optString(snake).ifBlank { optString(camel) }
+
+ internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? {
+ val gameId = game.optLong("id")
+ if (gameId <= 0L) return null
+ val user = game.optJSONObject("user")
+ val url = game.optString("url").ifBlank {
+ val owner = user?.optString("username").orEmpty()
+ if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId")
+ }
+ val traits = game.optJSONArray("traits")?.let { arr ->
+ (0 until arr.length()).map { arr.optString(it) }
+ }.orEmpty()
+ val platforms = game.optJSONObject("platforms")
+ val sale = game.optJSONObject("sale")
+ val rate = sale?.optDouble("rate", 0.0) ?: 0.0
+ val cents = game.optLong("min_price", game.optLong("minPrice"))
+ val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits))
+ val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0
+ val price = if (canBuy && cents > 0) money(cents) else ""
+ val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else ""
+ val tags = buildList {
+ if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows")
+ if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux")
+ if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS")
+ }.joinToString(", ")
+ val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") }
+ val tall = game.text("still_cover_url", "stillCoverUrl")
+ val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() }
+ return CatalogItem(
+ store = Store.ITCH,
+ id = ItchCatalog.slug(url).ifBlank { gameId.toString() },
+ title = game.optString("title").ifBlank { "itch.io game $gameId" },
+ imageUrl = cover.ifBlank { null },
+ tallImageUrl = tall.ifBlank { null },
+ tags = tags,
+ isFree = price.isEmpty(),
+ hasPrice = price.isNotEmpty(),
+ finalPrice = salePrice.ifEmpty { price },
+ originalPrice = if (salePrice.isNotEmpty()) price else "",
+ discountPercent = if (salePrice.isNotEmpty()) discount else 0,
+ storeUrl = url,
+ developer = author,
+ publisher = author,
+ releaseDate = game.text("published_at", "publishedAt").take(10),
+ description = cleanStoreText(game.text("short_text", "shortText")),
+ owned = downloadKeyId.isNotBlank(),
+ extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()),
+ )
+ }
+
+ private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt
new file mode 100644
index 000000000..43470e47a
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchPrefs.kt
@@ -0,0 +1,11 @@
+package com.droiddeck.launcher.stores.itch
+
+import android.content.Context
+import android.content.SharedPreferences
+
+object ItchPrefs {
+ const val FILE = "stores.itch"
+
+ @JvmStatic
+ fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt
new file mode 100644
index 000000000..0fd783ed6
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchShelves.kt
@@ -0,0 +1,199 @@
+package com.droiddeck.launcher.stores.itch
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import android.util.Xml
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreNet
+import com.droiddeck.launcher.stores.StoreShelves
+import com.droiddeck.launcher.stores.cleanStoreText
+import org.xmlpull.v1.XmlPullParser
+import java.io.StringReader
+import java.net.HttpURLConnection
+import java.net.URL
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+internal object ItchShelves {
+ private const val TAG = "ItchShelves"
+ private const val KEY = "shelves_cache"
+ private const val KEY_AT = "shelves_at"
+ private const val FRESH_MS = 3 * 60 * 60 * 1000L
+ private const val GAP_MS = 1_200L
+ private const val RETRY_MS = 20_000L
+
+ private val FEEDS = listOf(
+ "https://itch.io/games/newest.xml",
+ "https://itch.io/games/on-sale.xml",
+ "https://itch.io/games/price-free.xml",
+ "https://itch.io/games/top-rated.xml",
+ )
+ private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile")
+
+ @Volatile
+ private var memory: StoreShelves? = null
+
+ fun cached(context: Context): StoreShelves? {
+ memory?.let { return it }
+ val stored = stored(ItchPrefs.get(context.applicationContext))
+ if (stored != null) memory = stored
+ return stored
+ }
+
+ fun featured(context: Context, force: Boolean): StoreShelves? {
+ val prefs = ItchPrefs.get(context.applicationContext)
+ val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS
+ if (fresh && !force) {
+ cached(context)?.let { return it }
+ }
+ val fetched = fetch()
+ if (fetched != null) {
+ memory = fetched
+ prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply()
+ return fetched
+ }
+ return cached(context)
+ }
+
+ fun search(context: Context, query: String): List {
+ val key = ItchAuth.key(context.applicationContext)
+ if (key.isBlank()) return emptyList()
+ val out = ArrayList()
+ for (page in 1..2) {
+ val games = ItchApi.search(key, query, page) ?: break
+ if (games.length() == 0) break
+ for (i in 0 until games.length()) {
+ val game = games.optJSONObject(i) ?: continue
+ ItchLibrary.item(game, "")?.let { out.add(it) }
+ }
+ if (games.length() < 30) break
+ Thread.sleep(GAP_MS)
+ }
+ return out.distinctBy { it.id }
+ }
+
+ private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null))
+
+ private fun fetch(): StoreShelves? {
+ val lists = ArrayList>(FEEDS.size)
+ var answered = false
+ for ((index, url) in FEEDS.withIndex()) {
+ if (index > 0) Thread.sleep(GAP_MS)
+ var (code, body) = open(url)
+ if (code == 429) {
+ Log.w(TAG, "feed 429; backing off")
+ Thread.sleep(RETRY_MS)
+ val again = open(url)
+ code = again.first
+ body = again.second
+ if (code == 429) {
+ Log.w(TAG, "feed still 429; keeping the cached shelves")
+ lists.add(emptyList())
+ break
+ }
+ }
+ if (code in 200..299 && body != null) {
+ answered = true
+ lists.add(parse(body))
+ } else {
+ lists.add(emptyList())
+ }
+ }
+ if (!answered) return null
+ while (lists.size < FEEDS.size) lists.add(emptyList())
+ val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12)
+ val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art)
+ return shelves.takeUnless { it.isEmpty }
+ }
+
+ private fun open(url: String): Pair {
+ var conn: HttpURLConnection? = null
+ return try {
+ conn = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 20_000
+ readTimeout = 20_000
+ instanceFollowRedirects = true
+ setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
+ setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*")
+ }
+ val code = conn.responseCode
+ code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null
+ } catch (e: Exception) {
+ Log.w(TAG, "feed ${e.javaClass.simpleName}")
+ 0 to null
+ } finally {
+ runCatching { conn?.disconnect() }
+ }
+ }
+
+ internal fun parse(xml: String): List {
+ val out = ArrayList()
+ val parser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(StringReader(xml))
+ var event = parser.eventType
+ var field: String? = null
+ var current: HashMap? = null
+ while (event != XmlPullParser.END_DOCUMENT) {
+ when (event) {
+ XmlPullParser.START_TAG -> {
+ if (parser.name == "item") current = HashMap()
+ field = parser.name
+ }
+ XmlPullParser.TEXT -> {
+ val map = current
+ val name = field
+ if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text
+ }
+ XmlPullParser.END_TAG -> {
+ if (parser.name == "item") {
+ current?.let { item(it)?.let(out::add) }
+ current = null
+ }
+ field = null
+ }
+ }
+ event = parser.next()
+ }
+ return out
+ }
+
+ internal fun item(m: Map): CatalogItem? {
+ val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() }
+ if (link.isBlank()) return null
+ val title = m["plainTitle"]?.trim().orEmpty()
+ .ifBlank { m["title"].orEmpty().substringBefore(" [").trim() }
+ .ifBlank { ItchCatalog.slug(link) }
+ val price = m["price"]?.trim().orEmpty()
+ val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true)
+ val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty())
+ .map { it.groupValues[1].trim() }
+ .filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS }
+ .joinToString(", ")
+ return CatalogItem(
+ store = Store.ITCH,
+ id = ItchCatalog.slug(link),
+ title = title,
+ imageUrl = m["imageurl"]?.trim()?.ifBlank { null },
+ tags = tags,
+ isFree = free,
+ hasPrice = !free,
+ finalPrice = if (free) "" else price,
+ originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(),
+ discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0,
+ storeUrl = link,
+ releaseDate = date(m["pubDate"]),
+ description = cleanStoreText(m["description"]),
+ )
+ }
+
+ private fun date(raw: String?): String {
+ val text = raw?.trim().orEmpty()
+ if (text.isBlank()) return ""
+ return runCatching {
+ SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text)
+ }.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt
new file mode 100644
index 000000000..773ad22c3
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchUploads.kt
@@ -0,0 +1,36 @@
+package com.droiddeck.launcher.stores.itch
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+internal object ItchUploads {
+ private val GAME_TYPES = setOf("default", "flash", "unity", "java", "html", "")
+ private val PLATFORM_TRAITS = setOf("p_windows", "p_linux", "p_osx", "p_android")
+
+ fun windows(uploads: JSONArray?): JSONObject? {
+ if (uploads == null) return null
+ val all = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) }
+ .filter { it.optString("storage") != "external" && !it.optBoolean("demo") }
+ if (all.isEmpty()) return null
+ val games = all.filter { it.optString("type") in GAME_TYPES }
+ val preferred = games.ifEmpty { all }
+ val candidates = preferred.filter { windows(it) }.ifEmpty { preferred }
+ return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") }))
+ }
+
+ fun windows(upload: JSONObject): Boolean {
+ val platforms = upload.optJSONObject("platforms")
+ if (platforms != null) {
+ if (platforms.optBoolean("windows")) return true
+ if (platforms.optBoolean("linux") || platforms.optBoolean("osx") || platforms.optBoolean("android")) return false
+ }
+ val traits = upload.optJSONArray("traits") ?: return true
+ val names = (0 until traits.length()).map { traits.optString(it) }
+ if (names.any { it == "p_windows" }) return true
+ return names.none { it in PLATFORM_TRAITS }
+ }
+
+ fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes"))
+
+ fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/')
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt
new file mode 100644
index 000000000..cfeeeb8c4
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ItchKeyDialog.kt
@@ -0,0 +1,115 @@
+package com.droiddeck.launcher.ui
+
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoresState
+import com.droiddeck.launcher.stores.itch.ItchApi
+import com.droiddeck.launcher.stores.itch.ItchAuth
+import com.droiddeck.launcher.stores.itch.ItchLibrary
+
+private fun copiedKey(ctx: Context): String? {
+ val clipboard = ctx.getSystemService(ClipboardManager::class.java)
+ val text = runCatching { clipboard?.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() ?: return null
+ return ItchAuth.candidates(text).firstOrNull()
+}
+
+@Composable
+internal fun ItchKeyDialog(onDismiss: () -> Unit) {
+ val ctx = LocalContext.current
+ var key by remember { mutableStateOf(copiedKey(ctx).orEmpty()) }
+ var busy by remember { mutableStateOf(false) }
+ var rejected by remember { mutableStateOf(false) }
+
+ val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
+ DisposableEffect(lifecycle) {
+ val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
+ if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && key.isBlank()) {
+ copiedKey(ctx)?.let { key = it }
+ }
+ }
+ lifecycle.addObserver(observer)
+ onDispose { lifecycle.removeObserver(observer) }
+ }
+
+ fun submit() {
+ val raw = key.trim()
+ if (raw.isEmpty() || busy) return
+ busy = true
+ rejected = false
+ Thread({
+ val user = ItchAuth.verify(raw)
+ if (user == null) {
+ StoresState.post { busy = false; rejected = true }
+ return@Thread
+ }
+ ItchAuth.save(ctx, raw, user)
+ StoresState.post { busy = false; onDismiss() }
+ StoresState.refresh(ctx)
+ ItchLibrary.sync(ctx, true)
+ }, "itch-key").start()
+ }
+
+ AlertDialog(
+ onDismissRequest = { if (!busy) onDismiss() },
+ title = { Text(Store.ITCH.label) },
+ text = {
+ Column {
+ AdbTextField(
+ value = key,
+ onValueChange = { key = it; rejected = false },
+ label = stringResource(R.string.stores_itch_key),
+ keyboardType = KeyboardType.Ascii,
+ imeAction = ImeAction.Done,
+ focusOnShow = true,
+ onDone = { submit() },
+ )
+ Row(horizontalArrangement = Arrangement.Start) {
+ TextButton(onClick = {
+ ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)))
+ }) { Text(stringResource(R.string.stores_itch_get_key)) }
+ }
+ if (rejected) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.stores_itch_key_rejected),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(enabled = key.isNotBlank() && !busy, onClick = { submit() }) {
+ Text(stringResource(R.string.game_env_save))
+ }
+ },
+ dismissButton = {
+ TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
+ },
+ )
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt
index ec12d19af..8a2222017 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt
@@ -29,6 +29,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
+ Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
@@ -40,6 +41,7 @@ internal fun sourceLabel(source: String): String = when (source) {
Store.GOG.id -> Store.GOG.label
Store.EPIC.id -> Store.EPIC.label
Store.AMAZON.id -> Store.AMAZON.label
+ Store.ITCH.id -> Store.ITCH.label
else -> stringResource(R.string.source_added)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt
index e61130501..81a2fee3d 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt
@@ -182,10 +182,7 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
private fun InterruptedRow(item: com.droiddeck.launcher.stores.CatalogItem) {
val ctx = LocalContext.current
ArtStrip(item.imageUrl ?: item.tallImageUrl, item.title) {
- Text(item.title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
- SourceChip(item.store.id, small = true)
- Text(stringResource(R.string.stores_dl_interrupted), fontSize = 12.sp, color = Color(0xFFFFC24D), maxLines = 1)
- Spacer(Modifier.weight(1f))
+ StripLabel(item.title, item.store.id, stringResource(R.string.stores_dl_interrupted), Color(0xFFFFC24D))
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { StoresState.requestInstall(ctx, item) }
CancelAction { StoresState.clearUnfinished(ctx, item) }
}
@@ -207,14 +204,20 @@ private fun ArtStrip(cover: String?, name: String, content: @Composable androidx
}
}
+@Composable
+private fun androidx.compose.foundation.layout.RowScope.StripLabel(title: String, store: String, status: String, statusColor: Color) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) {
+ Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
+ SourceChip(store, small = true)
+ Text(status, fontSize = 12.sp, color = statusColor, maxLines = 1)
+ }
+}
+
@Composable
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) {
val ctx = LocalContext.current
ArtStrip(d.cover, d.name) {
- Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
- SourceChip(d.store.id, small = true)
- Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1)
- Spacer(Modifier.weight(1f))
+ StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f))
if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) {
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
index e9c0e39ac..883709667 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
@@ -576,13 +576,14 @@ private fun StoreChips(
) { onStore(store, rootBounds[store.id]) }
}
StoreChip(
- label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
- modifier = Modifier.weight(1f).onGloballyPositioned {
+ label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
+ modifier = Modifier.onGloballyPositioned {
bounds[DOWNLOADS] = it.boundsInParent()
StoresMotion.downloadsChip = it.boundsInRoot()
},
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
trail = if (active > 0) { { CountPill(active) } } else null,
+ description = stringResource(R.string.stores_tab_downloads),
onFocused = onFocusChip,
) { onDownloads() }
var cogAt by remember { mutableStateOf(null) }
@@ -596,7 +597,7 @@ private fun StoreChips(
@Composable
private fun StoreChip(
- label: String, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
+ label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
trail: (@Composable () -> Unit)? = null, description: String? = null,
onFocused: (String?) -> Unit = {}, onClick: () -> Unit,
) {
@@ -634,7 +635,7 @@ private fun StoreChip(
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) {
Box(Modifier.pulseRing(picked, mark), contentAlignment = Alignment.Center) { lead() }
}
- Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ if (label != null) Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
trail?.invoke()
}
}
@@ -724,11 +725,16 @@ private fun SignInButton(store: Store, large: Boolean = false) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val placed = remember { arrayOfNulls(1) }
+ var askKey by remember { mutableStateOf(false) }
+ if (askKey) ItchKeyDialog { askKey = false }
Box(Modifier.onGloballyPositioned { placed[0] = it }) {
PrimaryButton(stringResource(R.string.stores_signin), main = true, large = large, icon = if (large) Icons.AutoMirrored.Filled.Login else null) {
- val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
- if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
- else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
+ if (store == Store.ITCH) askKey = true
+ else {
+ val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
+ if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
+ else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
+ }
}
}
}
@@ -752,13 +758,16 @@ private fun Storefront(
val colors = MaterialTheme.colorScheme
val library = StoresState.library[store] ?: emptyList()
val shelves = StoresState.shelves[store]
+ val found = StoresState.found[store] ?: emptyList()
val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) }
val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } }
val q = query.trim().lowercase()
- fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
+ val foundKeys = remember(found) { found.map { it.key }.toSet() }
+ fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
fun shelf(l: List?) = l?.filter(::shown)
- val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
+ val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
+ LaunchedEffect(store, q) { StoresState.search(ctx, store, q) }
val installedOnly = tab == "installed"
val loaded = StoresState.library.containsKey(store)
val showGrid = tab != "store" || q.isNotEmpty()
@@ -998,6 +1007,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1)
Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD)
Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800)
+ Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
null -> return base
}
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index fcc1b2258..90a3fc96e 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -2045,6 +2045,16 @@
شراء · %1$s
مثبَّتة
في المكتبة
+ مفتاح API
+ الحصول على مفتاح
+ تم رفض المفتاح
+ ليس في مكتبة itch.io الخاصة بك
+ لا يوجد تنزيل لنظام Windows لهذه اللعبة
+ لم يتم العثور على لعبة Windows في التنزيل
+ تعذر استخراج التنزيل (رمز الخروج %1$d)
+ لم يستجب itch.io
+ أرشيفات %1$s غير مدعومة
+ استجاب itch.io بـ HTTP %1$d
مجاني
استئناف
تحديث
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 877992f59..31e1da902 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -2669,6 +2669,16 @@
Convirtiendo %1$s para Steam. Mantén DroidDeck abierto.
Este dispositivo no puede decodificar ese video.
No se pudo importar ese video
+ Clave de API
+ Obtener una clave
+ Clave rechazada
+ No está en tu biblioteca de itch.io
+ Este juego no tiene descarga para Windows
+ No se encontró un juego de Windows en la descarga
+ No se pudo extraer la descarga (salida %1$d)
+ itch.io no respondió
+ Los archivos %1$s no son compatibles
+ itch.io respondió HTTP %1$d
Cámaras Luma Ultra — Experimental
Seguimiento de posición de los Joy-Con
Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento.
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 4e14e9940..bf5096cc1 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -1961,6 +1961,16 @@
Купи · %1$s
Инсталирана
В библиотеката
+ API ключ
+ Вземи ключ
+ Ключът е отказан
+ Не е в библиотеката ви в itch.io
+ Няма изтегляне за Windows за тази игра
+ В изтеглянето не е намерена игра за Windows
+ Изтеглянето не можа да бъде разархивирано (код %1$d)
+ itch.io не отговори
+ Архивите %1$s не се поддържат
+ itch.io отговори HTTP %1$d
Безплатна
Продължи
Обнови
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 366be9edc..086a4dc05 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -2003,6 +2003,16 @@
Koupit · %1$s
Nainstalováno
V knihovně
+ Klíč API
+ Získat klíč
+ Klíč odmítnut
+ Není ve vaší knihovně itch.io
+ Pro tuto hru není ke stažení verze pro Windows
+ Ve stahování nebyla nalezena hra pro Windows
+ Stažený soubor nelze rozbalit (kód %1$d)
+ itch.io neodpovědělo
+ Archivy %1$s nejsou podporovány
+ itch.io odpovědělo HTTP %1$d
Zdarma
Pokračovat
Obnovit
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 46d435398..e73f2a773 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -1961,6 +1961,16 @@
Køb · %1$s
Installeret
I biblioteket
+ API-nøgle
+ Hent en nøgle
+ Nøgle afvist
+ Ikke i dit itch.io-bibliotek
+ Ingen Windows-download til dette spil
+ Der blev ikke fundet et Windows-spil i downloaden
+ Downloaden kunne ikke udpakkes (kode %1$d)
+ itch.io svarede ikke
+ %1$s-arkiver understøttes ikke
+ itch.io svarede HTTP %1$d
Gratis
Genoptag
Opdater
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 66b18fde8..5c2f7286c 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1950,6 +1950,16 @@
Kaufen · %1$s
Installiert
In der Bibliothek
+ API-Schlüssel
+ Schlüssel holen
+ Schlüssel abgelehnt
+ Nicht in deiner itch.io-Bibliothek
+ Kein Windows-Download für dieses Spiel
+ Im Download wurde kein Windows-Spiel gefunden
+ Download konnte nicht entpackt werden (Code %1$d)
+ itch.io hat nicht geantwortet
+ %1$s-Archive werden nicht unterstützt
+ itch.io antwortete mit HTTP %1$d
Kostenlos
Fortsetzen
Aktualisieren
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 786b4ddb5..aaa0aa8af 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -1961,6 +1961,16 @@
Αγορά · %1$s
Εγκατεστημένο
Στη βιβλιοθήκη
+ Κλειδί API
+ Λήψη κλειδιού
+ Το κλειδί απορρίφθηκε
+ Δεν είναι στη βιβλιοθήκη σας στο itch.io
+ Δεν υπάρχει λήψη για Windows για αυτό το παιχνίδι
+ Δεν βρέθηκε παιχνίδι Windows στη λήψη
+ Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d)
+ Το itch.io δεν απάντησε
+ Τα αρχεία %1$s δεν υποστηρίζονται
+ Το itch.io απάντησε HTTP %1$d
Δωρεάν
Συνέχιση
Ανανέωση
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 7db317747..073d3971f 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1979,6 +1979,16 @@
Comprar · %1$s
Instalado
En la biblioteca
+ Clave de API
+ Obtener una clave
+ Clave rechazada
+ No está en tu biblioteca de itch.io
+ No hay descarga para Windows de este juego
+ No se ha encontrado un juego de Windows en la descarga
+ No se ha podido extraer la descarga (código %1$d)
+ itch.io no respondió
+ Los archivos %1$s no son compatibles
+ itch.io respondió HTTP %1$d
Gratis
Reanudar
Actualizar
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index de265dcab..61a3d124a 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -1961,6 +1961,16 @@
Osta · %1$s
Asennettu
Kirjastossa
+ API-avain
+ Hae avain
+ Avain hylätty
+ Ei itch.io-kirjastossasi
+ Ei Windows-latausta tälle pelille
+ Latauksesta ei löytynyt Windows-peliä
+ Latausta ei voitu purkaa (koodi %1$d)
+ itch.io ei vastannut
+ %1$s-arkistot eivät ole tuettuja
+ itch.io vastasi HTTP %1$d
Ilmainen
Jatka
Päivitä
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 1d09298d5..952fa65cb 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1979,6 +1979,16 @@
Acheter · %1$s
Installé
Dans la bibliothèque
+ Clé d\'API
+ Obtenir une clé
+ Clé refusée
+ Absent de votre bibliothèque itch.io
+ Pas de téléchargement Windows pour ce jeu
+ Aucun jeu Windows trouvé dans le téléchargement
+ Impossible d\'extraire le téléchargement (code %1$d)
+ itch.io n\'a pas répondu
+ Les archives %1$s ne sont pas prises en charge
+ itch.io a répondu HTTP %1$d
Gratuit
Reprendre
Actualiser
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 90c47ff45..691f28d59 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -1961,6 +1961,16 @@
खरीदें · %1$s
इंस्टॉल है
लाइब्रेरी में
+ API कुंजी
+ कुंजी पाएं
+ कुंजी अस्वीकृत
+ आपकी itch.io लाइब्रेरी में नहीं
+ इस गेम के लिए Windows डाउनलोड नहीं है
+ डाउनलोड में कोई Windows गेम नहीं मिला
+ डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d)
+ itch.io ने जवाब नहीं दिया
+ %1$s आर्काइव समर्थित नहीं हैं
+ itch.io ने HTTP %1$d लौटाया
फ़्री
फिर से शुरू करें
रीफ़्रेश करें
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 96cb5ed36..16a054ba4 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -1961,6 +1961,16 @@
Megvétel · %1$s
Telepítve
A könyvtárban
+ API-kulcs
+ Kulcs beszerzése
+ A kulcs elutasítva
+ Nincs az itch.io-könyvtáradban
+ Ehhez a játékhoz nincs Windows-letöltés
+ A letöltésben nem található Windows-játék
+ A letöltés nem bontható ki (kód: %1$d)
+ Az itch.io nem válaszolt
+ A(z) %1$s archívumok nem támogatottak
+ Az itch.io HTTP %1$d választ adott
Ingyenes
Folytatás
Frissítés
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 9ab984dce..0126a527a 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -1940,6 +1940,16 @@
Beli · %1$s
Terinstal
Dalam pustaka
+ Kunci API
+ Dapatkan kunci
+ Kunci ditolak
+ Tidak ada di pustaka itch.io Anda
+ Tidak ada unduhan Windows untuk game ini
+ Tidak ada game Windows di unduhan
+ Tidak dapat mengekstrak unduhan (kode %1$d)
+ itch.io tidak menjawab
+ Arsip %1$s tidak didukung
+ itch.io menjawab HTTP %1$d
Gratis
Lanjutkan
Segarkan
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index cd769f615..a9d7cb606 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1971,6 +1971,16 @@
Acquista · %1$s
Installato
In libreria
+ Chiave API
+ Ottieni una chiave
+ Chiave rifiutata
+ Non è nella tua libreria itch.io
+ Nessun download per Windows di questo gioco
+ Nessun gioco Windows trovato nel download
+ Impossibile estrarre il download (codice %1$d)
+ itch.io non ha risposto
+ Gli archivi %1$s non sono supportati
+ itch.io ha risposto HTTP %1$d
Gratis
Riprendi
Aggiorna
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index b350ede00..f7899fea4 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -1940,6 +1940,16 @@
購入 · %1$s
インストール済み
ライブラリにあり
+ APIキー
+ キーを取得
+ キーが拒否されました
+ itch.io ライブラリにありません
+ このゲームには Windows 版のダウンロードがありません
+ ダウンロードに Windows 版のゲームが見つかりません
+ ダウンロードを展開できませんでした (終了コード %1$d)
+ itch.io から応答がありません
+ %1$s 形式のアーカイブには対応していません
+ itch.io が HTTP %1$d を返しました
無料
再開
更新
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index f076dc4fb..db3418dc1 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1940,6 +1940,16 @@
구매 · %1$s
설치됨
라이브러리에 있음
+ API 키
+ 키 받기
+ 키가 거부되었습니다
+ itch.io 라이브러리에 없습니다
+ 이 게임의 Windows 다운로드가 없습니다
+ 다운로드에서 Windows 게임을 찾을 수 없습니다
+ 다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d)
+ itch.io가 응답하지 않았습니다
+ %1$s 압축 파일은 지원하지 않습니다
+ itch.io가 HTTP %1$d을(를) 반환했습니다
무료
재개
새로 고침
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 344e02520..4cec0534e 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -1940,6 +1940,16 @@
Beli · %1$s
Dipasang
Dalam pustaka
+ Kunci API
+ Dapatkan kunci
+ Kunci ditolak
+ Tiada dalam pustaka itch.io anda
+ Tiada muat turun Windows untuk permainan ini
+ Tiada permainan Windows ditemui dalam muat turun
+ Tidak dapat mengekstrak muat turun (kod %1$d)
+ itch.io tidak menjawab
+ Arkib %1$s tidak disokong
+ itch.io menjawab HTTP %1$d
Percuma
Sambung
Segar semula
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 3b316f00e..058a20cf8 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -1961,6 +1961,16 @@
Kjøp · %1$s
Installert
I biblioteket
+ API-nøkkel
+ Hent en nøkkel
+ Nøkkelen ble avvist
+ Ikke i itch.io-biblioteket ditt
+ Ingen Windows-nedlasting for dette spillet
+ Fant ikke noe Windows-spill i nedlastingen
+ Kunne ikke pakke ut nedlastingen (kode %1$d)
+ itch.io svarte ikke
+ %1$s-arkiver støttes ikke
+ itch.io svarte HTTP %1$d
Gratis
Gjenoppta
Oppdater
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index ec1b209ab..cb5127130 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -1961,6 +1961,16 @@
Kopen · %1$s
Geïnstalleerd
In bibliotheek
+ API-sleutel
+ Sleutel ophalen
+ Sleutel geweigerd
+ Niet in je itch.io-bibliotheek
+ Geen Windows-download voor dit spel
+ Geen Windows-game gevonden in de download
+ Download kan niet worden uitgepakt (code %1$d)
+ itch.io gaf geen antwoord
+ %1$s-archieven worden niet ondersteund
+ itch.io antwoordde HTTP %1$d
Gratis
Hervatten
Vernieuwen
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 35a3afaef..bb7e40dcf 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -2003,6 +2003,16 @@
Kup · %1$s
Zainstalowano
W bibliotece
+ Klucz API
+ Pobierz klucz
+ Klucz odrzucony
+ Nie ma go w Twojej bibliotece itch.io
+ Brak wersji na Windows dla tej gry
+ W pobranych plikach nie znaleziono gry na Windows
+ Nie można rozpakować pobranego pliku (kod %1$d)
+ itch.io nie odpowiedziało
+ Archiwa %1$s nie są obsługiwane
+ itch.io odpowiedziało HTTP %1$d
Bezpłatna
Wznów
Odśwież
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 614ece612..931f557bd 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -1971,6 +1971,16 @@
Comprar · %1$s
Instalado
Na biblioteca
+ Chave de API
+ Obter uma chave
+ Chave rejeitada
+ Não está na sua biblioteca do itch.io
+ Sem transferência para Windows deste jogo
+ Não foi encontrado nenhum jogo para Windows na transferência
+ Não foi possível extrair a transferência (código %1$d)
+ O itch.io não respondeu
+ Ficheiros %1$s não são suportados
+ O itch.io respondeu HTTP %1$d
Grátis
Retomar
Atualizar
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index b5c4889e3..bfc2b4120 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1971,6 +1971,16 @@
Comprar · %1$s
Instalado
Na biblioteca
+ Chave de API
+ Obter uma chave
+ Chave rejeitada
+ Não está na sua biblioteca do itch.io
+ Sem download para Windows deste jogo
+ Nenhum jogo para Windows encontrado no download
+ Não foi possível extrair o download (código %1$d)
+ O itch.io não respondeu
+ Arquivos %1$s não são suportados
+ O itch.io respondeu HTTP %1$d
Grátis
Retomar
Atualizar
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 840d1dfb3..01f2f37f6 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -1982,6 +1982,16 @@
Cumpără · %1$s
Instalat
În bibliotecă
+ Cheie API
+ Obține o cheie
+ Cheie respinsă
+ Nu este în biblioteca ta itch.io
+ Nu există descărcare pentru Windows la acest joc
+ Nu s-a găsit niciun joc Windows în descărcare
+ Descărcarea nu a putut fi extrasă (cod %1$d)
+ itch.io nu a răspuns
+ Arhivele %1$s nu sunt acceptate
+ itch.io a răspuns HTTP %1$d
Gratuit
Reluare
Reîmprospătează
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index f939fd97f..bab12fb06 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -2000,6 +2000,16 @@
Купить · %1$s
Установлено
В библиотеке
+ Ключ API
+ Получить ключ
+ Ключ отклонён
+ Нет в вашей библиотеке itch.io
+ Для этой игры нет загрузки для Windows
+ В загрузке не найдена игра для Windows
+ Не удалось распаковать загрузку (код %1$d)
+ itch.io не ответил
+ Архивы %1$s не поддерживаются
+ itch.io ответил HTTP %1$d
Бесплатно
Продолжить
Обновить
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 58279d36b..33a2024b3 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -1961,6 +1961,16 @@
Köp · %1$s
Installerat
I biblioteket
+ API-nyckel
+ Hämta en nyckel
+ Nyckeln avvisades
+ Inte i ditt itch.io-bibliotek
+ Ingen Windows-nedladdning för det här spelet
+ Inget Windows-spel hittades i nedladdningen
+ Det gick inte att packa upp nedladdningen (kod %1$d)
+ itch.io svarade inte
+ %1$s-arkiv stöds inte
+ itch.io svarade HTTP %1$d
Gratis
Återuppta
Uppdatera
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index f13ce19aa..2d03c33e4 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -1940,6 +1940,16 @@
ซื้อ · %1$s
ติดตั้งแล้ว
อยู่ในไลบรารี
+ คีย์ API
+ รับคีย์
+ คีย์ถูกปฏิเสธ
+ ไม่มีในคลัง itch.io ของคุณ
+ เกมนี้ไม่มีการดาวน์โหลดสำหรับ Windows
+ ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด
+ แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d)
+ itch.io ไม่ตอบสนอง
+ ไม่รองรับไฟล์ %1$s
+ itch.io ตอบกลับ HTTP %1$d
ฟรี
ดำเนินการต่อ
รีเฟรช
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 7a7e40f23..71906dca8 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1950,6 +1950,16 @@
Satın al · %1$s
Yüklü
Kütüphanede
+ API anahtarı
+ Anahtar al
+ Anahtar reddedildi
+ itch.io kütüphanenizde yok
+ Bu oyunun Windows indirmesi yok
+ İndirmede Windows oyunu bulunamadı
+ İndirme ayıklanamadı (kod %1$d)
+ itch.io yanıt vermedi
+ %1$s arşivleri desteklenmiyor
+ itch.io HTTP %1$d yanıtı verdi
Ücretsiz
Sürdür
Yenile
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 6a19b33c9..4a72e62c7 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -2003,6 +2003,16 @@
Купити · %1$s
Встановлено
У бібліотеці
+ Ключ API
+ Отримати ключ
+ Ключ відхилено
+ Немає у вашій бібліотеці itch.io
+ Для цієї гри немає завантаження для Windows
+ У завантаженні не знайдено гри для Windows
+ Не вдалося розпакувати завантаження (код %1$d)
+ itch.io не відповів
+ Архіви %1$s не підтримуються
+ itch.io відповів HTTP %1$d
Безкоштовно
Продовжити
Оновити
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index e1f58f9e8..eb7f8f4f2 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -1940,6 +1940,16 @@
Mua · %1$s
Đã cài
Trong thư viện
+ Khóa API
+ Lấy khóa
+ Khóa bị từ chối
+ Không có trong thư viện itch.io của bạn
+ Không có bản tải Windows cho game này
+ Không tìm thấy trò chơi Windows trong bản tải xuống
+ Không thể giải nén bản tải xuống (mã %1$d)
+ itch.io không phản hồi
+ Không hỗ trợ tệp %1$s
+ itch.io trả về HTTP %1$d
Miễn phí
Tiếp tục
Làm mới
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index caf528050..0c9146a79 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -2016,6 +2016,16 @@
购买 · %1$s
已安装
已在库中
+ API 密钥
+ 获取密钥
+ 密钥被拒绝
+ 不在你的 itch.io 库中
+ 这个游戏没有 Windows 下载
+ 下载内容中未找到 Windows 游戏
+ 无法解压下载内容(代码 %1$d)
+ itch.io 没有响应
+ 不支持 %1$s 压缩包
+ itch.io 返回 HTTP %1$d
免费
继续
刷新
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 252446f85..422d8ee8b 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -1940,6 +1940,16 @@
購買 · %1$s
已安裝
已在收藏庫中
+ API 金鑰
+ 取得金鑰
+ 金鑰被拒絕
+ 不在你的 itch.io 收藏庫中
+ 這個遊戲沒有 Windows 下載
+ 下載內容中找不到 Windows 遊戲
+ 無法解壓下載內容(代碼 %1$d)
+ itch.io 沒有回應
+ 不支援 %1$s 壓縮檔
+ itch.io 回應 HTTP %1$d
免費
繼續
重新整理
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 10865ff6d..59d65bad8 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -1940,6 +1940,16 @@
購買 · %1$s
已安裝
已在收藏庫中
+ API 金鑰
+ 取得金鑰
+ 金鑰被拒絕
+ 不在你的 itch.io 收藏庫中
+ 這個遊戲沒有 Windows 下載
+ 下載內容中找不到 Windows 遊戲
+ 無法解壓縮下載內容(代碼 %1$d)
+ itch.io 沒有回應
+ 不支援 %1$s 壓縮檔
+ itch.io 回應 HTTP %1$d
免費
繼續
重新整理
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 424d8e35b..c1baba801 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2004,6 +2004,16 @@
Buy · %1$s
Installed
In library
+ API key
+ Get a key
+ Key rejected
+ Not in your itch.io library
+ No Windows download for this game
+ No Windows game found in the download
+ Could not extract the download (exit %1$d)
+ itch.io did not answer
+ %1$s archives are not supported
+ itch.io answered HTTP %1$d
Free
Resume
Refresh
From 5970de928e5387f8aa76b161361cb09f6d22e0ed Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 02:06:38 -0400
Subject: [PATCH 014/156] feat: library and store tabs per store with installed
games listed first (#1116)
---
.../launcher/ui/StoreGridNavigation.kt | 17 +-
.../com/droiddeck/launcher/ui/StoresPage.kt | 188 ++++++++----------
app/src/main/res/values-ar/strings.xml | 5 +-
app/src/main/res/values-b+es+419/strings.xml | 5 +-
app/src/main/res/values-bg/strings.xml | 5 +-
app/src/main/res/values-cs/strings.xml | 5 +-
app/src/main/res/values-da/strings.xml | 5 +-
app/src/main/res/values-de/strings.xml | 5 +-
app/src/main/res/values-el/strings.xml | 5 +-
app/src/main/res/values-es/strings.xml | 5 +-
app/src/main/res/values-fi/strings.xml | 5 +-
app/src/main/res/values-fr/strings.xml | 5 +-
app/src/main/res/values-hi/strings.xml | 5 +-
app/src/main/res/values-hu/strings.xml | 5 +-
app/src/main/res/values-in/strings.xml | 5 +-
app/src/main/res/values-it/strings.xml | 5 +-
app/src/main/res/values-ja/strings.xml | 5 +-
app/src/main/res/values-ko/strings.xml | 5 +-
app/src/main/res/values-ms/strings.xml | 5 +-
app/src/main/res/values-nb/strings.xml | 5 +-
app/src/main/res/values-nl/strings.xml | 5 +-
app/src/main/res/values-pl/strings.xml | 5 +-
app/src/main/res/values-pt-rPT/strings.xml | 5 +-
app/src/main/res/values-pt/strings.xml | 5 +-
app/src/main/res/values-ro/strings.xml | 5 +-
app/src/main/res/values-ru/strings.xml | 5 +-
app/src/main/res/values-sv/strings.xml | 5 +-
app/src/main/res/values-th/strings.xml | 5 +-
app/src/main/res/values-tr/strings.xml | 5 +-
app/src/main/res/values-uk/strings.xml | 5 +-
app/src/main/res/values-vi/strings.xml | 5 +-
app/src/main/res/values-zh-rCN/strings.xml | 5 +-
app/src/main/res/values-zh-rHK/strings.xml | 5 +-
app/src/main/res/values-zh-rTW/strings.xml | 5 +-
app/src/main/res/values/strings.xml | 5 +-
.../launcher/ui/StoreGridNavigationTest.kt | 25 +++
.../droiddeck/launcher/ui/StoreTabsTest.kt | 9 +-
37 files changed, 199 insertions(+), 205 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt
index d019bc009..3996d3042 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGridNavigation.kt
@@ -1,6 +1,19 @@
package com.droiddeck.launcher.ui
-internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int): Int? {
+// `split` is the size of a leading section that starts its own rows; 0 means one continuous grid.
+internal fun storeGridNeighbor(index: Int, count: Int, columns: Int, direction: Int, split: Int = 0): Int? {
if (index !in 0 until count || columns < 1 || direction !in listOf(-1, 1)) return null
- return (index + columns * direction).takeIf { it in 0 until count }
+ if (split !in 1 until count) return (index + columns * direction).takeIf { it in 0 until count }
+ val inFirst = index < split
+ val start = if (inFirst) 0 else split
+ val end = if (inFirst) split else count
+ val local = index - start
+ val within = start + local + columns * direction
+ if (within in start until end) return within
+ val column = local % columns
+ return when {
+ inFirst && direction > 0 -> split + minOf(column, count - split - 1)
+ !inFirst && direction < 0 && local < columns -> ((split - 1) / columns) * columns + column.coerceAtMost((split - 1) % columns)
+ else -> null
+ }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
index 883709667..2feda7597 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
@@ -148,9 +148,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine
private const val DOWNLOADS = "downloads"
-private val TABS = listOf("store", "library", "installed")
+private val TABS = listOf("library", "store")
-internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) TABS.drop(1) else TABS
+internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) TABS.take(1) else TABS
internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library"
@@ -214,41 +214,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
}
}
val pal = LocalPalette.current
- fun openModes(st: Store, at: Rect?) {
- if (chip != st.id) { pickChip(st.id); return }
- if (at == null || !StoresState.isSignedIn(st) || StoresState.expired[st] == true) { move("first"); return }
- val modes = tabsFor(st)
- val current = if (chip == st.id) shownTab else null
- var picked = false
- StoresMotion.ask(StepAsk(
- anchor = at, side = StepSide.Right, accent = storePalette(pal, st).signal, items = modes.size,
- onDismiss = { move(if (picked) "first" else "item:storechip:${st.id}") },
- handle = { ChipLabel(st, MaterialTheme.colorScheme.onBackground) },
- ) {
- StoreTheme(st) {
- val library = StoresState.library[st].orEmpty()
- val installedCount = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(st, StoresState.installed, library).size }
- StepList {
- modes.forEachIndexed { i, m ->
- val label = when (m) {
- "store" -> stringResource(R.string.stores_tab_store)
- "installed" -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedCount)
- else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
- }
- val focusHere = if (current != null) m == current else i == 0
- StepChoice(
- label, selected = m == current, enabled = open,
- modifier = Modifier.fillMaxWidth().stepItem(i).then(if (focusHere) Modifier.focusRequester(first) else Modifier),
- ) {
- picked = true
- if (chip != st.id) { chip = st.id; openGame = null; query = "" }
- tab = m
- StoresMotion.fold()
- }
- }
- }
- }
- })
+ fun cycleStore(step: Int) {
+ val ids = Store.entries.map { it.id }
+ val at = ids.indexOf(chip)
+ pickChip(ids[if (at < 0) (if (step > 0) 0 else ids.size - 1) else (at + step + ids.size) % ids.size])
+ }
+ fun openModes(st: Store) {
+ if (chip != st.id) pickChip(st.id) else move("first")
}
BackHandler(enabled = openGame != null) { closeGame() }
val scroll = rememberScrollState()
@@ -326,13 +298,13 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
.bumpers(
- onPrevious = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + t.size - 1) % t.size]) } },
- onNext = { if (openGame == null && chip != DOWNLOADS) { val t = tabsFor(store); switchTab(t[(t.indexOf(shownTab) + 1) % t.size]) } },
+ onPrevious = { if (openGame == null) cycleStore(-1) },
+ onNext = { if (openGame == null) cycleStore(1) },
),
) {
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
StoreTheme(Store.byId(focusedChip ?: chip)) {
- Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
+ Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, _ -> openModes(st) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
}
}
@@ -390,7 +362,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) }
st == null -> {}
!k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) }
- else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) })
+ else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) })
}
}
}
@@ -752,7 +724,7 @@ internal fun StoreLogo(store: Store, size: Dp) {
private fun Storefront(
store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions,
grid: LazyGridState,
- onQuery: (String) -> Unit, onOpen: (String) -> Unit,
+ onQuery: (String) -> Unit, onOpen: (String) -> Unit, onTab: (String) -> Unit,
) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
@@ -768,7 +740,6 @@ private fun Storefront(
fun shelf(l: List?) = l?.filter(::shown)
val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
LaunchedEffect(store, q) { StoresState.search(ctx, store, q) }
- val installedOnly = tab == "installed"
val loaded = StoresState.library.containsKey(store)
val showGrid = tab != "store" || q.isNotEmpty()
val sort = s.storesSort
@@ -782,63 +753,45 @@ private fun Storefront(
val lookup: (CatalogItem) -> Long = { i -> byKey[i.key] ?: 0L }
lookup
}
- val isSizeSort = (tab == "library" || tab == "installed") && (sort == "sizeAsc" || sort == "sizeDesc")
- val candidates = remember(library, installedItems, everything, tab, q, s.storesShowMature) {
- when (tab) {
- "library" -> library.filter(::matches)
- "installed" -> installedItems.filter(::matches)
- else -> everything.filter { matches(it) && shown(it) }
- }
+ val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc")
+ val candidates = remember(library, everything, tab, q, s.storesShowMature) {
+ if (tab == "library") library.filter(::matches) else everything.filter { matches(it) && shown(it) }
}
val candidateKeys = remember(candidates) { candidates.map { it.key } }
var resolvedSizeSortKey by remember(store, tab) { mutableStateOf?>(null) }
val sizeSortPending = isSizeSort && resolvedSizeSortKey != candidateKeys
var stableVisible by remember(store, tab) { mutableStateOf(emptyList()) }
- val resolvedSizes = when {
- !isSizeSort -> emptyMap()
- installedOnly -> com.droiddeck.launcher.stores.StoreSizes.onDisk.toMap()
- else -> com.droiddeck.launcher.stores.StoreSizes.known.toMap()
- }
- val sizeFor = remember(resolvedSizes, installedOnly) {
+ val resolvedSizes = if (isSizeSort) com.droiddeck.launcher.stores.StoreSizes.known.toMap() else emptyMap()
+ val sizeFor = remember(resolvedSizes) {
val resolved = resolvedSizes
- val lookup: (CatalogItem) -> Long = { item ->
- resolved[item.key] ?: if (installedOnly) 0L else item.sizeBytes
- }
+ val lookup: (CatalogItem) -> Long = { item -> resolved[item.key] ?: item.sizeBytes }
lookup
}
- val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor) {
- if (tab == "library" || tab == "installed")
- sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor)
+ val sortedVisible = remember(candidates, tab, sort, lastPlayedFor, installedAtFor, sizeFor, installedKeys) {
+ if (tab == "library") installedFirst(sortCatalogItems(candidates, sort, lastPlayed = lastPlayedFor, installedAt = installedAtFor, size = sizeFor), installedKeys)
else candidates
}
val visible = remember(sizeSortPending, candidates, stableVisible, sortedVisible) {
if (sizeSortPending) retainCatalogOrder(candidates, stableVisible) else sortedVisible
}
+ val installedShown = if (tab == "library") visible.count { it.id in installedKeys } else 0
+ val split = installedShown.takeIf { it in 1 until visible.size } ?: 0
SideEffect {
if (!sizeSortPending && stableVisible != sortedVisible) stableVisible = sortedVisible
}
LaunchedEffect(store, tab, sort, candidates, candidateKeys) {
if (!isSizeSort || resolvedSizeSortKey == candidateKeys) return@LaunchedEffect
val requests = candidates.map { item ->
- val future: CompletableFuture = if (tab == "installed") {
- StoresState.installedGame(store, item.id)?.let {
- com.droiddeck.launcher.stores.StoreSizes.resolveDisk(ctx, item.key, it)
- } ?: CompletableFuture.completedFuture(null)
- } else {
- com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item)
- }
- item to future
+ item to com.droiddeck.launcher.stores.StoreSizes.resolve(ctx, item)
}
CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await()
requests.forEach { (item, future) ->
val bytes = future.getNow(null) ?: return@forEach
- if (tab == "installed") com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key] = bytes
- else if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
+ if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
}
resolvedSizeSortKey = candidateKeys
}
val empty = stringResource(when {
- installedOnly -> R.string.stores_installed_empty
tab == "library" && library.isEmpty() && StoresState.status[store] != null -> R.string.stores_library_loading
else -> R.string.stores_nothing_matches
})
@@ -849,7 +802,7 @@ private fun Storefront(
val scope = rememberCoroutineScope()
val headerItems = 1 + (if (sizeSortPending) 1 else 0) +
(if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0)
- val listMode = s.storesView == "list" && (tab == "library" || tab == "installed")
+ val listMode = s.storesView == "list" && tab == "library"
val card: @Composable (CatalogItem) -> Unit = { item ->
val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced
firstPlaced = true
@@ -861,14 +814,14 @@ private fun Storefront(
else -> 0
}
val index = visible.indexOfFirst { it.key == item.key }
- val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction)
+ val next = storeGridNeighbor(index, visible.size, if (listMode) 1 else columns, direction, split)
when {
direction == 0 || index < 0 -> false
next == null -> direction > 0
else -> {
if (key.action == KeyEvent.ACTION_DOWN) {
val target = "card:${visible[next].key}"
- val targetIndex = headerItems + next
+ val targetIndex = headerItems + next + if (split == 0) 0 else if (next < split) 1 else 2
val visibleTarget = grid.layoutInfo.visibleItemsInfo.any { it.index == targetIndex }
if (!visibleTarget || (focus.attached[target] ?: 0) == 0 ||
runCatching { focus.items.getValue(target).requestFocus() }.isFailure) scope.launch {
@@ -894,21 +847,19 @@ private fun Storefront(
) {
item(key = "header", span = { GridItemSpan(maxLineSpan) }) {
Rise(1) {
+ val tabs = tabsFor(store)
+ val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
+ else stringResource(R.string.stores_tab_library)
ModeHeader(
- when {
- tab == "store" -> stringResource(R.string.stores_tab_store)
- !loaded -> stringResource(if (installedOnly) R.string.stores_tab_installed else R.string.stores_tab_library)
- installedOnly -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedItems.size)
- else -> stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
- },
+ libraryLabel,
query, onQuery,
- placeholder = when {
- tab == "store" -> stringResource(R.string.stores_search_catalog, store.label)
- installedOnly -> stringResource(R.string.stores_search_installed)
- else -> stringResource(R.string.stores_search_library)
- },
+ placeholder = if (tab == "store") stringResource(R.string.stores_search_catalog, store.label) else stringResource(R.string.stores_search_library),
+ lead = if (tabs.size < 2) null else { {
+ val labels = tabs.map { if (it == "store") stringResource(R.string.stores_tab_store) else libraryLabel }
+ HeaderTabs(labels, tabs.indexOf(tab).coerceAtLeast(0)) { onTab(tabs[it]) }
+ } },
) {
- if (tab == "library" || tab == "installed") {
+ if (tab == "library") {
SortButton(s, a)
ViewButton(s, a)
}
@@ -934,8 +885,14 @@ private fun Storefront(
StoresState.status[store]?.let { line -> item(key = "sync", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { SyncBar(line) } } }
StoresState.problems[store]?.let { line -> item(key = "problem", span = { GridItemSpan(maxLineSpan) }) { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(line) } } } }
when (tab) {
- "library", "installed" -> if (!loaded) Unit
- else storeGrid(visible, empty, card)
+ "library" -> if (!loaded) Unit
+ else if (split == 0) storeGrid(visible, empty, card)
+ else {
+ item(key = "installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_installed)) }
+ storeGrid(visible.take(split), empty, card)
+ item(key = "not-installed-label", span = { GridItemSpan(maxLineSpan) }) { SectionLabel(stringResource(R.string.stores_section_not_installed)) }
+ storeGrid(visible.drop(split), empty, card)
+ }
else -> {
if (q.isNotEmpty()) storeGrid(visible, empty, card)
else {
@@ -965,14 +922,48 @@ private suspend fun CompletableFuture.await(): T =
}
@Composable
-internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
+private fun SectionLabel(title: String) {
+ Text(
+ title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp),
+ )
+}
+
+@Composable
+private fun HeaderTabs(labels: List, selected: Int, onSelect: (Int) -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val shape = RoundedCornerShape(8.dp)
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ labels.forEachIndexed { i, label ->
+ val src = remember { MutableInteractionSource() }
+ val hot = rememberHot(src)
+ val pick = { onSelect(i) }
+ Text(
+ label, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1,
+ color = if (i == selected || hot) colors.onBackground else colors.onSurfaceVariant.copy(alpha = 0.7f),
+ modifier = Modifier.clip(shape).glideBorder(hot, shape, pal.signal)
+ .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick)
+ .controllerConfirm(onClick = pick)
+ .padding(horizontal = 4.dp, vertical = 6.dp),
+ )
+ }
+ }
+}
+
+@Composable
+internal fun ModeHeader(
+ title: String, query: String, onQuery: (String) -> Unit, placeholder: String,
+ lead: (@Composable () -> Unit)? = null, trailing: @Composable () -> Unit,
+) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var searching by remember { mutableStateOf(query.isNotEmpty()) }
val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen")
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) {
- Text(
+ if (lead != null) lead()
+ else Text(
title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
)
@@ -994,14 +985,6 @@ internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit,
}
}
-@Composable
-private fun ChipLabel(store: Store, ink: Color) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Box(Modifier.size(12.dp).clip(CircleShape).background(sourceColours(store.id).dot))
- Text(store.shortLabel, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1)
- }
-}
-
internal fun storePalette(base: Palette, store: Store?): Palette {
val (accent, deep) = when (store) {
Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1)
@@ -1228,6 +1211,11 @@ private fun Shelf(title: String, items: List?, card: @Composable (C
}
}
+internal fun installedFirst(items: List, installed: Set): List {
+ val (on, off) = items.partition { it.id in installed }
+ return on + off
+}
+
private fun LazyGridScope.storeGrid(games: List, empty: String, card: @Composable (CatalogItem) -> Unit) {
if (games.isEmpty()) item(key = "empty", span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 90a3fc96e..36caa8aee 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -2010,7 +2010,6 @@
الإعدادات
(لم يتم تسجيل الدخول)
المتجر
- المثبَّتة
المكتبة
الكل
التنزيلات
@@ -2022,10 +2021,8 @@
البقاء مسجَّل الدخول
تسجيل الخروج
البحث في كتالوج %1$s…
- البحث في الألعاب المثبَّتة…
البحث في مكتبتك…
البحث
- لا شيء مثبَّت
جارٍ التحميل
لا توجد ألعاب
جارٍ تحميل المتجر…
@@ -2044,6 +2041,8 @@
عرض على %1$s
شراء · %1$s
مثبَّتة
+ مثبَّتة
+ غير مثبَّت
في المكتبة
مفتاح API
الحصول على مفتاح
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 31e1da902..da88522af 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -1977,7 +1977,6 @@
Ajustes
(sin iniciar sesión)
Tienda
- Instalados
Biblioteca
Todos
Descargas
@@ -1989,10 +1988,8 @@
Mantener la sesión
Cerrar sesión
Buscar en el catálogo de %1$s…
- Buscar juegos instalados…
Buscar en tu biblioteca…
Buscar
- Nada instalado
Cargando
No hay juegos
Cargando la tienda…
@@ -2011,6 +2008,8 @@
Ver en %1$s
Comprar · %1$s
Instalado
+ Instalado
+ No instalada
En la biblioteca
Gratis
Reanudar
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index bf5096cc1..a76a3cd91 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -1926,7 +1926,6 @@
Настройки
(не е вписан)
Магазин
- Инсталирани
Библиотека
Всички
Изтегляния
@@ -1938,10 +1937,8 @@
Остани вписан
Изход
Търсене в каталога на %1$s…
- Търсене в инсталираните игри…
Търсене в твоята библиотека…
Търсене
- Нищо инсталирано
Зареждане
Няма игри
Зареждане на магазина…
@@ -1960,6 +1957,8 @@
Виж в %1$s
Купи · %1$s
Инсталирана
+ Инсталирана
+ Не е инсталирано
В библиотеката
API ключ
Вземи ключ
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 086a4dc05..5ab150110 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -1968,7 +1968,6 @@
Nastavení
(nepřihlášen)
Obchod
- Nainstalované
Knihovna
Vše
Stahování
@@ -1980,10 +1979,8 @@
Zůstat přihlášen
Odhlásit se
Hledat v katalogu %1$s…
- Hledat v nainstalovaných hrách…
Hledat ve vaší knihovně…
Hledat
- Nic není nainstalováno
Načítání
Žádné hry
Načítání obchodu…
@@ -2002,6 +1999,8 @@
Zobrazit na %1$s
Koupit · %1$s
Nainstalováno
+ Nainstalováno
+ Není nainstalováno
V knihovně
Klíč API
Získat klíč
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index e73f2a773..77f4eb7a0 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -1926,7 +1926,6 @@
Indstillinger
(ikke logget ind)
Butik
- Installeret
Bibliotek
Alle
Downloads
@@ -1938,10 +1937,8 @@
Bliv logget ind
Log ud
Søg i %1$s-kataloget…
- Søg i installerede spil…
Søg i dit bibliotek…
Søg
- Intet installeret
Indlæser
Ingen spil
Indlæser butikken…
@@ -1960,6 +1957,8 @@
Se på %1$s
Køb · %1$s
Installeret
+ Installeret
+ Ikke installeret
I biblioteket
API-nøgle
Hent en nøgle
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 5c2f7286c..9a41a6627 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1915,7 +1915,6 @@
Einstellungen
(nicht angemeldet)
Store
- Installiert
Bibliothek
Alle
Downloads
@@ -1927,10 +1926,8 @@
Angemeldet bleiben
Abmelden
Katalog von %1$s durchsuchen…
- Installierte Spiele durchsuchen…
Deine Bibliothek durchsuchen…
Suchen
- Nichts installiert
Wird geladen
Keine Spiele
Store wird geladen…
@@ -1949,6 +1946,8 @@
Auf %1$s ansehen
Kaufen · %1$s
Installiert
+ Installiert
+ Nicht installiert
In der Bibliothek
API-Schlüssel
Schlüssel holen
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index aaa0aa8af..430fb46b0 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -1926,7 +1926,6 @@
Ρυθμίσεις
(μη συνδεδεμένος)
Κατάστημα
- Εγκατεστημένα
Βιβλιοθήκη
Όλα
Λήψεις
@@ -1938,10 +1937,8 @@
Παραμονή συνδεδεμένου
Αποσύνδεση
Αναζήτηση στον κατάλογο %1$s…
- Αναζήτηση εγκατεστημένων παιχνιδιών…
Αναζήτηση στη βιβλιοθήκη σας…
Αναζήτηση
- Τίποτα εγκατεστημένο
Φόρτωση
Δεν υπάρχουν παιχνίδια
Φόρτωση του καταστήματος…
@@ -1960,6 +1957,8 @@
Προβολή στο %1$s
Αγορά · %1$s
Εγκατεστημένο
+ Εγκατεστημένο
+ Δεν είναι εγκατεστημένο
Στη βιβλιοθήκη
Κλειδί API
Λήψη κλειδιού
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 073d3971f..4d6ba89f8 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1944,7 +1944,6 @@
Ajustes
(sin iniciar sesión)
Tienda
- Instalados
Biblioteca
Todos
Descargas
@@ -1956,10 +1955,8 @@
Mantener la sesión
Cerrar sesión
Buscar en el catálogo de %1$s…
- Buscar juegos instalados…
Buscar en tu biblioteca…
Buscar
- Nada instalado
Cargando
No hay juegos
Cargando la tienda…
@@ -1978,6 +1975,8 @@
Ver en %1$s
Comprar · %1$s
Instalado
+ Instalado
+ No instalada
En la biblioteca
Clave de API
Obtener una clave
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 61a3d124a..bd2f9d176 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -1926,7 +1926,6 @@
Asetukset
(ei kirjautunut)
Kauppa
- Asennetut
Kirjasto
Kaikki
Lataukset
@@ -1938,10 +1937,8 @@
Pysy kirjautuneena
Kirjaudu ulos
Hae %1$s-luettelosta…
- Hae asennetuista peleistä…
Hae kirjastostasi…
Hae
- Ei mitään asennettuna
Latautuu
Ei pelejä
Ladataan kauppaa…
@@ -1960,6 +1957,8 @@
Näytä %1$s:ssä
Osta · %1$s
Asennettu
+ Asennettu
+ Ei asennettu
Kirjastossa
API-avain
Hae avain
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 952fa65cb..7d8b89eca 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -1944,7 +1944,6 @@
Paramètres
(non connecté)
Boutique
- Installés
Bibliothèque
Tous
Téléchargements
@@ -1956,10 +1955,8 @@
Rester connecté
Se déconnecter
Rechercher dans le catalogue %1$s…
- Rechercher dans les jeux installés…
Rechercher dans votre bibliothèque…
Rechercher
- Aucun jeu installé
Chargement
Aucun jeu
Chargement de la boutique…
@@ -1978,6 +1975,8 @@
Voir sur %1$s
Acheter · %1$s
Installé
+ Installé
+ Non installé
Dans la bibliothèque
Clé d\'API
Obtenir une clé
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 691f28d59..75adfcb7c 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -1926,7 +1926,6 @@
सेटिंग
(साइन इन नहीं है)
स्टोर
- इंस्टॉल किए
लाइब्रेरी
सभी
डाउनलोड
@@ -1938,10 +1937,8 @@
साइन इन रहें
साइन आउट करें
%1$s के कैटलॉग में सर्च करें…
- इंस्टॉल किए गेम में सर्च करें…
अपनी लाइब्रेरी में सर्च करें…
सर्च
- कुछ इंस्टॉल नहीं है
लोड हो रहा है
कोई गेम नहीं
स्टोर लोड हो रहा है…
@@ -1960,6 +1957,8 @@
%1$s पर देखें
खरीदें · %1$s
इंस्टॉल है
+ इंस्टॉल है
+ इंस्टॉल नहीं है
लाइब्रेरी में
API कुंजी
कुंजी पाएं
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 16a054ba4..fa569cd9b 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -1926,7 +1926,6 @@
Beállítások
(nincs bejelentkezve)
Áruház
- Telepítve
Könyvtár
Összes
Letöltések
@@ -1938,10 +1937,8 @@
Bejelentkezve marad
Kijelentkezés
Keresés a %1$s katalógusban…
- Keresés a telepített játékok között…
Keresés a könyvtárában…
Keresés
- Nincs telepítve semmi
Betöltés
Nincs játék
Az áruház betöltése…
@@ -1960,6 +1957,8 @@
Megtekintés itt: %1$s
Megvétel · %1$s
Telepítve
+ Telepítve
+ Nincs telepítve
A könyvtárban
API-kulcs
Kulcs beszerzése
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 0126a527a..a14662f53 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -1905,7 +1905,6 @@
Setelan
(belum masuk)
Toko
- Terinstal
Pustaka
Semua
Unduhan
@@ -1917,10 +1916,8 @@
Tetap masuk
Keluar
Cari katalog %1$s…
- Cari gim yang terinstal…
Cari pustaka Anda…
Cari
- Belum ada yang terinstal
Memuat
Tidak ada gim
Memuat toko…
@@ -1939,6 +1936,8 @@
Lihat di %1$s
Beli · %1$s
Terinstal
+ Terinstal
+ Belum terinstal
Dalam pustaka
Kunci API
Dapatkan kunci
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index a9d7cb606..301076df7 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1936,7 +1936,6 @@
Impostazioni
(accesso non eseguito)
Negozio
- Installati
Libreria
Tutti
Download
@@ -1948,10 +1947,8 @@
Resta connesso
Esci
Cerca nel catalogo di %1$s…
- Cerca tra i giochi installati…
Cerca nella tua libreria…
Cerca
- Niente di installato
Caricamento
Nessun gioco
Caricamento del negozio…
@@ -1970,6 +1967,8 @@
Visualizza su %1$s
Acquista · %1$s
Installato
+ Installato
+ Non installata
In libreria
Chiave API
Ottieni una chiave
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index f7899fea4..af2e8bba6 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -1905,7 +1905,6 @@
設定
(未ログイン)
ストア
- インストール済み
ライブラリ
すべて
ダウンロード
@@ -1917,10 +1916,8 @@
ログインしたままにする
ログアウト
%1$sのカタログを検索…
- インストール済みのゲームを検索…
ライブラリを検索…
検索
- 何もインストールされていません
読み込み中
ゲームがありません
ストアを読み込み中…
@@ -1939,6 +1936,8 @@
%1$sで見る
購入 · %1$s
インストール済み
+ インストール済み
+ 未インストール
ライブラリにあり
APIキー
キーを取得
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index db3418dc1..aeb066798 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1905,7 +1905,6 @@
설정
(로그인 안 됨)
스토어
- 설치됨
라이브러리
전체
다운로드
@@ -1917,10 +1916,8 @@
로그인 유지
로그아웃
%1$s 카탈로그 검색…
- 설치된 게임 검색…
내 라이브러리 검색…
검색
- 설치된 항목 없음
불러오는 중
게임 없음
스토어 불러오는 중…
@@ -1939,6 +1936,8 @@
%1$s에서 보기
구매 · %1$s
설치됨
+ 설치됨
+ 설치되지 않음
라이브러리에 있음
API 키
키 받기
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 4cec0534e..afd271efc 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -1905,7 +1905,6 @@
Tetapan
(belum log masuk)
Kedai
- Dipasang
Pustaka
Semua
Muat turun
@@ -1917,10 +1916,8 @@
Kekal log masuk
Log keluar
Cari katalog %1$s…
- Cari permainan dipasang…
Cari pustaka anda…
Cari
- Tiada apa dipasang
Memuatkan
Tiada permainan
Memuatkan kedai…
@@ -1939,6 +1936,8 @@
Lihat pada %1$s
Beli · %1$s
Dipasang
+ Dipasang
+ Belum dipasang
Dalam pustaka
Kunci API
Dapatkan kunci
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 058a20cf8..1649f4e4a 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -1926,7 +1926,6 @@
Innstillinger
(ikke innlogget)
Butikk
- Installert
Bibliotek
Alle
Nedlastinger
@@ -1938,10 +1937,8 @@
Fortsett innlogget
Logg ut
Søk i %1$s-katalogen…
- Søk i installerte spill…
Søk i biblioteket ditt…
Søk
- Ingenting installert
Laster
Ingen spill
Laster butikken…
@@ -1960,6 +1957,8 @@
Vis på %1$s
Kjøp · %1$s
Installert
+ Installert
+ Ikke installert
I biblioteket
API-nøkkel
Hent en nøkkel
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index cb5127130..3ac9d8029 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -1926,7 +1926,6 @@
Instellingen
(niet aangemeld)
Winkel
- Geïnstalleerd
Bibliotheek
Alle
Downloads
@@ -1938,10 +1937,8 @@
Aangemeld blijven
Afmelden
Zoeken in de catalogus van %1$s…
- Geïnstalleerde games zoeken…
Zoeken in je bibliotheek…
Zoeken
- Niets geïnstalleerd
Laden
Geen games
Winkel laden…
@@ -1960,6 +1957,8 @@
Bekijken op %1$s
Kopen · %1$s
Geïnstalleerd
+ Geïnstalleerd
+ Niet geïnstalleerd
In bibliotheek
API-sleutel
Sleutel ophalen
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index bb7e40dcf..5828bb5a1 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -1968,7 +1968,6 @@
Ustawienia
(niezalogowano)
Sklep
- Zainstalowane
Biblioteka
Wszystkie
Pobierania
@@ -1980,10 +1979,8 @@
Zostań zalogowany
Wyloguj się
Szukaj w katalogu %1$s…
- Szukaj zainstalowanych gier…
Szukaj w Twojej bibliotece…
Szukaj
- Nic nie zainstalowano
Wczytywanie
Brak gier
Wczytywanie sklepu…
@@ -2002,6 +1999,8 @@
Zobacz w %1$s
Kup · %1$s
Zainstalowano
+ Zainstalowano
+ Nie zainstalowano
W bibliotece
Klucz API
Pobierz klucz
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 931f557bd..eb3dbde1e 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -1936,7 +1936,6 @@
Definições
(sessão não iniciada)
Loja
- Instalados
Biblioteca
Todos
Transferências
@@ -1948,10 +1947,8 @@
Manter sessão iniciada
Terminar sessão
Pesquisar no catálogo %1$s…
- Pesquisar jogos instalados…
Pesquisar na sua biblioteca…
Pesquisar
- Nada instalado
A carregar
Nenhum jogo
A carregar a loja…
@@ -1970,6 +1967,8 @@
Ver em %1$s
Comprar · %1$s
Instalado
+ Instalado
+ Não instalado
Na biblioteca
Chave de API
Obter uma chave
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index bfc2b4120..9119ead1b 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1936,7 +1936,6 @@
Configurações
(sem login)
Loja
- Instalados
Biblioteca
Todos
Downloads
@@ -1948,10 +1947,8 @@
Continuar conectado
Sair
Pesquisar no catálogo de %1$s…
- Pesquisar jogos instalados…
Pesquisar na sua biblioteca…
Pesquisar
- Nada instalado
Carregando
Nenhum jogo
Carregando a loja…
@@ -1970,6 +1967,8 @@
Ver em %1$s
Comprar · %1$s
Instalado
+ Instalado
+ Não instalado
Na biblioteca
Chave de API
Obter uma chave
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 01f2f37f6..e16c6174a 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -1947,7 +1947,6 @@
Setări
(neautentificat)
Magazin
- Instalate
Bibliotecă
Toate
Descărcări
@@ -1959,10 +1958,8 @@
Rămâi autentificat
Deconectare
Caută în catalogul %1$s…
- Caută jocurile instalate…
Caută în biblioteca ta…
Căutare
- Nimic instalat
Se încarcă
Niciun joc
Se încarcă magazinul…
@@ -1981,6 +1978,8 @@
Vezi pe %1$s
Cumpără · %1$s
Instalat
+ Instalat
+ Neinstalat
În bibliotecă
Cheie API
Obține o cheie
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index bab12fb06..4c3e71733 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1965,7 +1965,6 @@
Настройки
(вход не выполнен)
Магазин
- Установлено
Библиотека
Все
Загрузки
@@ -1977,10 +1976,8 @@
Не выходить
Выйти
Поиск в каталоге %1$s…
- Поиск установленных игр…
Поиск в библиотеке…
Поиск
- Ничего не установлено
Загрузка
Нет игр
Загрузка магазина…
@@ -1999,6 +1996,8 @@
Открыть в %1$s
Купить · %1$s
Установлено
+ Установлено
+ Не установлено
В библиотеке
Ключ API
Получить ключ
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 33a2024b3..39f63eef1 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -1926,7 +1926,6 @@
Inställningar
(inte inloggad)
Butik
- Installerade
Bibliotek
Alla
Nedladdningar
@@ -1938,10 +1937,8 @@
Fortsätt vara inloggad
Logga ut
Sök i %1$s-katalogen…
- Sök installerade spel…
Sök i ditt bibliotek…
Sök
- Inget installerat
Läser in
Inga spel
Läser in butiken…
@@ -1960,6 +1957,8 @@
Visa på %1$s
Köp · %1$s
Installerat
+ Installerat
+ Inte installerad
I biblioteket
API-nyckel
Hämta en nyckel
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 2d03c33e4..ced2fb961 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -1905,7 +1905,6 @@
การตั้งค่า
(ยังไม่ลงชื่อเข้าใช้)
สโตร์
- ติดตั้งแล้ว
ไลบรารี
ทั้งหมด
การดาวน์โหลด
@@ -1917,10 +1916,8 @@
ลงชื่อเข้าใช้ต่อไป
ลงชื่อออก
ค้นหาแคตตาล็อกของ %1$s…
- ค้นหาเกมที่ติดตั้งไว้…
ค้นหาไลบรารีของคุณ…
ค้นหา
- ไม่มีสิ่งที่ติดตั้งไว้
กำลังโหลด
ไม่มีเกม
กำลังโหลดสโตร์…
@@ -1939,6 +1936,8 @@
ดูบน %1$s
ซื้อ · %1$s
ติดตั้งแล้ว
+ ติดตั้งแล้ว
+ ยังไม่ติดตั้ง
อยู่ในไลบรารี
คีย์ API
รับคีย์
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 71906dca8..42da82a97 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1915,7 +1915,6 @@
Ayarlar
(oturum açılmadı)
Mağaza
- Yüklü
Kütüphane
Tümü
İndirmeler
@@ -1927,10 +1926,8 @@
Oturumu açık tut
Oturumu kapat
%1$s kataloğunda ara…
- Yüklü oyunlarda ara…
Kütüphanenizde ara…
Ara
- Yüklü bir şey yok
Yükleniyor
Oyun yok
Mağaza yükleniyor…
@@ -1949,6 +1946,8 @@
%1$s\'te görüntüle
Satın al · %1$s
Yüklü
+ Yüklü
+ Yüklü değil
Kütüphanede
API anahtarı
Anahtar al
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 4a72e62c7..03eed1e71 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -1968,7 +1968,6 @@
Налаштування
(вхід не виконано)
Магазин
- Встановлені
Бібліотека
Усі
Завантаження
@@ -1980,10 +1979,8 @@
Не виходити
Вийти
Пошук у каталозі %1$s…
- Пошук встановлених ігор…
Пошук у вашій бібліотеці…
Пошук
- Нічого не встановлено
Завантаження
Немає ігор
Завантаження магазину…
@@ -2002,6 +1999,8 @@
Переглянути на %1$s
Купити · %1$s
Встановлено
+ Встановлено
+ Не встановлено
У бібліотеці
Ключ API
Отримати ключ
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index eb7f8f4f2..9a49e869b 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -1905,7 +1905,6 @@
Thiết lập
(chưa đăng nhập)
Cửa hàng
- Đã cài
Thư viện
Tất cả
Tải xuống
@@ -1917,10 +1916,8 @@
Vẫn đăng nhập
Đăng xuất
Tìm trong danh mục %1$s…
- Tìm game đã cài…
Tìm trong thư viện của bạn…
Tìm kiếm
- Chưa cài gì
Đang tải
Không có game
Đang tải store…
@@ -1939,6 +1936,8 @@
Xem trên %1$s
Mua · %1$s
Đã cài
+ Đã cài
+ Chưa cài
Trong thư viện
Khóa API
Lấy khóa
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 0c9146a79..65896e07e 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1981,7 +1981,6 @@
设置
(未登录)
商店
- 已安装
库
全部
下载
@@ -1993,10 +1992,8 @@
保持登录
退出登录
搜索 %1$s 目录…
- 搜索已安装的游戏…
搜索你的库…
搜索
- 尚未安装任何内容
加载中
没有游戏
正在加载商店…
@@ -2015,6 +2012,8 @@
在 %1$s 上查看
购买 · %1$s
已安装
+ 已安装
+ 未安装
已在库中
API 密钥
获取密钥
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 422d8ee8b..1618d8501 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -1905,7 +1905,6 @@
設定
(未登入)
商店
- 已安裝
收藏庫
全部
下載
@@ -1917,10 +1916,8 @@
保持登入
登出
搜尋 %1$s 目錄…
- 搜尋已安裝的遊戲…
搜尋你的收藏庫…
搜尋
- 未安裝任何遊戲
正在載入
沒有遊戲
正在載入商店…
@@ -1939,6 +1936,8 @@
在 %1$s 查看
購買 · %1$s
已安裝
+ 已安裝
+ 未安裝
已在收藏庫中
API 金鑰
取得金鑰
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 59d65bad8..04dc2a74e 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -1905,7 +1905,6 @@
設定
(未登入)
商店
- 已安裝
收藏庫
全部
下載
@@ -1917,10 +1916,8 @@
保持登入
登出
搜尋 %1$s 目錄…
- 搜尋已安裝的遊戲…
搜尋你的收藏庫…
搜尋
- 尚未安裝任何內容
正在載入
沒有遊戲
正在載入商店…
@@ -1939,6 +1936,8 @@
在 %1$s 檢視
購買 · %1$s
已安裝
+ 已安裝
+ 未安裝
已在收藏庫中
API 金鑰
取得金鑰
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c1baba801..44fb4fc2a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1969,7 +1969,6 @@
Settings
(not signed in)
Store
- Installed
Library
All
Downloads
@@ -1981,10 +1980,8 @@
Stay signed in
Sign out
Search the %1$s catalog…
- Search installed games…
Search your library…
Search
- Nothing installed
Loading
No games
Loading the store…
@@ -2003,6 +2000,8 @@
View on %1$s
Buy · %1$s
Installed
+ Installed
+ Not installed
In library
API key
Get a key
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt
index 4f0b9d421..ad1b13df7 100644
--- a/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreGridNavigationTest.kt
@@ -26,4 +26,29 @@ class StoreGridNavigationTest {
assertEquals(5, storeGridNeighbor(2, 10, 3, 1))
assertEquals(6, storeGridNeighbor(2, 10, 4, 1))
}
+
+ @Test fun downFromTheInstalledSectionLandsInTheSameColumnBelowTheDivider() {
+ assertEquals(6, storeGridNeighbor(0, 12, 4, 1, split = 6).let { storeGridNeighbor(it!!, 12, 4, 1, split = 6) })
+ assertEquals(6, storeGridNeighbor(4, 12, 4, 1, split = 6))
+ assertEquals(7, storeGridNeighbor(5, 12, 4, 1, split = 6))
+ assertEquals(8, storeGridNeighbor(2, 12, 4, 1, split = 6))
+ }
+
+ @Test fun upFromBelowTheDividerClampsToTheShortInstalledRow() {
+ assertEquals(4, storeGridNeighbor(6, 12, 4, -1, split = 6))
+ assertEquals(5, storeGridNeighbor(7, 12, 4, -1, split = 6))
+ assertEquals(5, storeGridNeighbor(9, 12, 4, -1, split = 6))
+ assertEquals(6, storeGridNeighbor(10, 12, 4, -1, split = 6))
+ }
+
+ @Test fun lastSectionStillStopsAtItsIncompleteRow() {
+ assertNull(storeGridNeighbor(9, 11, 4, 1, split = 6))
+ assertEquals(10, storeGridNeighbor(6, 11, 4, 1, split = 6))
+ assertNull(storeGridNeighbor(1, 11, 4, -1, split = 6))
+ }
+
+ @Test fun installedGamesComeFirstKeepingTheirSortedOrder() {
+ val items = listOf("a", "b", "c", "d").map { com.droiddeck.launcher.stores.CatalogItem(com.droiddeck.launcher.stores.Store.GOG, it, it, null) }
+ assertEquals(listOf("b", "d", "a", "c"), installedFirst(items, setOf("d", "b")).map { it.id })
+ }
}
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt
index 2be67a4b6..a5578a0f7 100644
--- a/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/ui/StoreTabsTest.kt
@@ -5,12 +5,13 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class StoreTabsTest {
- @Test fun storeLibraryInstalledAmazonWithoutStore() {
- assertEquals(listOf("store", "library", "installed"), tabsFor(Store.GOG))
- assertEquals(listOf("library", "installed"), tabsFor(Store.AMAZON))
+ @Test fun libraryAndStoreAmazonLibraryOnly() {
+ assertEquals(listOf("library", "store"), tabsFor(Store.GOG))
+ assertEquals(listOf("library", "store"), tabsFor(Store.ITCH))
+ assertEquals(listOf("library"), tabsFor(Store.AMAZON))
assertEquals("library", tabFor(Store.AMAZON, "store"))
assertEquals("library", tabFor(Store.GOG, "all"))
- assertEquals("installed", tabFor(Store.EPIC, "installed"))
+ assertEquals("library", tabFor(Store.EPIC, "installed"))
assertEquals("store", tabFor(Store.GOG, "store"))
}
}
From c5f7f437263fd113d1f877f7ec9e7103b2ae6485 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 08:23:02 -0400
Subject: [PATCH 015/156] feat: play button on installed store game pages
(#1125)
---
.../main/java/com/droiddeck/launcher/ui/StoresDetail.kt | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
index f6bc614b9..151a487dc 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
@@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -115,10 +117,13 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
Actions {
when {
installed != null -> {
- ActionChip(stringResource(R.string.stores_in_steam), ok = true)
val gone = StoresState.uninstalling["${store.id}:$id"]
+ PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null, main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) {
+ launchStoreGame(ctx, store, id, s, a)
+ }
+ ActionChip(stringResource(R.string.stores_in_steam), ok = true)
if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {}
- else UninstallButton(title, installed, enabled = download == null, modifier = leadOn) {
+ else UninstallButton(title, installed, enabled = download == null) {
StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() }
}
}
From 7733e29d10f8de1b510ae6ade4ddacfec2625009 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 08:45:58 -0400
Subject: [PATCH 016/156] feat: capsule view with DVD-case store art across all
stores (#1117)
---
.../launcher/session/SessionPrefs.kt | 2 +-
.../com/droiddeck/launcher/ui/StoresPage.kt | 59 +++++++++++++------
app/src/main/res/values-ar/strings.xml | 1 +
app/src/main/res/values-b+es+419/strings.xml | 1 +
app/src/main/res/values-bg/strings.xml | 1 +
app/src/main/res/values-cs/strings.xml | 1 +
app/src/main/res/values-da/strings.xml | 1 +
app/src/main/res/values-de/strings.xml | 1 +
app/src/main/res/values-el/strings.xml | 1 +
app/src/main/res/values-es/strings.xml | 1 +
app/src/main/res/values-fi/strings.xml | 1 +
app/src/main/res/values-fr/strings.xml | 1 +
app/src/main/res/values-hi/strings.xml | 1 +
app/src/main/res/values-hu/strings.xml | 1 +
app/src/main/res/values-in/strings.xml | 1 +
app/src/main/res/values-it/strings.xml | 1 +
app/src/main/res/values-ja/strings.xml | 1 +
app/src/main/res/values-ko/strings.xml | 1 +
app/src/main/res/values-ms/strings.xml | 1 +
app/src/main/res/values-nb/strings.xml | 1 +
app/src/main/res/values-nl/strings.xml | 1 +
app/src/main/res/values-pl/strings.xml | 1 +
app/src/main/res/values-pt-rPT/strings.xml | 1 +
app/src/main/res/values-pt/strings.xml | 1 +
app/src/main/res/values-ro/strings.xml | 1 +
app/src/main/res/values-ru/strings.xml | 1 +
app/src/main/res/values-sv/strings.xml | 1 +
app/src/main/res/values-th/strings.xml | 1 +
app/src/main/res/values-tr/strings.xml | 1 +
app/src/main/res/values-uk/strings.xml | 1 +
app/src/main/res/values-vi/strings.xml | 1 +
app/src/main/res/values-zh-rCN/strings.xml | 1 +
app/src/main/res/values-zh-rHK/strings.xml | 1 +
app/src/main/res/values-zh-rTW/strings.xml | 1 +
app/src/main/res/values/strings.xml | 1 +
35 files changed, 76 insertions(+), 18 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index 49aa2e9ba..4c4cd9808 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -119,7 +119,7 @@ object SessionPrefs {
}
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
- val STORES_VIEWS = listOf("cards", "list")
+ val STORES_VIEWS = listOf("cards", "capsules", "list")
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
index 2feda7597..3933450d4 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
@@ -90,6 +90,7 @@ import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.ViewList
+import androidx.compose.material.icons.filled.ViewModule
import com.droiddeck.launcher.stores.sortCatalogItems
import com.droiddeck.launcher.stores.retainCatalogOrder
import androidx.compose.foundation.layout.widthIn
@@ -803,6 +804,8 @@ private fun Storefront(
val headerItems = 1 + (if (sizeSortPending) 1 else 0) +
(if (StoresState.status[store] != null) 1 else 0) + (if (StoresState.problems[store] != null) 1 else 0)
val listMode = s.storesView == "list" && tab == "library"
+ val capsules = s.storesView == "capsules" && tab == "library"
+ val tileWidth = if (capsules) CapsuleWidth else CardWidth
val card: @Composable (CatalogItem) -> Unit = { item ->
val first = if (showGrid) item.key == visible.firstOrNull()?.key else !firstPlaced
firstPlaced = true
@@ -834,12 +837,12 @@ private fun Storefront(
}
}
if (listMode) GameListRow(item, store, installedKeys, onOpen, first, navigation)
- else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation)
+ else GameCard(item, store, s, a, installedKeys, onOpen, first, navigation, capsule = capsules)
}
LazyVerticalGrid(
- columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(CardWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged {
+ columns = if (listMode) GridCells.Fixed(1) else GridCells.Adaptive(tileWidth), state = grid, modifier = Modifier.fillMaxSize().focusGroup().onSizeChanged {
val gap = with(density) { 10.dp.roundToPx() }
- val minimum = with(density) { (if (listMode) CardWidth * 2 else CardWidth).roundToPx() }
+ val minimum = with(density) { (if (listMode) CardWidth * 2 else tileWidth).roundToPx() }
columns = ((it.width + gap) / (minimum + gap)).toInt().coerceAtLeast(1)
},
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -1094,12 +1097,13 @@ private fun SortButton(s: FrontEndState, a: FrontEndActions) {
@Composable
private fun ViewButton(s: FrontEndState, a: FrontEndActions) {
- val list = s.storesView == "list"
+ val views = SessionPrefs.STORES_VIEWS
+ val next = views[(views.indexOf(s.storesView) + 1) % views.size]
HeaderIconButton(
- if (list) stringResource(R.string.stores_view_cards) else stringResource(R.string.stores_view_list),
- if (list) Icons.Filled.GridView else Icons.Filled.ViewList,
+ stringResource(when (next) { "capsules" -> R.string.stores_view_capsules; "list" -> R.string.stores_view_list; else -> R.string.stores_view_cards }),
+ when (next) { "capsules" -> Icons.Filled.ViewModule; "list" -> Icons.Filled.ViewList; else -> Icons.Filled.GridView },
"view",
- ) { a.onStoresView(if (list) "cards" else "list") }
+ ) { a.onStoresView(next) }
}
@Composable
@@ -1226,9 +1230,13 @@ private fun LazyGridScope.storeGrid(games: List, empty: String, car
}
private val CardWidth = 112.dp
+private val CapsuleWidth = 92.dp
@Composable
-private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit, first: Boolean = false, navigation: Modifier = Modifier) {
+private fun GameCard(
+ item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set, onOpen: (String) -> Unit,
+ first: Boolean = false, navigation: Modifier = Modifier, capsule: Boolean = false,
+) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -1241,7 +1249,8 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
val placed = remember { arrayOfNulls(1) }
val corner = with(androidx.compose.ui.platform.LocalDensity.current) { 10.dp.toPx() }
val open = {
- placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, item.imageUrl ?: item.tallImageUrl)) }
+ val art = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl
+ placed[0]?.takeIf { it.isAttached }?.let { StoresMotion.markCard(CardMark(item.key, it.boundsInRoot(), corner, art)) }
onOpen(item.key)
}
val screen = with(androidx.compose.ui.platform.LocalDensity.current) { androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp.dp.toPx() }
@@ -1259,7 +1268,7 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
.clip(RoundedCornerShape(10.dp)).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open),
) {
- CardFace(item, download, hot) { CardMarker(item, installed, download) }
+ CardFace(item, download, hot, capsule) { CardMarker(item, installed, download, compact = capsule) }
}
}
@@ -1281,13 +1290,28 @@ private val CardStrip = 26.dp
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
-private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, marker: @Composable () -> Unit) {
- val url = item.imageUrl ?: item.tallImageUrl
+private fun CardFace(
+ item: CatalogItem, download: com.droiddeck.launcher.stores.download.DownloadEntry?, hot: Boolean, capsule: Boolean = false,
+ marker: @Composable () -> Unit,
+) {
+ val url = if (capsule) item.tallImageUrl ?: item.imageUrl else item.imageUrl ?: item.tallImageUrl
val blurs = android.os.Build.VERSION.SDK_INT >= 31
+ var landscape by remember(url) { mutableStateOf(false) }
BoxWithConstraints(Modifier.fillMaxWidth()) {
- val total = maxWidth * 9f / 16f + CardStrip
+ val total = if (capsule) maxWidth * 7f / 5f + CardStrip else maxWidth * 9f / 16f + CardStrip
Box(Modifier.fillMaxWidth().height(total).background(Color(0xFF101318)).then(if (url == null) Modifier.background(artBrush(hueOf(item.title))) else Modifier)) {
- if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
+ if (url != null && capsule && landscape) {
+ // Stores without portrait covers (itch) would lose most of the art to a portrait crop.
+ AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize().blur(24.dp))
+ Spacer(Modifier.matchParentSize().background(Color.Black.copy(alpha = if (blurs) 0.35f else 0.7f)))
+ AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.matchParentSize().padding(bottom = CardStrip))
+ } else if (url != null) AsyncImage(
+ model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(),
+ onSuccess = { r ->
+ val art = r.result.drawable
+ if (capsule && art.intrinsicWidth > 0 && art.intrinsicHeight > 0) landscape = art.intrinsicWidth > art.intrinsicHeight * 0.95f
+ },
+ )
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(CardStrip).clipToBounds()) {
if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop,
@@ -1315,7 +1339,7 @@ private fun CardFace(item: CatalogItem, download: com.droiddeck.launcher.stores.
}
@Composable
-private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?) {
+private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droiddeck.launcher.stores.download.DownloadEntry?, compact: Boolean = false) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val small = 10.sp
@@ -1323,12 +1347,13 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi
installed -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
val ctx = LocalContext.current
val game = StoresState.installedGame(item.store, item.id)
- if (game != null) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) }
- com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
+ if (game != null && !compact) androidx.compose.runtime.LaunchedEffect(item.key) { com.droiddeck.launcher.stores.StoreSizes.requestDisk(ctx, item.key, game) }
+ if (!compact) com.droiddeck.launcher.stores.StoreSizes.onDisk[item.key]?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp))
}
download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
+ item.owned && compact -> Unit
item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1)
item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 36caa8aee..e496994a4 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -2067,6 +2067,7 @@
تاريخ الإصدار
عرض القائمة
عرض البطاقات
+ عرض الأغلفة
إظهار المحتوى للبالغين
Epic
EOS
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index da88522af..fab4a066f 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -2024,6 +2024,7 @@
Fecha de lanzamiento
Vista de lista
Vista de tarjetas
+ Vista de portadas
Mostrar contenido para adultos
Epic
EOS
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index a76a3cd91..5e375bca2 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -1983,6 +1983,7 @@
Дата на издаване
Изглед на списък
Изглед с карти
+ Изглед с обложки
Показвай съдържание за възрастни
Epic
EOS
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 5ab150110..b4d674c32 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -2025,6 +2025,7 @@
Datum vydání
Zobrazení seznamu
Zobrazení karet
+ Zobrazení obalů
Zobrazovat obsah pro dospělé
Epic
EOS
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 77f4eb7a0..3b2bc8979 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -1983,6 +1983,7 @@
Udgivelsesdato
Listevisning
Kortvisning
+ Omslagsvisning
Vis indhold for voksne
Epic
EOS
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 9a41a6627..3a4c61b17 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1972,6 +1972,7 @@
Erscheinungsdatum
Listenansicht
Kartenansicht
+ Coveransicht
Inhalte für Erwachsene anzeigen
Epic
EOS
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 430fb46b0..4f4bfaa44 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -1983,6 +1983,7 @@
Ημερομηνία κυκλοφορίας
Προβολή λίστας
Προβολή καρτών
+ Προβολή εξωφύλλων
Εμφάνιση περιεχομένου για ενήλικες
Epic
EOS
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 4d6ba89f8..09660256e 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -2001,6 +2001,7 @@
Fecha de lanzamiento
Vista de lista
Vista de tarjetas
+ Vista de portadas
Mostrar contenido para adultos
Epic
EOS
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index bd2f9d176..88d810a69 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -1983,6 +1983,7 @@
Julkaisupäivä
Luettelonäkymä
Korttinäkymä
+ Kansinäkymä
Näytä aikuisille tarkoitettu sisältö
Epic
EOS
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 7d8b89eca..9393da87c 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -2001,6 +2001,7 @@
Date de sortie
Vue liste
Vue cartes
+ Vue jaquettes
Afficher le contenu pour adultes
Epic
EOS
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 75adfcb7c..91fae22a1 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -1983,6 +1983,7 @@
रिलीज़ तिथि
सूची दृश्य
कार्ड दृश्य
+ कवर व्यू
वयस्क कंटेंट दिखाएं
Epic
EOS
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index fa569cd9b..48809c616 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -1983,6 +1983,7 @@
Megjelenés dátuma
Listanézet
Kártyanézet
+ Borítónézet
Felnőtt tartalom megjelenítése
Epic
EOS
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index a14662f53..85abaeec8 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -1962,6 +1962,7 @@
Tanggal rilis
Tampilan daftar
Tampilan kartu
+ Tampilan sampul
Tampilkan konten dewasa
Epic
EOS
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 301076df7..19fe2f598 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1993,6 +1993,7 @@
Data di uscita
Visualizzazione elenco
Visualizzazione schede
+ Vista copertine
Mostra contenuti per adulti
Epic
EOS
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index af2e8bba6..c39625cd3 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -1962,6 +1962,7 @@
リリース日
リスト表示
カード表示
+ パッケージ表示
成人向けコンテンツを表示
Epic
EOS
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index aeb066798..8e2508ebe 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1962,6 +1962,7 @@
출시일
목록 보기
카드 보기
+ 표지 보기
성인용 콘텐츠 표시
Epic
EOS
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index afd271efc..537bd5421 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -1962,6 +1962,7 @@
Tarikh keluaran
Paparan senarai
Paparan kad
+ Paparan kulit
Papar kandungan matang
Epic
EOS
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 1649f4e4a..977db283c 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -1983,6 +1983,7 @@
Utgivelsesdato
Listevisning
Kortvisning
+ Omslagsvisning
Vis innhold for voksne
Epic
EOS
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 3ac9d8029..f841a1878 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -1983,6 +1983,7 @@
Releasedatum
Lijstweergave
Kaartweergave
+ Hoesweergave
Inhoud voor volwassenen tonen
Epic
EOS
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 5828bb5a1..d89698468 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -2025,6 +2025,7 @@
Data wydania
Widok listy
Widok kart
+ Widok okładek
Pokaż treści dla dorosłych
Epic
EOS
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index eb3dbde1e..55d8d0108 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -1993,6 +1993,7 @@
Data de lançamento
Vista de lista
Vista de cartões
+ Vista de capas
Mostrar conteúdo para adultos
Epic
EOS
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 9119ead1b..dd27961db 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1993,6 +1993,7 @@
Data de lançamento
Vista de lista
Vista de cartões
+ Visualização de capas
Mostrar conteúdo adulto
Epic
EOS
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index e16c6174a..637ffea5e 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -2004,6 +2004,7 @@
Data lansării
Vizualizare listă
Vizualizare carduri
+ Vizualizare coperți
Arată conținutul pentru adulți
Epic
EOS
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 4c3e71733..b5a52740a 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -2022,6 +2022,7 @@
Дата выхода
Вид списком
Вид карточками
+ Вид обложек
Показывать контент для взрослых
Epic
EOS
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 39f63eef1..6e97abd03 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -1983,6 +1983,7 @@
Utgivningsdatum
Listvy
Kortvy
+ Omslagsvy
Visa innehåll för vuxna
Epic
EOS
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index ced2fb961..24a52fc9e 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -1962,6 +1962,7 @@
วันวางจำหน่าย
มุมมองรายการ
มุมมองการ์ด
+ มุมมองปก
แสดงเนื้อหาสำหรับผู้ใหญ่
Epic
EOS
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 42da82a97..424db34ea 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -1972,6 +1972,7 @@
Çıkış tarihi
Liste görünümü
Kart görünümü
+ Kapak görünümü
Yetişkin içeriği göster
Epic
EOS
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 03eed1e71..2d79d0a94 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -2025,6 +2025,7 @@
Дата виходу
Вигляд списком
Вигляд картками
+ Вигляд обкладинок
Показувати контент для дорослих
Epic
EOS
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 9a49e869b..bf2c67811 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -1962,6 +1962,7 @@
Ngày phát hành
Chế độ danh sách
Chế độ thẻ
+ Chế độ xem bìa
Hiện nội dung người lớn
Epic
EOS
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 65896e07e..206e30f1d 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -2038,6 +2038,7 @@
发行日期
列表视图
卡片视图
+ 封面视图
显示成人内容
Epic
EOS
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 1618d8501..16f54ea4e 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -1962,6 +1962,7 @@
發行日期
列表檢視
卡片檢視
+ 封面檢視
顯示成人內容
Epic
EOS
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 04dc2a74e..95e5dd915 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -1962,6 +1962,7 @@
發行日期
列表檢視
卡片檢視
+ 封面檢視
顯示成人內容
Epic
EOS
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 44fb4fc2a..603099d9f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2026,6 +2026,7 @@
Release date
List view
Card view
+ Capsule view
Show mature content
Epic
EOS
From 55bf51cf37d46de612359c5e92440d77883e7207 Mon Sep 17 00:00:00 2001
From: kingokksa <47888451+kingokksa@users.noreply.github.com>
Date: Mon, 19 Oct 2026 21:54:19 +0800
Subject: [PATCH 017/156] Ship the desktop's translations as per-language packs
(#942)
---
.github/workflows/build-desktop-kde.yml | 6 ++-
tools/desktop-kde/build.sh | 12 ++++++
tools/tests/test_desktop_language.py | 51 +++++++++++++++++++++++++
3 files changed, 68 insertions(+), 1 deletion(-)
create mode 100644 tools/tests/test_desktop_language.py
diff --git a/.github/workflows/build-desktop-kde.yml b/.github/workflows/build-desktop-kde.yml
index 5a9933abc..365e3d4f8 100644
--- a/.github/workflows/build-desktop-kde.yml
+++ b/.github/workflows/build-desktop-kde.yml
@@ -64,6 +64,7 @@ jobs:
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
cp out/kwin/desktop-kde.kwin.txt out/
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
+ echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
- uses: actions/upload-artifact@v4
with:
name: desktop-kde
@@ -74,6 +75,8 @@ jobs:
out/desktop-kde.packages.txt
out/desktop-kde.kwin.txt
out/desktop-kde.sha256
+ out/desktop-kde.i18n.txt
+ out/desktop-kde-i18n-*.tar.zst
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
@@ -88,5 +91,6 @@ jobs:
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
- out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
+ out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
+ out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
gh release edit "$TAG" --latest=false --prerelease=false
diff --git a/tools/desktop-kde/build.sh b/tools/desktop-kde/build.sh
index 524a76f46..80bd41e11 100755
--- a/tools/desktop-kde/build.sh
+++ b/tools/desktop-kde/build.sh
@@ -40,6 +40,18 @@ if [ -n "${KDE_KWIN_OVERRIDE:-}" ]; then
install -m 755 "$KDE_KWIN_OVERRIDE/libkwin.so.6.7.5" "$work/root/usr/lib/libkwin.so.6.7.5"
fi
+if [ -d "$work/root/usr/share/locale" ]; then
+ for dir in "$work/root"/usr/share/locale/*/; do
+ loc=${dir#$work/root/usr/share/locale/}; loc=${loc%/}
+ case $loc in en*) continue ;; esac
+ [ -d "$dir/LC_MESSAGES" ] || continue
+ file=$out/desktop-kde-i18n-$loc.tar.zst
+ tar -C "$work/root" --zstd -cf "$file" "./usr/share/locale/$loc"
+ printf '%s %s %s\n' "$loc" "$(sha256sum "$file" | cut -d' ' -f1)" "$(stat -c %s "$file")"
+ done > "$out/desktop-kde.i18n.txt"
+ echo "i18n: $(wc -l < "$out/desktop-kde.i18n.txt") language packs"
+fi
+
rm -rf "$work/root/usr/share/doc" "$work/root/usr/share/man" "$work/root/usr/share/info" \
"$work/root/usr/share/gtk-doc" "$work/root/usr/include" "$work/root/usr/lib/pkgconfig" \
"$work/root/usr/lib/cmake" "$work/root/usr/share/help"
diff --git a/tools/tests/test_desktop_language.py b/tools/tests/test_desktop_language.py
new file mode 100644
index 000000000..19bbe2851
--- /dev/null
+++ b/tools/tests/test_desktop_language.py
@@ -0,0 +1,51 @@
+import re
+from pathlib import Path
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def read(path):
+ return path.read_text(encoding='utf-8')
+
+
+BUILD = ROOT / 'tools/desktop-kde/build.sh'
+WORKFLOW = ROOT / '.github/workflows/build-desktop-kde.yml'
+
+
+class DesktopLanguageBuildTest(unittest.TestCase):
+ def setUp(self):
+ self.build = read(BUILD)
+
+ def test_the_main_package_keeps_english_and_every_other_language_becomes_a_pack(self):
+ loop = self.build.split('usr/share/locale/*/', 1)[1].split('rm -rf "$work/root/usr/share/doc"', 1)[0]
+ self.assertIn("case $loc in en*) continue ;; esac", loop)
+ self.assertIn('[ -d "$dir/LC_MESSAGES" ] || continue', loop)
+ self.assertIn('desktop-kde-i18n-$loc.tar.zst', loop)
+ self.assertIn('> "$out/desktop-kde.i18n.txt"', loop)
+ self.assertIn('find "$work/root/usr/share/locale" -mindepth 1 -maxdepth 1 -type d ! -name \'en*\'',
+ self.build.split('i18n.txt"', 1)[1])
+
+ def test_the_index_lines_carry_what_the_app_verifies(self):
+ record = re.search(r"printf '%s %s %s\\n' \"\$loc\" \"\$\(sha256sum \"\$file\" \| cut -d' ' -f1\)\" \"\$\(stat -c %s \"\$file\"\)\"",
+ self.build)
+ self.assertIsNotNone(record)
+ line = 'de ' + 'a' * 64 + ' 262144'
+ fields = line.split()
+ self.assertEqual(3, len(fields))
+ self.assertTrue(re.fullmatch(r'[0-9a-f]{64}', fields[1]))
+ self.assertEqual(262144, int(fields[2]))
+
+ def test_the_workflow_ships_the_index_and_the_packs(self):
+ workflow = read(WORKFLOW)
+ self.assertIn('out/desktop-kde.i18n.txt', workflow)
+ self.assertEqual(1, len([line for line in workflow.splitlines()
+ if line.strip() == 'out/desktop-kde-i18n-*.tar.zst']))
+ release = workflow.split('gh release create', 1)[1]
+ self.assertIn('out/desktop-kde.i18n.txt', release)
+ self.assertIn('out/desktop-kde-i18n-*.tar.zst', release)
+
+
+if __name__ == '__main__':
+ unittest.main()
From ab6d100cea91fab400c0bbb3e6ad794d23749709 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 09:58:24 -0400
Subject: [PATCH 018/156] fix: keep the TP UBWC hint on for Samsung One UI 8
Gen 2 (#1128)
---
.../java/com/droiddeck/launcher/core/DeviceReport.kt | 1 +
.../java/com/droiddeck/launcher/gpu/TpUbwcHint.kt | 11 +++++++----
.../java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt | 6 ++++++
3 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
index 1290884bb..aad57eaef 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
@@ -200,6 +200,7 @@ object DeviceReport {
h("Drivers")
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
+ if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
index 84440c213..1ccee78e2 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
@@ -11,8 +11,10 @@ object TpUbwcHint {
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
- internal fun features(model: Int, systemDriverVersion: String?): String? {
+ // Samsung's One UI firmware sets it whatever blob version it reports.
+ internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
if (model != 740) return null
+ if (samsung) return "enable_tp_ubwc_flag_hint=1"
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
}
@@ -20,11 +22,12 @@ object TpUbwcHint {
@Synchronized
fun forThisDevice(): String? {
if (resolved) return cached
- val model = GpuInfo.detect().model
+ val gpu = GpuInfo.detect()
+ val model = gpu.model
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
- cached = features(model, version)
+ cached = features(model, version, gpu.oneUi8Gen2)
resolved = true
- Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
+ Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
return cached
}
diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt
index ab037e152..fe5183cfa 100644
--- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt
@@ -13,9 +13,15 @@ class TpUbwcHintTest {
assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.762.10"))
}
+ @Test fun a740OnOneUiTurnsTheHintOnWhateverTheBlobVersion() {
+ assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.53", samsung = true))
+ assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, null, samsung = true))
+ }
+
@Test fun otherGpusKeepTheirDriverDefault() {
assertNull(TpUbwcHint.features(750, "512.762.10"))
assertNull(TpUbwcHint.features(830, "512.676.53"))
+ assertNull(TpUbwcHint.features(750, "512.676.53", samsung = true))
}
@Test fun anUnreadableSystemDriverKeepsTheDriverDefault() {
From 2415ff0518a56d5b4820d75873ce5a1d6dbb255e Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:21:32 -0400
Subject: [PATCH 019/156] fix: DD-FG 3x/4x flicker and rebrand Win-FG to DD-FG
(#1132)
---
app/src/main/cpp/CMakeLists.txt | 28 +-
.../main/cpp/framegen/VulkanRendererContext.h | 10 +-
.../framegen/{winfg => ddfg}/LICENSE.win-fg | 0
.../NOTICE_FIDELITYFX_OPTICALFLOW.md | 0
.../cpp/framegen/{winfg => ddfg}/config.hpp | 72 ++---
.../winfg_engine.cpp => ddfg/ddfg_engine.cpp} | 23 +-
.../winfg_engine.h => ddfg/ddfg_engine.h} | 2 +-
.../winfg_vkd.cpp => ddfg/ddfg_vkd.cpp} | 6 +-
app/src/main/cpp/framegen/ddfg/ddfg_vkd.h | 4 +
.../{winfg => ddfg}/embedded_shaders.hpp | 10 +-
.../cpp/framegen/{winfg => ddfg}/framegen.cpp | 12 +-
.../cpp/framegen/{winfg => ddfg}/framegen.hpp | 6 +-
app/src/main/cpp/framegen/ddfg/log.hpp | 12 +
.../framegen/{winfg => ddfg}/record_impl.inc | 301 +++++++++---------
.../framegen/{winfg => ddfg}/vk_dispatch.hpp | 2 +-
app/src/main/cpp/framegen/winfg/log.hpp | 12 -
app/src/main/cpp/framegen/winfg/winfg_vkd.h | 4 -
app/src/main/cpp/waylandcomp/CMakeLists.txt | 16 +-
.../cpp/waylandcomp/src/framegen_bridge.c | 8 +-
.../cpp/waylandcomp/src/framegen_bridge.h | 4 +-
.../cpp/waylandcomp/src/framegen_engine.cpp | 32 +-
.../waylandcomp/src/framegen_engine_stub.c | 2 +-
.../main/cpp/waylandcomp/src/framegen_jni.c | 4 +-
.../com/droiddeck/launcher/gpu/FrameGen.kt | 21 +-
.../com/droiddeck/launcher/session/PerfHud.kt | 2 +-
.../com/droiddeck/launcher/ui/FrameGenMenu.kt | 2 +-
.../launcher/wayland/WaylandCompositor.java | 4 +-
app/src/main/res/values-ar/strings.xml | 2 +-
app/src/main/res/values-b+es+419/strings.xml | 2 +-
app/src/main/res/values-bg/strings.xml | 2 +-
app/src/main/res/values-cs/strings.xml | 2 +-
app/src/main/res/values-da/strings.xml | 2 +-
app/src/main/res/values-de/strings.xml | 2 +-
app/src/main/res/values-el/strings.xml | 2 +-
app/src/main/res/values-es/strings.xml | 2 +-
app/src/main/res/values-fi/strings.xml | 2 +-
app/src/main/res/values-fr/strings.xml | 2 +-
app/src/main/res/values-hi/strings.xml | 2 +-
app/src/main/res/values-hu/strings.xml | 2 +-
app/src/main/res/values-in/strings.xml | 2 +-
app/src/main/res/values-it/strings.xml | 2 +-
app/src/main/res/values-ja/strings.xml | 2 +-
app/src/main/res/values-ko/strings.xml | 2 +-
app/src/main/res/values-ms/strings.xml | 2 +-
app/src/main/res/values-nb/strings.xml | 2 +-
app/src/main/res/values-nl/strings.xml | 2 +-
app/src/main/res/values-pl/strings.xml | 2 +-
app/src/main/res/values-pt-rPT/strings.xml | 2 +-
app/src/main/res/values-pt/strings.xml | 2 +-
app/src/main/res/values-ro/strings.xml | 2 +-
app/src/main/res/values-ru/strings.xml | 2 +-
app/src/main/res/values-sv/strings.xml | 2 +-
app/src/main/res/values-th/strings.xml | 2 +-
app/src/main/res/values-tr/strings.xml | 2 +-
app/src/main/res/values-uk/strings.xml | 2 +-
app/src/main/res/values-vi/strings.xml | 2 +-
app/src/main/res/values-zh-rCN/strings.xml | 2 +-
app/src/main/res/values-zh-rHK/strings.xml | 2 +-
app/src/main/res/values-zh-rTW/strings.xml | 2 +-
app/src/main/res/values/strings.xml | 2 +-
.../droiddeck/launcher/gpu/FrameGenTest.kt | 10 +-
.../launcher/session/SettingsResetTest.kt | 2 +-
62 files changed, 353 insertions(+), 324 deletions(-)
rename app/src/main/cpp/framegen/{winfg => ddfg}/LICENSE.win-fg (100%)
rename app/src/main/cpp/framegen/{winfg => ddfg}/NOTICE_FIDELITYFX_OPTICALFLOW.md (100%)
rename app/src/main/cpp/framegen/{winfg => ddfg}/config.hpp (78%)
rename app/src/main/cpp/framegen/{winfg/winfg_engine.cpp => ddfg/ddfg_engine.cpp} (91%)
rename app/src/main/cpp/framegen/{winfg/winfg_engine.h => ddfg/ddfg_engine.h} (99%)
rename app/src/main/cpp/framegen/{winfg/winfg_vkd.cpp => ddfg/ddfg_vkd.cpp} (90%)
create mode 100644 app/src/main/cpp/framegen/ddfg/ddfg_vkd.h
rename app/src/main/cpp/framegen/{winfg => ddfg}/embedded_shaders.hpp (82%)
rename app/src/main/cpp/framegen/{winfg => ddfg}/framegen.cpp (96%)
rename app/src/main/cpp/framegen/{winfg => ddfg}/framegen.hpp (96%)
create mode 100644 app/src/main/cpp/framegen/ddfg/log.hpp
rename app/src/main/cpp/framegen/{winfg => ddfg}/record_impl.inc (65%)
rename app/src/main/cpp/framegen/{winfg => ddfg}/vk_dispatch.hpp (99%)
delete mode 100644 app/src/main/cpp/framegen/winfg/log.hpp
delete mode 100644 app/src/main/cpp/framegen/winfg/winfg_vkd.h
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
index 01a0a97a2..136e0f0e9 100644
--- a/app/src/main/cpp/CMakeLists.txt
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -11,23 +11,23 @@ add_subdirectory(adrenotools)
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
add_subdirectory(thirdparty/dxbc)
-set(WINFG_UPSTREAM "v0.3.0")
-set(WINFG_CHAIN_SRC
- framegen/winfg/framegen.cpp
- framegen/winfg/framegen.hpp
- framegen/winfg/record_impl.inc
- framegen/winfg/config.hpp
- framegen/winfg/embedded_shaders.hpp
- framegen/winfg/winfg_engine.cpp
+set(DDFG_UPSTREAM "v0.3.0")
+set(DDFG_CHAIN_SRC
+ framegen/ddfg/framegen.cpp
+ framegen/ddfg/framegen.hpp
+ framegen/ddfg/record_impl.inc
+ framegen/ddfg/config.hpp
+ framegen/ddfg/embedded_shaders.hpp
+ framegen/ddfg/ddfg_engine.cpp
)
-set(WINFG_HASH_INPUT "")
-foreach(f ${WINFG_CHAIN_SRC})
+set(DDFG_HASH_INPUT "")
+foreach(f ${DDFG_CHAIN_SRC})
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h)
- string(APPEND WINFG_HASH_INPUT "${_h}")
+ string(APPEND DDFG_HASH_INPUT "${_h}")
endforeach()
-string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}")
-string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
-message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
+string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}")
+string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH)
+message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})")
add_subdirectory(waylandcomp)
add_subdirectory(questxr)
diff --git a/app/src/main/cpp/framegen/VulkanRendererContext.h b/app/src/main/cpp/framegen/VulkanRendererContext.h
index efbc00c68..7cbbcdf42 100644
--- a/app/src/main/cpp/framegen/VulkanRendererContext.h
+++ b/app/src/main/cpp/framegen/VulkanRendererContext.h
@@ -131,7 +131,7 @@ struct VkTable {
#include
namespace lsfg { class Engine; }
-namespace winfg { class Engine; }
+namespace ddfg { class Engine; }
static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
@@ -280,7 +280,7 @@ public:
static constexpr int32_t kFgCaptureGame = -1;
void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0);
void setFrameGenEngine(int kind);
- void setWinFgTuning(int model, int perfPreset);
+ void setDdFgTuning(int model, int perfPreset);
private:
struct WinTex {
@@ -468,12 +468,12 @@ private:
uint64_t fgSourceFrames_ = 0;
std::string lsfgCachePath_;
bool lsfgEngineTried_ = false;
- std::unique_ptr winfgEngine_;
- bool winfgEngineTried_ = false;
+ std::unique_ptr ddfgEngine_;
+ bool ddfgEngineTried_ = false;
std::atomic fgEngineKind_{0};
std::atomic fgModel_{4};
std::atomic fgPerfPreset_{1};
- bool ensureWinFgEngine();
+ bool ensureDdFgEngine();
bool fgCapsOk() const;
uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; }
diff --git a/app/src/main/cpp/framegen/winfg/LICENSE.win-fg b/app/src/main/cpp/framegen/ddfg/LICENSE.win-fg
similarity index 100%
rename from app/src/main/cpp/framegen/winfg/LICENSE.win-fg
rename to app/src/main/cpp/framegen/ddfg/LICENSE.win-fg
diff --git a/app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md b/app/src/main/cpp/framegen/ddfg/NOTICE_FIDELITYFX_OPTICALFLOW.md
similarity index 100%
rename from app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md
rename to app/src/main/cpp/framegen/ddfg/NOTICE_FIDELITYFX_OPTICALFLOW.md
diff --git a/app/src/main/cpp/framegen/winfg/config.hpp b/app/src/main/cpp/framegen/ddfg/config.hpp
similarity index 78%
rename from app/src/main/cpp/framegen/winfg/config.hpp
rename to app/src/main/cpp/framegen/ddfg/config.hpp
index a3bf09a5e..b488711b5 100644
--- a/app/src/main/cpp/framegen/winfg/config.hpp
+++ b/app/src/main/cpp/framegen/ddfg/config.hpp
@@ -6,7 +6,7 @@
#include
#include
-namespace winfg {
+namespace ddfg {
struct Config {
bool enabled = false;
@@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) {
}
static inline std::string conf_path() {
- if (const char* p = std::getenv("WIN_FG_CONF")) return p;
+ if (const char* p = std::getenv("DD_FG_CONF")) return p;
const char* home = std::getenv("HOME");
- return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string();
+ return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string();
}
static inline std::string capture_root(const Config& c) {
if (!c.capDir.empty()) return c.capDir;
- if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture";
- return std::string("/tmp/winfg-capture");
+ if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture";
+ return std::string("/tmp/ddfg-capture");
}
static inline Config load_config() {
Config c;
- c.enabled = envi("WIN_FG_ENABLE", 0) != 0;
- if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
- if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
- c.model = envi("WIN_FG_MODEL", c.model);
- c.multiplier = envi("WIN_FG_MULT", c.multiplier);
- c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages);
- c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset);
- if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
- if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
- c.frIters = envi("WIN_FG_FR_ITERS", c.frIters);
- c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda);
- c.frDt = envf("WIN_FG_FR_DT", c.frDt);
- c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge);
- c.frEps = envf("WIN_FG_FR_EPS", c.frEps);
- c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale);
- c.beta = envf("WIN_FG_BETA", c.beta);
- c.lambda = envf("WIN_FG_LAMBDA", c.lambda);
- c.epsilon = envf("WIN_FG_EPSILON", c.epsilon);
- c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale);
- if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
- c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW);
- c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH);
- if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
- c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches);
- c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize);
- c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion);
- c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB);
- if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd;
- if (const char* h = std::getenv("WIN_FG_HUD_RECT")) {
+ c.enabled = envi("DD_FG_ENABLE", 0) != 0;
+ if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
+ if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
+ c.model = envi("DD_FG_MODEL", c.model);
+ c.multiplier = envi("DD_FG_MULT", c.multiplier);
+ c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages);
+ c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset);
+ if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
+ if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
+ c.frIters = envi("DD_FG_FR_ITERS", c.frIters);
+ c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda);
+ c.frDt = envf("DD_FG_FR_DT", c.frDt);
+ c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge);
+ c.frEps = envf("DD_FG_FR_EPS", c.frEps);
+ c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale);
+ c.beta = envf("DD_FG_BETA", c.beta);
+ c.lambda = envf("DD_FG_LAMBDA", c.lambda);
+ c.epsilon = envf("DD_FG_EPSILON", c.epsilon);
+ c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale);
+ if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
+ c.captureW = envi("DD_FG_CAPTURE_W", c.captureW);
+ c.captureH = envi("DD_FG_CAPTURE_H", c.captureH);
+ if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
+ c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches);
+ c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize);
+ c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion);
+ c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB);
+ if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd;
+ if (const char* h = std::getenv("DD_FG_HUD_RECT")) {
float r[4] = {0,0,0,0}; int n = 0;
std::string s = h; size_t start = 0;
for (; n < 4; ++n) {
@@ -266,8 +266,8 @@ static inline Config load_config() {
c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3];
}
const char* home = std::getenv("HOME");
- if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml");
- if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p);
+ if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml");
+ if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p);
c.sanitize();
return c;
}
diff --git a/app/src/main/cpp/framegen/winfg/winfg_engine.cpp b/app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp
similarity index 91%
rename from app/src/main/cpp/framegen/winfg/winfg_engine.cpp
rename to app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp
index 965e15a15..6e87890b3 100644
--- a/app/src/main/cpp/framegen/winfg/winfg_engine.cpp
+++ b/app/src/main/cpp/framegen/ddfg/ddfg_engine.cpp
@@ -1,10 +1,10 @@
-#include "winfg_engine.h"
-#include "winfg_vkd.h"
+#include "ddfg_engine.h"
+#include "ddfg_vkd.h"
#include "log.hpp"
#include
#include
-namespace winfg {
+namespace ddfg {
namespace {
float alphaFor(uint32_t k, uint32_t n) {
@@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); }
bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev,
uint32_t queueFamily, VkQueue queue) {
- if (!winfgVkdInit(table, dd_, id_)) {
- WFG_LOGE("native: dispatch incomplete; frame generation unavailable");
+ if (!ddfgVkdInit(table, dd_, id_)) {
+ DDFG_LOGE("native: dispatch incomplete; frame generation unavailable");
return false;
}
phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue;
id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_);
if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) {
- WFG_LOGE("native: FrameGen::init failed");
+ DDFG_LOGE("native: FrameGen::init failed");
unavailable_ = true;
return false;
}
- WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
+ DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
return true;
}
@@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) {
- WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
+ DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
unavailable_ = true;
return false;
}
@@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
}
builtW_ = w; builtH_ = h; builtFmt_ = fmt;
history_ = 0; curr_ = 0; frames_ = 0;
- WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
+ DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale);
return true;
}
+static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot");
+
uint32_t Engine::plan(uint32_t capacity) const {
if (!valid() || history_ < 2) return 0;
const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0);
@@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou
dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite);
- fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_);
+ fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_,
+ generation, generation > 0);
if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots;
(void)w; (void)h;
diff --git a/app/src/main/cpp/framegen/winfg/winfg_engine.h b/app/src/main/cpp/framegen/ddfg/ddfg_engine.h
similarity index 99%
rename from app/src/main/cpp/framegen/winfg/winfg_engine.h
rename to app/src/main/cpp/framegen/ddfg/ddfg_engine.h
index 9a607f531..55c1ef8fa 100644
--- a/app/src/main/cpp/framegen/winfg/winfg_engine.h
+++ b/app/src/main/cpp/framegen/ddfg/ddfg_engine.h
@@ -6,7 +6,7 @@
struct VkTable;
-namespace winfg {
+namespace ddfg {
constexpr uint32_t kMaxGenerations = 3;
diff --git a/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp
similarity index 90%
rename from app/src/main/cpp/framegen/winfg/winfg_vkd.cpp
rename to app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp
index a0c2dd441..ba8a7b3ad 100644
--- a/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp
+++ b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.cpp
@@ -1,14 +1,14 @@
-#include "winfg_vkd.h"
+#include "ddfg_vkd.h"
#include "../VulkanRendererContext.h"
#include
-bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) {
+bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) {
bool ok = true;
#define COPY(dst, fn) \
do { \
dst.fn = table.fn; \
if (!dst.fn) { \
- __android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \
+ __android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \
"entry point vk" #fn " did not resolve"); \
ok = false; \
} \
diff --git a/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h
new file mode 100644
index 000000000..8c5b69404
--- /dev/null
+++ b/app/src/main/cpp/framegen/ddfg/ddfg_vkd.h
@@ -0,0 +1,4 @@
+#pragma once
+#include "vk_dispatch.hpp"
+struct VkTable;
+bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id);
diff --git a/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp b/app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp
similarity index 82%
rename from app/src/main/cpp/framegen/winfg/embedded_shaders.hpp
rename to app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp
index 20dd4477c..61f5c154c 100644
--- a/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp
+++ b/app/src/main/cpp/framegen/ddfg/embedded_shaders.hpp
@@ -1,7 +1,7 @@
#pragma once
#include
#include
-namespace winfg { namespace embedded {
+namespace ddfg { namespace embedded {
static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x00000035u,0x00000037u,0x0003003eu,0x00000030u,0x00000038u,0x0004003du,0x0000003du,0x00000040u,0x0000003fu,0x0004003du,0x0000002eu,0x00000041u,0x00000030u,0x00070058u,0x00000043u,0x00000044u,0x00000040u,0x00000041u,0x00000002u,0x00000042u,0x0008004fu,0x00000039u,0x00000045u,0x00000044u,0x00000044u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000003bu,0x00000045u,0x0004003du,0x00000039u,0x00000048u,0x0000003bu,0x00050094u,0x00000013u,0x0000004du,0x00000048u,0x0000004cu,0x0003003eu,0x00000047u,0x0000004du,0x0004003du,0x00000014u,0x0000004eu,0x00000016u,0x0004003du,0x00000007u,0x0000004fu,0x00000009u,0x0004003du,0x00000013u,0x00000050u,0x00000047u,0x00070050u,0x00000043u,0x00000051u,0x00000050u,0x00000042u,0x00000042u,0x00000042u,0x00040063u,0x0000004eu,0x0000004fu,0x00000051u,0x000100fdu,0x00010038u};
static const size_t OF3_LUMA_LEN = 481;
static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x00050067u,0x00000007u,0x00000038u,0x00000037u,0x00000036u,0x0004006fu,0x0000002eu,0x00000039u,0x00000038u,0x0003003eu,0x00000030u,0x00000039u,0x0004003du,0x00000007u,0x0000003bu,0x00000009u,0x0004006fu,0x0000002eu,0x0000003cu,0x0000003bu,0x0005008eu,0x0000002eu,0x0000003eu,0x0000003cu,0x0000003du,0x00050050u,0x0000002eu,0x00000040u,0x0000003fu,0x0000003fu,0x00050081u,0x0000002eu,0x00000041u,0x0000003eu,0x00000040u,0x0004003du,0x0000002eu,0x00000042u,0x00000030u,0x00050088u,0x0000002eu,0x00000043u,0x00000041u,0x00000042u,0x0003003eu,0x0000003au,0x00000043u,0x0004003du,0x0000002eu,0x00000046u,0x00000030u,0x00050050u,0x0000002eu,0x00000047u,0x00000045u,0x00000045u,0x00050088u,0x0000002eu,0x00000048u,0x00000047u,0x00000046u,0x0003003eu,0x00000044u,0x00000048u,0x0004003du,0x00000032u,0x0000004bu,0x00000034u,0x0004003du,0x0000002eu,0x0000004cu,0x0000003au,0x00050041u,0x00000049u,0x0000004du,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x0000004eu,0x0000004du,0x0004007fu,0x00000013u,0x0000004fu,0x0000004eu,0x00050041u,0x00000049u,0x00000050u,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x00000051u,0x00000050u,0x0004007fu,0x00000013u,0x00000052u,0x00000051u,0x00050050u,0x0000002eu,0x00000053u,0x0000004fu,0x00000052u,0x00050081u,0x0000002eu,0x00000054u,0x0000004cu,0x00000053u,0x00070058u,0x00000056u,0x00000057u,0x0000004bu,0x00000054u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000058u,0x00000057u,0x00000000u,0x0003003eu,0x0000004au,0x00000058u,0x0004003du,0x00000032u,0x0000005au,0x00000034u,0x0004003du,0x0000002eu,0x0000005bu,0x0000003au,0x00050041u,0x00000049u,0x0000005cu,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x0000005du,0x0000005cu,0x00050041u,0x00000049u,0x0000005eu,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x0000005fu,0x0000005eu,0x0004007fu,0x00000013u,0x00000060u,0x0000005fu,0x00050050u,0x0000002eu,0x00000061u,0x0000005du,0x00000060u,0x00050081u,0x0000002eu,0x00000062u,0x0000005bu,0x00000061u,0x00070058u,0x00000056u,0x00000063u,0x0000005au,0x00000062u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000064u,0x00000063u,0x00000000u,0x0003003eu,0x00000059u,0x00000064u,0x0004003du,0x00000032u,0x00000066u,0x00000034u,0x0004003du,0x0000002eu,0x00000067u,0x0000003au,0x00050041u,0x00000049u,0x00000068u,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x00000069u,0x00000068u,0x0004007fu,0x00000013u,0x0000006au,0x00000069u,0x00050041u,0x00000049u,0x0000006bu,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x0000006cu,0x0000006bu,0x00050050u,0x0000002eu,0x0000006du,0x0000006au,0x0000006cu,0x00050081u,0x0000002eu,0x0000006eu,0x00000067u,0x0000006du,0x00070058u,0x00000056u,0x0000006fu,0x00000066u,0x0000006eu,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x00000070u,0x0000006fu,0x00000000u,0x0003003eu,0x00000065u,0x00000070u,0x0004003du,0x00000032u,0x00000072u,0x00000034u,0x0004003du,0x0000002eu,0x00000073u,0x0000003au,0x00050041u,0x00000049u,0x00000074u,0x00000044u,0x0000001au,0x0004003du,0x00000013u,0x00000075u,0x00000074u,0x00050041u,0x00000049u,0x00000076u,0x00000044u,0x00000024u,0x0004003du,0x00000013u,0x00000077u,0x00000076u,0x00050050u,0x0000002eu,0x00000078u,0x00000075u,0x00000077u,0x00050081u,0x0000002eu,0x00000079u,0x00000073u,0x00000078u,0x00070058u,0x00000056u,0x0000007au,0x00000072u,0x00000079u,0x00000002u,0x00000055u,0x00050051u,0x00000013u,0x0000007bu,0x0000007au,0x00000000u,0x0003003eu,0x00000071u,0x0000007bu,0x0004003du,0x00000014u,0x0000007cu,0x00000016u,0x0004003du,0x00000007u,0x0000007du,0x00000009u,0x0004003du,0x00000013u,0x0000007eu,0x0000004au,0x0004003du,0x00000013u,0x0000007fu,0x00000059u,0x00050081u,0x00000013u,0x00000080u,0x0000007eu,0x0000007fu,0x0004003du,0x00000013u,0x00000081u,0x00000065u,0x00050081u,0x00000013u,0x00000082u,0x00000080u,0x00000081u,0x0004003du,0x00000013u,0x00000083u,0x00000071u,0x00050081u,0x00000013u,0x00000084u,0x00000082u,0x00000083u,0x00050085u,0x00000013u,0x00000086u,0x00000084u,0x00000085u,0x00070050u,0x00000056u,0x00000087u,0x00000086u,0x00000055u,0x00000055u,0x00000055u,0x00040063u,0x0000007cu,0x0000007du,0x00000087u,0x000100fdu,0x00010038u};
@@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b
static const size_t OF3_GM_PREWARP_LEN = 706;
static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,0x00040015u,0x0000002fu,0x00000020u,0x00000000u,0x0004002bu,0x0000002fu,0x00000030u,0x00000000u,0x00040017u,0x00000035u,0x0000002fu,0x00000003u,0x00040020u,0x00000036u,0x00000001u,0x00000035u,0x0004003bu,0x00000036u,0x00000037u,0x00000001u,0x00040017u,0x00000038u,0x0000002fu,0x00000002u,0x00020014u,0x00000040u,0x00040020u,0x00000041u,0x00000007u,0x00000006u,0x0004002bu,0x0000002fu,0x0000004au,0x00000001u,0x00040020u,0x00000054u,0x00000007u,0x00000009u,0x0003001eu,0x00000056u,0x0000000au,0x00040020u,0x00000057u,0x00000002u,0x00000056u,0x0004003bu,0x00000057u,0x00000058u,0x00000002u,0x00040020u,0x00000059u,0x00000002u,0x00000009u,0x0004002bu,0x0000002fu,0x00000060u,0x00000002u,0x0004002bu,0x0000002fu,0x00000064u,0x00000003u,0x00040020u,0x00000067u,0x00000007u,0x0000000au,0x0004002bu,0x00000006u,0x00000070u,0xffffffffu,0x0005002cu,0x00000007u,0x00000071u,0x00000070u,0x0000001bu,0x0005002cu,0x00000007u,0x00000079u,0x0000001eu,0x0000001bu,0x0005002cu,0x00000007u,0x00000081u,0x0000001bu,0x00000070u,0x0005002cu,0x00000007u,0x00000089u,0x0000001bu,0x0000001eu,0x0004002bu,0x00000009u,0x000000b0u,0x3f000000u,0x00040017u,0x000000b7u,0x00000009u,0x00000002u,0x0004002bu,0x00000009u,0x000000c1u,0x3d4ccccdu,0x0004002bu,0x00000009u,0x000000c2u,0x3f800000u,0x0004003bu,0x00000017u,0x00000121u,0x00000000u,0x00090019u,0x0000013cu,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000002u,0x00040020u,0x0000013du,0x00000000u,0x0000013cu,0x0004003bu,0x0000013du,0x0000013eu,0x00000000u,0x0004002bu,0x0000002fu,0x00000142u,0x00000010u,0x0006002cu,0x00000035u,0x00000143u,0x00000142u,0x00000142u,0x0000004au,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000034u,0x00000007u,0x0004003bu,0x00000008u,0x0000003cu,0x00000007u,0x0004003bu,0x00000054u,0x00000055u,0x00000007u,0x0004003bu,0x00000054u,0x0000005cu,0x00000007u,0x0004003bu,0x00000054u,0x0000005fu,0x00000007u,0x0004003bu,0x00000054u,0x00000063u,0x00000007u,0x0004003bu,0x00000067u,0x00000068u,0x00000007u,0x0004003bu,0x00000008u,0x00000069u,0x00000007u,0x0004003bu,0x00000008u,0x0000006bu,0x00000007u,0x0004003bu,0x00000067u,0x0000006eu,0x00000007u,0x0004003bu,0x00000008u,0x00000073u,0x00000007u,0x0004003bu,0x00000008u,0x00000074u,0x00000007u,0x0004003bu,0x00000067u,0x00000077u,0x00000007u,0x0004003bu,0x00000008u,0x0000007bu,0x00000007u,0x0004003bu,0x00000008u,0x0000007cu,0x00000007u,0x0004003bu,0x00000067u,0x0000007fu,0x00000007u,0x0004003bu,0x00000008u,0x00000083u,0x00000007u,0x0004003bu,0x00000008u,0x00000084u,0x00000007u,0x0004003bu,0x00000067u,0x00000087u,0x00000007u,0x0004003bu,0x00000008u,0x0000008bu,0x00000007u,0x0004003bu,0x00000008u,0x0000008cu,0x00000007u,0x0004003bu,0x00000008u,0x0000008fu,0x00000007u,0x0004003bu,0x00000054u,0x00000093u,0x00000007u,0x0004003bu,0x00000008u,0x00000096u,0x00000007u,0x0004003bu,0x00000008u,0x00000097u,0x00000007u,0x0004003bu,0x00000054u,0x0000009au,0x00000007u,0x0004003bu,0x00000008u,0x0000009du,0x00000007u,0x0004003bu,0x00000008u,0x0000009eu,0x00000007u,0x0004003bu,0x00000054u,0x000000a1u,0x00000007u,0x0004003bu,0x00000008u,0x000000a4u,0x00000007u,0x0004003bu,0x00000008u,0x000000a5u,0x00000007u,0x0004003bu,0x00000054u,0x000000a8u,0x00000007u,0x0004003bu,0x00000008u,0x000000abu,0x00000007u,0x0004003bu,0x00000008u,0x000000acu,0x00000007u,0x0004003bu,0x00000054u,0x000000afu,0x00000007u,0x0004003bu,0x00000054u,0x000000bbu,0x00000007u,0x0004003bu,0x00000067u,0x000000c4u,0x00000007u,0x0004003bu,0x00000067u,0x000000c8u,0x00000007u,0x0004003bu,0x00000067u,0x000000ccu,0x00000007u,0x0004003bu,0x00000067u,0x000000d0u,0x00000007u,0x0004003bu,0x00000054u,0x000000d4u,0x00000007u,0x0004003bu,0x00000054u,0x000000d8u,0x00000007u,0x0004003bu,0x00000054u,0x000000e1u,0x00000007u,0x0004003bu,0x00000054u,0x000000eau,0x00000007u,0x0004003bu,0x00000054u,0x000000f3u,0x00000007u,0x0004003bu,0x00000054u,0x000000fcu,0x00000007u,0x0004003bu,0x00000067u,0x00000104u,0x00000007u,0x0004003bu,0x00000067u,0x00000114u,0x00000007u,0x0004003bu,0x00000067u,0x00000120u,0x00000007u,0x0004003bu,0x00000054u,0x0000012au,0x00000007u,0x0004003bu,0x00000067u,0x0000012eu,0x00000007u,0x0004003bu,0x00000067u,0x00000132u,0x00000007u,0x0004003du,0x00000035u,0x00000039u,0x00000037u,0x0007004fu,0x00000038u,0x0000003au,0x00000039u,0x00000039u,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x0000003bu,0x0000003au,0x0003003eu,0x00000034u,0x0000003bu,0x0004003du,0x00000016u,0x0000003du,0x00000018u,0x00040064u,0x00000015u,0x0000003eu,0x0000003du,0x00050067u,0x00000007u,0x0000003fu,0x0000003eu,0x0000001bu,0x0003003eu,0x0000003cu,0x0000003fu,0x00050041u,0x00000041u,0x00000042u,0x00000034u,0x00000030u,0x0004003du,0x00000006u,0x00000043u,0x00000042u,0x00050041u,0x00000041u,0x00000044u,0x0000003cu,0x00000030u,0x0004003du,0x00000006u,0x00000045u,0x00000044u,0x000500afu,0x00000040u,0x00000046u,0x00000043u,0x00000045u,0x000400a8u,0x00000040u,0x00000047u,0x00000046u,0x000300f7u,0x00000049u,0x00000000u,0x000400fau,0x00000047u,0x00000048u,0x00000049u,0x000200f8u,0x00000048u,0x00050041u,0x00000041u,0x0000004bu,0x00000034u,0x0000004au,0x0004003du,0x00000006u,0x0000004cu,0x0000004bu,0x00050041u,0x00000041u,0x0000004du,0x0000003cu,0x0000004au,0x0004003du,0x00000006u,0x0000004eu,0x0000004du,0x000500afu,0x00000040u,0x0000004fu,0x0000004cu,0x0000004eu,0x000200f9u,0x00000049u,0x000200f8u,0x00000049u,0x000700f5u,0x00000040u,0x00000050u,0x00000046u,0x00000005u,0x0000004fu,0x00000048u,0x000300f7u,0x00000052u,0x00000000u,0x000400fau,0x00000050u,0x00000051u,0x00000052u,0x000200f8u,0x00000051u,0x000100fdu,0x000200f8u,0x00000052u,0x00060041u,0x00000059u,0x0000005au,0x00000058u,0x0000001bu,0x00000030u,0x0004003du,0x00000009u,0x0000005bu,0x0000005au,0x0003003eu,0x00000055u,0x0000005bu,0x00060041u,0x00000059u,0x0000005du,0x00000058u,0x0000001bu,0x0000004au,0x0004003du,0x00000009u,0x0000005eu,0x0000005du,0x0003003eu,0x0000005cu,0x0000005eu,0x00060041u,0x00000059u,0x00000061u,0x00000058u,0x0000001bu,0x00000060u,0x0004003du,0x00000009u,0x00000062u,0x00000061u,0x0003003eu,0x0000005fu,0x00000062u,0x00060041u,0x00000059u,0x00000065u,0x00000058u,0x0000001bu,0x00000064u,0x0004003du,0x00000009u,0x00000066u,0x00000065u,0x0003003eu,0x00000063u,0x00000066u,0x0004003du,0x00000007u,0x0000006au,0x00000034u,0x0003003eu,0x00000069u,0x0000006au,0x0004003du,0x00000007u,0x0000006cu,0x0000003cu,0x0003003eu,0x0000006bu,0x0000006cu,0x00060039u,0x0000000au,0x0000006du,0x0000000eu,0x00000069u,0x0000006bu,0x0003003eu,0x00000068u,0x0000006du,0x0004003du,0x00000007u,0x0000006fu,0x00000034u,0x00050080u,0x00000007u,0x00000072u,0x0000006fu,0x00000071u,0x0003003eu,0x00000073u,0x00000072u,0x0004003du,0x00000007u,0x00000075u,0x0000003cu,0x0003003eu,0x00000074u,0x00000075u,0x00060039u,0x0000000au,0x00000076u,0x0000000eu,0x00000073u,0x00000074u,0x0003003eu,0x0000006eu,0x00000076u,0x0004003du,0x00000007u,0x00000078u,0x00000034u,0x00050080u,0x00000007u,0x0000007au,0x00000078u,0x00000079u,0x0003003eu,0x0000007bu,0x0000007au,0x0004003du,0x00000007u,0x0000007du,0x0000003cu,0x0003003eu,0x0000007cu,0x0000007du,0x00060039u,0x0000000au,0x0000007eu,0x0000000eu,0x0000007bu,0x0000007cu,0x0003003eu,0x00000077u,0x0000007eu,0x0004003du,0x00000007u,0x00000080u,0x00000034u,0x00050080u,0x00000007u,0x00000082u,0x00000080u,0x00000081u,0x0003003eu,0x00000083u,0x00000082u,0x0004003du,0x00000007u,0x00000085u,0x0000003cu,0x0003003eu,0x00000084u,0x00000085u,0x00060039u,0x0000000au,0x00000086u,0x0000000eu,0x00000083u,0x00000084u,0x0003003eu,0x0000007fu,0x00000086u,0x0004003du,0x00000007u,0x00000088u,0x00000034u,0x00050080u,0x00000007u,0x0000008au,0x00000088u,0x00000089u,0x0003003eu,0x0000008bu,0x0000008au,0x0004003du,0x00000007u,0x0000008du,0x0000003cu,0x0003003eu,0x0000008cu,0x0000008du,0x00060039u,0x0000000au,0x0000008eu,0x0000000eu,0x0000008bu,0x0000008cu,0x0003003eu,0x00000087u,0x0000008eu,0x0004003du,0x00000016u,0x00000090u,0x00000026u,0x00040064u,0x00000015u,0x00000091u,0x00000090u,0x00050067u,0x00000007u,0x00000092u,0x00000091u,0x0000001bu,0x0003003eu,0x0000008fu,0x00000092u,0x0004003du,0x00000007u,0x00000094u,0x00000034u,0x00050080u,0x00000007u,0x00000095u,0x00000094u,0x00000071u,0x0003003eu,0x00000096u,0x00000095u,0x0004003du,0x00000007u,0x00000098u,0x0000008fu,0x0003003eu,0x00000097u,0x00000098u,0x00060039u,0x00000009u,0x00000099u,0x00000013u,0x00000096u,0x00000097u,0x0003003eu,0x00000093u,0x00000099u,0x0004003du,0x00000007u,0x0000009bu,0x00000034u,0x00050080u,0x00000007u,0x0000009cu,0x0000009bu,0x00000079u,0x0003003eu,0x0000009du,0x0000009cu,0x0004003du,0x00000007u,0x0000009fu,0x0000008fu,0x0003003eu,0x0000009eu,0x0000009fu,0x00060039u,0x00000009u,0x000000a0u,0x00000013u,0x0000009du,0x0000009eu,0x0003003eu,0x0000009au,0x000000a0u,0x0004003du,0x00000007u,0x000000a2u,0x00000034u,0x00050080u,0x00000007u,0x000000a3u,0x000000a2u,0x00000081u,0x0003003eu,0x000000a4u,0x000000a3u,0x0004003du,0x00000007u,0x000000a6u,0x0000008fu,0x0003003eu,0x000000a5u,0x000000a6u,0x00060039u,0x00000009u,0x000000a7u,0x00000013u,0x000000a4u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a7u,0x0004003du,0x00000007u,0x000000a9u,0x00000034u,0x00050080u,0x00000007u,0x000000aau,0x000000a9u,0x00000089u,0x0003003eu,0x000000abu,0x000000aau,0x0004003du,0x00000007u,0x000000adu,0x0000008fu,0x0003003eu,0x000000acu,0x000000adu,0x00060039u,0x00000009u,0x000000aeu,0x00000013u,0x000000abu,0x000000acu,0x0003003eu,0x000000a8u,0x000000aeu,0x0004003du,0x00000009u,0x000000b1u,0x0000009au,0x0004003du,0x00000009u,0x000000b2u,0x00000093u,0x00050083u,0x00000009u,0x000000b3u,0x000000b1u,0x000000b2u,0x0004003du,0x00000009u,0x000000b4u,0x000000a8u,0x0004003du,0x00000009u,0x000000b5u,0x000000a1u,0x00050083u,0x00000009u,0x000000b6u,0x000000b4u,0x000000b5u,0x00050050u,0x000000b7u,0x000000b8u,0x000000b3u,0x000000b6u,0x0006000cu,0x00000009u,0x000000b9u,0x00000001u,0x00000042u,0x000000b8u,0x00050085u,0x00000009u,0x000000bau,0x000000b0u,0x000000b9u,0x0003003eu,0x000000afu,0x000000bau,0x0004003du,0x00000009u,0x000000bcu,0x0000005fu,0x0004007fu,0x00000009u,0x000000bdu,0x000000bcu,0x0004003du,0x00000009u,0x000000beu,0x000000afu,0x00050085u,0x00000009u,0x000000bfu,0x000000bdu,0x000000beu,0x0006000cu,0x00000009u,0x000000c0u,0x00000001u,0x0000001bu,0x000000bfu,0x0008000cu,0x00000009u,0x000000c3u,0x00000001u,0x0000002bu,0x000000c0u,0x000000c1u,0x000000c2u,0x0003003eu,0x000000bbu,0x000000c3u,0x0004003du,0x0000000au,0x000000c5u,0x0000006eu,0x0004003du,0x0000000au,0x000000c6u,0x00000068u,0x00050083u,0x0000000au,0x000000c7u,0x000000c5u,0x000000c6u,0x0003003eu,0x000000c4u,0x000000c7u,0x0004003du,0x0000000au,0x000000c9u,0x00000077u,0x0004003du,0x0000000au,0x000000cau,0x00000068u,0x00050083u,0x0000000au,0x000000cbu,0x000000c9u,0x000000cau,0x0003003eu,0x000000c8u,0x000000cbu,0x0004003du,0x0000000au,0x000000cdu,0x0000007fu,0x0004003du,0x0000000au,0x000000ceu,0x00000068u,0x00050083u,0x0000000au,0x000000cfu,0x000000cdu,0x000000ceu,0x0003003eu,0x000000ccu,0x000000cfu,0x0004003du,0x0000000au,0x000000d1u,0x00000087u,0x0004003du,0x0000000au,0x000000d2u,0x00000068u,0x00050083u,0x0000000au,0x000000d3u,0x000000d1u,0x000000d2u,0x0003003eu,0x000000d0u,0x000000d3u,0x0004003du,0x00000009u,0x000000d5u,0x00000063u,0x0004003du,0x00000009u,0x000000d6u,0x00000063u,0x00050085u,0x00000009u,0x000000d7u,0x000000d5u,0x000000d6u,0x0003003eu,0x000000d4u,0x000000d7u,0x0004003du,0x00000009u,0x000000d9u,0x000000bbu,0x0004003du,0x0000000au,0x000000dau,0x000000c4u,0x0004003du,0x0000000au,0x000000dbu,0x000000c4u,0x00050094u,0x00000009u,0x000000dcu,0x000000dau,0x000000dbu,0x0004003du,0x00000009u,0x000000ddu,0x000000d4u,0x00050081u,0x00000009u,0x000000deu,0x000000dcu,0x000000ddu,0x0006000cu,0x00000009u,0x000000dfu,0x00000001u,0x00000020u,0x000000deu,0x00050085u,0x00000009u,0x000000e0u,0x000000d9u,0x000000dfu,0x0003003eu,0x000000d8u,0x000000e0u,0x0004003du,0x00000009u,0x000000e2u,0x000000bbu,0x0004003du,0x0000000au,0x000000e3u,0x000000c8u,0x0004003du,0x0000000au,0x000000e4u,0x000000c8u,0x00050094u,0x00000009u,0x000000e5u,0x000000e3u,0x000000e4u,0x0004003du,0x00000009u,0x000000e6u,0x000000d4u,0x00050081u,0x00000009u,0x000000e7u,0x000000e5u,0x000000e6u,0x0006000cu,0x00000009u,0x000000e8u,0x00000001u,0x00000020u,0x000000e7u,0x00050085u,0x00000009u,0x000000e9u,0x000000e2u,0x000000e8u,0x0003003eu,0x000000e1u,0x000000e9u,0x0004003du,0x00000009u,0x000000ebu,0x000000bbu,0x0004003du,0x0000000au,0x000000ecu,0x000000ccu,0x0004003du,0x0000000au,0x000000edu,0x000000ccu,0x00050094u,0x00000009u,0x000000eeu,0x000000ecu,0x000000edu,0x0004003du,0x00000009u,0x000000efu,0x000000d4u,0x00050081u,0x00000009u,0x000000f0u,0x000000eeu,0x000000efu,0x0006000cu,0x00000009u,0x000000f1u,0x00000001u,0x00000020u,0x000000f0u,0x00050085u,0x00000009u,0x000000f2u,0x000000ebu,0x000000f1u,0x0003003eu,0x000000eau,0x000000f2u,0x0004003du,0x00000009u,0x000000f4u,0x000000bbu,0x0004003du,0x0000000au,0x000000f5u,0x000000d0u,0x0004003du,0x0000000au,0x000000f6u,0x000000d0u,0x00050094u,0x00000009u,0x000000f7u,0x000000f5u,0x000000f6u,0x0004003du,0x00000009u,0x000000f8u,0x000000d4u,0x00050081u,0x00000009u,0x000000f9u,0x000000f7u,0x000000f8u,0x0006000cu,0x00000009u,0x000000fau,0x00000001u,0x00000020u,0x000000f9u,0x00050085u,0x00000009u,0x000000fbu,0x000000f4u,0x000000fau,0x0003003eu,0x000000f3u,0x000000fbu,0x0004003du,0x00000009u,0x000000fdu,0x000000d8u,0x0004003du,0x00000009u,0x000000feu,0x000000e1u,0x00050081u,0x00000009u,0x000000ffu,0x000000fdu,0x000000feu,0x0004003du,0x00000009u,0x00000100u,0x000000eau,0x00050081u,0x00000009u,0x00000101u,0x000000ffu,0x00000100u,0x0004003du,0x00000009u,0x00000102u,0x000000f3u,0x00050081u,0x00000009u,0x00000103u,0x00000101u,0x00000102u,0x0003003eu,0x000000fcu,0x00000103u,0x0004003du,0x00000009u,0x00000105u,0x000000d8u,0x0004003du,0x0000000au,0x00000106u,0x0000006eu,0x0005008eu,0x0000000au,0x00000107u,0x00000106u,0x00000105u,0x0004003du,0x00000009u,0x00000108u,0x000000e1u,0x0004003du,0x0000000au,0x00000109u,0x00000077u,0x0005008eu,0x0000000au,0x0000010au,0x00000109u,0x00000108u,0x00050081u,0x0000000au,0x0000010bu,0x00000107u,0x0000010au,0x0004003du,0x00000009u,0x0000010cu,0x000000eau,0x0004003du,0x0000000au,0x0000010du,0x0000007fu,0x0005008eu,0x0000000au,0x0000010eu,0x0000010du,0x0000010cu,0x00050081u,0x0000000au,0x0000010fu,0x0000010bu,0x0000010eu,0x0004003du,0x00000009u,0x00000110u,0x000000f3u,0x0004003du,0x0000000au,0x00000111u,0x00000087u,0x0005008eu,0x0000000au,0x00000112u,0x00000111u,0x00000110u,0x00050081u,0x0000000au,0x00000113u,0x0000010fu,0x00000112u,0x0003003eu,0x00000104u,0x00000113u,0x0004003du,0x0000000au,0x00000115u,0x00000068u,0x0004003du,0x00000009u,0x00000116u,0x00000055u,0x0004003du,0x0000000au,0x00000117u,0x00000104u,0x0005008eu,0x0000000au,0x00000118u,0x00000117u,0x00000116u,0x00050081u,0x0000000au,0x00000119u,0x00000115u,0x00000118u,0x0004003du,0x00000009u,0x0000011au,0x00000055u,0x0004003du,0x00000009u,0x0000011bu,0x000000fcu,0x00050085u,0x00000009u,0x0000011cu,0x0000011au,0x0000011bu,0x00050081u,0x00000009u,0x0000011du,0x000000c2u,0x0000011cu,0x00070050u,0x0000000au,0x0000011eu,0x0000011du,0x0000011du,0x0000011du,0x0000011du,0x00050088u,0x0000000au,0x0000011fu,0x00000119u,0x0000011eu,0x0003003eu,0x00000114u,0x0000011fu,0x0004003du,0x00000016u,0x00000122u,0x00000121u,0x0004003du,0x00000007u,0x00000123u,0x00000034u,0x0004003du,0x00000007u,0x00000124u,0x0000003cu,0x00050050u,0x00000007u,0x00000125u,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x00000126u,0x00000124u,0x00000125u,0x0008000cu,0x00000007u,0x00000127u,0x00000001u,0x0000002du,0x00000123u,0x0000001cu,0x00000126u,0x00040064u,0x00000015u,0x00000128u,0x00000122u,0x0007005fu,0x0000000au,0x00000129u,0x00000128u,0x00000127u,0x00000002u,0x0000001bu,0x0003003eu,0x00000120u,0x00000129u,0x0004003du,0x00000009u,0x0000012bu,0x00000055u,0x0004003du,0x00000009u,0x0000012cu,0x0000005cu,0x00050085u,0x00000009u,0x0000012du,0x0000012bu,0x0000012cu,0x0003003eu,0x0000012au,0x0000012du,0x0004003du,0x0000000au,0x0000012fu,0x00000114u,0x0004003du,0x0000000au,0x00000130u,0x00000120u,0x00050083u,0x0000000au,0x00000131u,0x0000012fu,0x00000130u,0x0003003eu,0x0000012eu,0x00000131u,0x0004003du,0x0000000au,0x00000133u,0x00000114u,0x0004003du,0x0000000au,0x00000134u,0x0000012eu,0x0004003du,0x00000009u,0x00000135u,0x0000012au,0x0004007fu,0x00000009u,0x00000136u,0x00000135u,0x00070050u,0x0000000au,0x00000137u,0x00000136u,0x00000136u,0x00000136u,0x00000136u,0x0004003du,0x00000009u,0x00000138u,0x0000012au,0x00070050u,0x0000000au,0x00000139u,0x00000138u,0x00000138u,0x00000138u,0x00000138u,0x0008000cu,0x0000000au,0x0000013au,0x00000001u,0x0000002bu,0x00000134u,0x00000137u,0x00000139u,0x00050083u,0x0000000au,0x0000013bu,0x00000133u,0x0000013au,0x0003003eu,0x00000132u,0x0000013bu,0x0004003du,0x0000013cu,0x0000013fu,0x0000013eu,0x0004003du,0x00000007u,0x00000140u,0x00000034u,0x0004003du,0x0000000au,0x00000141u,0x00000132u,0x00040063u,0x0000013fu,0x00000140u,0x00000141u,0x000100fdu,0x00010038u,0x00050036u,0x0000000au,0x0000000eu,0x00000000u,0x0000000bu,0x00030037u,0x00000008u,0x0000000cu,0x00030037u,0x00000008u,0x0000000du,0x000200f8u,0x0000000fu,0x0004003du,0x00000016u,0x00000019u,0x00000018u,0x0004003du,0x00000007u,0x0000001au,0x0000000cu,0x0004003du,0x00000007u,0x0000001du,0x0000000du,0x00050050u,0x00000007u,0x0000001fu,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x00000020u,0x0000001du,0x0000001fu,0x0008000cu,0x00000007u,0x00000021u,0x00000001u,0x0000002du,0x0000001au,0x0000001cu,0x00000020u,0x00040064u,0x00000015u,0x00000022u,0x00000019u,0x0007005fu,0x0000000au,0x00000023u,0x00000022u,0x00000021u,0x00000002u,0x0000001bu,0x000200feu,0x00000023u,0x00010038u,0x00050036u,0x00000009u,0x00000013u,0x00000000u,0x00000010u,0x00030037u,0x00000008u,0x00000011u,0x00030037u,0x00000008u,0x00000012u,0x000200f8u,0x00000014u,0x0004003du,0x00000016u,0x00000027u,0x00000026u,0x0004003du,0x00000007u,0x00000028u,0x00000011u,0x0004003du,0x00000007u,0x00000029u,0x00000012u,0x00050050u,0x00000007u,0x0000002au,0x0000001eu,0x0000001eu,0x00050082u,0x00000007u,0x0000002bu,0x00000029u,0x0000002au,0x0008000cu,0x00000007u,0x0000002cu,0x00000001u,0x0000002du,0x00000028u,0x0000001cu,0x0000002bu,0x00040064u,0x00000015u,0x0000002du,0x00000027u,0x0007005fu,0x0000000au,0x0000002eu,0x0000002du,0x0000002cu,0x00000002u,0x0000001bu,0x00050051u,0x00000009u,0x00000031u,0x0000002eu,0x00000000u,0x000200feu,0x00000031u,0x00010038u};
static const size_t OF3_FLOWREG_LEN = 1936;
-static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0x00000022u,0x00000000u,0x00040047u,0x00000207u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00030016u,0x00000006u,0x00000020u,0x00040017u,0x00000007u,0x00000006u,0x00000003u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040021u,0x00000009u,0x00000006u,0x00000008u,0x0004002bu,0x00000006u,0x0000000eu,0x3e59b3d0u,0x0004002bu,0x00000006u,0x0000000fu,0x3f371759u,0x0004002bu,0x00000006u,0x00000010u,0x3d93dd98u,0x0006002cu,0x00000007u,0x00000011u,0x0000000eu,0x0000000fu,0x00000010u,0x00040015u,0x00000015u,0x00000020u,0x00000001u,0x00040017u,0x00000016u,0x00000015u,0x00000002u,0x00040020u,0x00000017u,0x00000007u,0x00000016u,0x00040015u,0x00000019u,0x00000020u,0x00000000u,0x00040017u,0x0000001au,0x00000019u,0x00000003u,0x00040020u,0x0000001bu,0x00000001u,0x0000001au,0x0004003bu,0x0000001bu,0x0000001cu,0x00000001u,0x00040017u,0x0000001du,0x00000019u,0x00000002u,0x00090019u,0x00000022u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000004u,0x00040020u,0x00000023u,0x00000000u,0x00000022u,0x0004003bu,0x00000023u,0x00000024u,0x00000000u,0x00020014u,0x00000027u,0x0004002bu,0x00000019u,0x00000028u,0x00000000u,0x00040020u,0x00000029u,0x00000007u,0x00000015u,0x0004002bu,0x00000019u,0x00000032u,0x00000001u,0x00040017u,0x0000003cu,0x00000006u,0x00000002u,0x00040020u,0x0000003du,0x00000007u,0x0000003cu,0x0004002bu,0x00000006u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000006u,0x00000047u,0x3f000000u,0x00040017u,0x0000004cu,0x00000006u,0x00000004u,0x000b001eu,0x0000004du,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x0000004cu,0x00040020u,0x0000004eu,0x00000002u,0x0000004du,0x0004003bu,0x0000004eu,0x0000004fu,0x00000002u,0x0004002bu,0x00000015u,0x00000050u,0x00000008u,0x00040020u,0x00000051u,0x00000002u,0x00000006u,0x0004002bu,0x00000019u,0x00000054u,0x00000002u,0x0004002bu,0x00000019u,0x0000005cu,0x00000003u,0x00090019u,0x00000089u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000008au,0x00000089u,0x00040020u,0x0000008bu,0x00000000u,0x0000008au,0x0004003bu,0x0000008bu,0x0000008cu,0x00000000u,0x0004002bu,0x00000006u,0x0000008fu,0x00000000u,0x00040020u,0x00000097u,0x00000007u,0x00000027u,0x0004002bu,0x00000015u,0x00000099u,0x00000000u,0x0004002bu,0x00000006u,0x0000009cu,0x40600000u,0x00040020u,0x0000009eu,0x00000007u,0x00000006u,0x00040020u,0x000000a8u,0x00000007u,0x0000004cu,0x0004003bu,0x0000008bu,0x000000aau,0x00000000u,0x0004003bu,0x0000008bu,0x000000afu,0x00000000u,0x0004002bu,0x00000006u,0x000000cdu,0x3dcccccdu,0x0004002bu,0x00000015u,0x000000e2u,0x00000001u,0x0004003bu,0x0000008bu,0x000000f1u,0x00000000u,0x0004002bu,0x00000015u,0x0000014au,0x00000005u,0x0004002bu,0x00000015u,0x0000014fu,0x00000003u,0x0004002bu,0x00000015u,0x00000170u,0x00000002u,0x0004002bu,0x00000006u,0x00000189u,0x322bcc77u,0x0004002bu,0x00000006u,0x000001b2u,0x3d23d70au,0x0004002bu,0x00000006u,0x000001b3u,0x3e3851ecu,0x0004002bu,0x00000015u,0x000001bcu,0x00000004u,0x0004002bu,0x00000006u,0x000001d3u,0x3e800000u,0x0004002bu,0x00000006u,0x000001d8u,0x3e4ccccdu,0x0004002bu,0x00000006u,0x000001d9u,0x3ecccccdu,0x0004002bu,0x00000019u,0x00000206u,0x00000010u,0x0006002cu,0x0000001au,0x00000207u,0x00000206u,0x00000206u,0x00000032u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000017u,0x00000018u,0x00000007u,0x0004003bu,0x00000017u,0x00000021u,0x00000007u,0x0004003bu,0x0000003du,0x0000003eu,0x00000007u,0x0004003bu,0x0000003du,0x00000044u,0x00000007u,0x0004003bu,0x00000097u,0x00000098u,0x00000007u,0x0004003bu,0x0000009eu,0x0000009fu,0x00000007u,0x0004003bu,0x0000009eu,0x000000a1u,0x00000007u,0x0004003bu,0x000000a8u,0x000000a9u,0x00000007u,0x0004003bu,0x000000a8u,0x000000aeu,0x00000007u,0x0004003bu,0x0000003du,0x000000b3u,0x00000007u,0x0004003bu,0x0000003du,0x000000bau,0x00000007u,0x0004003bu,0x0000009eu,0x000000c1u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c5u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c9u,0x00000007u,0x0004003bu,0x0000009eu,0x000000d5u,0x00000007u,0x0004003bu,0x0000003du,0x000000e0u,0x00000007u,0x0004003bu,0x0000003du,0x000000e8u,0x00000007u,0x0004003bu,0x00000008u,0x000000f0u,0x00000007u,0x0004003bu,0x00000008u,0x000000f6u,0x00000007u,0x0004003bu,0x0000009eu,0x000000fbu,0x00000007u,0x0004003bu,0x00000008u,0x000000fcu,0x00000007u,0x0004003bu,0x00000008u,0x0000010cu,0x00000007u,0x0004003bu,0x0000009eu,0x00000111u,0x00000007u,0x0004003bu,0x00000008u,0x00000112u,0x00000007u,0x0004003bu,0x00000008u,0x00000122u,0x00000007u,0x0004003bu,0x0000003du,0x00000127u,0x00000007u,0x0004003bu,0x0000009eu,0x0000012bu,0x00000007u,0x0004003bu,0x0000009eu,0x00000138u,0x00000007u,0x0004003bu,0x0000009eu,0x00000149u,0x00000007u,0x0004003bu,0x0000009eu,0x00000157u,0x00000007u,0x0004003bu,0x0000009eu,0x00000163u,0x00000007u,0x0004003bu,0x0000009eu,0x00000167u,0x00000007u,0x0004003bu,0x0000009eu,0x0000016au,0x00000007u,0x0004003bu,0x0000009eu,0x0000016eu,0x00000007u,0x0004003bu,0x0000009eu,0x00000179u,0x00000007u,0x0004003bu,0x0000009eu,0x00000183u,0x00000007u,0x0004003bu,0x00000008u,0x00000187u,0x00000007u,0x0004003bu,0x00000008u,0x0000018bu,0x00000007u,0x0004003bu,0x00000008u,0x000001a0u,0x00000007u,0x0004003bu,0x0000009eu,0x000001adu,0x00000007u,0x0004003bu,0x0000009eu,0x000001b1u,0x00000007u,0x0004003bu,0x0000009eu,0x000001b7u,0x00000007u,0x0004003bu,0x00000008u,0x000001c3u,0x00000007u,0x0004003bu,0x00000008u,0x000001c8u,0x00000007u,0x0004003bu,0x0000009eu,0x000001cdu,0x00000007u,0x0004003bu,0x0000009eu,0x000001d2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001d7u,0x00000007u,0x0004003bu,0x0000009eu,0x000001dcu,0x00000007u,0x0004003bu,0x00000008u,0x000001e5u,0x00000007u,0x0004003bu,0x0000009eu,0x000001eeu,0x00000007u,0x0004003bu,0x0000009eu,0x000001f2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001f6u,0x00000007u,0x0004003du,0x0000001au,0x0000001eu,0x0000001cu,0x0007004fu,0x0000001du,0x0000001fu,0x0000001eu,0x0000001eu,0x00000000u,0x00000001u,0x0004007cu,0x00000016u,0x00000020u,0x0000001fu,0x0003003eu,0x00000018u,0x00000020u,0x0004003du,0x00000022u,0x00000025u,0x00000024u,0x00040068u,0x00000016u,0x00000026u,0x00000025u,0x0003003eu,0x00000021u,0x00000026u,0x00050041u,0x00000029u,0x0000002au,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000002bu,0x0000002au,0x00050041u,0x00000029u,0x0000002cu,0x00000021u,0x00000028u,0x0004003du,0x00000015u,0x0000002du,0x0000002cu,0x000500afu,0x00000027u,0x0000002eu,0x0000002bu,0x0000002du,0x000400a8u,0x00000027u,0x0000002fu,0x0000002eu,0x000300f7u,0x00000031u,0x00000000u,0x000400fau,0x0000002fu,0x00000030u,0x00000031u,0x000200f8u,0x00000030u,0x00050041u,0x00000029u,0x00000033u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000034u,0x00000033u,0x00050041u,0x00000029u,0x00000035u,0x00000021u,0x00000032u,0x0004003du,0x00000015u,0x00000036u,0x00000035u,0x000500afu,0x00000027u,0x00000037u,0x00000034u,0x00000036u,0x000200f9u,0x00000031u,0x000200f8u,0x00000031u,0x000700f5u,0x00000027u,0x00000038u,0x0000002eu,0x00000005u,0x00000037u,0x00000030u,0x000300f7u,0x0000003au,0x00000000u,0x000400fau,0x00000038u,0x00000039u,0x0000003au,0x000200f8u,0x00000039u,0x000100fdu,0x000200f8u,0x0000003au,0x0004003du,0x00000016u,0x00000040u,0x00000021u,0x0004006fu,0x0000003cu,0x00000041u,0x00000040u,0x00050050u,0x0000003cu,0x00000042u,0x0000003fu,0x0000003fu,0x00050088u,0x0000003cu,0x00000043u,0x00000042u,0x00000041u,0x0003003eu,0x0000003eu,0x00000043u,0x0004003du,0x00000016u,0x00000045u,0x00000018u,0x0004006fu,0x0000003cu,0x00000046u,0x00000045u,0x00050050u,0x0000003cu,0x00000048u,0x00000047u,0x00000047u,0x00050081u,0x0000003cu,0x00000049u,0x00000046u,0x00000048u,0x0004003du,0x0000003cu,0x0000004au,0x0000003eu,0x00050085u,0x0000003cu,0x0000004bu,0x00000049u,0x0000004au,0x0003003eu,0x00000044u,0x0000004bu,0x00060041u,0x00000051u,0x00000052u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000053u,0x00000052u,0x00060041u,0x00000051u,0x00000055u,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000056u,0x00000055u,0x000500b8u,0x00000027u,0x00000057u,0x00000053u,0x00000056u,0x000300f7u,0x00000059u,0x00000000u,0x000400fau,0x00000057u,0x00000058u,0x00000059u,0x000200f8u,0x00000058u,0x00060041u,0x00000051u,0x0000005au,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x0000005bu,0x0000005au,0x00060041u,0x00000051u,0x0000005du,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x0000005eu,0x0000005du,0x000500b8u,0x00000027u,0x0000005fu,0x0000005bu,0x0000005eu,0x000200f9u,0x00000059u,0x000200f8u,0x00000059u,0x000700f5u,0x00000027u,0x00000060u,0x00000057u,0x0000003au,0x0000005fu,0x00000058u,0x000300f7u,0x00000062u,0x00000000u,0x000400fau,0x00000060u,0x00000061u,0x00000062u,0x000200f8u,0x00000061u,0x00050041u,0x00000029u,0x00000063u,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x00000064u,0x00000063u,0x0004006fu,0x00000006u,0x00000065u,0x00000064u,0x00060041u,0x00000051u,0x00000066u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000067u,0x00000066u,0x000500beu,0x00000027u,0x00000068u,0x00000065u,0x00000067u,0x000200f9u,0x00000062u,0x000200f8u,0x00000062u,0x000700f5u,0x00000027u,0x00000069u,0x00000060u,0x00000059u,0x00000068u,0x00000061u,0x000300f7u,0x0000006bu,0x00000000u,0x000400fau,0x00000069u,0x0000006au,0x0000006bu,0x000200f8u,0x0000006au,0x00050041u,0x00000029u,0x0000006cu,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000006du,0x0000006cu,0x0004006fu,0x00000006u,0x0000006eu,0x0000006du,0x00060041u,0x00000051u,0x0000006fu,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000070u,0x0000006fu,0x000500b8u,0x00000027u,0x00000071u,0x0000006eu,0x00000070u,0x000200f9u,0x0000006bu,0x000200f8u,0x0000006bu,0x000700f5u,0x00000027u,0x00000072u,0x00000069u,0x00000062u,0x00000071u,0x0000006au,0x000300f7u,0x00000074u,0x00000000u,0x000400fau,0x00000072u,0x00000073u,0x00000074u,0x000200f8u,0x00000073u,0x00050041u,0x00000029u,0x00000075u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000076u,0x00000075u,0x0004006fu,0x00000006u,0x00000077u,0x00000076u,0x00060041u,0x00000051u,0x00000078u,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x00000079u,0x00000078u,0x000500beu,0x00000027u,0x0000007au,0x00000077u,0x00000079u,0x000200f9u,0x00000074u,0x000200f8u,0x00000074u,0x000700f5u,0x00000027u,0x0000007bu,0x00000072u,0x0000006bu,0x0000007au,0x00000073u,0x000300f7u,0x0000007du,0x00000000u,0x000400fau,0x0000007bu,0x0000007cu,0x0000007du,0x000200f8u,0x0000007cu,0x00050041u,0x00000029u,0x0000007eu,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x0000007fu,0x0000007eu,0x0004006fu,0x00000006u,0x00000080u,0x0000007fu,0x00060041u,0x00000051u,0x00000081u,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x00000082u,0x00000081u,0x000500b8u,0x00000027u,0x00000083u,0x00000080u,0x00000082u,0x000200f9u,0x0000007du,0x000200f8u,0x0000007du,0x000700f5u,0x00000027u,0x00000084u,0x0000007bu,0x00000074u,0x00000083u,0x0000007cu,0x000300f7u,0x00000086u,0x00000000u,0x000400fau,0x00000084u,0x00000085u,0x00000086u,0x000200f8u,0x00000085u,0x0004003du,0x00000022u,0x00000087u,0x00000024u,0x0004003du,0x00000016u,0x00000088u,0x00000018u,0x0004003du,0x0000008au,0x0000008du,0x0000008cu,0x0004003du,0x0000003cu,0x0000008eu,0x00000044u,0x00070058u,0x0000004cu,0x00000090u,0x0000008du,0x0000008eu,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000091u,0x00000090u,0x00000090u,0x00000000u,0x00000001u,0x00000002u,0x00050051u,0x00000006u,0x00000092u,0x00000091u,0x00000000u,0x00050051u,0x00000006u,0x00000093u,0x00000091u,0x00000001u,0x00050051u,0x00000006u,0x00000094u,0x00000091u,0x00000002u,0x00070050u,0x0000004cu,0x00000095u,0x00000092u,0x00000093u,0x00000094u,0x0000003fu,0x00040063u,0x00000087u,0x00000088u,0x00000095u,0x000100fdu,0x000200f8u,0x00000086u,0x00050041u,0x00000051u,0x0000009au,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000009bu,0x0000009au,0x000500bau,0x00000027u,0x0000009du,0x0000009bu,0x0000009cu,0x0003003eu,0x00000098u,0x0000009du,0x0004003du,0x00000027u,0x000000a0u,0x00000098u,0x000300f7u,0x000000a3u,0x00000000u,0x000400fau,0x000000a0u,0x000000a2u,0x000000a4u,0x000200f8u,0x000000a2u,0x0003003eu,0x000000a1u,0x0000003fu,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a4u,0x00050041u,0x00000051u,0x000000a5u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x000000a6u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a6u,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a3u,0x0004003du,0x00000006u,0x000000a7u,0x000000a1u,0x0003003eu,0x0000009fu,0x000000a7u,0x0004003du,0x0000008au,0x000000abu,0x000000aau,0x0004003du,0x0000003cu,0x000000acu,0x00000044u,0x00070058u,0x0000004cu,0x000000adu,0x000000abu,0x000000acu,0x00000002u,0x0000008fu,0x0003003eu,0x000000a9u,0x000000adu,0x0004003du,0x0000008au,0x000000b0u,0x000000afu,0x0004003du,0x0000003cu,0x000000b1u,0x00000044u,0x00070058u,0x0000004cu,0x000000b2u,0x000000b0u,0x000000b1u,0x00000002u,0x0000008fu,0x0003003eu,0x000000aeu,0x000000b2u,0x0004003du,0x0000004cu,0x000000b4u,0x000000a9u,0x0007004fu,0x0000003cu,0x000000b5u,0x000000b4u,0x000000b4u,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000b6u,0x0000009fu,0x0005008eu,0x0000003cu,0x000000b7u,0x000000b5u,0x000000b6u,0x0004003du,0x0000003cu,0x000000b8u,0x0000003eu,0x00050085u,0x0000003cu,0x000000b9u,0x000000b7u,0x000000b8u,0x0003003eu,0x000000b3u,0x000000b9u,0x0004003du,0x0000004cu,0x000000bbu,0x000000aeu,0x0007004fu,0x0000003cu,0x000000bcu,0x000000bbu,0x000000bbu,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000bdu,0x0000009fu,0x0005008eu,0x0000003cu,0x000000beu,0x000000bcu,0x000000bdu,0x0004003du,0x0000003cu,0x000000bfu,0x0000003eu,0x00050085u,0x0000003cu,0x000000c0u,0x000000beu,0x000000bfu,0x0003003eu,0x000000bau,0x000000c0u,0x00050041u,0x0000009eu,0x000000c2u,0x000000a9u,0x0000005cu,0x0004003du,0x00000006u,0x000000c3u,0x000000c2u,0x0008000cu,0x00000006u,0x000000c4u,0x00000001u,0x0000002bu,0x000000c3u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c1u,0x000000c4u,0x00050041u,0x0000009eu,0x000000c6u,0x000000aeu,0x0000005cu,0x0004003du,0x00000006u,0x000000c7u,0x000000c6u,0x0008000cu,0x00000006u,0x000000c8u,0x00000001u,0x0000002bu,0x000000c7u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c5u,0x000000c8u,0x0004003du,0x0000003cu,0x000000cau,0x000000b3u,0x0006000cu,0x00000006u,0x000000cbu,0x00000001u,0x00000042u,0x000000cau,0x0003003eu,0x000000c9u,0x000000cbu,0x0004003du,0x00000006u,0x000000ccu,0x000000c9u,0x000500bau,0x00000027u,0x000000ceu,0x000000ccu,0x000000cdu,0x000300f7u,0x000000d0u,0x00000000u,0x000400fau,0x000000ceu,0x000000cfu,0x000000d0u,0x000200f8u,0x000000cfu,0x0004003du,0x00000006u,0x000000d1u,0x000000c9u,0x00050088u,0x00000006u,0x000000d2u,0x000000cdu,0x000000d1u,0x0004003du,0x0000003cu,0x000000d3u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000d4u,0x000000d3u,0x000000d2u,0x0003003eu,0x000000b3u,0x000000d4u,0x000200f9u,0x000000d0u,0x000200f8u,0x000000d0u,0x0004003du,0x0000003cu,0x000000d6u,0x000000bau,0x0006000cu,0x00000006u,0x000000d7u,0x00000001u,0x00000042u,0x000000d6u,0x0003003eu,0x000000d5u,0x000000d7u,0x0004003du,0x00000006u,0x000000d8u,0x000000d5u,0x000500bau,0x00000027u,0x000000d9u,0x000000d8u,0x000000cdu,0x000300f7u,0x000000dbu,0x00000000u,0x000400fau,0x000000d9u,0x000000dau,0x000000dbu,0x000200f8u,0x000000dau,0x0004003du,0x00000006u,0x000000dcu,0x000000d5u,0x00050088u,0x00000006u,0x000000ddu,0x000000cdu,0x000000dcu,0x0004003du,0x0000003cu,0x000000deu,0x000000bau,0x0005008eu,0x0000003cu,0x000000dfu,0x000000deu,0x000000ddu,0x0003003eu,0x000000bau,0x000000dfu,0x000200f9u,0x000000dbu,0x000200f8u,0x000000dbu,0x0004003du,0x0000003cu,0x000000e1u,0x00000044u,0x00050041u,0x00000051u,0x000000e3u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000e4u,0x000000e3u,0x0004003du,0x0000003cu,0x000000e5u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000e6u,0x000000e5u,0x000000e4u,0x00050083u,0x0000003cu,0x000000e7u,0x000000e1u,0x000000e6u,0x0003003eu,0x000000e0u,0x000000e7u,0x0004003du,0x0000003cu,0x000000e9u,0x00000044u,0x00050041u,0x00000051u,0x000000eau,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000ebu,0x000000eau,0x00050083u,0x00000006u,0x000000ecu,0x0000003fu,0x000000ebu,0x0004003du,0x0000003cu,0x000000edu,0x000000bau,0x0005008eu,0x0000003cu,0x000000eeu,0x000000edu,0x000000ecu,0x00050083u,0x0000003cu,0x000000efu,0x000000e9u,0x000000eeu,0x0003003eu,0x000000e8u,0x000000efu,0x0004003du,0x0000008au,0x000000f2u,0x000000f1u,0x0004003du,0x0000003cu,0x000000f3u,0x000000e0u,0x00070058u,0x0000004cu,0x000000f4u,0x000000f2u,0x000000f3u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000f5u,0x000000f4u,0x000000f4u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f0u,0x000000f5u,0x0004003du,0x0000008au,0x000000f7u,0x0000008cu,0x0004003du,0x0000003cu,0x000000f8u,0x000000e8u,0x00070058u,0x0000004cu,0x000000f9u,0x000000f7u,0x000000f8u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000fau,0x000000f9u,0x000000f9u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f6u,0x000000fau,0x0004003du,0x00000007u,0x000000fdu,0x000000f0u,0x0003003eu,0x000000fcu,0x000000fdu,0x00050039u,0x00000006u,0x000000feu,0x0000000bu,0x000000fcu,0x0004003du,0x0000008au,0x000000ffu,0x0000008cu,0x0004003du,0x0000003cu,0x00000100u,0x000000e0u,0x0004003du,0x0000008au,0x00000101u,0x000000aau,0x0004003du,0x0000003cu,0x00000102u,0x000000e0u,0x00070058u,0x0000004cu,0x00000103u,0x00000101u,0x00000102u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000104u,0x00000103u,0x00000103u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000105u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000106u,0x00000105u,0x0005008eu,0x0000003cu,0x00000107u,0x00000104u,0x00000106u,0x0004003du,0x0000003cu,0x00000108u,0x0000003eu,0x00050085u,0x0000003cu,0x00000109u,0x00000107u,0x00000108u,0x00050081u,0x0000003cu,0x0000010au,0x00000100u,0x00000109u,0x00070058u,0x0000004cu,0x0000010bu,0x000000ffu,0x0000010au,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x0000010du,0x0000010bu,0x0000010bu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000010cu,0x0000010du,0x00050039u,0x00000006u,0x0000010eu,0x0000000bu,0x0000010cu,0x00050083u,0x00000006u,0x0000010fu,0x000000feu,0x0000010eu,0x0006000cu,0x00000006u,0x00000110u,0x00000001u,0x00000004u,0x0000010fu,0x0003003eu,0x000000fbu,0x00000110u,0x0004003du,0x00000007u,0x00000113u,0x000000f6u,0x0003003eu,0x00000112u,0x00000113u,0x00050039u,0x00000006u,0x00000114u,0x0000000bu,0x00000112u,0x0004003du,0x0000008au,0x00000115u,0x000000f1u,0x0004003du,0x0000003cu,0x00000116u,0x000000e8u,0x0004003du,0x0000008au,0x00000117u,0x000000afu,0x0004003du,0x0000003cu,0x00000118u,0x000000e8u,0x00070058u,0x0000004cu,0x00000119u,0x00000117u,0x00000118u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x0000011au,0x00000119u,0x00000119u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x0000011bu,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000011cu,0x0000011bu,0x0005008eu,0x0000003cu,0x0000011du,0x0000011au,0x0000011cu,0x0004003du,0x0000003cu,0x0000011eu,0x0000003eu,0x00050085u,0x0000003cu,0x0000011fu,0x0000011du,0x0000011eu,0x00050081u,0x0000003cu,0x00000120u,0x00000116u,0x0000011fu,0x00070058u,0x0000004cu,0x00000121u,0x00000115u,0x00000120u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000123u,0x00000121u,0x00000121u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x00000122u,0x00000123u,0x00050039u,0x00000006u,0x00000124u,0x0000000bu,0x00000122u,0x00050083u,0x00000006u,0x00000125u,0x00000114u,0x00000124u,0x0006000cu,0x00000006u,0x00000126u,0x00000001u,0x00000004u,0x00000125u,0x0003003eu,0x00000111u,0x00000126u,0x0004003du,0x0000003cu,0x00000128u,0x00000044u,0x0004003du,0x0000003cu,0x00000129u,0x000000b3u,0x00050081u,0x0000003cu,0x0000012au,0x00000128u,0x00000129u,0x0003003eu,0x00000127u,0x0000012au,0x0004003du,0x0000003cu,0x0000012cu,0x000000b3u,0x0004003du,0x0000008au,0x0000012du,0x000000afu,0x0004003du,0x0000003cu,0x0000012eu,0x00000127u,0x00070058u,0x0000004cu,0x0000012fu,0x0000012du,0x0000012eu,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000130u,0x0000012fu,0x0000012fu,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000131u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000132u,0x00000131u,0x0005008eu,0x0000003cu,0x00000133u,0x00000130u,0x00000132u,0x0004003du,0x0000003cu,0x00000134u,0x0000003eu,0x00050085u,0x0000003cu,0x00000135u,0x00000133u,0x00000134u,0x00050081u,0x0000003cu,0x00000136u,0x0000012cu,0x00000135u,0x0006000cu,0x00000006u,0x00000137u,0x00000001u,0x00000042u,0x00000136u,0x0003003eu,0x0000012bu,0x00000137u,0x0004003du,0x00000006u,0x00000139u,0x000000c1u,0x0004003du,0x00000006u,0x0000013au,0x000000c5u,0x0007000cu,0x00000006u,0x0000013bu,0x00000001u,0x00000025u,0x00000139u,0x0000013au,0x0004003du,0x00000006u,0x0000013cu,0x0000012bu,0x0004007fu,0x00000006u,0x0000013du,0x0000013cu,0x0004003du,0x0000003cu,0x0000013eu,0x000000b3u,0x0006000cu,0x00000006u,0x0000013fu,0x00000001u,0x00000042u,0x0000013eu,0x0004003du,0x0000003cu,0x00000140u,0x000000bau,0x0006000cu,0x00000006u,0x00000141u,0x00000001u,0x00000042u,0x00000140u,0x00050081u,0x00000006u,0x00000142u,0x0000013fu,0x00000141u,0x00050041u,0x0000009eu,0x00000143u,0x0000003eu,0x00000028u,0x0004003du,0x00000006u,0x00000144u,0x00000143u,0x00050081u,0x00000006u,0x00000145u,0x00000142u,0x00000144u,0x00050088u,0x00000006u,0x00000146u,0x0000013du,0x00000145u,0x0006000cu,0x00000006u,0x00000147u,0x00000001u,0x0000001bu,0x00000146u,0x00050085u,0x00000006u,0x00000148u,0x0000013bu,0x00000147u,0x0003003eu,0x00000138u,0x00000148u,0x00050041u,0x00000051u,0x0000014bu,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x0000014cu,0x0000014bu,0x0004003du,0x00000006u,0x0000014du,0x000000fbu,0x00050085u,0x00000006u,0x0000014eu,0x0000014cu,0x0000014du,0x00050041u,0x00000051u,0x00000150u,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x00000151u,0x00000150u,0x0004003du,0x00000006u,0x00000152u,0x00000138u,0x00050083u,0x00000006u,0x00000153u,0x0000003fu,0x00000152u,0x00050085u,0x00000006u,0x00000154u,0x00000151u,0x00000153u,0x00050081u,0x00000006u,0x00000155u,0x0000014eu,0x00000154u,0x0004007fu,0x00000006u,0x00000156u,0x00000155u,0x0003003eu,0x00000149u,0x00000156u,0x00050041u,0x00000051u,0x00000158u,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x00000159u,0x00000158u,0x0004003du,0x00000006u,0x0000015au,0x00000111u,0x00050085u,0x00000006u,0x0000015bu,0x00000159u,0x0000015au,0x00050041u,0x00000051u,0x0000015cu,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x0000015du,0x0000015cu,0x0004003du,0x00000006u,0x0000015eu,0x00000138u,0x00050083u,0x00000006u,0x0000015fu,0x0000003fu,0x0000015eu,0x00050085u,0x00000006u,0x00000160u,0x0000015du,0x0000015fu,0x00050081u,0x00000006u,0x00000161u,0x0000015bu,0x00000160u,0x0004007fu,0x00000006u,0x00000162u,0x00000161u,0x0003003eu,0x00000157u,0x00000162u,0x00050041u,0x00000051u,0x00000164u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000165u,0x00000164u,0x00050083u,0x00000006u,0x00000166u,0x0000003fu,0x00000165u,0x0003003eu,0x00000163u,0x00000166u,0x00050041u,0x00000051u,0x00000168u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000169u,0x00000168u,0x0003003eu,0x00000167u,0x00000169u,0x0004003du,0x00000006u,0x0000016bu,0x00000149u,0x0004003du,0x00000006u,0x0000016cu,0x00000157u,0x0007000cu,0x00000006u,0x0000016du,0x00000001u,0x00000028u,0x0000016bu,0x0000016cu,0x0003003eu,0x0000016au,0x0000016du,0x0004003du,0x00000006u,0x0000016fu,0x00000163u,0x00050041u,0x00000051u,0x00000171u,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x00000172u,0x00000171u,0x0004003du,0x00000006u,0x00000173u,0x00000149u,0x0004003du,0x00000006u,0x00000174u,0x0000016au,0x00050083u,0x00000006u,0x00000175u,0x00000173u,0x00000174u,0x00050085u,0x00000006u,0x00000176u,0x00000172u,0x00000175u,0x0006000cu,0x00000006u,0x00000177u,0x00000001u,0x0000001bu,0x00000176u,0x00050085u,0x00000006u,0x00000178u,0x0000016fu,0x00000177u,0x0003003eu,0x0000016eu,0x00000178u,0x0004003du,0x00000006u,0x0000017au,0x00000167u,0x00050041u,0x00000051u,0x0000017bu,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x0000017cu,0x0000017bu,0x0004003du,0x00000006u,0x0000017du,0x00000157u,0x0004003du,0x00000006u,0x0000017eu,0x0000016au,0x00050083u,0x00000006u,0x0000017fu,0x0000017du,0x0000017eu,0x00050085u,0x00000006u,0x00000180u,0x0000017cu,0x0000017fu,0x0006000cu,0x00000006u,0x00000181u,0x00000001u,0x0000001bu,0x00000180u,0x00050085u,0x00000006u,0x00000182u,0x0000017au,0x00000181u,0x0003003eu,0x00000179u,0x00000182u,0x0004003du,0x00000006u,0x00000184u,0x0000016eu,0x0004003du,0x00000006u,0x00000185u,0x00000179u,0x00050081u,0x00000006u,0x00000186u,0x00000184u,0x00000185u,0x0003003eu,0x00000183u,0x00000186u,0x0004003du,0x00000006u,0x00000188u,0x00000183u,0x000500bau,0x00000027u,0x0000018au,0x00000188u,0x00000189u,0x000300f7u,0x0000018du,0x00000000u,0x000400fau,0x0000018au,0x0000018cu,0x00000198u,0x000200f8u,0x0000018cu,0x0004003du,0x00000006u,0x0000018eu,0x0000016eu,0x0004003du,0x00000007u,0x0000018fu,0x000000f0u,0x0005008eu,0x00000007u,0x00000190u,0x0000018fu,0x0000018eu,0x0004003du,0x00000006u,0x00000191u,0x00000179u,0x0004003du,0x00000007u,0x00000192u,0x000000f6u,0x0005008eu,0x00000007u,0x00000193u,0x00000192u,0x00000191u,0x00050081u,0x00000007u,0x00000194u,0x00000190u,0x00000193u,0x0004003du,0x00000006u,0x00000195u,0x00000183u,0x00060050u,0x00000007u,0x00000196u,0x00000195u,0x00000195u,0x00000195u,0x00050088u,0x00000007u,0x00000197u,0x00000194u,0x00000196u,0x0003003eu,0x0000018bu,0x00000197u,0x000200f9u,0x0000018du,0x000200f8u,0x00000198u,0x0004003du,0x00000007u,0x00000199u,0x000000f0u,0x0004003du,0x00000007u,0x0000019au,0x000000f6u,0x00050041u,0x00000051u,0x0000019bu,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x0000019cu,0x0000019bu,0x00060050u,0x00000007u,0x0000019du,0x0000019cu,0x0000019cu,0x0000019cu,0x0008000cu,0x00000007u,0x0000019eu,0x00000001u,0x0000002eu,0x00000199u,0x0000019au,0x0000019du,0x0003003eu,0x0000018bu,0x0000019eu,0x000200f9u,0x0000018du,0x000200f8u,0x0000018du,0x0004003du,0x00000007u,0x0000019fu,0x0000018bu,0x0003003eu,0x00000187u,0x0000019fu,0x0004003du,0x0000008au,0x000001a1u,0x000000f1u,0x0004003du,0x0000003cu,0x000001a2u,0x00000044u,0x00070058u,0x0000004cu,0x000001a3u,0x000001a1u,0x000001a2u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a4u,0x000001a3u,0x000001a3u,0x00000000u,0x00000001u,0x00000002u,0x0004003du,0x0000008au,0x000001a5u,0x0000008cu,0x0004003du,0x0000003cu,0x000001a6u,0x00000044u,0x00070058u,0x0000004cu,0x000001a7u,0x000001a5u,0x000001a6u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a8u,0x000001a7u,0x000001a7u,0x00000000u,0x00000001u,0x00000002u,0x00050041u,0x00000051u,0x000001a9u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000001aau,0x000001a9u,0x00060050u,0x00000007u,0x000001abu,0x000001aau,0x000001aau,0x000001aau,0x0008000cu,0x00000007u,0x000001acu,0x00000001u,0x0000002eu,0x000001a4u,0x000001a8u,0x000001abu,0x0003003eu,0x000001a0u,0x000001acu,0x0004003du,0x00000006u,0x000001aeu,0x000000fbu,0x0004003du,0x00000006u,0x000001afu,0x00000111u,0x0007000cu,0x00000006u,0x000001b0u,0x00000001u,0x00000025u,0x000001aeu,0x000001afu,0x0003003eu,0x000001adu,0x000001b0u,0x0004003du,0x00000006u,0x000001b4u,0x000001adu,0x0008000cu,0x00000006u,0x000001b5u,0x00000001u,0x00000031u,0x000001b2u,0x000001b3u,0x000001b4u,0x00050083u,0x00000006u,0x000001b6u,0x0000003fu,0x000001b5u,0x0003003eu,0x000001b1u,0x000001b6u,0x0004003du,0x00000006u,0x000001b8u,0x00000138u,0x0008000cu,0x00000006u,0x000001b9u,0x00000001u,0x0000002bu,0x000001b8u,0x0000008fu,0x0000003fu,0x0004003du,0x00000006u,0x000001bau,0x000001b1u,0x00050085u,0x00000006u,0x000001bbu,0x000001b9u,0x000001bau,0x0003003eu,0x000001b7u,0x000001bbu,0x00050041u,0x00000051u,0x000001bdu,0x0000004fu,0x000001bcu,0x0004003du,0x00000006u,0x000001beu,0x000001bdu,0x0004003du,0x00000006u,0x000001bfu,0x00000183u,0x0008000cu,0x00000006u,0x000001c0u,0x00000001u,0x00000031u,0x0000008fu,0x000001beu,0x000001bfu,0x0004003du,0x00000006u,0x000001c1u,0x000001b7u,0x00050085u,0x00000006u,0x000001c2u,0x000001c1u,0x000001c0u,0x0003003eu,0x000001b7u,0x000001c2u,0x0004003du,0x0000008au,0x000001c4u,0x000000f1u,0x0004003du,0x0000003cu,0x000001c5u,0x00000044u,0x00070058u,0x0000004cu,0x000001c6u,0x000001c4u,0x000001c5u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001c7u,0x000001c6u,0x000001c6u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c3u,0x000001c7u,0x0004003du,0x0000008au,0x000001c9u,0x0000008cu,0x0004003du,0x0000003cu,0x000001cau,0x00000044u,0x00070058u,0x0000004cu,0x000001cbu,0x000001c9u,0x000001cau,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001ccu,0x000001cbu,0x000001cbu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c8u,0x000001ccu,0x0004003du,0x00000007u,0x000001ceu,0x000001c8u,0x0004003du,0x00000007u,0x000001cfu,0x000001c3u,0x00050083u,0x00000007u,0x000001d0u,0x000001ceu,0x000001cfu,0x0006000cu,0x00000006u,0x000001d1u,0x00000001u,0x00000042u,0x000001d0u,0x0003003eu,0x000001cdu,0x000001d1u,0x0004003du,0x00000006u,0x000001d4u,0x000001b7u,0x0008000cu,0x00000006u,0x000001d5u,0x00000001u,0x00000031u,0x000000cdu,0x000001d3u,0x000001d4u,0x00050083u,0x00000006u,0x000001d6u,0x0000003fu,0x000001d5u,0x0003003eu,0x000001d2u,0x000001d6u,0x0004003du,0x00000006u,0x000001dau,0x000001cdu,0x0008000cu,0x00000006u,0x000001dbu,0x00000001u,0x00000031u,0x000001d8u,0x000001d9u,0x000001dau,0x0003003eu,0x000001d7u,0x000001dbu,0x0004003du,0x00000006u,0x000001ddu,0x000001d2u,0x0004003du,0x00000006u,0x000001deu,0x000001d7u,0x00050085u,0x00000006u,0x000001dfu,0x000001ddu,0x000001deu,0x0003003eu,0x000001dcu,0x000001dfu,0x0004003du,0x00000007u,0x000001e0u,0x000001a0u,0x0004003du,0x00000007u,0x000001e1u,0x000001c8u,0x0004003du,0x00000006u,0x000001e2u,0x000001dcu,0x00060050u,0x00000007u,0x000001e3u,0x000001e2u,0x000001e2u,0x000001e2u,0x0008000cu,0x00000007u,0x000001e4u,0x00000001u,0x0000002eu,0x000001e0u,0x000001e1u,0x000001e3u,0x0003003eu,0x000001a0u,0x000001e4u,0x0004003du,0x00000007u,0x000001e6u,0x000001a0u,0x0004003du,0x00000007u,0x000001e7u,0x00000187u,0x0004003du,0x00000006u,0x000001e8u,0x000001b7u,0x00060050u,0x00000007u,0x000001e9u,0x000001e8u,0x000001e8u,0x000001e8u,0x0008000cu,0x00000007u,0x000001eau,0x00000001u,0x0000002eu,0x000001e6u,0x000001e7u,0x000001e9u,0x0003003eu,0x000001e5u,0x000001eau,0x0004003du,0x00000027u,0x000001ebu,0x00000098u,0x000300f7u,0x000001edu,0x00000000u,0x000400fau,0x000001ebu,0x000001ecu,0x000001edu,0x000200f8u,0x000001ecu,0x0004003du,0x00000006u,0x000001efu,0x000000c9u,0x0004003du,0x00000006u,0x000001f0u,0x000000d5u,0x0007000cu,0x00000006u,0x000001f1u,0x00000001u,0x00000028u,0x000001efu,0x000001f0u,0x0003003eu,0x000001eeu,0x000001f1u,0x0004003du,0x00000006u,0x000001f3u,0x000001eeu,0x00050088u,0x00000006u,0x000001f4u,0x000001f3u,0x000000cdu,0x0008000cu,0x00000006u,0x000001f5u,0x00000001u,0x0000002bu,0x000001f4u,0x0000008fu,0x0000003fu,0x0003003eu,0x000001f2u,0x000001f5u,0x0004003du,0x00000006u,0x000001f7u,0x000001eeu,0x0007000cu,0x00000006u,0x000001f8u,0x00000001u,0x00000030u,0x000000cdu,0x000001f7u,0x0003003eu,0x000001f6u,0x000001f8u,0x0004003du,0x00000022u,0x000001f9u,0x00000024u,0x0004003du,0x00000016u,0x000001fau,0x00000018u,0x0004003du,0x00000006u,0x000001fbu,0x000001f6u,0x0004003du,0x00000006u,0x000001fcu,0x000001f2u,0x00070050u,0x0000004cu,0x000001fdu,0x000001fbu,0x000001fcu,0x0000008fu,0x0000003fu,0x00040063u,0x000001f9u,0x000001fau,0x000001fdu,0x000100fdu,0x000200f8u,0x000001edu,0x0004003du,0x00000022u,0x000001ffu,0x00000024u,0x0004003du,0x00000016u,0x00000200u,0x00000018u,0x0004003du,0x00000007u,0x00000201u,0x000001e5u,0x00050051u,0x00000006u,0x00000202u,0x00000201u,0x00000000u,0x00050051u,0x00000006u,0x00000203u,0x00000201u,0x00000001u,0x00050051u,0x00000006u,0x00000204u,0x00000201u,0x00000002u,0x00070050u,0x0000004cu,0x00000205u,0x00000202u,0x00000203u,0x00000204u,0x0000003fu,0x00040063u,0x000001ffu,0x00000200u,0x00000205u,0x000100fdu,0x00010038u,0x00050036u,0x00000006u,0x0000000bu,0x00000000u,0x00000009u,0x00030037u,0x00000008u,0x0000000au,0x000200f8u,0x0000000cu,0x0004003du,0x00000007u,0x0000000du,0x0000000au,0x00050094u,0x00000006u,0x00000012u,0x0000000du,0x00000011u,0x000200feu,0x00000012u,0x00010038u};
-static const size_t WFG_SYNTH_LEN = 3082;
+static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0x00000022u,0x00000000u,0x00040047u,0x00000207u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00030016u,0x00000006u,0x00000020u,0x00040017u,0x00000007u,0x00000006u,0x00000003u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040021u,0x00000009u,0x00000006u,0x00000008u,0x0004002bu,0x00000006u,0x0000000eu,0x3e59b3d0u,0x0004002bu,0x00000006u,0x0000000fu,0x3f371759u,0x0004002bu,0x00000006u,0x00000010u,0x3d93dd98u,0x0006002cu,0x00000007u,0x00000011u,0x0000000eu,0x0000000fu,0x00000010u,0x00040015u,0x00000015u,0x00000020u,0x00000001u,0x00040017u,0x00000016u,0x00000015u,0x00000002u,0x00040020u,0x00000017u,0x00000007u,0x00000016u,0x00040015u,0x00000019u,0x00000020u,0x00000000u,0x00040017u,0x0000001au,0x00000019u,0x00000003u,0x00040020u,0x0000001bu,0x00000001u,0x0000001au,0x0004003bu,0x0000001bu,0x0000001cu,0x00000001u,0x00040017u,0x0000001du,0x00000019u,0x00000002u,0x00090019u,0x00000022u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000004u,0x00040020u,0x00000023u,0x00000000u,0x00000022u,0x0004003bu,0x00000023u,0x00000024u,0x00000000u,0x00020014u,0x00000027u,0x0004002bu,0x00000019u,0x00000028u,0x00000000u,0x00040020u,0x00000029u,0x00000007u,0x00000015u,0x0004002bu,0x00000019u,0x00000032u,0x00000001u,0x00040017u,0x0000003cu,0x00000006u,0x00000002u,0x00040020u,0x0000003du,0x00000007u,0x0000003cu,0x0004002bu,0x00000006u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000006u,0x00000047u,0x3f000000u,0x00040017u,0x0000004cu,0x00000006u,0x00000004u,0x000b001eu,0x0000004du,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x00000006u,0x0000004cu,0x00040020u,0x0000004eu,0x00000002u,0x0000004du,0x0004003bu,0x0000004eu,0x0000004fu,0x00000002u,0x0004002bu,0x00000015u,0x00000050u,0x00000008u,0x00040020u,0x00000051u,0x00000002u,0x00000006u,0x0004002bu,0x00000019u,0x00000054u,0x00000002u,0x0004002bu,0x00000019u,0x0000005cu,0x00000003u,0x00090019u,0x00000089u,0x00000006u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000008au,0x00000089u,0x00040020u,0x0000008bu,0x00000000u,0x0000008au,0x0004003bu,0x0000008bu,0x0000008cu,0x00000000u,0x0004002bu,0x00000006u,0x0000008fu,0x00000000u,0x00040020u,0x00000097u,0x00000007u,0x00000027u,0x0004002bu,0x00000015u,0x00000099u,0x00000000u,0x0004002bu,0x00000006u,0x0000009cu,0x40600000u,0x00040020u,0x0000009eu,0x00000007u,0x00000006u,0x00040020u,0x000000a8u,0x00000007u,0x0000004cu,0x0004003bu,0x0000008bu,0x000000aau,0x00000000u,0x0004003bu,0x0000008bu,0x000000afu,0x00000000u,0x0004002bu,0x00000006u,0x000000cdu,0x3dcccccdu,0x0004002bu,0x00000015u,0x000000e2u,0x00000001u,0x0004003bu,0x0000008bu,0x000000f1u,0x00000000u,0x0004002bu,0x00000015u,0x0000014au,0x00000005u,0x0004002bu,0x00000015u,0x0000014fu,0x00000003u,0x0004002bu,0x00000015u,0x00000170u,0x00000002u,0x0004002bu,0x00000006u,0x00000189u,0x322bcc77u,0x0004002bu,0x00000006u,0x000001b2u,0x3d23d70au,0x0004002bu,0x00000006u,0x000001b3u,0x3e3851ecu,0x0004002bu,0x00000015u,0x000001bcu,0x00000004u,0x0004002bu,0x00000006u,0x000001d3u,0x3e800000u,0x0004002bu,0x00000006u,0x000001d8u,0x3e4ccccdu,0x0004002bu,0x00000006u,0x000001d9u,0x3ecccccdu,0x0004002bu,0x00000019u,0x00000206u,0x00000010u,0x0006002cu,0x0000001au,0x00000207u,0x00000206u,0x00000206u,0x00000032u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000017u,0x00000018u,0x00000007u,0x0004003bu,0x00000017u,0x00000021u,0x00000007u,0x0004003bu,0x0000003du,0x0000003eu,0x00000007u,0x0004003bu,0x0000003du,0x00000044u,0x00000007u,0x0004003bu,0x00000097u,0x00000098u,0x00000007u,0x0004003bu,0x0000009eu,0x0000009fu,0x00000007u,0x0004003bu,0x0000009eu,0x000000a1u,0x00000007u,0x0004003bu,0x000000a8u,0x000000a9u,0x00000007u,0x0004003bu,0x000000a8u,0x000000aeu,0x00000007u,0x0004003bu,0x0000003du,0x000000b3u,0x00000007u,0x0004003bu,0x0000003du,0x000000bau,0x00000007u,0x0004003bu,0x0000009eu,0x000000c1u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c5u,0x00000007u,0x0004003bu,0x0000009eu,0x000000c9u,0x00000007u,0x0004003bu,0x0000009eu,0x000000d5u,0x00000007u,0x0004003bu,0x0000003du,0x000000e0u,0x00000007u,0x0004003bu,0x0000003du,0x000000e8u,0x00000007u,0x0004003bu,0x00000008u,0x000000f0u,0x00000007u,0x0004003bu,0x00000008u,0x000000f6u,0x00000007u,0x0004003bu,0x0000009eu,0x000000fbu,0x00000007u,0x0004003bu,0x00000008u,0x000000fcu,0x00000007u,0x0004003bu,0x00000008u,0x0000010cu,0x00000007u,0x0004003bu,0x0000009eu,0x00000111u,0x00000007u,0x0004003bu,0x00000008u,0x00000112u,0x00000007u,0x0004003bu,0x00000008u,0x00000122u,0x00000007u,0x0004003bu,0x0000003du,0x00000127u,0x00000007u,0x0004003bu,0x0000009eu,0x0000012bu,0x00000007u,0x0004003bu,0x0000009eu,0x00000138u,0x00000007u,0x0004003bu,0x0000009eu,0x00000149u,0x00000007u,0x0004003bu,0x0000009eu,0x00000157u,0x00000007u,0x0004003bu,0x0000009eu,0x00000163u,0x00000007u,0x0004003bu,0x0000009eu,0x00000167u,0x00000007u,0x0004003bu,0x0000009eu,0x0000016au,0x00000007u,0x0004003bu,0x0000009eu,0x0000016eu,0x00000007u,0x0004003bu,0x0000009eu,0x00000179u,0x00000007u,0x0004003bu,0x0000009eu,0x00000183u,0x00000007u,0x0004003bu,0x00000008u,0x00000187u,0x00000007u,0x0004003bu,0x00000008u,0x0000018bu,0x00000007u,0x0004003bu,0x00000008u,0x000001a0u,0x00000007u,0x0004003bu,0x0000009eu,0x000001adu,0x00000007u,0x0004003bu,0x0000009eu,0x000001b1u,0x00000007u,0x0004003bu,0x0000009eu,0x000001b7u,0x00000007u,0x0004003bu,0x00000008u,0x000001c3u,0x00000007u,0x0004003bu,0x00000008u,0x000001c8u,0x00000007u,0x0004003bu,0x0000009eu,0x000001cdu,0x00000007u,0x0004003bu,0x0000009eu,0x000001d2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001d7u,0x00000007u,0x0004003bu,0x0000009eu,0x000001dcu,0x00000007u,0x0004003bu,0x00000008u,0x000001e5u,0x00000007u,0x0004003bu,0x0000009eu,0x000001eeu,0x00000007u,0x0004003bu,0x0000009eu,0x000001f2u,0x00000007u,0x0004003bu,0x0000009eu,0x000001f6u,0x00000007u,0x0004003du,0x0000001au,0x0000001eu,0x0000001cu,0x0007004fu,0x0000001du,0x0000001fu,0x0000001eu,0x0000001eu,0x00000000u,0x00000001u,0x0004007cu,0x00000016u,0x00000020u,0x0000001fu,0x0003003eu,0x00000018u,0x00000020u,0x0004003du,0x00000022u,0x00000025u,0x00000024u,0x00040068u,0x00000016u,0x00000026u,0x00000025u,0x0003003eu,0x00000021u,0x00000026u,0x00050041u,0x00000029u,0x0000002au,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000002bu,0x0000002au,0x00050041u,0x00000029u,0x0000002cu,0x00000021u,0x00000028u,0x0004003du,0x00000015u,0x0000002du,0x0000002cu,0x000500afu,0x00000027u,0x0000002eu,0x0000002bu,0x0000002du,0x000400a8u,0x00000027u,0x0000002fu,0x0000002eu,0x000300f7u,0x00000031u,0x00000000u,0x000400fau,0x0000002fu,0x00000030u,0x00000031u,0x000200f8u,0x00000030u,0x00050041u,0x00000029u,0x00000033u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000034u,0x00000033u,0x00050041u,0x00000029u,0x00000035u,0x00000021u,0x00000032u,0x0004003du,0x00000015u,0x00000036u,0x00000035u,0x000500afu,0x00000027u,0x00000037u,0x00000034u,0x00000036u,0x000200f9u,0x00000031u,0x000200f8u,0x00000031u,0x000700f5u,0x00000027u,0x00000038u,0x0000002eu,0x00000005u,0x00000037u,0x00000030u,0x000300f7u,0x0000003au,0x00000000u,0x000400fau,0x00000038u,0x00000039u,0x0000003au,0x000200f8u,0x00000039u,0x000100fdu,0x000200f8u,0x0000003au,0x0004003du,0x00000016u,0x00000040u,0x00000021u,0x0004006fu,0x0000003cu,0x00000041u,0x00000040u,0x00050050u,0x0000003cu,0x00000042u,0x0000003fu,0x0000003fu,0x00050088u,0x0000003cu,0x00000043u,0x00000042u,0x00000041u,0x0003003eu,0x0000003eu,0x00000043u,0x0004003du,0x00000016u,0x00000045u,0x00000018u,0x0004006fu,0x0000003cu,0x00000046u,0x00000045u,0x00050050u,0x0000003cu,0x00000048u,0x00000047u,0x00000047u,0x00050081u,0x0000003cu,0x00000049u,0x00000046u,0x00000048u,0x0004003du,0x0000003cu,0x0000004au,0x0000003eu,0x00050085u,0x0000003cu,0x0000004bu,0x00000049u,0x0000004au,0x0003003eu,0x00000044u,0x0000004bu,0x00060041u,0x00000051u,0x00000052u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000053u,0x00000052u,0x00060041u,0x00000051u,0x00000055u,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000056u,0x00000055u,0x000500b8u,0x00000027u,0x00000057u,0x00000053u,0x00000056u,0x000300f7u,0x00000059u,0x00000000u,0x000400fau,0x00000057u,0x00000058u,0x00000059u,0x000200f8u,0x00000058u,0x00060041u,0x00000051u,0x0000005au,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x0000005bu,0x0000005au,0x00060041u,0x00000051u,0x0000005du,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x0000005eu,0x0000005du,0x000500b8u,0x00000027u,0x0000005fu,0x0000005bu,0x0000005eu,0x000200f9u,0x00000059u,0x000200f8u,0x00000059u,0x000700f5u,0x00000027u,0x00000060u,0x00000057u,0x0000003au,0x0000005fu,0x00000058u,0x000300f7u,0x00000062u,0x00000000u,0x000400fau,0x00000060u,0x00000061u,0x00000062u,0x000200f8u,0x00000061u,0x00050041u,0x00000029u,0x00000063u,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x00000064u,0x00000063u,0x0004006fu,0x00000006u,0x00000065u,0x00000064u,0x00060041u,0x00000051u,0x00000066u,0x0000004fu,0x00000050u,0x00000028u,0x0004003du,0x00000006u,0x00000067u,0x00000066u,0x000500beu,0x00000027u,0x00000068u,0x00000065u,0x00000067u,0x000200f9u,0x00000062u,0x000200f8u,0x00000062u,0x000700f5u,0x00000027u,0x00000069u,0x00000060u,0x00000059u,0x00000068u,0x00000061u,0x000300f7u,0x0000006bu,0x00000000u,0x000400fau,0x00000069u,0x0000006au,0x0000006bu,0x000200f8u,0x0000006au,0x00050041u,0x00000029u,0x0000006cu,0x00000018u,0x00000028u,0x0004003du,0x00000015u,0x0000006du,0x0000006cu,0x0004006fu,0x00000006u,0x0000006eu,0x0000006du,0x00060041u,0x00000051u,0x0000006fu,0x0000004fu,0x00000050u,0x00000054u,0x0004003du,0x00000006u,0x00000070u,0x0000006fu,0x000500b8u,0x00000027u,0x00000071u,0x0000006eu,0x00000070u,0x000200f9u,0x0000006bu,0x000200f8u,0x0000006bu,0x000700f5u,0x00000027u,0x00000072u,0x00000069u,0x00000062u,0x00000071u,0x0000006au,0x000300f7u,0x00000074u,0x00000000u,0x000400fau,0x00000072u,0x00000073u,0x00000074u,0x000200f8u,0x00000073u,0x00050041u,0x00000029u,0x00000075u,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x00000076u,0x00000075u,0x0004006fu,0x00000006u,0x00000077u,0x00000076u,0x00060041u,0x00000051u,0x00000078u,0x0000004fu,0x00000050u,0x00000032u,0x0004003du,0x00000006u,0x00000079u,0x00000078u,0x000500beu,0x00000027u,0x0000007au,0x00000077u,0x00000079u,0x000200f9u,0x00000074u,0x000200f8u,0x00000074u,0x000700f5u,0x00000027u,0x0000007bu,0x00000072u,0x0000006bu,0x0000007au,0x00000073u,0x000300f7u,0x0000007du,0x00000000u,0x000400fau,0x0000007bu,0x0000007cu,0x0000007du,0x000200f8u,0x0000007cu,0x00050041u,0x00000029u,0x0000007eu,0x00000018u,0x00000032u,0x0004003du,0x00000015u,0x0000007fu,0x0000007eu,0x0004006fu,0x00000006u,0x00000080u,0x0000007fu,0x00060041u,0x00000051u,0x00000081u,0x0000004fu,0x00000050u,0x0000005cu,0x0004003du,0x00000006u,0x00000082u,0x00000081u,0x000500b8u,0x00000027u,0x00000083u,0x00000080u,0x00000082u,0x000200f9u,0x0000007du,0x000200f8u,0x0000007du,0x000700f5u,0x00000027u,0x00000084u,0x0000007bu,0x00000074u,0x00000083u,0x0000007cu,0x000300f7u,0x00000086u,0x00000000u,0x000400fau,0x00000084u,0x00000085u,0x00000086u,0x000200f8u,0x00000085u,0x0004003du,0x00000022u,0x00000087u,0x00000024u,0x0004003du,0x00000016u,0x00000088u,0x00000018u,0x0004003du,0x0000008au,0x0000008du,0x0000008cu,0x0004003du,0x0000003cu,0x0000008eu,0x00000044u,0x00070058u,0x0000004cu,0x00000090u,0x0000008du,0x0000008eu,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000091u,0x00000090u,0x00000090u,0x00000000u,0x00000001u,0x00000002u,0x00050051u,0x00000006u,0x00000092u,0x00000091u,0x00000000u,0x00050051u,0x00000006u,0x00000093u,0x00000091u,0x00000001u,0x00050051u,0x00000006u,0x00000094u,0x00000091u,0x00000002u,0x00070050u,0x0000004cu,0x00000095u,0x00000092u,0x00000093u,0x00000094u,0x0000003fu,0x00040063u,0x00000087u,0x00000088u,0x00000095u,0x000100fdu,0x000200f8u,0x00000086u,0x00050041u,0x00000051u,0x0000009au,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000009bu,0x0000009au,0x000500bau,0x00000027u,0x0000009du,0x0000009bu,0x0000009cu,0x0003003eu,0x00000098u,0x0000009du,0x0004003du,0x00000027u,0x000000a0u,0x00000098u,0x000300f7u,0x000000a3u,0x00000000u,0x000400fau,0x000000a0u,0x000000a2u,0x000000a4u,0x000200f8u,0x000000a2u,0x0003003eu,0x000000a1u,0x0000003fu,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a4u,0x00050041u,0x00000051u,0x000000a5u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x000000a6u,0x000000a5u,0x0003003eu,0x000000a1u,0x000000a6u,0x000200f9u,0x000000a3u,0x000200f8u,0x000000a3u,0x0004003du,0x00000006u,0x000000a7u,0x000000a1u,0x0003003eu,0x0000009fu,0x000000a7u,0x0004003du,0x0000008au,0x000000abu,0x000000aau,0x0004003du,0x0000003cu,0x000000acu,0x00000044u,0x00070058u,0x0000004cu,0x000000adu,0x000000abu,0x000000acu,0x00000002u,0x0000008fu,0x0003003eu,0x000000a9u,0x000000adu,0x0004003du,0x0000008au,0x000000b0u,0x000000afu,0x0004003du,0x0000003cu,0x000000b1u,0x00000044u,0x00070058u,0x0000004cu,0x000000b2u,0x000000b0u,0x000000b1u,0x00000002u,0x0000008fu,0x0003003eu,0x000000aeu,0x000000b2u,0x0004003du,0x0000004cu,0x000000b4u,0x000000a9u,0x0007004fu,0x0000003cu,0x000000b5u,0x000000b4u,0x000000b4u,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000b6u,0x0000009fu,0x0005008eu,0x0000003cu,0x000000b7u,0x000000b5u,0x000000b6u,0x0004003du,0x0000003cu,0x000000b8u,0x0000003eu,0x00050085u,0x0000003cu,0x000000b9u,0x000000b7u,0x000000b8u,0x0003003eu,0x000000b3u,0x000000b9u,0x0004003du,0x0000004cu,0x000000bbu,0x000000aeu,0x0007004fu,0x0000003cu,0x000000bcu,0x000000bbu,0x000000bbu,0x00000000u,0x00000001u,0x0004003du,0x00000006u,0x000000bdu,0x0000009fu,0x0005008eu,0x0000003cu,0x000000beu,0x000000bcu,0x000000bdu,0x0004003du,0x0000003cu,0x000000bfu,0x0000003eu,0x00050085u,0x0000003cu,0x000000c0u,0x000000beu,0x000000bfu,0x0003003eu,0x000000bau,0x000000c0u,0x00050041u,0x0000009eu,0x000000c2u,0x000000a9u,0x0000005cu,0x0004003du,0x00000006u,0x000000c3u,0x000000c2u,0x0008000cu,0x00000006u,0x000000c4u,0x00000001u,0x0000002bu,0x000000c3u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c1u,0x000000c4u,0x00050041u,0x0000009eu,0x000000c6u,0x000000aeu,0x0000005cu,0x0004003du,0x00000006u,0x000000c7u,0x000000c6u,0x0008000cu,0x00000006u,0x000000c8u,0x00000001u,0x0000002bu,0x000000c7u,0x0000008fu,0x0000003fu,0x0003003eu,0x000000c5u,0x000000c8u,0x0004003du,0x0000003cu,0x000000cau,0x000000b3u,0x0006000cu,0x00000006u,0x000000cbu,0x00000001u,0x00000042u,0x000000cau,0x0003003eu,0x000000c9u,0x000000cbu,0x0004003du,0x00000006u,0x000000ccu,0x000000c9u,0x000500bau,0x00000027u,0x000000ceu,0x000000ccu,0x000000cdu,0x000300f7u,0x000000d0u,0x00000000u,0x000400fau,0x000000ceu,0x000000cfu,0x000000d0u,0x000200f8u,0x000000cfu,0x0004003du,0x00000006u,0x000000d1u,0x000000c9u,0x00050088u,0x00000006u,0x000000d2u,0x000000cdu,0x000000d1u,0x0004003du,0x0000003cu,0x000000d3u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000d4u,0x000000d3u,0x000000d2u,0x0003003eu,0x000000b3u,0x000000d4u,0x000200f9u,0x000000d0u,0x000200f8u,0x000000d0u,0x0004003du,0x0000003cu,0x000000d6u,0x000000bau,0x0006000cu,0x00000006u,0x000000d7u,0x00000001u,0x00000042u,0x000000d6u,0x0003003eu,0x000000d5u,0x000000d7u,0x0004003du,0x00000006u,0x000000d8u,0x000000d5u,0x000500bau,0x00000027u,0x000000d9u,0x000000d8u,0x000000cdu,0x000300f7u,0x000000dbu,0x00000000u,0x000400fau,0x000000d9u,0x000000dau,0x000000dbu,0x000200f8u,0x000000dau,0x0004003du,0x00000006u,0x000000dcu,0x000000d5u,0x00050088u,0x00000006u,0x000000ddu,0x000000cdu,0x000000dcu,0x0004003du,0x0000003cu,0x000000deu,0x000000bau,0x0005008eu,0x0000003cu,0x000000dfu,0x000000deu,0x000000ddu,0x0003003eu,0x000000bau,0x000000dfu,0x000200f9u,0x000000dbu,0x000200f8u,0x000000dbu,0x0004003du,0x0000003cu,0x000000e1u,0x00000044u,0x00050041u,0x00000051u,0x000000e3u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000e4u,0x000000e3u,0x0004003du,0x0000003cu,0x000000e5u,0x000000b3u,0x0005008eu,0x0000003cu,0x000000e6u,0x000000e5u,0x000000e4u,0x00050083u,0x0000003cu,0x000000e7u,0x000000e1u,0x000000e6u,0x0003003eu,0x000000e0u,0x000000e7u,0x0004003du,0x0000003cu,0x000000e9u,0x00000044u,0x00050041u,0x00000051u,0x000000eau,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000000ebu,0x000000eau,0x00050083u,0x00000006u,0x000000ecu,0x0000003fu,0x000000ebu,0x0004003du,0x0000003cu,0x000000edu,0x000000bau,0x0005008eu,0x0000003cu,0x000000eeu,0x000000edu,0x000000ecu,0x00050083u,0x0000003cu,0x000000efu,0x000000e9u,0x000000eeu,0x0003003eu,0x000000e8u,0x000000efu,0x0004003du,0x0000008au,0x000000f2u,0x000000f1u,0x0004003du,0x0000003cu,0x000000f3u,0x000000e0u,0x00070058u,0x0000004cu,0x000000f4u,0x000000f2u,0x000000f3u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000f5u,0x000000f4u,0x000000f4u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f0u,0x000000f5u,0x0004003du,0x0000008au,0x000000f7u,0x0000008cu,0x0004003du,0x0000003cu,0x000000f8u,0x000000e8u,0x00070058u,0x0000004cu,0x000000f9u,0x000000f7u,0x000000f8u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000000fau,0x000000f9u,0x000000f9u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000000f6u,0x000000fau,0x0004003du,0x00000007u,0x000000fdu,0x000000f0u,0x0003003eu,0x000000fcu,0x000000fdu,0x00050039u,0x00000006u,0x000000feu,0x0000000bu,0x000000fcu,0x0004003du,0x0000008au,0x000000ffu,0x0000008cu,0x0004003du,0x0000003cu,0x00000100u,0x000000e0u,0x0004003du,0x0000008au,0x00000101u,0x000000aau,0x0004003du,0x0000003cu,0x00000102u,0x000000e0u,0x00070058u,0x0000004cu,0x00000103u,0x00000101u,0x00000102u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000104u,0x00000103u,0x00000103u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000105u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000106u,0x00000105u,0x0005008eu,0x0000003cu,0x00000107u,0x00000104u,0x00000106u,0x0004003du,0x0000003cu,0x00000108u,0x0000003eu,0x00050085u,0x0000003cu,0x00000109u,0x00000107u,0x00000108u,0x00050081u,0x0000003cu,0x0000010au,0x00000100u,0x00000109u,0x00070058u,0x0000004cu,0x0000010bu,0x000000ffu,0x0000010au,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x0000010du,0x0000010bu,0x0000010bu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x0000010cu,0x0000010du,0x00050039u,0x00000006u,0x0000010eu,0x0000000bu,0x0000010cu,0x00050083u,0x00000006u,0x0000010fu,0x000000feu,0x0000010eu,0x0006000cu,0x00000006u,0x00000110u,0x00000001u,0x00000004u,0x0000010fu,0x0003003eu,0x000000fbu,0x00000110u,0x0004003du,0x00000007u,0x00000113u,0x000000f6u,0x0003003eu,0x00000112u,0x00000113u,0x00050039u,0x00000006u,0x00000114u,0x0000000bu,0x00000112u,0x0004003du,0x0000008au,0x00000115u,0x000000f1u,0x0004003du,0x0000003cu,0x00000116u,0x000000e8u,0x0004003du,0x0000008au,0x00000117u,0x000000afu,0x0004003du,0x0000003cu,0x00000118u,0x000000e8u,0x00070058u,0x0000004cu,0x00000119u,0x00000117u,0x00000118u,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x0000011au,0x00000119u,0x00000119u,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x0000011bu,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x0000011cu,0x0000011bu,0x0005008eu,0x0000003cu,0x0000011du,0x0000011au,0x0000011cu,0x0004003du,0x0000003cu,0x0000011eu,0x0000003eu,0x00050085u,0x0000003cu,0x0000011fu,0x0000011du,0x0000011eu,0x00050081u,0x0000003cu,0x00000120u,0x00000116u,0x0000011fu,0x00070058u,0x0000004cu,0x00000121u,0x00000115u,0x00000120u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x00000123u,0x00000121u,0x00000121u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x00000122u,0x00000123u,0x00050039u,0x00000006u,0x00000124u,0x0000000bu,0x00000122u,0x00050083u,0x00000006u,0x00000125u,0x00000114u,0x00000124u,0x0006000cu,0x00000006u,0x00000126u,0x00000001u,0x00000004u,0x00000125u,0x0003003eu,0x00000111u,0x00000126u,0x0004003du,0x0000003cu,0x00000128u,0x00000044u,0x0004003du,0x0000003cu,0x00000129u,0x000000b3u,0x00050081u,0x0000003cu,0x0000012au,0x00000128u,0x00000129u,0x0003003eu,0x00000127u,0x0000012au,0x0004003du,0x0000003cu,0x0000012cu,0x000000b3u,0x0004003du,0x0000008au,0x0000012du,0x000000afu,0x0004003du,0x0000003cu,0x0000012eu,0x00000127u,0x00070058u,0x0000004cu,0x0000012fu,0x0000012du,0x0000012eu,0x00000002u,0x0000008fu,0x0007004fu,0x0000003cu,0x00000130u,0x0000012fu,0x0000012fu,0x00000000u,0x00000001u,0x00050041u,0x00000051u,0x00000131u,0x0000004fu,0x00000099u,0x0004003du,0x00000006u,0x00000132u,0x00000131u,0x0005008eu,0x0000003cu,0x00000133u,0x00000130u,0x00000132u,0x0004003du,0x0000003cu,0x00000134u,0x0000003eu,0x00050085u,0x0000003cu,0x00000135u,0x00000133u,0x00000134u,0x00050081u,0x0000003cu,0x00000136u,0x0000012cu,0x00000135u,0x0006000cu,0x00000006u,0x00000137u,0x00000001u,0x00000042u,0x00000136u,0x0003003eu,0x0000012bu,0x00000137u,0x0004003du,0x00000006u,0x00000139u,0x000000c1u,0x0004003du,0x00000006u,0x0000013au,0x000000c5u,0x0007000cu,0x00000006u,0x0000013bu,0x00000001u,0x00000025u,0x00000139u,0x0000013au,0x0004003du,0x00000006u,0x0000013cu,0x0000012bu,0x0004007fu,0x00000006u,0x0000013du,0x0000013cu,0x0004003du,0x0000003cu,0x0000013eu,0x000000b3u,0x0006000cu,0x00000006u,0x0000013fu,0x00000001u,0x00000042u,0x0000013eu,0x0004003du,0x0000003cu,0x00000140u,0x000000bau,0x0006000cu,0x00000006u,0x00000141u,0x00000001u,0x00000042u,0x00000140u,0x00050081u,0x00000006u,0x00000142u,0x0000013fu,0x00000141u,0x00050041u,0x0000009eu,0x00000143u,0x0000003eu,0x00000028u,0x0004003du,0x00000006u,0x00000144u,0x00000143u,0x00050081u,0x00000006u,0x00000145u,0x00000142u,0x00000144u,0x00050088u,0x00000006u,0x00000146u,0x0000013du,0x00000145u,0x0006000cu,0x00000006u,0x00000147u,0x00000001u,0x0000001bu,0x00000146u,0x00050085u,0x00000006u,0x00000148u,0x0000013bu,0x00000147u,0x0003003eu,0x00000138u,0x00000148u,0x00050041u,0x00000051u,0x0000014bu,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x0000014cu,0x0000014bu,0x0004003du,0x00000006u,0x0000014du,0x000000fbu,0x00050085u,0x00000006u,0x0000014eu,0x0000014cu,0x0000014du,0x00050041u,0x00000051u,0x00000150u,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x00000151u,0x00000150u,0x0004003du,0x00000006u,0x00000152u,0x00000138u,0x00050083u,0x00000006u,0x00000153u,0x0000003fu,0x00000152u,0x00050085u,0x00000006u,0x00000154u,0x00000151u,0x00000153u,0x00050081u,0x00000006u,0x00000155u,0x0000014eu,0x00000154u,0x0004007fu,0x00000006u,0x00000156u,0x00000155u,0x0003003eu,0x00000149u,0x00000156u,0x00050041u,0x00000051u,0x00000158u,0x0000004fu,0x0000014au,0x0004003du,0x00000006u,0x00000159u,0x00000158u,0x0004003du,0x00000006u,0x0000015au,0x00000111u,0x00050085u,0x00000006u,0x0000015bu,0x00000159u,0x0000015au,0x00050041u,0x00000051u,0x0000015cu,0x0000004fu,0x0000014fu,0x0004003du,0x00000006u,0x0000015du,0x0000015cu,0x0004003du,0x00000006u,0x0000015eu,0x00000138u,0x00050083u,0x00000006u,0x0000015fu,0x0000003fu,0x0000015eu,0x00050085u,0x00000006u,0x00000160u,0x0000015du,0x0000015fu,0x00050081u,0x00000006u,0x00000161u,0x0000015bu,0x00000160u,0x0004007fu,0x00000006u,0x00000162u,0x00000161u,0x0003003eu,0x00000157u,0x00000162u,0x00050041u,0x00000051u,0x00000164u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000165u,0x00000164u,0x00050083u,0x00000006u,0x00000166u,0x0000003fu,0x00000165u,0x0003003eu,0x00000163u,0x00000166u,0x00050041u,0x00000051u,0x00000168u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x00000169u,0x00000168u,0x0003003eu,0x00000167u,0x00000169u,0x0004003du,0x00000006u,0x0000016bu,0x00000149u,0x0004003du,0x00000006u,0x0000016cu,0x00000157u,0x0007000cu,0x00000006u,0x0000016du,0x00000001u,0x00000028u,0x0000016bu,0x0000016cu,0x0003003eu,0x0000016au,0x0000016du,0x0004003du,0x00000006u,0x0000016fu,0x00000163u,0x00050041u,0x00000051u,0x00000171u,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x00000172u,0x00000171u,0x0004003du,0x00000006u,0x00000173u,0x00000149u,0x0004003du,0x00000006u,0x00000174u,0x0000016au,0x00050083u,0x00000006u,0x00000175u,0x00000173u,0x00000174u,0x00050085u,0x00000006u,0x00000176u,0x00000172u,0x00000175u,0x0006000cu,0x00000006u,0x00000177u,0x00000001u,0x0000001bu,0x00000176u,0x00050085u,0x00000006u,0x00000178u,0x0000016fu,0x00000177u,0x0003003eu,0x0000016eu,0x00000178u,0x0004003du,0x00000006u,0x0000017au,0x00000167u,0x00050041u,0x00000051u,0x0000017bu,0x0000004fu,0x00000170u,0x0004003du,0x00000006u,0x0000017cu,0x0000017bu,0x0004003du,0x00000006u,0x0000017du,0x00000157u,0x0004003du,0x00000006u,0x0000017eu,0x0000016au,0x00050083u,0x00000006u,0x0000017fu,0x0000017du,0x0000017eu,0x00050085u,0x00000006u,0x00000180u,0x0000017cu,0x0000017fu,0x0006000cu,0x00000006u,0x00000181u,0x00000001u,0x0000001bu,0x00000180u,0x00050085u,0x00000006u,0x00000182u,0x0000017au,0x00000181u,0x0003003eu,0x00000179u,0x00000182u,0x0004003du,0x00000006u,0x00000184u,0x0000016eu,0x0004003du,0x00000006u,0x00000185u,0x00000179u,0x00050081u,0x00000006u,0x00000186u,0x00000184u,0x00000185u,0x0003003eu,0x00000183u,0x00000186u,0x0004003du,0x00000006u,0x00000188u,0x00000183u,0x000500bau,0x00000027u,0x0000018au,0x00000188u,0x00000189u,0x000300f7u,0x0000018du,0x00000000u,0x000400fau,0x0000018au,0x0000018cu,0x00000198u,0x000200f8u,0x0000018cu,0x0004003du,0x00000006u,0x0000018eu,0x0000016eu,0x0004003du,0x00000007u,0x0000018fu,0x000000f0u,0x0005008eu,0x00000007u,0x00000190u,0x0000018fu,0x0000018eu,0x0004003du,0x00000006u,0x00000191u,0x00000179u,0x0004003du,0x00000007u,0x00000192u,0x000000f6u,0x0005008eu,0x00000007u,0x00000193u,0x00000192u,0x00000191u,0x00050081u,0x00000007u,0x00000194u,0x00000190u,0x00000193u,0x0004003du,0x00000006u,0x00000195u,0x00000183u,0x00060050u,0x00000007u,0x00000196u,0x00000195u,0x00000195u,0x00000195u,0x00050088u,0x00000007u,0x00000197u,0x00000194u,0x00000196u,0x0003003eu,0x0000018bu,0x00000197u,0x000200f9u,0x0000018du,0x000200f8u,0x00000198u,0x0004003du,0x00000007u,0x00000199u,0x000000f0u,0x0004003du,0x00000007u,0x0000019au,0x000000f6u,0x00050041u,0x00000051u,0x0000019bu,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x0000019cu,0x0000019bu,0x00060050u,0x00000007u,0x0000019du,0x0000019cu,0x0000019cu,0x0000019cu,0x0008000cu,0x00000007u,0x0000019eu,0x00000001u,0x0000002eu,0x00000199u,0x0000019au,0x0000019du,0x0003003eu,0x0000018bu,0x0000019eu,0x000200f9u,0x0000018du,0x000200f8u,0x0000018du,0x0004003du,0x00000007u,0x0000019fu,0x0000018bu,0x0003003eu,0x00000187u,0x0000019fu,0x0004003du,0x0000008au,0x000001a1u,0x000000f1u,0x0004003du,0x0000003cu,0x000001a2u,0x00000044u,0x00070058u,0x0000004cu,0x000001a3u,0x000001a1u,0x000001a2u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a4u,0x000001a3u,0x000001a3u,0x00000000u,0x00000001u,0x00000002u,0x0004003du,0x0000008au,0x000001a5u,0x0000008cu,0x0004003du,0x0000003cu,0x000001a6u,0x00000044u,0x00070058u,0x0000004cu,0x000001a7u,0x000001a5u,0x000001a6u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001a8u,0x000001a7u,0x000001a7u,0x00000000u,0x00000001u,0x00000002u,0x00050041u,0x00000051u,0x000001a9u,0x0000004fu,0x000000e2u,0x0004003du,0x00000006u,0x000001aau,0x000001a9u,0x00060050u,0x00000007u,0x000001abu,0x000001aau,0x000001aau,0x000001aau,0x0008000cu,0x00000007u,0x000001acu,0x00000001u,0x0000002eu,0x000001a4u,0x000001a8u,0x000001abu,0x0003003eu,0x000001a0u,0x000001acu,0x0004003du,0x00000006u,0x000001aeu,0x000000fbu,0x0004003du,0x00000006u,0x000001afu,0x00000111u,0x0007000cu,0x00000006u,0x000001b0u,0x00000001u,0x00000025u,0x000001aeu,0x000001afu,0x0003003eu,0x000001adu,0x000001b0u,0x0004003du,0x00000006u,0x000001b4u,0x000001adu,0x0008000cu,0x00000006u,0x000001b5u,0x00000001u,0x00000031u,0x000001b2u,0x000001b3u,0x000001b4u,0x00050083u,0x00000006u,0x000001b6u,0x0000003fu,0x000001b5u,0x0003003eu,0x000001b1u,0x000001b6u,0x0004003du,0x00000006u,0x000001b8u,0x00000138u,0x0008000cu,0x00000006u,0x000001b9u,0x00000001u,0x0000002bu,0x000001b8u,0x0000008fu,0x0000003fu,0x0004003du,0x00000006u,0x000001bau,0x000001b1u,0x00050085u,0x00000006u,0x000001bbu,0x000001b9u,0x000001bau,0x0003003eu,0x000001b7u,0x000001bbu,0x00050041u,0x00000051u,0x000001bdu,0x0000004fu,0x000001bcu,0x0004003du,0x00000006u,0x000001beu,0x000001bdu,0x0004003du,0x00000006u,0x000001bfu,0x00000183u,0x0008000cu,0x00000006u,0x000001c0u,0x00000001u,0x00000031u,0x0000008fu,0x000001beu,0x000001bfu,0x0004003du,0x00000006u,0x000001c1u,0x000001b7u,0x00050085u,0x00000006u,0x000001c2u,0x000001c1u,0x000001c0u,0x0003003eu,0x000001b7u,0x000001c2u,0x0004003du,0x0000008au,0x000001c4u,0x000000f1u,0x0004003du,0x0000003cu,0x000001c5u,0x00000044u,0x00070058u,0x0000004cu,0x000001c6u,0x000001c4u,0x000001c5u,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001c7u,0x000001c6u,0x000001c6u,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c3u,0x000001c7u,0x0004003du,0x0000008au,0x000001c9u,0x0000008cu,0x0004003du,0x0000003cu,0x000001cau,0x00000044u,0x00070058u,0x0000004cu,0x000001cbu,0x000001c9u,0x000001cau,0x00000002u,0x0000008fu,0x0008004fu,0x00000007u,0x000001ccu,0x000001cbu,0x000001cbu,0x00000000u,0x00000001u,0x00000002u,0x0003003eu,0x000001c8u,0x000001ccu,0x0004003du,0x00000007u,0x000001ceu,0x000001c8u,0x0004003du,0x00000007u,0x000001cfu,0x000001c3u,0x00050083u,0x00000007u,0x000001d0u,0x000001ceu,0x000001cfu,0x0006000cu,0x00000006u,0x000001d1u,0x00000001u,0x00000042u,0x000001d0u,0x0003003eu,0x000001cdu,0x000001d1u,0x0004003du,0x00000006u,0x000001d4u,0x000001b7u,0x0008000cu,0x00000006u,0x000001d5u,0x00000001u,0x00000031u,0x000000cdu,0x000001d3u,0x000001d4u,0x00050083u,0x00000006u,0x000001d6u,0x0000003fu,0x000001d5u,0x0003003eu,0x000001d2u,0x000001d6u,0x0004003du,0x00000006u,0x000001dau,0x000001cdu,0x0008000cu,0x00000006u,0x000001dbu,0x00000001u,0x00000031u,0x000001d8u,0x000001d9u,0x000001dau,0x0003003eu,0x000001d7u,0x000001dbu,0x0004003du,0x00000006u,0x000001ddu,0x000001d2u,0x0004003du,0x00000006u,0x000001deu,0x000001d7u,0x00050085u,0x00000006u,0x000001dfu,0x000001ddu,0x000001deu,0x0003003eu,0x000001dcu,0x000001dfu,0x0004003du,0x00000007u,0x000001e0u,0x000001a0u,0x0004003du,0x00000007u,0x000001e1u,0x000001c8u,0x0004003du,0x00000006u,0x000001e2u,0x000001dcu,0x00060050u,0x00000007u,0x000001e3u,0x000001e2u,0x000001e2u,0x000001e2u,0x0008000cu,0x00000007u,0x000001e4u,0x00000001u,0x0000002eu,0x000001e0u,0x000001e1u,0x000001e3u,0x0003003eu,0x000001a0u,0x000001e4u,0x0004003du,0x00000007u,0x000001e6u,0x000001a0u,0x0004003du,0x00000007u,0x000001e7u,0x00000187u,0x0004003du,0x00000006u,0x000001e8u,0x000001b7u,0x00060050u,0x00000007u,0x000001e9u,0x000001e8u,0x000001e8u,0x000001e8u,0x0008000cu,0x00000007u,0x000001eau,0x00000001u,0x0000002eu,0x000001e6u,0x000001e7u,0x000001e9u,0x0003003eu,0x000001e5u,0x000001eau,0x0004003du,0x00000027u,0x000001ebu,0x00000098u,0x000300f7u,0x000001edu,0x00000000u,0x000400fau,0x000001ebu,0x000001ecu,0x000001edu,0x000200f8u,0x000001ecu,0x0004003du,0x00000006u,0x000001efu,0x000000c9u,0x0004003du,0x00000006u,0x000001f0u,0x000000d5u,0x0007000cu,0x00000006u,0x000001f1u,0x00000001u,0x00000028u,0x000001efu,0x000001f0u,0x0003003eu,0x000001eeu,0x000001f1u,0x0004003du,0x00000006u,0x000001f3u,0x000001eeu,0x00050088u,0x00000006u,0x000001f4u,0x000001f3u,0x000000cdu,0x0008000cu,0x00000006u,0x000001f5u,0x00000001u,0x0000002bu,0x000001f4u,0x0000008fu,0x0000003fu,0x0003003eu,0x000001f2u,0x000001f5u,0x0004003du,0x00000006u,0x000001f7u,0x000001eeu,0x0007000cu,0x00000006u,0x000001f8u,0x00000001u,0x00000030u,0x000000cdu,0x000001f7u,0x0003003eu,0x000001f6u,0x000001f8u,0x0004003du,0x00000022u,0x000001f9u,0x00000024u,0x0004003du,0x00000016u,0x000001fau,0x00000018u,0x0004003du,0x00000006u,0x000001fbu,0x000001f6u,0x0004003du,0x00000006u,0x000001fcu,0x000001f2u,0x00070050u,0x0000004cu,0x000001fdu,0x000001fbu,0x000001fcu,0x0000008fu,0x0000003fu,0x00040063u,0x000001f9u,0x000001fau,0x000001fdu,0x000100fdu,0x000200f8u,0x000001edu,0x0004003du,0x00000022u,0x000001ffu,0x00000024u,0x0004003du,0x00000016u,0x00000200u,0x00000018u,0x0004003du,0x00000007u,0x00000201u,0x000001e5u,0x00050051u,0x00000006u,0x00000202u,0x00000201u,0x00000000u,0x00050051u,0x00000006u,0x00000203u,0x00000201u,0x00000001u,0x00050051u,0x00000006u,0x00000204u,0x00000201u,0x00000002u,0x00070050u,0x0000004cu,0x00000205u,0x00000202u,0x00000203u,0x00000204u,0x0000003fu,0x00040063u,0x000001ffu,0x00000200u,0x00000205u,0x000100fdu,0x00010038u,0x00050036u,0x00000006u,0x0000000bu,0x00000000u,0x00000009u,0x00030037u,0x00000008u,0x0000000au,0x000200f8u,0x0000000cu,0x0004003du,0x00000007u,0x0000000du,0x0000000au,0x00050094u,0x00000006u,0x00000012u,0x0000000du,0x00000011u,0x000200feu,0x00000012u,0x00010038u};
+static const size_t DDFG_SYNTH_LEN = 3082;
struct Blob { const uint32_t* code; size_t words; };
-enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT };
+enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT };
static inline Blob blob(Shader s) {
switch (s) {
case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN };
@@ -35,7 +35,7 @@ static inline Blob blob(Shader s) {
case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN };
case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN };
case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN };
- case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN };
+ case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN };
default: return { nullptr, 0 };
}
}
diff --git a/app/src/main/cpp/framegen/winfg/framegen.cpp b/app/src/main/cpp/framegen/ddfg/framegen.cpp
similarity index 96%
rename from app/src/main/cpp/framegen/winfg/framegen.cpp
rename to app/src/main/cpp/framegen/ddfg/framegen.cpp
index 4ddd12021..82d0a764b 100644
--- a/app/src/main/cpp/framegen/winfg/framegen.cpp
+++ b/app/src/main/cpp/framegen/ddfg/framegen.cpp
@@ -4,9 +4,9 @@
#include
#include
-namespace winfg {
+namespace ddfg {
-#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0)
+#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0)
uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const {
for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i)
@@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id,
if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false;
if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false;
if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false;
- if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false;
- if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
- WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
+ if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false;
+ if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
+ DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters);
return true;
}
@@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) {
if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
ready_ = true;
- WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
+ DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset);
return true;
}
diff --git a/app/src/main/cpp/framegen/winfg/framegen.hpp b/app/src/main/cpp/framegen/ddfg/framegen.hpp
similarity index 96%
rename from app/src/main/cpp/framegen/winfg/framegen.hpp
rename to app/src/main/cpp/framegen/ddfg/framegen.hpp
index 8b32fd0c0..70719e796 100644
--- a/app/src/main/cpp/framegen/winfg/framegen.hpp
+++ b/app/src/main/cpp/framegen/ddfg/framegen.hpp
@@ -3,7 +3,7 @@
#include "config.hpp"
#include
-namespace winfg {
+namespace ddfg {
class FrameGen {
public:
@@ -12,7 +12,8 @@ public:
void configure(const Config& c);
bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false);
void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
- VkImageView outView, float alpha, uint32_t gmSlot = 0);
+ VkImageView outView, float alpha, uint32_t gmSlot = 0,
+ uint32_t synthSlot = 0, bool reuseFlow = false);
void destroy();
bool valid() const { return device_ != VK_NULL_HANDLE && ready_; }
const Config& config() const { return cfg_; }
@@ -25,6 +26,7 @@ public:
static constexpr int kGmSlots = 3;
+ static constexpr int kSynthSlots = 3;
static constexpr int kGmLevel = 3;
static constexpr int kGmThreads = 512;
static constexpr int kGmStride = 32;
diff --git a/app/src/main/cpp/framegen/ddfg/log.hpp b/app/src/main/cpp/framegen/ddfg/log.hpp
new file mode 100644
index 000000000..b4c6c5a8d
--- /dev/null
+++ b/app/src/main/cpp/framegen/ddfg/log.hpp
@@ -0,0 +1,12 @@
+#pragma once
+#if defined(__ANDROID__)
+ #include
+ #define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__)
+ #define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__)
+#else
+ #include
+ #define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
+ #define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
+#endif
+
+#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0)
diff --git a/app/src/main/cpp/framegen/winfg/record_impl.inc b/app/src/main/cpp/framegen/ddfg/record_impl.inc
similarity index 65%
rename from app/src/main/cpp/framegen/winfg/record_impl.inc
rename to app/src/main/cpp/framegen/ddfg/record_impl.inc
index 2e8fba009..554c05c7f 100644
--- a/app/src/main/cpp/framegen/winfg/record_impl.inc
+++ b/app/src/main/cpp/framegen/ddfg/record_impl.inc
@@ -1,31 +1,34 @@
namespace {
struct ScratchState {
- VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr;
+ VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{};
+ VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{};
+ void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{};
std::vector flowUbo; std::vector flowUboMem; std::vector flowUboPtr;
VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr;
VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr;
- VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{};
- VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{};
- void* gmUboPtr[winfg::FrameGen::kGmSlots]{};
+ VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{};
+ VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{};
+ void* gmUboPtr[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE;
std::vector sDownA, sDownB, sFlow;
- VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{};
+ VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE;
- VkDescriptorSet sSynth = VK_NULL_HANDLE;
- VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{};
- std::vector sGmPrewarp[winfg::FrameGen::kGmSlots];
+ VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{};
+ VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{};
+ std::vector sGmPrewarp[ddfg::FrameGen::kGmSlots];
VkExtent2D builtFor{0,0};
bool built = false;
bool flowInited = false;
+ bool flowReady = false;
};
ScratchState g_scratch;
}
-static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
+static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp,
VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) {
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
@@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS;
}
-static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev,
+static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev,
VkDescriptorPool pool, VkDescriptorSetLayout layout) {
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout;
VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s;
}
-static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
+static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) {
VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = ⅈ
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
-static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
+static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet
w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
-static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
+static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3])
out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0;
}
-namespace winfg {
+namespace ddfg {
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool,
@@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP
b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr;
};
auto& S = g_scratch;
- freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr);
+ for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr);
freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]);
@@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) {
int want = flowFinestForPreset(cfg_.perfPreset);
const int maxFinest = kLevels - 2;
if (want > maxFinest) {
- WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
+ DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
"(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest);
want = maxFinest;
}
@@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) {
const int prevFinest = flowFinest_;
flowFinest_ = want;
const bool live = (flowFinest_ != prevFinest) && ready_;
- WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
+ DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0);
if (!live) return;
@@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) {
dd_->DeviceWaitIdle(device_);
destroyScratch(dd_, device_, descPool_);
if (!onResize(extent_, VK_FORMAT_UNDEFINED, true))
- WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
+ DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
extent_.width, extent_.height);
for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0;
for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; }
gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f;
- WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
+ DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
}
bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
@@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
}
void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
- VkImageView outView, float alpha, uint32_t gmSlot) {
+ VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) {
if (!valid()) return;
if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0;
+ if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0;
const bool m4 = (cfg_.model == 4);
const Pipe& flowP = m4 ? pFlowM4_ : pFlow_;
const Pipe& expandP = m4 ? pExpandM4_ : pExpand_;
@@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
}
- bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
- if (g_scratch.gmUboPtr[gmSlot]) {
- GMUBO g{};
- g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
- g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
- if (gmEngaged) {
- g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
- g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
+ // Every generation of one real frame is recorded into the same command buffer, so the flow
+ // between prev and curr is computed once and each generation gets its own synth UBO and set.
+ if (reuseFlow && !g_scratch.flowReady) return;
+ if (!reuseFlow) {
+ bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
+ if (g_scratch.gmUboPtr[gmSlot]) {
+ GMUBO g{};
+ g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
+ g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
+ if (gmEngaged) {
+ g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
+ g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
+ }
+ std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
- std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
- }
- {
- static int lastEng = -1;
- if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
- WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
- gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
- lastEng = gmEngaged ? 1 : 0;
+ {
+ static int lastEng = -1;
+ if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
+ DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
+ gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
+ lastEng = gmEngaged ? 1 : 0;
+ }
}
+
+ wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
+ wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
}
SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0,
cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1};
- if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su));
+ if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su));
- wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
- wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
- wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
- wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
- wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
+ const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot];
+ wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
+ wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
+ wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline);
@@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
};
auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); };
- for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
- {
- VkClearColorValue zero{};
- VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
+ if (!reuseFlow) {
+ for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
+ {
+ VkClearColorValue zero{};
+ VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
+ for (int l = flowFinest_; l < kLevels; ++l) {
+ VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
+ b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
+ b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
+ b.image = flowLvl_[l].image; b.subresourceRange = rng;
+ b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
+ dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
+ dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
+ b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
+ b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
+ dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
+ }
+ }
+
+ disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
+ disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
+ bar(pyrA_[0]); bar(pyrB_[0]);
+ for (int l = 1; l < kLevels; ++l) {
+ disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
+ disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
+ bar(pyrA_[l]); bar(pyrB_[l]);
+ }
+ if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
+ dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
+ dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
+ dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
+ VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
+ mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
+ dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
+ 0, 1, &mb, 0, nullptr, 0, nullptr);
+ }
for (int l = flowFinest_; l < kLevels; ++l) {
- VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
- b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
- b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
- b.image = flowLvl_[l].image; b.subresourceRange = rng;
- b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
- dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
- dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
- b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
- b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
- dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
+ disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
+ bar(pyrAs_[l]);
+ }
+ for (int l = kLevels - 1; l >= flowFinest_; --l) {
+ disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
+ bar(flowLvl_[l]);
}
- }
- disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
- disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
- bar(pyrA_[0]); bar(pyrB_[0]);
- for (int l = 1; l < kLevels; ++l) {
- disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
- disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
- bar(pyrA_[l]); bar(pyrB_[l]);
- }
- if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
- dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
- dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
- dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
- VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
- mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
- dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
- 0, 1, &mb, 0, nullptr, 0, nullptr);
- }
- for (int l = flowFinest_; l < kLevels; ++l) {
- disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
- bar(pyrAs_[l]);
- }
- for (int l = kLevels - 1; l >= flowFinest_; --l) {
- disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
- bar(flowLvl_[l]);
- }
+ const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
+ if (frEngaged) {
+ const int F = flowFinest_;
+ const VkExtent2D fe = levelExtent(extent_, F);
+ if (g_scratch.flowRegUboPtr) {
+ FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
+ std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
+ }
+ FrameGen::Img* last = nullptr;
+ for (int it = 0; it < cfg_.frIters; ++it) {
+ VkDescriptorSet set; FrameGen::Img* outImg;
+ if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
+ else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
+ else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
+ dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
+ dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
+ dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
+ bar(*outImg);
+ last = outImg;
+ }
+ VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
+ mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
+ mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
+ dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
+ 0, 1, &mb, 0, nullptr, 0, nullptr);
+ VkImageCopy region{};
+ region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
+ region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
+ region.extent = {fe.width, fe.height, 1};
+ dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
+ flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion);
+ VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
+ mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
+ mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
+ dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
+ 0, 1, &mb2, 0, nullptr, 0, nullptr);
+ }
+ {
+ static int lastFr = -1;
+ int fe = frEngaged ? 1 : 0;
+ if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
+ DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
+ fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
+ lastFr = fe;
+ }
+ }
- const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
- if (frEngaged) {
- const int F = flowFinest_;
- const VkExtent2D fe = levelExtent(extent_, F);
- if (g_scratch.flowRegUboPtr) {
- FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
- std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
- }
- FrameGen::Img* last = nullptr;
- for (int it = 0; it < cfg_.frIters; ++it) {
- VkDescriptorSet set; FrameGen::Img* outImg;
- if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
- else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
- else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
- dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
- dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
- dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
- bar(*outImg);
- last = outImg;
- }
- VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
- mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
- mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
- dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
- 0, 1, &mb, 0, nullptr, 0, nullptr);
- VkImageCopy region{};
- region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
- region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
- region.extent = {fe.width, fe.height, 1};
- dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
- flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, ®ion);
- VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
- mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
- mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
- dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
- 0, 1, &mb2, 0, nullptr, 0, nullptr);
+ disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
+ bar(flowExpA_); bar(flowExpB_);
+ g_scratch.flowReady = true;
}
- {
- static int lastFr = -1;
- int fe = frEngaged ? 1 : 0;
- if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
- WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
- fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
- lastFr = fe;
- }
- }
-
- disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
- bar(flowExpA_); bar(flowExpB_);
- disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0));
+ disp(pSynth_, synthSet, levelExtent(extent_, 0));
}
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
@@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
S = ScratchState{};
const auto GEN = VK_IMAGE_LAYOUT_GENERAL;
- makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr);
+ for (int s = 0; s < FrameGen::kSynthSlots; ++s)
+ makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr);
if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); }
makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
@@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN);
}
- S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout);
- wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO));
- wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
- wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
+ for (int s = 0; s < FrameGen::kSynthSlots; ++s) {
+ S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout);
+ wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO));
+ wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
+ wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
+ }
S.builtFor = ext; S.built = true;
return true;
diff --git a/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp b/app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp
similarity index 99%
rename from app/src/main/cpp/framegen/winfg/vk_dispatch.hpp
rename to app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp
index ef259250b..7d61ba165 100644
--- a/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp
+++ b/app/src/main/cpp/framegen/ddfg/vk_dispatch.hpp
@@ -3,7 +3,7 @@
#include
#include
-namespace winfg {
+namespace ddfg {
static inline void* dispatch_key(void* handle) { return *reinterpret_cast(handle); }
diff --git a/app/src/main/cpp/framegen/winfg/log.hpp b/app/src/main/cpp/framegen/winfg/log.hpp
deleted file mode 100644
index 056f136f4..000000000
--- a/app/src/main/cpp/framegen/winfg/log.hpp
+++ /dev/null
@@ -1,12 +0,0 @@
-#pragma once
-#if defined(__ANDROID__)
- #include
- #define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__)
- #define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__)
-#else
- #include
- #define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
- #define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
-#endif
-
-#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0)
diff --git a/app/src/main/cpp/framegen/winfg/winfg_vkd.h b/app/src/main/cpp/framegen/winfg/winfg_vkd.h
deleted file mode 100644
index 8faffc826..000000000
--- a/app/src/main/cpp/framegen/winfg/winfg_vkd.h
+++ /dev/null
@@ -1,4 +0,0 @@
-#pragma once
-#include "vk_dispatch.hpp"
-struct VkTable;
-bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id);
diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt
index 7f6e0f7d9..33585818b 100644
--- a/app/src/main/cpp/waylandcomp/CMakeLists.txt
+++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt
@@ -43,10 +43,10 @@ target_include_directories(droiddeckwayland PRIVATE
)
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
-set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
+set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg)
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
- AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
- message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
+ AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp)
+ message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)")
target_sources(droiddeckwayland PRIVATE
src/framegen_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
@@ -66,9 +66,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
- ${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
- ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
- ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
+ ${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp
+ ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp
+ ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp
)
target_include_directories(droiddeckwayland PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/..
@@ -77,8 +77,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
target_compile_options(droiddeckwayland PRIVATE
$<$:-std=c++17 -fvisibility=hidden>)
target_compile_definitions(droiddeckwayland PRIVATE
- WINFG_UPSTREAM="${WINFG_UPSTREAM}"
- WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
+ DDFG_UPSTREAM="${DDFG_UPSTREAM}"
+ DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}"
)
target_link_libraries(droiddeckwayland dxbc atomic)
else()
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
index 379087045..94bb581a0 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
+++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
@@ -86,7 +86,7 @@ static int64_t now_ns(void) {
void vkp_framegen_set_engine(int kind) {
- if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG;
+ if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG;
atomic_store(&g_kind, kind);
atomic_store(&g_failed_kind, -1);
atomic_store(&g_cfg_dirty, 1);
@@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) {
atomic_store(&g_cfg_dirty, 1);
}
-void vkp_framegen_set_winfg_tuning(int model, int perf_preset) {
+void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) {
atomic_store(&g_model, model == 3 ? 3 : 4);
atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset);
atomic_store(&g_cfg_dirty, 1);
@@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled);
g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier;
- FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s",
+ FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s",
fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(),
- fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable");
+ fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable");
}
void vkp_framegen_device_lost(void) { g_dev_lost = 1; }
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h
index 5e27db40d..573d6fd3c 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h
+++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h
@@ -6,14 +6,14 @@
#endif
#include
-enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
+enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 };
#define VKP_FG_MAX_GENERATIONS 3
void vkp_framegen_set_engine(int kind);
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
void vkp_framegen_set_lsfg_cache_path(const char *path);
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
-void vkp_framegen_set_winfg_tuning(int model, int perf_preset);
+void vkp_framegen_set_ddfg_tuning(int model, int perf_preset);
int vkp_framegen_problem(void);
const char *vkp_framegen_caps_reason(void);
void vkp_framegen_stats(float out[6]);
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp
index 43edb1240..8558ae3d3 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp
+++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp
@@ -12,7 +12,7 @@
#include "lsfg/lsfg_engine.h"
#include "lsfg/lsfg_probe.h"
#include "lsfg/lsfg_vkd.h"
-#include "winfg/winfg_engine.h"
+#include "ddfg/ddfg_engine.h"
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
@@ -30,7 +30,7 @@ uint32_t g_qfam = 0;
bool g_probed = false, g_ready = false;
std::unique_ptr g_lsfg;
-std::unique_ptr g_winfg;
+std::unique_ptr g_ddfg;
int g_kind = -1;
char g_build[96] = "";
@@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) {
fge_stop();
if (!g_ready) return -1;
if (kind == 1) {
- auto e = std::make_unique();
+ auto e = std::make_unique();
if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) {
- ELOGE("winfg-native: engine init failed");
+ ELOGE("ddfg-native: engine init failed");
return -1;
}
- g_winfg = std::move(e);
+ g_ddfg = std::move(e);
g_kind = 1;
-#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH)
- std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH);
+#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH)
+ std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH);
#else
std::snprintf(g_build, sizeof(g_build), "embedded chain");
#endif
@@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) {
}
void fge_stop(void) {
- if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
+ if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
g_lsfg.reset();
- g_winfg.reset();
+ g_ddfg.reset();
g_kind = -1;
}
int fge_unavailable(void) {
- if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0;
+ if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0;
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
}
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
int model, int perf_preset) {
if (multiplier < 2) multiplier = 2;
- if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale);
+ if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale);
else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz);
}
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) {
- if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0;
+ if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0;
if (!g_lsfg) return 0;
g_lsfg->setGuestExtent(w, h);
return g_lsfg->prepare(w, h, fmt) ? 1 : 0;
}
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
- if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0;
+ if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0;
if (!g_lsfg) return 0;
g_lsfg->setPresentedRate(presented_rate);
return g_lsfg->plan(capacity, source_frames);
}
void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) {
- if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; }
+ if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; }
if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens);
}
void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view,
uint32_t w, uint32_t h) {
- if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; }
+ if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h);
}
@@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
}
}
-const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
+const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return g_build; }
}
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c
index b9c4dff36..310e10c34 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c
+++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c
@@ -34,5 +34,5 @@ void fge_forget_targets(void) {}
void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
*accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f;
}
-const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
+const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return "stub"; }
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_jni.c b/app/src/main/cpp/waylandcomp/src/framegen_jni.c
index ea5ce0396..35c5a3de4 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_jni.c
+++ b/app/src/main/cpp/waylandcomp/src/framegen_jni.c
@@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN
}
JNIEXPORT void JNICALL
-Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
+Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz,
jint model, jint perfPreset) {
(void)env; (void)clazz;
- vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
+ vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset);
}
JNIEXPORT jint JNICALL
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
index 952ada616..4282ecf6f 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
@@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
object FrameGen {
const val ENGINE_OFF = "off"
- const val ENGINE_WINFG = "winfg"
+ const val ENGINE_DDFG = "ddfg"
+ private const val LEGACY_ENGINE_WINFG = "winfg"
const val ENGINE_LSFG = "lsfg"
- private const val WINFG_PERF_PRESET = 2
- private const val WINFG_MODEL = 4
- private const val FLOW_SCALE_WINFG = 0.60f
+ private const val DDFG_PERF_PRESET = 2
+ private const val DDFG_MODEL = 4
+ private const val FLOW_SCALE_DDFG = 0.60f
private const val FLOW_SCALE_LSFG = 0.80f
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
@@ -29,7 +30,7 @@ object FrameGen {
fun mode(context: Context): Mode {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
+ val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: ENGINE_OFF }
val target = prefs.getInt("target", 0)
if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target)
return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4))
@@ -43,7 +44,7 @@ object FrameGen {
fun label(context: Context): String = label(context, mode(context))
fun label(context: Context, mode: Mode): String = when {
- mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier)
+ mode.engine == ENGINE_DDFG -> context.getString(R.string.frame_gen_ddfg, mode.multiplier)
mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target)
mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier)
else -> context.getString(R.string.frame_gen_off)
@@ -68,10 +69,10 @@ object FrameGen {
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target)
}
- ENGINE_WINFG -> {
- WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG)
- WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET)
- WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz)
+ ENGINE_DDFG -> {
+ WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG)
+ WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET)
+ WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
}
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt
index 042396b9f..55ab3f7fa 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt
@@ -77,7 +77,7 @@ class PerfHud(context: Context) {
val mode = FrameGen.mode(context)
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
- val name = if (lsfg) "LSFG" else "Win-FG"
+ val name = if (lsfg) "LSFG" else "DD-FG"
val label = FrameGen.label(context, mode)
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt
index 816177bc1..bac30bd3b 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt
@@ -27,7 +27,7 @@ internal fun FrameGenMenu(
onPick(m); host.open = null
}
item(FrameGen.Mode.OFF, focus = firstItemFocus)
- for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m))
+ for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_DDFG, m))
val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } +
FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) }
lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) }
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
index e15d80998..48d9b3576 100644
--- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
@@ -290,7 +290,7 @@ public final class WaylandCompositor {
public static native void nativeSendSceneInput(int type, int a, int b);
- public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_WINFG = 1;
+ public static final int FG_ENGINE_LSFG = 0, FG_ENGINE_DDFG = 1;
public static native void nativeSetFrameGenEngine(int kind);
@@ -300,7 +300,7 @@ public final class WaylandCompositor {
public static native void nativeSetFrameGenTuning(float flowScale, float refreshHz);
- public static native void nativeSetWinFgTuning(int model, int perfPreset);
+ public static native void nativeSetDdFgTuning(int model, int perfPreset);
public static native int nativeFrameGenProblem();
public static native String nativeFrameGenCapsReason();
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index e496994a4..24209cf92 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -30,7 +30,7 @@
توليد الإطارات
اختر وضع توليد الإطارات
إيقاف
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG تكيّفي %1$d
يتطلب Lossless Scaling على Steam
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index fab4a066f..58c46e41f 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -30,7 +30,7 @@
Generación de fotogramas
Elige el modo de generación de fotogramas
Desactivada
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adaptativo %1$d
Requiere Lossless Scaling en Steam
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 5e375bca2..910c6a994 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -30,7 +30,7 @@
Генериране на кадри
Изберете режима на генериране на кадри
Изключено
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Адаптивно %1$d
Изисква Lossless Scaling в Steam
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index b4d674c32..8b13a6ccd 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -30,7 +30,7 @@
Generování snímků
Vyberte režim generování snímků
Vypnuto
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptivní %1$d
Vyžaduje Lossless Scaling na Steamu
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 3b2bc8979..e5eebc3df 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -30,7 +30,7 @@
Billedgenerering
Vælg metode til billedgenerering
Fra
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptiv %1$d
Kræver Lossless Scaling på Steam
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 3a4c61b17..27564b23d 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -30,7 +30,7 @@
Frame-Generierung
Modus der Frame-Generierung auswählen
Aus
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptiv %1$d
Benötigt Lossless Scaling auf Steam
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 4f4bfaa44..70790dc77 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -30,7 +30,7 @@
Δημιουργία καρέ
Επιλέξτε τη λειτουργία δημιουργίας καρέ
Ανενεργό
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG προσαρμοστικό %1$d
Απαιτεί το Lossless Scaling στο Steam
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 09660256e..0d3475efe 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -30,7 +30,7 @@
Generación de fotogramas
Elige el modo de generación de fotogramas
Desactivada
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adaptativo %1$d
Requiere Lossless Scaling en Steam
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 88d810a69..c207b4e4c 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -30,7 +30,7 @@
Kehysten generointi
Valitse kehysten generoinnin tila
Pois
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Mukautuva %1$d
Vaatii Lossless Scalingin Steamista
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 9393da87c..49d302080 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -30,7 +30,7 @@
Génération d\'images
Sélectionnez le mode de génération d\'images à utiliser
Désactivé
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptatif %1$d
Nécessite Lossless Scaling sur Steam
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 91fae22a1..b17009905 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -30,7 +30,7 @@
फ़्रेम जनरेशन
फ़्रेम जनरेशन मोड चुनें
बंद
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG एडैप्टिव %1$d
Steam पर Lossless Scaling की ज़रूरत है
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 48809c616..a0dd71c18 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -30,7 +30,7 @@
Képkocka-generálás
Válassza ki a képkocka-generálás módját
Ki
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adaptív %1$d
A Lossless Scaling szükséges a Steamen
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 85abaeec8..61be2c5f1 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -30,7 +30,7 @@
Pembuatan frame
Pilih mode pembuatan frame
Nonaktif
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptif %1$d
Memerlukan Lossless Scaling di Steam
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 19fe2f598..98567796a 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -30,7 +30,7 @@
Generazione di frame
Seleziona la modalità di generazione di frame
Disattivata
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adattivo %1$d
Richiede Lossless Scaling su Steam
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index c39625cd3..9da8b70b6 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -30,7 +30,7 @@
フレーム生成
使用するフレーム生成モードを選択してください
オフ
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG アダプティブ %1$d
Steam版のLossless Scalingが必要です
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 8e2508ebe..0227531a5 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -30,7 +30,7 @@
프레임 생성
사용할 프레임 생성 모드를 선택하세요
끔
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG 적응형 %1$d
Steam에서 Lossless Scaling이 필요합니다
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 537bd5421..19c8a6d57 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -30,7 +30,7 @@
Penjanaan bingkai
Pilih mod penjanaan bingkai
Mati
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptif %1$d
Memerlukan Lossless Scaling pada Steam
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 977db283c..26f514ce6 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -30,7 +30,7 @@
Bildegenerering
Velg modus for bildegenerering
Av
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptiv %1$d
Krever Lossless Scaling på Steam
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index f841a1878..6d38c5395 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -30,7 +30,7 @@
Framegeneratie
Kies de modus voor framegeneratie
Uit
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adaptief %1$d
Vereist Lossless Scaling op Steam
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index d89698468..7886e8f0d 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -30,7 +30,7 @@
Generowanie klatek
Wybierz tryb generowania klatek
Wył.
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adaptacyjne %1$d
Wymaga Lossless Scaling ze Steam
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 55d8d0108..35f2b5577 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -30,7 +30,7 @@
Geração de fotogramas
Selecione o modo de geração de fotogramas
Desligado
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptativo %1$d
Requer o Lossless Scaling no Steam
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index dd27961db..0672620b8 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -30,7 +30,7 @@
Geração de quadros
Selecione o modo de geração de quadros
Desativada
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptativo %1$d
Requer o Lossless Scaling na Steam
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 637ffea5e..329865e8f 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -30,7 +30,7 @@
Generare de cadre
Selectează modul de generare a cadrelor
Dezactivat
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptiv %1$d
Necesită Lossless Scaling pe Steam
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index b5a52740a..e25310312 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -30,7 +30,7 @@
Генерация кадров
Выберите режим генерации кадров
Выкл.
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Адаптивный %1$d
Требуется Lossless Scaling из Steam
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 6e97abd03..0cf9b426c 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -30,7 +30,7 @@
Bildrutegenerering
Välj läge för bildrutegenerering
Av
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG adaptiv %1$d
Kräver Lossless Scaling på Steam
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 24a52fc9e..8bdf43b37 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -30,7 +30,7 @@
การสร้างเฟรม
เลือกโหมดการสร้างเฟรม
ปิด
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG แบบปรับอัตโนมัติ %1$d
ต้องมี Lossless Scaling บน Steam
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 424db34ea..a81213aef 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -30,7 +30,7 @@
Kare oluşturma
Kare oluşturma modunu seçin
Kapalı
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Uyarlanabilir %1$d
Steam\'de Lossless Scaling gerekli
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 2d79d0a94..2af43de09 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -30,7 +30,7 @@
Генерація кадрів
Виберіть режим генерації кадрів
Вимк.
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG адаптивний %1$d
Потрібен Lossless Scaling зі Steam
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index bf2c67811..70026208a 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -30,7 +30,7 @@
Tạo khung hình
Chọn chế độ tạo khung hình
Tắt
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG thích ứng %1$d
Cần Lossless Scaling trên Steam
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 206e30f1d..7de45e1b8 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -30,7 +30,7 @@
帧生成
选择帧生成模式
关闭
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG 自适应 %1$d
需要 Steam 上的 Lossless Scaling
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 16f54ea4e..66207ea8d 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -30,7 +30,7 @@
幀生成
選擇幀生成模式
關閉
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG 自適應 %1$d
需要 Steam 上的 Lossless Scaling
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 95e5dd915..7fdd8c25b 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -30,7 +30,7 @@
影格生成
請選擇你要使用的影格生成模式
關閉
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG 自適應 %1$d
需要 Steam 上的 Lossless Scaling
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 603099d9f..021c3217c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -34,7 +34,7 @@
Frame generation
Select the frame generation mode
Off
- Win-FG %1$d×
+ DD-FG %1$d×
LSFG %1$d×
LSFG Adaptive %1$d
Needs Lossless Scaling on Steam
diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt
index fe3c9773d..f4c3b79ea 100644
--- a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt
@@ -36,7 +36,13 @@ class FrameGenTest {
@Test fun onlyLsfgIsAdaptive() {
context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit()
- .putString("engine", FrameGen.ENGINE_WINFG).putInt("multiplier", 4).putInt("target", 60).commit()
- assertEquals(FrameGen.Mode(FrameGen.ENGINE_WINFG, 4), FrameGen.mode(context))
+ .putString("engine", FrameGen.ENGINE_DDFG).putInt("multiplier", 4).putInt("target", 60).commit()
+ assertEquals(FrameGen.Mode(FrameGen.ENGINE_DDFG, 4), FrameGen.mode(context))
+ }
+
+ @Test fun aSavedWinFgChoiceBecomesDdFg() {
+ context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit()
+ .putString("engine", "winfg").putInt("multiplier", 2).commit()
+ assertEquals(FrameGen.Mode(FrameGen.ENGINE_DDFG, 2), FrameGen.mode(context))
}
}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt
index e636bee70..81ace45da 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt
@@ -34,7 +34,7 @@ class SettingsResetTest {
SessionPrefs.setGuestHostname(app, "MyDeck")
ControllerPrefs.setOpacity(app, 40)
AppUiPrefs.setScale(app, 150)
- FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_WINFG, 3))
+ FrameGen.set(app, FrameGen.Mode(FrameGen.ENGINE_DDFG, 3))
prefs("steam_touch").edit().putBoolean("gyro", false).commit()
assertTrue(SettingsReset.resetAll(app, gameProfiles = false))
From a789d60f6ab5aa83ff2082fec5c7b1a1b52f9ce9 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:31:23 -0400
Subject: [PATCH 020/156] fix: register SteamVR only during a DroidDeck VR
session (#1137)
---
.../overlay/usr/local/bin/droiddeck-session | 1 +
.../overlay/usr/local/bin/droiddeck-steamvr | 23 ++++++++++-
tools/tests/test_steamvr_helper.py | 38 +++++++++++++++++++
3 files changed, 61 insertions(+), 1 deletion(-)
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
index 1566a1ba7..fdadc61ed 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
@@ -129,6 +129,7 @@ export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache}
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
rm -f "${XDG_RUNTIME_DIR:-/run/droiddeck}/droiddeck-steamvr/active"
+/usr/local/bin/droiddeck-steamvr unregister >/dev/null 2>&1 || true
if [ "$mode" = lxqt ] || [ "$mode" = desktop ]; then
/usr/local/bin/droiddeck-desktop-bookmarks || true
fi
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr
index 242bd169e..1de7cb89e 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr
@@ -41,6 +41,22 @@ configure_graphics() {
esac
}
+# Any OpenXR probe (Steam runs one through Proton at every client start) starts vrserver
+# while a runtime is registered, so SteamVR is registered only during a DroidDeck VR session.
+unregister() {
+ if grep -qF "\"$steamvr\"" "$HOME/.config/openvr/openvrpaths.vrpath" 2>/dev/null; then
+ rm -f "$HOME/.config/openvr/openvrpaths.vrpath"
+ fi
+ case "$(readlink "$HOME/.config/openxr/1/active_runtime.json" 2>/dev/null)" in
+ "$steamvr"/*) rm -f "$HOME/.config/openxr/1/active_runtime.json" ;;
+ esac
+}
+
+idle() {
+ [ ! -f "$state/active" ] && ! running && ! pgrep -x vrstartup >/dev/null 2>&1 &&
+ ( flock -n 9 ) 9>"$state/start.lock"
+}
+
stop() {
rm -f "$state/active"
for n in vrcompositor vrserver vrmonitor vrdashboard vrstartup vrwebhelper; do
@@ -56,6 +72,7 @@ stop() {
[ "$attempts" -lt 20 ] || { echo 'SteamVR did not stop; preferences remain backed up' >&2; return 1; }
sleep 0.1
done
+ unregister
quest_settings restore
}
@@ -74,6 +91,7 @@ status)
i=0; installed && i=1
r=0; running && r=1
if [ "$r" = 0 ] && [ -n "$(find "$state/active" -mmin +2 2>/dev/null)" ]; then rm -f "$state/active"; fi
+ if idle; then unregister; fi
n=0; [ -f "$state/installing" ] && n=1
d=0; [ -f "$driver/bin/linuxarm64/driver_droiddeck.so" ] && [ -f "$layer/libVkLayer_droiddeck_vr.so" ] && d=1
echo "installed=$i running=$r installing=$n driver=$d"
@@ -100,6 +118,9 @@ install)
stop)
stop || exit 1
;;
+unregister)
+ if idle; then unregister; fi
+ ;;
start)
exec 9>"$state/start.lock"
flock -n 9 || { echo "SteamVR start already in progress"; exit 0; }
@@ -234,7 +255,7 @@ JSON
echo "SteamVR starting; logs in $logs and $state/vrstartup.log"
;;
*)
- echo "usage: droiddeck-steamvr status|install|start|stop|launch" >&2
+ echo "usage: droiddeck-steamvr status|install|start|stop|launch|unregister" >&2
exit 2
;;
esac
diff --git a/tools/tests/test_steamvr_helper.py b/tools/tests/test_steamvr_helper.py
index 3fa3e8225..f99f5cbdc 100644
--- a/tools/tests/test_steamvr_helper.py
+++ b/tools/tests/test_steamvr_helper.py
@@ -210,6 +210,44 @@ class SteamVrHelperTest(unittest.TestCase):
def test_flat_presenter_does_not_enable_the_producer_lease(self):
self.assertIn('present=unset lease=unset', self.lease_log())
+ def registration(self):
+ return (self.home / '.config/openvr/openvrpaths.vrpath',
+ self.home / '.config/openxr/1/active_runtime.json')
+
+ def helper_action(self, action):
+ subprocess.run(['sh', str(self.helper), action], env=self.environment, check=True,
+ capture_output=True, timeout=5)
+
+ def test_runtime_is_registered_only_while_a_session_is_active(self):
+ vrpath, xr = self.registration()
+ self.start()
+ self.assertTrue(vrpath.is_file())
+ self.assertTrue(xr.is_symlink())
+ self.helper_action('status')
+ self.assertTrue(vrpath.is_file())
+ self.helper_action('stop')
+ self.assertFalse(vrpath.exists())
+ self.assertFalse(xr.is_symlink())
+
+ def test_idle_status_and_session_start_clear_a_leftover_registration(self):
+ vrpath, xr = self.registration()
+ for action in ('status', 'unregister'):
+ self.start()
+ (self.runtime / 'droiddeck-steamvr/active').unlink()
+ self.helper_action(action)
+ self.assertFalse(vrpath.exists(), action)
+ self.assertFalse(xr.is_symlink(), action)
+
+ def test_unregister_leaves_another_runtime_alone(self):
+ vrpath, xr = self.registration()
+ vrpath.parent.mkdir(parents=True)
+ xr.parent.mkdir(parents=True)
+ vrpath.write_text('{"runtime": ["/opt/other-runtime"]}')
+ xr.symlink_to('/opt/monado/openxr_monado.json')
+ self.helper_action('unregister')
+ self.assertTrue(vrpath.is_file())
+ self.assertTrue(xr.is_symlink())
+
if __name__ == '__main__':
unittest.main()
From 7c20d4b5c3cf38504d4ea310cef3ed878273a69f Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:18:26 -0400
Subject: [PATCH 021/156] Fix direct-touch right-click and Steam Touch mode
restoration (#677)
---
.../com/droiddeck/launcher/SessionActivity.kt | 12 ++++
.../launcher/input/DirectTouchHold.kt | 53 ++++++++++++++
.../launcher/input/DirectTouchHoldTest.kt | 66 ++++++++++++++++++
.../launcher/input/SessionTouchRoutingTest.kt | 69 +++++++++++++++++++
4 files changed, 200 insertions(+)
create mode 100644 app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 9f145ab57..8ad1af889 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -75,6 +75,7 @@ import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadMouse
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.input.SteamControllerBle
+import com.droiddeck.launcher.input.DirectTouchHold
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
@@ -188,6 +189,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
+ private lateinit var directTouchHold: DirectTouchHold
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private lateinit var scrollTouch: ScrollTouchGestures
private lateinit var padMouse: PadMouse
@@ -483,6 +485,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
+ directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
+ WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
+ }
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
@@ -1296,6 +1301,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextTouchMode = SessionPrefs.touchMode(this)
if (touchMode != nextTouchMode) {
if (::touchpad.isInitialized) touchpad.cancel()
+ if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
@@ -2286,6 +2292,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (next == routedTouchMode) return
if (::touchpad.isInitialized) touchpad.cancel()
+ if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
@@ -2353,6 +2360,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
return true
}
+ if (directTouchHold.onTouch(event)) return true
fun sendTouch(action: Int, index: Int) {
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
@@ -2502,6 +2510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
controls.visibility = View.GONE
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
+ applyTouchRouting()
return
}
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
@@ -2514,6 +2523,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (steam == null) {
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
+ applyTouchRouting()
}
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
@@ -2576,6 +2586,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releasePads()
touchpad.cancel()
scrollTouch.cancel()
+ directTouchHold.cancel()
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
keyboard?.hide()
sessionClipboard.stop()
@@ -2702,6 +2713,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
+ if (::directTouchHold.isInitialized) directTouchHold.cancel()
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
diff --git a/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt
new file mode 100644
index 000000000..318941c71
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/DirectTouchHold.kt
@@ -0,0 +1,53 @@
+package com.droiddeck.launcher.input
+
+import android.os.Handler
+import android.os.Looper
+import android.view.MotionEvent
+import kotlin.math.abs
+
+class DirectTouchHold(
+ private val slopPx: Float,
+ private val listener: PointerGestures.Listener,
+ private val cancelTouch: () -> Unit,
+) {
+ private val handler = Handler(Looper.getMainLooper())
+ private var x = 0f
+ private var y = 0f
+ private var consumed = false
+ private var pressed = false
+ private val release = Runnable {
+ if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
+ pressed = false
+ }
+ private val hold = Runnable {
+ cancelTouch()
+ consumed = true
+ listener.onLongPress()
+ listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
+ pressed = true
+ handler.postDelayed(release, 50L)
+ }
+
+ fun cancel() {
+ handler.removeCallbacks(hold)
+ handler.removeCallbacks(release)
+ release.run()
+ consumed = false
+ }
+
+ fun onTouch(event: MotionEvent): Boolean {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ cancel()
+ x = event.x; y = event.y
+ handler.postDelayed(hold, 500L)
+ }
+ MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
+ abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
+ MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
+ MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
+ MotionEvent.ACTION_CANCEL -> cancel()
+ }
+ return consumed
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt
new file mode 100644
index 000000000..d7dd5307a
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/input/DirectTouchHoldTest.kt
@@ -0,0 +1,66 @@
+package com.droiddeck.launcher.input
+
+import android.os.Looper
+import android.view.MotionEvent
+import java.time.Duration
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], manifest = Config.NONE)
+class DirectTouchHoldTest {
+ private val events = mutableListOf()
+ private val hold = DirectTouchHold(8f, object : PointerGestures.Listener {
+ override fun onMove(x: Float, y: Float) {}
+ override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
+ assertEquals(PointerGestures.BTN_RIGHT, button)
+ events += "$pressed at $x,$y"
+ }
+ override fun onWheel(steps: Int) {}
+ override fun onLongPress() {}
+ }) { events += "cancel touch" }
+
+ @Test fun holdReleasesRawTouchBeforeClickAndConsumesTheLift() {
+ assertFalse(touch(MotionEvent.ACTION_DOWN))
+ advance(500)
+ assertEquals(listOf("cancel touch", "true at 40.0,30.0"), events)
+ assertTrue(touch(MotionEvent.ACTION_UP))
+ advance(50)
+ assertEquals("false at 40.0,30.0", events.last())
+ assertFalse(touch(MotionEvent.ACTION_DOWN))
+ hold.cancel()
+ }
+
+ @Test fun tapDragSecondFingerAndCancellationDoNotRightClick() {
+ for (action in listOf(MotionEvent.ACTION_UP, MotionEvent.ACTION_MOVE,
+ MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_CANCEL)) {
+ touch(MotionEvent.ACTION_DOWN)
+ assertFalse(touch(action, 60f))
+ advance(600)
+ assertTrue(events.isEmpty())
+ hold.cancel()
+ }
+ }
+
+ @Test fun modeChangeCancelsPendingHoldAndReleasesAnActiveClick() {
+ touch(MotionEvent.ACTION_DOWN)
+ hold.cancel()
+ advance(600)
+ assertTrue(events.isEmpty())
+ touch(MotionEvent.ACTION_DOWN)
+ advance(500)
+ hold.cancel()
+ advance(100)
+ assertEquals(listOf("cancel touch", "true at 40.0,30.0", "false at 40.0,30.0"), events)
+ }
+
+ private fun advance(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms))
+ private fun touch(action: Int, x: Float = 40f): Boolean {
+ val event = MotionEvent.obtain(0, 0, action, x, 30f, 0)
+ return hold.onTouch(event).also { event.recycle() }
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt
new file mode 100644
index 000000000..610f7499e
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/input/SessionTouchRoutingTest.kt
@@ -0,0 +1,69 @@
+package com.droiddeck.launcher.input
+
+import android.view.View
+import com.droiddeck.launcher.SessionActivity
+import com.droiddeck.launcher.session.SessionPrefs
+import com.droiddeck.launcher.session.SessionService
+import com.droiddeck.launcher.session.SessionState
+import java.nio.file.Files
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.util.ReflectionHelpers
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], manifest = Config.NONE)
+class SessionTouchRoutingTest {
+ private lateinit var activity: SessionActivity
+ private lateinit var steam: SteamTouchControls
+ private lateinit var touchpad: TouchpadGestures
+ private val directory = Files.createTempDirectory("touch-routing").toFile()
+
+ @Before fun setUp() {
+ activity = Robolectric.buildActivity(SessionActivity::class.java).get()
+ activity.getSharedPreferences("session", 0).edit().clear().commit()
+ SessionState.mode = SessionService.MODE_STEAM
+ SteamTouchDevice.prepare(directory)
+ steam = SteamTouchControls(activity, SteamTouchDevice.current!!, {}, {})
+ steam.visibility = View.VISIBLE
+ touchpad = TouchpadGestures(8f, object : PointerGestures.Listener {
+ override fun onMove(x: Float, y: Float) {}
+ override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {}
+ override fun onWheel(steps: Int) {}
+ override fun onLongPress() {}
+ })
+ field("onScreenControls", OnScreenControls(activity, null).apply { visibility = View.GONE })
+ field("steamTouchControls", steam)
+ field("steamTouchOptions", SteamTouchConfig.Options(inputMode = SteamTouchConfig.INPUT_MOUSE, mouseMode = SteamTouchConfig.MOUSE_ABSOLUTE, trackpadSensitivity = 2f))
+ field("touchpad", touchpad)
+ field("routedTouchMode", SessionPrefs.TOUCH_DIRECT)
+ }
+
+ @After fun tearDown() {
+ SteamTouchDevice.release()
+ directory.deleteRecursively()
+ SessionState.mode = SessionService.MODE_STEAM
+ }
+
+ @Test fun disablingSteamTouchRestoresEverySavedModeEvenWhenRegularControlsAreAlreadyHidden() {
+ for (mode in listOf(SessionPrefs.TOUCH_PAD, SessionPrefs.TOUCH_DIRECT, SessionPrefs.TOUCH_AUTO, SessionPrefs.TOUCH_OFF)) {
+ SessionPrefs.setTouchMode(activity, mode)
+ SessionPrefs.setOscMode(activity, SessionPrefs.OSC_NEVER)
+ steam.visibility = View.VISIBLE
+ invoke("readPrefs")
+ assertEquals(SessionPrefs.TOUCH_DIRECT, ReflectionHelpers.getField(activity, "routedTouchMode"))
+ invoke("updateOnScreenControls")
+ assertEquals(View.GONE, steam.visibility)
+ assertEquals(mode, ReflectionHelpers.getField(activity, "routedTouchMode"))
+ assertEquals(1.15f, touchpad.sensitivity, 0f)
+ }
+ }
+
+ private fun field(name: String, value: Any) = ReflectionHelpers.setField(activity, name, value)
+ private fun invoke(name: String) = ReflectionHelpers.callInstanceMethod(activity, name)
+}
From 2ea86b4abb5ba57e8d79d4cf1c93d2efde7dcb41 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:18:52 -0400
Subject: [PATCH 022/156] fix: run x86 Proton under FEX in the Steam runtime it
requires (#1034)
---
.../overlay/usr/local/bin/droiddeck-fex | 82 +++++++++++++------
.../overlay/usr/local/bin/steam-compatibility | 31 ++++---
tools/tests/test_droiddeck_fex.py | 53 +++++++++++-
3 files changed, 127 insertions(+), 39 deletions(-)
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex
index 0de377ef0..a8627ca3a 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex
@@ -30,6 +30,8 @@ FEX_APPID = "3127680"
SNIPER_APPID = "1628350"
SLR4_APPID = "4183110"
INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper", SLR4_APPID: "SteamLinuxRuntime_4"}
+RUNTIMES = {SNIPER_APPID: ("sniper_platform_", "Steam Linux Runtime 3.0 (sniper"),
+ SLR4_APPID: ("steamrt4_platform_", "Steam Linux Runtime 4.0 (steamrt4")}
MARKER = ".droiddeck-fex"
LAYOUT = 1
ROOTFS_LAYOUT = 1
@@ -174,15 +176,15 @@ def platform_version(path):
return tuple(int(part) for part in re.findall(r"\d+", os.path.basename(path)))
-def runtime_platform(appid=SNIPER_APPID, prefix="sniper_platform_"):
- runtime = app_dir(appid)
+def runtime_platform(app=SNIPER_APPID):
+ runtime = app_dir(app)
if not runtime:
return None
try:
entries = os.listdir(runtime)
except OSError:
return None
- platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith(prefix)]
+ platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith(RUNTIMES[app][0])]
platforms = [path for path in platforms if os.path.isdir(os.path.join(path, "files"))
and os.path.isfile(os.path.join(path, "usr-mtree.txt.gz"))]
return max(platforms, key=platform_version, default=None)
@@ -245,7 +247,7 @@ def materialize(platform):
def graphics_platform():
- return runtime_platform(SLR4_APPID, "steamrt4_platform_")
+ return runtime_platform(SLR4_APPID)
def provides_newer(base, provider):
@@ -366,6 +368,24 @@ def link_steam_sdk():
break
+def link_guest_sdk(root):
+ # Proton's lsteamclient only looks in $HOME/.steam/sdk64; on ARM Steam that is the arm64 client, so
+ # the x86 runtime gets its own link, which FEX resolves before the host path.
+ for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))):
+ want = "x86_64" if sdk == "sdk64" else "i386"
+ directory = next((os.path.join(STEAM_ROOT, candidate) for candidate in candidates
+ if elf_arch(os.path.join(STEAM_ROOT, candidate, "steamclient.so")) == want), None)
+ if not directory:
+ continue
+ link = os.path.join(root, HOME.lstrip("/"), ".steam", sdk)
+ if os.path.islink(link) and os.readlink(link) == directory:
+ continue
+ os.makedirs(os.path.dirname(link), exist_ok=True)
+ if os.path.lexists(link):
+ os.unlink(link)
+ os.symlink(directory, link)
+
+
def steam_running():
for pid in os.listdir("/proc"):
if not pid.isdigit():
@@ -380,13 +400,15 @@ def steam_running():
return False
-def request_install():
+def request_install(app=SNIPER_APPID):
os.makedirs(os.path.dirname(WANTED), exist_ok=True)
write_if_changed(WANTED, "1\n")
if not steam_running():
return []
try:
compat = runpy.run_path(os.path.join(BIN_DIR, "steam-compatibility"))
+ if app != SNIPER_APPID and app_dir(app) is None:
+ return compat["request_app"](STEAM_ROOT, app)
return compat["request_fex"](STEAM_ROOT, set())
except Exception as error:
say("could not ask the client to install FEX (%s)" % error)
@@ -406,10 +428,10 @@ def describe(fex, platform, graphics=None):
}
-def prepare():
+def prepare(app=SNIPER_APPID):
with locked():
fex = fex_install()
- platform = runtime_platform()
+ platform = runtime_platform(app)
status = describe(fex, platform)
if not status["ready"]:
missing = []
@@ -422,8 +444,8 @@ def prepare():
elif not fex["server"]:
missing.append("FEXServer beside %s" % fex["bin"])
if not platform:
- missing.append("Steam Linux Runtime 3.0 (sniper, Steam app %s)" % SNIPER_APPID)
- requested = request_install()
+ missing.append("%s, Steam app %s)" % (RUNTIMES[app][1], app))
+ requested = request_install(app)
say("x86 Linux programs need %s; %s" % (" and ".join(missing),
"the Steam client was asked to install it" if requested else "open Steam once and it is installed for you"))
write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n")
@@ -432,20 +454,24 @@ def prepare():
if materialize(platform):
say("prepared %s as the x86 runtime" % os.path.basename(platform))
install_preloads(root)
- provider = graphics_platform()
- if provider and materialize(provider):
- say("prepared %s for the x86 runtime's C and C++ libraries" % os.path.basename(provider))
- if not provider:
- say("Steam Linux Runtime 4.0 (Steam app %s) is not installed yet; x86 games may not find their graphics drivers"
- % SLR4_APPID)
- elif not provides_newer(platform, provider):
- say("%s has no newer C and C++ libraries than %s" % (os.path.basename(provider), os.path.basename(platform)))
- provider = None
- else:
- if build_rootfs(platform, provider):
- say("built the x86 runtime from %s with the C and C++ libraries of %s"
- % (os.path.basename(platform), os.path.basename(provider)))
- root = os.path.join(STATE, "rootfs")
+ provider = None
+ if app == SNIPER_APPID:
+ provider = graphics_platform()
+ if provider and materialize(provider):
+ say("prepared %s for the x86 runtime's C and C++ libraries" % os.path.basename(provider))
+ if not provider:
+ say("Steam Linux Runtime 4.0 (Steam app %s) is not installed yet; x86 games may not find their graphics drivers"
+ % SLR4_APPID)
+ elif not provides_newer(platform, provider):
+ say("%s has no newer C and C++ libraries than %s" % (os.path.basename(provider), os.path.basename(platform)))
+ provider = None
+ else:
+ if build_rootfs(platform, provider):
+ say("built the x86 runtime from %s with the C and C++ libraries of %s"
+ % (os.path.basename(platform), os.path.basename(provider)))
+ root = os.path.join(STATE, "rootfs")
+ with contextlib.suppress(OSError):
+ link_guest_sdk(root)
write_config(fex, root)
fex["rootfs"] = root
try:
@@ -530,6 +556,8 @@ def guest_environment(fex, env):
result["STEAM_COMPAT_DATA_PATH"] = short
result.setdefault("FEX_SILENTLOG", "0")
result.setdefault("FEX_OUTPUTLOG", "stderr")
+ # The session's tunables are for the host libc; an x86 ld.so copying them under FEX fails in sbrk.
+ result.pop("GLIBC_TUNABLES", None)
# Mono's incremental Boehm collector corrupts its heap under FEX, so Unity games crash at their first collections.
result.setdefault("GC_DISABLE_INCREMENTAL", "1")
if fex["portable"]:
@@ -594,6 +622,12 @@ def vulkan_thunk_loads(fex, folder):
return True
+def required_runtime(program):
+ text = read_text(os.path.join(os.path.dirname(program), "toolmanifest.vdf")) or ""
+ match = re.search(r'"require_tool_appid"\s+"(\d+)"', text)
+ return match.group(1) if match and match.group(1) in RUNTIMES else SNIPER_APPID
+
+
def resolve(program, env):
if "/" in program:
return os.path.abspath(program)
@@ -605,7 +639,7 @@ def launch(command, env, mode, decide=None):
use = mode == "on" or (mode == "auto" and needs_fex(decide or program))
if not use:
return os.execvpe(command[0], command, env)
- fex = prepare()
+ fex = prepare(required_runtime(program))
if fex is None:
return 126
guest = guest_environment(fex, env)
diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
index fb84fd94d..242d21723 100755
--- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
+++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
@@ -901,24 +901,33 @@ def fex_wanted_marker():
return os.path.join(os.environ.get("HOME", "/root"), ".config", "droiddeck", "fex-wanted")
-def request_fex(steam_root, asked):
+def request_app(steam_root, app):
import subprocess
+ client = os.path.join(steam_root, "steamrtarm64", "steam")
+ if not os.access(client, os.X_OK):
+ return []
+ try:
+ process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, start_new_session=True)
+ except OSError:
+ return []
+ try:
+ process.wait(timeout=REQUEST_HANDOFF_SECONDS)
+ except subprocess.TimeoutExpired:
+ pass
+ return [app]
+
+
+def request_fex(steam_root, asked):
client = os.path.join(steam_root, "steamrtarm64", "steam")
missing = fex_missing(steam_root)
if not os.access(client, os.X_OK) or any(app in asked for app in missing):
return []
for app in missing:
asked.add(app)
- try:
- process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, start_new_session=True)
- except OSError:
- continue
- try:
- process.wait(timeout=REQUEST_HANDOFF_SECONDS)
- except subprocess.TimeoutExpired:
- pass
- return [app]
+ requested = request_app(steam_root, app)
+ if requested:
+ return requested
return []
diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py
index 0af8e7f4f..79a8a38bf 100644
--- a/tools/tests/test_droiddeck_fex.py
+++ b/tools/tests/test_droiddeck_fex.py
@@ -69,9 +69,10 @@ class FexTestCase(unittest.TestCase):
self.write(Path(library or self.steam) / "steamapps/appmanifest_3127680.acf", '"AppState" { "installdir" "FEX-Emu" }')
return tool
- def runtime(self, version="3.0.20260805.254768", lines=None):
- runtime = self.steam / "steamapps/common/SteamLinuxRuntime_sniper"
- platform = runtime / ("sniper_platform_" + version)
+ def runtime(self, version="3.0.20260805.254768", lines=None, app="1628350", folder="SteamLinuxRuntime_sniper",
+ prefix="sniper_platform_"):
+ runtime = self.steam / "steamapps/common" / folder
+ platform = runtime / (prefix + version)
files = platform / "files"
self.write(files / "lib/x86_64-linux-gnu/libc.so.6", elf(62))
self.write(files / "ab/cdef-1.bin", b"blob")
@@ -89,9 +90,16 @@ class FexTestCase(unittest.TestCase):
]
with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out:
out.write("\n".join(mtree) + "\n")
- self.write(self.steam / "steamapps/appmanifest_1628350.acf", '"AppState" { "installdir" "SteamLinuxRuntime_sniper" }')
+ self.write(self.steam / ("steamapps/appmanifest_%s.acf" % app), '"AppState" { "installdir" "%s" }' % folder)
return platform
+ def steamrt4(self):
+ return self.runtime("4.0.20260928.262390", app="4183110", folder="SteamLinuxRuntime_4", prefix="steamrt4_platform_")
+
+ def proton(self, require):
+ proton = self.steam / "steamapps/common/Proton - Experimental"
+ self.write(proton / "toolmanifest.vdf", '"manifest"\n{\n "commandline" "/proton %%verb%%"\n "require_tool_appid" "%s"\n}\n' % require)
+ return self.write(proton / "proton", "#!/usr/bin/env python3\n", 0o755)
def graphics(self, sniper, libc=b"GLIBC_2.2.5 GLIBC_2.31 GLIBC_2.34 GLIBC_2.41", libstdcxx=b"GLIBCXX_3.4.28 GLIBCXX_3.4.33"):
files = Path(sniper) / "files"
x86 = files / "lib/x86_64-linux-gnu"
@@ -272,6 +280,29 @@ class PrepareTest(FexTestCase):
status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text())
self.assertFalse(status["ready"])
+ def test_a_missing_steam_runtime_4_is_named(self):
+ self.fex_tool()
+ self.runtime()
+ with mock.patch.dict(G, {"steam_running": lambda: False}):
+ fex, said = self.quiet(FEX["prepare"], "4183110")
+ self.assertIsNone(fex)
+ self.assertIn("Steam Linux Runtime 4.0 (steamrt4, Steam app 4183110)", said)
+ self.assertNotIn("FEX (Steam app", said)
+
+ def test_the_x86_runtime_gets_its_own_steam_client_link(self):
+ self.fex_tool()
+ platform = self.runtime()
+ self.write(self.steam / "linuxarm64/steamclient.so", elf(183))
+ self.write(self.steam / "linux64/steamclient.so", elf(62))
+ self.write(self.steam / "linux32/steamclient.so", elf(3))
+ (self.home / ".steam").mkdir()
+ os.symlink(self.steam / "linuxarm64", self.home / ".steam/sdk64")
+ self.quiet(FEX["prepare"])
+ guest = platform / "files" / str(self.home).lstrip("/") / ".steam"
+ self.assertEqual(os.readlink(guest / "sdk64"), str(self.steam / "linux64"))
+ self.assertEqual(os.readlink(guest / "sdk32"), str(self.steam / "linux32"))
+ self.assertEqual(os.readlink(self.home / ".steam/sdk64"), str(self.steam / "linux64"))
+
def test_a_ready_install_is_prepared(self):
tool = self.fex_tool()
platform = self.runtime()
@@ -528,6 +559,20 @@ class LaunchTest(FexTestCase):
thunks = json.loads((self.home / ".local/share/droiddeck-fex/config/ThunksConfig.json").read_text())
self.assertEqual(thunks, {"ThunksDB": {"GL": 1, "Vulkan": 1}})
+ def test_host_libc_tunables_stay_on_the_host(self):
+ guest = FEX["guest_environment"]({"portable": True}, {"GLIBC_TUNABLES": "glibc.pthread.rseq=0"})
+ self.assertNotIn("GLIBC_TUNABLES", guest)
+
+ def test_proton_runs_in_the_runtime_its_manifest_requires(self):
+ self.fex_tool()
+ self.runtime()
+ platform = self.steamrt4()
+ proton = self.proton("4183110")
+ with mock.patch("os.execve") as emulated:
+ self.quiet(FEX["launch"], [str(proton), "waitforexitandrun", "game.exe"], {}, "on")
+ self.assertEqual(emulated.call_args[0][2]["FEX_ROOTFS"], str(platform / "files"))
+ self.assertEqual(FEX["required_runtime"](str(self.proton("1391110"))), "1628350")
+
def test_native_programs_run_directly(self):
program = self.write(self.tmp / "native", elf(183), 0o755)
with mock.patch("os.execvpe") as native, mock.patch("os.execve") as emulated:
From adcb04f62476c3036294c2f2f1db0acd88a10c97 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:19:10 -0400
Subject: [PATCH 023/156] fix: skip DirectAudio for Steam install scripts
(#1046)
---
.../overlay/usr/local/bin/steam-compatibility | 15 ++++++-
tools/tests/test_game_environment.py | 42 +++++++++++++++++--
2 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
index 242d21723..67a33b424 100755
--- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
+++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
@@ -243,6 +243,13 @@ bl_vacdiag() {
"""
BL_DIRECTAUDIO_SETUP = """
+bl_is_steam_installscript_target() {
+ local target=${1:-}
+ local steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam}
+ local helper="$steam_root/legacycompat/iscriptevaluator.exe"
+ [ -f "$helper" ] && [[ -n "$target" && "$target" -ef "$helper" ]]
+}
+
bl_directaudio() {
case "${verb:-}" in
run|waitforexitandrun|runinprefix) ;;
@@ -251,6 +258,10 @@ bl_directaudio() {
base=$1
[ -n "${BL_DIRECTAUDIO:-}" ] || return 0
[ -d "$BL_DIRECTAUDIO" ] || return 0
+ if bl_is_steam_installscript_target "${2:-}"; then
+ echo "droiddeck-proton: DirectAudio skipped for Steam's install-script evaluator" >&2
+ return 0
+ fi
version=$("$base/files/bin-arm64/wine" --version 2>/dev/null)
major=${version#wine-}
major=${major%%.*}
@@ -338,7 +349,7 @@ for name in %s; do
done
if [ -n "$depot" ]; then
bl_components "$depot"
- bl_directaudio "$depot"
+ bl_directaudio "$depot" "$@"
bl_game_cpus
bl_idtech3 "$@"
bl_vacdiag "$@"
@@ -393,7 +404,7 @@ if [ -n "${PROOT_FP_KEY:-}" ] && [ -f "$audit" ]; then
esac
fi
bl_components "$here"
-bl_directaudio "$here"
+bl_directaudio "$here" "$@"
bl_game_cpus
bl_idtech3 "$@"
bl_vacdiag "$@"
diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py
index 9c8a98c20..6757d46d6 100644
--- a/tools/tests/test_game_environment.py
+++ b/tools/tests/test_game_environment.py
@@ -434,6 +434,8 @@ class GameEnvironmentTest(unittest.TestCase):
self.assertEqual(changes, {"42": COMPAT["TOOL"]})
def test_both_proton_wrappers_call_environment_launcher(self):
+ self.assertIn('bl_directaudio "$depot" "$@"', COMPAT["LAUNCHER_SH"])
+ self.assertIn('bl_directaudio "$here" "$@"', COMPAT["EXTRA_WRAPPER_SH"] % "proton")
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', script)
@@ -849,12 +851,15 @@ class DirectAudioPrefixTest(unittest.TestCase):
(self.windows / folder).mkdir(parents=True)
self.reg = self.compat / "pfx/user.reg"
- def run_setup(self, verb="waitforexitandrun"):
- script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n'
- env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat))
+ def run_setup(self, verb="waitforexitandrun", target="game.exe", cwd=None):
+ script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1" "$3"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n'
+ steam = self.compat.parent / "Steam"
+ env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat),
+ STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam))
env.pop("WINEDLLPATH", None)
env.pop("WINE_AUDIO_DRIVER", None)
- return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb], env=env, capture_output=True, text=True, check=True)
+ return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb, target], cwd=cwd, env=env,
+ capture_output=True, text=True, check=True)
def audio_values(self):
found, current = [], None
@@ -869,6 +874,35 @@ class DirectAudioPrefixTest(unittest.TestCase):
path = self.windows / folder / "winedirectaudio.drv"
return os.readlink(path) if path.is_symlink() else None
+ def test_steam_installscript_evaluator_does_not_change_prefix_audio(self):
+ legacycompat = self.compat.parent / "Steam/legacycompat"
+ legacycompat.mkdir(parents=True)
+ (legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
+ self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
+
+ result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat)
+
+ self.assertIn("DirectAudio skipped for Steam's install-script evaluator", result.stderr)
+ self.assertIn("WINEDLLPATH=\n", result.stdout)
+ self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
+ self.assertEqual(self.reg.read_text(), "WINE REGISTRY Version 2\n\n#arch=win64\n")
+ self.assertIsNone(self.link("system32"))
+ self.assertIsNone(self.link("syswow64"))
+
+ def test_same_basename_outside_steam_legacycompat_keeps_normal_setup(self):
+ legacycompat = self.compat.parent / "Steam/legacycompat"
+ legacycompat.mkdir(parents=True)
+ (legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
+ other = self.compat.parent / "other"
+ other.mkdir()
+ (other / "iscriptevaluator.exe").write_bytes(b"unrelated executable")
+ self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
+
+ result = self.run_setup(target="iscriptevaluator.exe", cwd=other)
+
+ self.assertIn("DirectAudio selected", result.stderr)
+ self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
+ self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
def test_non_launch_verbs_preserve_audio_configuration(self):
for verb in ("getcompatpath", "getnativepath", "destroyprefix"):
with self.subTest(verb=verb):
From aa6fae192496be37f95b8210bfb91968ec783452 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:52:30 -0400
Subject: [PATCH 024/156] Fix intermittent CI package updates and escaped test
worker (#1151)
Fix escaped cloud-save test worker and CI package updates
---
.github/workflows/build.yml | 3 ++-
.../java/com/droiddeck/launcher/stores/CloudSaves.kt | 12 ++++++++----
.../com/droiddeck/launcher/stores/CloudSavesTest.kt | 7 +++++--
3 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5b1af9a2d..25ef8cc09 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -192,7 +192,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
- sudo apt-get update -qq
+ sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
+ -o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
index ccfd221ac..7c00981b2 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
@@ -13,6 +13,7 @@ import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
+import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.json.JSONObject
@@ -150,19 +151,22 @@ object CloudSaves {
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
- fun downloadOrDefer(context: Context, store: Store, id: String): Result {
+ fun downloadOrDefer(context: Context, store: Store, id: String): Result =
+ downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() })
+
+ internal fun downloadOrDefer(context: Context, store: Store, id: String, executor: Executor): Result {
val app = context.applicationContext
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
- Thread({
+ executor.execute {
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
while (System.currentTimeMillis() < end) {
- if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
+ if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@execute }
Thread.sleep(2000)
}
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
- }, "cloud-deferred").start()
+ }
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt
index 023013ed5..0c3044743 100644
--- a/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/stores/CloudSavesTest.kt
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.stores
import java.io.File
+import java.util.concurrent.Executor
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -11,7 +12,7 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [33])
+@Config(sdk = [33], application = android.app.Application::class)
class CloudSavesTest {
private val app get() = RuntimeEnvironment.getApplication()
@@ -83,9 +84,11 @@ class CloudSavesTest {
}
@Test fun aFirstLaunchWithoutAPrefixIsDeferredNotBlocked() {
+ val deferred = ArrayList()
val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "ELDERBORN").apply { mkdirs() }
File(folder, "ELDERBORN.exe").writeText("MZ")
StoreGameSidecar(Store.GOG, "1207664643", "ELDERBORN", "ELDERBORN.exe").write(folder)
- assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643").result)
+ assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643", Executor { deferred += it }).result)
+ assertEquals(1, deferred.size)
}
}
From 8e02904a049281deab6af1f3fe366d854615672b Mon Sep 17 00:00:00 2001
From: The412Banner <205237651+The412Banner@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:43:03 -0400
Subject: [PATCH 025/156] fix: adding a games folder on an SD card found
nothing (#1153)
Adding a folder on an SD card outside the Steam library added nothing and said
"Nothing new in this folder": every game's exe had no guest path, so each one
was skipped. Since #542 nothing created the game-folder binds that let older
builds use such folders; only migrated ones remained.
addFolder now binds the picked folder under /root/Games (named as
GamesFolderMigration names them, with a suffix when the name is taken) when the
session cannot see it, then adds its games. The bind is mounted from the next
session start.
---
.../droiddeck/launcher/frontend/AddedGames.kt | 16 +++++++
.../launcher/frontend/AddedExesTest.kt | 45 +++++++++++++++++++
2 files changed, 61 insertions(+)
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
index f0808edab..0c72812d1 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
@@ -243,6 +243,8 @@ object AddedGames {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
val app = context.applicationContext
+ // Binds are mounted at session start, so a new one reaches the session from the next start.
+ if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
var already = 0
@@ -266,6 +268,20 @@ object AddedGames {
return FolderResult(added, already)
}
+ @Synchronized
+ internal fun bindFolder(context: Context, dir: File) {
+ if (!dir.isDirectory || !dir.canRead()) return
+ val binds = SessionPrefs.gameFolderBinds(context)
+ if (binds.any { it.first == dir.path }) return
+ val taken = binds.map { it.second }.toSet()
+ val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest
+ var guest = base
+ var n = 2
+ while (guest in taken) guest = "$base-${n++}"
+ SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest))
+ Log.i(TAG, "games: bound $dir at $guest")
+ }
+
private fun listedFolders(context: Context, removed: Set): List {
val out = ArrayList()
GamesFolderMigration.run(context)
diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt
index 2d0a29de4..6cc36e909 100644
--- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt
@@ -4,6 +4,7 @@ import android.os.Environment
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -92,6 +93,50 @@ class AddedExesTest {
assertEquals(0, AddedGames.scan(app).size)
}
+ @Test fun addingAFolderTheSessionCannotSeeBindsItAndAddsItsGames() {
+ noGamesFolders()
+ val games = File(tmp.newFolder("card"), "Winlator/Games")
+ exe(File(games, "Alpha/Alpha.exe"))
+ exe(File(games, "Beta/Beta.exe"))
+ File(games, "steamapps").mkdirs()
+ assertNull(AddedGames.guestPath(app, games))
+
+ val result = AddedGames.addFolder(app, games)
+ assertEquals(listOf("Alpha", "Beta"), result.added.map { it.name })
+ assertEquals(listOf(games.path to "${AddedGames.GUEST_DIR}/Games"), SessionPrefs.gameFolderBinds(app))
+ assertEquals("${AddedGames.GUEST_DIR}/Games/Alpha/Alpha.exe", result.added.first().guestExe)
+
+ val again = AddedGames.addFolder(app, games)
+ assertEquals(0, again.added.size)
+ assertEquals(2, again.already)
+ assertEquals(1, SessionPrefs.gameFolderBinds(app).size)
+ }
+
+ @Test fun aSecondFolderWithTheSameNameGetsItsOwnGuestPath() {
+ noGamesFolders()
+ val first = File(tmp.newFolder("a"), "Games").apply { exe(File(this, "One/One.exe")) }
+ val second = File(tmp.newFolder("b"), "Games").apply { exe(File(this, "Two/Two.exe")) }
+ AddedGames.addFolder(app, first)
+ AddedGames.addFolder(app, second)
+ val guests = SessionPrefs.gameFolderBinds(app).map { it.second }
+ assertEquals(2, guests.toSet().size)
+ assertEquals("${AddedGames.GUEST_DIR}/Games", guests.first())
+ }
+
+ @Test fun aGuestPathAlreadyTakenIsNeverReused() {
+ noGamesFolders()
+ val a = File(tmp.newFolder("a"), "Games").apply { mkdirs() }
+ val b = File(tmp.newFolder("b"), "Games").apply { mkdirs() }
+ val c = File(tmp.newFolder("c"), "Games").apply { exe(File(this, "Three/Three.exe")) }
+ val clash = GamesFolderMigration.legacyRoots(listOf(a.path, b.path, c.path)).last().guest
+ SessionPrefs.setGameFolderBinds(app, listOf(a.path to "${AddedGames.GUEST_DIR}/Games", b.path to clash))
+ AddedGames.addFolder(app, c)
+ val guests = SessionPrefs.gameFolderBinds(app).map { it.second }
+ assertEquals(3, guests.size)
+ assertEquals(3, guests.toSet().size)
+ assertEquals("$clash-2", guests.last())
+ }
+
@Test fun aRemovedSingleExeGameStaysOffUntilPickedAgain() {
noGamesFolders()
val pick = onStorage("Download/Celeste/Celeste.exe")
From a63f3b1fbc0ca8809b36fef71c0324a5728a14f1 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Mon, 19 Oct 2026 18:44:10 -0400
Subject: [PATCH 026/156] Add experimental Xbox and PC Game Pass store support
(#1156)
---
.github/workflows/build.yml | 46 +-
app/build.gradle | 1 +
app/src/main/AndroidManifest.xml | 4 +
.../main/java/com/droiddeck/launcher/App.kt | 5 +
.../launcher/session/SessionFiles.kt | 5 +-
.../droiddeck/launcher/stores/CloudSaves.kt | 6 +-
.../droiddeck/launcher/stores/LoginFlows.kt | 2 +-
.../com/droiddeck/launcher/stores/Store.kt | 1 +
.../launcher/stores/StoreAccounts.kt | 12 +-
.../droiddeck/launcher/stores/StoreCatalog.kt | 6 +-
.../droiddeck/launcher/stores/StoreLaunch.kt | 9 +
.../launcher/stores/StoreLaunchRequests.kt | 7 +
.../droiddeck/launcher/stores/StoreSizes.kt | 8 +-
.../droiddeck/launcher/stores/StoresState.kt | 16 +-
.../launcher/stores/download/SavedQueue.kt | 4 +-
.../launcher/stores/xbox/XboxBackend.kt | 146 ++++
.../launcher/stores/xbox/XboxCatalog.kt | 107 +++
.../launcher/stores/xbox/XboxContent.kt | 105 +++
.../launcher/stores/xbox/XboxCredentials.kt | 86 +++
.../launcher/stores/xbox/XboxInstaller.kt | 104 +++
.../launcher/stores/xbox/XboxLibraryCache.kt | 57 ++
.../launcher/stores/xbox/XboxLoginActivity.kt | 182 +++++
.../launcher/stores/xbox/XboxManifest.kt | 70 ++
.../launcher/stores/xbox/XboxRuntime.kt | 46 ++
.../droiddeck/launcher/stores/xbox/Xodus.kt | 106 +++
.../com/droiddeck/launcher/ui/StoreChips.kt | 2 +
.../com/droiddeck/launcher/ui/StoresDetail.kt | 23 +-
.../com/droiddeck/launcher/ui/StoresPage.kt | 68 +-
.../stores/StoreAccountsCipherTest.kt | 19 +
.../launcher/stores/StoreLaunchTest.kt | 56 ++
.../launcher/stores/xbox/XboxCatalogTest.kt | 77 ++
.../stores/xbox/XboxLibraryCacheTest.kt | 53 ++
.../launcher/stores/xbox/XboxManifestTest.kt | 55 ++
docs/development/xbox-store.md | 106 +++
tools/build_local.sh | 7 +
.../overlay/usr/local/bin/droiddeck-game-env | 14 +
.../overlay/usr/local/bin/droiddeck-session | 9 +-
.../overlay/usr/local/bin/droiddeck-xbox | 183 +++++
tools/release/runtime_inputs.py | 5 +-
tools/tests/test_game_environment.py | 11 +
tools/tests/test_steam_cold_launch.py | 27 +-
tools/tests/test_xbox_launch.py | 73 ++
tools/xbox/LICENSE.LGPL-2.1 | 502 +++++++++++++
tools/xbox/LICENSE.nexus | 21 +
tools/xbox/LICENSE.nghttp2 | 23 +
tools/xbox/LICENSE.xodus | 674 ++++++++++++++++++
tools/xbox/README.md | 79 ++
tools/xbox/backend.lock.json | 109 +++
tools/xbox/build-local.sh | 18 +
tools/xbox/build.sh | 37 +
tools/xbox/droiddeck-credentials.patch | 154 ++++
tools/xbox/droiddeck-launch-view.patch | 86 +++
tools/xbox/prepare.py | 36 +
tools/xbox/runtime-probe.c | 23 +
tools/xbox/runtime.lock.json | 17 +
tools/xbox/runtime.py | 59 ++
56 files changed, 3720 insertions(+), 47 deletions(-)
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt
create mode 100644 docs/development/xbox-store.md
create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox
create mode 100644 tools/tests/test_xbox_launch.py
create mode 100644 tools/xbox/LICENSE.LGPL-2.1
create mode 100644 tools/xbox/LICENSE.nexus
create mode 100644 tools/xbox/LICENSE.nghttp2
create mode 100644 tools/xbox/LICENSE.xodus
create mode 100644 tools/xbox/README.md
create mode 100644 tools/xbox/backend.lock.json
create mode 100755 tools/xbox/build-local.sh
create mode 100755 tools/xbox/build.sh
create mode 100644 tools/xbox/droiddeck-credentials.patch
create mode 100644 tools/xbox/droiddeck-launch-view.patch
create mode 100755 tools/xbox/prepare.py
create mode 100644 tools/xbox/runtime-probe.c
create mode 100644 tools/xbox/runtime.lock.json
create mode 100755 tools/xbox/runtime.py
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 25ef8cc09..46fca6d7c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -29,7 +29,46 @@ env:
PRELOAD_CACHE_VERSION: v6
jobs:
+ xbox:
+ name: Xbox ARM64 backend
+ if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ - uses: actions/cache@v4
+ id: xbox-cache
+ with:
+ path: |
+ app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
+ app/build/xbox/xodus-droiddeck-source.tar.xz
+ app/build/xbox/runtime/*-source.tar.xz
+ key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
+ - name: Build Xbox backend
+ if: steps.xbox-cache.outputs.cache-hit != 'true'
+ run: |
+ rustup toolchain install 1.99.0 --profile minimal
+ sudo apt-get update -qq
+ sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
+ bash tools/xbox/build.sh
+ - uses: actions/upload-artifact@v4
+ with:
+ name: xbox-arm64
+ path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
+ retention-days: 14
+ - uses: actions/upload-artifact@v4
+ with:
+ name: xbox-corresponding-source
+ path: |
+ app/build/xbox/xodus-droiddeck-source.tar.xz
+ app/build/xbox/runtime/*-source.tar.xz
+ if-no-files-found: error
+ retention-days: 14
+
build:
+ needs: xbox
name: Build APK
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
@@ -184,7 +223,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
- key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
+ key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -464,6 +503,11 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
+ - uses: actions/download-artifact@v4
+ with:
+ name: xbox-arm64
+ path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
+
- name: Build GOG Galaxy companion
if: steps.preloads.outputs.cache-hit != 'true'
run: |
diff --git a/app/build.gradle b/app/build.gradle
index b38e1b35a..2e269ac50 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -318,6 +318,7 @@ dependencies {
implementation 'com.termux.termux-app:terminal-view:0.118.0'
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
+ implementation 'androidx.webkit:webkit:1.12.1'
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 095abb5bb..87768b8f7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -147,6 +147,10 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
+
+
= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
+ if (process == "$packageName:xboxlogin") {
+ if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
+ return
+ }
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
@@ -36,5 +40,6 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
+ com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index 681287c86..46d84c50a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -66,6 +66,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
+ "usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
@@ -133,7 +134,9 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
- ) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
+ ) + listOf("xodus-cli", "xodus-service", "LICENSE.xodus", "LICENSE.nexus", "LICENSE.LGPL-2.1", "LICENSE.nghttp2", "backend.lock.json", "runtime.lock.json", "version",
+ "windows/xgameruntime.dll", "windows/winhttp.dll", "windows/windows.web.dll", "windows/twinapi.appcore.dll", "windows/libnghttp2.dll")
+ .map { "usr/local/lib/droiddeck-xbox/$it" to "usr/local/lib/droiddeck-xbox/$it" } + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
index 7c00981b2..669a13eee 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
@@ -58,7 +58,7 @@ object CloudSaves {
val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
}
- Store.AMAZON, Store.ITCH -> ""
+ Store.AMAZON, Store.ITCH, Store.XBOX -> ""
} ?: return null
p.edit().putString("cloud_template_$id", fetched).apply()
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
@@ -96,7 +96,7 @@ object CloudSaves {
val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
- Store.AMAZON, Store.ITCH -> null
+ Store.AMAZON, Store.ITCH, Store.XBOX -> null
}
return dir to (if (dir == null) "unresolved" else "ok")
}
@@ -121,7 +121,7 @@ object CloudSaves {
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
}
- Store.AMAZON, Store.ITCH -> null
+ Store.AMAZON, Store.ITCH, Store.XBOX -> null
}
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
index 43517ebfe..be1b29ed0 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt
@@ -12,7 +12,7 @@ internal object LoginFlows {
Store.GOG -> GogFlow(state)
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
- Store.ITCH -> null
+ Store.ITCH, Store.XBOX -> null
}
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
index d7cc814cb..b5e0d1fbf 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt
@@ -4,6 +4,7 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
GOG("gog", "GOG", "GOG"),
EPIC("epic", "Epic Games", "Epic"),
AMAZON("amazon", "Amazon Games", "Amazon"),
+ XBOX("xbox", "Xbox", "Xbox"),
ITCH("itch", "itch.io", "itch");
companion object {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt
index 6b5f30186..27bf133f1 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt
@@ -67,7 +67,10 @@ object StoreAccounts {
fun write(context: Context, store: Store, json: JSONObject) {
unavailable.remove(store)
val f = credentialsFile(context, store)
- if (seal(f, json) == null) put(f, json.toString())
+ if (seal(f, json, retry = store == Store.XBOX) == null) {
+ check(store != Store.XBOX) { "Android Keystore is unavailable for Xbox credentials" }
+ put(f, json.toString())
+ }
}
@Synchronized
@@ -86,18 +89,19 @@ object StoreAccounts {
fun signedInAs(context: Context, store: Store): String? {
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
+ if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() }
if (json.optString("access_token", "").isEmpty()) return null
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
- private fun seal(f: File, json: JSONObject): File? {
- if (keystoreFailed) return null
+ private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? {
+ if (keystoreFailed && !retry) return null
val text = json.toString()
val envelope = try {
CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } }
} catch (e: Exception) {
keystoreFailed = true
- Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})")
+ Log.w(TAG, "stores: keystore unavailable (${e.javaClass.simpleName})")
return null
}
put(f, envelope.toString())
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt
index 049e2b926..6f4678c7f 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt
@@ -21,7 +21,9 @@ data class CatalogItem(
val sizeBytes: Long = 0L,
val extra: Map = emptyMap(),
val mature: Boolean = false,
+ val subscription: Boolean = false,
) {
+ val accessible: Boolean get() = owned || subscription
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
val key: String get() = "${store.id}:$id"
}
@@ -49,7 +51,9 @@ fun installedCards(store: Store, installed: List, library: L
return installed.filter { it.sidecar.store == store }.map { game ->
val s = game.sidecar
byId[s.id] ?: byTitle[s.title.lowercase()]
- ?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
+ ?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero,
+ owned = s.store != Store.XBOX || s.extra["access"] == "owned",
+ subscription = s.store == Store.XBOX && s.extra["access"] == "gamepass")
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt
index 4486df81e..e17aac578 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
+import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import java.io.File
@@ -63,6 +64,14 @@ object StoreLaunch {
private const val CODE_VAR = "DD_X"
private const val CLIENT_VAR = "DD_A"
+ fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? {
+ val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder ->
+ StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true
+ } ?: return null
+ val game = AddedGames.single(context, folder.path) ?: return null
+ return Library.addedGame(context, game)
+ }
+
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
if (sidecar.launcher != LAUNCHER) return
val file = File(folder, LAUNCHER)
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt
index 4005ff2b3..909c0b7fc 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt
@@ -37,6 +37,7 @@ object StoreLaunchRequests {
}
fun stop() {
+ Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -50,6 +51,12 @@ object StoreLaunchRequests {
return
}
val reply = when (request.optString("op")) {
+ "xbox-released" -> {
+ com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id"))
+ JSONObject().put("ok", true)
+ }
+ "xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
+ .getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
"epic-code" -> {
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
index 01014dbd7..b9b9354b7 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt
@@ -31,7 +31,7 @@ object StoreSizes {
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
fun request(context: Context, item: CatalogItem) {
- if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
+ if (!item.accessible || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
resolve(context, item).thenAccept { bytes ->
if (bytes != null) StoresState.post { known[item.key] = bytes }
}
@@ -39,7 +39,7 @@ object StoreSizes {
fun resolve(context: Context, item: CatalogItem): CompletableFuture {
val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 }
- if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
+ if (!item.accessible || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
if (available != null) return CompletableFuture.completedFuture(available)
val app = context.applicationContext
val version = item.extra["version"].orEmpty()
@@ -55,7 +55,7 @@ object StoreSizes {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.ITCH -> {}
- Store.GOG -> {}
+ Store.GOG, Store.XBOX -> {}
}
}
}, pool).exceptionally { error ->
@@ -108,7 +108,7 @@ object StoreSizes {
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
- Store.GOG -> 0L
+ Store.GOG, Store.XBOX -> 0L
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
} }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
index 63cc4a4cf..4aa57757c 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
@@ -20,6 +20,8 @@ object StoresState {
private val main = Handler(Looper.getMainLooper())
val accounts = mutableStateMapOf()
+ var accountsLoaded by mutableStateOf(false)
+ private set
val library = mutableStateMapOf>()
@@ -84,11 +86,15 @@ object StoresState {
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
main.post {
- accounts.clear()
- found.forEach { (store, name) -> accounts[store] = name }
- installed = onDisk
- unfinished = partial
- engine = version
+ androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot {
+ val restored = found.toMap()
+ accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) }
+ accounts.putAll(restored)
+ installed = onDisk
+ unfinished = partial
+ engine = version
+ accountsLoaded = true
+ }
}
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt
index 0c9fd0ccb..5a5eae4fa 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/download/SavedQueue.kt
@@ -78,7 +78,7 @@ object SavedQueue {
put("store", i.store.id); put("id", i.id); put("title", i.title)
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
- put("size", i.sizeBytes); put("owned", i.owned)
+ put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription)
put("extra", JSONObject(i.extra))
}
@@ -89,7 +89,7 @@ object SavedQueue {
id = o.getString("id"), title = o.getString("title"),
imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null },
tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"),
- owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"),
+ owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"),
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt
new file mode 100644
index 000000000..2eaa59080
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt
@@ -0,0 +1,146 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.stores.*
+import com.droiddeck.launcher.stores.download.DownloadEntry
+import com.droiddeck.launcher.stores.download.DownloadQueue
+import java.io.File
+
+object XboxBackend : StoreBackend {
+ override val store = Store.XBOX
+ @Volatile private var access: Map = emptyMap()
+ @Volatile private var generation = 0
+ private var syncing: Int? = null
+
+ override fun signIn(context: Context) {
+ context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+
+ @Synchronized override fun signOut(context: Context) {
+ generation++
+ XboxCredentials.invalidate(context)
+ XboxLibraryCache.clear(context)
+ XboxRuntime.stop()
+ access = emptyMap()
+ StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
+ }
+
+ @Synchronized override fun syncLibrary(context: Context, force: Boolean) {
+ val app = context.applicationContext
+ val current = generation
+ if (syncing == current) return
+ syncing = current
+ Thread({
+ try {
+ val account = StoreAccounts.signedInAs(app, store) ?: error("Sign in to Xbox first.")
+ val market = Xodus.market()
+ val cached = XboxLibraryCache.read(app, account, market)
+ synchronized(this) {
+ if (current != generation) return@Thread
+ if (cached != null) access = cached.games.associate { it.id to it.extra.getValue("access") }
+ }
+ StoresState.post {
+ if (current == generation) {
+ if (cached != null) StoresState.library[store] = mergeItems(StoresState.library[store], cached.games)
+ StoresState.problems.remove(store)
+ if (cached == null || force) StoresState.status[store] = "Syncing Xbox library…"
+ else StoresState.status.remove(store)
+ }
+ }
+ if (cached != null && !cached.needsRefresh(force)) return@Thread
+ val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market)
+ val games = XboxCatalog.products(licenses.keys.toList(), licenses, market).sortedBy { it.title.lowercase() }
+ synchronized(this) {
+ if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread
+ access = licenses
+ runCatching { XboxLibraryCache.write(app, account, market, games) }
+ .onFailure { StoresState.logLine("Xbox: library cache could not be saved") }
+ }
+ StoresState.post {
+ if (current == generation) {
+ StoresState.library[store] = mergeItems(StoresState.library[store], games)
+ StoresState.status.remove(store)
+ StoresState.problems.remove(store)
+ }
+ }
+ } catch (e: Exception) {
+ StoresState.post {
+ if (current == generation) {
+ StoresState.status.remove(store)
+ StoresState.problems[store] = e.message ?: "Xbox library could not be loaded."
+ }
+ }
+ } finally {
+ synchronized(this) { if (syncing == current) syncing = null }
+ }
+ }, "xbox-library").start()
+ }
+
+ override fun loadShelves(context: Context, force: Boolean) {
+ val current = generation
+ Thread({
+ runCatching { XboxCatalog.featured(access) }.onSuccess { games ->
+ if (current == generation) StoresState.post { StoresState.shelves[store] = StoreShelves(trending = games) }
+ }.onFailure { if (current == generation) StoresState.post { StoresState.problems[store] = "Microsoft Store could not be reached. Try again." } }
+ }, "xbox-store").start()
+ }
+
+ override fun search(context: Context, query: String): List = XboxCatalog.search(query, access)
+
+ class PackageChoice(val item: CatalogItem, val root: File, val choices: List)
+ var packageChoice by androidx.compose.runtime.mutableStateOf(null)
+ private set
+
+ fun dismissPackages() { packageChoice = null }
+ fun selectPackage(context: Context, choice: org.json.JSONObject) {
+ val pending = packageChoice ?: return
+ packageChoice = null
+ val item = pending.item.copy(extra = pending.item.extra + ("contentId" to choice.getString("contentId")))
+ com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, pending.root)
+ enqueue(context.applicationContext, item, pending.root)
+ }
+
+ override fun install(context: Context, item: CatalogItem, root: File) {
+ val app = context.applicationContext
+ if (!item.extra["baseId"].isNullOrBlank()) { XboxContent.install(app, item); return }
+ if (!item.extra["contentId"].isNullOrBlank()) { enqueue(app, item, root); return }
+ Thread({
+ try {
+ val plan = Xodus.run(app, listOf("resolve-package", item.id, "--json", "--market", item.extra["market"] ?: Xodus.market()))
+ require(plan.getInt("schema") == 1 && plan.getString("rootProductId") == item.id)
+ val choices = XboxCatalog.objects(plan.getJSONArray("choices")).filter { it.optBoolean("supported") && it.optString("kind") == "Game" }
+ check(choices.isNotEmpty()) { "This offer has no supported PC package." }
+ if (plan.getString("status") == "ready" && choices.size == 1) {
+ val selected = item.copy(extra = item.extra + ("contentId" to choices.single().getString("contentId")))
+ com.droiddeck.launcher.stores.download.SavedQueue.remember(app, selected, root)
+ enqueue(app, selected, root)
+ } else StoresState.post { packageChoice = PackageChoice(item, root, choices) }
+ } catch (e: Exception) {
+ DownloadQueue.recordFailed(app, DownloadEntry(store, item.id, item.title), e.message ?: "Xbox package could not be resolved.") { install(app, item, root) }
+ }
+ }, "xbox-package").start()
+ }
+
+ fun update(context: Context, game: InstalledStoreGame) {
+ check(!XboxRuntime.isActive(game.sidecar.id)) { "Close this Xbox game before updating it." }
+ val sidecar = game.sidecar
+ val item = CatalogItem(store, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover,
+ owned = sidecar.extra["access"] == "owned", subscription = sidecar.extra["access"] == "gamepass", extra = sidecar.extra)
+ val root = game.folder.parentFile!!.parentFile!!
+ com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root)
+ enqueue(context, item, root)
+ }
+
+ private fun enqueue(context: Context, item: CatalogItem, root: File) {
+ val app = context.applicationContext
+ val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
+ val entry = DownloadEntry(store, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes,
+ location = StoreInstallRoot.labelFor(app, folder))
+ DownloadQueue.enqueue(app, entry) { XboxInstaller(app, item, folder) }
+ }
+
+ override fun uninstall(context: Context, game: InstalledStoreGame) {}
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt
new file mode 100644
index 000000000..bb3fab4a2
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt
@@ -0,0 +1,107 @@
+package com.droiddeck.launcher.stores.xbox
+
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreNet
+import org.json.JSONArray
+import org.json.JSONObject
+import java.time.Instant
+
+internal object XboxCatalog {
+ private val productId = Regex("[A-Z0-9]{12}")
+ private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
+
+ fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now()): Map {
+ require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
+ val age = java.time.Duration.between(timestamp(json.getString("checkedAt")), now).seconds
+ require(age in -60..299)
+ val array = json.getJSONArray("entitlements")
+ require(array.length() <= 10_000)
+ val rows = LinkedHashMap()
+ val blocked = HashSet()
+ for (i in 0 until array.length()) {
+ val row = array.optJSONObject(i) ?: continue
+ val id = row.optString("productId")
+ if (!productId.matches(id)) continue
+ val valid = row.opt("status") is String && row.opt("isTrial") is Boolean && row.opt("special") is Boolean &&
+ row.opt("sharingSource") is String && runCatching { timestamp(row.getString("endDate")) }.isSuccess &&
+ (row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId")))
+ if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row
+ }
+ fun kind(id: String, visited: Set = emptySet()): String? {
+ if (id in blocked || id in visited || visited.size >= 16) return null
+ val row = rows[id] ?: return null
+ if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") ||
+ row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null
+ val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
+ if (parent == null) return if (id in plans) null else "owned"
+ if (parent in blocked) return null
+ if (parent in plans) return "gamepass"
+ return kind(parent, visited + id)
+ }
+ return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
+ }
+
+ private fun timestamp(value: String): Instant = java.time.OffsetDateTime.parse(value).toInstant()
+
+ fun products(ids: List, access: Map = emptyMap(), market: String = Xodus.market()): List {
+ return rawProducts(ids, market).mapNotNull { product(it, access) }
+ }
+
+ fun rawProducts(ids: List, market: String = Xodus.market()): List {
+ return ids.map { it.uppercase() }.filter { productId.matches(it) }.distinct().chunked(25).flatMap { batch ->
+ val body = get("https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${batch.joinToString(",")}&market=$market&languages=en-us,neutral")
+ objects(body.optJSONArray("Products")).filter { it.optString("ProductId").uppercase() in batch }
+ }
+ }
+
+ fun search(query: String, access: Map): List {
+ if (productId.matches(query.uppercase())) return products(listOf(query.uppercase()), access)
+ if (query.length !in 2..100) return emptyList()
+ val q = java.net.URLEncoder.encode(query, "UTF-8")
+ val body = get("https://apps.microsoft.com/api/products/search?query=$q&mediaType=games&age=all&price=all&category=all&subscription=all&gl=${Xodus.market()}&hl=en-us")
+ val ids = objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(60)
+ return products(ids, access)
+ }
+
+ fun featured(access: Map): List {
+ val body = get("https://apps.microsoft.com/api/Reco/GetComputedProductsList?listName=TopTrending&pgNo=1&noItems=12&filteredCategories=AllProducts&mediaType=Games&gl=${Xodus.market()}&hl=en-us&exp=0")
+ return products(objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(24), access)
+ }
+
+ internal fun product(json: JSONObject, access: Map): CatalogItem? {
+ val id = json.optString("ProductId").uppercase()
+ if (!productId.matches(id) || json.optString("ProductType") != "Game") return null
+ val localized = objects(json.optJSONArray("LocalizedProperties")).firstOrNull() ?: return null
+ val title = localized.optString("ProductTitle").takeIf { it.isNotBlank() } ?: return null
+ val packages = objects(json.optJSONArray("DisplaySkuAvailabilities")).flatMap {
+ objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages"))
+ }
+ val pc = packages.filter { pkg -> objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } }
+ if (pc.none { it.optString("PackageFormat").equals("MSIXVC", true) }) return null
+ val images = objects(localized.optJSONArray("Images"))
+ fun image(vararg kinds: String): String? = kinds.firstNotNullOfOrNull { purpose ->
+ images.firstOrNull { it.optString("ImagePurpose") == purpose }?.optString("Uri")?.let { raw ->
+ val url = if (raw.startsWith("//")) "https:$raw" else raw
+ runCatching { java.net.URI(url) }.getOrNull()?.takeIf {
+ it.scheme == "https" && it.host in setOf("store-images.s-microsoft.com", "store-images.microsoft.com") && it.userInfo == null && it.port == -1
+ }?.toString()
+ }
+ }
+ val kind = access[id]
+ return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"),
+ tallImageUrl = image("TitledPoster", "Poster", "BoxArt"),
+ owned = kind == "owned", subscription = kind == "gamepass",
+ storeUrl = "https://www.xbox.com/games/store/$id",
+ publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"),
+ description = localized.optString("ProductDescription"),
+ sizeBytes = pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L,
+ extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty()),
+ )
+ }
+
+ fun objects(array: JSONArray?): List = if (array == null) emptyList() else
+ (0 until array.length()).mapNotNull { array.optJSONObject(it) }
+
+ private fun get(url: String): JSONObject = JSONObject(StoreNet.get(url) ?: error("Microsoft Store could not be reached. Try again."))
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt
new file mode 100644
index 000000000..1594dee92
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxContent.kt
@@ -0,0 +1,105 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.stores.*
+import org.json.JSONObject
+import java.io.File
+
+internal object XboxContent {
+ data class Offer(val item: CatalogItem, val installed: Boolean)
+ data class Plan(val base: InstalledStoreGame, val offers: List, val error: String? = null)
+ var plan by mutableStateOf(null)
+ private set
+ fun dismiss() { plan = null }
+
+ fun manage(context: Context, base: InstalledStoreGame) {
+ val app = context.applicationContext
+ Thread({
+ try {
+ val manifest = XboxManifest.read(base.folder)
+ check(manifest.storeId.matches(Regex("[A-Z0-9]{12}"))) { "The package has no valid Store ID." }
+ val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox first.")
+ val market = base.sidecar.extra["market"] ?: Xodus.market()
+ val licenses = XboxCatalog.access(Xodus.run(app, listOf("library", "--json", "--market", market)), account, market)
+ val products = XboxCatalog.rawProducts(listOf(manifest.storeId) + manifest.related, market)
+ val baseProduct = products.singleOrNull { it.optString("ProductId") == manifest.storeId }
+ val families = packages(baseProduct).map { it.optString("PackageFamilyName") }.filter { it.startsWith(manifest.name + "_") }.toSet()
+ val offers = products.filter { it.optString("ProductId") in manifest.related && it.optString("ProductType") == "Durable" }.mapNotNull { product ->
+ val id = product.getString("ProductId")
+ val access = licenses[id] ?: return@mapNotNull null
+ val packages = packages(product).filter { it.optString("MainPackageFamilyNameForDlc") in families }
+ .distinctBy { it.optString("ContentId") }
+ if (packages.size != 1) return@mapNotNull null
+ val pkg = packages.single()
+ val cid = pkg.optString("ContentId")
+ if (!cid.matches(Regex("[0-9a-fA-F-]{36}"))) return@mapNotNull null
+ val localized = XboxCatalog.objects(product.optJSONArray("LocalizedProperties")).firstOrNull()
+ val item = CatalogItem(Store.XBOX, id, localized?.optString("ProductTitle")?.ifBlank { id } ?: id,
+ imageUrl = base.sidecar.cover, owned = access == "owned", subscription = access == "gamepass",
+ sizeBytes = pkg.optLong("MaxDownloadSizeInBytes"),
+ extra = mapOf("baseId" to base.sidecar.id, "contentId" to cid, "market" to market, "family" to pkg.getString("PackageFamilyName")))
+ Offer(item, File(base.folder, ".xbox-content/$id/.droiddeck-content.json").isFile)
+ }
+ StoresState.post { plan = Plan(base, offers) }
+ } catch (e: Exception) { StoresState.post { plan = Plan(base, emptyList(), e.message ?: "Xbox content could not be loaded.") } }
+ }, "xbox-content").start()
+ }
+
+ private fun packages(product: JSONObject?): List = XboxCatalog.objects(product?.optJSONArray("DisplaySkuAvailabilities"))
+ .filter { it.optJSONObject("Sku")?.optJSONObject("Properties")?.optBoolean("IsTrial", true) == false }
+ .flatMap { XboxCatalog.objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages")) }
+ .filter { pkg -> pkg.optString("PackageFormat") == "MSIXVC" &&
+ XboxCatalog.objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } }
+
+ private val parents = java.util.concurrent.ConcurrentHashMap()
+ fun busy(base: String): Boolean = StoresState.latestDownloads.any { it.isActive && parents[it.id] == base }
+
+ fun install(context: Context, item: CatalogItem) {
+ plan = null
+ val base = runCatching { base(context, item) }.getOrElse { error ->
+ StoresState.post { StoresState.problems[Store.XBOX] = error.message ?: "The base game is not available." }
+ return
+ }
+ if (XboxRuntime.isActive(base.sidecar.id)) {
+ StoresState.post { StoresState.problems[Store.XBOX] = "Close the Xbox game before changing its content." }
+ return
+ }
+ parents[item.id] = base.sidecar.id
+ val folder = File(base.folder, ".xbox-content/${item.id}")
+ if (folder.canonicalPath != base.folder.canonicalPath + "/.xbox-content/${item.id}") {
+ StoresState.post { StoresState.problems[Store.XBOX] = "The Xbox content folder contains a link outside this game." }
+ return
+ }
+ com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, base.folder.parentFile!!.parentFile!!)
+ com.droiddeck.launcher.stores.download.DownloadQueue.enqueue(context,
+ com.droiddeck.launcher.stores.download.DownloadEntry(Store.XBOX, item.id, item.title, cover = item.imageUrl, bytesTotal = item.sizeBytes)) {
+ XboxInstaller(context.applicationContext, item, folder)
+ }
+ }
+
+ fun base(context: Context, item: CatalogItem): InstalledStoreGame {
+ require(item.id.matches(Regex("[A-Z0-9]{12}")))
+ val id = item.extra["baseId"] ?: error("Missing Xbox base game")
+ val folder = StoreInstallRoot.gameFolders(context).firstOrNull { StoreGameSidecar.read(it)?.let { s -> s.store == Store.XBOX && s.id == id && s.isInstalled } == true }
+ ?: error("The Xbox base game is not installed.")
+ return InstalledStoreGame(StoreGameSidecar.read(folder)!!, folder)
+ }
+
+ fun complete(context: Context, item: CatalogItem, folder: File) {
+ val base = base(context, item)
+ val parent = XboxManifest.read(base.folder)
+ val content = XboxManifest.read(folder)
+ check(item.id in parent.related && content.storeId == item.id && content.dependency == parent.name && content.publisher == parent.publisher && item.extra["family"].orEmpty().startsWith(content.name + "_")) {
+ "The downloaded content does not belong to this Xbox game."
+ }
+ require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}")
+ val marker = File(folder, ".droiddeck-content.json")
+ val temporary = File(folder, marker.name + ".tmp")
+ temporary.writeText(JSONObject().put("productId", item.id).put("parent", base.sidecar.id)
+ .put("contentId", item.extra["contentId"]).put("version", content.version).toString())
+ check(temporary.renameTo(marker))
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt
new file mode 100644
index 000000000..14a2a8a89
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt
@@ -0,0 +1,86 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import android.net.LocalServerSocket
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreAccounts
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.Closeable
+import java.io.InputStream
+import java.util.UUID
+
+internal class XboxCredentials(private val app: Context) : Closeable {
+ val name = "droiddeck-xbox-${UUID.randomUUID()}"
+ private val generation = storage(app) { epoch(app) }
+ private val server = LocalServerSocket(name)
+ @Volatile private var closed = false
+
+ init {
+ Thread({
+ while (!closed) {
+ val socket = runCatching { server.accept() }.getOrNull() ?: break
+ socket.use {
+ it.soTimeout = 10_000
+ val reply = runCatching {
+ check(it.peerCredentials.uid == android.os.Process.myUid())
+ storage(app) {
+ check(epoch(app) == generation)
+ answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152)))
+ }
+ }.getOrElse { JSONObject().put("ok", false) }
+ runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) }
+ }
+ }
+ }, "xbox-credentials").start()
+ }
+
+ private fun answer(request: JSONObject): JSONObject {
+ val key = request.getString("key")
+ require(key.isNotEmpty() && key.length <= 512)
+ val record = StoreAccounts.read(app, Store.XBOX)
+ check(!StoreAccounts.isUnavailable(Store.XBOX))
+ val json = record ?: JSONObject()
+ val slots = json.optJSONObject("slots") ?: JSONObject().also { json.put("slots", it) }
+ val result = JSONObject().put("ok", true)
+ when (request.getString("op")) {
+ "get" -> result.put("value", slots.opt(key) ?: JSONObject.NULL)
+ "set" -> {
+ val bytes = request.getJSONArray("value")
+ require(bytes.length() <= 262_144)
+ for (i in 0 until bytes.length()) require(bytes.getInt(i) in 0..255)
+ slots.put(key, bytes)
+ StoreAccounts.write(app, Store.XBOX, json)
+ }
+ "remove" -> { slots.remove(key); StoreAccounts.write(app, Store.XBOX, json) }
+ else -> error("Invalid credential operation")
+ }
+ return result
+ }
+
+ override fun close() { closed = true; runCatching { server.close() } }
+
+ companion object {
+ val lock = Any()
+ private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("")
+ fun invalidate(app: Context) = storage(app) {
+ java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString())
+ StoreAccounts.clear(app, Store.XBOX)
+ }
+ fun storage(app: Context, block: () -> T): T = synchronized(lock) {
+ java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file ->
+ file.channel.lock().use { block() }
+ }
+ }
+ fun readLine(input: InputStream, limit: Int): String {
+ val out = java.io.ByteArrayOutputStream()
+ while (out.size() <= limit) {
+ val byte = input.read()
+ if (byte == 10) return out.toString("UTF-8")
+ if (byte < 0) throw java.io.EOFException("Xbox backend disconnected")
+ out.write(byte)
+ }
+ error("Xbox backend response exceeded its limit")
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt
new file mode 100644
index 000000000..44e9ceff3
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxInstaller.kt
@@ -0,0 +1,104 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import com.droiddeck.launcher.stores.*
+import com.droiddeck.launcher.stores.download.DownloadQueue
+import com.droiddeck.launcher.stores.download.DownloadStage
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+internal class XboxInstaller(private val app: Context, private val item: CatalogItem, private val folder: File) : DownloadQueue.DownloadJob {
+ private val cancelled = AtomicBoolean(false)
+ private val paused = CountDownLatch(1)
+ @Volatile private var child: Xodus.Child? = null
+ @Volatile private var delete = false
+
+ override fun run(handle: DownloadQueue.JobHandle): String? {
+ if (cancelled.get()) return null
+ check(StoreAccounts.signedInAs(app, Store.XBOX) != null) { "Sign in to Xbox before installing." }
+ val market = item.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
+ val selected = item.extra["contentId"].orEmpty()
+ require(selected.matches(Regex("[a-fA-F0-9-]{36}"))) { "Select a PC game package before installing." }
+ val content = !item.extra["baseId"].isNullOrBlank()
+ if (content) {
+ check(!XboxRuntime.isActive(item.extra["baseId"].orEmpty())) { "Close the base game before changing Xbox content." }
+ check(item.id in XboxManifest.read(XboxContent.base(app, item).folder).related) { "This content is not related to the base game." }
+ val base = XboxContent.base(app, item)
+ require(folder.canonicalPath == base.folder.canonicalPath + "/.xbox-content/${item.id}")
+ File(folder, ".droiddeck-content.json").delete()
+ folder.mkdirs()
+ } else {
+ check(!XboxRuntime.isActive(item.id)) { "Close this Xbox game before updating it." }
+ StoreInstalls.markUpdating(folder)
+ StoreInstalls.begin(folder, Store.XBOX, item.id, item.title, item.tallImageUrl, item.imageUrl)
+ }
+ var complete = false
+ var sequence = -1L
+ try {
+ val source = if (content) selected else item.id
+ val selection = if (content) emptyList() else listOf("--selected-content-id", selected)
+ Xodus.start(app, listOf("streaming", source, folder.path, "--market", market,
+ "--progress-json", "--control-stdin") + selection).use { process ->
+ child = process
+ val diagnostics = Xodus.diagnostics(process)
+ if (cancelled.get()) process.process.destroy()
+ process.process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line ->
+ if (line.startsWith("NEXUS_PROGRESS ")) {
+ val progress = JSONObject(line.removePrefix("NEXUS_PROGRESS "))
+ require(progress.getInt("schema") == 1 && progress.getLong("sequence") > sequence)
+ sequence = progress.getLong("sequence")
+ if (progress.optBoolean("paused")) paused.countDown()
+ when (progress.getString("phase")) {
+ "complete" -> complete = true
+ "downloading", "download", "transfer" -> handle.progress(progress.getLong("completedBytes"), progress.optLong("totalBytes"))
+ "finalizing", "installing" -> handle.stage(DownloadStage.INSTALL)
+ else -> handle.stage(DownloadStage.MANIFEST)
+ }
+ }
+ } }
+ check(process.process.waitFor(15, TimeUnit.SECONDS)) { "Xbox installer did not exit." }
+ diagnostics.join(2000)
+ if (cancelled.get()) return null
+ check(process.process.exitValue() == 0 && complete) { "Xbox download did not complete. Resume to retry with fresh licenses." }
+ }
+ if (content) {
+ XboxContent.complete(app, item, folder)
+ return folder.path
+ }
+ val exe = executable(folder)
+ StoreInstalls.complete(app, folder, StoreGameSidecar(Store.XBOX, item.id, item.title, exe,
+ installVersion = XboxManifest.read(folder).version, installedAt = System.currentTimeMillis(), cover = item.tallImageUrl, hero = item.imageUrl,
+ extra = item.extra + mapOf("contentId" to selected, "market" to market)))
+ return folder.path
+ } finally {
+ child = null
+ if (delete && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder)
+ else if (delete) StoreInstalls.discard(app, folder, emptyList())
+ }
+ }
+
+ override fun cancel(deleteFiles: Boolean) {
+ cancelled.set(true)
+ delete = deleteFiles
+ val process = child
+ if (process == null) {
+ if (deleteFiles && !item.extra["baseId"].isNullOrBlank()) StoreInstalls.deleteTree(folder)
+ else if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
+ return
+ }
+ Thread({
+ runCatching {
+ process.send(JSONObject().put("schema", 1).put("command", "pause"))
+ paused.await(3, TimeUnit.SECONDS)
+ }
+ process.close()
+ }, "xbox-download-stop").start()
+ }
+
+ companion object {
+ internal fun executable(folder: File): String = XboxManifest.executable(folder)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt
new file mode 100644
index 000000000..bf9e7ee70
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt
@@ -0,0 +1,57 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import org.json.JSONArray
+import org.json.JSONObject
+
+internal object XboxLibraryCache {
+ data class Snapshot(val games: List, val savedAt: Long) {
+ fun needsRefresh(force: Boolean, now: Long = System.currentTimeMillis()): Boolean =
+ force || now - savedAt !in 0 until 15 * 60 * 1000L
+ }
+
+ private fun prefs(app: Context) = app.getSharedPreferences("xbox", Context.MODE_PRIVATE)
+ fun read(app: Context, account: String, market: String): Snapshot? =
+ decode(prefs(app).getString("library", null), account, market)
+
+ fun write(app: Context, account: String, market: String, games: List) {
+ check(prefs(app).edit().putString("library", encode(account, market, games)).commit())
+ }
+
+ fun clear(app: Context) { prefs(app).edit().remove("library").commit() }
+
+ internal fun encode(account: String, market: String, games: List, now: Long = System.currentTimeMillis()): String =
+ JSONObject().put("schema", 1).put("account", account).put("market", market).put("savedAt", now)
+ .put("games", JSONArray().apply {
+ for (game in games) put(JSONObject().put("id", game.id).put("title", game.title)
+ .put("image", game.imageUrl).put("poster", game.tallImageUrl).put("description", game.description)
+ .put("developer", game.developer).put("publisher", game.publisher).put("size", game.sizeBytes)
+ .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none"))
+ }).toString()
+
+ internal fun decode(text: String?, account: String, market: String, now: Long = System.currentTimeMillis()): Snapshot? = runCatching {
+ require(text != null && text.length <= 8_000_000)
+ val json = JSONObject(text)
+ require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
+ val savedAt = json.getLong("savedAt")
+ require(savedAt > 0 && savedAt <= now + 60_000)
+ val rows = json.getJSONArray("games")
+ require(rows.length() <= 10_000)
+ val games = (0 until rows.length()).map { index ->
+ val row = rows.getJSONObject(index)
+ val id = row.getString("id")
+ val title = row.getString("title")
+ val access = row.getString("access")
+ require(id.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && access in setOf("owned", "gamepass"))
+ CatalogItem(Store.XBOX, id, title, row.optString("image").takeIf { it.isNotEmpty() },
+ tallImageUrl = row.optString("poster").takeIf { it.isNotEmpty() },
+ description = row.optString("description"), developer = row.optString("developer"), publisher = row.optString("publisher"),
+ sizeBytes = row.optLong("size"), owned = access == "owned", subscription = access == "gamepass",
+ storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access))
+ }
+ require(games.map { it.id }.distinct().size == games.size)
+ Snapshot(games, savedAt)
+ }.getOrNull()
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt
new file mode 100644
index 000000000..2192767fc
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt
@@ -0,0 +1,182 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.annotation.SuppressLint
+import android.graphics.Color
+import android.net.Uri
+import android.os.Bundle
+import android.webkit.CookieManager
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreAccounts
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+
+class XboxLoginActivity : ComponentActivity() {
+ private lateinit var host: LinearLayout
+ private lateinit var status: TextView
+ private var view: WebView? = null
+ @Volatile private var child: Xodus.Child? = null
+ @Volatile private var session = 0L
+ @Volatile private var closed = false
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ host = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) }
+ status = TextView(this).apply { setTextColor(Color.WHITE); text = "Preparing Microsoft sign-in…"; setPadding(24, 16, 24, 16) }
+ host.addView(status)
+ host.addView(Button(this).apply { text = getString(com.droiddeck.launcher.R.string.stores_login_close); setOnClickListener { finish() } })
+ setContentView(host)
+ if (android.os.Build.VERSION.SDK_INT < 28 || !WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ||
+ !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
+ status.text = "Update Android System WebView to sign in to Xbox."
+ return
+ }
+ CookieManager.getInstance().removeAllCookies { begin() }
+ }
+
+ private fun begin() {
+ if (closed) return
+ Thread({
+ if (closed) return@Thread
+ try {
+ Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process ->
+ child = process
+ if (closed) return@use
+ Xodus.diagnostics(process)
+ val timer = Thread({
+ runCatching { if (!process.process.waitFor(10, TimeUnit.MINUTES)) process.process.destroy() }
+ }, "xbox-login-timeout").apply { start() }
+ var ready = false
+ var latest = 0L
+ val input = process.process.inputStream.buffered()
+ while (!closed) {
+ val line = try { XboxCredentials.readLine(input, 131_072) } catch (e: java.io.EOFException) { break }
+ if (!line.startsWith("NEXUS_AUTH_V1 ")) continue
+ val frame = JSONObject(line.removePrefix("NEXUS_AUTH_V1 "))
+ when (frame.getString("type")) {
+ "ready" -> {
+ check(!ready && frame.getInt("version") == 1)
+ ready = true
+ process.send(JSONObject().put("type", "hello").put("version", 1))
+ }
+ "open" -> {
+ val next = frame.getLong("session")
+ check(ready && next > latest && next <= 32 && trusted(frame.getString("url")))
+ latest = next; session = next
+ runOnUiThread { if (!closed) open(frame) }
+ }
+ "close" -> {
+ check(frame.getLong("session") == session)
+ session = 0L
+ runOnUiThread { disposeView(); status.text = "Completing Microsoft sign-in…" }
+ }
+ "context" -> {
+ check(frame.getLong("session") == session)
+ val context = frame.getString("context")
+ check(context.length <= 4096)
+ val message = JSONObject().put("type", "callback").put("value", JSONObject()
+ .put("name", "CloudExperienceHost.getContext")
+ .put("args", org.json.JSONArray(listOf("CloudExperienceHost", "TokenBroker", "TokenBroker", "{\"PrivatePropertyBag\":1,\"PasswordlessConnect\":1,\"PreferAssociate\":1,\"ChromelessUI\":0}")))
+ .put("context", context))
+ val active = session
+ runOnUiThread { if (active == session && trusted(view?.url.orEmpty())) view?.evaluateJavascript(
+ "globalThis['CloudExperienceHost.Bridge.dispatchMessage'](${JSONObject.quote(message.toString())})", null) }
+ }
+ else -> error("Unsupported Xbox sign-in response")
+ }
+ }
+ check(process.process.waitFor(5, TimeUnit.SECONDS) && process.process.exitValue() == 0 && ready && latest > 0 && session == 0L)
+ timer.interrupt()
+ }
+ child = null
+ if (closed) return@Thread
+ val account = Xodus.run(applicationContext, listOf("account", "--json"))
+ check(account.optBoolean("stored"))
+ XboxCredentials.storage(this) {
+ val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing")
+ record.put("username", account.getString("username"))
+ StoreAccounts.write(this, Store.XBOX, record)
+ }
+ runOnUiThread { finish() }
+ } catch (e: Exception) {
+ child = null
+ if (!closed) runOnUiThread { disposeView(); status.text = "Microsoft sign-in did not complete. Close this window and try again." }
+ }
+ }, "xbox-login").start()
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun open(frame: JSONObject) {
+ disposeView()
+ val active = frame.getLong("session")
+ val web = WebView(this)
+ view = web
+ web.settings.apply {
+ javaScriptEnabled = true; domStorageEnabled = true
+ allowFileAccess = false; allowContentAccess = false
+ mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; MSAppHost/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.26100"
+ }
+ WebViewCompat.addWebMessageListener(web, "droiddeckXbox", origins) { _, message, origin, mainFrame, _ ->
+ val payload = message.data
+ if (mainFrame && trusted(origin.toString()) && trusted(web.url.orEmpty()) && active == session && payload != null && payload.toByteArray().size <= 65_536) {
+ runCatching { child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload)) }
+ }
+ }
+ WebViewCompat.addDocumentStartJavaScript(web,
+ "if(window===window.top){Object.defineProperty(globalThis.external,'notify',{value:p=>{if(typeof p==='string')droiddeckXbox.postMessage(p)},configurable:false,writable:false})}", origins)
+ web.webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = !trusted(request.url.toString())
+ override fun onPageFinished(view: WebView, url: String) {
+ if (!trusted(url)) { finish(); return }
+ status.text = "Microsoft sign-in"
+ val uri = Uri.parse(url)
+ if (uri.host == "login.live.com" && uri.path == "/ppsecure/post.srf") {
+ view.evaluateJavascript("JSON.stringify(globalThis.ServerData ?? null)") { raw ->
+ val payload = runCatching { org.json.JSONArray("[$raw]").getString(0) }.getOrNull()
+ if (active == session && payload != null && payload != "null" && payload.length <= 65_536) runCatching {
+ child?.send(JSONObject().put("type", "ipc").put("session", active).put("payload", payload))
+ }
+ }
+ }
+ }
+ }
+ host.addView(web, LinearLayout.LayoutParams(-1, 0, 1f))
+ val headers = frame.getJSONObject("headers")
+ val values = headers.keys().asSequence().associateWith { key ->
+ require(key in allowedHeaders)
+ headers.getString(key).also { value -> require(value.length <= 4096 && value.all { it in ' '..'~' }) }
+ }
+ web.loadUrl(frame.getString("url"), values)
+ }
+
+ private fun disposeView() { view?.let { host.removeView(it); it.stopLoading(); it.destroy() }; view = null }
+ override fun onDestroy() {
+ closed = true
+ val current = child; child = null
+ Thread({ runCatching { current?.close() } }, "xbox-login-close").start()
+ disposeView()
+ CookieManager.getInstance().removeAllCookies(null)
+ android.webkit.WebStorage.getInstance().deleteAllData()
+ super.onDestroy()
+ }
+
+ companion object {
+ private val hosts = setOf("login.live.com", "account.live.com", "login.microsoftonline.com", "login.microsoft.com", "account.microsoft.com", "sisu.xboxlive.com")
+ private val origins = hosts.map { "https://$it" }.toSet()
+ private val allowedHeaders = setOf("cxh-capabilities", "cxh-correlationid", "cxh-msabinaryversion", "cxh-identityclientbinaryversion", "cxh-osversioninfo", "cxh-platform", "cxh-protocol", "cxh-source", "hostapp")
+ internal fun trusted(value: String): Boolean = runCatching {
+ val url = java.net.URI(value)
+ url.scheme == "https" && url.host in hosts && url.port == -1 && url.userInfo == null
+ }.getOrDefault(false)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt
new file mode 100644
index 000000000..d738193ed
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxManifest.kt
@@ -0,0 +1,70 @@
+package com.droiddeck.launcher.stores.xbox
+
+import java.io.File
+import org.xmlpull.v1.XmlPullParser
+
+internal object XboxManifest {
+ data class Info(val storeId: String, val name: String, val publisher: String, val version: String,
+ val dependency: String, val related: List, val executables: List)
+
+ fun read(folder: File): Info {
+ val manifests = folder.listFiles().orEmpty().filter { it.name.equals("MicrosoftGame.config", true) && it.isFile }
+ require(manifests.size == 1) { "Missing or ambiguous MicrosoftGame.config." }
+ val manifest = manifests.single()
+ require(manifest.length() <= 1_048_576)
+ val parser = android.util.Xml.newPullParser().apply { setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false) }
+ val identities = ArrayList>()
+ val storeIds = ArrayList()
+ val dependencies = ArrayList()
+ val related = ArrayList()
+ val entries = ArrayList()
+ fun attribute(name: String) = parser.getAttributeValue(null, name).orEmpty()
+ var root = false
+ var elements = 0
+ manifest.inputStream().use { input ->
+ parser.setInput(input, null)
+ while (true) {
+ when (parser.nextToken()) {
+ XmlPullParser.END_DOCUMENT -> break
+ XmlPullParser.DOCDECL -> error("Xbox game manifests cannot contain a DTD.")
+ XmlPullParser.START_TAG -> {
+ require(++elements <= 10_000 && parser.depth <= 64) { "Xbox game manifest is too complex." }
+ if (parser.depth == 1) { require(!root && parser.name == "Game"); root = true }
+ when (parser.name) {
+ "Identity" -> identities.add(listOf(attribute("Name"), attribute("Publisher"), attribute("Version")))
+ "StoreId" -> storeIds.add(parser.nextText().trim())
+ "MainPackageDependency" -> dependencies.add(attribute("Name"))
+ "RelatedProduct" -> related.add(parser.nextText().trim())
+ "Executable" -> {
+ val path = attribute("Name").replace('\\', '/')
+ if (path.endsWith(".exe", true) && attribute("TargetDeviceFamily") in listOf("", "PC", "Windows.Desktop") &&
+ !attribute("IsDevOnly").equals("true", true)) entries.add(path)
+ }
+ }
+ }
+ }
+ }
+ }
+ require(root && identities.size <= 1 && storeIds.size <= 1 && dependencies.size <= 1) { "Invalid Xbox game manifest." }
+ val ids = related.distinct()
+ require(ids.size <= 128 && ids.all { it.matches(Regex("[A-Z0-9]{12}")) })
+ val identity = identities.singleOrNull()
+ return Info(storeIds.singleOrNull().orEmpty(), identity?.get(0).orEmpty(), identity?.get(1).orEmpty(), identity?.get(2).orEmpty(),
+ dependencies.singleOrNull().orEmpty(), ids, entries)
+ }
+
+ fun executable(folder: File): String {
+ for (path in read(folder).executables) {
+ require(!path.startsWith('/') && !path.contains(':') && path.split('/').none { it == ".." || it.isEmpty() })
+ var file = folder
+ for (part in path.split('/')) {
+ val matches = file.listFiles().orEmpty().filter { it.name.equals(part, true) }
+ require(matches.size == 1) { "Missing or ambiguous Xbox launch path." }
+ file = matches.single()
+ }
+ require(file.canonicalPath.startsWith(folder.canonicalPath + File.separator))
+ if (file.isFile) return file.relativeTo(folder).invariantSeparatorsPath
+ }
+ error("The Xbox package has no supported PC launch executable.")
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt
new file mode 100644
index 000000000..be4d0611b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt
@@ -0,0 +1,46 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import com.droiddeck.launcher.session.SessionState
+import com.droiddeck.launcher.stores.*
+import org.json.JSONObject
+import java.io.File
+
+internal object XboxRuntime {
+ private var child: Xodus.Child? = null
+ private var owner: String? = null
+ private val activeGames = HashMap()
+ @Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0
+ @Synchronized fun released(id: String) {
+ val count = activeGames.getOrDefault(id, 0)
+ if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1
+ }
+
+ @Synchronized fun prepare(app: Context, id: String): JSONObject {
+ check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" }
+ check(SessionState.running) { "No running DroidDeck session" }
+ check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" }
+ val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
+ val socket = File(app.filesDir, "xbox-runtime/xodus.sock")
+ if (child?.process?.isAlive != true || owner != account) {
+ stop()
+ socket.delete()
+ val service = Xodus.start(app, emptyList(), service = true)
+ child = service; owner = account
+ Xodus.diagnostics(service)
+ Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start()
+ val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
+ while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
+ if (!socket.exists() || !service.process.isAlive || !SessionState.running) {
+ stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.")
+ }
+ }
+ activeGames[id] = activeGames.getOrDefault(id, 0) + 1
+ return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket)
+ }
+
+ @Synchronized fun stop() {
+ val process = child; child = null; owner = null; activeGames.clear()
+ runCatching { process?.close() }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt
new file mode 100644
index 000000000..668ed9b3d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt
@@ -0,0 +1,106 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import android.os.Environment
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.session.SessionPrefs
+import org.json.JSONObject
+import java.io.Closeable
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+internal object Xodus {
+ const val DIR = "/usr/local/lib/droiddeck-xbox"
+ fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
+
+ class Child(val process: Process, private val credentials: XboxCredentials) : Closeable {
+ val credentialSocket: String get() = credentials.name
+ @Synchronized fun send(frame: JSONObject) {
+ process.outputStream.write((frame.toString() + "\n").toByteArray())
+ process.outputStream.flush()
+ }
+ override fun close() {
+ process.destroy()
+ if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
+ credentials.close()
+ }
+ }
+
+ @Synchronized private fun stage(app: Context) {
+ val root = File(LinuxRuntime.rootDir(app), DIR.removePrefix("/"))
+ val packaged = app.assets.open("linuxfs$DIR/version").bufferedReader().use { it.readText() }
+ if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return
+ fun copy(relative: String) {
+ val entries = app.assets.list("linuxfs$DIR/$relative").orEmpty()
+ if (entries.isNotEmpty()) { entries.forEach { copy(if (relative.isEmpty()) it else "$relative/$it") }; return }
+ if (relative == "version") return
+ val file = File(root, relative)
+ file.parentFile?.mkdirs()
+ val staged = File(file.path + ".staged")
+ app.assets.open("linuxfs$DIR/$relative").use { input -> staged.outputStream().use { input.copyTo(it) } }
+ staged.setExecutable(true, false)
+ check(staged.renameTo(file))
+ }
+ copy("")
+ File(root, "version").writeText(packaged)
+ }
+
+ fun start(app: Context, args: List, service: Boolean = false): Child {
+ check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." }
+ stage(app)
+ val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
+ check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
+ val credentials = XboxCredentials(app)
+ try {
+ val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() }
+ val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
+ "/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
+ "XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary,
+ ) + args)
+ val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app))
+ builder.environment().apply {
+ remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
+ put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
+ put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path)
+ if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
+ LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
+ }
+ return Child(builder.start(), credentials)
+ } catch (e: Exception) { credentials.close(); throw e }
+ }
+
+ fun run(app: Context, args: List, timeout: Long = 90): JSONObject {
+ start(app, args).use { child ->
+ val output = java.io.ByteArrayOutputStream()
+ var readError: Throwable? = null
+ val reader = Thread({
+ try {
+ val buffer = ByteArray(8192)
+ child.process.inputStream.use { input ->
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ check(output.size() + count <= 8_000_000)
+ output.write(buffer, 0, count)
+ }
+ }
+ } catch (t: Throwable) { readError = t; child.process.destroy() }
+ }, "xbox-result").apply { start() }
+ val diagnostic = diagnostics(child)
+ check(child.process.waitFor(timeout, TimeUnit.SECONDS)) { "Xbox request timed out. Try again." }
+ reader.join(3000); diagnostic.join(3000)
+ check(readError == null && !reader.isAlive) { "Xbox returned an invalid response." }
+ check(child.process.exitValue() == 0) { "Xbox request failed. Check your account and network connection." }
+ return JSONObject(output.toString("UTF-8"))
+ }
+ }
+
+ fun diagnostics(child: Child): Thread = Thread({
+ runCatching {
+ child.process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line ->
+ if (line.matches(Regex("NEXUS_CREDENTIAL_ERROR_V1 [a-z-]{1,48}|NEXUS_LIBRARY_DIAGNOSTIC_V1 [a-z-]{1,48} [0-9]{1,3} [0-9]{1,6}|NEXUS_SESSION_DIAGNOSTIC_V1 [a-z-]{1,64}|Account library unavailable \\([a-z-]{1,64}\\); credentials and installed games are unchanged\\.|Account library timed out; installed games are unchanged\\.")))
+ com.droiddeck.launcher.stores.StoresState.logLine("Xbox: $line")
+ } }
+ }
+ }, "xbox-diagnostics").apply { start() }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt
index 8a2222017..e913dc6af 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt
@@ -29,6 +29,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
+ Store.XBOX.id -> SourceColours(Color(0xFF17381B), Color(0xFFC6EDC2), Color(0xFF70C850))
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
@@ -41,6 +42,7 @@ internal fun sourceLabel(source: String): String = when (source) {
Store.GOG.id -> Store.GOG.label
Store.EPIC.id -> Store.EPIC.label
Store.AMAZON.id -> Store.AMAZON.label
+ Store.XBOX.id -> Store.XBOX.label
Store.ITCH.id -> Store.ITCH.label
else -> stringResource(R.string.source_added)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
index 151a487dc..8208cad30 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
@@ -97,7 +97,8 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location
- item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
+ item?.subscription == true -> "PC Game Pass"
+ item?.accessible == true -> stringResource(R.string.stores_eyebrow_library)
else -> stringResource(R.string.stores_eyebrow_store)
}
val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: ""
@@ -118,12 +119,20 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
when {
installed != null -> {
val gone = StoresState.uninstalling["${store.id}:$id"]
- PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null, main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) {
+ PrimaryButton(stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id)), main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn) {
launchStoreGame(ctx, store, id, s, a)
}
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
+ if (store == Store.XBOX) {
+ SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) {
+ com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed)
+ }
+ SecondaryButton("Manage content", enabled = download == null) {
+ com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed)
+ }
+ }
if (gone != null) SecondaryButton(stringResource(R.string.stores_uninstalling), enabled = false, progress = gone) {}
- else UninstallButton(title, installed, enabled = download == null) {
+ else UninstallButton(title, installed, enabled = download == null && (store != Store.XBOX || !com.droiddeck.launcher.stores.xbox.XboxContent.busy(id))) {
StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() }
}
}
@@ -143,11 +152,11 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
}
}
- item?.owned == true && StoresState.isUnfinished(item) -> {
+ item?.accessible == true && StoresState.isUnfinished(item) -> {
InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) }
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
}
- item?.owned == true -> InstallButton(
+ item?.accessible == true -> InstallButton(
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
leadOn,
) { StoresState.requestInstall(ctx, item) }
@@ -169,10 +178,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
}
val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty()
.takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty()
- val achievements = remember(store, id, item?.owned, installed) {
+ val achievements = remember(store, id, item?.accessible, installed) {
when {
StoreAchievements.forSource(store.id) == null -> null
- item?.owned == true -> StoreAchievements.Game.of(item)
+ item?.accessible == true -> StoreAchievements.Game.of(item)
installed != null -> StoreAchievements.Game.of(installed.sidecar)
else -> null
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
index 3933450d4..1feb8e280 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
@@ -155,12 +155,41 @@ internal fun tabsFor(store: Store?): List = if (store == Store.AMAZON) T
internal fun tabFor(store: Store?, tab: String): String = if (tab in tabsFor(store)) tab else "library"
-private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean)
+private data class PaneKey(val chip: String, val tab: String, val signedIn: Boolean, val accountsLoaded: Boolean)
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = LocalContext.current
+ com.droiddeck.launcher.stores.xbox.XboxContent.plan?.let { content ->
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() },
+ title = { Text("Xbox content") },
+ text = { Column {
+ if (content.error != null) Text(content.error)
+ else if (content.offers.isEmpty()) Text("No licensed PC content packages are available for this game.")
+ content.offers.forEach { offer ->
+ androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.install(ctx, offer.item) }) {
+ Text(offer.item.title + if (offer.installed) " · Update / verify" else " · Install")
+ }
+ }
+ } },
+ confirmButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxContent.dismiss() }) { Text(stringResource(android.R.string.ok)) } },
+ )
+ }
+ com.droiddeck.launcher.stores.xbox.XboxBackend.packageChoice?.let { pending ->
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() },
+ title = { Text("Choose PC content") },
+ text = { Column { pending.choices.forEach { choice ->
+ androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.selectPackage(ctx, choice) }) {
+ Text(choice.getString("title") + " (" + choice.getString("kind") + ")")
+ }
+ } } },
+ confirmButton = {},
+ dismissButton = { androidx.compose.material3.TextButton(onClick = { com.droiddeck.launcher.stores.xbox.XboxBackend.dismissPackages() }) { Text(stringResource(android.R.string.cancel)) } },
+ )
+ }
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
val padH = if (narrow) 14.dp else 22.dp
@@ -175,6 +204,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
val pane = PaneKey(
chip, if (store == null) "" else shownTab,
store == null || (StoresState.isSignedIn(store) && StoresState.expired[store] != true),
+ store == null || StoresState.accountsLoaded,
)
val grids = remember { HashMap() }
val grid = grids.getOrPut(pane) { LazyGridState() }
@@ -362,6 +392,9 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
when {
k.chip == DOWNLOADS -> Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { StoresDownloadsPane(s, a) }
st == null -> {}
+ !k.accountsLoaded -> Box(Modifier.fillMaxWidth().padding(top = 16.dp)) {
+ androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().height(3.dp))
+ }
!k.signedIn -> Box(Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 4.dp, bottom = 16.dp)) { SignInCard(st, viewportH - 20.dp) }
else -> Storefront(st, k.tab, query, s, a, grids.getOrPut(k) { LazyGridState() }, onQuery = { query = it }, onOpen = { open(it) }, onTab = { switchTab(it) })
}
@@ -541,10 +574,11 @@ private fun StoreChips(
Box(
Modifier.size(12.dp)
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
- .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
- ) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
+ .pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
+ .background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
+ )
},
- description = store.label + if (signedIn) "" else " " + stringResource(R.string.stores_chip_signed_out),
+ description = store.label + if (signedIn || !StoresState.accountsLoaded) "" else " " + stringResource(R.string.stores_chip_signed_out),
onFocused = onFocusChip,
) { onStore(store, rootBounds[store.id]) }
}
@@ -737,7 +771,7 @@ private fun Storefront(
val q = query.trim().lowercase()
val foundKeys = remember(found) { found.map { it.key }.toSet() }
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys
- fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
+ fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.accessible
fun shelf(l: List?) = l?.filter(::shown)
val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
LaunchedEffect(store, q) { StoresState.search(ctx, store, q) }
@@ -788,7 +822,7 @@ private fun Storefront(
CompletableFuture.allOf(*requests.map { it.second }.toTypedArray()).await()
requests.forEach { (item, future) ->
val bytes = future.getNow(null) ?: return@forEach
- if (item.owned && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
+ if (item.accessible && item.sizeBytes <= 0) com.droiddeck.launcher.stores.StoreSizes.known[item.key] = bytes
}
resolvedSizeSortKey = candidateKeys
}
@@ -993,6 +1027,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1)
Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD)
Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800)
+ Store.XBOX -> Color(0xFF70C850) to Color(0xFF107C10)
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
null -> return base
}
@@ -1259,7 +1294,7 @@ private fun GameCard(
modifier = Modifier.fillMaxWidth()
.onGloballyPositioned { c ->
placed[0] = c
- if (item.owned && item.sizeBytes <= 0) {
+ if (item.accessible && item.sizeBytes <= 0) {
val b = c.boundsInWindow()
if (b.bottom > -ahead && b.top < screen + ahead && b.width > 0f) com.droiddeck.launcher.stores.StoreSizes.request(ctx, item)
}
@@ -1352,9 +1387,9 @@ private fun CardMarker(item: CatalogItem, installed: Boolean, download: com.droi
Icon(Icons.Filled.Check, stringResource(R.string.stores_installed_chip), tint = pal.good, modifier = Modifier.size(11.dp))
}
download != null -> Text("${download.percent}%", fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
- item.owned && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
- item.owned && compact -> Unit
- item.owned -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
+ item.accessible && StoresState.isUnfinished(item) -> Text(stringResource(R.string.stores_resume), fontSize = small, fontWeight = FontWeight.Bold, color = pal.signal, maxLines = 1)
+ item.accessible && compact -> Unit
+ item.accessible -> com.droiddeck.launcher.stores.StoreSizes.size(item).takeIf { it > 0 }?.let { Text(formatBytes(it), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1) }
item.isFree -> Text(stringResource(R.string.stores_free), fontSize = small, fontWeight = FontWeight.Bold, color = pal.good, maxLines = 1)
item.isDiscounted -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
StateChip("-${item.discountPercent}%", ChipTone.DEAL, small = true)
@@ -1430,9 +1465,16 @@ internal fun openUrl(ctx: android.content.Context, url: String) {
internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }
- if (game == null) {
- android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
+ if (game != null) {
+ com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) }
return
}
- com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) }
+ val app = ctx.applicationContext
+ Thread({
+ val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
+ StoresState.post {
+ if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
+ else com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(installed) }
+ }
+ }, "store-play").start()
}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt
index aed81f155..e0c657a14 100644
--- a/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreAccountsCipherTest.kt
@@ -112,6 +112,25 @@ class StoreAccountsCipherTest {
assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString())
}
+ @Test fun xboxNeverFallsBackToPlaintextWhenKeystoreFails() {
+ StoreAccounts.keys = broken
+ org.junit.Assert.assertThrows(IllegalStateException::class.java) {
+ StoreAccounts.write(app, Store.XBOX, JSONObject().put("username", "test@example.test").put("slots", JSONObject().put("user-tokens", "synthetic-secret")))
+ }
+ assertFalse(file(Store.XBOX).exists())
+ StoreAccounts.keys = fake
+ StoreAccounts.write(app, Store.XBOX, creds)
+ assertTrue(CredentialCipher.isEnvelope(JSONObject(file(Store.XBOX).readText())))
+ }
+
+ @Test fun xboxUsernameAndBackendSlotsAreEncryptedTogether() {
+ val xbox = JSONObject().put("username", "test@example.test").put("slots", JSONObject().put("user-tokens", "synthetic-secret"))
+ StoreAccounts.write(app, Store.XBOX, xbox)
+ assertFalse(file(Store.XBOX).readText().contains("synthetic-secret"))
+ assertFalse(file(Store.XBOX).readText().contains("test@example.test"))
+ assertEquals("test@example.test", StoreAccounts.signedInAs(app, Store.XBOX))
+ }
+
@Test fun signOutRemovesTheFile() {
StoreAccounts.write(app, Store.GOG, creds)
StoreAccounts.clear(app, Store.GOG)
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt
new file mode 100644
index 000000000..898e074f6
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt
@@ -0,0 +1,56 @@
+package com.droiddeck.launcher.stores
+
+import android.app.Application
+import com.droiddeck.launcher.frontend.AddedGames
+import com.droiddeck.launcher.session.SessionPrefs
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], application = Application::class)
+class StoreLaunchTest {
+ private val app get() = RuntimeEnvironment.getApplication()
+
+ private fun install(state: String = StoreGameSidecar.STATE_INSTALLED): File {
+ SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
+ val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX), "Deep Rock Galactic")
+ File(folder, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("licensed image") }
+ StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe", state = state).write(folder)
+ return folder
+ }
+
+ @Test fun aCompletedInstallCanLaunchBeforeTheFrontendLibraryRefreshes() {
+ val folder = install()
+ val game = StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")!!
+ val added = AddedGames.single(app, folder.path)!!
+ assertEquals("xbox", game.source)
+ assertEquals("9NHFVWX1V7QJ", game.storeId)
+ assertEquals(folder, game.gameFiles)
+ assertEquals(added.gameId, game.gameId)
+ assertTrue(added.guestExe.endsWith("/FSD/Binaries/WinGDK/FSD.exe"))
+ }
+
+ @Test fun unfinishedPackagesCannotBecomePlayableThroughTheFallback() {
+ for (state in listOf(StoreGameSidecar.STATE_INSTALLING, StoreGameSidecar.STATE_UPDATING)) {
+ install(state)
+ assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ"))
+ }
+ }
+
+ @Test fun anotherStoreOrProductCannotUseTheXboxInstall() {
+ install()
+ assertNull(StoreLaunch.installedGame(app, Store.GOG, "9NHFVWX1V7QJ"))
+ assertNull(StoreLaunch.installedGame(app, Store.XBOX, "OTHERPRODUCT"))
+ }
+
+ @Test fun aMissingExecutableDoesNotBecomeALaunchTarget() {
+ val folder = install()
+ File(folder, "FSD/Binaries/WinGDK/FSD.exe").delete()
+ assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ"))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt
new file mode 100644
index 000000000..5eb1f2e3c
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt
@@ -0,0 +1,77 @@
+package com.droiddeck.launcher.stores.xbox
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Test
+import java.time.Instant
+
+@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
+@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
+class XboxCatalogTest {
+ private val now = Instant.parse("2026-10-19T12:00:00Z")
+ private val game = "9AAAAAAAAAAA"
+ private val parent = "9BBBBBBBBBBB"
+ private val plan = "CFQ7TTC0KGQ8"
+ private fun row(id: String, parent: String? = null) = JSONObject().put("productId", id).put("status", "Active")
+ .put("isTrial", false).put("special", false).put("sharingSource", "None")
+ .put("endDate", "2099-01-01T00:00:00Z").put("satisfiedByProductId", parent ?: JSONObject.NULL)
+ private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test")
+ .put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList()))
+ private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now)
+
+ @Test fun purchasedAndSubscriptionAccessRemainDistinct() {
+ assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan)))
+ assertFalse(access(row(plan)).containsKey(plan))
+ }
+
+ @Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() {
+ val checked = "2026-10-19T19:02:38.173609086+00:00"
+ val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00"))
+ .put("checkedAt", checked)
+ assertEquals(mapOf(game to "owned"), XboxCatalog.access(response, "test@example.test", "US",
+ Instant.parse("2026-10-19T19:02:39Z")))
+ }
+
+ @Test fun expiredTrialSharedAndUnknownFlagsDoNotGrantAccess() {
+ for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true,
+ "sharingSource" to "Family", "endDate" to "2020-01-01T00:00:00Z")) {
+ assertTrue(access(row(game).put(key, value)).isEmpty())
+ }
+ }
+
+ @Test fun malformedAndConflictingParentsCannotGrantDependentAccess() {
+ assertTrue(access(row(game, parent), row(parent).put("isTrial", JSONObject.NULL)).isEmpty())
+ assertTrue(access(row(game, plan), row(plan).put("endDate", "invalid")).isEmpty())
+ assertTrue(access(row(game), row(game, plan)).isEmpty())
+ assertTrue(access(row(game, parent), row(parent, game)).isEmpty())
+ }
+
+ @Test fun mismatchedAccountAndStaleResultsAreRejected() {
+ for (json in listOf(envelope(row(game)).put("account", "other@example.test"),
+ envelope(row(game)).put("checkedAt", "2020-01-01T00:00:00Z"))) {
+ assertThrows(IllegalArgumentException::class.java) { XboxCatalog.access(json, "test@example.test", "US", now) }
+ }
+ }
+
+ @Test fun installedSubscriptionDoesNotBecomeOwnershipWithoutACachedLibrary() {
+ val sidecar = com.droiddeck.launcher.stores.StoreGameSidecar(com.droiddeck.launcher.stores.Store.XBOX,
+ game, "A game", "Game.exe", extra = mapOf("access" to "gamepass"))
+ val card = com.droiddeck.launcher.stores.installedCards(com.droiddeck.launcher.stores.Store.XBOX,
+ listOf(com.droiddeck.launcher.stores.InstalledStoreGame(sidecar, java.io.File("/synthetic-game"))), emptyList()).single()
+ assertFalse(card.owned); assertTrue(card.subscription)
+ }
+
+ @Test fun consoleAndUwpOffersAreFilteredOut() {
+ fun product(platform: String, format: String) = JSONObject().put("ProductId", game).put("ProductType", "Game")
+ .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A PC game")))
+ .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject()
+ .put("Packages", JSONArray().put(JSONObject().put("PackageFormat", format).put("PlatformDependencies",
+ JSONArray().put(JSONObject().put("PlatformName", platform)))))))))
+ assertNull(XboxCatalog.product(product("Xbox.One", "MSIXVC"), mapOf(game to "owned")))
+ assertNull(XboxCatalog.product(product("Windows.Desktop", "APPX"), mapOf(game to "owned")))
+ val pc = XboxCatalog.product(product("Windows.Desktop", "MSIXVC"), mapOf(game to "gamepass"))!!
+ assertEquals(game, XboxCatalog.product(product("Windows.Desktop", "MSIXVC").put("ProductId", game.lowercase()), mapOf(game to "owned"))!!.id)
+ assertTrue(pc.accessible); assertTrue(pc.subscription); assertFalse(pc.owned)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt
new file mode 100644
index 000000000..8d545d20c
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt
@@ -0,0 +1,53 @@
+package com.droiddeck.launcher.stores.xbox
+
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import org.junit.Assert.*
+import org.junit.Test
+
+@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
+@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
+class XboxLibraryCacheTest {
+ private val account = "test@example.test"
+ private val now = 1_792_440_000_000L
+ private fun game(id: String, subscription: Boolean) = CatalogItem(Store.XBOX, id, "A game", "https://example.test/hero",
+ tallImageUrl = "https://example.test/poster", description = "Description", developer = "Developer", publisher = "Publisher",
+ sizeBytes = 12345, owned = !subscription, subscription = subscription, storeUrl = "https://www.xbox.com/games/store/$id",
+ extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned"))
+
+ @Test fun restartRestoresMetadataWithoutTurningSubscriptionsIntoPurchases() {
+ val games = listOf(game("9AAAAAAAAAAA", false), game("9BBBBBBBBBBB", true))
+ val text = XboxLibraryCache.encode(account, "US", games, now)
+ val cached = XboxLibraryCache.decode(text, account, "US", now)!!
+ assertEquals(games, cached.games)
+ assertFalse(cached.games[1].owned)
+ assertTrue(cached.games[1].subscription)
+ }
+
+ @Test fun anotherAccountMarketOrInvalidEnvelopeCannotRestoreTheLibrary() {
+ val text = XboxLibraryCache.encode(account, "US", listOf(game("9AAAAAAAAAAA", false)), now)
+ assertNull(XboxLibraryCache.decode(text, "other@example.test", "US", now))
+ assertNull(XboxLibraryCache.decode(text, account, "GB", now))
+ assertNull(XboxLibraryCache.decode(text.replace("\"schema\":1", "\"schema\":2"), account, "US", now))
+ assertNull(XboxLibraryCache.decode("invalid", account, "US", now))
+ assertNull(XboxLibraryCache.decode(text, account, "US", now - 120_000))
+ }
+
+ @Test fun freshCacheAvoidsNetworkAndForceOrStalenessRequestsARefresh() {
+ val cached = XboxLibraryCache.Snapshot(emptyList(), now)
+ assertFalse(cached.needsRefresh(false, now + 899_999))
+ assertTrue(cached.needsRefresh(false, now + 900_000))
+ assertTrue(cached.needsRefresh(true, now))
+ val old = XboxLibraryCache.decode(XboxLibraryCache.encode(account, "US", emptyList(), now), account, "US", now + 86_400_000)!!
+ assertTrue(old.needsRefresh(false, now + 86_400_000))
+ assertTrue(old.games.isEmpty())
+ }
+
+ @Test fun clearingTheCacheRemovesThePersistedAccountLibrary() {
+ val app = org.robolectric.RuntimeEnvironment.getApplication()
+ XboxLibraryCache.write(app, account, "US", listOf(game("9AAAAAAAAAAA", false)))
+ assertNotNull(XboxLibraryCache.read(app, account, "US"))
+ XboxLibraryCache.clear(app)
+ assertNull(XboxLibraryCache.read(app, account, "US"))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt
new file mode 100644
index 000000000..99ab0a0eb
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxManifestTest.kt
@@ -0,0 +1,55 @@
+package com.droiddeck.launcher.stores.xbox
+
+import org.junit.Assert.*
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
+@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
+class XboxManifestTest {
+ @get:Rule val temporary = TemporaryFolder()
+ @Test fun resolvesTheManifestExecutableWithWindowsLetterCasing() {
+ val root = temporary.newFolder()
+ java.io.File(root, "Bin").mkdir()
+ java.io.File(root, "Bin/Game.exe").writeText("encrypted")
+ java.io.File(root, "MicrosoftGame.Config").writeText("")
+ assertEquals("Bin/Game.exe", XboxInstaller.executable(root))
+ }
+ @Test fun rejectsTraversalAndExternalEntities() {
+ for (xml in listOf("",
+ "]>&external;",
+ "",
+ "]>")) {
+ val root = temporary.newFolder()
+ java.io.File(root, "MicrosoftGame.config").writeText(xml)
+ assertThrows(Exception::class.java) { XboxInstaller.executable(root) }
+ }
+ }
+ @Test fun androidManifestParserPreservesIdentityAndSelectsTheRetailPcExecutable() {
+ val root = temporary.newFolder()
+ java.io.File(root, "Retail.exe").writeText("encrypted")
+ java.io.File(root, "MicrosoftGame.Config").writeText("""
+
+
+
+
+
+ 9AAAAAAAAAAA9BBBBBBBBBBB
+ """)
+ val info = XboxManifest.read(root)
+ assertEquals("9AAAAAAAAAAA", info.storeId)
+ assertEquals("Test.Game", info.name)
+ assertEquals("CN=A & B", info.publisher)
+ assertEquals("1.2.3.0", info.version)
+ assertEquals("Test.Base", info.dependency)
+ assertEquals(listOf("9BBBBBBBBBBB"), info.related)
+ assertEquals(listOf("Retail.exe"), info.executables)
+ assertEquals("Retail.exe", XboxManifest.executable(root))
+ }
+ @Test fun signInBridgeRejectsUntrustedAndNonHttpsOrigins() {
+ assertTrue(XboxLoginActivity.trusted("https://login.live.com/ppsecure/InlineLogin.srf"))
+ for (url in listOf("http://login.live.com/", "https://login.live.com.evil.test/", "https://evil.test@login.live.com/", "https://login.live.com:8443/"))
+ assertFalse(XboxLoginActivity.trusted(url))
+ }
+}
diff --git a/docs/development/xbox-store.md b/docs/development/xbox-store.md
new file mode 100644
index 000000000..e65ede543
--- /dev/null
+++ b/docs/development/xbox-store.md
@@ -0,0 +1,106 @@
+# Xbox / PC Game Pass on Thor
+
+Branch: `feat/xbox-store`. This integrates PC Microsoft Store MSIXVC packages,
+not Xbox console emulation or UWP/APPX packages.
+
+## Testing on Thor
+
+1. Open DroidDeck, select Stores, then Xbox and Sign in. Use the Microsoft account
+ that owns the game or has PC Game Pass access. An isolated Microsoft sign-in
+ window opens; complete its verification and return to DroidDeck. On later
+ starts, the saved account should restore without another sign-in prompt;
+ initial account loading uses a neutral progress indicator.
+2. Refresh the Xbox library. Check a known owned PC game and, if applicable, a
+ Game Pass game. Subscription access is labeled PC Game Pass and does not
+ become permanent ownership. Console-only and unsupported package types are
+ filtered out. After the first successful sync, closing/reopening DroidDeck
+ should restore the saved library without waiting for Microsoft. Metadata is
+ scoped to account and market; on later starts, snapshots older than 15 minutes
+ refresh quietly in the background. Refresh checks immediately.
+ Downloading and launching still perform Microsoft licensing.
+3. Start with a small licensed title such as LIMBO. Search `LIMBO` if needed;
+ the verified US Product ID is `9PNG83MDD6SH`. Install through the normal store
+ download queue. If an offer contains several PC base packages, choose the
+ intended game. A package plan is not a license: Microsoft licensing must
+ succeed before files can be installed.
+4. Pause during transfer, resume, then repeat after closing/reopening DroidDeck.
+ It should retain partial files, refresh authenticated URLs and resume only
+ verified checkpoints. An error or cancelled process must never create an
+ installed/Play entry. Cancel removes a new installation's partial files.
+5. Press Play as soon as installation completes, including while the main library
+ list is still refreshing. It should resolve the completed installation instead
+ of showing the "Steam picks the game up when it next starts" toast.
+ A launch taking over 20 seconds should not produce a duplicate Steam launch
+ or a "Game already running" dialog while licensing is still preparing it.
+ Steam/session startup uses the selected ARM64 Proton.
+ First launch prepares that game's prefix. Check that a window appears, the
+ game reaches its menu, controller buttons/axes work, and audio is audible.
+ Record the selected Proton, game/version, and any startup failure.
+6. For a game with Xbox identity/cloud saves, check the gamertag, save something,
+ quit normally, wait for synchronization, then relaunch. Verify the save and,
+ where available, compare it on another legitimate Xbox PC installation.
+ In-game identity/cloud/multiplayer success is title-specific.
+7. Close the game, then try Update / verify from its Xbox detail page. It uses
+ fresh licensed streaming and the existing download queue. Interrupted updates
+ remain resumable; do not judge an interrupted package as a complete install.
+8. If you own related PC DLC, open Manage content. Only licensed, unambiguous
+ MSIXVC content associated with this game's package family is offered. Install
+ it, then relaunch and check that the game recognizes it. An account with no
+ such content should get an empty-content message. Base-game uninstall also
+ removes its managed content.
+9. Sign out from Xbox. Installed files should remain, account library data should
+ clear, and Play/download licensing should require sign-in again.
+
+Do not uninstall DroidDeck or clear application data to troubleshoot this build.
+The signed APK is installed in place to retain existing Steam and store data.
+
+## Evidence and limits
+
+Verified during development:
+
+- Native AArch64 CLI execution on Thor's existing Linux runtime.
+- Abstract-socket credential transport with a synthetic account on Thor; this
+ does not establish a real Microsoft sign-in.
+- Real Microsoft sign-in completed on Thor through the isolated Android host.
+ Its credential file is an Android Keystore AES-GCM envelope.
+- Signed-in library refresh on Thor returns 13 compatible PC titles. RFC 3339
+ offset timestamps from the backend are parsed by Android's offset-date parser.
+- Two force-stop/reopen tests restored all 13 titles from the saved library
+ without a native Xbox account request. Cache tests cover account/market
+ isolation, explicit and stale refreshes, sign-out removal and subscription
+ metadata remaining distinct from purchases.
+- Two further cold-start checks observed no sign-in prompt while the saved
+ account loaded; both restored all 13 titles and the installed launcher.
+- Licensed Minecraft Launcher package download and manifest-selected executable
+ registration on Thor (`Minecraft.exe`, package version `2.6.2.0`). An initial
+ Android DOM feature failure was fixed with Android's DTD-rejecting pull parser;
+ retry reused the downloaded files. This launcher install does not validate
+ gameplay.
+- Public LIMBO package resolution on Thor: a Windows.Desktop MSIXVC package.
+- Native AMD64 Windows module loading through Thor's ARM64 Proton/FEX, including
+ the `InitializeApiImplEx2` Xbox runtime export. The original builtin markers
+ caused error 126; the reproducible native-marker adaptation resolves that.
+- Minecraft Launcher's licensed executable was materialized by Xodus, started
+ through ARM64 Proton/FEX, completed its self-update, and rendered its full
+ Windows launcher with a Microsoft account displayed on Thor. The account
+ service remained running with the executable. Minecraft gameplay and in-game
+ Xbox services remain unverified.
+- Deep Rock Galactic's GDK executable also loads through Xodus/ARM64 Proton.
+ Its default launch crashes in D3D12 ray-tracing pipeline creation. Disabling
+ ray tracing in Unreal reaches the rendered title screen on Thor; this workaround
+ is applied to `FSD-WinGDK-Shipping.exe`. Gameplay and online services remain
+ unverified.
+- Account-free tests cover purchase/subscription separation, stale/wrong-account
+ responses, malformed parent licenses, manifest traversal/XXE rejection,
+ fail-closed encrypted credential writes, download checkpoint behavior,
+ private temporary launch views, bridge authentication, and ordinary store
+ launch regressions.
+
+Game rendering/input/audio, in-game account identity, cloud synchronization,
+DLC activation and multiplayer require the account/game tests above. DLL
+loading alone is not evidence of a playable game.
+Native-module tests use a disposable prefix under
+`/data/local/tmp/droiddeck-xbox-probe`; existing game prefixes are not used.
+
+Build recipes, pinned source revisions, hashes, notices and runtime architecture
+are documented in [tools/xbox](../../tools/xbox/README.md).
diff --git a/tools/build_local.sh b/tools/build_local.sh
index c4c0e07dc..e03c49bef 100755
--- a/tools/build_local.sh
+++ b/tools/build_local.sh
@@ -351,6 +351,13 @@ fi
cd "${repo_root}"
python3 tools/quest/build-driver.py "${NDK}"
+xbox_out="${linuxfs_dir}/usr/local/lib/droiddeck-xbox"
+xbox_key=$(inputs_hash "native-arm64-ubuntu24.04" tools/xbox)
+if ! restore_step xbox "${xbox_key}" "${xbox_out}"; then
+ bash tools/xbox/build-local.sh
+ save_step xbox "${xbox_key}" "${xbox_out}"
+fi
+
comet_out="${linuxfs_dir}/usr/local/lib/droiddeck-comet"
comet_key=$(inputs_hash "${image_id}" tools/comet)
if ! restore_step comet "${comet_key}" "${comet_out}"; then
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
index 30c8a2555..f83e93ae3 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
@@ -98,6 +98,12 @@ def godot_sentry(game):
return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off"
+@fix
+def deep_rock_gdk_raytracing(game):
+ if game.stem == "fsd-wingdk-shipping" and not any(game.has_arg(arg) for arg in ("-raytracing", "-noraytracing")):
+ return {}, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"], "Deep Rock Galactic GDK: ray tracing off (D3D12 pipeline creation fails on Thor)"
+
+
@fix
def arkham_city_x87(game):
"""Batman: Arkham City (BatmanAC.exe, also started through BmLauncher.exe beside it) leans on x87
@@ -310,6 +316,14 @@ def main():
if mic_enabled == "0":
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
env = force_ssbs(command, env)
+ xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox")))
+ try:
+ xbox_result = xbox["launch"](command, env)
+ except (OSError, ValueError, KeyError, RuntimeError) as error:
+ print("droiddeck-game-env: Xbox launch could not start (%s)" % error, file=sys.stderr)
+ return 1
+ if xbox_result is not None:
+ return xbox_result
sync = load_sync()
launch, launch_env = select_sync(command, env, sync)
if launch[0] != command[0]:
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
index fdadc61ed..1b2ae2f48 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
@@ -596,6 +596,7 @@ case $mode in
steam_hold_launch() {
local game=$1 shortcut= waited=0 status=0
_bl_focused() { xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p'; }
+ _bl_starting() { pgrep -f "reaper SteamLaunch AppId=$(((game >> 32) & 0xffffffff))( |$)" >/dev/null 2>&1; }
_bl_hand_off() {
printf '%s\n' "$game" > "$BL_LAUNCH_DIR/steam-game.tmp" && mv -f "$BL_LAUNCH_DIR/steam-game.tmp" "$BL_LAUNCH_DIR/steam-game"
}
@@ -610,16 +611,22 @@ case $mode in
_bl_hand_off
[ -n "$shortcut" ] || return 0
# Steam can drop a shortcut launch it gets before the shortcut's overview exists (large libraries).
- # Hand it over again only if the game never took focus, so a launch that worked is not started twice.
+ # A launch can be running before it creates its first window.
while [ "$(_bl_focused)" = 769 ] && [ "$waited" -lt 20 ]; do
sleep 1
waited=$((waited + 1))
done
[ "$(_bl_focused)" = 769 ] || return 0
+ if _bl_starting; then
+ echo "== steam: the requested game is already starting; not launching again"
+ return 0
+ fi
echo "== steam: the game is not in front after about 20 s; waiting for the shortcut overview"
/usr/local/bin/droiddeck-agent --wait-steam-shortcut-overview "$game" 45 || status=$?
if [ "$status" -eq 3 ]; then
echo "== steam: not a shortcut CGameID; not launching again"
+ elif _bl_starting; then
+ echo "== steam: the requested game started while waiting; not launching again"
elif [ "$(_bl_focused)" = 769 ]; then
echo "== steam: launching the requested game again $game (overview status $status)"
_bl_hand_off
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox
new file mode 100755
index 000000000..beec0a65b
--- /dev/null
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox
@@ -0,0 +1,183 @@
+#!/usr/bin/python3
+"""Licensed Xbox launches through Xodus and the game's selected ARM64 Proton."""
+import hmac
+import json
+import os
+from pathlib import Path
+import runpy
+import secrets
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+
+BIN = Path(__file__).resolve().parent
+RUNTIME = Path('/usr/local/lib/droiddeck-xbox')
+DLLS = ('xgameruntime.dll', 'winhttp.dll', 'windows.web.dll', 'twinapi.appcore.dll', 'libnghttp2.dll')
+
+
+def bridge(upstream):
+ key = secrets.token_hex(32).encode('ascii')
+ server = socket.socket()
+ server.bind(('127.0.0.1', 0))
+ server.listen(16)
+ clients = set()
+ lock = threading.Lock()
+ stopped = threading.Event()
+
+ def forward(source, target):
+ try:
+ while not stopped.is_set():
+ data = source.recv(65536)
+ if not data:
+ break
+ target.sendall(data)
+ except OSError:
+ pass
+ finally:
+ try:
+ target.shutdown(socket.SHUT_WR)
+ except OSError:
+ pass
+
+ def connect(client):
+ remote = None
+ try:
+ client.settimeout(10)
+ supplied = b''
+ while len(supplied) < len(key):
+ part = client.recv(len(key) - len(supplied))
+ if not part:
+ return
+ supplied += part
+ if not hmac.compare_digest(supplied, key):
+ return
+ remote = socket.socket(socket.AF_UNIX)
+ remote.connect(upstream)
+ client.settimeout(None)
+ with lock:
+ clients.add(remote)
+ writer = threading.Thread(target=forward, args=(client, remote), daemon=True)
+ writer.start()
+ forward(remote, client)
+ writer.join(2)
+ except OSError:
+ pass
+ finally:
+ for sock in (client, remote):
+ if sock is not None:
+ with lock:
+ clients.discard(sock)
+ sock.close()
+
+ def accept():
+ while not stopped.is_set():
+ try:
+ client, _ = server.accept()
+ except OSError:
+ break
+ with lock:
+ if len(clients) >= 64:
+ client.close()
+ continue
+ clients.add(client)
+ threading.Thread(target=connect, args=(client,), daemon=True).start()
+
+ threading.Thread(target=accept, daemon=True).start()
+
+ def close():
+ stopped.set()
+ server.close()
+ with lock:
+ for client in clients:
+ try:
+ client.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ client.close()
+ clients.clear()
+
+ return server.getsockname()[1], key.decode('ascii'), close
+
+
+def wine(argv):
+ with open(os.environ['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file:
+ command = json.load(file)
+ if len(command) < 3 or command[1] != 'waitforexitandrun' or len(argv) != 1:
+ raise ValueError('Invalid Xbox Proton invocation')
+ env = dict(os.environ)
+ env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None)
+ env.pop('DROIDDECK_XBOX_COMMAND_FILE', None)
+ prefix = Path(os.environ['STEAM_COMPAT_DATA_PATH']) / 'pfx'
+ prefix.parent.mkdir(parents=True, exist_ok=True)
+ if not (prefix / 'system.reg').is_file():
+ result = subprocess.run([command[0], 'run', 'cmd', '/c', 'exit', '0'], stdout=sys.stderr, env=env)
+ if result.returncode:
+ return result.returncode
+ system = prefix / 'drive_c/windows/system32'
+ system.mkdir(parents=True, exist_ok=True)
+ for name in DLLS:
+ target = system / name
+ if target.is_symlink():
+ target.unlink()
+ shutil.copyfile(RUNTIME / 'windows' / name, target)
+ overrides = ';'.join(name[:-4] + '=n' for name in DLLS if name != 'libnghttp2.dll')
+ env['WINEDLLOVERRIDES'] = overrides + ';' + env.get('WINEDLLOVERRIDES', '')
+ return subprocess.call([*command[:2], argv[0], *command[3:]], env=env)
+
+
+def launch(command, env):
+ if len(command) < 3 or command[1] != 'waitforexitandrun' or env.get('DROIDDECK_XBOX_ACTIVE') == '1':
+ return None
+ store = runpy.run_path(str(BIN / 'droiddeck-store-launch'))
+ found = store['find_store_game'](command[2:])
+ if found is None or found[1].get('store') != 'xbox':
+ return None
+ folder, sidecar = found
+ for name in DLLS:
+ if not (RUNTIME / 'windows' / name).is_file():
+ raise RuntimeError('Xbox Windows runtime is missing; update the Linux runtime')
+ launch_dir = env.get('BL_LAUNCH_DIR')
+ if not launch_dir:
+ raise RuntimeError('No Xbox account channel; start the game from DroidDeck')
+ reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=95)
+ if not reply or not reply.get('ok'):
+ raise RuntimeError('Xbox account service is unavailable; sign in again and retry')
+ port, key, close = bridge(reply['socket'])
+ try:
+ with tempfile.TemporaryDirectory(prefix='droiddeck-xbox-') as temporary:
+ context = Path(temporary) / 'command.json'
+ context.write_text(json.dumps(command), encoding='utf-8')
+ context.chmod(0o600)
+ owned_env = dict(env, DROIDDECK_XBOX_ACTIVE='1', DROIDDECK_XBOX_COMMAND_FILE=str(context),
+ DROIDDECK_XBOX_CREDENTIAL_SOCKET=reply['credentials'],
+ XODUS_SOCKET=reply['socket'], XODUS_BRIDGE_PORT=str(port), XODUS_BRIDGE_KEY=key,
+ XODUS_LOG='off', RUST_LOG='off', XODUS_SAVE_SYNC='auto' if sidecar.get('cloud', True) else 'off')
+ content = folder / '.xbox-content'
+ roots = []
+ if content.is_dir():
+ for entry in content.iterdir():
+ try:
+ marker = json.loads((entry / '.droiddeck-content.json').read_text())
+ if marker.get('parent') == sidecar['id'] and marker.get('productId') == entry.name and not entry.is_symlink():
+ roots.append('Z:' + str(entry).replace('/', '\\'))
+ except (OSError, ValueError, TypeError):
+ pass
+ if roots:
+ owned_env['XODUS_CONTENT_ROOTS'] = ';'.join(roots)
+ market = sidecar.get('extra', {}).get('market', 'US')
+ return subprocess.call([str(RUNTIME / 'xodus-cli'), 'run', str(folder), str(BIN / 'droiddeck-xbox'),
+ '--exe', sidecar['exe'], '--materialize-chain', '--market', market], env=owned_env)
+ finally:
+ close()
+ store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'id': sidecar['id']})
+
+
+if __name__ == '__main__':
+ try:
+ sys.exit(wine(sys.argv[1:]))
+ except (OSError, ValueError, KeyError, RuntimeError):
+ print('droiddeck-xbox: Xbox launch failed; check your account, runtime and package.', file=sys.stderr)
+ sys.exit(1)
diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py
index a539e46c4..fefef3eab 100644
--- a/tools/release/runtime_inputs.py
+++ b/tools/release/runtime_inputs.py
@@ -15,7 +15,7 @@ MANIFEST = Path('app/build/runtime-inputs.json')
SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst',
'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio',
'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env',
- 'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/build_local.sh',
+ 'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/xbox', 'tools/build_local.sh',
'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal',
'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh',
'app/src/main/cpp/waylandcomp/src/headpose_shm.h',
@@ -89,6 +89,9 @@ def required():
['mangoapp', 'libfmt.so.10', 'libspdlog.so.1.13', 'libglfw.so.3', 'libtraceevent.so.1', 'libtracefs.so.1']]
paths += ['assets/linuxfs/usr/local/lib/droiddeck-comet/' + name for name in
['comet', 'GalaxyCommunication.exe', 'update-permissions.exe', 'LICENSE', 'version']]
+ paths += ['assets/linuxfs/usr/local/lib/droiddeck-xbox/' + name for name in
+ ['xodus-cli', 'xodus-service', 'version', 'backend.lock.json', 'runtime.lock.json', 'LICENSE.xodus', 'LICENSE.nexus', 'LICENSE.LGPL-2.1', 'LICENSE.nghttp2',
+ 'windows/xgameruntime.dll', 'windows/winhttp.dll', 'windows/windows.web.dll', 'windows/twinapi.appcore.dll', 'windows/libnghttp2.dll']]
for driver in ['linux-wine11', 'linux-wine11-systhread']:
paths += ['assets/directaudio/' + driver + '/' + name for name in
['aarch64-unix/winedirectaudio.so', 'aarch64-windows/winedirectaudio.drv',
diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py
index 6757d46d6..eaef4c6d0 100644
--- a/tools/tests/test_game_environment.py
+++ b/tools/tests/test_game_environment.py
@@ -179,6 +179,17 @@ class GameEnvironmentTest(unittest.TestCase):
(other / "Other.exe").touch()
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
+ def test_deep_rock_gdk_avoids_the_raytracing_pipeline_crash(self):
+ game = "/game/FSD/Binaries/WinGDK/FSD-WinGDK-Shipping.exe"
+ env, extra, notes = MODULE["engine_fixes"](game, [])
+ self.assertEqual(extra, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"])
+ self.assertEqual(env, {})
+ self.assertTrue(notes)
+ for choice in ("-raytracing", "-noraytracing"):
+ self.assertEqual(MODULE["engine_fixes"](game, [choice]), ({}, [], []))
+ self.assertEqual(MODULE["engine_fixes"]("/game/FSD-Win64-Shipping.exe", []), ({}, [], []))
+ self.assertEqual(MODULE["engine_fixes"]("/game/Other-WinGDK-Shipping.exe", []), ({}, [], []))
+
def test_arkham_city_gets_full_x87_precision_over_the_preset(self):
with tempfile.TemporaryDirectory() as tmp:
city = Path(tmp) / "Batman Arkham City GOTY/Binaries/Win32"
diff --git a/tools/tests/test_steam_cold_launch.py b/tools/tests/test_steam_cold_launch.py
index 2c80a2546..162037eba 100644
--- a/tools/tests/test_steam_cold_launch.py
+++ b/tools/tests/test_steam_cold_launch.py
@@ -1,4 +1,4 @@
-"""Requested Steam games are handed over as soon as the interface is up; a shortcut is handed over again only if it never took focus."""
+"""Retry a dropped shortcut without duplicating a launch that is still preparing its first window."""
import os
from pathlib import Path
import subprocess
@@ -48,14 +48,21 @@ class SteamColdLaunchTest(unittest.TestCase):
sleep = self.bin / "sleep"
sleep.write_text("#!/bin/sh\nprintf '%s\n' \"$@\" >> \"$SLEEP_ARGS\"\n")
sleep.chmod(0o755)
+ pgrep = self.bin / "pgrep"
+ pgrep.write_text(
+ "#!/bin/sh\n"
+ "printf '%s\\n' \"$@\" > \"$HOME/pgrep-args\"\n"
+ "n=$(cat \"$HOME/pgrep-count\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$HOME/pgrep-count\"\n"
+ "[ \"$n\" -ge \"${RUNNING_AFTER:-999}\" ]\n")
+ pgrep.chmod(0o755)
self.block = LAUNCH_BLOCK.replace("/usr/local/bin/droiddeck-agent", str(agent))
- def run_launch(self, target, focus="769", helper_status=0):
+ def run_launch(self, target, focus="769", helper_status=0, running_after=999):
env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir),
PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args),
AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status),
- FOCUS=focus, FOCUS_COUNT=str(self.focus_count))
+ FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after))
script = self.block + "\nprintf 'ARG:%s\n' \"$@\"\nwait\n"
url = target if target.startswith("steam://") else "steam://rungameid/" + target
result = subprocess.run(["bash", "-c", script, "cold-launch", url],
@@ -85,6 +92,20 @@ class SteamColdLaunchTest(unittest.TestCase):
self.assertEqual(2, len(self.hand_offs(result)), result.stdout)
self.assertIn("overview status 1", result.stdout)
+ def test_a_game_still_starting_without_a_window_is_not_launched_twice(self):
+ result = self.run_launch(SHORTCUT, running_after=1)
+ self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
+ self.assertFalse(self.agent_args.exists())
+ self.assertEqual(["-f", "reaper SteamLaunch AppId=3551182975( |$)"],
+ (self.root / "pgrep-args").read_text().splitlines())
+ self.assertIn("already starting", result.stdout)
+
+ def test_a_game_starting_during_the_overview_wait_is_not_launched_twice(self):
+ result = self.run_launch(SHORTCUT, running_after=2)
+ self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
+ self.assertTrue(self.agent_args.exists())
+ self.assertIn("started while waiting", result.stdout)
+
def test_a_game_that_takes_focus_during_the_overview_wait_is_not_started_twice(self):
focus = " ".join(["769"] * 23) + " 4242"
result = self.run_launch(SHORTCUT, focus=focus, helper_status=0)
diff --git a/tools/tests/test_xbox_launch.py b/tools/tests/test_xbox_launch.py
new file mode 100644
index 000000000..7419dd62c
--- /dev/null
+++ b/tools/tests/test_xbox_launch.py
@@ -0,0 +1,73 @@
+import json
+import os
+from pathlib import Path
+import runpy
+import socket
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+
+BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
+xbox = runpy.run_path(str(BIN / 'droiddeck-xbox'))
+
+
+class XboxLaunchTest(unittest.TestCase):
+ def test_other_stores_keep_their_existing_launch(self):
+ with tempfile.TemporaryDirectory() as root:
+ folder = Path(root)
+ (folder / '.droiddeck-store.json').write_text(json.dumps({'store': 'gog', 'id': '1'}))
+ self.assertIsNone(xbox['launch'](['/proton', 'waitforexitandrun', str(folder / 'Game.exe')], {}))
+
+ def test_bridge_authenticates_before_forwarding(self):
+ with tempfile.TemporaryDirectory() as root:
+ path = str(Path(root) / 'service.sock')
+ upstream = socket.socket(socket.AF_UNIX)
+ upstream.bind(path); upstream.listen(1); upstream.settimeout(3)
+ received = []
+ def serve():
+ connection, _ = upstream.accept()
+ with connection:
+ received.append(connection.recv(8))
+ connection.sendall(b'reply')
+ worker = threading.Thread(target=serve); worker.start()
+ port, key, close = xbox['bridge'](path)
+ try:
+ with socket.create_connection(('127.0.0.1', port), timeout=2) as denied:
+ denied.sendall(b'x' * 64)
+ self.assertEqual(denied.recv(1), b'')
+ with socket.create_connection(('127.0.0.1', port), timeout=2) as client:
+ client.sendall(key[:20].encode())
+ client.sendall(key[20:].encode() + b'request')
+ self.assertEqual(client.recv(5), b'reply')
+ worker.join(3)
+ self.assertFalse(worker.is_alive())
+ self.assertEqual(received, [b'request'])
+ finally:
+ close(); upstream.close()
+
+ def test_wine_wrapper_preserves_selected_proton_args_and_prefix_isolation(self):
+ with tempfile.TemporaryDirectory() as root:
+ root = Path(root)
+ prefix = root / 'compat/pfx'
+ prefix.mkdir(parents=True)
+ runtime = root / 'runtime/windows'; runtime.mkdir(parents=True)
+ for name in xbox['DLLS']:
+ (runtime / name).write_bytes(b'MZ-synthetic')
+ command = root / 'command.json'
+ command.write_text(json.dumps(['/selected/proton', 'waitforexitandrun', '/encrypted/Game.exe', '--custom', 'two words']))
+ env = {'STEAM_COMPAT_DATA_PATH': str(prefix.parent), 'DROIDDECK_XBOX_COMMAND_FILE': str(command),
+ 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'private-account-channel', 'XODUS_BRIDGE_KEY': 'runtime-key'}
+ with patch.dict(os.environ, env), patch.dict(xbox['wine'].__globals__, RUNTIME=runtime.parent), patch('subprocess.run') as initialize, patch('subprocess.call', return_value=7) as call:
+ initialize.return_value.returncode = 0
+ self.assertEqual(xbox['wine'](['/private-view/Game.exe']), 7)
+ argv = call.call_args.args[0]
+ self.assertEqual(argv, ['/selected/proton', 'waitforexitandrun', '/private-view/Game.exe', '--custom', 'two words'])
+ self.assertIn('xgameruntime=n', call.call_args.kwargs['env']['WINEDLLOVERRIDES'])
+ self.assertEqual((prefix / 'drive_c/windows/system32/xgameruntime.dll').read_bytes(), b'MZ-synthetic')
+ initialize.assert_called_once()
+ for child in (initialize, call):
+ child_env = child.call_args.kwargs['env']
+ self.assertNotIn('DROIDDECK_XBOX_CREDENTIAL_SOCKET', child_env)
+ self.assertNotIn('DROIDDECK_XBOX_COMMAND_FILE', child_env)
+ self.assertEqual(child_env['XODUS_BRIDGE_KEY'], 'runtime-key')
diff --git a/tools/xbox/LICENSE.LGPL-2.1 b/tools/xbox/LICENSE.LGPL-2.1
new file mode 100644
index 000000000..732811e4f
--- /dev/null
+++ b/tools/xbox/LICENSE.LGPL-2.1
@@ -0,0 +1,502 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/tools/xbox/LICENSE.nexus b/tools/xbox/LICENSE.nexus
new file mode 100644
index 000000000..6c103f8ac
--- /dev/null
+++ b/tools/xbox/LICENSE.nexus
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Xodus Nexus contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/tools/xbox/LICENSE.nghttp2 b/tools/xbox/LICENSE.nghttp2
new file mode 100644
index 000000000..80201792e
--- /dev/null
+++ b/tools/xbox/LICENSE.nghttp2
@@ -0,0 +1,23 @@
+The MIT License
+
+Copyright (c) 2012, 2014, 2015, 2016 Tatsuhiro Tsujikawa
+Copyright (c) 2012, 2014, 2015, 2016 nghttp2 contributors
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/tools/xbox/LICENSE.xodus b/tools/xbox/LICENSE.xodus
new file mode 100644
index 000000000..f288702d2
--- /dev/null
+++ b/tools/xbox/LICENSE.xodus
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)