diff --git a/.efrocachemap b/.efrocachemap
index d4f909e1..e4cdbfb4 100644
--- a/.efrocachemap
+++ b/.efrocachemap
@@ -1,425 +1,425 @@
{
- "assets/build/ba_data/audio/achievement.ogg": "https://files.ballistica.net/cache/ba1/d2/9f/8ba4d5c258e9841dde0235d794b5",
- "assets/build/ba_data/audio/actionHero1.ogg": "https://files.ballistica.net/cache/ba1/a9/8e/717d49c51bba6b32d3d09f0d0f2b",
- "assets/build/ba_data/audio/actionHero2.ogg": "https://files.ballistica.net/cache/ba1/ab/c7/70d6c42e89e584e29d8e6f70638e",
- "assets/build/ba_data/audio/actionHero3.ogg": "https://files.ballistica.net/cache/ba1/27/d5/f549caba1da9969e7f2c15fc042e",
- "assets/build/ba_data/audio/actionHero4.ogg": "https://files.ballistica.net/cache/ba1/8e/46/24e5ea7c5d80d4f95c487996a601",
- "assets/build/ba_data/audio/actionHeroDeath.ogg": "https://files.ballistica.net/cache/ba1/a6/4a/6ed4571f79bd42dd2a97df6a6aaa",
- "assets/build/ba_data/audio/actionHeroFall.ogg": "https://files.ballistica.net/cache/ba1/48/19/7e6743d5e4c68b74445fc1462c6c",
- "assets/build/ba_data/audio/actionHeroHit1.ogg": "https://files.ballistica.net/cache/ba1/3c/45/ef2b2e8e033960fdc2ebc6378cb7",
- "assets/build/ba_data/audio/actionHeroHit2.ogg": "https://files.ballistica.net/cache/ba1/8d/ff/27b2227b0b416005cf1ad0640316",
- "assets/build/ba_data/audio/activateBeep.ogg": "https://files.ballistica.net/cache/ba1/e3/7c/53bc57c5c7f57bb35d22bb7f696a",
- "assets/build/ba_data/audio/agent1.ogg": "https://files.ballistica.net/cache/ba1/52/9e/f41b7d3434bb99f711fef71ba8c6",
- "assets/build/ba_data/audio/agent2.ogg": "https://files.ballistica.net/cache/ba1/3f/df/8ad978342caf7d4888d548a25acb",
- "assets/build/ba_data/audio/agent3.ogg": "https://files.ballistica.net/cache/ba1/82/ef/00a7f193a6b884926d2fe1e3c1ed",
- "assets/build/ba_data/audio/agent4.ogg": "https://files.ballistica.net/cache/ba1/e5/69/f39dcf5457f635e2253152c71939",
- "assets/build/ba_data/audio/agentDeath.ogg": "https://files.ballistica.net/cache/ba1/20/36/ea6eab558ed786afadec5ccc5313",
- "assets/build/ba_data/audio/agentFall.ogg": "https://files.ballistica.net/cache/ba1/ba/40/7584e852d068e68120d0d9174886",
- "assets/build/ba_data/audio/agentHit1.ogg": "https://files.ballistica.net/cache/ba1/69/90/bcf822ca886392162aa22f4dde91",
- "assets/build/ba_data/audio/agentHit2.ogg": "https://files.ballistica.net/cache/ba1/fa/81/17dd543d7f279158f808350403a3",
- "assets/build/ba_data/audio/alarm.ogg": "https://files.ballistica.net/cache/ba1/1f/9f/a85fbb506297c6bff299cfca7220",
- "assets/build/ba_data/audio/ali1.ogg": "https://files.ballistica.net/cache/ba1/3f/01/449837b714e5b8f7432eaa608fda",
- "assets/build/ba_data/audio/ali2.ogg": "https://files.ballistica.net/cache/ba1/69/ef/064006961b26d1364f5f12174de8",
- "assets/build/ba_data/audio/ali3.ogg": "https://files.ballistica.net/cache/ba1/08/0d/a124f81887ff8a7b8165cbf37af8",
- "assets/build/ba_data/audio/ali4.ogg": "https://files.ballistica.net/cache/ba1/4f/9f/af5ee5de7d7616e2a095464b3410",
- "assets/build/ba_data/audio/aliDeath.ogg": "https://files.ballistica.net/cache/ba1/c9/97/d67f9f9958569253ded3cc9f680c",
- "assets/build/ba_data/audio/aliFall.ogg": "https://files.ballistica.net/cache/ba1/cc/09/af7da183c4fbebb066e0ab7dff0f",
- "assets/build/ba_data/audio/aliHit1.ogg": "https://files.ballistica.net/cache/ba1/38/bf/f2e585574d65b8e9296655854c29",
- "assets/build/ba_data/audio/aliHit2.ogg": "https://files.ballistica.net/cache/ba1/90/17/d1b1bf6c5430ef90fac3412d670a",
- "assets/build/ba_data/audio/alien1.ogg": "https://files.ballistica.net/cache/ba1/d6/84/a7e374fe5e96b4c3b8d570b9887b",
- "assets/build/ba_data/audio/alien2.ogg": "https://files.ballistica.net/cache/ba1/bd/4e/8ff64e144bd1b1bf8235950b8c0b",
- "assets/build/ba_data/audio/alien3.ogg": "https://files.ballistica.net/cache/ba1/f5/e1/3f028e12a15b4acfbf7103785243",
- "assets/build/ba_data/audio/alien4.ogg": "https://files.ballistica.net/cache/ba1/ef/a3/26359db5c116a8ebd4a34e15b3fb",
- "assets/build/ba_data/audio/alienDeath.ogg": "https://files.ballistica.net/cache/ba1/ef/e3/cb997c5fc3f053f4255c0f416ba9",
- "assets/build/ba_data/audio/alienFall.ogg": "https://files.ballistica.net/cache/ba1/1b/ca/ed192f6dcb92330e71c93ba196b6",
- "assets/build/ba_data/audio/alienHit1.ogg": "https://files.ballistica.net/cache/ba1/d3/07/7d7402fa5d072829e2172b413d20",
- "assets/build/ba_data/audio/alienHit2.ogg": "https://files.ballistica.net/cache/ba1/82/98/5f941cf1bb05e5bcbc7608c587ac",
- "assets/build/ba_data/audio/announceEight.ogg": "https://files.ballistica.net/cache/ba1/a2/e0/a61ebd8009032c3dfcd7c68a40d9",
- "assets/build/ba_data/audio/announceFive.ogg": "https://files.ballistica.net/cache/ba1/a7/9a/1886861099d6daca53e9fca3f572",
- "assets/build/ba_data/audio/announceFour.ogg": "https://files.ballistica.net/cache/ba1/48/16/ace4874f8babda31cecf3ff59460",
- "assets/build/ba_data/audio/announceNine.ogg": "https://files.ballistica.net/cache/ba1/4f/c2/66ae23dea5ad0dce1e3a4e028da3",
- "assets/build/ba_data/audio/announceOne.ogg": "https://files.ballistica.net/cache/ba1/21/89/e0953e2951346753f5b049c3878d",
- "assets/build/ba_data/audio/announceSeven.ogg": "https://files.ballistica.net/cache/ba1/cf/cf/9c26d68440bd704945cf83a42e3f",
- "assets/build/ba_data/audio/announceSix.ogg": "https://files.ballistica.net/cache/ba1/98/a4/ab780d080a902cef3e261770670e",
- "assets/build/ba_data/audio/announceTen.ogg": "https://files.ballistica.net/cache/ba1/df/cb/d68c7897013a1d9e03a46e20593f",
- "assets/build/ba_data/audio/announceThree.ogg": "https://files.ballistica.net/cache/ba1/d3/21/e9b72b1b217bee2a83ebf3de802e",
- "assets/build/ba_data/audio/announceTwo.ogg": "https://files.ballistica.net/cache/ba1/90/d4/baa71dc6c16de135266de4417ad8",
- "assets/build/ba_data/audio/assassin1.ogg": "https://files.ballistica.net/cache/ba1/78/c1/9f7e31c9d5fd20a1a0e58946721e",
- "assets/build/ba_data/audio/assassin2.ogg": "https://files.ballistica.net/cache/ba1/1d/6b/dd593f245bc766f67801c07c800b",
- "assets/build/ba_data/audio/assassin3.ogg": "https://files.ballistica.net/cache/ba1/a9/86/90ced874739d1320608fe33f696c",
- "assets/build/ba_data/audio/assassin4.ogg": "https://files.ballistica.net/cache/ba1/84/8e/4709c67341da6877f7531d87e990",
- "assets/build/ba_data/audio/assassinDeath.ogg": "https://files.ballistica.net/cache/ba1/31/ee/dbaeb702cb04575b8262b4a01dcc",
- "assets/build/ba_data/audio/assassinFall.ogg": "https://files.ballistica.net/cache/ba1/49/c1/316397d0c26f5fc423bbbbd27ff0",
- "assets/build/ba_data/audio/assassinHit1.ogg": "https://files.ballistica.net/cache/ba1/27/ff/1a300e244dde1a9087df88c39bb2",
- "assets/build/ba_data/audio/assassinHit2.ogg": "https://files.ballistica.net/cache/ba1/49/f6/4d49e96bb9fbdbabca92a5d84921",
- "assets/build/ba_data/audio/bear1.ogg": "https://files.ballistica.net/cache/ba1/08/da/68f8e152d186ac93c35e356d22bd",
- "assets/build/ba_data/audio/bear2.ogg": "https://files.ballistica.net/cache/ba1/6b/8d/edc1543e80e290aa670bf7474885",
- "assets/build/ba_data/audio/bear3.ogg": "https://files.ballistica.net/cache/ba1/34/6d/09656673977df46e96062cba9b8a",
- "assets/build/ba_data/audio/bear4.ogg": "https://files.ballistica.net/cache/ba1/18/b6/3d7d28a8b25af86b33aa92f02a78",
- "assets/build/ba_data/audio/bearDeath.ogg": "https://files.ballistica.net/cache/ba1/d6/4d/42a0a7f23b207095ca6d2e3fb415",
- "assets/build/ba_data/audio/bearFall.ogg": "https://files.ballistica.net/cache/ba1/0a/f9/48efaf15321f5ab04353dbcefe3a",
- "assets/build/ba_data/audio/bearHit1.ogg": "https://files.ballistica.net/cache/ba1/dc/dd/a59124d4ee4f068ce87dfad96a63",
- "assets/build/ba_data/audio/bearHit2.ogg": "https://files.ballistica.net/cache/ba1/ef/c7/2fc7cbdcf4625b7015e050958eeb",
- "assets/build/ba_data/audio/bellHigh.ogg": "https://files.ballistica.net/cache/ba1/4a/e2/1842c6aa68a0fc8db5713eb1f4d1",
- "assets/build/ba_data/audio/bellLow.ogg": "https://files.ballistica.net/cache/ba1/70/a5/27615f255641f9bf97ba02ee610f",
- "assets/build/ba_data/audio/bellMed.ogg": "https://files.ballistica.net/cache/ba1/b9/93/5a847d9d8476f64fa6e3a54e49e3",
- "assets/build/ba_data/audio/bigImpact.ogg": "https://files.ballistica.net/cache/ba1/9e/6d/d386b11c201c6942eff44b2f902b",
- "assets/build/ba_data/audio/bigImpact2.ogg": "https://files.ballistica.net/cache/ba1/ca/d7/7b1a99160176f4f984b40dc88c5c",
- "assets/build/ba_data/audio/blank.ogg": "https://files.ballistica.net/cache/ba1/a4/bb/d564bc6b2bbce15e9413835faee3",
- "assets/build/ba_data/audio/blip.ogg": "https://files.ballistica.net/cache/ba1/25/05/bc35e1834a8d3b6fb098b70ad559",
- "assets/build/ba_data/audio/block.ogg": "https://files.ballistica.net/cache/ba1/1c/7d/aa6d3454431c5a6a1c9d44ee3dda",
- "assets/build/ba_data/audio/bombDrop01.ogg": "https://files.ballistica.net/cache/ba1/10/2f/0ec5e8335e1c522d77a877808d4d",
- "assets/build/ba_data/audio/bombDrop02.ogg": "https://files.ballistica.net/cache/ba1/82/d8/c5879ccf65062410d30e388ed550",
- "assets/build/ba_data/audio/bombRoll01.ogg": "https://files.ballistica.net/cache/ba1/d2/92/526900582d9d19fa730d0f23f31d",
- "assets/build/ba_data/audio/bones1.ogg": "https://files.ballistica.net/cache/ba1/c7/fd/3654fc5875828592aa2240c1a5c4",
- "assets/build/ba_data/audio/bones2.ogg": "https://files.ballistica.net/cache/ba1/c0/bc/e2f489ed7fa5b8f29f4214f92a9b",
- "assets/build/ba_data/audio/bones3.ogg": "https://files.ballistica.net/cache/ba1/ac/f0/67262750370df9ce2fa0ce6cf084",
- "assets/build/ba_data/audio/bonesDeath.ogg": "https://files.ballistica.net/cache/ba1/19/3c/31d70812dc519d32b124602424e0",
- "assets/build/ba_data/audio/bonesFall.ogg": "https://files.ballistica.net/cache/ba1/85/a7/a2231b604aa73e4f72ba93a709aa",
- "assets/build/ba_data/audio/boo.ogg": "https://files.ballistica.net/cache/ba1/4f/61/810cac6c4dc04ce0e484b5f0ee95",
- "assets/build/ba_data/audio/boxDrop.ogg": "https://files.ballistica.net/cache/ba1/88/73/727941acfef7a14f8448bb50c51f",
- "assets/build/ba_data/audio/boxingBell.ogg": "https://files.ballistica.net/cache/ba1/a9/66/ae2645c9fd64011dde6f96c9b3ad",
- "assets/build/ba_data/audio/bunny1.ogg": "https://files.ballistica.net/cache/ba1/20/a8/7fe62f74802a4a4fd13385440d0c",
- "assets/build/ba_data/audio/bunny2.ogg": "https://files.ballistica.net/cache/ba1/0f/ea/af1163da28dd803799460d0cf75e",
- "assets/build/ba_data/audio/bunny3.ogg": "https://files.ballistica.net/cache/ba1/f4/a6/c5bc1fe3b25108635025290b9c41",
- "assets/build/ba_data/audio/bunny4.ogg": "https://files.ballistica.net/cache/ba1/00/4f/77facdcc2b2ba8fbf1a20cf3b273",
- "assets/build/ba_data/audio/bunnyDeath.ogg": "https://files.ballistica.net/cache/ba1/d7/40/07e61c803b187abd0715c0e226de",
- "assets/build/ba_data/audio/bunnyFall.ogg": "https://files.ballistica.net/cache/ba1/c9/c1/c15c0e6c8dfde62315a8c5314d30",
- "assets/build/ba_data/audio/bunnyHit1.ogg": "https://files.ballistica.net/cache/ba1/4a/7f/903703552b19e71693ec041037ac",
- "assets/build/ba_data/audio/bunnyHit2.ogg": "https://files.ballistica.net/cache/ba1/d0/31/cb5fd87a73ce47b9c79f87e163ab",
- "assets/build/ba_data/audio/bunnyJump.ogg": "https://files.ballistica.net/cache/ba1/13/04/e46e6c1d7644606885e4b43800b2",
- "assets/build/ba_data/audio/cashRegister.ogg": "https://files.ballistica.net/cache/ba1/8e/9f/8e03065e97edeaa59703120a0ed4",
- "assets/build/ba_data/audio/cashRegister2.ogg": "https://files.ballistica.net/cache/ba1/2b/17/39e8e9bda873e09db8ab599ebd70",
- "assets/build/ba_data/audio/charSelectMusic.ogg": "https://files.ballistica.net/cache/ba1/a5/e5/deae67f63b9be8eca28169908ce3",
- "assets/build/ba_data/audio/cheer.ogg": "https://files.ballistica.net/cache/ba1/87/5b/7982e82c6a09d2e6f351ef665288",
- "assets/build/ba_data/audio/click01.ogg": "https://files.ballistica.net/cache/ba1/06/2a/e91c1eb70b729731a825647cbbfb",
- "assets/build/ba_data/audio/corkPop.ogg": "https://files.ballistica.net/cache/ba1/33/5f/58da365edf74e1b981b59891ddb4",
- "assets/build/ba_data/audio/cowboy1.ogg": "https://files.ballistica.net/cache/ba1/a7/05/680daba00b737cb918eec632dbfe",
- "assets/build/ba_data/audio/cowboy2.ogg": "https://files.ballistica.net/cache/ba1/2b/67/15711076f135fa638bd072a4b487",
- "assets/build/ba_data/audio/cowboy3.ogg": "https://files.ballistica.net/cache/ba1/4b/0f/46de2edd7a264fba3a8973c078ca",
- "assets/build/ba_data/audio/cowboy4.ogg": "https://files.ballistica.net/cache/ba1/1e/78/f8b4d4d9fd1bdcce9b79fa6f3d0c",
- "assets/build/ba_data/audio/cowboyDeath.ogg": "https://files.ballistica.net/cache/ba1/05/1a/afae164d0e81babbacaa0eed27ea",
- "assets/build/ba_data/audio/cowboyFall.ogg": "https://files.ballistica.net/cache/ba1/57/2f/a9c6adbe96fa94ab840be5373a44",
- "assets/build/ba_data/audio/cowboyHit1.ogg": "https://files.ballistica.net/cache/ba1/9e/f7/4dadf69fb9e04a08871c5967a05c",
- "assets/build/ba_data/audio/cowboyHit2.ogg": "https://files.ballistica.net/cache/ba1/d9/3c/4a4507cf25a5bf2f6fad76e6a9e1",
- "assets/build/ba_data/audio/crowdChant.ogg": "https://files.ballistica.net/cache/ba1/36/db/f4432f85f19cce87dc8a62ba497d",
- "assets/build/ba_data/audio/cyborg1.ogg": "https://files.ballistica.net/cache/ba1/6c/62/33a5b99062293b0a38cdf484bc58",
- "assets/build/ba_data/audio/cyborg2.ogg": "https://files.ballistica.net/cache/ba1/b7/a3/032be02fca5b2b06e3f7d6500640",
- "assets/build/ba_data/audio/cyborg3.ogg": "https://files.ballistica.net/cache/ba1/c3/82/1e7949473dbbdaa9688f11ad282a",
- "assets/build/ba_data/audio/cyborg4.ogg": "https://files.ballistica.net/cache/ba1/7c/d6/2461bd28a2c40be389f3f5c5b517",
- "assets/build/ba_data/audio/cyborgDeath.ogg": "https://files.ballistica.net/cache/ba1/d8/47/3376feef35a36db9c31da212a911",
- "assets/build/ba_data/audio/cyborgFall.ogg": "https://files.ballistica.net/cache/ba1/2c/87/4c812fe231fcd3b77a1b63b5ae8d",
- "assets/build/ba_data/audio/cyborgHit1.ogg": "https://files.ballistica.net/cache/ba1/e0/7f/65f0ceab26d2ec41901a7dba3367",
- "assets/build/ba_data/audio/cyborgHit2.ogg": "https://files.ballistica.net/cache/ba1/92/8e/2735ab2817ab899c810faa3f4214",
- "assets/build/ba_data/audio/cymbal.ogg": "https://files.ballistica.net/cache/ba1/47/bb/89f7104e8b1b58844f2b7bbea5c8",
- "assets/build/ba_data/audio/debrisFall.ogg": "https://files.ballistica.net/cache/ba1/59/f8/793797d16965601403fbd76d7122",
- "assets/build/ba_data/audio/deek.ogg": "https://files.ballistica.net/cache/ba1/4f/5d/2b8d74c023bd92db32a9b6abf27e",
- "assets/build/ba_data/audio/deek2.ogg": "https://files.ballistica.net/cache/ba1/31/3e/ecc2351f4097de01b4ef19d8e4a1",
- "assets/build/ba_data/audio/ding.ogg": "https://files.ballistica.net/cache/ba1/9f/4f/0246b0dc02d11a47a866006d364b",
- "assets/build/ba_data/audio/dingSmall.ogg": "https://files.ballistica.net/cache/ba1/a8/f3/02a413d75105c4e789d6c62d983f",
- "assets/build/ba_data/audio/dingSmallHigh.ogg": "https://files.ballistica.net/cache/ba1/f8/f8/fb3490eda7ca3daaff63e0415830",
- "assets/build/ba_data/audio/dripity.ogg": "https://files.ballistica.net/cache/ba1/a1/33/f9189cdf3088e37b7af0f2da9f6c",
- "assets/build/ba_data/audio/drumRoll.ogg": "https://files.ballistica.net/cache/ba1/e8/13/086773cd523943940cf3de806497",
- "assets/build/ba_data/audio/error.ogg": "https://files.ballistica.net/cache/ba1/b4/ad/9dfe09980f2d6c21af2e40e3ca87",
- "assets/build/ba_data/audio/explosion01.ogg": "https://files.ballistica.net/cache/ba1/2f/d9/97b803aa445fee5282f3a8bcdd5a",
- "assets/build/ba_data/audio/explosion02.ogg": "https://files.ballistica.net/cache/ba1/87/88/996566f228d9473956c8c2575003",
- "assets/build/ba_data/audio/explosion03.ogg": "https://files.ballistica.net/cache/ba1/0c/d8/68d1776fedbb89620f9d7615f634",
- "assets/build/ba_data/audio/explosion04.ogg": "https://files.ballistica.net/cache/ba1/89/c5/70e32b94138e11275ea222882840",
- "assets/build/ba_data/audio/explosion05.ogg": "https://files.ballistica.net/cache/ba1/01/08/43231dbc2c3630da84c446c3b57e",
- "assets/build/ba_data/audio/fanfare.ogg": "https://files.ballistica.net/cache/ba1/16/d0/8b7d5c981390d0b71eac730a810d",
- "assets/build/ba_data/audio/flagCatcherMusic.ogg": "https://files.ballistica.net/cache/ba1/86/e7/0e647b76c4d28b788fd80e0e2546",
- "assets/build/ba_data/audio/flyingMusic.ogg": "https://files.ballistica.net/cache/ba1/95/cd/61d54bc3e331f0b2ec4a7dd55e37",
- "assets/build/ba_data/audio/foghorn.ogg": "https://files.ballistica.net/cache/ba1/17/6b/58c0f2aae49f9e3dcb845a3d45f4",
- "assets/build/ba_data/audio/footImpact01.ogg": "https://files.ballistica.net/cache/ba1/21/23/41148f46251d9fe740a9f6975bb7",
- "assets/build/ba_data/audio/footImpact02.ogg": "https://files.ballistica.net/cache/ba1/15/0a/1bc32d46e2c5564d423c4eef2594",
- "assets/build/ba_data/audio/footImpact03.ogg": "https://files.ballistica.net/cache/ba1/13/da/5fc81a71dfd3cf9570d5a31289e1",
- "assets/build/ba_data/audio/forwardMarchMusic.ogg": "https://files.ballistica.net/cache/ba1/35/04/e6f29d4167b0cffed6cd95b4edaa",
- "assets/build/ba_data/audio/freeze.ogg": "https://files.ballistica.net/cache/ba1/7c/63/c7ffc0a3748acfe48d15299433b2",
- "assets/build/ba_data/audio/frosty01.ogg": "https://files.ballistica.net/cache/ba1/ca/f3/fbb572d011011773ef9e2cb8d517",
- "assets/build/ba_data/audio/frosty02.ogg": "https://files.ballistica.net/cache/ba1/62/b0/31dd9e9adb86006e7cc22bcc43d5",
- "assets/build/ba_data/audio/frosty03.ogg": "https://files.ballistica.net/cache/ba1/6d/81/bdad0ab14f5d08e4eec07b448820",
- "assets/build/ba_data/audio/frosty04.ogg": "https://files.ballistica.net/cache/ba1/62/8a/b24b5b34ee4353a48f7a80e7d526",
- "assets/build/ba_data/audio/frosty05.ogg": "https://files.ballistica.net/cache/ba1/e2/d8/fb46e471d4c18269e2b463d8a4d9",
- "assets/build/ba_data/audio/frostyDeath.ogg": "https://files.ballistica.net/cache/ba1/47/1b/1c646af3b2c17ba3daf7092a2c72",
- "assets/build/ba_data/audio/frostyFall.ogg": "https://files.ballistica.net/cache/ba1/70/1e/4276253d747e380a2de9ce4514f6",
- "assets/build/ba_data/audio/frostyHit01.ogg": "https://files.ballistica.net/cache/ba1/2a/f6/6e2df24f49af739fedb1f7f65a6e",
- "assets/build/ba_data/audio/frostyHit02.ogg": "https://files.ballistica.net/cache/ba1/ac/26/ef3f46f8ed9d65225c32468d71c3",
- "assets/build/ba_data/audio/frostyHit03.ogg": "https://files.ballistica.net/cache/ba1/c1/ba/89d48c268110acec203e7f9f6fd9",
- "assets/build/ba_data/audio/fuse01.ogg": "https://files.ballistica.net/cache/ba1/d9/ea/0e00da95a2f1e611ddb07211ebe5",
- "assets/build/ba_data/audio/gladiator1.ogg": "https://files.ballistica.net/cache/ba1/d7/39/bd8119eae6558cc58d2024e9fd27",
- "assets/build/ba_data/audio/gladiator2.ogg": "https://files.ballistica.net/cache/ba1/71/45/f3f027adb94077308cb1cf80e05f",
- "assets/build/ba_data/audio/gladiator3.ogg": "https://files.ballistica.net/cache/ba1/52/95/231c534a606bd9df2cd94a0261b0",
- "assets/build/ba_data/audio/gladiator4.ogg": "https://files.ballistica.net/cache/ba1/39/0d/17e8d36c933bc2476ddb45f1f1f8",
- "assets/build/ba_data/audio/gladiatorDeath.ogg": "https://files.ballistica.net/cache/ba1/54/e8/9489e9cb83a6509258e53140fb3d",
- "assets/build/ba_data/audio/gladiatorFall.ogg": "https://files.ballistica.net/cache/ba1/a5/17/ba3cdd74be5110fdaa36c9ba9f23",
- "assets/build/ba_data/audio/gladiatorHit1.ogg": "https://files.ballistica.net/cache/ba1/bf/01/1f7363688b77a9b522c0cfb24d32",
- "assets/build/ba_data/audio/gladiatorHit2.ogg": "https://files.ballistica.net/cache/ba1/dc/10/88a3664f6d53e0509d2393d88aa1",
- "assets/build/ba_data/audio/gong.ogg": "https://files.ballistica.net/cache/ba1/4a/93/5f144784791eb6a9556c1608f449",
- "assets/build/ba_data/audio/grandRompMusic.ogg": "https://files.ballistica.net/cache/ba1/fe/37/2444874e5fa5ca5b2006b1e02d5b",
- "assets/build/ba_data/audio/gravelSkid.ogg": "https://files.ballistica.net/cache/ba1/69/c3/f342f0728fca01f5815bebc12b5e",
- "assets/build/ba_data/audio/gunCocking.ogg": "https://files.ballistica.net/cache/ba1/ad/5d/61299a3e7e749b0820b3b14e85f8",
- "assets/build/ba_data/audio/healthPowerup.ogg": "https://files.ballistica.net/cache/ba1/b7/f8/325609f9aeefc7c7625ee9b6fdc2",
- "assets/build/ba_data/audio/hiss.ogg": "https://files.ballistica.net/cache/ba1/80/38/767dbf6d2be69784de415bd3242c",
- "assets/build/ba_data/audio/impactHard.ogg": "https://files.ballistica.net/cache/ba1/da/02/d9eaa62bbb6cd2f945be58079e67",
- "assets/build/ba_data/audio/impactHard2.ogg": "https://files.ballistica.net/cache/ba1/5f/20/e755ece22d2a9830855a4e1f6070",
- "assets/build/ba_data/audio/impactHard3.ogg": "https://files.ballistica.net/cache/ba1/fa/96/4ad8be51f4e5be4a7816cac306d5",
- "assets/build/ba_data/audio/impactMedium.ogg": "https://files.ballistica.net/cache/ba1/71/c2/a515223089fd7869557ff77e8fb3",
- "assets/build/ba_data/audio/impactMedium2.ogg": "https://files.ballistica.net/cache/ba1/86/69/13a025f692a55fcbfee563ed863b",
- "assets/build/ba_data/audio/jack01.ogg": "https://files.ballistica.net/cache/ba1/27/12/fcdce5c2534201b15ca4c2380608",
- "assets/build/ba_data/audio/jack02.ogg": "https://files.ballistica.net/cache/ba1/33/5f/6bb0dffec14c2d3a3c601da23377",
- "assets/build/ba_data/audio/jack03.ogg": "https://files.ballistica.net/cache/ba1/08/e3/ae5342d0fd19b90d8ae688b45fdd",
- "assets/build/ba_data/audio/jack04.ogg": "https://files.ballistica.net/cache/ba1/fe/d4/b264d6ada9ada3bfdef372392288",
- "assets/build/ba_data/audio/jack05.ogg": "https://files.ballistica.net/cache/ba1/a3/5f/45a42149edcad5d9e62a4dd8ad93",
- "assets/build/ba_data/audio/jack06.ogg": "https://files.ballistica.net/cache/ba1/d0/0f/c7e91e47e838b08770c5de97246b",
- "assets/build/ba_data/audio/jackDeath01.ogg": "https://files.ballistica.net/cache/ba1/e2/b8/02e58d71ae249544615a67c03296",
- "assets/build/ba_data/audio/jackFall01.ogg": "https://files.ballistica.net/cache/ba1/2e/13/860f88fa2b8c4b1defa6a5fceb0a",
- "assets/build/ba_data/audio/jackHit01.ogg": "https://files.ballistica.net/cache/ba1/b9/6b/33fffdd01b8bdccaf86855f2adbf",
- "assets/build/ba_data/audio/jackHit02.ogg": "https://files.ballistica.net/cache/ba1/94/23/3774cac4aa2b6686744595e7bb5c",
- "assets/build/ba_data/audio/jackHit03.ogg": "https://files.ballistica.net/cache/ba1/95/58/b37b545f984225c8b9d8c100c1ad",
- "assets/build/ba_data/audio/jackHit04.ogg": "https://files.ballistica.net/cache/ba1/33/a2/923ee4faf663ffdcb7c4915408d7",
- "assets/build/ba_data/audio/jackHit05.ogg": "https://files.ballistica.net/cache/ba1/91/bf/8acb25409d688f8868732a98869e",
- "assets/build/ba_data/audio/jackHit06.ogg": "https://files.ballistica.net/cache/ba1/f1/29/079b85a50ac3227eb7580a3bcca0",
- "assets/build/ba_data/audio/jackHit07.ogg": "https://files.ballistica.net/cache/ba1/a6/81/39bf1cd3f6829af9b14b20225907",
- "assets/build/ba_data/audio/jumpsuit1.ogg": "https://files.ballistica.net/cache/ba1/bd/77/58cc735320dd9c8cc42f607d4b5d",
- "assets/build/ba_data/audio/jumpsuit2.ogg": "https://files.ballistica.net/cache/ba1/e9/b4/a20e16879bd9dbb799a234b765d5",
- "assets/build/ba_data/audio/jumpsuit3.ogg": "https://files.ballistica.net/cache/ba1/a0/c6/f7fa993a5fd278a78b46e09f9b75",
- "assets/build/ba_data/audio/jumpsuit4.ogg": "https://files.ballistica.net/cache/ba1/a1/27/4da6a33575dbe88648dfbc8d05d7",
- "assets/build/ba_data/audio/jumpsuitDeath.ogg": "https://files.ballistica.net/cache/ba1/80/8e/42fe43c6e622b03172ba9189d5f1",
- "assets/build/ba_data/audio/jumpsuitFall.ogg": "https://files.ballistica.net/cache/ba1/9e/c2/05090792c71d651017d7d4a15850",
- "assets/build/ba_data/audio/jumpsuitHit1.ogg": "https://files.ballistica.net/cache/ba1/74/14/4266a79b4a1b1f61fdc6a5c65fbe",
- "assets/build/ba_data/audio/jumpsuitHit2.ogg": "https://files.ballistica.net/cache/ba1/32/1f/8dc55112a2cbba1921b5d6f89ac3",
- "assets/build/ba_data/audio/kronk1.ogg": "https://files.ballistica.net/cache/ba1/4e/6f/9f55ffe6b9739371a0a810a600e5",
- "assets/build/ba_data/audio/kronk10.ogg": "https://files.ballistica.net/cache/ba1/47/f8/51bebed376036961c88565c06c14",
- "assets/build/ba_data/audio/kronk2.ogg": "https://files.ballistica.net/cache/ba1/09/08/a0bec68a4112cf585677afe748a4",
- "assets/build/ba_data/audio/kronk3.ogg": "https://files.ballistica.net/cache/ba1/8e/c8/2ed9ae22042b9acf0feeec8ae1ef",
- "assets/build/ba_data/audio/kronk4.ogg": "https://files.ballistica.net/cache/ba1/da/b1/57b26205ef88c824820d74d06453",
- "assets/build/ba_data/audio/kronk5.ogg": "https://files.ballistica.net/cache/ba1/b2/67/a3d3ff13521f9d580c68d59b45be",
- "assets/build/ba_data/audio/kronk6.ogg": "https://files.ballistica.net/cache/ba1/f5/43/1411aabd658621b66f85f0c48263",
- "assets/build/ba_data/audio/kronk7.ogg": "https://files.ballistica.net/cache/ba1/31/3a/8d9c64badd6d6b5370d210348b4d",
- "assets/build/ba_data/audio/kronk8.ogg": "https://files.ballistica.net/cache/ba1/a3/56/2e62b9e34892645d3229e8953508",
- "assets/build/ba_data/audio/kronk9.ogg": "https://files.ballistica.net/cache/ba1/65/ef/dd4851e044c2edec0931c58261d1",
- "assets/build/ba_data/audio/kronkDeath.ogg": "https://files.ballistica.net/cache/ba1/3b/59/8918fbbc563e2053a48708a1c5ca",
- "assets/build/ba_data/audio/kronkFall.ogg": "https://files.ballistica.net/cache/ba1/04/d9/5c4eb21b88d943b39e2a338c93ca",
- "assets/build/ba_data/audio/laser.ogg": "https://files.ballistica.net/cache/ba1/85/28/4870efb11aa77d4e628472ce2f62",
- "assets/build/ba_data/audio/laserReverse.ogg": "https://files.ballistica.net/cache/ba1/98/ca/b1b23e0c21acf47876d677328493",
- "assets/build/ba_data/audio/mel01.ogg": "https://files.ballistica.net/cache/ba1/2c/8f/36e020affbf23cbfa20dc486291e",
- "assets/build/ba_data/audio/mel02.ogg": "https://files.ballistica.net/cache/ba1/bd/56/f5d03c1d70bb298f02ace96d0fe4",
- "assets/build/ba_data/audio/mel03.ogg": "https://files.ballistica.net/cache/ba1/43/d6/92cf472e4fdcc62dd28d24617121",
- "assets/build/ba_data/audio/mel04.ogg": "https://files.ballistica.net/cache/ba1/6f/4a/c729e3fa4f0bc04b9d0c86454ba0",
- "assets/build/ba_data/audio/mel05.ogg": "https://files.ballistica.net/cache/ba1/38/d3/3b4b44646fb32c7616bbdb84943c",
- "assets/build/ba_data/audio/mel06.ogg": "https://files.ballistica.net/cache/ba1/c1/f2/2209937359c488e2eee3ebfb0b74",
- "assets/build/ba_data/audio/mel07.ogg": "https://files.ballistica.net/cache/ba1/c7/00/88715b50c463ec8f90aa5f5d814a",
- "assets/build/ba_data/audio/mel08.ogg": "https://files.ballistica.net/cache/ba1/cc/18/51b007d3eda6cd03dd9ae6575161",
- "assets/build/ba_data/audio/mel09.ogg": "https://files.ballistica.net/cache/ba1/44/aa/1edab3702912292f0ba5d25d6b29",
- "assets/build/ba_data/audio/mel10.ogg": "https://files.ballistica.net/cache/ba1/1b/7b/2b8e2684f3b9f5eaec906d2c895b",
- "assets/build/ba_data/audio/melDeath01.ogg": "https://files.ballistica.net/cache/ba1/4a/f8/cf3a2f5394d1a1350ce65574c217",
- "assets/build/ba_data/audio/melFall01.ogg": "https://files.ballistica.net/cache/ba1/81/30/94cc749ec9b4f6e04d8ae0ff8891",
- "assets/build/ba_data/audio/menuMusic.ogg": "https://files.ballistica.net/cache/ba1/bd/95/1c1d27c90cedcd3c2c466ba4af05",
- "assets/build/ba_data/audio/metalHit.ogg": "https://files.ballistica.net/cache/ba1/f7/98/3d81888061e6821438c47987dda1",
- "assets/build/ba_data/audio/metalSkid.ogg": "https://files.ballistica.net/cache/ba1/17/13/5029f01d8c048a58cfcbed0ddcc4",
- "assets/build/ba_data/audio/ninjaAttack1.ogg": "https://files.ballistica.net/cache/ba1/2a/18/1f1ad3878d79efe97b408d745881",
- "assets/build/ba_data/audio/ninjaAttack2.ogg": "https://files.ballistica.net/cache/ba1/b9/4d/3f93af21c7a073f0751e307dd4b0",
- "assets/build/ba_data/audio/ninjaAttack3.ogg": "https://files.ballistica.net/cache/ba1/13/c3/8251857b304034ef93a9ec668381",
- "assets/build/ba_data/audio/ninjaAttack4.ogg": "https://files.ballistica.net/cache/ba1/45/39/714130286da1a5edae376c6efd92",
- "assets/build/ba_data/audio/ninjaAttack5.ogg": "https://files.ballistica.net/cache/ba1/ed/38/2cdcaea2bbbd84bc0fe1cb4f184a",
- "assets/build/ba_data/audio/ninjaAttack6.ogg": "https://files.ballistica.net/cache/ba1/38/6a/df7417238bd97fc68afdc4a0d1dd",
- "assets/build/ba_data/audio/ninjaAttack7.ogg": "https://files.ballistica.net/cache/ba1/56/f0/3b82da9e99b259c857bc269f1898",
- "assets/build/ba_data/audio/ninjaDeath1.ogg": "https://files.ballistica.net/cache/ba1/f9/04/cadcebd5c3729926f2f50370a8e4",
- "assets/build/ba_data/audio/ninjaFall1.ogg": "https://files.ballistica.net/cache/ba1/38/69/7179a0a5655f810f28b19af5cc1f",
- "assets/build/ba_data/audio/ninjaHit1.ogg": "https://files.ballistica.net/cache/ba1/3d/5d/8e1fed6e0e40d7743613cdf5ef59",
- "assets/build/ba_data/audio/ninjaHit2.ogg": "https://files.ballistica.net/cache/ba1/2c/ba/a75741ab5e5de95d7d8e88969a22",
- "assets/build/ba_data/audio/ninjaHit3.ogg": "https://files.ballistica.net/cache/ba1/bf/5f/7265f22f04e2130c24f2a001d0c2",
- "assets/build/ba_data/audio/ninjaHit4.ogg": "https://files.ballistica.net/cache/ba1/5f/b0/ede4fa97a9dfa27b2ca1107fee16",
- "assets/build/ba_data/audio/ninjaHit5.ogg": "https://files.ballistica.net/cache/ba1/f0/00/ec3188103c119c1dc4781f28604b",
- "assets/build/ba_data/audio/ninjaHit6.ogg": "https://files.ballistica.net/cache/ba1/69/34/e835f781dfa77da25124824d8aac",
- "assets/build/ba_data/audio/ninjaHit7.ogg": "https://files.ballistica.net/cache/ba1/b0/b1/0934cf37dae8ebf9413cb619c89b",
- "assets/build/ba_data/audio/ninjaHit8.ogg": "https://files.ballistica.net/cache/ba1/81/ea/c0b6ba9f05499d1254c80eaf56fd",
- "assets/build/ba_data/audio/oldLady1.ogg": "https://files.ballistica.net/cache/ba1/88/8c/e63c26010c85d164adce3990f30e",
- "assets/build/ba_data/audio/oldLady2.ogg": "https://files.ballistica.net/cache/ba1/96/af/84fa9302f076b6a6276bc99179e0",
- "assets/build/ba_data/audio/oldLady3.ogg": "https://files.ballistica.net/cache/ba1/30/50/4d10bb83e747029460f7870e1601",
- "assets/build/ba_data/audio/oldLady4.ogg": "https://files.ballistica.net/cache/ba1/60/f2/a38d94950cc14ee2fdfb31e00146",
- "assets/build/ba_data/audio/oldLadyDeath.ogg": "https://files.ballistica.net/cache/ba1/09/2a/bbf7214449d1bbfbfdbac8cb11a9",
- "assets/build/ba_data/audio/oldLadyFall.ogg": "https://files.ballistica.net/cache/ba1/63/6e/9758dc9487224d80129a17d9be04",
- "assets/build/ba_data/audio/oldLadyHit1.ogg": "https://files.ballistica.net/cache/ba1/92/8d/c5a9d63b9bb4d72784ccbfb86bdf",
- "assets/build/ba_data/audio/oldLadyHit2.ogg": "https://files.ballistica.net/cache/ba1/2f/ed/b0dc30ff4fbc5f024aa0cc86e6a2",
- "assets/build/ba_data/audio/ooh.ogg": "https://files.ballistica.net/cache/ba1/ca/17/6a512761959489dd97cea66f8fb9",
- "assets/build/ba_data/audio/operaSinger1.ogg": "https://files.ballistica.net/cache/ba1/1f/e6/5cbc1d640d779651abda6ddc2510",
- "assets/build/ba_data/audio/operaSinger2.ogg": "https://files.ballistica.net/cache/ba1/85/82/b7fce504e3b3ab1e3a90c8ceca2a",
- "assets/build/ba_data/audio/operaSinger3.ogg": "https://files.ballistica.net/cache/ba1/2c/3d/94c95cfb0ddbd2287aa98ff9ec52",
- "assets/build/ba_data/audio/operaSinger4.ogg": "https://files.ballistica.net/cache/ba1/4e/ae/b918a8de6d54f072d3cd7f4d0c18",
- "assets/build/ba_data/audio/operaSingerDeath.ogg": "https://files.ballistica.net/cache/ba1/4b/d5/46061ca14b67460493259045ea43",
- "assets/build/ba_data/audio/operaSingerFall.ogg": "https://files.ballistica.net/cache/ba1/d8/9a/78d427405b693121c8cf18e789e1",
- "assets/build/ba_data/audio/operaSingerHit1.ogg": "https://files.ballistica.net/cache/ba1/fb/16/007d8e2c229cbf1861021e0a0023",
- "assets/build/ba_data/audio/operaSingerHit2.ogg": "https://files.ballistica.net/cache/ba1/8f/94/0041b1ba8cbc8155c1156a4a9d92",
- "assets/build/ba_data/audio/orchestraHit.ogg": "https://files.ballistica.net/cache/ba1/34/39/230a50bafeb4fa7ed5b7edc73a36",
- "assets/build/ba_data/audio/orchestraHit2.ogg": "https://files.ballistica.net/cache/ba1/08/1b/74e10d63ce851db3339352fa7d20",
- "assets/build/ba_data/audio/orchestraHit3.ogg": "https://files.ballistica.net/cache/ba1/e1/97/bf98875661f61f733207b28d299a",
- "assets/build/ba_data/audio/orchestraHit4.ogg": "https://files.ballistica.net/cache/ba1/7a/33/d13149b91c176002c51caacc0b04",
- "assets/build/ba_data/audio/orchestraHitBig1.ogg": "https://files.ballistica.net/cache/ba1/1b/ad/10baf05c9348c34cd5c4333905b0",
- "assets/build/ba_data/audio/orchestraHitBig2.ogg": "https://files.ballistica.net/cache/ba1/3e/9c/d7e6d61c713cc4bdee2116657eee",
- "assets/build/ba_data/audio/penguin1.ogg": "https://files.ballistica.net/cache/ba1/02/52/3f09afe2ecfbf07501a93b43f3b1",
- "assets/build/ba_data/audio/penguin2.ogg": "https://files.ballistica.net/cache/ba1/25/bf/632302bd31340b129f5945376530",
- "assets/build/ba_data/audio/penguin3.ogg": "https://files.ballistica.net/cache/ba1/8c/20/a82230a6b246d5a46f91c791a3e7",
- "assets/build/ba_data/audio/penguin4.ogg": "https://files.ballistica.net/cache/ba1/bb/ba/83754ebb41d4ef7b2c0fa8dd51ab",
- "assets/build/ba_data/audio/penguinDeath.ogg": "https://files.ballistica.net/cache/ba1/fb/49/b8e0919f57a4eb021b1cb969a88a",
- "assets/build/ba_data/audio/penguinFall.ogg": "https://files.ballistica.net/cache/ba1/1f/c3/e8f21b8b3657ffa8b86af8dacc6f",
- "assets/build/ba_data/audio/penguinHit1.ogg": "https://files.ballistica.net/cache/ba1/15/d6/7ce277305546211e8e6aed59e5a0",
- "assets/build/ba_data/audio/penguinHit2.ogg": "https://files.ballistica.net/cache/ba1/a3/34/e3cfa4803c4ae29788857eabb87d",
- "assets/build/ba_data/audio/pixie1.ogg": "https://files.ballistica.net/cache/ba1/5d/e7/e3bb76e4855bab3bfa237975a8b6",
- "assets/build/ba_data/audio/pixie2.ogg": "https://files.ballistica.net/cache/ba1/ab/39/2a73f2b59560267557d6aa9a1d94",
- "assets/build/ba_data/audio/pixie3.ogg": "https://files.ballistica.net/cache/ba1/94/36/0e8edbfdcc0e327095c9153b7f1b",
- "assets/build/ba_data/audio/pixie4.ogg": "https://files.ballistica.net/cache/ba1/95/ae/a33bdbdb81ad8e9739df5f96a43f",
- "assets/build/ba_data/audio/pixieDeath.ogg": "https://files.ballistica.net/cache/ba1/5d/8c/22cab8ae2623d284b8c70dc08638",
- "assets/build/ba_data/audio/pixieFall.ogg": "https://files.ballistica.net/cache/ba1/37/54/7c75f1660a9aa2bc5f370a1414eb",
- "assets/build/ba_data/audio/pixieHit1.ogg": "https://files.ballistica.net/cache/ba1/d2/20/41cd9666cc82eb905391e5f09358",
- "assets/build/ba_data/audio/pixieHit2.ogg": "https://files.ballistica.net/cache/ba1/1b/c8/436ef74c333eab1087f534d51a87",
- "assets/build/ba_data/audio/playerDeath.ogg": "https://files.ballistica.net/cache/ba1/87/9a/d7d892c820ea7dd0398cac88ffa1",
- "assets/build/ba_data/audio/playerLeft.ogg": "https://files.ballistica.net/cache/ba1/04/b7/16c085238f547631378195827698",
- "assets/build/ba_data/audio/pop01.ogg": "https://files.ballistica.net/cache/ba1/98/71/28a573d9a037e568e178359bb02b",
- "assets/build/ba_data/audio/powerdown01.ogg": "https://files.ballistica.net/cache/ba1/ed/a7/8e3a7511ab0fafbb9b52445348f4",
- "assets/build/ba_data/audio/powerup01.ogg": "https://files.ballistica.net/cache/ba1/3d/f3/d1e6660cd061b5a1b67b0e98088b",
- "assets/build/ba_data/audio/punch01.ogg": "https://files.ballistica.net/cache/ba1/f7/0d/22cce48b0346ec0d948b9808dff3",
- "assets/build/ba_data/audio/punchStrong01.ogg": "https://files.ballistica.net/cache/ba1/80/4c/00f81efc80a2c68212f62fcc7b01",
- "assets/build/ba_data/audio/punchStrong02.ogg": "https://files.ballistica.net/cache/ba1/c3/1d/eaf2396f4e9127b7a66197b4ad75",
- "assets/build/ba_data/audio/punchSwish.ogg": "https://files.ballistica.net/cache/ba1/b4/d6/008534042a94cd99523bfcabdcbd",
- "assets/build/ba_data/audio/punchWeak01.ogg": "https://files.ballistica.net/cache/ba1/d0/94/53212b0bf039671333388100c9aa",
- "assets/build/ba_data/audio/raceBeep1.ogg": "https://files.ballistica.net/cache/ba1/e9/c2/22fd9db278c0a441661b7a14f398",
- "assets/build/ba_data/audio/raceBeep2.ogg": "https://files.ballistica.net/cache/ba1/2a/86/6d59a75d7cfaaf2f5d38d55015b5",
- "assets/build/ba_data/audio/refWhistle.ogg": "https://files.ballistica.net/cache/ba1/92/d7/d8b7b01eccf13f58aceaee05bf79",
- "assets/build/ba_data/audio/robot1.ogg": "https://files.ballistica.net/cache/ba1/25/8a/dd1abdace3a4fc3e73586d8bb103",
- "assets/build/ba_data/audio/robot2.ogg": "https://files.ballistica.net/cache/ba1/27/b3/ed6ccb5391bb5b0d4415e09afc37",
- "assets/build/ba_data/audio/robot3.ogg": "https://files.ballistica.net/cache/ba1/2b/a9/2b958ed91060cd3e9ab1b657a71f",
- "assets/build/ba_data/audio/robot4.ogg": "https://files.ballistica.net/cache/ba1/15/77/ebf9129bc8cf2428418d7484b19d",
- "assets/build/ba_data/audio/robotDeath.ogg": "https://files.ballistica.net/cache/ba1/52/1f/578eabdd26e645f88ae7b3715d82",
- "assets/build/ba_data/audio/robotFall.ogg": "https://files.ballistica.net/cache/ba1/61/f1/1f4bf39e3d496541f8f117128896",
- "assets/build/ba_data/audio/robotHit1.ogg": "https://files.ballistica.net/cache/ba1/7e/e7/c8c8fc38fd68434de77eb22af479",
- "assets/build/ba_data/audio/robotHit2.ogg": "https://files.ballistica.net/cache/ba1/a1/e4/3f07c6f8dc90781609ccf73f2bfe",
- "assets/build/ba_data/audio/runAwayMusic.ogg": "https://files.ballistica.net/cache/ba1/ba/1c/b684591f05af402c5a77d9e78ef9",
- "assets/build/ba_data/audio/santa01.ogg": "https://files.ballistica.net/cache/ba1/87/f7/06c8bd9468c30fc04993ce43c813",
- "assets/build/ba_data/audio/santa02.ogg": "https://files.ballistica.net/cache/ba1/b5/86/09edb5a7df6eb3a675ca5821a91c",
- "assets/build/ba_data/audio/santa03.ogg": "https://files.ballistica.net/cache/ba1/41/5f/4db6735c4cb1acca84e930fc05b7",
- "assets/build/ba_data/audio/santa04.ogg": "https://files.ballistica.net/cache/ba1/f8/2a/7d03c0fa2f3277b9461963486083",
- "assets/build/ba_data/audio/santa05.ogg": "https://files.ballistica.net/cache/ba1/4a/8a/f38d78093c9682ebe4f465f904fe",
- "assets/build/ba_data/audio/santaDeath.ogg": "https://files.ballistica.net/cache/ba1/20/e6/ca1615ed18b55fa82d3604ee610f",
- "assets/build/ba_data/audio/santaFall.ogg": "https://files.ballistica.net/cache/ba1/6c/e0/4fb79b714b95e304ad92bb9e90cc",
- "assets/build/ba_data/audio/santaHit01.ogg": "https://files.ballistica.net/cache/ba1/e1/8a/f1ff4f53b6458b667463df023772",
- "assets/build/ba_data/audio/santaHit02.ogg": "https://files.ballistica.net/cache/ba1/a2/21/15ec6792a9bfe46ede252733e2f8",
- "assets/build/ba_data/audio/santaHit03.ogg": "https://files.ballistica.net/cache/ba1/47/51/54f71289e17b6a086997116ced97",
- "assets/build/ba_data/audio/santaHit04.ogg": "https://files.ballistica.net/cache/ba1/e2/71/4a4b49fa9f6bc3094f63395eb11a",
- "assets/build/ba_data/audio/scamper01.ogg": "https://files.ballistica.net/cache/ba1/91/f6/00f6677870b55037e0ca6dfa6bac",
- "assets/build/ba_data/audio/scaryMusic.ogg": "https://files.ballistica.net/cache/ba1/af/0d/6d5c74255e2c2659ffea0a84bc18",
- "assets/build/ba_data/audio/score.ogg": "https://files.ballistica.net/cache/ba1/f0/fe/7f33e7660c204c5d03f48c2025ee",
- "assets/build/ba_data/audio/scoreHit01.ogg": "https://files.ballistica.net/cache/ba1/fb/06/8926f64cf5d49ebb0cf964505e51",
- "assets/build/ba_data/audio/scoreHit02.ogg": "https://files.ballistica.net/cache/ba1/c5/5f/898fe8045c83d2c96f713ee12f88",
- "assets/build/ba_data/audio/scoreIncrease.ogg": "https://files.ballistica.net/cache/ba1/c1/f4/2790931f749d5322e3ac094fbe79",
- "assets/build/ba_data/audio/scoresEpicMusic.ogg": "https://files.ballistica.net/cache/ba1/dc/54/816b47036e51d5b1925eb1fe9cd4",
- "assets/build/ba_data/audio/shatter.ogg": "https://files.ballistica.net/cache/ba1/d9/f1/18c6e5aa42b03c7933595240dd8c",
- "assets/build/ba_data/audio/shieldDown.ogg": "https://files.ballistica.net/cache/ba1/4b/31/e3cc5bc0d8daa7c0282367e7cd4c",
- "assets/build/ba_data/audio/shieldHit.ogg": "https://files.ballistica.net/cache/ba1/ab/8d/2092774e4b2b9dbbd512317a00cf",
- "assets/build/ba_data/audio/shieldUp.ogg": "https://files.ballistica.net/cache/ba1/14/87/63c5e81a26d1e0ac5480e4998c7c",
- "assets/build/ba_data/audio/skid01.ogg": "https://files.ballistica.net/cache/ba1/1d/1a/6f2f183c0d69dbf7df1b0eac35d8",
- "assets/build/ba_data/audio/slowEpicMusic.ogg": "https://files.ballistica.net/cache/ba1/e9/13/983b62ba6759d355e7f3f364cb2f",
- "assets/build/ba_data/audio/sparkle01.ogg": "https://files.ballistica.net/cache/ba1/4c/87/9e1d47ec1370cb56a4db96801a76",
- "assets/build/ba_data/audio/sparkle02.ogg": "https://files.ballistica.net/cache/ba1/69/d8/3596d410738931b296ac542ac65c",
- "assets/build/ba_data/audio/sparkle03.ogg": "https://files.ballistica.net/cache/ba1/06/c0/bed26cac2d94cff65e18b9bd6b44",
- "assets/build/ba_data/audio/spawn.ogg": "https://files.ballistica.net/cache/ba1/f6/ca/3d7bb3f383a7c91da33df4b2f1f6",
- "assets/build/ba_data/audio/spazAttack01.ogg": "https://files.ballistica.net/cache/ba1/d9/73/f459675f255567824a30ca1162a2",
- "assets/build/ba_data/audio/spazAttack02.ogg": "https://files.ballistica.net/cache/ba1/f9/06/6c1b6597396aa774b9eb3ea6fd1d",
- "assets/build/ba_data/audio/spazAttack03.ogg": "https://files.ballistica.net/cache/ba1/89/d0/ead2cd4a0a604caa3f47d4e79228",
- "assets/build/ba_data/audio/spazAttack04.ogg": "https://files.ballistica.net/cache/ba1/e3/74/ada09133a2b21ad6f8755a63cad8",
- "assets/build/ba_data/audio/spazDeath01.ogg": "https://files.ballistica.net/cache/ba1/0f/08/739bfc0e87d84a95ef839520cdd7",
- "assets/build/ba_data/audio/spazEff.ogg": "https://files.ballistica.net/cache/ba1/3f/a0/c1003a34f7c31292a62cc97d4184",
- "assets/build/ba_data/audio/spazFall01.ogg": "https://files.ballistica.net/cache/ba1/11/78/0f80bb08c63bbf075158c45d8aa3",
- "assets/build/ba_data/audio/spazImpact01.ogg": "https://files.ballistica.net/cache/ba1/8c/35/68667339c031f5783e7017915ed6",
- "assets/build/ba_data/audio/spazImpact02.ogg": "https://files.ballistica.net/cache/ba1/73/e0/9be5bfface94706af3cc2c164f4a",
- "assets/build/ba_data/audio/spazImpact03.ogg": "https://files.ballistica.net/cache/ba1/f3/38/3e7ad049b5600a38f7fe4e3550f0",
- "assets/build/ba_data/audio/spazImpact04.ogg": "https://files.ballistica.net/cache/ba1/98/ac/4569ac759052dcf171c14a6674e8",
- "assets/build/ba_data/audio/spazJump01.ogg": "https://files.ballistica.net/cache/ba1/71/c4/123f2024c16151c89802f07803a9",
- "assets/build/ba_data/audio/spazJump02.ogg": "https://files.ballistica.net/cache/ba1/e2/eb/96b4de3dd6df94bb1f39b8a12e4d",
- "assets/build/ba_data/audio/spazJump03.ogg": "https://files.ballistica.net/cache/ba1/7e/42/5e2841e5d426ae0dcde40b59003b",
- "assets/build/ba_data/audio/spazJump04.ogg": "https://files.ballistica.net/cache/ba1/88/48/c41610cc9a1b42300686301f69f0",
- "assets/build/ba_data/audio/spazOw.ogg": "https://files.ballistica.net/cache/ba1/05/1c/f52026ff9cdd7f99c25d048b8418",
- "assets/build/ba_data/audio/spazPickup01.ogg": "https://files.ballistica.net/cache/ba1/17/53/7f0e82a5fef9c2ab8ab0a11da06c",
- "assets/build/ba_data/audio/spazScream01.ogg": "https://files.ballistica.net/cache/ba1/5b/8a/d754cdd0d89fef49c0ab8fba67e4",
- "assets/build/ba_data/audio/splatter.ogg": "https://files.ballistica.net/cache/ba1/35/3b/2986b53483b342bda57a84bd9e25",
- "assets/build/ba_data/audio/sportsMusic.ogg": "https://files.ballistica.net/cache/ba1/0a/c7/04ddd16a99f3b71850053c4931ba",
- "assets/build/ba_data/audio/stickyImpact.ogg": "https://files.ballistica.net/cache/ba1/7c/97/b784f6e54ced9b9e7084747b4efc",
- "assets/build/ba_data/audio/superPunch.ogg": "https://files.ballistica.net/cache/ba1/e7/6a/21e2dd4fe3a12d4f9db10e5647bd",
- "assets/build/ba_data/audio/superhero1.ogg": "https://files.ballistica.net/cache/ba1/c1/dd/dee32573a58ffd83cc00689ccfb4",
- "assets/build/ba_data/audio/superhero2.ogg": "https://files.ballistica.net/cache/ba1/00/fd/d2d3bd1ad913b7159873b58706f8",
- "assets/build/ba_data/audio/superhero3.ogg": "https://files.ballistica.net/cache/ba1/aa/1c/67e2a563354aff2460cbe2eccf52",
- "assets/build/ba_data/audio/superhero4.ogg": "https://files.ballistica.net/cache/ba1/81/96/7bb011d80de9182b56f0a4872d0d",
- "assets/build/ba_data/audio/superheroDeath.ogg": "https://files.ballistica.net/cache/ba1/85/d0/f061401183a4f6f5fc91995b6f76",
- "assets/build/ba_data/audio/superheroFall.ogg": "https://files.ballistica.net/cache/ba1/cc/f5/a2cf691726deb992081aecafb1bc",
- "assets/build/ba_data/audio/superheroHit1.ogg": "https://files.ballistica.net/cache/ba1/d4/e9/8d6126156d834c8ae84afdb28c33",
- "assets/build/ba_data/audio/superheroHit2.ogg": "https://files.ballistica.net/cache/ba1/46/68/dedbd03193754b3f937324f5d3c4",
- "assets/build/ba_data/audio/survivalMusic.ogg": "https://files.ballistica.net/cache/ba1/47/fc/601d034cf62aba583cac5521b627",
- "assets/build/ba_data/audio/swip.ogg": "https://files.ballistica.net/cache/ba1/7b/8a/998711379e489600665c5116d2b6",
- "assets/build/ba_data/audio/swip2.ogg": "https://files.ballistica.net/cache/ba1/2a/fc/e47bedc09e64e4d5c51ddaa2b18c",
- "assets/build/ba_data/audio/swish.ogg": "https://files.ballistica.net/cache/ba1/6e/6c/6ffd6f7c142bd7cf05c98df1cd5a",
- "assets/build/ba_data/audio/swish2.ogg": "https://files.ballistica.net/cache/ba1/a9/6c/bb02b87eeafb72b88c2b17c7f443",
- "assets/build/ba_data/audio/swish3.ogg": "https://files.ballistica.net/cache/ba1/a2/72/fdde40e2eedde9647467a272e39f",
- "assets/build/ba_data/audio/tap.ogg": "https://files.ballistica.net/cache/ba1/3b/3f/b5dd305b652fbd817a5c8a9c09d1",
- "assets/build/ba_data/audio/technoHit01.ogg": "https://files.ballistica.net/cache/ba1/65/20/06100b2ec255563927c0185c3199",
- "assets/build/ba_data/audio/tick.ogg": "https://files.ballistica.net/cache/ba1/97/57/dbfc82a25ba4153be0a85e420d3f",
- "assets/build/ba_data/audio/ticking.ogg": "https://files.ballistica.net/cache/ba1/f0/fc/9697c52a8298000f63b8d0d9fc3c",
- "assets/build/ba_data/audio/tickingCrazy.ogg": "https://files.ballistica.net/cache/ba1/13/be/cb51602790cee789598ee4ba237c",
- "assets/build/ba_data/audio/toTheDeathMusic.ogg": "https://files.ballistica.net/cache/ba1/ad/f3/f1219e2e320c1bca00dc7be034fd",
- "assets/build/ba_data/audio/trashRummage.ogg": "https://files.ballistica.net/cache/ba1/b6/40/b42ee407cb6460ab45bcfd0eb707",
- "assets/build/ba_data/audio/victoryMusic.ogg": "https://files.ballistica.net/cache/ba1/45/69/89e1f27a004507a98a63a72f8632",
- "assets/build/ba_data/audio/warnBeep.ogg": "https://files.ballistica.net/cache/ba1/6e/e1/d2384fdc2987710aa24a8cb8248d",
- "assets/build/ba_data/audio/warnBeeps.ogg": "https://files.ballistica.net/cache/ba1/48/ac/c12870dbac3d12e705f9354aec72",
- "assets/build/ba_data/audio/warrior1.ogg": "https://files.ballistica.net/cache/ba1/42/d7/6fde00abcddcc7469436820f9155",
- "assets/build/ba_data/audio/warrior2.ogg": "https://files.ballistica.net/cache/ba1/66/13/daec33822de45d32c50dda7a9db0",
- "assets/build/ba_data/audio/warrior3.ogg": "https://files.ballistica.net/cache/ba1/59/8d/d97ea49d6f0422da11823e18c3df",
- "assets/build/ba_data/audio/warrior4.ogg": "https://files.ballistica.net/cache/ba1/ac/dc/7f7682b4a5603ef27650b2456ac7",
- "assets/build/ba_data/audio/warriorDeath.ogg": "https://files.ballistica.net/cache/ba1/88/71/76555d9b53b029d9d973c311c450",
- "assets/build/ba_data/audio/warriorFall.ogg": "https://files.ballistica.net/cache/ba1/78/93/654e954bf52df964ba69ffe5dc5a",
- "assets/build/ba_data/audio/warriorHit1.ogg": "https://files.ballistica.net/cache/ba1/b9/00/1dea8f4393e71d373051f7d6e83b",
- "assets/build/ba_data/audio/warriorHit2.ogg": "https://files.ballistica.net/cache/ba1/b7/6c/dd05e76055fc57f9d19fd232f3e2",
- "assets/build/ba_data/audio/whenJohnnyComesMarchingHomeMusic.ogg": "https://files.ballistica.net/cache/ba1/80/18/2e9f72069cebbc4fd4f66009f686",
- "assets/build/ba_data/audio/witch1.ogg": "https://files.ballistica.net/cache/ba1/d9/29/d859837ebe122cb682973be4f8f8",
- "assets/build/ba_data/audio/witch2.ogg": "https://files.ballistica.net/cache/ba1/90/e2/f1c1dde870e74de9953991ee3ae9",
- "assets/build/ba_data/audio/witch3.ogg": "https://files.ballistica.net/cache/ba1/bd/22/85eef9e2d2495518d0736110a95b",
- "assets/build/ba_data/audio/witch4.ogg": "https://files.ballistica.net/cache/ba1/54/ae/a2428af20c6085636f85a0f2a140",
- "assets/build/ba_data/audio/witchDeath.ogg": "https://files.ballistica.net/cache/ba1/09/a4/6ba2c6ff8ca85d7016962d0947fa",
- "assets/build/ba_data/audio/witchFall.ogg": "https://files.ballistica.net/cache/ba1/94/9d/8497f02c50a0756a87a2f4b78fa5",
- "assets/build/ba_data/audio/witchHit1.ogg": "https://files.ballistica.net/cache/ba1/fc/47/d650d4a82529f11502861723f13e",
- "assets/build/ba_data/audio/witchHit2.ogg": "https://files.ballistica.net/cache/ba1/50/45/b8809360598df3f0203f62e788b0",
- "assets/build/ba_data/audio/wizard1.ogg": "https://files.ballistica.net/cache/ba1/9c/38/0c6a42cd90025cad61c43c7abc8a",
- "assets/build/ba_data/audio/wizard2.ogg": "https://files.ballistica.net/cache/ba1/12/56/4cb851ee7ebc308ae1a50771cf8f",
- "assets/build/ba_data/audio/wizard3.ogg": "https://files.ballistica.net/cache/ba1/e6/8f/5094937a979dbcc509713d2f4366",
- "assets/build/ba_data/audio/wizard4.ogg": "https://files.ballistica.net/cache/ba1/ed/60/d29dc09f12486e9ac0abd95b1270",
- "assets/build/ba_data/audio/wizardDeath.ogg": "https://files.ballistica.net/cache/ba1/45/c3/f9c6f78915a06787cbbb3750521c",
- "assets/build/ba_data/audio/wizardFall.ogg": "https://files.ballistica.net/cache/ba1/7d/8d/a6769c6a8f66d17a1417de4ed09f",
- "assets/build/ba_data/audio/wizardHit1.ogg": "https://files.ballistica.net/cache/ba1/9f/c2/5025c9fe242b84e3ada693fa835f",
- "assets/build/ba_data/audio/wizardHit2.ogg": "https://files.ballistica.net/cache/ba1/b9/04/c83d85abe1aed831c025e664bb80",
- "assets/build/ba_data/audio/woodDebrisFall.ogg": "https://files.ballistica.net/cache/ba1/90/f0/9c1b3cc64bc841f128075dc3d4b1",
- "assets/build/ba_data/audio/wrestler1.ogg": "https://files.ballistica.net/cache/ba1/bb/d1/907e03fae0606f3d26e1b8872e4c",
- "assets/build/ba_data/audio/wrestler2.ogg": "https://files.ballistica.net/cache/ba1/c8/2d/62f5f4cea7ad690a9a675d27eb42",
- "assets/build/ba_data/audio/wrestler3.ogg": "https://files.ballistica.net/cache/ba1/f8/15/686b9d131de8e70841bf449bbf76",
- "assets/build/ba_data/audio/wrestler4.ogg": "https://files.ballistica.net/cache/ba1/e2/2b/0e029172c72f1164813ec3f883be",
- "assets/build/ba_data/audio/wrestlerDeath.ogg": "https://files.ballistica.net/cache/ba1/ec/ea/2c38a9feadd86c2468a0e26a3871",
- "assets/build/ba_data/audio/wrestlerFall.ogg": "https://files.ballistica.net/cache/ba1/f8/68/a86da4d0fa832d29d558ec3bf925",
- "assets/build/ba_data/audio/wrestlerHit1.ogg": "https://files.ballistica.net/cache/ba1/40/7e/9ed1d178e0b312277caaba2971d5",
- "assets/build/ba_data/audio/wrestlerHit2.ogg": "https://files.ballistica.net/cache/ba1/e0/66/f8b3b030d5d4d1c54d9b40959665",
- "assets/build/ba_data/audio/zoeAttack01.ogg": "https://files.ballistica.net/cache/ba1/30/ee/c1222e9cbed55980b40127ba14ff",
- "assets/build/ba_data/audio/zoeAttack02.ogg": "https://files.ballistica.net/cache/ba1/dc/fd/d6ecaa6e91b94c1a0b604bd437f4",
- "assets/build/ba_data/audio/zoeAttack03.ogg": "https://files.ballistica.net/cache/ba1/5d/9c/27defeed77fbf787151b89955d2f",
- "assets/build/ba_data/audio/zoeAttack04.ogg": "https://files.ballistica.net/cache/ba1/f8/0c/acd7cde5c8e1e01d92c63b70a644",
- "assets/build/ba_data/audio/zoeDeath01.ogg": "https://files.ballistica.net/cache/ba1/ff/90/ebfcd5852f95e6f3d2f8b6c8c9ce",
- "assets/build/ba_data/audio/zoeEff.ogg": "https://files.ballistica.net/cache/ba1/ee/0c/bfd0f105580f41ec43c14e1a4d26",
- "assets/build/ba_data/audio/zoeFall01.ogg": "https://files.ballistica.net/cache/ba1/cf/7c/9b8e9f1d0387c7fed639511c882b",
- "assets/build/ba_data/audio/zoeImpact01.ogg": "https://files.ballistica.net/cache/ba1/17/3d/7a8c78e0aa6db9aabff2851fa7f7",
- "assets/build/ba_data/audio/zoeImpact02.ogg": "https://files.ballistica.net/cache/ba1/c1/bd/b54f9081b11bd5ec04cf4a56013a",
- "assets/build/ba_data/audio/zoeImpact03.ogg": "https://files.ballistica.net/cache/ba1/02/02/008abd88c1deeac85ed88d25b42d",
- "assets/build/ba_data/audio/zoeImpact04.ogg": "https://files.ballistica.net/cache/ba1/6f/b2/a1a774e466433f0c5326ffdfcb61",
- "assets/build/ba_data/audio/zoeJump01.ogg": "https://files.ballistica.net/cache/ba1/77/9c/e0529c9dd0a732b4b195676e14f2",
- "assets/build/ba_data/audio/zoeJump02.ogg": "https://files.ballistica.net/cache/ba1/88/d8/88ec7aa0c8c1b5219658f78ac2d3",
- "assets/build/ba_data/audio/zoeJump03.ogg": "https://files.ballistica.net/cache/ba1/8c/2a/caa5b929b32aa5226d96df5733a9",
- "assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/51/eb/0a567253cc08c94c5d315a64d9af",
- "assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/bc/8f/a9c51a09c418136e386b7fdf21c7",
- "assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/02/e5/84916e123f47ccf11ddda380d699",
+ "assets/build/ba_data/audio/achievement.ogg": "https://files.ballistica.net/cache/ba1/cb/ab/2d6ea35efc25d60a438e7ece17da",
+ "assets/build/ba_data/audio/actionHero1.ogg": "https://files.ballistica.net/cache/ba1/5f/f8/e28c70dae7478253a593055b7f39",
+ "assets/build/ba_data/audio/actionHero2.ogg": "https://files.ballistica.net/cache/ba1/a2/9c/1e5d37cc7f17faa51b8687bdfbdd",
+ "assets/build/ba_data/audio/actionHero3.ogg": "https://files.ballistica.net/cache/ba1/83/c6/af98a7f49372ee42ae4e13b94c1f",
+ "assets/build/ba_data/audio/actionHero4.ogg": "https://files.ballistica.net/cache/ba1/a1/ec/92187fe07c7104ecb95d592745ce",
+ "assets/build/ba_data/audio/actionHeroDeath.ogg": "https://files.ballistica.net/cache/ba1/b5/40/f13df4e9759bf6a07d16126ad209",
+ "assets/build/ba_data/audio/actionHeroFall.ogg": "https://files.ballistica.net/cache/ba1/f6/e2/e79abf259be67f747227f703897b",
+ "assets/build/ba_data/audio/actionHeroHit1.ogg": "https://files.ballistica.net/cache/ba1/5e/86/aad0e972ecd05cbb47c0d763ed94",
+ "assets/build/ba_data/audio/actionHeroHit2.ogg": "https://files.ballistica.net/cache/ba1/b3/3b/061719631d7b6456007fee1ea7cb",
+ "assets/build/ba_data/audio/activateBeep.ogg": "https://files.ballistica.net/cache/ba1/78/5e/925c8c553299727135831a57b70e",
+ "assets/build/ba_data/audio/agent1.ogg": "https://files.ballistica.net/cache/ba1/19/73/5845fb4cb3452a4e7cc406d6c308",
+ "assets/build/ba_data/audio/agent2.ogg": "https://files.ballistica.net/cache/ba1/32/7f/ca537f1df01d03a883edd3e56465",
+ "assets/build/ba_data/audio/agent3.ogg": "https://files.ballistica.net/cache/ba1/bb/8a/543d0f44b6c7e7435f309db67830",
+ "assets/build/ba_data/audio/agent4.ogg": "https://files.ballistica.net/cache/ba1/64/f7/968ec161fbd4664dcb02adad36bc",
+ "assets/build/ba_data/audio/agentDeath.ogg": "https://files.ballistica.net/cache/ba1/cc/eb/0f4fb1f08488b983696f9bae1f04",
+ "assets/build/ba_data/audio/agentFall.ogg": "https://files.ballistica.net/cache/ba1/2b/07/8147ad5e3ed939b20917bca07ce7",
+ "assets/build/ba_data/audio/agentHit1.ogg": "https://files.ballistica.net/cache/ba1/28/97/81c2486a7f2a21b01c0c37897851",
+ "assets/build/ba_data/audio/agentHit2.ogg": "https://files.ballistica.net/cache/ba1/97/5b/68d5e47bcc38b41c1c021fca6734",
+ "assets/build/ba_data/audio/alarm.ogg": "https://files.ballistica.net/cache/ba1/e6/c7/494a9ab00fcbefcafc5708b965fc",
+ "assets/build/ba_data/audio/ali1.ogg": "https://files.ballistica.net/cache/ba1/ab/06/a5101ba8bf3ece607dcab63f638c",
+ "assets/build/ba_data/audio/ali2.ogg": "https://files.ballistica.net/cache/ba1/eb/30/68ed4470cd99fd9d5e92da402e79",
+ "assets/build/ba_data/audio/ali3.ogg": "https://files.ballistica.net/cache/ba1/08/45/fd48e69b43ad0c407a3c6538187d",
+ "assets/build/ba_data/audio/ali4.ogg": "https://files.ballistica.net/cache/ba1/64/82/010bfa0a8c42b16590fd3567a5d3",
+ "assets/build/ba_data/audio/aliDeath.ogg": "https://files.ballistica.net/cache/ba1/61/45/bc437757318afdaafcd939295257",
+ "assets/build/ba_data/audio/aliFall.ogg": "https://files.ballistica.net/cache/ba1/f1/35/4e5c372963c6151bf8c4e330add0",
+ "assets/build/ba_data/audio/aliHit1.ogg": "https://files.ballistica.net/cache/ba1/a1/bf/236d797fefd739a0faca9d43a05e",
+ "assets/build/ba_data/audio/aliHit2.ogg": "https://files.ballistica.net/cache/ba1/3e/63/dcb20edee20a60d5a1ed442aaeb9",
+ "assets/build/ba_data/audio/alien1.ogg": "https://files.ballistica.net/cache/ba1/92/1d/ecbba63b9a4f09362fdae312bbed",
+ "assets/build/ba_data/audio/alien2.ogg": "https://files.ballistica.net/cache/ba1/d6/47/894134eeb6d969d2ccd62325d54f",
+ "assets/build/ba_data/audio/alien3.ogg": "https://files.ballistica.net/cache/ba1/6d/14/2f5afc9cab01c13d4101014497cb",
+ "assets/build/ba_data/audio/alien4.ogg": "https://files.ballistica.net/cache/ba1/31/9e/cf4924e83b1295ac46e3ef6b3200",
+ "assets/build/ba_data/audio/alienDeath.ogg": "https://files.ballistica.net/cache/ba1/5c/06/88114cbc4523a6967fb068a31d72",
+ "assets/build/ba_data/audio/alienFall.ogg": "https://files.ballistica.net/cache/ba1/50/e6/19e2c76c858a0062e23630d2a739",
+ "assets/build/ba_data/audio/alienHit1.ogg": "https://files.ballistica.net/cache/ba1/67/62/a4501475b13c36e3cd20c08535a1",
+ "assets/build/ba_data/audio/alienHit2.ogg": "https://files.ballistica.net/cache/ba1/d5/c3/c4616a4d3a68a972bd53944839bb",
+ "assets/build/ba_data/audio/announceEight.ogg": "https://files.ballistica.net/cache/ba1/42/b1/e686feb97d65eaae220f8fcadfef",
+ "assets/build/ba_data/audio/announceFive.ogg": "https://files.ballistica.net/cache/ba1/61/a4/7436a7c49e99630ff02d342399c3",
+ "assets/build/ba_data/audio/announceFour.ogg": "https://files.ballistica.net/cache/ba1/3e/48/20584897c0b9a834dec42da0abbd",
+ "assets/build/ba_data/audio/announceNine.ogg": "https://files.ballistica.net/cache/ba1/53/e0/6e3980f8662d57259735a1ba74a9",
+ "assets/build/ba_data/audio/announceOne.ogg": "https://files.ballistica.net/cache/ba1/bb/d8/23d80294d743605ffe300bc13c67",
+ "assets/build/ba_data/audio/announceSeven.ogg": "https://files.ballistica.net/cache/ba1/c0/70/32a7f0985cb4da92ab54a353434f",
+ "assets/build/ba_data/audio/announceSix.ogg": "https://files.ballistica.net/cache/ba1/81/8d/a4adf86b22c56301e775eb5ec7c8",
+ "assets/build/ba_data/audio/announceTen.ogg": "https://files.ballistica.net/cache/ba1/e1/fa/14a0e93ab607c0d0cb59e44d2595",
+ "assets/build/ba_data/audio/announceThree.ogg": "https://files.ballistica.net/cache/ba1/82/c4/9a3f54546bb67069d4eb28e14bb7",
+ "assets/build/ba_data/audio/announceTwo.ogg": "https://files.ballistica.net/cache/ba1/75/41/888c7b22351424e71527792c9e2f",
+ "assets/build/ba_data/audio/assassin1.ogg": "https://files.ballistica.net/cache/ba1/65/d9/1f1d586e426935f4e42d613bf229",
+ "assets/build/ba_data/audio/assassin2.ogg": "https://files.ballistica.net/cache/ba1/c2/1a/46720c6613b6aa32ece147707383",
+ "assets/build/ba_data/audio/assassin3.ogg": "https://files.ballistica.net/cache/ba1/e9/4a/42a2dc60b7b889b8ac446fa2bca5",
+ "assets/build/ba_data/audio/assassin4.ogg": "https://files.ballistica.net/cache/ba1/56/5c/fc77452f83d8f37b2dac7d1b429c",
+ "assets/build/ba_data/audio/assassinDeath.ogg": "https://files.ballistica.net/cache/ba1/bd/ee/e9e36b97abf7b59e20bacc1cfb06",
+ "assets/build/ba_data/audio/assassinFall.ogg": "https://files.ballistica.net/cache/ba1/18/a6/613c0cdc89266d007e30942cf933",
+ "assets/build/ba_data/audio/assassinHit1.ogg": "https://files.ballistica.net/cache/ba1/ed/c5/288fd1d3b1f046e54fe15bfbb0cb",
+ "assets/build/ba_data/audio/assassinHit2.ogg": "https://files.ballistica.net/cache/ba1/9e/3a/6599fd2c5b164e323f0c1520cd54",
+ "assets/build/ba_data/audio/bear1.ogg": "https://files.ballistica.net/cache/ba1/4f/56/abd87a1509aae2fe2a916a838738",
+ "assets/build/ba_data/audio/bear2.ogg": "https://files.ballistica.net/cache/ba1/5a/79/e3d5eb66dad4419a444ef9e78800",
+ "assets/build/ba_data/audio/bear3.ogg": "https://files.ballistica.net/cache/ba1/40/41/fc30bda6d0512c65f0f5c2af5fd6",
+ "assets/build/ba_data/audio/bear4.ogg": "https://files.ballistica.net/cache/ba1/11/d0/2098ad6707eb3990ebfa247ed2aa",
+ "assets/build/ba_data/audio/bearDeath.ogg": "https://files.ballistica.net/cache/ba1/43/7d/f55df97e3ece6d0a35131a7cbd8e",
+ "assets/build/ba_data/audio/bearFall.ogg": "https://files.ballistica.net/cache/ba1/b8/11/a8fc8e12c61284355395b66f1208",
+ "assets/build/ba_data/audio/bearHit1.ogg": "https://files.ballistica.net/cache/ba1/d5/5c/401deb576e2b33abecce9f11a1c3",
+ "assets/build/ba_data/audio/bearHit2.ogg": "https://files.ballistica.net/cache/ba1/f5/c3/f9045d531d4f6a94345f23eef7c2",
+ "assets/build/ba_data/audio/bellHigh.ogg": "https://files.ballistica.net/cache/ba1/83/12/2563f760c5708d07131b62a3205c",
+ "assets/build/ba_data/audio/bellLow.ogg": "https://files.ballistica.net/cache/ba1/0c/e7/0b7f120498c93f4b73d9605a9c05",
+ "assets/build/ba_data/audio/bellMed.ogg": "https://files.ballistica.net/cache/ba1/3c/86/0ffdef9573bed45f5c02b16a0a9c",
+ "assets/build/ba_data/audio/bigImpact.ogg": "https://files.ballistica.net/cache/ba1/a9/33/6d6856fce790ea58725968ee994f",
+ "assets/build/ba_data/audio/bigImpact2.ogg": "https://files.ballistica.net/cache/ba1/79/78/c5ae3896392876024d0dc5b09abe",
+ "assets/build/ba_data/audio/blank.ogg": "https://files.ballistica.net/cache/ba1/36/34/1091be93fc4de625b349619e5d4a",
+ "assets/build/ba_data/audio/blip.ogg": "https://files.ballistica.net/cache/ba1/56/6a/0a9ce1a16ff567346b5274d4d3fc",
+ "assets/build/ba_data/audio/block.ogg": "https://files.ballistica.net/cache/ba1/94/8b/612f7219dda2ab10bc915826bc1d",
+ "assets/build/ba_data/audio/bombDrop01.ogg": "https://files.ballistica.net/cache/ba1/48/e4/7059abf02828e2b2028ece33d4fb",
+ "assets/build/ba_data/audio/bombDrop02.ogg": "https://files.ballistica.net/cache/ba1/c4/09/667eaeab8e953e266b2590174392",
+ "assets/build/ba_data/audio/bombRoll01.ogg": "https://files.ballistica.net/cache/ba1/eb/e6/449ff777ac20f3dde1dbc8fcbae5",
+ "assets/build/ba_data/audio/bones1.ogg": "https://files.ballistica.net/cache/ba1/86/9e/cdc4cc56d18de403c997721c296e",
+ "assets/build/ba_data/audio/bones2.ogg": "https://files.ballistica.net/cache/ba1/a4/27/84d4389613cb70e6b6b01c518342",
+ "assets/build/ba_data/audio/bones3.ogg": "https://files.ballistica.net/cache/ba1/b5/75/8fc4027848a6b070d8a5d738c46f",
+ "assets/build/ba_data/audio/bonesDeath.ogg": "https://files.ballistica.net/cache/ba1/6b/b6/0297afbded9af2527b42229a8594",
+ "assets/build/ba_data/audio/bonesFall.ogg": "https://files.ballistica.net/cache/ba1/fc/6c/83feba62ebdefa5c54fab3e1ff7f",
+ "assets/build/ba_data/audio/boo.ogg": "https://files.ballistica.net/cache/ba1/ec/db/582d0730aa22e1f96329e7b5bd22",
+ "assets/build/ba_data/audio/boxDrop.ogg": "https://files.ballistica.net/cache/ba1/ca/32/f71e4e7c6c821fe3cedf729ceda6",
+ "assets/build/ba_data/audio/boxingBell.ogg": "https://files.ballistica.net/cache/ba1/d9/f6/a76ab7158580e9900cf8b09dc5c7",
+ "assets/build/ba_data/audio/bunny1.ogg": "https://files.ballistica.net/cache/ba1/50/3d/ded806bbb47d207d85487989ef75",
+ "assets/build/ba_data/audio/bunny2.ogg": "https://files.ballistica.net/cache/ba1/6c/0c/1a7063168bb67d625f095409ba22",
+ "assets/build/ba_data/audio/bunny3.ogg": "https://files.ballistica.net/cache/ba1/77/40/5c29e993c896e5aac26173c297bc",
+ "assets/build/ba_data/audio/bunny4.ogg": "https://files.ballistica.net/cache/ba1/b9/8e/2c3a9bc55c140bea8b22bfc74e05",
+ "assets/build/ba_data/audio/bunnyDeath.ogg": "https://files.ballistica.net/cache/ba1/c8/b6/0fae511916818a5d2e96f997bd53",
+ "assets/build/ba_data/audio/bunnyFall.ogg": "https://files.ballistica.net/cache/ba1/16/42/f56c2241fc379d4fed2e859de91d",
+ "assets/build/ba_data/audio/bunnyHit1.ogg": "https://files.ballistica.net/cache/ba1/ed/19/4c7adb273ecd9a4daeb0aa1871e7",
+ "assets/build/ba_data/audio/bunnyHit2.ogg": "https://files.ballistica.net/cache/ba1/56/46/6089ad88b17cc7b54cb8278e0827",
+ "assets/build/ba_data/audio/bunnyJump.ogg": "https://files.ballistica.net/cache/ba1/51/89/51bf0ef263ce6e93fe4952053ee2",
+ "assets/build/ba_data/audio/cashRegister.ogg": "https://files.ballistica.net/cache/ba1/19/ba/474d5016013849089bd53fea405b",
+ "assets/build/ba_data/audio/cashRegister2.ogg": "https://files.ballistica.net/cache/ba1/57/2a/9f46332bf8fec2f9edeb1e2b41d0",
+ "assets/build/ba_data/audio/charSelectMusic.ogg": "https://files.ballistica.net/cache/ba1/88/43/ca42428df5ddd98afcdc230d3bfa",
+ "assets/build/ba_data/audio/cheer.ogg": "https://files.ballistica.net/cache/ba1/88/d3/7c4a6ce953e32bea70f2a960aa2d",
+ "assets/build/ba_data/audio/click01.ogg": "https://files.ballistica.net/cache/ba1/46/52/58ade1fd15a5b105156d94435dc7",
+ "assets/build/ba_data/audio/corkPop.ogg": "https://files.ballistica.net/cache/ba1/bb/64/00f03c3c8dc1a4de948135d0313b",
+ "assets/build/ba_data/audio/cowboy1.ogg": "https://files.ballistica.net/cache/ba1/68/13/107eb7b0ab45fe2c97c20d0aa0d1",
+ "assets/build/ba_data/audio/cowboy2.ogg": "https://files.ballistica.net/cache/ba1/4e/20/d14b17a7c7dfd6c1e7a581ab7c13",
+ "assets/build/ba_data/audio/cowboy3.ogg": "https://files.ballistica.net/cache/ba1/b3/13/24d779bccb5d629232bad9561696",
+ "assets/build/ba_data/audio/cowboy4.ogg": "https://files.ballistica.net/cache/ba1/45/4f/e74a3a2af2266f6cb84634794b43",
+ "assets/build/ba_data/audio/cowboyDeath.ogg": "https://files.ballistica.net/cache/ba1/32/60/f8f038fd6051536073aeb3afc8f6",
+ "assets/build/ba_data/audio/cowboyFall.ogg": "https://files.ballistica.net/cache/ba1/1a/7b/40760c8cf56f49cfd5f50ebeb1b8",
+ "assets/build/ba_data/audio/cowboyHit1.ogg": "https://files.ballistica.net/cache/ba1/a3/11/ed69e9bf10790291bdc4ff58c4f3",
+ "assets/build/ba_data/audio/cowboyHit2.ogg": "https://files.ballistica.net/cache/ba1/c6/50/1fd260288b18d4713de3953ff80b",
+ "assets/build/ba_data/audio/crowdChant.ogg": "https://files.ballistica.net/cache/ba1/0a/84/2866198cedd9f6d6130cb1c515b4",
+ "assets/build/ba_data/audio/cyborg1.ogg": "https://files.ballistica.net/cache/ba1/e3/9f/471f6a84f196ed866b2174bf9c92",
+ "assets/build/ba_data/audio/cyborg2.ogg": "https://files.ballistica.net/cache/ba1/43/58/d8eaf3477f352bf2639f6bc69338",
+ "assets/build/ba_data/audio/cyborg3.ogg": "https://files.ballistica.net/cache/ba1/91/71/6eca76defc42c58c0e4d3a563cce",
+ "assets/build/ba_data/audio/cyborg4.ogg": "https://files.ballistica.net/cache/ba1/34/62/4ff3eafad0116710a8b43167899d",
+ "assets/build/ba_data/audio/cyborgDeath.ogg": "https://files.ballistica.net/cache/ba1/39/15/2b868c62b7e57871f86456702e2d",
+ "assets/build/ba_data/audio/cyborgFall.ogg": "https://files.ballistica.net/cache/ba1/5e/03/a97150ab79c3c5f6949f257f0d05",
+ "assets/build/ba_data/audio/cyborgHit1.ogg": "https://files.ballistica.net/cache/ba1/e9/2a/c2ceda9d5108ec9da01f6eaf6d66",
+ "assets/build/ba_data/audio/cyborgHit2.ogg": "https://files.ballistica.net/cache/ba1/f2/4a/57ca0f7f061c81894ae31240fb68",
+ "assets/build/ba_data/audio/cymbal.ogg": "https://files.ballistica.net/cache/ba1/65/3d/096ba6468a191c2bb0841715cf0e",
+ "assets/build/ba_data/audio/debrisFall.ogg": "https://files.ballistica.net/cache/ba1/c5/2c/daeea5fe8ee17bb98fccd964294d",
+ "assets/build/ba_data/audio/deek.ogg": "https://files.ballistica.net/cache/ba1/30/8c/7443bed085ff0142cf02a1212bd5",
+ "assets/build/ba_data/audio/deek2.ogg": "https://files.ballistica.net/cache/ba1/02/9b/d82898d809fcdc31536434c4664f",
+ "assets/build/ba_data/audio/ding.ogg": "https://files.ballistica.net/cache/ba1/a6/05/8d63d138a3def941a5fa3e9d29d8",
+ "assets/build/ba_data/audio/dingSmall.ogg": "https://files.ballistica.net/cache/ba1/2e/99/c2bbeb7ad34af7cdc2e5bdc0162e",
+ "assets/build/ba_data/audio/dingSmallHigh.ogg": "https://files.ballistica.net/cache/ba1/56/34/44a17eb4a03b0af4cda2635e6f9f",
+ "assets/build/ba_data/audio/dripity.ogg": "https://files.ballistica.net/cache/ba1/25/2c/a0c8f8b402266692fc619caecee9",
+ "assets/build/ba_data/audio/drumRoll.ogg": "https://files.ballistica.net/cache/ba1/77/1e/35b3f6e0536006c7cb49f0cb10df",
+ "assets/build/ba_data/audio/error.ogg": "https://files.ballistica.net/cache/ba1/66/94/51b1f7f236d527e5c6b7aaa38df4",
+ "assets/build/ba_data/audio/explosion01.ogg": "https://files.ballistica.net/cache/ba1/28/8b/354d177513c116e8ffe8c4a33170",
+ "assets/build/ba_data/audio/explosion02.ogg": "https://files.ballistica.net/cache/ba1/a2/85/7e036bfe7ca05f71d32da98c5f97",
+ "assets/build/ba_data/audio/explosion03.ogg": "https://files.ballistica.net/cache/ba1/a8/79/bece5fe5fd9f4e7d51efa1dc1456",
+ "assets/build/ba_data/audio/explosion04.ogg": "https://files.ballistica.net/cache/ba1/cb/21/360b3d69ad2f26ccba5357e39e1a",
+ "assets/build/ba_data/audio/explosion05.ogg": "https://files.ballistica.net/cache/ba1/ca/48/22180176ef8ca975c15dc0fe8fdf",
+ "assets/build/ba_data/audio/fanfare.ogg": "https://files.ballistica.net/cache/ba1/e9/08/ca9f982446c394120e8b1087c839",
+ "assets/build/ba_data/audio/flagCatcherMusic.ogg": "https://files.ballistica.net/cache/ba1/f8/4a/e6e9f5e1748fc0f5da61b4270051",
+ "assets/build/ba_data/audio/flyingMusic.ogg": "https://files.ballistica.net/cache/ba1/e0/50/ac8414bb072b1003689d2e25aa55",
+ "assets/build/ba_data/audio/foghorn.ogg": "https://files.ballistica.net/cache/ba1/ad/81/f239c532e098a75ec31b0ea1be18",
+ "assets/build/ba_data/audio/footImpact01.ogg": "https://files.ballistica.net/cache/ba1/95/cd/b1cdf4f368f181485364c4523e1f",
+ "assets/build/ba_data/audio/footImpact02.ogg": "https://files.ballistica.net/cache/ba1/cb/62/1aa42e0ffd1fc6a2d903d41c2ffb",
+ "assets/build/ba_data/audio/footImpact03.ogg": "https://files.ballistica.net/cache/ba1/1d/1e/51ac91bcb961f6919c239de832db",
+ "assets/build/ba_data/audio/forwardMarchMusic.ogg": "https://files.ballistica.net/cache/ba1/ae/e2/f126bdf270f6e0e0df8960d47e62",
+ "assets/build/ba_data/audio/freeze.ogg": "https://files.ballistica.net/cache/ba1/aa/09/04cfd6bd13c85d4fea0c1ea64cd2",
+ "assets/build/ba_data/audio/frosty01.ogg": "https://files.ballistica.net/cache/ba1/fe/2c/523d70333081c5a601754af177b8",
+ "assets/build/ba_data/audio/frosty02.ogg": "https://files.ballistica.net/cache/ba1/7e/31/ef9b158f59f7c2ba999d6e7ab659",
+ "assets/build/ba_data/audio/frosty03.ogg": "https://files.ballistica.net/cache/ba1/85/b0/c0af5d2822fb8b16d54547de91e0",
+ "assets/build/ba_data/audio/frosty04.ogg": "https://files.ballistica.net/cache/ba1/a5/30/8df73c185bfdea6ff68ade0cc1fd",
+ "assets/build/ba_data/audio/frosty05.ogg": "https://files.ballistica.net/cache/ba1/3d/51/8cdde58436dbf07811e17960530c",
+ "assets/build/ba_data/audio/frostyDeath.ogg": "https://files.ballistica.net/cache/ba1/81/4f/94d1d3a0a0d4d76ec157c9e62f5d",
+ "assets/build/ba_data/audio/frostyFall.ogg": "https://files.ballistica.net/cache/ba1/66/f1/189455d59d83befc748023eb5a3b",
+ "assets/build/ba_data/audio/frostyHit01.ogg": "https://files.ballistica.net/cache/ba1/93/b9/efb726844982629e84436eff6b70",
+ "assets/build/ba_data/audio/frostyHit02.ogg": "https://files.ballistica.net/cache/ba1/5a/c6/f5468203e6a1e7a88cd1df36cddb",
+ "assets/build/ba_data/audio/frostyHit03.ogg": "https://files.ballistica.net/cache/ba1/11/a7/10058c88ae6c0616c242a81225cb",
+ "assets/build/ba_data/audio/fuse01.ogg": "https://files.ballistica.net/cache/ba1/c0/6d/cea041c63d5e6c2673a155ef3b5e",
+ "assets/build/ba_data/audio/gladiator1.ogg": "https://files.ballistica.net/cache/ba1/83/c3/7f27a72348692a28f3d934721a70",
+ "assets/build/ba_data/audio/gladiator2.ogg": "https://files.ballistica.net/cache/ba1/c6/25/9f7e88466aa35312b576671efcb9",
+ "assets/build/ba_data/audio/gladiator3.ogg": "https://files.ballistica.net/cache/ba1/9a/a0/dd97f411483f32e9ca28e97978ec",
+ "assets/build/ba_data/audio/gladiator4.ogg": "https://files.ballistica.net/cache/ba1/e9/0b/1037b4fd77a0609a731421142069",
+ "assets/build/ba_data/audio/gladiatorDeath.ogg": "https://files.ballistica.net/cache/ba1/58/dc/242c3c9db9ea287a7ca84d4b58f2",
+ "assets/build/ba_data/audio/gladiatorFall.ogg": "https://files.ballistica.net/cache/ba1/9e/b4/e97cb15384b43bc44848a9940fcd",
+ "assets/build/ba_data/audio/gladiatorHit1.ogg": "https://files.ballistica.net/cache/ba1/78/1c/2c14f09c156faaf53bc97f1fa4ab",
+ "assets/build/ba_data/audio/gladiatorHit2.ogg": "https://files.ballistica.net/cache/ba1/ee/0b/724da82d345acce6d24f1f78aebe",
+ "assets/build/ba_data/audio/gong.ogg": "https://files.ballistica.net/cache/ba1/9d/98/fe9e5e31712035f2f821732fa426",
+ "assets/build/ba_data/audio/grandRompMusic.ogg": "https://files.ballistica.net/cache/ba1/44/9c/820f7ee74ad55c985a41824b629f",
+ "assets/build/ba_data/audio/gravelSkid.ogg": "https://files.ballistica.net/cache/ba1/91/a5/103c0131a043b57271a7dd498de1",
+ "assets/build/ba_data/audio/gunCocking.ogg": "https://files.ballistica.net/cache/ba1/8c/f3/c5e91220d25f23cbf61e8c2de65e",
+ "assets/build/ba_data/audio/healthPowerup.ogg": "https://files.ballistica.net/cache/ba1/21/79/faae5fb678a77a4514479213c5d2",
+ "assets/build/ba_data/audio/hiss.ogg": "https://files.ballistica.net/cache/ba1/14/a2/ad09926d21069aad7b56a55bb2b4",
+ "assets/build/ba_data/audio/impactHard.ogg": "https://files.ballistica.net/cache/ba1/7d/8b/8121a2270a1ba3efa3709a07c17a",
+ "assets/build/ba_data/audio/impactHard2.ogg": "https://files.ballistica.net/cache/ba1/08/1f/df44f42082c6f465ff5a78a832cc",
+ "assets/build/ba_data/audio/impactHard3.ogg": "https://files.ballistica.net/cache/ba1/78/6f/765aaeff1cd83131ebda60a2ed28",
+ "assets/build/ba_data/audio/impactMedium.ogg": "https://files.ballistica.net/cache/ba1/93/44/fc428d21a3e9a748389b78657ed0",
+ "assets/build/ba_data/audio/impactMedium2.ogg": "https://files.ballistica.net/cache/ba1/67/e0/6a5ba28a3750d940cfc85df9a48c",
+ "assets/build/ba_data/audio/jack01.ogg": "https://files.ballistica.net/cache/ba1/6f/d4/d026538f3457f2551e2b32f8a04e",
+ "assets/build/ba_data/audio/jack02.ogg": "https://files.ballistica.net/cache/ba1/b1/bb/f521552e2d5563d0002471f4934e",
+ "assets/build/ba_data/audio/jack03.ogg": "https://files.ballistica.net/cache/ba1/d8/27/5a3eb6f846ad3e1baf8f8bd180ab",
+ "assets/build/ba_data/audio/jack04.ogg": "https://files.ballistica.net/cache/ba1/82/e3/22c30cbca677c1146fd7e2a44402",
+ "assets/build/ba_data/audio/jack05.ogg": "https://files.ballistica.net/cache/ba1/03/88/f8a5726e7f05ca77fcb11458b84d",
+ "assets/build/ba_data/audio/jack06.ogg": "https://files.ballistica.net/cache/ba1/96/0f/3a11156fd9e49625ef93e5f1837c",
+ "assets/build/ba_data/audio/jackDeath01.ogg": "https://files.ballistica.net/cache/ba1/57/bd/dd8f71ede748e5b816fa0edacf2a",
+ "assets/build/ba_data/audio/jackFall01.ogg": "https://files.ballistica.net/cache/ba1/cd/da/511c6b136af4e42325894a17c974",
+ "assets/build/ba_data/audio/jackHit01.ogg": "https://files.ballistica.net/cache/ba1/10/17/301e483f642387bcf5b9a6cac455",
+ "assets/build/ba_data/audio/jackHit02.ogg": "https://files.ballistica.net/cache/ba1/a5/cb/47ed0c7ad9f1239fc0292eada391",
+ "assets/build/ba_data/audio/jackHit03.ogg": "https://files.ballistica.net/cache/ba1/bd/9c/dabb062bb53f6889324ed4e2648c",
+ "assets/build/ba_data/audio/jackHit04.ogg": "https://files.ballistica.net/cache/ba1/0f/66/4969ee4ffd6f93f8091d7e3c460e",
+ "assets/build/ba_data/audio/jackHit05.ogg": "https://files.ballistica.net/cache/ba1/e8/1e/f9df352de2a81c2c8e7cbb9d53f8",
+ "assets/build/ba_data/audio/jackHit06.ogg": "https://files.ballistica.net/cache/ba1/36/11/7f08ed2a79c0357346544a378b97",
+ "assets/build/ba_data/audio/jackHit07.ogg": "https://files.ballistica.net/cache/ba1/34/6b/d0de6621336d8a2dbac0b5a9cc64",
+ "assets/build/ba_data/audio/jumpsuit1.ogg": "https://files.ballistica.net/cache/ba1/2e/ba/5251d92cfc50e05891f49266a58c",
+ "assets/build/ba_data/audio/jumpsuit2.ogg": "https://files.ballistica.net/cache/ba1/85/ad/6318e0d7dfb4c562f0796d752ca1",
+ "assets/build/ba_data/audio/jumpsuit3.ogg": "https://files.ballistica.net/cache/ba1/b0/3a/b493714bed82a8447adcc12a305c",
+ "assets/build/ba_data/audio/jumpsuit4.ogg": "https://files.ballistica.net/cache/ba1/61/f6/07faf71815cc86137d4b0ee873bc",
+ "assets/build/ba_data/audio/jumpsuitDeath.ogg": "https://files.ballistica.net/cache/ba1/a0/ca/91cab7d426036018c9f695056001",
+ "assets/build/ba_data/audio/jumpsuitFall.ogg": "https://files.ballistica.net/cache/ba1/2e/89/3b4f798016588c8787598ba28104",
+ "assets/build/ba_data/audio/jumpsuitHit1.ogg": "https://files.ballistica.net/cache/ba1/54/3e/eabd980699d6a30c4f4a0d6ef239",
+ "assets/build/ba_data/audio/jumpsuitHit2.ogg": "https://files.ballistica.net/cache/ba1/19/e9/2077741fb34eeff9aa71f83dd098",
+ "assets/build/ba_data/audio/kronk1.ogg": "https://files.ballistica.net/cache/ba1/39/e8/d2b97739d30bb8933ee3fadbcbdd",
+ "assets/build/ba_data/audio/kronk10.ogg": "https://files.ballistica.net/cache/ba1/2e/f6/e062133061984ce247db1148235a",
+ "assets/build/ba_data/audio/kronk2.ogg": "https://files.ballistica.net/cache/ba1/59/47/70f7400429b641ff159b9be3bd68",
+ "assets/build/ba_data/audio/kronk3.ogg": "https://files.ballistica.net/cache/ba1/90/98/b950d293de08669c5eb37cf5b089",
+ "assets/build/ba_data/audio/kronk4.ogg": "https://files.ballistica.net/cache/ba1/1d/f4/47454ddc187e9c28f09b5f2641dd",
+ "assets/build/ba_data/audio/kronk5.ogg": "https://files.ballistica.net/cache/ba1/c9/0f/efacd82b0a3a1f7b7b0fa611ab24",
+ "assets/build/ba_data/audio/kronk6.ogg": "https://files.ballistica.net/cache/ba1/76/d6/8cd1f0b55d4297e24d94d86b27f9",
+ "assets/build/ba_data/audio/kronk7.ogg": "https://files.ballistica.net/cache/ba1/87/88/0cc4e3afd6735e62009a1b7a559f",
+ "assets/build/ba_data/audio/kronk8.ogg": "https://files.ballistica.net/cache/ba1/03/43/00789923ce5d554b1fc59dc29efe",
+ "assets/build/ba_data/audio/kronk9.ogg": "https://files.ballistica.net/cache/ba1/9e/37/905f2b0bf9b62aac66370e22171a",
+ "assets/build/ba_data/audio/kronkDeath.ogg": "https://files.ballistica.net/cache/ba1/84/93/9d86402d4c414f7f8e5cbb84e7da",
+ "assets/build/ba_data/audio/kronkFall.ogg": "https://files.ballistica.net/cache/ba1/39/5e/bee74f268c641c327fe574ff96a1",
+ "assets/build/ba_data/audio/laser.ogg": "https://files.ballistica.net/cache/ba1/ba/11/d9ea29ec409d58ef9933051f00a3",
+ "assets/build/ba_data/audio/laserReverse.ogg": "https://files.ballistica.net/cache/ba1/4d/6c/ea540847197dd3817f00836b7c47",
+ "assets/build/ba_data/audio/mel01.ogg": "https://files.ballistica.net/cache/ba1/65/1f/f146b8e3a3854caf4d3e9dfb5907",
+ "assets/build/ba_data/audio/mel02.ogg": "https://files.ballistica.net/cache/ba1/a9/06/26c796767095b7cae2b822807736",
+ "assets/build/ba_data/audio/mel03.ogg": "https://files.ballistica.net/cache/ba1/fe/d5/6f54d408cc2a4c9e875d72a2471f",
+ "assets/build/ba_data/audio/mel04.ogg": "https://files.ballistica.net/cache/ba1/6c/70/91661316e90b2fc681b248196415",
+ "assets/build/ba_data/audio/mel05.ogg": "https://files.ballistica.net/cache/ba1/aa/c2/2d1a6e91f87196ab0e0582b34f4d",
+ "assets/build/ba_data/audio/mel06.ogg": "https://files.ballistica.net/cache/ba1/90/a4/cfcf8f764e5e6f63869f01e9240b",
+ "assets/build/ba_data/audio/mel07.ogg": "https://files.ballistica.net/cache/ba1/1c/7b/c8a607b89f95e7c3c91a9a6dbd1f",
+ "assets/build/ba_data/audio/mel08.ogg": "https://files.ballistica.net/cache/ba1/d3/fb/ec4085912068c8890bfdaf5f6f41",
+ "assets/build/ba_data/audio/mel09.ogg": "https://files.ballistica.net/cache/ba1/99/60/1ba62554da26788072721151c7ca",
+ "assets/build/ba_data/audio/mel10.ogg": "https://files.ballistica.net/cache/ba1/38/ed/d131624611fd8bf12faf95ad1bfe",
+ "assets/build/ba_data/audio/melDeath01.ogg": "https://files.ballistica.net/cache/ba1/56/55/cfcbc1b20f357843d1efa62bdc17",
+ "assets/build/ba_data/audio/melFall01.ogg": "https://files.ballistica.net/cache/ba1/82/e6/9ce3ae6f486f6f951e97c9eac869",
+ "assets/build/ba_data/audio/menuMusic.ogg": "https://files.ballistica.net/cache/ba1/2f/04/3b69056ee929761c7e7e702db2c1",
+ "assets/build/ba_data/audio/metalHit.ogg": "https://files.ballistica.net/cache/ba1/9c/30/a61de1a6d1787ab90b2647e6d8e2",
+ "assets/build/ba_data/audio/metalSkid.ogg": "https://files.ballistica.net/cache/ba1/03/77/fe701a38c7c448b6c29c44df1be3",
+ "assets/build/ba_data/audio/ninjaAttack1.ogg": "https://files.ballistica.net/cache/ba1/3e/f6/b245f532ead500368f0b413177ce",
+ "assets/build/ba_data/audio/ninjaAttack2.ogg": "https://files.ballistica.net/cache/ba1/7b/a9/47dfcb386fc92e07af0badffebe2",
+ "assets/build/ba_data/audio/ninjaAttack3.ogg": "https://files.ballistica.net/cache/ba1/8c/87/d4e6698efa137c523ffe9843d46f",
+ "assets/build/ba_data/audio/ninjaAttack4.ogg": "https://files.ballistica.net/cache/ba1/a3/35/5f1533eef06b6e0c6d4cce1bb437",
+ "assets/build/ba_data/audio/ninjaAttack5.ogg": "https://files.ballistica.net/cache/ba1/98/72/c5ff77cf253c0141531c791b2fd3",
+ "assets/build/ba_data/audio/ninjaAttack6.ogg": "https://files.ballistica.net/cache/ba1/87/ef/abcb38ba5f79212d975f1e8660f3",
+ "assets/build/ba_data/audio/ninjaAttack7.ogg": "https://files.ballistica.net/cache/ba1/3f/6f/94d48a93f1ecb66f786c27a9fd63",
+ "assets/build/ba_data/audio/ninjaDeath1.ogg": "https://files.ballistica.net/cache/ba1/62/63/10c7a4204a1383b8330bae91af14",
+ "assets/build/ba_data/audio/ninjaFall1.ogg": "https://files.ballistica.net/cache/ba1/6d/38/7067e8b67e42646f0a3f6fea0b25",
+ "assets/build/ba_data/audio/ninjaHit1.ogg": "https://files.ballistica.net/cache/ba1/52/dd/c411d8585d17f542075f279e1b83",
+ "assets/build/ba_data/audio/ninjaHit2.ogg": "https://files.ballistica.net/cache/ba1/18/ef/e6df5814069eb171a43014dda531",
+ "assets/build/ba_data/audio/ninjaHit3.ogg": "https://files.ballistica.net/cache/ba1/8d/94/751982314c25413ab46b4ba4552c",
+ "assets/build/ba_data/audio/ninjaHit4.ogg": "https://files.ballistica.net/cache/ba1/fc/45/513450f5f446ce5c256828b79785",
+ "assets/build/ba_data/audio/ninjaHit5.ogg": "https://files.ballistica.net/cache/ba1/13/35/a55d46a38eacd2ceb43ee8703b73",
+ "assets/build/ba_data/audio/ninjaHit6.ogg": "https://files.ballistica.net/cache/ba1/49/27/eb032a6501af3b4d95d3fa1f8dda",
+ "assets/build/ba_data/audio/ninjaHit7.ogg": "https://files.ballistica.net/cache/ba1/e9/ed/bde7ba9d85250298b1002f23fa9e",
+ "assets/build/ba_data/audio/ninjaHit8.ogg": "https://files.ballistica.net/cache/ba1/c4/0f/b4be18ea1ccd5c8d9a2bc784f19f",
+ "assets/build/ba_data/audio/oldLady1.ogg": "https://files.ballistica.net/cache/ba1/01/d5/d07ff67ffc9ed9c30433a57a722d",
+ "assets/build/ba_data/audio/oldLady2.ogg": "https://files.ballistica.net/cache/ba1/8b/2a/2c0c10526294afbac2ed5fdbe7d5",
+ "assets/build/ba_data/audio/oldLady3.ogg": "https://files.ballistica.net/cache/ba1/46/d7/a64bb5019b01a9bf8a3a71c32d85",
+ "assets/build/ba_data/audio/oldLady4.ogg": "https://files.ballistica.net/cache/ba1/63/64/a98e6bbeffe5a8947a7320ca06c5",
+ "assets/build/ba_data/audio/oldLadyDeath.ogg": "https://files.ballistica.net/cache/ba1/a0/ca/4a4c7cc453d0f254dfeffdaad436",
+ "assets/build/ba_data/audio/oldLadyFall.ogg": "https://files.ballistica.net/cache/ba1/92/41/b3d47aa08e285a464ab1d81e02b5",
+ "assets/build/ba_data/audio/oldLadyHit1.ogg": "https://files.ballistica.net/cache/ba1/68/74/6019b76e9d36f153ead330ebbdee",
+ "assets/build/ba_data/audio/oldLadyHit2.ogg": "https://files.ballistica.net/cache/ba1/06/2c/3807ed553aacf8ffcac05bc334a9",
+ "assets/build/ba_data/audio/ooh.ogg": "https://files.ballistica.net/cache/ba1/75/78/b05997e40ea9327283bfbcd73d0f",
+ "assets/build/ba_data/audio/operaSinger1.ogg": "https://files.ballistica.net/cache/ba1/67/b4/456ca84157861e1a01552f1bbb60",
+ "assets/build/ba_data/audio/operaSinger2.ogg": "https://files.ballistica.net/cache/ba1/7f/e1/aa3e383b835e6caabc52e92c0f2e",
+ "assets/build/ba_data/audio/operaSinger3.ogg": "https://files.ballistica.net/cache/ba1/2f/3b/8f41cca76f4d874df8c418a14844",
+ "assets/build/ba_data/audio/operaSinger4.ogg": "https://files.ballistica.net/cache/ba1/86/0c/4814731188a54fc6829eb3d4987e",
+ "assets/build/ba_data/audio/operaSingerDeath.ogg": "https://files.ballistica.net/cache/ba1/37/6f/82c8fe95d70eccacd9c5d95f54a5",
+ "assets/build/ba_data/audio/operaSingerFall.ogg": "https://files.ballistica.net/cache/ba1/c0/d4/6f960ef15bbded8ce6f2b5b6657f",
+ "assets/build/ba_data/audio/operaSingerHit1.ogg": "https://files.ballistica.net/cache/ba1/a8/c0/c1aea91573c7902322105d920502",
+ "assets/build/ba_data/audio/operaSingerHit2.ogg": "https://files.ballistica.net/cache/ba1/6a/b8/0ed146abd109205008d9badea4ae",
+ "assets/build/ba_data/audio/orchestraHit.ogg": "https://files.ballistica.net/cache/ba1/e2/1f/ece696b97992ca2193c352f3da84",
+ "assets/build/ba_data/audio/orchestraHit2.ogg": "https://files.ballistica.net/cache/ba1/93/23/089101e0936aa2f05a3aaee20f8e",
+ "assets/build/ba_data/audio/orchestraHit3.ogg": "https://files.ballistica.net/cache/ba1/6f/88/41a8b64754c979c2f608558b2205",
+ "assets/build/ba_data/audio/orchestraHit4.ogg": "https://files.ballistica.net/cache/ba1/0d/52/4396bd2e95954bbc3c88aba19f5f",
+ "assets/build/ba_data/audio/orchestraHitBig1.ogg": "https://files.ballistica.net/cache/ba1/cb/48/deb5a4eeeaa0e9c14456f66d2fe8",
+ "assets/build/ba_data/audio/orchestraHitBig2.ogg": "https://files.ballistica.net/cache/ba1/2a/c4/84c2e64feffba098cd1dacb59c1c",
+ "assets/build/ba_data/audio/penguin1.ogg": "https://files.ballistica.net/cache/ba1/66/e4/d940e0bcae6a7a658aacf15ee8c6",
+ "assets/build/ba_data/audio/penguin2.ogg": "https://files.ballistica.net/cache/ba1/5e/dc/54dd8785e0236478f219e15a2b32",
+ "assets/build/ba_data/audio/penguin3.ogg": "https://files.ballistica.net/cache/ba1/d7/6e/fab15b63c2e8dafef1f523af9448",
+ "assets/build/ba_data/audio/penguin4.ogg": "https://files.ballistica.net/cache/ba1/df/72/f49eb5520e1275556a309925ca2f",
+ "assets/build/ba_data/audio/penguinDeath.ogg": "https://files.ballistica.net/cache/ba1/31/a8/f414335a2e8f2f7f11d927740993",
+ "assets/build/ba_data/audio/penguinFall.ogg": "https://files.ballistica.net/cache/ba1/75/ac/6673cf05b554f93e4f80e5a1f032",
+ "assets/build/ba_data/audio/penguinHit1.ogg": "https://files.ballistica.net/cache/ba1/fb/80/d751a93245e5a81f2d7122b10601",
+ "assets/build/ba_data/audio/penguinHit2.ogg": "https://files.ballistica.net/cache/ba1/37/07/219f073f5e8042795662c37e27c2",
+ "assets/build/ba_data/audio/pixie1.ogg": "https://files.ballistica.net/cache/ba1/a1/ba/0e576188e12d5d3d90dbe2ba4aa2",
+ "assets/build/ba_data/audio/pixie2.ogg": "https://files.ballistica.net/cache/ba1/a2/a8/df2edb0fcddee0a292b116f6511a",
+ "assets/build/ba_data/audio/pixie3.ogg": "https://files.ballistica.net/cache/ba1/4e/f6/74bc253a5cc01d681e4ad8d38337",
+ "assets/build/ba_data/audio/pixie4.ogg": "https://files.ballistica.net/cache/ba1/da/7e/bf6c1a336eab325cc602a1876a23",
+ "assets/build/ba_data/audio/pixieDeath.ogg": "https://files.ballistica.net/cache/ba1/62/46/e12933d09d0d589c7c122c9b8fa8",
+ "assets/build/ba_data/audio/pixieFall.ogg": "https://files.ballistica.net/cache/ba1/0c/e1/b1a37fc2a365f693aa40a756c235",
+ "assets/build/ba_data/audio/pixieHit1.ogg": "https://files.ballistica.net/cache/ba1/2a/7a/12e4dafb36ffef321e55f943a12c",
+ "assets/build/ba_data/audio/pixieHit2.ogg": "https://files.ballistica.net/cache/ba1/61/1c/93fb0399ad2bcab8ccdb5e145633",
+ "assets/build/ba_data/audio/playerDeath.ogg": "https://files.ballistica.net/cache/ba1/26/47/b948161697a84fb0bce339f3ad71",
+ "assets/build/ba_data/audio/playerLeft.ogg": "https://files.ballistica.net/cache/ba1/bc/9d/da4df4b55e84fe9eb05509049f21",
+ "assets/build/ba_data/audio/pop01.ogg": "https://files.ballistica.net/cache/ba1/e0/ca/fc13159fce5ef09dc2ad1db9645d",
+ "assets/build/ba_data/audio/powerdown01.ogg": "https://files.ballistica.net/cache/ba1/ac/8f/4d4e570ab6238879232242bd2367",
+ "assets/build/ba_data/audio/powerup01.ogg": "https://files.ballistica.net/cache/ba1/16/7a/93da7e6859d880331e589732cf70",
+ "assets/build/ba_data/audio/punch01.ogg": "https://files.ballistica.net/cache/ba1/bb/fd/85fbab1939dea07583aae2c9f43b",
+ "assets/build/ba_data/audio/punchStrong01.ogg": "https://files.ballistica.net/cache/ba1/46/1b/62bc8d51c3672bacb9c2b99ce66f",
+ "assets/build/ba_data/audio/punchStrong02.ogg": "https://files.ballistica.net/cache/ba1/d5/f7/feb06da4c021a2c81a505593a7f4",
+ "assets/build/ba_data/audio/punchSwish.ogg": "https://files.ballistica.net/cache/ba1/fa/47/9494fe756a1f7cde27deab651a2d",
+ "assets/build/ba_data/audio/punchWeak01.ogg": "https://files.ballistica.net/cache/ba1/8c/ac/4055d2df143632938148dd369b40",
+ "assets/build/ba_data/audio/raceBeep1.ogg": "https://files.ballistica.net/cache/ba1/1c/52/61b720a805db063223eb5f9572f4",
+ "assets/build/ba_data/audio/raceBeep2.ogg": "https://files.ballistica.net/cache/ba1/c7/b3/7c33fec0f23065a56d7bdd082af8",
+ "assets/build/ba_data/audio/refWhistle.ogg": "https://files.ballistica.net/cache/ba1/9b/dd/da98b9d3efafaf2655407f64a09c",
+ "assets/build/ba_data/audio/robot1.ogg": "https://files.ballistica.net/cache/ba1/c4/f3/46c5b5a8d60f1ee7e5836ddf3baf",
+ "assets/build/ba_data/audio/robot2.ogg": "https://files.ballistica.net/cache/ba1/6c/d5/59c4c07ec9edec23590bcc9aa7d7",
+ "assets/build/ba_data/audio/robot3.ogg": "https://files.ballistica.net/cache/ba1/10/98/27748b76c889f5b21def4c702341",
+ "assets/build/ba_data/audio/robot4.ogg": "https://files.ballistica.net/cache/ba1/b4/32/0bf631e348c14b41a64190e8376a",
+ "assets/build/ba_data/audio/robotDeath.ogg": "https://files.ballistica.net/cache/ba1/65/3e/599289f5b9d08d02607f26f9c42c",
+ "assets/build/ba_data/audio/robotFall.ogg": "https://files.ballistica.net/cache/ba1/35/cc/9693e8fa39a5ad2a245f3a848e72",
+ "assets/build/ba_data/audio/robotHit1.ogg": "https://files.ballistica.net/cache/ba1/70/a9/07a5eb870bfd24143a5a1dae6b11",
+ "assets/build/ba_data/audio/robotHit2.ogg": "https://files.ballistica.net/cache/ba1/60/79/490c8f75cce45183a66357d30a85",
+ "assets/build/ba_data/audio/runAwayMusic.ogg": "https://files.ballistica.net/cache/ba1/28/8d/db39413af847b4aba765938a439c",
+ "assets/build/ba_data/audio/santa01.ogg": "https://files.ballistica.net/cache/ba1/8f/25/7b73c6cb3a67dd4d0c537a8ff486",
+ "assets/build/ba_data/audio/santa02.ogg": "https://files.ballistica.net/cache/ba1/6b/62/d9acb101fc2ae91cce4ebf21dea7",
+ "assets/build/ba_data/audio/santa03.ogg": "https://files.ballistica.net/cache/ba1/1c/ed/a69839caf83c6f2678b194aa5bb2",
+ "assets/build/ba_data/audio/santa04.ogg": "https://files.ballistica.net/cache/ba1/c9/46/1e88517f694e317b2e19574c6817",
+ "assets/build/ba_data/audio/santa05.ogg": "https://files.ballistica.net/cache/ba1/90/5f/dcb00f73be3f7ba6a3d2253f29db",
+ "assets/build/ba_data/audio/santaDeath.ogg": "https://files.ballistica.net/cache/ba1/64/4b/3d8d95da0c30e91db75a67726e8b",
+ "assets/build/ba_data/audio/santaFall.ogg": "https://files.ballistica.net/cache/ba1/13/45/ec999a23e68ad3b3ca731e8ee004",
+ "assets/build/ba_data/audio/santaHit01.ogg": "https://files.ballistica.net/cache/ba1/7c/35/1c404fe708f33b7ff8fa9aaf1d6b",
+ "assets/build/ba_data/audio/santaHit02.ogg": "https://files.ballistica.net/cache/ba1/b7/7c/05b6af01635a185c7ef6eda204ef",
+ "assets/build/ba_data/audio/santaHit03.ogg": "https://files.ballistica.net/cache/ba1/bb/3a/faf8638851148f5a1c6759afd9f7",
+ "assets/build/ba_data/audio/santaHit04.ogg": "https://files.ballistica.net/cache/ba1/cd/49/6722b707a2863cb28f18e4e4d359",
+ "assets/build/ba_data/audio/scamper01.ogg": "https://files.ballistica.net/cache/ba1/3a/50/6874379b5acfa4333fac8374f388",
+ "assets/build/ba_data/audio/scaryMusic.ogg": "https://files.ballistica.net/cache/ba1/de/62/d51342211c0ee9ffc15753a0dc38",
+ "assets/build/ba_data/audio/score.ogg": "https://files.ballistica.net/cache/ba1/35/63/59c439ce3ae53448b69e65b12f01",
+ "assets/build/ba_data/audio/scoreHit01.ogg": "https://files.ballistica.net/cache/ba1/ae/3d/4817efe2e3397780a333b75df614",
+ "assets/build/ba_data/audio/scoreHit02.ogg": "https://files.ballistica.net/cache/ba1/66/3e/db8772a6b9f0fd0f56072fa1ec65",
+ "assets/build/ba_data/audio/scoreIncrease.ogg": "https://files.ballistica.net/cache/ba1/a3/d4/2d4f01ac1e805a91f2f6232a0047",
+ "assets/build/ba_data/audio/scoresEpicMusic.ogg": "https://files.ballistica.net/cache/ba1/7a/dd/61fb2430a8f22259f2cbe4558766",
+ "assets/build/ba_data/audio/shatter.ogg": "https://files.ballistica.net/cache/ba1/d1/d6/50f5299a21f7f88e35871be01342",
+ "assets/build/ba_data/audio/shieldDown.ogg": "https://files.ballistica.net/cache/ba1/4f/31/da9f37085fa732a001dc23eeb32b",
+ "assets/build/ba_data/audio/shieldHit.ogg": "https://files.ballistica.net/cache/ba1/c1/16/915c9c8ea1d8996fd8fb7d18ab9d",
+ "assets/build/ba_data/audio/shieldUp.ogg": "https://files.ballistica.net/cache/ba1/3f/d1/096975c0e59df14948b0a3595c3a",
+ "assets/build/ba_data/audio/skid01.ogg": "https://files.ballistica.net/cache/ba1/f0/f8/9e6e4378363b6e8eec747cd592e6",
+ "assets/build/ba_data/audio/slowEpicMusic.ogg": "https://files.ballistica.net/cache/ba1/e0/76/3d356a9f7c2ecf52144adcd93c3e",
+ "assets/build/ba_data/audio/sparkle01.ogg": "https://files.ballistica.net/cache/ba1/00/23/d33bc638bb97885470d8d1dceff4",
+ "assets/build/ba_data/audio/sparkle02.ogg": "https://files.ballistica.net/cache/ba1/0f/97/6bf234b8297dacb24d16f3fc6835",
+ "assets/build/ba_data/audio/sparkle03.ogg": "https://files.ballistica.net/cache/ba1/7b/67/f32943885c7b4e42cd71c950c094",
+ "assets/build/ba_data/audio/spawn.ogg": "https://files.ballistica.net/cache/ba1/5b/e1/7976a3409551f06b465dbf9afc92",
+ "assets/build/ba_data/audio/spazAttack01.ogg": "https://files.ballistica.net/cache/ba1/ce/c1/a0fe34d38d134a260e8215117be5",
+ "assets/build/ba_data/audio/spazAttack02.ogg": "https://files.ballistica.net/cache/ba1/5b/ab/f5708a7196cd7c1d3c5856c49077",
+ "assets/build/ba_data/audio/spazAttack03.ogg": "https://files.ballistica.net/cache/ba1/02/b9/ced762c00a80b55f85e5a537694d",
+ "assets/build/ba_data/audio/spazAttack04.ogg": "https://files.ballistica.net/cache/ba1/58/09/ffc7f2269fccb7d6ba3a2d4fa70e",
+ "assets/build/ba_data/audio/spazDeath01.ogg": "https://files.ballistica.net/cache/ba1/64/75/818509a97557a1efed0d391c7d26",
+ "assets/build/ba_data/audio/spazEff.ogg": "https://files.ballistica.net/cache/ba1/bd/70/525e9bd572821a59886d89790f48",
+ "assets/build/ba_data/audio/spazFall01.ogg": "https://files.ballistica.net/cache/ba1/f0/4b/07e339002b262a7ffe2427a749e3",
+ "assets/build/ba_data/audio/spazImpact01.ogg": "https://files.ballistica.net/cache/ba1/0e/33/de9d8f2006bdfba05d4daa4c43bc",
+ "assets/build/ba_data/audio/spazImpact02.ogg": "https://files.ballistica.net/cache/ba1/19/ea/6d27a7931ed4b60f6de393422046",
+ "assets/build/ba_data/audio/spazImpact03.ogg": "https://files.ballistica.net/cache/ba1/fd/7c/abc4d0b8350f263675fce1719815",
+ "assets/build/ba_data/audio/spazImpact04.ogg": "https://files.ballistica.net/cache/ba1/0e/04/29b57759816c1dc80f0fcc8dbdf5",
+ "assets/build/ba_data/audio/spazJump01.ogg": "https://files.ballistica.net/cache/ba1/0a/74/02bcb3ccfdc45548d2d6328d5762",
+ "assets/build/ba_data/audio/spazJump02.ogg": "https://files.ballistica.net/cache/ba1/87/ae/f80c533dedc2a5048c31c7fd05a5",
+ "assets/build/ba_data/audio/spazJump03.ogg": "https://files.ballistica.net/cache/ba1/8d/f3/d89923c4894e8fe5a144f3dac2b8",
+ "assets/build/ba_data/audio/spazJump04.ogg": "https://files.ballistica.net/cache/ba1/01/76/c82cf77f0ea683ced0dce96a6590",
+ "assets/build/ba_data/audio/spazOw.ogg": "https://files.ballistica.net/cache/ba1/81/30/c272cef86432c32683fff2b6a856",
+ "assets/build/ba_data/audio/spazPickup01.ogg": "https://files.ballistica.net/cache/ba1/0f/3f/49392c99fe3f5029086a1cc80d8a",
+ "assets/build/ba_data/audio/spazScream01.ogg": "https://files.ballistica.net/cache/ba1/ac/99/e38ffb38dcac926ebc9666e6687a",
+ "assets/build/ba_data/audio/splatter.ogg": "https://files.ballistica.net/cache/ba1/65/0e/95216b45b9fb7e1fcf29753a3b86",
+ "assets/build/ba_data/audio/sportsMusic.ogg": "https://files.ballistica.net/cache/ba1/b8/be/bded110195557ffeb9928f8f35a8",
+ "assets/build/ba_data/audio/stickyImpact.ogg": "https://files.ballistica.net/cache/ba1/7f/96/aca2db315454d319400d505c84bd",
+ "assets/build/ba_data/audio/superPunch.ogg": "https://files.ballistica.net/cache/ba1/68/b1/3857d2b85c2137dfc71c59d99866",
+ "assets/build/ba_data/audio/superhero1.ogg": "https://files.ballistica.net/cache/ba1/f8/be/d5ddef17888c0f0918a6ca5f9fed",
+ "assets/build/ba_data/audio/superhero2.ogg": "https://files.ballistica.net/cache/ba1/c5/88/fa61647f14b026cdb0c710a6a241",
+ "assets/build/ba_data/audio/superhero3.ogg": "https://files.ballistica.net/cache/ba1/a6/1a/a0fa6c752ceacbc3d581191bea79",
+ "assets/build/ba_data/audio/superhero4.ogg": "https://files.ballistica.net/cache/ba1/e8/a8/ffc0743aa669e24bb7c856359ff1",
+ "assets/build/ba_data/audio/superheroDeath.ogg": "https://files.ballistica.net/cache/ba1/98/00/7fa8feb367547d7b4053d6d6f8e8",
+ "assets/build/ba_data/audio/superheroFall.ogg": "https://files.ballistica.net/cache/ba1/38/46/dd8019ab7a705500e32609f8d240",
+ "assets/build/ba_data/audio/superheroHit1.ogg": "https://files.ballistica.net/cache/ba1/d7/64/04ae978100fa9d92f94b3e474c36",
+ "assets/build/ba_data/audio/superheroHit2.ogg": "https://files.ballistica.net/cache/ba1/71/d2/d20bcca0587ea75ec510b9e9d9a3",
+ "assets/build/ba_data/audio/survivalMusic.ogg": "https://files.ballistica.net/cache/ba1/7b/3a/afefd67ab21251571721a84d5631",
+ "assets/build/ba_data/audio/swip.ogg": "https://files.ballistica.net/cache/ba1/a5/fc/7c9573cac08082db1eb1a1780be6",
+ "assets/build/ba_data/audio/swip2.ogg": "https://files.ballistica.net/cache/ba1/c0/8a/f4acc9197c51dd87bdcd649979f7",
+ "assets/build/ba_data/audio/swish.ogg": "https://files.ballistica.net/cache/ba1/5e/ef/8085f12f781b3fd1a83e7a3f9ec1",
+ "assets/build/ba_data/audio/swish2.ogg": "https://files.ballistica.net/cache/ba1/e2/42/6242489e58bd314faddbc2cdfa39",
+ "assets/build/ba_data/audio/swish3.ogg": "https://files.ballistica.net/cache/ba1/cd/00/dc59c4e85428742a3219d790862c",
+ "assets/build/ba_data/audio/tap.ogg": "https://files.ballistica.net/cache/ba1/07/fb/512704ee3acc4f54dbbe335a0b92",
+ "assets/build/ba_data/audio/technoHit01.ogg": "https://files.ballistica.net/cache/ba1/d7/3d/c4bd72b56365c29c2ff7eaf237f7",
+ "assets/build/ba_data/audio/tick.ogg": "https://files.ballistica.net/cache/ba1/e7/22/9effe1e9201ca6f787536c2cc7d1",
+ "assets/build/ba_data/audio/ticking.ogg": "https://files.ballistica.net/cache/ba1/01/a7/e9b2614da4bb07ad3c54d81284fe",
+ "assets/build/ba_data/audio/tickingCrazy.ogg": "https://files.ballistica.net/cache/ba1/49/b1/b258d1b3223fe5c2f76590cc3609",
+ "assets/build/ba_data/audio/toTheDeathMusic.ogg": "https://files.ballistica.net/cache/ba1/5d/e8/f388a2c241952af159074ec131a5",
+ "assets/build/ba_data/audio/trashRummage.ogg": "https://files.ballistica.net/cache/ba1/9e/a1/56f36558300299849f1141bbe313",
+ "assets/build/ba_data/audio/victoryMusic.ogg": "https://files.ballistica.net/cache/ba1/b4/3f/3910943af8d4303708c75cbfe621",
+ "assets/build/ba_data/audio/warnBeep.ogg": "https://files.ballistica.net/cache/ba1/dc/37/9c623b28aae041ca19ba3dc97f6a",
+ "assets/build/ba_data/audio/warnBeeps.ogg": "https://files.ballistica.net/cache/ba1/0e/6c/ebc2183df283709a0198849fd7c0",
+ "assets/build/ba_data/audio/warrior1.ogg": "https://files.ballistica.net/cache/ba1/ab/cb/7e57a8733470f535142015d9582f",
+ "assets/build/ba_data/audio/warrior2.ogg": "https://files.ballistica.net/cache/ba1/35/65/14173145d20eaf84ad79fb0f1a69",
+ "assets/build/ba_data/audio/warrior3.ogg": "https://files.ballistica.net/cache/ba1/6a/1c/4d238beefe81e1d44ef8eef00de0",
+ "assets/build/ba_data/audio/warrior4.ogg": "https://files.ballistica.net/cache/ba1/42/07/6d08d278a613b85adde7b2c8b8f8",
+ "assets/build/ba_data/audio/warriorDeath.ogg": "https://files.ballistica.net/cache/ba1/62/18/36598cfbd77bc73bd6bb4b02d0ac",
+ "assets/build/ba_data/audio/warriorFall.ogg": "https://files.ballistica.net/cache/ba1/b8/f1/c9b2fe1babd2c3027c2c6b955dd9",
+ "assets/build/ba_data/audio/warriorHit1.ogg": "https://files.ballistica.net/cache/ba1/66/88/1aba5328534ea0b21db7e0c35a97",
+ "assets/build/ba_data/audio/warriorHit2.ogg": "https://files.ballistica.net/cache/ba1/ff/cb/42e31f8089ad1760d7aad0d7ff7f",
+ "assets/build/ba_data/audio/whenJohnnyComesMarchingHomeMusic.ogg": "https://files.ballistica.net/cache/ba1/fb/d9/c6925d7fb50c229857c172478bc3",
+ "assets/build/ba_data/audio/witch1.ogg": "https://files.ballistica.net/cache/ba1/5c/03/9f32f1bda3d7fa8ded269938d5fc",
+ "assets/build/ba_data/audio/witch2.ogg": "https://files.ballistica.net/cache/ba1/3e/f6/3eb43eba873b08f776cd54ba4ecd",
+ "assets/build/ba_data/audio/witch3.ogg": "https://files.ballistica.net/cache/ba1/fc/38/74f15bb395ecd29b50ef4cacbd3b",
+ "assets/build/ba_data/audio/witch4.ogg": "https://files.ballistica.net/cache/ba1/8f/71/87c58a955524e8f7ef19db0b2c19",
+ "assets/build/ba_data/audio/witchDeath.ogg": "https://files.ballistica.net/cache/ba1/63/b7/34d892c9382fcca2143f47360681",
+ "assets/build/ba_data/audio/witchFall.ogg": "https://files.ballistica.net/cache/ba1/ca/21/e880c95abe23462d31027d396d17",
+ "assets/build/ba_data/audio/witchHit1.ogg": "https://files.ballistica.net/cache/ba1/28/ff/eda01ba4d712ed8f87ad41689eb5",
+ "assets/build/ba_data/audio/witchHit2.ogg": "https://files.ballistica.net/cache/ba1/7f/9b/9ae61751b71e2b3cd6e380351afe",
+ "assets/build/ba_data/audio/wizard1.ogg": "https://files.ballistica.net/cache/ba1/1d/54/3f31d7d0e0b9c0e7514c8d7b07d6",
+ "assets/build/ba_data/audio/wizard2.ogg": "https://files.ballistica.net/cache/ba1/fc/c5/02945950ad0e595983b9042a7fd2",
+ "assets/build/ba_data/audio/wizard3.ogg": "https://files.ballistica.net/cache/ba1/c3/64/86c3743515757904d14676aecadc",
+ "assets/build/ba_data/audio/wizard4.ogg": "https://files.ballistica.net/cache/ba1/92/6e/1274261230bf73320ac2b8ea32a2",
+ "assets/build/ba_data/audio/wizardDeath.ogg": "https://files.ballistica.net/cache/ba1/68/57/dfa7a98252eeb017ba95022a71d4",
+ "assets/build/ba_data/audio/wizardFall.ogg": "https://files.ballistica.net/cache/ba1/72/7a/6fd39cfd7cf25152fe267061b6e6",
+ "assets/build/ba_data/audio/wizardHit1.ogg": "https://files.ballistica.net/cache/ba1/d8/47/8774de373d5716f8b1def11323ba",
+ "assets/build/ba_data/audio/wizardHit2.ogg": "https://files.ballistica.net/cache/ba1/6e/6d/14771c5d4885856765fef6668ffd",
+ "assets/build/ba_data/audio/woodDebrisFall.ogg": "https://files.ballistica.net/cache/ba1/79/94/e96e205a438bcf6ea27829616eeb",
+ "assets/build/ba_data/audio/wrestler1.ogg": "https://files.ballistica.net/cache/ba1/2f/eb/15d8e43ce34d1677a25bf3870d30",
+ "assets/build/ba_data/audio/wrestler2.ogg": "https://files.ballistica.net/cache/ba1/f7/9a/27c12f0bc136c5ff5d361a855b54",
+ "assets/build/ba_data/audio/wrestler3.ogg": "https://files.ballistica.net/cache/ba1/22/cb/8a7866702f5e95d18287ad3a3b3c",
+ "assets/build/ba_data/audio/wrestler4.ogg": "https://files.ballistica.net/cache/ba1/08/9f/90516d853c3e62ecd1dcb1d9f0ff",
+ "assets/build/ba_data/audio/wrestlerDeath.ogg": "https://files.ballistica.net/cache/ba1/dc/a6/8019396b91152cab4db09b0a7ec8",
+ "assets/build/ba_data/audio/wrestlerFall.ogg": "https://files.ballistica.net/cache/ba1/49/16/5edc44a1ed74fc37b57c7473b1f6",
+ "assets/build/ba_data/audio/wrestlerHit1.ogg": "https://files.ballistica.net/cache/ba1/3d/fb/6b2741940714e3f5a564c55ccd6a",
+ "assets/build/ba_data/audio/wrestlerHit2.ogg": "https://files.ballistica.net/cache/ba1/4c/e3/f398eae1b37afbec4b42cd88c90c",
+ "assets/build/ba_data/audio/zoeAttack01.ogg": "https://files.ballistica.net/cache/ba1/37/04/5a12395a7df33f69d7bf874f5054",
+ "assets/build/ba_data/audio/zoeAttack02.ogg": "https://files.ballistica.net/cache/ba1/f3/b1/ac78bfad015ebdc003a408bbf1c0",
+ "assets/build/ba_data/audio/zoeAttack03.ogg": "https://files.ballistica.net/cache/ba1/97/e0/2f82bf9878472e3e5535a29f6b57",
+ "assets/build/ba_data/audio/zoeAttack04.ogg": "https://files.ballistica.net/cache/ba1/2c/6a/da814c4c37dd0b5d0ff3564be852",
+ "assets/build/ba_data/audio/zoeDeath01.ogg": "https://files.ballistica.net/cache/ba1/0b/3f/d197dafe8e65bc9ad44227d76595",
+ "assets/build/ba_data/audio/zoeEff.ogg": "https://files.ballistica.net/cache/ba1/d0/63/161c6c7139a432a6548657b2cbf8",
+ "assets/build/ba_data/audio/zoeFall01.ogg": "https://files.ballistica.net/cache/ba1/15/9b/bc1e6d1c46984ff15d8c7c7be553",
+ "assets/build/ba_data/audio/zoeImpact01.ogg": "https://files.ballistica.net/cache/ba1/14/ab/e0ffc2ee28d047d498e7fb9d9a29",
+ "assets/build/ba_data/audio/zoeImpact02.ogg": "https://files.ballistica.net/cache/ba1/46/06/2da242b286aa30d24b1a51c48a1e",
+ "assets/build/ba_data/audio/zoeImpact03.ogg": "https://files.ballistica.net/cache/ba1/5b/bb/820dca4d4ebd863aacab96739197",
+ "assets/build/ba_data/audio/zoeImpact04.ogg": "https://files.ballistica.net/cache/ba1/2e/83/10966e237b36b0726962e7511789",
+ "assets/build/ba_data/audio/zoeJump01.ogg": "https://files.ballistica.net/cache/ba1/51/b0/6912fe028eb5e787e8b2e2451aa7",
+ "assets/build/ba_data/audio/zoeJump02.ogg": "https://files.ballistica.net/cache/ba1/ad/3b/1ea95c528b3ac203407c71b6c250",
+ "assets/build/ba_data/audio/zoeJump03.ogg": "https://files.ballistica.net/cache/ba1/88/2d/857625213c26f31ca53625228ac5",
+ "assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/dc/d2/160fc27fcaff10793327ac2c70fd",
+ "assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/11/7a/87d6bca0acfb877fd4fd8fe3a598",
+ "assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/44/f5/c943c9075abb3e1835d2408a1ef8",
"assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/bd/6c/48e30d0a2215958f8ae1a02805ba",
"assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/ca/75/3de74bd6e498113b99bbf9eda645",
"assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/61/03/89070ca765e06da3a419a579f503",
@@ -3971,50 +3971,50 @@
"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/b2/e5/0ee0561e16257a32830645239f34",
"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/e3/10/a6410ca04d9706646f77a1cd7432",
- "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/bc/98/4799abf4b7a56390650e0ca0d9bd",
- "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/bf/bf/1604a0293c5aa2e37911e1658771",
- "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e3/00/fdddb7b085d10b9020b5e8a37737",
- "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/dd/9b/61f2a773d305b6603751192a9f6f",
- "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/cb/05/0e0d5b65658f56a1ce846ebf6223",
- "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/61/54/dd0fda3db51d62eafb1c98e36354",
- "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/07/31/fabf6c99c51347995a08934544dc",
- "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/dd/5b/c4e30243c2529ab39b6fb8350715",
- "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/c2/d6/781e395d0f179b6045d4d258a174",
- "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/59/52/9f6df32262d8326b4f037dce4292",
- "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/c0/74/4dcbb152ff5e166508d2fd8f6f33",
- "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/5f/e3/afcebfc9365c48aa5b8c655283cf",
- "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/ac/5c/c008db9992e94225a2ba7b19533f",
- "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/05/38/45d4fe4fbc9fe8c5a127c06a8503",
- "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/ef/11/4fc7f1c786d282369326544cfbe0",
- "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/36/3e/5f3e0e88a2f645de91e6e748257a",
- "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/d5/78/d15d84ccbf8d44879b240aea0bd7",
- "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/bd/57/57a27ee6a9b9234861ef0ae5bdda",
- "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/04/80/c4abe4921c9dca83d764358d45f8",
- "build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/cb/a6/560fb2889c78f14c47f7185b284c",
- "build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/24/29/b23ac44bf2a76fcb5b4920d76bb2",
- "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/2e/1e/d4b6c04e0f7f277487bc21d88c44",
- "build/prefab/lib/linux_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/18/87/1a907cb4fbde86c26b295da615ef",
- "build/prefab/lib/linux_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/a8/22/0215aaf316e7841a001840181f30",
- "build/prefab/lib/linux_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/5b/76/b68f63c60efb32d18bfda5ac2125",
- "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/19/01/ca0d7b90ca48daa28a8ff7b5f9e7",
- "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/c0/a7/d748772aa00b604fc43bb9ef0c21",
- "build/prefab/lib/mac_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/8b/e9/437a4918cf4d7eb6498f24a9ff16",
- "build/prefab/lib/mac_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/ae/44/9bdd10f4bb0b2fdc67bc57624872",
- "build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/12/5d/3a5acb1cbf7b390fe561aacecaaa",
- "build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/16/ae/e32e6075da6ced98216aa83c87e3",
- "build/prefab/lib/mac_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/8c/38/d67105c92308fc168e2f82eb89db",
- "build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/f5/9b/e02bc0842ec9422cdd1332928805",
- "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/91/8a/03c904a39698ba5804365bec8ae2",
- "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/44/a4/f76c3f28464408d10e0b4ba2b33c",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/46/ed/7a596e5d725752af99f08e39878b",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/c1/d7/b2368705dbbe68720c318778afc7",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/0f/4f/f12ef3415aa4339e650330cc310b",
- "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/0f/67/5fea68f30efc01d82687be7fd452",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/da/c0/0707d6d205e9dfe4f613ba672918",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/a8/e1/726ea2e9710a12bb375bd5b8c43d",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/28/4f/38d2d59336874ec660cb909aad4b",
- "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/57/9b/99e8f77f47dd8ce0272d59e990e0",
+ "build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/9a/74/282ba4731114c2d7caf25ecf7906",
+ "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/4d/8a/99168a6e43fbe1396891de359d71",
+ "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/f2/08/0662177c11669e6533cff41ca505",
+ "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/b3/34/a5ec0c985c54fb9cd715008bc326",
+ "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/e2/83/13a4b554ee947460454e81d70e3f",
+ "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/38/b4/68bb2704cfa6a061341571ae00a8",
+ "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/83/a5/8b267696cf929ad1e6d7e5e49f8b",
+ "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/f2/12/205008f75fa65c0a715198e0942c",
+ "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/28/2f/edcf3adbd3097299475e87e32084",
+ "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/3e/f5/748d921986a48c7aaf3b14569751",
+ "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/67/b5/1c678edd3720d83fa64c2ab0d83d",
+ "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a9/d0/f81e3398dd298e2b674f2036a23e",
+ "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/82/36/aeb2d725778a4ebaf7afd71f8750",
+ "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/f2/92/e5d485610ee43060e7845d0e249e",
+ "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/4b/07/e67992e54bee413fe030589b12c2",
+ "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/c1/bf/966057408787088a3e821d4795b1",
+ "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/fd/22/c43657b3bbe2f430c6de10aac8e6",
+ "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/9b/55/06822d9cc2c656736d54817be996",
+ "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/d1/26/06c368355613f179e9d6c52a1efb",
+ "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/85/0e/3f3320a08003e40633ccf772da9d",
+ "build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b9/df/bf2e825628494d81e78d4c51e5ec",
+ "build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b4/8b/bcce0396452ed6fea409a6c3f1d2",
+ "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/32/76/0c9686d9a11091fb436938d83a4c",
+ "build/prefab/lib/linux_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/5c/83/55b33e7ccbb7e63807005f3e9da5",
+ "build/prefab/lib/linux_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/c7/32/55b8a340b05da164d8d29f702915",
+ "build/prefab/lib/linux_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/21/17/1a896a26cc9e4a5fb092a6419933",
+ "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/e8/9f/382298830c87ed692c41259da3c6",
+ "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/21/69/67236ce45bcecc2a13e8d2ffa210",
+ "build/prefab/lib/mac_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/39/85/2512bb6fdb5d7aefb3a2e54200f9",
+ "build/prefab/lib/mac_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/7e/24/90c7ab793925086d951f16bc4b82",
+ "build/prefab/lib/mac_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/8e/52/edf00d7fc606b5fd28c1360c9928",
+ "build/prefab/lib/mac_arm64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/82/57/54a1fc8de56c8f1fefe6947bc355",
+ "build/prefab/lib/mac_x86_64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/4a/1e/8d6cd2d0d2bb4c19713f16b7b303",
+ "build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/9b/da/97ef92e8f6d7a834ab521eba3f1e",
+ "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/ab/81/3af07f1a43103d611774cc3600c9",
+ "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/d1/63/6a1b2d695a667df137094e13b3f8",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/63/3e/a571557b816d27f6483c6b576e9a",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/b5/fe/0270830ee87bab9e37defbe6e552",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/ee/db/dae2c2054701de01132a326299c6",
+ "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/14/bb/c35a583881212c553429f6957e0e",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/cf/d5/ace461574dad8a3ec7e06ad47cdf",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/2c/5c/bc8451a1c605130859cc08a2abe2",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/53/43/bec9c8419dbdc4caf76230d57569",
+ "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/cf/5f/a2be3b925b4b877aa86a5bd043e4",
"src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/6e/6f/004b696e9a13b083069374e4bb6a",
"src/ballistica/generated/python_embedded/bootstrap.inc": "https://files.ballistica.net/cache/ba1/d3/db/e73d4dcf1280d5f677c3cf8b47c3"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e265c137..1096815f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,4 @@
-### 1.7.0 (20578, 2022-05-28)
+### 1.7.0 (20580, 2022-05-30)
- V2 accounts are now available (woohoo!). These are called 'BombSquad Accounts' in the account section. V2 accounts communicate with a completely new server and will be the foundation for lots of new functionality in the future. However they also function as a V1 account so existing functionality should still work. Note that the new 'workspaces' feature for V2-accounts is not yet enabled in this build, but it will be in the next few builds. Also note that account types such as GameCenter and Google-Play will be 'upgraded' to V2 accounts in the future so there is no need to try this out if you use one of those. But if you use device-accounts you might want to create yourself a V2 account, since device-accounts will remain V1-only (though you can link an old device-account to a v2-enabled account if you want to keep your progress). Getting a V2 account now also gives you a chance to reserve a nice account-tag before all the good ones are taken.
- Legacy account subsystem has been renamed from `ba.app.accounts` to `ba.app.accounts_v1`
- Added `ba.app.accounts_v2` subsystem for working with V2 accounts.
@@ -15,6 +15,7 @@
- `_ba.get_account_misc_read_val()` is now `_ba.get_v1_account_misc_read_val()`
- `_ba.get_account_misc_read_val_2()` is now `_ba.get_v1_account_misc_read_val_2()`
- `_ba.get_account_ticket_count()` is now `_ba.get_v1_account_ticket_count()`
+- Exposing more sources in the public repo; namely networking stuff. I realize this probably opens up some attack vectors for hackers but also opens up options for server-owners to add their own defenses without having to wait on me. Hopefully this won't prove to be a bad idea.
### 1.6.12 (20567, 2022-05-04)
diff --git a/assets/src/ba_data/python/._ba_sources_hash b/assets/src/ba_data/python/._ba_sources_hash
index 6fcf3172..1b67a7cf 100644
--- a/assets/src/ba_data/python/._ba_sources_hash
+++ b/assets/src/ba_data/python/._ba_sources_hash
@@ -1 +1 @@
-14398100813069830297938811166218395775
\ No newline at end of file
+25124287646962522356366356681900113695
\ No newline at end of file
diff --git a/ballisticacore-cmake/CMakeLists.txt b/ballisticacore-cmake/CMakeLists.txt
index 64d88864..b0ac7fdf 100644
--- a/ballisticacore-cmake/CMakeLists.txt
+++ b/ballisticacore-cmake/CMakeLists.txt
@@ -302,11 +302,17 @@ add_executable(ballisticacore
${BA_SRC_ROOT}/ballistica/dynamics/rigid_body.h
${BA_SRC_ROOT}/ballistica/game/account.h
${BA_SRC_ROOT}/ballistica/game/client_controller_interface.h
+ ${BA_SRC_ROOT}/ballistica/game/connection/connection.cc
${BA_SRC_ROOT}/ballistica/game/connection/connection.h
+ ${BA_SRC_ROOT}/ballistica/game/connection/connection_set.cc
${BA_SRC_ROOT}/ballistica/game/connection/connection_set.h
+ ${BA_SRC_ROOT}/ballistica/game/connection/connection_to_client.cc
${BA_SRC_ROOT}/ballistica/game/connection/connection_to_client.h
+ ${BA_SRC_ROOT}/ballistica/game/connection/connection_to_client_udp.cc
${BA_SRC_ROOT}/ballistica/game/connection/connection_to_client_udp.h
+ ${BA_SRC_ROOT}/ballistica/game/connection/connection_to_host.cc
${BA_SRC_ROOT}/ballistica/game/connection/connection_to_host.h
+ ${BA_SRC_ROOT}/ballistica/game/connection/connection_to_host_udp.cc
${BA_SRC_ROOT}/ballistica/game/connection/connection_to_host_udp.h
${BA_SRC_ROOT}/ballistica/game/friend_score_set.h
${BA_SRC_ROOT}/ballistica/game/game.cc
@@ -488,8 +494,11 @@ add_executable(ballisticacore
${BA_SRC_ROOT}/ballistica/media/media.h
${BA_SRC_ROOT}/ballistica/media/media_server.cc
${BA_SRC_ROOT}/ballistica/media/media_server.h
+ ${BA_SRC_ROOT}/ballistica/networking/network_reader.cc
${BA_SRC_ROOT}/ballistica/networking/network_reader.h
+ ${BA_SRC_ROOT}/ballistica/networking/network_write_module.cc
${BA_SRC_ROOT}/ballistica/networking/network_write_module.h
+ ${BA_SRC_ROOT}/ballistica/networking/networking.cc
${BA_SRC_ROOT}/ballistica/networking/networking.h
${BA_SRC_ROOT}/ballistica/networking/networking_sys.h
${BA_SRC_ROOT}/ballistica/networking/sockaddr.cc
@@ -550,6 +559,8 @@ add_executable(ballisticacore
${BA_SRC_ROOT}/ballistica/python/methods/python_methods_input.h
${BA_SRC_ROOT}/ballistica/python/methods/python_methods_media.cc
${BA_SRC_ROOT}/ballistica/python/methods/python_methods_media.h
+ ${BA_SRC_ROOT}/ballistica/python/methods/python_methods_networking.cc
+ ${BA_SRC_ROOT}/ballistica/python/methods/python_methods_networking.h
${BA_SRC_ROOT}/ballistica/python/methods/python_methods_system.cc
${BA_SRC_ROOT}/ballistica/python/methods/python_methods_system.h
${BA_SRC_ROOT}/ballistica/python/methods/python_methods_ui.cc
diff --git a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj
index d208d02b..f5cef4ae 100644
--- a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj
+++ b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj
@@ -293,11 +293,17 @@
+
+
+
+
+
+
@@ -479,8 +485,11 @@
+
+
+
@@ -541,6 +550,8 @@
+
+
diff --git a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters
index 4644102e..baf512a4 100644
--- a/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters
+++ b/ballisticacore-windows/Generic/BallisticaCoreGeneric.vcxproj.filters
@@ -313,21 +313,39 @@
ballistica\game
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
@@ -871,12 +889,21 @@
ballistica\media
+
+ ballistica\networking
+
ballistica\networking
+
+ ballistica\networking
+
ballistica\networking
+
+ ballistica\networking
+
ballistica\networking
@@ -1057,6 +1084,12 @@
ballistica\python\methods
+
+ ballistica\python\methods
+
+
+ ballistica\python\methods
+
ballistica\python\methods
diff --git a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj
index d79838a4..dcca3aa9 100644
--- a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj
+++ b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj
@@ -288,11 +288,17 @@
+
+
+
+
+
+
@@ -474,8 +480,11 @@
+
+
+
@@ -536,6 +545,8 @@
+
+
diff --git a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters
index 4644102e..baf512a4 100644
--- a/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters
+++ b/ballisticacore-windows/Headless/BallisticaCoreHeadless.vcxproj.filters
@@ -313,21 +313,39 @@
ballistica\game
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
+
+ ballistica\game\connection
+
ballistica\game\connection
@@ -871,12 +889,21 @@
ballistica\media
+
+ ballistica\networking
+
ballistica\networking
+
+ ballistica\networking
+
ballistica\networking
+
+ ballistica\networking
+
ballistica\networking
@@ -1057,6 +1084,12 @@
ballistica\python\methods
+
+ ballistica\python\methods
+
+
+ ballistica\python\methods
+
ballistica\python\methods
diff --git a/src/ballistica/ballistica.cc b/src/ballistica/ballistica.cc
index 9e98fcd6..e45c8d64 100644
--- a/src/ballistica/ballistica.cc
+++ b/src/ballistica/ballistica.cc
@@ -21,7 +21,7 @@
namespace ballistica {
// These are set automatically via script; don't modify them here.
-const int kAppBuildNumber = 20578;
+const int kAppBuildNumber = 20580;
const char* kAppVersion = "1.7.0";
// Our standalone globals.
diff --git a/src/ballistica/ballistica.h b/src/ballistica/ballistica.h
index 699ce0c9..ea4adeaa 100644
--- a/src/ballistica/ballistica.h
+++ b/src/ballistica/ballistica.h
@@ -183,6 +183,13 @@ auto AppInternalOnGameThreadPause() -> void;
auto AppInternalDirectSendLogs(const std::string& prefix,
const std::string& suffix, bool instant,
int* result = nullptr) -> void;
+auto AppInternalClientInfoQuery(const std::string& val1,
+ const std::string& val2,
+ const std::string& val3, int build_number)
+ -> void;
+auto AppInternalCalcV1PeerHash(const std::string& peer_hash_input)
+ -> std::string;
+auto AppInternalV1SetClientInfo(JsonDict* dict) -> void;
/// Does it appear that we are a blessed build with no known user-modifications?
auto IsUnmodifiedBlessedBuild() -> bool;
diff --git a/src/ballistica/core/types.h b/src/ballistica/core/types.h
index ffceef07..9e553ce5 100644
--- a/src/ballistica/core/types.h
+++ b/src/ballistica/core/types.h
@@ -95,6 +95,7 @@ class Input;
class InputDevice;
struct JointFixedEF;
class Joystick;
+class JsonDict;
class KeyboardInput;
class Material;
class MaterialAction;
diff --git a/src/ballistica/game/connection/connection.cc b/src/ballistica/game/connection/connection.cc
new file mode 100644
index 00000000..75e87f96
--- /dev/null
+++ b/src/ballistica/game/connection/connection.cc
@@ -0,0 +1,495 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/game/connection/connection.h"
+
+#include "ballistica/generic/huffman.h"
+#include "ballistica/generic/json.h"
+#include "ballistica/generic/utils.h"
+#include "ballistica/math/vector3f.h"
+#include "ballistica/networking/networking.h"
+#include "ballistica/platform/platform.h"
+
+namespace ballistica {
+
+// How long to go without sending a state packet before
+// we send keepalives. Keepalives contain the latest ack info.
+const int kKeepaliveDelay = 100; // 1000/15
+
+// How long before an individual packet is re-sent if we haven't gotten an ack.
+const int kPacketResendTime = 100;
+
+// How old a packet must be before we prune it.
+const int kPacketPruneTime = 10000;
+
+// How long to go between pruning our packets.
+const int kPacketPruneInterval = 1000;
+
+Connection::Connection() {
+ // NOLINTNEXTLINE(cppcoreguidelines-prefer-member-initializer)
+ creation_time_ = last_average_update_time_ = GetRealTime();
+}
+
+void Connection::ProcessWaitingMessages() {
+ // Process waiting in-messages until we find one that's missing.
+ while (true) {
+ auto i = in_messages_.find(next_in_message_num_);
+ if (i == in_messages_.end()) {
+ break;
+ }
+ HandleMessagePacket(i->second.data);
+ in_messages_.erase(i);
+ next_in_message_num_++;
+
+ // Moving to a new in-message-num also resets our next-unreliable-num.
+ next_in_unreliable_message_num_ = 0;
+ }
+}
+
+void Connection::EmbedAcks(millisecs_t real_time, std::vector* data,
+ int offset) {
+ assert(data);
+
+ // Store full value for the next message num we want.
+ memcpy(data->data() + offset, &next_in_message_num_,
+ sizeof(next_in_message_num_));
+
+ // Now store a 1-byte bitfield telling which of the 8 messages following
+ // next_in_message_num_ we already have. This helps prevent redundant
+ // re-sends on the other end if we just missed one random packet, etc.
+ uint8_t extra_bits = 0;
+ uint16_t num = next_in_message_num_;
+ for (uint32_t i = 0; i < 8; i++) {
+ if (in_messages_.find(++num) != in_messages_.end()) {
+ extra_bits |= (0x01u << i);
+ }
+ }
+ (*data)[offset + 2] = extra_bits;
+ last_ack_send_time_ = real_time;
+}
+
+void Connection::HandleResends(millisecs_t real_time,
+ const std::vector& data, int offset) {
+ // Pull the next number they want.
+ uint16_t their_next_in;
+ memcpy(&their_next_in, data.data() + offset, sizeof(their_next_in));
+
+ // Along with a bit-field of which ones after that they already have..
+ // (prevents some un-necessary re-sending)
+ uint8_t extra_bits = data[offset + 2];
+
+ // Get a rough ping by looking at the previous packet and measuring its
+ // round-trip time if it has not yet been ack'ed.
+ auto test_num = static_cast(their_next_in - 1u);
+ auto j = out_messages_.find(test_num);
+ if (j != out_messages_.end()) {
+ ReliableMessageOut& msg(j->second);
+ if (!msg.acked) {
+ float smoothing = 0.95f;
+ average_ping_ =
+ smoothing * average_ping_
+ + (1.0f - smoothing)
+ * static_cast(real_time - msg.first_send_time);
+ }
+ msg.acked = true;
+ }
+
+ // Re-send up to 9 un-acked packets if it's been long enough.
+ // (their next requested plus their 8 extra-bits)
+ uint16_t num = their_next_in;
+ for (uint32_t i = 0; i < 9; i++) {
+ // If we've reached our next out-number, we havn't sent it yet so we're
+ // peachy.
+ if (num == next_out_message_num_) break;
+
+ bool they_want_this_packet;
+ if (i == 0) {
+ // They *always* want the one they're asking for.
+ they_want_this_packet = true;
+ } else {
+ they_want_this_packet = ((extra_bits & (0x01u << (i - 1))) == 0);
+ }
+
+ // If we have no record for this out-packet, it's too old; abort the
+ // connection.
+ auto j2 = out_messages_.find(num);
+ if (j2 == out_messages_.end()) {
+ Error("");
+ return;
+ }
+ ReliableMessageOut& msg(j2->second);
+
+ // Check with the actual packet for ack state (it may have been acked by
+ // another packet but not this one).
+ if (!they_want_this_packet) {
+ msg.acked = true;
+ }
+
+ // If its un-acked and older than our threshold, re-send.
+ if (!msg.acked && real_time - msg.last_send_time > msg.resend_time) {
+ msg.resend_time *= 2; // wait twice as long with each resend..
+ msg.last_send_time = real_time;
+
+ // Add our header/acks and go ahead and send this one out.
+ // 1 byte for type, 2 for packet-num, 3 for acks
+ std::vector data_out(msg.data.size() + kMessagePacketHeaderSize);
+ data_out[0] = BA_GAMEPACKET_MESSAGE;
+ memcpy(data_out.data() + 1, &num, sizeof(num));
+ EmbedAcks(real_time, &data_out, 3);
+ memcpy(&(data_out[6]), &(msg.data[0]), msg.data.size());
+ SendGamePacket(data_out);
+ resend_packet_count_++;
+ resend_bytes_out_ += data_out.size();
+ }
+ num++;
+ }
+}
+
+void Connection::HandleGamePacketCompressed(const std::vector& data) {
+ std::vector data_decompressed;
+ try {
+ data_decompressed = g_utils->huffman()->decompress(data);
+ } catch (const std::exception& e) {
+ Log(std::string("EXC in huffman decompression for packet: ") + e.what());
+
+ // Hmmm i guess lets just ignore this packet and keep on trucking?.. or
+ // should we kill the connection?
+ return;
+ }
+ bytes_in_compressed_ += data.size();
+ HandleGamePacket(data_decompressed);
+ packet_count_in_++;
+ bytes_in_ += data_decompressed.size();
+}
+
+void Connection::HandleGamePacket(const std::vector& data) {
+ // Sub-classes shouldn't let invalid messages get to us.
+ assert(!data.empty());
+
+ switch (data[0]) {
+ case BA_GAMEPACKET_KEEPALIVE: {
+ if (data.size() != 4) {
+ BA_LOG_ONCE("Error: got invalid BA_GAMEPACKET_KEEPALIVE packet.");
+ return;
+ }
+ millisecs_t real_time = GetRealTime();
+ HandleResends(real_time, data, 1);
+ break;
+ }
+
+ case BA_GAMEPACKET_MESSAGE: {
+ millisecs_t real_time = GetRealTime();
+
+ // Expect 1 byte type, 2 byte num, 3 byte acks, at least 1 byte payload.
+ if (data.size() < 7) {
+ Log("Error: Got invalid BA_PACKET_STATE packet.");
+ return;
+ }
+ uint16_t num;
+ memcpy(&num, data.data() + 1, sizeof(num));
+
+ // Run any necessary re-sends based on this guy's acks.
+ HandleResends(real_time, data, 3);
+
+ // If they're an upcoming message number this difference will be small;
+ // otherwise we can ignore them since they're in the past.
+ if (num - next_in_message_num_ > 32000) {
+ return;
+ }
+
+ // Store this packet.
+ ReliableMessageIn& msg(in_messages_[num]);
+ msg.data.resize(data.size() - 6);
+ memcpy(&(msg.data[0]), &(data[6]), msg.data.size());
+ msg.arrival_time = GetRealTime();
+
+ // Now run all in-order packets we've got.
+ ProcessWaitingMessages();
+
+ break;
+ }
+
+ case BA_GAMEPACKET_MESSAGE_UNRELIABLE: {
+ // Expect 1 byte type, 2 byte num, 2 byte unreliable-num, 3 byte acks,
+ // at least 1 byte payload.
+ if (data.size() < 9) {
+ Log("Error: Got invalid BA_PACKET_STATE_UNRELIABLE packet.");
+ return;
+ }
+ uint16_t num, num_unreliable;
+ memcpy(&num, data.data() + 1, sizeof(num));
+ memcpy(&num_unreliable, data.data() + 3, sizeof(num_unreliable));
+
+ // *ONLY* apply this if its num is the next one we're waiting for and
+ // num_unreliable is >= our next unreliable num
+ if (num == next_in_message_num_
+ && num_unreliable >= next_in_unreliable_message_num_) {
+ std::vector msg_data(data.size() - 8);
+ memcpy(&(msg_data[0]), &(data[8]), msg_data.size());
+ HandleMessagePacket(msg_data);
+ next_in_unreliable_message_num_ =
+ static_cast(num_unreliable + 1u);
+ }
+ break;
+ }
+
+ default:
+ Log("Connection got unknown packet type: "
+ + std::to_string(static_cast(data[0])));
+ break;
+ }
+}
+
+void Connection::Error(const std::string& msg) {
+ // If we've already errored, just ignore.
+ if (errored_) {
+ return;
+ }
+ errored_ = true;
+ if (!msg.empty()) {
+ ScreenMessage(msg, {1.0f, 0.0, 0.0f});
+ }
+}
+
+void Connection::SendReliableMessage(const std::vector& data) {
+ assert(!data.empty());
+
+ // If our connection is going down, silently ignore this.
+ if (connection_dying_) {
+ return;
+ }
+
+ // To allow sending messages of any size, we transparently break large
+ // messages up into BA_MESSAGE_MULTIPART messages which are transparently
+ // re-assembled on the other end.
+ if (data.size() > 480) {
+ auto data_size = static_cast(data.size());
+ uint32_t part_start = 0;
+ uint32_t part_size = 479;
+ while (true) {
+ // If this takes us to the end of the message, send a multipart-end.
+ if ((part_start + part_size) >= data_size) {
+ part_size = data_size - part_start;
+ assert(part_size > 0);
+ // 1 byte type plus data
+ std::vector part_message(1 + part_size);
+ part_message[0] = BA_MESSAGE_MULTIPART_END;
+ memcpy(&(part_message[1]), &(data[part_start]), part_size);
+ SendReliableMessage(part_message);
+ return;
+ } else {
+ std::vector part_message(1 + part_size);
+ part_message[0] = BA_MESSAGE_MULTIPART;
+ memcpy(&(part_message[1]), &(data[part_start]), part_size);
+ SendReliableMessage(part_message);
+ }
+ part_start += part_size;
+ }
+ }
+
+ uint16_t num = next_out_message_num_++;
+
+ // By incrementing reliable-message-num we reset the unreliable num.
+ next_out_unreliable_message_num_ = 0;
+
+ // Add an entry for it.
+ assert(out_messages_.find(num) == out_messages_.end());
+ ReliableMessageOut& msg(out_messages_[num]);
+
+ millisecs_t real_time = GetRealTime();
+
+ msg.data = data;
+ msg.first_send_time = msg.last_send_time = real_time;
+ msg.resend_time = kPacketResendTime;
+ msg.acked = false;
+
+ // Add our header/acks and go ahead and send this one out.
+ // 1 byte for type, 2 for packet-num, 3 for acks
+ std::vector data_out(data.size() + kMessagePacketHeaderSize);
+
+ data_out[0] = BA_GAMEPACKET_MESSAGE;
+ memcpy(data_out.data() + 1, &num, sizeof(num));
+ EmbedAcks(real_time, &data_out, 3);
+ memcpy(&(data_out[6]), &(data[0]), data.size());
+ SendGamePacket(data_out);
+}
+
+void Connection::SendUnreliableMessage(const std::vector& data) {
+ // For now we just silently drop anything bigger than our max packet size.
+ if (data.size() + 8 > kMaxPacketSize) {
+ BA_LOG_ONCE("Error: Dropping outgoing unreliable packet of size "
+ + std::to_string(data.size()) + ".");
+ return;
+ }
+
+ // If our connection is going down, silently ignore this.
+ if (connection_dying_) {
+ return;
+ }
+
+ uint16_t num = next_out_unreliable_message_num_++;
+ millisecs_t real_time = GetRealTime();
+
+ // Add our header/acks and go ahead and send this one out.
+ // 1 byte for type, 2 for packet-num, 2 for unreliable packet-num, 3 for acks.
+ std::vector data_out(data.size() + 8);
+
+ data_out[0] = BA_GAMEPACKET_MESSAGE_UNRELIABLE;
+ memcpy(data_out.data() + 1, &next_out_message_num_,
+ sizeof(next_out_message_num_));
+ memcpy(data_out.data() + 3, &num, sizeof(num));
+ EmbedAcks(real_time, &data_out, 5);
+ memcpy(&(data_out[8]), &(data[0]), data.size());
+ SendGamePacket(data_out);
+}
+
+void Connection::SendJMessage(cJSON* val) {
+ char* s = cJSON_PrintUnformatted(val);
+ auto s_len = static_cast(strlen(s));
+ std::vector msg(1u + s_len + 1u);
+ msg[0] = BA_MESSAGE_JMESSAGE;
+ memcpy(msg.data() + 1u, s, s_len + 1u);
+ free(s);
+ SendReliableMessage(msg);
+}
+
+void Connection::Update() {
+ millisecs_t real_time = GetRealTime();
+
+ // Update our averages once per second.
+ while (real_time - last_average_update_time_ > 1000) {
+ last_average_update_time_ += 1000; // Don't want this to drift.
+ last_resend_packet_count_ = resend_packet_count_;
+ last_resend_bytes_out_ = resend_bytes_out_;
+ last_bytes_out_ = bytes_out_;
+ last_bytes_out_compressed_ = bytes_out_compressed_;
+ last_packet_count_out_ = packet_count_out_;
+ last_bytes_in_ = bytes_in_;
+ last_bytes_in_compressed_ = bytes_in_compressed_;
+ last_packet_count_in_ = packet_count_in_;
+ bytes_out_ = packet_count_out_ = bytes_out_compressed_ = 0;
+ bytes_in_ = bytes_in_compressed_ = packet_count_in_ = 0;
+ resend_packet_count_ = resend_bytes_out_ = 0;
+ }
+
+ if (can_communicate() && real_time - last_ack_send_time_ > kKeepaliveDelay) {
+ // If we haven't sent anything with an ack out in a while, send along
+ // a keepalive packet (a packet containing nothing but an ack).
+
+ // 1 byte type, 2 byte next-expected, 1 byte extra-acks.
+ std::vector data(4);
+ data[0] = BA_GAMEPACKET_KEEPALIVE;
+ EmbedAcks(real_time, &data, 1);
+ SendGamePacket(data);
+ }
+
+ // Occasionally prune our in and out messages.
+ if (real_time - last_prune_time_ > kPacketPruneInterval) {
+ last_prune_time_ = real_time;
+ {
+ int prune_count = 0;
+ for (auto i = out_messages_.begin(); i != out_messages_.end();) {
+ if (real_time - i->second.first_send_time > kPacketPruneTime) {
+ auto i_next = i;
+ i_next++;
+ out_messages_.erase(i);
+ prune_count++;
+ i = i_next;
+ } else {
+ i++;
+ }
+ }
+ }
+ {
+ int prune_count = 0;
+ for (auto i = in_messages_.begin(); i != in_messages_.end();) {
+ if (real_time - i->second.arrival_time > kPacketPruneTime) {
+ auto i_next = i;
+ i_next++;
+ in_messages_.erase(i);
+ prune_count++;
+ i = i_next;
+ } else {
+ i++;
+ }
+ }
+ }
+ }
+}
+
+void Connection::HandleMessagePacket(const std::vector& buffer) {
+ switch (buffer[0]) {
+ // Re-assemble multipart messages that come in and pass them along as
+ // regular messages.
+ case BA_MESSAGE_MULTIPART:
+ case BA_MESSAGE_MULTIPART_END: {
+ if (buffer.size() > 1) {
+ // Append everything minus the type byte.
+ auto old_size = static_cast(multipart_buffer_.size());
+ multipart_buffer_.resize(old_size + (buffer.size() - 1));
+ memcpy(&(multipart_buffer_[old_size]), &(buffer[1]), buffer.size() - 1);
+ } else {
+ Log("got invalid BA_MESSAGE_MULTIPART");
+ }
+ if (buffer[0] == BA_MESSAGE_MULTIPART_END) {
+ HandleMessagePacket(multipart_buffer_);
+ multipart_buffer_.clear();
+ }
+ break;
+ }
+ default: {
+ // Let's silently ignore these since we may be adding various
+ // messages mid-protocol in a backwards-compatible way.
+ // BA_LOG_ONCE("Got unrecognized packet type:
+ // "+std::to_string(int(buffer[0])));
+ }
+ }
+}
+
+void Connection::SendGamePacket(const std::vector& data) {
+ // Don't want to call a pure-virtual SendGamePacketCompressed().
+ if (connection_dying_) {
+ return;
+ }
+
+ assert(!data.empty());
+
+ // Normally we withhold all packets until we know we speak the
+ // same language. However, DISCONNECT is a special case.
+ // (if we don't speak the same language we still need to be
+ // able to tell them to buzz off)
+ bool can_send = can_communicate();
+ if (data[0] == BA_GAMEPACKET_DISCONNECT) {
+ can_send = true;
+ }
+
+ // We aren't allowed to send anything out except handshakes until
+ // we've established that we can speak their language.
+ // If something does come through, just ignore it.
+ if (!can_send && data[0] != BA_GAMEPACKET_HANDSHAKE
+ && data[0] != BA_GAMEPACKET_HANDSHAKE_RESPONSE) {
+ if (explicit_bool(false)) {
+ BA_LOG_ONCE("SendGamePacket() called before can_communicate set ("
+ + g_platform->DemangleCXXSymbol(typeid(*this).name())
+ + " ptype " + std::to_string(static_cast(data[0]))
+ + ")");
+ }
+ return;
+ }
+
+ packet_count_out_++;
+ bytes_out_ += data.size();
+
+ // We huffman-compress gamepackets on their way out.
+ std::vector data_compressed = g_utils->huffman()->compress(data);
+
+#if kTestPacketDrops
+ if (rand() % 100 < kTestPacketDropPercent) { // NOLINT
+ return;
+ }
+#endif
+
+ bytes_out_compressed_ += data_compressed.size();
+ SendGamePacketCompressed(data_compressed);
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/game/connection/connection_set.cc b/src/ballistica/game/connection/connection_set.cc
new file mode 100644
index 00000000..fcd96868
--- /dev/null
+++ b/src/ballistica/game/connection/connection_set.cc
@@ -0,0 +1,707 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/game/connection/connection_set.h"
+
+#include "ballistica/game/connection/connection_to_client_udp.h"
+#include "ballistica/game/connection/connection_to_host_udp.h"
+#include "ballistica/game/game.h"
+#include "ballistica/game/player.h"
+#include "ballistica/game/session/host_session.h"
+#include "ballistica/input/device/input_device.h"
+#include "ballistica/networking/network_write_module.h"
+#include "ballistica/networking/sockaddr.h"
+#include "ballistica/python/python.h"
+#include "ballistica/python/python_sys.h"
+
+namespace ballistica {
+ConnectionSet::ConnectionSet() = default;
+
+auto ConnectionSet::GetConnectionToHostUDP() -> ConnectionToHostUDP* {
+ ConnectionToHost* h = connection_to_host_.get();
+ return h ? h->GetAsUDP() : nullptr;
+}
+
+void ConnectionSet::RegisterClientController(ClientControllerInterface* c) {
+ // This shouldn't happen, but if there's already a controller registered,
+ // detach all clients from it.
+ if (client_controller_) {
+ Log("RegisterClientController() called "
+ "but already have a controller; bad.");
+ for (auto&& i : connections_to_clients_) {
+ assert(i.second.exists());
+ i.second->SetController(nullptr);
+ }
+ }
+
+ // Ok, now assign the new and attach all currently-connected clients to it.
+ client_controller_ = c;
+ if (client_controller_) {
+ for (auto&& i : connections_to_clients_) {
+ assert(i.second.exists());
+ if (i.second->can_communicate()) {
+ i.second->SetController(client_controller_);
+ }
+ }
+ }
+}
+
+auto ConnectionSet::Update() -> void {
+ // First do housekeeping on our client/host connections.
+ for (auto&& i : connections_to_clients_) {
+ BA_IFDEBUG(Object::WeakRef test_ref(i.second));
+ i.second->Update();
+
+ // Make sure the connection didn't kill itself in the update.
+ assert(test_ref.exists());
+ }
+
+ if (connection_to_host_.exists()) {
+ connection_to_host_->Update();
+ }
+}
+
+auto ConnectionSet::GetConnectedClientCount() const -> int {
+ assert(InGameThread());
+ int count = 0;
+ for (auto&& i : connections_to_clients_) {
+ if (i.second.exists() && i.second->can_communicate()) {
+ count++;
+ }
+ }
+ return count;
+}
+
+void ConnectionSet::SendChatMessage(const std::string& message,
+ const std::vector* clients,
+ const std::string* sender_override) {
+ // Sending to particular clients is only applicable while hosting.
+ if (clients != nullptr && connection_to_host() != nullptr) {
+ throw Exception("Can't send chat message to specific clients as a client.");
+ }
+
+ // Same with overriding sender name
+ if (sender_override != nullptr && connection_to_host() != nullptr) {
+ throw Exception(
+ "Can't send chat message with sender_override as a client.");
+ }
+
+ std::string our_spec_string;
+
+ if (sender_override != nullptr) {
+ std::string override_final = *sender_override;
+ if (override_final.size() > kMaxPartyNameCombinedSize) {
+ override_final.resize(kMaxPartyNameCombinedSize);
+ override_final += "...";
+ }
+ our_spec_string =
+ PlayerSpec::GetDummyPlayerSpec(override_final).GetSpecString();
+ } else {
+ if (connection_to_host() != nullptr) {
+ // NOTE - we send our own spec string with the chat message whether we're
+ // a client or server.. however on protocol version 30+ this is ignored
+ // by the server and replaced with a spec string it generates for us.
+ // so once we know we're connected to a 30+ server we can start sending
+ // blank strings as a client.
+ // (not that it really matters; chat messages are tiny overall)
+ our_spec_string = PlayerSpec::GetAccountPlayerSpec().GetSpecString();
+ } else {
+ // As a host we want to do the equivalent of
+ // ConnectionToClient::GetCombinedSpec() except for local connections (so
+ // send our name as the combination of local players if possible). Look
+ // for players coming from this client-connection; if we find any, make a
+ // spec out of their name(s).
+ std::string p_name_combined;
+ if (auto* hs =
+ dynamic_cast(g_game->GetForegroundSession())) {
+ for (auto&& p : hs->players()) {
+ InputDevice* input_device = p->GetInputDevice();
+ if (p->accepted() && p->name_is_real() && input_device != nullptr
+ && !input_device->IsRemoteClient()) {
+ if (!p_name_combined.empty()) {
+ p_name_combined += "/";
+ }
+ p_name_combined += p->GetName();
+ }
+ }
+ }
+ if (p_name_combined.size() > kMaxPartyNameCombinedSize) {
+ p_name_combined.resize(kMaxPartyNameCombinedSize);
+ p_name_combined += "...";
+ }
+ if (!p_name_combined.empty()) {
+ our_spec_string =
+ PlayerSpec::GetDummyPlayerSpec(p_name_combined).GetSpecString();
+ } else {
+ our_spec_string = PlayerSpec::GetAccountPlayerSpec().GetSpecString();
+ }
+ }
+ }
+
+ // If we find a newline, only take the first line (prevent people from
+ // covering the screen with obnoxious chat messages).
+ std::string message2 = message;
+ size_t nlpos = message2.find('\n');
+ if (nlpos != std::string::npos) {
+ message2 = message2.substr(0, nlpos);
+ }
+
+ // If we're the host, run filters before we send the message out.
+ // If the filter kills the message, don't send.
+ bool allow_message = g_python->FilterChatMessage(&message2, -1);
+ if (!allow_message) {
+ return;
+ }
+
+ // 1 byte type + 1 byte spec-string-length + message.
+ std::vector msg_out(1 + 1 + our_spec_string.size()
+ + message2.size());
+ msg_out[0] = BA_MESSAGE_CHAT;
+ size_t spec_size = our_spec_string.size();
+ assert(spec_size < 256);
+ msg_out[1] = static_cast(spec_size);
+ memcpy(&(msg_out[2]), our_spec_string.c_str(), spec_size);
+ memcpy(&(msg_out[2 + spec_size]), message2.c_str(), message2.size());
+
+ // If we're a client, send this to the host (it will make its way back to us
+ // when they send to clients).
+ if (ConnectionToHost* hc = connection_to_host()) {
+ hc->SendReliableMessage(msg_out);
+ } else {
+ // Ok we're the host.
+
+ // Send to all (or at least some) connected clients.
+ for (auto&& i : connections_to_clients_) {
+ // Skip if its going to specific ones and this one doesn't match.
+ if (clients != nullptr) {
+ auto found = false;
+ for (auto&& c : *clients) {
+ if (c == i.second->id()) {
+ found = true;
+ }
+ }
+ if (!found) {
+ continue;
+ }
+ }
+
+ if (i.second->can_communicate()) {
+ i.second->SendReliableMessage(msg_out);
+ }
+ }
+
+ // And display locally if the message is addressed to all.
+ if (clients == nullptr) {
+ g_game->LocalDisplayChatMessage(msg_out);
+ }
+ }
+}
+
+// Can probably kill this.
+auto ConnectionSet::GetConnectionsToClients()
+ -> std::vector {
+ std::vector connections;
+ connections.reserve(connections_to_clients_.size());
+ for (auto& connections_to_client : connections_to_clients_) {
+ if (connections_to_client.second.exists()) {
+ connections.push_back(connections_to_client.second.get());
+ } else {
+ Log("HAVE NONEXISTENT CONNECTION_TO_CLIENT IN LIST; UNEXPECTED");
+ }
+ }
+ return connections;
+}
+
+void ConnectionSet::PushUDPConnectionPacketCall(
+ const std::vector& data, const SockAddr& addr) {
+ // Avoid buffer-full errors if something is causing us to write too often;
+ // these are unreliable messages so its ok to just drop them.
+ if (!g_game->CheckPushSafety()) {
+ BA_LOG_ONCE(
+ "Ignoring excessive udp-connection input packets; (could this be a "
+ "flood attack?).");
+ return;
+ }
+
+ g_game->PushCall([this, data, addr] { UDPConnectionPacket(data, addr); });
+}
+
+auto ConnectionSet::Shutdown() -> void {
+ // If we have any client/host connections, give them
+ // a chance to shoot off disconnect packets or whatnot.
+ for (auto& connection : connections_to_clients_) {
+ connection.second->RequestDisconnect();
+ }
+ if (connection_to_host_.exists()) {
+ connection_to_host_->RequestDisconnect();
+ }
+}
+
+void ConnectionSet::SendScreenMessageToClients(const std::string& s, float r,
+ float g, float b) {
+ for (auto&& i : connections_to_clients_) {
+ if (i.second.exists() && i.second->can_communicate()) {
+ i.second->SendScreenMessage(s, r, g, b);
+ }
+ }
+}
+
+void ConnectionSet::SendScreenMessageToSpecificClients(
+ const std::string& s, float r, float g, float b,
+ const std::vector& clients) {
+ for (auto&& i : connections_to_clients_) {
+ if (i.second.exists() && i.second->can_communicate()) {
+ // Only send if this client is in our list.
+ for (auto c : clients) {
+ if (c == i.second->id()) {
+ i.second->SendScreenMessage(s, r, g, b);
+ break;
+ }
+ }
+ }
+ }
+
+ // Now print locally only if -1 is in our list.
+ for (auto c : clients) {
+ if (c == -1) {
+ ScreenMessage(s, {r, g, b});
+ break;
+ }
+ }
+}
+
+void ConnectionSet::SendScreenMessageToAll(const std::string& s, float r,
+ float g, float b) {
+ SendScreenMessageToClients(s, r, g, b);
+ ScreenMessage(s, {r, g, b});
+}
+
+auto ConnectionSet::PrepareForLaunchHostSession() -> void {
+ // If for some reason we're still attached to a host, kill the connection.
+ if (connection_to_host_.exists()) {
+ Log("Had host-connection during LaunchHostSession(); shouldn't happen.");
+ connection_to_host_->RequestDisconnect();
+ connection_to_host_.Clear();
+ has_connection_to_host_ = false;
+ g_game->UpdateGameRoster();
+ }
+}
+
+auto ConnectionSet::HandleClientDisconnected(int id) -> void {
+ auto i = connections_to_clients_.find(id);
+ if (i != connections_to_clients_.end()) {
+ bool was_connected = i->second->can_communicate();
+ std::string leaver_spec = i->second->peer_spec().GetSpecString();
+ std::vector leave_msg(leaver_spec.size() + 1);
+ leave_msg[0] = BA_MESSAGE_PARTY_MEMBER_LEFT;
+ memcpy(&(leave_msg[1]), leaver_spec.c_str(), leaver_spec.size());
+ connections_to_clients_.erase(i);
+
+ // If the client was connected, they were on the roster.
+ // We need to update it and send it to all remaining clients since they're
+ // gone. Also inform everyone who just left so they can announce it
+ // (technically could consolidate these messages but whatever...).
+ if (was_connected) {
+ g_game->UpdateGameRoster();
+ for (auto&& connection : connections_to_clients_) {
+ if (g_game->ShouldAnnouncePartyJoinsAndLeaves()) {
+ connection.second->SendReliableMessage(leave_msg);
+ }
+ }
+ }
+ }
+}
+
+auto ConnectionSet::DisconnectClient(int client_id, int ban_seconds) -> bool {
+ assert(InGameThread());
+
+ if (connection_to_host_.exists()) {
+ // Kick-votes first appeared in 14248
+ if (connection_to_host_->build_number() < 14248) {
+ return false;
+ }
+ if (client_id > 255) {
+ Log("DisconnectClient got client_id > 255 (" + std::to_string(client_id)
+ + ")");
+ } else {
+ std::vector msg_out(2);
+ msg_out[0] = BA_MESSAGE_KICK_VOTE;
+ msg_out[1] = static_cast_check_fit(client_id);
+ connection_to_host_->SendReliableMessage(msg_out);
+ return true;
+ }
+ } else {
+ // No host connection - look for clients.
+ auto i = connections_to_clients_.find(client_id);
+
+ if (i != connections_to_clients_.end()) {
+ // If this is considered a kick, add an entry to our banned list so we
+ // know not to let them back in for a while.
+ if (ban_seconds > 0) {
+ g_game->BanPlayer(i->second->peer_spec(), 1000 * ban_seconds);
+ }
+ i->second->RequestDisconnect();
+
+ // Do the official local disconnect immediately with the sounds and all
+ // that.
+ PushClientDisconnectedCall(client_id);
+
+ return true;
+ }
+ }
+ return false;
+}
+
+void ConnectionSet::PushClientDisconnectedCall(int id) {
+ g_game->PushCall([this, id] { HandleClientDisconnected(id); });
+}
+
+void ConnectionSet::PushDisconnectedFromHostCall() {
+ g_game->PushCall([this] {
+ if (connection_to_host_.exists()) {
+ bool was_connected = connection_to_host_->can_communicate();
+ connection_to_host_.Clear();
+ has_connection_to_host_ = false;
+
+ // Clear out our party roster.
+ g_game->UpdateGameRoster();
+
+ // Go back to main menu *if* the connection was fully connected.
+ // Otherwise we're still probably sitting at the main menu
+ // so no need to reset it.
+ if (was_connected) {
+ g_game->RunMainMenu();
+ }
+ }
+ });
+}
+
+void ConnectionSet::PushHostConnectedUDPCall(const SockAddr& addr,
+ bool print_connect_progress) {
+ g_game->PushCall([this, addr, print_connect_progress] {
+ // Attempt to disconnect any clients we have, turn off public-party
+ // advertising, etc.
+ g_game->CleanUpBeforeConnectingToHost();
+ print_udp_connect_progress_ = print_connect_progress;
+ connection_to_host_ = Object::New(addr);
+ has_connection_to_host_ = true;
+ printed_host_disconnect_ = false;
+ });
+}
+
+void ConnectionSet::PushDisconnectFromHostCall() {
+ g_game->PushCall([this] {
+ if (connection_to_host_.exists()) {
+ connection_to_host_->RequestDisconnect();
+ }
+ });
+}
+
+auto ConnectionSet::UnregisterClientController(ClientControllerInterface* c)
+ -> void {
+ assert(c);
+
+ // This shouldn't happen.
+ if (client_controller_ != c) {
+ Log("UnregisterClientController() called with a non-registered "
+ "controller");
+ return;
+ }
+
+ // Ok, detach all our controllers from this guy.
+ if (client_controller_) {
+ for (auto&& i : connections_to_clients_) {
+ i.second->SetController(nullptr);
+ }
+ }
+ client_controller_ = nullptr;
+}
+
+void ConnectionSet::ForceDisconnectClients() {
+ for (auto&& i : connections_to_clients_) {
+ if (ConnectionToClient* client = i.second.get()) {
+ client->RequestDisconnect();
+ }
+ }
+ connections_to_clients_.clear();
+}
+
+// Called for low level packets coming in pertaining to udp
+// host/client-connections.
+auto ConnectionSet::UDPConnectionPacket(const std::vector& data_in,
+ const SockAddr& addr) -> void {
+ assert(!data_in.empty());
+
+ const uint8_t* data = &(data_in[0]);
+ auto data_size = static_cast(data_in.size());
+
+ switch (data[0]) {
+ case BA_PACKET_CLIENT_ACCEPT: {
+ if (data_size == 3) {
+ uint8_t request_id = data[2];
+
+ // If we have a udp-host-connection and its request-id matches, we're
+ // accepted; hooray!
+ ConnectionToHostUDP* hc = GetConnectionToHostUDP();
+ if (hc && hc->request_id() == request_id) {
+ hc->set_client_id(data[1]);
+ }
+ }
+ break;
+ }
+ case BA_PACKET_DISCONNECT_FROM_CLIENT_REQUEST: {
+ if (data_size == 2) {
+ // Client is telling us (host) that it wants to disconnect.
+ uint8_t client_id = data[1];
+
+ // Wipe that client out (if it still exists).
+ PushClientDisconnectedCall(client_id);
+
+ // Now send an ack so they know it's been taken care of.
+ g_network_write_module->PushSendToCall(
+ {BA_PACKET_DISCONNECT_FROM_CLIENT_ACK, client_id}, addr);
+ }
+ break;
+ }
+ case BA_PACKET_DISCONNECT_FROM_CLIENT_ACK: {
+ if (data_size == 2) {
+ // Host is telling us (client) that we've been disconnected.
+ uint8_t client_id = data[1];
+ ConnectionToHostUDP* hc = GetConnectionToHostUDP();
+ if (hc && hc->client_id() == client_id) {
+ PushDisconnectedFromHostCall();
+ }
+ }
+ break;
+ }
+ case BA_PACKET_DISCONNECT_FROM_HOST_REQUEST: {
+ if (data_size == 2) {
+ uint8_t client_id = data[1];
+
+ // Host is telling us (client) to disconnect.
+ ConnectionToHostUDP* hc = GetConnectionToHostUDP();
+ if (hc && hc->client_id() == client_id) {
+ PushDisconnectedFromHostCall();
+ }
+
+ // Now send an ack so they know it's been taken care of.
+ g_network_write_module->PushSendToCall(
+ {BA_PACKET_DISCONNECT_FROM_HOST_ACK, client_id}, addr);
+ }
+ break;
+ }
+ case BA_PACKET_DISCONNECT_FROM_HOST_ACK: {
+ break;
+ }
+ case BA_PACKET_CLIENT_GAMEPACKET_COMPRESSED: {
+ if (data_size > 2) {
+ uint8_t client_id = data[1];
+ auto i = connections_to_clients_.find(client_id);
+ if (i != connections_to_clients_.end()) {
+ // FIXME: could change HandleGamePacketCompressed to avoid this
+ // copy.
+ std::vector data2(data_size - 2);
+ memcpy(data2.data(), data + 2, data_size - 2);
+ i->second->HandleGamePacketCompressed(data2);
+ return;
+ } else {
+ // Send a disconnect request aimed at them.
+ g_network_write_module->PushSendToCall(
+ {BA_PACKET_DISCONNECT_FROM_HOST_REQUEST, client_id}, addr);
+ }
+ }
+ break;
+ }
+
+ case BA_PACKET_HOST_GAMEPACKET_COMPRESSED: {
+ if (data_size > 2) {
+ uint8_t request_id = data[1];
+
+ ConnectionToHostUDP* hc = GetConnectionToHostUDP();
+ if (hc && hc->request_id() == request_id) {
+ // FIXME: Should change HandleGamePacketCompressed to avoid this copy.
+ std::vector data2(data_size - 2);
+ memcpy(data2.data(), data + 2, data_size - 2);
+ hc->HandleGamePacketCompressed(data2);
+ }
+ }
+ break;
+ }
+
+ case BA_PACKET_CLIENT_DENY:
+ case BA_PACKET_CLIENT_DENY_PARTY_FULL:
+ case BA_PACKET_CLIENT_DENY_ALREADY_IN_PARTY:
+ case BA_PACKET_CLIENT_DENY_VERSION_MISMATCH: {
+ if (data_size == 2) {
+ uint8_t request_id = data[1];
+ ConnectionToHostUDP* hc = GetConnectionToHostUDP();
+
+ // If they're for-sure rejecting *this* connection, kill it.
+ if (hc && hc->request_id() == request_id) {
+ bool keep_trying = false;
+
+ // OBSOLETE BUT HERE FOR BACKWARDS COMPAT WITH 1.4.98 servers.
+ // Newer servers never deny us in this way and simply include
+ // their protocol version in the handshake they send us, allowing us
+ // to decide whether we support talking to them or not.
+ if (data[0] == BA_PACKET_CLIENT_DENY_VERSION_MISMATCH) {
+ // If we've got more protocols we can try, keep trying to connect
+ // with our other protocols until one works or we run out.
+ // FIXME: We should move this logic to the gamepacket or message
+ // level so it works for all connection types.
+ keep_trying = hc->SwitchProtocol();
+ if (!keep_trying) {
+ if (!printed_host_disconnect_) {
+ ScreenMessage(g_game->GetResourceString(
+ "connectionFailedVersionMismatchText"),
+ {1, 0, 0});
+ printed_host_disconnect_ = true;
+ }
+ }
+ } else if (data[0] == BA_PACKET_CLIENT_DENY_PARTY_FULL) {
+ if (!printed_host_disconnect_) {
+ if (print_udp_connect_progress_) {
+ ScreenMessage(
+ g_game->GetResourceString("connectionFailedPartyFullText"),
+ {1, 0, 0});
+ }
+ printed_host_disconnect_ = true;
+ }
+ } else if (data[0] == BA_PACKET_CLIENT_DENY_ALREADY_IN_PARTY) {
+ if (!printed_host_disconnect_) {
+ ScreenMessage(g_game->GetResourceString(
+ "connectionFailedHostAlreadyInPartyText"),
+ {1, 0, 0});
+ printed_host_disconnect_ = true;
+ }
+ } else {
+ if (!printed_host_disconnect_) {
+ ScreenMessage(g_game->GetResourceString("connectionRejectedText"),
+ {1, 0, 0});
+ printed_host_disconnect_ = true;
+ }
+ }
+ if (!keep_trying) {
+ PushDisconnectedFromHostCall();
+ }
+ }
+ }
+ break;
+ }
+ case BA_PACKET_CLIENT_REQUEST: {
+ if (data_size > 4) {
+ // Bytes 2 and 3 are their protocol ID, byte 4 is request ID, the rest
+ // is session-id.
+ uint16_t protocol_id;
+ memcpy(&protocol_id, data + 1, 2);
+ uint8_t request_id = data[3];
+
+ // They also send us their session-ID which should
+ // be completely unique to them; we can use this to lump client
+ // requests together and such.
+ std::vector client_instance_buffer(data_size - 4 + 1);
+ memcpy(&(client_instance_buffer[0]), data + 4, data_size - 4);
+ client_instance_buffer[data_size - 4] = 0; // terminate string
+ std::string client_instance_uuid = &(client_instance_buffer[0]);
+
+ if (static_cast(connections_to_clients_.size() + 1)
+ >= g_game->public_party_max_size()) {
+ // If we've reached our party size limit (including ourself in that
+ // count), reject.
+
+ // Newer version have a specific party-full message; send that first
+ // but also follow up with a generic deny message for older clients.
+ g_network_write_module->PushSendToCall(
+ {BA_PACKET_CLIENT_DENY_PARTY_FULL, request_id}, addr);
+
+ g_network_write_module->PushSendToCall(
+ {BA_PACKET_CLIENT_DENY, request_id}, addr);
+
+ } else if (connection_to_host_.exists()) {
+ // If we're connected to someone else, we can't have clients.
+ g_network_write_module->PushSendToCall(
+ {BA_PACKET_CLIENT_DENY_ALREADY_IN_PARTY, request_id}, addr);
+ } else {
+ // Otherwise go ahead and make them a new client connection.
+ Object::Ref connection_to_client;
+
+ // Go through and see if we already have a client-connection for
+ // this request-id.
+ for (auto&& i : connections_to_clients_) {
+ if (ConnectionToClientUDP* cc_udp = i.second->GetAsUDP()) {
+ if (cc_udp->client_instance_uuid() == client_instance_uuid) {
+ connection_to_client = cc_udp;
+ break;
+ }
+ }
+ }
+ if (!connection_to_client.exists()) {
+ // Create them a client object.
+ // Try to find an unused client-id in the range 0-255.
+ int client_id = 0;
+ bool found = false;
+ for (int i = 0; i < 256; i++) {
+ int test_id = (next_connection_to_client_id_ + i) % 255;
+ if (connections_to_clients_.find(test_id)
+ == connections_to_clients_.end()) {
+ client_id = test_id;
+ found = true;
+ break;
+ }
+ }
+ next_connection_to_client_id_++;
+
+ // If all 255 slots are taken (whaaaaaaa?), reject them.
+ if (!found) {
+ std::vector msg_out(2);
+ msg_out[0] = BA_PACKET_CLIENT_DENY;
+ msg_out[1] = request_id;
+ g_network_write_module->PushSendToCall(msg_out, addr);
+ Log("All client slots full; really?..");
+ break;
+ }
+ connection_to_client = Object::New(
+ addr, client_instance_uuid, request_id, client_id);
+ connections_to_clients_[client_id] = connection_to_client;
+ }
+
+ // If we got to this point, regardless of whether
+ // we already had a connection or not, tell them
+ // they're accepted.
+ std::vector msg_out(3);
+ msg_out[0] = BA_PACKET_CLIENT_ACCEPT;
+ assert(connection_to_client->id() < 256);
+ msg_out[1] =
+ static_cast_check_fit(connection_to_client->id());
+ msg_out[2] = request_id;
+ g_network_write_module->PushSendToCall(msg_out, addr);
+ }
+ }
+ break;
+ }
+ default:
+ // Assuming we can get random other noise in here;
+ // should just silently ignore.
+ break;
+ }
+}
+
+void ConnectionSet::SetClientInfoFromMasterServer(
+ const std::string& client_token, PyObject* info_obj) {
+ // NOLINTNEXTLINE (python doing bitwise math on signed int)
+ if (!PyDict_Check(info_obj)) {
+ Log("got non-dict for master-server client info for token " + client_token
+ + ": " + Python::ObjToString(info_obj));
+ return;
+ }
+ for (ConnectionToClient* client : GetConnectionsToClients()) {
+ if (client->token() == client_token) {
+ client->HandleMasterServerClientInfo(info_obj);
+
+ // Roster will now include account-id...
+ g_game->mark_game_roster_dirty();
+ break;
+ }
+ }
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/game/connection/connection_to_client.cc b/src/ballistica/game/connection/connection_to_client.cc
new file mode 100644
index 00000000..abc59536
--- /dev/null
+++ b/src/ballistica/game/connection/connection_to_client.cc
@@ -0,0 +1,762 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/game/connection/connection_to_client.h"
+
+// #include "ballistica/app/app_internal.h"
+#include "ballistica/audio/audio.h"
+#include "ballistica/game/client_controller_interface.h"
+#include "ballistica/game/connection/connection_set.h"
+#include "ballistica/game/game.h"
+#include "ballistica/game/player.h"
+#include "ballistica/game/session/host_session.h"
+#include "ballistica/generic/json.h"
+#include "ballistica/input/device/client_input_device.h"
+#include "ballistica/media/media.h"
+#include "ballistica/networking/networking.h"
+#include "ballistica/python/python.h"
+#include "ballistica/python/python_sys.h"
+
+namespace ballistica {
+
+// How long new clients have to wait before starting a kick vote.
+const int kNewClientKickVoteDelay = 60000;
+
+ConnectionToClient::ConnectionToClient(int id) : id_(id) {
+ // We calc this once just in case it changes on our end
+ // (the client uses it for their verification hash so we need to
+ // ensure it stays consistent).
+ our_handshake_player_spec_str_ =
+ PlayerSpec::GetAccountPlayerSpec().GetSpecString();
+
+ // On newer protocols we include an extra salt value
+ // to ensure the hash the client generates can't be recycled.
+ if (explicit_bool(kProtocolVersion >= 33)) {
+ our_handshake_salt_ = std::to_string(rand()); // NOLINT
+ }
+}
+
+auto ConnectionToClient::ShouldPrintIncompatibleClientErrors() const -> bool {
+ return false;
+}
+
+void ConnectionToClient::SetController(ClientControllerInterface* c) {
+ // If we had an old client-controller, inform it we're leaving it.
+ if (controller_) {
+ controller_->OnClientDisconnected(this);
+ controller_ = nullptr;
+ }
+
+ // If we've got a new one, connect it.
+ if (c) {
+ controller_ = c;
+ // We automatically push a session reset command before turning
+ // a client connection over to a new controller.
+ // The previous client may not have cleaned up after itself
+ // in cases such as truncated replays, etc.
+ SendReliableMessage(std::vector(1, BA_MESSAGE_SESSION_RESET));
+ controller_->OnClientConnected(this);
+ }
+}
+
+ConnectionToClient::~ConnectionToClient() {
+ // If we've got a controller, disconnect from it.
+ SetController(nullptr);
+
+ // If we had made any input-devices, they're just pointers that
+ // we have to pass along to g_input to delete for us.
+ for (auto&& i : client_input_devices_) {
+ g_input->RemoveInputDevice(i.second, false);
+ }
+
+ // If they had been announced as connected, announce their departure.
+ if (can_communicate() && g_game->ShouldAnnouncePartyJoinsAndLeaves()) {
+ std::string s = g_game->GetResourceString("playerLeftPartyText");
+ Utils::StringReplaceOne(&s, "${NAME}", peer_spec().GetDisplayString());
+ ScreenMessage(s, {1, 0.5f, 0.0f});
+ g_audio->PlaySound(g_media->GetSound(SystemSoundID::kCorkPop));
+ }
+}
+
+void ConnectionToClient::Update() {
+ Connection::Update(); // Handles common stuff.
+
+ millisecs_t real_time = GetRealTime();
+
+ // If we're waiting for handshake response still, keep sending out handshake
+ // attempts.
+ if (!can_communicate() && real_time - last_hand_shake_send_time_ > 1000) {
+ // In newer protocols we embed a json dict as the second part of the
+ // handshake packet; this way we can evolve the protocol more
+ // easily in the future.
+ if (explicit_bool(kProtocolVersion >= 33)) {
+ // Construct a json dict with our player-spec-string as one element.
+ JsonDict dict;
+ dict.AddString("s", our_handshake_player_spec_str_);
+
+ // We also add our random salt for hashing.
+ dict.AddString("l", our_handshake_salt_);
+
+ std::string out = dict.PrintUnformatted();
+ std::vector data(3 + out.size());
+ data[0] = BA_GAMEPACKET_HANDSHAKE;
+ uint16_t val = kProtocolVersion;
+ memcpy(data.data() + 1, &val, sizeof(val));
+ memcpy(data.data() + 3, out.c_str(), out.size());
+ SendGamePacket(data);
+ } else {
+ // (KILL THIS WHEN kProtocolVersionMin >= 33)
+ // on older protocols, we simply embedded our spec-string as the second
+ // part of the handshake packet
+ std::vector data(3 + our_handshake_player_spec_str_.size());
+ data[0] = BA_GAMEPACKET_HANDSHAKE;
+ uint16_t val = kProtocolVersion;
+ memcpy(data.data() + 1, &val, sizeof(val));
+ memcpy(data.data() + 3, our_handshake_player_spec_str_.c_str(),
+ our_handshake_player_spec_str_.size());
+ SendGamePacket(data);
+ }
+ last_hand_shake_send_time_ = real_time;
+ }
+}
+
+void ConnectionToClient::HandleGamePacket(const std::vector& data) {
+ // If we've errored, just respond to everything with 'GO AWAY!'.
+ if (errored()) {
+ std::vector data2(1);
+ data2[0] = BA_GAMEPACKET_DISCONNECT;
+ SendGamePacket(data2);
+ return;
+ }
+
+ if (data.empty()) {
+ Log("Error: ConnectionToClient got data size 0.");
+ return;
+ }
+ switch (data[0]) {
+ case BA_GAMEPACKET_HANDSHAKE_RESPONSE: {
+ // We sent the client a handshake and they're responding.
+ if (data.size() < 3) {
+ Log("got invalid BA_GAMEPACKET_HANDSHAKE_RESPONSE");
+ return;
+ }
+
+ // In newer builds we expect to be sent a json dict here;
+ // pull client's spec from that.
+ if (explicit_bool(kProtocolVersion >= 33)) {
+ std::vector string_buffer(data.size() - 3 + 1);
+ memcpy(&(string_buffer[0]), &(data[3]), data.size() - 3);
+ string_buffer[string_buffer.size() - 1] = 0;
+ cJSON* handshake = cJSON_Parse(string_buffer.data());
+ if (handshake) {
+ if (cJSON* pspec = cJSON_GetObjectItem(handshake, "s")) {
+ set_peer_spec(PlayerSpec(pspec->valuestring));
+ }
+
+ // Newer builds also send their public-device-id; servers
+ // can use this to combat simple spam attacks.
+ if (cJSON* pubdeviceid = cJSON_GetObjectItem(handshake, "d")) {
+ public_device_id_ = pubdeviceid->valuestring;
+ }
+ cJSON_Delete(handshake);
+ }
+ } else {
+ // (KILL THIS WHEN kProtocolVersionMin >= 33)
+ // older versions only contained the client spec
+ // pull client's spec from the handshake packet..
+ std::vector string_buffer(data.size() - 3 + 1);
+ memcpy(&(string_buffer[0]), &(data[3]), data.size() - 3);
+ string_buffer[string_buffer.size() - 1] = 0;
+ set_peer_spec(PlayerSpec(&(string_buffer[0])));
+ }
+ // FIXME: We should maybe set some sort of 'pending' peer-spec
+ // and fetch their actual info from the master-server.
+ // (or at least make that an option for internet servers)
+
+ // Compare this against our blocked specs.. if there's a match, reject
+ // them.
+ if (g_game->IsPlayerBanned(peer_spec())) {
+ Error("");
+ return;
+ }
+
+ // Bytes 2 and 3 are their protocol version.
+ uint16_t val;
+ memcpy(&val, data.data() + 1, sizeof(val));
+ if (val != kProtocolVersion) {
+ // Depending on the connection type we may print the connection
+ // failure or not. (If we invited them it'd be good to know about the
+ // failure).
+ std::string s;
+ if (ShouldPrintIncompatibleClientErrors()) {
+ // If they get here, announce on the host that the client is
+ // incompatible. UDP connections will get rejected during the
+ // connection attempt so this will only apply to things like Google
+ // Play invites where we probably want to be more verbose as
+ // to why the game just died.
+ s = g_game->GetResourceString("incompatibleVersionPlayerText");
+ Utils::StringReplaceOne(&s, "${NAME}",
+ peer_spec().GetDisplayString());
+ }
+ Error(s);
+ return;
+ }
+
+ // At this point we know we speak their language so we can send
+ // them things beyond handshake packets.
+ if (!can_communicate()) {
+ set_can_communicate(true);
+
+ // Don't allow fresh clients to start kick votes for a while.
+ next_kick_vote_allow_time_ = GetRealTime() + kNewClientKickVoteDelay;
+
+ // At this point we have their name, so lets announce their arrival.
+ if (g_game->ShouldAnnouncePartyJoinsAndLeaves()) {
+ std::string s = g_game->GetResourceString("playerJoinedPartyText");
+ Utils::StringReplaceOne(&s, "${NAME}",
+ peer_spec().GetDisplayString());
+ ScreenMessage(s, {0.5f, 1, 0.5f});
+ g_audio->PlaySound(g_media->GetSound(SystemSoundID::kGunCock));
+ }
+
+ // Also mark the time for flashing the 'someone just joined your
+ // party' message in the corner.
+ g_game->set_last_connection_to_client_join_time(GetRealTime());
+
+ // Added midway through protocol 29:
+ // We now send a json dict of info about ourself first thing. This
+ // gives us a nice open-ended way to expand functionality/etc. going
+ // forward. The other end will expect that this is the first reliable
+ // message they get; if something else shows up first they'll assume
+ // we're an old build and not sending this.
+ {
+ cJSON* info_dict = cJSON_CreateObject();
+ cJSON_AddItemToObject(info_dict, "b",
+ cJSON_CreateNumber(kAppBuildNumber));
+
+ // Add a name entry if we've got a public party name set.
+ if (!g_game->public_party_name().empty()) {
+ cJSON_AddItemToObject(
+ info_dict, "n",
+ cJSON_CreateString(g_game->public_party_name().c_str()));
+ }
+ std::string info = cJSON_PrintUnformatted(info_dict);
+ cJSON_Delete(info_dict);
+
+ std::vector info_msg(info.size() + 1);
+ info_msg[0] = BA_MESSAGE_HOST_INFO;
+ memcpy(&(info_msg[1]), info.c_str(), info.size());
+ SendReliableMessage(info_msg);
+ }
+
+ std::string joiner_spec = peer_spec().GetSpecString();
+ std::vector join_msg(joiner_spec.size() + 1);
+ join_msg[0] = BA_MESSAGE_PARTY_MEMBER_JOINED;
+ memcpy(&(join_msg[1]), joiner_spec.c_str(), joiner_spec.size());
+
+ for (auto&& i : g_game->connections()->connections_to_clients()) {
+ // Also send a 'party-member-joined' notification to all clients
+ // *except* the new one.
+ if (i.second.exists() && i.second.get() != this
+ && g_game->ShouldAnnouncePartyJoinsAndLeaves()) {
+ i.second->SendReliableMessage(join_msg);
+ }
+ }
+
+ // Update the game party roster and send it to all clients (including
+ // this new one).
+ g_game->UpdateGameRoster();
+
+ // Lastly, we hand this connection over to whoever is currently
+ // feeding client connections.
+ if (g_game->connections()->client_controller()) {
+ SetController(g_game->connections()->client_controller());
+ }
+ }
+ break;
+ }
+
+ default:
+ // Let our base class handle common stuff *if* we're connected.
+ if (can_communicate()) {
+ Connection::HandleGamePacket(data);
+ }
+ break;
+ }
+}
+void ConnectionToClient::Error(const std::string& msg) {
+ // Take no further action at this time aside from printing it.
+ // If we receive any more messages from the client we'll respond
+ // with a disconnect message in HandleGamePacket().
+ Connection::Error(msg); // Common stuff.
+}
+
+void ConnectionToClient::SendScreenMessage(const std::string& s, float r,
+ float g, float b) {
+ // Older clients don't support the screen-message message, so in that case
+ // we just send it as a chat-message from .
+ if (build_number() < 14248) {
+ std::string value = g_game->CompileResourceString(s, "sendScreenMessage");
+ std::string our_spec_string =
+ PlayerSpec::GetDummyPlayerSpec("").GetSpecString();
+ std::vector msg_out(1 + 1 + our_spec_string.size() + value.size());
+ msg_out[0] = BA_MESSAGE_CHAT;
+ size_t spec_size = our_spec_string.size();
+ assert(spec_size < 256);
+ msg_out[1] = static_cast(spec_size);
+ memcpy(&(msg_out[2]), our_spec_string.c_str(),
+ static_cast(spec_size));
+ memcpy(&(msg_out[2 + spec_size]), value.c_str(), value.size());
+ SendReliableMessage(msg_out);
+ } else {
+ cJSON* msg = cJSON_CreateObject();
+ cJSON_AddNumberToObject(msg, "t", BA_JMESSAGE_SCREEN_MESSAGE);
+ cJSON_AddStringToObject(msg, "m", s.c_str());
+ cJSON_AddNumberToObject(msg, "r", r);
+ cJSON_AddNumberToObject(msg, "g", g);
+ cJSON_AddNumberToObject(msg, "b", b);
+ SendJMessage(msg);
+ cJSON_Delete(msg);
+ }
+}
+
+void ConnectionToClient::HandleMessagePacket(
+ const std::vector& buffer) {
+ if (buffer.empty()) {
+ Log("Error: Got invalid HandleMessagePacket.");
+ return;
+ }
+
+ // If the first message we get is not client-info, it means we're talking to
+ // an older client that won't be sending us info.
+ if (!got_client_info_ && buffer[0] != BA_MESSAGE_CLIENT_INFO) {
+ build_number_ = 0;
+ got_client_info_ = true;
+ }
+
+ switch (buffer[0]) {
+ case BA_MESSAGE_JMESSAGE: {
+ if (buffer.size() >= 3 && buffer[buffer.size() - 1] == 0) {
+ cJSON* msg =
+ cJSON_Parse(reinterpret_cast(buffer.data() + 1));
+ if (msg) {
+ cJSON_Delete(msg);
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_KICK_VOTE: {
+ if (buffer.size() == 2) {
+ for (auto&& i : g_game->connections()->connections_to_clients()) {
+ ConnectionToClient* client = i.second.get();
+ if (client->id() == static_cast(buffer[1])) {
+ g_game->StartKickVote(this, client);
+ break;
+ }
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_CLIENT_INFO: {
+ if (buffer.size() > 1) {
+ std::vector str_buffer(buffer.size());
+ memcpy(&(str_buffer[0]), &(buffer[1]), buffer.size() - 1);
+ str_buffer[str_buffer.size() - 1] = 0;
+ cJSON* info = cJSON_Parse(reinterpret_cast(&(buffer[1])));
+ if (info) {
+ cJSON* b = cJSON_GetObjectItem(info, "b");
+ if (b) {
+ build_number_ = b->valueint;
+ } else {
+ Log("no buildnumber in clientinfo msg");
+ }
+
+ // Grab their token (we use this to ask the
+ // server for their v1 account info).
+ cJSON* t = cJSON_GetObjectItem(info, "tk");
+ if (t) {
+ token_ = t->valuestring;
+ } else {
+ Log("no token in clientinfo msg");
+ }
+
+ // Newer clients also pass a peer-hash, which
+ // we can include with the token to allow the
+ // v1 server to better verify the client's identity.
+ cJSON* ph = cJSON_GetObjectItem(info, "ph");
+ if (ph) {
+ peer_hash_ = ph->valuestring;
+ }
+ if (!token_.empty()) {
+ // Kick off a query to the master-server for this client's info.
+ // FIXME: we need to add retries for this in case of failure.
+ AppInternalClientInfoQuery(
+ token_, our_handshake_player_spec_str_ + our_handshake_salt_,
+ peer_hash_, build_number_);
+ }
+ cJSON_Delete(info);
+ } else {
+ Log("got invalid json in clientinfo message: '"
+ + std::string(reinterpret_cast(&(buffer[1]))) + "'");
+ }
+ }
+ got_client_info_ = true;
+ break;
+ }
+
+ case BA_MESSAGE_CLIENT_PLAYER_PROFILES_JSON: {
+ // Newer type using json.
+ // Only accept peer info if we've not gotten official info from
+ // the master server (and if we're allowing it in general).
+ if (!g_game->require_client_authentication()
+ && !got_info_from_master_server_) {
+ std::vector b2(buffer.size());
+ memcpy(&(b2[0]), &(buffer[1]), buffer.size() - 1);
+ b2[buffer.size() - 1] = 0;
+ PythonRef args(Py_BuildValue("(s)", b2.data()), PythonRef::kSteal);
+ PythonRef results =
+ g_python->obj(Python::ObjID::kJsonLoadsCall).Call(args);
+ if (results.exists()) {
+ player_profiles_ = results;
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_CLIENT_PLAYER_PROFILES: {
+ // Ok at this point we shouldn't attempt to eval these;
+ // they would have been sent in python 2 and we're python 3
+ // so they likely will fail in subtle ways.
+ // ('u' prefixes before unicode and this and that)
+ // Just gonna hope everyone is updated to a recent-ish version so
+ // we don't get these.
+ // This might be a good argument to separate out the protocol versions
+ // we support for game streams vs client-connections. We could disallow
+ // connections to/from these older peers while still allowing old replays
+ // to play back.
+ BA_LOG_ONCE("Received old pre-json player profiles msg; ignoring.");
+ break;
+ }
+
+ case BA_MESSAGE_CHAT: {
+ // We got a chat message from a client.
+ millisecs_t now = GetRealTime();
+
+ // Ignore this if they're chat blocked.
+ if (now >= chat_block_time_) {
+ // We keep track of their recent chat times.
+ // If they exceed a certain amount in the last several seconds,
+ // Institute a chat block.
+ last_chat_times_.push_back(now);
+ uint32_t timeSample = 5000;
+ if (now >= timeSample) {
+ while (!last_chat_times_.empty()
+ && last_chat_times_[0] < now - timeSample) {
+ last_chat_times_.erase(last_chat_times_.begin());
+ }
+ }
+
+ // If we require client-info and don't have it from this guy yet,
+ // ignore their chat messages (prevent bots from jumping in and
+ // spamming before we can verify their identities)
+ if (g_game->require_client_authentication()
+ && !got_info_from_master_server_) {
+ Log("Ignoring chat message from peer with no client info.");
+ SendScreenMessage(R"({"r":"loadingTryAgainText"})", 1, 0, 0);
+ } else if (last_chat_times_.size() >= 5) {
+ chat_block_time_ = now + next_chat_block_seconds_ * 1000;
+ g_game->connections()->SendScreenMessageToAll(
+ R"({"r":"internal.chatBlockedText","s":[["${NAME}",)"
+ + Utils::GetJSONString(
+ GetCombinedSpec().GetDisplayString().c_str())
+ + R"(],["${TIME}",")"
+ + std::to_string(next_chat_block_seconds_) + "\"]]}",
+ 1, 1, 0);
+ next_chat_block_seconds_ *= 2; // make it worse next time
+
+ } else {
+ // Send this along to all clients.
+ // *however* we want to ignore the player-spec that was included in
+ // the chat message and replace it with our own for this
+ // client-connection.
+ if (buffer.size() > 3) {
+ int spec_len = buffer[1];
+ auto msg_len = static_cast(buffer.size() - spec_len - 2);
+ if (spec_len > 0 && msg_len >= 0) {
+ std::vector b2(static_cast(msg_len) + 1);
+ if (msg_len > 0) {
+ memcpy(&(b2[0]), &(buffer[2 + spec_len]),
+ static_cast(msg_len));
+ }
+ b2[msg_len] = 0;
+
+ // Clamp messages at a reasonable size
+ // (yes, people used this to try and crash machines).
+ if (b2.size() > 100) {
+ SendScreenMessage(
+ "{\"t\":[\"serverResponses\","
+ "\"Message is too long.\"]}",
+ 1, 0, 0);
+ } else if (g_game->kick_vote_in_progress()
+ && (!strcmp(b2.data(), "1")
+ || !strcmp(b2.data(), "2"))) {
+ // Special case - if there's a kick vote going on, take '1' or
+ // '2' to be votes.
+ // TODO(ericf): Disable this based on build-numbers once we've
+ // got GUI voting working.
+ if (!kick_voted_) {
+ kick_voted_ = true;
+ kick_vote_choice_ = !strcmp(b2.data(), "1");
+ } else {
+ SendScreenMessage(R"({"r":"votedAlreadyText"})", 1, 0, 0);
+ }
+ } else {
+ // Pass the message through any custom filtering we've got.
+ // If the filter tells us to ignore it, we're done.
+ std::string message = b2.data();
+ bool allow_message =
+ g_python->FilterChatMessage(&message, id());
+ if (!allow_message) {
+ break;
+ }
+
+ std::string spec_string = GetCombinedSpec().GetSpecString();
+ std::vector msg_out(1 + 1 + spec_string.size()
+ + message.size());
+ msg_out[0] = BA_MESSAGE_CHAT;
+ size_t spec_size = spec_string.size();
+ assert(spec_size < 256);
+ msg_out[1] = static_cast(spec_size);
+ memcpy(&(msg_out[2]), spec_string.c_str(),
+ static_cast(spec_size));
+ memcpy(&(msg_out[2 + spec_size]), message.c_str(),
+ message.size());
+
+ // Send it out to all clients.
+ for (auto&& i :
+ g_game->connections()->connections_to_clients()) {
+ if (i.second->can_communicate()) {
+ i.second->SendReliableMessage(msg_out);
+ }
+ }
+
+ // Display it locally.
+ g_game->LocalDisplayChatMessage(msg_out);
+ }
+ }
+ }
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_REMOTE_PLAYER_INPUT_COMMANDS: {
+ if (ClientInputDevice* client_input_device =
+ GetClientInputDevice(buffer[1])) {
+ int count = static_cast((buffer.size() - 2) / 5);
+ if ((buffer.size() - 2) % 5 != 0) {
+ Log("Error: invalid player-input-commands packet");
+ break;
+ }
+ int index = 2;
+ for (int i = 0; i < count; i++) {
+ auto type = (InputType)buffer[index++];
+ float val;
+ memcpy(&val, &(buffer[index]), 4);
+ index += 4;
+ client_input_device->PassInputCommand(type, val);
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_REMOVE_REMOTE_PLAYER: {
+ last_remove_player_time_ = GetRealTime();
+ if (buffer.size() != 2) {
+ Log("Error: invalid remove-remote-player packet");
+ break;
+ }
+ if (ClientInputDevice* cid = GetClientInputDevice(buffer[1])) {
+ if (Player* player = cid->GetPlayer()) {
+ HostSession* host_session = player->GetHostSession();
+ if (!host_session) throw Exception("Player's host-session not found");
+ host_session->RemovePlayer(player);
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_REQUEST_REMOTE_PLAYER: {
+ if (buffer.size() != 2) {
+ Log("Error: invalid remote-player-request packet");
+ break;
+ }
+
+ // Create/fetch our client-input that represents this guy
+ // and submit a player-request on it's behalf.
+ ClientInputDevice* cid = GetClientInputDevice(buffer[1]);
+
+ if (auto* hs =
+ dynamic_cast(g_game->GetForegroundSession())) {
+ if (!cid->attached_to_player()) {
+ millisecs_t seconds_since_last_left =
+ (GetRealTime() - last_remove_player_time_) / 1000;
+ int min_seconds_since_left = 10;
+
+ // If someone on this connection left less than 10 seconds ago,
+ // prevent them from immediately jumping back in.
+ if (seconds_since_last_left < min_seconds_since_left) {
+ SendScreenMessage(
+ "{\"t\":[\"serverResponses\",\"You can join in ${COUNT} "
+ "seconds.\"],\"s\":[[\"${COUNT}\",\""
+ + std::to_string(min_seconds_since_left
+ - seconds_since_last_left)
+ + "\"]]}",
+ 1, 1, 0);
+ } else {
+ bool still_waiting = (g_game->require_client_authentication()
+ && !got_info_from_master_server_);
+ // If we're not allowing peer client-info and have yet to get
+ // master-server info for this client, delay their join (we'll
+ // eventually give up and just give them a blank slate).
+ if (still_waiting && (GetRealTime() - creation_time() < 10000)) {
+ SendScreenMessage(
+ "{\"v\":\"${A}...\",\"s\":[[\"${A}\",{\"r\":"
+ "\"loadingTryAgainText\",\"f\":\"loadingText\"}]]}",
+ 1, 1, 0);
+ } else {
+ // Either timed out or have info; let the request go through.
+ if (still_waiting) {
+ Log("Allowing player-request without client\'s master-server "
+ "info (build "
+ + std::to_string(build_number_) + ")");
+ }
+ hs->RequestPlayer(cid);
+ }
+ }
+ }
+ } else {
+ Log("Error: ConnectionToClient got remote player"
+ " request but have no host session");
+ }
+ break;
+ }
+ default: {
+ // Hackers have attempted to mess with servers by sending huge amounts of
+ // data through chat messages/etc. Let's watch out for mutli-part messages
+ // growing too large and kick/ban the client if they do.
+ if (buffer[0] == BA_MESSAGE_MULTIPART) {
+ if (multipart_buffer_size() > 50000) {
+ // Its not actually unknown but shhh don't tell the hackers...
+ SendScreenMessage(R"({"r":"errorUnknownText"})", 1, 0, 0);
+ Log("Client data limit exceeded by '" + peer_spec().GetShortName()
+ + "'; kicking.");
+ g_game->BanPlayer(peer_spec(), 1000 * 60);
+ Error("");
+ return;
+ }
+ }
+
+ Connection::HandleMessagePacket(buffer);
+ }
+ }
+}
+
+auto ConnectionToClient::GetCombinedSpec() -> PlayerSpec {
+ // Look for players coming from this client-connection.
+ // If we find any, make a spec out of their name(s).
+ if (auto* hs = dynamic_cast(g_game->GetForegroundSession())) {
+ std::string p_name_combined;
+ for (auto&& p : hs->players()) {
+ InputDevice* input_device = p->GetInputDevice();
+ if (!p->GetName().empty() && p->name_is_real() && p->accepted()
+ && input_device != nullptr && input_device->IsRemoteClient()) {
+ auto* cid = static_cast(input_device);
+ ConnectionToClient* ctc = cid->connection_to_client();
+
+ // Add some basic info for each remote player.
+ if (ctc != nullptr && ctc == this) {
+ if (!p_name_combined.empty()) {
+ p_name_combined += "/";
+ }
+ p_name_combined += p->GetName();
+ }
+ }
+ }
+ if (p_name_combined.size() > kMaxPartyNameCombinedSize) {
+ p_name_combined.resize(kMaxPartyNameCombinedSize);
+ p_name_combined += "...";
+ }
+ if (!p_name_combined.empty()) {
+ return PlayerSpec::GetDummyPlayerSpec(p_name_combined);
+ }
+ }
+
+ // Welp, that didn't work.
+ // As a fallback, just use the peer spec (account name, etc.)
+ return peer_spec();
+}
+
+auto ConnectionToClient::GetClientInputDevice(int remote_id)
+ -> ClientInputDevice* {
+ auto i = client_input_devices_.find(remote_id);
+ if (i == client_input_devices_.end()) {
+ // InputDevices need to be manually allocated and passed to g_input to
+ // store.
+ auto cid = Object::NewDeferred(remote_id, this);
+ client_input_devices_[remote_id] = cid;
+ g_input->AddInputDevice(cid, false);
+ return cid;
+ }
+ return i->second;
+}
+
+auto ConnectionToClient::GetAsUDP() -> ConnectionToClientUDP* {
+ return nullptr;
+}
+
+void ConnectionToClient::HandleMasterServerClientInfo(PyObject* info_obj) {
+ PyObject* profiles_obj = PyDict_GetItemString(info_obj, "p");
+ if (profiles_obj != nullptr) {
+ player_profiles_.Acquire(profiles_obj);
+ }
+
+ // This will also contain a public account-id (if the query was valid).
+ // Store it away for whoever wants it.
+ PyObject* public_id_obj = PyDict_GetItemString(info_obj, "u");
+ if (public_id_obj != nullptr && Python::IsPyString(public_id_obj)) {
+ peer_public_account_id_ = Python::GetPyString(public_id_obj);
+ } else {
+ peer_public_account_id_ = "";
+
+ // If the server returned no valid account info for them
+ // and we're not trusting peers, kick this fella right out
+ // and ban him for a short bit (to hopefully limit rejoin spam).
+ if (g_game->require_client_authentication()) {
+ SendScreenMessage(
+ "{\"t\":[\"serverResponses\","
+ "\"Your account was rejected. Are you signed in?\"]}",
+ 1, 0, 0);
+ Log("Master server found no valid account for '"
+ + peer_spec().GetShortName() + "'; kicking.");
+
+ // Not benning anymore. People were exploiting this by impersonating
+ // other players using their public ids to get them banned from
+ // their own servers/etc.
+ // g_game->BanPlayer(peer_spec(), 1000 * 60);
+ Error("");
+ }
+ }
+ got_info_from_master_server_ = true;
+}
+
+auto ConnectionToClient::IsAdmin() const -> bool {
+ if (peer_public_account_id_.empty()) {
+ return false;
+ }
+ return (g_game->admin_public_ids().find(peer_public_account_id_)
+ != g_game->admin_public_ids().end());
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/game/connection/connection_to_client_udp.cc b/src/ballistica/game/connection/connection_to_client_udp.cc
new file mode 100644
index 00000000..b75ad69a
--- /dev/null
+++ b/src/ballistica/game/connection/connection_to_client_udp.cc
@@ -0,0 +1,95 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/game/connection/connection_to_client_udp.h"
+
+#include "ballistica/game/connection/connection_set.h"
+#include "ballistica/game/game.h"
+#include "ballistica/networking/network_write_module.h"
+#include "ballistica/networking/sockaddr.h"
+
+namespace ballistica {
+
+ConnectionToClientUDP::ConnectionToClientUDP(const SockAddr& addr,
+ std::string client_name,
+ uint8_t request_id, int client_id)
+ : ConnectionToClient(client_id),
+ request_id_(request_id),
+ addr_(new SockAddr(addr)),
+ client_instance_uuid_(std::move(client_name)),
+ last_client_response_time_(g_game->master_time()),
+ did_die_(false) {}
+
+ConnectionToClientUDP::~ConnectionToClientUDP() {
+ // This prevents anything from trying to send
+ // (and thus crashing in pure-virtual SendGamePacketCompressed) as we die.
+ set_connection_dying(true);
+}
+
+void ConnectionToClientUDP::SendGamePacketCompressed(
+ const std::vector& data) {
+ // Ok, we've got a random chunk of (possibly) compressed data to send over
+ // the wire.. lets stick a header on it and ship it out.
+ std::vector data_full(data.size() + 2);
+ memcpy(&(data_full[2]), &data[0], data.size());
+ data_full[0] = BA_PACKET_HOST_GAMEPACKET_COMPRESSED;
+
+ // Go ahead and include their original request_id so they know we're talking
+ // to them.
+ data_full[1] = request_id_;
+
+ // Ship this off to the net-out thread to send; at this point we don't know
+ // or case what happens to it.
+ assert(g_network_write_module);
+ g_network_write_module->PushSendToCall(data_full, *addr_);
+}
+
+void ConnectionToClientUDP::Update() {
+ ConnectionToClient::Update();
+
+ millisecs_t current_time = g_game->master_time();
+
+ // if its been long enough since we've heard anything from the host, error.
+ if (current_time - last_client_response_time_
+ > (can_communicate() ? 10000u : 5000u)) {
+ // die immediately in this case; no use trying to wait for a
+ // disconnect-ack since we've already given up hope of hearing from them..
+ Die();
+ return;
+ }
+}
+void ConnectionToClientUDP::HandleGamePacket(
+ const std::vector& buffer) {
+ // keep track of when we last heard from the host for disconnect purposes
+ last_client_response_time_ = g_game->master_time();
+ ConnectionToClient::HandleGamePacket(buffer);
+}
+
+void ConnectionToClientUDP::Die() {
+ if (did_die_) {
+ Log("Error: Posting multiple die messages; probably not good.");
+ return;
+ }
+ // this will actually clear the object..
+ g_game->connections()->PushClientDisconnectedCall(id());
+ did_die_ = true;
+}
+
+auto ConnectionToClientUDP::GetAsUDP() -> ConnectionToClientUDP* {
+ return this;
+}
+
+void ConnectionToClientUDP::RequestDisconnect() {
+ // mark us as errored so all future communication results in more disconnect
+ // requests
+ set_errored(true);
+ SendDisconnectRequest();
+}
+
+void ConnectionToClientUDP::SendDisconnectRequest() {
+ std::vector data(2);
+ data[0] = BA_PACKET_DISCONNECT_FROM_HOST_REQUEST;
+ data[1] = static_cast(id());
+ g_network_write_module->PushSendToCall(data, *addr_);
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/game/connection/connection_to_host.cc b/src/ballistica/game/connection/connection_to_host.cc
new file mode 100644
index 00000000..8c5eae4f
--- /dev/null
+++ b/src/ballistica/game/connection/connection_to_host.cc
@@ -0,0 +1,494 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/game/connection/connection_to_host.h"
+
+#include "ballistica/audio/audio.h"
+#include "ballistica/game/game.h"
+#include "ballistica/game/session/net_client_session.h"
+#include "ballistica/generic/json.h"
+#include "ballistica/input/device/input_device.h"
+#include "ballistica/input/input.h"
+#include "ballistica/math/vector3f.h"
+#include "ballistica/media/media.h"
+#include "ballistica/networking/networking.h"
+#include "ballistica/platform/platform.h"
+#include "ballistica/python/python.h"
+#include "ballistica/python/python_sys.h"
+
+namespace ballistica {
+
+ConnectionToHost::ConnectionToHost() = default;
+
+auto ConnectionToHost::GetAsUDP() -> ConnectionToHostUDP* { return nullptr; }
+
+ConnectionToHost::~ConnectionToHost() {
+ // If we were considered 'connected', announce that we're leaving.
+ if (can_communicate()) {
+ // If we've already printed a 'connected' message, print 'disconnected'.
+ // Otherwise say the connection was rejected.
+ if (printed_connect_message_) {
+ // Use the party/game name if we've got it; otherwise say
+ // '${PEER-NAME}'s party'.
+ std::string s;
+ if (!party_name_.empty()) {
+ s = g_game->GetResourceString("leftGameText");
+ Utils::StringReplaceOne(&s, "${NAME}", party_name_);
+ } else {
+ s = g_game->GetResourceString("leftPartyText");
+ Utils::StringReplaceOne(&s, "${NAME}", peer_spec().GetDisplayString());
+ }
+ ScreenMessage(s, {1, 0.5f, 0.0f});
+ g_audio->PlaySound(g_media->GetSound(SystemSoundID::kCorkPop));
+ } else {
+ ScreenMessage(g_game->GetResourceString("connectionRejectedText"),
+ {1, 0, 0});
+ }
+ }
+}
+
+void ConnectionToHost::Update() { Connection::Update(); }
+
+// Seems we get a false alarm here.
+#pragma clang diagnostic push
+#pragma ide diagnostic ignored "LocalValueEscapesScope"
+
+void ConnectionToHost::HandleGamePacket(const std::vector& data) {
+ // If we've errored, ignore everything; we're just a zombie.
+ if (errored()) {
+ // Hmmm; do we want to respond with disconnect packets here?
+ // (not remembering why server side does that but we don't).
+ return;
+ }
+
+ if (data.empty()) {
+ return;
+ }
+
+ switch (data[0]) {
+ case BA_GAMEPACKET_HANDSHAKE: {
+ if (data.size() <= 3) {
+ break;
+ }
+
+ // We expect a > 3 byte handshake packet with protocol version as the
+ // second and third bytes and name/info beyond that.
+ // (player-spec for protocol <= 32 and info json dict for 33+).
+
+ // If we don't support their protocol, let them know..
+ bool compatible = false;
+ uint16_t their_protocol_version;
+ memcpy(&their_protocol_version, data.data() + 1,
+ sizeof(their_protocol_version));
+ if (their_protocol_version >= kProtocolVersionMin
+ && their_protocol_version <= kProtocolVersion) {
+ compatible = true;
+
+ // If we are compatible, set our protocol version to match
+ // what they're dealing.
+ protocol_version_ = their_protocol_version;
+ }
+
+ // Ok now we know if we can talk to them. Respond so they know
+ // whether they can talk to us.
+
+ // (packet-type, our protocol-version, our spec/info)
+ // For server-protocol < 32 we provide our player-spec.
+ // For server-protocol 33+ we provide json info dict.
+ if (their_protocol_version >= 33) {
+ // Construct a json dict with our player-spec-string as one element
+ JsonDict dict;
+ dict.AddString("s", PlayerSpec::GetAccountPlayerSpec().GetSpecString());
+
+ // Also add our public device id. Servers can
+ // use this to combat spammers.
+ dict.AddString("d", g_platform->GetPublicDeviceUUID());
+
+ std::string out = dict.PrintUnformatted();
+
+ std::vector data2(3 + out.size());
+ data2[0] = BA_GAMEPACKET_HANDSHAKE_RESPONSE;
+ auto val = static_cast(protocol_version_);
+ memcpy(data2.data() + 1, &val, sizeof(val));
+ memcpy(data2.data() + 3, out.c_str(), out.size());
+ SendGamePacket(data2);
+ } else {
+ // (KILL THIS WHEN kProtocolVersionMin >= 33)
+ std::string our_spec_str =
+ PlayerSpec::GetAccountPlayerSpec().GetSpecString();
+ std::vector response(3 + our_spec_str.size());
+ response[0] = BA_GAMEPACKET_HANDSHAKE_RESPONSE;
+ auto val = static_cast(protocol_version_);
+ memcpy(response.data() + 1, &val, sizeof(val));
+ memcpy(response.data() + 3, our_spec_str.c_str(), our_spec_str.size());
+ SendGamePacket(response);
+ }
+
+ if (!compatible) {
+ if (their_protocol_version > kProtocolVersion) {
+ Error(g_game->GetResourceString("incompatibleNewerVersionHostText"));
+ } else {
+ Error(g_game->GetResourceString("incompatibleVersionHostText"));
+ }
+ return;
+ }
+
+ // If we're freshly establishing that we're able to talk to them
+ // in a language they understand, go ahead and kick some stuff off.
+ if (!can_communicate()) {
+ if (their_protocol_version >= 33) {
+ // In newer protocols, handshake contains a json dict
+ // so we can evolve it going forward.
+ std::vector string_buffer(data.size() - 3 + 1);
+ memcpy(&(string_buffer[0]), &(data[3]), data.size() - 3);
+ string_buffer[string_buffer.size() - 1] = 0;
+ cJSON* handshake = cJSON_Parse(string_buffer.data());
+ if (handshake) {
+ // We hash this to prove that we're us; keep it around.
+ peer_hash_input_ = "";
+ cJSON* pspec = cJSON_GetObjectItem(handshake, "s");
+ if (pspec) {
+ peer_hash_input_ += pspec->valuestring;
+ set_peer_spec(PlayerSpec(pspec->valuestring));
+ }
+ cJSON* salt = cJSON_GetObjectItem(handshake, "l");
+ if (salt) {
+ peer_hash_input_ += salt->valuestring;
+ }
+ cJSON_Delete(handshake);
+ }
+ } else {
+ // (KILL THIS WHEN kProtocolVersionMin >= 33)
+ // In older protocols, handshake simply contained a
+ // player-spec for the host.
+
+ // Pull host's PlayerSpec from the handshake packet.
+ std::vector string_buffer(data.size() - 3 + 1);
+ memcpy(&(string_buffer[0]), &(data[3]), data.size() - 3);
+ string_buffer[string_buffer.size() - 1] = 0;
+
+ // We hash this to prove that we're us; keep it around.
+ peer_hash_input_ = string_buffer.data();
+ set_peer_spec(PlayerSpec(string_buffer.data()));
+ }
+
+ peer_hash_ = AppInternalCalcV1PeerHash(peer_hash_input_);
+
+ set_can_communicate(true);
+ g_game->LaunchClientSession();
+
+ // NOTE:
+ // we don't actually print a 'connected' message until after
+ // we get our first message (it may influence the message we print and
+ // there's also a chance we could still get booted after sending our
+ // info message)
+
+ // Wire ourselves up to drive the client-session we're in.
+ auto* cs =
+ dynamic_cast(g_game->GetForegroundSession());
+ assert(cs);
+ assert(!cs->connection_to_host());
+ client_session_ = cs;
+ cs->SetConnectionToHost(this);
+
+ // The very first thing we send is our client-info
+ // which is a json dict with arbitrary data.
+ {
+ JsonDict dict;
+ dict.AddNumber("b", kAppBuildNumber);
+
+ AppInternalV1SetClientInfo(&dict);
+
+ // Pass the hash we generated from their handshake; they can use
+ // this to make sure we're who we say we are.
+ dict.AddString("ph", peer_hash_);
+ std::string info = dict.PrintUnformatted();
+ std::vector msg(info.size() + 1);
+ msg[0] = BA_MESSAGE_CLIENT_INFO;
+ memcpy(&(msg[1]), info.c_str(), info.size());
+ SendReliableMessage(msg);
+ }
+
+ // Send them our player-profiles so we can use them on their end.
+ // (the host generally will pull these from the master server
+ // to prevent cheating, but in some cases these are used)
+
+ // On newer hosts we send these as json.
+ if (protocol_version_ >= 32) {
+ // (This is a borrowed ref)
+ PyObject* profiles = g_python->GetRawConfigValue("Player Profiles");
+ PythonRef empty_dict;
+ if (!profiles) {
+ Log("No profiles found; sending empty list to host");
+ empty_dict.Steal(PyDict_New());
+ profiles = empty_dict.get();
+ }
+ if (profiles != nullptr) {
+ // Dump them to a json string.
+ PythonRef args(Py_BuildValue("(O)", profiles), PythonRef::kSteal);
+ PythonRef keywds(Py_BuildValue("{s(ss)}", "separators", ",", ":"),
+ PythonRef::kSteal);
+ PythonRef results =
+ g_python->obj(Python::ObjID::kJsonDumpsCall).Call(args, keywds);
+ if (!results.exists()) {
+ Log("Error getting json dump of local profiles");
+ } else {
+ try {
+ // Pull the string as utf8 and send.
+ std::string s = results.ValueAsString();
+ std::vector msg(s.size() + 1);
+ msg[0] = BA_MESSAGE_CLIENT_PLAYER_PROFILES_JSON;
+ memcpy(&(msg[1]), &s[0], s.size());
+ SendReliableMessage(msg);
+ } catch (const std::exception& e) {
+ Log(std::string("exc sending player profiles to host: ")
+ + e.what());
+ }
+ }
+ }
+ } else {
+ Log("Connected to old protocol; can't send player profiles");
+ }
+ }
+ break;
+ }
+
+ case BA_GAMEPACKET_DISCONNECT: {
+ // They told us to leave, so lets do so :-(
+ ErrorSilent();
+ break;
+ }
+
+ default:
+ // Let our base class handle common stuff *if* we're connected.
+ if (can_communicate()) {
+ Connection::HandleGamePacket(data);
+ }
+ break;
+ }
+}
+
+#pragma clang diagnostic pop
+
+void ConnectionToHost::HandleMessagePacket(const std::vector& buffer) {
+ assert(InGameThread());
+
+ if (buffer.empty()) {
+ Log("Error: got invalid HandleMessagePacket");
+ return;
+ }
+
+ // If the first message we get is not host-info, it means we're talking to
+ // an older host that won't be sending us info.
+ if (!got_host_info_ && buffer[0] != BA_MESSAGE_HOST_INFO) {
+ build_number_ = 0;
+ got_host_info_ = true;
+ }
+
+ switch (buffer[0]) {
+ case BA_MESSAGE_HOST_INFO: {
+ if (buffer.size() > 1) {
+ std::vector str_buffer(buffer.size());
+ memcpy(&(str_buffer[0]), &(buffer[1]), buffer.size() - 1);
+ str_buffer[str_buffer.size() - 1] = 0;
+ cJSON* info = cJSON_Parse(reinterpret_cast(&(buffer[1])));
+ if (info) {
+ // Build number.
+ cJSON* b = cJSON_GetObjectItem(info, "b");
+ if (b) {
+ build_number_ = b->valueint;
+ } else {
+ Log("no buildnumber in hostinfo msg");
+ }
+ // Party name.
+ cJSON* n = cJSON_GetObjectItem(info, "n");
+ if (n != nullptr) {
+ party_name_ = Utils::GetValidUTF8(n->valuestring, "bsmhi");
+ }
+ cJSON_Delete(info);
+ } else {
+ Log("got invalid json in hostinfo message");
+ }
+ }
+ got_host_info_ = true;
+ break;
+ }
+
+ case BA_MESSAGE_PARTY_ROSTER: {
+ if (buffer.size() >= 3 && buffer[buffer.size() - 1] == 0) {
+ // Expand this into a json object; if it's valid, replace the game's
+ // current roster with it.
+ cJSON* new_roster =
+ cJSON_Parse(reinterpret_cast(&(buffer[1])));
+ if (new_roster) {
+ g_game->SetGameRoster(new_roster);
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_JMESSAGE: {
+ // High level json messages (nice and easy to expand on but not
+ // especially efficient).
+ if (buffer.size() >= 3 && buffer[buffer.size() - 1] == 0) {
+ cJSON* msg =
+ cJSON_Parse(reinterpret_cast(buffer.data() + 1));
+ if (msg) {
+ cJSON* type = cJSON_GetObjectItem(msg, "t");
+ if (type != nullptr) {
+ switch (type->valueint) {
+ case BA_JMESSAGE_SCREEN_MESSAGE: {
+ std::string m;
+ float r = 1.0f;
+ float g = 1.0f;
+ float b = 1.0f;
+ if (cJSON* r_obj = cJSON_GetObjectItem(msg, "r")) {
+ r = static_cast(r_obj->valuedouble);
+ }
+ if (cJSON* g_obj = cJSON_GetObjectItem(msg, "g")) {
+ g = static_cast(g_obj->valuedouble);
+ }
+ if (cJSON* b_obj = cJSON_GetObjectItem(msg, "b")) {
+ b = static_cast(b_obj->valuedouble);
+ }
+ if (cJSON* m_obj = cJSON_GetObjectItem(msg, "m")) {
+ m = m_obj->valuestring;
+ ScreenMessage(m, {r, g, b});
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ cJSON_Delete(msg);
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_PARTY_MEMBER_JOINED: {
+ if (buffer.size() > 1) {
+ std::vector str_buffer(buffer.size());
+ memcpy(&(str_buffer[0]), &(buffer[1]), buffer.size() - 1);
+ str_buffer[str_buffer.size() - 1] = 0;
+ std::string s = g_game->GetResourceString("playerJoinedPartyText");
+ Utils::StringReplaceOne(
+ &s, "${NAME}", PlayerSpec(str_buffer.data()).GetDisplayString());
+ ScreenMessage(s, {0.5f, 1.0f, 0.5f});
+ g_audio->PlaySound(g_media->GetSound(SystemSoundID::kGunCock));
+ }
+ break;
+ }
+
+ case BA_MESSAGE_PARTY_MEMBER_LEFT: {
+ // Host is informing us that someone in the party left.
+ if (buffer.size() > 1) {
+ std::vector str_buffer(buffer.size());
+ memcpy(&(str_buffer[0]), &(buffer[1]), buffer.size() - 1);
+ str_buffer[str_buffer.size() - 1] = 0;
+ std::string s = g_game->GetResourceString("playerLeftPartyText");
+ Utils::StringReplaceOne(
+ &s, "${NAME}", PlayerSpec(&(str_buffer[0])).GetDisplayString());
+ ScreenMessage(s, {1, 0.5f, 0.0f});
+ g_audio->PlaySound(g_media->GetSound(SystemSoundID::kCorkPop));
+ }
+ break;
+ }
+
+ case BA_MESSAGE_ATTACH_REMOTE_PLAYER_2: {
+ // New-style packet which includes a 32-bit player_id.
+ if (buffer.size() != 6) {
+ Log("Error: invalid attach-remote-player-2 msg");
+ break;
+ }
+
+ // Grab this local input-device and tell it its controlling something on
+ // the host.
+ InputDevice* input_device = g_input->GetInputDevice(buffer[1]);
+ if (input_device) {
+ uint32_t player_id;
+ memcpy(&player_id, &(buffer[2]), sizeof(player_id));
+ input_device->AttachToRemotePlayer(
+ this, static_cast_check_fit(player_id));
+ }
+
+ // Once we've gotten one of these we know to ignore the old style.
+ ignore_old_attach_remote_player_packets_ = true;
+ break;
+ }
+
+ case BA_MESSAGE_ATTACH_REMOTE_PLAYER: {
+ // If our server uses the newer ones, we should ignore these.
+ if (!ignore_old_attach_remote_player_packets_) {
+ // This message was used in older versions but is flawed in that
+ // player-id is an 8 bit value which isn't enough for longstanding
+ // public servers.
+ // TODO(ericf): can remove this once back-compat-protocol > 29.
+ if (buffer.size() != 3) {
+ Log("Error: Invalid attach-remote-player msg.");
+ break;
+ }
+
+ // Grab this local input-device and tell it its controlling something
+ // on the host.
+ InputDevice* input_device = g_input->GetInputDevice(buffer[1]);
+ if (input_device) {
+ input_device->AttachToRemotePlayer(this, buffer[2]);
+ }
+ }
+ break;
+ }
+
+ case BA_MESSAGE_CHAT: {
+ g_game->LocalDisplayChatMessage(buffer);
+ break;
+ }
+
+ case BA_MESSAGE_DETACH_REMOTE_PLAYER: {
+ if (buffer.size() != 2) {
+ Log("Error: Invalid detach-remote-player msg");
+ break;
+ }
+ InputDevice* input_device = g_input->GetInputDevice(buffer[1]);
+ if (input_device && input_device->GetRemotePlayer() == this)
+ input_device->DetachFromPlayer();
+ break;
+ }
+
+ case BA_MESSAGE_SESSION_COMMANDS:
+ case BA_MESSAGE_SESSION_RESET:
+ case BA_MESSAGE_SESSION_DYNAMICS_CORRECTION: {
+ // These commands are consumed directly by the session.
+ if (client_session_.exists()) {
+ client_session_->HandleSessionMessage(buffer);
+ }
+ break;
+ }
+
+ default: {
+ Connection::HandleMessagePacket(buffer);
+ }
+ }
+
+ // After we get our first message from the server is when we print our
+ // 'connected to XXX' message.
+ if (!printed_connect_message_) {
+ std::string s;
+
+ // If we've got a name for their party, use it; otherwise call it
+ // '${NAME}'s party'.
+ if (!party_name_.empty()) {
+ s = g_game->GetResourceString("connectedToGameText");
+ Utils::StringReplaceOne(&s, "${NAME}", party_name_);
+ } else {
+ s = g_game->GetResourceString("connectedToPartyText");
+ Utils::StringReplaceOne(&s, "${NAME}", peer_spec().GetDisplayString());
+ }
+ ScreenMessage(s, {0.5f, 1, 0.5f});
+ g_audio->PlaySound(g_media->GetSound(SystemSoundID::kGunCock));
+
+ printed_connect_message_ = true;
+ }
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/game/connection/connection_to_host_udp.cc b/src/ballistica/game/connection/connection_to_host_udp.cc
new file mode 100644
index 00000000..79d3f906
--- /dev/null
+++ b/src/ballistica/game/connection/connection_to_host_udp.cc
@@ -0,0 +1,185 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/game/connection/connection_to_host_udp.h"
+
+#include "ballistica/game/connection/connection_set.h"
+#include "ballistica/game/game.h"
+#include "ballistica/math/vector3f.h"
+#include "ballistica/networking/network_write_module.h"
+#include "ballistica/networking/sockaddr.h"
+
+namespace ballistica {
+
+auto ConnectionToHostUDP::SwitchProtocol() -> bool {
+ if (protocol_version() > kProtocolVersionMin) {
+ set_protocol_version(protocol_version() - 1);
+
+ // Need a new request id so we ignore further responses to our previous
+ // requests.
+ GetRequestID();
+ return true;
+ }
+ return false;
+}
+
+ConnectionToHostUDP::ConnectionToHostUDP(const SockAddr& addr)
+ : addr_(new SockAddr(addr)),
+ client_id_(-1),
+ last_client_id_request_time_(0),
+ last_disconnect_request_time_(0),
+ did_die_(false),
+ last_host_response_time_(g_game->master_time()) {
+ GetRequestID();
+ if (g_game->connections()->GetPrintUDPConnectProgress()) {
+ ScreenMessage(g_game->GetResourceString("connectingToPartyText"));
+ }
+}
+
+ConnectionToHostUDP::~ConnectionToHostUDP() {
+ // This prevents anything from trying to send (and thus crashing in
+ // pure-virtual SendGamePacketCompressed) as we die.
+ set_connection_dying(true);
+}
+
+void ConnectionToHostUDP::GetRequestID() {
+ // We store a unique-ish request ID to minimize the chance that data for
+ // previous connections/etc will muck with us.
+ // Try to start this value at something that won't be common in packets to
+ // minimize chance of garbage packets causing trouble.
+ static auto next_request_id =
+ static_cast(71 + (rand() % 151)); // NOLINT
+ request_id_ = next_request_id++;
+}
+
+void ConnectionToHostUDP::Update() {
+ ConnectionToHost::Update();
+
+ millisecs_t current_time = g_game->master_time();
+
+ // If we've not gotten a client_id from the host yet, keep pestering it.
+ if (!errored()) {
+ if (client_id_ == -1 && current_time - last_client_id_request_time_ > 500) {
+ last_client_id_request_time_ = current_time;
+
+ // Client request packet: contains our protocol version (2 bytes), our
+ // request id (1 byte), and our session-identifier (remainder of the
+ // message).
+ const std::string& uuid{GetAppInstanceUUID()};
+ std::vector msg(4 + uuid.size());
+ msg[0] = BA_PACKET_CLIENT_REQUEST;
+ auto p_version = static_cast(protocol_version());
+ memcpy(&(msg[1]), &p_version, 2);
+ msg[3] = request_id_;
+ memcpy(&(msg[4]), uuid.c_str(), uuid.size());
+ g_network_write_module->PushSendToCall(msg, *addr_);
+ }
+ }
+
+ // If its been long enough since we've heard anything from the host, error.
+ if (current_time - last_host_response_time_
+ > (can_communicate() ? 10000u : 5000u)) {
+ // If the connection never got established, announce it failed.
+ if (!can_communicate()) {
+ ScreenMessage(g_game->GetResourceString("connectionFailedText"),
+ {1, 0, 0});
+ }
+
+ // Die immediately in this case; no use trying to wait for a disconnect-ack
+ // since we've already given up hope of hearing from them.
+ Die();
+ return;
+ } else if (errored()) {
+ // If we've errored, keep sending disconnect-requests periodically.
+ // Once we get a response (or time out in the above code) we'll die.
+ if (current_time - last_disconnect_request_time_ > 1000) {
+ last_disconnect_request_time_ = current_time;
+
+ // If we haven't even got a client id yet, we can't send disconnect
+ // requests; just die.
+ if (client_id_ == -1) {
+ Die();
+ return;
+ } else {
+ SendDisconnectRequest();
+ }
+ }
+ }
+}
+
+// Tells the game to actually kill us. We try to inform the server of our
+// departure before doing this when possible.
+void ConnectionToHostUDP::Die() {
+ if (did_die_) {
+ Log("Error: posting multiple die messages; probably not good.");
+ return;
+ }
+ if (g_game->connections()->connection_to_host() == this) {
+ g_game->connections()->PushDisconnectedFromHostCall();
+ did_die_ = true;
+ } else {
+ Log("Error: Running update for non-current host-connection; shouldn't "
+ "happen.");
+ }
+}
+
+void ConnectionToHostUDP::SendDisconnectRequest() {
+ assert(client_id_ != -1);
+ if (client_id_ != -1) {
+ std::vector data(2);
+ data[0] = BA_PACKET_DISCONNECT_FROM_CLIENT_REQUEST;
+ data[1] = static_cast_check_fit(client_id_);
+ g_network_write_module->PushSendToCall(data, *addr_);
+ }
+}
+
+void ConnectionToHostUDP::HandleGamePacket(const std::vector& buffer) {
+ // Keep track of when we last heard from the host for time-out purposes.
+ last_host_response_time_ = g_game->master_time();
+
+ ConnectionToHost::HandleGamePacket(buffer);
+}
+
+void ConnectionToHostUDP::SendGamePacketCompressed(
+ const std::vector& data) {
+ assert(!data.empty());
+
+ // Ok, we've got a random chunk of (possibly) compressed data to send over
+ // the wire. Lets stick a header on it and ship it out.
+ std::vector data_full(data.size() + 2);
+ memcpy(&(data_full[2]), &data[0], data.size());
+ data_full[0] = BA_PACKET_CLIENT_GAMEPACKET_COMPRESSED;
+ data_full[1] = static_cast_check_fit(client_id_);
+
+ // Ship this off to the net-out thread to send; at this point we don't know
+ // or care what happens to it.
+ assert(g_network_write_module);
+ g_network_write_module->PushSendToCall(data_full, *addr_);
+}
+
+void ConnectionToHostUDP::Error(const std::string& msg) {
+ // On our initial erroring, send a disconnect request immediately if we've
+ // got an ID otherwise just kill ourselves instantly.
+ if (!errored()) {
+ if (client_id_ != -1) {
+ SendDisconnectRequest();
+ } else {
+ Die();
+ }
+ }
+
+ // Common error stuff.
+ ConnectionToHost::Error(msg);
+}
+
+auto ConnectionToHostUDP::GetAsUDP() -> ConnectionToHostUDP* { return this; }
+
+void ConnectionToHostUDP::RequestDisconnect() {
+ // Mark us as errored so all future communication results in more disconnect
+ // requests.
+ set_errored(true);
+ if (client_id_ != -1) {
+ SendDisconnectRequest();
+ }
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/networking/network_reader.cc b/src/ballistica/networking/network_reader.cc
new file mode 100644
index 00000000..7779a58c
--- /dev/null
+++ b/src/ballistica/networking/network_reader.cc
@@ -0,0 +1,493 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/networking/network_reader.h"
+
+#include "ballistica/game/connection/connection_set.h"
+#include "ballistica/game/game.h"
+#include "ballistica/game/player_spec.h"
+#include "ballistica/generic/json.h"
+#include "ballistica/input/remote_app.h"
+#include "ballistica/math/vector3f.h"
+#include "ballistica/networking/network_write_module.h"
+#include "ballistica/networking/sockaddr.h"
+#include "ballistica/platform/platform.h"
+#include "ballistica/python/python.h"
+
+namespace ballistica {
+
+NetworkReader::NetworkReader(int port) : port4_(port), port6_(port) {
+ thread_ = new std::thread(RunThreadStatic, this);
+ assert(g_network_reader == nullptr);
+ g_network_reader = this;
+}
+
+auto NetworkReader::Pause() -> void {
+ assert(InMainThread());
+ assert(!paused_);
+ {
+ std::unique_lock lock(paused_mutex_);
+ paused_ = true;
+ }
+
+ // Ok now attempt to send a quick ping to ourself to wake us up so we can kill
+ // our socket.
+ if (port4_ != -1) {
+ PokeSelf();
+ } else {
+ Log("Error: NetworkReader port is -1 on pause");
+ }
+}
+
+void NetworkReader::Resume() {
+ assert(InMainThread());
+ assert(paused_);
+
+ {
+ std::unique_lock lock(paused_mutex_);
+ paused_ = false;
+ }
+
+ // Poke our thread so it can go on its way.
+ paused_cv_.notify_all();
+}
+
+void NetworkReader::PokeSelf() {
+ int sd = socket(AF_INET, SOCK_DGRAM, 0);
+ if (sd < 0) {
+ Log("ERROR: unable to create sleep ping socket; errno "
+ + g_platform->GetSocketErrorString());
+ } else {
+ struct sockaddr_in serv_addr {};
+ memset(&serv_addr, 0, sizeof(serv_addr));
+ serv_addr.sin_family = AF_INET;
+ serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // NOLINT
+ serv_addr.sin_port = 0; // any
+ int bresult = ::bind(sd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
+ if (bresult == 1) {
+ Log("ERROR: unable to bind sleep socket: "
+ + g_platform->GetSocketErrorString());
+ } else {
+ struct sockaddr_in t_addr {};
+ memset(&t_addr, 0, sizeof(t_addr));
+ t_addr.sin_family = AF_INET;
+ t_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // NOLINT
+ t_addr.sin_port = htons(port4_); // NOLINT
+ char b[1] = {BA_PACKET_POKE};
+ ssize_t sresult =
+ sendto(sd, b, 1, 0, (struct sockaddr*)(&t_addr), sizeof(t_addr));
+ if (sresult == -1) {
+ Log("Error on sleep self-sendto: "
+ + g_platform->GetSocketErrorString());
+ }
+ }
+ g_platform->CloseSocket(sd);
+ }
+}
+
+auto NetworkReader::RunThread() -> int {
+ if (!HeadlessMode()) {
+ remote_server_ = std::make_unique();
+ }
+
+ // Do this whole thing in a loop. If we get put to sleep we just start over.
+ while (true) {
+ // Sleep until we're unpaused.
+ if (paused_) {
+ std::unique_lock lock(paused_mutex_);
+ paused_cv_.wait(lock, [this] { return (!paused_); });
+ }
+ {
+ // This needs to be locked during any socket-descriptor changes/writes.
+ std::lock_guard lock(sd_mutex_);
+
+ int result;
+ int print_port_unavailable = false;
+ int initial_requested_port = port4_;
+
+ sd4_ = socket(AF_INET, SOCK_DGRAM, 0);
+ if (sd4_ < 0) {
+ Log("ERROR: Unable to open host socket; errno "
+ + g_platform->GetSocketErrorString());
+ } else {
+ g_platform->SetSocketNonBlocking(sd4_);
+
+ // Bind to local server port.
+ struct sockaddr_in serv_addr {};
+ serv_addr.sin_family = AF_INET;
+ serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // NOLINT
+
+ // Try our requested port for v4, then go with any available if that
+ // doesn't work.
+ serv_addr.sin_port = htons(port4_); // NOLINT
+ result = ::bind(sd4_, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
+ if (result != 0) {
+ // If we're headless then we abort here; we're useless if we don't get
+ // the port we wanted.
+ if (HeadlessMode()) {
+ Log("FATAL ERROR: unable to bind to requested udp port "
+ + std::to_string(port4_) + " (ipv4)");
+ exit(1);
+ }
+
+ // Primary ipv4 bind failed; try on any port as a backup.
+ print_port_unavailable = true;
+ serv_addr.sin_port = htons(0); // NOLINT
+ result =
+ ::bind(sd4_, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
+
+ // Wuh oh; no ipv6 for us i guess.
+ if (result != 0) {
+ g_platform->CloseSocket(sd4_);
+ sd4_ = -1;
+ }
+ }
+ }
+
+ // See what v4 port we actually wound up with.
+ if (sd4_ != -1) {
+ struct sockaddr_in sa {};
+ socklen_t sa_len = sizeof(sa);
+ if (getsockname(sd4_, reinterpret_cast(&sa), &sa_len) == 0) {
+ port4_ = ntohs(sa.sin_port); // NOLINT
+
+ // Aim for a v6 port to match whatever we wound up with on the v4
+ // side.
+ port6_ = port4_;
+ }
+ }
+
+ // Ok now lets try to create an ipv6 socket on the same port.
+ // (its actually possible to just create a v6 socket and let the OSs
+ // dual-stack support provide v4 connectivity too, but that's not
+ // available everywhere (win XP, etc) so let's do this for now.
+ sd6_ = socket(AF_INET6, SOCK_DGRAM, 0);
+ if (sd6_ < 0) {
+ Log("ERROR: Unable to open ipv6 socket: "
+ + g_platform->GetSocketErrorString());
+ } else {
+ // Since we're explicitly creating both a v4 and v6 socket, tell the v6
+ // to *not* do both itself (not sure if this is necessary; on mac it
+ // still seems to come up.. but apparently that's not always the case).
+ int on = 1;
+ if (setsockopt(sd6_, IPPROTO_IPV6, IPV6_V6ONLY,
+ reinterpret_cast(&on), sizeof(on))
+ == -1) {
+ Log("error setting socket as ipv6-only");
+ }
+
+ g_platform->SetSocketNonBlocking(sd6_);
+ struct sockaddr_in6 serv_addr {};
+ memset(&serv_addr, 0, sizeof(serv_addr));
+ serv_addr.sin6_family = AF_INET6;
+ serv_addr.sin6_port = htons(port6_); // NOLINT
+ serv_addr.sin6_addr = in6addr_any;
+ result = ::bind(sd6_, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
+
+ if (result != 0) {
+ if (HeadlessMode()) {
+ Log("FATAL ERROR: unable to bind to requested udp port "
+ + std::to_string(port6_) + " (ipv6)");
+ exit(1);
+ }
+ // Primary ipv6 bind failed; try backup.
+
+ // We don't care if our random backup ports don't match; only if our
+ // target port failed.
+ if (port6_ == initial_requested_port) {
+ print_port_unavailable = true;
+ }
+ serv_addr.sin6_port = htons(0); // NOLINT
+ result =
+ ::bind(sd6_, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
+ if (result != 0) {
+ // Wuh oh; no ipv6 for us i guess.
+ g_platform->CloseSocket(sd6_);
+ sd6_ = -1;
+ }
+ }
+ }
+
+ // See what v6 port we actually wound up with.
+ if (sd6_ != -1) {
+ struct sockaddr_in sa {};
+ socklen_t sa_len = sizeof(sa);
+ if (getsockname(sd6_, reinterpret_cast(&sa), &sa_len) == 0) {
+ port6_ = ntohs(sa.sin_port); // NOLINT
+ }
+ }
+ if (print_port_unavailable) {
+ // FIXME - use translations here
+ ScreenMessage("Unable to bind udp port "
+ + std::to_string(initial_requested_port)
+ + "; some network functionality may fail.",
+ {1, 0.5f, 0});
+ Log("Unable to bind udp port " + std::to_string(initial_requested_port)
+ + "; some network functionality may fail.",
+ true, false);
+ }
+ }
+ char buffer[10000];
+
+ // Now just listen and forward messages along.
+ while (true) {
+ struct sockaddr_storage from {};
+ socklen_t from_size = sizeof(from);
+ fd_set readset;
+ FD_ZERO(&readset);
+ if (sd4_ != -1) {
+ FD_SET(sd4_, &readset); // NOLINT
+ }
+ if (sd6_ != -1) {
+ FD_SET(sd6_, &readset); // NOLINT
+ }
+ int maxfd = std::max(sd4_, sd6_);
+ int sresult = select(maxfd + 1, &readset, nullptr, nullptr, nullptr);
+ if (sresult == -1) {
+ // No big deal if we get interrupted occasionally.
+ if (g_platform->GetSocketError() == EINTR) {
+ // Aint no thang.
+ } else {
+ // Let's complain for anything else though.
+ Log("Error on select: " + g_platform->GetSocketErrorString());
+ }
+ } else {
+ int* fds[2] = {&sd4_, &sd6_};
+
+ // Wait for any data on either of our sockets.
+ for (auto& fd : fds) {
+ int& sd(*fd);
+ if (sd != -1) {
+ if (FD_ISSET(sd, &readset)) {
+ ssize_t rresult =
+ recvfrom(sd, buffer, sizeof(buffer), 0,
+ reinterpret_cast(&from), &from_size);
+ if (rresult == 0) {
+ Log("ERROR: NetworkReader Recv got length 0; this shouldn't "
+ "happen");
+ } else if (rresult == -1) {
+ // This needs to be locked during any sd changes/writes.
+ std::lock_guard lock(sd_mutex_);
+
+ // If either of our sockets goes down lets close *both* of
+ // them.
+ if (sd4_ != -1) {
+ g_platform->CloseSocket(sd4_);
+ sd4_ = -1;
+ }
+ if (sd6_ != -1) {
+ g_platform->CloseSocket(sd6_);
+ sd6_ = -1;
+ }
+ } else {
+ assert(from_size >= 0);
+ auto rresult2 = static_cast(rresult);
+ // If we get *any* data while paused, kill both our
+ // sockets (we ping ourself for this purpose).
+ if (paused_) {
+ // This needs to be locked during any sd changes/writes.
+ std::lock_guard lock(sd_mutex_);
+ if (sd4_ != -1) {
+ g_platform->CloseSocket(sd4_);
+ sd4_ = -1;
+ }
+ if (sd6_ != -1) {
+ g_platform->CloseSocket(sd6_);
+ sd6_ = -1;
+ }
+ break;
+ }
+ switch (buffer[0]) {
+ case BA_PACKET_POKE:
+ break;
+ case BA_PACKET_SIMPLE_PING: {
+ // This needs to be locked during any sd changes/writes.
+ std::lock_guard lock(sd_mutex_);
+ char msg[1] = {BA_PACKET_SIMPLE_PONG};
+ sendto(sd, msg, 1, 0, reinterpret_cast(&from),
+ from_size);
+ break;
+ }
+ case BA_PACKET_JSON_PING: {
+ if (rresult2 > 1) {
+ std::vector s_buffer(rresult2);
+ memcpy(s_buffer.data(), buffer + 1, rresult2 - 1);
+ s_buffer[rresult2 - 1] = 0; // terminate string
+ std::string response = HandleJSONPing(s_buffer.data());
+ if (!response.empty()) {
+ std::vector msg(1 + response.size());
+ msg[0] = BA_PACKET_JSON_PONG;
+ memcpy(msg.data() + 1, response.c_str(),
+ response.size());
+ std::lock_guard lock(sd_mutex_);
+ sendto(sd, msg.data(),
+ static_cast_check_fit(
+ msg.size()),
+ 0, reinterpret_cast(&from),
+ from_size);
+ }
+ }
+ break;
+ }
+ case BA_PACKET_JSON_PONG: {
+ if (rresult2 > 1) {
+ std::vector s_buffer(rresult2);
+ memcpy(s_buffer.data(), buffer + 1, rresult2 - 1);
+ s_buffer[rresult2 - 1] = 0; // terminate string
+ cJSON* data = cJSON_Parse(s_buffer.data());
+ if (data != nullptr) {
+ cJSON_Delete(data);
+ }
+ }
+ break;
+ }
+ case BA_PACKET_REMOTE_PING:
+ case BA_PACKET_REMOTE_PONG:
+ case BA_PACKET_REMOTE_ID_REQUEST:
+ case BA_PACKET_REMOTE_ID_RESPONSE:
+ case BA_PACKET_REMOTE_DISCONNECT:
+ case BA_PACKET_REMOTE_STATE:
+ case BA_PACKET_REMOTE_STATE2:
+ case BA_PACKET_REMOTE_STATE_ACK:
+ case BA_PACKET_REMOTE_DISCONNECT_ACK:
+ case BA_PACKET_REMOTE_GAME_QUERY:
+ case BA_PACKET_REMOTE_GAME_RESPONSE:
+ // These packets are associated with the remote app; let the
+ // remote server handle them.
+ if (remote_server_) {
+ remote_server_->HandleData(
+ sd, reinterpret_cast(buffer), rresult2,
+ reinterpret_cast(&from),
+ static_cast(from_size));
+ }
+ break;
+
+ case BA_PACKET_CLIENT_REQUEST:
+ case BA_PACKET_CLIENT_ACCEPT:
+ case BA_PACKET_CLIENT_DENY:
+ case BA_PACKET_CLIENT_DENY_ALREADY_IN_PARTY:
+ case BA_PACKET_CLIENT_DENY_VERSION_MISMATCH:
+ case BA_PACKET_CLIENT_DENY_PARTY_FULL:
+ case BA_PACKET_DISCONNECT_FROM_CLIENT_REQUEST:
+ case BA_PACKET_DISCONNECT_FROM_CLIENT_ACK:
+ case BA_PACKET_DISCONNECT_FROM_HOST_REQUEST:
+ case BA_PACKET_DISCONNECT_FROM_HOST_ACK:
+ case BA_PACKET_CLIENT_GAMEPACKET_COMPRESSED:
+ case BA_PACKET_HOST_GAMEPACKET_COMPRESSED: {
+ // These messages are associated with udp host/client
+ // connections.. pass them to the game thread to wrangle.
+ std::vector msg_buffer(rresult2);
+ memcpy(&(msg_buffer[0]), buffer, rresult2);
+ g_game->connections()->PushUDPConnectionPacketCall(
+ msg_buffer, SockAddr(from));
+ break;
+ }
+
+ case BA_PACKET_GAME_QUERY: {
+ if (rresult2 == 5) {
+ // If we're already in a party, don't advertise since they
+ // wouldn't be able to join us anyway.
+ if (g_game->connections()->has_connection_to_host()) {
+ break;
+ }
+
+ // Pull the query id from the packet.
+ uint32_t query_id;
+ memcpy(&query_id, buffer + 1, 4);
+
+ // Ship them a response packet containing the query id,
+ // our protocol version, our unique-session-id, and our
+ // player_spec.
+ char msg[400];
+
+ std::string usid = GetAppInstanceUUID();
+ std::string player_spec_string;
+
+ // If we're signed in, send our account spec.
+ // Otherwise just send a dummy made with our device name.
+ player_spec_string =
+ PlayerSpec::GetAccountPlayerSpec().GetSpecString();
+
+ // This should always be the case (len needs to be 1 byte)
+ assert(player_spec_string.size() < 256);
+
+ assert(!usid.empty());
+ if (usid.size() > 100) {
+ Log("had to truncate session-id; shouldn't happen");
+ usid.resize(100);
+ }
+ if (usid.empty()) {
+ usid = "error";
+ }
+
+ msg[0] = BA_PACKET_GAME_QUERY_RESPONSE;
+ memcpy(msg + 1, &query_id, 4);
+ uint32_t protocol_version = kProtocolVersion;
+ memcpy(msg + 5, &protocol_version, 4);
+ msg[9] = static_cast(usid.size());
+ msg[10] = static_cast(player_spec_string.size());
+
+ memcpy(msg + 11, usid.c_str(), usid.size());
+ memcpy(msg + 11 + usid.size(), player_spec_string.c_str(),
+ player_spec_string.size());
+ size_t msg_len =
+ 11 + player_spec_string.size() + usid.size();
+
+ std::vector msg_buffer(msg_len);
+ memcpy(&msg_buffer[0], msg, msg_len);
+
+ g_network_write_module->PushSendToCall(msg_buffer,
+ SockAddr(from));
+ break;
+
+ } else {
+ Log("Error: Got invalid game-query packet of len "
+ + std::to_string(rresult2) + "; expected 5.");
+ }
+
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // If *both* of our sockets are dead, break out.
+ if (sd4_ == -1 && sd6_ == -1) {
+ break;
+ }
+ }
+
+ // Sleep for a moment to keep us from running wild if we're unable to block.
+ Platform::SleepMS(1000);
+ }
+}
+
+NetworkReader::~NetworkReader() = default;
+
+auto NetworkReader::HandleJSONPing(const std::string& data_str) -> std::string {
+ cJSON* data = cJSON_Parse(data_str.c_str());
+ if (data == nullptr) {
+ return "";
+ }
+ cJSON_Delete(data);
+
+ // Ok lets include some basic info that might be pertinent to someone pinging
+ // us. Currently that includes our current/max connection count.
+ char buffer[256];
+ int party_size = 0;
+ int party_size_max = 10;
+ if (g_python != nullptr) {
+ party_size = g_game->public_party_size();
+ party_size_max = g_game->public_party_max_size();
+ }
+ snprintf(buffer, sizeof(buffer), R"({"b":%d,"ps":%d,"psmx":%d})",
+ kAppBuildNumber, party_size, party_size_max);
+ return buffer;
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/networking/network_write_module.cc b/src/ballistica/networking/network_write_module.cc
new file mode 100644
index 00000000..88b1b415
--- /dev/null
+++ b/src/ballistica/networking/network_write_module.cc
@@ -0,0 +1,31 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/networking/network_write_module.h"
+
+#include "ballistica/networking/networking.h"
+#include "ballistica/networking/sockaddr.h"
+
+namespace ballistica {
+
+NetworkWriteModule::NetworkWriteModule(Thread* thread)
+ : Module("networkWrite", thread) {
+ // we're a singleton
+ assert(g_network_write_module == nullptr);
+ g_network_write_module = this;
+}
+
+void NetworkWriteModule::PushSendToCall(const std::vector& msg,
+ const SockAddr& addr) {
+ // Avoid buffer-full errors if something is causing us to write too often;
+ // these are unreliable messages so its ok to just drop them.
+ if (!CheckPushSafety()) {
+ BA_LOG_ONCE("Excessive send-to calls in net-write-module.");
+ return;
+ }
+ PushCall([this, msg, addr] {
+ assert(g_network_reader);
+ Networking::SendTo(msg, addr);
+ });
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/networking/networking.cc b/src/ballistica/networking/networking.cc
new file mode 100644
index 00000000..0c27f645
--- /dev/null
+++ b/src/ballistica/networking/networking.cc
@@ -0,0 +1,267 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/networking/networking.h"
+
+#include "ballistica/app/app_globals.h"
+#include "ballistica/game/player_spec.h"
+#include "ballistica/networking/network_reader.h"
+#include "ballistica/networking/sockaddr.h"
+#include "ballistica/platform/platform.h"
+
+namespace ballistica {
+
+struct Networking::ScanResultsEntryPriv {
+ PlayerSpec player_spec;
+ std::string address;
+ uint32_t last_query_id{};
+ millisecs_t last_contact_time{};
+};
+
+Networking::Networking() {
+ assert(InGameThread());
+ Resume();
+}
+
+Networking::~Networking() = default;
+
+// Note: for now we're making our host-scan network calls directly from the game
+// thread. This is generally not a good idea since it appears that even in
+// non-blocking mode they're still blocking for 3-4ms sometimes. But for now
+// since this is only used minimally and only while in the UI i guess it's ok.
+void Networking::HostScanCycle() {
+ assert(InGameThread());
+
+ // We need to create a scanner socket - an ipv4 socket we can send out
+ // broadcast messages from.
+ if (scan_socket_ == -1) {
+ scan_socket_ = socket(AF_INET, SOCK_DGRAM, 0);
+
+ if (scan_socket_ == -1) {
+ Log("Error opening scan socket: " + g_platform->GetSocketErrorString()
+ + ".");
+ return;
+ }
+
+ // Since this guy lives in the game-thread we need it to not block.
+ if (!g_platform->SetSocketNonBlocking(scan_socket_)) {
+ Log("Error setting socket non-blocking.");
+ g_platform->CloseSocket(scan_socket_);
+ scan_socket_ = -1;
+ return;
+ }
+
+ // Bind to whatever.
+ struct sockaddr_in serv_addr {};
+ memset(&serv_addr, 0, sizeof(serv_addr));
+ serv_addr.sin_family = AF_INET;
+ serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // NOLINT
+ serv_addr.sin_port = 0; // any
+ int result =
+ ::bind(scan_socket_, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
+ if (result == 1) {
+ Log("Error binding socket: " + g_platform->GetSocketErrorString() + ".");
+ g_platform->CloseSocket(scan_socket_);
+ scan_socket_ = -1;
+ return;
+ }
+
+ // Enable broadcast on the socket.
+ BA_SOCKET_SETSOCKOPT_VAL_TYPE op_val{1};
+ result = setsockopt(scan_socket_, SOL_SOCKET, SO_BROADCAST, &op_val,
+ sizeof(op_val));
+
+ if (result != 0) {
+ Log("Error enabling broadcast for scan-socket: "
+ + g_platform->GetSocketErrorString() + ".");
+ g_platform->CloseSocket(scan_socket_);
+ scan_socket_ = -1;
+ return;
+ }
+ }
+
+ // Ok we've got a valid scanner socket. Now lets send out broadcast pings on
+ // all available networks.
+ std::vector addrs = g_platform->GetBroadcastAddrs();
+ for (auto&& i : addrs) {
+ sockaddr_in addr{};
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons(kDefaultPort); // NOLINT
+ addr.sin_addr.s_addr = htonl(i); // NOLINT
+
+ // Include our query id (so we can sort out which responses come back
+ // quickest).
+ uint8_t data[5];
+ data[0] = BA_PACKET_GAME_QUERY;
+ memcpy(data + 1, &next_scan_query_id_, 4);
+ BA_DEBUG_TIME_CHECK_BEGIN(sendto);
+ ssize_t result = sendto(
+ scan_socket_, reinterpret_cast(data), sizeof(data),
+ 0, reinterpret_cast(&addr), sizeof(addr));
+ BA_DEBUG_TIME_CHECK_END(sendto, 10);
+ if (result == -1) {
+ int err = g_platform->GetSocketError();
+ switch (err) { // NOLINT(hicpp-multiway-paths-covered)
+ case ENETUNREACH:
+ break;
+ default:
+ Log("Error on scanSocket sendto: "
+ + g_platform->GetSocketErrorString());
+ }
+ }
+ }
+ next_scan_query_id_++;
+
+ // ..and see if any responses came in from previous sends.
+ char buffer[256];
+ struct sockaddr_storage from {};
+ socklen_t from_size = sizeof(from);
+ while (true) {
+ BA_DEBUG_TIME_CHECK_BEGIN(recvfrom);
+ ssize_t result = recvfrom(scan_socket_, buffer, sizeof(buffer), 0,
+ reinterpret_cast(&from), &from_size);
+ BA_DEBUG_TIME_CHECK_END(recvfrom, 10);
+
+ if (result == -1) {
+ int err = g_platform->GetSocketError();
+ switch (err) { // NOLINT(hicpp-multiway-paths-covered)
+ case EWOULDBLOCK:
+ break;
+ default:
+ Log("Error: recvfrom error: " + g_platform->GetSocketErrorString());
+ break;
+ }
+ break;
+ }
+
+ if (result > 2 && buffer[0] == BA_PACKET_GAME_QUERY_RESPONSE) {
+ // Size should be between 13 and 366 (1 byte type, 4 byte query_id, 4
+ // byte protocol_id, 1 byte id_len, 1 byte player_spec_len, 1-100 byte
+ // id, 1-255 byte player-spec).
+ if (result >= 14 && result <= 366) {
+ uint32_t protocol_version;
+ uint32_t query_id;
+
+ memcpy(&query_id, buffer + 1, 4);
+ memcpy(&protocol_version, buffer + 5, 4);
+ auto id_len = static_cast(buffer[9]);
+ auto player_spec_len = static_cast(buffer[10]);
+
+ if (id_len > 0 && id_len <= 100 && player_spec_len > 0
+ && player_spec_len <= 255
+ && (11 + id_len + player_spec_len == result)) {
+ char id[101];
+ char player_spec_str[256];
+ memcpy(id, buffer + 11, id_len);
+ memcpy(player_spec_str, buffer + 11 + id_len, player_spec_len);
+
+ id[id_len] = 0;
+ player_spec_str[player_spec_len] = 0;
+
+ // Add or modify an entry for this.
+ {
+ std::lock_guard lock(scan_results_mutex_);
+
+ // Ignore if it looks like its us.
+ if (id != GetAppInstanceUUID()) {
+ std::string key = id;
+ auto i = scan_results_.find(key);
+
+ // Make a new entry if its not there.
+ bool do_update_entry = (i == scan_results_.end()
+ || i->second.last_query_id != query_id);
+ if (do_update_entry) {
+ ScanResultsEntryPriv& entry(scan_results_[key]);
+ entry.player_spec = PlayerSpec(player_spec_str);
+ char buffer2[256];
+ entry.address = inet_ntop(
+ AF_INET,
+ &((reinterpret_cast(&from))->sin_addr),
+ buffer2, sizeof(buffer2));
+ entry.last_query_id = query_id;
+ entry.last_contact_time = GetRealTime();
+ }
+ }
+ PruneScanResults();
+ }
+ } else {
+ Log("Error: Got invalid BA_PACKET_GAME_QUERY_RESPONSE packet");
+ }
+ } else {
+ Log("Error: Got invalid BA_PACKET_GAME_QUERY_RESPONSE packet");
+ }
+ }
+ }
+}
+
+auto Networking::GetScanResults() -> std::vector {
+ std::vector results;
+ results.resize(scan_results_.size());
+ {
+ std::lock_guard lock(scan_results_mutex_);
+ int out_num = 0;
+ for (auto&& i : scan_results_) {
+ ScanResultsEntryPriv& in(i.second);
+ ScanResultsEntry& out(results[out_num]);
+ out.display_string = in.player_spec.GetDisplayString();
+ out.address = in.address;
+ out_num++;
+ }
+ PruneScanResults();
+ }
+ return results;
+}
+
+void Networking::PruneScanResults() {
+ millisecs_t t = GetRealTime();
+ auto i = scan_results_.begin();
+ while (i != scan_results_.end()) {
+ auto i_next = i;
+ i_next++;
+ if (t - i->second.last_contact_time > 3000) {
+ scan_results_.erase(i);
+ }
+ i = i_next;
+ }
+}
+
+void Networking::EndHostScanning() {
+ if (scan_socket_ != -1) {
+ g_platform->CloseSocket(scan_socket_);
+ scan_socket_ = -1;
+ }
+}
+
+void Networking::Pause() {
+ if (!running_) Log("Networking::pause() called with running_ already false");
+ running_ = false;
+
+ // Game is going into background or whatnot. Kill any sockets/etc.
+ EndHostScanning();
+}
+
+void Networking::Resume() {
+ if (running_) {
+ Log("Networking::resume() called with running_ already true");
+ }
+ running_ = true;
+}
+
+void Networking::SendTo(const std::vector& buffer,
+ const SockAddr& addr) {
+ assert(g_network_reader);
+ assert(!buffer.empty());
+
+ // This needs to be locked during any sd changes/writes.
+ std::lock_guard lock(g_network_reader->sd_mutex());
+
+ // Only send if the relevant socket is currently up.. silently ignore
+ // otherwise.
+ int sd = addr.IsV6() ? g_network_reader->sd6() : g_network_reader->sd4();
+ if (sd != -1) {
+ sendto(sd, (const char*)&buffer[0],
+ static_cast_check_fit(buffer.size()), 0,
+ addr.GetSockAddr(), addr.GetSockAddrLen());
+ }
+}
+
+} // namespace ballistica
diff --git a/src/ballistica/networking/sockaddr.cc b/src/ballistica/networking/sockaddr.cc
index 8630d1bd..cc38770c 100644
--- a/src/ballistica/networking/sockaddr.cc
+++ b/src/ballistica/networking/sockaddr.cc
@@ -7,28 +7,8 @@ namespace ballistica {
SockAddr::SockAddr(const std::string& addr, int port) {
memset(&addr_, 0, sizeof(addr_));
- // try ipv4...
+ // Try ipv4 and then ipv6.
{
- // inet_pton is not available on XP :-/
- // hmmm at this point we probably don't care; should test inet_pton.
- // #if BA_OSTYPE_WINDOWS
- // int addr_size = sizeof(addr_);
- // std::wstring addr2;
- // addr2.assign(addr.begin(), addr.end());
- // struct sockaddr_in* a4 = reinterpret_cast(&addr_);
- // struct sockaddr_in6* a6 = reinterpret_cast(&addr_);
- // int result =
- // WSAStringToAddress(const_cast(addr2.c_str()), AF_INET,
- // nullptr, (LPSOCKADDR)a4, &addr_size);
- // if (result == 0) {
- // if (a4->sin_family == AF_INET) {
- // a4->sin_port = htons(port);
- // return;
- // } else if (a6->sin6_family == AF_INET6) {
- // a6->sin6_port = htons(port);
- // }
- // }
- // #else
struct in_addr addr_out {};
int result = inet_pton(AF_INET, addr.c_str(), &addr_out);
if (result == 1) {
@@ -48,9 +28,8 @@ SockAddr::SockAddr(const std::string& addr, int port) {
return;
}
}
- // #endif
}
- throw Exception("Invalid address: '" + addr + "'");
+ throw Exception("Invalid address: '" + addr + "'.");
}
} // namespace ballistica
diff --git a/src/ballistica/python/methods/python_methods_networking.cc b/src/ballistica/python/methods/python_methods_networking.cc
new file mode 100644
index 00000000..e1cef513
--- /dev/null
+++ b/src/ballistica/python/methods/python_methods_networking.cc
@@ -0,0 +1,558 @@
+// Released under the MIT License. See LICENSE for details.
+
+#include "ballistica/python/methods/python_methods_networking.h"
+
+#include "ballistica/app/app_globals.h"
+#include "ballistica/game/connection/connection_set.h"
+#include "ballistica/game/connection/connection_to_client.h"
+#include "ballistica/game/connection/connection_to_host.h"
+#include "ballistica/game/game.h"
+#include "ballistica/math/vector3f.h"
+#include "ballistica/networking/network_reader.h"
+#include "ballistica/networking/networking.h"
+#include "ballistica/networking/sockaddr.h"
+#include "ballistica/networking/telnet_server.h"
+#include "ballistica/platform/platform.h"
+#include "ballistica/python/python.h"
+#include "ballistica/python/python_sys.h"
+
+namespace ballistica {
+
+// Ignore signed bitwise stuff; python macros do it quite a bit.
+#pragma clang diagnostic push
+#pragma ide diagnostic ignored "hicpp-signed-bitwise"
+
+auto PyGetPublicPartyEnabled(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("getpublicpartyenabled");
+ static const char* kwlist[] = {nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "",
+ const_cast(kwlist)))
+ return nullptr;
+ assert(g_python);
+ if (g_game->public_party_enabled()) {
+ Py_RETURN_TRUE;
+ } else {
+ Py_RETURN_FALSE;
+ }
+ BA_PYTHON_CATCH;
+}
+
+auto PySetPublicPartyEnabled(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("setpublicpartyenabled");
+ int enable;
+ static const char* kwlist[] = {"enabled", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "p",
+ const_cast(kwlist), &enable)) {
+ return nullptr;
+ }
+ assert(g_python);
+ g_game->SetPublicPartyEnabled(static_cast(enable));
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PySetPublicPartyName(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("setpublicpartyname");
+ PyObject* name_obj;
+ static const char* kwlist[] = {"name", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "O",
+ const_cast(kwlist), &name_obj)) {
+ return nullptr;
+ }
+ std::string name = Python::GetPyString(name_obj);
+ assert(g_python);
+ g_game->SetPublicPartyName(name);
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PySetPublicPartyStatsURL(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("setpublicpartystatsurl");
+ PyObject* url_obj;
+ static const char* kwlist[] = {"url", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "O",
+ const_cast(kwlist), &url_obj)) {
+ return nullptr;
+ }
+ // The call expects an empty string for the no-url option.
+ std::string url = (url_obj == Py_None) ? "" : Python::GetPyString(url_obj);
+ assert(g_python);
+ g_game->SetPublicPartyStatsURL(url);
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyGetPublicPartyMaxSize(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("getpublicpartymaxsize");
+ static const char* kwlist[] = {nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "",
+ const_cast(kwlist))) {
+ return nullptr;
+ }
+ assert(g_python);
+ return PyLong_FromLong(g_game->public_party_max_size());
+ BA_PYTHON_CATCH;
+}
+
+auto PySetPublicPartyMaxSize(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("setpublicpartymaxsize");
+ int max_size;
+ static const char* kwlist[] = {"max_size", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "i",
+ const_cast(kwlist), &max_size)) {
+ return nullptr;
+ }
+ assert(g_python);
+ g_game->SetPublicPartyMaxSize(max_size);
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PySetAuthenticateClients(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("set_authenticate_clients");
+ int enable;
+ static const char* kwlist[] = {"enable", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "p",
+ const_cast(kwlist), &enable)) {
+ return nullptr;
+ }
+ assert(g_game);
+ g_game->set_require_client_authentication(static_cast(enable));
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PySetAdmins(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("set_admins");
+ PyObject* admins_obj;
+ static const char* kwlist[] = {"admins", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "O",
+ const_cast(kwlist), &admins_obj)) {
+ return nullptr;
+ }
+ assert(g_game);
+
+ auto admins = Python::GetPyStrings(admins_obj);
+ std::set adminset;
+ for (auto&& admin : admins) {
+ adminset.insert(admin);
+ }
+ g_game->set_admin_public_ids(adminset);
+
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PySetEnableDefaultKickVoting(PyObject* self, PyObject* args,
+ PyObject* keywds) -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("set_enable_default_kick_voting");
+ int enable;
+ static const char* kwlist[] = {"enable", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "p",
+ const_cast(kwlist), &enable)) {
+ return nullptr;
+ }
+ assert(g_game);
+ g_game->set_kick_voting_enabled(static_cast(enable));
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyConnectToParty(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("connect_to_party");
+ std::string address;
+ PyObject* address_obj;
+ int port = kDefaultPort;
+
+ // Whether we should print standard 'connecting...' and 'party full..'
+ // messages when false, only odd errors such as version incompatibility will
+ // be printed and most connection attempts will be silent todo: could
+ // generalize this to pass all results to a callback instead
+ int print_progress = 1;
+ static const char* kwlist[] = {"address", "port", "print_progress", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "O|ip",
+ const_cast(kwlist), &address_obj,
+ &port, &print_progress)) {
+ return nullptr;
+ }
+ address = Python::GetPyString(address_obj);
+
+ // Disallow in headless build (people were using this for spam-bots).
+
+ if (HeadlessMode()) {
+ throw Exception("Not available in headless mode.");
+ }
+
+ SockAddr s;
+ try {
+ s = SockAddr(address, port);
+
+ // HACK: CLion currently flags our catch clause as unreachable even
+ // though SockAddr constructor can throw exceptions. Work around that here.
+ if (explicit_bool(false)) {
+ throw Exception();
+ }
+ } catch (const std::exception&) {
+ ScreenMessage(g_game->GetResourceString("invalidAddressErrorText"),
+ {1, 0, 0});
+ Py_RETURN_NONE;
+ }
+ g_game->connections()->PushHostConnectedUDPCall(
+ s, static_cast(print_progress));
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyClientInfoQueryResponse(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("client_info_query_response");
+ const char* token;
+ PyObject* response_obj;
+ static const char* kwlist[] = {"token", "response", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "sO",
+ const_cast(kwlist), &token,
+ &response_obj)) {
+ return nullptr;
+ }
+ g_game->connections()->SetClientInfoFromMasterServer(token, response_obj);
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyGetConnectionToHostInfo(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("get_connection_to_host_info");
+ static const char* kwlist[] = {nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "",
+ const_cast(kwlist))) {
+ return nullptr;
+ }
+ ConnectionToHost* hc = g_game->connections()->connection_to_host();
+ if (hc) {
+ return Py_BuildValue("{sssi}", "name", hc->party_name().c_str(),
+ "build_number", hc->build_number());
+ } else {
+ return Py_BuildValue("{}");
+ }
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyDisconnectFromHost(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("disconnect_from_host");
+ static const char* kwlist[] = {nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "",
+ const_cast(kwlist))) {
+ return nullptr;
+ }
+ g_game->connections()->PushDisconnectFromHostCall();
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyDisconnectClient(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("disconnect_client");
+ int client_id;
+ int ban_time = 300; // Old default before we exposed this.
+ static const char* kwlist[] = {"client_id", "ban_time", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "i|i",
+ const_cast(kwlist), &client_id,
+ &ban_time)) {
+ return nullptr;
+ }
+ bool kickable = g_game->connections()->DisconnectClient(client_id, ban_time);
+ if (kickable) {
+ Py_RETURN_TRUE;
+ } else {
+ Py_RETURN_FALSE;
+ }
+ BA_PYTHON_CATCH;
+}
+
+auto PyGetClientPublicDeviceUUID(PyObject* self, PyObject* args,
+ PyObject* keywds) -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("get_client_public_device_uuid");
+ int client_id;
+ static const char* kwlist[] = {"client_id", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "i",
+ const_cast(kwlist), &client_id)) {
+ return nullptr;
+ }
+ auto&& connection{
+ g_game->connections()->connections_to_clients().find(client_id)};
+
+ // Does this connection exist?
+ if (connection == g_game->connections()->connections_to_clients().end()) {
+ Py_RETURN_NONE;
+ }
+
+ // Connections should always be valid refs.
+ assert(connection->second.exists());
+
+ // Old clients don't assign this; it will be empty.
+ if (connection->second->public_device_id().empty()) {
+ Py_RETURN_NONE;
+ }
+ return PyUnicode_FromString(connection->second->public_device_id().c_str());
+ BA_PYTHON_CATCH;
+}
+
+auto PyGetGamePort(PyObject* self, PyObject* args) -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("get_game_port");
+ int port = 0;
+ if (g_network_reader != nullptr) {
+ // Hmmm; we're just fetching the ipv4 port here; 6 could be different.
+ port = g_network_reader->port4();
+ }
+ return Py_BuildValue("i", port);
+ BA_PYTHON_CATCH;
+}
+
+auto PySetMasterServerSource(PyObject* self, PyObject* args) -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("set_master_server_source");
+ int source;
+ if (!PyArg_ParseTuple(args, "i", &source)) return nullptr;
+ if (source != 0 && source != 1) {
+ BA_LOG_ONCE("Error: Invalid server source: " + std::to_string(source)
+ + ".");
+ source = 1;
+ }
+ g_app_globals->master_server_source = source;
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PySetTelnetAccessEnabled(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("set_telnet_access_enabled");
+ assert(InGameThread());
+ int enable;
+ static const char* kwlist[] = {"enable", nullptr};
+ if (!PyArg_ParseTupleAndKeywords(args, keywds, "p",
+ const_cast(kwlist), &enable)) {
+ return nullptr;
+ }
+ if (g_app_globals->telnet_server) {
+ g_app_globals->telnet_server->SetAccessEnabled(static_cast(enable));
+ } else {
+ throw Exception("Telnet server not enabled.");
+ }
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyHostScanCycle(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("host_scan_cycle");
+ g_networking->HostScanCycle();
+ std::vector results =
+ g_networking->GetScanResults();
+ PyObject* py_list = PyList_New(0);
+ for (auto&& i : results) {
+ PyList_Append(py_list, Py_BuildValue("{ssss}", "display_string",
+ i.display_string.c_str(), "address",
+ i.address.c_str()));
+ }
+ return py_list;
+ BA_PYTHON_CATCH;
+}
+
+auto PyEndHostScanning(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("end_host_scanning");
+ g_networking->EndHostScanning();
+ Py_RETURN_NONE;
+ BA_PYTHON_CATCH;
+}
+
+auto PyHaveConnectedClients(PyObject* self, PyObject* args, PyObject* keywds)
+ -> PyObject* {
+ BA_PYTHON_TRY;
+ Platform::SetLastPyCall("have_connected_clients");
+ if (g_game->connections()->GetConnectedClientCount() > 0) {
+ Py_RETURN_TRUE;
+ } else {
+ Py_RETURN_FALSE;
+ }
+ BA_PYTHON_CATCH;
+}
+
+auto PythonMethodsNetworking::GetMethods() -> std::vector {
+ return {
+ {"have_connected_clients", (PyCFunction)PyHaveConnectedClients,
+ METH_VARARGS | METH_KEYWORDS,
+ "have_connected_clients() -> bool\n"
+ "\n"
+ "(internal)\n"
+ "\n"
+ "Category: General Utility Functions"},
+
+ {"end_host_scanning", (PyCFunction)PyEndHostScanning,
+ METH_VARARGS | METH_KEYWORDS,
+ "end_host_scanning() -> None\n"
+ "\n"
+ "(internal)\n"
+ "\n"
+ "Category: General Utility Functions"},
+
+ {"host_scan_cycle", (PyCFunction)PyHostScanCycle,
+ METH_VARARGS | METH_KEYWORDS,
+ "host_scan_cycle() -> list\n"
+ "\n"
+ "(internal)"},
+
+ {"set_telnet_access_enabled", (PyCFunction)PySetTelnetAccessEnabled,
+ METH_VARARGS | METH_KEYWORDS,
+ "set_telnet_access_enabled(enable: bool)\n"
+ " -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_master_server_source", PySetMasterServerSource, METH_VARARGS,
+ "set_master_server_source(source: int) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"get_game_port", PyGetGamePort, METH_VARARGS,
+ "get_game_port() -> int\n"
+ "\n"
+ "(internal)\n"
+ "\n"
+ "Return the port ballistica is hosting on."},
+
+ {"disconnect_from_host", (PyCFunction)PyDisconnectFromHost,
+ METH_VARARGS | METH_KEYWORDS,
+ "disconnect_from_host() -> None\n"
+ "\n"
+ "(internal)\n"
+ "\n"
+ "Category: General Utility Functions"},
+
+ {"disconnect_client", (PyCFunction)PyDisconnectClient,
+ METH_VARARGS | METH_KEYWORDS,
+ "disconnect_client(client_id: int, ban_time: int = 300) -> bool\n"
+ "\n"
+ "(internal)"},
+
+ {"get_client_public_device_uuid",
+ (PyCFunction)PyGetClientPublicDeviceUUID, METH_VARARGS | METH_KEYWORDS,
+ "get_client_public_device_uuid(client_id: int) -> Optional[str]\n"
+ "\n"
+ "(internal)\n"
+ "\n"
+ "Category: General Utility Functions\n"
+ "\n"
+ "Return a public device UUID for a client. If the client does not\n"
+ "exist or is running a version older than 1.6.10, returns None.\n"
+ "Public device UUID uniquely identifies the device the client is\n"
+ "using in a semi-permanent way. The UUID value will change\n"
+ "periodically with updates to the game or operating system."},
+
+ {"get_connection_to_host_info", (PyCFunction)PyGetConnectionToHostInfo,
+ METH_VARARGS | METH_KEYWORDS,
+ "get_connection_to_host_info() -> dict\n"
+ "\n"
+ "(internal)"},
+
+ {"client_info_query_response", (PyCFunction)PyClientInfoQueryResponse,
+ METH_VARARGS | METH_KEYWORDS,
+ "client_info_query_response(token: str, response: Any) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"connect_to_party", (PyCFunction)PyConnectToParty,
+ METH_VARARGS | METH_KEYWORDS,
+ "connect_to_party(address: str, port: int = None,\n"
+ " print_progress: bool = True) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_authenticate_clients", (PyCFunction)PySetAuthenticateClients,
+ METH_VARARGS | METH_KEYWORDS,
+ "set_authenticate_clients(enable: bool) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_admins", (PyCFunction)PySetAdmins, METH_VARARGS | METH_KEYWORDS,
+ "set_admins(admins: list[str]) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_enable_default_kick_voting",
+ (PyCFunction)PySetEnableDefaultKickVoting, METH_VARARGS | METH_KEYWORDS,
+ "set_enable_default_kick_voting(enable: bool) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_public_party_max_size", (PyCFunction)PySetPublicPartyMaxSize,
+ METH_VARARGS | METH_KEYWORDS,
+ "set_public_party_max_size(max_size: int) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"get_public_party_max_size", (PyCFunction)PyGetPublicPartyMaxSize,
+ METH_VARARGS | METH_KEYWORDS,
+ "get_public_party_max_size() -> int\n"
+ "\n"
+ "(internal)"},
+
+ {"set_public_party_stats_url", (PyCFunction)PySetPublicPartyStatsURL,
+ METH_VARARGS | METH_KEYWORDS,
+ "set_public_party_stats_url(url: Optional[str]) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_public_party_name", (PyCFunction)PySetPublicPartyName,
+ METH_VARARGS | METH_KEYWORDS,
+ "set_public_party_name(name: str) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"set_public_party_enabled", (PyCFunction)PySetPublicPartyEnabled,
+ METH_VARARGS | METH_KEYWORDS,
+ "set_public_party_enabled(enabled: bool) -> None\n"
+ "\n"
+ "(internal)"},
+
+ {"get_public_party_enabled", (PyCFunction)PyGetPublicPartyEnabled,
+ METH_VARARGS | METH_KEYWORDS,
+ "get_public_party_enabled() -> bool\n"
+ "\n"
+ "(internal)"},
+ };
+}
+
+#pragma clang diagnostic pop
+
+} // namespace ballistica
diff --git a/src/ballistica/python/methods/python_methods_networking.h b/src/ballistica/python/methods/python_methods_networking.h
new file mode 100644
index 00000000..bf86808f
--- /dev/null
+++ b/src/ballistica/python/methods/python_methods_networking.h
@@ -0,0 +1,20 @@
+// Released under the MIT License. See LICENSE for details.
+
+#ifndef BALLISTICA_PYTHON_METHODS_PYTHON_METHODS_NETWORKING_H_
+#define BALLISTICA_PYTHON_METHODS_PYTHON_METHODS_NETWORKING_H_
+
+#include
+
+#include "ballistica/ballistica.h"
+
+namespace ballistica {
+
+/// Networking related individual python methods for our module.
+class PythonMethodsNetworking {
+ public:
+ static auto GetMethods() -> std::vector;
+};
+
+} // namespace ballistica
+
+#endif // BALLISTICA_PYTHON_METHODS_PYTHON_METHODS_NETWORKING_H_
diff --git a/src/ballistica/python/python.cc b/src/ballistica/python/python.cc
index 8d86e081..9d3f82e2 100644
--- a/src/ballistica/python/python.cc
+++ b/src/ballistica/python/python.cc
@@ -39,6 +39,7 @@
#include "ballistica/python/methods/python_methods_graphics.h"
#include "ballistica/python/methods/python_methods_input.h"
#include "ballistica/python/methods/python_methods_media.h"
+#include "ballistica/python/methods/python_methods_networking.h"
#include "ballistica/python/methods/python_methods_system.h"
#include "ballistica/python/methods/python_methods_ui.h"
#include "ballistica/python/python_command.h"
@@ -1005,6 +1006,7 @@ void Python::Reset(bool do_init) {
auto Python::GetModuleMethods() -> std::vector {
std::vector all_methods;
for (auto&& methods : {
+ PythonMethodsNetworking::GetMethods(),
PythonMethodsUI::GetMethods(),
PythonMethodsInput::GetMethods(),
PythonMethodsApp::GetMethods(),