diff --git a/.efrocachemap b/.efrocachemap
index 769e3cdf..b2a4ef26 100644
--- a/.efrocachemap
+++ b/.efrocachemap
@@ -420,41 +420,42 @@
"assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/60/ad/38269b7f1c7dc20cb9a506cd0681",
"assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/72/85/d6fc4d16b7081d91fba2850b5b10",
"assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/e9/ae/1d674d0c086eaa0bd1c3b1db0505",
- "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/89/ec/d472036fbb09f310891761beb39a",
- "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/b0/05/e530acaba539f040ce61e22561dc",
+ "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/9b/ec/d11f3e0238ff648bce3657fe5d50",
+ "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/92/43/36b34307575f6d6219bdf4898e18",
"assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/61/03/89070ca765e06da3a419a579f503",
- "assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/1f/7f/af259ba9b41556e5e667ad4c646d",
- "assets/build/ba_data/data/languages/chinesetraditional.json": "https://files.ballistica.net/cache/ba1/3c/22/78a56fc40426ab19ad4e76924b78",
+ "assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/96/96/1390940b8457b477113194acbb41",
+ "assets/build/ba_data/data/languages/chinesetraditional.json": "https://files.ballistica.net/cache/ba1/f7/b0/191439142c6d6da4a056edc98b38",
"assets/build/ba_data/data/languages/croatian.json": "https://files.ballistica.net/cache/ba1/c9/73/01a1343af814131b1ee96af0b687",
- "assets/build/ba_data/data/languages/czech.json": "https://files.ballistica.net/cache/ba1/61/20/01291c2cb72b22f204730c0d7574",
+ "assets/build/ba_data/data/languages/czech.json": "https://files.ballistica.net/cache/ba1/4e/8c/72ddb584856a15dfb11df95f9283",
"assets/build/ba_data/data/languages/danish.json": "https://files.ballistica.net/cache/ba1/6a/fa/fcf4a804beaff927b0f12c179eaa",
"assets/build/ba_data/data/languages/dutch.json": "https://files.ballistica.net/cache/ba1/68/93/da8e9874f41a786edf52ba4ccaad",
- "assets/build/ba_data/data/languages/english.json": "https://files.ballistica.net/cache/ba1/a0/1d/5fbc922d01521142c2a347b1b024",
- "assets/build/ba_data/data/languages/esperanto.json": "https://files.ballistica.net/cache/ba1/4c/c7/0184b8178869d1a3827a1bfcd5bb",
- "assets/build/ba_data/data/languages/filipino.json": "https://files.ballistica.net/cache/ba1/c7/2e/e0520f58206da01b829e02ff4576",
- "assets/build/ba_data/data/languages/french.json": "https://files.ballistica.net/cache/ba1/e8/84/6c9f123e9a0d82fc595c8f55ac7c",
- "assets/build/ba_data/data/languages/german.json": "https://files.ballistica.net/cache/ba1/8a/09/3e0fa9e44913b53f4dab195d3fae",
- "assets/build/ba_data/data/languages/gibberish.json": "https://files.ballistica.net/cache/ba1/5f/51/c15d74d2fe4e88ee1e3db0986500",
- "assets/build/ba_data/data/languages/greek.json": "https://files.ballistica.net/cache/ba1/aa/da/dfc8d710af960d7300c7090faeab",
- "assets/build/ba_data/data/languages/hindi.json": "https://files.ballistica.net/cache/ba1/09/55/b50104638f60636af2263877bb7f",
- "assets/build/ba_data/data/languages/hungarian.json": "https://files.ballistica.net/cache/ba1/d8/f2/aa16bc336bd7660cc86c3264bfc4",
- "assets/build/ba_data/data/languages/indonesian.json": "https://files.ballistica.net/cache/ba1/3a/6b/34714586cb4e9f1b12f8ae54cac8",
- "assets/build/ba_data/data/languages/italian.json": "https://files.ballistica.net/cache/ba1/12/62/862228b229057877e89fb195d41d",
- "assets/build/ba_data/data/languages/korean.json": "https://files.ballistica.net/cache/ba1/7c/38/d4a44c481757d355836f292ede48",
- "assets/build/ba_data/data/languages/persian.json": "https://files.ballistica.net/cache/ba1/10/13/1228836444f7557211f0058ef9bd",
- "assets/build/ba_data/data/languages/polish.json": "https://files.ballistica.net/cache/ba1/19/e9/59c891b1fb85f3ba9f19283c233d",
- "assets/build/ba_data/data/languages/portuguese.json": "https://files.ballistica.net/cache/ba1/61/5b/847c03407d1c3a85866833323676",
+ "assets/build/ba_data/data/languages/english.json": "https://files.ballistica.net/cache/ba1/ad/85/3caf0c981389e46835862248386a",
+ "assets/build/ba_data/data/languages/esperanto.json": "https://files.ballistica.net/cache/ba1/ac/f5/c0922a99e40dfc9f5e026d43b533",
+ "assets/build/ba_data/data/languages/filipino.json": "https://files.ballistica.net/cache/ba1/dc/50/c109f475599608a7e8fdacea667c",
+ "assets/build/ba_data/data/languages/french.json": "https://files.ballistica.net/cache/ba1/4f/4d/b259b145a69db1d34052281bb3bb",
+ "assets/build/ba_data/data/languages/german.json": "https://files.ballistica.net/cache/ba1/cb/7b/b881356f22e5f8293a5e4b8976ae",
+ "assets/build/ba_data/data/languages/gibberish.json": "https://files.ballistica.net/cache/ba1/ee/fa/6eb38e7d4cfd13702c66d37b9723",
+ "assets/build/ba_data/data/languages/greek.json": "https://files.ballistica.net/cache/ba1/8a/82/392f2a4e0e6e5a5e367f19e49538",
+ "assets/build/ba_data/data/languages/hindi.json": "https://files.ballistica.net/cache/ba1/2e/d1/b506ddf3d27af7be1ae6159531a3",
+ "assets/build/ba_data/data/languages/hungarian.json": "https://files.ballistica.net/cache/ba1/b2/81/53c8cd7617d649403e539c3a6171",
+ "assets/build/ba_data/data/languages/indonesian.json": "https://files.ballistica.net/cache/ba1/30/91/9dc870d35ddca657bf07d0f88a82",
+ "assets/build/ba_data/data/languages/italian.json": "https://files.ballistica.net/cache/ba1/dd/be/1ae65f073d563e69c0c5246d20d7",
+ "assets/build/ba_data/data/languages/korean.json": "https://files.ballistica.net/cache/ba1/32/97/4c61425d7c200a5c4c9ae3a94a0e",
+ "assets/build/ba_data/data/languages/malay.json": "https://files.ballistica.net/cache/ba1/b4/02/f686ce008312e74d5953a511c8b2",
+ "assets/build/ba_data/data/languages/persian.json": "https://files.ballistica.net/cache/ba1/70/07/7864c07c7177dd751780ccc85b34",
+ "assets/build/ba_data/data/languages/polish.json": "https://files.ballistica.net/cache/ba1/38/31/fd930431c7612d0ab0e73e6bc74f",
+ "assets/build/ba_data/data/languages/portuguese.json": "https://files.ballistica.net/cache/ba1/cb/96/86bb139f20e126593f7fdba9e9e1",
"assets/build/ba_data/data/languages/romanian.json": "https://files.ballistica.net/cache/ba1/d7/06/9d70642d0a4d1e3b1c2149d7a17c",
- "assets/build/ba_data/data/languages/russian.json": "https://files.ballistica.net/cache/ba1/34/ed/b97350983272e4b23bf140d7a5f4",
+ "assets/build/ba_data/data/languages/russian.json": "https://files.ballistica.net/cache/ba1/6c/62/06869ed55a656b6e51b4d22e6fa8",
"assets/build/ba_data/data/languages/serbian.json": "https://files.ballistica.net/cache/ba1/4e/91/6f2a9a3ce733908e91377a6ddb9a",
- "assets/build/ba_data/data/languages/slovak.json": "https://files.ballistica.net/cache/ba1/f9/4b/d9f01814224066856695452ef57c",
- "assets/build/ba_data/data/languages/spanish.json": "https://files.ballistica.net/cache/ba1/ce/be/2f06c3436871fd464ff3a62597d9",
- "assets/build/ba_data/data/languages/swedish.json": "https://files.ballistica.net/cache/ba1/91/0a/35c4baf539d5951fc03a794c0e0b",
- "assets/build/ba_data/data/languages/tamil.json": "https://files.ballistica.net/cache/ba1/64/22/7bc899ecbec52cf978a1faf1c127",
- "assets/build/ba_data/data/languages/thai.json": "https://files.ballistica.net/cache/ba1/f7/df/7ba5f99c5c2c4c86fc0503fcf0b7",
- "assets/build/ba_data/data/languages/turkish.json": "https://files.ballistica.net/cache/ba1/ee/08/1f77c7c320d8d8504a11ee495db3",
- "assets/build/ba_data/data/languages/ukrainian.json": "https://files.ballistica.net/cache/ba1/f3/92/fd7ee5fa8a92fcc8fd2219a88a2f",
- "assets/build/ba_data/data/languages/venetian.json": "https://files.ballistica.net/cache/ba1/2e/86/10d3e39d35014d039cc9ea886ca7",
+ "assets/build/ba_data/data/languages/slovak.json": "https://files.ballistica.net/cache/ba1/20/a9/163d189884edf802636bf291e432",
+ "assets/build/ba_data/data/languages/spanish.json": "https://files.ballistica.net/cache/ba1/0b/21/a4d09ca1fea8bbf347ed7091c8a2",
+ "assets/build/ba_data/data/languages/swedish.json": "https://files.ballistica.net/cache/ba1/3b/9f/d40c1423d260784970fd7364ca30",
+ "assets/build/ba_data/data/languages/tamil.json": "https://files.ballistica.net/cache/ba1/3d/83/e1bb0a664d1c14c41b1a083acf0d",
+ "assets/build/ba_data/data/languages/thai.json": "https://files.ballistica.net/cache/ba1/d6/16/523c643358880b03b233ed88e557",
+ "assets/build/ba_data/data/languages/turkish.json": "https://files.ballistica.net/cache/ba1/63/c8/6cfbfd6702c80aa9df490e4629d7",
+ "assets/build/ba_data/data/languages/ukrainian.json": "https://files.ballistica.net/cache/ba1/3e/b6/052f1faed0264bf7135feb5c4cc3",
+ "assets/build/ba_data/data/languages/venetian.json": "https://files.ballistica.net/cache/ba1/a6/ed/416638d46950c9ab4f6155b9c334",
"assets/build/ba_data/data/languages/vietnamese.json": "https://files.ballistica.net/cache/ba1/1f/ae/abe3f105b3c4b51f6b7942773305",
"assets/build/ba_data/data/maps/big_g.json": "https://files.ballistica.net/cache/ba1/47/0a/a617cc85d927b576c4e6fc1091ed",
"assets/build/ba_data/data/maps/bridgit.json": "https://files.ballistica.net/cache/ba1/03/4b/57ee9b42854b26f23f81bd8c58ef",
@@ -1858,6 +1859,10 @@
"assets/build/ba_data/textures/menuIcon.ktx": "https://files.ballistica.net/cache/ba1/e7/8c/dde40b132e096841299505e2b665",
"assets/build/ba_data/textures/menuIcon.pvr": "https://files.ballistica.net/cache/ba1/91/d3/6e3b38a70caa2bc68ddff50e3421",
"assets/build/ba_data/textures/menuIcon_preview.png": "https://files.ballistica.net/cache/ba1/1f/22/69a013729949501405ea342a1a8b",
+ "assets/build/ba_data/textures/merch.dds": "https://files.ballistica.net/cache/ba1/da/6a/87779603f295cf30fcec2d3a4e9f",
+ "assets/build/ba_data/textures/merch.ktx": "https://files.ballistica.net/cache/ba1/5b/c4/2df30594a9e7c1988d92435d7bd0",
+ "assets/build/ba_data/textures/merch.pvr": "https://files.ballistica.net/cache/ba1/20/76/b8da7a2dded8c137c8388bcb6986",
+ "assets/build/ba_data/textures/merch_preview.png": "https://files.ballistica.net/cache/ba1/31/ad/9ffc829f1fcff718106d129ac062",
"assets/build/ba_data/textures/meter.dds": "https://files.ballistica.net/cache/ba1/52/a8/00244236f0e6126e2e14bd1f3106",
"assets/build/ba_data/textures/meter.ktx": "https://files.ballistica.net/cache/ba1/b5/e7/3ba3d4c7c7e05081eb033ae5da8f",
"assets/build/ba_data/textures/meter.pvr": "https://files.ballistica.net/cache/ba1/58/de/ab83ad9e0d9b434ca42811030ef9",
@@ -4003,51 +4008,51 @@
"assets/src/ba_data/python/ba/_generated/__init__.py": "https://files.ballistica.net/cache/ba1/ee/e8/cad05aa531c7faf7ff7b96db7f6e",
"assets/src/ba_data/python/ba/_generated/enums.py": "https://files.ballistica.net/cache/ba1/1c/77/ac670a5118abdf8a7687af0e159b",
"ballisticacore-windows/Generic/BallisticaCore.ico": "https://files.ballistica.net/cache/ba1/89/c0/e32c7d2a35dc9aef57cc73b0911a",
- "build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/bd/82/98ea775b22a1113323a1ddf12b6a",
- "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/e1/0d/0a431edcdee394a4e4d5b18608d7",
- "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e8/94/162a944636170ac881d3f3dfd805",
- "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/f8/96/fea047474c276064176b65f7e48a",
- "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/12/eb/226aba01e295a807614c64c44d40",
- "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/23/71/f60c8e90699d887979c4ad26a2e2",
- "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/8d/80/05e3d712c67d0fcde0e8605d0be7",
- "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/7f/05/498a538fd28fcd1de964c046b8f2",
- "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d1/6e/01e46632d17db0597d1aef3394d1",
- "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/aa/12/7992a25feae2b0bf2c8fe2023187",
- "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/99/a9/2b251e46b2e9e4ef143a0308d9f3",
- "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/6a/93/faee77acd35111d083998df65aaa",
- "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/25/c1/9b6efb09c364beae30a40510bfcf",
- "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/18/76/3f8e144f6727ee8f5f5f4b0b6ddc",
- "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/3c/e8/02284ba36b4de9ca68cdd7c3e689",
- "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/15/cf/c02041bdc6fa5d0042408e591dcf",
- "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/62/98/ee64d80a6332d7d9fc57a2605a2d",
- "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/54/e6/c4c75d29a7c19c34ba5876c4c350",
- "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/69/3c/96b8690702f596182a305b5b4489",
- "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/b4/b4/81668b3afad33372276a46545aae",
- "build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/c3/1a/ae199ada4bf5a649f73fe1663868",
- "build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/a2/4f/2cf4047fdbac4a661ca99d4aedb8",
- "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/06/f5/b0fdcf55008fc53e1660f7bc841f",
- "build/prefab/lib/linux_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/2c/af/159cc0021a3751da19f4d6832602",
- "build/prefab/lib/linux_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cc/4d/0586cbf47105ba1224a445cd72f4",
- "build/prefab/lib/linux_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/f8/33/ae7f84447a19e465c134355b359e",
- "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/40/eb/004a4ea3094210114fd739cb9fcb",
- "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/45/ed/36918bf061396d6e1f9814afce8c",
- "build/prefab/lib/mac_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/8f/28/7c06af16187bf2db92cf99763f4e",
- "build/prefab/lib/mac_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/44/69/aeba19cb88e6b57c4ab9325f5877",
- "build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/69/4e/b8354e50de6f2afb45b342919868",
- "build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/70/65/f35430e7328bc7ac30de3960dfc7",
- "build/prefab/lib/mac_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/d8/47/c89b62ea5a71854b303f9e85e5d7",
- "build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/f2/19/280f6773b3563e012ad6bfad33dc",
- "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1e/17/e5ef6ce0e41360a43c63ef9c0974",
- "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cd/78/1adf82e5c3e456d2ea2d4290c61c",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/6b/5f/c5dc0b2a2809bc1d3ea57fb985d0",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/56/ee/49a4a2d7940fd239a66c04657c90",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/39/72/fb9b3400c5d128ad156818ede03d",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/22/65/550d27a960822b8846c0c0a440be",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/33/3b/9b38515580edd4616f9955f7e33c",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/fe/1a/80ddcd73df9985ab768e617a6c2b",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/1d/27/0d69901bb721f986fdbfc490100f",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/e9/59/ab5278ceeae3656f91c6d3c68c83",
- "src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/25/77/8093dfffddaa80cd513ddaa61867",
+ "build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/a2/53/595db696c112a17513214a62eee7",
+ "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/27/db/57461eaa99ad29c86aab36b26a82",
+ "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/63/39/ad16cba9e1efb90fb90ae4d564d2",
+ "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/53/1e/40e9ac482c8d04a56e340100fba6",
+ "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/52/6c/5cf1aa6b12d251faea83d2f63377",
+ "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/65/91/1e7e14afff621cb2070644f57bbc",
+ "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/0e/81/2cd5d6fbe320802b90d81768bd73",
+ "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/9a/02/d7393257500edfd7879baeea431d",
+ "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/96/a2/6d45db766e7299b743156cce615b",
+ "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/52/b8/99e7cb5fa71615795f272ed2c518",
+ "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/5b/c7/6d474e85577975383f1ae1fc7377",
+ "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a1/df/9bd1a6b76c8bf77917753959195f",
+ "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/cb/da/9d073b52363b6bc0240fe5e79bf1",
+ "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/c9/23/7060ae125ba189320190b321d947",
+ "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/0f/43/49d1bee57cb412e0b7ba59dd9769",
+ "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e0/38/826f664787217ca44d569a5723d7",
+ "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/36/57/0c4fc5b9294b04a312ccd8832254",
+ "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/98/90/f34fd62f7d512cffd3c9534da10b",
+ "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/cb/a5/5d01935a74a86efcf1885975cfbf",
+ "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/c7/78/a8765f7463f3651c71cd5ca71cb6",
+ "build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cb/3b/83e6150eebf4fa7b5e8a7b863219",
+ "build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/44/70/2878a73f2d55849cd6a75e401575",
+ "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/24/49/4baca475df5c7f87e6ed17d46696",
+ "build/prefab/lib/linux_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b9/46/1c6b679ef9db6807100bc0bba261",
+ "build/prefab/lib/linux_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/da/b9/427dfd7ae8efbe6009964fe34beb",
+ "build/prefab/lib/linux_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/ac/2c/fc0a576c3d957896bfd3de792af6",
+ "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/39/08/4033ab823798c48b3446c032a72c",
+ "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/3a/c6/189744027136a7411d5dfa5c5cf4",
+ "build/prefab/lib/mac_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/7a/c6/bb533c59368fdf45d65812f37a27",
+ "build/prefab/lib/mac_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/3c/6e/88180b8e905df7453e0f989da027",
+ "build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/2c/99/e35a71c9c410da4035a0456314b6",
+ "build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/47/b2/bb092304cd5e5f3bdb6e085197de",
+ "build/prefab/lib/mac_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/9c/ce/523a32e2dce1174df21373ab5765",
+ "build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b2/ee/b908410d85c763d5bca09a3bc893",
+ "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/fd/24/8c51e5752064d4b487dff42a7ffa",
+ "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/93/c6/40e9e47dd33c88646510212ff321",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/a1/25/ca39d9309b63ed38bee3be1f9e67",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/b3/ef/a6240c67194508ac7bd58ba73391",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/c2/cc/b081c59aa873304086d2e44c1f30",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/d3/cb/8c1ed9ee3e8f1b0d866160257506",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/f0/f8/fb0aa403b29d5c5a9ebb5aec66fb",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/0b/03/ed35c58e80013f47b57c838c12d4",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/18/1d/cfc42a4939904783c897322207aa",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/68/82/c02d830bdb12f57ae976c5acc4fb",
+ "src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/23/ce/68396b1b7ec6d2f8425902148140",
"src/ballistica/generated/python_embedded/bootstrap.inc": "https://files.ballistica.net/cache/ba1/2d/4f/f4fe67827f36cd59cd5193333a02",
"src/ballistica/generated/python_embedded/bootstrap_monolithic.inc": "https://files.ballistica.net/cache/ba1/ef/c1/aa5f1aa10af89f5c0b1e616355fd"
}
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 955f5b44..068328e0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,9 +16,9 @@ jobs:
check_linux:
runs-on: ubuntu-20.04
steps:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v3
- name: Set up Python
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
@@ -31,9 +31,9 @@ jobs:
compile_linux:
runs-on: ubuntu-20.04
steps:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v3
- name: Set up Python
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Compile binary
@@ -47,9 +47,9 @@ jobs:
check_and_compile_windows:
runs-on: windows-2019
steps:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v3
- name: Set up Python
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml
index 89615536..5f3cb01c 100644
--- a/.idea/dictionaries/ericf.xml
+++ b/.idea/dictionaries/ericf.xml
@@ -73,6 +73,7 @@
allsettings
allteams
allwarnings
+ alogins
aman
amazonaws
aname
@@ -114,6 +115,7 @@
appmode
appname
appnameupper
+ appnow
appspot
appstate
appstore
@@ -184,6 +186,7 @@
availplug
aval
awaitable
+ awaitables
axismotion
bacfg
backgrounded
@@ -374,6 +377,7 @@
certifi
cfconfig
cfenv
+ cfgdict
cfgdir
cfgkey
cfgkeys
@@ -677,6 +681,7 @@
dosomething
dostar
dostuff
+ downcasting
downmix
dpad
dpath
@@ -718,6 +723,7 @@
dummyret
dummytoken
dummyval
+ dumpminlog
dups
dval
dxml
@@ -726,6 +732,7 @@
eaddrnotavail
easteregghunt
echofile
+ echotime
edcc
editcontroller
editgame
@@ -848,6 +855,7 @@
fakeshake
fallbacks
farthestpt
+ faulthandler
fback
fbase
fclose
@@ -858,6 +866,7 @@
fcontents
fcount
fdata
+ fdcount
fdesc
fdict
fdout
@@ -1271,6 +1280,7 @@
iprof
isatty
iscale
+ iscoroutinefunction
iserverget
iserverput
ispunch
@@ -1372,6 +1382,7 @@
levelmodule
levelname
levelno
+ levelnos
levelstr
lfull
lfval
@@ -1522,6 +1533,7 @@
masterhash
masterver
mastervers
+ matchlines
mathmodule
mathnode
mathutils
@@ -1537,6 +1549,7 @@
mdiv
mdocs
mdocslines
+ mdpath
mdpi
megalint
memfunctions
@@ -1564,6 +1577,7 @@
minigame
minigames
miniplayer
+ minlog
minping
minusbutton
minval
@@ -2337,6 +2351,7 @@
shobs
shortname
shouldn
+ showbuffer
showpoints
showstats
showsubseconds
@@ -2513,6 +2528,7 @@
svne
svvv
swht
+ swidth
swiftc
swip
swipsound
@@ -2549,6 +2565,9 @@
targs
tasklabel
tbegin
+ tbfile
+ tbfiles
+ tbpath
tbtcolor
tbtn
tbttxt
@@ -2850,6 +2869,7 @@
wanttype
warntype
wasdead
+ wasn
wavenum
weakref
weakrefs
@@ -2859,7 +2879,9 @@
webpage
webpages
weeeird
+ whatarev
whatevs
+ whatisv
wheee
whos
widgetdeathtime
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6540f64e..b84d61d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,46 @@
-### 1.7.14 (build 20934, api 7, 2022-11-16)
+### 1.7.19 (build 20993, api 7, 2023-01-17)
+
+### 1.7.18 (build 20989, api 7, 2023-01-16)
+- Reworked some low level asynchronous messaging functionality in efro.message and efro.rpc. Previously these were a little *too* asynchronous which could lead to messages being received in a different order than they were sent, which is not desirable.
+- Added a way to suppress 'Your build is outdated' messages at launch (see `ba._hooks.show_client_too_old_error()`).
+
+### 1.7.17 (build 20983, api 7, 2023-01-09)
+- V2 accounts now show a 'Unlink Legacy (V1) Accounts' button in account settings if they have any old V1 links present. This can be used to clear out old links to replace them with V2 links which work correctly with V2 accounts.
+- `ba.internal.dump_tracebacks()` is now `ba.internal.dump_app_state()` and `ba.internal.log_dumped_tracebacks()` is now `ba.internal.log_dumped_app_state()`. This reflects the fact that these calls may be expanded to include other app state in the future (C++ layer thread states, etc.).
+- Added `ba.app.health_monitor` which will dump app state if the logic thread ever stops responding for 5+ seconds while the app is running (to help diagnose deadlock situations).
+- Various extra logging and bug fixes related to V2 accounts and master server communication (trying to get this stuff working as smoothly as possible now that it is feature-complete).
+
+### 1.7.16 (build 20969, api 7, 2022-12-18)
+- Fixed a bug where profile names encased in curly brackets could cause harmless error messages.
+- Android will no longer log errors on ba.open_url() calls if a browser is not available (it still just falls back to the in-app dialog in that case).
+- The 'Upgrade' button for device accounts now signs you out and closes the upgrade window to hopefully make it more clear that you need to sign in with your newly created/upgraded BombSquad account.
+- Fixed a bug where the remote app could not connect for the first 5 seconds after launching the app.
+- Added Malay language. Ick; apparently its been sitting done for a while and I hadn't realized it wasn't added to the game yet. Apologies!. And thanks to all contributors!
+- Added 'enable_queue' server config setting. This defaults to True but can be turned off as a workaround for server owners targeted by queue spam attacks.
+- The public party list no longer sorts servers without queues at the end of the list. This sorting was put there long ago to prioritize fancy new queue-supporting servers but now it would just make the few that opt out of queues hard to find. Doh. So opting out of queues is probably not a great idea until this build is widespread.
+- Public uuids now only change once every 6 months or so instead of with every version bump. This way periods of heavy development won't put added strain on server owners trying to keep ban lists up to date and whatnot.
+- Added a merch button in the in-game store that goes to the ballistica.net merch page (though it only shows up in the few countries where merch is available).
+
+### 1.7.15 (build 20960, api 7, 2022-12-04)
+- The cancel button on the 'Sign in with a Bombsquad Account' popup no longer respond to system cancel buttons (escape key, android back button, etc). Turns out some Android people were pressing back repeatedly to come back from a browser after signing in and immediately canceling their sign in attempts in the game before they completed. Hopefully this will avoid some frustration.
+- Fixed an issue where back presses could result in multiple main menu windows appearing.
+
+### 1.7.14 (build 20958, api 7, 2022-12-03)
- Android Google Play logins now provide V2 accounts with access to all V2 features such as a globally-unique account tag, cloud-console, and workspaces. They should still retain their V1 data as well.
- V2 accounts now have a 'Manage Account' button in the app account window which will sign you into a browser with your current account.
- Removed Google App Invite functionality which has been deprecated for a while now. Google Play users can still get tickets by sharing the app via codes (same as other platforms).
- Updated Android root-detection library to the latest version. Please holler if you are getting new false 'your device is rooted' errors when trying to play tournaments or anything like that.
- Removed a few obsolete internal functions: `_ba.is_ouya_build()`, `_ba.android_media_scan_file()`.
- Renaming some methods/data to disambiguate 'login' vs 'sign-in', both in the app and on ballistica.net. Those two terms are somewhat ambiguous and interchangeable in English and can either be a verb or a noun. I'd like to keep things clear in Ballistica by always using 'sign-in' for the verb form and 'login' for the noun. For example: 'You can now sign in to your account using your Google Play login'.
-- WARNING: There are currently some rough edges with Google Play V2 accounts; for example Google Play achievements and leaderboards UIs are not currently showing up. I will be cleaning all of this up before the official 1.7.14 release.
-- Commit Last Manual Party Connect Port to config. Previously, it always assumed the port to be 43210.
+- Fixed the 'your config is broken' dialog that shows on desktop builds if the game's config file is corrupt and can't be read. It should let you edit the config or replace it with a default.
+- `ba.printobjects()` is now `ba.ls_objects()`. It technically logs and doesn't print so the former name was a bit misleading.
+- Added `ba.ls_input_devices()` to dump debug info about the current set of input devices. Can be helpful to diagnose mysterious devices joining games unintentionally and things like that.
+- Added 'raw' bool arg to `ba.pushcall()`. Passing True for it disables context save/restore and thread checks.
+- Added `ba.internal.dump_tracebacks()` which can be used to dump the stack state of all Python threads after some delay. Useful for debugging deadlock; just call right before said deadlock occurs. Results will be logged on the next app launch if they cannot be immediately.
+- Fixed a low level event-loop issue that in some cases was preventing the Android version from properly pausing/resuming the app or managing connections while in the background. If you look at the devices section on ballistica.net you should now see your device disappear when you background the app and reappear when you foreground it. Please holler if not.
+- Device accounts are now marked as deprecated, and signing in with one now brings up an 'upgrade' UI which allows converting it to a V2 account. It is my hope to push the entire client ecosystem to V2 accounts as quickly as possible since trying to support both independent V1 accounts and V2 accounts is a substantial technical burden.
+- Fixed an issue where Log calls made within `Thread::PushThreadMessage()` could result in deadlock.
+- Fixed an issue where some Android hardware buttons could theoretically cause rogue game controller button presses (due to downcasting int values > 255 into a uint8 value).
### 1.7.13 (build 20919, api 7, 2022-11-03)
- Android target-sdk has been updated to 33 (Android 13). Please holler if anything seems broken or is behaving differently than before on Android.
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 79ecd547..378c525a 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -38,4 +38,8 @@
### Vishal332008
- Bug Fixer
-- Modder
\ No newline at end of file
+- Modder
+
+### Era0S
+- Fixed a single bug
+- Modder
diff --git a/assets/.asset_manifest_private.json b/assets/.asset_manifest_private.json
index 68e5523f..6b62d1ec 100644
--- a/assets/.asset_manifest_private.json
+++ b/assets/.asset_manifest_private.json
@@ -441,6 +441,7 @@
"ba_data/data/languages/indonesian.json",
"ba_data/data/languages/italian.json",
"ba_data/data/languages/korean.json",
+ "ba_data/data/languages/malay.json",
"ba_data/data/languages/persian.json",
"ba_data/data/languages/polish.json",
"ba_data/data/languages/portuguese.json",
@@ -1880,6 +1881,10 @@
"ba_data/textures/menuIcon.ktx",
"ba_data/textures/menuIcon.pvr",
"ba_data/textures/menuIcon_preview.png",
+ "ba_data/textures/merch.dds",
+ "ba_data/textures/merch.ktx",
+ "ba_data/textures/merch.pvr",
+ "ba_data/textures/merch_preview.png",
"ba_data/textures/meter.dds",
"ba_data/textures/meter.ktx",
"ba_data/textures/meter.pvr",
diff --git a/assets/.asset_manifest_public.json b/assets/.asset_manifest_public.json
index d6172785..895eda83 100644
--- a/assets/.asset_manifest_public.json
+++ b/assets/.asset_manifest_public.json
@@ -356,6 +356,7 @@
"ba_data/python/bastd/ui/__pycache__/tournamentscores.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/trophies.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/url.cpython-310.opt-1.pyc",
+ "ba_data/python/bastd/ui/__pycache__/v2upgrade.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/__pycache__/watch.cpython-310.opt-1.pyc",
"ba_data/python/bastd/ui/account/__init__.py",
"ba_data/python/bastd/ui/account/__pycache__/__init__.cpython-310.opt-1.pyc",
@@ -514,6 +515,7 @@
"ba_data/python/bastd/ui/tournamentscores.py",
"ba_data/python/bastd/ui/trophies.py",
"ba_data/python/bastd/ui/url.py",
+ "ba_data/python/bastd/ui/v2upgrade.py",
"ba_data/python/bastd/ui/watch.py",
"ba_data/python/efro/__init__.py",
"ba_data/python/efro/__pycache__/__init__.cpython-310.opt-1.pyc",
diff --git a/assets/Makefile b/assets/Makefile
index 63833361..2776b045 100644
--- a/assets/Makefile
+++ b/assets/Makefile
@@ -381,6 +381,7 @@ SCRIPT_TARGETS_PY_PUBLIC = \
build/ba_data/python/bastd/ui/tournamentscores.py \
build/ba_data/python/bastd/ui/trophies.py \
build/ba_data/python/bastd/ui/url.py \
+ build/ba_data/python/bastd/ui/v2upgrade.py \
build/ba_data/python/bastd/ui/watch.py \
build/server/ballisticacore_server.py
@@ -633,6 +634,7 @@ SCRIPT_TARGETS_PYC_PUBLIC = \
build/ba_data/python/bastd/ui/__pycache__/tournamentscores.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/trophies.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/url.cpython-310.opt-1.pyc \
+ build/ba_data/python/bastd/ui/__pycache__/v2upgrade.cpython-310.opt-1.pyc \
build/ba_data/python/bastd/ui/__pycache__/watch.cpython-310.opt-1.pyc \
build/server/__pycache__/ballisticacore_server.cpython-310.opt-1.pyc
@@ -4915,6 +4917,7 @@ DATA_TARGETS = \
build/ba_data/data/languages/indonesian.json \
build/ba_data/data/languages/italian.json \
build/ba_data/data/languages/korean.json \
+ build/ba_data/data/languages/malay.json \
build/ba_data/data/languages/persian.json \
build/ba_data/data/languages/polish.json \
build/ba_data/data/languages/portuguese.json \
@@ -5595,6 +5598,7 @@ TEX2D_DDS_TARGETS = \
build/ba_data/textures/menuBG.dds \
build/ba_data/textures/menuButton.dds \
build/ba_data/textures/menuIcon.dds \
+ build/ba_data/textures/merch.dds \
build/ba_data/textures/meter.dds \
build/ba_data/textures/monkeyFaceLevelColor.dds \
build/ba_data/textures/monkeyFacePreview.dds \
@@ -6000,6 +6004,7 @@ TEX2D_PVR_TARGETS = \
build/ba_data/textures/menuBG.pvr \
build/ba_data/textures/menuButton.pvr \
build/ba_data/textures/menuIcon.pvr \
+ build/ba_data/textures/merch.pvr \
build/ba_data/textures/meter.pvr \
build/ba_data/textures/monkeyFaceLevelColor.pvr \
build/ba_data/textures/monkeyFacePreview.pvr \
@@ -6405,6 +6410,7 @@ TEX2D_KTX_TARGETS = \
build/ba_data/textures/menuBG.ktx \
build/ba_data/textures/menuButton.ktx \
build/ba_data/textures/menuIcon.ktx \
+ build/ba_data/textures/merch.ktx \
build/ba_data/textures/meter.ktx \
build/ba_data/textures/monkeyFaceLevelColor.ktx \
build/ba_data/textures/monkeyFacePreview.ktx \
@@ -6810,6 +6816,7 @@ TEX2D_PREVIEW_PNG_TARGETS = \
build/ba_data/textures/menuBG_preview.png \
build/ba_data/textures/menuButton_preview.png \
build/ba_data/textures/menuIcon_preview.png \
+ build/ba_data/textures/merch_preview.png \
build/ba_data/textures/meter_preview.png \
build/ba_data/textures/monkeyFaceLevelColor_preview.png \
build/ba_data/textures/monkeyFacePreview_preview.png \
diff --git a/assets/src/ba_data/python/._ba_sources_hash b/assets/src/ba_data/python/._ba_sources_hash
index bb72bb71..71902207 100644
--- a/assets/src/ba_data/python/._ba_sources_hash
+++ b/assets/src/ba_data/python/._ba_sources_hash
@@ -1 +1 @@
-136821726394202151644063370854718971574
\ No newline at end of file
+71864217068887285722858773141608052966
\ No newline at end of file
diff --git a/assets/src/ba_data/python/_ba.py b/assets/src/ba_data/python/_ba.py
index 671c8d6d..eb4a7c75 100644
--- a/assets/src/ba_data/python/_ba.py
+++ b/assets/src/ba_data/python/_ba.py
@@ -2429,12 +2429,42 @@ def lock_all_input() -> None:
return None
+def login_adapter_back_end_active_change(login_type: str, active: bool) -> None:
+
+ """(internal)"""
+ return None
+
+
def login_adapter_get_sign_in_token(login_type: str, attempt_id: int) -> None:
"""(internal)"""
return None
+def ls_input_devices() -> None:
+
+ """Print debugging info about game objects.
+
+ Category: **General Utility Functions**
+
+ This call only functions in debug builds of the game.
+ It prints various info about the current object count, etc.
+ """
+ return None
+
+
+def ls_objects() -> None:
+
+ """Log debugging info about C++ level objects.
+
+ Category: **General Utility Functions**
+
+ This call only functions in debug builds of the game.
+ It prints various info about the current object count, etc.
+ """
+ return None
+
+
def mac_music_app_get_library_source() -> None:
"""(internal)"""
@@ -2653,32 +2683,17 @@ def printnodes() -> None:
return None
-def printobjects() -> None:
-
- """Print debugging info about game objects.
-
- Category: **General Utility Functions**
-
- This call only functions in debug builds of the game.
- It prints various info about the current object count, etc.
- """
- return None
-
-
def pushcall(
call: Callable,
from_other_thread: bool = False,
suppress_other_thread_warning: bool = False,
other_thread_use_fg_context: bool = False,
+ raw: bool = False,
) -> None:
- """Pushes a call onto the event loop to be run during the next cycle.
-
+ """Push a call to the logic event-loop.
Category: **General Utility Functions**
- This can be handy for calls that are disallowed from within other
- callbacks, etc.
-
This call expects to be used in the logic thread, and will automatically
save and restore the ba.Context to behave seamlessly.
@@ -2687,6 +2702,7 @@ def pushcall(
the call will always run in the UI context on the logic thread
or whichever context is in the foreground if
other_thread_use_fg_context is True.
+ Passing raw=True will disable thread checks and context sets/restores.
"""
return None
@@ -3009,6 +3025,12 @@ def set_public_party_name(name: str) -> None:
return None
+def set_public_party_queue_enabled(max_size: bool) -> None:
+
+ """(internal)"""
+ return None
+
+
def set_public_party_stats_url(url: str | None) -> None:
"""(internal)"""
diff --git a/assets/src/ba_data/python/_bainternal.py b/assets/src/ba_data/python/_bainternal.py
index d1bf8109..99fd8ee4 100644
--- a/assets/src/ba_data/python/_bainternal.py
+++ b/assets/src/ba_data/python/_bainternal.py
@@ -252,7 +252,6 @@ def submit_score(
name: Any,
score: int | None,
callback: Callable,
- friend_callback: Callable | None,
order: str = 'increasing',
tournament_id: str | None = None,
score_type: str = 'points',
diff --git a/assets/src/ba_data/python/ba/__init__.py b/assets/src/ba_data/python/ba/__init__.py
index 290d8482..82738c9e 100644
--- a/assets/src/ba_data/python/ba/__init__.py
+++ b/assets/src/ba_data/python/ba/__init__.py
@@ -43,7 +43,8 @@ from _ba import (
newnode,
playsound,
printnodes,
- printobjects,
+ ls_objects,
+ ls_input_devices,
pushcall,
quit,
rowwidget,
@@ -316,7 +317,8 @@ __all__ = [
'print_error',
'print_exception',
'printnodes',
- 'printobjects',
+ 'ls_objects',
+ 'ls_input_devices',
'pushcall',
'quit',
'rowwidget',
diff --git a/assets/src/ba_data/python/ba/_accountv1.py b/assets/src/ba_data/python/ba/_accountv1.py
index c2b81164..7f946097 100644
--- a/assets/src/ba_data/python/ba/_accountv1.py
+++ b/assets/src/ba_data/python/ba/_accountv1.py
@@ -48,6 +48,9 @@ class AccountV1Subsystem:
_ba.pushcall(do_auto_sign_in)
+ def on_app_pause(self) -> None:
+ """Should be called when app is pausing."""
+
def on_app_resume(self) -> None:
"""Should be called when the app is resumed."""
diff --git a/assets/src/ba_data/python/ba/_accountv2.py b/assets/src/ba_data/python/ba/_accountv2.py
index 32870fe0..5e676230 100644
--- a/assets/src/ba_data/python/ba/_accountv2.py
+++ b/assets/src/ba_data/python/ba/_accountv2.py
@@ -4,17 +4,24 @@
from __future__ import annotations
+import hashlib
+import logging
from typing import TYPE_CHECKING
+from efro.call import tpartial
+from efro.error import CommunicationError
+from bacommon.login import LoginType
import _ba
if TYPE_CHECKING:
from typing import Any
- from bacommon.login import LoginType
from ba._login import LoginAdapter
+DEBUG_LOG = False
+
+
class AccountV2Subsystem:
"""Subsystem for modern account handling in the app.
@@ -24,20 +31,20 @@ class AccountV2Subsystem:
"""
def __init__(self) -> None:
- from bacommon.login import LoginType
# Whether or not everything related to an initial login
# (or lack thereof) has completed. This includes things like
# workspace syncing. Completion of this is what flips the app
# into 'running' state.
- self._initial_login_completed = False
+ self._initial_sign_in_completed = False
self._kicked_off_workspace_load = False
self.login_adapters: dict[LoginType, LoginAdapter] = {}
self._implicit_signed_in_adapter: LoginAdapter | None = None
- self._auto_signed_in = False
+ self._implicit_state_changed = False
+ self._can_do_auto_sign_in = True
if _ba.app.platform == 'android' and _ba.app.subplatform == 'google':
from ba._login import LoginAdapterGPGS
@@ -91,7 +98,7 @@ class AccountV2Subsystem:
if account.workspaceid is not None:
assert account.workspacename is not None
if (
- not self._initial_login_completed
+ not self._initial_sign_in_completed
and not self._kicked_off_workspace_load
):
self._kicked_off_workspace_load = True
@@ -114,9 +121,9 @@ class AccountV2Subsystem:
return
# Ok; no workspace to worry about; carry on.
- if not self._initial_login_completed:
- self._initial_login_completed = True
- _ba.app.on_initial_login_completed()
+ if not self._initial_sign_in_completed:
+ self._initial_sign_in_completed = True
+ _ba.app.on_initial_sign_in_completed()
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
"""Should be called when logins for the active account change."""
@@ -127,7 +134,7 @@ class AccountV2Subsystem:
def on_implicit_sign_in(
self, login_type: LoginType, login_id: str, display_name: str
) -> None:
- """An implicit login happened."""
+ """An implicit sign-in happened (called by native layer)."""
from ba._login import LoginAdapter
with _ba.Context('ui'):
@@ -138,7 +145,7 @@ class AccountV2Subsystem:
)
def on_implicit_sign_out(self, login_type: LoginType) -> None:
- """An implicit logout happened."""
+ """An implicit sign-out happened (called by native layer)."""
with _ba.Context('ui'):
self.login_adapters[login_type].set_implicit_login_state(None)
@@ -149,9 +156,15 @@ class AccountV2Subsystem:
within a few seconds of app launch; the app can move forward
with the startup sequence at that point.
"""
- if not self._initial_login_completed:
- self._initial_login_completed = True
- _ba.app.on_initial_login_completed()
+ if not self._initial_sign_in_completed:
+ self._initial_sign_in_completed = True
+ _ba.app.on_initial_sign_in_completed()
+
+ @staticmethod
+ def _hashstr(val: str) -> str:
+ md5 = hashlib.md5()
+ md5.update(val.encode())
+ return md5.hexdigest()
def on_implicit_login_state_changed(
self,
@@ -160,18 +173,75 @@ class AccountV2Subsystem:
) -> None:
"""Called when implicit login state changes.
- Logins that tend to sign themselves in/out in the background are
- considered implicit. We may choose to honor or ignore their states,
- allowing the user to opt for other login types even if the default
- implicit one can't be explicitly logged out or otherwise controlled.
+ Login systems that tend to sign themselves in/out in the
+ background are considered implicit. We may choose to honor or
+ ignore their states, allowing the user to opt for other login
+ types even if the default implicit one can't be explicitly
+ logged out or otherwise controlled.
"""
+ from ba._language import Lstr
+
assert _ba.in_logic_thread()
+ cfg = _ba.app.config
+ cfgkey = 'ImplicitLoginStates'
+ cfgdict = _ba.app.config.setdefault(cfgkey, {})
+
# Store which (if any) adapter is currently implicitly signed in.
+ # Making the assumption there will only ever be one implicit
+ # adapter at a time; may need to update this if that changes.
+ prev_state = cfgdict.get(login_type.value)
if state is None:
self._implicit_signed_in_adapter = None
+ new_state = cfgdict[login_type.value] = None
else:
self._implicit_signed_in_adapter = self.login_adapters[login_type]
+ new_state = cfgdict[login_type.value] = self._hashstr(
+ state.login_id
+ )
+
+ # Special case: if the user is already signed in but not with
+ # this implicit login, we may want to let them know that the
+ # 'Welcome back FOO' they likely just saw is not actually
+ # accurate.
+ if (
+ self.primary is not None
+ and not self.login_adapters[login_type].is_back_end_active()
+ ):
+ if login_type is LoginType.GPGS:
+ service_str = Lstr(resource='googlePlayText')
+ else:
+ service_str = None
+ if service_str is not None:
+ _ba.timer(
+ 2.0,
+ tpartial(
+ _ba.screenmessage,
+ Lstr(
+ resource='notUsingAccountText',
+ subs=[
+ ('${ACCOUNT}', state.display_name),
+ ('${SERVICE}', service_str),
+ ],
+ ),
+ (1, 0.5, 0),
+ ),
+ )
+
+ cfg.commit()
+
+ # We want to respond any time the implicit state changes;
+ # generally this means the user has explicitly signed in/out or
+ # switched accounts within that back-end.
+ if prev_state != new_state:
+ if DEBUG_LOG:
+ logging.debug(
+ 'AccountV2: Implicit state changed (%s -> %s);'
+ ' will update app sign-in state accordingly.',
+ prev_state,
+ new_state,
+ )
+ self._implicit_state_changed = True
# We may want to auto-sign-in based on this new state.
self._update_auto_sign_in()
@@ -187,12 +257,59 @@ class AccountV2Subsystem:
def _update_auto_sign_in(self) -> None:
from ba._internal import get_v1_account_state
- # We attempt auto-sign-in only once.
- if self._auto_signed_in:
+ # If implicit state has changed, try to respond.
+ if self._implicit_state_changed:
+ if self._implicit_signed_in_adapter is None:
+ # If implicit back-end is signed out, follow suit
+ # immediately; no need to wait for network connectivity.
+ if DEBUG_LOG:
+ logging.debug(
+ 'AccountV2: Signing out as result'
+ ' of implicit state change...',
+ )
+ _ba.app.accounts_v2.set_primary_credentials(None)
+ self._implicit_state_changed = False
+
+ # Once we've made a move here we don't want to
+ # do any more automatic stuff.
+ self._can_do_auto_sign_in = False
+
+ else:
+ # Ok; we've got a new implicit state. If we've got
+ # connectivity, let's attempt to sign in with it.
+ # Consider this an 'explicit' sign in because the
+ # implicit-login state change presumably was triggered
+ # by some user action (signing in, signing out, or
+ # switching accounts via the back-end).
+ # NOTE: should test case where we don't have
+ # connectivity here.
+ if _ba.app.cloud.is_connected():
+ if DEBUG_LOG:
+ logging.debug(
+ 'AccountV2: Signing in as result'
+ ' of implicit state change...',
+ )
+ self._implicit_signed_in_adapter.sign_in(
+ self._on_explicit_sign_in_completed,
+ description='implicit state change',
+ )
+ self._implicit_state_changed = False
+
+ # Once we've made a move here we don't want to
+ # do any more automatic stuff.
+ self._can_do_auto_sign_in = False
+
+ if not self._can_do_auto_sign_in:
return
# If we're not currently signed in, we have connectivity, and
- # we have an available implicit adapter, do an auto-sign-in.
+ # we have an available implicit login, auto-sign-in with it once.
+ # The implicit-state-change logic above should keep things
+ # mostly in-sync, but that might not always be the case due to
+ # connectivity or other issues. We prefer to keep people signed
+ # in as a rule, even if there are corner cases where this might
+ # not be what they want (A user signing out and then restarting
+ # may be auto-signed back in).
connected = _ba.app.cloud.is_connected()
signed_in_v1 = get_v1_account_state() == 'signed_in'
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
@@ -202,24 +319,78 @@ class AccountV2Subsystem:
and not signed_in_v2
and self._implicit_signed_in_adapter is not None
):
- self._auto_signed_in = True # Only attempt this once
- self._implicit_signed_in_adapter.sign_in(self._on_sign_in_completed)
+ if DEBUG_LOG:
+ logging.debug(
+ 'AccountV2: Signing in due to on-launch-auto-sign-in...',
+ )
+ self._can_do_auto_sign_in = False # Only ATTEMPT once
+ self._implicit_signed_in_adapter.sign_in(
+ self._on_implicit_sign_in_completed, description='auto-sign-in'
+ )
- def _on_sign_in_completed(
+ def _on_explicit_sign_in_completed(
self,
adapter: LoginAdapter,
result: LoginAdapter.SignInResult | Exception,
) -> None:
+ """A sign-in has completed that the user asked for explicitly."""
+ from ba._language import Lstr
+
+ del adapter # Unused.
+
+ # Make some noise on errors since the user knows a
+ # sign-in attempt is happening in this case (the 'explicit' part).
+ if isinstance(result, Exception):
+ # We expect the occasional communication errors;
+ # Log a full exception for anything else though.
+ if not isinstance(result, CommunicationError):
+ logging.warning(
+ 'Error on explicit accountv2 sign in attempt.',
+ exc_info=result,
+ )
+
+ # For now just show 'error'. Should do better than this.
+ with _ba.Context('ui'):
+ _ba.screenmessage(
+ Lstr(resource='internal.signInErrorText'),
+ color=(1, 0, 0),
+ )
+ _ba.playsound(_ba.getsound('error'))
+
+ # Also I suppose we should sign them out in this case since
+ # it could be misleading to be still signed in with the old
+ # account.
+ _ba.app.accounts_v2.set_primary_credentials(None)
+ return
+
+ _ba.app.accounts_v2.set_primary_credentials(result.credentials)
+
+ def _on_implicit_sign_in_completed(
+ self,
+ adapter: LoginAdapter,
+ result: LoginAdapter.SignInResult | Exception,
+ ) -> None:
+ """A sign-in has completed that the user didn't ask for explicitly."""
from ba._internal import get_v1_account_state
del adapter # Unused.
- # Silently ignore errors.
+ # Log errors but don't inform the user; they're not aware of this
+ # attempt and ignorance is bliss.
if isinstance(result, Exception):
+ # We expect the occasional communication errors;
+ # Log a full exception for anything else though.
+ if not isinstance(result, CommunicationError):
+ logging.warning(
+ 'Error on implicit accountv2 sign in attempt.',
+ exc_info=result,
+ )
return
# If we're still connected and still not signed in,
- # plug in the credentials we got.
+ # plug in the credentials we got. We want to be extra cautious
+ # in case the user has since explicitly signed in since we
+ # kicked off.
connected = _ba.app.cloud.is_connected()
signed_in_v1 = get_v1_account_state() == 'signed_in'
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
@@ -227,9 +398,9 @@ class AccountV2Subsystem:
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
def _on_set_active_workspace_completed(self) -> None:
- if not self._initial_login_completed:
- self._initial_login_completed = True
- _ba.app.on_initial_login_completed()
+ if not self._initial_sign_in_completed:
+ self._initial_sign_in_completed = True
+ _ba.app.on_initial_sign_in_completed()
class AccountV2Handle:
diff --git a/assets/src/ba_data/python/ba/_app.py b/assets/src/ba_data/python/ba/_app.py
index 46ac8e6f..9c44d333 100644
--- a/assets/src/ba_data/python/ba/_app.py
+++ b/assets/src/ba_data/python/ba/_app.py
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
from bastd.actor import spazappearance
from ba._accountv2 import AccountV2Subsystem
from ba._level import Level
+ from ba._apputils import AppHealthMonitor
class App:
@@ -50,7 +51,9 @@ class App:
# Implementations for these will be filled in by internal libs.
accounts_v2: AccountV2Subsystem
cloud: CloudSubsystem
+
log_handler: efro.log.LogHandler
+ health_monitor: AppHealthMonitor
class State(Enum):
"""High level state the app can be in."""
@@ -232,7 +235,7 @@ class App:
self.state = self.State.LAUNCHING
self._launch_completed = False
- self._initial_login_completed = False
+ self._initial_sign_in_completed = False
self._meta_scan_completed = False
self._called_on_app_running = False
self._app_paused = False
@@ -346,7 +349,6 @@ class App:
# pylint: disable=cyclic-import
# pylint: disable=too-many-locals
from ba import _asyncio
- from ba import _apputils
from ba import _appconfig
from ba import _map
from ba import _campaign
@@ -354,10 +356,16 @@ class App:
from bastd import maps as stdmaps
from bastd.actor import spazappearance
from ba._generated.enums import TimeType
+ from ba._apputils import (
+ log_dumped_app_state,
+ handle_leftover_v1_cloud_log_file,
+ AppHealthMonitor,
+ )
assert _ba.in_logic_thread()
self._aioloop = _asyncio.setup_asyncio()
+ self.health_monitor = AppHealthMonitor()
cfg = self.config
@@ -401,15 +409,15 @@ class App:
# If there's a leftover log file, attempt to upload it to the
# master-server and/or get rid of it.
- _apputils.handle_leftover_v1_cloud_log_file()
+ handle_leftover_v1_cloud_log_file()
# Only do this stuff if our config file is healthy so we don't
# overwrite a broken one or whatnot and wipe out data.
if not self.config_file_healthy:
if self.platform in ('mac', 'linux', 'windows'):
- from bastd.ui import configerror
+ from bastd.ui.configerror import ConfigErrorWindow
- configerror.ConfigErrorWindow()
+ _ba.pushcall(ConfigErrorWindow)
return
# For now on other systems we just overwrite the bum config.
@@ -459,6 +467,9 @@ class App:
'on_app_launch found state %s; expected LAUNCHING.', self.state
)
+ # If any traceback dumps happened last run, log and clear them.
+ log_dumped_app_state()
+
self._launch_completed = True
self._update_state()
@@ -483,9 +494,24 @@ class App:
assert _ba.in_logic_thread()
if self._app_paused:
- self.state = self.State.PAUSED
+ # Entering paused state:
+ if self.state is not self.State.PAUSED:
+ self.state = self.State.PAUSED
+ self.cloud.on_app_pause()
+ self.accounts_v1.on_app_pause()
+ self.plugins.on_app_pause()
+ self.health_monitor.on_app_pause()
else:
- if self._initial_login_completed and self._meta_scan_completed:
+ # Leaving paused state:
+ if self.state is self.State.PAUSED:
+ self.fg_state += 1
+ self.cloud.on_app_resume()
+ self.accounts_v1.on_app_resume()
+ self.music.on_app_resume()
+ self.plugins.on_app_resume()
+ self.health_monitor.on_app_resume()
+
+ if self._initial_sign_in_completed and self._meta_scan_completed:
self.state = self.State.RUNNING
if not self._called_on_app_running:
self._called_on_app_running = True
@@ -498,19 +524,16 @@ class App:
def on_app_pause(self) -> None:
"""Called when the app goes to a suspended state."""
+ assert not self._app_paused # Should avoid redundant calls.
self._app_paused = True
self._update_state()
- self.plugins.on_app_pause()
def on_app_resume(self) -> None:
"""Run when the app resumes from a suspended state."""
+ assert self._app_paused # Should avoid redundant calls.
self._app_paused = False
self._update_state()
- self.fg_state += 1
- self.accounts_v1.on_app_resume()
- self.music.on_app_resume()
- self.plugins.on_app_resume()
def on_app_shutdown(self) -> None:
"""(internal)"""
@@ -701,8 +724,8 @@ class App:
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
- def on_initial_login_completed(self) -> None:
- """Callback to be run after initial login process (or lack thereof).
+ def on_initial_sign_in_completed(self) -> None:
+ """Callback to be run after initial sign-in (or lack thereof).
This period includes things such as syncing account workspaces
or other data so it may take a substantial amount of time.
@@ -713,5 +736,5 @@ class App:
# (account workspaces).
self.meta.start_extra_scan()
- self._initial_login_completed = True
+ self._initial_sign_in_completed = True
self._update_state()
diff --git a/assets/src/ba_data/python/ba/_apputils.py b/assets/src/ba_data/python/ba/_apputils.py
index 166b4d33..af4a7769 100644
--- a/assets/src/ba_data/python/ba/_apputils.py
+++ b/assets/src/ba_data/python/ba/_apputils.py
@@ -5,12 +5,17 @@ from __future__ import annotations
import gc
import os
+import logging
+from threading import Thread
+from dataclasses import dataclass
from typing import TYPE_CHECKING
+from efro.log import LogLevel
+from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
import _ba
if TYPE_CHECKING:
- from typing import Any
+ from typing import Any, TextIO
import ba
@@ -260,3 +265,245 @@ def print_corrupt_file_error() -> None:
_ba.timer(
2.0, Call(_ba.playsound, _ba.getsound('error')), timetype=TimeType.REAL
)
+
+
+_tbfiles: list[TextIO] = []
+
+
+@ioprepped
+@dataclass
+class DumpedAppStateMetadata:
+ """High level info about a dumped app state."""
+
+ reason: str
+ app_time: float
+ log_level: LogLevel
+
+
+def dump_app_state(
+ delay: float = 0.0,
+ reason: str = 'Unspecified',
+ log_level: LogLevel = LogLevel.WARNING,
+) -> None:
+ """Dump various app state for debugging purposes.
+
+ This includes stack traces for all Python threads (and potentially
+ other info in the future).
+
+ This is intended for use debugging deadlock situations. It will dump
+ to preset file location(s) in the app config dir, and will attempt to
+ log and clear the results after dumping. If that should fail (due to
+ a hung app, etc.), then the results will be logged and cleared on the
+ next app run.
+
+ Do not use this call during regular smooth operation of the app; it
+ is should only be used for debugging or in response to confirmed
+ problems as it can leak file descriptors, cause hitches, etc.
+ """
+ # pylint: disable=consider-using-with
+ import faulthandler
+ from ba._generated.enums import TimeType
+
+ # Dump our metadata immediately. If a delay is passed, it generally
+ # means we expect things to hang momentarily, so we should not delay
+ # writing our metadata or it will likely not happen. Though we
+ # should remember that metadata doesn't line up perfectly in time with
+ # the dump in that case.
+ try:
+ mdpath = os.path.join(
+ os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
+ )
+ with open(mdpath, 'w', encoding='utf-8') as outfile:
+ outfile.write(
+ dataclass_to_json(
+ DumpedAppStateMetadata(
+ reason=reason,
+ app_time=_ba.time(TimeType.REAL),
+ log_level=log_level,
+ )
+ )
+ )
+ except Exception:
+ # Abandon whole dump if we can't write metadata.
+ logging.exception('Error writing app state dump metadata.')
+ return
+
+ tbpath = os.path.join(
+ os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
+ )
+
+ # faulthandler needs the raw file descriptor to still be valid when
+ # it fires, so stuff this into a global var to make sure it doesn't get
+ # cleaned up.
+ tbfile = open(tbpath, 'w', encoding='utf-8')
+ _tbfiles.append(tbfile)
+
+ if delay > 0.0:
+ faulthandler.dump_traceback_later(delay, file=tbfile)
+ else:
+ faulthandler.dump_traceback(file=tbfile)
+
+ # Attempt to log shortly after dumping.
+ # Allow sufficient time since we don't know how long the dump takes.
+ # We want this to work from any thread, so need to kick this part
+ # over to the logic thread so timer works.
+ _ba.pushcall(
+ lambda: _ba.timer(
+ delay + 1.0, log_dumped_app_state, timetype=TimeType.REAL
+ ),
+ from_other_thread=True,
+ suppress_other_thread_warning=True,
+ )
+
+
+def log_dumped_app_state() -> None:
+ """If an app-state dump exists, log it and clear it. No-op otherwise."""
+
+ try:
+ out = ''
+ mdpath = os.path.join(
+ os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
+ )
+ if os.path.exists(mdpath):
+ with open(mdpath, 'r', encoding='utf-8') as infile:
+ metadata = dataclass_from_json(
+ DumpedAppStateMetadata, infile.read()
+ )
+ os.unlink(mdpath)
+ out += (
+ f'App state dump:\nReason: {metadata.reason}\n'
+ f'Time: {metadata.app_time:.2f}'
+ )
+ tbpath = os.path.join(
+ os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
+ )
+ if os.path.exists(tbpath):
+ with open(tbpath, 'r', encoding='utf-8') as infile:
+ out += '\nPython tracebacks:\n' + infile.read()
+ os.unlink(tbpath)
+ logging.log(metadata.log_level.python_logging_level, out)
+ except Exception:
+ logging.exception('Error logging dumped app state.')
+
+
+class AppHealthMonitor:
+ """Logs things like app-not-responding issues."""
+
+ def __init__(self) -> None:
+ assert _ba.in_logic_thread()
+ self._running = True
+ self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
+ self._thread.start()
+ self._response = False
+ self._first_check = True
+
+ def _app_monitor_thread_main(self) -> None:
+
+ try:
+ self._monitor_app()
+ except Exception:
+ logging.exception('Error in AppHealthMonitor thread.')
+
+ def _set_response(self) -> None:
+ assert _ba.in_logic_thread()
+ self._response = True
+
+ def _check_running(self) -> bool:
+ # Workaround for the fact that mypy assumes _running
+ # doesn't change during the course of a function.
+ return self._running
+
+ def _monitor_app(self) -> None:
+ import time
+
+ while bool(True):
+
+ # Always sleep a bit between checks.
+ time.sleep(1.234)
+
+ # Do nothing while backgrounded.
+ while not self._running:
+ time.sleep(2.3456)
+
+ # Wait for the logic thread to run something we send it.
+ starttime = time.monotonic()
+ self._response = False
+ _ba.pushcall(self._set_response, raw=True)
+ while not self._response:
+
+ # Abort this check if we went into the background.
+ if not self._check_running():
+ break
+
+ # Wait a bit longer the first time through since the app
+ # could still be starting up; we generally don't want to
+ # report that.
+ threshold = 10 if self._first_check else 5
+
+ # If we've been waiting too long (and the app is running)
+ # dump the app state and bail. Make an exception for the
+ # first check though since the app could just be taking
+ # a while to get going; we don't want to report that.
+ duration = time.monotonic() - starttime
+ if duration > threshold:
+ dump_app_state(
+ reason=f'Logic thread unresponsive'
+ f' for {threshold} seconds.'
+ )
+
+ # We just do one alert for now.
+ return
+
+ time.sleep(1.042)
+
+ self._first_check = False
+
+ def on_app_pause(self) -> None:
+ """Should be called when the app pauses."""
+ assert _ba.in_logic_thread()
+ self._running = False
+
+ def on_app_resume(self) -> None:
+ """Should be called when the app resumes."""
+ assert _ba.in_logic_thread()
+ self._running = True
+
+
+def on_too_many_file_descriptors() -> None:
+ """Called when too many file descriptors are open; trying to debug."""
+ from ba._generated.enums import TimeType
+
+ real_time = _ba.time(TimeType.REAL)
+
+ def _do_log() -> None:
+ pid = os.getpid()
+ try:
+ fdcount: int | str = len(os.listdir(f'/proc/{pid}/fd'))
+ except Exception as exc:
+ fdcount = f'? ({exc})'
+ logging.warning(
+ 'TOO MANY FDS at %.2f. We are pid %d. FDCount is %s.',
+ real_time,
+ pid,
+ fdcount,
+ )
+
+ Thread(target=_do_log, daemon=True).start()
+
+ # import io
+ # from efro.debug import printtypes
+
+ # with io.StringIO() as fstr:
+ # fstr.write('Too many FDs.\n')
+ # printtypes(file=fstr)
+ # fstr.seek(0)
+ # logging.warning(fstr.read())
+ # import socket
+
+ # objs: list[Any] = []
+ # for obj in gc.get_objects():
+ # if isinstance(obj, socket.socket):
+ # objs.append(obj)
+ # test = open('/Users/ericf/.zshrc', 'r', encoding='utf-8')
+ # reveal_type(test)
+ # print('FOUND', len(objs))
diff --git a/assets/src/ba_data/python/ba/_bootstrap.py b/assets/src/ba_data/python/ba/_bootstrap.py
index 02f8cbf1..48f599b4 100644
--- a/assets/src/ba_data/python/ba/_bootstrap.py
+++ b/assets/src/ba_data/python/ba/_bootstrap.py
@@ -47,7 +47,7 @@ def bootstrap() -> None:
# Give a soft warning if we're being used with a different binary
# version than we expect.
- expected_build = 20934
+ expected_build = 20993
running_build: int = env['build_number']
if running_build != expected_build:
print(
@@ -120,7 +120,8 @@ def bootstrap() -> None:
import __main__
# Clear out the standard quit/exit messages since they don't
- # work for us.
+ # work in our embedded situation (should revisit this once we're
+ # usable from a standard interpreter).
del __main__.__builtins__.quit
del __main__.__builtins__.exit
diff --git a/assets/src/ba_data/python/ba/_cloud.py b/assets/src/ba_data/python/ba/_cloud.py
index 19202b55..e9ead0b4 100644
--- a/assets/src/ba_data/python/ba/_cloud.py
+++ b/assets/src/ba_data/python/ba/_cloud.py
@@ -33,6 +33,12 @@ class CloudSubsystem:
"""
return False # Needs to be overridden
+ def on_app_pause(self) -> None:
+ """Should be called when the app pauses."""
+
+ def on_app_resume(self) -> None:
+ """Should be called when the app resumes."""
+
def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes."""
if DEBUG_LOG:
@@ -125,6 +131,12 @@ class CloudSubsystem:
) -> bacommon.cloud.WorkspaceFetchResponse:
...
+ @overload
+ def send_message(
+ self, msg: bacommon.cloud.MerchAvailabilityMessage
+ ) -> bacommon.cloud.MerchAvailabilityResponse:
+ ...
+
@overload
def send_message(
self, msg: bacommon.cloud.TestMessage
diff --git a/assets/src/ba_data/python/ba/_coopgame.py b/assets/src/ba_data/python/ba/_coopgame.py
index 0df8beb2..6fedc3ca 100644
--- a/assets/src/ba_data/python/ba/_coopgame.py
+++ b/assets/src/ba_data/python/ba/_coopgame.py
@@ -11,7 +11,7 @@ from ba._gameactivity import GameActivity
from ba._general import WeakCall
if TYPE_CHECKING:
- from typing import Any, Sequence
+ from typing import Sequence
from bastd.actor.playerspaz import PlayerSpaz
import ba
@@ -56,56 +56,6 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
# Preload achievement images in case we get some.
_ba.timer(2.0, WeakCall(self._preload_achievements))
- def _show_standard_scores_to_beat_ui(
- self, scores: list[dict[str, Any]]
- ) -> None:
- from efro.util import asserttype
- from ba._gameutils import timestring, animate
- from ba._nodeactor import NodeActor
- from ba._generated.enums import TimeFormat
-
- display_type = self.get_score_type()
- if scores is not None:
-
- # Sort by originating date so that the most recent is first.
- scores.sort(reverse=True, key=lambda s: asserttype(s['time'], int))
-
- # Now make a display for the most recent challenge.
- for score in scores:
- if score['type'] == 'score_challenge':
- tval = (
- score['player']
- + ': '
- + timestring(
- int(score['value']) * 10,
- timeformat=TimeFormat.MILLISECONDS,
- ).evaluate()
- if display_type == 'time'
- else str(score['value'])
- )
- hattach = 'center' if display_type == 'time' else 'left'
- halign = 'center' if display_type == 'time' else 'left'
- pos = (20, -70) if display_type == 'time' else (20, -130)
- txt = NodeActor(
- _ba.newnode(
- 'text',
- attrs={
- 'v_attach': 'top',
- 'h_attach': hattach,
- 'h_align': halign,
- 'color': (0.7, 0.4, 1, 1),
- 'shadow': 0.5,
- 'flatness': 1.0,
- 'position': pos,
- 'scale': 0.6,
- 'text': tval,
- },
- )
- ).autoretain()
- assert txt.node is not None
- animate(txt.node, 'scale', {1.0: 0.0, 1.1: 0.7, 1.2: 0.6})
- break
-
# FIXME: this is now redundant with activityutils.getscoreconfig();
# need to kill this.
def get_score_type(self) -> str:
diff --git a/assets/src/ba_data/python/ba/_hooks.py b/assets/src/ba_data/python/ba/_hooks.py
index 21d453b5..f7d0e471 100644
--- a/assets/src/ba_data/python/ba/_hooks.py
+++ b/assets/src/ba_data/python/ba/_hooks.py
@@ -1,13 +1,13 @@
# Released under the MIT License. See LICENSE for details.
#
-"""Snippets of code for use by the internal C++ layer.
+"""Snippets of code for use by the internal layer.
-History: originally I would dynamically compile/eval bits of Python text
-from within C++ code, but the major downside there was that none of that was
-type-checked so if names or arguments changed I would never catch code breakage
-until the code was next run. By defining all snippets I use here and then
-capturing references to them all at launch I can immediately verify everything
-I'm looking for exists and pylint/mypy can do their magic on this file.
+History: originally the engine would dynamically compile/eval various Python
+code from within C++ code, but the major downside there was that none of it
+was type-checked so if names or arguments changed it would go unnoticed
+until it broke at runtime. By instead defining such snippets here and then
+capturing references to them all at launch it is possible to allow linting
+and type-checking magic to happen and most issues will be caught immediately.
"""
# (most of these are self-explanatory)
# pylint: disable=missing-function-docstring
@@ -461,12 +461,42 @@ def login_adapter_get_sign_in_token_response(
) -> None:
"""Login adapter do-sign-in completed."""
from bacommon.login import LoginType
- from ba._login import LoginAdapterGPGS
+ from ba._login import LoginAdapterNative
login_type = LoginType(login_type_str)
attempt_id = int(attempt_id_str)
result = None if result_str == '' else result_str
with _ba.Context('ui'):
adapter = _ba.app.accounts_v2.login_adapters[login_type]
- assert isinstance(adapter, LoginAdapterGPGS)
+ assert isinstance(adapter, LoginAdapterNative)
adapter.on_sign_in_complete(attempt_id=attempt_id, result=result)
+
+
+def show_client_too_old_error() -> None:
+ """Called at launch if the server tells us we're too old to talk to it."""
+ from ba._language import Lstr
+
+ # If you are using an old build of the app and would like to stop
+ # seeing this error at launch, do:
+ # ba.app.config['SuppressClientTooOldErrorForBuild'] = ba.app.build_number
+ # ba.app.config.commit()
+ # Note that you will have to do that again later if you update to
+ # a newer build.
+ if (
+ _ba.app.config.get('SuppressClientTooOldErrorForBuild')
+ == _ba.app.build_number
+ ):
+ return
+
+ _ba.playsound(_ba.getsound('error'))
+ _ba.screenmessage(
+ Lstr(
+ translate=(
+ 'serverResponses',
+ 'Server functionality is no longer supported'
+ ' in this version of the game;\n'
+ 'Please update to a newer version.',
+ )
+ ),
+ color=(1, 0, 0),
+ )
diff --git a/assets/src/ba_data/python/ba/_internal.py b/assets/src/ba_data/python/ba/_internal.py
index 4d6ab704..4fb62ba2 100644
--- a/assets/src/ba_data/python/ba/_internal.py
+++ b/assets/src/ba_data/python/ba/_internal.py
@@ -104,7 +104,6 @@ def submit_score(
name: Any,
score: int | None,
callback: Callable,
- friend_callback: Callable | None,
order: str = 'increasing',
tournament_id: str | None = None,
score_type: str = 'points',
@@ -125,7 +124,6 @@ def submit_score(
name=name,
score=score,
callback=callback,
- friend_callback=friend_callback,
order=order,
tournament_id=tournament_id,
score_type=score_type,
diff --git a/assets/src/ba_data/python/ba/_language.py b/assets/src/ba_data/python/ba/_language.py
index 9d1fbecc..812f1ec9 100644
--- a/assets/src/ba_data/python/ba/_language.py
+++ b/assets/src/ba_data/python/ba/_language.py
@@ -67,38 +67,39 @@ class LanguageSubsystem:
def _get_default_language(self) -> str:
languages = {
+ 'ar': 'Arabic',
+ 'be': 'Belarussian',
+ 'zh': 'Chinese',
+ 'hr': 'Croatian',
+ 'cs': 'Czech',
+ 'da': 'Danish',
+ 'nl': 'Dutch',
+ 'eo': 'Esperanto',
+ 'fil': 'Filipino',
+ 'fr': 'French',
'de': 'German',
+ 'el': 'Greek',
+ 'hi': 'Hindi',
+ 'hu': 'Hungarian',
+ 'id': 'Indonesian',
+ 'it': 'Italian',
+ 'ko': 'Korean',
+ 'ms': 'Malay',
+ 'fa': 'Persian',
+ 'pl': 'Polish',
+ 'pt': 'Portuguese',
+ 'ro': 'Romanian',
+ 'ru': 'Russian',
+ 'sr': 'Serbian',
'es': 'Spanish',
'sk': 'Slovak',
- 'it': 'Italian',
- 'nl': 'Dutch',
- 'da': 'Danish',
- 'pt': 'Portuguese',
- 'fr': 'French',
- 'el': 'Greek',
- 'ru': 'Russian',
- 'pl': 'Polish',
'sv': 'Swedish',
- 'eo': 'Esperanto',
- 'cs': 'Czech',
- 'hr': 'Croatian',
- 'hu': 'Hungarian',
- 'be': 'Belarussian',
- 'ro': 'Romanian',
- 'ko': 'Korean',
- 'fa': 'Persian',
- 'ar': 'Arabic',
- 'zh': 'Chinese',
- 'tr': 'Turkish',
- 'th': 'Thai',
- 'id': 'Indonesian',
- 'sr': 'Serbian',
- 'uk': 'Ukrainian',
- 'vi': 'Vietnamese',
- 'vec': 'Venetian',
- 'hi': 'Hindi',
'ta': 'Tamil',
- 'fil': 'Filipino',
+ 'th': 'Thai',
+ 'tr': 'Turkish',
+ 'uk': 'Ukrainian',
+ 'vec': 'Venetian',
+ 'vi': 'Vietnamese',
}
# Special case for Chinese: map specific variations to traditional.
diff --git a/assets/src/ba_data/python/ba/_login.py b/assets/src/ba_data/python/ba/_login.py
index 2ec91382..49541bd5 100644
--- a/assets/src/ba_data/python/ba/_login.py
+++ b/assets/src/ba_data/python/ba/_login.py
@@ -4,6 +4,7 @@
from __future__ import annotations
+import time
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, final
@@ -57,6 +58,9 @@ class LoginAdapter:
# current active primary account.
self._active_login_id: str | None = None
+ self._last_sign_in_time: float | None = None
+ self._last_sign_in_desc: str | None = None
+
def on_app_launch(self) -> None:
"""Should be called for each adapter in on_app_launch."""
@@ -102,6 +106,9 @@ class LoginAdapter:
# (possibly) push it to the app for handling.
self._update_implicit_login_state()
+ # This might affect whether we consider that back-end as 'active'.
+ self._update_back_end_active()
+
def set_active_logins(self, logins: dict[LoginType, str]) -> None:
"""Keep the adapter informed of actively used logins.
@@ -116,7 +123,7 @@ class LoginAdapter:
logging.debug(
'LoginAdapter: %s adapter got active logins %s.',
self.login_type.name,
- logins,
+ {k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
)
self._active_login_id = logins.get(self.login_type)
@@ -139,6 +146,7 @@ class LoginAdapter:
def sign_in(
self,
result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
+ description: str,
) -> None:
"""Attempt an explicit sign in via this adapter.
@@ -148,6 +156,38 @@ class LoginAdapter:
"""
assert _ba.in_logic_thread()
from ba._general import Call
+ from ba._generated.enums import TimeType
+
+ # Have been seeing multiple sign-in attempts come through
+ # nearly simultaneously which can be problematic server-side.
+ # Let's error if a sign-in attempt is made within a few seconds
+ # of the last one to address this.
+ now = time.monotonic()
+ appnow = _ba.time(TimeType.REAL)
+ if self._last_sign_in_time is not None:
+ since_last = now - self._last_sign_in_time
+ if since_last < 1.0:
+ logging.warning(
+ 'LoginAdapter: %s adapter sign_in() called too soon'
+ ' (%.2fs) after last; this-desc="%s", last-desc="%s",'
+ ' ba-real-time=%.2f.',
+ self.login_type.name,
+ since_last,
+ description,
+ self._last_sign_in_desc,
+ appnow,
+ )
+ _ba.pushcall(
+ Call(
+ result_cb,
+ self,
+ RuntimeError('sign_in called too soon after last.'),
+ )
+ )
+ return
+
+ self._last_sign_in_desc = description
+ self._last_sign_in_time = now
if DEBUG_LOG:
logging.debug(
@@ -171,7 +211,7 @@ class LoginAdapter:
Call(
result_cb,
self,
- RuntimeError('fetch-sign-in-token failed'),
+ RuntimeError('fetch-sign-in-token failed.'),
)
)
return
@@ -189,7 +229,6 @@ class LoginAdapter:
def _got_sign_in_response(
response: bacommon.cloud.SignInResponse | Exception,
) -> None:
- # from ba._language import Lstr
if isinstance(response, Exception):
if DEBUG_LOG:
@@ -199,10 +238,6 @@ class LoginAdapter:
self.login_type.name,
response,
)
- # _ba.screenmessage(
- # Lstr(resource='errorText'), color=(1, 0, 0)
- # )
- # _ba.playsound(_ba.getsound('error'))
_ba.pushcall(Call(result_cb, self, response))
else:
if DEBUG_LOG:
@@ -213,7 +248,10 @@ class LoginAdapter:
)
if response.credentials is None:
result2: LoginAdapter.SignInResult | Exception = (
- RuntimeError('No credentials returned.')
+ RuntimeError(
+ 'No credentials returned after'
+ ' submitting sign-in-token.'
+ )
)
else:
result2 = self.SignInResult(
@@ -222,13 +260,22 @@ class LoginAdapter:
_ba.pushcall(Call(result_cb, self, result2))
_ba.app.cloud.send_message_cb(
- bacommon.cloud.SignInMessage(self.login_type, result),
+ bacommon.cloud.SignInMessage(
+ self.login_type,
+ result,
+ description=description,
+ apptime=appnow,
+ ),
on_response=_got_sign_in_response,
)
# Kick off the process by fetching a sign-in token.
self.get_sign_in_token(completion_cb=_got_sign_in_token_result)
+ def is_back_end_active(self) -> bool:
+ """Is this adapter's back-end currently active?"""
+ return self._back_end_active
+
def get_sign_in_token(
self, completion_cb: Callable[[str | None], None]
) -> None:
@@ -289,8 +336,8 @@ class LoginAdapter:
self._back_end_active = is_active
-class LoginAdapterGPGS(LoginAdapter):
- """Google Play Game Services adapter."""
+class LoginAdapterNative(LoginAdapter):
+ """A login adapter that does its work in the native layer."""
def __init__(self) -> None:
super().__init__(LoginType.GPGS)
@@ -308,6 +355,9 @@ class LoginAdapterGPGS(LoginAdapter):
self._sign_in_attempt_num += 1
_ba.login_adapter_get_sign_in_token(self.login_type.value, attempt_id)
+ def on_back_end_active_change(self, active: bool) -> None:
+ _ba.login_adapter_back_end_active_change(self.login_type.value, active)
+
def on_sign_in_complete(self, attempt_id: int, result: str | None) -> None:
"""Called by the native layer on a completed attempt."""
assert _ba.in_logic_thread()
@@ -316,3 +366,7 @@ class LoginAdapterGPGS(LoginAdapter):
return
callback = self._sign_in_attempts.pop(attempt_id)
callback(result)
+
+
+class LoginAdapterGPGS(LoginAdapterNative):
+ """Google Play Game Services adapter."""
diff --git a/assets/src/ba_data/python/ba/_plugin.py b/assets/src/ba_data/python/ba/_plugin.py
index 3e896887..b9812980 100644
--- a/assets/src/ba_data/python/ba/_plugin.py
+++ b/assets/src/ba_data/python/ba/_plugin.py
@@ -173,7 +173,7 @@ class PluginSubsystem:
color=(1, 1, 0),
)
plugnames = ', '.join(disappeared_plugs)
- logging.warning(
+ logging.info(
'%d plugin(s) no longer found: %s.',
len(disappeared_plugs),
plugnames,
diff --git a/assets/src/ba_data/python/ba/_servermode.py b/assets/src/ba_data/python/ba/_servermode.py
index f5a40551..52f433fa 100644
--- a/assets/src/ba_data/python/ba/_servermode.py
+++ b/assets/src/ba_data/python/ba/_servermode.py
@@ -418,6 +418,7 @@ class ServerController:
# Call set-enabled last (will push state to the cloud).
_ba.set_public_party_max_size(self._config.max_party_size)
+ _ba.set_public_party_queue_enabled(self._config.enable_queue)
_ba.set_public_party_name(self._config.party_name)
_ba.set_public_party_stats_url(self._config.stats_url)
_ba.set_public_party_enabled(self._config.party_is_public)
diff --git a/assets/src/ba_data/python/ba/_store.py b/assets/src/ba_data/python/ba/_store.py
index 850a0aa0..ef8385d9 100644
--- a/assets/src/ba_data/python/ba/_store.py
+++ b/assets/src/ba_data/python/ba/_store.py
@@ -30,6 +30,8 @@ def get_store_item_name_translated(item_name: str) -> ba.Lstr:
return _language.Lstr(
translate=('characterNames', item_info['character'])
)
+ if item_name in ['merch']:
+ return _language.Lstr(resource='merchText')
if item_name in ['upgrades.pro', 'pro']:
return _language.Lstr(
resource='store.bombSquadProNameText',
@@ -50,8 +52,17 @@ def get_store_item_display_size(item_name: str) -> tuple[float, float]:
"""(internal)"""
if item_name.startswith('characters.'):
return 340 * 0.6, 430 * 0.6
- if item_name in ['pro', 'upgrades.pro']:
- return 650 * 0.9, 500 * 0.85
+ if item_name in ['pro', 'upgrades.pro', 'merch']:
+ from ba._generated.enums import UIScale
+
+ return 650 * 0.9, 500 * (
+ 0.72
+ if (
+ _ba.app.config.get('Merch Link')
+ and _ba.app.ui.uiscale is UIScale.SMALL
+ )
+ else 0.85
+ )
if item_name.startswith('maps.'):
return 510 * 0.6, 450 * 0.6
if item_name.startswith('icons.'):
@@ -96,6 +107,7 @@ def get_store_items() -> dict[str, dict]:
'characters.taobaomascot': {'character': 'Taobao Mascot'},
'characters.santa': {'character': 'Santa Claus'},
'characters.bunny': {'character': 'Easter Bunny'},
+ 'merch': {},
'pro': {},
'maps.lake_frigid': {'map_type': maps.LakeFrigid},
'games.ninja_fight': {
@@ -193,9 +205,7 @@ def get_store_items() -> dict[str, dict]:
'icons.fireball': {'icon': _ba.charstr(SpecialChar.FIREBALL)},
'icons.mikirog': {'icon': _ba.charstr(SpecialChar.MIKIROG)},
}
- store_items = _ba.app.store_items
- assert store_items is not None
- return store_items
+ return _ba.app.store_items
def get_store_layout() -> dict[str, list[dict[str, Any]]]:
@@ -261,7 +271,6 @@ def get_store_layout() -> dict[str, list[dict[str, Any]]]:
],
}
store_layout = _ba.app.store_layout
- assert store_layout is not None
store_layout['characters'] = [
{
'items': [
@@ -302,6 +311,12 @@ def get_store_layout() -> dict[str, list[dict[str, Any]]]:
'items': ['games.easter_egg_hunt'],
}
)
+
+ # This will cause merch to show only if the master-server has
+ # given us a link (which means merch is available in our region).
+ store_layout['extras'] = [{'items': ['pro']}]
+ if _ba.app.config.get('Merch Link'):
+ store_layout['extras'][0]['items'].append('merch')
return store_layout
diff --git a/assets/src/ba_data/python/ba/internal.py b/assets/src/ba_data/python/ba/internal.py
index e96cb0aa..4b04a9a7 100644
--- a/assets/src/ba_data/python/ba/internal.py
+++ b/assets/src/ba_data/python/ba/internal.py
@@ -40,6 +40,7 @@ from _ba import (
get_public_party_max_size,
set_public_party_name,
set_public_party_max_size,
+ set_public_party_queue_enabled,
set_authenticate_clients,
set_public_party_enabled,
reset_random_player_names,
@@ -100,6 +101,8 @@ from ba._apputils import (
is_browser_likely_available,
get_remote_app_name,
should_submit_debug_info,
+ dump_app_state,
+ log_dumped_app_state,
)
from ba._benchmark import (
run_gpu_benchmark,
@@ -211,6 +214,7 @@ __all__ = [
'get_public_party_max_size',
'set_public_party_name',
'set_public_party_max_size',
+ 'set_public_party_queue_enabled',
'set_authenticate_clients',
'set_public_party_enabled',
'reset_random_player_names',
@@ -330,4 +334,6 @@ __all__ = [
'sign_out_v1',
'sign_in_v1',
'mark_config_dirty',
+ 'dump_app_state',
+ 'log_dumped_app_state',
]
diff --git a/assets/src/ba_data/python/bastd/activity/coopscore.py b/assets/src/ba_data/python/bastd/activity/coopscore.py
index 5c32fdeb..63864605 100644
--- a/assets/src/ba_data/python/bastd/activity/coopscore.py
+++ b/assets/src/ba_data/python/bastd/activity/coopscore.py
@@ -116,7 +116,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
self._newly_complete: bool | None = None
self._is_more_levels: bool | None = None
self._next_level_name: str | None = None
- self._show_friend_scores: bool | None = None
self._show_info: dict[str, Any] | None = None
self._name_str: str | None = None
self._friends_loading_status: ba.Actor | None = None
@@ -177,12 +176,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
.replace(' ', '_')
)
- # If game-center/etc scores are available we show our friends'
- # scores. Otherwise we show our local high scores.
- self._show_friend_scores = ba.internal.game_service_has_leaderboard(
- self._game_name_str, self._game_config_str
- )
-
try:
self._old_best_rank = self._campaign.getlevel(
self._level_name
@@ -366,21 +359,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
ba.internal.set_ui_input_device(None) # Menu is up for grabs.
- if self._show_friend_scores:
- ba.buttonwidget(
- parent=rootc,
- color=(0.45, 0.4, 0.5),
- position=(h_offs - 520, v_offs + 480),
- size=(300, 60),
- label=ba.Lstr(resource='topFriendsText'),
- on_activate_call=ba.WeakCall(self._ui_gc),
- transition_delay=delay + 0.5,
- icon=self._game_service_leaderboards_texture,
- icon_color=self._game_service_icon_color,
- autoselect=True,
- selectable=can_select_extra_buttons,
- )
-
if self._have_achievements and self._account_has_achievements:
ba.buttonwidget(
parent=rootc,
@@ -773,18 +751,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
[p.name for p in self._playerinfos]
)
- if self._show_friend_scores:
- self._friends_loading_status = Text(
- ba.Lstr(
- value='${A}...',
- subs=[('${A}', ba.Lstr(resource='loadingText'))],
- ),
- position=(-405, 150 + 30),
- color=(1, 1, 1, 0.4),
- transition=Text.Transition.FADE_IN,
- scale=0.7,
- transition_delay=2.0,
- )
self._score_loading_status = Text(
ba.Lstr(
value='${A}...',
@@ -850,8 +816,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
# We expect this only in kiosk mode; complain otherwise.
if not (ba.app.demo_mode or ba.app.arcade_mode):
print('got not-signed-in at score-submit; unexpected')
- if self._show_friend_scores:
- ba.pushcall(ba.WeakCall(self._got_friend_score_results, None))
ba.pushcall(ba.WeakCall(self._got_score_results, None))
else:
assert self._game_name_str is not None
@@ -862,9 +826,6 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
name_str,
self._score,
ba.WeakCall(self._got_score_results),
- ba.WeakCall(self._got_friend_score_results)
- if self._show_friend_scores
- else None,
order=self._score_order,
tournament_id=self.session.tournament_id,
score_type=self._score_type,
@@ -899,138 +860,118 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
assert txt.node
txt.node.client_only = True
- # If we have no friend scores, display local best scores.
- if self._show_friend_scores:
+ ts_height = 300
+ ts_h_offs = -480
+ v_offs = 40
+ Text(
+ ba.Lstr(resource='yourBestScoresText')
+ if self._score_type == 'points'
+ else ba.Lstr(resource='yourBestTimesText'),
+ maxwidth=210,
+ position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
+ transition=Text.Transition.IN_RIGHT,
+ v_align=Text.VAlign.CENTER,
+ scale=1.2,
+ transition_delay=1.8,
+ ).autoretain()
- # Host has a button, so we need client-only text.
- ts_height = 300
- ts_h_offs = -480
- v_offs = 40
- txt = Text(
- ba.Lstr(resource='topFriendsText'),
- maxwidth=210,
- position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
- transition=Text.Transition.IN_RIGHT,
- v_align=Text.VAlign.CENTER,
- scale=1.2,
- transition_delay=1.8,
- ).autoretain()
- assert txt.node
- txt.node.client_only = True
- else:
+ display_scores = list(our_high_scores)
+ display_count = 5
- ts_height = 300
- ts_h_offs = -480
- v_offs = 40
- Text(
- ba.Lstr(resource='yourBestScoresText')
- if self._score_type == 'points'
- else ba.Lstr(resource='yourBestTimesText'),
- maxwidth=210,
- position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
- transition=Text.Transition.IN_RIGHT,
- v_align=Text.VAlign.CENTER,
- scale=1.2,
- transition_delay=1.8,
- ).autoretain()
+ while len(display_scores) < display_count:
+ display_scores.append((0, None))
- display_scores = list(our_high_scores)
- display_count = 5
-
- while len(display_scores) < display_count:
- display_scores.append((0, None))
-
- showed_ours = False
- h_offs_extra = 85 if self._score_type == 'points' else 130
- v_offs_extra = 20
- v_offs_names = 0
- scale = 1.0
- p_count = len(self._playerinfos)
- h_offs_extra -= 75
- if p_count > 1:
- h_offs_extra -= 20
- if p_count == 2:
- scale = 0.9
- elif p_count == 3:
- scale = 0.65
- elif p_count == 4:
- scale = 0.5
- times: list[tuple[float, float]] = []
- for i in range(display_count):
- times.insert(
- random.randrange(0, len(times) + 1),
- (1.9 + i * 0.05, 2.3 + i * 0.05),
- )
- for i in range(display_count):
- try:
- if display_scores[i][1] is None:
- name_str = '-'
- else:
- # noinspection PyUnresolvedReferences
- name_str = ', '.join(
- [p['name'] for p in display_scores[i][1]['players']]
- )
- except Exception:
- ba.print_exception(
- f'Error calcing name_str for {display_scores}'
- )
+ showed_ours = False
+ h_offs_extra = 85 if self._score_type == 'points' else 130
+ v_offs_extra = 20
+ v_offs_names = 0
+ scale = 1.0
+ p_count = len(self._playerinfos)
+ h_offs_extra -= 75
+ if p_count > 1:
+ h_offs_extra -= 20
+ if p_count == 2:
+ scale = 0.9
+ elif p_count == 3:
+ scale = 0.65
+ elif p_count == 4:
+ scale = 0.5
+ times: list[tuple[float, float]] = []
+ for i in range(display_count):
+ times.insert(
+ random.randrange(0, len(times) + 1),
+ (1.9 + i * 0.05, 2.3 + i * 0.05),
+ )
+ for i in range(display_count):
+ try:
+ if display_scores[i][1] is None:
name_str = '-'
- if display_scores[i] == our_score and not showed_ours:
- flash = True
- color0 = (0.6, 0.4, 0.1, 1.0)
- color1 = (0.6, 0.6, 0.6, 1.0)
- tdelay1 = 3.7
- tdelay2 = 3.7
- showed_ours = True
else:
- flash = False
- color0 = (0.6, 0.4, 0.1, 1.0)
- color1 = (0.6, 0.6, 0.6, 1.0)
- tdelay1 = times[i][0]
- tdelay2 = times[i][1]
- Text(
- str(display_scores[i][0])
- if self._score_type == 'points'
- else ba.timestring(
- display_scores[i][0] * 10,
- timeformat=ba.TimeFormat.MILLISECONDS,
- suppress_format_warning=True,
- ),
- position=(
- ts_h_offs + 20 + h_offs_extra,
- v_offs_extra
- + ts_height / 2
- + -ts_height * (i + 1) / 10
- + v_offs
- + 11.0,
- ),
- h_align=Text.HAlign.RIGHT,
- v_align=Text.VAlign.CENTER,
- color=color0,
- flash=flash,
- transition=Text.Transition.IN_RIGHT,
- transition_delay=tdelay1,
- ).autoretain()
+ # noinspection PyUnresolvedReferences
+ name_str = ', '.join(
+ [p['name'] for p in display_scores[i][1]['players']]
+ )
+ except Exception:
+ ba.print_exception(
+ f'Error calcing name_str for {display_scores}'
+ )
+ name_str = '-'
+ if display_scores[i] == our_score and not showed_ours:
+ flash = True
+ color0 = (0.6, 0.4, 0.1, 1.0)
+ color1 = (0.6, 0.6, 0.6, 1.0)
+ tdelay1 = 3.7
+ tdelay2 = 3.7
+ showed_ours = True
+ else:
+ flash = False
+ color0 = (0.6, 0.4, 0.1, 1.0)
+ color1 = (0.6, 0.6, 0.6, 1.0)
+ tdelay1 = times[i][0]
+ tdelay2 = times[i][1]
+ Text(
+ str(display_scores[i][0])
+ if self._score_type == 'points'
+ else ba.timestring(
+ display_scores[i][0] * 10,
+ timeformat=ba.TimeFormat.MILLISECONDS,
+ suppress_format_warning=True,
+ ),
+ position=(
+ ts_h_offs + 20 + h_offs_extra,
+ v_offs_extra
+ + ts_height / 2
+ + -ts_height * (i + 1) / 10
+ + v_offs
+ + 11.0,
+ ),
+ h_align=Text.HAlign.RIGHT,
+ v_align=Text.VAlign.CENTER,
+ color=color0,
+ flash=flash,
+ transition=Text.Transition.IN_RIGHT,
+ transition_delay=tdelay1,
+ ).autoretain()
- Text(
- ba.Lstr(value=name_str),
- position=(
- ts_h_offs + 35 + h_offs_extra,
- v_offs_extra
- + ts_height / 2
- + -ts_height * (i + 1) / 10
- + v_offs_names
- + v_offs
- + 11.0,
- ),
- maxwidth=80.0 + 100.0 * len(self._playerinfos),
- v_align=Text.VAlign.CENTER,
- color=color1,
- flash=flash,
- scale=scale,
- transition=Text.Transition.IN_RIGHT,
- transition_delay=tdelay2,
- ).autoretain()
+ Text(
+ ba.Lstr(value=name_str),
+ position=(
+ ts_h_offs + 35 + h_offs_extra,
+ v_offs_extra
+ + ts_height / 2
+ + -ts_height * (i + 1) / 10
+ + v_offs_names
+ + v_offs
+ + 11.0,
+ ),
+ maxwidth=80.0 + 100.0 * len(self._playerinfos),
+ v_align=Text.VAlign.CENTER,
+ color=color1,
+ flash=flash,
+ scale=scale,
+ transition=Text.Transition.IN_RIGHT,
+ transition_delay=tdelay2,
+ ).autoretain()
# Show achievements for this level.
ts_height = -150
diff --git a/assets/src/ba_data/python/bastd/actor/spaz.py b/assets/src/ba_data/python/bastd/actor/spaz.py
index 4d1529fe..038ba00d 100644
--- a/assets/src/ba_data/python/bastd/actor/spaz.py
+++ b/assets/src/ba_data/python/bastd/actor/spaz.py
@@ -180,6 +180,7 @@ class Spaz(ba.Actor):
self._bomb_wear_off_flash_timer: ba.Timer | None = None
self._multi_bomb_wear_off_timer: ba.Timer | None = None
self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None
+ self._curse_timer: ba.Timer | None = None
self.bomb_count = self.default_bomb_count
self._max_bomb_count = self.default_bomb_count
self.bomb_type_default = self.default_bomb_type
@@ -620,7 +621,8 @@ class Spaz(ba.Actor):
self.node.curse_death_time = int(
1000.0 * (tval + self.curse_time)
)
- ba.timer(5.0, ba.WeakCall(self.curse_explode))
+ self._curse_timer = ba.Timer(5.0,
+ ba.WeakCall(self.curse_explode))
def equip_boxing_gloves(self) -> None:
"""
diff --git a/assets/src/ba_data/python/bastd/game/football.py b/assets/src/ba_data/python/bastd/game/football.py
index a88583e4..a8d87771 100644
--- a/assets/src/ba_data/python/bastd/game/football.py
+++ b/assets/src/ba_data/python/bastd/game/football.py
@@ -616,9 +616,6 @@ class FootballCoopGame(ba.CoopGameActivity[Player, Team]):
for bottype in self._bot_types_initial:
self._spawn_bot(bottype)
- def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
- self._show_standard_scores_to_beat_ui(scores)
-
def _on_bot_spawn(self, spaz: SpazBot) -> None:
# We want to move to the left by default.
spaz.target_point_default = ba.Vec3(0, 0, 0)
diff --git a/assets/src/ba_data/python/bastd/game/onslaught.py b/assets/src/ba_data/python/bastd/game/onslaught.py
index 90aa87be..bf275137 100644
--- a/assets/src/ba_data/python/bastd/game/onslaught.py
+++ b/assets/src/ba_data/python/bastd/game/onslaught.py
@@ -682,9 +682,6 @@ class OnslaughtGame(ba.CoopGameActivity[Player, Team]):
self._bots = SpazBotSet()
ba.timer(4.0, self._start_updating_waves)
- def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
- self._show_standard_scores_to_beat_ui(scores)
-
def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]:
totalpts = 0
totaldudes = 0
diff --git a/assets/src/ba_data/python/bastd/game/runaround.py b/assets/src/ba_data/python/bastd/game/runaround.py
index d8f5e4b4..379ae8e3 100644
--- a/assets/src/ba_data/python/bastd/game/runaround.py
+++ b/assets/src/ba_data/python/bastd/game/runaround.py
@@ -684,9 +684,6 @@ class RunaroundGame(ba.CoopGameActivity[Player, Team]):
},
)
- def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
- self._show_standard_scores_to_beat_ui(scores)
-
def _update_waves(self) -> None:
# pylint: disable=too-many-branches
diff --git a/assets/src/ba_data/python/bastd/game/thelaststand.py b/assets/src/ba_data/python/bastd/game/thelaststand.py
index 2040e5c2..62e98c91 100644
--- a/assets/src/ba_data/python/bastd/game/thelaststand.py
+++ b/assets/src/ba_data/python/bastd/game/thelaststand.py
@@ -326,9 +326,6 @@ class TheLastStandGame(ba.CoopGameActivity[Player, Team]):
else:
super().handlemessage(msg)
- def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
- self._show_standard_scores_to_beat_ui(scores)
-
def end_game(self) -> None:
# Tell our bots to celebrate just to rub it in.
self._bots.final_celebrate()
diff --git a/assets/src/ba_data/python/bastd/ui/account/settings.py b/assets/src/ba_data/python/bastd/ui/account/settings.py
index 0f7f59ab..214f8c92 100644
--- a/assets/src/ba_data/python/bastd/ui/account/settings.py
+++ b/assets/src/ba_data/python/bastd/ui/account/settings.py
@@ -17,6 +17,11 @@ import ba.internal
if TYPE_CHECKING:
from ba.internal import LoginAdapter
+# These days we're directing people to the web based account settings
+# for V2 account linking and trying to get them to disconnect remaining
+# V1 links, but leaving this escape hatch here in case needed.
+FORCE_ENABLE_V1_LINKING = False
+
class AccountSettingsWindow(ba.Window):
"""Window for account related functionality."""
@@ -33,10 +38,14 @@ class AccountSettingsWindow(ba.Window):
self._sign_in_v2_proxy_button: ba.Widget | None = None
self._sign_in_device_button: ba.Widget | None = None
+ self._show_legacy_unlink_button = False
+
self._signing_in_adapter: LoginAdapter | None = None
self._close_once_signed_in = close_once_signed_in
ba.set_analytics_screen('Account Window')
+ self._explicitly_signed_out_of_gpgs = False
+
# If they provided an origin-widget, scale up from that.
scale_origin: tuple[float, float] | None
if origin_widget is not None:
@@ -50,14 +59,8 @@ class AccountSettingsWindow(ba.Window):
self._r = 'accountSettingsWindow'
self._modal = modal
self._needs_refresh = False
- self._signed_in = ba.internal.get_v1_account_state() == 'signed_in'
- self._account_state_num = ba.internal.get_v1_account_state_num()
- self._show_linked = (
- self._signed_in
- and ba.internal.get_v1_account_misc_read_val(
- 'allowAccountLinking2', False
- )
- )
+ self._v1_signed_in = ba.internal.get_v1_account_state() == 'signed_in'
+ self._v1_account_state_num = ba.internal.get_v1_account_state_num()
self._check_sign_in_timer = ba.Timer(
1.0,
ba.WeakCall(self._update),
@@ -66,12 +69,12 @@ class AccountSettingsWindow(ba.Window):
)
# Currently we can only reset achievements on game-center.
- account_type: str | None
- if self._signed_in:
- account_type = ba.internal.get_v1_account_type()
+ v1_account_type: str | None
+ if self._v1_signed_in:
+ v1_account_type = ba.internal.get_v1_account_type()
else:
- account_type = None
- self._can_reset_achievements = account_type == 'Game Center'
+ v1_account_type = None
+ self._can_reset_achievements = v1_account_type == 'Game Center'
app = ba.app
uiscale = app.ui.uiscale
@@ -104,7 +107,7 @@ class AccountSettingsWindow(ba.Window):
# Legacy v1 device accounts are currently always available
# (though we need to start phasing them out at some point).
- self._show_sign_in_buttons.append('Local')
+ self._show_sign_in_buttons.append('Device')
top_extra = 15 if uiscale is ba.UIScale.SMALL else 0
super().__init__(
@@ -183,31 +186,25 @@ class AccountSettingsWindow(ba.Window):
def _update(self) -> None:
# If they want us to close once we're signed in, do so.
- if self._close_once_signed_in and self._signed_in:
+ if self._close_once_signed_in and self._v1_signed_in:
self._back()
return
# Hmm should update this to use get_account_state_num.
# Theoretically if we switch from one signed-in account to another
# in the background this would break.
- account_state_num = ba.internal.get_v1_account_state_num()
- account_state = ba.internal.get_v1_account_state()
-
- show_linked = (
- self._signed_in
- and ba.internal.get_v1_account_misc_read_val(
- 'allowAccountLinking2', False
- )
- )
+ v1_account_state_num = ba.internal.get_v1_account_state_num()
+ v1_account_state = ba.internal.get_v1_account_state()
+ show_legacy_unlink_button = self._should_show_legacy_unlink_button()
if (
- account_state_num != self._account_state_num
- or self._show_linked != show_linked
+ v1_account_state_num != self._v1_account_state_num
+ or show_legacy_unlink_button != self._show_legacy_unlink_button
or self._needs_refresh
):
- self._show_linked = show_linked
- self._account_state_num = account_state_num
- self._signed_in = account_state == 'signed_in'
+ self._v1_account_state_num = v1_account_state_num
+ self._v1_signed_in = v1_account_state == 'signed_in'
+ self._show_legacy_unlink_button = show_legacy_unlink_button
self._refresh()
# Go ahead and refresh some individual things
@@ -229,22 +226,27 @@ class AccountSettingsWindow(ba.Window):
# pylint: disable=cyclic-import
from bastd.ui import confirm
+ primary_v2_account = ba.app.accounts_v2.primary
+
v1_state = ba.internal.get_v1_account_state()
- account_type = (
+ v1_account_type = (
ba.internal.get_v1_account_type()
if v1_state == 'signed_in'
else 'unknown'
)
- is_google = account_type == 'Google Play'
+ # We expose GPGS-specific functionality only if it is 'active'
+ # (meaning the current GPGS player matches one of our account's
+ # logins).
+ gpgs_adapter = ba.app.accounts_v2.login_adapters.get(LoginType.GPGS)
+ is_gpgs = (
+ False if gpgs_adapter is None else gpgs_adapter.is_back_end_active()
+ )
- show_local_signed_in_as = False
- local_signed_in_as_space = 50.0
-
- show_signed_in_as = self._signed_in
+ show_signed_in_as = self._v1_signed_in
signed_in_as_space = 95.0
- show_sign_in_benefits = not self._signed_in
+ show_sign_in_benefits = not self._v1_signed_in
sign_in_benefits_space = 80.0
show_signing_in_text = (
@@ -257,32 +259,30 @@ class AccountSettingsWindow(ba.Window):
and self._signing_in_adapter is None
and 'Google Play' in self._show_sign_in_buttons
)
- show_device_sign_in_button = (
- v1_state == 'signed_out'
- and self._signing_in_adapter is None
- and 'Local' in self._show_sign_in_buttons
- )
show_v2_proxy_sign_in_button = (
v1_state == 'signed_out'
and self._signing_in_adapter is None
and 'V2Proxy' in self._show_sign_in_buttons
)
+ show_device_sign_in_button = (
+ v1_state == 'signed_out'
+ and self._signing_in_adapter is None
+ and 'Device' in self._show_sign_in_buttons
+ )
sign_in_button_space = 70.0
+ deprecated_space = 60
- show_game_service_button = self._signed_in and account_type in [
+ show_game_service_button = self._v1_signed_in and v1_account_type in [
'Game Center'
]
game_service_button_space = 60.0
- show_linked_accounts_text = (
- self._signed_in
- and ba.internal.get_v1_account_misc_read_val(
- 'allowAccountLinking2', False
- )
- )
+ show_what_is_v2 = self._v1_signed_in and v1_account_type == 'V2'
+
+ show_linked_accounts_text = self._v1_signed_in
linked_accounts_text_space = 60.0
- show_achievements_button = self._signed_in and account_type in (
+ show_achievements_button = self._v1_signed_in and v1_account_type in (
'Google Play',
'Local',
'V2',
@@ -290,42 +290,46 @@ class AccountSettingsWindow(ba.Window):
achievements_button_space = 60.0
show_achievements_text = (
- self._signed_in and not show_achievements_button
+ self._v1_signed_in and not show_achievements_button
)
achievements_text_space = 27.0
- show_leaderboards_button = self._signed_in and is_google
+ show_leaderboards_button = self._v1_signed_in and is_gpgs
leaderboards_button_space = 60.0
- show_campaign_progress = self._signed_in
+ show_campaign_progress = self._v1_signed_in
campaign_progress_space = 27.0
- show_tickets = self._signed_in
+ show_tickets = self._v1_signed_in
tickets_space = 27.0
show_reset_progress_button = False
reset_progress_button_space = 70.0
- show_manage_v2_account_button = self._signed_in and account_type == 'V2'
+ show_manage_v2_account_button = (
+ self._v1_signed_in and v1_account_type == 'V2'
+ )
manage_v2_account_button_space = 100.0
- show_player_profiles_button = self._signed_in
+ show_player_profiles_button = self._v1_signed_in
player_profiles_button_space = (
70.0 if show_manage_v2_account_button else 100.0
)
- show_link_accounts_button = (
- self._signed_in
- and ba.internal.get_v1_account_misc_read_val(
- 'allowAccountLinking2', False
- )
+ show_link_accounts_button = self._v1_signed_in and (
+ primary_v2_account is None or FORCE_ENABLE_V1_LINKING
)
link_accounts_button_space = 70.0
show_unlink_accounts_button = show_link_accounts_button
unlink_accounts_button_space = 90.0
- show_sign_out_button = self._signed_in and account_type in [
+ show_v2_link_info = self._v1_signed_in and not show_link_accounts_button
+ v2_link_info_space = 70.0
+
+ legacy_unlink_button_space = 120.0
+
+ show_sign_out_button = self._v1_signed_in and v1_account_type in [
'Local',
'Google Play',
'V2',
@@ -337,25 +341,23 @@ class AccountSettingsWindow(ba.Window):
# to be verified.
show_cancel_sign_in_button = self._signing_in_adapter is not None or (
ba.app.accounts_v2.have_primary_credentials()
- and ba.app.accounts_v2.primary is None
+ and primary_v2_account is None
)
cancel_sign_in_button_space = 70.0
if self._subcontainer is not None:
self._subcontainer.delete()
self._sub_height = 60.0
- if show_local_signed_in_as:
- self._sub_height += local_signed_in_as_space
if show_signed_in_as:
self._sub_height += signed_in_as_space
if show_signing_in_text:
self._sub_height += signing_in_text_space
if show_google_play_sign_in_button:
self._sub_height += sign_in_button_space
- if show_device_sign_in_button:
- self._sub_height += sign_in_button_space
if show_v2_proxy_sign_in_button:
self._sub_height += sign_in_button_space
+ if show_device_sign_in_button:
+ self._sub_height += sign_in_button_space + deprecated_space
if show_game_service_button:
self._sub_height += game_service_button_space
if show_linked_accounts_text:
@@ -382,6 +384,10 @@ class AccountSettingsWindow(ba.Window):
self._sub_height += link_accounts_button_space
if show_unlink_accounts_button:
self._sub_height += unlink_accounts_button_space
+ if show_v2_link_info:
+ self._sub_height += v2_link_info_space
+ if self._show_legacy_unlink_button:
+ self._sub_height += legacy_unlink_button_space
if show_sign_out_button:
self._sub_height += sign_out_button_space
if show_cancel_sign_in_button:
@@ -398,27 +404,8 @@ class AccountSettingsWindow(ba.Window):
first_selectable = None
v = self._sub_height - 10.0
- if show_local_signed_in_as:
- v -= local_signed_in_as_space * 0.6
- ba.textwidget(
- parent=self._subcontainer,
- position=(self._sub_width * 0.5, v),
- size=(0, 0),
- text=ba.Lstr(
- resource='accountSettingsWindow.deviceSpecificAccountText',
- subs=[
- ('${NAME}', ba.internal.get_v1_account_display_string())
- ],
- ),
- scale=0.7,
- color=(0.5, 0.5, 0.6),
- maxwidth=self._sub_width * 0.9,
- flatness=1.0,
- h_align='center',
- v_align='center',
- )
- v -= local_signed_in_as_space * 0.4
-
+ self._account_name_what_is_text: ba.Widget | None
+ self._account_name_what_is_y = 0.0
self._account_name_text: ba.Widget | None
if show_signed_in_as:
v -= signed_in_as_space * 0.2
@@ -437,7 +424,7 @@ class AccountSettingsWindow(ba.Window):
h_align='center',
v_align='center',
)
- v -= signed_in_as_space * 0.4
+ v -= signed_in_as_space * 0.5
self._account_name_text = ba.textwidget(
parent=self._subcontainer,
position=(self._sub_width * 0.5, v),
@@ -449,10 +436,39 @@ class AccountSettingsWindow(ba.Window):
h_align='center',
v_align='center',
)
+
+ if show_what_is_v2:
+ self._account_name_what_is_y = v - 23.0
+ self._account_name_what_is_text = ba.textwidget(
+ parent=self._subcontainer,
+ position=(0.0, self._account_name_what_is_y),
+ size=(200.0, 60),
+ text=ba.Lstr(
+ value='${WHAT} -->',
+ subs=[('${WHAT}', ba.Lstr(resource='whatIsThisText'))],
+ ),
+ scale=0.6,
+ color=(0.3, 0.7, 0.05),
+ maxwidth=200.0,
+ h_align='right',
+ v_align='center',
+ autoselect=True,
+ selectable=True,
+ on_activate_call=show_what_is_v2_page,
+ click_activate=True,
+ )
+ if first_selectable is None:
+ first_selectable = self._account_name_what_is_text
+ else:
+ self._account_name_what_is_text = None
+
self._refresh_account_name_text()
+
v -= signed_in_as_space * 0.4
+
else:
self._account_name_text = None
+ self._account_name_what_is_text = None
if self._back_button is None:
bbtn = ba.internal.get_special_widget('back_button')
@@ -606,7 +622,7 @@ class AccountSettingsWindow(ba.Window):
if show_device_sign_in_button:
button_width = 350
- v -= sign_in_button_space
+ v -= sign_in_button_space + deprecated_space
self._sign_in_device_button = btn = ba.buttonwidget(
parent=self._subcontainer,
position=((self._sub_width - button_width) * 0.5, v - 20),
@@ -615,6 +631,18 @@ class AccountSettingsWindow(ba.Window):
label='',
on_activate_call=lambda: self._sign_in_press('Local'),
)
+ ba.textwidget(
+ parent=self._subcontainer,
+ h_align='center',
+ v_align='center',
+ size=(0, 0),
+ position=(self._sub_width * 0.5, v + 60),
+ text=ba.Lstr(resource='deprecatedText'),
+ scale=0.8,
+ maxwidth=300,
+ color=(0.6, 0.55, 0.45),
+ )
+
ba.textwidget(
parent=self._subcontainer,
draw_controller=btn,
@@ -709,12 +737,12 @@ class AccountSettingsWindow(ba.Window):
if show_game_service_button:
button_width = 300
v -= game_service_button_space * 0.85
- account_type = ba.internal.get_v1_account_type()
- if account_type == 'Game Center':
- account_type_name = ba.Lstr(resource='gameCenterText')
+ v1_account_type = ba.internal.get_v1_account_type()
+ if v1_account_type == 'Game Center':
+ v1_account_type_name = ba.Lstr(resource='gameCenterText')
else:
raise ValueError(
- "unknown account type: '" + str(account_type) + "'"
+ "unknown account type: '" + str(v1_account_type) + "'"
)
self._game_service_button = btn = ba.buttonwidget(
parent=self._subcontainer,
@@ -724,7 +752,7 @@ class AccountSettingsWindow(ba.Window):
autoselect=True,
on_activate_call=ba.internal.show_online_score_ui,
size=(button_width, 50),
- label=account_type_name,
+ label=v1_account_type_name,
)
if first_selectable is None:
first_selectable = btn
@@ -767,11 +795,15 @@ class AccountSettingsWindow(ba.Window):
autoselect=True,
icon=ba.gettexture(
'googlePlayAchievementsIcon'
- if is_google
+ if is_gpgs
else 'achievementsIcon'
),
- icon_color=(0.8, 0.95, 0.7) if is_google else (0.85, 0.8, 0.9),
- on_activate_call=self._on_achievements_press,
+ icon_color=(0.8, 0.95, 0.7) if is_gpgs else (0.85, 0.8, 0.9),
+ on_activate_call=(
+ self._on_custom_achievements_press
+ if is_gpgs
+ else self._on_achievements_press
+ ),
size=(button_width, 50),
label='',
)
@@ -903,6 +935,7 @@ class AccountSettingsWindow(ba.Window):
scale=0.9,
color=(0.75, 0.7, 0.8),
maxwidth=self._sub_width * 0.95,
+ text=ba.Lstr(resource=self._r + '.linkedAccountsText'),
h_align='center',
v_align='center',
)
@@ -911,6 +944,8 @@ class AccountSettingsWindow(ba.Window):
else:
self._linked_accounts_text = None
+ # Show link/unlink buttons only for V1 accounts.
+
if show_link_accounts_button:
v -= link_accounts_button_space
self._link_accounts_button = btn = ba.buttonwidget(
@@ -990,6 +1025,50 @@ class AccountSettingsWindow(ba.Window):
else:
self._unlink_accounts_button = None
+ if show_v2_link_info:
+ v -= v2_link_info_space
+ ba.textwidget(
+ parent=self._subcontainer,
+ h_align='center',
+ v_align='center',
+ size=(0, 0),
+ position=(self._sub_width * 0.5, v + v2_link_info_space - 20),
+ text=ba.Lstr(resource='v2AccountLinkingInfoText'),
+ flatness=1.0,
+ scale=0.8,
+ maxwidth=450,
+ color=(0.5, 0.45, 0.55),
+ )
+
+ if self._show_legacy_unlink_button:
+ v -= legacy_unlink_button_space
+ button_width_w = button_width * 1.5
+ ba.textwidget(
+ parent=self._subcontainer,
+ position=(self._sub_width * 0.5 - 150.0, v + 75),
+ size=(300.0, 60),
+ text=ba.Lstr(resource='whatIsThisText'),
+ scale=0.8,
+ color=(0.3, 0.7, 0.05),
+ maxwidth=200.0,
+ h_align='center',
+ v_align='center',
+ autoselect=True,
+ selectable=True,
+ on_activate_call=show_what_is_legacy_unlinking_page,
+ click_activate=True,
+ )
+ btn = ba.buttonwidget(
+ parent=self._subcontainer,
+ position=((self._sub_width - button_width_w) * 0.5, v + 25),
+ autoselect=True,
+ size=(button_width_w, 60),
+ label=ba.Lstr(resource=self._r + '.unlinkLegacyV1AccountsText'),
+ textcolor=(0.8, 0.4, 0),
+ color=(0.55, 0.5, 0.6),
+ on_activate_call=self._unlink_accounts_press,
+ )
+
if show_sign_out_button:
v -= sign_out_button_space
self._sign_out_button = btn = ba.buttonwidget(
@@ -1044,33 +1123,24 @@ class AccountSettingsWindow(ba.Window):
)
self._needs_refresh = False
+ def _on_custom_achievements_press(self) -> None:
+ ba.timer(
+ 0.15,
+ ba.Call(ba.internal.show_online_score_ui, 'achievements'),
+ timetype=ba.TimeType.REAL,
+ )
+
def _on_achievements_press(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui import achievements
- account_state = ba.internal.get_v1_account_state()
- account_type = (
- ba.internal.get_v1_account_type()
- if account_state == 'signed_in'
- else 'unknown'
+ assert self._achievements_button is not None
+ achievements.AchievementsWindow(
+ position=self._achievements_button.get_screen_space_center()
)
- # for google play we use the built-in UI; otherwise pop up our own
- if account_type == 'Google Play':
- ba.timer(
- 0.15,
- ba.Call(ba.internal.show_online_score_ui, 'achievements'),
- timetype=ba.TimeType.REAL,
- )
- elif account_type != 'unknown':
- assert self._achievements_button is not None
- achievements.AchievementsWindow(
- position=self._achievements_button.get_screen_space_center()
- )
- else:
- print(
- 'ERROR: unknown account type in on_achievements_press:',
- account_type,
- )
+
+ def _on_what_is_v2_press(self) -> None:
+ show_what_is_v2_page()
def _on_manage_account_press(self) -> None:
ba.screenmessage(ba.Lstr(resource='oneMomentText'))
@@ -1106,7 +1176,7 @@ class AccountSettingsWindow(ba.Window):
timetype=ba.TimeType.REAL,
)
- def _have_unlinkable_accounts(self) -> bool:
+ def _have_unlinkable_v1_accounts(self) -> bool:
# if this is not present, we haven't had contact from the server so
# let's not proceed..
if ba.internal.get_public_login_id() is None:
@@ -1119,16 +1189,33 @@ class AccountSettingsWindow(ba.Window):
def _update_unlink_accounts_button(self) -> None:
if self._unlink_accounts_button is None:
return
- if self._have_unlinkable_accounts():
+ if self._have_unlinkable_v1_accounts():
clr = (0.75, 0.7, 0.8, 1.0)
else:
clr = (1.0, 1.0, 1.0, 0.25)
ba.textwidget(edit=self._unlink_accounts_button_label, color=clr)
+ def _should_show_legacy_unlink_button(self) -> bool:
+
+ # Only show this when fully signed in to a v2 account.
+ if not self._v1_signed_in or ba.app.accounts_v2.primary is None:
+ return False
+
+ out = self._have_unlinkable_v1_accounts()
+ return out
+
def _update_linked_accounts_text(self) -> None:
if self._linked_accounts_text is None:
return
+ # Disable this by default when signed in to a V2 account
+ # (since this shows V1 links which we should no longer care about).
+ if (
+ ba.app.accounts_v2.primary is not None
+ and not FORCE_ENABLE_V1_LINKING
+ ):
+ return
+
# if this is not present, we haven't had contact from the server so
# let's not proceed..
if ba.internal.get_public_login_id() is None:
@@ -1138,13 +1225,9 @@ class AccountSettingsWindow(ba.Window):
accounts = ba.internal.get_v1_account_misc_read_val_2(
'linkedAccounts', []
)
- # our_account = _bs.get_v1_account_display_string()
- # accounts = [a for a in accounts if a != our_account]
- # accounts_str = u', '.join(accounts) if accounts else
- # ba.Lstr(translate=('settingNames', 'None'))
# UPDATE - we now just print the number here; not the actual
- # accounts
- # (they can see that in the unlink section if they're curious)
+ # accounts (they can see that in the unlink section if they're
+ # curious)
accounts_str = str(max(0, len(accounts) - 1))
ba.textwidget(
edit=self._linked_accounts_text,
@@ -1195,6 +1278,7 @@ class AccountSettingsWindow(ba.Window):
)
def _refresh_account_name_text(self) -> None:
+
if self._account_name_text is None:
return
try:
@@ -1202,7 +1286,20 @@ class AccountSettingsWindow(ba.Window):
except Exception:
ba.print_exception()
name_str = '??'
+
ba.textwidget(edit=self._account_name_text, text=name_str)
+ if self._account_name_what_is_text is not None:
+ swidth = ba.internal.get_string_width(
+ name_str, suppress_warning=True
+ )
+ # Eww; number-fudging. Need to recalibrate this if
+ # account name scaling changes.
+ x = self._sub_width * 0.5 - swidth * 0.75 - 170
+
+ ba.textwidget(
+ edit=self._account_name_what_is_text,
+ position=(x, self._account_name_what_is_y),
+ )
def _refresh_achievements(self) -> None:
if (
@@ -1232,7 +1329,7 @@ class AccountSettingsWindow(ba.Window):
# pylint: disable=cyclic-import
from bastd.ui.account import unlink
- if not self._have_unlinkable_accounts():
+ if not self._have_unlinkable_v1_accounts():
ba.playsound(ba.getsound('error'))
return
unlink.AccountUnlinkWindow(origin_widget=self._unlink_accounts_button)
@@ -1263,6 +1360,11 @@ class AccountSettingsWindow(ba.Window):
def _sign_out_press(self) -> None:
if ba.app.accounts_v2.have_primary_credentials():
+ if (
+ ba.app.accounts_v2.primary is not None
+ and LoginType.GPGS in ba.app.accounts_v2.primary.logins
+ ):
+ self._explicitly_signed_out_of_gpgs = True
ba.app.accounts_v2.set_primary_credentials(None)
else:
ba.internal.sign_out_v1()
@@ -1301,7 +1403,8 @@ class AccountSettingsWindow(ba.Window):
if adapter is not None:
self._signing_in_adapter = adapter
adapter.sign_in(
- result_cb=ba.WeakCall(self._on_adapter_sign_in_result)
+ result_cb=ba.WeakCall(self._on_adapter_sign_in_result),
+ description='account settings button',
)
# Will get 'Signing in...' to show.
self._needs_refresh = True
@@ -1334,6 +1437,27 @@ class AccountSettingsWindow(ba.Window):
# when finished.
ba.app.accounts_v2.set_primary_credentials(result.credentials)
+ # Special case - if the user has explicitly logged out and
+ # logged in again with GPGS via this button, warn them that
+ # they need to use the app if they want to switch to a
+ # different GPGS account.
+ if (
+ self._explicitly_signed_out_of_gpgs
+ and adapter.login_type is LoginType.GPGS
+ ):
+ # Delay this slightly so it hopefully pops up after
+ # credentials go through and the account name shows up.
+ ba.timer(
+ 1.5,
+ ba.Call(
+ ba.screenmessage,
+ ba.Lstr(
+ resource=self._r
+ + '.googlePlayGamesAccountSwitchText'
+ ),
+ ),
+ )
+
# Speed any UI updates along.
self._needs_refresh = True
ba.timer(0.1, ba.WeakCall(self._update), timetype=ba.TimeType.REAL)
@@ -1402,3 +1526,15 @@ class AccountSettingsWindow(ba.Window):
ba.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception:
ba.print_exception(f'Error restoring state for {self}.')
+
+
+def show_what_is_v2_page() -> None:
+ """Show the webpage describing V2 accounts."""
+ bamasteraddr = ba.internal.get_master_server_address(version=2)
+ ba.open_url(f'{bamasteraddr}/whatisv2')
+
+
+def show_what_is_legacy_unlinking_page() -> None:
+ """Show the webpage describing legacy unlinking."""
+ bamasteraddr = ba.internal.get_master_server_address(version=2)
+ ba.open_url(f'{bamasteraddr}/whatarev1links')
diff --git a/assets/src/ba_data/python/bastd/ui/account/v2proxy.py b/assets/src/ba_data/python/bastd/ui/account/v2proxy.py
index a2d4fb76..d782fcf7 100644
--- a/assets/src/ba_data/python/bastd/ui/account/v2proxy.py
+++ b/assets/src/ba_data/python/bastd/ui/account/v2proxy.py
@@ -67,11 +67,14 @@ class V2ProxySignInWindow(ba.Window):
label=ba.Lstr(resource='cancelText'),
on_activate_call=self._done,
autoselect=True,
+ color=(0.55, 0.5, 0.6),
textcolor=(0.75, 0.7, 0.8),
)
- ba.containerwidget(
- edit=self._root_widget, cancel_button=self._cancel_button
- )
+
+ if bool(False):
+ ba.containerwidget(
+ edit=self._root_widget, cancel_button=self._cancel_button
+ )
self._update_timer: ba.Timer | None = None
diff --git a/assets/src/ba_data/python/bastd/ui/configerror.py b/assets/src/ba_data/python/bastd/ui/configerror.py
index 129e5eaa..3165bfc3 100644
--- a/assets/src/ba_data/python/bastd/ui/configerror.py
+++ b/assets/src/ba_data/python/bastd/ui/configerror.py
@@ -20,12 +20,12 @@ class ConfigErrorWindow(ba.Window):
self._config_file_path = ba.app.config_file_path
width = 800
super().__init__(
- ba.containerwidget(size=(width, 300), transition='in_right')
+ ba.containerwidget(size=(width, 400), transition='in_right')
)
padding = 20
ba.textwidget(
parent=self._root_widget,
- position=(padding, 220),
+ position=(padding, 220 + 60),
size=(width - 2 * padding, 100 - 2 * padding),
h_align='center',
v_align='top',
@@ -41,7 +41,7 @@ class ConfigErrorWindow(ba.Window):
)
ba.textwidget(
parent=self._root_widget,
- position=(padding, 198),
+ position=(padding, 198 + 60),
size=(width - 2 * padding, 100 - 2 * padding),
h_align='center',
v_align='top',
diff --git a/assets/src/ba_data/python/bastd/ui/gather/publictab.py b/assets/src/ba_data/python/bastd/ui/gather/publictab.py
index fafd19b4..e874af6b 100644
--- a/assets/src/ba_data/python/bastd/ui/gather/publictab.py
+++ b/assets/src/ba_data/python/bastd/ui/gather/publictab.py
@@ -1094,7 +1094,6 @@ class PublicGatherTab(GatherTab):
self._parties_sorted.sort(
key=lambda p: (
- p[1].queue is None, # Show non-queued last.
p[1].ping if p[1].ping is not None else 999999.0,
p[1].index,
)
diff --git a/assets/src/ba_data/python/bastd/ui/mainmenu.py b/assets/src/ba_data/python/bastd/ui/mainmenu.py
index a3d67738..b4369f0a 100644
--- a/assets/src/ba_data/python/bastd/ui/mainmenu.py
+++ b/assets/src/ba_data/python/bastd/ui/mainmenu.py
@@ -515,7 +515,20 @@ class MainMenuWindow(ba.Window):
self._tdelay = 2.0
self._t_delay_inc = 0.02
self._t_delay_play = 1.7
- self._next_refresh_allow_time = ba.time(ba.TimeType.REAL) + 2.01
+
+ def _set_allow_time() -> None:
+ self._next_refresh_allow_time = ba.time(ba.TimeType.REAL) + 2.5
+
+ # Slight hack: widget transitions currently only progress when
+ # frames are being drawn, but this tends to get called before
+ # frame drawing even starts, meaning we don't know exactly how
+ # long we should wait before refreshing to avoid interrupting
+ # the transition. To make things a bit better, let's do a
+ # redundant set of the time in a deferred call which hopefully
+ # happens closer to actual frame draw times.
+ _set_allow_time()
+ ba.pushcall(_set_allow_time)
+
ba.app.did_menu_intro = True
self._width = 400.0
self._height = 200.0
diff --git a/assets/src/ba_data/python/bastd/ui/playlist/editgame.py b/assets/src/ba_data/python/bastd/ui/playlist/editgame.py
index ee3a2cd5..a2483364 100644
--- a/assets/src/ba_data/python/bastd/ui/playlist/editgame.py
+++ b/assets/src/ba_data/python/bastd/ui/playlist/editgame.py
@@ -473,7 +473,6 @@ class PlaylistEditGameWindow(ba.Window):
# Ok now wire up the column.
try:
- # pylint: disable=unsubscriptable-object
prev_widgets: list[ba.Widget] | None = None
for cwdg in widget_column:
if prev_widgets is not None:
diff --git a/assets/src/ba_data/python/bastd/ui/store/browser.py b/assets/src/ba_data/python/bastd/ui/store/browser.py
index 49a301c2..1bc8da43 100644
--- a/assets/src/ba_data/python/bastd/ui/store/browser.py
+++ b/assets/src/ba_data/python/bastd/ui/store/browser.py
@@ -4,18 +4,25 @@
# pylint: disable=too-many-lines
from __future__ import annotations
+import time
import copy
import math
+import logging
import weakref
from enum import Enum
+from threading import Thread
from typing import TYPE_CHECKING
+from efro.error import CommunicationError
+import bacommon.cloud
import ba
import ba.internal
if TYPE_CHECKING:
from typing import Any, Callable, Sequence
+MERCH_LINK_KEY = 'Merch Link'
+
class StoreBrowserWindow(ba.Window):
"""Window for browsing the store."""
@@ -593,8 +600,14 @@ class StoreBrowserWindow(ba.Window):
else:
self._last_buy_time = curtime
- # Pro is an actual IAP; the rest are ticket purchases.
- if item == 'pro':
+ # Merch is a special case - just a link.
+ if item == 'merch':
+ url = ba.app.config.get('Merch Link')
+ if isinstance(url, str):
+ ba.open_url(url)
+
+ # Pro is an actual IAP, and the rest are ticket purchases.
+ elif item == 'pro':
ba.playsound(ba.getsound('click01'))
# Purchase either pro or pro_sale depending on whether
@@ -681,7 +694,9 @@ class StoreBrowserWindow(ba.Window):
assert self.button_infos is not None
for b_type, b_info in self.button_infos.items():
- if b_type in ['upgrades.pro', 'pro']:
+ if b_type == 'merch':
+ purchased = False
+ elif b_type in ['upgrades.pro', 'pro']:
purchased = ba.app.accounts_v1.have_pro()
else:
purchased = ba.internal.get_purchased(b_type)
@@ -707,7 +722,11 @@ class StoreBrowserWindow(ba.Window):
color = (0.4, 0.8, 0.1)
extra_image_opacity = 1.0
call = b_info['call'] if 'call' in b_info else None
- if b_type in ['upgrades.pro', 'pro']:
+ if b_type == 'merch':
+ price_text = ''
+ price_text_left = ''
+ price_text_right = ''
+ elif b_type in ['upgrades.pro', 'pro']:
sale_time = get_available_sale_time('extras')
if sale_time is not None:
priceraw = ba.internal.get_price('pro')
@@ -888,7 +907,14 @@ class StoreBrowserWindow(ba.Window):
dummy_name
)
section['v_spacing'] = (
- -17 if self._tab == 'characters' else 0
+ -25
+ if (
+ self._tab == 'extras'
+ and uiscale is ba.UIScale.SMALL
+ )
+ else -17
+ if self._tab == 'characters'
+ else 0
)
if 'title' not in section:
section['title'] = ''
@@ -900,7 +926,13 @@ class StoreBrowserWindow(ba.Window):
else 0
)
section['y_offs'] = (
- 55
+ 20
+ if (
+ self._tab == 'extras'
+ and uiscale is ba.UIScale.SMALL
+ and ba.app.config.get('Merch Link')
+ )
+ else 55
if (
self._tab == 'extras'
and uiscale is ba.UIScale.SMALL
@@ -917,7 +949,9 @@ class StoreBrowserWindow(ba.Window):
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks
- from bastd.ui.store import item as storeitemui
+ from bastd.ui.store.item import (
+ instantiate_store_item_display,
+ )
title_spacing = 40
button_border = 20
@@ -1102,7 +1136,7 @@ class StoreBrowserWindow(ba.Window):
+ (b_width + button_spacing) * col,
v - b_height + boffs_v2,
)
- storeitemui.instantiate_store_item_display(
+ instantiate_store_item_display(
item_name,
item,
parent_widget=cnt2,
@@ -1121,7 +1155,6 @@ class StoreBrowserWindow(ba.Window):
# Wire this button to the equivalent in the
# previous row.
if prev_row_buttons is not None:
- # pylint: disable=unsubscriptable-object
if len(prev_row_buttons) > col:
ba.widget(
edit=btn,
@@ -1325,3 +1358,41 @@ class StoreBrowserWindow(ba.Window):
)
if self._on_close_call is not None:
self._on_close_call()
+
+
+def _check_merch_availability_in_bg_thread() -> None:
+ # pylint: disable=cell-var-from-loop
+
+ # Merch is available from some countries only.
+ # Make a reasonable check to ask the master-server about this at
+ # launch and store the results.
+ for _i in range(15):
+ try:
+ if ba.app.cloud.is_connected():
+ response = ba.app.cloud.send_message(
+ bacommon.cloud.MerchAvailabilityMessage()
+ )
+
+ def _store_in_logic_thread() -> None:
+ cfg = ba.app.config
+ current: str | None = cfg.get(MERCH_LINK_KEY)
+ if not isinstance(current, str | None):
+ current = None
+ if current != response.url:
+ cfg[MERCH_LINK_KEY] = response.url
+ cfg.commit()
+
+ # If we successfully get a response, kick it over to the
+ # logic thread to store and we're done.
+ ba.pushcall(_store_in_logic_thread, from_other_thread=True)
+ return
+ except CommunicationError:
+ pass
+ except Exception:
+ logging.warning(
+ 'Unexpected error in merch-availability-check.', exc_info=True
+ )
+ time.sleep(1.1934) # A bit randomized to avoid aliasing.
+
+
+Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()
diff --git a/assets/src/ba_data/python/bastd/ui/store/item.py b/assets/src/ba_data/python/bastd/ui/store/item.py
index 246e84e9..6c8b827e 100644
--- a/assets/src/ba_data/python/bastd/ui/store/item.py
+++ b/assets/src/ba_data/python/bastd/ui/store/item.py
@@ -45,19 +45,23 @@ def instantiate_store_item_display(
item['name'] = title = get_store_item_name_translated(item_name)
btn: ba.Widget | None
+
+ # Hack; showbuffer stuff isn't working well when we're showing merch.
+ showbuffer = 10 if item_name in {'merch', 'pro', 'pro_sale'} else 76.0
+
if button:
item['button'] = btn = ba.buttonwidget(
parent=parent_widget,
position=b_pos,
transition_delay=delay,
- show_buffer_top=76.0,
+ show_buffer_top=showbuffer,
enable_sound=False,
button_type='square',
size=(b_width, b_height),
autoselect=True,
label='',
)
- ba.widget(edit=btn, show_buffer_bottom=76.0)
+ ba.widget(edit=btn, show_buffer_bottom=showbuffer)
else:
btn = None
@@ -92,6 +96,10 @@ def instantiate_store_item_display(
tint_tex = character.icon_mask_texture
title_v = 0.255
price_v = 0.145
+ elif item_name == 'merch':
+ base_text_scale = 0.6
+ title_v = 0.85
+ price_v = 0.15
elif item_name in ['upgrades.pro', 'pro']:
base_text_scale = 0.6
title_v = 0.85
@@ -165,6 +173,23 @@ def instantiate_store_item_display(
tint2_color=tint2_color,
)
+ if item_name == 'merch':
+ frame_size = b_width * 0.65
+ im_dim = frame_size * (100.0 / 113.0)
+ im_pos = (
+ b_pos[0] + b_width * 0.5 - im_dim * 0.5 + b_offs_x,
+ b_pos[1] + b_height * 0.47 - im_dim * 0.5,
+ )
+ ba.imagewidget(
+ parent=parent_widget,
+ position=im_pos,
+ size=(im_dim, im_dim),
+ transition_delay=delay,
+ draw_controller=btn,
+ opacity=1.0,
+ texture=ba.gettexture('merch'),
+ )
+
if item_name in ['pro', 'upgrades.pro']:
frame_size = b_width * 0.5
im_dim = frame_size * (100.0 / 113.0)
@@ -184,7 +209,6 @@ def instantiate_store_item_display(
)
txt = ba.Lstr(resource='store.bombSquadProNewDescriptionText')
- # t = 'foo\nfoo\nfoo\nfoo\nfoo\nfoo'
item['descriptionText'] = ba.textwidget(
parent=parent_widget,
text=txt,
diff --git a/assets/src/ba_data/python/bastd/ui/v2upgrade.py b/assets/src/ba_data/python/bastd/ui/v2upgrade.py
new file mode 100644
index 00000000..7210c95e
--- /dev/null
+++ b/assets/src/ba_data/python/bastd/ui/v2upgrade.py
@@ -0,0 +1,117 @@
+# Released under the MIT License. See LICENSE for details.
+#
+"""UI for upgrading V1 accounts to V2."""
+
+from __future__ import annotations
+
+import ba
+import ba.internal
+
+
+class V2UpgradeWindow(ba.Window):
+ """A window presenting a URL to the user visually."""
+
+ def __init__(self, login_name: str, code: str):
+ from bastd.ui.account.settings import show_what_is_v2_page
+
+ app = ba.app
+ uiscale = app.ui.uiscale
+
+ self._code = code
+
+ self._width = 700
+ self._height = 270
+ super().__init__(
+ root_widget=ba.containerwidget(
+ size=(self._width, self._height + 40),
+ transition='in_right',
+ scale=(
+ 1.25
+ if uiscale is ba.UIScale.SMALL
+ else 1.25
+ if uiscale is ba.UIScale.MEDIUM
+ else 1.25
+ ),
+ )
+ )
+ ba.playsound(ba.getsound('error'))
+
+ ba.textwidget(
+ parent=self._root_widget,
+ position=(self._width * 0.5, self._height - 46),
+ size=(0, 0),
+ color=ba.app.ui.title_color,
+ h_align='center',
+ v_align='center',
+ text=ba.Lstr(
+ resource='deviceAccountUpgradeText',
+ subs=[('${NAME}', login_name)],
+ ),
+ maxwidth=self._width * 0.95,
+ )
+ ba.textwidget(
+ parent=self._root_widget,
+ position=(self._width * 0.5, 125),
+ size=(0, 0),
+ scale=0.8,
+ color=(0.7, 0.8, 0.7),
+ h_align='center',
+ v_align='center',
+ text=(
+ ba.charstr(ba.SpecialChar.LOCAL_ACCOUNT)
+ + login_name
+ + ' ----> '
+ + ba.charstr(ba.SpecialChar.V2_LOGO)
+ + login_name
+ ),
+ maxwidth=self._width * 0.95,
+ )
+ button_width = 200
+
+ cancel_button = ba.buttonwidget(
+ parent=self._root_widget,
+ position=(20, 25),
+ size=(button_width, 65),
+ autoselect=True,
+ label=ba.Lstr(resource='notNowText'),
+ on_activate_call=self._done,
+ )
+
+ _what_is_this_button = ba.buttonwidget(
+ parent=self._root_widget,
+ position=(self._width * 0.5 - button_width * 0.5, 25),
+ size=(button_width, 65),
+ autoselect=True,
+ label=ba.Lstr(resource='whatIsThisText'),
+ color=(0.55, 0.5, 0.6),
+ textcolor=(0.75, 0.7, 0.8),
+ on_activate_call=show_what_is_v2_page,
+ )
+
+ upgrade_button = ba.buttonwidget(
+ parent=self._root_widget,
+ position=(self._width - button_width - 20, 25),
+ size=(button_width, 65),
+ autoselect=True,
+ label=ba.Lstr(resource='upgradeText'),
+ on_activate_call=self._upgrade_press,
+ )
+
+ ba.containerwidget(
+ edit=self._root_widget,
+ selected_child=upgrade_button,
+ cancel_button=cancel_button,
+ )
+
+ def _upgrade_press(self) -> None:
+ # Get rid of the window and sign out before kicking the
+ # user over to a browser to do the upgrade. This hopefully
+ # makes it more clear when they come back that they need to
+ # sign in with the 'BombSquad account' option.
+ ba.containerwidget(edit=self._root_widget, transition='out_left')
+ ba.internal.sign_out_v1()
+ bamasteraddr = ba.internal.get_master_server_address(version=2)
+ ba.open_url(f'{bamasteraddr}/v2uda/{self._code}')
+
+ def _done(self) -> None:
+ ba.containerwidget(edit=self._root_widget, transition='out_left')
diff --git a/ballisticacore-cmake/.idea/dictionaries/ericf.xml b/ballisticacore-cmake/.idea/dictionaries/ericf.xml
index 60593a9f..21191a9c 100644
--- a/ballisticacore-cmake/.idea/dictionaries/ericf.xml
+++ b/ballisticacore-cmake/.idea/dictionaries/ericf.xml
@@ -48,6 +48,7 @@
allobjs
allocs
allwarnings
+ alogins
alot
alphaimg
alphapixels
@@ -71,6 +72,7 @@
appconfig
appname
appnameupper
+ appnow
appspot
appstate
apptime
@@ -93,6 +95,7 @@
avel
avels
awaitable
+ awaitables
axismotion
backgrounded
backgrounding
@@ -217,6 +220,7 @@
cend
centiseconds
certifi
+ cfgdict
cfgdir
cfgpath
changeme
@@ -367,6 +371,7 @@
doraise
dosomething
dout
+ downcasting
downsample
dpad
dpads
@@ -385,11 +390,13 @@
dummyret
dummyval
dummyvalid
+ dumpminlog
dval
dxgi
dynamicdata
echidna
echofile
+ echotime
edef
effmult
efro
@@ -456,9 +463,11 @@
fastldlt
fastlsolve
fastltsolve
+ faulthandler
fbos
fcntl
fdata
+ fdcount
fdirx
fdiry
fdirz
@@ -667,6 +676,7 @@
ioprepped
ioprepping
ioreg
+ iscoroutinefunction
iserverget
iserverput
isinst
@@ -728,6 +738,7 @@
leaderboards
lenval
levelno
+ levelnos
levelstr
lgui
lhalf
@@ -797,11 +808,13 @@
maskuv
masterver
mastervers
+ matchlines
maximus
maxpathlen
maxtries
maxwait
maxwidth
+ mdpath
mediump
memalign
memchr
@@ -819,6 +832,7 @@
millisecs
minelem
miniplayer
+ minlog
minping
minsdl
mipmapcount
@@ -1240,6 +1254,7 @@
shhh
shifthigh
shouldnt
+ showbuffer
shufflable
signsubscale
sigsetjmp
@@ -1334,6 +1349,7 @@
subtypestr
successmsg
sval
+ swidth
swiftc
symbolification
syscalls
@@ -1345,6 +1361,9 @@
targs
tasklabel
tbegin
+ tbfile
+ tbfiles
+ tbpath
tcls
tdels
tdiff
@@ -1500,6 +1519,7 @@
wakeups
walisser
wasdebug
+ wasn
watte
wdeprecated
weakref
@@ -1508,6 +1528,8 @@
weeeird
welp
whaaaaaaa
+ whatarev
+ whatisv
wheee
wheeee
wiimote
diff --git a/ballisticacore-cmake/CMakeLists.txt b/ballisticacore-cmake/CMakeLists.txt
index 77deaf38..4b79cb15 100644
--- a/ballisticacore-cmake/CMakeLists.txt
+++ b/ballisticacore-cmake/CMakeLists.txt
@@ -464,7 +464,6 @@ add_executable(ballisticacore
${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host.h
${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host_udp.cc
${BA_SRC_ROOT}/ballistica/logic/connection/connection_to_host_udp.h
- ${BA_SRC_ROOT}/ballistica/logic/friend_score_set.h
${BA_SRC_ROOT}/ballistica/logic/host_activity.cc
${BA_SRC_ROOT}/ballistica/logic/host_activity.h
${BA_SRC_ROOT}/ballistica/logic/logic.cc
diff --git a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj
index 8f4b7c5a..18e36464 100644
--- a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj
+++ b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj
@@ -455,7 +455,6 @@
-
diff --git a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters
index 4430ba75..75bed1a1 100644
--- a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters
+++ b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters
@@ -799,9 +799,6 @@
ballistica\logic\connection
-
- ballistica\logic
-
ballistica\logic
diff --git a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj
index f1654fcf..78353bdb 100644
--- a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj
+++ b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj
@@ -450,7 +450,6 @@
-
diff --git a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters
index 4430ba75..75bed1a1 100644
--- a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters
+++ b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters
@@ -799,9 +799,6 @@
ballistica\logic\connection
-
- ballistica\logic
-
ballistica\logic
diff --git a/src/ballistica/app/app_flavor.cc b/src/ballistica/app/app_flavor.cc
index d238d854..0d237d87 100644
--- a/src/ballistica/app/app_flavor.cc
+++ b/src/ballistica/app/app_flavor.cc
@@ -156,7 +156,6 @@ void AppFlavor::UpdatePauseResume() {
void AppFlavor::OnPause() {
assert(InMainThread());
- // Avoid reading gyro values for a short time to avoid hitches when restored.
g_graphics->SetGyroEnabled(false);
// IMPORTANT: Any on-pause related stuff that threads need to do must
@@ -234,20 +233,64 @@ void AppFlavor::SetProductPrice(const std::string& product,
void AppFlavor::PauseApp() {
assert(InMainThread());
+ millisecs_t start_time{Platform::GetCurrentMilliseconds()};
+
+ // Apple mentioned 5 seconds to run stuff once backgrounded or
+ // they bring down the hammer. Let's aim to stay under 2.
+ millisecs_t max_duration{2000};
+
Platform::DebugLog("PauseApp@"
+ std::to_string(Platform::GetCurrentMilliseconds()));
assert(!sys_paused_app_);
sys_paused_app_ = true;
UpdatePauseResume();
+
+ // We assume that the OS will completely suspend our process the moment
+ // we return from this call (though this is not technically true on all
+ // platforms). So we want to spin and wait for threads to actually
+ // process the pause message.
+ size_t running_thread_count{};
+ while (std::abs(Platform::GetCurrentMilliseconds() - start_time)
+ < max_duration) {
+ // If/when we get to a point with no threads waiting to be paused,
+ // we're good to go.
+ auto threads{Thread::GetStillPausingThreads()};
+ running_thread_count = threads.size();
+ if (running_thread_count == 0) {
+ if (g_buildconfig.debug_build()) {
+ Log(LogLevel::kDebug,
+ "PauseApp() completed in "
+ + std::to_string(Platform::GetCurrentMilliseconds()
+ - start_time)
+ + "ms.");
+ }
+ return;
+ }
+ }
+
+ // If we made it here, we timed out. Complain.
+ Log(LogLevel::kError,
+ std::string("PauseApp() took too long; ")
+ + std::to_string(running_thread_count)
+ + " threads not yet paused after "
+ + std::to_string(Platform::GetCurrentMilliseconds() - start_time)
+ + " ms.");
}
void AppFlavor::ResumeApp() {
assert(InMainThread());
+ millisecs_t start_time{Platform::GetCurrentMilliseconds()};
Platform::DebugLog("ResumeApp@"
+ std::to_string(Platform::GetCurrentMilliseconds()));
assert(sys_paused_app_);
sys_paused_app_ = false;
UpdatePauseResume();
+ if (g_buildconfig.debug_build()) {
+ Log(LogLevel::kDebug,
+ "ResumeApp() completed in "
+ + std::to_string(Platform::GetCurrentMilliseconds() - start_time)
+ + "ms.");
+ }
}
void AppFlavor::DidFinishRenderingFrame(FrameDef* frame) {}
@@ -265,6 +308,7 @@ void AppFlavor::PrimeEventPump() {
#pragma mark Push-Calls
+// FIXME - move this call to Platform.
void AppFlavor::PushShowOnlineScoreUICall(const std::string& show,
const std::string& game,
const std::string& game_version) {
@@ -316,14 +360,6 @@ void AppFlavor::PushOpenURLCall(const std::string& url) {
thread()->PushCall([url] { g_platform->OpenURL(url); });
}
-void AppFlavor::PushGetFriendScoresCall(const std::string& game,
- const std::string& game_version,
- void* data) {
- thread()->PushCall([game, game_version, data] {
- g_platform->GetFriendScores(game, game_version, data);
- });
-}
-
void AppFlavor::PushSubmitScoreCall(const std::string& game,
const std::string& game_version,
int64_t score) {
diff --git a/src/ballistica/app/app_flavor.h b/src/ballistica/app/app_flavor.h
index fb1f013a..c0693b48 100644
--- a/src/ballistica/app/app_flavor.h
+++ b/src/ballistica/app/app_flavor.h
@@ -43,16 +43,17 @@ class AppFlavor {
/// Should process any pending OS events, etc.
virtual auto RunEvents() -> void;
- // These should be called by the window, view-controller, sdl,
- // or whatever is driving the app. They must be called from the main thread.
-
- /// Should be called on mobile when the app is backgrounded.
- /// Pauses threads, closes network sockets, etc.
+ /// Put the app into a paused state. Should be called from the main
+ /// thread. Pauses work, closes network sockets, etc.
+ /// Corresponds to being backgrounded on mobile, etc.
+ /// It is assumed that, as soon as this call returns, all work is
+ /// finished and all threads can be suspended by the OS without any
+ /// negative side effects.
auto PauseApp() -> void;
auto paused() const -> bool { return actually_paused_; }
- /// Should be called on mobile when the app is foregrounded.
+ /// Resume the app; corresponds to returning to foreground on mobile/etc.
/// Spins threads back up, re-opens network sockets, etc.
auto ResumeApp() -> void;
@@ -101,9 +102,6 @@ class AppFlavor {
auto PushShowOnlineScoreUICall(const std::string& show,
const std::string& game,
const std::string& game_version) -> void;
- auto PushGetFriendScoresCall(const std::string& game,
- const std::string& game_version, void* data)
- -> void;
auto PushSubmitScoreCall(const std::string& game,
const std::string& game_version, int64_t score)
-> void;
diff --git a/src/ballistica/ballistica.cc b/src/ballistica/ballistica.cc
index 6fa5faad..b4c024ee 100644
--- a/src/ballistica/ballistica.cc
+++ b/src/ballistica/ballistica.cc
@@ -32,8 +32,8 @@
namespace ballistica {
// These are set automatically via script; don't modify them here.
-const int kAppBuildNumber = 20934;
-const char* kAppVersion = "1.7.14";
+const int kAppBuildNumber = 20993;
+const char* kAppVersion = "1.7.19";
// Our standalone globals.
// These are separated out for easy access.
diff --git a/src/ballistica/core/logging.cc b/src/ballistica/core/logging.cc
index 50a66c90..f6dced95 100644
--- a/src/ballistica/core/logging.cc
+++ b/src/ballistica/core/logging.cc
@@ -70,11 +70,11 @@ auto Logging::V1CloudLog(const std::string& msg) -> void {
std::scoped_lock lock(g_app->v1_cloud_log_mutex);
if (!g_app->v1_cloud_log_full) {
(g_app->v1_cloud_log) += (msg + "\n");
- if ((g_app->v1_cloud_log).size() > 10000) {
+ if ((g_app->v1_cloud_log).size() > 25000) {
// Allow some reasonable overflow for last statement.
- if ((g_app->v1_cloud_log).size() > 100000) {
+ if ((g_app->v1_cloud_log).size() > 250000) {
// FIXME: This could potentially chop up utf-8 chars.
- (g_app->v1_cloud_log).resize(100000);
+ (g_app->v1_cloud_log).resize(250000);
}
g_app->v1_cloud_log += "\n\n";
g_app->v1_cloud_log_full = true;
diff --git a/src/ballistica/core/object.cc b/src/ballistica/core/object.cc
index ee3280ee..6efc0098 100644
--- a/src/ballistica/core/object.cc
+++ b/src/ballistica/core/object.cc
@@ -11,7 +11,7 @@
namespace ballistica {
-void Object::PrintObjects() {
+void Object::LsObjects() {
#if BA_DEBUG_BUILD
std::string s;
{
@@ -47,7 +47,7 @@ void Object::PrintObjects() {
for (auto&& i : obj_map) {
sorted.emplace_back(i.second, i.first);
}
- std::sort(sorted.begin(), sorted.end());
+ std::sort(sorted.rbegin(), sorted.rend());
for (auto&& i : sorted) {
s += "\n " + std::to_string(i.first) + ": " + i.second;
}
@@ -56,7 +56,7 @@ void Object::PrintObjects() {
}
Log(LogLevel::kInfo, s);
#else
- Log(LogLevel::kInfo, "PrintObjects() only functions in debug builds.");
+ Log(LogLevel::kInfo, "LsObjects() only functions in debug builds.");
#endif // BA_DEBUG_BUILD
}
diff --git a/src/ballistica/core/object.h b/src/ballistica/core/object.h
index 6847e43b..5cb03e1b 100644
--- a/src/ballistica/core/object.h
+++ b/src/ballistica/core/object.h
@@ -21,8 +21,8 @@ class Object {
Object();
virtual ~Object();
- /// Prints a tally of object types and counts (debug build only).
- static void PrintObjects();
+ /// Logs a tally of ba::Object types and counts (debug build only).
+ static void LsObjects();
// Object classes can provide descriptive names for themselves;
// these are used for debugging and other purposes.
diff --git a/src/ballistica/core/thread.cc b/src/ballistica/core/thread.cc
index de2b5985..c1fae4a8 100644
--- a/src/ballistica/core/thread.cc
+++ b/src/ballistica/core/thread.cc
@@ -91,7 +91,7 @@ auto Thread::RunAssetsThreadP(void* data) -> void* {
return nullptr;
}
-void Thread::SetPaused(bool paused) {
+void Thread::PushSetPaused(bool paused) {
// Can be toggled from the main thread only.
assert(std::this_thread::get_id() == g_app->main_thread_id);
PushThreadMessage(ThreadMessage(paused ? ThreadMessage::Type::kPause
@@ -101,13 +101,24 @@ void Thread::SetPaused(bool paused) {
void Thread::WaitForNextEvent(bool single_cycle) {
// If we're running a single cycle we never stop to wait.
if (single_cycle) {
+ // Need to revisit this if we ever do single-cycle for
+ // the gil-holding thread so we don't starve other Python threads.
+ assert(!acquires_python_gil_);
return;
}
- // We also never wait if we have pending runnables.
- // (we run all existing runnables in each loop cycle, but one of those
- // may have enqueued more).
- if (has_pending_runnables()) {
+ // We also never wait if we have pending runnables; we wan't to run
+ // things as soon as we can. We chew through all runnables at the end
+ // of the loop so it might seem like there should never be any here,
+ // but runnables can add other runnables that won't get processed until
+ // the next time through.
+ // BUG FIX: We now skip this if we're paused since we don't run runnables
+ // in that case. This was preventing us from releasing the GIL while paused
+ // (and I assume causing us to spin full-speed through the loop; ugh).
+ // NOTE: It is theoretically possible for a runnable to add another runnable
+ // each time through the loop which would effectively starve the GIL as
+ // well; do we need to worry about that case?
+ if (has_pending_runnables() && !paused_) {
return;
}
@@ -422,15 +433,17 @@ Thread::~Thread() = default;
#pragma clang diagnostic push
#pragma ide diagnostic ignored "ConstantConditionsOC"
-void Thread::LogThreadMessageTally() {
+void Thread::LogThreadMessageTally(
+ std::vector>* log_entries) {
// Prevent recursion.
if (!writing_tally_) {
writing_tally_ = true;
std::unordered_map tally;
- Log(LogLevel::kError, "Thread message tally ("
+ log_entries->emplace_back(std::make_pair(
+ LogLevel::kError, "Thread message tally ("
+ std::to_string(thread_messages_.size())
- + " in list):");
+ + " in list):"));
for (auto&& m : thread_messages_) {
std::string s;
switch (m.type) {
@@ -464,8 +477,9 @@ void Thread::LogThreadMessageTally() {
}
int entry = 1;
for (auto&& i : tally) {
- Log(LogLevel::kError, " #" + std::to_string(entry++) + " ("
- + std::to_string(i.second) + "x): " + i.first);
+ log_entries->emplace_back(std::make_pair(
+ LogLevel::kError, " #" + std::to_string(entry++) + " ("
+ + std::to_string(i.second) + "x): " + i.first));
}
writing_tally_ = false;
}
@@ -473,15 +487,18 @@ void Thread::LogThreadMessageTally() {
#pragma clang diagnostic pop
void Thread::PushThreadMessage(const ThreadMessage& t) {
+ // We don't want to make log calls while holding this mutex;
+ // log calls acquire the GIL and if the GIL-holder (generally
+ // the logic thread) is trying to send a thread message to the
+ // thread doing the logging we would get deadlock.
+ // So tally up any logs and send them after.
+ std::vector> log_entries;
{
std::unique_lock lock(thread_message_mutex_);
// Plop the data on to the list; we're assuming the mutex is locked.
thread_messages_.push_back(t);
- // Keep our own count; apparently size() on an stl list involves iterating.
- // FIXME: Actually I don't think this is the case anymore; should check.
-
// Debugging: show message count states.
if (explicit_bool(false)) {
static int one_off = 0;
@@ -498,8 +515,9 @@ void Thread::PushThreadMessage(const ThreadMessage& t) {
// Show count periodically.
if ((std::this_thread::get_id() == g_app->main_thread_id) && foo > 100) {
foo = 0;
- Log(LogLevel::kInfo,
- "MSG COUNT " + std::to_string(thread_messages_.size()));
+ log_entries.emplace_back(std::make_pair(
+ LogLevel::kInfo,
+ "MSG COUNT " + std::to_string(thread_messages_.size())));
}
}
@@ -507,9 +525,11 @@ void Thread::PushThreadMessage(const ThreadMessage& t) {
static bool sent_error = false;
if (!sent_error) {
sent_error = true;
- Log(LogLevel::kError,
- "ThreadMessage list > 1000 in thread: " + GetCurrentThreadName());
- LogThreadMessageTally();
+ log_entries.emplace_back(std::make_pair(
+ LogLevel::kError,
+ "ThreadMessage list > 1000 in thread: " + GetCurrentThreadName()));
+
+ LogThreadMessageTally(&log_entries);
}
}
@@ -523,15 +543,36 @@ void Thread::PushThreadMessage(const ThreadMessage& t) {
// available.
}
thread_message_cv_.notify_all();
+
+ // Now log anything we accumulated safely outside of the locked section.
+ for (auto&& log_entry : log_entries) {
+ Log(log_entry.first, log_entry.second);
+ }
}
-void Thread::SetThreadsPaused(bool paused) {
+auto Thread::SetThreadsPaused(bool paused) -> void {
+ assert(std::this_thread::get_id() == g_app->main_thread_id);
g_app->threads_paused = paused;
for (auto&& i : g_app->pausable_threads) {
- i->SetPaused(paused);
+ i->PushSetPaused(paused);
}
}
+auto Thread::GetStillPausingThreads() -> std::vector {
+ std::vector threads;
+ assert(std::this_thread::get_id() == g_app->main_thread_id);
+
+ // Only return results if an actual pause is in effect.
+ if (g_app->threads_paused) {
+ for (auto&& i : g_app->pausable_threads) {
+ if (!i->paused()) {
+ threads.push_back(i);
+ }
+ }
+ }
+ return threads;
+}
+
auto Thread::AreThreadsPaused() -> bool { return g_app->threads_paused; }
auto Thread::NewTimer(millisecs_t length, bool repeat,
@@ -553,6 +594,7 @@ auto Thread::GetCurrentThreadName() -> std::string {
}
}
+ // Ask pthread for the thread name if we don't have one.
// FIXME - move this to platform.
#if BA_OSTYPE_MACOS || BA_OSTYPE_IOS_TVOS || BA_OSTYPE_LINUX
std::string name = "unknown (sys-name=";
diff --git a/src/ballistica/core/thread.h b/src/ballistica/core/thread.h
index d5a63d01..a0c5c594 100644
--- a/src/ballistica/core/thread.h
+++ b/src/ballistica/core/thread.h
@@ -45,7 +45,8 @@ class Thread {
void SetAcquiresPythonGIL();
- void SetPaused(bool paused);
+ void PushSetPaused(bool paused);
+
auto thread_id() const -> std::thread::id { return thread_id_; }
// Needed in rare cases where we jump physical threads.
@@ -97,6 +98,10 @@ class Thread {
/// the app through a flood of packets.
auto CheckPushSafety() -> bool;
+ static auto GetStillPausingThreads() -> std::vector;
+
+ auto paused() { return paused_; }
+
private:
struct ThreadMessage {
enum class Type { kShutdown = 999, kRunnable, kPause, kResume };
@@ -113,7 +118,8 @@ class Thread {
auto SetInternalThreadName(const std::string& name) -> void;
auto WaitForNextEvent(bool single_cycle) -> void;
auto LoopUpkeep(bool once) -> void;
- auto LogThreadMessageTally() -> void;
+ auto LogThreadMessageTally(
+ std::vector>* log_entries) -> void;
auto PushLocalRunnable(Runnable* runnable, bool* completion_flag) -> void;
auto PushCrossThreadRunnable(Runnable* runnable, bool* completion_flag)
-> void;
diff --git a/src/ballistica/core/types.h b/src/ballistica/core/types.h
index 663d0ad9..956d63f8 100644
--- a/src/ballistica/core/types.h
+++ b/src/ballistica/core/types.h
@@ -80,7 +80,6 @@ class Data;
class DataData;
class Dynamics;
class FrameDef;
-struct FriendScoreSet;
class GLContext;
class GlobalsNode;
class Graphics;
diff --git a/src/ballistica/graphics/gl/gl_sys.h b/src/ballistica/graphics/gl/gl_sys.h
index 8ffa9ad8..154e0ce7 100644
--- a/src/ballistica/graphics/gl/gl_sys.h
+++ b/src/ballistica/graphics/gl/gl_sys.h
@@ -61,9 +61,9 @@
#if BA_OSTYPE_MACOS
#if BA_XCODE_BUILD
#include
+#include
#include
#endif // BA_XCODE_BUILD
-#include
#endif // BA_OSTYPE_MACOS
#endif // BA_OSTYPE_IOS_TVOS || BA_OSTYPE_ANDROID
diff --git a/src/ballistica/input/input.cc b/src/ballistica/input/input.cc
index bebef567..a6a1b289 100644
--- a/src/ballistica/input/input.cc
+++ b/src/ballistica/input/input.cc
@@ -1818,4 +1818,48 @@ auto Input::GetKeyName(int keycode) -> std::string {
#pragma clang diagnostic pop
#endif // BA_SDL2_BUILD || BA_MINSDL_BUILD
+auto Input::LsInputDevices() -> void {
+ BA_PRECONDITION(InLogicThread());
+
+ std::string out;
+
+ std::string ind{" "};
+ int index{0};
+ for (auto& device : input_devices_) {
+ if (index != 0) {
+ out += "\n";
+ }
+ out += std::to_string(index + 1) + ":\n";
+ out += ind + "name: " + device->GetDeviceName() + "\n";
+ out += ind + "index: " + std::to_string(device->index()) + "\n";
+ out += (ind + "is-controller: " + std::to_string(device->IsController())
+ + "\n");
+ out += (ind + "is-sdl-controller: "
+ + std::to_string(device->IsSDLController()) + "\n");
+ out += (ind + "is-touch-screen: " + std::to_string(device->IsTouchScreen())
+ + "\n");
+ out += (ind + "is-remote-control: "
+ + std::to_string(device->IsRemoteControl()) + "\n");
+ out += (ind + "is-test-input: " + std::to_string(device->IsTestInput())
+ + "\n");
+ out +=
+ (ind + "is-keyboard: " + std::to_string(device->IsKeyboard()) + "\n");
+ out += (ind + "is-mfi-controller: "
+ + std::to_string(device->IsMFiController()) + "\n");
+ out += (ind + "is-local: " + std::to_string(device->IsLocal()) + "\n");
+ out += (ind + "is-ui-only: " + std::to_string(device->IsUIOnly()) + "\n");
+ out += (ind + "is-remote-app: " + std::to_string(device->IsRemoteApp())
+ + "\n");
+
+ out += ind + "attached-to: "
+ + (device->GetRemotePlayer() != nullptr ? "remote-player"
+ : device->GetPlayer() != nullptr ? "local-player"
+ : "nothing");
+
+ ++index;
+ }
+
+ Log(LogLevel::kInfo, out);
+}
+
} // namespace ballistica
diff --git a/src/ballistica/input/input.h b/src/ballistica/input/input.h
index 1ece106a..3c3659c7 100644
--- a/src/ballistica/input/input.h
+++ b/src/ballistica/input/input.h
@@ -126,6 +126,7 @@ class Input {
auto PushTouchEvent(const TouchEvent& touch_event) -> void;
auto PushDestroyKeyboardInputDevices() -> void;
auto PushCreateKeyboardInputDevices() -> void;
+ auto LsInputDevices() -> void;
/// Roughly how long in milliseconds have all input devices been idle.
auto input_idle_time() const { return input_idle_time_; }
@@ -170,7 +171,6 @@ class Input {
bool have_non_touch_inputs_{};
float cursor_pos_x_{};
float cursor_pos_y_{};
- // millisecs_t last_input_time_{};
millisecs_t last_click_time_{};
millisecs_t double_click_time_{200};
millisecs_t last_mouse_move_time_{};
diff --git a/src/ballistica/input/remote_app.cc b/src/ballistica/input/remote_app.cc
index 43c3549a..09320c24 100644
--- a/src/ballistica/input/remote_app.cc
+++ b/src/ballistica/input/remote_app.cc
@@ -377,12 +377,14 @@ auto RemoteAppServer::GetClient(int request_id, struct sockaddr* addr,
}
}
- // Don't reuse a slot for 5 seconds.
+ // Don't reuse a slot for 5 seconds (if its been heard from since this time).
millisecs_t cooldown_time = GetRealTime() - 5000;
// Ok, not there already.. now look for a non-taken one and return that.
for (int i = 0; i < kMaxRemoteAppClients; i++) {
- if (!clients_[i].in_use && clients_[i].last_contact_time < cooldown_time) {
+ if (!clients_[i].in_use
+ && (clients_[i].last_contact_time == 0
+ || clients_[i].last_contact_time < cooldown_time)) {
// Ok lets fill out the client.
clients_[i].in_use = true;
clients_[i].next_state_id = 0;
diff --git a/src/ballistica/logic/friend_score_set.h b/src/ballistica/logic/friend_score_set.h
deleted file mode 100644
index e90dd619..00000000
--- a/src/ballistica/logic/friend_score_set.h
+++ /dev/null
@@ -1,30 +0,0 @@
-// Released under the MIT License. See LICENSE for details.
-
-#ifndef BALLISTICA_LOGIC_FRIEND_SCORE_SET_H_
-#define BALLISTICA_LOGIC_FRIEND_SCORE_SET_H_
-
-#include
-#include
-#include
-
-namespace ballistica {
-
-// Used by game-center/etc when reporting friend scores to the game.
-struct FriendScoreSet {
- FriendScoreSet(bool success, void* user_data)
- : success(success), user_data(user_data) {}
- struct Entry {
- Entry(int score, std::string name, bool is_me)
- : score(score), name(std::move(name)), is_me(is_me) {}
- int score;
- std::string name;
- bool is_me;
- };
- std::list entries;
- bool success;
- void* user_data;
-};
-
-} // namespace ballistica
-
-#endif // BALLISTICA_LOGIC_FRIEND_SCORE_SET_H_
diff --git a/src/ballistica/logic/logic.cc b/src/ballistica/logic/logic.cc
index 7e43bc65..4ebe931d 100644
--- a/src/ballistica/logic/logic.cc
+++ b/src/ballistica/logic/logic.cc
@@ -19,7 +19,6 @@
#include "ballistica/logic/connection/connection_set.h"
#include "ballistica/logic/connection/connection_to_client_udp.h"
#include "ballistica/logic/connection/connection_to_host_udp.h"
-#include "ballistica/logic/friend_score_set.h"
#include "ballistica/logic/host_activity.h"
#include "ballistica/logic/player.h"
#include "ballistica/logic/session/client_session.h"
@@ -1138,11 +1137,6 @@ void Logic::PushPlaySoundCall(SystemSoundID sound) {
[sound] { g_audio->PlaySound(g_assets->GetSound(sound)); });
}
-void Logic::PushFriendScoreSetCall(const FriendScoreSet& score_set) {
- thread()->PushCall(
- [score_set] { g_python->HandleFriendScoresCB(score_set); });
-}
-
void Logic::PushConfirmQuitCall() {
thread()->PushCall([this] {
assert(InLogicThread());
@@ -2114,6 +2108,20 @@ void Logic::SetPublicPartySize(int count) {
}
}
+auto Logic::SetPublicPartyQueueEnabled(bool enabled) -> void {
+ assert(InLogicThread());
+ if (enabled == public_party_queue_enabled_) {
+ return;
+ }
+ public_party_queue_enabled_ = enabled;
+
+ // Push our new state to the server *ONLY* if public-party is turned on
+ // (wasteful otherwise).
+ if (public_party_enabled_) {
+ g_app_internal->PushPublicPartyState();
+ }
+}
+
void Logic::SetPublicPartyMaxSize(int count) {
assert(InLogicThread());
if (count == public_party_max_size_) {
diff --git a/src/ballistica/logic/logic.h b/src/ballistica/logic/logic.h
index 5a5c0bfa..7ba31f1d 100644
--- a/src/ballistica/logic/logic.h
+++ b/src/ballistica/logic/logic.h
@@ -88,7 +88,6 @@ class Logic {
auto PushConfirmQuitCall() -> void;
auto PushStringEditSetCall(const std::string& value) -> void;
auto PushStringEditCancelCall() -> void;
- auto PushFriendScoreSetCall(const FriendScoreSet& score_set) -> void;
auto PushShowURLCall(const std::string& url) -> void;
auto PushOnAppResumeCall() -> void;
auto PushFrameDefRequest() -> void;
@@ -215,6 +214,10 @@ class Logic {
auto public_party_size() const { return public_party_size_; }
auto SetPublicPartySize(int count) -> void;
auto public_party_max_size() const { return public_party_max_size_; }
+ auto SetPublicPartyQueueEnabled(bool enabled) -> void;
+ auto public_party_queue_enabled() const {
+ return public_party_queue_enabled_;
+ }
auto public_party_max_player_count() const {
return public_party_max_player_count_;
}
@@ -323,6 +326,7 @@ class Logic {
bool public_party_enabled_{};
int public_party_size_{1}; // Always count ourself (is that what we want?).
int public_party_max_size_{8};
+ bool public_party_queue_enabled_{true};
int public_party_player_count_{0};
int public_party_max_player_count_{8};
std::string public_party_name_;
diff --git a/src/ballistica/networking/network_reader.cc b/src/ballistica/networking/network_reader.cc
index 68097bf2..dfd20e2a 100644
--- a/src/ballistica/networking/network_reader.cc
+++ b/src/ballistica/networking/network_reader.cc
@@ -2,6 +2,7 @@
#include "ballistica/networking/network_reader.h"
+#include "ballistica/core/thread.h"
#include "ballistica/generic/json.h"
#include "ballistica/input/remote_app.h"
#include "ballistica/logic/connection/connection_set.h"
@@ -175,6 +176,25 @@ static auto HandleGameQuery(const char* buffer, size_t size,
}
}
+auto NetworkReader::CheckFDThreshold(int val) -> void {
+ if (passed_fd_threshold_) {
+ return;
+ }
+
+ // Let's trigger when we pass 2/3 of the FD limit.
+ if (val < FD_SETSIZE * 2 / 3) {
+ return;
+ }
+
+ // If we pass the threshold, do a one-time dump of info
+ // to try and debug it.
+ passed_fd_threshold_ = true;
+ g_logic->thread()->PushCall([val] {
+ assert(InLogicThread());
+ g_python->obj(Python::ObjID::kOnTooManyFileDescriptorsCall).Call();
+ });
+}
+
auto NetworkReader::RunThread() -> int {
if (!HeadlessMode()) {
remote_server_ = std::make_unique();
@@ -202,6 +222,7 @@ auto NetworkReader::RunThread() -> int {
// Try to get a clean error instead of a crash if we exceed our
// open file descriptor limit (except on windows where FD_SETSIZE
// is apparently a dummy value).
+ CheckFDThreshold(sd4_);
if (sd4_ < 0 || sd4_ >= FD_SETSIZE) {
FatalError("Socket/File Descriptor Overflow (sd4="
+ std::to_string(sd4_) + ", FD_SETSIZE="
@@ -216,6 +237,7 @@ auto NetworkReader::RunThread() -> int {
// Try to get a clean error instead of a crash if we exceed our
// open file descriptor limit (except on windows where FD_SETSIZE
// is apparently a dummy value).
+ CheckFDThreshold(sd6_);
if (sd6_ < 0 || sd6_ >= FD_SETSIZE) {
FatalError("Socket/File Descriptor Overflow (sd6="
+ std::to_string(sd6_) + ", FD_SETSIZE="
diff --git a/src/ballistica/networking/network_reader.h b/src/ballistica/networking/network_reader.h
index 7d86249b..543a84e0 100644
--- a/src/ballistica/networking/network_reader.h
+++ b/src/ballistica/networking/network_reader.h
@@ -32,6 +32,7 @@ class NetworkReader {
auto sd6() const { return sd6_; }
private:
+ auto CheckFDThreshold(int val) -> void;
auto OpenSockets() -> void;
auto PokeSelf() -> void;
auto RunThread() -> int;
@@ -53,6 +54,7 @@ class NetworkReader {
bool paused_{};
std::mutex paused_mutex_;
std::condition_variable paused_cv_;
+ bool passed_fd_threshold_{};
};
} // namespace ballistica
diff --git a/src/ballistica/platform/apple/platform_apple.h b/src/ballistica/platform/apple/platform_apple.h
index 6aa86d56..5c819577 100644
--- a/src/ballistica/platform/apple/platform_apple.h
+++ b/src/ballistica/platform/apple/platform_apple.h
@@ -37,8 +37,6 @@ class PlatformApple : public Platform {
const std::vector& widths, float scale)
-> void* override;
auto GetTextTextureData(void* tex) -> uint8_t* override;
- auto GetFriendScores(const std::string& game, const std::string& game_version,
- void* py_callback) -> void override;
auto SubmitScore(const std::string& game, const std::string& version,
int64_t score) -> void override;
auto ReportAchievement(const std::string& achievement) -> void override;
diff --git a/src/ballistica/platform/platform.cc b/src/ballistica/platform/platform.cc
index 04adb2dc..f3067fdd 100644
--- a/src/ballistica/platform/platform.cc
+++ b/src/ballistica/platform/platform.cc
@@ -28,7 +28,6 @@
#include "ballistica/graphics/mesh/sprite_mesh.h"
#include "ballistica/graphics/vr_graphics.h"
#include "ballistica/input/input.h"
-#include "ballistica/logic/friend_score_set.h"
#include "ballistica/logic/logic.h"
#include "ballistica/networking/networking_sys.h"
#include "ballistica/platform/sdl/sdl_app.h"
@@ -196,6 +195,11 @@ auto Platform::LoginAdapterGetSignInToken(const std::string& login_type,
});
}
+auto Platform::LoginAdapterBackEndActiveChange(const std::string& login_type,
+ bool active) -> void {
+ // Default is no-op.
+}
+
auto Platform::GetDeviceV1AccountUUIDPrefix() -> std::string {
Log(LogLevel::kError, "GetDeviceV1AccountUUIDPrefix() unimplemented");
return "u";
@@ -217,8 +221,15 @@ auto Platform::GetPublicDeviceUUID() -> std::string {
// This UUID is supposed to change periodically, so let's plug in
// some stuff to enforce that.
inputs.emplace_back(GetOSVersionString());
- inputs.emplace_back(kAppVersion);
- inputs.emplace_back("kerploople");
+
+ // This part gets shuffled periodically by my version-increment tools.
+ // We used to plug version in directly here, but that caused uuids to
+ // shuffle too rapidly during periods of rapid development. This
+ // keeps it more constant.
+ // __last_rand_uuid_component_shuffle_date__ 2022 12 17
+ auto rand_uuid_component{"BMCJPHH0SC22KB0WVJ1RAYD68TPEXL58"};
+
+ inputs.emplace_back(rand_uuid_component);
auto gil{Python::ScopedInterpreterLock()};
auto pylist{g_python->StringList(inputs)};
auto args{g_python->SingleMemberTuple(pylist)};
@@ -862,13 +873,6 @@ auto Platform::ConvertIncomingLeaderboardScore(
return score;
}
-void Platform::GetFriendScores(const std::string& game,
- const std::string& game_version, void* data) {
- // As a default, just fail gracefully.
- Log(LogLevel::kError, "FIXME: GetFriendScores unimplemented");
- g_logic->PushFriendScoreSetCall(FriendScoreSet(false, data));
-}
-
void Platform::SubmitScore(const std::string& game, const std::string& version,
int64_t score) {
Log(LogLevel::kError, "FIXME: SubmitScore() unimplemented");
diff --git a/src/ballistica/platform/platform.h b/src/ballistica/platform/platform.h
index 8edcade4..27b03ded 100644
--- a/src/ballistica/platform/platform.h
+++ b/src/ballistica/platform/platform.h
@@ -339,6 +339,10 @@ class Platform {
/// Called when a Python LoginAdapter is requesting an explicit sign-in.
virtual auto LoginAdapterGetSignInToken(const std::string& login_type,
int attempt_id) -> void;
+ /// Called when a Python LoginAdapter is informing us that a back-end is
+ /// active/inactive.
+ virtual auto LoginAdapterBackEndActiveChange(const std::string& login_type,
+ bool active) -> void;
#pragma mark MUSIC PLAYBACK ----------------------------------------------------
@@ -368,9 +372,6 @@ class Platform {
virtual auto ConvertIncomingLeaderboardScore(
const std::string& leaderboard_id, int score) -> int;
- virtual auto GetFriendScores(const std::string& game,
- const std::string& game_version,
- void* py_callback) -> void;
virtual auto SubmitScore(const std::string& game, const std::string& version,
int64_t score) -> void;
virtual auto ReportAchievement(const std::string& achievement) -> void;
diff --git a/src/ballistica/python/methods/python_methods_app.cc b/src/ballistica/python/methods/python_methods_app.cc
index d2460ea9..2de0a6dc 100644
--- a/src/ballistica/python/methods/python_methods_app.cc
+++ b/src/ballistica/python/methods/python_methods_app.cc
@@ -6,6 +6,7 @@
#include "ballistica/app/app_flavor.h"
#include "ballistica/assets/component/texture.h"
#include "ballistica/core/logging.h"
+#include "ballistica/core/thread.h"
#include "ballistica/graphics/graphics.h"
#include "ballistica/logic/connection/connection_set.h"
#include "ballistica/logic/host_activity.h"
@@ -308,18 +309,29 @@ auto PyPushCall(PyObject* self, PyObject* args, PyObject* keywds) -> PyObject* {
int from_other_thread{};
int suppress_warning{};
int other_thread_use_fg_context{};
- static const char* kwlist[] = {"call", "from_other_thread",
+ int raw{0};
+ static const char* kwlist[] = {"call",
+ "from_other_thread",
"suppress_other_thread_warning",
- "other_thread_use_fg_context", nullptr};
- if (!PyArg_ParseTupleAndKeywords(args, keywds, "O|ppp",
+ "other_thread_use_fg_context",
+ "raw",
+ nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "O|pppp",
const_cast(kwlist), &call_obj,
&from_other_thread, &suppress_warning,
- &other_thread_use_fg_context)) {
+ &other_thread_use_fg_context, &raw)) {
return nullptr;
}
- // The from-other-thread case is basically a different call.
- if (from_other_thread) {
+ // 'raw' mode does no thread checking and no context saves/restores.
+ if (raw) {
+ Py_INCREF(call_obj);
+ g_logic->thread()->PushCall([call_obj] {
+ assert(InLogicThread());
+
+ PythonRef(call_obj, PythonRef::kSteal).Call();
+ });
+ } else if (from_other_thread) {
// Warn the user not to use this from the logic thread since it doesnt
// save/restore context.
if (!suppress_warning && InLogicThread()) {
@@ -1132,15 +1144,12 @@ auto PythonMethodsApp::GetMethods() -> std::vector {
{"pushcall", (PyCFunction)PyPushCall, METH_VARARGS | METH_KEYWORDS,
"pushcall(call: Callable, from_other_thread: bool = False,\n"
" suppress_other_thread_warning: bool = False,\n"
- " other_thread_use_fg_context: bool = False) -> None\n"
- "\n"
- "Pushes a call onto the event loop to be run during the next cycle.\n"
+ " other_thread_use_fg_context: bool = False,\n"
+ " raw: bool = False) -> None\n"
"\n"
+ "Push a call to the logic event-loop.\n"
"Category: **General Utility Functions**\n"
"\n"
- "This can be handy for calls that are disallowed from within other\n"
- "callbacks, etc.\n"
- "\n"
"This call expects to be used in the logic thread, and will "
"automatically\n"
"save and restore the ba.Context to behave seamlessly.\n"
@@ -1149,8 +1158,9 @@ auto PythonMethodsApp::GetMethods() -> std::vector {
"however, you can pass 'from_other_thread' as True. In this case\n"
"the call will always run in the UI context on the logic thread\n"
"or whichever context is in the foreground if\n"
- "other_thread_use_fg_context is True."},
-
+ "other_thread_use_fg_context is True.\n"
+ "Passing raw=True will disable thread checks and context"
+ " sets/restores."},
{"getactivity", (PyCFunction)PyGetActivity,
METH_VARARGS | METH_KEYWORDS,
"getactivity(doraise: bool = True) -> \n"
diff --git a/src/ballistica/python/methods/python_methods_networking.cc b/src/ballistica/python/methods/python_methods_networking.cc
index 6c5a4d6e..bd59b102 100644
--- a/src/ballistica/python/methods/python_methods_networking.cc
+++ b/src/ballistica/python/methods/python_methods_networking.cc
@@ -114,6 +114,21 @@ auto PySetPublicPartyMaxSize(PyObject* self, PyObject* args, PyObject* keywds)
BA_PYTHON_CATCH;
}
+auto PySetPublicPartyQueueEnabled(PyObject* self, PyObject* args,
+ PyObject* keywds) -> PyObject* {
+ BA_PYTHON_TRY;
+ int enabled;
+ static const char* kwlist[] = {"enabled", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "p",
+ const_cast(kwlist), &enabled)) {
+ return nullptr;
+ }
+ assert(g_python);
+ g_logic->SetPublicPartyQueueEnabled(enabled);
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
auto PySetAuthenticateClients(PyObject* self, PyObject* args, PyObject* keywds)
-> PyObject* {
BA_PYTHON_TRY;
@@ -498,6 +513,13 @@ auto PythonMethodsNetworking::GetMethods() -> std::vector {
METH_VARARGS | METH_KEYWORDS,
"set_public_party_max_size(max_size: int) -> None\n"
"\n"
+
+ "(internal)"},
+
+ {"set_public_party_queue_enabled",
+ (PyCFunction)PySetPublicPartyQueueEnabled, METH_VARARGS | METH_KEYWORDS,
+ "set_public_party_queue_enabled(max_size: bool) -> None\n"
+ "\n"
"(internal)"},
{"get_public_party_max_size", (PyCFunction)PyGetPublicPartyMaxSize,
diff --git a/src/ballistica/python/methods/python_methods_system.cc b/src/ballistica/python/methods/python_methods_system.cc
index b4a161b0..1d9f879b 100644
--- a/src/ballistica/python/methods/python_methods_system.cc
+++ b/src/ballistica/python/methods/python_methods_system.cc
@@ -638,6 +638,22 @@ auto PyLoginAdapterGetSignInToken(PyObject* self, PyObject* args,
BA_PYTHON_CATCH;
}
+auto PyLoginAdapterBackEndActiveChange(PyObject* self, PyObject* args,
+ PyObject* keywds) -> PyObject* {
+ BA_PYTHON_TRY;
+ const char* login_type;
+ int active;
+ static const char* kwlist[] = {"login_type", "active", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "sp",
+ const_cast(kwlist), &login_type,
+ &active)) {
+ return nullptr;
+ }
+ g_platform->LoginAdapterBackEndActiveChange(login_type, active);
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
auto PySetInternalLanguageKeys(PyObject* self, PyObject* args) -> PyObject* {
BA_PYTHON_TRY;
PyObject* list_obj;
@@ -715,10 +731,18 @@ auto PyAndroidShowWifiSettings(PyObject* self, PyObject* args, PyObject* keywds)
BA_PYTHON_CATCH;
}
-auto PyPrintObjects(PyObject* self, PyObject* args, PyObject* keywds)
+auto PyLsObjects(PyObject* self, PyObject* args, PyObject* keywds)
-> PyObject* {
BA_PYTHON_TRY;
- Object::PrintObjects();
+ Object::LsObjects();
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyLsInputDevices(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ g_input->LsInputDevices();
Py_RETURN_NONE;
BA_PYTHON_CATCH;
}
@@ -756,6 +780,7 @@ auto PythonMethodsSystem::GetMethods() -> std::vector {
"\n"
"If this returns False, UIs should not show 'copy to clipboard'\n"
"buttons, etc."},
+
{"clipboard_has_text", (PyCFunction)PyClipboardHasText, METH_NOARGS,
"clipboard_has_text() -> bool\n"
"\n"
@@ -765,6 +790,7 @@ auto PythonMethodsSystem::GetMethods() -> std::vector {
"\n"
"This will return False if no system clipboard is available; no need\n"
" to call ba.clipboard_is_supported() separately."},
+
{"clipboard_set_text", (PyCFunction)PyClipboardSetText,
METH_VARARGS | METH_KEYWORDS,
"clipboard_set_text(value: str) -> None\n"
@@ -775,6 +801,7 @@ auto PythonMethodsSystem::GetMethods() -> std::vector {
"\n"
"Ensure that ba.clipboard_is_supported() returns True before adding\n"
" buttons/etc. that make use of this functionality."},
+
{"clipboard_get_text", (PyCFunction)PyClipboardGetText, METH_NOARGS,
"clipboard_get_text() -> str\n"
"\n"
@@ -784,9 +811,20 @@ auto PythonMethodsSystem::GetMethods() -> std::vector {
"\n"
"Ensure that ba.clipboard_has_text() returns True before calling\n"
" this function."},
- {"printobjects", (PyCFunction)PyPrintObjects,
+
+ {"ls_objects", (PyCFunction)PyLsObjects, METH_VARARGS | METH_KEYWORDS,
+ "ls_objects() -> None\n"
+ "\n"
+ "Log debugging info about C++ level objects.\n"
+ "\n"
+ "Category: **General Utility Functions**\n"
+ "\n"
+ "This call only functions in debug builds of the game.\n"
+ "It prints various info about the current object count, etc."},
+
+ {"ls_input_devices", (PyCFunction)PyLsInputDevices,
METH_VARARGS | METH_KEYWORDS,
- "printobjects() -> None\n"
+ "ls_input_devices() -> None\n"
"\n"
"Print debugging info about game objects.\n"
"\n"
@@ -859,6 +897,14 @@ auto PythonMethodsSystem::GetMethods() -> std::vector {
"\n"
"(internal)"},
+ {"login_adapter_back_end_active_change",
+ (PyCFunction)PyLoginAdapterBackEndActiveChange,
+ METH_VARARGS | METH_KEYWORDS,
+ "login_adapter_back_end_active_change(login_type: str, active: bool)"
+ " -> None\n"
+ "\n"
+ "(internal)"},
+
{"submit_analytics_counts", (PyCFunction)PySubmitAnalyticsCounts,
METH_VARARGS | METH_KEYWORDS,
"submit_analytics_counts() -> None\n"
diff --git a/src/ballistica/python/python.cc b/src/ballistica/python/python.cc
index 4ca762fe..4d2c57cc 100644
--- a/src/ballistica/python/python.cc
+++ b/src/ballistica/python/python.cc
@@ -14,7 +14,6 @@
#include "ballistica/input/device/joystick.h"
#include "ballistica/input/device/keyboard_input.h"
#include "ballistica/internal/app_internal.h"
-#include "ballistica/logic/friend_score_set.h"
#include "ballistica/logic/host_activity.h"
#include "ballistica/logic/player.h"
#include "ballistica/logic/v1_account.h"
@@ -2144,40 +2143,6 @@ void Python::CaptureKeyboardInput(PyObject* obj) {
}
void Python::ReleaseKeyboardInput() { keyboard_call_.Release(); }
-void Python::HandleFriendScoresCB(const FriendScoreSet& score_set) {
- // This is the initial strong-ref to this pointer
- // so it will be cleaned up properly.
- Object::Ref cb(
- static_cast(score_set.user_data));
-
- // We pass None on error.
- if (!score_set.success) {
- PythonRef args(Py_BuildValue("(O)", Py_None), PythonRef::kSteal);
- cb->Run(args);
- } else {
- // Otherwise convert it to a python list and pass that.
- PyObject* py_list = PyList_New(0);
- std::string icon_str;
-#if BA_USE_GOOGLE_PLAY_GAME_SERVICES
- icon_str = g_logic->CharStr(SpecialChar::kGooglePlayGamesLogo);
-#elif BA_USE_GAME_CIRCLE
- icon_str = g_logic->CharStr(SpecialChar::kGameCircleLogo);
-#elif BA_USE_GAME_CENTER
- icon_str = g_logic->CharStr(SpecialChar::kGameCenterLogo);
-#endif
- for (auto&& i : score_set.entries) {
- PyObject* obj =
- Py_BuildValue("[isi]", i.score, (icon_str + i.name).c_str(),
- static_cast(i.is_me));
- PyList_Append(py_list, obj);
- Py_DECREF(obj);
- }
- PythonRef args(Py_BuildValue("(O)", py_list), PythonRef::kSteal);
- Py_DECREF(py_list);
- cb->Run(args);
- }
-}
-
auto Python::HandleKeyPressEvent(const SDL_Keysym& keysym) -> bool {
assert(InLogicThread());
if (!keyboard_call_.exists()) {
diff --git a/src/ballistica/python/python.h b/src/ballistica/python/python.h
index 284e589f..40a2abac 100644
--- a/src/ballistica/python/python.h
+++ b/src/ballistica/python/python.h
@@ -114,7 +114,6 @@ class Python {
auto ReleaseGamePadInput() -> void;
auto CaptureKeyboardInput(PyObject* obj) -> void;
auto ReleaseKeyboardInput() -> void;
- auto HandleFriendScoresCB(const FriendScoreSet& ss) -> void;
auto IssueCallInLogicThreadWarning(PyObject* call) -> void;
/// Borrowed from python's source code: used in overriding of objects' dir()
@@ -369,6 +368,7 @@ class Python {
kImplicitSignInCall,
kImplicitSignOutCall,
kLoginAdapterGetSignInTokenResponseCall,
+ kOnTooManyFileDescriptorsCall,
kLast // Sentinel; must be at end.
};
diff --git a/src/ballistica/ui/widget/container_widget.cc b/src/ballistica/ui/widget/container_widget.cc
index 5d82083b..5cdd262e 100644
--- a/src/ballistica/ui/widget/container_widget.cc
+++ b/src/ballistica/ui/widget/container_widget.cc
@@ -446,7 +446,9 @@ auto ContainerWidget::HandleMessage(const WidgetMessage& m) -> bool {
CheckLayout();
// Ignore mouse stuff while transitioning out.
- if (transitioning_ && transitioning_out_) break;
+ if (transitioning_ && transitioning_out_) {
+ break;
+ }
float x = m.fval1;
float y = m.fval2;
@@ -505,7 +507,9 @@ auto ContainerWidget::HandleMessage(const WidgetMessage& m) -> bool {
CheckLayout();
// Ignore mouse stuff while transitioning.
- if (transitioning_ && transitioning_out_) break;
+ if (transitioning_ && transitioning_out_) {
+ break;
+ }
float x = m.fval1;
float y = m.fval2;
@@ -546,7 +550,9 @@ auto ContainerWidget::HandleMessage(const WidgetMessage& m) -> bool {
CheckLayout();
// Ignore mouse stuff while transitioning.
- if (transitioning_ && transitioning_out_) break;
+ if (transitioning_ && transitioning_out_) {
+ break;
+ }
float x = m.fval1;
float y = m.fval2;
@@ -909,7 +915,9 @@ void ContainerWidget::Draw(RenderPass* pass, bool draw_transparent) {
}
// Don't draw if we've fully transitioned out.
- if (transitioning_out_ && !transitioning_) return;
+ if (transitioning_out_ && !transitioning_) {
+ return;
+ }
float l = transition_offset_x_smoothed_ + transition_scale_offset_x_;
float r = l + width_;
diff --git a/src/ballistica/ui/widget/root_widget.cc b/src/ballistica/ui/widget/root_widget.cc
index 976dd8e8..c9028fc9 100644
--- a/src/ballistica/ui/widget/root_widget.cc
+++ b/src/ballistica/ui/widget/root_widget.cc
@@ -1108,7 +1108,15 @@ void RootWidget::SetOverlayWidget(StackWidget* w) {
overlay_stack_widget_ = w;
}
-void RootWidget::OnCancelCustom() { g_ui->PushBackButtonCall(nullptr); }
+void RootWidget::OnCancelCustom() {
+ // Need to revisit this. If the cancel event it pushes is not handled, it will
+ // wind up back here where it pushes another back call. This cycle repeats
+ // forever until something comes along which does handle cancel events and
+ // then it gets them all. Current repro case is Sign-in-with-BombSquad-Account
+ // window - press escape a few times while that is up and then click cancel;
+ // This code is only used for toolbar mode so should be safe to leave it
+ // disabled for now. g_ui->PushBackButtonCall(nullptr);
+}
auto RootWidget::GetSpecialWidget(const std::string& s) const -> Widget* {
if (s == "party_button") {
diff --git a/src/ballistica/ui/widget/text_widget.cc b/src/ballistica/ui/widget/text_widget.cc
index c45241e9..ab40826d 100644
--- a/src/ballistica/ui/widget/text_widget.cc
+++ b/src/ballistica/ui/widget/text_widget.cc
@@ -441,20 +441,23 @@ void TextWidget::SetText(const std::string& text_in_raw) {
bool do_format_check{};
bool print_false_positives{};
- if (g_buildconfig.debug_build()) {
- do_format_check = explicit_bool(true);
- } else {
- if (text_in_raw.size() > 1 && text_in_raw[0] == '{'
- && text_in_raw[text_in_raw.size() - 1] == '}') {
- // Ok, its got bounds like json; now if its either missing quotes or a
- // colon then let's check it.
- if (!strstr(text_in_raw.c_str(), "\"")
- || !strstr(text_in_raw.c_str(), ":")) {
- do_format_check = true;
+ // Only non-editable text support resource-strings.
+ if (!editable_) {
+ if (g_buildconfig.debug_build()) {
+ do_format_check = explicit_bool(true);
+ } else {
+ if (text_in_raw.size() > 1 && text_in_raw[0] == '{'
+ && text_in_raw[text_in_raw.size() - 1] == '}') {
+ // Ok, its got bounds like json; now if its either missing quotes or a
+ // colon then let's check it.
+ if (!strstr(text_in_raw.c_str(), "\"")
+ || !strstr(text_in_raw.c_str(), ":")) {
+ do_format_check = true;
- // We wanna avoid doing this check when we don't have to.
- // so lets print if we get a false positive
- print_false_positives = true;
+ // We wanna avoid doing this check when we don't have to.
+ // so lets print if we get a false positive
+ print_false_positives = true;
+ }
}
}
}
diff --git a/src/meta/bameta/python_embedded/binding.py b/src/meta/bameta/python_embedded/binding.py
index df969526..00436446 100644
--- a/src/meta/bameta/python_embedded/binding.py
+++ b/src/meta/bameta/python_embedded/binding.py
@@ -146,4 +146,5 @@ def get_binding_values() -> tuple[Any, ...]:
_hooks.implicit_sign_in, # kImplicitSignInCall
_hooks.implicit_sign_out, # kImplicitSignOutCall
_hooks.login_adapter_get_sign_in_token_response, # kLoginAdapterGetSignInTokenResponseCall
+ _apputils.on_too_many_file_descriptors, # kOnTooManyFileDescriptorsCall
) # yapf: disable
diff --git a/tests/test_efro/test_message.py b/tests/test_efro/test_message.py
index 9916bf3a..1a944041 100644
--- a/tests/test_efro/test_message.py
+++ b/tests/test_efro/test_message.py
@@ -198,9 +198,9 @@ class _BoundTestMessageSenderAsync(BoundMessageSender):
async def send_async(self, message: _TMsg3) -> None:
...
- async def send_async(self, message: Message) -> Response | None:
+ def send_async(self, message: Message) -> Awaitable[Response | None]:
"""Send a message asynchronously."""
- return await self._sender.send_async(self._obj, message)
+ return self._sender.send_async(self._obj, message)
# SEND_ASYNC_CODE_TEST_END
@@ -261,9 +261,9 @@ class _BoundTestMessageSenderBBoth(BoundMessageSender):
async def send_async(self, message: _TMsg4) -> None:
...
- async def send_async(self, message: Message) -> Response | None:
+ def send_async(self, message: Message) -> Awaitable[Response | None]:
"""Send a message asynchronously."""
- return await self._sender.send_async(self._obj, message)
+ return self._sender.send_async(self._obj, message)
# SEND_BOTH_CODE_TEST_END
@@ -424,11 +424,11 @@ class _TestAsyncMessageReceiver(MessageReceiver):
class _BoundTestAsyncMessageReceiver(BoundMessageReceiver):
"""Protocol-specific bound receiver."""
- async def handle_raw_message(
+ def handle_raw_message(
self, message: str, raise_unregistered: bool = False
- ) -> str:
+ ) -> Awaitable[str]:
"""Asynchronously handle a raw incoming message."""
- return await self._receiver.handle_raw_message_async(
+ return self._receiver.handle_raw_message_async(
self._obj, message, raise_unregistered
)
diff --git a/tools/bacommon/cloud.py b/tools/bacommon/cloud.py
index bb36f6c1..df621b09 100644
--- a/tools/bacommon/cloud.py
+++ b/tools/bacommon/cloud.py
@@ -157,6 +157,24 @@ class WorkspaceFetchResponse(Response):
done: Annotated[bool, IOAttrs('d')] = False
+@ioprepped
+@dataclass
+class MerchAvailabilityMessage(Message):
+ """Can we show merch link?"""
+
+ @classmethod
+ def get_response_types(cls) -> list[type[Response] | None]:
+ return [MerchAvailabilityResponse]
+
+
+@ioprepped
+@dataclass
+class MerchAvailabilityResponse(Response):
+ """About that merch..."""
+
+ url: Annotated[str | None, IOAttrs('u')]
+
+
@ioprepped
@dataclass
class SignInMessage(Message):
@@ -165,6 +183,10 @@ class SignInMessage(Message):
login_type: Annotated[LoginType, IOAttrs('l')]
sign_in_token: Annotated[str, IOAttrs('t')]
+ # For debugging. Can remove soft_default once build 20988+ is ubiquitous.
+ description: Annotated[str, IOAttrs('d', soft_default='-')]
+ apptime: Annotated[float, IOAttrs('at', soft_default=-1.0)]
+
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [SignInResponse]
diff --git a/tools/bacommon/login.py b/tools/bacommon/login.py
index 957997da..c8ec0057 100644
--- a/tools/bacommon/login.py
+++ b/tools/bacommon/login.py
@@ -19,3 +19,13 @@ class LoginType(Enum):
# Google Play Game Services
GPGS = 'gpgs'
+
+ @property
+ def displayname(self) -> str:
+ """Human readable name for this value."""
+ cls = type(self)
+ match self:
+ case cls.EMAIL:
+ return 'Email/Password'
+ case cls.GPGS:
+ return 'Google Play Games'
diff --git a/tools/bacommon/servermanager.py b/tools/bacommon/servermanager.py
index e21b1d1f..7c13910a 100644
--- a/tools/bacommon/servermanager.py
+++ b/tools/bacommon/servermanager.py
@@ -138,6 +138,11 @@ class ServerConfig:
tuple[float, float, float], tuple[float, float, float]
] | None = None
+ # Whether to enable the queue where players can line up before entering
+ # your server. Disabling this can be used as a workaround to deal with
+ # queue spamming attacks.
+ enable_queue: bool = True
+
# (internal) stress-testing mode.
stress_test_players: int | None = None
diff --git a/tools/batools/build.py b/tools/batools/build.py
index c31586f5..dd8e6ace 100644
--- a/tools/batools/build.py
+++ b/tools/batools/build.py
@@ -40,22 +40,22 @@ class PyRequirement:
# as manually-installed bits, pip itself must have some way to allow for
# that, right?...
PY_REQUIREMENTS = [
- PyRequirement(modulename='pylint', minversion=[2, 14, 5]),
- PyRequirement(modulename='mypy', minversion=[0, 971]),
+ PyRequirement(modulename='pylint', minversion=[2, 15, 9]),
+ PyRequirement(modulename='mypy', minversion=[0, 991]),
PyRequirement(modulename='cpplint', minversion=[1, 6, 1]),
- PyRequirement(modulename='pytest', minversion=[7, 1, 2]),
+ PyRequirement(modulename='pytest', minversion=[7, 2, 0]),
PyRequirement(modulename='pytz'),
PyRequirement(modulename='ansiwrap'),
PyRequirement(modulename='yaml', pipname='PyYAML'),
PyRequirement(modulename='requests'),
PyRequirement(modulename='pdoc'),
- PyRequirement(pipname='black', minversion=[22, 10, 0]),
- PyRequirement(pipname='typing_extensions', minversion=[4, 3, 0]),
+ PyRequirement(pipname='black', minversion=[22, 12, 0]),
+ PyRequirement(pipname='typing_extensions', minversion=[4, 4, 0]),
PyRequirement(pipname='types-filelock', minversion=[3, 2, 7]),
- PyRequirement(pipname='types-requests', minversion=[2, 28, 9]),
+ PyRequirement(pipname='types-requests', minversion=[2, 28, 11, 7]),
PyRequirement(pipname='types-pytz', minversion=[2022, 2, 1, 0]),
- PyRequirement(pipname='types-PyYAML', minversion=[6, 0, 11]),
- PyRequirement(pipname='certifi', minversion=[2022, 6, 15]),
+ PyRequirement(pipname='types-PyYAML', minversion=[6, 0, 12, 2]),
+ PyRequirement(pipname='certifi', minversion=[2022, 12, 7]),
PyRequirement(pipname='types-certifi', minversion=[2021, 10, 8, 3]),
]
diff --git a/tools/batools/project.py b/tools/batools/project.py
index 48c4d561..f4d82d7f 100755
--- a/tools/batools/project.py
+++ b/tools/batools/project.py
@@ -729,8 +729,10 @@ class Updater:
def _check_misc(self) -> None:
# Misc sanity checks.
- if not self._public:
- # Make sure we're set to prod master server.
+
+ # Make sure we're set to prod master server.
+ # (but ONLY when checking; still want to be able to run updates).
+ if not self._public and self._check:
with open(
'src/ballistica/internal/master_server_config.h',
encoding='utf-8',
diff --git a/tools/efro/call.py b/tools/efro/call.py
index 7013f3dd..8e811e50 100644
--- a/tools/efro/call.py
+++ b/tools/efro/call.py
@@ -274,6 +274,44 @@ if TYPE_CHECKING:
) -> _CallNoArgs[OutT]:
...
+ # 4 arg call; 3 args bundled.
+ # noinspection PyPep8Naming
+ @overload
+ def Call(
+ call: Callable[[In1T, In2T, In3T, In4T], OutT],
+ arg1: In1T,
+ arg2: In2T,
+ arg3: In3T,
+ ) -> _Call1Arg[In4T, OutT]:
+ ...
+
+ # 4 arg call; 2 args bundled.
+ # noinspection PyPep8Naming
+ @overload
+ def Call(
+ call: Callable[[In1T, In2T, In3T, In4T], OutT],
+ arg1: In1T,
+ arg2: In2T,
+ ) -> _Call2Args[In3T, In4T, OutT]:
+ ...
+
+ # 4 arg call; 1 arg bundled.
+ # noinspection PyPep8Naming
+ @overload
+ def Call(
+ call: Callable[[In1T, In2T, In3T, In4T], OutT],
+ arg1: In1T,
+ ) -> _Call3Args[In2T, In3T, In4T, OutT]:
+ ...
+
+ # 4 arg call; no args bundled.
+ # noinspection PyPep8Naming
+ @overload
+ def Call(
+ call: Callable[[In1T, In2T, In3T, In4T], OutT],
+ ) -> _Call4Args[In1T, In2T, In3T, In4T, OutT]:
+ ...
+
# 5 arg call; 5 args bundled.
# noinspection PyPep8Naming
@overload
diff --git a/tools/efro/dataclassio/_base.py b/tools/efro/dataclassio/_base.py
index 134ea001..0402488b 100644
--- a/tools/efro/dataclassio/_base.py
+++ b/tools/efro/dataclassio/_base.py
@@ -111,6 +111,8 @@ class IOAttrs:
boundaries (see efro.util.utc_today()).
'whole_hours', if True, requires datetime values to lie exactly on hour
boundaries (see efro.util.utc_this_hour()).
+ 'whole_minutes', if True, requires datetime values to lie exactly on minute
+ boundaries (see efro.util.utc_this_minute()).
'soft_default', if passed, injects a default value into dataclass
instantiation when the field is not present in the input data.
This allows dataclasses to add new non-optional fields while
@@ -136,6 +138,7 @@ class IOAttrs:
store_default: bool = True
whole_days: bool = False
whole_hours: bool = False
+ whole_minutes: bool = False
soft_default: Any = MISSING
soft_default_factory: Callable[[], Any] | _MissingType = MISSING
@@ -145,6 +148,7 @@ class IOAttrs:
store_default: bool = store_default,
whole_days: bool = whole_days,
whole_hours: bool = whole_hours,
+ whole_minutes: bool = whole_minutes,
soft_default: Any = MISSING,
soft_default_factory: Callable[[], Any] | _MissingType = MISSING,
):
@@ -160,6 +164,8 @@ class IOAttrs:
self.whole_days = whole_days
if whole_hours != cls.whole_hours:
self.whole_hours = whole_hours
+ if whole_minutes != cls.whole_minutes:
+ self.whole_minutes = whole_minutes
if soft_default is not cls.soft_default:
# Do what dataclasses does with its default types and
@@ -216,13 +222,18 @@ class IOAttrs:
raise ValueError(
f'Value {value} at {fieldpath} is not a whole day.'
)
- if self.whole_hours:
+ elif self.whole_hours:
if any(
x != 0 for x in (value.minute, value.second, value.microsecond)
):
raise ValueError(
f'Value {value} at {fieldpath}' f' is not a whole hour.'
)
+ elif self.whole_minutes:
+ if any(x != 0 for x in (value.second, value.microsecond)):
+ raise ValueError(
+ f'Value {value} at {fieldpath}' f' is not a whole minute.'
+ )
def _get_origin(anntype: Any) -> Any:
diff --git a/tools/efro/error.py b/tools/efro/error.py
index d3353708..456fc6f9 100644
--- a/tools/efro/error.py
+++ b/tools/efro/error.py
@@ -80,6 +80,15 @@ class IntegrityError(ValueError):
"""Data has been tampered with or corrupted in some form."""
+class AuthenticationError(Exception):
+ """Authentication has failed for some operation.
+
+ This can be raised if server-side-verification does not match
+ client-supplied credentials, if an invalid password is supplied
+ for a sign-in attempt, etc.
+ """
+
+
def is_urllib_communication_error(exc: BaseException, url: str | None) -> bool:
"""Is the provided exception from urllib a communication-related error?
diff --git a/tools/efro/log.py b/tools/efro/log.py
index 6ee2e896..02afc2a0 100644
--- a/tools/efro/log.py
+++ b/tools/efro/log.py
@@ -37,7 +37,27 @@ class LogLevel(Enum):
ERROR = 3
CRITICAL = 4
+ @property
+ def python_logging_level(self) -> int:
+ """Give the corresponding logging level."""
+ return LOG_LEVEL_LEVELNOS[self]
+ @classmethod
+ def from_python_logging_level(cls, levelno: int) -> LogLevel:
+ """Given a Python logging level, return a LogLevel."""
+ return LEVELNO_LOG_LEVELS[levelno]
+
+
+# Python logging levels from LogLevels
+LOG_LEVEL_LEVELNOS = {
+ LogLevel.DEBUG: logging.DEBUG,
+ LogLevel.INFO: logging.INFO,
+ LogLevel.WARNING: logging.WARNING,
+ LogLevel.ERROR: logging.ERROR,
+ LogLevel.CRITICAL: logging.CRITICAL,
+}
+
+# LogLevels from Python logging levels
LEVELNO_LOG_LEVELS = {
logging.DEBUG: LogLevel.DEBUG,
logging.INFO: LogLevel.INFO,
@@ -128,7 +148,9 @@ class LogHandler(logging.Handler):
self._cache_lock = Lock()
self._printed_callback_error = False
self._thread_bootstrapped = False
- self._thread = Thread(target=self._thread_main, daemon=True)
+ self._thread = Thread(target=self._log_thread_main, daemon=True)
+ if __debug__:
+ self._last_slow_emit_warning_time: float | None = None
self._thread.start()
# Spin until our thread is up and running; otherwise we could
@@ -145,7 +167,7 @@ class LogHandler(logging.Handler):
with self._callbacks_lock:
self._callbacks.append(call)
- def _thread_main(self) -> None:
+ def _log_thread_main(self) -> None:
self._event_loop = asyncio.new_event_loop()
# NOTE: if we ever use default threadpool at all we should allow
# setting it for our loop.
@@ -172,20 +194,15 @@ class LogHandler(logging.Handler):
now = utc_now()
with self._cache_lock:
- # Quick out: if oldest cache entry is still valid,
- # don't touch anything.
- if (
+ # Prune the oldest entry as long as there is a first one that
+ # is too old.
+ while (
self._cache
- and (now - self._cache[0][1].time) < self._cache_time_limit
+ and (now - self._cache[0][1].time) >= self._cache_time_limit
):
- continue
-
- # Ok; full prune.
- self._cache = [
- e
- for e in self._cache
- if (now - e[1].time) < self._cache_time_limit
- ]
+ popped = self._cache.pop(0)
+ self._cache_size -= popped[0]
+ self._cache_index_offset += 1
def get_cached(
self, start_index: int = 0, max_entries: int | None = None
@@ -223,6 +240,9 @@ class LogHandler(logging.Handler):
)
def emit(self, record: logging.LogRecord) -> None:
+ if __debug__:
+ starttime = time.monotonic()
+
# Called by logging to send us records.
# We simply package them up and ship them to our thread.
# UPDATE: turns out we CAN get log messages from this thread
@@ -246,6 +266,9 @@ class LogHandler(logging.Handler):
# didn't expect to be stringified.
msg = self.format(record)
+ if __debug__:
+ formattime = time.monotonic()
+
# Also immediately print pretty colored output to our echo file
# (generally stderr). We do this part here instead of in our bg
# thread because the delay can throw off command line prompts or
@@ -257,6 +280,9 @@ class LogHandler(logging.Handler):
else:
self._echofile.write(f'{msg}\n')
+ if __debug__:
+ echotime = time.monotonic()
+
self._event_loop.call_soon_threadsafe(
tpartial(
self._emit_in_thread,
@@ -267,6 +293,37 @@ class LogHandler(logging.Handler):
)
)
+ if __debug__:
+ # Make noise if we're taking a significant amount of time here.
+ # Limit the noise to once every so often though; otherwise we
+ # could get a feedback loop where every log emit results in a
+ # warning log which results in another, etc.
+ now = time.monotonic()
+ # noinspection PyUnboundLocalVariable
+ duration = now - starttime
+ # noinspection PyUnboundLocalVariable
+ format_duration = formattime - starttime
+ # noinspection PyUnboundLocalVariable
+ echo_duration = echotime - formattime
+ if duration > 0.05 and (
+ self._last_slow_emit_warning_time is None
+ or now > self._last_slow_emit_warning_time + 10.0
+ ):
+ # Logging calls from *within* a logging handler
+ # sounds sketchy, so let's just kick this over to
+ # the bg event loop thread we've already got.
+ self._last_slow_emit_warning_time = now
+ self._event_loop.call_soon_threadsafe(
+ tpartial(
+ logging.warning,
+ 'efro.log.LogHandler emit took too long'
+ ' (%.2fs total; %.2fs format, %.2fs echo).',
+ duration,
+ format_duration,
+ echo_duration,
+ )
+ )
+
def _emit_in_thread(
self, name: str, levelno: int, created: float, message: str
) -> None:
diff --git a/tools/efro/message/_message.py b/tools/efro/message/_message.py
index 9343a396..0697ad53 100644
--- a/tools/efro/message/_message.py
+++ b/tools/efro/message/_message.py
@@ -43,6 +43,20 @@ class SysResponse:
users of the api never see them.
"""
+ def set_local_exception(self, exc: Exception) -> None:
+ """Attach a local exception to facilitate better logging/handling.
+
+ Be aware that this data does not get serialized and only
+ exists on the local object.
+ """
+ setattr(self, '_sr_local_exception', exc)
+
+ def get_local_exception(self) -> Exception | None:
+ """Fetch a local attached exception."""
+ value = getattr(self, '_sr_local_exception', None)
+ assert isinstance(value, Exception | None)
+ return value
+
# Some standard response types:
diff --git a/tools/efro/message/_protocol.py b/tools/efro/message/_protocol.py
index 3c24c5b0..ce86d39b 100644
--- a/tools/efro/message/_protocol.py
+++ b/tools/efro/message/_protocol.py
@@ -282,10 +282,13 @@ class MessageProtocol:
def _get_module_header(
self,
part: Literal['sender', 'receiver'],
- extra_import_code: str | None = None,
+ extra_import_code: str | None,
+ enable_async_sends: bool,
) -> str:
"""Return common parts of generated modules."""
- # pylint: disable=too-many-locals, too-many-branches
+ # pylint: disable=too-many-locals
+ # pylint: disable=too-many-branches
+ # pylint: disable=too-many-statements
import textwrap
tpimports: dict[str, list[str]] = {}
@@ -342,7 +345,7 @@ class MessageProtocol:
if part == 'sender':
import_lines += (
- 'from efro.message import MessageSender,' ' BoundMessageSender'
+ 'from efro.message import MessageSender, BoundMessageSender'
)
tpimport_typing_extras = ''
else:
@@ -362,11 +365,18 @@ class MessageProtocol:
import_lines += f'\n{extra_import_code}\n'
ovld = ', overload' if not single_message_type else ''
+ ovld2 = (
+ ', cast, Awaitable'
+ if (single_message_type and part == 'sender' and enable_async_sends)
+ else ''
+ )
tpimport_lines = textwrap.indent(tpimport_lines, ' ')
baseimps = ['Any']
if part == 'receiver':
baseimps.append('Callable')
+ if part == 'sender' and enable_async_sends:
+ baseimps.append('Awaitable')
baseimps_s = ', '.join(baseimps)
out = (
'# Released under the MIT License. See LICENSE for details.\n'
@@ -375,7 +385,7 @@ class MessageProtocol:
f'\n'
f'from __future__ import annotations\n'
f'\n'
- f'from typing import TYPE_CHECKING{ovld}\n'
+ f'from typing import TYPE_CHECKING{ovld}{ovld2}\n'
f'\n'
f'{import_lines}\n'
f'\n'
@@ -399,13 +409,16 @@ class MessageProtocol:
) -> str:
"""Used by create_sender_module(); do not call directly."""
# pylint: disable=too-many-locals
+ # pylint: disable=too-many-branches
import textwrap
msgtypes = list(self.message_ids_by_type.keys())
ppre = '_' if private else ''
out = self._get_module_header(
- 'sender', extra_import_code=protocol_module_level_import_code
+ 'sender',
+ extra_import_code=protocol_module_level_import_code,
+ enable_async_sends=enable_async_sends,
)
ccind = textwrap.indent(protocol_create_code, ' ')
out += (
@@ -438,7 +451,8 @@ class MessageProtocol:
continue
pfx = 'async ' if async_pass else ''
sfx = '_async' if async_pass else ''
- awt = 'await ' if async_pass else ''
+ # awt = 'await ' if async_pass else ''
+ awt = ''
how = 'asynchronously' if async_pass else 'synchronously'
if len(msgtypes) == 1:
@@ -451,22 +465,29 @@ class MessageProtocol:
rtypevar = ' | '.join(_filt_tp_name(t) for t in rtypes)
else:
rtypevar = _filt_tp_name(rtypes[0])
+ if async_pass:
+ rtypevar = f'Awaitable[{rtypevar}]'
out += (
f'\n'
- f' {pfx}def send{sfx}(self,'
+ f' def send{sfx}(self,'
f' message: {msgtypevar})'
f' -> {rtypevar}:\n'
f' """Send a message {how}."""\n'
f' out = {awt}self._sender.'
f'send{sfx}(self._obj, message)\n'
- f' assert isinstance(out, {rtypevar})\n'
- f' return out\n'
)
+ if not async_pass:
+ out += (
+ f' assert isinstance(out, {rtypevar})\n'
+ ' return out\n'
+ )
+ else:
+ out += f' return cast({rtypevar}, out)\n'
+
else:
for msgtype in msgtypes:
msgtypevar = msgtype.__name__
- # rtypes = msgtype.get_response_types()
rtypes = msgtype.get_response_types()
if len(rtypes) > 1:
rtypevar = ' | '.join(
@@ -482,10 +503,13 @@ class MessageProtocol:
f' -> {rtypevar}:\n'
f' ...\n'
)
+ rtypevar = 'Response | None'
+ if async_pass:
+ rtypevar = f'Awaitable[{rtypevar}]'
out += (
f'\n'
- f' {pfx}def send{sfx}(self, message: Message)'
- f' -> Response | None:\n'
+ f' def send{sfx}(self, message: Message)'
+ f' -> {rtypevar}:\n'
f' """Send a message {how}."""\n'
f' return {awt}self._sender.'
f'send{sfx}(self._obj, message)\n'
@@ -509,7 +533,9 @@ class MessageProtocol:
ppre = '_' if private else ''
msgtypes = list(self.message_ids_by_type.keys())
out = self._get_module_header(
- 'receiver', extra_import_code=protocol_module_level_import_code
+ 'receiver',
+ extra_import_code=protocol_module_level_import_code,
+ enable_async_sends=False,
)
ccind = textwrap.indent(protocol_create_code, ' ')
out += (
@@ -602,11 +628,11 @@ class MessageProtocol:
if is_async:
out += (
'\n'
- ' async def handle_raw_message(\n'
+ ' def handle_raw_message(\n'
' self, message: str, raise_unregistered: bool = False\n'
- ' ) -> str:\n'
+ ' ) -> Awaitable[str]:\n'
' """Asynchronously handle a raw incoming message."""\n'
- ' return await self._receiver.'
+ ' return self._receiver.'
'handle_raw_message_async(\n'
' self._obj, message, raise_unregistered\n'
' )\n'
diff --git a/tools/efro/message/_receiver.py b/tools/efro/message/_receiver.py
index e4c92447..3d8477c3 100644
--- a/tools/efro/message/_receiver.py
+++ b/tools/efro/message/_receiver.py
@@ -62,12 +62,6 @@ class MessageReceiver:
[Any, Message | None, Response | SysResponse, dict], None
] | None = None
- # TODO: don't currently have async encode equivalent
- # or either for sender; can add as needed.
- self._decode_filter_async_call: Callable[
- [Any, dict, Message], Awaitable[None]
- ] | None = None
-
# noinspection PyProtectedMember
def register_handler(
self, call: Callable[[Any, Message], Response | None]
@@ -96,14 +90,17 @@ class MessageReceiver:
# Make sure we are only given async methods if we are an async handler
# and sync ones otherwise.
- is_async = inspect.iscoroutinefunction(call)
- if self.is_async != is_async:
- msg = (
- 'Expected a sync method; found an async one.'
- if is_async
- else 'Expected an async method; found a sync one.'
- )
- raise ValueError(msg)
+ # UPDATE - can't do this anymore since we now sometimes use
+ # regular functions which return awaitables instead of having
+ # the entire function be async.
+ # is_async = inspect.iscoroutinefunction(call)
+ # if self.is_async != is_async:
+ # msg = (
+ # 'Expected a sync method; found an async one.'
+ # if is_async
+ # else 'Expected an async method; found a sync one.'
+ # )
+ # raise ValueError(msg)
# Check annotation types to determine what message types we handle.
# Return-type annotation can be a Union, but we probably don't
@@ -189,19 +186,6 @@ class MessageReceiver:
self._decode_filter_call = call
return call
- def decode_filter_async_method(
- self, call: Callable[[Any, dict, Message], Awaitable[None]]
- ) -> Callable[[Any, dict, Message], Awaitable[None]]:
- """Function decorator for defining a decode filter.
-
- Decode filters can be used to extract extra data from incoming
- message dicts. Note that this version will only work with
- handle_raw_message_async().
- """
- assert self._decode_filter_async_call is None
- self._decode_filter_async_call = call
- return call
-
def encode_filter_method(
self,
call: Callable[
@@ -247,24 +231,6 @@ class MessageReceiver:
bound_obj, _msg_dict, msg_decoded = self._decode_incoming_message_base(
bound_obj=bound_obj, msg=msg
)
-
- # If they've set an async filter but are calling sync
- # handle_raw_message() its likely a bug.
- assert self._decode_filter_async_call is None
-
- return msg_decoded
-
- async def _decode_incoming_message_async(
- self, bound_obj: Any, msg: str
- ) -> Message:
- bound_obj, msg_dict, msg_decoded = self._decode_incoming_message_base(
- bound_obj=bound_obj, msg=msg
- )
-
- if self._decode_filter_async_call is not None:
- await self._decode_filter_async_call(
- bound_obj, msg_dict, msg_decoded
- )
return msg_decoded
def encode_user_response(
@@ -316,6 +282,7 @@ class MessageReceiver:
"""
assert not self.is_async, "can't call sync handler on async receiver"
msg_decoded: Message | None = None
+ msgtype: type[Message] | None = None
try:
msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded)
@@ -335,41 +302,93 @@ class MessageReceiver:
bound_obj, msg_decoded, exc
)
if dolog:
- logging.exception('Error in efro.message handling.')
+ if msgtype is not None:
+ logging.exception(
+ 'Error handling %s.%s message.',
+ msgtype.__module__,
+ msgtype.__qualname__,
+ )
+ else:
+ logging.exception('Error in efro.message handling.')
return rstr
- async def handle_raw_message_async(
+ def handle_raw_message_async(
self, bound_obj: Any, msg: str, raise_unregistered: bool = False
- ) -> str:
+ ) -> Awaitable[str]:
"""Should be called when the receiver gets a message.
The return value is the raw response to the message.
"""
+
+ # Note: This call is synchronous so that the first part of it can
+ # happen synchronously. If the whole call were async we wouldn't be
+ # able to guarantee that messages handlers would be called in the
+ # order the messages were received.
+
assert self.is_async, "can't call async handler on sync receiver"
msg_decoded: Message | None = None
+ msgtype: type[Message] | None = None
try:
- msg_decoded = await self._decode_incoming_message_async(
- bound_obj, msg
- )
+ msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded)
handler = self._handlers.get(msgtype)
if handler is None:
raise RuntimeError(f'Got unhandled message type: {msgtype}.')
- response = await handler(bound_obj, msg_decoded)
- assert isinstance(response, Response | None)
- return self.encode_user_response(bound_obj, msg_decoded, response)
+ handler_awaitable = handler(bound_obj, msg_decoded)
except Exception as exc:
if raise_unregistered and isinstance(
exc, UnregisteredMessageIDError
):
raise
- rstr, dolog = self.encode_error_response(
- bound_obj, msg_decoded, exc
+ return self._handle_raw_message_async_error(
+ bound_obj, msg_decoded, msgtype, exc
)
- if dolog:
+
+ # Return an awaitable to handle the rest asynchronously.
+ return self._handle_raw_message_async(
+ bound_obj, msg_decoded, msgtype, handler_awaitable
+ )
+
+ async def _handle_raw_message_async_error(
+ self,
+ bound_obj: Any,
+ msg_decoded: Message | None,
+ msgtype: type[Message] | None,
+ exc: Exception,
+ ) -> str:
+ rstr, dolog = self.encode_error_response(bound_obj, msg_decoded, exc)
+ if dolog:
+ if msgtype is not None:
+ logging.exception(
+ 'Error handling %s.%s message.',
+ msgtype.__module__,
+ msgtype.__qualname__,
+ )
+ else:
logging.exception('Error in efro.message handling.')
- return rstr
+ return rstr
+
+ async def _handle_raw_message_async(
+ self,
+ bound_obj: Any,
+ msg_decoded: Message,
+ msgtype: type[Message] | None,
+ handler_awaitable: Awaitable[Response | None],
+ ) -> str:
+ """Should be called when the receiver gets a message.
+
+ The return value is the raw response to the message.
+ """
+ try:
+ response = await handler_awaitable
+ assert isinstance(response, Response | None)
+ return self.encode_user_response(bound_obj, msg_decoded, response)
+
+ except Exception as exc:
+ return await self._handle_raw_message_async_error(
+ bound_obj, msg_decoded, msgtype, exc
+ )
class BoundMessageReceiver:
diff --git a/tools/efro/message/_sender.py b/tools/efro/message/_sender.py
index bd9e94db..63effe76 100644
--- a/tools/efro/message/_sender.py
+++ b/tools/efro/message/_sender.py
@@ -6,7 +6,6 @@ Supports static typing for message types and possible return types.
from __future__ import annotations
-import logging
from typing import TYPE_CHECKING
from efro.error import CleanError, RemoteError, CommunicationError
@@ -45,6 +44,9 @@ class MessageSender:
self._send_async_raw_message_call: Callable[
[Any, str], Awaitable[str]
] | None = None
+ self._send_async_raw_message_ex_call: Callable[
+ [Any, str, Message], Awaitable[str]
+ ] | None = None
self._encode_filter_call: Callable[
[Any, Message, dict], None
] | None = None
@@ -76,11 +78,32 @@ class MessageSender:
CommunicationErrors raised here will be returned to the sender
as such; all other exceptions will result in a RuntimeError for
the sender.
+
+ IMPORTANT: Generally async send methods should not be implemented
+ as 'async' methods, but instead should be regular methods that
+ return awaitable objects. This way it can be guaranteed that
+ outgoing messages are synchronously enqueued in the correct
+ order, and then async calls can be returned which finish each
+ send. If the entire call is async, they may be enqueued out of
+ order in rare cases.
"""
assert self._send_async_raw_message_call is None
self._send_async_raw_message_call = call
return call
+ def send_async_ex_method(
+ self, call: Callable[[Any, str, Message], Awaitable[str]]
+ ) -> Callable[[Any, str, Message], Awaitable[str]]:
+ """Function decorator for extended send-async method.
+
+ Version of send_async_method which is also is passed the original
+ unencoded message; can be useful for cases where metadata is sent
+ along with messages referring to their payloads/etc.
+ """
+ assert self._send_async_raw_message_ex_call is None
+ self._send_async_raw_message_ex_call = call
+ return call
+
def encode_filter_method(
self, call: Callable[[Any, Message, dict], None]
) -> Callable[[Any, Message, dict], None]:
@@ -127,17 +150,34 @@ class MessageSender:
),
)
- async def send_async(
+ def send_async(
self, bound_obj: Any, message: Message
- ) -> Response | None:
+ ) -> Awaitable[Response | None]:
"""Send a message asynchronously."""
+
+ # Note: This call is synchronous so that the first part of it can
+ # happen synchronously. If the whole call were async we wouldn't be
+ # able to guarantee that messages sent in order would actually go
+ # out in order.
+ raw_response_awaitable = self.fetch_raw_response_async(
+ bound_obj=bound_obj,
+ message=message,
+ )
+ # Now return an awaitable that will finish the send.
+ return self._send_async_awaitable(
+ bound_obj, message, raw_response_awaitable
+ )
+
+ async def _send_async_awaitable(
+ self,
+ bound_obj: Any,
+ message: Message,
+ raw_response_awaitable: Awaitable[Response | SysResponse],
+ ) -> Response | None:
return self.unpack_raw_response(
bound_obj=bound_obj,
message=message,
- raw_response=await self.fetch_raw_response_async(
- bound_obj=bound_obj,
- message=message,
- ),
+ raw_response=await raw_response_awaitable,
)
def fetch_raw_response(
@@ -158,52 +198,95 @@ class MessageSender:
bound_obj, msg_encoded
)
except Exception as exc:
- # Any error in the raw send call gets recorded as either
- # a local or communication error.
- return ErrorSysResponse(
- error_message=f'Error in MessageSender @send_method'
- f' ({type(exc)}): {exc}',
+ response = ErrorSysResponse(
+ error_message='Error in MessageSender @send_method.',
error_type=(
ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError)
else ErrorSysResponse.ErrorType.LOCAL
),
)
+ # Can include the actual exception since we'll be looking at
+ # this locally; might be helpful.
+ response.set_local_exception(exc)
+ return response
return self._decode_raw_response(bound_obj, message, response_encoded)
- async def fetch_raw_response_async(
+ def fetch_raw_response_async(
self, bound_obj: Any, message: Message
- ) -> Response | SysResponse:
- """Fetch a raw message response.
+ ) -> Awaitable[Response | SysResponse]:
+ """Fetch a raw message response awaitable.
- The result of this should be passed to unpack_raw_response() to
- produce the final message result.
+ The result of this should be awaited and then passed to
+ unpack_raw_response() to produce the final message result.
Generally you can just call send(); calling fetch and unpack
manually is for when message sending and response handling need
to happen in different contexts/threads.
"""
- if self._send_async_raw_message_call is None:
+ # Note: This call is synchronous so that the first part of it can
+ # happen synchronously. If the whole call were async we wouldn't be
+ # able to guarantee that messages sent in order would actually go
+ # out in order.
+ if (
+ self._send_async_raw_message_call is None
+ and self._send_async_raw_message_ex_call is None
+ ):
raise RuntimeError('send_async() is unimplemented for this type.')
msg_encoded = self._encode_message(bound_obj, message)
try:
- response_encoded = await self._send_async_raw_message_call(
- bound_obj, msg_encoded
- )
+ if self._send_async_raw_message_ex_call is not None:
+ send_awaitable = self._send_async_raw_message_ex_call(
+ bound_obj, msg_encoded, message
+ )
+ else:
+ assert self._send_async_raw_message_call is not None
+ send_awaitable = self._send_async_raw_message_call(
+ bound_obj, msg_encoded
+ )
except Exception as exc:
- # Any error in the raw send call gets recorded as either
- # a local or communication error.
- return ErrorSysResponse(
- error_message=f'Error in MessageSender @send_async_method'
- f' ({type(exc)}): {exc}',
+ return self._error_awaitable(exc)
+
+ # Now return an awaitable to finish the job.
+ return self._fetch_raw_response_awaitable(
+ bound_obj, message, send_awaitable
+ )
+
+ async def _error_awaitable(self, exc: Exception) -> SysResponse:
+ response = ErrorSysResponse(
+ error_message='Error in MessageSender @send_async_method.',
+ error_type=(
+ ErrorSysResponse.ErrorType.COMMUNICATION
+ if isinstance(exc, CommunicationError)
+ else ErrorSysResponse.ErrorType.LOCAL
+ ),
+ )
+ # Can include the actual exception since we'll be looking at
+ # this locally; might be helpful.
+ response.set_local_exception(exc)
+ return response
+
+ async def _fetch_raw_response_awaitable(
+ self, bound_obj: Any, message: Message, send_awaitable: Awaitable[str]
+ ) -> Response | SysResponse:
+
+ try:
+ response_encoded = await send_awaitable
+ except Exception as exc:
+ response = ErrorSysResponse(
+ error_message='Error in MessageSender @send_async_method.',
error_type=(
ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError)
else ErrorSysResponse.ErrorType.LOCAL
),
)
+ # Can include the actual exception since we'll be looking at
+ # this locally; might be helpful.
+ response.set_local_exception(exc)
+ return response
return self._decode_raw_response(bound_obj, message, response_encoded)
def unpack_raw_response(
@@ -250,18 +333,14 @@ class MessageSender:
self._decode_filter_call(
bound_obj, message, response_dict, response
)
- except Exception:
- # If we got to this point, we successfully communicated
- # with the other end so errors represent protocol mismatches
- # or other invalid data. For now let's just log it but perhaps
- # we'd want to somehow embed it in the ErrorSysResponse to be
- # available directly to the user later.
- logging.exception('Error decoding raw response')
+ except Exception as exc:
response = ErrorSysResponse(
- error_message='Error decoding raw response;'
- ' see log for details.',
+ error_message='Error decoding raw response.',
error_type=ErrorSysResponse.ErrorType.LOCAL,
)
+ # Since we'll be looking at this locally, we can include
+ # extra info for logging/etc.
+ response.set_local_exception(exc)
return response
def _unpack_raw_response(
@@ -282,16 +361,24 @@ class MessageSender:
# Some error occurred. Raise a local Exception for it.
if isinstance(raw_response, ErrorSysResponse):
+ # Errors that happened locally can attach their exceptions
+ # here for extra logging goodness.
+ local_exception = raw_response.get_local_exception()
+
if (
raw_response.error_type
is ErrorSysResponse.ErrorType.COMMUNICATION
):
- raise CommunicationError(raw_response.error_message)
+ raise CommunicationError(
+ raw_response.error_message
+ ) from local_exception
# If something went wrong on *our* end of the connection,
# don't say it was a remote error.
if raw_response.error_type is ErrorSysResponse.ErrorType.LOCAL:
- raise RuntimeError(raw_response.error_message)
+ raise RuntimeError(
+ raw_response.error_message
+ ) from local_exception
# If they want to support clean errors, do those.
if (
@@ -299,14 +386,18 @@ class MessageSender:
and raw_response.error_type
is ErrorSysResponse.ErrorType.REMOTE_CLEAN
):
- raise CleanError(raw_response.error_message)
+ raise CleanError(
+ raw_response.error_message
+ ) from local_exception
if (
self.protocol.forward_communication_errors
and raw_response.error_type
is ErrorSysResponse.ErrorType.REMOTE_COMMUNICATION
):
- raise CommunicationError(raw_response.error_message)
+ raise CommunicationError(
+ raw_response.error_message
+ ) from local_exception
# Everything else gets lumped in as a remote error.
raise RemoteError(
@@ -316,7 +407,7 @@ class MessageSender:
if self._peer_desc_call is None
else self._peer_desc_call(bound_obj)
),
- )
+ ) from local_exception
assert isinstance(raw_response, Response)
return raw_response
@@ -345,23 +436,23 @@ class BoundMessageSender:
assert self._obj is not None
return self._sender.send(bound_obj=self._obj, message=message)
- async def send_async_untyped(self, message: Message) -> Response | None:
+ def send_async_untyped(
+ self, message: Message
+ ) -> Awaitable[Response | None]:
"""Send a message asynchronously.
Whenever possible, use the send_async() call provided by generated
subclasses instead of this; it will provide better type safety.
"""
assert self._obj is not None
- return await self._sender.send_async(
- bound_obj=self._obj, message=message
- )
+ return self._sender.send_async(bound_obj=self._obj, message=message)
- async def fetch_raw_response_async_untyped(
+ def fetch_raw_response_async_untyped(
self, message: Message
- ) -> Response | SysResponse:
+ ) -> Awaitable[Response | SysResponse]:
"""Split send (part 1 of 2)."""
assert self._obj is not None
- return await self._sender.fetch_raw_response_async(
+ return self._sender.fetch_raw_response_async(
bound_obj=self._obj, message=message
)
diff --git a/tools/efro/rpc.py b/tools/efro/rpc.py
index c6412bfd..fad19124 100644
--- a/tools/efro/rpc.py
+++ b/tools/efro/rpc.py
@@ -323,12 +323,12 @@ class RPCEndpoint:
if self.debug_print:
self.debug_print_call(f'{self._label}: finished.')
- async def send_message(
+ def send_message(
self,
message: bytes,
timeout: float | None = None,
close_on_error: bool = True,
- ) -> bytes:
+ ) -> Awaitable[bytes]:
"""Send a message to the peer and return a response.
If timeout is not provided, the default will be used.
@@ -340,7 +340,10 @@ class RPCEndpoint:
respect to a given endpoint. Pass close_on_error=False to
override this for a particular message.
"""
- # pylint: disable=too-many-branches
+ # Note: This call is synchronous so that the first part of it
+ # (enqueueing outgoing messages) happens synchronously. If it were
+ # a pure async call it could be possible for send order to vary
+ # based on how the async tasks get processed.
if self.debug_print_io:
self.debug_print_call(
@@ -358,16 +361,6 @@ class RPCEndpoint:
f'{self._label}: have peerinfo? {self._peer_info is not None}.'
)
- # We need to know their protocol, so if we haven't gotten a handshake
- # from them yet, just wait.
- while self._peer_info is None:
- await asyncio.sleep(0.01)
- assert self._peer_info is not None
-
- if self._peer_info.protocol == 1:
- if len(message) > 65535:
- raise RuntimeError('Message cannot be larger than 65535 bytes')
-
# message_id is a 16 bit looping value.
message_id = self._next_message_id
self._next_message_id = (self._next_message_id + 1) % 65536
@@ -420,8 +413,35 @@ class RPCEndpoint:
if timeout is None:
timeout = self.DEFAULT_MESSAGE_TIMEOUT
assert timeout is not None
+
+ bytes_awaitable = msgobj.wait_task
+
+ # Now complete the send asynchronously.
+ return self._send_message(
+ message, timeout, close_on_error, bytes_awaitable, message_id
+ )
+
+ async def _send_message(
+ self,
+ message: bytes,
+ timeout: float | None,
+ close_on_error: bool,
+ bytes_awaitable: asyncio.Task[bytes],
+ message_id: int,
+ ) -> bytes:
+
+ # We need to know their protocol, so if we haven't gotten a handshake
+ # from them yet, just wait.
+ while self._peer_info is None:
+ await asyncio.sleep(0.01)
+ assert self._peer_info is not None
+
+ if self._peer_info.protocol == 1:
+ if len(message) > 65535:
+ raise RuntimeError('Message cannot be larger than 65535 bytes')
+
try:
- return await asyncio.wait_for(msgobj.wait_task, timeout=timeout)
+ return await asyncio.wait_for(bytes_awaitable, timeout=timeout)
except asyncio.CancelledError as exc:
# Question: we assume this means the above wait_for() was
# cancelled; how do we distinguish between this and *us* being
@@ -449,7 +469,7 @@ class RPCEndpoint:
)
# Stop waiting on the response.
- msgobj.wait_task.cancel()
+ bytes_awaitable.cancel()
# Remove the record of this message.
del self._in_flight_messages[message_id]
@@ -628,7 +648,8 @@ class RPCEndpoint:
# Now just sit and handle stuff as it comes in.
while True:
- assert not self._closing
+ if self._closing:
+ return
# Read message type.
mtype = _PacketType(await self._read_int_8())
diff --git a/tools/efro/util.py b/tools/efro/util.py
index 553120f0..2c0ab72a 100644
--- a/tools/efro/util.py
+++ b/tools/efro/util.py
@@ -39,6 +39,7 @@ class _EmptyObj:
pass
+# TODO: kill this and just use efro.call.tpartial
if TYPE_CHECKING:
Call = Call
else:
diff --git a/tools/efrotools/ios.py b/tools/efrotools/ios.py
index 4b2bfd3f..db724320 100644
--- a/tools/efrotools/ios.py
+++ b/tools/efrotools/ios.py
@@ -134,6 +134,7 @@ def _add_build_to_xcarchive(
MODES['debug']['configuration'],
'-archivePath',
str(archivepathbase),
+ '-allowProvisioningUpdates',
]
subprocess.run(args, check=True, capture_output=False)
diff --git a/tools/efrotools/pcommand.py b/tools/efrotools/pcommand.py
index 8a5a66a1..7e8c2ede 100644
--- a/tools/efrotools/pcommand.py
+++ b/tools/efrotools/pcommand.py
@@ -162,6 +162,13 @@ def _spelling(words: list[str]) -> None:
print(f'Modified {num_modded_dictionaries} dictionaries.')
+def pur() -> None:
+ """Run pur using project's Python version."""
+ import subprocess
+
+ subprocess.run([sys.executable, '-m', 'pur'] + sys.argv[2:], check=True)
+
+
def spelling_all() -> None:
"""Add all misspellings from a pycharm run."""
import subprocess
diff --git a/tools/efrotools/pylintplugins.py b/tools/efrotools/pylintplugins.py
index 325ca9ef..07b05a32 100644
--- a/tools/efrotools/pylintplugins.py
+++ b/tools/efrotools/pylintplugins.py
@@ -45,6 +45,19 @@ def ignore_type_check_filter(if_node: nc.NodeNG) -> nc.NodeNG:
and isinstance(if_node.parent, astroid.Module)
):
+ # Special case: some third party modules are starting to contain
+ # code that we don't handle cleanly which results in pylint runs
+ # breaking. For now just ignoring them as they pop up.
+ # We should try to figure out how to disable this filtering
+ # for third party modules altogether or make our filtering more
+ # robust.
+ if if_node.parent.name in {
+ 'filelock',
+ 'aiohttp.web_app',
+ 'aiohttp.web_response',
+ }:
+ return if_node
+
module_node = if_node.parent
# Remove any locals getting defined under this if statement.
@@ -177,7 +190,7 @@ def var_annotations_filter(node: nc.NodeNG) -> nc.NodeNG:
# Future behavior:
# Annotated assigns under functions are not evaluated.
# Class and module vars are normally not either. However we
- # do evaluate if we come across an 'ioprepped' dataclass
+ # *do* evaluate if we come across an 'ioprepped' dataclass
# decorator. (the 'ioprepped' decorator explicitly evaluates
# dataclass annotations).
@@ -230,7 +243,8 @@ def var_annotations_filter(node: nc.NodeNG) -> nc.NodeNG:
break
fnode = fnode.parent
- # If this annotation won't be eval'ed, replace it with a dummy string.
+ # If this annotation won't be eval'ed, replace its annotation with
+ # a dummy value.
if not willeval:
dummyval = astroid.Const(parent=node, value='dummyval')
node.annotation = dummyval