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(),