From 92ab8db91fa73e5e8447a1ceeccaf55ca20718b0 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Tue, 26 Jul 2022 16:50:13 -0700 Subject: [PATCH] meta improvements --- .efrocachemap | 56 +-- .idea/dictionaries/ericf.xml | 4 + CHANGELOG.md | 5 +- assets/src/ba_data/python/ba/_app.py | 24 +- assets/src/ba_data/python/ba/_gameactivity.py | 3 +- assets/src/ba_data/python/ba/_gameutils.py | 21 + assets/src/ba_data/python/ba/_map.py | 16 - assets/src/ba_data/python/ba/_meta.py | 373 ++++++++---------- assets/src/ba_data/python/ba/_playlist.py | 16 +- assets/src/ba_data/python/ba/_plugin.py | 45 ++- assets/src/ba_data/python/ba/_store.py | 32 ++ assets/src/ba_data/python/ba/internal.py | 17 +- .../python/bastd/ui/onscreenkeyboard.py | 16 +- .../python/bastd/ui/playlist/addgame.py | 5 +- .../.idea/dictionaries/ericf.xml | 4 + src/ballistica/ballistica.cc | 2 +- 16 files changed, 355 insertions(+), 284 deletions(-) diff --git a/.efrocachemap b/.efrocachemap index ce7102c5..321c5e6f 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -3995,26 +3995,26 @@ "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/36/14/efd9145a5e1562b04f6ff560e00d", - "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/68/f3/376aecc59b69d288c276327f0a99", - "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a7/c1/24da478bac1eb0d9e5090a37e2af", - "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a6/e3/a453b14a443de5dc69e32d62b425", - "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d2/1d/b1a59e26f6899e7779abb70e7c69", - "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/62/f8/3798837a711a1e488f97e9d30fea", - "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/ee/11/e183b40452ced610c0ca90f211b2", - "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/2c/d1/2732b76fd513d1aed1ca0a9b9597", - "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/92/26/bb135305ca78c79f03cc54aa8ed6", - "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/03/8a/6fb8aa21f23580e331079c285bbf", - "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/67/66/c70b8602a431689381516385ef4c", - "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e6/e4/cad1d7bf7347a8f505fc9ed3d483", - "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/75/a6/f2926b1c3083cde654e744fa7081", - "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/2c/71/b8587bf758d8774dc5363e99c25b", - "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/78/a8/eea0ff02bd7dfdac45f5e8bb1b91", - "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/2e/cb/c100875d6d87b162c5ee5d8e7623", - "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/51/06/f5d5e4167caf3c1920e041ce79ae", - "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/89/fb/53cbead744939cbef30386a74b0b", - "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/ec/f4/45308c7323f909964fb81ce0b2cc", - "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/b0/f1/0e699be629f7936a67e7b336a383", + "build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/79/7a/c383c0a5aa5cd3a4e998ae2d607f", + "build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/52/36/3db8e70d87bc3399bdc39a26dea4", + "build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/26/d4/3209d789bbf8a73f4914aa7ce4d3", + "build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/59/e3/c2bc89f60961f4ed0708a5340ec8", + "build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d4/8e/f7cb156fb91af56adeb9ed08a76d", + "build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/44/88/634e154803570f4b977a51114706", + "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/85/fd/5afc22c9dfe62bdbec27694b0076", + "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a1/d0/0c906dd30d70314b13d4bbc6b5bf", + "build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/32/2d/80933cdd46b3f8f5a41e431a8ac1", + "build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/e3/1c/2cc790a44ed8abe0d7cab40a4fba", + "build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/df/ab/9222758826b0a48a87abcaa1d4a6", + "build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/77/f4/52b8e2abd145a276e614a9286dbe", + "build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/95/9b/58a4d68b9eaafefa0a108a5cf293", + "build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/cf/ab/572963d01007626cfa067ad750b4", + "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/c5/6b/3a003ef79f24b9d4d7b2c2b71be2", + "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/23/1c/4027cf14f282242f2ea8a11c684d", + "build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/3b/b9/d897246697814e97bd241909d78c", + "build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/11/2e/5019b997e6703cc65033a5ad9aaa", + "build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/9c/1f/c045bc85e0d062fbe496279ecea8", + "build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/e2/c2/2bcbf1dcf557a21b82a8b91dc0a1", "build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1a/6c/0c89987987ba2394da51b13d48cb", "build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b8/b1/7ce57441f44a48185d1842cc954e", "build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/0d/3e/77ed4e2948754ceb60353ab90091", @@ -4031,14 +4031,14 @@ "build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/82/b5/e8a3fa193cff285aa20afeeae35f", "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b5/a5/aba084a4026ebb1cd8ef8c141bc4", "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1c/17/d35f66f340e9cddd948dcec5453d", - "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/b5/cd/789085462ab13214d6addbe06921", - "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/5a/98/cbca8986e08d5fcfdc7aeaef25e2", - "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/7a/2d/cef593252fbe3fddc22f6e788130", - "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/0a/30/b9b0d8629eb372e48cb0bc2d0241", - "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/5e/1f/86c5af445fe4c21e58ebcf5c7f39", - "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/b5/38/ac11b6c33d404a421e8adf354202", - "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/64/2d/2833633e1277af02c072b1cbc03c", - "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/bd/1c/36e1ee0184d510d548f002ddacfe", + "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/f1/c6/8faa5f4940ea61876f3e8e8dfb06", + "build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/ed/c8/c72049370c7fa1f8ef703526b818", + "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/88/74/1404c5895de5eb6da05c32be5cd5", + "build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/36/1b/2710d1d788bf5a6518f6092a9829", + "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/5e/8f/53b5139a47bd6d8b637b3e8c97c2", + "build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/84/e3/f5b80f011508edfabc2e042ea356", + "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/a0/4b/f177f48bb3a0125c072079c561d6", + "build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/3c/57/39092393925cb999582a15023275", "src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/7d/3e/229a581cb2454ed856f1d8b564a7", "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/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index afd796c3..93067d86 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -799,9 +799,11 @@ expectedsig explodable explodey + exportlist exportoptions exportoptionspath exporttype + exporttypestr extradata extraflagmat extrahash @@ -1266,6 +1268,7 @@ jsonutils juleskie kbclass + kbexports kbytecount keepalive keepalives @@ -1782,6 +1785,7 @@ pathcapture pathdst pathlib + pathlist pathnames pathparts pathsrc diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f57bc2..0f965f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ -### 1.7.6 (build 20674, api 7, 2022-07-25) +### 1.7.6 (build 20676, api 7, 2022-07-26) +- Cleaned up MetaSubsystem code +- It is now possible to tell the meta system about arbitrary classes (ba_meta export foo.bar.Class) instead of just the preset types 'plugin', 'game', etc. +- Newly discovered plugins are now activated immediately instead of requiring a restart. ### 1.7.5 (build 20672, api 7, 2022-07-25) - Android build now uses the ReLinker library to load the native main.so, which will (hopefully) avoid some random load failures on older Android versions. diff --git a/assets/src/ba_data/python/ba/_app.py b/assets/src/ba_data/python/ba/_app.py index c04b7fe9..8031d289 100644 --- a/assets/src/ba_data/python/ba/_app.py +++ b/assets/src/ba_data/python/ba/_app.py @@ -223,6 +223,7 @@ class App: self._launch_completed = False self._initial_login_completed = False + self._meta_scan_completed = False self._called_on_app_running = False self._app_paused = False @@ -344,6 +345,8 @@ class App: from bastd.actor import spazappearance from ba._generated.enums import TimeType + assert _ba.in_game_thread() + self._aioloop = _asyncio.setup_asyncio() cfg = self.config @@ -415,6 +418,9 @@ class App: if not self.headless_mode: _ba.timer(3.0, check_special_offer, timetype=TimeType.REAL) + # Get meta-system scanning built-in stuff in the bg. + self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete) + self.accounts_v2.on_app_launch() self.accounts_v1.on_app_launch() @@ -429,17 +435,27 @@ class App: def on_app_running(self) -> None: """Called when initially entering the running state.""" - self.meta.on_app_running() self.plugins.on_app_running() # from ba._dependency import test_depset # test_depset() + def on_meta_scan_complete(self) -> None: + """Called by meta-scan when it is done doing its thing.""" + assert _ba.in_game_thread() + self.plugins.on_meta_scan_complete() + + assert not self._meta_scan_completed + self._meta_scan_completed = True + self._update_state() + def _update_state(self) -> None: + assert _ba.in_game_thread() + if self._app_paused: self.state = self.State.PAUSED else: - if self._initial_login_completed: + if self._initial_login_completed and self._meta_scan_completed: self.state = self.State.RUNNING if not self._called_on_app_running: self._called_on_app_running = True @@ -650,5 +666,9 @@ class App: This should also run after a short amount of time if no login has occurred. """ + # Tell meta it can start scanning extra stuff that just showed up + # (account workspaces). + self.meta.start_extra_scan() + self._initial_login_completed = True self._update_state() diff --git a/assets/src/ba_data/python/ba/_gameactivity.py b/assets/src/ba_data/python/ba/_gameactivity.py index 0d221851..1b91f16f 100644 --- a/assets/src/ba_data/python/ba/_gameactivity.py +++ b/assets/src/ba_data/python/ba/_gameactivity.py @@ -17,6 +17,7 @@ from ba._error import NotFoundError, print_error, print_exception from ba._general import Call, WeakCall from ba._player import PlayerInfo from ba import _map +from ba import _store if TYPE_CHECKING: from typing import Any, Callable, Sequence @@ -1159,7 +1160,7 @@ class GameActivity(Activity[PlayerType, TeamType]): else: # If settings doesn't specify a map, pick a random one from the # list of supported ones. - unowned_maps = _map.get_unowned_maps() + unowned_maps = _store.get_unowned_maps() valid_maps: list[str] = [ m for m in self.get_supported_maps(type(self.session)) if m not in unowned_maps diff --git a/assets/src/ba_data/python/ba/_gameutils.py b/assets/src/ba_data/python/ba/_gameutils.py index 97cac63a..65d93345 100644 --- a/assets/src/ba_data/python/ba/_gameutils.py +++ b/assets/src/ba_data/python/ba/_gameutils.py @@ -399,3 +399,24 @@ def cameraflash(duration: float = 999.0) -> None: light.node.delete, timeformat=TimeFormat.MILLISECONDS) activity.camera_flash_data.append(light) # type: ignore + + +def get_game_types() -> list[type[ba.GameActivity]]: + """Return all available game types.""" + # pylint: disable=cyclic-import + from ba._general import getclass + from ba._gameactivity import GameActivity + from ba._store import get_unowned_game_types + + gameclassnames = _ba.app.meta.wait_for_scan_results().exports_of_class( + GameActivity) + gameclasses = [] + for gameclassname in gameclassnames: + try: + cls = getclass(gameclassname, GameActivity) + gameclasses.append(cls) + except Exception: + from ba import _error + _error.print_exception('error importing ' + str(gameclassname)) + unowned = get_unowned_game_types() + return [cls for cls in gameclasses if cls not in unowned] diff --git a/assets/src/ba_data/python/ba/_map.py b/assets/src/ba_data/python/ba/_map.py index 6cbc48e0..122e4df6 100644 --- a/assets/src/ba_data/python/ba/_map.py +++ b/assets/src/ba_data/python/ba/_map.py @@ -101,22 +101,6 @@ def getmaps(playtype: str) -> list[str]: if playtype in val.get_play_types()) -def get_unowned_maps() -> list[str]: - """Return the list of local maps not owned by the current account. - - Category: **Asset Functions** - """ - from ba import _store - unowned_maps: set[str] = set() - if not _ba.app.headless_mode: - for map_section in _store.get_store_layout()['maps']: - for mapitem in map_section['items']: - if not _ba.get_purchased(mapitem): - m_info = _store.get_store_item(mapitem) - unowned_maps.add(m_info['map_type'].name) - return sorted(unowned_maps) - - def get_map_class(name: str) -> type[ba.Map]: """Return a map type given a name. diff --git a/assets/src/ba_data/python/ba/_meta.py b/assets/src/ba_data/python/ba/_meta.py index 8b29f9e3..a008be98 100644 --- a/assets/src/ba_data/python/ba/_meta.py +++ b/assets/src/ba_data/python/ba/_meta.py @@ -6,15 +6,16 @@ from __future__ import annotations import os import time -import threading +import logging +from threading import Thread from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar from dataclasses import dataclass, field import _ba if TYPE_CHECKING: - import ba + from typing import Callable # The meta api version of this build of the game. # Only packages and modules requiring this exact api version @@ -22,15 +23,28 @@ if TYPE_CHECKING: # See: https://ballistica.net/wiki/Meta-Tags CURRENT_API_VERSION = 7 +# Meta export lines can use these names to represent these classes. +# This is purely a convenience; it is possible to use full class paths +# instead of these or to make the meta system aware of arbitrary classes. +EXPORT_CLASS_NAME_SHORTCUTS: dict[str, str] = { + 'plugin': 'ba.Plugin', + 'keyboard': 'ba.Keyboard', + 'game': 'ba.GameActivity', +} + +T = TypeVar('T') + @dataclass class ScanResults: - """Final results from a metadata scan.""" - games: list[str] = field(default_factory=list) - plugins: list[str] = field(default_factory=list) - keyboards: list[str] = field(default_factory=list) - errors: str = '' - warnings: str = '' + """Final results from a meta-scan.""" + exports: dict[str, list[str]] = field(default_factory=dict) + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def exports_of_class(self, cls: type) -> list[str]: + """Return exports of a given class.""" + return self.exports.get(f'{cls.__module__}.{cls.__qualname__}', []) class MetadataSubsystem: @@ -42,99 +56,52 @@ class MetadataSubsystem: """ def __init__(self) -> None: - self.scanresults: ScanResults | None = None + + self._scan: DirectoryScan | None = None + + # Can be populated before starting the scan. self.extra_scan_dirs: list[str] = [] - def on_app_running(self) -> None: - """Should be called when the app enters the running state.""" + # Results populated once scan is complete. + self.scanresults: ScanResults | None = None - # Start scanning for things exposed via ba_meta. - self.start_scan() + self._scan_complete_cb: Callable[[], None] | None = None - def start_scan(self) -> None: - """Begin scanning script directories for scripts containing metadata. + def start_scan(self, scan_complete_cb: Callable[[], None]) -> None: + """Begin the overall scan. - Should be called only once at launch.""" - app = _ba.app - if self.scanresults is not None: - print('WARNING: meta scan run more than once.') - pythondirs = ([app.python_directory_app, app.python_directory_user] + - self.extra_scan_dirs) - thread = ScanThread(pythondirs) - thread.start() + This will start scanning built in directories (which for vanilla + installs should be the vast majority of the work). This should only + be called once. + """ + assert self._scan_complete_cb is None + assert self._scan is None - def handle_scan_results(self, results: ScanResults) -> None: - """Called in the game thread with results of a completed scan.""" + self._scan_complete_cb = scan_complete_cb + self._scan = DirectoryScan( + [_ba.app.python_directory_app, _ba.app.python_directory_user]) - from ba._language import Lstr - from ba._plugin import PotentialPlugin + Thread(target=self._do_scan_dirs, daemon=True).start() - # Warnings generally only get printed locally for users' benefit - # (things like out-of-date scripts being ignored, etc.) - # Errors are more serious and will get included in the regular log - # warnings = results.get('warnings', '') - # errors = results.get('errors', '') - if results.warnings != '' or results.errors != '': - import textwrap - _ba.screenmessage(Lstr(resource='scanScriptsErrorText'), - color=(1, 0, 0)) - _ba.playsound(_ba.getsound('error')) - if results.warnings != '': - _ba.log(textwrap.indent(results.warnings, - 'Warning (meta-scan): '), - to_server=False) - if results.errors != '': - _ba.log(textwrap.indent(results.errors, 'Error (meta-scan): ')) + def start_extra_scan(self) -> None: + """Provide extra dirs to be scanned (namely Workspace dirs). - # Handle plugins. - plugs = _ba.app.plugins - config_changed = False - found_new = False - plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {}) - assert isinstance(plugstates, dict) + This is the bare minimum part of the scan that must be delayed until + workspaces have been synced/etc. This must be called exactly once. + """ + assert self._scan is not None + self._scan.set_extras(self.extra_scan_dirs) - # Create a potential-plugin for each class we found in the scan. - for class_path in results.plugins: - plugs.potential_plugins.append( - PotentialPlugin(display_name=Lstr(value=class_path), - class_path=class_path, - available=True)) - if class_path not in plugstates: - # Go ahead and enable new plugins by default, but we'll - # inform the user that they need to restart to pick them up. - # they can also disable them in settings so they never load. - plugstates[class_path] = {'enabled': True} - config_changed = True - found_new = True + # Let the game know we're done. + assert self._scan_complete_cb is not None + self._scan_complete_cb() - # Also add a special one for any plugins set to load but *not* found - # in the scan (this way they will show up in the UI so we can disable - # them) - for class_path, plugstate in plugstates.items(): - enabled = plugstate.get('enabled', False) - assert isinstance(enabled, bool) - if enabled and class_path not in results.plugins: - plugs.potential_plugins.append( - PotentialPlugin(display_name=Lstr(value=class_path), - class_path=class_path, - available=False)) - - plugs.potential_plugins.sort(key=lambda p: p.class_path) - - if found_new: - _ba.screenmessage(Lstr(resource='pluginsDetectedText'), - color=(0, 1, 0)) - _ba.playsound(_ba.getsound('ding')) - - if config_changed: - _ba.app.config.commit() - - def get_scan_results(self) -> ScanResults: - """Return meta scan results; block if the scan is not yet complete.""" + def wait_for_scan_results(self) -> ScanResults: + """Return scan results, blocking if the scan is not yet complete.""" if self.scanresults is None: - print('WARNING: ba.meta.get_scan_results()' - ' called before scan completed.' - ' This can cause hitches.') + logging.warning('ba.meta.wait_for_scan_results()' + ' called before scan completed;' + ' this can cause hitches.') # Now wait a bit for the scan to complete. # Eventually error though if it doesn't. @@ -146,69 +113,49 @@ class MetadataSubsystem: 'timeout waiting for meta scan to complete.') return self.scanresults - def get_game_types(self) -> list[type[ba.GameActivity]]: - """Return available game types.""" - from ba._general import getclass - from ba._gameactivity import GameActivity - gameclassnames = self.get_scan_results().games - gameclasses = [] - for gameclassname in gameclassnames: - try: - cls = getclass(gameclassname, GameActivity) - gameclasses.append(cls) - except Exception: - from ba import _error - _error.print_exception('error importing ' + str(gameclassname)) - unowned = self.get_unowned_game_types() - return [cls for cls in gameclasses if cls not in unowned] + def _handle_scan_results(self) -> None: + """Called in the logic thread with results of a completed scan.""" + from ba._language import Lstr + assert _ba.in_game_thread() - def get_unowned_game_types(self) -> set[type[ba.GameActivity]]: - """Return present game types not owned by the current account.""" + results = self.scanresults + assert results is not None + + # Spit out any warnings/errors that happened. + # Warnings generally only get printed locally for users' benefit + # (things like out-of-date scripts being ignored, etc.) + # Errors are more serious and will get included in the regular log. + if results.warnings or results.errors: + import textwrap + _ba.screenmessage(Lstr(resource='scanScriptsErrorText'), + color=(1, 0, 0)) + _ba.playsound(_ba.getsound('error')) + if results.warnings: + _ba.log(textwrap.indent('\n'.join(results.warnings), + 'Warning (meta-scan): '), + to_server=False) + if results.errors: + _ba.log( + textwrap.indent('\n'.join(results.errors), + 'Error (meta-scan): ')) + + def _do_scan_dirs(self) -> None: + """Runs a scan (for use in background thread).""" try: - from ba import _store - unowned_games: set[type[ba.GameActivity]] = set() - if not _ba.app.headless_mode: - for section in _store.get_store_layout()['minigames']: - for mname in section['items']: - if not _ba.get_purchased(mname): - m_info = _store.get_store_item(mname) - unowned_games.add(m_info['gametype']) - return unowned_games - except Exception: - from ba import _error - _error.print_exception('error calcing un-owned games') - return set() - - -class ScanThread(threading.Thread): - """Thread to scan script dirs for metadata.""" - - def __init__(self, dirs: list[str]): - super().__init__() - self._dirs = dirs - - def run(self) -> None: - from ba._general import Call - try: - scan = DirectoryScan(self._dirs) - scan.scan() - results = scan.results + assert self._scan is not None + self._scan.run() + results = self._scan.results + self._scan = None except Exception as exc: - results = ScanResults(errors=f'Scan exception: {exc}') + results = ScanResults(errors=[f'Scan exception: {exc}']) - # Push a call to the game thread to print warnings/errors - # or otherwise deal with scan results. - _ba.pushcall(Call(_ba.app.meta.handle_scan_results, results), - from_other_thread=True) - - # We also, however, immediately make results available. - # This is because the game thread may be blocked waiting - # for them so we can't push a call or we'd get deadlock. - _ba.app.meta.scanresults = results + # Place results and tell the logic thread they're ready. + self.scanresults = results + _ba.pushcall(self._handle_scan_results, from_other_thread=True) class DirectoryScan: - """Handles scanning directories for metadata.""" + """Scans directories for metadata.""" def __init__(self, paths: list[str]): """Given one or more paths, parses available meta information. @@ -218,9 +165,42 @@ class DirectoryScan: """ # Skip non-existent paths completely. - self.paths = [Path(p) for p in paths if os.path.isdir(p)] + self.base_paths = [Path(p) for p in paths if os.path.isdir(p)] + self.extra_paths: list[Path] = [] + self.extra_paths_set = False self.results = ScanResults() + def set_extras(self, paths: list[str]) -> None: + """Set extra portion.""" + # Skip non-existent paths completely. + self.extra_paths += [Path(p) for p in paths if os.path.isdir(p)] + self.extra_paths_set = True + + def run(self) -> None: + """Do the thing.""" + for pathlist in [self.base_paths, self.extra_paths]: + + # Spin and wait until extra paths are provided before doing them. + if pathlist is self.extra_paths: + while not self.extra_paths_set: + time.sleep(0.001) + + modules: list[tuple[Path, Path]] = [] + for path in pathlist: + self._get_path_module_entries(path, '', modules) + for moduledir, subpath in modules: + try: + self._scan_module(moduledir, subpath) + except Exception: + import traceback + self.results.warnings.append( + f"Error scanning '{subpath}': " + + traceback.format_exc()) + + # Sort our results + for exportlist in self.results.exports.values(): + exportlist.sort() + def _get_path_module_entries(self, path: Path, subpath: str | Path, modules: list[tuple[Path, Path]]) -> None: """Scan provided path and add module entries to provided list.""" @@ -235,7 +215,7 @@ class DirectoryScan: entries = [] except Exception as exc: # Unexpected; report this. - self.results.errors += f'{exc}\n' + self.results.errors.append(str(exc)) entries = [] # Now identify python packages/modules out of what we found. @@ -246,24 +226,7 @@ class DirectoryScan: and Path(entry[0], entry[1], '__init__.py').is_file()): modules.append(entry) - def scan(self) -> None: - """Scan provided paths.""" - modules: list[tuple[Path, Path]] = [] - for path in self.paths: - self._get_path_module_entries(path, '', modules) - for moduledir, subpath in modules: - try: - self.scan_module(moduledir, subpath) - except Exception: - import traceback - self.results.warnings += ("Error scanning '" + str(subpath) + - "': " + traceback.format_exc() + - '\n') - # Sort our results - self.results.games.sort() - self.results.plugins.sort() - - def scan_module(self, moduledir: Path, subpath: Path) -> None: + def _scan_module(self, moduledir: Path, subpath: Path) -> None: """Scan an individual module and add the findings to results.""" if subpath.name.endswith('.py'): fpath = Path(moduledir, subpath) @@ -277,19 +240,20 @@ class DirectoryScan: lnum: l[1:].split() for lnum, l in enumerate(flines) if '# ba_meta ' in l } - toplevel = len(subpath.parts) <= 1 - required_api = self.get_api_requirement(subpath, meta_lines, toplevel) + is_top_level = len(subpath.parts) <= 1 + required_api = self._get_api_requirement(subpath, meta_lines, + is_top_level) # Top level modules with no discernible api version get ignored. - if toplevel and required_api is None: + if is_top_level and required_api is None: return # If we find a module requiring a different api version, warn # and ignore. if required_api is not None and required_api != CURRENT_API_VERSION: - self.results.warnings += ( + self.results.warnings.append( f'Warning: {subpath} requires api {required_api} but' - f' we are running {CURRENT_API_VERSION}; ignoring module.\n') + f' we are running {CURRENT_API_VERSION}; ignoring module.') return # Ok; can proceed with a full scan of this module. @@ -302,11 +266,11 @@ class DirectoryScan: self._get_path_module_entries(moduledir, subpath, submodules) for submodule in submodules: if submodule[1].name != '__init__.py': - self.scan_module(submodule[0], submodule[1]) + self._scan_module(submodule[0], submodule[1]) except Exception: import traceback - self.results.warnings += ( - f"Error scanning '{subpath}': {traceback.format_exc()}\n") + self.results.warnings.append( + f"Error scanning '{subpath}': {traceback.format_exc()}") def _process_module_meta_tags(self, subpath: Path, flines: list[str], meta_lines: dict[int, list[str]]) -> None: @@ -315,10 +279,9 @@ class DirectoryScan: # meta_lines is just anything containing '# ba_meta '; make sure # the ba_meta is in the right place. if mline[0] != 'ba_meta': - self.results.warnings += ( - 'Warning: ' + str(subpath) + - ': malformed ba_meta statement on line ' + - str(lindex + 1) + '.\n') + self.results.warnings.append( + f'Warning: {subpath}:' + f' malformed ba_meta statement on line {lindex + 1}.') elif (len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api'): # Ignore 'require api X' lines in this pass. @@ -326,31 +289,28 @@ class DirectoryScan: elif len(mline) != 3 or mline[1] != 'export': # Currently we only support 'ba_meta export FOO'; # complain for anything else we see. - self.results.warnings += ( - 'Warning: ' + str(subpath) + - ': unrecognized ba_meta statement on line ' + - str(lindex + 1) + '.\n') + self.results.warnings.append( + f'Warning: {subpath}' + f': unrecognized ba_meta statement on line {lindex + 1}.') else: # Looks like we've got a valid export line! modulename = '.'.join(subpath.parts) if subpath.name.endswith('.py'): modulename = modulename[:-3] - exporttype = mline[2] + exporttypestr = mline[2] export_class_name = self._get_export_class_name( subpath, flines, lindex) if export_class_name is not None: classname = modulename + '.' + export_class_name - if exporttype == 'game': - self.results.games.append(classname) - elif exporttype == 'plugin': - self.results.plugins.append(classname) - elif exporttype == 'keyboard': - self.results.keyboards.append(classname) - else: - self.results.warnings += ( - 'Warning: ' + str(subpath) + - ': unrecognized export type "' + exporttype + - '" on line ' + str(lindex + 1) + '.\n') + + # If export type is one of our shortcuts, sub in the + # actual class path. Otherwise assume its a classpath + # itself. + exporttype = EXPORT_CLASS_NAME_SHORTCUTS.get(exporttypestr) + if exporttype is None: + exporttype = exporttypestr + self.results.exports.setdefault(exporttype, + []).append(classname) def _get_export_class_name(self, subpath: Path, lines: list[str], lindex: int) -> str | None: @@ -372,13 +332,12 @@ class DirectoryScan: classname = cbits[0] break # Success! if classname is None: - self.results.warnings += ( - 'Warning: ' + str(subpath) + ': class definition not found' - ' below "ba_meta export" statement on line ' + - str(lindexorig + 1) + '.\n') + self.results.warnings.append( + f'Warning: {subpath}: class definition not found below' + f' "ba_meta export" statement on line {lindexorig + 1}.') return classname - def get_api_requirement( + def _get_api_requirement( self, subpath: Path, meta_lines: dict[int, list[str]], @@ -399,15 +358,15 @@ class DirectoryScan: # Ok; not successful. lets issue warnings for a few error cases. if len(lines) > 1: - self.results.warnings += ( - 'Warning: ' + str(subpath) + - ': multiple "# ba_meta require api " lines found;' - ' ignoring module.\n') + self.results.warnings.append( + f'Warning: {subpath}: multiple' + ' "# ba_meta require api " lines found;' + ' ignoring module.') elif not lines and toplevel and meta_lines: # If we're a top-level module containing meta lines but # no valid "require api" line found, complain. - self.results.warnings += ( - 'Warning: ' + str(subpath) + - ': no valid "# ba_meta require api " line found;' - ' ignoring module.\n') + self.results.warnings.append( + f'Warning: {subpath}:' + ' no valid "# ba_meta require api " line found;' + ' ignoring module.') return None diff --git a/assets/src/ba_data/python/ba/_playlist.py b/assets/src/ba_data/python/ba/_playlist.py index f99aabe4..848259a2 100644 --- a/assets/src/ba_data/python/ba/_playlist.py +++ b/assets/src/ba_data/python/ba/_playlist.py @@ -29,14 +29,15 @@ def filter_playlist(playlist: PlaylistType, # pylint: disable=too-many-branches # pylint: disable=too-many-statements import _ba - from ba import _map - from ba import _general - from ba import _gameactivity + from ba._map import get_filtered_map_name + from ba._store import get_unowned_maps, get_unowned_game_types + from ba._general import getclass + from ba._gameactivity import GameActivity goodlist: list[dict] = [] unowned_maps: Sequence[str] if remove_unowned or mark_unowned: - unowned_maps = _map.get_unowned_maps() - unowned_game_types = _ba.app.meta.get_unowned_game_types() + unowned_maps = get_unowned_maps() + unowned_game_types = get_unowned_game_types() else: unowned_maps = [] unowned_game_types = set() @@ -53,7 +54,7 @@ def filter_playlist(playlist: PlaylistType, del entry['map'] # Update old map names to new ones. - entry['settings']['map'] = _map.get_filtered_map_name( + entry['settings']['map'] = get_filtered_map_name( entry['settings']['map']) if remove_unowned and entry['settings']['map'] in unowned_maps: continue @@ -120,8 +121,7 @@ def filter_playlist(playlist: PlaylistType, entry['type'] = ( 'bastd.game.targetpractice.TargetPracticeGame') - gameclass = _general.getclass(entry['type'], - _gameactivity.GameActivity) + gameclass = getclass(entry['type'], GameActivity) if remove_unowned and gameclass in unowned_game_types: continue diff --git a/assets/src/ba_data/python/ba/_plugin.py b/assets/src/ba_data/python/ba/_plugin.py index 9e7200f2..c4702045 100644 --- a/assets/src/ba_data/python/ba/_plugin.py +++ b/assets/src/ba_data/python/ba/_plugin.py @@ -25,6 +25,46 @@ class PluginSubsystem: self.potential_plugins: list[ba.PotentialPlugin] = [] self.active_plugins: dict[str, ba.Plugin] = {} + def on_meta_scan_complete(self) -> None: + """Should be called when meta-scanning is complete.""" + from ba._language import Lstr + + plugs = _ba.app.plugins + config_changed = False + found_new = False + plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {}) + assert isinstance(plugstates, dict) + + results = _ba.app.meta.scanresults + assert results is not None + + # Create a potential-plugin for each class we found in the scan. + for class_path in results.exports_of_class(Plugin): + plugs.potential_plugins.append( + PotentialPlugin(display_name=Lstr(value=class_path), + class_path=class_path, + available=True)) + if class_path not in plugstates: + # Go ahead and enable new plugins by default, but we'll + # inform the user that they need to restart to pick them up. + # they can also disable them in settings so they never load. + plugstates[class_path] = {'enabled': True} + config_changed = True + found_new = True + + plugs.potential_plugins.sort(key=lambda p: p.class_path) + + # Note: these days we complete meta-scan and immediately activate + # plugins, so we don't need the message about 'restart to activate' + # anymore. + if found_new and bool(False): + _ba.screenmessage(Lstr(resource='pluginsDetectedText'), + color=(0, 1, 0)) + _ba.playsound(_ba.getsound('ding')) + + if config_changed: + _ba.app.config.commit() + def on_app_running(self) -> None: """Should be called when the app reaches the running state.""" # Load up our plugins and go ahead and call their on_app_running calls. @@ -69,10 +109,7 @@ class PluginSubsystem: from ba._language import Lstr # Note: the plugins we load is purely based on what's enabled - # in the app config. Our meta-scan gives us a list of available - # plugins, but that is only used to give the user a list of plugins - # that they can enable. (we wouldn't want to look at meta-scan here - # anyway because it may not be done yet at this point in the launch) + # in the app config. Its not our job to look at meta stuff here. plugstates: dict[str, dict] = _ba.app.config.get('Plugins', {}) assert isinstance(plugstates, dict) plugkeys: list[str] = sorted(key for key, val in plugstates.items() diff --git a/assets/src/ba_data/python/ba/_store.py b/assets/src/ba_data/python/ba/_store.py index 81a2e21a..deeefb0d 100644 --- a/assets/src/ba_data/python/ba/_store.py +++ b/assets/src/ba_data/python/ba/_store.py @@ -509,3 +509,35 @@ def get_available_sale_time(tab: str) -> int | None: from ba import _error _error.print_exception('error calcing sale time') return None + + +def get_unowned_maps() -> list[str]: + """Return the list of local maps not owned by the current account. + + Category: **Asset Functions** + """ + unowned_maps: set[str] = set() + if not _ba.app.headless_mode: + for map_section in get_store_layout()['maps']: + for mapitem in map_section['items']: + if not _ba.get_purchased(mapitem): + m_info = get_store_item(mapitem) + unowned_maps.add(m_info['map_type'].name) + return sorted(unowned_maps) + + +def get_unowned_game_types() -> set[type[ba.GameActivity]]: + """Return present game types not owned by the current account.""" + try: + unowned_games: set[type[ba.GameActivity]] = set() + if not _ba.app.headless_mode: + for section in get_store_layout()['minigames']: + for mname in section['items']: + if not _ba.get_purchased(mname): + m_info = get_store_item(mname) + unowned_games.add(m_info['gametype']) + return unowned_games + except Exception: + from ba import _error + _error.print_exception('error calcing un-owned games') + return set() diff --git a/assets/src/ba_data/python/ba/internal.py b/assets/src/ba_data/python/ba/internal.py index 95a7bfc3..7bb22e6d 100644 --- a/assets/src/ba_data/python/ba/internal.py +++ b/assets/src/ba_data/python/ba/internal.py @@ -7,9 +7,8 @@ or disappear without warning, so should be avoided (or used sparingly and defensively) in mods. """ -from ba._map import (get_unowned_maps, get_map_class, register_map, - preload_map_preview_media, get_map_display_string, - get_filtered_map_name) +from ba._map import (get_map_class, register_map, preload_map_preview_media, + get_map_display_string, get_filtered_map_name) from ba._appconfig import commit_app_config from ba._input import (get_device_value, get_input_map_hash, get_input_device_config) @@ -34,13 +33,14 @@ from ba._playlist import (get_default_free_for_all_playlist, from ba._store import (get_available_sale_time, get_available_purchase_count, get_store_item_name_translated, get_store_item_display_size, get_store_layout, - get_store_item, get_clean_price) + get_store_item, get_clean_price, get_unowned_maps, + get_unowned_game_types) from ba._tournament import get_tournament_prize_strings -from ba._gameutils import get_trophy_string +from ba._gameutils import get_trophy_string, get_game_types __all__ = [ - 'get_unowned_maps', 'get_map_class', 'register_map', - 'preload_map_preview_media', 'get_map_display_string', + 'get_unowned_maps', 'get_unowned_game_types', 'get_map_class', + 'register_map', 'preload_map_preview_media', 'get_map_display_string', 'get_filtered_map_name', 'commit_app_config', 'get_device_value', 'get_input_map_hash', 'get_input_device_config', 'getclass', 'json_prep', 'get_type_name', 'JoinActivity', 'ScoreScreenActivity', @@ -56,5 +56,6 @@ __all__ = [ 'get_default_teams_playlist', 'filter_playlist', 'get_available_sale_time', 'get_available_purchase_count', 'get_store_item_name_translated', 'get_store_item_display_size', 'get_store_layout', 'get_store_item', - 'get_clean_price', 'get_tournament_prize_strings', 'get_trophy_string' + 'get_clean_price', 'get_tournament_prize_strings', 'get_trophy_string', + 'get_game_types' ] diff --git a/assets/src/ba_data/python/bastd/ui/onscreenkeyboard.py b/assets/src/ba_data/python/bastd/ui/onscreenkeyboard.py index 69d2303a..3997cca3 100644 --- a/assets/src/ba_data/python/bastd/ui/onscreenkeyboard.py +++ b/assets/src/ba_data/python/bastd/ui/onscreenkeyboard.py @@ -213,8 +213,10 @@ class OnScreenKeyboardWindow(ba.Window): # Show change instructions only if we have more than one # keyboard option. - if (ba.app.meta.scanresults is not None - and len(ba.app.meta.scanresults.keyboards) > 1): + keyboards = (ba.app.meta.scanresults.exports_of_class( + ba.Keyboard) if ba.app.meta.scanresults is not None + else []) + if len(keyboards) > 1: ba.textwidget( parent=self._root_widget, h_align='center', @@ -239,7 +241,8 @@ class OnScreenKeyboardWindow(ba.Window): def _get_keyboard(self) -> ba.Keyboard: assert ba.app.meta.scanresults is not None - classname = ba.app.meta.scanresults.keyboards[self._keyboard_index] + classname = ba.app.meta.scanresults.exports_of_class( + ba.Keyboard)[self._keyboard_index] kbclass = ba.getclass(classname, ba.Keyboard) return kbclass() @@ -318,10 +321,11 @@ class OnScreenKeyboardWindow(ba.Window): def _next_keyboard(self) -> None: assert ba.app.meta.scanresults is not None - self._keyboard_index = (self._keyboard_index + 1) % len( - ba.app.meta.scanresults.keyboards) + kbexports = ba.app.meta.scanresults.exports_of_class(ba.Keyboard) + self._keyboard_index = (self._keyboard_index + 1) % len(kbexports) + self._load_keyboard() - if len(ba.app.meta.scanresults.keyboards) < 2: + if len(kbexports) < 2: ba.playsound(ba.getsound('error')) ba.screenmessage(ba.Lstr(resource='keyboardNoOthersAvailableText'), color=(1, 0, 0)) diff --git a/assets/src/ba_data/python/bastd/ui/playlist/addgame.py b/assets/src/ba_data/python/bastd/ui/playlist/addgame.py index 8ca5c6c2..065749c4 100644 --- a/assets/src/ba_data/python/bastd/ui/playlist/addgame.py +++ b/assets/src/ba_data/python/bastd/ui/playlist/addgame.py @@ -119,6 +119,7 @@ class PlaylistAddGameWindow(ba.Window): self._refresh() def _refresh(self, select_get_more_games_button: bool = False) -> None: + from ba.internal import get_game_types if self._column is not None: self._column.delete() @@ -128,8 +129,8 @@ class PlaylistAddGameWindow(ba.Window): margin=0) gametypes = [ - gt for gt in ba.app.meta.get_game_types() if - gt.supports_session_type(self._editcontroller.get_session_type()) + gt for gt in get_game_types() if gt.supports_session_type( + self._editcontroller.get_session_type()) ] # Sort in the current language. diff --git a/ballisticacore-cmake/.idea/dictionaries/ericf.xml b/ballisticacore-cmake/.idea/dictionaries/ericf.xml index 6faa8fa9..a13e3a39 100644 --- a/ballisticacore-cmake/.idea/dictionaries/ericf.xml +++ b/ballisticacore-cmake/.idea/dictionaries/ericf.xml @@ -412,6 +412,8 @@ expbool expectedsig expl + exportlist + exporttypestr extradata extrahash extrascale @@ -653,6 +655,7 @@ jnames json's juleskie + kbexports keepalives kerploople keyanntype @@ -931,6 +934,7 @@ passcode pathcapture pathdst + pathlist pathparts pathsrc pausable diff --git a/src/ballistica/ballistica.cc b/src/ballistica/ballistica.cc index 743dc92e..08c146dd 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 = 20674; +const int kAppBuildNumber = 20676; const char* kAppVersion = "1.7.6"; // Our standalone globals.