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`
   
## 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.
***

# 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.

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
[](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()