├── .github ├── FUNDING.yml └── workflows │ └── release.yaml ├── .gitignore ├── BF2AutoSpectator ├── __init__.py ├── __main__.py ├── common │ ├── __init__.py │ ├── classes.py │ ├── commands.py │ ├── config.py │ ├── constants.py │ ├── exceptions.py │ ├── logger.py │ └── utility.py ├── game │ ├── __init__.py │ ├── instance_manager.py │ └── instance_state.py ├── global_state.py ├── remote │ ├── __init__.py │ ├── controller_client.py │ └── obs_client.py └── spectate.py ├── BUILD.md ├── LICENSE.md ├── README.md ├── overrides └── mods │ ├── Arctic_Warfare │ └── Menu_client.zip │ └── bfp2 │ ├── Fonts_client.zip │ ├── localization │ └── english │ │ └── english.utxt │ ├── menu_client.zip │ └── menu_server.zip ├── pickle └── histograms.pickle ├── redist └── bf2-conman.exe ├── renovate.json ├── requirements.txt ├── setup.cfg ├── setup.py └── versionfile.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ cetteup ] 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-and-release: 13 | runs-on: windows-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 3.10 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.10" 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install wheel 24 | pip install pyinstaller pyinstaller-versionfile 25 | $(if (Test-Path requirements.txt) { pip install -r requirements.txt }) 26 | - name: Generate versionfile 27 | run: | 28 | create-version-file.exe versionfile.yaml --outfile versionfile 29 | - name: Build executable 30 | run: | 31 | pyinstaller.exe BF2AutoSpectator\spectate.py --onefile --clean --name="BF2AutoSpectator" --add-data="pickle/*.pickle;pickle/" --add-data="redist/*.exe;redist/" --version-file="versionfile" 32 | - name: Create release archive 33 | run: | 34 | Compress-Archive -Path "dist\BF2AutoSpectator.exe","overrides" -DestinationPath BF2AutoSpectator-${{ github.ref_name }}.zip 35 | - name: Create hash files 36 | run: | 37 | $(Get-FileHash -Path BF2AutoSpectator-${{ github.ref_name }}.zip -Algorithm MD5).Hash.toLower() + "`n" | Out-File -NoNewline BF2AutoSpectator-${{ github.ref_name }}.zip.md5 38 | $(Get-FileHash -Path BF2AutoSpectator-${{ github.ref_name }}.zip -Algorithm SHA256).Hash.toLower() + "`n" | Out-File -NoNewline BF2AutoSpectator-${{ github.ref_name }}.zip.sha256 39 | - name: Create release 40 | uses: softprops/action-gh-release@v2 41 | with: 42 | files: BF2AutoSpectator-${{ github.ref_name }}.zip* 43 | draft: true 44 | generate_release_notes: true 45 | name: BF2AutoSpectator ${{ github.ref_name }} 46 | body: This is the ${{ github.ref_name }} release of the BF2 auto spectator. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .idea/ 127 | 128 | debug/ 129 | BF2AutoSpectator-debug/ 130 | versionfile 131 | -------------------------------------------------------------------------------- /BF2AutoSpectator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/BF2AutoSpectator/__init__.py -------------------------------------------------------------------------------- /BF2AutoSpectator/__main__.py: -------------------------------------------------------------------------------- 1 | from BF2AutoSpectator import spectate 2 | 3 | if __name__ == '__main__': 4 | spectate.run() 5 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/BF2AutoSpectator/common/__init__.py -------------------------------------------------------------------------------- /BF2AutoSpectator/common/classes.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | 4 | def __call__(cls, *args, **kwargs): 5 | if cls not in cls._instances: 6 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 7 | return cls._instances[cls] 8 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/commands.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Union 2 | 3 | from BF2AutoSpectator.common.classes import Singleton 4 | 5 | 6 | class CommandStore(metaclass=Singleton): 7 | commands: Dict[str, Union[bool, dict]] 8 | 9 | def __init__(self): 10 | self.commands = {} 11 | 12 | def set(self, key: str, value: Union[bool, dict]): 13 | self.commands[key] = value 14 | 15 | def get(self, key: str) -> Optional[Union[bool, dict]]: 16 | return self.commands.get(key) 17 | 18 | def pop(self, key: str) -> Optional[Union[bool, dict]]: 19 | if key not in self.commands: 20 | return None 21 | 22 | return self.commands.pop(key) 23 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timedelta 3 | from typing import Tuple, Optional 4 | 5 | from BF2AutoSpectator.common import constants 6 | from BF2AutoSpectator.common.classes import Singleton 7 | 8 | 9 | class Config(metaclass=Singleton): 10 | ROOT_DIR: str = os.path.dirname(__file__).replace('\\BF2AutoSpectator\\common', '') 11 | PWD: str = os.getcwd() 12 | DEBUG_DIR: str = os.path.join(PWD, f'{constants.APP_NAME}-debug') 13 | 14 | __player_name: str 15 | __player_pass: str 16 | __server_ip: str 17 | __server_port: str 18 | __server_pass: Optional[str] 19 | __server_mod: str 20 | 21 | __game_path: str 22 | __tesseract_path: str 23 | __limit_rtl: bool 24 | __instance_rtl: int 25 | __map_load_delay: int 26 | 27 | __use_controller: bool 28 | __controller_base_uri: str 29 | __control_obs: bool 30 | __obs_url: str 31 | __obs_source_name: str 32 | 33 | __resolution: str 34 | __debug_screenshot: bool 35 | 36 | __min_iterations_on_player: int 37 | __max_iterations_on_player: int 38 | __max_iterations_on_default_camera_view: int 39 | __lockup_iterations_on_spawn_menu: int 40 | 41 | __player_rotation_paused: bool = False 42 | __player_rotation_paused_until: datetime = None 43 | 44 | def set_options(self, player_name: str, player_pass: str, server_ip: str, server_port: str, server_pass: str, 45 | server_mod: str, game_path: str, tesseract_path: str, limit_rtl: bool, instance_rtl: int, map_load_delay: int, 46 | use_controller: bool, controller_base_uri: str, control_obs: bool, obs_url: str, obs_source_name: str, 47 | resolution: str, debug_screenshot: bool, 48 | min_iterations_on_player: int, max_iterations_on_player: int, 49 | max_iterations_on_default_camera_view: int, lockup_iterations_on_spawn_menu: int): 50 | self.__player_name = player_name 51 | self.__player_pass = player_pass 52 | self.__server_ip = server_ip 53 | self.__server_port = server_port 54 | self.__server_pass = server_pass 55 | self.__server_mod = server_mod 56 | 57 | self.__game_path = game_path 58 | self.__tesseract_path = tesseract_path 59 | self.__limit_rtl = limit_rtl 60 | self.__instance_rtl = instance_rtl 61 | self.__map_load_delay = map_load_delay 62 | 63 | self.__use_controller = use_controller 64 | self.__controller_base_uri = controller_base_uri 65 | self.__control_obs = control_obs 66 | self.__obs_url = obs_url 67 | self.__obs_source_name = obs_source_name 68 | 69 | self.__resolution = resolution 70 | 71 | self.__debug_screenshot = debug_screenshot 72 | 73 | self.__min_iterations_on_player = min_iterations_on_player 74 | self.__max_iterations_on_player = max_iterations_on_player 75 | self.__max_iterations_on_default_camera_view = max_iterations_on_default_camera_view 76 | self.__lockup_iterations_on_spawn_menu = lockup_iterations_on_spawn_menu 77 | 78 | def get_player_name(self) -> str: 79 | return self.__player_name 80 | 81 | def get_player_pass(self) -> str: 82 | return self.__player_pass 83 | 84 | def set_server(self, server_ip: str, server_port: str, server_pass: Optional[str], server_mod: str) -> None: 85 | self.__server_ip = server_ip 86 | self.__server_port = server_port 87 | self.__server_pass = server_pass 88 | self.__server_mod = server_mod 89 | 90 | def get_server(self) -> Tuple[str, str, Optional[str], str]: 91 | return self.__server_ip, self.__server_port, self.__server_pass, self.__server_mod 92 | 93 | def set_server_ip(self, server_ip: str) -> None: 94 | self.__server_ip = server_ip 95 | 96 | def get_server_ip(self) -> str: 97 | return self.__server_ip 98 | 99 | def set_server_port(self, server_port: str) -> None: 100 | self.__server_port = server_port 101 | 102 | def get_server_port(self) -> str: 103 | return self.__server_port 104 | 105 | def set_server_pass(self, server_pass: str) -> None: 106 | self.__server_pass = server_pass 107 | 108 | def get_server_pass(self) -> str: 109 | return self.__server_pass 110 | 111 | def set_server_mod(self, server_mod: str) -> None: 112 | self.__server_mod = server_mod 113 | 114 | def get_server_mod(self) -> str: 115 | return self.__server_mod 116 | 117 | def get_game_path(self) -> str: 118 | return self.__game_path 119 | 120 | def get_tesseract_path(self) -> str: 121 | return self.__tesseract_path 122 | 123 | def limit_rtl(self) -> bool: 124 | return self.__limit_rtl 125 | 126 | def get_instance_trl(self) -> int: 127 | return self.__instance_rtl 128 | 129 | def get_map_load_delay(self) -> int: 130 | return self.__map_load_delay 131 | 132 | def use_controller(self) -> bool: 133 | return self.__use_controller 134 | 135 | def get_controller_base_uri(self) -> str: 136 | return self.__controller_base_uri 137 | 138 | def control_obs(self) -> bool: 139 | return self.__control_obs 140 | 141 | def get_obs_url(self) -> str: 142 | return self.__obs_url 143 | 144 | def get_obs_source_name(self) -> str: 145 | return self.__obs_source_name 146 | 147 | def get_resolution(self) -> str: 148 | return self.__resolution 149 | 150 | def debug_screenshot(self) -> bool: 151 | return self.__debug_screenshot 152 | 153 | def set_debug_screenshot(self, debug_screenshot: bool) -> None: 154 | self.__debug_screenshot = debug_screenshot 155 | 156 | def get_min_iterations_on_player(self) -> int: 157 | return self.__min_iterations_on_player 158 | 159 | def get_max_iterations_on_player(self) -> int: 160 | return self.__max_iterations_on_player 161 | 162 | def get_max_iterations_on_default_camera_view(self) -> int: 163 | return self.__max_iterations_on_default_camera_view 164 | 165 | def get_lockup_iterations_on_spawn_menu(self) -> int: 166 | return self.__lockup_iterations_on_spawn_menu 167 | 168 | def player_rotation_paused(self) -> bool: 169 | return self.__player_rotation_paused 170 | 171 | def get_player_rotation_paused_until(self) -> datetime: 172 | return self.__player_rotation_paused_until 173 | 174 | def pause_player_rotation(self, pause_for_minutes: int) -> None: 175 | self.__player_rotation_paused = True 176 | self.__player_rotation_paused_until = datetime.now() + timedelta(minutes=pause_for_minutes) 177 | 178 | def unpause_player_rotation(self) -> None: 179 | self.__player_rotation_paused = False 180 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/constants.py: -------------------------------------------------------------------------------- 1 | APP_NAME = 'BF2AutoSpectator' 2 | APP_VERSION = '0.14.0' 3 | BF2_EXE = 'BF2.exe' 4 | BF2_WINDOW_TITLE = 'BF2 (v1.5.3153-802.0, pid:' 5 | TESSERACT_EXE = 'tesseract.exe' 6 | WINDOW_TITLE_BAR_HEIGHT = 31 7 | WINDOW_SHADOW_SIZE = 8 8 | HISTCMP_MAX_DELTA = 0.25 9 | DEFAULT_CAMERA_VIEW_HISTCMP_MAX_DELTA = 0.175 10 | PLAYER_ROTATION_PAUSE_DURATION = 5 11 | TEAMS_SPAWN_MENU_LEFT = ['usmc', 'eu', 'navy-seal', 'sas', 'rebels-left', 'spetsnaz-left', 'peglegs', 'canada-left', 12 | 'russia-left'] 13 | TEAMS_SPAWN_MENU_RIGHT = ['china', 'mec', 'mec-sf', 'insurgent', 'rebels-right', 'spetsnaz-right', 'undead', 14 | 'russia-right', 'canada-right'] 15 | COORDINATES = { 16 | '720p': { 17 | # format for click coordinates: tuple(x coordinate, y coordinate) 18 | # legacy mouse moves use relative offsets instead of absolute coordinates, but are stored the same way 19 | 'clicks': { 20 | 'bfhq-menu-item': (111, 50), 21 | 'multiplayer-menu-item': (331, 50), 22 | 'join-internet-menu-item': (111, 85), 23 | 'quit-menu-item': (1182, 50), 24 | 'connect-to-ip-button': (111, 452), 25 | 'connect-to-ip-ok-button': (777, 362), 26 | 'disconnect-prompt-yes-button': (706, 394), 27 | 'disconnect-button': (1210, 725), 28 | 'game-message-close-button': (806, 412), 29 | 'map-briefing-eor-item': (750, 85), 30 | 'join-game-button': (1210, 725), 31 | 'spawnpoint-deselect': (250, 50), 32 | 'suicide-button': (469, 459) 33 | }, 34 | # format for ocr coordinates: tuple(x coordinate, y coordinate, width, height) 35 | 'ocr': { 36 | 'quit-menu-item': [(1152, 11, 83, 689)], 37 | 'game-message-header': [(392, 192, 758, 503)], 38 | 'game-message-text': [(392, 214, 418, 488)], 39 | 'connect-to-ip-button': [(42, 417, 1128, 285)], 40 | 'disconnect-prompt-header': [(329, 227, 841, 475)], 41 | 'disconnect-button': [(1125, 694, 63, 10)], 42 | 'play-now-button': [(1087, 695, 101, 7)], 43 | 'join-game-button': [(1155, 694, 45, 10)], 44 | 'map-briefing-header': [(16, 81, 1149, 619)], 45 | 'special-forces-class-label': [(52, 94, 1088, 608)], 46 | 'spawn-selected-text': [(520, 4, 568, 700)], 47 | 'suicide-button': [(932, 647, 273, 54)], 48 | 'console-command': [(1, 230, 1274, 480)], 49 | 'eor-header-items': [(64, 51, 1125, 649), (271, 51, 906, 649), (486, 51, 697, 649), (688, 51, 476, 649)], 50 | 'eor-map-details': [(761, 83, 309, 620), (1248, 539, 12, 164), (1197, 517, 13, 183)] 51 | }, 52 | 'hists': { 53 | 'teams': [(60, 38, 1179, 669), (201, 38, 1038, 669)], 54 | 'menu': { 55 | 'multiplayer': (221, 3, 859, 716), 56 | 'join-internet': (10, 42, 1071, 677) 57 | }, 58 | 'eor': { 59 | 'score-list': (10, 42, 1071, 677), 60 | 'top-players': (221, 42, 860, 677), 61 | 'top-scores': (434, 42, 647, 677), 62 | 'map-briefing': (646, 42, 435, 677), 63 | 'loading-bar': (9, 685, 1220, 22) 64 | }, 65 | 'spawn-menu': { 66 | 'close-button': (1232, 38, 23, 664) 67 | }, 68 | 'scoreboard': { 69 | 'table-icons-left': (367, 89, 675, 607), 70 | 'table-icons-right': (992, 89, 50, 607) 71 | } 72 | } 73 | }, 74 | '900p': { 75 | # format for click coordinates: tuple(x coordinate, y coordinate) 76 | # legacy mouse moves use relative offsets instead of absolute coordinates, but are stored the same way 77 | 'clicks': { 78 | 'bfhq-menu-item': (138, 52), 79 | 'multiplayer-menu-item': (410, 52), 80 | 'join-internet-menu-item': (138, 97), 81 | 'quit-menu-item': (1468, 52), 82 | 'connect-to-ip-button': (122, 558), 83 | 'connect-to-ip-ok-button': (958, 440), 84 | 'disconnect-prompt-yes-button': (885, 487), 85 | 'disconnect-button': (1468, 906), 86 | 'game-message-close-button': (1002, 501), 87 | 'map-briefing-eor-item': (938, 97), 88 | 'join-game-button': (1468, 906), 89 | 'spawnpoint-deselect': (250, 50), 90 | 'suicide-button': (497, 455) 91 | }, 92 | # format for ocr coordinates: tuple(x coordinate, y coordinate, width, height) 93 | 'ocr': { 94 | 'quit-menu-item': [(1441, 16, 112, 862)], 95 | 'game-message-header': [(492, 243, 956, 632)], 96 | 'game-message-text': [(492, 269, 588, 611)], 97 | 'connect-to-ip-button': [(54, 520, 1412, 358)], 98 | 'disconnect-prompt-header': [(412, 284, 1056, 591)], 99 | 'disconnect-button': [(1410, 869, 82, 11)], 100 | 'play-now-button': [(1347, 869, 123, 9)], 101 | 'join-game-button': [(1442, 869, 60, 13)], 102 | 'map-briefing-header': [(18, 102, 1441, 776)], 103 | 'special-forces-class-label': [(65, 118, 1371, 762)], 104 | 'spawn-selected-text': [(650, 5, 709, 877)], 105 | 'suicide-button': [(1165, 810, 347, 70)], 106 | 'console-command': [(1, 230, 1594, 660)], 107 | 'eor-header-items': [(80, 63, 1406, 815), (339, 63, 1131, 815), (607, 63, 872, 815), (861, 63, 596, 815)], 108 | 'eor-map-details': [(948, 103, 402, 776), (1556, 675, 20, 207), (1492, 648, 14, 230)] 109 | }, 110 | 'hists': { 111 | 'teams': [(73, 46, 1467, 836), (249, 46, 1291, 836)], 112 | 'menu': { 113 | 'multiplayer': (277, 3, 1074, 896), 114 | 'join-internet': (12, 53, 1339, 846) 115 | }, 116 | 'eor': { 117 | 'score-list': (12, 53, 1339, 846), 118 | 'top-players': (276, 53, 1075, 846), 119 | 'top-scores': (542, 53, 809, 846), 120 | 'map-briefing': (808, 53, 543, 846), 121 | 'loading-bar': (12, 858, 1525, 27) 122 | }, 123 | 'spawn-menu': { 124 | 'close-button': (1541, 47, 30, 830) 125 | }, 126 | 'scoreboard': { 127 | 'table-icons-left': (457, 111, 845, 761), 128 | 'table-icons-right': (1240, 111, 62, 761) 129 | } 130 | } 131 | }, 132 | # format for spawn coordinates: list(team 0 tuple, team 1 tuple, alternate spawn tuple...) 133 | # with tuple(x offset, y offset) 134 | 'spawns': { 135 | 'dalian-plant': { 136 | '16': [(361, 237), (400, 325)], 137 | '32': [(618, 218), (292, 296)], 138 | '64': [(618, 218), (296, 296)] 139 | }, 140 | 'strike-at-karkand': { 141 | '16': [(490, 390), (463, 98)], 142 | '32': [(444, 397), (529, 96), (418, 237), (355, 131), (401, 186), (418, 139), (454, 102)], 143 | '64': [(382, 390), (569, 160), (356, 236), (339, 186), (294, 131), (393, 101), (466, 149), (468, 96), (521, 117)] 144 | }, 145 | 'dragon-valley': { 146 | '16': [(333, 154), (530, 308)], 147 | '32': [(487, 98), (497, 358), (438, 159), (360, 243), (439, 257), (392, 319)], 148 | '64': [(520, 63), (476, 363), (404, 55), (458, 118), (396, 137), (466, 177), (426, 160), (432, 225), (377, 283), (432, 294), (400, 337)] 149 | }, 150 | 'fushe-pass': { 151 | '16': [(565, 201), (380, 272)], 152 | '32': [(537, 309), (322, 189)], 153 | '64': [(562, 132), (253, 312)] 154 | }, 155 | 'daqing-oilfields': { 156 | '16': [(397, 324), (474, 106)], 157 | '32': [(504, 353), (363, 138)], 158 | '64': [(500, 346), (363, 137)] 159 | }, 160 | 'gulf-of-oman': { 161 | '16': [(416, 355), (434, 122)], 162 | '32': [(305, 307), (606, 94)], 163 | '64': [(302, 330), (581, 132)] 164 | }, 165 | 'road-to-jalalabad': { 166 | '16': [(382, 315), (487, 133)], 167 | '32': [(313, 163), (566, 158), (403, 291), (461, 320), (498, 274), (564, 253), (536, 206)], 168 | '64': [(314, 159), (569, 156), (432, 198), (403, 291), (461, 320), (498, 274), (564, 253), (536, 206)] 169 | }, 170 | 'wake-island-2007': { 171 | '64': [(283, 91), (524, 290), (445, 194), (503, 192), (434, 274), (469, 297)] 172 | }, 173 | 'zatar-wetlands': { 174 | '16': [(304, 251), (559, 277)], 175 | '32': [(271, 199), (594, 316)], 176 | '64': [(328, 140), (604, 336)] 177 | }, 178 | 'sharqi-peninsula': { 179 | '16': [(495, 209), (360, 284)], 180 | '32': [(475, 222), (321, 130), (436, 225), (403, 222), (412, 255), (372, 203)], 181 | '64': [(476, 220), (321, 128), (436, 225), (403, 222), (422, 194), (412, 255), (372, 203)] 182 | }, 183 | 'kubra-dam': { 184 | '16': [(555, 240), (391, 145)], 185 | '32': [(491, 140), (336, 330), (426, 150), (339, 135), (383, 254), (376, 184), (332, 240), (304, 210)], 186 | '64': [(494, 137), (336, 330), (426, 150), (382, 93), (339, 135), (383, 254), (376, 184), (332, 240), (304, 210)] 187 | }, 188 | 'operation-clean-sweep': { 189 | '16': [(392, 113), (525, 361)], 190 | '32': [(373, 120), (549, 332), (379, 251), (426, 333), (489, 336), (528, 378)], 191 | '64': [(326, 120), (579, 249), (334, 241), (376, 316), (435, 320), (473, 360), (490, 310), (563, 289)] 192 | }, 193 | 'mashtuur-city': { 194 | '16': [(503, 316), (406, 155)], 195 | '32': [(560, 319), (328, 89), (481, 294), (452, 238), (328, 89), (560, 319), (393, 194), (356, 274)], 196 | '64': [(563, 319), (328, 89), (519, 220), (481, 294), (452, 238), (328, 89), (560, 319), (393, 194), (356, 274)] 197 | }, 198 | 'midnight-sun': { 199 | '16': [(551, 196), (344, 259)], 200 | '32': [(591, 207), (293, 274)], 201 | '64': [(590, 207), (317, 287)] 202 | }, 203 | 'operation-road-rage': { 204 | '16': [(566, 158), (332, 311)], 205 | '32': [(419, 32), (457, 409)], 206 | '64': [(419, 32), (458, 407)] 207 | }, 208 | 'taraba-quarry': { 209 | '16': [(567, 355), (357, 85)], 210 | '32': [(569, 346), (310, 379)] 211 | }, 212 | 'great-wall': { 213 | '16': [(312, 186), (544, 199)], 214 | '32': [(529, 122), (368, 360)] 215 | }, 216 | 'highway-tampa': { 217 | '16': [(579, 312), (330, 380)], 218 | '32': [(612, 246), (426, 54)], 219 | '64': [(612, 246), (428, 52)] 220 | }, 221 | 'operation-blue-pearl': { 222 | '16': [(586, 317), (309, 169)], 223 | '32': [(602, 257), (270, 208), (445, 233), (404, 291), (383, 231), (333, 262), (297, 277)], 224 | '64': [(588, 268), (280, 154), (435, 258), (403, 316), (381, 256), (333, 287), (297, 302), (280, 247)] 225 | }, 226 | 'songhua-stalemate': { 227 | '16': [(529, 252), (345, 248)], 228 | '32': [(561, 247), (305, 234), (502, 247), (462, 305), (305, 234), (561, 247), (416, 219), (369, 242)], 229 | '64': [(561, 247), (305, 234), (502, 247), (530, 143), (458, 295), (305, 234), (561, 247), (402, 339), (416, 219), (369, 242)] 230 | }, 231 | 'operation-harvest': { 232 | '16': [(314, 342), (585, 124)], 233 | '32': [(315, 389), (506, 93)], 234 | '64': [(544, 393), (509, 93), (486, 333), (401, 272), (498, 232), (555, 196), (500, 164)] 235 | }, 236 | 'operation-smoke-screen': { 237 | '16': [(398, 88), (506, 358)], 238 | '32': [(434, 98), (466, 383)] 239 | }, 240 | 'warlord': { 241 | '16': [(385, 347), (425, 100), (401, 263), (448, 143), (401, 143)], 242 | '32': [(465, 372), (427, 108), (398, 309), (460, 273), (412, 227), (427, 178), (453, 107)], 243 | '64': [(440, 370), (403, 111), (376, 309), (433, 277), (393, 231), (480, 201), (406, 183), (431, 111)] 244 | }, 245 | 'surge': { 246 | '16': [(415, 355), (425, 138), (429, 253)], 247 | '32': [(465, 389), (406, 84), (398, 206), (475, 221), (528, 117), (404, 150)], 248 | '64': [(467, 380), (411, 99), (318, 307), (442, 273), (473, 231), (517, 141), (323, 152), (406, 165)] 249 | }, 250 | 'night-flight': { 251 | '16': [(495, 106), (453, 314), (495, 226), (375, 235)], 252 | '32': [(495, 108), (435, 331), (480, 177), (386, 184), (447, 246)], 253 | '64': [(544, 120), (391, 337), (439, 131), (432, 195), (346, 202), (401, 258)] 254 | }, 255 | 'mass-destruction': { 256 | '16': [(540, 108), (414, 324), (552, 250), (403, 241)], 257 | '32': [(386, 372), (504, 179), (373, 318), (473, 328), (455, 233), (530, 317)], 258 | '64': [(386, 369), (481, 120), (373, 318), (473, 333), (530, 315), (455, 233), (509, 176)] 259 | }, 260 | 'devils-perch': { 261 | '16': [(472, 203), (369, 218), (467, 296), (411, 203), (401, 249)], 262 | '32': [(519, 236), (350, 232), (457, 182), (443, 252), (426, 302), (386, 214)], 263 | '64': [(397, 172), (324, 277), (578, 306), (415, 236), (401, 294), (561, 272), (507, 277), (459, 262), (386, 334), (355, 263)] 264 | }, 265 | 'ghost-town': { 266 | '16': [(498, 299), (378, 139), (447, 232), (339, 236)], 267 | '32': [(419, 358), (461, 124)], 268 | '64': [(419, 358), (461, 124)] 269 | }, 270 | 'the-iron-gator': { 271 | '16': [(411, 204), (471, 303), (384, 158), (425, 223)], 272 | '32': [(339, 187), (494, 151), (349, 201), (381, 248), (370, 220), (406, 273), (389, 265)], 273 | '64': [(339, 187), (494, 151), (349, 201), (381, 248), (370, 220), (406, 273), (389, 265)] 274 | }, 275 | 'leviathan': { 276 | '16': [(323, 96), (578, 324), (325, 155), (387, 206), (508, 236)], 277 | '32': [(271, 225), (621, 285), (342, 204), (335, 168), (402, 291), (449, 322), (402, 229)], 278 | '64': [(264, 215), (618, 287), (330, 202), (332, 172), (401, 100), (399, 274), (451, 185), (446, 322), (544, 212)] 279 | }, 280 | 'dalian-2v2': { 281 | '16': [(573, 296), (375, 108)], 282 | '32': [(607, 238), (307, 307)], 283 | '64': [(607, 238), (307, 307)] 284 | }, 285 | 'sharqi-2v2': { 286 | # All sizes are the exact same 287 | '16': [(328, 73), (462, 380)], 288 | '32': [(328, 73), (462, 380)], 289 | '64': [(328, 73), (462, 380)] 290 | }, 291 | 'dragon-2v2': { 292 | # 32 and 64 size are broken (no spawn menu) 293 | '16': [(322, 155), (490, 299)], 294 | }, 295 | 'daqing-2v2': { 296 | # All sizes are the exact same 297 | '16': [(425, 354), (503, 102)], 298 | '32': [(425, 354), (503, 102)], 299 | '64': [(425, 354), (503, 102)] 300 | }, 301 | 'black-beards-atol': { 302 | '16': [(308, 238), (560, 230)], 303 | '32': [(444, 360), (440, 120)], 304 | '64': [(426, 395), (453, 94)] 305 | }, 306 | 'black-beards-atol-ctf': { 307 | # 64 is the only available size 308 | '64': [(412, 395), (443, 78)] 309 | }, 310 | 'blue-bayou': { 311 | '16': [(364, 130), (504, 324)], 312 | '32': [(355, 153), (485, 329)], 313 | '64': [(363, 163), (480, 324)] 314 | }, 315 | 'blue-bayou-ctf': { 316 | # 64 is the only available size 317 | '64': [(363, 128), (505, 325)] 318 | }, 319 | 'blue-bayou-zombie': { 320 | # 64 is the only available size and primary team spawns currently do not work reliably because 321 | # you sometimes need to select the zombie class first 322 | '64': [(396, 183), (378, 203)] 323 | }, 324 | 'crossbones-keep': { 325 | '16': [(448, 324), (450, 185)], 326 | '32': [(315, 379), (450, 185)], 327 | '64': [(315, 379), (450, 185)] 328 | }, 329 | 'crossbones-keep-zombie': { 330 | # 64 is the only available size and primary team spawns currently do not work reliably because 331 | # you sometimes need to select the zombie class first 332 | '64': [(453, 145), (456, 112)] 333 | }, 334 | 'dead-calm': { 335 | '16': [(406, 127), (474, 302)], 336 | '32': [(274, 355), (600, 90)], 337 | '64': [(274, 355), (600, 90)] 338 | }, 339 | 'frylar': { 340 | '16': [(500, 240), (366, 200)], 341 | '32': [(500, 240), (366, 200)], 342 | '64': [(500, 240), (366, 200)] 343 | }, 344 | 'frylar-ctf': { 345 | # 64 is the only available size 346 | '64': [(500, 240), (366, 200)] 347 | }, 348 | 'frylar-zombie': { 349 | # 64 is the only available size and primary team spawns currently do not work reliably because 350 | # you sometimes need to select the zombie class first 351 | '64': [(539, 246), (457, 230)] 352 | }, 353 | 'lost-at-sea': { 354 | '16': [(565, 356), (327, 118)], 355 | '32': [(565, 356), (327, 118)], 356 | '64': [(565, 356), (327, 118)] 357 | }, 358 | 'ome-hearty-beach': { 359 | '16': [(348, 208), (580, 180)], 360 | '32': [(296, 208), (560, 238)], 361 | '64': [(296, 208), (560, 238)] 362 | }, 363 | 'ome-hearty-beach-zombie': { 364 | # 64 is the only available size and primary team spawns currently do not work reliably because 365 | # you sometimes need to select the zombie class first 366 | '64': [(315, 215), (423, 240)] 367 | }, 368 | 'pelican-point': { 369 | '16': [(472, 120), (455, 365)], 370 | '32': [(433, 139), (421, 295)], 371 | '64': [(433, 139), (338, 331)] 372 | }, 373 | 'pelican-point-ctf': { 374 | # 64 is the only available size 375 | '64': [(462, 117), (385, 340)] 376 | }, 377 | 'pressgang-port': { 378 | '16': [(428, 80), (452, 385)], 379 | '32': [(442, 139), (438, 325)], 380 | '64': [(442, 68), (440, 398)] 381 | }, 382 | 'pressgang-port-ctf': { 383 | # 64 is the only available size 384 | '64': [(440, 383), (441, 81)] 385 | }, 386 | 'sailors-warning': { 387 | '16': [(273, 268), (610, 268)], 388 | '32': [(273, 268), (610, 268)], 389 | '64': [(273, 268), (610, 268)] 390 | }, 391 | 'shallow-draft': { 392 | '16': [(523, 120), (326, 353)], 393 | '32': [(523, 117), (340, 334)], 394 | '64': [(526, 114), (340, 334)] 395 | }, 396 | 'shallow-draft-ctf': { 397 | # 64 is the only available size 398 | '64': [(522, 112), (340, 334)] 399 | }, 400 | 'shipwreck-shoals': { 401 | '16': [(359, 278), (468, 258)], 402 | '32': [(359, 278), (468, 258)], 403 | '64': [(359, 278), (468, 258)] 404 | }, 405 | 'shipwreck-shoals-ctf': { 406 | # 64 is the only available size 407 | '64': [(418, 101), (495, 364)] 408 | }, 409 | 'shiver-me-timbers': { 410 | '16': [(537, 362), (392, 127)], 411 | '32': [(547, 235), (438, 107)], 412 | '64': [(547, 242), (429, 168)] 413 | }, 414 | 'shiver-me-timbers-ctf': { 415 | # 64 is the only available size 416 | '64': [(540, 272), (315, 110)] 417 | }, 418 | 'storm-the-bastion': { 419 | '16': [(501, 348), (345, 158)], 420 | '32': [(510, 98), (370, 180)], 421 | '64': [(454, 188), (274, 383)] 422 | }, 423 | 'storm-the-bastion-zombie': { 424 | # Primary team spawns currently do not work reliably because you sometimes 425 | # need to select the zombie class first 426 | '32': [(411, 238), (411, 264)], 427 | '64': [(526, 293), (403, 210)] 428 | }, 429 | 'stranded': { 430 | '16': [(353, 290), (510, 178)], 431 | '32': [(608, 401), (278, 78)], 432 | '64': [(608, 401), (278, 78)] 433 | }, 434 | 'stranded-ctf': { 435 | # 64 is the only available size 436 | '64': [(353, 290), (507, 178)] 437 | }, 438 | 'wake-island-1707': { 439 | '16': [(390, 304), (521, 157)], 440 | '32': [(560, 350), (492, 106)], 441 | '64': [(534, 319), (350, 155)] 442 | }, 443 | 'yukon-bridge': { 444 | '16': [(578, 226), (291, 231)], 445 | '32': [(569, 229), (291, 234), (434, 205)], 446 | '64': [(567, 223), (301, 234), (434, 203)] 447 | }, 448 | 'rocky-mountains': { 449 | '16': [(461, 110), (458, 343)], 450 | '32': [(468, 30), (461, 413)], 451 | '64': [(436, 30), (433, 410)] 452 | }, 453 | 'stalingrad-snow': { 454 | '16': [(426, 63), (444, 402)], 455 | '32': [(579, 134), (442, 342)], 456 | '64': [(351, 63), (451, 410), (445, 64), (535, 61)] 457 | }, 458 | 'spring-thaw': { 459 | # 32 is the only available size 460 | '32': [(481, 82), (327, 371)] 461 | }, 462 | 'frostbite-night': { 463 | # 16 is the only available size 464 | '16': [(574, 270), (355, 169)], 465 | }, 466 | 'frostbite': { 467 | # No 64 size available 468 | '16': [(550, 372), (359, 95)], 469 | '32': [(596, 232), (287, 392)] 470 | }, 471 | 'christmas-hill': { 472 | # 32 is the only available size 473 | '32': [(428, 239), (423, 77)] 474 | }, 475 | 'blitzkrieg': { 476 | # 16 is the only available size 477 | '16': [(576, 134), (351, 355)] 478 | }, 479 | 'alpin-ressort': { 480 | '16': [(398, 214), (523, 263)], 481 | '32': [(516, 230), (305, 219)], 482 | '64': [(312, 86), (497, 328)] 483 | }, 484 | 'snowy-park-day': { 485 | # 16 is the only available size 486 | '16': [(438, 227), (451, 227)] 487 | }, 488 | 'snowy-park': { 489 | # 16 is the only available size 490 | '16': [(438, 227), (451, 227)] 491 | }, 492 | 'winter-wake-island': { 493 | # Map offers all sizes, but they are all the same 494 | '16': [(359, 158), (524, 290), (445, 194), (503, 192), (434, 274), (469, 297)], 495 | '32': [(359, 158), (524, 290), (445, 194), (503, 192), (434, 274), (469, 297)], 496 | '64': [(359, 158), (524, 290), (445, 194), (503, 192), (434, 274), (469, 297)] 497 | }, 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/exceptions.py: -------------------------------------------------------------------------------- 1 | class SpectatorException(Exception): 2 | pass 3 | 4 | 5 | class SpawnCoordinatesNotAvailableException(SpectatorException): 6 | pass 7 | 8 | 9 | class ClientNotConnectedException(SpectatorException): 10 | pass 11 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/logger.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | import os.path 3 | import sys 4 | from logging.handlers import RotatingFileHandler 5 | 6 | from BF2AutoSpectator.common.config import Config 7 | 8 | logger = logging.getLogger('BF2AutoSpectator') 9 | logger.propagate = False 10 | 11 | formatter = logging.Formatter(fmt='%(asctime)s %(levelname)-8s %(message)s') 12 | 13 | sh = logging.StreamHandler( 14 | stream=sys.stdout 15 | ) 16 | sh.setFormatter(formatter) 17 | logger.addHandler(sh) 18 | 19 | rfh = RotatingFileHandler( 20 | filename=os.path.join(Config.PWD, 'BF2AutoSpectator.log'), 21 | maxBytes=100*1000*1000 # keep 100 megabytes of logs 22 | ) 23 | rfh.setFormatter(formatter) 24 | logger.addHandler(rfh) 25 | -------------------------------------------------------------------------------- /BF2AutoSpectator/common/utility.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import os 3 | import subprocess 4 | import time 5 | from datetime import datetime 6 | from enum import Enum 7 | from typing import Optional, Tuple, List, Union 8 | 9 | import cv2 10 | import jellyfish 11 | import numpy as np 12 | import psutil 13 | import pyautogui 14 | import pytesseract 15 | import win32api 16 | import win32con 17 | import win32gui 18 | import win32process 19 | from PIL import Image, ImageOps 20 | from numpy import ndarray 21 | 22 | from BF2AutoSpectator.common import constants 23 | from BF2AutoSpectator.common.config import Config 24 | from BF2AutoSpectator.common.logger import logger 25 | 26 | SendInput = ctypes.windll.user32.SendInput 27 | # C struct redefinitions 28 | PUL = ctypes.POINTER(ctypes.c_ulong) 29 | 30 | 31 | class Window: 32 | handle: int 33 | title: str 34 | rect: Tuple[int, int, int, int] 35 | class_name: str 36 | pid: int 37 | 38 | def __init__(self, handle: int, title: str, rect: Tuple[int, int, int, int], class_name: str, pid: int): 39 | self.handle = handle 40 | self.title = title 41 | self.rect = rect 42 | self.class_name = class_name 43 | self.pid = pid 44 | 45 | def get_size(self) -> Tuple[int, int]: 46 | # Size on Windows contains the window header and the halo/shadow around the window, 47 | # which needs to be subtracted to get the real size 48 | left, top, right, bottom = self.rect 49 | return right - constants.WINDOW_SHADOW_SIZE - left - constants.WINDOW_SHADOW_SIZE, \ 50 | bottom - constants.WINDOW_SHADOW_SIZE - top - constants.WINDOW_TITLE_BAR_HEIGHT 51 | 52 | 53 | class KeyBdInput(ctypes.Structure): 54 | _fields_ = [("wVk", ctypes.c_ushort), 55 | ("wScan", ctypes.c_ushort), 56 | ("dwFlags", ctypes.c_ulong), 57 | ("time", ctypes.c_ulong), 58 | ("dwExtraInfo", PUL)] 59 | 60 | 61 | class HardwareInput(ctypes.Structure): 62 | _fields_ = [("uMsg", ctypes.c_ulong), 63 | ("wParamL", ctypes.c_short), 64 | ("wParamH", ctypes.c_ushort)] 65 | 66 | 67 | class MouseInput(ctypes.Structure): 68 | _fields_ = [("dx", ctypes.c_long), 69 | ("dy", ctypes.c_long), 70 | ("mouseData", ctypes.c_ulong), 71 | ("dwFlags", ctypes.c_ulong), 72 | ("time", ctypes.c_ulong), 73 | ("dwExtraInfo", PUL)] 74 | 75 | 76 | class Input_I(ctypes.Union): 77 | _fields_ = [("ki", KeyBdInput), 78 | ("mi", MouseInput), 79 | ("hi", HardwareInput)] 80 | 81 | 82 | class Input(ctypes.Structure): 83 | _fields_ = [("type", ctypes.c_ulong), 84 | ("ii", Input_I)] 85 | 86 | 87 | class ImageOperation(Enum): 88 | invert = 1 89 | solarize = 2 90 | grayscale = 3 91 | colorize = 4 92 | 93 | 94 | def is_responding_pid(pid: int) -> bool: 95 | try: 96 | return psutil.Process(pid=pid).status() == psutil.STATUS_RUNNING 97 | except (psutil.NoSuchProcess, psutil.AccessDenied): 98 | return False 99 | 100 | 101 | def taskkill_pid(pid: int) -> bool: 102 | try: 103 | process = psutil.Process(pid=pid) 104 | process.kill() 105 | process.wait(5) 106 | return not process.is_running() 107 | except psutil.NoSuchProcess: 108 | return True 109 | except psutil.AccessDenied: 110 | return False 111 | 112 | 113 | def press_key(key_code: int) -> None: 114 | extra = ctypes.c_ulong(0) 115 | ii_ = Input_I() 116 | ii_.ki = KeyBdInput(0, key_code, 0x0008, 0, ctypes.pointer(extra)) 117 | x = Input(ctypes.c_ulong(1), ii_) 118 | ctypes.windll.user32.SendInput(1, ctypes.pointer(x), ctypes.sizeof(x)) 119 | 120 | 121 | def release_key(key_code: int) -> None: 122 | extra = ctypes.c_ulong(0) 123 | ii_ = Input_I() 124 | ii_.ki = KeyBdInput(0, key_code, 0x0008 | 0x0002, 0, ctypes.pointer(extra)) 125 | x = Input(ctypes.c_ulong(1), ii_) 126 | ctypes.windll.user32.SendInput(1, ctypes.pointer(x), ctypes.sizeof(x)) 127 | 128 | 129 | def auto_press_key(key_code: int) -> None: 130 | press_key(key_code) 131 | time.sleep(.08) 132 | release_key(key_code) 133 | 134 | 135 | def window_enumeration_handler(hwnd: int, top_windows: list): 136 | """Add window title and ID to array.""" 137 | tid, pid = win32process.GetWindowThreadProcessId(hwnd) 138 | window = Window( 139 | hwnd, 140 | win32gui.GetWindowText(hwnd), 141 | win32gui.GetWindowRect(hwnd), 142 | win32gui.GetClassName(hwnd), 143 | pid 144 | ) 145 | 146 | top_windows.append(window) 147 | 148 | 149 | def find_window_by_title(search_title: str, search_class: str = None) -> Optional[Window]: 150 | # Reset top windows array 151 | top_windows = [] 152 | 153 | # Call window enumeration handler 154 | win32gui.EnumWindows(window_enumeration_handler, top_windows) 155 | found_window = None 156 | for window in top_windows: 157 | if search_title in window.title and \ 158 | (search_class is None or search_class in window.class_name): 159 | found_window = window 160 | 161 | return found_window 162 | 163 | 164 | # Move mouse using old mouse_event method (relative, by "mickeys) 165 | def mouse_move_legacy(dx: int, dy: int) -> None: 166 | win32api.mouse_event(win32con.MOUSEEVENTF_MOVE, dx, dy) 167 | time.sleep(.08) 168 | 169 | 170 | def mouse_move_to_game_window_coord(game_window: Window, resolution: str, key: str, legacy: bool = False) -> None: 171 | """ 172 | Move mouse cursor to specified game window coordinates 173 | :param game_window: Game window to move mouse in/on 174 | :param resolution: resolution to get/use coordinates for 175 | :param key: key of click target in coordinates dict 176 | :param legacy: whether to use legacy mouse move instead of pyautogui move 177 | :return: 178 | """ 179 | if legacy: 180 | mouse_move_legacy(constants.COORDINATES[resolution]['clicks'][key][0], 181 | constants.COORDINATES[resolution]['clicks'][key][1]) 182 | else: 183 | pyautogui.moveTo( 184 | game_window.rect[0] + constants.COORDINATES[resolution]['clicks'][key][0], 185 | game_window.rect[1] + constants.COORDINATES[resolution]['clicks'][key][1] 186 | ) 187 | 188 | 189 | def is_cursor_on_game_window(game_window: Window) -> bool: 190 | # https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-cursorinfo 191 | flags, handle, (px, py) = win32gui.GetCursorInfo() 192 | # Get current (!) game window rectangle 193 | try: 194 | left, top, right, bottom = win32gui.GetWindowRect(game_window.handle) 195 | except win32gui.error: # PyCharm claims win32gui.error does not exist, but it does 196 | return False 197 | 198 | """ 199 | Allow cursor to only be within the actual window "body", ignore the title bar and the shadow around it. If the game 200 | is currently not in the menu (meaning we are controlling the mouse movement by mickeys), the cursor position is of 201 | no use. However, Windows flags the cursor as hidden and returns the handle as 0. 202 | """ 203 | if flags == 0 and handle == 0 or \ 204 | left + constants.WINDOW_SHADOW_SIZE <= px <= right - constants.WINDOW_SHADOW_SIZE and \ 205 | top + constants.WINDOW_TITLE_BAR_HEIGHT <= py <= bottom - constants.WINDOW_SHADOW_SIZE: 206 | return True 207 | 208 | return False 209 | 210 | 211 | def mouse_click_in_game_window(game_window: Window, legacy: bool = False) -> None: 212 | if not is_cursor_on_game_window(game_window): 213 | logger.warning(f'Mouse cursor is not on game window, ignoring mouse click') 214 | return 215 | 216 | if legacy: 217 | mouse_click_legacy() 218 | else: 219 | pyautogui.leftClick() 220 | 221 | 222 | # Mouse click using old mouse_event method 223 | def mouse_click_legacy() -> None: 224 | win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0) 225 | time.sleep(.08) 226 | win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0) 227 | 228 | 229 | def mouse_reset_legacy() -> None: 230 | win32api.mouse_event(win32con.MOUSEEVENTF_MOVE, -10000, -10000) 231 | time.sleep(.2) 232 | 233 | 234 | def mouse_reset(game_window: Window) -> None: 235 | """ 236 | Reset mouse cursor to the center of the game window (via pyautogui) 237 | :param game_window: Game window to move mouse in/on 238 | :return: 239 | """ 240 | left, top, right, bottom = game_window.rect 241 | pyautogui.moveTo((right - left)/2 + left, (bottom - top - 40)/2 + top) 242 | 243 | 244 | def screenshot_region( 245 | region: Tuple[int, int, int, int], 246 | image_ops: Optional[List[Tuple[ImageOperation, Optional[dict]]]] = None, 247 | crops: Optional[List[Tuple[int, int, int, int]]] = None, 248 | show: bool = False 249 | ) -> Tuple[Union[Image.Image, List[Image.Image]], Image.Image]: 250 | """ 251 | Take a screenshot of the specified screen region (wrapper for pyautogui.screenshot) 252 | :param region: region to take screenshot of, format: (left, top, width, height) 253 | :param image_ops: List of image operation tuples, format: (operation, arguments) 254 | :param crops: List of image crop tuples, format: (left, top, right, bottom) 255 | :param show: whether to show the screenshot 256 | :return: 257 | """ 258 | screenshot = pyautogui.screenshot(region=region) 259 | results: List[Image.Image] = [] 260 | # Apply zero-crop if no crops have been given, since we should not modify the original screenshot 261 | for crop in crops if crops is not None else [(0, 0, 0, 0)]: 262 | cropped = ImageOps.crop(screenshot, crop) 263 | 264 | if image_ops is not None: 265 | for operation in image_ops: 266 | method, args = operation 267 | if method is ImageOperation.invert: 268 | cropped = ImageOps.invert(cropped) 269 | elif method is ImageOperation.solarize: 270 | cropped = ImageOps.solarize(cropped, **(args if args is not None else {})) 271 | elif method is ImageOperation.grayscale: 272 | cropped = ImageOps.grayscale(cropped) 273 | elif method is ImageOperation.colorize: 274 | cropped = ImageOps.colorize(cropped, **(args if args is not None else {})) 275 | 276 | if show: 277 | cropped.show() 278 | 279 | # Save screenshot to debug directory if debugging is enabled 280 | config = Config() 281 | if config.debug_screenshot(): 282 | # Save screenshot 283 | try: 284 | cropped.save( 285 | os.path.join( 286 | Config.DEBUG_DIR, 287 | f'screenshot-{datetime.now().strftime("%Y-%m-%d-%H-%M-%S-%f")}.jpg' 288 | ) 289 | ) 290 | except OSError as e: 291 | logger.error(f'Failed to save screenshot to disk: {e}') 292 | 293 | results.append(cropped) 294 | 295 | # Return list of cropped screenshots if there are multiple, else return the sole result directly 296 | return results if len(results) > 1 else results.pop(), screenshot 297 | 298 | 299 | def screenshot_game_window_region( 300 | game_window: Window, 301 | image_ops: Optional[List[Tuple[ImageOperation, Optional[dict]]]] = None, 302 | crops: Optional[List[Tuple[int, int, int, int]]] = None, 303 | show: bool = False 304 | ) -> Tuple[Union[Image.Image, List[Image.Image]], Image.Image]: 305 | """ 306 | Take a screenshot of the specified game window region 307 | :param game_window: game window to take screenshot of 308 | :param image_ops: List of image operation tuples, format: (operation, arguments) 309 | :param crops: List of image crop tuples, format: (left, top, right, bottom) 310 | :param show: whether to show the screenshot 311 | :return: 312 | """ 313 | left, top, right, bottom = game_window.rect 314 | return screenshot_region( 315 | ( 316 | left + constants.WINDOW_SHADOW_SIZE, 317 | top + constants.WINDOW_TITLE_BAR_HEIGHT, 318 | right - constants.WINDOW_SHADOW_SIZE - left - constants.WINDOW_SHADOW_SIZE, 319 | bottom - constants.WINDOW_SHADOW_SIZE - top - constants.WINDOW_TITLE_BAR_HEIGHT 320 | ), 321 | image_ops, crops, show 322 | ) 323 | 324 | 325 | def init_pytesseract(tesseract_path: str) -> None: 326 | pytesseract.pytesseract.tesseract_cmd = os.path.join(tesseract_path, constants.TESSERACT_EXE) 327 | 328 | 329 | def image_to_string(image: Image, ocr_config: str) -> str: 330 | """ 331 | Extract text from an image (wrapper for pytesseract.image_to_string) 332 | :param image: PIL image to extract text from 333 | :param ocr_config: config/parameters for Tesseract OCR (see https://guides.nyu.edu/tesseract/usage) 334 | :return: 335 | """ 336 | # pytesseract stopped stripping \n\x0c from ocr results, 337 | # returning raw results instead (https://github.com/madmaze/pytesseract/issues/297) 338 | # so strip those characters as well as spaces after getting the result 339 | ocr_result = pytesseract.image_to_string(image, config=ocr_config).strip(' \n\x0c') 340 | 341 | # Print ocr result if debugging is enabled 342 | config = Config() 343 | if config.debug_screenshot(): 344 | logger.debug(f'OCR result: {ocr_result}') 345 | 346 | return ocr_result.lower() 347 | 348 | 349 | # Take a screenshot of the given region and run the result through OCR 350 | def ocr_screenshot_region( 351 | region: Tuple[int, int, int, int], 352 | image_ops: Optional[List[Tuple[ImageOperation, Optional[dict]]]] = None, 353 | crops: Optional[List[Tuple[int, int, int, int]]] = None, 354 | show: bool = False, ocr_config: str = r'--oem 3 --psm 7' 355 | ) -> Union[str, List[str]]: 356 | result, screenshot = screenshot_region(region, image_ops, crops, show) 357 | 358 | if isinstance(result, Image.Image): 359 | return image_to_string(result, ocr_config) 360 | 361 | ocr_results: List[str] = [] 362 | for cropped in result: 363 | ocr_results.append(image_to_string(cropped, ocr_config)) 364 | 365 | return ocr_results 366 | 367 | 368 | def ocr_screenshot_game_window_region( 369 | game_window: Window, resolution: str, key: str, 370 | image_ops: Optional[List[Tuple[ImageOperation, Optional[dict]]]] = None, 371 | show: bool = False, ocr_config: str = r'--oem 3 --psm 7' 372 | ) -> Union[str, List[str]]: 373 | """ 374 | Run a region of a game window through OCR (wrapper for ocr_screenshot_region) 375 | :param game_window: game window to take screenshot of 376 | :param resolution: resolution to get/use coordinates for 377 | :param key: key of region in coordinates dict 378 | :param image_ops: List of image operation tuples, format: (operation, arguments) 379 | :param show: whether to show the screenshot 380 | :param ocr_config: config/parameters for Tesseract OCR (see https://guides.nyu.edu/tesseract/usage) 381 | :return: 382 | """ 383 | left, top, right, bottom = game_window.rect 384 | return ocr_screenshot_region( 385 | ( 386 | left + constants.WINDOW_SHADOW_SIZE, 387 | top + constants.WINDOW_TITLE_BAR_HEIGHT, 388 | right - constants.WINDOW_SHADOW_SIZE - left - constants.WINDOW_SHADOW_SIZE, 389 | bottom - constants.WINDOW_SHADOW_SIZE - top - constants.WINDOW_TITLE_BAR_HEIGHT 390 | ), 391 | image_ops, 392 | constants.COORDINATES[resolution]['ocr'][key], 393 | show, 394 | ocr_config 395 | ) 396 | 397 | 398 | def histogram_screenshot_region(game_window: Window, crop: Tuple[int, int, int, int]) -> ndarray: 399 | result, screenshot = screenshot_game_window_region(game_window, crops=[crop]) 400 | 401 | return calc_cv2_hist_from_pil_image(result) 402 | 403 | 404 | def calc_cv2_hist_from_pil_image(pil_image: Image) -> ndarray: 405 | # Convert PIL to cv2 image 406 | cv_image = cv2.cvtColor(np.asarray(pil_image), cv2.COLOR_RGB2BGR) 407 | histogram = cv2.calcHist([cv_image], [0], None, [256], [0, 256]) 408 | 409 | return histogram 410 | 411 | 412 | def calc_cv2_hist_delta(a: ndarray, b: ndarray) -> float: 413 | return cv2.compareHist(a, b, cv2.HISTCMP_BHATTACHARYYA) 414 | 415 | 416 | def get_resolution_window_size(resolution: str) -> Tuple[int, int]: 417 | # Set window size based on resolution 418 | window_size = None 419 | if resolution == '720p': 420 | window_size = (1280, 720) 421 | elif resolution == '900p': 422 | window_size = (1600, 900) 423 | 424 | return window_size 425 | 426 | 427 | def get_command_line_by_pid(pid: int) -> Optional[List[str]]: 428 | try: 429 | return psutil.Process(pid=pid).cmdline() 430 | except (psutil.NoSuchProcess, psutil.AccessDenied): 431 | return 432 | 433 | 434 | def get_mod_from_command_line(pid: int) -> Optional[str]: 435 | command_line = get_command_line_by_pid(pid) 436 | 437 | if command_line is None: 438 | return 439 | 440 | for index, arg in enumerate(command_line): 441 | # Next argument after "+modPath" should be the mod path value 442 | if arg == '+modPath' and index + 1 < len(command_line): 443 | # Return mod path without leading "mods/" 444 | return command_line[index + 1][5:] 445 | 446 | 447 | def run_conman(args: List[str]) -> None: 448 | command = [os.path.join(Config.ROOT_DIR, 'redist', 'bf2-conman.exe'), '--no-gui', *args] 449 | p = subprocess.run(command, timeout=1.0, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 450 | return p.check_returncode() 451 | 452 | 453 | def is_similar_str(a: str, b: str, threshold: float = .8) -> bool: 454 | return jellyfish.jaro_similarity(a, b) >= threshold 455 | -------------------------------------------------------------------------------- /BF2AutoSpectator/game/__init__.py: -------------------------------------------------------------------------------- 1 | from .instance_manager import GameInstanceManager, GameMessage 2 | from .instance_state import GameInstanceState 3 | 4 | __all__ = ['GameInstanceManager', 'GameMessage', 'GameInstanceState'] 5 | -------------------------------------------------------------------------------- /BF2AutoSpectator/game/instance_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import re 4 | import subprocess 5 | import time 6 | from enum import Enum 7 | from typing import Tuple, Optional 8 | 9 | import numpy as np 10 | import pyautogui 11 | import win32con 12 | import win32gui 13 | 14 | from BF2AutoSpectator.common import constants 15 | from BF2AutoSpectator.common.exceptions import SpawnCoordinatesNotAvailableException 16 | from BF2AutoSpectator.common.logger import logger 17 | from BF2AutoSpectator.common.utility import Window, find_window_by_title, get_resolution_window_size, \ 18 | mouse_move_to_game_window_coord, mouse_click_in_game_window, ocr_screenshot_game_window_region, auto_press_key, \ 19 | mouse_reset_legacy, mouse_move_legacy, is_responding_pid, histogram_screenshot_region, calc_cv2_hist_delta, \ 20 | ImageOperation, mouse_reset, get_mod_from_command_line, run_conman, ocr_screenshot_region, is_similar_str, \ 21 | press_key, release_key 22 | from .instance_state import GameInstanceState 23 | 24 | # Remove the top left corner from pyautogui failsafe points 25 | # (avoid triggering failsafe exception due to mouse moving to top left during spawn) 26 | del pyautogui.FAILSAFE_POINTS[0] 27 | 28 | 29 | MAP_NAME_REGEX_NvN = re.compile(r'(\d+).?v.?(\d+)') 30 | MAP_NAME_REGEX_SEPARATORS = re.compile(r'[_.\s]') 31 | MAP_NAME_REGEX_EXTRA = re.compile(r'[\'()]') 32 | MAP_NAME_REGEX_MULTI = re.compile(r'[-]{2,}') 33 | 34 | 35 | class GameMessage(str, Enum): 36 | ServerFull = 'server-full' 37 | Kicked = 'kicked' 38 | Banned = 'banned' 39 | ConnectionLost = 'connection-lost' 40 | ConnectionFailed = 'connection-failed' 41 | ModifiedContent = 'modified-content' 42 | InvalidIP = 'invalid-ip' 43 | ReadError = 'read-error' 44 | ConnectionRefused = 'connection-refused' 45 | Unknown = 'unknown' 46 | 47 | 48 | class GameInstanceManager: 49 | game_path: str 50 | player_name: str 51 | player_pass: str 52 | resolution: str 53 | histograms: dict 54 | 55 | game_window: Optional[Window] = None 56 | 57 | state: GameInstanceState 58 | 59 | def __init__(self, game_path: str, player_name: str, player_pass: str, resolution: str, histograms: dict): 60 | self.game_path = game_path 61 | self.player_name = player_name 62 | self.player_pass = player_pass 63 | self.resolution = resolution 64 | self.histograms = histograms 65 | 66 | # Init game instance state 67 | self.state = GameInstanceState() 68 | 69 | """ 70 | Attribute getters/setters 71 | """ 72 | def get_state(self) -> GameInstanceState: 73 | return self.state 74 | 75 | def get_game_window(self) -> Optional[Window]: 76 | return self.game_window 77 | 78 | """ 79 | Functions for launching, finding and destroying/quitting a game instance 80 | """ 81 | @staticmethod 82 | def prepare_game_launch() -> None: 83 | """ 84 | BF2 will query any server in the server history upon login, significantly slowing down the login and thus the 85 | launch. The effect is even greater if any of the servers in the history are offline. For those, BF2 waits for 86 | the status query to time out. So, use bf2-conman (https://github.com/cetteup/conman/releases/tag/v0.1.5) 87 | to purge the server history. We also remove old demo bookmarks and purge the shader and logo cache, just to 88 | keep this nice and neat. 89 | """ 90 | try: 91 | run_conman([ 92 | '--purge-server-history', 93 | '--purge-old-demo-bookmarks', 94 | '--purge-shader-cache', 95 | '--purge-logo-cache' 96 | ]) 97 | except (FileNotFoundError, PermissionError, subprocess.SubprocessError) as e: 98 | logger.error(f'Failed to run pre-launch cleanup ({e})') 99 | 100 | def launch_instance(self, mod: str) -> Tuple[bool, bool, Optional[str]]: 101 | """ 102 | Launch a new game instance 103 | :return: True if game was launched successfully, else False 104 | """ 105 | self.prepare_game_launch() 106 | 107 | szx, szy = get_resolution_window_size(self.resolution) 108 | 109 | # Prepare command 110 | command = [ 111 | os.path.join(self.game_path, constants.BF2_EXE), '+restart', '1', '+modPath', f'mods/{mod}', 112 | '+playerName', self.player_name, '+playerPassword', self.player_pass, 113 | '+szx', str(szx), '+szy', str(szy), '+fullscreen', '0', '+wx', '5', '+wy', '5', 114 | '+developer', '1', '+disableShaderCache', '1', '+ignoreAsserts', '1' 115 | ] 116 | 117 | # Run command 118 | try: 119 | p = subprocess.Popen( 120 | command, 121 | close_fds=True, cwd=self.game_path, 122 | stdout=subprocess.PIPE, stderr=subprocess.PIPE 123 | ) 124 | except (FileNotFoundError, PermissionError, subprocess.SubprocessError) as e: 125 | logger.error(f'Failed to launch game instance ({e})') 126 | return False, False, None 127 | 128 | # Wait for game window to come up 129 | game_window_present, correct_params, running_mod = False, False, None 130 | check_count = 0 131 | check_limit = 5 132 | while p.poll() is None and not game_window_present and check_count < check_limit: 133 | # If we join a server with a different mod without knowing it, the game will restart with that mod 134 | # => update config to use whatever mod the game is now running with 135 | game_window_present, correct_params, running_mod = self.find_instance(mod) 136 | check_count += 1 137 | time.sleep(4) 138 | 139 | # If game window came up, give it some time for login etc. 140 | if game_window_present: 141 | time.sleep(6) 142 | 143 | return game_window_present, correct_params, running_mod 144 | 145 | def find_instance(self, mod: str) -> Tuple[bool, bool, Optional[str]]: 146 | self.game_window = find_window_by_title(constants.BF2_WINDOW_TITLE, 'BF2') 147 | 148 | if self.game_window is None: 149 | return False, False, None 150 | 151 | # Found a game window => validate mod and resolution match expected values 152 | running_mod = get_mod_from_command_line(self.game_window.pid) 153 | logger.debug(f'Found game is running mod "{running_mod}"') 154 | logger.debug(f'Expected mod is "{mod}"') 155 | 156 | actual_window_size = self.game_window.get_size() 157 | expected_window_size = get_resolution_window_size(self.resolution) 158 | logger.debug(f'Found game window size is {actual_window_size}') 159 | logger.debug(f'Expected game window size is {expected_window_size}') 160 | 161 | as_expected = True 162 | if running_mod != mod: 163 | logger.warning('Found game is running a different mod than expected') 164 | as_expected = False 165 | if actual_window_size != expected_window_size: 166 | logger.warning('Found game window is a different resolution/size than expected') 167 | as_expected = False 168 | 169 | return True, as_expected, running_mod 170 | 171 | def quit_instance(self) -> bool: 172 | if not self.open_menu(): 173 | return False 174 | 175 | # Click quit menu item 176 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'quit-menu-item') 177 | time.sleep(.2) 178 | mouse_click_in_game_window(self.game_window) 179 | 180 | time.sleep(2) 181 | 182 | return not is_responding_pid(self.game_window.pid) 183 | 184 | def open_menu(self, max_attempts: int = 5, sleep: float = 1.0) -> bool: 185 | # Spam press ESC if menu is not already visible 186 | attempt = 0 187 | while not (in_menu := self.is_in_menu()) and attempt < max_attempts: 188 | auto_press_key(0x01) 189 | attempt += 1 190 | time.sleep(sleep) 191 | 192 | if not in_menu: 193 | return False 194 | 195 | # Reset the mouse to the center of the screen in order to not block any common OCR/histogram spots 196 | mouse_reset(self.game_window) 197 | 198 | return True 199 | 200 | """ 201 | Functions for detecting game state elements 202 | """ 203 | def is_game_message_visible(self) -> bool: 204 | return 'game message' in ocr_screenshot_game_window_region( 205 | self.game_window, 206 | self.resolution, 207 | 'game-message-header', 208 | image_ops=[(ImageOperation.invert, None)] 209 | ) 210 | 211 | def get_game_message(self) -> Tuple[GameMessage, str]: 212 | # Get ocr result of game message content region 213 | game_message = ocr_screenshot_game_window_region( 214 | self.game_window, 215 | self.resolution, 216 | 'game-message-text', 217 | image_ops=[(ImageOperation.invert, None)] 218 | ) 219 | 220 | if 'full' in game_message: 221 | return GameMessage.ServerFull, game_message 222 | elif 'kicked' in game_message: 223 | return GameMessage.Kicked, game_message 224 | elif 'banned' in game_message: 225 | return GameMessage.Banned, game_message 226 | elif 'connection' in game_message and 'lost' in game_message: 227 | return GameMessage.ConnectionLost, game_message 228 | elif 'failed to connect' in game_message: 229 | return GameMessage.ConnectionFailed, game_message 230 | elif 'modified content' in game_message: 231 | return GameMessage.ModifiedContent, game_message 232 | elif 'invalid ip address' in game_message: 233 | return GameMessage.InvalidIP, game_message 234 | elif 'error reading from the server' in game_message: 235 | return GameMessage.ReadError, game_message 236 | elif 'server has refused the connection' in game_message: 237 | return GameMessage.ConnectionRefused, game_message 238 | 239 | return GameMessage.Unknown, game_message 240 | 241 | def is_in_menu(self) -> bool: 242 | # Get ocr result of quit menu item area 243 | return 'quit' in ocr_screenshot_game_window_region( 244 | self.game_window, 245 | self.resolution, 246 | 'quit-menu-item', 247 | image_ops=[ 248 | (ImageOperation.grayscale, None), 249 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 250 | (ImageOperation.invert, None), 251 | ] 252 | ) 253 | 254 | def is_multiplayer_menu_active(self) -> bool: 255 | return self.is_menu_item_active('multiplayer') 256 | 257 | def is_join_internet_menu_active(self) -> bool: 258 | return self.is_menu_item_active('join-internet') 259 | 260 | def is_menu_item_active(self, menu_item: str) -> bool: 261 | histogram = histogram_screenshot_region( 262 | self.game_window, 263 | constants.COORDINATES[self.resolution]['hists']['menu'][menu_item] 264 | ) 265 | delta = calc_cv2_hist_delta( 266 | histogram, 267 | self.histograms[self.resolution]['menu'][menu_item]['active'] 268 | ) 269 | 270 | return delta < constants.HISTCMP_MAX_DELTA 271 | 272 | def is_disconnect_prompt_visible(self) -> bool: 273 | return 'disconnect' in ocr_screenshot_game_window_region( 274 | self.game_window, 275 | self.resolution, 276 | 'disconnect-prompt-header', 277 | image_ops=[(ImageOperation.invert, None)] 278 | ) 279 | 280 | def is_disconnect_button_visible(self) -> bool: 281 | return 'disconnect' in ocr_screenshot_game_window_region( 282 | self.game_window, 283 | self.resolution, 284 | 'disconnect-button', 285 | image_ops=[ 286 | (ImageOperation.grayscale, None), 287 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 288 | (ImageOperation.invert, None) 289 | ] 290 | ) 291 | 292 | def is_play_now_button_visible(self) -> bool: 293 | return 'play now' in ocr_screenshot_game_window_region( 294 | self.game_window, 295 | self.resolution, 296 | 'play-now-button', 297 | image_ops=[ 298 | (ImageOperation.grayscale, None), 299 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 100, 'whitepoint': 200}) 300 | ] 301 | ) 302 | 303 | def is_round_end_screen_visible(self) -> bool: 304 | round_end_screen_items = ['score-list', 'top-players', 'top-scores', 'map-briefing'] 305 | active = [self.is_round_end_screen_item_active(item) for item in round_end_screen_items] 306 | 307 | # During map load, only item is active at any time. When the round just ended, all are active. 308 | if not (all(active) or len([a for a in active if a]) == 1): 309 | return False 310 | 311 | # Run expensive multi-ocr only after faster histogram based detection succeeded 312 | item_labels = ocr_screenshot_game_window_region( 313 | self.game_window, 314 | self.resolution, 315 | 'eor-header-items', 316 | image_ops=[ 317 | (ImageOperation.grayscale, None), 318 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 50, 'whitepoint': 135}), 319 | (ImageOperation.invert, None), 320 | ] 321 | ) 322 | 323 | # Due to the eor header items being transparent, ocr is not going to always detect all items 324 | # So, we'll take any ocr match (the strings are fairly unique) 325 | return any(label in item_labels for label in ['score list', 'top players', 'top scores', 'map briefing']) 326 | 327 | def is_round_end_screen_item_active(self, round_end_screen_item: str) -> bool: 328 | histogram = histogram_screenshot_region( 329 | self.game_window, 330 | constants.COORDINATES[self.resolution]['hists']['eor'][round_end_screen_item] 331 | ) 332 | delta = calc_cv2_hist_delta( 333 | histogram, 334 | self.histograms[self.resolution]['eor'][round_end_screen_item]['active'] 335 | ) 336 | 337 | return delta < constants.HISTCMP_MAX_DELTA 338 | 339 | def is_connect_to_ip_button_visible(self) -> bool: 340 | return 'connect to ip' in ocr_screenshot_game_window_region( 341 | self.game_window, 342 | self.resolution, 343 | 'connect-to-ip-button', 344 | image_ops=[ 345 | (ImageOperation.grayscale, None), 346 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 347 | (ImageOperation.invert, None) 348 | ] 349 | ) 350 | 351 | def is_join_game_button_visible(self) -> bool: 352 | # Reset mouse to avoid blocking ocr of button region 353 | mouse_reset(self.game_window) 354 | 355 | # Get ocr result of bottom left corner where "join game"-button would be 356 | return 'join game' in ocr_screenshot_game_window_region( 357 | self.game_window, 358 | self.resolution, 359 | 'join-game-button', 360 | image_ops=[ 361 | (ImageOperation.grayscale, None), 362 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 363 | (ImageOperation.invert, None) 364 | ] 365 | ) 366 | 367 | def is_map_loading(self) -> bool: 368 | # Check if join game button is present (check this first in order to avoid race condition where eor screen 369 | # is visible when checked but join game button is not visible because we entered the map) 370 | join_game_button_present = self.is_join_game_button_visible() 371 | 372 | # Check if game is on round end screen 373 | return self.is_round_end_screen_visible() and not join_game_button_present 374 | 375 | def is_loading_bar_visible(self) -> bool: 376 | histogram = histogram_screenshot_region( 377 | self.game_window, 378 | constants.COORDINATES[self.resolution]['hists']['eor']['loading-bar'] 379 | ) 380 | 381 | delta = calc_cv2_hist_delta( 382 | histogram, 383 | self.histograms[self.resolution]['eor']['loading-bar'] 384 | ) 385 | 386 | return delta < constants.HISTCMP_MAX_DELTA 387 | 388 | def is_map_briefing_visible(self) -> bool: 389 | return 'map briefing' in ocr_screenshot_game_window_region( 390 | self.game_window, 391 | self.resolution, 392 | 'map-briefing-header', 393 | image_ops=[(ImageOperation.invert, None)] 394 | ) 395 | 396 | def open_map_briefing(self) -> bool: 397 | # Don't block any OCR regions 398 | mouse_reset(self.game_window) 399 | 400 | if self.is_map_briefing_visible(): 401 | return True 402 | 403 | # Cannot open map briefing unless join game button is visible 404 | if not self.is_join_game_button_visible(): 405 | return False 406 | 407 | # Move cursor onto map briefing header and click 408 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'map-briefing-eor-item') 409 | time.sleep(.2) 410 | mouse_click_in_game_window(self.game_window, legacy=True) 411 | 412 | time.sleep(.5) 413 | 414 | # Move cursor back to default position 415 | mouse_reset(self.game_window) 416 | 417 | return self.is_map_briefing_visible() 418 | 419 | def is_spawn_menu_visible(self) -> bool: 420 | histogram = histogram_screenshot_region( 421 | self.game_window, 422 | constants.COORDINATES[self.resolution]['hists']['spawn-menu']['close-button'] 423 | ) 424 | delta = calc_cv2_hist_delta( 425 | histogram, 426 | self.histograms[self.resolution]['spawn-menu']['close-button'] 427 | ) 428 | 429 | return delta < constants.HISTCMP_MAX_DELTA 430 | 431 | def get_map_details(self) -> Tuple[str, int, str]: 432 | ocr_map_name, ocr_map_size, ocr_game_mode = ocr_screenshot_game_window_region( 433 | self.game_window, 434 | self.resolution, 435 | 'eor-map-details', 436 | image_ops=[(ImageOperation.invert, None)] 437 | ) 438 | 439 | logger.debug(f'Detected map details: {ocr_map_name}/{ocr_map_size}/{ocr_game_mode}') 440 | 441 | # Normalize raw OCR results 442 | map_name = self.normalize_map_name(ocr_map_name) 443 | map_size = self.normalize_map_size(ocr_map_size) 444 | game_mode = ocr_game_mode 445 | 446 | logger.debug(f'Normalized map details: {map_name}/{map_size}/{game_mode}') 447 | 448 | return map_name, map_size, game_mode 449 | 450 | @staticmethod 451 | def normalize_map_name(ocr_result: str) -> str: 452 | # Make sure any weird OCR result for 2v2/NvN maps are turned into just NvN 453 | ocr_result = MAP_NAME_REGEX_NvN.sub('\\1v\\2', ocr_result) 454 | # Replace spaces/underscores/dots with dashes 455 | ocr_result = MAP_NAME_REGEX_SEPARATORS.sub('-', ocr_result) 456 | # Remove any other special characters 457 | ocr_result = MAP_NAME_REGEX_EXTRA.sub('', ocr_result) 458 | # "Merge" multiple dashes into one 459 | ocr_result = MAP_NAME_REGEX_MULTI.sub('-', ocr_result) 460 | 461 | # Convert to lower case 462 | ocr_result = ocr_result.lower() 463 | 464 | # Check if map is known 465 | # Also check while replacing first g with q, second t with i and trailing colon with e 466 | # to account for common ocr errors 467 | if ocr_result in constants.COORDINATES['spawns'].keys(): 468 | # If block only serves to not run regex if ocr result already matches a known map 469 | return ocr_result 470 | elif (normalized := re.sub(r'^([^g]*?)g(.*)$', '\\1q\\2', ocr_result)) \ 471 | in constants.COORDINATES['spawns'].keys(): 472 | return normalized 473 | elif (normalized := re.sub(r'^([^t]*?t[^t]+?)t(.*)$', '\\1i\\2', ocr_result)) \ 474 | in constants.COORDINATES['spawns'].keys(): 475 | return normalized 476 | elif (normalized := re.sub(r'^(.*?):$', '\\1e', ocr_result)) \ 477 | in constants.COORDINATES['spawns'].keys(): 478 | return normalized 479 | else: 480 | return ocr_result 481 | 482 | @staticmethod 483 | def normalize_map_size(ocr_result: str) -> int: 484 | map_size = -1 485 | # Make sure ocr result only contains numbers 486 | if re.match(r'^[0-9]+$', ocr_result): 487 | map_size = int(ocr_result) 488 | 489 | return map_size 490 | 491 | def get_player_team(self) -> Optional[int]: 492 | # Get histograms of team selection areas 493 | team_selection_histograms = [] 494 | for coord_set in constants.COORDINATES[self.resolution]['hists']['teams']: 495 | histogram = histogram_screenshot_region( 496 | self.game_window, 497 | coord_set 498 | ) 499 | team_selection_histograms.append(histogram) 500 | 501 | # Calculate histogram deltas and compare against known ones 502 | team = None 503 | for team_key in constants.TEAMS_SPAWN_MENU_LEFT: 504 | histogram_delta = calc_cv2_hist_delta( 505 | team_selection_histograms[0], 506 | self.histograms[self.resolution]['teams'][team_key]['active'] 507 | ) 508 | 509 | if histogram_delta < constants.HISTCMP_MAX_DELTA: 510 | team = 0 511 | break 512 | 513 | for team_key in constants.TEAMS_SPAWN_MENU_RIGHT: 514 | histogram_delta = calc_cv2_hist_delta( 515 | team_selection_histograms[1], 516 | self.histograms[self.resolution]['teams'][team_key]['active'] 517 | ) 518 | 519 | if histogram_delta < constants.HISTCMP_MAX_DELTA: 520 | team = 1 521 | break 522 | 523 | logger.debug(f'Detected team is {team}') 524 | 525 | return team 526 | 527 | def is_default_camera_view_visible(self) -> bool: 528 | map_name = self.state.get_rotation_map_name() 529 | # Return false if map has not been determined (yet) or is not supported 530 | if map_name is None or map_name not in self.histograms[self.resolution]['maps']['default-camera-view']: 531 | return False 532 | 533 | histogram = histogram_screenshot_region( 534 | self.game_window, 535 | ( 536 | 168, 537 | 0, 538 | 168, 539 | 0 540 | ) 541 | ) 542 | delta = calc_cv2_hist_delta( 543 | histogram, 544 | self.histograms[self.resolution]['maps']['default-camera-view'][map_name] 545 | ) 546 | 547 | return delta < constants.DEFAULT_CAMERA_VIEW_HISTCMP_MAX_DELTA 548 | 549 | def is_sufficient_action_on_screen(self, screenshot_count: int = 3, screenshot_sleep: float = .55, 550 | min_delta: float = .022) -> bool: 551 | histograms = [] 552 | 553 | # Take screenshots and calculate histograms 554 | for i in range(0, screenshot_count): 555 | histogram = histogram_screenshot_region( 556 | self.game_window, 557 | ( 558 | 168, 559 | 0, 560 | 168, 561 | 0 562 | ) 563 | ) 564 | histograms.append(histogram) 565 | 566 | # Sleep before taking next screenshot 567 | if i + 1 < screenshot_count: 568 | time.sleep(screenshot_sleep) 569 | 570 | histogram_deltas = [] 571 | # Calculate histogram differences 572 | for j in range(0, len(histograms) - 1): 573 | histogram_deltas.append(calc_cv2_hist_delta(histograms[j], histograms[j + 1])) 574 | 575 | # Take average of deltas 576 | average_delta = np.average(histogram_deltas) 577 | 578 | logger.debug(f'Average histogram delta: {average_delta}') 579 | 580 | return average_delta > min_delta 581 | 582 | """ 583 | Functions to interact with the game instance (=change state) 584 | """ 585 | def bring_to_foreground(self) -> None: 586 | win32gui.ShowWindow(self.game_window.handle, win32con.SW_SHOW) 587 | win32gui.SetForegroundWindow(self.game_window.handle) 588 | 589 | def connect_to_server(self, server_ip: str, server_port: str, server_pass: Optional[str] = None) -> bool: 590 | if not self.is_multiplayer_menu_active(): 591 | # Move cursor onto multiplayer menu item and click 592 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'multiplayer-menu-item') 593 | time.sleep(.2) 594 | mouse_click_in_game_window(self.game_window) 595 | 596 | if not self.is_join_internet_menu_active(): 597 | # Move cursor onto join internet menu item and click 598 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'join-internet-menu-item') 599 | time.sleep(.2) 600 | mouse_click_in_game_window(self.game_window) 601 | 602 | check_count = 0 603 | check_limit = 10 604 | while not self.is_connect_to_ip_button_visible() and check_count < check_limit: 605 | check_count += 1 606 | time.sleep(1) 607 | 608 | if not self.is_connect_to_ip_button_visible(): 609 | return False 610 | 611 | # Move cursor onto connect to ip button and click 612 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'connect-to-ip-button') 613 | time.sleep(.2) 614 | mouse_click_in_game_window(self.game_window) 615 | 616 | # Give field popup time to appear 617 | time.sleep(.3) 618 | 619 | # Clear out ip field 620 | pyautogui.press('backspace', presses=20, interval=.05) 621 | 622 | # Write ip 623 | pyautogui.write(server_ip, interval=.05) 624 | 625 | # Hit tab to enter port 626 | pyautogui.press('tab') 627 | 628 | # Clear out port field 629 | pyautogui.press('backspace', presses=10, interval=.05) 630 | 631 | # Write port 632 | pyautogui.write(server_port, interval=.05) 633 | 634 | time.sleep(.3) 635 | 636 | # Write password if required 637 | # Field clears itself, so need to clear manually 638 | if server_pass is not None: 639 | pyautogui.press('tab') 640 | 641 | pyautogui.write(server_pass, interval=.05) 642 | 643 | time.sleep(.3) 644 | 645 | # Move cursor onto ok button and click 646 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'connect-to-ip-ok-button') 647 | time.sleep(.2) 648 | mouse_click_in_game_window(self.game_window) 649 | 650 | # Successfully joining a server means leaving the menu, so wait for menu to disappear 651 | # (cancel further checks when a game/error message is present) 652 | check_count = 0 653 | check_limit = 16 654 | in_menu = True 655 | game_message_visible = False 656 | while in_menu and not game_message_visible and check_count < check_limit: 657 | in_menu = self.is_in_menu() 658 | game_message_visible = self.is_game_message_visible() 659 | # Game will show a "you need to disconnect first" prompt if it was still connected to a server 660 | if self.is_disconnect_prompt_visible(): 661 | logger.warning('Disconnect prompt is visible, clicking "Yes" to disconnect') 662 | # Click "yes" in order to disconnect 663 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'disconnect-prompt-yes-button') 664 | time.sleep(.2) 665 | mouse_click_in_game_window(self.game_window) 666 | time.sleep(.5) 667 | # Stop checking, since we will stay in menu (game only disconnects here, we need to retry connecting) 668 | break 669 | check_count += 1 670 | time.sleep(1) 671 | 672 | return not in_menu 673 | 674 | def disconnect_from_server(self) -> bool: 675 | # Make sure disconnect button is present 676 | if self.is_disconnect_button_visible(): 677 | # Move cursor onto disconnect button and click 678 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'disconnect-button') 679 | time.sleep(.2) 680 | mouse_click_in_game_window(self.game_window) 681 | 682 | # Reset mouse to avoid blocking ocr of button region 683 | mouse_reset(self.game_window) 684 | 685 | check = 0 686 | max_checks = 5 687 | while not self.is_play_now_button_visible() and check < max_checks: 688 | check += 1 689 | time.sleep(.3) 690 | 691 | # We should still be in the menu but see the "play now" button instead of the "disconnect" button 692 | return self.is_in_menu() and self.is_play_now_button_visible() 693 | 694 | def delay_map_load(self, delay: int) -> bool: 695 | if not self.state.map_loading(): 696 | return False 697 | 698 | check_count = 0 699 | check_limit = 18 700 | started_loading = False 701 | while not started_loading and check_count < check_limit: 702 | started_loading = self.is_loading_bar_visible() 703 | if not started_loading: 704 | check_count += 1 705 | time.sleep(.25) 706 | 707 | if not started_loading: 708 | return False 709 | 710 | # Toggling ALT somehow "pauses"/"resumes" the game while keeping the audio running 711 | # In contrast, BF2mld's approach of suspending the process pauses the audio (not ideal with loading music on) 712 | logger.debug('Suspending map load') 713 | pyautogui.press('alt') 714 | time.sleep(delay) 715 | 716 | logger.debug('Resuming map load') 717 | pyautogui.press('alt') 718 | 719 | return True 720 | 721 | def toggle_hud(self, direction: int) -> bool: 722 | return self.issue_console_command(f'renderer.drawHud {str(direction)}') 723 | 724 | def issue_console_command(self, command: str) -> bool: 725 | # Open/toggle console 726 | self.toggle_console() 727 | 728 | # Clear out command input 729 | attempt = 0 730 | max_attempts = 5 731 | while not (ready := self.is_console_ready()) and attempt < max_attempts: 732 | pyautogui.press('backspace', presses=pow((attempt + 1), 2), interval=.05) 733 | attempt += 1 734 | 735 | if not ready: 736 | return False 737 | 738 | # Write command 739 | pyautogui.write(command, interval=.05) 740 | time.sleep(.3) 741 | 742 | # Read command back 743 | written_command = self.get_console_command(len(command)) 744 | if not is_similar_str(command, written_command.lstrip('>')): 745 | return False 746 | 747 | # Hit enter 748 | pyautogui.press('enter') 749 | time.sleep(.1) 750 | 751 | # X / toggle console 752 | self.toggle_console() 753 | 754 | return not self.is_console_ready() 755 | 756 | def is_console_ready(self) -> bool: 757 | # We should only see the input "prompt" 758 | return self.get_console_command(3) == '>' 759 | 760 | def get_console_command(self, characters: int) -> str: 761 | """ 762 | Read current console command, including ">" prompt 763 | :param characters: number of characters to read 764 | :return: current console command (note: due to how tiny the text is, 765 | don't expect an exact match with the command that was put in) 766 | """ 767 | # Set screenshot width based on command length (add 5px per character) 768 | left, top, right, bottom = self.game_window.rect 769 | return ocr_screenshot_region( 770 | ( 771 | left + constants.WINDOW_SHADOW_SIZE, 772 | top + constants.WINDOW_TITLE_BAR_HEIGHT, 773 | right - constants.WINDOW_SHADOW_SIZE - left - constants.WINDOW_SHADOW_SIZE, 774 | bottom - constants.WINDOW_SHADOW_SIZE - top - constants.WINDOW_TITLE_BAR_HEIGHT 775 | ), 776 | crops=[( 777 | constants.COORDINATES[self.resolution]['ocr']['console-command'][0][0], 778 | constants.COORDINATES[self.resolution]['ocr']['console-command'][0][1], 779 | constants.COORDINATES[self.resolution]['ocr']['console-command'][0][2] - characters * 6, 780 | constants.COORDINATES[self.resolution]['ocr']['console-command'][0][3] 781 | )] 782 | ) 783 | 784 | @staticmethod 785 | def toggle_console() -> None: 786 | auto_press_key(0x1d) 787 | time.sleep(.25) 788 | 789 | @staticmethod 790 | def open_spawn_menu(sleep: float = 1.5) -> None: 791 | auto_press_key(0x1c) 792 | time.sleep(sleep) 793 | 794 | def spawn_coordinates_available(self) -> bool: 795 | map_name = self.state.get_rotation_map_name() 796 | return map_name in constants.COORDINATES['spawns'].keys() and \ 797 | str(self.state.get_rotation_map_size()) in constants.COORDINATES['spawns'][map_name].keys() and \ 798 | self.state.get_rotation_game_mode() == 'conquest' 799 | 800 | def spawn_suicide(self, randomize: bool = False) -> bool: 801 | # Treat spawn attempt as failed if no spawn point could be selected 802 | if self.is_spawn_point_selectable() and \ 803 | (randomize and self.select_random_spawn_point() or (not randomize and self.select_spawn_point())): 804 | # Hit enter to spawn 805 | auto_press_key(0x1c) 806 | time.sleep(1) 807 | 808 | # Re-open spawn menu 809 | self.open_spawn_menu(.3) 810 | 811 | # Reset cursor again 812 | mouse_reset_legacy() 813 | 814 | # De-select spawn point 815 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'spawnpoint-deselect', True) 816 | time.sleep(0.3) 817 | mouse_click_in_game_window(self.game_window, legacy=True) 818 | 819 | # Suicide button may be visible even though we did not detect a spawn as selected 820 | # e.g. when spectator is still alive from a previous spawn-suicide attempt 821 | suicide_button_visible = self.is_suicide_button_visible() 822 | if suicide_button_visible: 823 | mouse_reset_legacy() 824 | # Click suicide button 825 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'suicide-button', True) 826 | time.sleep(.3) 827 | mouse_click_in_game_window(self.game_window, legacy=True) 828 | time.sleep(.5) 829 | 830 | # Reset mouse again to make sure it does not block any OCR attempts 831 | mouse_reset_legacy() 832 | 833 | # Make sure the button was visible before but is not any longer 834 | return suicide_button_visible and not self.is_suicide_button_visible() 835 | 836 | def select_spawn_point(self) -> bool: 837 | # Make sure spawning on map and size is supported 838 | map_name = self.state.get_rotation_map_name() 839 | map_size = str(self.state.get_rotation_map_size()) 840 | if not self.spawn_coordinates_available(): 841 | raise SpawnCoordinatesNotAvailableException 842 | 843 | # Reset mouse to top left corner 844 | mouse_reset_legacy() 845 | 846 | # Select default spawn based on current team 847 | spawn_coordinates = constants.COORDINATES['spawns'][map_name][map_size][self.state.get_round_team()] 848 | mouse_move_legacy(spawn_coordinates[0], spawn_coordinates[1]) 849 | time.sleep(.3) 850 | mouse_click_in_game_window(self.game_window, legacy=True) 851 | 852 | # Try any alternate spawns if primary one is not available 853 | alternate_spawns = constants.COORDINATES['spawns'][map_name][str(map_size)][2:] 854 | if not self.is_spawn_point_selected() and len(alternate_spawns) > 0: 855 | logger.warning('Default spawn point could not be selected, trying alternate spawn points') 856 | # Iterate over alternate spawns in reverse order for team 1 857 | # (spawns are ordered by "likeliness" of team 0 having control over them) 858 | if self.state.get_round_team() == 1: 859 | alternate_spawns.reverse() 860 | 861 | # Try to select any of the alternate spawn points 862 | for coordinates in alternate_spawns: 863 | logger.debug(f'Trying spawn coordinates {coordinates}') 864 | mouse_reset_legacy() 865 | mouse_move_legacy(*coordinates) 866 | time.sleep(.1) 867 | mouse_click_in_game_window(self.game_window, legacy=True) 868 | time.sleep(.1) 869 | if self.is_spawn_point_selected(): 870 | break 871 | 872 | return self.is_spawn_point_selected() 873 | 874 | def select_random_spawn_point(self) -> bool: 875 | attempt = 0 876 | max_attempts = 5 877 | while not self.is_spawn_point_selected() and attempt < max_attempts: 878 | # Reset mouse to top left corner 879 | mouse_reset_legacy() 880 | 881 | # Try to select a spawn point by randomly clicking on the spawn menu map 882 | # (use bigger step, since clicking next to a spawn point also works) 883 | spawn_coordinates = random.randrange(260, 613, 22), random.randrange(50, 403, 22) 884 | mouse_move_legacy(spawn_coordinates[0], spawn_coordinates[1]) 885 | time.sleep(.3) 886 | mouse_click_in_game_window(self.game_window, legacy=True) 887 | 888 | attempt += 1 889 | 890 | return self.is_spawn_point_selected() 891 | 892 | @staticmethod 893 | def start_spectating_via_freecam_toggle() -> None: 894 | auto_press_key(0x39) 895 | time.sleep(.2) 896 | 897 | def is_spawn_point_selectable(self) -> bool: 898 | return 'select' in ocr_screenshot_game_window_region( 899 | self.game_window, 900 | self.resolution, 901 | 'spawn-selected-text', 902 | image_ops=[ 903 | (ImageOperation.grayscale, None), 904 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 905 | (ImageOperation.invert, None) 906 | ] 907 | ) 908 | 909 | def is_spawn_point_selected(self) -> bool: 910 | return 'done' in ocr_screenshot_game_window_region( 911 | self.game_window, 912 | self.resolution, 913 | 'spawn-selected-text', 914 | image_ops=[ 915 | (ImageOperation.grayscale, None), 916 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 917 | (ImageOperation.invert, None) 918 | ] 919 | ) 920 | 921 | def is_suicide_button_visible(self) -> bool: 922 | return 'suicide' in ocr_screenshot_game_window_region( 923 | self.game_window, 924 | self.resolution, 925 | 'suicide-button', 926 | image_ops=[ 927 | (ImageOperation.grayscale, None), 928 | (ImageOperation.colorize, {'black': '#000', 'white': '#fff', 'blackpoint': 30, 'whitepoint': 175}), 929 | (ImageOperation.invert, None) 930 | ] 931 | ) 932 | 933 | def show_scoreboard(self, duration: float = .5) -> bool: 934 | # Press tab 935 | press_key(0x0f) 936 | time.sleep(.25) 937 | 938 | # Scoreboard should be visible 939 | if not self.is_scoreboard_visible(): 940 | release_key(0x0f) 941 | return False 942 | 943 | time.sleep(duration) 944 | 945 | # Release tab 946 | release_key(0x0f) 947 | time.sleep(.25) 948 | 949 | # Scoreboard should no longer be visible 950 | return not self.is_scoreboard_visible() 951 | 952 | def is_scoreboard_visible(self) -> bool: 953 | for side in ['table-icons-left', 'table-icons-right']: 954 | histogram = histogram_screenshot_region( 955 | self.game_window, 956 | constants.COORDINATES[self.resolution]['hists']['scoreboard'][side] 957 | ) 958 | 959 | delta = calc_cv2_hist_delta( 960 | histogram, 961 | self.histograms[self.resolution]['scoreboard'][side] 962 | ) 963 | 964 | if delta >= constants.HISTCMP_MAX_DELTA: 965 | return False 966 | 967 | return True 968 | 969 | @staticmethod 970 | def rotate_to_next_player(): 971 | auto_press_key(0x2e) 972 | 973 | def join_game(self) -> bool: 974 | if not self.is_join_game_button_visible(): 975 | return False 976 | 977 | # Move cursor onto join game button and click 978 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'join-game-button') 979 | time.sleep(.2) 980 | mouse_click_in_game_window(self.game_window, legacy=True) 981 | 982 | time.sleep(.5) 983 | 984 | return not self.is_join_game_button_visible() 985 | 986 | def close_game_message(self) -> None: 987 | # Move cursor onto ok button and click 988 | mouse_move_to_game_window_coord(self.game_window, self.resolution, 'game-message-close-button') 989 | time.sleep(.2) 990 | mouse_click_in_game_window(self.game_window) 991 | -------------------------------------------------------------------------------- /BF2AutoSpectator/game/instance_state.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Tuple, Optional 3 | 4 | 5 | class GameInstanceState: 6 | # Global details 7 | __current_mod: str = None 8 | __spectator_on_server: bool = False 9 | __hud_hidden: bool = False 10 | __map_loading: bool = False 11 | __active_join_possible_after: datetime = None 12 | 13 | # TTL details 14 | __round_num: int = 0 15 | __rtl_restart_required: bool = False 16 | 17 | # Server details 18 | __server_ip: str = None 19 | __server_port: str = None 20 | __server_password: str = None 21 | 22 | # Map details (map is as in one entry in the map rotation) 23 | __rotation_map_load_delayed: bool = False 24 | __rotation_map_name: str = None 25 | __rotation_map_size: int = -1 26 | __rotation_game_mode: str = None 27 | __round_spawned: bool = False 28 | __round_spawn_randomize_coordinates: bool = False 29 | __round_freecam_toggle_spawn_attempted: bool = False 30 | __round_entered: bool = False 31 | 32 | # Round details 33 | __round_team: int = -1 34 | 35 | # Counters 36 | __iterations_on_spawn_menu: int = 0 37 | __iterations_on_default_camera_view: int = 0 38 | __iterations_on_player: int = 0 39 | 40 | # Error details 41 | __error_unresponsive_count = 0 42 | __error_restart_required: bool = False 43 | __halted_since: Optional[datetime] = None 44 | 45 | # Global getter/setter functions 46 | def set_spectator_on_server(self, spectator_on_server: bool): 47 | self.__spectator_on_server = spectator_on_server 48 | 49 | def spectator_on_server(self) -> bool: 50 | return self.__spectator_on_server 51 | 52 | def set_hud_hidden(self, hud_hidden: bool): 53 | self.__hud_hidden = hud_hidden 54 | 55 | def hud_hidden(self) -> bool: 56 | return self.__hud_hidden 57 | 58 | def set_map_loading(self, map_loading: bool): 59 | self.__map_loading = map_loading 60 | 61 | def map_loading(self) -> bool: 62 | return self.__map_loading 63 | 64 | def set_active_join_possible(self, after: float): 65 | self.__active_join_possible_after = datetime.now() + timedelta(seconds=after) 66 | 67 | def active_join_pending(self) -> bool: 68 | return self.__active_join_possible_after is not None 69 | 70 | def active_join_possible(self) -> bool: 71 | return self.__active_join_possible_after is not None and datetime.now() > self.__active_join_possible_after 72 | 73 | def increment_round_num(self): 74 | self.__round_num += 1 75 | 76 | def decrement_round_num(self): 77 | self.__round_num -= 1 78 | 79 | def get_round_num(self) -> int: 80 | return self.__round_num 81 | 82 | def set_rtl_restart_required(self, restart_required: bool): 83 | self.__rtl_restart_required = restart_required 84 | 85 | def rtl_restart_required(self) -> bool: 86 | return self.__rtl_restart_required 87 | 88 | # Server getter/setter functions 89 | def set_server(self, server_ip: str, server_port: str, server_password: str): 90 | self.__server_ip = server_ip 91 | self.__server_port = server_port 92 | self.__server_password = server_password 93 | 94 | def get_server(self) -> Tuple[str, str, str]: 95 | return self.__server_ip, self.__server_port, self.__server_password 96 | 97 | def set_server_ip(self, server_ip: str): 98 | self.__server_ip = server_ip 99 | 100 | def get_server_ip(self) -> str: 101 | return self.__server_ip 102 | 103 | def set_server_port(self, server_port: str): 104 | self.__server_port = server_port 105 | 106 | def get_server_port(self) -> str: 107 | return self.__server_port 108 | 109 | def set_server_password(self, server_password: str): 110 | self.__server_password = server_password 111 | 112 | def get_server_password(self) -> str: 113 | return self.__server_password 114 | 115 | def set_rotation_map_load_delayed(self, delayed: bool): 116 | self.__rotation_map_load_delayed = delayed 117 | 118 | def rotation_map_load_delayed(self) -> bool: 119 | return self.__rotation_map_load_delayed 120 | 121 | def set_rotation_map_name(self, map_name: str): 122 | self.__rotation_map_name = map_name 123 | 124 | def get_rotation_map_name(self) -> str: 125 | return self.__rotation_map_name 126 | 127 | def set_rotation_map_size(self, map_size: int): 128 | self.__rotation_map_size = map_size 129 | 130 | def get_rotation_map_size(self) -> int: 131 | return self.__rotation_map_size 132 | 133 | def set_rotation_game_mode(self, game_mode: str): 134 | self.__rotation_game_mode = game_mode 135 | 136 | def get_rotation_game_mode(self) -> str: 137 | return self.__rotation_game_mode 138 | 139 | def set_round_entered(self, entered: bool): 140 | self.__round_entered = entered 141 | 142 | def round_entered(self) -> bool: 143 | return self.__round_entered 144 | 145 | def set_round_team(self, team: int): 146 | self.__round_team = team 147 | 148 | def get_round_team(self) -> int: 149 | return self.__round_team 150 | 151 | def set_round_spawned(self, spawned: bool): 152 | self.__round_spawned = spawned 153 | 154 | def round_spawned(self) -> bool: 155 | return self.__round_spawned 156 | 157 | def set_round_spawn_randomize_coordinates(self, randomize: bool): 158 | self.__round_spawn_randomize_coordinates = randomize 159 | 160 | def get_round_spawn_randomize_coordinates(self) -> bool: 161 | return self.__round_spawn_randomize_coordinates 162 | 163 | def set_round_freecam_toggle_spawn_attempted(self, attempted: bool): 164 | self.__round_freecam_toggle_spawn_attempted = attempted 165 | 166 | def round_freecam_toggle_spawn_attempted(self) -> bool: 167 | return self.__round_freecam_toggle_spawn_attempted 168 | 169 | def increment_iterations_on_spawn_menu(self): 170 | self.__iterations_on_spawn_menu += 1 171 | 172 | def get_iterations_on_spawn_menu(self) -> int: 173 | return self.__iterations_on_spawn_menu 174 | 175 | def increment_iterations_on_default_camera_view(self): 176 | self.__iterations_on_default_camera_view += 1 177 | 178 | def get_iterations_on_default_camera_view(self) -> int: 179 | return self.__iterations_on_default_camera_view 180 | 181 | def set_iterations_on_player(self, iterations: int): 182 | self.__iterations_on_player = iterations 183 | 184 | def increment_iterations_on_player(self): 185 | self.__iterations_on_player += 1 186 | 187 | def get_iterations_on_player(self) -> int: 188 | return self.__iterations_on_player 189 | 190 | def increment_error_unresponsive_count(self): 191 | self.__error_unresponsive_count += 1 192 | 193 | def get_error_unresponsive_count(self) -> int: 194 | return self.__error_unresponsive_count 195 | 196 | def set_error_restart_required(self, restart_required: bool): 197 | self.__error_restart_required = restart_required 198 | 199 | def error_restart_required(self) -> bool: 200 | return self.__error_restart_required 201 | 202 | def set_halted(self, halted: bool): 203 | # Don't overwrite any existing timestamp 204 | if self.__halted_since is not None: 205 | return 206 | self.__halted_since = datetime.now() if halted else None 207 | 208 | def halted(self, grace_period: float = 0.0) -> bool: 209 | if self.__halted_since is None: 210 | return False 211 | return datetime.now() >= self.__halted_since + timedelta(seconds=grace_period) 212 | 213 | # Reset relevant fields after map rotation 214 | def map_rotation_reset(self): 215 | self.__active_join_possible_after = None 216 | self.__rotation_map_load_delayed = False 217 | self.__rotation_map_name = None 218 | self.__rotation_map_size = -1 219 | self.__rotation_game_mode = None 220 | self.__round_entered = False 221 | self.__round_team = -1 222 | self.__round_spawned = False 223 | self.__round_spawn_randomize_coordinates = False 224 | self.__round_freecam_toggle_spawn_attempted = False 225 | self.__iterations_on_spawn_menu = 0 226 | self.__iterations_on_default_camera_view = 0 227 | self.__iterations_on_player = 0 228 | 229 | # Reset relevant fields when round ended 230 | def round_end_reset(self): 231 | self.__active_join_possible_after = None 232 | self.__round_entered = False 233 | self.__round_team = -1 234 | self.__round_spawned = False 235 | self.__round_spawn_randomize_coordinates = False 236 | self.__round_freecam_toggle_spawn_attempted = False 237 | self.__iterations_on_spawn_menu = 0 238 | self.__iterations_on_default_camera_view = 0 239 | self.__iterations_on_player = 0 240 | 241 | def reset_iterations_on_spawn_menu(self): 242 | self.__iterations_on_spawn_menu = 0 243 | 244 | def reset_iterations_on_default_camera_view(self): 245 | self.__iterations_on_default_camera_view = 0 246 | 247 | def reset_iterations_on_player(self): 248 | self.__iterations_on_player = 0 249 | 250 | def reset_error_unresponsive_count(self): 251 | self.__error_unresponsive_count = 0 252 | 253 | # Reset relevant fields after/on game restart 254 | def restart_reset(self): 255 | self.__spectator_on_server = False 256 | self.__hud_hidden = False 257 | self.__map_loading = False 258 | self.__active_join_possible_after = None 259 | self.__round_num = 0 260 | self.__rotation_map_load_delayed = False 261 | self.__rotation_map_name = None 262 | self.__rotation_map_size = -1 263 | self.__rotation_game_mode = None 264 | self.__round_entered = False 265 | self.__round_team = -1 266 | self.__round_spawned = False 267 | self.__round_spawn_randomize_coordinates = False 268 | self.__round_freecam_toggle_spawn_attempted = False 269 | self.__iterations_on_spawn_menu = 0 270 | self.__iterations_on_default_camera_view = 0 271 | self.__iterations_on_player = 0 272 | self.__error_unresponsive_count = 0 273 | self.__error_restart_required = False 274 | self.__halted_since = None 275 | -------------------------------------------------------------------------------- /BF2AutoSpectator/global_state.py: -------------------------------------------------------------------------------- 1 | class GlobalState: 2 | """ 3 | Holds global state variables, meaning variables not tied to an individual game instance 4 | """ 5 | __stopped: bool = False 6 | __halted: bool = False 7 | 8 | def set_stopped(self, stopped: bool) -> None: 9 | self.__stopped = stopped 10 | 11 | def stopped(self) -> bool: 12 | return self.__stopped 13 | 14 | def set_halted(self, halted: bool) -> None: 15 | self.__halted = halted 16 | 17 | def halted(self) -> bool: 18 | return self.__halted 19 | -------------------------------------------------------------------------------- /BF2AutoSpectator/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from .controller_client import ControllerClient, GamePhase 2 | from .obs_client import OBSClient 3 | 4 | __all__ = ['ControllerClient', 'GamePhase', 'OBSClient'] 5 | -------------------------------------------------------------------------------- /BF2AutoSpectator/remote/controller_client.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union 3 | 4 | import socketio 5 | 6 | from BF2AutoSpectator.common.commands import CommandStore 7 | from BF2AutoSpectator.common.logger import logger 8 | 9 | 10 | class GamePhase(str, Enum): 11 | initial = 'initializing' 12 | launching = 'launching' 13 | inMenu = 'in-menu' 14 | loading = 'loading' 15 | spawning = 'spawning' 16 | spectating = 'spectating' 17 | betweenRounds = 'between-rounds' 18 | closing = 'closing' 19 | starting = 'starting' 20 | stopping = 'stopping' 21 | stopped = 'stopped' 22 | halted = 'halted' 23 | 24 | 25 | class ControllerClient: 26 | base_uri: str 27 | 28 | sio: socketio.Client 29 | 30 | def __init__(self, base_uri: str): 31 | self.base_uri = base_uri 32 | 33 | self.sio = socketio.Client() 34 | 35 | @self.sio.event 36 | def connect(): 37 | logger.info('Connected to controller') 38 | 39 | @self.sio.event 40 | def disconnect(): 41 | logger.warning('Disconnected from controller') 42 | 43 | @self.sio.on('command') 44 | def on_command(dto): 45 | logger.debug(f'Controller issued command {dto["command"]} ({dto["args"]})') 46 | cs = CommandStore() 47 | cs.set(dto['command'], dto['args']) 48 | 49 | def connect(self) -> None: 50 | self.sio.connect(self.base_uri, namespaces=['/']) 51 | 52 | def disconnect(self) -> None: 53 | self.sio.disconnect() 54 | 55 | def __del__(self): 56 | self.sio.disconnect() 57 | 58 | def update_current_server(self, server_ip: str, server_port: str, server_pass: str = None) -> None: 59 | if not self.sio.connected: 60 | return 61 | 62 | try: 63 | self.sio.emit('server', { 64 | 'ip': server_ip, 65 | 'port': server_port, 66 | 'password': server_pass 67 | }) 68 | except socketio.client.exceptions.SocketIOError as e: 69 | logger.error(f'Failed to send current server update to controller ({e})') 70 | 71 | def reset_current_server(self) -> None: 72 | if not self.sio.connected: 73 | return 74 | 75 | try: 76 | self.sio.emit('reset') 77 | except socketio.client.exceptions.SocketIOError as e: 78 | logger.error(f'Failed to send current server reset to controller ({e})') 79 | 80 | def update_game_phase(self, phase: GamePhase, **kwargs: Union[str, int, dict]) -> None: 81 | if not self.sio.connected: 82 | return 83 | 84 | try: 85 | self.sio.emit('phase', { 86 | 'phase': phase, 87 | **kwargs 88 | }) 89 | except socketio.client.exceptions.SocketIOError as e: 90 | logger.error(f'Failed to send game phase to controller ({e})') 91 | 92 | def report_player_rotation(self) -> None: 93 | if not self.sio.connected: 94 | return 95 | 96 | try: 97 | self.sio.emit('rotate') 98 | except socketio.client.exceptions.SocketIOError as e: 99 | logger.error(f'Failed to send player rotation report to controller ({e})') 100 | -------------------------------------------------------------------------------- /BF2AutoSpectator/remote/obs_client.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | from urllib.parse import urlparse 4 | 5 | import obsws_python as obs 6 | 7 | from BF2AutoSpectator.common.exceptions import ClientNotConnectedException 8 | from BF2AutoSpectator.common.logger import logger 9 | 10 | 11 | class OBSClient: 12 | host: str 13 | port: int 14 | password: str 15 | 16 | obs: Optional[obs.ReqClient] 17 | 18 | def __init__(self, url: str): 19 | pr = urlparse(url) 20 | self.host = pr.hostname 21 | self.port = pr.port 22 | self.password = pr.password 23 | 24 | def connect(self) -> None: 25 | self.obs = obs.ReqClient(host=self.host, port=self.port, password=self.password) 26 | 27 | def disconnect(self) -> None: 28 | if hasattr(self, 'obs'): 29 | self.obs.base_client.ws.close() 30 | 31 | def __del__(self): 32 | self.disconnect() 33 | 34 | def is_stream_active(self) -> bool: 35 | self.__ensure_connected() 36 | 37 | status = self.obs.get_stream_status() 38 | 39 | return status.output_active or status.output_reconnecting 40 | 41 | def start_stream(self) -> None: 42 | self.__ensure_connected() 43 | 44 | self.obs.start_stream() 45 | 46 | def stop_stream(self) -> None: 47 | self.__ensure_connected() 48 | 49 | self.obs.stop_stream() 50 | 51 | def set_capture_window(self, input_name: str, executable: str, title: str) -> None: 52 | self.__ensure_connected() 53 | 54 | resp = self.__try_get_input_settings(input_name) 55 | if resp is not None and resp.input_settings.get('capture_mode') == 'window': 56 | self.obs.set_input_settings(input_name, { 57 | 'window': self.__format_window_title(executable, title) 58 | }, True) 59 | 60 | def __try_get_input_settings(self, input_name: str) -> Optional[dataclass]: 61 | try: 62 | return self.obs.get_input_settings(input_name) 63 | except obs.reqs.OBSSDKRequestError as e: 64 | logger.error(f'Failed to get input settings: {e}') 65 | 66 | def __ensure_connected(self) -> None: 67 | if not hasattr(self, 'obs'): 68 | raise ClientNotConnectedException('OBSClient is not connected') 69 | 70 | @staticmethod 71 | def __format_window_title(executable: str, title: str) -> str: 72 | escaped = title.replace(':', '#3A') 73 | return f'{escaped}:{escaped}:{executable}' 74 | -------------------------------------------------------------------------------- /BF2AutoSpectator/spectate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import pickle 5 | import sys 6 | import time 7 | from datetime import datetime 8 | 9 | from BF2AutoSpectator.common import constants 10 | from BF2AutoSpectator.common.commands import CommandStore 11 | from BF2AutoSpectator.common.config import Config 12 | from BF2AutoSpectator.common.exceptions import SpawnCoordinatesNotAvailableException 13 | from BF2AutoSpectator.common.logger import logger 14 | from BF2AutoSpectator.common.utility import is_responding_pid, find_window_by_title, taskkill_pid, init_pytesseract 15 | from BF2AutoSpectator.game import GameInstanceManager, GameMessage 16 | from BF2AutoSpectator.remote import ControllerClient, GamePhase, OBSClient 17 | from BF2AutoSpectator.global_state import GlobalState 18 | 19 | 20 | def run(): 21 | parser = argparse.ArgumentParser( 22 | prog='BF2AutoSpectator', 23 | description='Launch and control a Battlefield 2 spectator instance' 24 | ) 25 | parser.add_argument('--version', action='version', version=f'{constants.APP_NAME} v{constants.APP_VERSION}') 26 | parser.add_argument('--player-name', help='Account name of spectating player', type=str, required=True) 27 | parser.add_argument('--player-pass', help='Account password of spectating player', type=str, required=True) 28 | parser.add_argument('--server-ip', help='IP of sever to join for spectating', type=str, required=True) 29 | parser.add_argument('--server-port', help='Port of sever to join for spectating', type=str, default='16567') 30 | parser.add_argument('--server-pass', help='Password of sever to join for spectating', type=str) 31 | parser.add_argument('--server-mod', help='Mod of sever to join for spectating', type=str, 32 | choices=['bf2', 'xpack', 'bfp2', 'arctic_warfare'], default='bf2') 33 | parser.add_argument('--game-path', help='Path to BF2 install folder', 34 | type=str, default='C:\\Program Files (x86)\\EA Games\\Battlefield 2\\') 35 | parser.add_argument('--game-res', help='Resolution to use for BF2 window', choices=['720p', '900p'], type=str, default='720p') 36 | parser.add_argument('--tesseract-path', help='Path to Tesseract install folder', 37 | type=str, default='C:\\Program Files\\Tesseract-OCR\\') 38 | parser.add_argument('--instance-rtl', help='How many rounds to use a game instance for (rounds to live)', type=int, default=6) 39 | parser.add_argument('--min-iterations-on-player', 40 | help='Number of iterations to stay on a player before allowing the next_player command', 41 | type=int, default=1) 42 | parser.add_argument('--map-load-delay', 43 | help='Number of seconds to delay map loading by (think: BF2mld)', 44 | type=int, default=5) 45 | parser.add_argument('--use-controller', dest='use_controller', action='store_true') 46 | parser.add_argument('--controller-base-uri', help='Base uri of web controller', type=str) 47 | parser.add_argument('--control-obs', dest='control_obs', action='store_true') 48 | parser.add_argument('--obs-url', help='OBS WebSocket URL in format "ws://:password@hostname:port"', type=str) 49 | parser.add_argument('--obs-source-name', help='OBS game source name', type=str, default='Battlefield 2') 50 | parser.add_argument('--no-rtl-limit', dest='limit_rtl', action='store_false') 51 | parser.add_argument('--debug-log', dest='debug_log', action='store_true') 52 | parser.add_argument('--debug-screenshot', dest='debug_screenshot', action='store_true') 53 | parser.set_defaults(limit_rtl=True, debug_log=False, debug_screenshot=False, use_controller=False, control_obs=False) 54 | args = parser.parse_args() 55 | 56 | logger.setLevel(logging.DEBUG if args.debug_log else logging.INFO) 57 | 58 | # Transfer argument values to config 59 | config = Config() 60 | config.set_options( 61 | player_name=args.player_name, 62 | player_pass=args.player_pass, 63 | server_ip=args.server_ip, 64 | server_port=args.server_port, 65 | server_pass=args.server_pass, 66 | server_mod=args.server_mod, 67 | game_path=args.game_path, 68 | tesseract_path=args.tesseract_path, 69 | limit_rtl=args.limit_rtl, 70 | instance_rtl=args.instance_rtl, 71 | map_load_delay=args.map_load_delay, 72 | use_controller=args.use_controller, 73 | controller_base_uri=args.controller_base_uri, 74 | control_obs=args.control_obs, 75 | obs_url=args.obs_url, 76 | obs_source_name=args.obs_source_name, 77 | resolution=args.game_res, 78 | debug_screenshot=args.debug_screenshot, 79 | min_iterations_on_player=args.min_iterations_on_player, 80 | max_iterations_on_player=5, 81 | max_iterations_on_default_camera_view=6, 82 | lockup_iterations_on_spawn_menu=5 83 | ) 84 | 85 | # Make sure provided paths.py are valid 86 | if not os.path.isfile(os.path.join(config.get_tesseract_path(), constants.TESSERACT_EXE)): 87 | sys.exit(f'Could not find {constants.TESSERACT_EXE} in given install folder: {args.tesseract_path}') 88 | elif not os.path.isfile(os.path.join(config.get_game_path(), constants.BF2_EXE)): 89 | sys.exit(f'Could not find {constants.BF2_EXE} in given game install folder: {config.get_game_path()}') 90 | 91 | # Init pytesseract 92 | init_pytesseract(config.get_tesseract_path()) 93 | 94 | # Load pickles 95 | logger.debug('Loading pickles') 96 | with open(os.path.join(config.ROOT_DIR, 'pickle', 'histograms.pickle'), 'rb') as histogramFile: 97 | histograms = pickle.load(histogramFile) 98 | 99 | # Init debug directory if debugging is/could be enabled 100 | if config.debug_screenshot() or config.use_controller(): 101 | # Create debug output dir if needed 102 | if not os.path.isdir(config.DEBUG_DIR): 103 | os.mkdir(Config.DEBUG_DIR) 104 | 105 | # Init game instance state store 106 | gim = GameInstanceManager( 107 | config.get_game_path(), 108 | config.get_player_name(), 109 | config.get_player_pass(), 110 | config.get_resolution(), 111 | histograms 112 | ) 113 | gis = gim.get_state() 114 | cc = ControllerClient( 115 | config.get_controller_base_uri() 116 | ) 117 | obsc = OBSClient( 118 | config.get_obs_url() 119 | ) 120 | cs = CommandStore() 121 | 122 | if config.use_controller(): 123 | cc.connect() 124 | cc.update_game_phase(GamePhase.initial) 125 | 126 | if config.control_obs(): 127 | obsc.connect() 128 | 129 | # Try to find any existing game instance 130 | logger.info('Looking for an existing game instance') 131 | got_instance, correct_params, *_ = gim.find_instance(config.get_server_mod()) 132 | 133 | if got_instance and config.control_obs(): 134 | logger.debug('Found existing game window, updating OBS capture window') 135 | try: 136 | obsc.set_capture_window(config.get_obs_source_name(), constants.BF2_EXE, gim.game_window.title) 137 | except Exception as e: 138 | logger.error(f'Failed to update OBS capture window: {e}') 139 | elif not got_instance: 140 | logger.info('Did not find any existing game instance, will launch a new one') 141 | gis.set_error_restart_required(True) 142 | elif not correct_params: 143 | logger.warning('Found game instance is not running with correct parameters, restart required') 144 | gis.set_error_restart_required(True) 145 | 146 | # Start with max to switch away from dead spectator right away 147 | gis.set_iterations_on_player(config.get_max_iterations_on_player()) 148 | gs = GlobalState() 149 | while True: 150 | bf2_window = gim.get_game_window() 151 | # Try to bring BF2 window to foreground 152 | if bf2_window is not None and not gis.error_restart_required(): 153 | try: 154 | gim.bring_to_foreground() 155 | except Exception as e: 156 | logger.error(f'Failed to bring BF2 window to foreground ({str(e)}), restart required') 157 | gis.set_error_restart_required(True) 158 | 159 | # Check if game froze 160 | if bf2_window is not None and not gis.error_restart_required() and not is_responding_pid(bf2_window.pid): 161 | logger.info('Game froze, checking unresponsive count') 162 | # Game will temporarily freeze when map load finishes or when joining server, so don't restart right away 163 | if gis.get_error_unresponsive_count() < 3: 164 | logger.info('Unresponsive count below limit, giving time to recover') 165 | gis.increment_error_unresponsive_count() 166 | time.sleep(2) 167 | continue 168 | else: 169 | logger.error('Unresponsive count exceeded limit, scheduling restart') 170 | gis.set_error_restart_required(True) 171 | elif bf2_window is not None and not gis.error_restart_required() and gis.get_error_unresponsive_count() > 0: 172 | logger.info('Game recovered from temp freeze, resetting unresponsive count') 173 | # Game got it together, reset unresponsive count 174 | gis.reset_error_unresponsive_count() 175 | # Wait for a few seconds to let game settle back in 176 | time.sleep(3) 177 | 178 | # Check for (debug assertion and Visual C++ Runtime) error window 179 | if not gis.error_restart_required() and \ 180 | (find_window_by_title('BF2 Error') is not None or 181 | find_window_by_title('Microsoft Visual C++ Runtime Library') is not None): 182 | logger.error('BF2 error window present, scheduling restart') 183 | gis.set_error_restart_required(True) 184 | 185 | # Check if a game restart command was issued to the controller 186 | # (command store will be empty when not using controller, making this a noop) 187 | force_next_player = False 188 | if cs.pop('start'): 189 | if gs.stopped(): 190 | logger.info('Start command issued via controller, queueing game start') 191 | cc.update_game_phase(GamePhase.starting) 192 | gs.set_stopped(False) 193 | # Set restart required flag 194 | gis.set_error_restart_required(True) 195 | else: 196 | logger.info('Not currently stopped, ignoring start command issued via controller') 197 | 198 | if cs.pop('stop'): 199 | if not gs.stopped(): 200 | logger.info('Stop command issued via controller, queueing game stop') 201 | cc.update_game_phase(GamePhase.stopping) 202 | gs.set_stopped(True) 203 | else: 204 | logger.info('Already stopped, ignoring stop command issued via controller') 205 | 206 | if cs.pop('release'): 207 | if gis.halted(): 208 | logger.info('Release command issued via controller, queuing release') 209 | gs.set_halted(False) 210 | else: 211 | logger.info('Not currently halted, ignoring release command issued via controller') 212 | 213 | if cs.pop('game_restart'): 214 | logger.info('Game restart requested via controller, queueing game restart') 215 | # Set restart required flag 216 | gis.set_error_restart_required(True) 217 | 218 | if cs.pop('rotation_pause'): 219 | logger.info('Player rotation pause requested via controller, pausing rotation') 220 | # Set pause via config 221 | config.pause_player_rotation(constants.PLAYER_ROTATION_PAUSE_DURATION) 222 | 223 | if cs.pop('rotation_resume'): 224 | logger.info('Player rotation resume requested via controller, resuming rotation') 225 | # Unpause via config 226 | config.unpause_player_rotation() 227 | # Set counter to max to rotate off current player right away 228 | gis.set_iterations_on_player(config.get_max_iterations_on_player()) 229 | 230 | if cs.pop('next_player'): 231 | """ 232 | A common issue with the next_player command is it being issued right before we switch to the next player 233 | anyway, either because we reached the iteration limit or detected the player as being afk. So, only 234 | act on command if 235 | a) we did not *just* to this one or 236 | b) the player rotation is paused (eliminates the risk, since we don't switch automatically) 237 | """ 238 | if (gis.get_iterations_on_player() + 1 > config.get_min_iterations_on_player() or 239 | config.player_rotation_paused()): 240 | logger.info('Manual switch to next player requested via controller, queueing switch') 241 | force_next_player = True 242 | else: 243 | logger.info('Minimum number of iterations on player is not reached, ' 244 | 'ignoring controller request to switch to next player') 245 | 246 | if cs.pop('respawn'): 247 | logger.info('Respawn requested via controller, queueing respawn') 248 | gis.set_round_spawned(False) 249 | 250 | if server := cs.pop('join'): 251 | logger.info(f'Controller sent a server to join ({server["ip"]}:{server["port"]})') 252 | config.set_server(server['ip'], server['port'], server.get('password'), 'bf2') 253 | 254 | if cs.pop('rejoin'): 255 | logger.info('Rejoin requested via controller, queuing disconnect') 256 | gis.set_spectator_on_server(False) 257 | 258 | if cs.pop('debug'): 259 | if logger.level != logging.DEBUG or not config.debug_screenshot(): 260 | logger.info('Debug toggle issued via controller, enabling debug options') 261 | logger.setLevel(logging.DEBUG) 262 | config.set_debug_screenshot(True) 263 | else: 264 | logger.info('Debug toggle issued via controller, disabling debug options') 265 | logger.setLevel(logging.INFO) 266 | config.set_debug_screenshot(False) 267 | 268 | if config.control_obs(): 269 | streaming = None 270 | try: 271 | streaming = obsc.is_stream_active() 272 | except Exception as e: 273 | logger.error(f'Failed to check OBS stream status: {str(e)}') 274 | 275 | # When halted, only stop stream after a 180-second grace period to make the reason (error message) 276 | # visible for viewers and/or allow controller to initiate a server switch, resolving the halted state 277 | if streaming is True and (gs.stopped() or gis.halted(grace_period=180)): 278 | # Stop stream when stopped or game instance is halted 279 | logger.info('Stopping OBS stream') 280 | try: 281 | obsc.stop_stream() 282 | time.sleep(5) 283 | except Exception as e: 284 | logger.error(f'Failed to stop OBS stream: {str(e)}') 285 | elif streaming is False and not (gs.stopped() or gis.halted()) and bf2_window is not None: 286 | # Start stream when neither stopped nor halted and BF2 window is open 287 | logger.info('Starting OBS stream') 288 | try: 289 | obsc.start_stream() 290 | time.sleep(5) 291 | except Exception as e: 292 | logger.error(f'Failed to start OBS stream: {str(e)}') 293 | 294 | # Stop existing (and start a new) game instance if required 295 | if gs.stopped() or gis.rtl_restart_required() or gis.error_restart_required(): 296 | if bf2_window is not None and (gs.stopped() or gis.rtl_restart_required()): 297 | cc.update_game_phase(GamePhase.closing) 298 | # Quit out of current instance 299 | logger.info('Quitting existing game instance') 300 | gis.set_rtl_restart_required(False) 301 | if gim.quit_instance(): 302 | logger.debug('Successfully quit game instance') 303 | else: 304 | # If quit was not successful, switch to error restart 305 | logger.error('Quitting existing game instance failed, switching to killing process') 306 | gis.set_error_restart_required(True) 307 | 308 | # Don't use elif here so error restart can be executed right after a failed quit attempt 309 | if bf2_window is not None and gis.error_restart_required(): 310 | cc.update_game_phase(GamePhase.closing) 311 | # Kill any remaining instance by pid 312 | logger.info('Killing existing game instance') 313 | killed = taskkill_pid(bf2_window.pid) 314 | logger.debug(f'Instance killed: {killed}') 315 | # Give Windows time to actually close the window 316 | time.sleep(3) 317 | 318 | # Run find instance to update (dispose of) current game window reference 319 | gim.find_instance(config.get_server_mod()) 320 | 321 | # Don't launch a new instance when stopped 322 | if gs.stopped(): 323 | cc.reset_current_server() 324 | cc.update_game_phase(GamePhase.stopped) 325 | time.sleep(30) 326 | continue 327 | 328 | # Init game new game instance 329 | logger.info('Starting new game instance') 330 | cc.update_game_phase(GamePhase.launching) 331 | got_instance, correct_params, running_mod = gim.launch_instance(config.get_server_mod()) 332 | 333 | """ 334 | BF2 will "magically" restart the game in order switch mods if we join a server with a different mod. Meaning 335 | the window will close when joining the server, which will be detected and trigger an error restart. Since the 336 | game already started a new instance and "+multi" is off, launch_instance will not be able to start another 337 | instance and will instead pick up the instance from the restart. However, BF2 does not keep the game window size 338 | parameters during these restarts. So, after the restart, we have 339 | a) a resolution mismatch (since the parameters were not used for the restart) and 340 | b) a mod mismatch (since the game restarted with a different "+modPath" without telling us) 341 | We will now need to restart again in order to restore the correct resolution, but we need to start with 342 | "+modPath" set to what the game set during the restart. 343 | """ 344 | if running_mod is not None and running_mod != config.get_server_mod(): 345 | logger.warning(f'Game restart itself with a different mod, updating config') 346 | config.set_server_mod(running_mod) 347 | 348 | if got_instance and correct_params and config.control_obs(): 349 | logger.debug('Game instance launched, updating OBS capture window') 350 | try: 351 | obsc.set_capture_window(config.get_obs_source_name(), constants.BF2_EXE, gim.game_window.title) 352 | except Exception as e: 353 | logger.error(f'Failed to update OBS capture window: {e}') 354 | elif not got_instance: 355 | logger.error('Game instance was not launched, retrying') 356 | continue 357 | elif not correct_params: 358 | logger.error('Game instance was not launched with correct parameters, restart required') 359 | continue 360 | 361 | # Bring window to foreground 362 | try: 363 | gim.bring_to_foreground() 364 | except Exception as e: 365 | logger.error(f'Failed to bring BF2 window to foreground ({str(e)}), restart required') 366 | continue 367 | 368 | # Ensure game menu is open, try to open it if not 369 | if gim.is_in_menu() or gim.open_menu(): 370 | cc.update_game_phase(GamePhase.inMenu) 371 | gis.restart_reset() 372 | else: 373 | logger.error('Game menu is not visible and could not be opened, restart required') 374 | gis.set_error_restart_required(True) 375 | 376 | continue 377 | 378 | if gim.is_game_message_visible(): 379 | logger.debug('Game message present, ocr-ing message') 380 | game_message, raw_message = gim.get_game_message() 381 | 382 | if game_message is GameMessage.ServerFull: 383 | logger.warning('Server full, trying to rejoin in 20 seconds') 384 | gis.set_spectator_on_server(False) 385 | time.sleep(20) 386 | elif game_message is GameMessage.Kicked: 387 | logger.warning('Got kicked, trying to rejoin') 388 | gis.set_spectator_on_server(False) 389 | elif game_message is GameMessage.Banned and not (gis.halted() and not gs.halted()): 390 | logger.critical('Got banned, contact server admin') 391 | gis.set_spectator_on_server(False) 392 | gis.set_halted(True) 393 | gs.set_halted(True) 394 | elif game_message is GameMessage.ConnectionLost or game_message is GameMessage.ConnectionFailed: 395 | logger.error('Connection lost/failed, trying to reconnect') 396 | gis.set_spectator_on_server(False) 397 | elif game_message is GameMessage.ModifiedContent: 398 | logger.warning('Got kicked for modified content, trying to rejoin') 399 | gis.set_spectator_on_server(False) 400 | elif game_message is GameMessage.InvalidIP: 401 | logger.error('Join by ip dialogue bugged, restart required') 402 | gis.set_error_restart_required(True) 403 | elif game_message is GameMessage.ReadError: 404 | logger.error('Error reading from GameSpy-ish backend, restart required') 405 | gis.set_error_restart_required(True) 406 | elif game_message is GameMessage.ConnectionRefused: 407 | logger.error('Failed to connect to GameSpy-ish backend, restart required') 408 | gis.set_error_restart_required(True) 409 | elif not (gis.halted() and not gs.halted()): 410 | logger.critical(f'Unhandled game message: {raw_message}') 411 | gis.set_spectator_on_server(False) 412 | gis.set_halted(True) 413 | gs.set_halted(True) 414 | 415 | if not gis.halted(): 416 | cc.update_game_phase(GamePhase.inMenu) 417 | # Close game message to enable actions 418 | gim.close_game_message() 419 | elif not gs.halted(): 420 | cc.update_game_phase(GamePhase.inMenu) 421 | # Close game message to release halted state 422 | logger.info('Releasing halted state') 423 | gim.close_game_message() 424 | gis.set_halted(False) 425 | elif config.use_controller(): 426 | # The situation that caused us to halt can be rectified via the controller 427 | # (game restart/switching servers) 428 | cc.reset_current_server() 429 | cc.update_game_phase(GamePhase.halted, server={ 430 | 'ip': config.get_server_ip(), 431 | 'port': config.get_server_port(), 432 | 'password': config.get_server_pass() 433 | }) 434 | time.sleep(20) 435 | else: 436 | # There is no clear way to recover without a controller, so just exit 437 | sys.exit(1) 438 | 439 | continue 440 | 441 | # Regularly update current server in case controller is restarted or loses state another way 442 | if gis.spectator_on_server() and gis.get_iterations_on_player() == config.get_max_iterations_on_player(): 443 | cc.update_current_server( 444 | gis.get_server_ip(), 445 | gis.get_server_port(), 446 | gis.get_server_password() 447 | ) 448 | 449 | if config.use_controller() and gis.spectator_on_server() and not gis.map_loading() and \ 450 | (config.get_server_ip() != gis.get_server_ip() or 451 | config.get_server_port() != gis.get_server_port() or 452 | config.get_server_pass() != gis.get_server_password()): 453 | logger.info('Server switch requested via controller, disconnecting from current server') 454 | """ 455 | Don't spam press ESC before disconnecting. It can lead to the game opening the menu again after the map has 456 | loaded when (re-)joining a server. Instead, press ESC once and wait a bit longer. Fail and retry next 457 | iteration if menu does not open in time. 458 | """ 459 | if (gim.is_in_menu() or gim.open_menu(max_attempts=1, sleep=3.0)) and gim.disconnect_from_server(): 460 | cc.update_game_phase(GamePhase.inMenu) 461 | gis.set_spectator_on_server(False) 462 | 463 | # If game instance is about to be replaced, add one more round on the new server 464 | if gis.get_round_num() + 1 >= config.get_instance_trl(): 465 | logger.debug('Extending instance lifetime by one round on the new server') 466 | gis.decrement_round_num() 467 | else: 468 | logger.error('Failed to disconnect from server, restart required') 469 | gis.set_error_restart_required(True) 470 | continue 471 | 472 | # Player is not on server, check if rejoining is possible and makes sense 473 | if not gis.spectator_on_server(): 474 | # Ensure game menu is open, try to open it if not 475 | """ 476 | Don't spam press ESC before (re-)joining. It can lead to the game opening the menu again after the map has 477 | loaded. Instead, press ESC once and wait a bit longer. Fail and restart game if menu does not open in time. 478 | """ 479 | if gim.is_in_menu() or gim.open_menu(max_attempts=1, sleep=3.0): 480 | cc.update_game_phase(GamePhase.inMenu) 481 | else: 482 | logger.error('Game menu is not visible and could not be opened, restart required') 483 | gis.set_error_restart_required(True) 484 | continue 485 | 486 | # Disconnect from server if still connected according to menu 487 | if gim.is_disconnect_button_visible(): 488 | logger.warning('Game is still connected to a server, disconnecting') 489 | disconnected = gim.disconnect_from_server() 490 | if not disconnected: 491 | logger.error('Failed to disconnect from server, restart required') 492 | gis.set_error_restart_required(True) 493 | continue 494 | 495 | # (Re-)connect to server 496 | logger.info('(Re-)Connecting to server') 497 | server_ip, server_port, server_pass, *_ = config.get_server() 498 | connected = gim.connect_to_server(server_ip, server_port, server_pass) 499 | # Treat re-connecting as map rotation (state wise) 500 | gis.map_rotation_reset() 501 | # Update state 502 | gis.set_spectator_on_server(connected) 503 | gis.set_map_loading(connected) 504 | if connected: 505 | cc.update_current_server(server_ip, server_port, server_pass) 506 | cc.update_game_phase(GamePhase.loading) 507 | gis.set_server(server_ip, server_port, server_pass) 508 | else: 509 | logger.error('Failed to (re-)connect to server') 510 | 511 | continue 512 | 513 | on_round_finish_screen = gim.is_round_end_screen_visible() 514 | map_is_loading = gim.is_map_loading() 515 | map_briefing_present = gim.is_map_briefing_visible() 516 | default_camera_view_visible = gim.is_default_camera_view_visible() 517 | 518 | # Update instance state if any map load/eor screen is present 519 | # (only _set_ map loading state here, since it should only be _unset_ when attempting to spawn 520 | if not gis.map_loading() and (on_round_finish_screen or map_is_loading or map_briefing_present): 521 | gis.set_map_loading(True) 522 | 523 | # Always reset iteration counter if default camera view is no longer visible 524 | if not default_camera_view_visible and gis.get_iterations_on_default_camera_view() > 0: 525 | logger.info('Game is no longer on default camera view, resetting counter') 526 | gis.reset_iterations_on_default_camera_view() 527 | if config.limit_rtl() and on_round_finish_screen and gis.get_round_num() >= config.get_instance_trl(): 528 | logger.info('Game instance has reached rtl limit, restart required') 529 | gis.set_rtl_restart_required(True) 530 | elif map_is_loading: 531 | logger.info('Map is loading') 532 | # Reset state once if it still reflected to be "in" the round 533 | if gis.round_entered(): 534 | logger.info('Performing map rotation reset') 535 | cc.update_game_phase(GamePhase.betweenRounds) 536 | gis.map_rotation_reset() 537 | time.sleep(6) 538 | continue 539 | 540 | # Suspend/delay map loading to avoid a modified content kick on map switches 541 | delay = config.get_map_load_delay() 542 | if delay > 0 and not gis.rotation_map_load_delayed() and gim.delay_map_load(delay): 543 | gis.set_rotation_map_load_delayed(True) 544 | elif delay == 0 or gis.rotation_map_load_delayed(): 545 | time.sleep(3) 546 | 547 | # Set loading phase *after* between rounds phase to make sure we go spectating -> between rounds -> loading 548 | cc.update_game_phase(GamePhase.loading) 549 | elif map_briefing_present: 550 | logger.info('Map briefing present, checking map') 551 | map_name, map_size, game_mode = gim.get_map_details() 552 | 553 | # Update map state if relevant and required 554 | # Map size should always be != -1 even for unknown maps, only reason for it being -1 would be that the map 555 | # briefing was no longer visible when map size was checked 556 | if map_size != -1 and ( 557 | map_name != gis.get_rotation_map_name() or 558 | map_size != gis.get_rotation_map_size() or 559 | game_mode != gis.get_rotation_game_mode() 560 | ): 561 | logger.debug(f'Updating map state: {map_name}/{map_size}/{game_mode}') 562 | gis.set_rotation_map_name(map_name) 563 | gis.set_rotation_map_size(map_size) 564 | gis.set_rotation_game_mode(game_mode) 565 | 566 | # Give go-ahead for active joining 567 | if map_size != -1 and not gis.active_join_pending(): 568 | logger.debug('Enabling active joining') 569 | gis.set_active_join_possible(after=10) 570 | 571 | # Try to join the game if active join is possible 572 | if gis.active_join_possible() and gim.join_game(): 573 | logger.debug('Entered game by clicking "Join game" button') 574 | 575 | time.sleep(3) 576 | elif on_round_finish_screen: 577 | logger.info('Game is on round finish screen') 578 | # Reset state once if it still reflected to be "in" the round 579 | if gis.round_entered(): 580 | logger.info('Performing round end reset') 581 | cc.update_game_phase(GamePhase.betweenRounds) 582 | gis.round_end_reset() 583 | continue 584 | 585 | """ 586 | When server "rotates" on the same map, we enter the loading state and reset the map details. 587 | However, the map briefing is not opened automatically. So unless we open it manually, we never see the 588 | map briefing and thus cannot detect the map. 589 | """ 590 | if gim.is_join_game_button_visible(): 591 | logger.info('Join game button is visible but map briefing is not, opening map briefing') 592 | if not gim.open_map_briefing(): 593 | logger.error('Failed to open map briefing, attempting to join game and queuing reconnect') 594 | # We need to join the game, else the ESC press to open the menu will join the game instead of 595 | # opening the menu 596 | gim.join_game() 597 | gis.set_spectator_on_server(False) 598 | continue 599 | time.sleep(3) 600 | elif default_camera_view_visible and gis.round_spawned() and \ 601 | gis.get_iterations_on_default_camera_view() == 0: 602 | # In rare cases, an AFK/dead player might be detected as the default camera view 603 | # => try to rotate to next player to "exit" what is detected as the default camera view 604 | logger.info('Game is on default camera view, trying to rotate to next player') 605 | gim.rotate_to_next_player() 606 | gis.increment_iterations_on_default_camera_view() 607 | time.sleep(3) 608 | elif default_camera_view_visible and gis.round_spawned() and \ 609 | gis.get_iterations_on_default_camera_view() < config.get_max_iterations_on_default_camera_view(): 610 | # Default camera view is visible after spawning once, either after a round restart or after the round ended 611 | logger.info('Game is still on default camera view, waiting to see if round ended') 612 | gis.increment_iterations_on_default_camera_view() 613 | time.sleep(3) 614 | elif default_camera_view_visible and gis.round_spawned() and \ 615 | gis.get_iterations_on_default_camera_view() == config.get_max_iterations_on_default_camera_view(): 616 | # Default camera view has been visible for a while, most likely due to a round restart 617 | # => try to restart spectating by pressing space (only works on freecam-enabled servers) 618 | logger.info('Game is still on default camera view, trying to (re-)start spectating via freecam toggle') 619 | gim.start_spectating_via_freecam_toggle() 620 | gis.increment_iterations_on_default_camera_view() 621 | time.sleep(3) 622 | elif default_camera_view_visible and gis.round_spawned() and \ 623 | gis.get_iterations_on_default_camera_view() > config.get_max_iterations_on_default_camera_view(): 624 | # Default camera view has been visible for a while, failed to restart spectating by pressing space 625 | # => spawn-suicide again to restart spectating 626 | logger.info('Game is still on default camera view, queueing another spawn-suicide to restart spectating') 627 | gis.set_round_spawned(False) 628 | gis.reset_iterations_on_default_camera_view() 629 | elif default_camera_view_visible and not gis.round_spawned() and not gis.round_freecam_toggle_spawn_attempted(): 630 | # Try to restart spectating without suiciding on consecutive rounds (only works on freecam-enabled servers) 631 | logger.info('Game is on default camera view, trying to (re-)start spectating via freecam toggle') 632 | gis.set_map_loading(False) 633 | gim.start_spectating_via_freecam_toggle() 634 | gis.set_round_freecam_toggle_spawn_attempted(True) 635 | time.sleep(.5) 636 | # Set round spawned to true of default camera view is no longer visible, else enable hud for spawn-suicide 637 | if not gim.is_default_camera_view_visible(): 638 | logger.info('Started spectating via freecam toggle, skipping spawn-suicide') 639 | cc.update_game_phase(GamePhase.spectating) 640 | gis.set_round_spawned(True) 641 | gis.increment_round_num() 642 | logger.debug(f'Entering round #{gis.get_round_num()} using this instance') 643 | # Spectator has "entered" round, update state accordingly 644 | gis.set_round_entered(True) 645 | # We entered a new round, so we most likely won't be on the player the rotation was originally paused on 646 | # => unpause rotation 647 | config.unpause_player_rotation() 648 | # No need to immediately rotate to next player (usually done after spawn-suicide) 649 | # => set iteration counter to 0 650 | gis.reset_iterations_on_player() 651 | else: 652 | # Don't log this as an error since it's totally normal 653 | logger.info('Failed to start spectating via freecam toggle, continuing to spawn-suicide') 654 | elif not on_round_finish_screen and not gis.round_spawned(): 655 | # Loaded into map, now trying to start spectating 656 | cc.update_game_phase(GamePhase.spawning) 657 | gis.set_map_loading(False) 658 | # Re-enable hud if required 659 | if gis.hud_hidden(): 660 | # Give game time to swap teams 661 | time.sleep(3) 662 | # Re-enable hud 663 | logger.info('Enabling hud') 664 | if not gim.toggle_hud(1): 665 | logger.error(f'Failed to toggle hud, restart required') 666 | gis.set_error_restart_required(True) 667 | continue 668 | # Update state 669 | gis.set_hud_hidden(False) 670 | time.sleep(1) 671 | 672 | if not gim.is_spawn_menu_visible(): 673 | logger.info('Spawn menu not visible, opening with enter') 674 | gim.open_spawn_menu() 675 | # Force another attempt re-enable hud 676 | gis.set_hud_hidden(True) 677 | continue 678 | 679 | logger.info('Determining team') 680 | current_team = gim.get_player_team() 681 | if current_team is not None: 682 | gis.set_round_team(current_team) 683 | gis.set_round_spawn_randomize_coordinates(False) 684 | logger.debug(f'Current team index is {gis.get_round_team()} ' 685 | f'({"USMC/EU/..." if gis.get_round_team() == 0 else "MEC/CHINA/..."})') 686 | elif gim.spawn_coordinates_available(): 687 | # We should be able to detect the team if we have spawn coordinates for the map/size/game mode combination 688 | logger.error('Failed to determine current team, retrying') 689 | # Force another attempt re-enable hud 690 | gis.set_hud_hidden(True) 691 | continue 692 | elif not gis.get_round_spawn_randomize_coordinates(): 693 | # If we were not able to detect a team and map/size/game mod combination is not supported, 694 | # assume that team detection is not available (unsupported mod/custom map) 695 | logger.warning('Team detection is not available, switching to spawn point coordinate randomization') 696 | gis.set_round_spawn_randomize_coordinates(True) 697 | 698 | """ 699 | BF2 sometimes gets stuck on the spawn menu. It will ignore any mouse input, 700 | so no spawn point can be selected. This can usually be fixed by opening the scoreboard once. 701 | """ 702 | if (gis.get_iterations_on_spawn_menu() + 1) % config.get_lockup_iterations_on_spawn_menu() == 0: 703 | logger.warning('Spawn menu may have locked up, trying to recover by toggling scoreboard') 704 | if not gim.show_scoreboard(): 705 | logger.error('Scoreboard did not open/close when trying to recover from spawn menu lockup, ' 706 | 'restart required') 707 | gis.set_error_restart_required(True) 708 | continue 709 | 710 | logger.info('Spawning once') 711 | spawn_succeeded = False 712 | if not gis.get_round_spawn_randomize_coordinates(): 713 | try: 714 | spawn_succeeded = gim.spawn_suicide() 715 | except SpawnCoordinatesNotAvailableException: 716 | logger.warning(f'Spawn point coordinates not available for current combination of map/size/game mode ' 717 | f'({gis.get_rotation_map_name()}/' 718 | f'{gis.get_rotation_map_size()}/' 719 | f'{gis.get_rotation_game_mode()}), switching to spawn point coordinate randomization') 720 | gis.set_round_spawn_randomize_coordinates(True) 721 | 722 | if gis.get_round_spawn_randomize_coordinates(): 723 | logger.info(f'Attempting to spawn by selecting randomly generated spawn point coordinates') 724 | spawn_succeeded = gim.spawn_suicide(randomize=True) 725 | 726 | if spawn_succeeded: 727 | logger.info('Spawn succeeded') 728 | gis.reset_iterations_on_spawn_menu() 729 | else: 730 | logger.warning('Spawn failed, retrying') 731 | gis.increment_iterations_on_spawn_menu() 732 | gis.set_round_spawned(spawn_succeeded) 733 | 734 | # Set counter to max to skip spectator 735 | gis.set_iterations_on_player(config.get_max_iterations_on_player()) 736 | # Unpause in order to not stay on the spectator after suicide 737 | config.unpause_player_rotation() 738 | elif not on_round_finish_screen and not gis.hud_hidden(): 739 | logger.info('Hiding hud') 740 | if not gim.toggle_hud(0): 741 | logger.error(f'Failed to toggle hud, restart required') 742 | gis.set_error_restart_required(True) 743 | continue 744 | cc.update_game_phase(GamePhase.spectating) 745 | gis.set_hud_hidden(True) 746 | elif not on_round_finish_screen and not gis.round_entered(): 747 | gis.increment_round_num() 748 | logger.debug(f'Entering round #{gis.get_round_num()} using this instance') 749 | # Spectator has "entered" round, update state accordingly 750 | gis.set_round_entered(True) 751 | elif not on_round_finish_screen and gis.get_iterations_on_player() < config.get_max_iterations_on_player() and \ 752 | not config.player_rotation_paused() and not force_next_player: 753 | # Check if player is afk 754 | if not gim.is_sufficient_action_on_screen(): 755 | logger.info('Insufficient action on screen') 756 | gis.set_iterations_on_player(config.get_max_iterations_on_player()) 757 | else: 758 | logger.info('Nothing to do, stay on player') 759 | gis.increment_iterations_on_player() 760 | time.sleep(2) 761 | elif not on_round_finish_screen and config.player_rotation_paused() and not force_next_player: 762 | logger.info(f'Player rotation is paused until {config.get_player_rotation_paused_until().isoformat()}') 763 | # If rotation pause flag is still set even though the pause expired, remove the flag 764 | if config.get_player_rotation_paused_until() < datetime.now(): 765 | logger.info('Player rotation pause expired, re-enabling rotation') 766 | config.unpause_player_rotation() 767 | # Set counter to max to rotate off current player right away 768 | gis.set_iterations_on_player(config.get_max_iterations_on_player()) 769 | else: 770 | time.sleep(2) 771 | elif not on_round_finish_screen: 772 | logger.info('Rotating to next player') 773 | gim.rotate_to_next_player() 774 | cc.report_player_rotation() 775 | gis.reset_iterations_on_player() 776 | 777 | 778 | if __name__ == '__main__': 779 | run() 780 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Bulding the executable 2 | In order to provide a simple way of running the spectator without having to install Python, releases as standalone executables can be found under [releases](https://github.com/cetteup/BF2AutoSpectator/releases). 3 | 4 | ## Prerequisites 5 | Make you sure you have all dependencies (see `requirements.txt`) as well as [pyinstaller](https://www.pyinstaller.org/) and [pyinstaller-versionfile](https://pypi.org/project/pyinstaller-versionfile/) installed, before you run the build command. 6 | 7 | ## Build command 8 | First, generate a version file. 9 | 10 | ```commandline 11 | create-version-file.exe versionfile.yaml --outfile versionfile 12 | ``` 13 | 14 | Then, run the following command to build the executable. 15 | 16 | ```commandline 17 | pyinstaller.exe .\BF2AutoSpectator\spectate.py --onefile --clean --name="BF2AutoSpectator" --add-data="pickle/*.pickle;pickle/" --add-data="redist/*.exe;redist/" --version-file="versionfile" 18 | ``` 19 | 20 | This will create a `BF2AutoSpectator.exe` in `.\dist`. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 cetteup 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BF2AutoSpectator 2 | An automated spectator for Battlefield 2 written in Python 🐍 3 | 4 | Battefield 2 might be an old game that is known for sometimes having more dust than hit registration. But it's still around after 15 years and people still play it. So, why not support this game and community with an automated spectator that makes it easy to, for example, stream this game around the clock so people can (re-)discover it via a [live stream](https://www.twitch.tv/bf2tv). 5 | 6 | ## Project state 7 | The goal is to provide a fully automated, fire-and-forget spectator for the game. However, this project is currently in a beta-like state. *Thus, some monitoring of the spectator and any live stream is strongly recommended.* 8 | 9 | ## Features 10 | - automatic start of a BF2 game instance (can be disabled to attach to an existing instance) 11 | - automatic account login 12 | - automatic server join (only by server ip; custom ports and server passwords are supported) 13 | - automatic spawning in game to enable "spectator mode" (see supported maps/sizes below) 14 | - automatic rotation between players of spectator's team 15 | - restart spectating after round restart 16 | - purge server history before launching the game via [bf2-conman](https://github.com/cetteup/conman/releases/tag/v0.1.1) 17 | - in game error detection and handling 18 | - game freeze detection and handling 19 | - support for 720p (1280x720) and 900p (1600x900) game window size/resolution 20 | - (optional) remote control using [bf2-auto-spectator-controller](https://github.com/cetteup/bf2-auto-spectator-controller) 21 | - (optional) control live stream via [OBS WebSocket](https://obsproject.com/kb/remote-control-guide) 22 | 23 | ## Command line arguments 24 | | Argument | Description | Default | Required | 25 | |-------------------------|----------------------------------------------------------------|------------------------------------------------|----------| 26 | | `--version` | Output version information | | | 27 | | `--player-name` | Name of bf2hub account | None | Yes | 28 | | `--player-password` | Passwort for bf2hub account | None | Yes | 29 | | `--server-ip` | IP of server to join | None | Yes | 30 | | `--server-port` | Port of server to join | 16567 | No | 31 | | `--server-pass` | Passwort for server to join | None | No | 32 | | `--server-mod` | Mod of server to join | bf2 | No | 33 | | `--game-path` | Path to BF2 install folder | C:\Program Files (x86)\EA Games\Battlefield 2\ | No | 34 | | `--game-res` | Resolution to use for BF2 window | 720p | No | 35 | | `--tesseract-path` | Path to Tesseract install folder | C:\Program Files\Tesseract-OCR\ | No | 36 | | `--use-controller` | Use a bf2-auto-spectator-controller instance | | | 37 | | `--controller-base-uri` | Base uri of controller instance (format: http[s]://[hostname]) | | | 38 | | `--control-obs` | Control OBS via WebSocket | | | 39 | | `--obs-url` | OBS WebSocket URL (format: ws://:password@hostname:port) | | | 40 | | `--debug-log` | Add debugging information to log output | | | 41 | | `--debug-screenshot` | Write any screenshots to disk for debugging | | | 42 | 43 | You can always get these details locally by providing the `--help` argument. 44 | 45 | ## Supported maps for auto-spawning 46 | ### Vanilla maps 47 | | Map name | 16 size | 32 size | 64 size | 48 | |------------------------|---------|---------|---------| 49 | | Dalian Plant | Yes | Yes | Yes | 50 | | Daqing Oilfields | Yes | Yes | Yes | 51 | | Dragon Valley | Yes | Yes | Yes | 52 | | FuShe Pass | Yes | Yes | Yes | 53 | | Great Wall | Yes | Yes | n/a | 54 | | Gulf of Oman | Yes | Yes | Yes | 55 | | Highway Tampa | Yes | Yes | Yes | 56 | | Kubra Dam | Yes | Yes | Yes | 57 | | Mashtuur City | Yes | Yes | Yes | 58 | | Midnight Sun | Yes | Yes | Yes | 59 | | Operation Blue Pearl | Yes | Yes | Yes | 60 | | Operation Clean Sweep | Yes | Yes | Yes | 61 | | Operation Harvest | Yes | Yes | Yes | 62 | | Operation Road Rage | Yes | Yes | Yes | 63 | | Operation Smoke Screen | Yes | Yes | n/a | 64 | | Road to Jalalabad | Yes | Yes | Yes | 65 | | Sharqi Peninsula | Yes | Yes | Yes | 66 | | Songhua Stalemate | Yes | Yes | Yes | 67 | | Strike at Karkand | Yes | Yes | Yes | 68 | | Taraba Quarry | Yes | Yes | n/a | 69 | | Wake Island 2007 | n/a | n/a | Yes | 70 | | Zatar Wetlands | Yes | Yes | Yes | 71 | 72 | ### Special Forces maps 73 | | Map name | 16 size | 32 size | 64 size | 74 | |------------------------|---------|---------|---------| 75 | | Devil's Perch | Yes | Yes | Yes | 76 | | Ghost Town | Yes | Yes | Yes | 77 | | Leviathan | Yes | Yes | Yes | 78 | | Mass Destruction | Yes | Yes | Yes | 79 | | Night Flight | Yes | Yes | Yes | 80 | | Surge | Yes | Yes | Yes | 81 | | The Iron Gator | Yes | Yes | Yes | 82 | | Warlord | Yes | Yes | Yes | 83 | 84 | ### Battlefield Pirates 2 mod maps 85 | | Map name | 16 size | 32 size | 64 size | 86 | |------------------------------|---------|---------|---------| 87 | | Black Beard's Atol | Yes | Yes | Yes | 88 | | Black Beard's Atol - CTF | n/a | n/a | Yes | 89 | | Blue Bayou | Yes | Yes | Yes | 90 | | Blue Bayou - CTF | n/a | n/a | Yes | 91 | | Blue Bayou - Zombie ¹ | n/a | n/a | Yes | 92 | | Crossbones Keep | Yes | Yes | Yes | 93 | | Crossbones Keep - Zombie ¹ | n/a | n/a | Yes | 94 | | Dead Calm | Yes | Yes | Yes | 95 | | Frylar | Yes | Yes | Yes | 96 | | Frylar - CTF | n/a | n/a | Yes | 97 | | Frylar - Zombie ¹ | n/a | n/a | Yes | 98 | | Lost At Sea | Yes | Yes | Yes | 99 | | O'Me Hearty Beach | Yes | Yes | Yes | 100 | | O'Me Hearty Beach - Zombie ¹ | n/a | n/a | Yes | 101 | | Pelican Point | Yes | Yes | Yes | 102 | | Pelican Point - CTF | n/a | n/a | Yes | 103 | | Pressgang Port | Yes | Yes | Yes | 104 | | Pressgang Port - CTF | n/a | n/a | Yes | 105 | | Sailors Warning | Yes | Yes | Yes | 106 | | Shallow Draft | Yes | Yes | Yes | 107 | | Shallow Draft - CTF | n/a | n/a | Yes | 108 | | Shipwreck Shoals | Yes | Yes | Yes | 109 | | Shipwreck Shoals - CTF | n/a | n/a | Yes | 110 | | Shiver Me Timbers | Yes | Yes | Yes | 111 | | Shiver Me Timbers - CTF | n/a | n/a | Yes | 112 | | Storm the Bastion | Yes | Yes | Yes | 113 | | Storm the Bastion - Zombie ¹ | n/a | Yes | Yes | 114 | | Stranded | Yes | Yes | Yes | 115 | | Stranded - CTF | n/a | n/a | Yes | 116 | | Wake Island 1707 | Yes | Yes | Yes | 117 | 118 | ¹ spawning on zombie versions of maps may fail if the spectator happens to be a zombie, since the zombie class is not always selected by default 119 | 120 | ### Arctic Warfare mod maps 121 | | Map name | 16 size | 32 size | 64 size | 122 | |-----------------|---------|---------|---------| 123 | | Rocky Mountains | Yes | Yes | Yes | 124 | | Yukon Bridge | Yes | Yes | Yes | 125 | 126 | ### Custom maps 127 | | Map name | 16 size | 32 size | 64 size | 128 | |-----------------------------------------------------------------------------------------------|---------|---------|---------| 129 | | [Dalian 2v2](https://bf2.nihlen.net/maps) | Yes | Yes | Yes | 130 | | [Daqing 2v2](https://bf2.nihlen.net/maps) | Yes | Yes | Yes | 131 | | [Dragon 2v2](https://bf2.nihlen.net/maps) | Yes | n/a ¹ | n/a ¹ | 132 | | [Sharqi 2v2](https://bf2.nihlen.net/maps) | Yes | Yes | Yes | 133 | | [Alpin Ressort](https://www.lost-soldiers.org/files/file-11) | Yes | Yes | Yes | 134 | | [Blitzkrieg](https://www.lost-soldiers.org/files/file-11) | Yes | n/a | n/a | 135 | | [Christmas Hill](https://www.lost-soldiers.org/files/file-11) | n/a | Yes | n/a | 136 | | [Frostbite](https://www.lost-soldiers.org/files/file-11) | Yes | Yes | n/a | 137 | | [Frostbite Night](https://www.lost-soldiers.org/files/file-11) | Yes | n/a | n/a | 138 | | [Snowy Park](https://www.lost-soldiers.org/files/file-11) | Yes | n/a | n/a | 139 | | [Snowy Park (Day)](https://www.lost-soldiers.org/files/file-11) | Yes | n/a | n/a | 140 | | [Spring Thaw](https://www.lost-soldiers.org/files/file-11) | n/a | Yes | n/a | 141 | | [Stalingrad Snow](https://www.lost-soldiers.org/files/file-11) | Yes | Yes | Yes | 142 | | [Winter Wake Island](https://www.moddb.com/games/battlefield-2/addons/winter-wake-island-map) | Yes | Yes | Yes | 143 | 144 | ¹ the spawn menu is broken on these sizes of the map 145 | 146 | ## Setup 147 | ### Download and install software 148 | 1. Download and install Tesseract v5.0.1.20220118 from the [Uni Mannheim server](https://digi.bib.uni-mannheim.de/tesseract/) (be sure to use the 64 bit version, [direct link](https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-v5.0.1.20220118.exe)) 149 | 2. Download and extract the [latest release](https://github.com/cetteup/BF2AutoSpectator/releases/latest) 150 | 151 | ### In-game setup 152 | Setting up an extra bf2hub account for the spectator is strongly recommended in order to not mess with your existing account's settings/stats. 153 | 154 | Any settings not explicitly mentioned here can be changed however you like. 155 | 1. Under "game options" 156 | 1. Enable "auto ready" 157 | 2. Enable "opt out of voting" 158 | 3. Set all transparency values to 0 159 | 2. Under "controls" 160 | 1. Remove crouch binding and airplane/helicopter free look binding (and/or any other `left ctrl` bindings) 161 | 2. Bind `left ctrl` to console 162 | 163 | ## How to run 164 | 1. Open CMD or Powershell (recommended) 165 | 2. Enter the path to the controller.exe (can be done by dragging & dropping the .exe onto the CMD/Powershell window) 166 | 3. Enter required command line arguments (see above) 167 | 4. Enter any additional command line arguments (optional) 168 | 5. Hit enter to run the command 169 | 170 | If you want to stop the spectator, hit CTRL + C in the CMD/Powershell window at any time. 171 | 172 | **Please note: You cannot (really) use the computer while the spectator is running. It relies on having control over mouse and keyboard and needs the game window to be focused and in the foreground.** You do, however, have small time-windows between the spectator's actions in which you can start/stop the stream, stop the spectator etc. 173 | 174 | ## Known limitations 175 | - Windows display scaling must be set to 100% 176 | - game locale/language must be set to English 177 | - some elements of your English locale must be somewhat standard (team names, kick messages, menu items etc.) 178 | - the camera and it's movement around the player are controlled by the game 179 | - the spectator is taking up a slot on the server, since it is technically a regular player 180 | -------------------------------------------------------------------------------- /overrides/mods/Arctic_Warfare/Menu_client.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/overrides/mods/Arctic_Warfare/Menu_client.zip -------------------------------------------------------------------------------- /overrides/mods/bfp2/Fonts_client.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/overrides/mods/bfp2/Fonts_client.zip -------------------------------------------------------------------------------- /overrides/mods/bfp2/localization/english/english.utxt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/overrides/mods/bfp2/localization/english/english.utxt -------------------------------------------------------------------------------- /overrides/mods/bfp2/menu_client.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/overrides/mods/bfp2/menu_client.zip -------------------------------------------------------------------------------- /overrides/mods/bfp2/menu_server.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/overrides/mods/bfp2/menu_server.zip -------------------------------------------------------------------------------- /pickle/histograms.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/pickle/histograms.pickle -------------------------------------------------------------------------------- /redist/bf2-conman.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cetteup/BF2AutoSpectator/837c0b1fa3acafe88e2fdd3fc9f8993a8b52835d/redist/bf2-conman.exe -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":semanticCommits" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==10.4.0 2 | PyAutoGUI==0.9.54 3 | numpy==2.0.0 4 | opencv_python==4.10.0.84 5 | pytesseract==0.3.10 6 | requests==2.32.3 7 | pywin32==306 8 | psutil==6.0.0 9 | python-socketio[client]==5.11.3 10 | jellyfish==1.0.4 11 | obsws-python==1.7.0 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = BF2AutoSpectator 3 | version = 0.14.0 4 | description = An automated spectator for Battlefield 2 written in Python 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/cetteup/BF2AutoSpectator 8 | project_urls = 9 | Bug Tracker = https://github.com/cetteup/BF2AutoSpectator/issues 10 | author = cetteup 11 | author_email = me@cetteup.com 12 | license = MIT 13 | classifiers = 14 | Development Status :: 4 - Beta 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: MIT License 17 | Operating System :: Microsoft :: Windows :: Windows 10 18 | Programming Language :: Python :: 3 19 | 20 | [options] 21 | packages = BF2AutoSpectator 22 | python_requires = >=3.8 23 | 24 | [options.entry_points] 25 | console_scripts = 26 | bf2-auto-spectator = BF2AutoSpectator.__main__:run 27 | find-spawn-points = BF2AutoSpectator.find_spawn_points:run -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /versionfile.yaml: -------------------------------------------------------------------------------- 1 | Version: 0.14.0.0 2 | CompanyName: cetteup 3 | FileDescription: BF2 auto spectator 4 | InternalName: BF2 auto spectator 5 | OriginalFilename: BF2AutoSpectator.exe 6 | ProductName: BF2 auto spectator --------------------------------------------------------------------------------