Repository: ReZeroE/StarRail Branch: main Commit: 59830ce63768 Files: 59 Total size: 214.2 KB Directory structure: gitextract_b305clb7/ ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src/ │ └── starrail/ │ ├── __init__.py │ ├── automation/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ ├── __init__.py │ │ │ └── automation_config_handler.py │ │ ├── pixel_calculator/ │ │ │ ├── __init__.py │ │ │ ├── pixel_calculator.py │ │ │ └── resolution_detector.py │ │ ├── recorder.py │ │ └── units/ │ │ ├── __init__.py │ │ ├── action.py │ │ └── sequence.py │ ├── bin/ │ │ ├── __init__.py │ │ ├── loader/ │ │ │ ├── __init__.py │ │ │ └── loader.py │ │ ├── logs/ │ │ │ └── starrail_log.txt │ │ ├── pick/ │ │ │ ├── __init__.py │ │ │ └── pick.py │ │ ├── pid/ │ │ │ └── get_active_pid.c │ │ └── scheduler/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ ├── __init__.py │ │ │ └── starrail_schedule_config.py │ │ └── starrail_scheduler.py │ ├── config/ │ │ ├── __init__.py │ │ └── config_handler.py │ ├── constants.py │ ├── controllers/ │ │ ├── __init__.py │ │ ├── automation_controller.py │ │ ├── c_click_controller.py │ │ ├── star_rail_app.py │ │ ├── streaming_assets_controller.py │ │ ├── web_controller.py │ │ └── webcache_controller.py │ ├── data/ │ │ └── textfiles/ │ │ ├── disclaimer.txt │ │ └── webcache_explain.txt │ ├── entrypoints/ │ │ ├── __init__.py │ │ ├── entrypoint_handler.py │ │ ├── entrypoints.py │ │ └── help_format_handler.py │ ├── exceptions/ │ │ ├── __init__.py │ │ └── exceptions.py │ └── utils/ │ ├── __init__.py │ ├── binary_decoder.py │ ├── game_detector.py │ ├── json_handler.py │ ├── perm_elevate.py │ ├── process_handler.py │ └── utils.py └── tests/ ├── test_starrail.py └── verify_package.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Ignore existing configs src/starrail/config/starrail_config.json src/starrail/bin/scheduler/config/*.json # Local Screenshots src/starrail/_data/images/screenshots/*.png # Temp SIFT Test Images src/starrail/_utils/cv2_SIFT/tmp_data # Incomplete UI scripts src/starrail/user_interface/ui*.py # Temp Test Scritps tests/tmp*test.py # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ================================================ FILE: CHANGELOG.md ================================================ # Change Log ### Version 1.0.5 - [9/25/2024] - Added feature to show live game status (`starrail status --live`). - Various minor bug fixes and code optimizations. ### Version 1.0.4 - [9/4/2024] - Added the following quick-links to the web controller - BiliBili (CN) `> starrail bilibili` - Homepage (CN) `> starrail homepage -cn` - Various minor bug fixes and code optimizations. ### Version 1.0.3, 1.0.4 - [8/29/2024] - Fixed more compatibility issues with the updated game directory structure. - Improved error messaging for the `starrail` scheduler and automation handler. - Introduced a handler to manage case when web cache binary file are locked when the game is active. - Updated the automation section in the README file to use `automation remove` instead of `automation delete`. - Various minor bug fixes and code optimizations. ### Version 1.0.2 - [8/27/2024] - Resolved issue with auto-detecting the game's executable's path. - New HoyoPlay launcher introduced a new game directory structure that broke old game detecting system. Package now matches: 1. `.../Star Rail/Game/StarRail.exe` 2. `.../Star Rail Games/StarRail.exe` - Implemented new "weak match" feature to future-proof another change in game directory structure. - Added new error message if package is unable to locate the game. ### Version 1.0.1 - [8/26/2024] - Resolved version support issues (now supports Python 3.7 and later). ### Version 1.0.0 - [8/1/2024] 1. Complete redesign of the entire project. - [Package Info](https://github.com/ReZeroE/StarRail/wiki/3.-Package-Information) - About Package - Version - Author - Repository - [CLI Launcher & Scheduler](https://github.com/ReZeroE/StarRail/wiki/4.-Start-Stop-&-Schedule-Game) - Start Game - Stop Game - Schedule Start/Stop - [Game Configuration](https://github.com/ReZeroE/StarRail/wiki/5.-Game-Configurations) - Real-time Game Status - Base Game Information - Detailed Client Information - [Simple Automation](https://github.com/ReZeroE/StarRail/wiki/6.-Simple-Automation) - Custom Automation - Uniform Clicks - [Official Page Access](https://github.com/ReZeroE/StarRail/wiki/7.-Official-Page-Access) - Official Homepage - Official Youtube Page - Official HoyoLab Page - [Binary Utilities](https://github.com/ReZeroE/StarRail/wiki/8.-Binary-Utilities) - Decoded Web Cache (events, pulls, announcements) - Cached Pull History - Supplementary Binary Decoder - [Misc Utilities](https://github.com/ReZeroE/StarRail/wiki/9.-Misc-Utilities) - View Screenshots - View Game Logs
Please view the [developer's note](https://github.com/ReZeroE/StarRail/wiki/99.-Developer's-Note) for more information regarding the project overhaul.
***
## Legacy Version Logs ### Version 0.0.3 - [6/7/2023] 1. Optimized `starrail configure` to use multiprocessing Managers (speedup in local game search). 2. Added feature to downscale screen feature matching threshold based on the native screen resolution. - Enabled `starrail` to support 4K, 2K, 1080P or lower resolution screens. 3. Added logout feature. ### Version 0.0.3 - [6/4/2023] 1. Added new rewards logic maps (Daily Training, Assignments) 2. Restructured the code framework of logic maps for better optimization. 3. The starrail show-config command now displays the absolute path of the game executable after configuration. 4. Adjusted image feature matching values to allow for less accurate matches in specific circumstances. 5. The time delay following a simulated mouse or keyboard key click has been extended. ### Version 0.0.3 - [6/3/2023] 1. Added Logic Map for Calyx Golden (bud_of_memories, bud_of_aether, bud_of_threasures) 2. Tested automation features for login, reward collection, and Calyx Golden. 3. Implemented "secondary image detection" with SIFT and FLANN for non-centered buttons (non-centered buttons were previously tracked with pixels offsets (x, y)). ### Version 0.0.3 - [6/1/2023] 1. Implemented "Logic Maps" structures (process sequence maps for automation). 2. Implemented base wrapper classes for auto grind(Calyx), reward collection, and login that utilizes the Logic Maps for automation. 3. Added Logic Maps for login and reward collection. 4. Updated project code structure. ### Version 0.0.3 - [5/21/2023] 1. Implemented SIFT (Scale Invariant Feature Transform) algorithm for feature detection and description, and FLANN (Fast Library for Approximate Nearest Neighbors) for feature matching. This is used to auto-detect buttons on-screen for executing process sequences. 2. Implemented RANSAC for finding homography to account for any scale, rotation or translation between the images to support various game window sizes (4k, 2k, 1080p, etc). ### Version 0.0.3 - [5/10/2023] 1. Optimized the `starrail configure` process to use multithreading when searching for the local game instance (Honkai: Star Rail) for a decrease in runtime. ### Version 0.0.3 - [5/1/2023] 1. Removed faulty dependencies that cannot be properly installed from PyPI 2. Resolved game path auto-detection issue ### Version 0.0.2 - [4/30/2023] 1. Stablized commandline features for start, stop, configure 2. Added commandline feature for overwriting previous path configurations: ```shell $ starrail set-path ``` 3. And other efficiency and maintainability related optimizations ### Version 0.0.2 - [4/29/2023] Added commandline support for the following operations (**UNSTABLE**): 1. Configure `starrail` (only once after download): ```shell $ starrail configure ``` 2. Starting Honkai: Star Rail from the commandline ```shell $ starrail start ``` 3. Terminating Honkai: Star Rail from the commandline (started from `starrail`) ```shell $ starrail stop ``` ### Version 0.0.1 - [4/26/2023] Initial Release ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct _Updated on 7/1/2024_ ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at kevinliu@vt.edu. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Kevin L. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ recursive-include src/starrail *.json *.png *.jpg *.jpeg *.txt *.c *.exe exclude src/starrail/config/*.json exclude src/starrail/bin/scheduler/config/*.json exclude src/starrail/automation/config/automation-data/*.json ================================================ FILE: README.md ================================================ # Honkai: Star Rail - `starrail`

Centered Image

![badge](https://img.shields.io/pypi/dm/starrail) ![GitHub License](https://img.shields.io/github/license/rezeroe/starrail) ![support](https://img.shields.io/badge/support-Python_3.7%2B-blue) ![size](https://img.shields.io/github/repo-size/rezeroe/starrail) ## Overview The `starrail` package is a CLI (command-line interface) tool designed for managing and interacting with the game, Honkai: Star Rail, directly from your terminal. ### Key Features: - **Game Launch Control** - Start/Stop the game application directory from the terminal (skips launcher). - Schedule the game to start/stop at any given time (i.e. 10:30AM). - **Simple Automation** - Custom Macros: Record and playback recorded mouse + keyboard click sequences in-game. - Uniform Click: Allows automatic uniform-interval mouse clicking (duration can be randomized). - **Binary Decoding** - Web Cache: Access decoded web cache URLs containing information about pulls, events, and announcements. - Streaming Assets: Access detailed client streaming asset information (read-only). - Supplement Binary Decoder: Provides supplementary tools to decode any ASCII-based binary file. - **Other Features** - Screenshots: Open the screenshots directory from the CLI without the game launcher. - Official Pages: Open the official HoyoVerse web pages easily from the CLI.
> [!NOTE] > **Installation Requirements** > - Windows 10 or later > - Python 3.7 or later > - Official Honkai: Star Rail Installation
Please review the CHANGELOG for the latest project updates and the [developer's note](https://github.com/ReZeroE/StarRail/wiki/99.-Developer's-Note) for more information regarding the project overhaul.
***
![CLI](https://i.imgur.com/882zWGf.png) # Installation / Setup **STEP 1 - To Install** the `starrail` package, run with **admin permissions**: ```shell > pip install starrail==1.0.5 OR > git clone https://github.com/ReZeroE/StarRail.git > cd StarRail/ > pip install -e . ```
**STEP 2 - To configure** the `starrail` module after installing, run: ```shell > starrail configure ```
**STEP 3 - To verify** that the installation was successful, run: ```shell > starrail config ```
> [!NOTE] > If you encounter any issues during the installation/setup process, visit [this page](https://github.com/ReZeroE/StarRail/wiki/99.-Common-Setup-Issues) for more help!
***
# Start CLI The `starrail` package provides its own standalone CLI environment. You may access the CLI by running: ```shell > starrail ``` This will bring up the StarRail CLI environment where all the commands can be executed without the `starrail` prefix. ![ABC](https://i.imgur.com/cFKRjFV.png) The StarRail CLI environment facilitates efficient execution of the package's supported commands (see Usage Guide section below). Although all commands can be executed directly outside of the CLI environment in any terminal, activating the StarRail CLI allows you to run commands without the `starrail` prefix. > [!NOTE] > > Inside of the StarRail CLI: > ```shell > > about > ``` > > Outside of the StarRail CLI (any terminal): > ```shell > > starrail about > ``` For this guide, all following commands will be shown as if the CLI environment has not been activated.
***
# Usage Guide Below is a brief overview of all the features supported by the `starrail` package. Each section includes a link to a full documentation page where each function is detailed comprehensively. - [Package Info](https://github.com/ReZeroE/StarRail/wiki/3.-Package-Information) - About Package - Version - Author - Repository - [CLI Launcher & Scheduler](https://github.com/ReZeroE/StarRail/wiki/4.-Start-Stop-&-Schedule-Game) - Start Game - Stop Game - Schedule Start/Stop - [Game Configuration](https://github.com/ReZeroE/StarRail/wiki/5.-Game-Configurations) - Real-time Game Status - Base Game Information - Detailed Client Information - [Simple Automation](https://github.com/ReZeroE/StarRail/wiki/6.-Simple-Automation) - Custom Automation - Uniform Clicks - [Official Page Access](https://github.com/ReZeroE/StarRail/wiki/7.-Official-Page-Access) - Official Homepage - Official Youtube Page - Official HoyoLab Page - [Binary Utilities](https://github.com/ReZeroE/StarRail/wiki/8.-Binary-Utilities) - Decoded Web Cache (events, pulls, announcements) - Cached Pull History - Supplementary Binary Decoder - [Misc Utilities](https://github.com/ReZeroE/StarRail/wiki/9.-Misc-Utilities) - View Screenshots - View Game Logs
For the entire list of commands available in the `starrail` package, run: ```shell > starrail help ```
## 1. Package Info To see the general information about the `starrail` package, run: ```shell > starrail about # Shows all information about the package OR > starrail version # Shows HSR and SR-CLI package version > starrail author # Shows author information > starrail repo [--open] # Shows repository link ```
***
## 2. Start/Stop & Schedule Game To start, stop, or schedule the start or stop of Honkai: Star Rail, the following commands are provided. ### ☆ Start Game To start Honkai: Star Rail, run: ```shell > starrail start ```
### ☆ Stop Game To stop Honkai: Star Rail, run: ```shell > starrail stop ```
### ☆ Schedule Start/Stop To schedule the start/stop of Honkai: Star Rail at a given time, you may use the scheduler supplied in the `starrail` package. To view the scheduler's help panel, run: ```shell > starrail schedule ``` **Supported Scheduler Commands** (see usage below) 1. Show Schedule 2. Add Schedule 3. Remove Schedule 4. Clear (remove all) Schedules ``` Example Command Description ---------------------------------------- ---------------------------------------------- starrail schedule add --time 10:30 --action start Schedule Honkai Star Rail to START at 10:30 AM starrail schedule add --time 15:30 --action stop Schedule Honkai Star Rail to STOP at 3:30 PM starrail schedule remove Remove an existing scheduled job starrail schedule show Show all scheduled jobs and their details starrail schedule clear Cancel all scheduled jobs (irreversible) ``` > [!NOTE] > **For the full documentation on scheduling**, visit [this page](https://github.com/ReZeroE/StarRail/wiki/4.-CLI-Launcher-&-Scheduler).
***
## 3. Game Configuration To view Honkai: Star Rail's configuration as well as its detailed client information, several commands are provided. ### ☆ Real-time Game Status To show the real-time status of the game while it's running, run: ```shell > starrail status ``` ``` HSR Status Details ------------- ------------------------------------- Status ✓ Running Process ID 59420 Started On 2024-0x-xx 15:11:04 CPU Percent 1.3% CPU Affinity 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 IO Operations Writes: 247728, Reads: 580093 ``` To show live game status non-stop, run with argument `-l` or `--live`: ```shell > starrail status [-l|--live] ```
### ☆ Base Game Information To view the game's basic information configured under the `starrail` package listed below, run: - Game Version - Game Executable Location - Game Screenshots Directory - Game Logs Directory - Game Executable SHA256 ```shell > starrail config ``` ``` Title Details Related Command ------------------ ------------------------------------------------ -------------------- Game Version 2.3.0 starrail version Game Executable E:\Star Rail\Game\StarRail.exe starrail start/stop Game Screenshots E:\Star Rail\Game\StarRail_Data\ScreenShots starrail screenshots Game Logs E:\Star Rail\logs starrail game-logs Game (.exe) SHA256 2axxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2c ```
### ☆ Detailed Client Information To view detailed information about Honkai: Star Rail, run: - Client Version - Client Datetime - Client Detailed Version - Application Identifier - Service Entrypoint(s) - Client Engine Version - Other Information ```shell > starrail details ``` ``` Title Details (binary) ---------------------- -------------------------------------------------------- Version V2.3Live Datetime String 20240607-2202 Detailed Version 7x02406xx-2xx2-V2.3Live-7xxxxx5-CNPRODWin2.3.0-CnLive-v2 Application Identifier com.miHoYo.hkrpg Service Endpoints https://globaldp-prod-cn0x.bhsr.com/query_dispatch Engine Version EngineReleaseV2.3 Other StartAsset StartDesignData dxxxxxxxxb f4xxxxxxxxxxxxxxxxxxxxxxa7 ```
***
## 4. Simple Automation The `starrail` package supports two automation features to assist with the gameplay of Honkai: Star Rail. ### ☆ Custom Automation This feature enables users to record a sequence of keyboard and mouse actions and replay them within the game. It is specifically optimized for Honkai: Star Rail, ensuring that all actions are only recorded and executed when the game is in focus, thereby preventing unintentional interactions with other applications. To start, enter the following to bring up the help panel for automation: ```shell > starrail automation ``` ``` Example Command Description -------------------------- ---------------------------------------------------- starrail automation record Create and record a new automation sequence (macros) starrail automation show List all recorded automation sequences starrail automation run Run a recorded automation sequence starrail automation remove Delete a recorded automation sequence starrail automation clear Delete all recorded automation sequences ``` > [!NOTE] > **For a full documentation / guide on custom automation**, visit [this page](https://github.com/ReZeroE/StarRail/wiki/6.-Simple-Automation).
### ☆ Uniform Clicks This feature assists with repetitive mouse clicking (such as for "Start Again" after completing a stage in HSR). To use this feature, run: ```shell > starrail click ```
**For example**, to control the mouse to click: 1. Once every 5 seconds and 2. Hold for 2 seconds each click ```shell > starrail click --interval 5 --randomize 1 --hold 2 ```
***
## 5. Official Pages The `starrail` package supports the following simple commands to access Honkai: Star Rail's official pages. ### ☆ Offical Homepage To start Honkai: Star Rail's Official Home Page, run: ```shell > starrail homepage OR > starrail homepage -cn # CN Homepage ```
### ☆ Official HoyoLab Page To start Honkai: Star Rail's Official HoyoLab Page, run: ```shell > starrail hoyolab ```
### ☆ Offical Youtube Page To start Honkai: Star Rail's Official Youtube Page, run: ```shell > starrail youtube ```
### ☆ Offical BiliBili Page (CN) To start Honkai: Star Rail's Official BiliBili Page, run: ```shell > starrail bilibili ```
***
## 6. Binary Utilities The `starrail` package supports a list of binary-related utility commands for web cache and streaming assets decoding. ### ☆ Web Cache Honkai: Star Rail's web cache stores recent web data. To access the decoded web cache URLs containing cached information about events, pulls, and announcements without loading into the game, run: ```shell > starrail webcache ``` The results will be listed in two sections: 1. Events/Pulls Web Cache 2. Announcements Web Cache
### ☆ Cached Pulls To access your Honkai: Star Rail's cached pulls information without logging in to the game, run: ```shell > starrail pulls ``` Web view of all the pull information will open in the default browser.
### ☆ Supplement Binary Decoder The package provides this supplementary tools to decode any ASCII-based binary file. ```shell > starrail decode --path ``` All ASCII-based information will be outputted into a table with index listings as following: ``` Index Content ------- -------------- 0 This is a test 1 /Root xxxx 0 R 2 /Info 1 0 R>> 3 start ref ... ..... ```
***
## 7. Misc Utilities The `starrail` package provides the following quality-of-life utility features to quickly access key game information. ### ☆ Access Screenshots To access the screenshots without the client or searching through the directory, run: ```shell > starrail screenshots ```
### ☆ Access Game Logs To access the game's log files, run: ```shell > starrail game-logs ```
### ☆ Session runtime To get the runtime of the current Honkai: Star Rail session (how long the game has been running since it started), run: ```shell > starrail runtime ```
***
## Disclaimer The "starrail" Python 3 module is an external CLI tool designed to automate the gameplay of Honkai Star Rail. It is designed solely interacts with the game through the existing user interface, and it abides by the Fair Gaming Declaration set forth by COGNOSPHERE PTE. LTD. The package is designed to provide a streamlined and efficient way for users to interact with the game through features already provided within the game, and it does not, in any way, intend to damage the balance of the game or provide any unfair advantages. The package does NOT modify any files in any way. The creator(s) of this package has no relationship with MiHoYo, the game's developer. The use of this package is entirely at the user's own risk, and the creator accepts no responsibility for any damage or loss caused by the package's use. It is the user's responsibility to ensure that they use the package according to Honkai Star Rail's Fair Gaming Declaration, and the creator accepts no responsibility for any consequences resulting from its misuse, including game account penalties, suspension, or bans. By using this package, the user agrees to ALL terms and conditions and acknowledges that the creator will not be held liable for any negative outcomes that may occur as a result of its use.
***
## Repository Star History [![Star History Chart](https://api.star-history.com/svg?repos=ReZeroE/StarRail&type=Date)](https://star-history.com/#ReZeroE/StarRail&Date)
***
## License
================================================ FILE: pyproject.toml ================================================ [build-system] requires = [ "setuptools>=42", "tabulate", "termcolor", "pytest", "psutil", "pynput", "pyautogui", "wheel" ] build-backend = "setuptools.build_meta" ================================================ FILE: requirements.txt ================================================ tabulate termcolor pytest psutil pynput pyautogui pywin32 screeninfo schedule pyreadline3 ================================================ FILE: setup.cfg ================================================ [metadata] name = starrail version = 1.0.5 author = Kevin L. author_email = kevinliu@vt.edu description = Honkai: Star Rail Command Line Tool (CLI) long_description = file: README.md long_description_content_type = text/markdown license_files = LICENSE url = https://github.com/ReZeroE/StarRail project_urls = Bug Tracker = https://github.com/ReZeroE/StarRail/issues classifiers = Programming Language :: Python :: 3 License :: OSI Approved :: MIT License Operating System :: OS Independent Development Status :: 1 - Planning [options] package_dir = = src packages = find: python_requires = >=3.7 install_requires = file: requirements.txt include_package_data = True license = MIT [options.packages.find] packages = starrail where = src [options.entry_points] console_scripts = starrail = starrail.entrypoints.entrypoints:start_starrail ================================================ FILE: setup.py ================================================ import os from setuptools import setup, find_packages setup( name="starrail", version="1.0.5", author="Kevin L.", author_email="kevinliu@vt.edu", description="Honkai: Star Rail Command Line Tool (CLI)", long_description=open("README.md").read(), long_description_content_type="text/markdown", url="https://github.com/ReZeroE/StarRail", project_urls={ "Bug Tracker": "https://github.com/ReZeroE/StarRail/issues", }, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Development Status :: 1 - Planning", ], license="MIT", package_dir={"": "src"}, packages=find_packages(where="src"), python_requires=">=3.7", install_requires=open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "requirements.txt")).read().splitlines(), include_package_data=True, entry_points={ "console_scripts": [ "starrail = starrail.entrypoints.entrypoints:start_starrail", ], }, ) ================================================ FILE: src/starrail/__init__.py ================================================ """ StarRail ===== Honkai: Star Rail CLI Toolkit """ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from starrail.constants import AUTHOR, VERSION, COMMAND, GAME_NAME __author__ = AUTHOR __version__ = VERSION __support__ = GAME_NAME __all__ = [COMMAND] ================================================ FILE: src/starrail/automation/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/automation/config/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/automation/config/automation_config_handler.py ================================================ from starrail.utils.utils import * from starrail.utils.json_handler import JSONConfigHandler class StarRailAutomationConfig: def __init__(self): self.automation_data_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), f"automation-data") if not os.path.isdir(self.automation_data_dir): os.mkdir(self.automation_data_dir) def load_all_automations(self): sequence_name_list = os.listdir(self.automation_data_dir) raw_json_configs = [] for seq_filename in sequence_name_list: AUTOMATION_FILE = os.path.join(self.automation_data_dir, seq_filename) config_handler = JSONConfigHandler(AUTOMATION_FILE) if config_handler.CONFIG_EXISTS(): raw_json_config = config_handler.LOAD_CONFIG() # Loads the sequence config file raw_json_configs.append(raw_json_config) return raw_json_configs def load_automation(self, automation_name): config_handler = JSONConfigHandler(os.path.join(self.automation_data_dir, f"automation-{automation_name}.json")) return config_handler.LOAD_CONFIG() def save_automation(self, automation_name, payload): config_handler = JSONConfigHandler(os.path.join(self.automation_data_dir, f"automation-{automation_name}.json")) return config_handler.SAVE_CONFIG(payload) def delete_automation(self, automation_name): config_handler = JSONConfigHandler(os.path.join(self.automation_data_dir, f"automation-{automation_name}.json")) if config_handler.CONFIG_EXISTS(): return config_handler.DELETE_CONFIG() return False ================================================ FILE: src/starrail/automation/pixel_calculator/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/automation/pixel_calculator/pixel_calculator.py ================================================ from starrail.utils.utils import aprint, LogType from starrail.automation.pixel_calculator.resolution_detector import ResolutionDetector class PixelCalculator: def __init__(self, monitor_info: dict): self.prev_monitor_width = monitor_info["width"] self.prev_monitor_height = monitor_info["height"] current_monitor_info = ResolutionDetector.get_primary_monitor_size() self.current_monitor_width = current_monitor_info["width"] self.current_monitor_height = current_monitor_info["height"] @staticmethod def transform_coordinate(prev_coor: tuple, prev_window_info: dict): try: x, y = prev_coor curr_window_info = ResolutionDetector.get_foreground_window_size() curr_x = curr_window_info["left"] curr_y = curr_window_info["top"] curr_w = curr_window_info["width"] curr_h = curr_window_info["height"] # print(curr_x, curr_y, curr_w, curr_h) prev_x = prev_window_info["left"] prev_y = prev_window_info["top"] prev_w = prev_window_info["width"] prev_h = prev_window_info["height"] # Normalize the coordinate relative to the original window normalized_x = (x - prev_x) / prev_w normalized_y = (y - prev_y) / prev_h new_x = curr_x + normalized_x * curr_w new_y = curr_y + normalized_y * curr_h return (int(new_x), int(new_y)) except KeyError: # Unavailable for pixel calculator (ver 0.0.2) return prev_coor ================================================ FILE: src/starrail/automation/pixel_calculator/resolution_detector.py ================================================ import time import win32process import pygetwindow from ctypes import windll from screeninfo import get_monitors from win32gui import GetWindowRect, GetForegroundWindow from starrail.exceptions.exceptions import * from starrail.utils.process_handler import ProcessHandler class ResolutionDetector: windll.user32.SetProcessDPIAware() @staticmethod def get_primary_monitor_size() -> dict: monitors = get_monitors() for m in monitors: if m.is_primary: monitor_info = { "width": m.width, "height": m.height } return monitor_info return None @staticmethod def get_foreground_window_size(): window_size = GetWindowRect(GetForegroundWindow()) monitor_size = ResolutionDetector.get_primary_monitor_size() return { 'left' : window_size[0], 'top' : window_size[1], 'width' : window_size[2], 'height' : window_size[3], 'is_fullscreen' : window_size[2] == monitor_size["width"] and window_size[2] == monitor_size["height"] } @staticmethod def get_window_size(): pid = ProcessHandler.get_focused_pid() # print(f"Currently focused PID: {pid}") if pid == None: return None win_info: dict = ResolutionDetector.get_window_info(pid) if win_info == None: raise Exception(f"Failed to fetch window size. No results returned (PID {pid}).") monitor_info = ResolutionDetector.get_primary_monitor_size() if monitor_info["width"] == win_info["width"] and \ monitor_info["height"] == win_info["height"] and \ win_info["top"] == 0 and win_info["left"] == 0: win_info["is_fullscreen"] = True else: win_info["is_fullscreen"] = False return win_info @staticmethod def get_window_info(pid, retry: int = 5): for _ in range(retry): windows = pygetwindow.getWindowsWithTitle('') # Get all windows for window in windows: _, window_pid = win32process.GetWindowThreadProcessId(window._hWnd) if window_pid == pid: if (window.left >= 0 and window.top >= 0) and (window.width > 0 and window.height > 0): return { 'left' : window.left, 'top' : window.top, 'width' : window.width, 'height' : window.height, 'is_fullscreen' : None } time.sleep(1) return None ================================================ FILE: src/starrail/automation/recorder.py ================================================ import time import threading import tkinter as tk import asyncio from pynput import mouse, keyboard from starrail.constants import RECORDER_WINDOW_INFO, CALIBRATION_MONITOR_INFO from starrail.automation.units.action import Action, MouseAction, KeyboardAction, ScrollAction from starrail.automation.units.sequence import AutomationSequence from starrail.automation.pixel_calculator.resolution_detector import ResolutionDetector from starrail.utils.utils import * from starrail.exceptions.exceptions import * from starrail.controllers.star_rail_app import HonkaiStarRail ''' MouseAction 1. Action delay ScrollAction 1. Action delay KeyboardAction: 1. Action delay 2. Hold time (0.9.0) ''' class AutomationRecorder(): def __init__(self, sequence: AutomationSequence, starrail_instance: HonkaiStarRail): self.sequence = sequence self.starrail = starrail_instance self.label = None self.prev_action_time = None self.is_recording = False self.stop_event = threading.Event() self.keyboard_key = None self.keyboard_press_time = None def create_indicator_window(self): LENGTH, WIDTH, RADIUS = self.__scale_window(RECORDER_WINDOW_INFO['height'], RECORDER_WINDOW_INFO['width'], RECORDER_WINDOW_INFO["border-radius"]) root = tk.Tk() root.title("Waiting for game to be focused...") root.geometry(f"{WIDTH}x{LENGTH}+0+0") root.overrideredirect(True) # Remove window decorations root.attributes('-topmost', True) # STAY ON TOOOOOPPPPPP # Set the overall background color to black and then make it transparent background_color = 'black' root.configure(bg=background_color) root.attributes('-transparentcolor', background_color) canvas = tk.Canvas(root, bg=background_color, highlightthickness=0) canvas.pack(fill=tk.BOTH, expand=True) # Replace 'black' with the color of your choice for the rounded rectangle canvas_color = '#333333' canvas.create_polygon( [ RADIUS, 0, WIDTH - RADIUS, 0, WIDTH, RADIUS, WIDTH, LENGTH - RADIUS, WIDTH - RADIUS, LENGTH, RADIUS, LENGTH, 0, LENGTH - RADIUS, 0, RADIUS ], smooth=True, fill=canvas_color) self.label = tk.Label(canvas, text="Waiting for game to be focused...", font=('Helvetica', 9), fg='#FFFFFF', bg=canvas_color) self.label.place(relx=0.4, rely=0.5, anchor='center') button_width = 120 button_height = 70 button_width, button_height, _ = self.__scale_window(button_width, button_height) stop_button = tk.Button(canvas, text='Stop\nRecording', font=('Helvetica', 10), command=lambda: self.stop_recording(root), bg='#14628c', fg='#FFFFFF') stop_button.place(relx=0.85, rely=0.5, anchor='center', width=button_width, height=button_height) # Mouse movement handling def on_press(event): root._drag_start_x = event.x root._drag_start_y = event.y def on_drag(event): dx = event.x - root._drag_start_x dy = event.y - root._drag_start_y x = root.winfo_x() + dx y = root.winfo_y() + dy root.geometry(f"+{x}+{y}") root.bind('', on_press) root.bind('', on_drag) self.label.config(text=f"Waiting for game to be focused...") root.mainloop() # ============================================= # ===============| BASE DRIVER | ============== # ============================================= def stop_recording(self, root: tk.Tk): self.stop_event.set() self.is_recording = False self.sequence.actions = self.sequence.actions[:-1] # Remove the last key click (user clicks on the Stop Recording button) root.quit() def pause_or_resume_recording(self): self.is_recording = not self.is_recording def record(self, start_on_callback=False) -> AutomationSequence: threading.Thread(target=self.create_indicator_window, daemon=True).start() if start_on_callback: self.is_recording = True self.prev_action_time = None aprint(f"Ready to record.\n{Printer.to_lightblue(' - To start')}: Focus onto the game and the recording will automatically start.\n{Printer.to_lightblue(' - To stop')}: Click 'Stop Recording' on the top-left corner of the screen.") else: raise Exception("Record must start on callback. Other case not implemented.") # mouse_listener = mouse.Listener(on_click=self.__on_mouse_action) # keyboard_listener = keyboard.Listener(on_press=self.__on_keyboard_action) with mouse.Listener(on_click=self.__on_mouse_action, on_scroll=self.__on_scroll_action) as mouse_listener, \ keyboard.Listener(on_press=self.__on_keyboard_action_press, on_release=self.__on_keyboard_action_release) as keyboard_listener: mouse_listener_thread = threading.Thread(target=mouse_listener.join) keyboard_listener_thread = threading.Thread(target=keyboard_listener.join) mouse_listener_thread.start() keyboard_listener_thread.start() # Wait until stop recording event is triggered self.stop_event.wait() # Stop listeners mouse_listener.stop() keyboard_listener.stop() # Wait for listeners to finish mouse_listener_thread.join() keyboard_listener_thread.join() time.sleep(0.3) return self.sequence # ============================================= # ============| ON-ACTION DRIVER | ============ # ============================================= def __on_mouse_action(self, x, y, button, pressed): ''' On mouse action is executed twice: 1. when the mouse is pressed 2. when the mouse is released The application is only focused on-release, therefore we need to ignore the on-press action. As of version 0.9.0, hold-time has not been implemented. ''' if pressed or not self.starrail.is_focused(): return if self.is_recording: now = time.time() delay = now - self.prev_action_time if self.prev_action_time else float(0) clicked = button == mouse.Button.left # If left is clicked, the click is registered as "clicked", else if right is clicked, only the mouse movement will be registered. self.sequence.add(MouseAction((x, y), delay, clicked, ResolutionDetector.get_foreground_window_size())) self.prev_action_time = now self.on_mouse_action_update_window(x, y, delay) def __on_scroll_action(self, x, y, dx, dy): if not self.starrail.is_focused(): return if self.is_recording: now = time.time() delay = now - self.prev_action_time if self.prev_action_time else float(0) self.sequence.add(ScrollAction((x, y), dx, dy, delay, ResolutionDetector.get_foreground_window_size())) self.prev_action_time = now self.on_scroll_action_update_window(x, y, dx, dy, delay) ''' Key hold_time has been implemented as of 0.9.0 and there are a few key notes. 1. Only one key will be recorded at a time. No key or key-mouse combination is supported. 2. When a new key is pressed with the previous key not yet released, the new key will be completely ignored until the release of the previous key. ''' def __on_keyboard_action_press(self, key): # NOTE: This function will be executed repeatedly during a key-hold by the keyboard listener (aka param key will remain the same on every iteration). # If the current key is the same as the previous key, continue. if key == self.keyboard_key: pass # If there is already an press-regeistered key but it's not the current key (a new key is pressed while the # previous key has not been released), do not register this new key. elif self.keyboard_key != None and key != self.keyboard_key: pass # New key pressed. else: self.keyboard_key = key self.keyboard_press_time = time.time() def __on_keyboard_action_release(self, key): # If key released is not the press-registered key, then ignore this key if key != self.keyboard_key: return # If the app is unfocused, reset the press-registered key and time if not self.starrail.is_focused(): self.keyboard_key = None self.keyboard_press_time = None return # Pause/resume listener on Enter key press elif key == keyboard.Key.space: self.keyboard_key = None self.keyboard_press_time = None self.pause_or_resume_recording() return # Return to not record this space key press # Record only if is_recording is set to True if self.is_recording: now = time.time() hold_time = now - self.keyboard_press_time delay = now - self.prev_action_time - hold_time if self.prev_action_time else float(0) self.sequence.add(KeyboardAction(key, delay, hold_time)) self.prev_action_time = now self.keyboard_key = None self.keyboard_press_time = None self.on_keyboard_action_update_window(key, delay, hold_time) # ============================================= # ============| UPDATE UI WINDOW | ============ # ============================================= def on_mouse_action_update_window(self, x, y, delay): if self.label: self.__update_label(text=f"Mouse Click Detected - ({x}, {y})\nDelay: {round(delay, 2)}") def on_scroll_action_update_window(self, x, y, dx, dy, delay): if self.label: if dy > 0: self.__update_label(text=f"Scroll Detected - ({x}, {y}) - Scrolled UP\nDelay: {round(delay, 2)}") elif dy < 0: self.__update_label(text=f"Scroll Detected - ({x}, {y}) - Scrolled DOWN\nDelay: {round(delay, 2)}") else: self.__update_label(text="Scroll Detected - Horizontal scroll not supported.") def on_keyboard_action_update_window(self, key, delay, hold_time): if self.label: try: self.__update_label(text=f"Key Detected - '{key.char}'\nDelay: {round(delay, 2)}, Hold: {round(hold_time, 2)}") except AttributeError: self.__update_label(text=f"Key Detected - '{key}'\nDelay: {round(delay, 2)}, Hold: {round(hold_time, 2)}") def on_pause_action_update_window(self): if self.is_recording: self.__update_label(text=f"Recording resumed...") else: self.__update_label(text=f"Recording paused...") def __update_label(self, text: str): self.label.place(relx=0.4, rely=0.6, anchor='center') self.label.config(text=f"Total Actions Recorded: {len(self.sequence.actions)}\n{text}\n") def __scale_window(self, standard_width, standard_height, standard_radius=None): monitor_info = ResolutionDetector.get_primary_monitor_size() width_ratio = monitor_info["width"] / CALIBRATION_MONITOR_INFO["width"] height_ratio = monitor_info["height"] / CALIBRATION_MONITOR_INFO["height"] if standard_radius != None: radius = standard_radius * ((width_ratio+height_ratio)/2) else: radius = 0 return int(standard_width*width_ratio), int(standard_height*height_ratio), int(radius) ================================================ FILE: src/starrail/automation/units/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/automation/units/action.py ================================================ import re import time import pyautogui import pynput from pynput import keyboard from pynput.keyboard import Key from abc import ABC, abstractclassmethod from starrail.constants import PYNPUT_KEY_MAPPING ''' Action: A single mouse of keyboard action. Sequence: A list/sequence of Action objects. ''' class Action(ABC): @abstractclassmethod def __init__(self, *args): self.delay = ... from pynput.keyboard import Key @abstractclassmethod def __repr__(self): pass @abstractclassmethod def execute(self, *args): pass @abstractclassmethod def to_json(self): pass class MouseAction(Action): def __init__(self, coor: tuple, delay: float, click: bool, window_info: dict): self.coordinate = coor self.delay = delay self.click = click self.window_info = window_info self.is_valid_for_pixel_calc = self.__is_valid_for_pixel_calc() def execute(self): ''' The delay represent the time lag between the current click and the previous click, therefore time.sleep() is executed at the start of a new action. ''' x = self.coordinate[0] y = self.coordinate[1] pyautogui.moveTo(x, y, duration=0.1) if self.click: # pyautogui.click(interval=0.1) pyautogui.mouseDown() time.sleep(0.1) pyautogui.mouseUp() def to_json(self): return { "coordinate": { "x": self.coordinate[0], "y": self.coordinate[1] }, "delay": self.delay, "click": self.click, "window_info": self.window_info } def __repr__(self): return f"MouseAction(coor={self.coordinate}, delay={round(self.delay, 2)}, click={self.click})" def __is_valid_for_pixel_calc(self): try: assert("width" in self.window_info) assert("height" in self.window_info) assert("top" in self.window_info) assert("left" in self.window_info) assert("is_fullscreen" in self.window_info) return True except AssertionError: return False class ScrollAction(Action): def __init__(self, coor: tuple, dx: float, dy: float, delay: float, window_info: dict): self.coordinate = coor self.delay = delay self.dx = dx self.dy = dy self.window_info = window_info self.is_valid_for_pixel_calc = self.__is_valid_for_pixel_calc() def execute(self): ''' Only dy scrolling is supported as of version 0.9 ''' x = self.coordinate[0] y = self.coordinate[1] pyautogui.moveTo(x, y, duration=0.1) time.sleep(0.05) pyautogui.scroll(self.dy) def to_json(self): return { "coordinate": { "x": self.coordinate[0], "y": self.coordinate[1] }, "scroll": { "dx": self.dx, "dy": self.dy }, "delay": self.delay, "window_info": self.window_info } def __repr__(self): return f"ScrollAction(coor={self.coordinate}, scroll=(dx={self.dx}, dy={self.dy}) delay={round(self.delay, 2)}" def __is_valid_for_pixel_calc(self): try: assert("width" in self.window_info) assert("height" in self.window_info) assert("top" in self.window_info) assert("left" in self.window_info) assert("is_fullscreen" in self.window_info) return True except AssertionError: return False class KeyboardAction(Action): def __init__(self, key: str, delay: float, hold_time: float): self.key = self.reformat_key(key) self.delay = delay self.hold_time = hold_time if hold_time > 0.05 else 0.05 # If hold time < 0.05, then use 0.05 instead (or else the key might not register) def execute(self, keyboard): ''' The delay represent the time lag between the current click and the previous click, therefore time.sleep() is executed at the start of a new action. ''' self.press_key(self.key, keyboard) def to_json(self): return { "key": self.key, "delay": self.delay, "hold_time": self.hold_time } def __repr__(self): return f"KeyboardAction(key={self.key}, delay={round(self.delay, 2)}, hold_time={round(self.hold_time, 2)})" def press_key(self, key: str, pynput_keyboard: pynput.keyboard.Controller): ''' This is going to be a little difficult to explain, but essentially the string format keys recorded by pynput can't be re-recognized by pynput for execution. Therefore, a mapping between the string formated keys (collected by pynput itself) and the actual Key object is used to convert the key when trying to execute the keyboard action. There are going to be a wide range of keys that aren't supported including "hotkeys" or a combination of two or more different keys. This will need to be specified in the ongoing documentation. ''' # TODO: find a better way to implement this, if possible pynput_key = PYNPUT_KEY_MAPPING.get(key, key) try: pynput_keyboard.press(pynput_key) time.sleep(self.hold_time) pynput_keyboard.release(pynput_key) except (keyboard.Controller.InvalidKeyException, ValueError) as ex: # TODO: log this unsupported key as opposed to print it # print(f"Unsupported key: <{key}>") pass def reformat_key(self, key: keyboard.Key) -> str: key: str = str(key).strip().replace("'", "") removing = ["_r", "_gr", "_l"] for suffix in removing: if key.endswith(suffix): key = key.replace(suffix, "") return key ================================================ FILE: src/starrail/automation/units/sequence.py ================================================ import os import time import copy from datetime import datetime from pynput import keyboard from starrail.automation.units.action import Action, MouseAction, ScrollAction, KeyboardAction from starrail.exceptions.exceptions import SRExit from starrail.constants import VERSION from starrail.utils.utils import * from starrail.automation.pixel_calculator.resolution_detector import ResolutionDetector from starrail.automation.pixel_calculator.pixel_calculator import PixelCalculator SUBMODULE_NAME = "AUTO" class AutomationSequence: def __init__( self, sequence_name: str ): self.sequence_name = sequence_name # Default to None (populated during some __init__() and parse_json()) self.date_created: datetime = None # Default to None (and set at to_json() and parse_json()) self.primary_monitor_info: dict = None # Fetch PRIMARY monitor's size (width x height) self.other_data = None self.actions: list[Action] = [] self.global_delay = 0 def __progress_bar(self, actions: list, prefix="", size=40, out=sys.stdout): count = len(actions) start = time.time() # time estimate start total_time = self.get_runtime() def show(j, delay, remaining_time_str): x = int(size*j/count) aprint(f"{prefix}|{u'█'*x}{(' '*(size-x))}| {int(j)}/{count} - Remaining: {remaining_time_str}", end='\r', file=out, flush=True) def secs_to_str(secs): mins, sec = divmod(secs, 60) time_str = f"{int(mins)} mins {round(sec, 2)} secs" return time_str show(0.1, delay=actions[0].delay, remaining_time_str=secs_to_str(total_time)) # avoid div/0 for i, action in enumerate(actions): yield action total_time -= action.delay show(i+1, action.delay, secs_to_str(total_time)) print("", flush=True, file=out) # def normalize(self): # ''' # Introduced in version 0.9.0, the automation will be self-normalized to merge hold actions. # ''' # NORMALIZE_THRESHOLD = 0.05 # actions_copy = copy.deepcopy(self.actions) # prev_action = None # for idx, action in enumerate(self.actions): # if isinstance(action, KeyboardAction) and isinstance(prev_action, KeyboardAction): # if action["delay"] def execute(self): def verbose_action(idx: int, action: Action): buffer_space = " "*5 # Verbose current action # aprint(f"(CMD {idx+1}/{len(self.actions)}) Executing: {action.__repr__()}{buffer_space}", end="\r") aprint(f"[Action {idx}/{len(self.actions)}] Running... ", submodule_name=SUBMODULE_NAME, end="\r") def verbose_warning(): # Verbose warning if the current action isn't suited for the pixel calculator developed in ver0.0.2+ for action in self.actions: if isinstance(action, MouseAction) and action.is_valid_for_pixel_calc == False: aprint(f"This automation sequence is not available for pixel calculator in version {VERSION}.") return # Becuase the pixel calculator will directory modify the MouseAction's coordinates, we need a way to reset the # sequence's coordinates after the sequence finishes running. Therefore, we first make a copy of the sequence # before it is modified by the pixel calculator and then replace the modified sequence at the end. actions_copy = copy.deepcopy(self.actions) verbose_warning() pynput_keyboard = keyboard.Controller() # for idx, action in enumerate(self.__progress_bar(self.actions, f"Running: ", 40)): for idx, action in enumerate(self.actions): verbose_action(idx, action) time.sleep(action.delay) # Execute current action after standard action delay time.sleep(self.global_delay) # Execute current action after global delay # ==================================== # ===========| KEYBOARD | ============ # ==================================== if isinstance(action, KeyboardAction): action.execute(pynput_keyboard) # ================================== # ============| MOUSE | ============ # ================================== elif isinstance(action, MouseAction): if action.is_valid_for_pixel_calc == True: try: new_coord = PixelCalculator.transform_coordinate(action.coordinate, action.window_info) action.coordinate = new_coord except Exception as ex: # TODO: log this error # aprint(Printer.to_lightgrey(f"Warning: PixelCalc not working ({ex})")) pass action.execute() # ================================== # ===========| SCROLL | ============ # ================================== elif isinstance(action, ScrollAction): action.execute() # Reset the modified version of the sequence (modified by the pixel calculator) self.actions = actions_copy def add(self, action: Action): assert(isinstance(action, Action)) self.actions.append(action) def to_json(self): json_data = dict() json_data["metadata"] = { "sequence_name" : self.sequence_name, "date_created" : DatetimeHandler.datetime_to_str(self.date_created), "monitor_info" : ResolutionDetector.get_primary_monitor_size(), "other_data" : self.other_data } json_data["actions_sequence"] = [action.to_json() for action in self.actions] return json_data @staticmethod def parse_config(raw_json_config: list): metadata = raw_json_config["metadata"] sequence_name = metadata["sequence_name"] sequence = AutomationSequence(sequence_name) sequence.date_created = DatetimeHandler.str_to_datetime(metadata["date_created"]) sequence.primary_monitor_info = metadata["monitor_info"] sequence.other_data = metadata["other_data"] actions_sequence = raw_json_config["actions_sequence"] for raw_action in actions_sequence: # MOUSE ACTION ================================================ if "click" in raw_action: mouse_action = MouseAction( ( raw_action["coordinate"]["x"], raw_action["coordinate"]["y"] ), raw_action["delay"], raw_action["click"], raw_action["window_info"] ) sequence.actions.append(mouse_action) # SCROLL ACTION ================================================ elif "scroll" in raw_action: scroll_action = ScrollAction( ( raw_action["coordinate"]["x"], raw_action["coordinate"]["y"] ), raw_action["scroll"]["dx"], raw_action["scroll"]["dy"], raw_action["delay"], raw_action["window_info"] ) sequence.actions.append(scroll_action) # KEYBOARD ACTION ============================================== elif "key" in raw_action: keyboard_action = KeyboardAction( raw_action["key"], raw_action["delay"], raw_action["hold_time"] ) sequence.actions.append(keyboard_action) else: raise Exception(f"Action ({raw_action}) can't be interpreted!") return sequence def print_sequence(self): for action in self.actions: print(action) def set_date_created_to_current(self): self.date_created = DatetimeHandler.get_datetime() def set_global_delay(self, global_delay: int): self.global_delay = global_delay def get_runtime(self): runtime = 0 for action in self.actions: runtime += action.delay runtime += self.global_delay return round(runtime, 2) # New feature in 1.0.0 designed to auto-correct consecutive key holding # def auto_correct(self): # actions_copy = copy.deepcopy(self.actions) # for idx, action in enumerate(self.actions): # if isinstance(action, KeyboardAction): # if actions_copy[idx].key == actions_copy[idx+1].key: # actions_copy[idx].delay += actions_copy[idx+1].delay ================================================ FILE: src/starrail/bin/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/bin/loader/__init__.py ================================================ ================================================ FILE: src/starrail/bin/loader/loader.py ================================================ from itertools import cycle from shutil import get_terminal_size from threading import Thread from time import sleep class Loader: def __init__(self, desc="Loading...", end="Done!", timeout=0.1): """ A loader-like context manager Args: desc (str, optional): The loader's description. Defaults to "Loading...". end (str, optional): Final print. Defaults to "Done!". timeout (float, optional): Sleep time between prints. Defaults to 0.1. """ self.desc = desc self.end = end self.timeout = timeout self._thread = Thread(target=self._animate, daemon=True) self.steps = ['|', '/', '-', '\\'] self.done = False def start(self): self._thread.start() return self def _animate(self): for c in cycle(self.steps): if self.done: break print(f"\r{self.desc} {c}", flush=True, end="") sleep(self.timeout) def __enter__(self): self.start() def stop(self): self.done = True cols = get_terminal_size((80, 20)).columns # print("\r" + " " * cols, end="", flush=True) if self.end != None: print(f"\r{self.end}", flush=True) def __exit__(self, exc_type, exc_value, tb): # handle exceptions with those variables ^ self.stop() if __name__ == "__main__": with Loader("Loading with context manager..."): for i in range(10): sleep(0.25) loader = Loader("Loading with object...", "That was fast!", 0.05).start() # for i in range(10): sleep(5) loader.stop() ================================================ FILE: src/starrail/bin/logs/starrail_log.txt ================================================ ================================================ FILE: src/starrail/bin/pick/__init__.py ================================================ # The MIT License (MIT) # Copyright (c) 2016 Wang Dàpéng # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/bin/pick/pick.py ================================================ # The MIT License (MIT) # Copyright (c) 2016 Wang Dàpéng # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import curses from colorama import init as colorama_init; colorama_init() from dataclasses import dataclass, field from typing import Any, List, Optional, Sequence, Tuple, TypeVar, Union, Generic __all__ = ["Picker", "pick", "Option"] @dataclass class Option: label: str value: Any KEYS_ENTER = (curses.KEY_ENTER, ord("\n"), ord("\r")) KEYS_UP = (curses.KEY_UP, ord("w")) KEYS_DOWN = (curses.KEY_DOWN, ord("s")) KEYS_SELECT = (curses.KEY_RIGHT, ord(" ")) SYMBOL_CIRCLE_FILLED = "[x]" SYMBOL_CIRCLE_EMPTY = "[ ]" OPTION_T = TypeVar("OPTION_T", str, Option) PICK_RETURN_T = Tuple[OPTION_T, int] @dataclass class Picker(Generic[OPTION_T]): options: Sequence[OPTION_T] title: Optional[str] = None indicator: str = ">" default_index: int = 0 multiselect: bool = False min_selection_count: int = 0 selected_indexes: List[int] = field(init=False, default_factory=list) index: int = field(init=False, default=0) screen: Optional["curses._CursesWindow"] = None def __post_init__(self) -> None: if len(self.options) == 0: raise ValueError("options should not be an empty list") if self.default_index >= len(self.options): raise ValueError("default_index should be less than the length of options") if self.multiselect and self.min_selection_count > len(self.options): raise ValueError( "min_selection_count is bigger than the available options, you will not be able to make any selection" ) self.index = self.default_index def move_up(self) -> None: self.index -= 1 if self.index < 0: self.index = len(self.options) - 1 def move_down(self) -> None: self.index += 1 if self.index >= len(self.options): self.index = 0 def mark_index(self) -> None: if self.multiselect: if self.index in self.selected_indexes: self.selected_indexes.remove(self.index) else: self.selected_indexes.append(self.index) def get_selected(self) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]: """return the current selected option as a tuple: (option, index) or as a list of tuples (in case multiselect==True) """ if self.multiselect: return_tuples = [] for selected in self.selected_indexes: return_tuples.append((self.options[selected], selected)) return return_tuples else: return self.options[self.index], self.index def get_title_lines(self) -> List[str]: if self.title: return self.title.split("\n") + [""] return [] def get_option_lines(self) -> List[str]: lines: List[str] = [] for index, option in enumerate(self.options): if not self.multiselect: if index == self.index: prefix = SYMBOL_CIRCLE_FILLED else: prefix = SYMBOL_CIRCLE_EMPTY else: if index == self.index: prefix = self.indicator else: prefix = len(self.indicator) * " " if self.multiselect: symbol = ( SYMBOL_CIRCLE_FILLED if index in self.selected_indexes else SYMBOL_CIRCLE_EMPTY ) prefix = f"{prefix} {symbol}" option_as_str = option.label if isinstance(option, Option) else option lines.append(f"{prefix} {option_as_str}") return lines def get_lines(self) -> Tuple[List, int]: title_lines = self.get_title_lines() option_lines = self.get_option_lines() lines = title_lines + option_lines current_line = self.index + len(title_lines) + 1 return lines, current_line def draw(self, screen: "curses._CursesWindow") -> None: """draw the curses ui on the screen, handle scroll if needed""" screen.clear() x, y = 1, 1 # start point max_y, max_x = screen.getmaxyx() max_rows = max_y - y # the max rows we can draw lines, current_line = self.get_lines() # calculate how many lines we should scroll, relative to the top scroll_top = 0 if current_line > max_rows: scroll_top = current_line - max_rows lines_to_draw = lines[scroll_top : scroll_top + max_rows] for line in lines_to_draw: screen.addnstr(y, x, line, max_x - 2) y += 1 screen.refresh() def run_loop( self, screen: "curses._CursesWindow" ) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]: while True: self.draw(screen) c = screen.getch() if c in KEYS_UP: self.move_up() elif c in KEYS_DOWN: self.move_down() elif c in KEYS_ENTER: if ( self.multiselect and len(self.selected_indexes) < self.min_selection_count ): continue return self.get_selected() elif c in KEYS_SELECT and self.multiselect: self.mark_index() def config_curses(self) -> None: try: # use the default colors of the terminal curses.use_default_colors() # hide the cursor curses.curs_set(0) except: # Curses failed to initialize color support, eg. when TERM=vt100 curses.initscr() def _start(self, screen: "curses._CursesWindow"): self.config_curses() return self.run_loop(screen) def start(self): if self.screen: # Given an existing screen # don't make any lasting changes last_cur = curses.curs_set(0) ret = self.run_loop(self.screen) if last_cur: curses.curs_set(last_cur) return ret return curses.wrapper(self._start) def pick( options: Sequence[OPTION_T], title: Optional[str] = None, indicator: str = ">", default_index: int = 0, multiselect: bool = False, min_selection_count: int = 0, screen: Optional["curses._CursesWindow"] = None, ): picker: Picker = Picker( options, title, indicator, default_index, multiselect, min_selection_count, screen, ) return picker.start() pick(['a', 'b']) ================================================ FILE: src/starrail/bin/pid/get_active_pid.c ================================================ // Compile: // gcc .\get_active_pid.c -o ../get_active_pid #include #include int main() { DWORD pid; HWND hwnd = GetForegroundWindow(); // get handle of currently active window if (hwnd == NULL) { printf("No active window\n"); return 1; } GetWindowThreadProcessId(hwnd, &pid); // get PID // printf("Active window PID: %lu\n", pid); printf("%lu\n", pid); return 0; } ================================================ FILE: src/starrail/bin/scheduler/__init__.py ================================================ ================================================ FILE: src/starrail/bin/scheduler/config/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/bin/scheduler/config/starrail_schedule_config.py ================================================ from starrail.utils.utils import * from starrail.utils.json_handler import JSONConfigHandler class StarRailScheduleConfig(JSONConfigHandler): def __init__(self): __scheduler_config = os.path.join(os.path.abspath(os.path.dirname(__file__)), "schedules.json") super().__init__(__scheduler_config, list) def load_schedule(self): return self.LOAD_CONFIG() def save_schedule(self, payload): ret = self.SAVE_CONFIG(payload) ================================================ FILE: src/starrail/bin/scheduler/starrail_scheduler.py ================================================ import schedule import time import threading from enum import Enum import re import tabulate from starrail.utils.utils import * from starrail.bin.scheduler.config.starrail_schedule_config import StarRailScheduleConfig from starrail.controllers.star_rail_app import HonkaiStarRail SUBMODULE_NAME = "SR-SCL" SUBMODULE_VERSION = "1.0" class OperationTypes(Enum): START = "Start Game" END = "Stop Game" class StartRailJob: def __init__(self, job_id: int, job: schedule.Job, op_type: OperationTypes): self.job_id = job_id self.op_type = op_type self.schedule_job = job self.interval = job.interval self.unit = job.unit self.last_run = job.last_run self.next_run = job.next_run def __str__(self): return f"[{Printer.to_lightpurple('JOB')}] {self.op_type} Game - Next run: {self.next_run}, Last run: {self.last_run}" def print_job(self): print(self.__str__()) def to_dict(self): return { "id" : self.job_id, "op_type" : self.op_type.value, 'interval' : self.interval, 'unit' : self.unit, 'next_run' : str(self.next_run), 'last_run' : str(self.last_run), } class StarRailScheduler: def __init__(self, starrail_instance: HonkaiStarRail): self.starrail = starrail_instance self.schedule_config = StarRailScheduleConfig() self.jobs: dict[int, StartRailJob] = dict() self.__load_schedules() # Load schedule into jobs self._stop_event = threading.Event() self.scheduler_thread = threading.Thread(target=self.__run_scheduler, daemon=True) self.scheduler_thread.start() # ============================================= # =========| START/STOP FUNCTIONS | =========== # ============================================= def __load_schedules(self): schedules: list[StartRailJob] = self.schedule_config.load_schedule() if schedules == None: return for data in schedules: job_id = data["id"] op_type = OperationTypes(data['op_type']) matched_time = re.search("[0-9]{2}:[0-9]{2}:[0-9]{2}", data['next_run']) if matched_time: schedule_time = matched_time.group(0) else: aprint(f"Failed to read schedule with data: {data}", log_type=LogType.ERROR) continue if op_type == OperationTypes.START: job = schedule.every(data['interval']).day.at(schedule_time).do(self.starrail.start) elif op_type == OperationTypes.END: job = schedule.every(data['interval']).day.at(schedule_time).do(self.starrail.terminate) job.last_run = data["last_run"] self.jobs[job_id] = StartRailJob(job_id, job, op_type) def __run_scheduler(self): while not self._stop_event.is_set(): schedule.run_pending() time.sleep(1) def stop_scheduler(self): aprint("Stopping the scheduler...", submodule_name=SUBMODULE_NAME) self._stop_event.set() self.scheduler_thread.join() # ============================================= # =========| ADD/REMOVE FUNCTIONS | =========== # ============================================= def add_new_schedule(self, time_str, operation_type: OperationTypes): parsed_time = self.__parse_time_format(time_str) # If parsed time is invalid if parsed_time == None: aprint(f"Cannot parse time: {time_str}", log_type=LogType.ERROR) raise SRExit() # If scheduled job already exist at the new job time for job_id, job in self.jobs.items(): if job.next_run.strftime(TIME_FORMAT) == parsed_time: aprint(f"{Printer.to_lightred(f'Job (ID {job_id}) is already scheduled at time {parsed_time}.')}") return if operation_type == OperationTypes.START: job = schedule.every().day.at(parsed_time).do(self.starrail.start) elif operation_type == OperationTypes.END: job = schedule.every().day.at(parsed_time).do(self.starrail.terminate) sr_job_id = self.__get_next_job_id() sr_job = StartRailJob(sr_job_id, job, operation_type) self.jobs[sr_job_id] = sr_job aprint(f"New scheduled job (ID {sr_job_id}) has been added to the scheduler successfully!") self.show_schedules() # Save new schedules list into config self.schedule_config.save_schedule([job.to_dict() for job in self.jobs.values()]) def remove_schedule(self): if len(self.jobs) == 0: aprint("No scheduled job to remove.") return self.show_schedules() aprint(f"Which job would you like to remove? [ID 1{'-' + str(len(self.jobs)) if len(self.jobs) > 1 else ''}] ", end="") user_input_id = input("") job_id = self.__parse_id(user_input_id) sr_job: StartRailJob = self.__get_job_with_id(job_id) # Removing job from schedule aprint(f"Removing job (ID {job_id})...", end="\r") schedule.cancel_job(sr_job.schedule_job) # Removing job from cache del self.jobs[job_id] # Reset job IDs after deletion new_schedule_dict = dict() for new_job_id, (old_job_id, sr_job) in enumerate(self.jobs.items()): sr_job.job_id = new_job_id+1 new_schedule_dict[new_job_id+1] = sr_job self.jobs = new_schedule_dict # Saving jobs to config self.schedule_config.save_schedule([job.to_dict() for job in self.jobs.values()]) aprint(f"Job (ID {job_id}) removed successfully.") def show_schedules(self): headers = ["ID", "Type", "Action", "Next Run", "Last Run"] headers = [Printer.to_lightpurple(title) for title in headers] payload = [] for job_id, job in self.jobs.items(): payload.append([job_id, "Scheduled Job", job.op_type.value, job.next_run, "None" if job.last_run == None else job.last_run]) tab = tabulate.tabulate(payload, headers) print("\n" + tab + "\n", flush=True) def clear_schedules(self): if len(self.jobs) == 0: aprint("No scheduled job to clear.") return self.show_schedules() aprint(f"Are you sure you want to clear all {len(self.jobs)} schedules? [y/n] ", end="") user_input = input("").lower().strip() if user_input == "y": for job_id, sr_job in self.jobs.items(): aprint(f"Canceling schedule job (ID {job_id})...", end=" ") schedule.cancel_job(sr_job.schedule_job) print("Done") time.sleep(0.1) self.jobs.clear() self.schedule_config.save_schedule([]) aprint("All scheduled jobs have been canceled successfully.") else: aprint("Clear operation canceled.") # ============================================= # ===========| HELPER FUNCTIONS | ============= # ============================================= def __parse_time_format(self, str_time: str): str_time = str_time.strip().lower() match = re.search("[0-9]+:[0-9]+:[0-9]+", str_time) if match: return match.group(0) match2 = re.search("[0-9]+:[0-9]+", str_time) if match2: return f"{match2.group(0)}:00" try: digit_time = int(str_time) return f"{str_time}:00:00" except ValueError: pass return None def __parse_id(self, str_id: str): try: str_id = int(str_id) except TypeError: aprint(f"Invalid ID: {str_id}", log_type=LogType.ERROR) raise SRExit() return str_id def __get_job_with_id(self, job_id: int): try: target_job = self.jobs[job_id] return target_job except KeyError: aprint(f"Invalid ID: {job_id}", log_type=LogType.ERROR) raise SRExit() def __get_next_job_id(self): return len(self.jobs) + 1 # # # Usage example # sr = HonkaiStarRail() # scheduler = SRScheduler(sr) # scheduler.add_new_schedule("17:52", OperationTypes.START) # Schedules ABC to run every 10 minutes # scheduler.add_new_schedule("22:12", OperationTypes.END) # Schedules ABC to run every 10 minutes # while True: # user_input = input("Enter 'show' to display the schedule or 'exit' to quit: ").strip().lower() # if user_input == 'show': # scheduler.show_schedules() # if user_input == 'remove': # scheduler.remove_schedule() # elif user_input == 'exit': # scheduler.stop_scheduler() # break ================================================ FILE: src/starrail/config/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/config/config_handler.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os import json from starrail.constants import * from starrail.exceptions.exceptions import * from starrail.utils.json_handler import JSONConfigHandler from pathlib import Path # TODO: Config is only loaded the first time when the StarRailConfig is initialized. This is an issue # when the module is configured in the CLI since the loaded configuration persists in the CLI. class StarRailConfig(JSONConfigHandler): def __init__(self): __starrail_config = os.path.join(os.path.abspath(os.path.dirname(__file__)), "starrail_config.json") super().__init__(__starrail_config, dict) __raw_config: dict try: __raw_config = self.LOAD_CONFIG() except json.JSONDecodeError: self.__reset_config() __raw_config = self.LOAD_CONFIG() # Config variables self.instance_pid: int = None self.root_path: Path = None # Root as D:\HoYoPlay\games self.innr_path: Path = None # Inner as D:\HoYoPlay\games\Star Rail Games or D:\HoYoPlay\games\Game self.game_path: Path = None # Game as D:\HoYoPlay\games\Star Rail Games\StarRail.exe self.disclaimer: bool = False # Load config into attributes if __raw_config != None: try: self.instance_pid = __raw_config["instance"]["pid"] self.root_path = __raw_config["static"]["root_path"] self.innr_path = __raw_config["static"]["innr_path"] self.game_path = __raw_config["static"]["game_path"] self.disclaimer = __raw_config["static"]["disclaimer"] if self.root_path != None: self.root_path = Path(self.root_path) if self.innr_path != None: self.innr_path = Path(self.innr_path) if self.game_path != None: self.game_path = Path(self.game_path) except KeyError: self.__reset_config() else: self.__reset_config() # ================================================== # ============== | UTILITY FUNCTIONS | ============= # ================================================== def save_current_config(self): self.SAVE_CONFIG( { "instance": { "pid": self.instance_pid }, "static": { "root_path": str(self.root_path), "innr_path": str(self.innr_path), "game_path": str(self.game_path), "disclaimer": self.disclaimer } } ) def full_configured(self) -> bool: return self.path_configured() and self.disclaimer_configured() def path_configured(self) -> bool: if isinstance(self.game_path, str): self.game_path = Path(self.game_path) if isinstance(self.innr_path, str): self.innr_path = Path(self.innr_path) if isinstance(self.root_path, str): self.root_path = Path(self.root_path) if isinstance(self.root_path, Path) and isinstance(self.game_path, Path) and isinstance(self.innr_path, Path): return self.game_path.exists() and self.root_path.exists() and self.innr_path.exists() return False def disclaimer_configured(self) -> bool: return self.disclaimer # ================================================== # ============== | HELPER FUNCTIONS | ============== # ================================================== def set_path(self, game_path: str): self.game_path = Path(game_path) self.innr_path = os.path.dirname(game_path) self.root_path = Path(os.path.dirname(os.path.dirname(game_path))) def __reset_config(self): self.SAVE_CONFIG( { "instance": { "pid": None }, "static": { "root_path": None, "innr_path": None, "game_path": None, "disclaimer": False } } ) self.game_path = None self.innr_path = None self.root_path = None self.disclaimer = False ================================================ FILE: src/starrail/constants.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os # ============================================== # ==========| MODULE/GAME CONSTANTS | ========== # ============================================== BASENAME = "StarRail CLI" SHORTNAME = "StarRail" COMMAND = "starrail" VERSION = "1.0.5" VERSION_DESC = "Beta" DEVELOPMENT = VERSION_DESC.lower() != "stable" TIME_FORMAT = "%H:%M:%S" DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" GAME_NAME = "Honkai: Star Rail" GAME_FILENAME = "StarRail.exe" GAME_FILE_PATH = f"Star Rail/Game/{GAME_FILENAME}" GAME_FILE_PATH_NEW = f"Star Rail Games/{GAME_FILENAME}" AUTHOR = "Kevin L." AUTHOR_DETAIL = f"{AUTHOR} - kevinliu@vt.edu - Github: ReZeroE" REPOSITORY = "https://github.com/ReZeroE/StarRail" ISSUES = f"{REPOSITORY}/issues" HOMEPAGE_URL = "https://hsr.hoyoverse.com/en-us/home" HOMEPAGE_URL_CN = "https://sr.mihoyo.com/" HOYOLAB_URL = "https://www.hoyolab.com/home" YOUTUBE_URL = "https://www.youtube.com/channel/UC2PeMPA8PAOp-bynLoCeMLA" BILIBILI_URL = "https://space.bilibili.com/1340190821" # ============================================== # =============| OTHER CONSTANTS | ============= # ============================================== CURSOR_UP_ANSI = "\033[A" # See utils/game_detector for details on "Weak Match" MIN_WEAK_MATCH_EXE_SIZE = 0.5 # megabytes # ============================================== # ==================| PATHS | ================== # ============================================== HOME_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) __PYTHON_MODULE_PATH = os.path.join(HOME_DIRECTORY, "Lib", "site-packages", "starrail") if os.path.exists(__PYTHON_MODULE_PATH): # If the module is built using `pip install .`, then the path would be something like C:/PythonXXX/Lib/site-packages/ HOME_DIRECTORY = __PYTHON_MODULE_PATH STARRAIL_DIRECTORY = __PYTHON_MODULE_PATH else: # Else if the module is built locally using `pip install -e .`, then the path will be the local directory structure STARRAIL_DIRECTORY = os.path.join(HOME_DIRECTORY, "src", "starrail") # ============================================== # =================| GLOBALS | ================= # ============================================== # Global variables to identify whether the program is in CLI mode # MUST BE USED AS constants.CLI_MODE (only this accesses the re-bind value) CLI_MODE = False # ============================================== # ================| RECORDER | ================= # ============================================== # Recording window size preset: RECORDER_WINDOW_INFO = { "width": 600, "height": 100, "border-radius": 10 } # Recording window preset size calibrated using: CALIBRATION_MONITOR_INFO = { "width": 3840, "height": 2160 } # ============================================== # ==================| OTHER | ================== # ============================================== WEBCACHE_IGNORE_FILETYPES = [ ".js", ".css", ".png", ".jpg", ".jpeg" ] from pynput.keyboard import Key PYNPUT_KEY_MAPPING = { 'Key.esc' : Key.esc, 'Key.space' : Key.space, 'Key.backspace' : Key.backspace, 'Key.enter' : Key.enter, 'Key.tab' : Key.tab, 'Key.caps_lock' : Key.caps_lock, 'Key.shift' : Key.shift, 'Key.ctrl' : Key.ctrl, 'Key.alt' : Key.alt, 'Key.delete' : Key.delete, 'Key.end' : Key.end, 'Key.home' : Key.home, 'Key.f1' : Key.f1, 'Key.f2' : Key.f2, 'Key.f3' : Key.f3, 'Key.f4' : Key.f4, 'Key.f5' : Key.f5, 'Key.f6' : Key.f6, 'Key.f7' : Key.f7, 'Key.f8' : Key.f8, 'Key.f9' : Key.f9, 'Key.f10' : Key.f10, 'Key.f11' : Key.f11, 'Key.f12' : Key.f12, 'Key.page_down' : Key.page_down, 'Key.page_up' : Key.page_up, 'Key.up' : Key.up, 'Key.down' : Key.down, 'Key.left' : Key.left, 'Key.right' : Key.right, 'Key.media_play_pause' : Key.media_play_pause, 'Key.media_volume_mute' : Key.media_volume_mute, 'Key.media_volume_up' : Key.media_volume_up, 'Key.media_volume_down' : Key.media_volume_down, 'Key.media_previous' : Key.media_previous, 'Key.media_next' : Key.media_next, 'Key.insert' : Key.insert, 'Key.menu' : Key.menu, 'Key.num_lock' : Key.num_lock, 'Key.pause' : Key.pause, 'Key.print_screen' : Key.print_screen, 'Key.scroll_lock' : Key.scroll_lock } ================================================ FILE: src/starrail/controllers/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/controllers/automation_controller.py ================================================ import os import sys import time import tabulate from starrail.utils.utils import * from starrail.automation.units.sequence import AutomationSequence from starrail.automation.recorder import AutomationRecorder from starrail.automation.config.automation_config_handler import StarRailAutomationConfig from starrail.controllers.star_rail_app import HonkaiStarRail class StarRailAutomationController: def __init__(self, starrail_instance: HonkaiStarRail): self.automation_config = StarRailAutomationConfig() self.automation_sequences: dict[int, AutomationSequence] = self.__load_all_sequences() self.starrail = starrail_instance # Only used by recorder to identify if the game is focused. def __load_all_sequences(self): sequence_list: list[AutomationSequence] = [] sequence_config_list = self.automation_config.load_all_automations() for config in sequence_config_list: sequence = AutomationSequence.parse_config(config) sequence_list.append(sequence) sequence_list.sort(key=lambda seq: seq.date_created) return {idx+1: sequence for idx, sequence in enumerate(sequence_list)} def verbose_general_usage(self): headers = [Printer.to_lightpurple(title) for title in ["Example Command", "Description"]] data = [ [color_cmd("automation record"), "Create and record a new automation sequence (macros)"], [color_cmd("automation show"), "List all recorded automation sequences"], [color_cmd("automation run"), "Run a recorded automation sequence"], [color_cmd("automation remove"), "Delete a recorded automated sequence"], [color_cmd("automation clear"), "Delete all recorded automation sequences"], ] tab = tabulate.tabulate(data, headers) print("\n" + tab + "\n") # ============================================= # ============| FETCH AUTOMATION | ============ # ============================================= def get_all_sequences(self, list_format=False): if list_format: return list(self.automation_sequences.values()) return self.automation_sequences def get_sequence(self, sequence_name: str): target_sequence_name = self.__reformat_sequence_name(sequence_name) for sequence in self.automation_sequences.values(): if sequence.sequence_name == target_sequence_name: return sequence return None def get_sequence_with_id(self, sequence_id: int): try: return self.automation_sequences[sequence_id] except KeyError: return None # ============================================= # ============| DRIVER FUNCTIONS | ============ # ============================================= def run_requence(self, sequence_name=None): if len(self.automation_sequences) == 0: aprint(f"{Printer.to_lightred('No automation sequence has been recorded.')}") self.verbose_general_usage() return sequence = None if sequence_name: r_sequence_name = self.__reformat_sequence_name(sequence_name) sequence = self.get_sequence(r_sequence_name) if sequence == None: aprint(f"Invalid input. No sequence with name {sequence_name}.") raise SRExit() else: self.show_sequences() aprint(f"Which sequence would you like to run? ({self.get_range_string()}) ", end="") user_input_id = input("") sequence = self.get_sequence_with_id(self.tryget_int(user_input_id)) if sequence == None: aprint(f"Invalid input. No sequence with ID {user_input_id}.") raise SRExit() aprint(f"Run automation '{sequence.sequence_name}' ({Printer.to_lightgrey(f'approximately {sequence.get_runtime()} seconds')})? [y/n] ", end="") user_input = input("").strip().lower() if user_input != "y": return aprint(f"Automation run ready start.\n{Printer.to_lightblue(' - To start')}: Focus on the game and the automation will automatically start.") while True: if self.starrail.is_focused(): time.sleep(1) break else: time.sleep(0.5) sequence.execute() aprint("Automation sequence run complete! ") def show_sequences(self): headers = [Printer.to_lightpurple(title) for title in ["ID", "Sequence Name", "Date Created", "Runtime", "Actions Count"]] data = [] for seq_id, sequence in self.automation_sequences.items(): row = [Printer.to_lightblue(seq_id), sequence.sequence_name, sequence.date_created, f"{sequence.get_runtime()} seconds", len(sequence.actions)] data.append(row) tab = tabulate.tabulate(data, headers) print("\n" + tab + "\n", flush=True) def record_sequences(self, sequence_name=None): if sequence_name == None: aprint("New sequence name: ", end="") sequence_name = input("").strip() # Initialize empty automation sequence for storing recording NEW_SEQUENCE_NAME = self.__reformat_sequence_name(sequence_name) if self.__sequence_already_exist(NEW_SEQUENCE_NAME): aprint(f"Sequence with name '{NEW_SEQUENCE_NAME}' {Printer.to_lightred('already exist')}.") raise SRExit() recording_sequence = AutomationSequence(NEW_SEQUENCE_NAME) recording_sequence.set_date_created_to_current() # Start recording try: action_recorder = AutomationRecorder(recording_sequence, self.starrail) action_recorder.record(start_on_callback=True) except Exception as ex: aprint(f"Uncaught Error (during recording): {ex}") raise SRExit() # TODO: Auto refactor the recorded sequence such that consecutive holds are merged. # Convert recorded sequence into JSON and save it as config json_sequence = recording_sequence.to_json() success = self.automation_config.save_automation(recording_sequence.sequence_name, json_sequence) assert(success == True) # Cache recorded sequence in memory sequence_id = self.get_next_sequence_id() self.automation_sequences[sequence_id] = recording_sequence aprint(f"Recording complete. Saved as '{Printer.to_light_blue(recording_sequence.sequence_name)}' (ID {sequence_id}).\n{Printer.to_lightblue(' - To view')}: {color_cmd('automation show')}\n{Printer.to_lightblue(' - To run')}: {color_cmd(f'automation run')}") return recording_sequence def delete_sequence(self): if len(self.automation_sequences) == 0: aprint("No automation sequence has been recorded.") raise SRExit() self.show_sequences() aprint(f"Which sequence would you like to delete? ({self.get_range_string()}) ", end="") user_input_id = input("") seq_id = self.tryget_int(user_input_id) sequence = self.get_sequence_with_id(seq_id) if sequence == None: aprint(f"Invalid input. No sequence with ID {user_input_id}.") raise SRExit() self.automation_config.delete_automation(sequence.sequence_name) del self.automation_sequences[seq_id] aprint(f"Automation sequence `{sequence.sequence_name}` has been deleted.") def clear_sequences(self): if len(self.automation_sequences) == 0: aprint("No automation sequence has been recorded.") raise SRExit() self.show_sequences() aprint(f"Are you sure you would like to clear all {len(self.automation_sequences)} automation sequences? [y/n] ", end="") user_input = input("").strip().lower() if user_input == "y": for sequence in self.automation_sequences.values(): aprint(f"Deleting automation sequence '{sequence.sequence_name}' ...") self.automation_config.delete_automation(sequence.sequence_name) self.automation_sequences.clear() aprint("All automation sequences have been deleted.") # ============================================= # ============| HELPER FUNCTIONS | ============ # ============================================= def tryget_int(self, user_input_id): if isinstance(user_input_id, str): try: seq_id = int(user_input_id) except TypeError: aprint(f"Invalid input: {user_input_id}", log_type=LogType.ERROR) raise SRExit() elif isinstance(user_input_id, int): seq_id = user_input_id return seq_id def __sequence_already_exist(self, r_sequence_name): for seq in self.automation_sequences.values(): if seq.sequence_name == r_sequence_name: return True return False def get_range_string(self): if len(self.automation_sequences) <= 1: return "ID 1" return f"ID {min(self.automation_sequences.keys())}-{max(self.automation_sequences.keys())}" def get_next_sequence_id(self): if len(self.automation_sequences) == 0: return 1 return max(self.automation_sequences.keys()) + 1 def __reformat_sequence_name(self, sequence_name: str): return sequence_name.strip().replace(" ", "-").lower() ================================================ FILE: src/starrail/controllers/c_click_controller.py ================================================ import time import pyautogui import random from pynput import keyboard from threading import Event from starrail.exceptions.exceptions import SRExit from starrail.utils.utils import aprint, Printer, is_admin, color_cmd from starrail.constants import BASENAME class ContinuousClickController: def __init__(self): self.thread_event = Event() self.pause = False self.click_count = 0 def click_continuously( self, count: int = -1, interval: float = 1.0, randomize_by: float = 0.0, hold_time: float = 0.1, start_after: float = 5.0, quiet: bool = False ): self.__verbose_start(count, interval, randomize_by, hold_time, start_after, quiet) listener = keyboard.Listener(on_press=self.__on_press) listener.start() time.sleep(start_after) try: while True: if self.click_count == count or self.thread_event.is_set(): listener.stop() break elif self.pause: time.sleep(0.1) continue if not quiet: self.__verbose_click(count) self.__click(hold_time, interval, randomize_by) except KeyboardInterrupt: print("") listener.stop() time.sleep(1) raise SRExit() def __click(self, hold_time, interval, randomize_interval): self.click_count += 1 pyautogui.mouseDown() time.sleep(hold_time) pyautogui.mouseUp() time.sleep(interval) time.sleep(random.uniform(0, randomize_interval)) def __verbose_start(self, count, interval, randomize_by, hold_time, start_after, quiet): title_text = Printer.to_lightblue(f"Uniform clicking starting in {start_after} seconds.") stop_text = Printer.to_lightblue(" - To stop: ") + "Press CTRL+C or ESC" pause_text = Printer.to_lightblue(" - To pause: ") + "Press SPACE" count_text = "INFINITE" if count == -1 else str(count) count_text = Printer.to_purple(" - Total clicks: ") + count_text interval_text = Printer.to_purple(" - Click interval: ") + f"{interval} seconds" if randomize_by > 0: interval_text += f" + random(0, {randomize_by}) seconds" hold_time_text = Printer.to_purple(" - Hold duration: ") + f"{hold_time} seconds" warning_text = "" if not is_admin(): warning_text = Printer.to_lightred(f"\n\nNote: Currently not running {BASENAME} as admin. Clicks will not work in game applications.") ccmd = color_cmd("starrail elevate", with_quotes=True) warning_text += Printer.to_lightred(f"\n Run ") + ccmd + Printer.to_lightred(f" to elevate permissions to admin.") aprint( f"{title_text} \ \n{stop_text} \ \n{pause_text} \ \n\n{count_text} \ \n{hold_time_text} \ \n{interval_text} \ {warning_text}\n" ) def __verbose_click(self, max_count): buffer = " " * 10 x, y = pyautogui.position() max_count_text = "INF" if max_count == -1 else max_count aprint(f"[Count {self.click_count + 1}/{max_count_text}] Clicking ({x}, {y})...{buffer}", end="\r") def __on_press(self, button): if button == keyboard.Key.esc: if self.click_count > 0: print("") aprint("Esc key pressed, stopping...") self.thread_event.set() exit() # After the event is set, the listener thread termiantes if button == keyboard.Key.space: self.pause = not self.pause buffer = " " * 7 if self.pause: aprint(f"Clicks paused. Press space again to start.{buffer}", end="\r") else: aprint(f"Clicks unpaused. Press space again to pause.{buffer}", end="\r") ================================================ FILE: src/starrail/controllers/star_rail_app.py ================================================ import os import sys import time import psutil import tabulate import webbrowser import subprocess import configparser from pathlib import Path from starrail.constants import CURSOR_UP_ANSI from starrail.utils.utils import * from starrail.utils.process_handler import ProcessHandler from starrail.config.config_handler import StarRailConfig from starrail.controllers.webcache_controller import StarRailWebCacheController, StarRailWebCacheBinaryFile from starrail.controllers.streaming_assets_controller import StarRailStreamingAssetsController, StarRailStreamingAssetsBinaryFile from starrail.bin.loader.loader import Loader class HonkaiStarRail: def __init__(self): self.config = StarRailConfig() self.module_configured = self.config.full_configured() self.webcache_controller = StarRailWebCacheController(self.config) self.streaming_assets_controller = StarRailStreamingAssetsController(self.config) # ============================================= # ============| DRIVER FUNCTIONS | ============ # ============================================= def start(self) -> bool: aprint("Starting Honkai: Star Rail...") starrail_proc = self.get_starrail_process() # If the application is already running if starrail_proc != None and starrail_proc.is_running(): ctext = Printer.to_lightred("already running") aprint(f"[PID {starrail_proc.pid}] Application `{starrail_proc.name()}` is {ctext} in the background.") raise SRExit() # If the application is not running, then start the application success = subprocess.Popen([str(self.config.game_path)], shell=True) if self.wait_to_start(): starrail_proc = self.get_starrail_process() aprint(f"[PID {starrail_proc.pid}] Honkai: Star Rail has started successfully!") return True aprint(f"Honkai: Star Rail failed to start due to an unknown reason.") return False def terminate(self) -> bool: aprint("Terminating Honkai: Star Rail...", end="\r") # If the cached proc PID is working if self.config.instance_pid != None: try: starrail_proc = psutil.Process(self.config.instance_pid) if self.proc_is_starrail(starrail_proc): starrail_proc.terminate() aprint("Honkai: Star Rail terminated successfully.") return True except psutil.NoSuchProcess: self.config.instance_pid = None self.config.save_current_config() # If no PID is cached, find proc and termiante starrail_proc = self.get_starrail_process() if starrail_proc != None: starrail_proc.terminate() aprint("Honkai: Star Rail terminated successfully.") return True aprint(f"Honkai: Star Rail is currently {Printer.to_lightred('not running')}. ") return False def schedule(self): # Scheduler implemented seperately in starrail/bin as of version 1.0.0 ... # ============================================= # ===========| UTILITY FUNCTIONS | ============ # ============================================= def show_status(self, live=False): aprint("Loading status for the Honkai: Star Rail process...") def print_status(): starrail_proc = self.get_starrail_process() headers = [Printer.to_lightblue(title) for title in ["Title", "HSR Real-time Status"]] data_dict = { "Status" : "N/A", "Process ID" : "N/A", "Started On" : "N/A", "CPU Percent" : "N/A", "CPU Affinity" : "N/A", "IO Operations" : "N/A", "RAM Usage" : "N/A" } if starrail_proc != None: # Is running data_dict["Status"] = bool_to_str(starrail_proc.is_running()) data_dict["Process ID"] = starrail_proc.pid data_dict["Started On"] = DatetimeHandler.epoch_to_time_str(starrail_proc.create_time()) data_dict["CPU Percent"] = f"{starrail_proc.cpu_percent(1)}%" data_dict["CPU Affinity"] = ",".join([str(e) for e in starrail_proc.cpu_affinity()]) data_dict["IO Operations"] = f"Writes: {starrail_proc.io_counters().write_count}, Reads: {starrail_proc.io_counters().read_count}" data_dict["RAM Usage"] = f"{round(psutil.virtual_memory()[3]/1000000000, 4)} GB" else: data_dict["Status"] = bool_to_str(False) # All other options remain as N/A data_list = [[Printer.to_lightpurple(k), v] for k, v in data_dict.items()] table = tabulate.tabulate(data_list, headers=headers) print("\n" + table + "\n") if live: print(CURSOR_UP_ANSI * (len(data_list)+5)) try: if live: # TODO: Force CLI mode before allowing live status while True: print_status() else: print_status() except KeyboardInterrupt: aprint("Status reading stopped.") raise SRExit() def show_config(self): # print(Printer.to_lightpurple("\n - Game Configuration Table -")) headers = [Printer.to_lightblue(title) for title in ["Title", "Details", "Relevant Command"]] data = [ ["Game Version", self.fetch_game_version(), color_cmd("starrail version")], ["Game Executable", os.path.normpath(self.config.game_path), color_cmd("starrail start/stop")], ["Game Screenshots", self.__get_screenshot_path(), color_cmd("starrail screenshots")], ["Game Logs", self.__get_log_path(), color_cmd("starrail game-logs")], ["Game (.exe) SHA256", HashCalculator.SHA256(self.config.game_path) if self.config.game_path.exists() else None, ""] ] for row in data: row[0] = Printer.to_lightpurple(row[0]) table = tabulate.tabulate(data, headers=headers) print("\n" + table + "\n") def screenshots(self): aprint("Opening the screenshots directory...", end="\r") screenshot_path = self.__get_screenshot_path() if not os.path.isdir(screenshot_path): aprint(f"No screenshots taken.{" "*20}") return os.startfile(screenshot_path) aprint(f"Screenshots directory opened in the File Explorer ({Printer.to_lightgrey(screenshot_path)}).") def logs(self): aprint("Opening the logs directory...", end="\r") logs_path = self.__get_log_path() if not os.path.isdir(logs_path): aprint("No logs directory available locally.") return os.startfile(logs_path) aprint(f"Logs directory opened in the File Explorer ({Printer.to_lightgrey(logs_path)}).") def show_pulls(self): aprint("Showing the pull history page...", end="\r") cache_urls = self.webcache_controller.get_events_cache() if cache_urls == None: aprint("No pull history cache found locally.") return for url in cache_urls: webbrowser.open(url) aprint("All pages opened successfully. ") def verbose_play_time(self): sr_proc = self.get_starrail_process() if sr_proc == None: aprint("Honkai: Star Rail is currently not running.") return ctime = sr_proc.create_time() time_delta = datetime.now() - DatetimeHandler.epoch_to_datetime(ctime) aprint(f"{Printer.to_lightblue('Session Time:')} {DatetimeHandler.seconds_to_time_str(time_delta.seconds)}") # ============================================= # ==========| WEBCACHE FUNCTIONS | ============ # ============================================= def webcache_announcements(self): aprint("Decoding announcement webcache...") cache_urls = self.webcache_controller.get_announcements_cache() if cache_urls == None: aprint("No announcement webcache found.") else: self.__print_cached_urls(cache_urls) def webcache_events(self): aprint("Decoding events/pulls webcache...") cache_urls = self.webcache_controller.get_events_cache() if cache_urls == None: aprint("No events webcache found.") else: self.__print_cached_urls(cache_urls) def webcache_all(self): e_cache_urls = self.webcache_controller.get_events_cache() a_cache_urls = self.webcache_controller.get_announcements_cache() if e_cache_urls == None or len(e_cache_urls) == 0 and \ a_cache_urls == None or len(a_cache_urls) == 0: aprint("No available webcache found.") return else: print("") new_line = False if e_cache_urls != None: new_line = True print(Printer.to_lightblue(" - Events/Pulls Web Cache -")) self.__print_cached_urls(e_cache_urls) if a_cache_urls != None: if new_line: print("") print(Printer.to_lightblue(" - Announcements Web Cache -")) self.__print_cached_urls(a_cache_urls) def __print_cached_urls(self, url_list): for idx, url in enumerate(url_list): print(f"[{Printer.to_lightpurple(f'URL {idx+1}')}] " + url) # ================================================= # ========| STREAMING ASSETS FUNCTIONS | ========== # ================================================= def streaming_assets(self): aprint("Decoding streaming assets...") headers = [Printer.to_lightblue(title) for title in ["Title", "Details (binary)"]] sa_binary_dict = self.streaming_assets_controller.get_sa_binary_version() sa_client_dict = self.streaming_assets_controller.get_sa_client_config() sa_dev_dict = self.streaming_assets_controller.get_sa_dev_config() master_dict = merge_dicts(sa_binary_dict, sa_client_dict, sa_dev_dict) if len(master_dict) > 0: master_list = [[Printer.to_lightpurple(title), "\n".join(data) if isinstance(data, list) else data] for title, data in master_dict.items() if title != "Other"] if "Other" in master_dict.keys(): data = master_dict["Other"] master_list.append([Printer.to_lightpurple("Other"), "\n".join(data) if isinstance(data, list) else data]) table = tabulate.tabulate(master_list, headers) print("\n" + table + "\n") # ============================================= # =========| PATH HELPER FUNCTIONS | ========== # ============================================= def __get_screenshot_path(self): return os.path.normpath(os.path.join(self.config.innr_path, "StarRail_Data", "ScreenShots")) def __get_log_path(self): return os.path.normpath(os.path.join(self.config.innr_path, "logs")) def __get_game_config_path(self): return os.path.normpath(os.path.join(self.config.innr_path, "config.ini")) def fetch_game_version(self): config = configparser.ConfigParser() config.read(self.__get_game_config_path()) game_version = "N/A" try: game_version = config.get('general', 'game_version') except configparser.NoSectionError: try: game_version = config.get('General', 'game_version') except configparser.NoSectionError: pass return game_version # ============================================= # ======| START/STOP HELPER FUNCTIONS | ======= # ============================================= def wait_to_start(self, timeout = 30) -> bool: starting_time = time.time() while time.time() - starting_time < timeout: if self.is_running(): return True time.sleep(0.5) return False def is_running(self) -> bool: return self.get_starrail_process() != None def is_focused(self) -> bool: hsr_proc = self.get_starrail_process() if hsr_proc == None: return False focused_pid = ProcessHandler.get_focused_pid() if hsr_proc.pid == focused_pid: return True return False def get_starrail_process(self) -> psutil.Process: EXE_BASENAME = os.path.basename(self.config.game_path) for p in psutil.process_iter(['pid', 'name', 'exe']): # name == EXE_BASENAME is for optimization only (proceed only if filename is the same) if p.info["name"] == EXE_BASENAME: try: starrail_proc = psutil.Process(p.info['pid']) if self.proc_is_starrail(starrail_proc): # If the cached PID isn't the current PID, reset it if self.config.instance_pid != starrail_proc.pid: self.config.instance_pid = starrail_proc.pid self.config.save_current_config() return starrail_proc except psutil.NoSuchProcess: continue return None def proc_is_starrail(self, starrail_proc: psutil.Process): return starrail_proc.is_running() and Path(starrail_proc.exe()) == Path(self.config.game_path) ================================================ FILE: src/starrail/controllers/streaming_assets_controller.py ================================================ import os import re import sys from pathlib import Path from enum import Enum from starrail.config.config_handler import StarRailConfig from starrail.utils.utils import aprint, Printer from starrail.constants import WEBCACHE_IGNORE_FILETYPES, GAME_FILE_PATH, GAME_FILE_PATH_NEW from starrail.utils.binary_decoder import StarRailBinaryDecoder SUBMODULE_NAME = "SR-SAC" class StarRailStreamingAssetsBinaryFile(Enum): SA_BinaryVersion = "BinaryVersion.bytes" SA_ClientConfig = "ClientConfig.bytes" SA_DevConfig = "DevConfig.bytes" class StarRailStreamingAssetsController: def __init__(self, starrail_config: StarRailConfig): self.starrail_config = starrail_config self.binary_decoder = StarRailBinaryDecoder() # ============================================= # ============| DRIVER FUNCTIONS | ============ # ============================================= def get_decoded_streaming_assets(self, sa_binary_file: StarRailStreamingAssetsBinaryFile): decoded_strings = self.decode_streaming_assets(sa_binary_file) if decoded_strings == None: return None filtered_dict = self.parse_webcache(decoded_strings, sa_binary_file) return filtered_dict def get_sa_binary_version(self): return self.get_decoded_streaming_assets(StarRailStreamingAssetsBinaryFile.SA_BinaryVersion) def get_sa_client_config(self): return self.get_decoded_streaming_assets(StarRailStreamingAssetsBinaryFile.SA_ClientConfig) def get_sa_dev_config(self): return self.get_decoded_streaming_assets(StarRailStreamingAssetsBinaryFile.SA_DevConfig) # ============================================= # ==========| SUBDRIVER FUNCTIONS | =========== # ============================================= def decode_streaming_assets(self, sa_binary_file: StarRailStreamingAssetsBinaryFile): file_path = os.path.join(self.starrail_config.innr_path, "StarRail_Data", "StreamingAssets", sa_binary_file.value) if not os.path.isfile(file_path): aprint(Printer.to_lightred(f"Decoder cannot locate streaming assets file '{file_path}'.")) return aprint(f"Decoding {Printer.to_lightgrey(file_path)} ...", submodule_name=SUBMODULE_NAME) try: return self.binary_decoder.decode_raw_binary_file(file_path) except PermissionError as ex: aprint(f"{Printer.to_lightred('Permission denied.')}") return None def parse_webcache(self, decoded_strings, sa_binary_file: StarRailStreamingAssetsBinaryFile): data_dict = dict() if sa_binary_file == StarRailStreamingAssetsBinaryFile.SA_BinaryVersion: for string in decoded_strings: string = string.strip() if re.search("PRODWin[0-9]{1}.[0-9]{1}.[0-9]{1}", string) != None: data_dict["Detailed Version"] = string elif re.search("V[0-9]{1}.[0-9]{1}", string) != None: data_dict["Version"] = string elif re.search("[0-9]{8}-[0-9]{4}", string) != None: data_dict["Datetime String"] = string else: try: endpoints = data_dict["Other"] endpoints.append(string) data_dict["Other"] = endpoints except KeyError: data_dict["Other"] = [string] if sa_binary_file == StarRailStreamingAssetsBinaryFile.SA_ClientConfig: for string in decoded_strings: string = string.strip() match = re.search("https.*", string) if string.startswith("com."): data_dict["Application Identifier"] = string elif match != None: try: endpoints = data_dict["Server Endpoints"] endpoints.append(match.group(0)) data_dict["Service Endpoints"] = endpoints except KeyError: data_dict["Service Endpoints"] = [match.group(0)] else: try: endpoints = data_dict["Unknown"] endpoints.append(string) data_dict["Unknown"] = endpoints except KeyError: data_dict["Unknown"] = [string] if sa_binary_file == StarRailStreamingAssetsBinaryFile.SA_DevConfig: for string in decoded_strings: string = string.strip() if re.search("V[0-9]{1}.[0-9]{1}", string) != None: if "EngineRelease" in string: data_dict["Engine Version"] = string else: data_dict["Unknown Version"] = string else: try: endpoints = data_dict["Unknown"] endpoints.append(string) data_dict["Unknown"] = endpoints except KeyError: data_dict["Unknown"] = [string] if len(data_dict) > 0: return data_dict return None ================================================ FILE: src/starrail/controllers/web_controller.py ================================================ import webbrowser from starrail.constants import HOMEPAGE_URL, HOMEPAGE_URL_CN, HOYOLAB_URL, YOUTUBE_URL, BILIBILI_URL from starrail.utils.utils import aprint, Printer class StarRailWebController: def __init__(self): pass def homepage(self, cn=False): if cn: aprint(f"Opening Honkai: Star Rail's official home page (CN)...\n - {Printer.to_lightgrey(HOMEPAGE_URL_CN)}") webbrowser.open(HOMEPAGE_URL_CN) else: aprint(f"Opening Honkai: Star Rail's official home page...\n - {Printer.to_lightgrey(HOMEPAGE_URL)}") webbrowser.open(HOMEPAGE_URL) def hoyolab(self): aprint(f"Opening Honkai: Star Rail's HoyoLab page...\n - {Printer.to_lightgrey(HOYOLAB_URL)}") webbrowser.open(HOYOLAB_URL) def youtube(self): aprint(f"Opening Honkai: Star Rail's Youtube page...\n - {Printer.to_lightgrey(YOUTUBE_URL)}") webbrowser.open(YOUTUBE_URL) def bilibili(self): aprint(f"Opening Honkai: Star Rail's BiliBili page...\n - {Printer.to_lightgrey(BILIBILI_URL)}") webbrowser.open(BILIBILI_URL) ================================================ FILE: src/starrail/controllers/webcache_controller.py ================================================ import os import re import sys import shutil from pathlib import Path from enum import Enum from starrail.config.config_handler import StarRailConfig from starrail.utils.utils import aprint, Printer from starrail.constants import WEBCACHE_IGNORE_FILETYPES from starrail.utils.binary_decoder import StarRailBinaryDecoder SUBMODULE_NAME = "SR-WCC" class StarRailWebCacheBinaryFile(Enum): WEBCACHE_DATA0 = "data_0" WEBCACHE_DATA1 = "data_1" # Anncouncements WEBCACHE_DATA2 = "data_2" # Events/Pulls class StarRailWebCacheController: def __init__(self, starrail_config: StarRailConfig): self.starrail_config = starrail_config self.binary_decoder = StarRailBinaryDecoder() # ============================================= # ============| DRIVER FUNCTIONS | ============ # ============================================= def get_decoded_webcache(self, webcache_binary_file: StarRailWebCacheBinaryFile): decoded_strings = self.decode_webcache(webcache_binary_file) if decoded_strings == None: return None filtered_urls = self.parse_webcache(decoded_strings, webcache_binary_file) return filtered_urls def get_announcements_cache(self): return self.get_decoded_webcache(StarRailWebCacheBinaryFile.WEBCACHE_DATA1) def get_events_cache(self): return self.get_decoded_webcache(StarRailWebCacheBinaryFile.WEBCACHE_DATA2) # ============================================= # ==========| SUBDRIVER FUNCTIONS | =========== # ============================================= def decode_webcache(self, webcache_binary_file: StarRailWebCacheBinaryFile): # Get webCache path (varying webcache versioning) def get_webcache_path(): webcache_path = "" webcache_semi_path = Path(os.path.join(self.starrail_config.innr_path, "StarRail_Data", "webCaches")) try: version_dirs = [d for d in webcache_semi_path.iterdir() if d.is_dir()] if len(version_dirs) > 0: version_dir = version_dirs[0] webcache_path = os.path.join(self.starrail_config.innr_path, "StarRail_Data", "webCaches", version_dir, "Cache", "Cache_Data", webcache_binary_file.value) return webcache_path else: return None except Exception as ex: return None file_path = get_webcache_path() if file_path == None or not os.path.isfile(file_path): return None aprint(f"Decoding {webcache_binary_file.value} ({Printer.to_lightgrey(file_path)}) ...", submodule_name=SUBMODULE_NAME) try: return self.binary_decoder.decode_raw_binary_file(file_path) except PermissionError: aprint(f"{Printer.to_lightred('Web cache is LOCKED.')} Web cache is only available when the game is not running.", submodule_name=SUBMODULE_NAME) return None def parse_webcache(self, decoded_strings, webcache_file: StarRailWebCacheBinaryFile): if webcache_file == StarRailWebCacheBinaryFile.WEBCACHE_DATA0: pass if webcache_file == StarRailWebCacheBinaryFile.WEBCACHE_DATA1: return self.filter_urls("webstatic.mihoyo.com/hkrpg/announcement", decoded_strings) if webcache_file == StarRailWebCacheBinaryFile.WEBCACHE_DATA2: return self.filter_urls("webstatic.mihoyo.com/hkrpg/event", decoded_strings) return None # ============================================= # ============| HELPER FUNCTIONS | ============ # ============================================= def filter_urls(self, target_sequence: str, decoded_strings): # Filter out URLs without the target sequence and end with the ignoring file types filtered_urls = [] for url in decoded_strings: url = url.strip() if target_sequence in url and not self.should_ignore(url): match = re.search("https.*", url) if match != None: filtered_urls.append(match.group(0)) return filtered_urls def should_ignore(self, url: str): # Should ignore current URL because of its file type for ignoring_file_type in WEBCACHE_IGNORE_FILETYPES: if url.endswith(ignoring_file_type): return True return False ================================================ FILE: src/starrail/data/textfiles/disclaimer.txt ================================================ ===================================================================== ================== | STARRAIL PACKAGE DISCLAIMER | ================== ===================================================================== The "starrail" Python 3 module is an external CLI tool designed to automate the gameplay of Honkai Star Rail. It is designed solely interacts with the game through the existing user interface, and it abides by the Fair Gaming Declaration set forth by COGNOSPHERE PTE. LTD. The package is designed to provide a streamlined and efficient way for users to interact with the game through features already provided within the game, and it does not, in any way, intend to damage the balance of the game or provide any unfair advantages. The package does NOT modify any files in any way. The creator(s) of this package has no relationship with MiHoYo, the game's developer. The use of this package is entirely at the user's own risk, and the creator accepts no responsibility for any damage or loss caused by the package's use. It is the user's responsibility to ensure that they use the package according to Honkai Star Rail's Fair Gaming Declaration, and the creator accepts no responsibility for any consequences resulting from its misuse, including game account penalties, suspension, or bans. Please note that according to MiHoYo's Honkai: Star Rail Fair Gaming Declaration (https://hsr.hoyoverse.com/en-us/news/111244): "It is strictly forbidden to use external plug-ins, game accelerators/boosters, scripts, or any other third-party tools that damage the balance of the game. Once discovered, COGNOSPHERE PTE. LTD. (referred to as "we" henceforth) will take appropriate actions depending on the severity and frequency of the offenses. These actions include removing rewards obtained through such infringements, suspending the game account, or permanently banning the game account. Therefore, the user of this package must be aware that the use of this package may result in the above actions being taken against their game account by MiHoYo." By using this package, the user agrees to ALL terms and conditions and acknowledges that the creator will not be held liable for any negative outcomes that may occur as a result of its use. ================================================ FILE: src/starrail/data/textfiles/webcache_explain.txt ================================================ Web cache for Honkai: Star Rail stores recent web data. You can open URLs to view announcements, events, or pull status, allowing quick access without loading into the game. ================================================ FILE: src/starrail/entrypoints/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/entrypoints/entrypoint_handler.py ================================================ import readline import argparse import getpass import webbrowser import tabulate import subprocess from starrail.constants import BASENAME, AUTHOR, VERSION, VERSION_DESC, AUTHOR_DETAIL, REPOSITORY, CURSOR_UP_ANSI, REPOSITORY, STARRAIL_DIRECTORY, ISSUES from starrail.utils.utils import * from starrail.utils.binary_decoder import StarRailBinaryDecoder from starrail.utils.game_detector import StarRailGameDetector from starrail.utils.perm_elevate import StarRailPermissionsHandler from starrail.exceptions.exceptions import SRExit, StarRailBaseException from starrail.controllers.star_rail_app import HonkaiStarRail from starrail.controllers.web_controller import StarRailWebController from starrail.controllers.automation_controller import StarRailAutomationController from starrail.controllers.c_click_controller import ContinuousClickController from starrail.bin.loader.loader import Loader from starrail.bin.scheduler.starrail_scheduler import StarRailScheduler, OperationTypes class StarRailEntryPointHandler: def __init__(self): self.star_rail = HonkaiStarRail() self.web_controller = StarRailWebController() self.scheduler = StarRailScheduler(self.star_rail) # Only the start/stop is used from the starrail instance self.automation_controller = StarRailAutomationController(self.star_rail) # ================================================ # ==================| PROJECT | ================== # ================================================ def about(self, args): headers = [Printer.to_lightblue(title) for title in ["Title", "Description"]] data = [ ["Module", BASENAME], ["Version", f"{VERSION_DESC}-{VERSION}"], ["Author", AUTHOR], ["Repository", REPOSITORY], ["Directory", STARRAIL_DIRECTORY] ] for row in data: row[0] = Printer.to_lightpurple(row[0]) print("\n" + tabulate.tabulate(data, headers=headers) + "\n") def version(self, args): headers = [Printer.to_lightblue(title) for title in ["Program", "Description", "Version"]] data = [ ["Honkai: Star Rail", "Game", self.star_rail.fetch_game_version()], [BASENAME, "Module", f"{VERSION_DESC}-{VERSION}"] ] for row in data: row[0] = Printer.to_lightpurple(row[0]) print("\n" + tabulate.tabulate(data, headers=headers) + "\n") def author(self, args): aprint(AUTHOR_DETAIL) def repo(self, args): aprint(REPOSITORY) if args.open: webbrowser.open(REPOSITORY) # ================================================= # ==================| GAME INFO | ================= # ================================================= def show_status(self, args): self.star_rail.show_status(args.live) def show_config(self, args): self.star_rail.show_config() def show_details(self, args): self.star_rail.streaming_assets() def play_time(self, args): self.star_rail.verbose_play_time() # ================================================= # ===============| LAUNCH DRIVER | ================ # ================================================= def start(self, args): self.star_rail.start() def stop(self, args): self.star_rail.terminate() def schedule(self, args): def verbose_add_usage(): aprint(f"To schedule a new game start, run:\n{color_cmd('starrail schedule add --action start --time 10:30', True)}") def verbose_general_usage(): headers = [Printer.to_lightpurple(title) for title in ["Example Command", "Description"]] data = [ [color_cmd("schedule add --time 10:30 --action start"), "Schedule Honkai Star Rail to START at 10:30 AM"], [color_cmd("schedule add --time 15:30 --action stop"), "Schedule Honkai Star Rail to STOP at 3:30 PM"], [color_cmd("schedule remove"), "Remove an existing scheduled job"], [color_cmd("schedule show"), "Show all scheduled jobs and their details"], [color_cmd("schedule clear"), "Cancel all schedule jobs (irreversible)"] ] tab = tabulate.tabulate(data, headers) print("\n" + tab + "\n") if args.subcommand == "add": op_type = None if args.action.lower().strip() == "start": op_type = OperationTypes.START elif args.action.lower().strip() == "stop": op_type = OperationTypes.END else: verbose_add_usage() return if not args.time: verbose_add_usage() return self.scheduler.add_new_schedule(args.time, op_type) elif args.subcommand == "remove": self.scheduler.remove_schedule() elif args.subcommand == "show": self.scheduler.show_schedules() elif args.subcommand == "clear": self.scheduler.clear_schedules() elif args.subcommand == "help": verbose_general_usage() else: if args.subcommand != None: aprint(Printer.to_lightred(f"Unknown scheduling action: '{args.subcommand}'")) verbose_general_usage() # ================================================= # =================| AUTOMATION | ================= # ================================================= def automation(self, args): def force_admin(): if not is_admin(): aprint(f"{Printer.to_lightred('Admin permission required for automation')}.\nWould you like to elevate the permissions to admin? [y/n] ", end="") if input("").strip().lower() == "y": StarRailPermissionsHandler.elevate() raise SRExit() else: raise SRExit() if args.action == "record": force_admin() self.automation_controller.record_sequences() elif args.action == "run": force_admin() self.automation_controller.run_requence() elif args.action == "show": self.automation_controller.show_sequences() elif args.action == "remove": self.automation_controller.delete_sequence() elif args.action == "clear": self.automation_controller.clear_sequences() else: if args.action != None: aprint(Printer.to_lightred(f"Unknown automation action: '{args.action}'")) self.automation_controller.verbose_general_usage() def click_continuously(self, args): def force_admin(args): if not is_admin(): aprint(f"{Printer.to_lightblue('Admin permission required for clicks to be registered in-game')}.\nWould you like to elevate the permissions to admin? [y/n] ", end="") if input("").strip().lower() == "y": StarRailPermissionsHandler.elevate([ "click", "--clicks", args.clicks, "--interval", args.interval, "--randomize", args.randomize, "--hold", args.hold, "--delay", args.delay, "--quiet" if args.quiet else "" ]) raise SRExit() else: raise SRExit() cc_controller = ContinuousClickController() force_admin(args) cc_controller.click_continuously(args.clicks, args.interval, args.randomize, args.hold, args.delay, args.quiet) # ================================================= # ==================| KEY URLS | ================== # ================================================= def homepage(self, args): self.web_controller.homepage(args.cn) def hoyolab(self, args): self.web_controller.hoyolab() def youtube(self, args): self.web_controller.youtube() def bilibili(self, args): self.web_controller.bilibili() # =================================================== # ===============| UTILITY FUNCTIONS | ============== # =================================================== def screenshots(self, args): self.star_rail.screenshots() def game_logs(self, args): self.star_rail.logs() def decode(self, args): ascii_binary_decoder = StarRailBinaryDecoder() ascii_binary_decoder.user_decode(args.path, args.min_length) def pulls(self, args): self.star_rail.show_pulls() def webcache(self, args): if not args.quiet: print_webcache_explanation() if args.announcements: self.star_rail.webcache_announcements() elif args.events: self.star_rail.webcache_events() else: self.star_rail.webcache_all() # ================================================= # =============| BLOCKING FUNCTIONS | ============= # ================================================= def configure(self, args): if self.star_rail.config.full_configured(): aprint(Printer.to_lightgreen("Configuration Already Completed!")) return # os.system("cls") if self.star_rail.config.disclaimer_configured() == False: # print(Printer.to_skyblue("\n\n - STEP 1 OF 2. DISCLAIMER AGREEMENT - \n")) step_two_text = " - STEP 1 OF 2. DISCLAIMER AGREEMENT - " print(Printer.to_skyblue(f"\n\n {'='*len(step_two_text)}")) print(Printer.to_skyblue(f" {step_two_text}")) print(Printer.to_skyblue(f" {'='*len(step_two_text)}\n")) print_disclaimer() print(Printer.to_lightred("[IMPORTANT] Please read the disclaimer above before continuing!")) if input("AGREE? [y/n] ").lower() == "y": self.star_rail.config.disclaimer = True self.star_rail.config.save_current_config() else: return if self.star_rail.config.path_configured() == False: step_two_text = "- STEP 2 OF 2. SET UP GAME PATH (StarRail.exe) -" print(Printer.to_skyblue(f"\n\n {'='*len(step_two_text)}")) print(Printer.to_skyblue(f" {step_two_text}")) print(Printer.to_skyblue(f" {'='*len(step_two_text)}\n")) logt = f"Auto Detecting Honkai: Star Rail ({Printer.to_lightgrey('this may take a while')})..." with Loader(logt, end=None): star_rail_game_detector = StarRailGameDetector() game_path = star_rail_game_detector.find_game() if game_path == None: print(CURSOR_UP_ANSI, flush=True) aprint(Printer.to_lightred("Cannot locate game Honkai: Star Rail.") + " "*15 + f"\nFile issue at '{ISSUES}' for more help.") raise SRExit() self.star_rail.config.set_path(game_path) self.star_rail.config.save_current_config() print(CURSOR_UP_ANSI, flush=True) aprint("Game found at: " + game_path + " "*10) aprint("Configuration Complete!") # =================================================== # ===============| HELPER FUNCTIONS | =============== # =================================================== def elevate(self, args): StarRailPermissionsHandler.elevate(args.arguments) # ================================================= # ===============| CLI DRIVERS | ================== # ================================================= def print_title(self): # columns, _ = shutil.get_terminal_size(fallback=(80, 20)) # bar = '▬' * (columns//1 - 12) # bar = Printer.to_lightblue(f"▷ ▷ ▷ {bar} ◁ ◁ ◁") # print(bar) title = Printer.to_lightblue( r""" ____ _ ____ _ _ ____ _ ___ / ___|| |_ __ _ _ __| _ \ __ _(_) | / ___| | |_ _| \___ \| __/ _` | '__| |_) / _` | | | | | | | | | ___) | || (_| | | | _ < (_| | | | | |___| |___ | | |____/ \__\__,_|_| |_| \_\__,_|_|_| \____|_____|___| """) desc = Printer.to_purple("""A lightweight Command Line Utility For Honkai: Star Rail!""") postfix = Printer.to_lightgrey(REPOSITORY) author = Printer.to_lightgrey(f"By {AUTHOR}") print(center_text(title)) print_centered(f"{desc}\n{postfix}\n{author}\n") def print_init_help(self): access_time = colored(f"Access Time: {DatetimeHandler.get_datetime_str()}", "dark_grey") username = getpass.getuser() isadmin = "ADMIN" if is_admin() else "USER" quit_cmd = Printer.to_purple("exit") cls_cmd = Printer.to_purple("clear") help_cmd = Printer.to_purple("help") dev_str = "" # if DEVELOPMENT: # dev_str = f"({Printer.to_lightred("Dev=True")}) {Printer.to_lightgrey("Development commands available.")}\n" welcome_str = f"Welcome to the {BASENAME} Environment ({VERSION_DESC}-{VERSION})" exit_str = f"Type '{quit_cmd}' to quit {SHORTNAME} CLI" cls_str = f"Type '{cls_cmd}' to clear terminal" help_str = f"Type '{help_cmd}' to display commands list" print(f"{dev_str}{welcome_str}\n {help_str}\n {exit_str}\n {cls_str}\n") def clear_screen(self): os.system('cls') def check_custom_commands(self, user_input: str): # Return 0 to continue loop, 1 to short-circit loop and 'continue' loop, 2 to exit loop if user_input.lower() in ['exit', 'quit']: self.clear_screen() return 2 if user_input.lower() in ['clear', "cls", "reset"]: self.clear_screen() self.print_title() exit_cmd = color_cmd("exit", with_quotes=True) print(f"Terminal cleared. Type {exit_cmd} to quit.") return 1 if user_input.strip() == '': return 1 return 0 def setup_key_bindings(self): readline.parse_and_bind(r'"\C-w": backward-kill-word') readline.parse_and_bind(r'"\C-a": beginning-of-line') readline.parse_and_bind(r'"\C-e": end-of-line') readline.parse_and_bind(r'"\C-u": unix-line-discard') readline.parse_and_bind(r'"\C-k": kill-line') readline.parse_and_bind(r'"\C-y": yank') readline.parse_and_bind(r'"\C-b": backward-char') readline.parse_and_bind(r'"\C-f": forward-char') readline.parse_and_bind(r'"\C-p": previous-history') readline.parse_and_bind(r'"\C-n": next-history') readline.parse_and_bind(r'"\C-b": backward-word') readline.parse_and_bind(r'"\C-f": forward-word') def start_cli(self, parser: argparse.ArgumentParser): prefilled = False # =============| SET CLI MODE |============== constants.CLI_MODE = True # ============| SETUP READLINE |============= self.setup_key_bindings() # ==============| PRINT TITLE |============== self.clear_screen() self.print_title() self.print_init_help() cli_env_alert = False # ================| CLI LOOP |================ while True: try: starrail_cli = colored(f"{DatetimeHandler.get_time_str()} StarRail", "dark_grey") print(f"[{starrail_cli}] > ", end="", flush=True) user_input = input().strip() continue_loop = self.check_custom_commands(user_input) if continue_loop == 1: continue elif continue_loop == 2: break # Verify that the stdout and stderr aren't closed if sys.stdout.closed or sys.stderr.closed: text = "STDOUT and STDERR" if sys.stdout.closed and sys.stderr.closed \ else "STDOUT" if sys.stdout.closed \ else "STDERR" aprint(f"System {text} has been closed unexpectedly. Exiting {BASENAME} environment...") sys.exit() # =============| PARSE ARGUMENT |============= if user_input.startswith(COMMAND): user_input = user_input.lstrip(COMMAND).strip() if not cli_env_alert: print(Printer.to_lightgrey(f"Currently in the StarRail CLI environment. You may call `{user_input}` directly without the `{COMMAND}` prefix.")) cli_env_alert = not cli_env_alert args = parser.parse_args(user_input.split()) if hasattr(args, 'func'): try: args.func(args) except SRExit: continue else: parser.print_help() # ==============| ON EXCEPTION |============== except KeyboardInterrupt: # print("\nNote: Type 'exit' to quit.") print("") continue except SystemExit: continue # ================================================ # ==================| FIREFLY | ================== # ================================================ def firefly(self, args): lines = [ " I dreamed of a scorched earth.", " A new shoot sprouted from the earth.", " It bloomed in the morning sun", " ... and whispered to me.", " Like fyreflies to a flame...", " life begets death.", "", " -- Firefly S.A.M." ] text = "\n".join(lines) firefly_colors = [ Printer.to_pale_yellow, Printer.to_light_blue, Printer.to_teal, Printer.to_turquoise, # Printer.to_dark_teal, Printer.to_turquoise, Printer.to_teal, Printer.to_light_blue, Printer.to_pale_yellow, ] result = "" color_index = 0 for char in text: if char != "\n": color_func = firefly_colors[color_index % len(firefly_colors)] result += color_func(char) color_index += 1 else: result += "\n" print("\n" + result) ================================================ FILE: src/starrail/entrypoints/entrypoints.py ================================================ import os import sys import time import argparse start_time = time.time() from starrail.entrypoints.entrypoint_handler import StarRailEntryPointHandler from starrail.entrypoints.help_format_handler import HelpFormatHandler from starrail.utils.utils import aprint, verify_platform, is_admin, Printer, color_cmd from starrail.constants import COMMAND, DEVELOPMENT from starrail.exceptions.exceptions import StarRailOSNotSupported, SRExit class StarRailArgParser(argparse.ArgumentParser): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.groups = [] def add_group(self, title, description=None): group = {'title': title, 'description': description, 'parsers': []} self.groups.append(group) return group def add_parser_to_group(self, group, parser): group['parsers'].append(parser) def error(self, message): if "invalid choice:" in message: command = message.split("'")[1] helpt = Printer.to_purple("starrail help") aprint(f"Command not recognized: {command}\nType '{helpt}' for the commands list") else: parts = message.split("'") if len(parts) > 1: command = parts[1] helpt = Printer.to_purple(f"{command} --help") aprint(f"{message.capitalize()}\nType '{helpt}' for its arguments list") else: helpt = Printer.to_purple(" --help") aprint(f"{message.capitalize()}\nType '{helpt}' for its arguments list") self.exit(2) def start_starrail(): os.system("") # Enables ANSI escape characters in terminal entrypoint_handler = StarRailEntryPointHandler() help_format_handler = HelpFormatHandler() parser = StarRailArgParser(prog=COMMAND, description="StarRail CLI Module") subparsers = parser.add_subparsers(dest='command', help='commands') help_parser = subparsers.add_parser('help', help='Show this help message and exit') help_parser.set_defaults(func=lambda args: help_format_handler.print_help(args, parser)) # ================================================ # ==================| PROJECT | ================== # ================================================ about_group = parser.add_group('Package Info', 'Get information about the starrail package.') abt_parser = subparsers.add_parser('about', help='Verbose all information about this module', description='Verbose all information about this module') abt_parser.set_defaults(func=entrypoint_handler.about) parser.add_parser_to_group(about_group, abt_parser) version_parser = subparsers.add_parser('version', help='Verbose game AND module version', description='Verbose game AND module version') version_parser.set_defaults(func=entrypoint_handler.version) parser.add_parser_to_group(about_group, version_parser) repo_parser = subparsers.add_parser('repo', help='Verbose module repository link', description='Verbose module repository link') repo_parser.add_argument('--open', '-o', action='store_true', default=False, help='Open repository in web.') repo_parser.set_defaults(func=entrypoint_handler.repo) parser.add_parser_to_group(about_group, repo_parser) author_parser = subparsers.add_parser('author', help='Verbose module author', description='Verbose module author') author_parser.set_defaults(func=entrypoint_handler.author) parser.add_parser_to_group(about_group, author_parser) # ================================================ # =================| GAME INFO | ================= # ================================================ game_info_group = parser.add_group('Game Info', 'Get static and realtime information about the local installation of Honkai: Star Rail.') show_status = subparsers.add_parser('status', help='Show real-time game status (game process)', description='Show real-time game status (game process)') show_status.add_argument('--live', '-l', action='store_true', default=False, help='Show live game status (non-stop).') show_status.set_defaults(func=entrypoint_handler.show_status) parser.add_parser_to_group(game_info_group, show_status) show_config = subparsers.add_parser('config', help='Show game configuration in the starrail module', description='Show game configuration in the starrail module') show_config.set_defaults(func=entrypoint_handler.show_config) parser.add_parser_to_group(game_info_group, show_config) details_config = subparsers.add_parser('details', help='Show detailed information about the game and the client', description='Show detailed information about the game and the client') details_config.set_defaults(func=entrypoint_handler.show_details) parser.add_parser_to_group(game_info_group, details_config) pt_config = subparsers.add_parser('runtime', help='Show time in hour, minutes, and seconds since this game session has started', description='Show time in hour, minutes, and seconds since this game session has started') pt_config.set_defaults(func=entrypoint_handler.play_time) parser.add_parser_to_group(game_info_group, pt_config) # ================================================= # ===============| LAUNCH DRIVER | ================ # ================================================= apps_group = parser.add_group('Start/Stop Commands', 'Start/Stop or schedule Honkai: Star Rail application from the CLI.') start_parser = subparsers.add_parser('start', help='Start the Honkai: Star Rail application', description='Start the Honkai: Star Rail application') start_parser.set_defaults(func=entrypoint_handler.start) parser.add_parser_to_group(apps_group, start_parser) stop_parser = subparsers.add_parser('stop', help='Terminate the Honkai: Star Rail application', description='Terminate the Honkai: Star Rail application') stop_parser.set_defaults(func=entrypoint_handler.stop) parser.add_parser_to_group(apps_group, stop_parser) schedule_parser = subparsers.add_parser('schedule', help='Schedule the start/stop of the application at a given time', description='Schedule the start/stop of the application at a given time') schedule_parser.add_argument('subcommand', nargs='?', default=None, help='add / remove / show') schedule_parser.add_argument('--action', type=str, default="start", help='Specify start or stop (only needed for schedule add)') schedule_parser.add_argument('--time', type=str, help='Specify the scheduled time (only needed for schedule add) (i.e. 10:30)') schedule_parser.set_defaults(func=entrypoint_handler.schedule) parser.add_parser_to_group(apps_group, schedule_parser) # ================================================= # =================| AUTOMATION | ================= # ================================================= auto_group = parser.add_group('Automation Commands', 'Simple automation for automating Honkai: Star Rail\'s gameplay.') auto_parser = subparsers.add_parser('automation', help='', description='') auto_parser.add_argument('action', nargs='?', default=None, help='record / run / show / remove / clear') auto_parser.set_defaults(func=entrypoint_handler.automation) parser.add_parser_to_group(auto_group, auto_parser) click_parser = subparsers.add_parser('click', help='Continuously click mouse based on given interval.', description='Continuously click mouse based on given interval.') click_parser.add_argument('--clicks', '-c', type=int, default=-1, help='Number of clicks. Leave empty (default) to run forever') click_parser.add_argument('--interval', '-i', type=float, default=1, help='Interval delay (seconds) between clicks') click_parser.add_argument('--randomize', '-r', type=float, default=1.0, help='Randomize the click interval by added 0 to x seconds to the interval specification.') click_parser.add_argument('--hold', type=float, default=0.1, help='Delay (seconds) between click press and release') click_parser.add_argument('--delay', '-d', type=float, default=3, help='Delay (seconds) before the clicks start') click_parser.add_argument('--quiet', '-q', action='store_true', default=False, help='Run without verbosing progress') click_parser.set_defaults(func=entrypoint_handler.click_continuously) parser.add_parser_to_group(auto_group, click_parser) # ================================================= # ==================| KEY URLS | ================== # ================================================= url_group = parser.add_group('Official Pages', "Access official Honkai: Star Rail's web pages from the CLI") homepage_parser = subparsers.add_parser('homepage', help='Open the official home page of Honkai: Star Rail', description='Open the official home page of Honkai: Star Rail') homepage_parser.add_argument('-cn', action='store_true', default=False, help='Open CN version of the home page.') homepage_parser.set_defaults(func=entrypoint_handler.homepage) parser.add_parser_to_group(url_group, homepage_parser) hoyolab_parser = subparsers.add_parser('hoyolab', help='Open the HoyoLab page of Honkai: Star Rail', description='Open the HoyoLab page of Honkai: Star Rail') hoyolab_parser.set_defaults(func=entrypoint_handler.hoyolab) parser.add_parser_to_group(url_group, hoyolab_parser) youtube_parser = subparsers.add_parser('youtube', help='Open the official Youtube page of Honkai: Star Rail', description='Open the official Youtube page of Honkai: Star Rail') youtube_parser.set_defaults(func=entrypoint_handler.youtube) parser.add_parser_to_group(url_group, youtube_parser) bilibili_parser = subparsers.add_parser('bilibili', help='Open the official BiliBili page of Honkai: Star Rail', description='Open the official BiliBili page of Honkai: Star Rail') bilibili_parser.set_defaults(func=entrypoint_handler.bilibili) parser.add_parser_to_group(url_group, bilibili_parser) # ================================================= # =============| UTILITY FUNCTIONS | ============== # ================================================= utility_group = parser.add_group('Utility Commands', 'Honkai: Star Rail utility features directly from the CLI.') sc_config = subparsers.add_parser('screenshots', help='Open the screenshots directory in File Explorer', description='Open the screenshots directory in File Explorer') sc_config.set_defaults(func=entrypoint_handler.screenshots) parser.add_parser_to_group(utility_group, sc_config) logs_config = subparsers.add_parser('game-logs', help='Open the games log directory in File Explorer', description='Open the games log directory in File Explorer') logs_config.set_defaults(func=entrypoint_handler.game_logs) parser.add_parser_to_group(utility_group, logs_config) decode_config = subparsers.add_parser('decode', help='Decode ASCII-based binary files', description='Decode ASCII-based binary files') decode_config.add_argument('--path', type=str, default=None, help='Path of the binary file to decode.') decode_config.add_argument('--min-length', type=int, default=8, help='Minimum ASCII length to be considered as a valid string.') decode_config.set_defaults(func=entrypoint_handler.decode) parser.add_parser_to_group(utility_group, decode_config) pulls_config = subparsers.add_parser('pulls', help='View the pull history page directly in the browser', description='View the pull history page directly in the browser') pulls_config.set_defaults(func=entrypoint_handler.pulls) parser.add_parser_to_group(utility_group, pulls_config) cache_announcement_config = subparsers.add_parser('webcache', help='Show decoded web cache URLs (events, pulls, announcements)', description='Show decoded web cache URLs (events, pulls, announcements)') cache_announcement_config.add_argument('--announcements', '-a', action='store_true', default=False, help='Show cached announcements.') cache_announcement_config.add_argument('--events', '-e', action='store_true', default=False, help='Show cached events.') cache_announcement_config.add_argument('--quiet', '-q', action='store_true', default=False, help='Do not verbose web cache explanation.') cache_announcement_config.add_argument('--open', action='store_true', default=False, help='Open all web cache URLs found.') cache_announcement_config.set_defaults(func=entrypoint_handler.webcache) parser.add_parser_to_group(utility_group, cache_announcement_config) # ================================================= # ==========| BASE CONFIGURATION CMD | ============ # ================================================= app_utility_group = parser.add_group('Configure Commands', 'Configure (auto-locate) and Honkai: Star Rail on the local machine. Must be ran before anything else.') sync_parser = subparsers.add_parser('configure', help='Configure the starrail module on the local machine (auto-locate the Star Rail application). ', description="Configure the starrail module on the local machine (auto-locate the Star Rail application).") sync_parser.set_defaults(func=entrypoint_handler.configure) parser.add_parser_to_group(app_utility_group, sync_parser) # ================================================= # ==============| HELPER FUNCTIONS | ============== # ================================================= module_utility_group = parser.add_group('Helper Commands', '') elev_parser = subparsers.add_parser('elevate', help=f'Request {COMMAND} to be ran as admin.', description=f'Request {COMMAND} to be ran as admin.') elev_parser.add_argument('arguments', nargs='*', default=[], help=f'Any arguments to be followed after `{COMMAND}`') elev_parser.set_defaults(func=entrypoint_handler.elevate) parser.add_parser_to_group(module_utility_group, elev_parser) # ================================================ # ==================| FIREFLY | ================== # ================================================ ff_group = parser.add_group('???', 'What could this be?') ff_parser = subparsers.add_parser('FIREFLY') ff_parser.set_defaults(func=entrypoint_handler.firefly) parser.add_parser_to_group(ff_group, ff_parser) # =========================================================================================== # >>> BLOCKING FUNCTIONS # =========================================================================================== if verify_platform() == False: raise StarRailOSNotSupported() module_fully_configured = entrypoint_handler.star_rail.config.full_configured() if not module_fully_configured: # If the module is not fully configured, then force user to first configure the module def blocked_func(args): text = color_cmd('starrail configure') aprint(f"The StarRail module is not fully configured to run on this machine.\nTo configure the module, run `{text}`") raise SRExit() for name, subparser in subparsers.choices.items(): if name not in ["configure", "version", "author", "repo"]: subparser.set_defaults(func=blocked_func) # isadmin = is_admin() # if not isadmin: # # Certain commands require admin permissions to execute # def blocked_func(args): # elevate_cmd = color_cmd("amiya elevate", with_quotes=True) # aprint(f"Insufficient permission. Run {elevate_cmd} to elevate permissions first.") # raise SRExit() # for name, subparser in subparsers.choices.items(): # if name in ["record-auto", "run-auto"]: # subparser.set_defaults(func=blocked_func) # =========================================================================================== # >>> PARSER DRIVER # =========================================================================================== # Check if no command line arguments are provided if len(sys.argv) == 1: aprint("Loading starrail CLI environment...") entrypoint_handler.start_cli(parser) else: # Normal command line execution args = parser.parse_args() if hasattr(args, 'func'): try: args.func(args) except KeyboardInterrupt: aprint("Keyboard Interrupt! StarRail Exiting.") except SRExit: exit() else: parser.print_help() ================================================ FILE: src/starrail/entrypoints/help_format_handler.py ================================================ import argparse from starrail.utils.utils import * class HelpFormatHandler: def print_help(self, args, parser): for group in parser.groups: if group['description']: title = Printer.to_lightred(u"\u2606 " + group['title']) description = Printer.to_lightgrey(" : " + group['description']) print(f"\n{title}{description}") else: title = Printer.to_lightred(u"\u2606 " + group['title']) print(f"\n{title}") for subparser in group['parsers']: prog_cmd = Printer.to_lightblue(subparser.prog) print(f" {prog_cmd}: {subparser.description or 'No description available.'}") for action in subparser._actions: if action.option_strings: print(f" {Printer.to_purple(', '.join(action.option_strings))}: {Printer.to_lightgrey(action.help)}") else: print(f" {Printer.to_purple(action.dest)}: {Printer.to_lightgrey(action.help)}") print("") ================================================ FILE: src/starrail/exceptions/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/exceptions/exceptions.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. class StarRailBaseException(Exception): __module__ = 'builtins' def __init__(self, message): super().__init__(message) class StarRailModuleException(Exception): __module__ = 'builtins' def __init__(self, err_code): self.message = f"\nAn unexpected exception has occurred ({err_code}). Please seek help at https://github.com/ReZeroE/StarRail/issues." super().__init__(self.message) class StarRailOSNotSupported(Exception): __module__ = 'builtins' def __init__(self): super().__init__(f"\nThe starrail package only supports Windows installations of Honkai Star Rail.") class StarRailConfigNotExistsException(Exception): __module__ = 'builtins' def __init__(self, config_path): super().__init__(f"\nThe StarRail config file cannot be found at {config_path} (should be caught accordingly).") # This is a special exception that is used in place of exit() to avoid IO read error in the CLI environment (to avoid closing STDOUT). # This error is caught and replaced with 'continue' in the CLI env and 'exit()' in the normal mode. class SRExit(Exception): __module__ = 'builtins' def __init__(self, message="Module internal exit requested (should be caught accordingly)."): super().__init__(message) ================================================ FILE: src/starrail/utils/__init__.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. ================================================ FILE: src/starrail/utils/binary_decoder.py ================================================ import re import os import tabulate from starrail.utils.utils import * SUBMODULE_NAME = "SR-DB" class StarRailBinaryDecoder: def __init__(self): self.decoded_count = 0 def decode_raw_binary_file(self, file_path, min_length=8): """ Extract readable strings from a binary file. Expecting length > given min_length parameter. :param file_path: Path to the binary file :param min_length: Minimum length of strings to extract :return: list of extracted strings """ with open(file_path, 'rb') as file: binary_content = file.read() # Use regular expression to find readable strings pattern = re.compile(b'[ -~]{%d,}' % min_length) strings = pattern.findall(binary_content) # Decode bytes to strings decoded_strings = [s.decode('utf-8', errors='ignore') for s in strings] self.decoded_count += 1 return decoded_strings def user_decode(self, file_path=None, min_length=8): if not file_path: aprint("Binary File Path: ", end="") user_input = input("").strip().lower().replace("\"", "") if not os.path.isfile(user_input): aprint(Printer.to_lightred(f"Invalid path: {user_input}")) return file_path = user_input results = [] try: results = self.decode_raw_binary_file(file_path, min_length) except Exception as ex: aprint(f"File cannot be decoded ({ex}).", submodule_name=SUBMODULE_NAME) return if len(results) == 0: aprint("No results are found.", submodule_name=SUBMODULE_NAME) return headers = [Printer.to_purple(title) for title in ["Index", "Content"]] data = [[Printer.to_lightblue(idx), content] for idx, content in enumerate(results)] print(tabulate.tabulate(data, headers)) ================================================ FILE: src/starrail/utils/game_detector.py ================================================ import os import sys import string from pathlib import Path from concurrent import futures from multiprocessing import Manager import queue from starrail.constants import GAME_FILENAME, GAME_FILE_PATH, GAME_FILE_PATH_NEW, MIN_WEAK_MATCH_EXE_SIZE from starrail.exceptions.exceptions import * class StarRailGameDetector: """ Honkai Star Rail Game Detector - Finds the game on local drives """ def __init__(self): # Weak Match # Implemented in 1.0.2, weak match will return valid game path based solely on the filename # of the game (StarRail.exe) if no game path can be matched. This is not the best idea to go # about resolving the issue with Hoyo having different directory structures for different # game versions, but it is the best one I can think of right now to futue-proof another directory # structure change. manager = Manager() self.weak_matches = manager.Queue() # Use Manager's Queue def get_local_drives(self): available_drives = ['%s:' % d for d in string.ascii_uppercase if os.path.exists('%s:' % d)] return available_drives def find_game_in_path(self, path, name, stop_flag): for root, _, files in os.walk(path): if stop_flag.value: # Check the shared flag value. return None # If game file in the directory if name in files: abs_path = os.path.join(root, name) self.weak_matches.put(abs_path) # Check if the game's entire path is in the path found if os.path.normpath(GAME_FILE_PATH) in abs_path or os.path.normpath(GAME_FILE_PATH_NEW) in abs_path: return os.path.join(root, name) return None def find_game(self, paths=[], name=GAME_FILENAME): for p in paths: if not os.path.exists(p): raise StarRailBaseException(f"Path does not exist. [{p}]") if len(paths) == 0: paths = self.get_local_drives() paths = [f"{path}\\" if path.endswith(":") and len(path) == 2 else path for path in paths] worker_threads = 1 if os.cpu_count() > 1: worker_threads = os.cpu_count() - 1 with futures.ProcessPoolExecutor(max_workers=worker_threads) as executor, Manager() as manager: stop_flag = manager.Value('b', False) # Create a boolean shared flag. future_to_path = {executor.submit(self.find_game_in_path, path, name, stop_flag): path for path in paths} for future in futures.as_completed(future_to_path): result = future.result() if result is not None: stop_flag.value = True # Set the flag to true when a result is found. return result # No match is found, then: while not self.weak_matches.empty(): path = self.weak_matches.get() if self.is_file_over_size(path): return path # No match and no valid weak match return None def is_file_over_size(self, file_path): if not os.path.isfile(file_path): return False megabytes = MIN_WEAK_MATCH_EXE_SIZE * 1024 * 1024 file_size = os.path.getsize(file_path) return file_size > megabytes ================================================ FILE: src/starrail/utils/json_handler.py ================================================ import os import json from abc import ABC from starrail.exceptions.exceptions import StarRailBaseException class JSONConfigHandler(ABC): def __init__(self, config_abs_path, config_type=dict): self.config_file = config_abs_path self.config_type = config_type def LOAD_CONFIG(self): try: with open(self.config_file, "r") as rf: config = json.load(rf) return config except FileNotFoundError: return None except json.JSONDecodeError: self.SAVE_CONFIG([] if self.config_type == list else dict()) return None def SAVE_CONFIG(self, json_payload) -> bool: try: with open(self.config_file, "w") as wf: json.dump(json_payload, wf, indent=4) except Exception as ex: StarRailBaseException(f"Config file '{self.config_file}' cannot be created due to an unknown error ({ex}).") return True def DELETE_CONFIG(self): if not self.CONFIG_EXISTS(): return False try: os.remove(self.config_file) except: return False return True def CONFIG_EXISTS(self): return os.path.isfile(self.config_file) def VALIDATE_CONFIG(self): ... ================================================ FILE: src/starrail/utils/perm_elevate.py ================================================ import subprocess from starrail.utils.utils import * import signal import time import psutil class StarRailPermissionsHandler: @staticmethod def elevate(arguments: list = []): ARGUMENTS = " ".join([str(arg) for arg in arguments]) try: # \k keeps the terminal open after the user exits the starrail cli env cmd_command = f'cmd /k "{COMMAND} {ARGUMENTS}"' result = subprocess.run(["powershell", "-Command", f'Start-Process cmd -ArgumentList \'/k {cmd_command}\' -Verb RunAs']) if result.returncode == 0: print("") aprint(Printer.to_lightgreen(f"Admin permission granted.") + f"\nPlease use the new terminal with the {SHORTNAME}-CLI that opened.") else: aprint(f"Command 'starrail elevate' permission denied.", log_type=LogType.ERROR) except Exception as e: aprint(f"Failed to elevate privileges: {e}") time.sleep(3) cmd_proc_id = psutil.Process(os.getppid()).ppid() kill_command = f'taskkill /F /PID {cmd_proc_id}' os.system(kill_command) @staticmethod def elevate_post_cli(follow_up_cmd: str): try: # \k keeps the terminal open after the user exits the starrail cli env cmd_command_1 = f'cmd /k "{COMMAND}"' combined_commands = f"{cmd_command_1} && {follow_up_cmd}" result = subprocess.run([ "powershell", "-Command", f'Start-Process cmd -ArgumentList \'/k "{combined_commands}"\' -Verb RunAs' ]) if result.returncode == 0: aprint(f"Permissions granted. Please use the new terminal with the {SHORTNAME}-CLI that opened.") else: aprint(f"Command 'starrail elevate' permission denied.", log_type=LogType.ERROR) except Exception as e: aprint(f"Failed to elevate privileges: {e}") ================================================ FILE: src/starrail/utils/process_handler.py ================================================ import psutil import time import ctypes import subprocess from starrail.utils.utils import * class ProcessHandler: @staticmethod def get_focused_pid(): user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 hwnd = user32.GetForegroundWindow() pid = ctypes.c_ulong() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) return pid.value @staticmethod def get_related_processes(process_pid: int): process = psutil.Process(process_pid) if not process.is_running(): return None, None parent_procs: list[psutil.Process] = [] children_procs: list[psutil.Process] = [] try: parent_procs = process.parents() children_procs = process.children(recursive=True) except: pass return parent_procs, children_procs @staticmethod def kill_pid(pid, verbose_failure=True, is_child=False): process_info = f"process {pid}" if not is_child else f"child process {pid}" try: proc = psutil.Process(pid) if proc.is_running(): proc.kill(); time.sleep(0.1) aprint(f"Process {proc.name()} (PID {proc.pid}) terminated successfully.", submodule_name="ProcHandler") return True except psutil.NoSuchProcess: if verbose_failure: aprint(f"Unabled to terminate {process_info} because it's already closed.", log_type=LogType.ERROR, submodule_name="ProcHandler") except Exception as ex: if verbose_failure: aprint(f"Unabled to terminate {process_info} ({ex}).", log_type=LogType.ERROR, submodule_name="ProcHandler") return False @staticmethod def kill_pid_and_residual(pid): try: proc = psutil.Process(pid) child_procs = [] try: child_procs = proc.children(recursive=True) except: pass except psutil.NoSuchProcess: aprint(f"Unabled to terminate root process {pid} because it's already closed.", log_type=LogType.WARNING, submodule_name="ProcHandler") return ProcessHandler.kill_pid(pid, verbose_failure=True) for cproc in child_procs: if cproc.is_running(): ProcessHandler.kill_pid(cproc.pid, verbose_failure=False, is_child=True) time.sleep(0.5) ================================================ FILE: src/starrail/utils/utils.py ================================================ # SPDX-License-Identifier: MIT # MIT License # # Copyright (c) 2024 Kevin L. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os import re import sys import string import shutil from datetime import datetime from enum import Enum import pyautogui import platform from concurrent import futures from multiprocessing import Manager import hashlib import readline from termcolor import colored from starrail import constants from starrail.constants import BASENAME, SHORTNAME, COMMAND, GAME_FILENAME, DATETIME_FORMAT, TIME_FORMAT from starrail.exceptions.exceptions import * # ================================================= # ==============| CUSTOM PRINTER | ================ # ================================================= class Printer: @staticmethod def hex_text(text, hex_color): def hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) rgb = hex_to_rgb(hex_color) escape_seq = f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" # ANSI escape code for 24-bit (true color): \x1b[38;2;;;m return f"{escape_seq}{text}\x1b[0m" @staticmethod def to_purple(text): return Printer.hex_text(text, "#a471bf") @staticmethod def to_lightpurple(text): return Printer.hex_text(text, "#c38ef5") @staticmethod def to_skyblue(text): return Printer.hex_text(text, "#6dcfd1") @staticmethod def to_lightgrey(text): return Printer.hex_text(text, "#8a8a8a") @staticmethod def to_blue(text): return Printer.hex_text(text, "#3c80f0") @staticmethod def to_lightblue(text): return Printer.hex_text(text, "#8ab1f2") @staticmethod def to_darkblue(text): return Printer.hex_text(text, "#2a9bc3") @staticmethod def to_lightgreen(text): return Printer.hex_text(text, "#74d47b") @staticmethod def to_lightred(text): return Printer.hex_text(text, "#f27e82") def to_githubblack(text): return Printer.hex_text(text, "#121212") # C9A2FF, 7A7A7A # Firefly Colors @staticmethod def to_pale_yellow(text): return Printer.hex_text(text, "#f3f2c9") @staticmethod def to_light_blue(text): return Printer.hex_text(text, "#b9d8d6") @staticmethod def to_teal(text): return Printer.hex_text(text, "#7ba7a8") @staticmethod def to_turquoise(text): return Printer.hex_text(text, "#4a8593") @staticmethod def to_dark_teal(text): return Printer.hex_text(text, "#2f6b72") class LogType(Enum): NORMAL = "white" SUCCESS = "green" WARNING = "yellow" ERROR = "red" def __get_colored_prefix(): # return f"[{colored(BASENAME, "cyan")}] " return f"[{Printer.to_purple(SHORTNAME)}] " def __get_colored_submodule(submodule_name): colored_submodule_name = Printer.to_purple(submodule_name) return f"[{colored_submodule_name}] " def atext(text: str, log_type: LogType = LogType.NORMAL) -> str: rtext = colored(text, log_type.value) return f"{__get_colored_prefix()}{rtext}" def aprint( text: str, log_type: LogType = LogType.NORMAL, end: str = "\n", submodule_name: str = "", new_line_no_prefix = True, file = sys.stdout, flush = True ): # The new_line_no_prefix param coupled with \n in the text param will put the # text after the new line character on the next line, but without a prefix. if "\n" in text and new_line_no_prefix == True: text = text.replace("\n", f"\n ") # Set colored submodule name if available colored_submodule_name = "" if submodule_name: colored_submodule_name = __get_colored_submodule(submodule_name) # Get colored prefix and text colored_text = colored(text, log_type.value) colored_prefix = __get_colored_prefix() # Put all together and print final_text = f"{colored_prefix}{colored_submodule_name}{colored_text}" print(final_text, end=end, file=file, flush=flush) def get_prefix_space(): return " " * (len(SHORTNAME)+2) def color_cmd(text: str, with_quotes: bool = False): text = text.lower() if constants.CLI_MODE == True: text = text.replace(COMMAND, "").strip() else: if not text.startswith(COMMAND): text = f"{COMMAND} {text}" colored_cmd = colored(text, "light_cyan") if with_quotes: return f"'{colored_cmd}'" return colored_cmd def bool_to_str(boolean: bool, true_text="Running", false_text="Not Running"): CHECKMARK = "\u2713" CROSSMARK = "\u2717" if boolean: return Printer.to_lightgreen(f"{CHECKMARK} {true_text}") return Printer.to_lightred(f"{CROSSMARK} {false_text}") class StarRailGameDetector: """ Honkai Star Rail Game Detector - Finds the game on local drives """ def get_local_drives(self): available_drives = ['%s:' % d for d in string.ascii_uppercase if os.path.exists('%s:' % d)] return available_drives def find_game_in_path(self, path, name, stop_flag): for root, _, files in os.walk(path): if stop_flag.value: # Check the shared flag value. return None if name in files: return os.path.join(root, name) return None def find_game(self, paths=[], name=GAME_FILENAME): for p in paths: if not os.path.exists(p): raise StarRailBaseException(f"Path does not exist. [{p}]") if len(paths) == 0: paths = self.get_local_drives() paths = [f"{path}\\" if path.endswith(":") and len(path) == 2 else path for path in paths] worker_threads = 1 if os.cpu_count() > 1: worker_threads = os.cpu_count() - 1 with futures.ProcessPoolExecutor(max_workers=worker_threads) as executor, Manager() as manager: stop_flag = manager.Value('b', False) # Create a boolean shared flag. future_to_path = {executor.submit(self.find_game_in_path, path, name, stop_flag): path for path in paths} for future in futures.as_completed(future_to_path): result = future.result() if result is not None: stop_flag.value = True # Set the flag to true when a result is found. return result return None class StarRailScreenshotController: """ Honkai Star Rail Screenshot Controller - Takes and stores in-game screenshots for processing """ def __init__(self): self.__screenshot_abspath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_data", "images", "screenshots", "screenshot.png") def get_screenshot_path(self): return self.__screenshot_abspath def take_screenshot(self): myScreenshot = pyautogui.screenshot() myScreenshot.save(self.__screenshot_abspath) assert(os.path.isfile(self.__screenshot_abspath)) def verify_platform(): """ Verifies whether the OS is supported by the package. Package starrail only support Windows installations of Honkai Star Rail. :return: true if running on Windows, false otherwise :rtype: bool """ return os.name == "nt" def is_admin() -> bool: try: # For Windows if platform.system().lower() == "windows": import ctypes return ctypes.windll.shell32.IsUserAnAdmin() != 0 # For Linux and MacOS else: return os.getuid() == 0 # os.getuid() returns '0' if running as root except Exception as e: aprint(f"Error checking administrative privileges: {e}", log_type=LogType.ERROR) return False def print_disclaimer(): DISCLAIMER_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "textfiles", "disclaimer.txt") with open(DISCLAIMER_PATH, "r") as rf: lines = rf.readlines() print("".join(lines) + "\n") def print_webcache_explanation(): print(Printer.to_lightred("\n What is Web Cache?")) print(f"{Printer.to_lightred(' > ') + 'Web cache for Honkai: Star Rail stores recent web data.'}") print(f"{Printer.to_lightred(' > ') + 'You can open URLs to view announcements, events, or pull status, allowing quick access without loading into the game.'}\n") # ================================================= # ============| CENTER TEXT HELPER | ============== # ================================================= # DON"T CHANGE THE FOLLOWING TWO CENTER TEXT FUNCTIONS. I GOT THESE TO WORK AFTER HOURS. BOTH ARE NEEDED! def center_text(text: str): terminal_width, terminal_height = shutil.get_terminal_size((80, 20)) # Default size lines = text.split('\n') max_width = max(len(line) for line in lines) left_padding = (terminal_width - max_width) // 2 new_text = [] for line in lines: new_text.append(' ' * left_padding + line) return "\n".join(new_text) def print_centered(text: str): def strip_ansi_codes(s): return re.sub(r'\x1B[@-_][0-?]*[ -/]*[@-~]', '', s) terminal_width = os.get_terminal_size().columns lines = text.split('\n') for line in lines: line_without_ansi = strip_ansi_codes(line) leading_spaces = (terminal_width - len(line_without_ansi)) // 2 print(' ' * leading_spaces + line.strip()) class DatetimeHandler: @staticmethod def get_datetime(): return datetime.now().replace(microsecond=0) def get_datetime_str(): return datetime.now().strftime(DATETIME_FORMAT) def get_time_str(): return datetime.now().strftime(TIME_FORMAT) @staticmethod def datetime_to_str(datetime: datetime): return datetime.strftime(DATETIME_FORMAT) @staticmethod def str_to_datetime(datetime_str: str): return datetime.strptime(datetime_str, DATETIME_FORMAT) @staticmethod def epoch_to_time_str(epoch_time: float): return datetime.fromtimestamp(epoch_time).strftime(DATETIME_FORMAT) @staticmethod def epoch_to_datetime(epoch_time: float): return datetime.fromtimestamp(epoch_time) @staticmethod def seconds_to_time_str(seconds: int): hours, remainder = divmod(seconds, 3600) minutes, seconds = divmod(remainder, 60) str_ret = "" if hours > 0: str_ret += f"{hours} {'Hours' if hours > 1 else 'Hour'} " if minutes > 0: str_ret += f"{minutes} {'Minutes' if minutes > 1 else 'Minute'} " str_ret += f"{seconds} {'Seconds' if seconds > 1 else 'Second'}" return str_ret class HashCalculator: @staticmethod def SHA256(file_path: str): sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() def merge_dicts(*dicts): result = {} for d in dicts: if d == None: continue for key, value in d.items(): if key in result: if isinstance(result[key], (list, tuple)): if isinstance(value, (list, tuple)): result[key].extend(value) else: result[key].append(value) else: if isinstance(value, (list, tuple)): result[key] = [result[key]] + list(value) else: result[key] = [result[key], value] else: result[key] = value if not isinstance(value, (list, tuple)) else list(value) return result ================================================ FILE: tests/test_starrail.py ================================================ # # SPDX-License-Identifier: MIT # # MIT License # # # # Copyright (c) 2024 Kevin L. # # # # Permission is hereby granted, free of charge, to any person obtaining a copy # # of this software and associated documentation files (the "Software"), to deal # # in the Software without restriction, including without limitation the rights # # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # # copies of the Software, and to permit persons to whom the Software is # # furnished to do so, subject to the following conditions: # # # # The above copyright notice and this permission notice shall be included in all # # copies or substantial portions of the Software. # # # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # # SOFTWARE. # import subprocess # import psutil # import pytest # import time # import os # import pyautogui # # =============================================== # # ==== | THE FOLLOWING IS FOR TESTING ONLY | ==== # # =============================================== # if __name__ == "__main__": # pass ================================================ FILE: tests/verify_package.py ================================================ import os ''' Internal script to verify that all python directory is contains __init__.py ''' def check_init_files(build_dir=os.path.join(os.path.dirname(os.path.dirname(__file__)), "src")): paths = [] for root, dirs, files in os.walk(build_dir): has_python_files = any(file.endswith('.py') for file in files) if has_python_files and '__init__.py' not in files: paths.append(root) if len(paths) == 0: print("No missing __init__ files.") return for path in paths: print(f"Missing __init__ file at {path}") if __name__ == "__main__": check_init_files()