[
  {
    "path": ".gitignore",
    "content": "# Ignore existing configs\nsrc/starrail/config/starrail_config.json\nsrc/starrail/bin/scheduler/config/*.json\n\n# Local Screenshots\nsrc/starrail/_data/images/screenshots/*.png\n\n# Temp SIFT Test Images\nsrc/starrail/_utils/cv2_SIFT/tmp_data\n\n# Incomplete UI scripts\nsrc/starrail/user_interface/ui*.py\n\n# Temp Test Scritps\ntests/tmp*test.py\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Change Log\n\n### Version 1.0.5 - [9/25/2024]\n- Added feature to show live game status (`starrail status --live`).\n- Various minor bug fixes and code optimizations. \n   \n### Version 1.0.4 - [9/4/2024]\n- Added the following quick-links to the web controller\n    - BiliBili (CN) `> starrail bilibili`\n    - Homepage (CN) `> starrail homepage -cn`\n- Various minor bug fixes and code optimizations. \n\n### Version 1.0.3, 1.0.4 - [8/29/2024]\n- Fixed more compatibility issues with the updated game directory structure.\n- Improved error messaging for the `starrail` scheduler and automation handler.\n- Introduced a handler to manage case when web cache binary file are locked when the game is active.\n- Updated the automation section in the README file to use `automation remove` instead of `automation delete`.\n- Various minor bug fixes and code optimizations.\n\n### Version 1.0.2 - [8/27/2024]\n- Resolved issue with auto-detecting the game's executable's path.\n    - New HoyoPlay launcher introduced a new game directory structure that broke old game detecting system. Package now matches:\n        1. `.../Star Rail/Game/StarRail.exe`\n        2. `.../Star Rail Games/StarRail.exe`\n    - Implemented new \"weak match\" feature to future-proof another change in game directory structure. \n    - Added new error message if package is unable to locate the game.\n\n### Version 1.0.1 - [8/26/2024]\n- Resolved version support issues (now supports Python 3.7 and later).\n\n### Version 1.0.0 - [8/1/2024]\n1. Complete redesign of the entire project.\n    - [Package Info](https://github.com/ReZeroE/StarRail/wiki/3.-Package-Information)\n        - About Package\n        - Version\n        - Author\n        - Repository\n\n    - [CLI Launcher & Scheduler](https://github.com/ReZeroE/StarRail/wiki/4.-Start-Stop-&-Schedule-Game)\n        - Start Game\n        - Stop Game\n        - Schedule Start/Stop\n\n    - [Game Configuration](https://github.com/ReZeroE/StarRail/wiki/5.-Game-Configurations)\n        - Real-time Game Status\n        - Base Game Information\n        - Detailed Client Information\n\n    - [Simple Automation](https://github.com/ReZeroE/StarRail/wiki/6.-Simple-Automation)\n        - Custom Automation\n        - Uniform Clicks\n\n    - [Official Page Access](https://github.com/ReZeroE/StarRail/wiki/7.-Official-Page-Access)\n        - Official Homepage\n        - Official Youtube Page\n        - Official HoyoLab Page\n\n    - [Binary Utilities](https://github.com/ReZeroE/StarRail/wiki/8.-Binary-Utilities)\n        - Decoded Web Cache (events, pulls, announcements)\n        - Cached Pull History\n        - Supplementary Binary Decoder\n\n    - [Misc Utilities](https://github.com/ReZeroE/StarRail/wiki/9.-Misc-Utilities)\n        - View Screenshots\n        - View Game Logs\n\n<br/>\n\nPlease view the [developer's note](https://github.com/ReZeroE/StarRail/wiki/99.-Developer's-Note) for more information regarding the project overhaul.\n\n<br/>\n\n***\n\n<br/>\n\n## Legacy Version Logs\n\n### Version 0.0.3 - [6/7/2023]\n1. Optimized `starrail configure` to use multiprocessing Managers (speedup in local game search). \n2. Added feature to downscale screen feature matching threshold based on the native screen resolution.\n    - Enabled `starrail` to support 4K, 2K, 1080P or lower resolution screens.\n3. Added logout feature. \n\n### Version 0.0.3 - [6/4/2023]\n1. Added new rewards logic maps (Daily Training, Assignments)\n2. Restructured the code framework of logic maps for better optimization.\n3. The starrail show-config command now displays the absolute path of the game executable after configuration.\n4. Adjusted image feature matching values to allow for less accurate matches in specific circumstances.\n5. The time delay following a simulated mouse or keyboard key click has been extended.\n\n### Version 0.0.3 - [6/3/2023]\n1. Added Logic Map for Calyx Golden (bud_of_memories, bud_of_aether, bud_of_threasures)\n2. Tested automation features for login, reward collection, and Calyx Golden.\n3. Implemented \"secondary image detection\" with SIFT and FLANN for non-centered buttons (non-centered buttons were previously tracked with pixels offsets (x, y)).   \n\n### Version 0.0.3 - [6/1/2023]\n1. Implemented \"Logic Maps\" structures (process sequence maps for automation).\n2. Implemented base wrapper classes for auto grind(Calyx), reward collection, and login that utilizes the Logic Maps for automation.\n3. Added Logic Maps for login and reward collection.\n4. Updated project code structure.\n\n### Version 0.0.3 - [5/21/2023]\n1. 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. \n2. 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).\n\n### Version 0.0.3 - [5/10/2023]\n1. Optimized the `starrail configure` process to use multithreading when searching for the local game instance (Honkai: Star Rail) for a decrease in runtime.\n\n### Version 0.0.3 - [5/1/2023]\n1. Removed faulty dependencies that cannot be properly installed from PyPI\n2. Resolved game path auto-detection issue\n\n### Version 0.0.2 - [4/30/2023]\n1. Stablized commandline features for start, stop, configure\n2. Added commandline feature for overwriting previous path configurations:\n```shell\n$ starrail set-path\n```\n3. And other efficiency and maintainability related optimizations\n\n### Version 0.0.2 - [4/29/2023]\nAdded commandline support for the following operations (**UNSTABLE**):\n\n1. Configure `starrail` (only once after download):\n```shell\n$ starrail configure\n```\n2. Starting Honkai: Star Rail from the commandline\n```shell\n$ starrail start\n```\n3. Terminating Honkai: Star Rail from the commandline (started from `starrail`)\n```shell\n$ starrail stop\n```\n\n### Version 0.0.1 - [4/26/2023]\nInitial Release\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n_Updated on 7/1/2024_\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, gender identity and expression, level of experience,\neducation, socio-economic status, nationality, personal appearance, race,\nreligion, or sexual identity and orientation.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or\n  advances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team at kevinliu@vt.edu. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html\n\n[homepage]: https://www.contributor-covenant.org\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 Kevin L.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "MANIFEST.in",
    "content": "recursive-include src/starrail *.json *.png *.jpg *.jpeg *.txt *.c *.exe\n\nexclude src/starrail/config/*.json\nexclude src/starrail/bin/scheduler/config/*.json\n\nexclude src/starrail/automation/config/automation-data/*.json"
  },
  {
    "path": "README.md",
    "content": "# Honkai: Star Rail - `starrail`\n\n   \n\n\n\n<p align=\"center\">\n  <img src=\"https://i.imgur.com/lE9hrlV.png\" height=\"auto\" alt=\"Centered Image\"/>\n</p>\n\n![badge](https://img.shields.io/pypi/dm/starrail) ![GitHub License](https://img.shields.io/github/license/rezeroe/starrail) ![support](https://img.shields.io/badge/support-Python_3.7%2B-blue) ![size](https://img.shields.io/github/repo-size/rezeroe/starrail)\n\n\n## Overview\n\n\nThe `starrail` package is a CLI (command-line interface) tool designed for managing and interacting with the game, Honkai: Star Rail, directly from your terminal. \n\n\n\n### Key Features:\n- **Game Launch Control**\n   - Start/Stop the game application directory from the terminal (skips launcher).\n   - Schedule the game to start/stop at any given time (i.e. 10:30AM).\n\n     \n- **Simple Automation**\n   - Custom Macros: Record and playback recorded mouse + keyboard click sequences in-game.\n   - Uniform Click: Allows automatic uniform-interval mouse clicking (duration can be randomized).\n\n     \n- **Binary Decoding**\n   - Web Cache: Access decoded web cache URLs containing information about pulls, events, and announcements.\n   - Streaming Assets: Access detailed client streaming asset information (read-only).\n   - Supplement Binary Decoder: Provides supplementary tools to decode any ASCII-based binary file.\n\n     \n- **Other Features**\n   - Screenshots: Open the screenshots directory from the CLI without the game launcher. \n   - Official Pages: Open the official HoyoVerse web pages easily from the CLI.\n\n<br/>\n\n\n\n\n\n> [!NOTE]  \n> **Installation Requirements**\n>  - Windows 10 or later\n>  - Python 3.7 or later\n>  - Official Honkai: Star Rail Installation\n\n<br/>\n\nPlease 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.\n\n<br/>\n\n\n\n***\n\n<br/>\n\n![CLI](https://i.imgur.com/882zWGf.png)\n\n# Installation / Setup\n\n\n\n\n**STEP 1 - To Install** the `starrail` package, run with **admin permissions**:\n```shell\n> pip install starrail==1.0.5\n\nOR\n\n> git clone https://github.com/ReZeroE/StarRail.git\n> cd StarRail/\n> pip install -e .\n```\n\n<br/>\n\n**STEP 2 - To configure** the `starrail` module after installing, run:\n```shell\n> starrail configure\n```\n\n<br/>\n\n**STEP 3 - To verify** that the installation was successful, run:\n```shell\n> starrail config\n```\n\n<br/>\n\n> [!NOTE]\n> 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!\n\n\n<br/>\n\n***\n\n<br/>\n\n\n# Start CLI\n\nThe `starrail` package provides its own standalone CLI environment. You may access the CLI by running:\n```shell\n> starrail\n```\nThis will bring up the StarRail CLI environment where all the commands can be executed without the `starrail` prefix.\n\n![ABC](https://i.imgur.com/cFKRjFV.png)\n\nThe 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.\n\n> [!NOTE]\n> \n> Inside of the StarRail CLI:\n> ```shell\n> > about\n> ```\n> \n> Outside of the StarRail CLI (any terminal):\n> ```shell\n> > starrail about\n> ```\n\n\nFor this guide, all following commands will be shown as if the CLI environment has not been activated.\n\n<br/>\n\n***\n\n<br/>\n\n\n\n# Usage Guide\n\nBelow 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.\n\n- [Package Info](https://github.com/ReZeroE/StarRail/wiki/3.-Package-Information)\n   - About Package\n   - Version\n   - Author\n   - Repository\n\n- [CLI Launcher & Scheduler](https://github.com/ReZeroE/StarRail/wiki/4.-Start-Stop-&-Schedule-Game)\n   - Start Game\n   - Stop Game\n   - Schedule Start/Stop\n\n- [Game Configuration](https://github.com/ReZeroE/StarRail/wiki/5.-Game-Configurations)\n   - Real-time Game Status\n   - Base Game Information\n   - Detailed Client Information\n   \n- [Simple Automation](https://github.com/ReZeroE/StarRail/wiki/6.-Simple-Automation)\n   - Custom Automation\n   - Uniform Clicks\n\n- [Official Page Access](https://github.com/ReZeroE/StarRail/wiki/7.-Official-Page-Access)\n   - Official Homepage\n   - Official Youtube Page\n   - Official HoyoLab Page\n \n- [Binary Utilities](https://github.com/ReZeroE/StarRail/wiki/8.-Binary-Utilities)\n   - Decoded Web Cache (events, pulls, announcements)\n   - Cached Pull History\n   - Supplementary Binary Decoder\n\n- [Misc Utilities](https://github.com/ReZeroE/StarRail/wiki/9.-Misc-Utilities)\n   - View Screenshots\n   - View Game Logs\n  \n<br/>\n\nFor the entire list of commands available in the `starrail` package, run:\n```shell\n> starrail help\n```\n\n<br/>\n\n\n## 1. Package Info\nTo see the general information about the `starrail` package, run:\n```shell\n> starrail about                # Shows all information about the package\n\nOR\n\n> starrail version              # Shows HSR and SR-CLI package version\n> starrail author               # Shows author information\n> starrail repo [--open]        # Shows repository link\n```\n\n<br/>\n\n***\n\n<br/>\n\n\n## 2. Start/Stop & Schedule Game\nTo start, stop, or schedule the start or stop of Honkai: Star Rail, the following commands are provided.\n\n### ☆ Start Game\nTo start Honkai: Star Rail, run:\n```shell\n> starrail start\n```\n\n<br/>\n\n### ☆ Stop Game\nTo stop Honkai: Star Rail, run:\n```shell\n> starrail stop\n```\n\n<br/>\n\n### ☆ Schedule Start/Stop\nTo schedule the start/stop of Honkai: Star Rail at a given time, you may use the scheduler supplied in the `starrail` package.\n\nTo view the scheduler's help panel, run:\n```shell\n> starrail schedule\n```\n\n**Supported Scheduler Commands** (see usage below)\n1. Show Schedule\n2. Add Schedule\n3. Remove Schedule\n4. Clear (remove all) Schedules\n\n```\nExample Command                                    Description\n----------------------------------------           ----------------------------------------------\nstarrail schedule add --time 10:30 --action start  Schedule Honkai Star Rail to START at 10:30 AM\nstarrail schedule add --time 15:30 --action stop   Schedule Honkai Star Rail to STOP  at 3:30 PM\nstarrail schedule remove                           Remove an existing scheduled job\nstarrail schedule show                             Show all scheduled jobs and their details\nstarrail schedule clear                            Cancel all scheduled jobs (irreversible)\n```\n\n\n> [!NOTE]\n> **For the full documentation on scheduling**, visit [this page](https://github.com/ReZeroE/StarRail/wiki/4.-CLI-Launcher-&-Scheduler).\n\n<br/>\n\n***\n\n<br/>\n\n## 3. Game Configuration\n\nTo view Honkai: Star Rail's configuration as well as its detailed client information, several commands are provided.\n\n### ☆ Real-time Game Status\nTo show the real-time status of the game while it's running, run:\n```shell\n> starrail status\n```\n```\n               HSR Status Details\n-------------  -------------------------------------\nStatus         ✓ Running\nProcess ID     59420\nStarted On     2024-0x-xx 15:11:04\nCPU Percent    1.3%\nCPU Affinity   0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15\nIO Operations  Writes: 247728, Reads: 580093\n```\nTo show live game status non-stop, run with argument `-l` or `--live`:\n```shell\n> starrail status [-l|--live]\n```\n\n<br/>\n\n### ☆ Base Game Information\nTo view the game's basic information configured under the `starrail` package listed below, run:\n- Game Version\n- Game Executable Location\n- Game Screenshots Directory\n- Game Logs Directory\n- Game Executable SHA256\n\n```shell\n> starrail config\n```\n```\nTitle               Details                                           Related Command\n------------------  ------------------------------------------------  --------------------\nGame Version        2.3.0                                             starrail version\nGame Executable     E:\\Star Rail\\Game\\StarRail.exe                    starrail start/stop\nGame Screenshots    E:\\Star Rail\\Game\\StarRail_Data\\ScreenShots       starrail screenshots\nGame Logs           E:\\Star Rail\\logs                                 starrail game-logs\nGame (.exe) SHA256  2axxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2c\n```\n\n<br/>\n\n### ☆ Detailed Client Information\nTo view detailed information about Honkai: Star Rail, run:\n- Client Version\n- Client Datetime\n- Client Detailed Version\n- Application Identifier\n- Service Entrypoint(s)\n- Client Engine Version\n- Other Information\n\n```shell\n> starrail details\n```\n```\nTitle                   Details (binary)\n----------------------  --------------------------------------------------------\nVersion                 V2.3Live\nDatetime String         20240607-2202\nDetailed Version        7x02406xx-2xx2-V2.3Live-7xxxxx5-CNPRODWin2.3.0-CnLive-v2\nApplication Identifier  com.miHoYo.hkrpg\nService Endpoints       https://globaldp-prod-cn0x.bhsr.com/query_dispatch\nEngine Version          EngineReleaseV2.3\nOther                   StartAsset\n                        StartDesignData\n                        dxxxxxxxxb\n                        f4xxxxxxxxxxxxxxxxxxxxxxa7\n```\n\n<br/>\n\n***\n\n<br/>\n\n\n## 4. Simple Automation\nThe `starrail` package supports two automation features to assist with the gameplay of Honkai: Star Rail.\n\n### ☆ Custom Automation\nThis 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.\n\nTo start, enter the following to bring up the help panel for automation:\n```shell\n> starrail automation\n```\n\n```\nExample Command             Description\n--------------------------  ----------------------------------------------------\nstarrail automation record  Create and record a new automation sequence (macros)\nstarrail automation show    List all recorded automation sequences\nstarrail automation run     Run a recorded automation sequence\nstarrail automation remove  Delete a recorded automation sequence\nstarrail automation clear   Delete all recorded automation sequences\n```\n> [!NOTE]\n> **For a full documentation / guide on custom automation**, visit [this page](https://github.com/ReZeroE/StarRail/wiki/6.-Simple-Automation). \n\n\n<br/>\n\n\n### ☆ Uniform Clicks\nThis feature assists with repetitive mouse clicking (such as for \"Start Again\" after completing a stage in HSR).\n\nTo use this feature, run:\n```shell\n> starrail click\n```\n\n<br/>\n\n**For example**, to control the mouse to click:\n1. Once every 5 seconds and\n2. Hold for 2 seconds each click\n```shell\n> starrail click --interval 5 --randomize 1 --hold 2\n```\n\n<br/>\n\n***\n\n<br/>\n\n\n## 5. Official Pages\n\nThe `starrail` package supports the following simple commands to access Honkai: Star Rail's official pages.\n\n### ☆ Offical Homepage\nTo start Honkai: Star Rail's Official Home Page, run:\n```shell\n> starrail homepage\nOR\n> starrail homepage -cn # CN Homepage\n```\n\n\n<br/>\n\n### ☆ Official HoyoLab Page\nTo start Honkai: Star Rail's Official HoyoLab Page, run:\n```shell\n> starrail hoyolab\n```\n\n<br/>\n\n### ☆ Offical Youtube Page\nTo start Honkai: Star Rail's Official Youtube Page, run:\n```shell\n> starrail youtube\n```\n\n<br/>\n\n### ☆ Offical BiliBili Page (CN)\nTo start Honkai: Star Rail's Official BiliBili Page, run:\n```shell\n> starrail bilibili\n```\n\n<br/>\n\n***\n\n<br/>\n\n## 6. Binary Utilities\n\nThe `starrail` package supports a list of binary-related utility commands for web cache and streaming assets decoding.\n\n### ☆ Web Cache\nHonkai: 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:\n```shell\n> starrail webcache\n```\nThe results will be listed in two sections:\n1. Events/Pulls Web Cache\n2. Announcements Web Cache\n\n\n<br/>\n\n### ☆ Cached Pulls\nTo access your Honkai: Star Rail's cached pulls information without logging in to the game, run:\n```shell\n> starrail pulls\n```\nWeb view of all the pull information will open in the default browser.\n\n<br/>\n\n### ☆ Supplement Binary Decoder\nThe package provides this supplementary tools to decode any ASCII-based binary file.\n```shell\n> starrail decode --path <path_to_file>\n```\nAll ASCII-based information will be outputted into a table with index listings as following:\n```\n  Index  Content\n-------  --------------\n      0  This is a test\n      1  /Root xxxx 0 R\n      2  /Info 1 0 R>>\n      3  start ref\n    ...  .....\n```\n\n<br/>\n\n***\n\n<br/>\n\n\n## 7. Misc Utilities\n\nThe `starrail` package provides the following quality-of-life utility features to quickly access key game information.\n\n### ☆ Access Screenshots\nTo access the screenshots without the client or searching through the directory, run:\n```shell\n> starrail screenshots\n```\n\n<br/>\n\n### ☆ Access Game Logs\nTo access the game's log files, run:\n```shell\n> starrail game-logs\n```\n\n<br/>\n\n\n### ☆ Session runtime\nTo get the runtime of the current Honkai: Star Rail session (how long the game has been running since it started), run:\n```shell\n> starrail runtime\n```\n\n<br/>\n\n***\n\n<br/>\n\n## Disclaimer\n\nThe \"starrail\" Python 3 module is an external CLI tool\ndesigned to automate the gameplay of Honkai Star Rail. It is designed\nsolely interacts with the game through the existing user interface,\nand it abides by the Fair Gaming Declaration set forth by COGNOSPHERE\nPTE. LTD. The package is designed to provide a streamlined and\nefficient way for users to interact with the game through features\nalready provided within the game, and it does not, in any way, intend \nto damage the balance of the game or provide any unfair advantages. \nThe package does NOT modify any files in any way.\n\nThe creator(s) of this package has no relationship with MiHoYo, the\ngame's developer. The use of this package is entirely at the user's\nown risk, and the creator accepts no responsibility for any damage or\nloss caused by the package's use. It is the user's responsibility to\nensure that they use the package according to Honkai Star Rail's Fair\nGaming Declaration, and the creator accepts no responsibility for any\nconsequences resulting from its misuse, including game account\npenalties, suspension, or bans.\n\nBy using this package, the user agrees to ALL terms and conditions\nand acknowledges that the creator will not be held liable for any\nnegative outcomes that may occur as a result of its use.\n\n\n<br/>\n\n***\n\n<br/>\n\n## Repository Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=ReZeroE/StarRail&type=Date)](https://star-history.com/#ReZeroE/StarRail&Date)\n\n\n<br/>\n\n***\n\n<br/>\n\n## License\n\n<img src=\"https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/MIT_logo.svg/220px-MIT_logo.svg.png\" align=\"left\" width=\"150\"/>\n\n<ul>\n - MIT Licensed\n</ul>\n\n<br clear=\"left\"/>\n\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\n    \"setuptools>=42\",\n    \"tabulate\",\n    \"termcolor\",\n    \"pytest\",\n    \"psutil\",\n    \"pynput\",\n    \"pyautogui\",\n    \"wheel\"\n]\nbuild-backend = \"setuptools.build_meta\""
  },
  {
    "path": "requirements.txt",
    "content": "tabulate\ntermcolor\npytest\npsutil\npynput\npyautogui\npywin32\nscreeninfo\nschedule\npyreadline3"
  },
  {
    "path": "setup.cfg",
    "content": "[metadata]\nname = starrail\nversion = 1.0.5\nauthor = Kevin L.\nauthor_email = kevinliu@vt.edu\ndescription = Honkai: Star Rail Command Line Tool (CLI)\nlong_description = file: README.md\nlong_description_content_type = text/markdown\nlicense_files = LICENSE\nurl = https://github.com/ReZeroE/StarRail\nproject_urls =\n    Bug Tracker = https://github.com/ReZeroE/StarRail/issues\nclassifiers =\n    Programming Language :: Python :: 3\n    License :: OSI Approved :: MIT License\n    Operating System :: OS Independent\n    Development Status :: 1 - Planning\n\n[options]\npackage_dir =\n    = src\npackages = find:\npython_requires = >=3.7\ninstall_requires = file: requirements.txt\ninclude_package_data = True\nlicense = MIT\n\n[options.packages.find]\npackages = starrail\nwhere = src\n\n[options.entry_points]\nconsole_scripts =\n    starrail = starrail.entrypoints.entrypoints:start_starrail"
  },
  {
    "path": "setup.py",
    "content": "import os\nfrom setuptools import setup, find_packages\n\nsetup(\n    name=\"starrail\",\n    version=\"1.0.5\",\n    author=\"Kevin L.\",\n    author_email=\"kevinliu@vt.edu\",\n    description=\"Honkai: Star Rail Command Line Tool (CLI)\",\n    long_description=open(\"README.md\").read(),\n    long_description_content_type=\"text/markdown\",\n    url=\"https://github.com/ReZeroE/StarRail\",\n    project_urls={\n        \"Bug Tracker\": \"https://github.com/ReZeroE/StarRail/issues\",\n    },\n    classifiers=[\n        \"Programming Language :: Python :: 3\",\n        \"License :: OSI Approved :: MIT License\",\n        \"Operating System :: OS Independent\",\n        \"Development Status :: 1 - Planning\",\n    ],\n    license=\"MIT\",\n    package_dir={\"\": \"src\"},\n    packages=find_packages(where=\"src\"),\n    python_requires=\">=3.7\",\n    install_requires=open(os.path.join(os.path.dirname(os.path.abspath(__file__)), \"requirements.txt\")).read().splitlines(),\n    include_package_data=True,\n    entry_points={\n        \"console_scripts\": [\n            \"starrail = starrail.entrypoints.entrypoints:start_starrail\",\n        ],\n    },\n)\n"
  },
  {
    "path": "src/starrail/__init__.py",
    "content": "\"\"\"\nStarRail\n=====\nHonkai: Star Rail CLI Toolkit\n\"\"\"\n\n# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nfrom starrail.constants import AUTHOR, VERSION, COMMAND, GAME_NAME\n\n__author__  = AUTHOR\n__version__ = VERSION\n__support__ = GAME_NAME\n__all__     = [COMMAND]"
  },
  {
    "path": "src/starrail/automation/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/automation/config/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/automation/config/automation_config_handler.py",
    "content": "from starrail.utils.utils import *\nfrom starrail.utils.json_handler import JSONConfigHandler\n\nclass StarRailAutomationConfig:\n    def __init__(self):\n        self.automation_data_dir =  os.path.join(os.path.abspath(os.path.dirname(__file__)), f\"automation-data\")\n        if not os.path.isdir(self.automation_data_dir):\n            os.mkdir(self.automation_data_dir)  \n    \n    def load_all_automations(self):\n        sequence_name_list = os.listdir(self.automation_data_dir)\n        \n        raw_json_configs = []\n        for seq_filename in sequence_name_list:\n            AUTOMATION_FILE = os.path.join(self.automation_data_dir, seq_filename)\n            config_handler = JSONConfigHandler(AUTOMATION_FILE)\n            if config_handler.CONFIG_EXISTS():\n                raw_json_config = config_handler.LOAD_CONFIG() # Loads the sequence config file\n                raw_json_configs.append(raw_json_config)\n        return raw_json_configs\n\n    def load_automation(self, automation_name):\n        config_handler = JSONConfigHandler(os.path.join(self.automation_data_dir, f\"automation-{automation_name}.json\"))\n        return config_handler.LOAD_CONFIG()\n    \n    def save_automation(self, automation_name, payload):\n        config_handler = JSONConfigHandler(os.path.join(self.automation_data_dir, f\"automation-{automation_name}.json\"))\n        return config_handler.SAVE_CONFIG(payload)\n    \n    def delete_automation(self, automation_name):\n        config_handler = JSONConfigHandler(os.path.join(self.automation_data_dir, f\"automation-{automation_name}.json\"))\n        if config_handler.CONFIG_EXISTS():\n            return config_handler.DELETE_CONFIG()\n        return False"
  },
  {
    "path": "src/starrail/automation/pixel_calculator/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/automation/pixel_calculator/pixel_calculator.py",
    "content": "from starrail.utils.utils import aprint, LogType\nfrom starrail.automation.pixel_calculator.resolution_detector import ResolutionDetector\n\nclass PixelCalculator:\n    def __init__(self, monitor_info: dict):\n        self.prev_monitor_width = monitor_info[\"width\"]\n        self.prev_monitor_height = monitor_info[\"height\"]\n    \n        current_monitor_info = ResolutionDetector.get_primary_monitor_size()\n        self.current_monitor_width = current_monitor_info[\"width\"]\n        self.current_monitor_height = current_monitor_info[\"height\"]\n    \n    \n    @staticmethod\n    def transform_coordinate(prev_coor: tuple, prev_window_info: dict):\n        try:\n            x, y = prev_coor\n            \n            curr_window_info = ResolutionDetector.get_foreground_window_size()\n            curr_x = curr_window_info[\"left\"]\n            curr_y = curr_window_info[\"top\"]\n            curr_w = curr_window_info[\"width\"]\n            curr_h = curr_window_info[\"height\"]\n            \n            # print(curr_x, curr_y, curr_w, curr_h)\n            \n            prev_x = prev_window_info[\"left\"]\n            prev_y = prev_window_info[\"top\"]\n            prev_w = prev_window_info[\"width\"]\n            prev_h = prev_window_info[\"height\"]\n            \n            # Normalize the coordinate relative to the original window\n            normalized_x = (x - prev_x) / prev_w\n            normalized_y = (y - prev_y) / prev_h\n\n            new_x = curr_x + normalized_x * curr_w\n            new_y = curr_y + normalized_y * curr_h\n\n            return (int(new_x), int(new_y))\n    \n        except KeyError:\n            # Unavailable for pixel calculator (ver 0.0.2)\n            return prev_coor\n"
  },
  {
    "path": "src/starrail/automation/pixel_calculator/resolution_detector.py",
    "content": "import time\nimport win32process\nimport pygetwindow\nfrom ctypes import windll\nfrom screeninfo import get_monitors\nfrom win32gui import GetWindowRect, GetForegroundWindow\n\nfrom starrail.exceptions.exceptions import *\nfrom starrail.utils.process_handler import ProcessHandler\n\n\nclass ResolutionDetector:\n    windll.user32.SetProcessDPIAware()\n    \n    @staticmethod\n    def get_primary_monitor_size() -> dict:\n        monitors = get_monitors()\n        for m in monitors:\n            if m.is_primary:\n                monitor_info = {\n                    \"width\": m.width,\n                    \"height\": m.height\n                }\n                return monitor_info\n        return None\n\n    @staticmethod\n    def get_foreground_window_size():\n        window_size = GetWindowRect(GetForegroundWindow())\n        monitor_size = ResolutionDetector.get_primary_monitor_size()\n        return {\n            'left'          : window_size[0],\n            'top'           : window_size[1],\n            'width'         : window_size[2],\n            'height'        : window_size[3],\n            'is_fullscreen' : window_size[2] == monitor_size[\"width\"] and window_size[2] == monitor_size[\"height\"]\n        }\n\n    @staticmethod\n    def get_window_size():\n        pid = ProcessHandler.get_focused_pid()\n        # print(f\"Currently focused PID: {pid}\")\n        if pid == None: return None\n        \n        win_info: dict = ResolutionDetector.get_window_info(pid)\n        if win_info == None:\n            raise Exception(f\"Failed to fetch window size. No results returned (PID {pid}).\")\n        \n        monitor_info = ResolutionDetector.get_primary_monitor_size()\n        \n        if  monitor_info[\"width\"] == win_info[\"width\"] and \\\n            monitor_info[\"height\"] == win_info[\"height\"] and \\\n            win_info[\"top\"] == 0 and win_info[\"left\"] == 0:\n            \n            win_info[\"is_fullscreen\"] = True\n        else:\n            win_info[\"is_fullscreen\"] = False\n            \n        return win_info\n\n\n    @staticmethod\n    def get_window_info(pid, retry: int = 5):\n        for _ in range(retry):\n            windows = pygetwindow.getWindowsWithTitle('')  # Get all windows\n            for window in windows:\n                _, window_pid = win32process.GetWindowThreadProcessId(window._hWnd)\n                if window_pid == pid:\n                    if (window.left >= 0 and window.top >= 0) and (window.width > 0 and window.height > 0):\n                        return {\n                            'left'          : window.left,\n                            'top'           : window.top,\n                            'width'         : window.width,\n                            'height'        : window.height,\n                            'is_fullscreen' : None\n                        }\n            time.sleep(1)\n        return None\n        \n    \n    \n"
  },
  {
    "path": "src/starrail/automation/recorder.py",
    "content": "import time\nimport threading\nimport tkinter as tk\nimport asyncio\nfrom pynput import mouse, keyboard\n\nfrom starrail.constants import RECORDER_WINDOW_INFO, CALIBRATION_MONITOR_INFO\nfrom starrail.automation.units.action import Action, MouseAction, KeyboardAction, ScrollAction\nfrom starrail.automation.units.sequence import AutomationSequence\nfrom starrail.automation.pixel_calculator.resolution_detector import ResolutionDetector\n\nfrom starrail.utils.utils import *\nfrom starrail.exceptions.exceptions import *\nfrom starrail.controllers.star_rail_app import HonkaiStarRail\n\n\n'''\nMouseAction\n    1. Action delay\nScrollAction\n    1. Action delay\nKeyboardAction:\n    1. Action delay\n    2. Hold time (0.9.0)\n'''\n\nclass AutomationRecorder():\n    def __init__(self, sequence: AutomationSequence, starrail_instance: HonkaiStarRail):\n        self.sequence = sequence\n        self.starrail = starrail_instance\n        \n        self.label              = None\n        self.prev_action_time   = None\n        self.is_recording       = False\n        self.stop_event         = threading.Event()\n\n        self.keyboard_key      = None\n        self.keyboard_press_time = None\n    \n    \n    def create_indicator_window(self):\n        LENGTH, WIDTH, RADIUS = self.__scale_window(RECORDER_WINDOW_INFO['height'], RECORDER_WINDOW_INFO['width'], RECORDER_WINDOW_INFO[\"border-radius\"])\n        \n        root = tk.Tk()\n        root.title(\"Waiting for game to be focused...\")\n        root.geometry(f\"{WIDTH}x{LENGTH}+0+0\")\n        root.overrideredirect(True)  # Remove window decorations\n\n        root.attributes('-topmost', True) # STAY ON TOOOOOPPPPPP\n\n        # Set the overall background color to black and then make it transparent\n        background_color = 'black'\n        root.configure(bg=background_color)\n        root.attributes('-transparentcolor', background_color)\n\n        canvas = tk.Canvas(root, bg=background_color, highlightthickness=0)\n        canvas.pack(fill=tk.BOTH, expand=True)\n\n        # Replace 'black' with the color of your choice for the rounded rectangle\n        canvas_color = '#333333'\n        canvas.create_polygon(\n            [\n                RADIUS,             0,                  WIDTH - RADIUS,     0, \n                WIDTH,              RADIUS,             WIDTH,              LENGTH - RADIUS, \n                WIDTH - RADIUS,     LENGTH,             RADIUS,             LENGTH, \n                0,                  LENGTH - RADIUS,    0,                  RADIUS\n            ],\n            smooth=True, fill=canvas_color)\n\n        self.label = tk.Label(canvas, text=\"Waiting for game to be focused...\", font=('Helvetica', 9), fg='#FFFFFF', bg=canvas_color)\n        self.label.place(relx=0.4, rely=0.5, anchor='center')\n\n        button_width = 120\n        button_height = 70\n        button_width, button_height, _ = self.__scale_window(button_width, button_height)\n        stop_button = tk.Button(canvas, text='Stop\\nRecording', font=('Helvetica', 10), command=lambda: self.stop_recording(root), bg='#14628c', fg='#FFFFFF')\n        stop_button.place(relx=0.85, rely=0.5, anchor='center', width=button_width, height=button_height)\n    \n        # Mouse movement handling\n        def on_press(event):\n            root._drag_start_x = event.x\n            root._drag_start_y = event.y\n\n        def on_drag(event):\n            dx = event.x - root._drag_start_x\n            dy = event.y - root._drag_start_y\n            x = root.winfo_x() + dx\n            y = root.winfo_y() + dy\n            root.geometry(f\"+{x}+{y}\")\n\n        root.bind('<Button-1>', on_press)\n        root.bind('<B1-Motion>', on_drag)\n\n        self.label.config(text=f\"Waiting for game to be focused...\")\n\n        root.mainloop()\n    \n    \n    # =============================================\n    # ===============| BASE DRIVER | ==============\n    # =============================================\n    \n    def stop_recording(self, root: tk.Tk):\n        self.stop_event.set()\n        self.is_recording = False\n        self.sequence.actions = self.sequence.actions[:-1]    # Remove the last key click (user clicks on the Stop Recording button)\n        root.quit()\n        \n    def pause_or_resume_recording(self):\n        self.is_recording = not self.is_recording\n        \n    def record(self, start_on_callback=False) -> AutomationSequence:\n        threading.Thread(target=self.create_indicator_window, daemon=True).start()\n        \n        if start_on_callback:\n            self.is_recording = True\n            self.prev_action_time = None\n            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.\")\n        else:\n            raise Exception(\"Record must start on callback. Other case not implemented.\")\n        \n        # mouse_listener = mouse.Listener(on_click=self.__on_mouse_action)\n        # keyboard_listener = keyboard.Listener(on_press=self.__on_keyboard_action)\n        \n        with mouse.Listener(on_click=self.__on_mouse_action, on_scroll=self.__on_scroll_action) as mouse_listener, \\\n            keyboard.Listener(on_press=self.__on_keyboard_action_press, on_release=self.__on_keyboard_action_release) as keyboard_listener:\n            \n            mouse_listener_thread     = threading.Thread(target=mouse_listener.join)\n            keyboard_listener_thread  = threading.Thread(target=keyboard_listener.join)\n            \n            mouse_listener_thread.start()\n            keyboard_listener_thread.start()\n\n            # Wait until stop recording event is triggered\n            self.stop_event.wait()\n\n            # Stop listeners\n            mouse_listener.stop()\n            keyboard_listener.stop()\n\n            # Wait for listeners to finish\n            mouse_listener_thread.join()\n            keyboard_listener_thread.join()\n\n        time.sleep(0.3)\n        return self.sequence\n    \n    \n    # =============================================\n    # ============| ON-ACTION DRIVER | ============\n    # =============================================\n    \n    def __on_mouse_action(self, x, y, button, pressed):\n        '''\n        On mouse action is executed twice:\n            1. when the mouse is pressed\n            2. when the mouse is released\n            \n        The application is only focused on-release, therefore we need to ignore the on-press action.\n        As of version 0.9.0, hold-time has not been implemented.\n        '''\n        if pressed or not self.starrail.is_focused():\n            return\n        \n        if self.is_recording:\n            now = time.time()\n            delay = now - self.prev_action_time if self.prev_action_time else float(0)\n            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.\n            self.sequence.add(MouseAction((x, y), delay, clicked, ResolutionDetector.get_foreground_window_size()))\n            self.prev_action_time = now\n            self.on_mouse_action_update_window(x, y, delay)\n\n\n    def __on_scroll_action(self, x, y, dx, dy):\n        if not self.starrail.is_focused():\n            return\n        \n        if self.is_recording:\n            now = time.time()\n            delay = now - self.prev_action_time if self.prev_action_time else float(0)\n            self.sequence.add(ScrollAction((x, y), dx, dy, delay, ResolutionDetector.get_foreground_window_size()))\n            self.prev_action_time = now\n            self.on_scroll_action_update_window(x, y, dx, dy, delay)\n\n\n    '''\n    Key hold_time has been implemented as of 0.9.0 and there are a few key notes.\n        1. Only one key will be recorded at a time. No key or key-mouse combination is supported.\n        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.\n    '''\n    def __on_keyboard_action_press(self, key):\n        # 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).\n        \n        # If the current key is the same as the previous key, continue.\n        if key == self.keyboard_key:\n            pass\n        # If there is already an press-regeistered key but it's not the current key (a new key is pressed while the\n        # previous key has not been released), do not register this new key.\n        elif self.keyboard_key != None and key != self.keyboard_key:\n            pass\n        # New key pressed.\n        else:\n            self.keyboard_key = key\n            self.keyboard_press_time = time.time()\n\n    def __on_keyboard_action_release(self, key):\n        # If key released is not the press-registered key, then ignore this key\n        if key != self.keyboard_key:\n            return\n        \n        # If the app is unfocused, reset the press-registered key and time\n        if not self.starrail.is_focused():\n            self.keyboard_key = None\n            self.keyboard_press_time = None\n            return\n        \n        # Pause/resume listener on Enter key press\n        elif key == keyboard.Key.space:\n            self.keyboard_key = None\n            self.keyboard_press_time = None\n            self.pause_or_resume_recording()\n            return  # Return to not record this space key press\n\n        # Record only if is_recording is set to True\n        if self.is_recording:\n            now         = time.time()\n            hold_time   = now - self.keyboard_press_time\n            delay       = now - self.prev_action_time - hold_time if self.prev_action_time else float(0)\n            \n            self.sequence.add(KeyboardAction(key, delay, hold_time))\n            self.prev_action_time = now\n            self.keyboard_key = None\n            self.keyboard_press_time = None\n            self.on_keyboard_action_update_window(key, delay, hold_time)\n\n\n\n\n    # =============================================\n    # ============| UPDATE UI WINDOW | ============\n    # =============================================\n\n    def on_mouse_action_update_window(self, x, y, delay):\n        if self.label:\n            self.__update_label(text=f\"Mouse Click Detected - ({x}, {y})\\nDelay: {round(delay, 2)}\")\n            \n    def on_scroll_action_update_window(self, x, y, dx, dy, delay):\n        if self.label:\n            if dy > 0:\n                self.__update_label(text=f\"Scroll Detected - ({x}, {y}) - Scrolled UP\\nDelay: {round(delay, 2)}\")\n            elif dy < 0:\n                self.__update_label(text=f\"Scroll Detected - ({x}, {y}) - Scrolled DOWN\\nDelay: {round(delay, 2)}\")\n            else:\n                self.__update_label(text=\"Scroll Detected - Horizontal scroll not supported.\")\n\n    def on_keyboard_action_update_window(self, key, delay, hold_time):\n        if self.label:\n            try:\n                self.__update_label(text=f\"Key Detected - '{key.char}'\\nDelay: {round(delay, 2)}, Hold: {round(hold_time, 2)}\")\n            except AttributeError:\n                self.__update_label(text=f\"Key Detected - '{key}'\\nDelay: {round(delay, 2)}, Hold: {round(hold_time, 2)}\")\n    \n    def on_pause_action_update_window(self):\n        if self.is_recording:\n            self.__update_label(text=f\"Recording resumed...\")\n        else:\n            self.__update_label(text=f\"Recording paused...\")\n            \n\n    def __update_label(self, text: str):\n        self.label.place(relx=0.4, rely=0.6, anchor='center')\n        self.label.config(text=f\"Total Actions Recorded: {len(self.sequence.actions)}\\n{text}\\n\")\n\n    def __scale_window(self, standard_width, standard_height, standard_radius=None):\n        monitor_info = ResolutionDetector.get_primary_monitor_size()\n        width_ratio  = monitor_info[\"width\"]  / CALIBRATION_MONITOR_INFO[\"width\"]\n        height_ratio = monitor_info[\"height\"] / CALIBRATION_MONITOR_INFO[\"height\"]\n        \n        if standard_radius != None:\n            radius = standard_radius * ((width_ratio+height_ratio)/2)\n        else:\n            radius = 0\n        \n        return int(standard_width*width_ratio), int(standard_height*height_ratio), int(radius)"
  },
  {
    "path": "src/starrail/automation/units/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/automation/units/action.py",
    "content": "\nimport re\nimport time\nimport pyautogui\nimport pynput\nfrom pynput import keyboard\nfrom pynput.keyboard import Key\nfrom abc import ABC, abstractclassmethod\nfrom starrail.constants import PYNPUT_KEY_MAPPING\n'''\nAction: A single mouse of keyboard action.\nSequence: A list/sequence of Action objects.\n'''\n\n\nclass Action(ABC):\n    @abstractclassmethod\n    def __init__(self, *args):\n        self.delay = ...\n        from pynput.keyboard import Key\n    \n    @abstractclassmethod\n    def __repr__(self):\n        pass\n    \n    @abstractclassmethod\n    def execute(self, *args):\n        pass\n    \n    @abstractclassmethod\n    def to_json(self):\n        pass\n\n\nclass MouseAction(Action):\n    def __init__(self, coor: tuple, delay: float, click: bool, window_info: dict):\n        self.coordinate     = coor\n        self.delay          = delay\n        self.click          = click\n        self.window_info    = window_info\n        \n        self.is_valid_for_pixel_calc = self.__is_valid_for_pixel_calc()\n \n    def execute(self):\n        '''\n        The delay represent the time lag between the current click and the previous click,\n        therefore time.sleep() is executed at the start of a new action.\n        '''\n        \n        x = self.coordinate[0]\n        y = self.coordinate[1]\n        pyautogui.moveTo(x, y, duration=0.1)\n        \n        if self.click:\n            # pyautogui.click(interval=0.1)\n            \n            pyautogui.mouseDown()\n            time.sleep(0.1)  \n            pyautogui.mouseUp()\n    \n    def to_json(self):\n        return {\n            \"coordinate\": {\n                \"x\": self.coordinate[0],\n                \"y\": self.coordinate[1]\n            },\n            \"delay\": self.delay,\n            \"click\": self.click,\n            \"window_info\": self.window_info\n        }\n        \n    def __repr__(self):\n        return f\"MouseAction(coor={self.coordinate}, delay={round(self.delay, 2)}, click={self.click})\"\n\n    def __is_valid_for_pixel_calc(self):\n        try:\n            assert(\"width\" in self.window_info)\n            assert(\"height\" in self.window_info)\n            assert(\"top\" in self.window_info)\n            assert(\"left\" in self.window_info)\n            assert(\"is_fullscreen\" in self.window_info)\n            return True\n        except AssertionError:\n            return False\n\n\nclass ScrollAction(Action):\n    def __init__(self, coor: tuple, dx: float, dy: float, delay: float, window_info: dict):\n        self.coordinate     = coor\n        self.delay          = delay\n        self.dx             = dx\n        self.dy             = dy\n        self.window_info    = window_info\n        \n        self.is_valid_for_pixel_calc = self.__is_valid_for_pixel_calc()\n \n    def execute(self):\n        '''\n        Only dy scrolling is supported as of version 0.9\n        '''\n        x = self.coordinate[0]\n        y = self.coordinate[1]\n        pyautogui.moveTo(x, y, duration=0.1)\n        time.sleep(0.05)\n        pyautogui.scroll(self.dy)\n    \n    def to_json(self):\n        return {\n            \"coordinate\": {\n                \"x\": self.coordinate[0],\n                \"y\": self.coordinate[1]\n            },\n            \"scroll\": {\n                \"dx\": self.dx,\n                \"dy\": self.dy\n            },\n            \"delay\": self.delay,\n            \"window_info\": self.window_info\n        }\n        \n    def __repr__(self):\n        return f\"ScrollAction(coor={self.coordinate}, scroll=(dx={self.dx}, dy={self.dy}) delay={round(self.delay, 2)}\"\n\n    def __is_valid_for_pixel_calc(self):\n        try:\n            assert(\"width\" in self.window_info)\n            assert(\"height\" in self.window_info)\n            assert(\"top\" in self.window_info)\n            assert(\"left\" in self.window_info)\n            assert(\"is_fullscreen\" in self.window_info)\n            return True\n        except AssertionError:\n            return False\n \n\nclass KeyboardAction(Action):\n    def __init__(self, key: str, delay: float, hold_time: float):\n        self.key = self.reformat_key(key)\n        self.delay = delay\n        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)\n    \n    def execute(self, keyboard):\n        '''\n        The delay represent the time lag between the current click and the previous click,\n        therefore time.sleep() is executed at the start of a new action.\n        '''\n        \n        self.press_key(self.key, keyboard)\n    \n    def to_json(self):\n        return {\n            \"key\": self.key,\n            \"delay\": self.delay,\n            \"hold_time\": self.hold_time\n        }\n        \n    def __repr__(self):\n        return f\"KeyboardAction(key={self.key}, delay={round(self.delay, 2)}, hold_time={round(self.hold_time, 2)})\"\n\n    def press_key(self, key: str, pynput_keyboard: pynput.keyboard.Controller):\n        '''\n        This is going to be a little difficult to explain, but essentially the string format keys\n        recorded by pynput can't be re-recognized by pynput for execution. Therefore, a mapping\n        between the string formated keys (collected by pynput itself) and the actual Key object\n        is used to convert the key when trying to execute the keyboard action.\n        \n        There are going to be a wide range of keys that aren't supported including \"hotkeys\" or\n        a combination of two or more different keys. This will need to be specified in the \n        ongoing documentation.\n        '''\n        # TODO: find a better way to implement this, if possible\n        pynput_key = PYNPUT_KEY_MAPPING.get(key, key)\n        try:\n            pynput_keyboard.press(pynput_key)\n            time.sleep(self.hold_time)\n            pynput_keyboard.release(pynput_key)\n        except (keyboard.Controller.InvalidKeyException, ValueError) as ex:\n            # TODO: log this unsupported key as opposed to print it\n            # print(f\"Unsupported key: <{key}>\")\n            pass\n        \n    def reformat_key(self, key: keyboard.Key) -> str:\n        key: str = str(key).strip().replace(\"'\", \"\")\n        \n        removing = [\"_r\", \"_gr\", \"_l\"]\n        for suffix in removing:\n            if key.endswith(suffix):\n                key = key.replace(suffix, \"\")\n\n        return key\n    "
  },
  {
    "path": "src/starrail/automation/units/sequence.py",
    "content": "import os\nimport time\nimport copy\nfrom datetime import datetime\nfrom pynput import keyboard\nfrom starrail.automation.units.action import Action, MouseAction, ScrollAction, KeyboardAction\nfrom starrail.exceptions.exceptions import SRExit\nfrom starrail.constants import VERSION\nfrom starrail.utils.utils import *\nfrom starrail.automation.pixel_calculator.resolution_detector import ResolutionDetector\nfrom starrail.automation.pixel_calculator.pixel_calculator import PixelCalculator\n\nSUBMODULE_NAME = \"AUTO\"\n\nclass AutomationSequence:\n    def __init__(\n        self,\n        sequence_name: str\n    ):\n        self.sequence_name                  = sequence_name                 # Default to None (populated during some __init__() and parse_json())\n        self.date_created: datetime         = None                          # Default to None (and set at to_json() and parse_json())\n        self.primary_monitor_info: dict     = None                          # Fetch PRIMARY monitor's size (width x height)\n        self.other_data                     = None\n        self.actions: list[Action]          = []\n        \n        self.global_delay                   = 0\n    \n    \n    def __progress_bar(self, actions: list, prefix=\"\", size=40, out=sys.stdout):\n        count = len(actions)\n        start = time.time() # time estimate start\n        total_time = self.get_runtime()\n        \n        \n        def show(j, delay, remaining_time_str):\n            x = int(size*j/count)   \n            aprint(f\"{prefix}|{u'█'*x}{(' '*(size-x))}| {int(j)}/{count}  -  Remaining: {remaining_time_str}\", end='\\r', file=out, flush=True) \n        \n        def secs_to_str(secs):\n            mins, sec = divmod(secs, 60)\n            time_str = f\"{int(mins)} mins {round(sec, 2)} secs\"\n            return time_str\n        \n        show(0.1, delay=actions[0].delay, remaining_time_str=secs_to_str(total_time)) # avoid div/0 \n        for i, action in enumerate(actions):\n            yield action\n            \n            total_time -= action.delay\n            show(i+1, action.delay, secs_to_str(total_time))\n            \n        print(\"\", flush=True, file=out)\n    \n    # def normalize(self):\n    #     '''\n    #     Introduced in version 0.9.0, the automation will be self-normalized to merge hold actions.\n    #     '''\n    #     NORMALIZE_THRESHOLD = 0.05\n    #     actions_copy = copy.deepcopy(self.actions)\n        \n    #     prev_action = None\n    #     for idx, action in enumerate(self.actions):\n    #         if isinstance(action, KeyboardAction) and isinstance(prev_action, KeyboardAction):\n    #             if action[\"delay\"]\n                    \n                \n    \n    def execute(self):\n    \n        def verbose_action(idx: int, action: Action):\n            buffer_space = \" \"*5                                # Verbose current action\n            # aprint(f\"(CMD {idx+1}/{len(self.actions)}) Executing: {action.__repr__()}{buffer_space}\", end=\"\\r\")\n            aprint(f\"[Action {idx}/{len(self.actions)}] Running... \", submodule_name=SUBMODULE_NAME, end=\"\\r\")\n        \n        def verbose_warning():\n            # Verbose warning if the current action isn't suited for the pixel calculator developed in ver0.0.2+\n            for action in self.actions:\n                if isinstance(action, MouseAction) and action.is_valid_for_pixel_calc == False:\n                    aprint(f\"This automation sequence is not available for pixel calculator in version {VERSION}.\")\n                    return\n        \n\n        # Becuase the pixel calculator will directory modify the MouseAction's coordinates, we need a way to reset the \n        # sequence's coordinates after the sequence finishes running. Therefore, we first make a copy of the sequence\n        # before it is modified by the pixel calculator and then replace the modified sequence at the end.\n        actions_copy = copy.deepcopy(self.actions)\n        \n        verbose_warning()\n        pynput_keyboard = keyboard.Controller()\n        \n        # for idx, action in enumerate(self.__progress_bar(self.actions, f\"Running: \", 40)):\n            \n        for idx, action in enumerate(self.actions):\n            verbose_action(idx, action)\n            \n            time.sleep(action.delay)                            # Execute current action after standard action delay\n            time.sleep(self.global_delay)                       # Execute current action after global delay\n\n            # ====================================\n            # ===========| KEYBOARD | ============\n            # ====================================\n            if isinstance(action, KeyboardAction):\n                action.execute(pynput_keyboard)\n                \n            # ==================================\n            # ============| MOUSE | ============\n            # ==================================\n            elif isinstance(action, MouseAction):\n                \n                if action.is_valid_for_pixel_calc == True:\n                    try:\n                        new_coord = PixelCalculator.transform_coordinate(action.coordinate, action.window_info)\n                        action.coordinate = new_coord\n                    except Exception as ex:\n                        # TODO: log this error\n                        # aprint(Printer.to_lightgrey(f\"Warning: PixelCalc not working ({ex})\"))\n                        pass\n                \n                action.execute()\n                \n            # ==================================\n            # ===========| SCROLL | ============\n            # ==================================\n            elif isinstance(action, ScrollAction):\n                action.execute()\n            \n        # Reset the modified version of the sequence (modified by the pixel calculator)\n        self.actions = actions_copy\n\n    def add(self, action: Action):\n        assert(isinstance(action, Action))\n        self.actions.append(action)\n\n    def to_json(self):\n        json_data = dict()\n        json_data[\"metadata\"] = {\n            \"sequence_name\" : self.sequence_name,\n            \"date_created\"  : DatetimeHandler.datetime_to_str(self.date_created),\n            \"monitor_info\"  : ResolutionDetector.get_primary_monitor_size(),\n            \"other_data\"    : self.other_data\n        }\n        json_data[\"actions_sequence\"] = [action.to_json() for action in self.actions]\n        return json_data\n\n    @staticmethod\n    def parse_config(raw_json_config: list):\n        metadata        = raw_json_config[\"metadata\"]\n        sequence_name   = metadata[\"sequence_name\"]\n        \n        sequence = AutomationSequence(sequence_name)\n        \n        sequence.date_created           = DatetimeHandler.str_to_datetime(metadata[\"date_created\"])\n        sequence.primary_monitor_info   = metadata[\"monitor_info\"]\n        sequence.other_data             = metadata[\"other_data\"]\n        \n        actions_sequence = raw_json_config[\"actions_sequence\"]\n        for raw_action in actions_sequence:\n            \n            # MOUSE ACTION ================================================\n            if \"click\" in raw_action:\n                mouse_action = MouseAction(\n                    (\n                        raw_action[\"coordinate\"][\"x\"],\n                        raw_action[\"coordinate\"][\"y\"]\n                    ),\n                    raw_action[\"delay\"],\n                    raw_action[\"click\"],\n                    raw_action[\"window_info\"]\n                )\n                sequence.actions.append(mouse_action)\n            \n            # SCROLL ACTION ================================================\n            elif \"scroll\" in raw_action:\n                scroll_action = ScrollAction(\n                    (\n                        raw_action[\"coordinate\"][\"x\"],\n                        raw_action[\"coordinate\"][\"y\"]\n                    ),\n                    raw_action[\"scroll\"][\"dx\"],\n                    raw_action[\"scroll\"][\"dy\"],\n                    raw_action[\"delay\"],\n                    raw_action[\"window_info\"]\n                )\n                sequence.actions.append(scroll_action)\n            \n            # KEYBOARD ACTION ==============================================\n            elif \"key\" in raw_action:\n                keyboard_action = KeyboardAction(\n                    raw_action[\"key\"],\n                    raw_action[\"delay\"],\n                    raw_action[\"hold_time\"]\n                )\n                sequence.actions.append(keyboard_action)\n            else:\n                raise Exception(f\"Action ({raw_action}) can't be interpreted!\")\n            \n        return sequence\n    \n    def print_sequence(self):\n        for action in self.actions:\n            print(action)\n\n    def set_date_created_to_current(self):\n        self.date_created = DatetimeHandler.get_datetime()\n        \n    def set_global_delay(self, global_delay: int):\n        self.global_delay = global_delay    \n    \n    def get_runtime(self):\n        runtime = 0\n        for action in self.actions:\n            runtime += action.delay\n            runtime += self.global_delay\n        return round(runtime, 2)\n    \n    # New feature in 1.0.0 designed to auto-correct consecutive key holding\n    # def auto_correct(self):\n    #     actions_copy = copy.deepcopy(self.actions)\n    #     for idx, action in enumerate(self.actions):\n            \n    #         if isinstance(action, KeyboardAction):\n    #             if actions_copy[idx].key == actions_copy[idx+1].key:\n    #                 actions_copy[idx].delay += actions_copy[idx+1].delay\n                    "
  },
  {
    "path": "src/starrail/bin/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/bin/loader/__init__.py",
    "content": ""
  },
  {
    "path": "src/starrail/bin/loader/loader.py",
    "content": "from itertools import cycle\nfrom shutil import get_terminal_size\nfrom threading import Thread\nfrom time import sleep\n\nclass Loader:\n    def __init__(self, desc=\"Loading...\", end=\"Done!\", timeout=0.1):\n        \"\"\"\n        A loader-like context manager\n\n        Args:\n            desc (str, optional): The loader's description. Defaults to \"Loading...\".\n            end (str, optional): Final print. Defaults to \"Done!\".\n            timeout (float, optional): Sleep time between prints. Defaults to 0.1.\n        \"\"\"\n        self.desc = desc\n        self.end = end\n        self.timeout = timeout\n\n        self._thread = Thread(target=self._animate, daemon=True)\n        self.steps = ['|', '/', '-', '\\\\']\n        self.done = False\n\n    def start(self):\n        self._thread.start()\n        return self\n\n    def _animate(self):\n        for c in cycle(self.steps):\n            if self.done:\n                break\n            print(f\"\\r{self.desc} {c}\", flush=True, end=\"\")\n            sleep(self.timeout)\n\n    def __enter__(self):\n        self.start()\n\n    def stop(self):\n        self.done = True\n        cols = get_terminal_size((80, 20)).columns\n        # print(\"\\r\" + \" \" * cols, end=\"\", flush=True)\n        if self.end != None:\n            print(f\"\\r{self.end}\", flush=True)\n\n    def __exit__(self, exc_type, exc_value, tb):\n        # handle exceptions with those variables ^\n        self.stop()\n\n\nif __name__ == \"__main__\":\n    with Loader(\"Loading with context manager...\"):\n        for i in range(10):\n            sleep(0.25)\n\n    loader = Loader(\"Loading with object...\", \"That was fast!\", 0.05).start()\n    # for i in range(10):\n    sleep(5)\n    loader.stop()"
  },
  {
    "path": "src/starrail/bin/logs/starrail_log.txt",
    "content": ""
  },
  {
    "path": "src/starrail/bin/pick/__init__.py",
    "content": "# The MIT License (MIT)\n\n# Copyright (c) 2016 Wang Dàpéng\n\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/bin/pick/pick.py",
    "content": "# The MIT License (MIT)\n\n# Copyright (c) 2016 Wang Dàpéng\n\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nimport curses\nfrom colorama import init as colorama_init; colorama_init()\nfrom dataclasses import dataclass, field\nfrom typing import Any, List, Optional, Sequence, Tuple, TypeVar, Union, Generic\n\n__all__ = [\"Picker\", \"pick\", \"Option\"]\n\n\n@dataclass\nclass Option:\n    label: str\n    value: Any\n\n\nKEYS_ENTER = (curses.KEY_ENTER, ord(\"\\n\"), ord(\"\\r\"))\nKEYS_UP = (curses.KEY_UP, ord(\"w\"))\nKEYS_DOWN = (curses.KEY_DOWN, ord(\"s\"))\nKEYS_SELECT = (curses.KEY_RIGHT, ord(\" \"))\n\nSYMBOL_CIRCLE_FILLED = \"[x]\"\nSYMBOL_CIRCLE_EMPTY = \"[ ]\"\n\nOPTION_T = TypeVar(\"OPTION_T\", str, Option)\nPICK_RETURN_T = Tuple[OPTION_T, int]\n\n\n@dataclass\nclass Picker(Generic[OPTION_T]):\n    options: Sequence[OPTION_T]\n    title: Optional[str] = None\n    indicator: str = \">\"\n    default_index: int = 0\n    multiselect: bool = False\n    min_selection_count: int = 0\n    selected_indexes: List[int] = field(init=False, default_factory=list)\n    index: int = field(init=False, default=0)\n    screen: Optional[\"curses._CursesWindow\"] = None\n\n    def __post_init__(self) -> None:\n        if len(self.options) == 0:\n            raise ValueError(\"options should not be an empty list\")\n\n        if self.default_index >= len(self.options):\n            raise ValueError(\"default_index should be less than the length of options\")\n\n        if self.multiselect and self.min_selection_count > len(self.options):\n            raise ValueError(\n                \"min_selection_count is bigger than the available options, you will not be able to make any selection\"\n            )\n\n        self.index = self.default_index\n\n    def move_up(self) -> None:\n        self.index -= 1\n        if self.index < 0:\n            self.index = len(self.options) - 1\n\n    def move_down(self) -> None:\n        self.index += 1\n        if self.index >= len(self.options):\n            self.index = 0\n\n    def mark_index(self) -> None:\n        if self.multiselect:\n            if self.index in self.selected_indexes:\n                self.selected_indexes.remove(self.index)\n            else:\n                self.selected_indexes.append(self.index)\n\n    def get_selected(self) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]:\n        \"\"\"return the current selected option as a tuple: (option, index)\n        or as a list of tuples (in case multiselect==True)\n        \"\"\"\n        if self.multiselect:\n            return_tuples = []\n            for selected in self.selected_indexes:\n                return_tuples.append((self.options[selected], selected))\n            return return_tuples\n        else:\n            return self.options[self.index], self.index\n\n    def get_title_lines(self) -> List[str]:\n        if self.title:\n            return self.title.split(\"\\n\") + [\"\"]\n        return []\n\n    def get_option_lines(self) -> List[str]:\n        lines: List[str] = []\n        for index, option in enumerate(self.options):\n            if not self.multiselect:\n                if index == self.index:\n                    prefix = SYMBOL_CIRCLE_FILLED\n                else:\n                    prefix = SYMBOL_CIRCLE_EMPTY\n            else:\n                if index == self.index:\n                    prefix = self.indicator\n                else:\n                    prefix = len(self.indicator) * \" \"\n\n            if self.multiselect:\n                symbol = (\n                    SYMBOL_CIRCLE_FILLED\n                    if index in self.selected_indexes\n                    else SYMBOL_CIRCLE_EMPTY\n                )\n                prefix = f\"{prefix} {symbol}\"\n\n            option_as_str = option.label if isinstance(option, Option) else option\n            lines.append(f\"{prefix} {option_as_str}\")\n\n        return lines\n\n    def get_lines(self) -> Tuple[List, int]:\n        title_lines = self.get_title_lines()\n        option_lines = self.get_option_lines()\n        lines = title_lines + option_lines\n        current_line = self.index + len(title_lines) + 1\n        return lines, current_line\n\n    def draw(self, screen: \"curses._CursesWindow\") -> None:\n        \"\"\"draw the curses ui on the screen, handle scroll if needed\"\"\"\n        screen.clear()\n\n        x, y = 1, 1  # start point\n        max_y, max_x = screen.getmaxyx()\n        max_rows = max_y - y  # the max rows we can draw\n\n        lines, current_line = self.get_lines()\n\n        # calculate how many lines we should scroll, relative to the top\n        scroll_top = 0\n        if current_line > max_rows:\n            scroll_top = current_line - max_rows\n\n        lines_to_draw = lines[scroll_top : scroll_top + max_rows]\n\n        for line in lines_to_draw:\n            screen.addnstr(y, x, line, max_x - 2)\n            y += 1\n\n        screen.refresh()\n\n    def run_loop(\n        self, screen: \"curses._CursesWindow\"\n    ) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]:\n        while True:\n            self.draw(screen)\n            c = screen.getch()\n            if c in KEYS_UP:\n                self.move_up()\n            elif c in KEYS_DOWN:\n                self.move_down()\n            elif c in KEYS_ENTER:\n                if (\n                    self.multiselect\n                    and len(self.selected_indexes) < self.min_selection_count\n                ):\n                    continue\n                return self.get_selected()\n            elif c in KEYS_SELECT and self.multiselect:\n                self.mark_index()\n\n    def config_curses(self) -> None:\n        try:\n            # use the default colors of the terminal\n            curses.use_default_colors()\n            # hide the cursor\n            curses.curs_set(0)\n        except:\n            # Curses failed to initialize color support, eg. when TERM=vt100\n            curses.initscr()\n\n    def _start(self, screen: \"curses._CursesWindow\"):\n        self.config_curses()\n        return self.run_loop(screen)\n\n    def start(self):\n        if self.screen:\n            # Given an existing screen\n            # don't make any lasting changes\n            last_cur = curses.curs_set(0)\n            ret = self.run_loop(self.screen)\n            if last_cur:\n                curses.curs_set(last_cur)\n            return ret\n        return curses.wrapper(self._start)\n\n\ndef pick(\n    options: Sequence[OPTION_T],\n    title: Optional[str] = None,\n    indicator: str = \">\",\n    default_index: int = 0,\n    multiselect: bool = False,\n    min_selection_count: int = 0,\n    screen: Optional[\"curses._CursesWindow\"] = None,\n):\n    picker: Picker = Picker(\n        options,\n        title,\n        indicator,\n        default_index,\n        multiselect,\n        min_selection_count,\n        screen,\n    )\n    return picker.start()\n\npick(['a', 'b'])"
  },
  {
    "path": "src/starrail/bin/pid/get_active_pid.c",
    "content": "// Compile: \n//    gcc .\\get_active_pid.c -o ../get_active_pid\n\n#include <windows.h>\n#include <stdio.h>\n\nint main() {\n    DWORD pid;\n    HWND hwnd = GetForegroundWindow();      // get handle of currently active window\n    if (hwnd == NULL) {\n        printf(\"No active window\\n\");\n        return 1;\n    }\n\n    GetWindowThreadProcessId(hwnd, &pid);   // get PID\n    // printf(\"Active window PID: %lu\\n\", pid);\n    printf(\"%lu\\n\", pid);\n\n    return 0;\n}"
  },
  {
    "path": "src/starrail/bin/scheduler/__init__.py",
    "content": ""
  },
  {
    "path": "src/starrail/bin/scheduler/config/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/bin/scheduler/config/starrail_schedule_config.py",
    "content": "from starrail.utils.utils import *\nfrom starrail.utils.json_handler import JSONConfigHandler\n\nclass StarRailScheduleConfig(JSONConfigHandler):\n    def __init__(self):\n        __scheduler_config =  os.path.join(os.path.abspath(os.path.dirname(__file__)), \"schedules.json\")\n        super().__init__(__scheduler_config, list)\n    \n    def load_schedule(self):\n        return self.LOAD_CONFIG()\n    \n    def save_schedule(self, payload):\n        ret = self.SAVE_CONFIG(payload)"
  },
  {
    "path": "src/starrail/bin/scheduler/starrail_scheduler.py",
    "content": "import schedule\nimport time\nimport threading\nfrom enum import Enum\nimport re\nimport tabulate\n\nfrom starrail.utils.utils import *\nfrom starrail.bin.scheduler.config.starrail_schedule_config import StarRailScheduleConfig\nfrom starrail.controllers.star_rail_app import HonkaiStarRail\n\nSUBMODULE_NAME = \"SR-SCL\"\nSUBMODULE_VERSION = \"1.0\"\n\nclass OperationTypes(Enum):\n    START   = \"Start Game\"\n    END     = \"Stop Game\"\n\n\nclass StartRailJob:\n    def __init__(self, job_id: int, job: schedule.Job, op_type: OperationTypes):\n        self.job_id = job_id\n        self.op_type = op_type\n        self.schedule_job = job\n        \n        self.interval   = job.interval\n        self.unit       = job.unit\n        self.last_run   = job.last_run\n        self.next_run   = job.next_run\n\n    def __str__(self):\n        return f\"[{Printer.to_lightpurple('JOB')}] {self.op_type} Game - Next run: {self.next_run}, Last run: {self.last_run}\"\n    \n    def print_job(self):\n        print(self.__str__())\n        \n    def to_dict(self):\n        return {\n            \"id\"        : self.job_id,\n            \"op_type\"   : self.op_type.value,\n            'interval'  : self.interval,\n            'unit'      : self.unit,\n            'next_run'  : str(self.next_run),\n            'last_run'  : str(self.last_run),\n        }\n\n\nclass StarRailScheduler:\n    def __init__(self, starrail_instance: HonkaiStarRail):\n        self.starrail = starrail_instance\n        self.schedule_config = StarRailScheduleConfig()\n        \n        self.jobs: dict[int, StartRailJob] = dict()\n        self.__load_schedules() # Load schedule into jobs\n        \n        self._stop_event = threading.Event()\n        self.scheduler_thread = threading.Thread(target=self.__run_scheduler, daemon=True)\n        self.scheduler_thread.start()\n\n\n    # =============================================\n    # =========| START/STOP FUNCTIONS | ===========\n    # =============================================\n\n    def __load_schedules(self):\n        schedules: list[StartRailJob] = self.schedule_config.load_schedule()\n        if schedules == None:\n            return\n        \n        for data in schedules:\n            job_id = data[\"id\"]\n            op_type = OperationTypes(data['op_type'])\n            \n            matched_time = re.search(\"[0-9]{2}:[0-9]{2}:[0-9]{2}\", data['next_run'])\n            if matched_time:\n                schedule_time = matched_time.group(0)\n            else:\n                aprint(f\"Failed to read schedule with data: {data}\", log_type=LogType.ERROR)\n                continue\n            \n            if op_type == OperationTypes.START:\n                job = schedule.every(data['interval']).day.at(schedule_time).do(self.starrail.start)\n            elif op_type == OperationTypes.END:\n                job = schedule.every(data['interval']).day.at(schedule_time).do(self.starrail.terminate)\n            \n            job.last_run = data[\"last_run\"]\n            self.jobs[job_id] = StartRailJob(job_id, job, op_type)\n        \n    def __run_scheduler(self):\n        while not self._stop_event.is_set():\n            schedule.run_pending()\n            time.sleep(1)\n    \n    def stop_scheduler(self):\n        aprint(\"Stopping the scheduler...\", submodule_name=SUBMODULE_NAME)\n        self._stop_event.set()\n        self.scheduler_thread.join()\n\n\n    # =============================================\n    # =========| ADD/REMOVE FUNCTIONS | ===========\n    # =============================================\n\n    def add_new_schedule(self, time_str, operation_type: OperationTypes):\n        parsed_time = self.__parse_time_format(time_str)\n        \n        # If parsed time is invalid\n        if parsed_time == None:\n            aprint(f\"Cannot parse time: {time_str}\", log_type=LogType.ERROR)\n            raise SRExit()\n        \n        # If scheduled job already exist at the new job time\n        for job_id, job in self.jobs.items():\n            if job.next_run.strftime(TIME_FORMAT) == parsed_time:\n                aprint(f\"{Printer.to_lightred(f'Job (ID {job_id}) is already scheduled at time {parsed_time}.')}\")\n                return\n        \n        if operation_type == OperationTypes.START:\n            job = schedule.every().day.at(parsed_time).do(self.starrail.start)\n        elif operation_type == OperationTypes.END:\n            job = schedule.every().day.at(parsed_time).do(self.starrail.terminate)\n\n        sr_job_id = self.__get_next_job_id()\n        sr_job = StartRailJob(sr_job_id, job, operation_type)\n        self.jobs[sr_job_id] = sr_job\n        \n        aprint(f\"New scheduled job (ID {sr_job_id}) has been added to the scheduler successfully!\")\n        self.show_schedules()\n        \n        # Save new schedules list into config\n        self.schedule_config.save_schedule([job.to_dict() for job in self.jobs.values()])\n\n    def remove_schedule(self):\n        if len(self.jobs) == 0:\n            aprint(\"No scheduled job to remove.\")\n            return\n\n        self.show_schedules()\n        aprint(f\"Which job would you like to remove? [ID 1{'-' + str(len(self.jobs)) if len(self.jobs) > 1 else ''}] \", end=\"\")\n        user_input_id = input(\"\")\n        job_id = self.__parse_id(user_input_id)\n        sr_job: StartRailJob = self.__get_job_with_id(job_id)\n        \n        # Removing job from schedule\n        aprint(f\"Removing job (ID {job_id})...\", end=\"\\r\")\n        schedule.cancel_job(sr_job.schedule_job)\n        \n        # Removing job from cache\n        del self.jobs[job_id]\n        \n        # Reset job IDs after deletion\n        new_schedule_dict = dict()\n        for new_job_id, (old_job_id, sr_job) in enumerate(self.jobs.items()):\n            sr_job.job_id = new_job_id+1\n            new_schedule_dict[new_job_id+1] = sr_job\n        self.jobs = new_schedule_dict\n        \n        # Saving jobs to config\n        self.schedule_config.save_schedule([job.to_dict() for job in self.jobs.values()])\n        aprint(f\"Job (ID {job_id}) removed successfully.\")\n\n    def show_schedules(self):\n        headers = [\"ID\", \"Type\", \"Action\", \"Next Run\", \"Last Run\"]\n        headers = [Printer.to_lightpurple(title) for title in headers]\n        payload = []\n        for job_id, job in self.jobs.items():\n            payload.append([job_id, \"Scheduled Job\", job.op_type.value, job.next_run, \"None\" if job.last_run == None else job.last_run])\n        \n        \n        tab = tabulate.tabulate(payload, headers)\n        print(\"\\n\" + tab + \"\\n\", flush=True)\n        \n    def clear_schedules(self):\n        if len(self.jobs) == 0:\n            aprint(\"No scheduled job to clear.\")\n            return\n        \n        self.show_schedules()\n        aprint(f\"Are you sure you want to clear all {len(self.jobs)} schedules? [y/n] \", end=\"\")\n        user_input = input(\"\").lower().strip()\n        if user_input == \"y\":\n            for job_id, sr_job in self.jobs.items():\n                aprint(f\"Canceling schedule job (ID {job_id})...\", end=\" \")\n                schedule.cancel_job(sr_job.schedule_job)\n                print(\"Done\")\n                time.sleep(0.1)\n            \n            self.jobs.clear()\n            self.schedule_config.save_schedule([])\n            aprint(\"All scheduled jobs have been canceled successfully.\")\n            \n        else:\n            aprint(\"Clear operation canceled.\")\n        \n        \n    # =============================================\n    # ===========| HELPER FUNCTIONS | =============\n    # =============================================\n\n    def __parse_time_format(self, str_time: str):\n        str_time = str_time.strip().lower()\n        \n        match = re.search(\"[0-9]+:[0-9]+:[0-9]+\", str_time)\n        if match:\n            return match.group(0)\n        \n        match2 = re.search(\"[0-9]+:[0-9]+\", str_time)\n        if match2:\n            return f\"{match2.group(0)}:00\"\n        \n        try:\n            digit_time = int(str_time)\n            return f\"{str_time}:00:00\"\n        except ValueError:\n            pass\n        \n        return None\n\n    def __parse_id(self, str_id: str):\n        try:\n            str_id = int(str_id)\n        except TypeError:\n            aprint(f\"Invalid ID: {str_id}\", log_type=LogType.ERROR)\n            raise SRExit()\n        return str_id\n\n    def __get_job_with_id(self, job_id: int):\n        try:\n            target_job = self.jobs[job_id]\n            return target_job\n        except KeyError:\n            aprint(f\"Invalid ID: {job_id}\", log_type=LogType.ERROR)\n            raise SRExit()\n\n    def __get_next_job_id(self):\n        return len(self.jobs) + 1\n\n\n# # # Usage example\n# sr = HonkaiStarRail()\n# scheduler = SRScheduler(sr)\n# scheduler.add_new_schedule(\"17:52\", OperationTypes.START)  # Schedules ABC to run every 10 minutes\n# scheduler.add_new_schedule(\"22:12\", OperationTypes.END)  # Schedules ABC to run every 10 minutes\n\n# while True:\n#     user_input = input(\"Enter 'show' to display the schedule or 'exit' to quit: \").strip().lower()\n#     if user_input == 'show':\n#         scheduler.show_schedules()\n#     if user_input == 'remove':\n#         scheduler.remove_schedule()\n#     elif user_input == 'exit':\n#         scheduler.stop_scheduler()\n#         break\n"
  },
  {
    "path": "src/starrail/config/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/config/config_handler.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nimport os\nimport json\nfrom starrail.constants import *\nfrom starrail.exceptions.exceptions import *\nfrom starrail.utils.json_handler import JSONConfigHandler\nfrom pathlib import Path\n\n# TODO: Config is only loaded the first time when the StarRailConfig is initialized. This is an issue\n# when the module is configured in the CLI since the loaded configuration persists in the CLI.\n\nclass StarRailConfig(JSONConfigHandler):\n    def __init__(self):\n        __starrail_config =  os.path.join(os.path.abspath(os.path.dirname(__file__)), \"starrail_config.json\")\n        super().__init__(__starrail_config, dict)\n        \n        __raw_config: dict\n        try:\n            __raw_config = self.LOAD_CONFIG()\n        except json.JSONDecodeError:\n            self.__reset_config()\n            __raw_config = self.LOAD_CONFIG()\n        \n        # Config variables\n        self.instance_pid: int  = None\n        self.root_path: Path    = None  # Root as   D:\\HoYoPlay\\games\n        self.innr_path: Path    = None  # Inner as  D:\\HoYoPlay\\games\\Star Rail Games or D:\\HoYoPlay\\games\\Game\n        self.game_path: Path    = None  # Game as   D:\\HoYoPlay\\games\\Star Rail Games\\StarRail.exe\n        self.disclaimer: bool   = False\n        \n        # Load config into attributes\n        if __raw_config != None:\n            try:\n                self.instance_pid   = __raw_config[\"instance\"][\"pid\"]\n                self.root_path      = __raw_config[\"static\"][\"root_path\"]\n                self.innr_path      = __raw_config[\"static\"][\"innr_path\"]\n                self.game_path      = __raw_config[\"static\"][\"game_path\"]\n                self.disclaimer     = __raw_config[\"static\"][\"disclaimer\"]\n                \n                if self.root_path != None:\n                    self.root_path = Path(self.root_path)\n                if self.innr_path != None:\n                    self.innr_path = Path(self.innr_path)\n                if self.game_path != None:\n                    self.game_path = Path(self.game_path)\n                \n                    \n            except KeyError:\n                self.__reset_config()\n        else:\n            self.__reset_config()\n    \n    \n    # ==================================================\n    # ============== | UTILITY FUNCTIONS | =============\n    # ==================================================\n    \n    def save_current_config(self):\n        self.SAVE_CONFIG(\n            {\n                \"instance\": {\n                    \"pid\": self.instance_pid\n                },\n                \"static\": {\n                    \"root_path\": str(self.root_path),\n                    \"innr_path\": str(self.innr_path),\n                    \"game_path\": str(self.game_path),\n                    \"disclaimer\": self.disclaimer\n                }\n            }\n        )\n\n    def full_configured(self) -> bool:\n        return self.path_configured() and self.disclaimer_configured()\n    \n    def path_configured(self) -> bool:\n        if isinstance(self.game_path, str):\n            self.game_path = Path(self.game_path)\n        if isinstance(self.innr_path, str):\n            self.innr_path = Path(self.innr_path)\n        if isinstance(self.root_path, str):\n            self.root_path = Path(self.root_path)\n            \n        if isinstance(self.root_path, Path) and isinstance(self.game_path, Path) and isinstance(self.innr_path, Path):\n            return self.game_path.exists() and self.root_path.exists() and self.innr_path.exists()\n        \n        return False\n    \n    def disclaimer_configured(self) -> bool:\n        return self.disclaimer\n    \n    \n    # ==================================================\n    # ============== | HELPER FUNCTIONS | ==============\n    # ==================================================\n\n    def set_path(self, game_path: str):\n        self.game_path = Path(game_path)\n        self.innr_path = os.path.dirname(game_path)\n        self.root_path = Path(os.path.dirname(os.path.dirname(game_path)))\n    \n\n    def __reset_config(self):\n        self.SAVE_CONFIG(\n            {\n                \"instance\": {\n                    \"pid\": None\n                },\n                \"static\": {\n                    \"root_path\": None,\n                    \"innr_path\": None,\n                    \"game_path\": None,\n                    \"disclaimer\": False\n                }\n            }\n        )\n        self.game_path = None\n        self.innr_path = None\n        self.root_path = None\n        self.disclaimer = False\n\n\n"
  },
  {
    "path": "src/starrail/constants.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nimport os\n\n# ==============================================\n# ==========| MODULE/GAME CONSTANTS | ==========\n# ==============================================\n\nBASENAME            = \"StarRail CLI\"\nSHORTNAME           = \"StarRail\"\nCOMMAND             = \"starrail\"\nVERSION             = \"1.0.5\"\nVERSION_DESC        = \"Beta\"\nDEVELOPMENT         = VERSION_DESC.lower() != \"stable\"\n\nTIME_FORMAT         = \"%H:%M:%S\"\nDATETIME_FORMAT     = \"%Y-%m-%d %H:%M:%S\"\n\nGAME_NAME           = \"Honkai: Star Rail\"\nGAME_FILENAME       = \"StarRail.exe\"\nGAME_FILE_PATH      = f\"Star Rail/Game/{GAME_FILENAME}\"\nGAME_FILE_PATH_NEW  = f\"Star Rail Games/{GAME_FILENAME}\"\n\nAUTHOR              = \"Kevin L.\"\nAUTHOR_DETAIL       = f\"{AUTHOR} - kevinliu@vt.edu - Github: ReZeroE\"\nREPOSITORY          = \"https://github.com/ReZeroE/StarRail\"\nISSUES              = f\"{REPOSITORY}/issues\"\n\nHOMEPAGE_URL        = \"https://hsr.hoyoverse.com/en-us/home\"\nHOMEPAGE_URL_CN     = \"https://sr.mihoyo.com/\"\nHOYOLAB_URL         = \"https://www.hoyolab.com/home\"\nYOUTUBE_URL         = \"https://www.youtube.com/channel/UC2PeMPA8PAOp-bynLoCeMLA\"\nBILIBILI_URL        = \"https://space.bilibili.com/1340190821\"\n\n\n# ==============================================\n# =============| OTHER CONSTANTS | =============\n# ==============================================\n\nCURSOR_UP_ANSI  = \"\\033[A\"\n\n# See utils/game_detector for details on \"Weak Match\"\nMIN_WEAK_MATCH_EXE_SIZE = 0.5 # megabytes\n\n\n# ==============================================\n# ==================| PATHS | ==================\n# ==============================================\n\nHOME_DIRECTORY      = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))\n\n__PYTHON_MODULE_PATH = os.path.join(HOME_DIRECTORY, \"Lib\", \"site-packages\", \"starrail\")\nif os.path.exists(__PYTHON_MODULE_PATH):\n    # If the module is built using `pip install .`, then the path would be something like C:/PythonXXX/Lib/site-packages/<package_name>\n    HOME_DIRECTORY = __PYTHON_MODULE_PATH\n    STARRAIL_DIRECTORY = __PYTHON_MODULE_PATH\nelse:\n    # Else if the module is built locally using `pip install -e .`, then the path will be the local directory structure\n    STARRAIL_DIRECTORY   = os.path.join(HOME_DIRECTORY, \"src\", \"starrail\")\n\n\n# ==============================================\n# =================| GLOBALS | =================\n# ==============================================\n\n# Global variables to identify whether the program is in CLI mode\n# MUST BE USED AS constants.CLI_MODE (only this accesses the re-bind value)\nCLI_MODE        = False\n\n\n# ==============================================\n# ================| RECORDER | =================\n# ==============================================\n\n# Recording window size preset:\nRECORDER_WINDOW_INFO = {\n    \"width\": 600,\n    \"height\": 100,\n    \"border-radius\": 10\n}\n\n# Recording window preset size calibrated using:\nCALIBRATION_MONITOR_INFO = {\n    \"width\": 3840,\n    \"height\": 2160\n}\n\n\n# ==============================================\n# ==================| OTHER | ==================\n# ==============================================\n\nWEBCACHE_IGNORE_FILETYPES = [\n    \".js\",\n    \".css\",\n    \".png\",\n    \".jpg\",\n    \".jpeg\"\n]\n\nfrom pynput.keyboard import Key\nPYNPUT_KEY_MAPPING = {\n    'Key.esc'       : Key.esc,\n    'Key.space'     : Key.space,\n    'Key.backspace' : Key.backspace,\n    'Key.enter'     : Key.enter,\n    \n    'Key.tab'       : Key.tab,\n    'Key.caps_lock' : Key.caps_lock,\n    'Key.shift'     : Key.shift,\n    'Key.ctrl'      : Key.ctrl,\n    'Key.alt'       : Key.alt,\n    \n    'Key.delete'    : Key.delete,\n    'Key.end'       : Key.end,\n    'Key.home'      : Key.home,\n    \n    'Key.f1'        : Key.f1,\n    'Key.f2'        : Key.f2,\n    'Key.f3'        : Key.f3,\n    'Key.f4'        : Key.f4,\n    'Key.f5'        : Key.f5,\n    'Key.f6'        : Key.f6,\n    'Key.f7'        : Key.f7,\n    'Key.f8'        : Key.f8,\n    'Key.f9'        : Key.f9,\n    'Key.f10'       : Key.f10,\n    'Key.f11'       : Key.f11,\n    'Key.f12'       : Key.f12,\n    \n    'Key.page_down' : Key.page_down,\n    'Key.page_up'   : Key.page_up,\n    \n    'Key.up'        : Key.up,\n    'Key.down'      : Key.down,\n    'Key.left'      : Key.left,\n    'Key.right'     : Key.right,\n    \n    'Key.media_play_pause'  : Key.media_play_pause,\n    'Key.media_volume_mute' : Key.media_volume_mute,\n    'Key.media_volume_up'   : Key.media_volume_up,\n    'Key.media_volume_down' : Key.media_volume_down,\n    'Key.media_previous'    : Key.media_previous,\n    'Key.media_next'        : Key.media_next,\n    'Key.insert'            : Key.insert,\n    'Key.menu'              : Key.menu,\n    'Key.num_lock'          : Key.num_lock,\n    'Key.pause'             : Key.pause,\n    'Key.print_screen'      : Key.print_screen,\n    'Key.scroll_lock'       : Key.scroll_lock\n}"
  },
  {
    "path": "src/starrail/controllers/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/controllers/automation_controller.py",
    "content": "\nimport os\nimport sys\nimport time\nimport tabulate\n\nfrom starrail.utils.utils import *\n\nfrom starrail.automation.units.sequence import AutomationSequence\nfrom starrail.automation.recorder import AutomationRecorder\nfrom starrail.automation.config.automation_config_handler import StarRailAutomationConfig\nfrom starrail.controllers.star_rail_app import HonkaiStarRail\n\nclass StarRailAutomationController:\n    def __init__(self, starrail_instance: HonkaiStarRail):\n        self.automation_config = StarRailAutomationConfig()\n        self.automation_sequences: dict[int, AutomationSequence] = self.__load_all_sequences()\n    \n        self.starrail = starrail_instance # Only used by recorder to identify if the game is focused.\n    \n    def __load_all_sequences(self):\n        sequence_list: list[AutomationSequence] = []\n        \n        sequence_config_list = self.automation_config.load_all_automations()\n        for config in sequence_config_list:\n            sequence = AutomationSequence.parse_config(config)\n            sequence_list.append(sequence)\n            \n        sequence_list.sort(key=lambda seq: seq.date_created)\n        return {idx+1: sequence for idx, sequence in enumerate(sequence_list)}\n    \n    def verbose_general_usage(self):\n        headers = [Printer.to_lightpurple(title) for title in [\"Example Command\", \"Description\"]]\n        data = [\n            [color_cmd(\"automation record\"),    \"Create and record a new automation sequence (macros)\"],\n            [color_cmd(\"automation show\"),      \"List all recorded automation sequences\"],\n            [color_cmd(\"automation run\"),       \"Run a recorded automation sequence\"],\n            [color_cmd(\"automation remove\"),    \"Delete a recorded automated sequence\"],\n            [color_cmd(\"automation clear\"),     \"Delete all recorded automation sequences\"],\n        ]\n        \n        tab = tabulate.tabulate(data, headers)\n        print(\"\\n\" + tab + \"\\n\")\n    \n    \n    # =============================================\n    # ============| FETCH AUTOMATION | ============\n    # =============================================\n    \n    def get_all_sequences(self, list_format=False):\n        if list_format:\n            return list(self.automation_sequences.values())\n        return self.automation_sequences\n    \n    def get_sequence(self, sequence_name: str):\n        target_sequence_name = self.__reformat_sequence_name(sequence_name)\n        for sequence in self.automation_sequences.values():\n            if sequence.sequence_name == target_sequence_name:\n                return sequence\n        return None\n    \n    def get_sequence_with_id(self, sequence_id: int):\n        try:\n            return self.automation_sequences[sequence_id]\n        except KeyError:\n            return None\n    \n    \n    # =============================================\n    # ============| DRIVER FUNCTIONS | ============\n    # =============================================\n    \n    def run_requence(self, sequence_name=None):\n        if len(self.automation_sequences) == 0:\n            aprint(f\"{Printer.to_lightred('No automation sequence has been recorded.')}\")\n            self.verbose_general_usage()\n            return\n        \n        sequence = None\n        \n        if sequence_name:\n            r_sequence_name = self.__reformat_sequence_name(sequence_name)\n            sequence = self.get_sequence(r_sequence_name)\n            if sequence == None:\n                aprint(f\"Invalid input. No sequence with name {sequence_name}.\")\n                raise SRExit()\n            \n        else:\n            self.show_sequences()\n            aprint(f\"Which sequence would you like to run? ({self.get_range_string()}) \", end=\"\")\n            user_input_id = input(\"\")\n            \n            sequence = self.get_sequence_with_id(self.tryget_int(user_input_id))\n            if sequence == None:\n                aprint(f\"Invalid input. No sequence with ID {user_input_id}.\")\n                raise SRExit()\n    \n        aprint(f\"Run automation '{sequence.sequence_name}' ({Printer.to_lightgrey(f'approximately {sequence.get_runtime()} seconds')})? [y/n] \", end=\"\")\n        user_input = input(\"\").strip().lower()\n        if user_input != \"y\": return\n        \n        aprint(f\"Automation run ready start.\\n{Printer.to_lightblue(' - To start')}: Focus on the game and the automation will automatically start.\")\n        while True:\n            if self.starrail.is_focused():\n                time.sleep(1)\n                break\n            else:\n                time.sleep(0.5)\n        \n        sequence.execute()\n        aprint(\"Automation sequence run complete!                                                            \")\n    \n    def show_sequences(self):\n        headers = [Printer.to_lightpurple(title) for title in [\"ID\", \"Sequence Name\", \"Date Created\",  \"Runtime\", \"Actions Count\"]]\n        data = []\n        for seq_id, sequence in self.automation_sequences.items():\n            row = [Printer.to_lightblue(seq_id), sequence.sequence_name, sequence.date_created, f\"{sequence.get_runtime()} seconds\", len(sequence.actions)]\n            data.append(row)\n        \n        tab = tabulate.tabulate(data, headers)\n        print(\"\\n\" + tab + \"\\n\", flush=True)\n    \n    def record_sequences(self, sequence_name=None):\n        if sequence_name == None:\n            aprint(\"New sequence name: \", end=\"\") \n            sequence_name = input(\"\").strip()\n        \n        # Initialize empty automation sequence for storing recording\n        NEW_SEQUENCE_NAME = self.__reformat_sequence_name(sequence_name)\n        if self.__sequence_already_exist(NEW_SEQUENCE_NAME):\n            aprint(f\"Sequence with name '{NEW_SEQUENCE_NAME}' {Printer.to_lightred('already exist')}.\")\n            raise SRExit()\n        \n        recording_sequence = AutomationSequence(NEW_SEQUENCE_NAME)                  \n        recording_sequence.set_date_created_to_current()\n        \n        # Start recording\n        try:\n            action_recorder = AutomationRecorder(recording_sequence, self.starrail)\n            action_recorder.record(start_on_callback=True)\n        except Exception as ex:\n            aprint(f\"Uncaught Error (during recording): {ex}\")\n            raise SRExit()\n        \n        # TODO: Auto refactor the recorded sequence such that consecutive holds are merged.\n        \n        # Convert recorded sequence into JSON and save it as config\n        json_sequence = recording_sequence.to_json()\n        success = self.automation_config.save_automation(recording_sequence.sequence_name, json_sequence)\n        assert(success == True)\n        \n        # Cache recorded sequence in memory\n        sequence_id = self.get_next_sequence_id()\n        self.automation_sequences[sequence_id] = recording_sequence\n\n        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')}\")\n        return recording_sequence\n    \n    def delete_sequence(self):\n        if len(self.automation_sequences) == 0:\n            aprint(\"No automation sequence has been recorded.\")\n            raise SRExit()\n\n        self.show_sequences()\n        aprint(f\"Which sequence would you like to delete? ({self.get_range_string()}) \", end=\"\")\n        user_input_id = input(\"\")\n        \n        seq_id = self.tryget_int(user_input_id)\n        sequence = self.get_sequence_with_id(seq_id)\n        if sequence == None:\n            aprint(f\"Invalid input. No sequence with ID {user_input_id}.\")\n            raise SRExit()\n    \n        self.automation_config.delete_automation(sequence.sequence_name)\n        del self.automation_sequences[seq_id]\n        aprint(f\"Automation sequence `{sequence.sequence_name}` has been deleted.\")\n        \n    def clear_sequences(self):\n        if len(self.automation_sequences) == 0:\n            aprint(\"No automation sequence has been recorded.\")\n            raise SRExit()\n\n        self.show_sequences()\n        aprint(f\"Are you sure you would like to clear all {len(self.automation_sequences)} automation sequences? [y/n] \", end=\"\")\n        user_input = input(\"\").strip().lower()\n        if user_input == \"y\":\n            for sequence in self.automation_sequences.values():\n                aprint(f\"Deleting automation sequence '{sequence.sequence_name}' ...\")\n                self.automation_config.delete_automation(sequence.sequence_name)\n            self.automation_sequences.clear()\n            \n        aprint(\"All automation sequences have been deleted.\")\n    \n\n    # =============================================\n    # ============| HELPER FUNCTIONS | ============\n    # =============================================\n\n    def tryget_int(self, user_input_id):\n        if isinstance(user_input_id, str):\n            try:\n                seq_id = int(user_input_id)\n            except TypeError:\n                aprint(f\"Invalid input: {user_input_id}\", log_type=LogType.ERROR)\n                raise SRExit()\n        elif isinstance(user_input_id, int):\n            seq_id = user_input_id\n        return seq_id\n\n    def __sequence_already_exist(self, r_sequence_name):\n        for seq in self.automation_sequences.values():\n            if seq.sequence_name == r_sequence_name:\n                return True\n        return False\n\n    def get_range_string(self):\n        if len(self.automation_sequences) <= 1:\n            return \"ID 1\"\n        return f\"ID {min(self.automation_sequences.keys())}-{max(self.automation_sequences.keys())}\"\n    \n    def get_next_sequence_id(self):\n        if len(self.automation_sequences) == 0:\n            return 1\n        return max(self.automation_sequences.keys()) + 1\n    \n    def __reformat_sequence_name(self, sequence_name: str):\n        return sequence_name.strip().replace(\" \", \"-\").lower()"
  },
  {
    "path": "src/starrail/controllers/c_click_controller.py",
    "content": "import time\nimport pyautogui\nimport random\nfrom pynput import keyboard\nfrom threading import Event\n\nfrom starrail.exceptions.exceptions import SRExit\nfrom starrail.utils.utils import aprint, Printer, is_admin, color_cmd\nfrom starrail.constants import BASENAME\n\nclass ContinuousClickController:\n    def __init__(self):\n        self.thread_event = Event()\n        self.pause = False\n        self.click_count = 0\n\n\n    def click_continuously(\n        self,\n        count: int          = -1,\n        interval: float     = 1.0,\n        randomize_by: float = 0.0,\n        hold_time: float    = 0.1,\n        start_after: float  = 5.0,\n        quiet: bool         = False\n    ):\n        self.__verbose_start(count, interval, randomize_by, hold_time, start_after, quiet)\n\n        listener = keyboard.Listener(on_press=self.__on_press)\n        listener.start()\n        \n        time.sleep(start_after)\n        try:\n            while True:\n                if self.click_count == count or self.thread_event.is_set():\n                    listener.stop()\n                    break\n                elif self.pause:\n                    time.sleep(0.1)\n                    continue\n\n                if not quiet:\n                    self.__verbose_click(count)\n\n                self.__click(hold_time, interval, randomize_by)\n\n        except KeyboardInterrupt:\n            print(\"\")\n            listener.stop()\n            time.sleep(1)\n            raise SRExit()\n\n\n    def __click(self, hold_time, interval, randomize_interval):\n        self.click_count += 1\n        \n        pyautogui.mouseDown()\n        time.sleep(hold_time)\n        pyautogui.mouseUp()\n        \n        time.sleep(interval)\n        time.sleep(random.uniform(0, randomize_interval))\n\n\n    def __verbose_start(self, count, interval, randomize_by, hold_time, start_after, quiet):\n        title_text = Printer.to_lightblue(f\"Uniform clicking starting in {start_after} seconds.\")\n        stop_text = Printer.to_lightblue(\" - To stop:  \") + \"Press CTRL+C or ESC\"\n        pause_text = Printer.to_lightblue(\" - To pause: \") + \"Press SPACE\"\n        count_text = \"INFINITE\" if count == -1 else str(count)\n        count_text = Printer.to_purple(\" - Total clicks:   \") + count_text\n        interval_text = Printer.to_purple(\" - Click interval: \") + f\"{interval} seconds\"\n        if randomize_by > 0:\n            interval_text += f\" + random(0, {randomize_by}) seconds\"\n        hold_time_text = Printer.to_purple(\" - Hold duration:  \") + f\"{hold_time} seconds\"\n        \n        warning_text = \"\"\n        if not is_admin():\n            warning_text = Printer.to_lightred(f\"\\n\\nNote: Currently not running {BASENAME} as admin. Clicks will not work in game applications.\")\n            ccmd = color_cmd(\"starrail elevate\", with_quotes=True)\n            warning_text += Printer.to_lightred(f\"\\n      Run \") + ccmd + Printer.to_lightred(f\" to elevate permissions to admin.\")\n        \n        aprint(\n            f\"{title_text} \\\n            \\n{stop_text} \\\n            \\n{pause_text} \\\n            \\n\\n{count_text} \\\n            \\n{hold_time_text} \\\n            \\n{interval_text} \\\n            {warning_text}\\n\"\n        )\n\n    def __verbose_click(self, max_count):\n        buffer          = \" \" * 10\n        x, y            = pyautogui.position()\n        max_count_text  = \"INF\" if max_count == -1 else max_count\n        aprint(f\"[Count {self.click_count + 1}/{max_count_text}] Clicking ({x}, {y})...{buffer}\", end=\"\\r\")\n\n\n    def __on_press(self, button):\n        if button == keyboard.Key.esc:\n            if self.click_count > 0:\n                print(\"\")\n            aprint(\"Esc key pressed, stopping...\")\n            self.thread_event.set()\n            exit() # After the event is set, the listener thread termiantes\n\n        if button == keyboard.Key.space:\n            self.pause = not self.pause\n\n            buffer = \" \" * 7\n            if self.pause:\n                aprint(f\"Clicks paused. Press space again to start.{buffer}\", end=\"\\r\")\n            else:\n                aprint(f\"Clicks unpaused. Press space again to pause.{buffer}\", end=\"\\r\")\n\n"
  },
  {
    "path": "src/starrail/controllers/star_rail_app.py",
    "content": "\nimport os\nimport sys\nimport time\nimport psutil\nimport tabulate\nimport webbrowser\nimport subprocess\nimport configparser\nfrom pathlib import Path\n\nfrom starrail.constants import CURSOR_UP_ANSI\nfrom starrail.utils.utils import *\nfrom starrail.utils.process_handler import ProcessHandler\nfrom starrail.config.config_handler import StarRailConfig\nfrom starrail.controllers.webcache_controller import StarRailWebCacheController, StarRailWebCacheBinaryFile\nfrom starrail.controllers.streaming_assets_controller import StarRailStreamingAssetsController, StarRailStreamingAssetsBinaryFile\n\nfrom starrail.bin.loader.loader import Loader \n\nclass HonkaiStarRail:\n    def __init__(self):\n        self.config = StarRailConfig()\n        self.module_configured = self.config.full_configured()\n        \n        self.webcache_controller         = StarRailWebCacheController(self.config)\n        self.streaming_assets_controller = StarRailStreamingAssetsController(self.config)\n    \n    \n    # =============================================\n    # ============| DRIVER FUNCTIONS | ============\n    # =============================================\n    \n    def start(self) -> bool:\n        aprint(\"Starting Honkai: Star Rail...\")\n        starrail_proc = self.get_starrail_process()\n        \n        # If the application is already running\n        if starrail_proc != None and starrail_proc.is_running():\n            ctext = Printer.to_lightred(\"already running\")\n            aprint(f\"[PID {starrail_proc.pid}] Application `{starrail_proc.name()}` is {ctext} in the background.\")\n            raise SRExit()\n\n        # If the application is not running, then start the application\n        success = subprocess.Popen([str(self.config.game_path)], shell=True)\n        if self.wait_to_start():\n            starrail_proc = self.get_starrail_process()\n            aprint(f\"[PID {starrail_proc.pid}] Honkai: Star Rail has started successfully!\")\n            return True\n\n        aprint(f\"Honkai: Star Rail failed to start due to an unknown reason.\")\n        return False\n\n    def terminate(self) -> bool:\n        aprint(\"Terminating Honkai: Star Rail...\", end=\"\\r\")\n        \n        # If the cached proc PID is working\n        if self.config.instance_pid != None:\n            try:\n                starrail_proc = psutil.Process(self.config.instance_pid)\n                if self.proc_is_starrail(starrail_proc):\n                    starrail_proc.terminate()\n                    aprint(\"Honkai: Star Rail terminated successfully.\")\n                    return True\n            except psutil.NoSuchProcess:\n                self.config.instance_pid = None\n                self.config.save_current_config()\n        \n        # If no PID is cached, find proc and termiante\n        starrail_proc = self.get_starrail_process()\n        if starrail_proc != None:\n            starrail_proc.terminate()\n            aprint(\"Honkai: Star Rail terminated successfully.\")\n            return True\n        \n        aprint(f\"Honkai: Star Rail is currently {Printer.to_lightred('not running')}.      \")\n        return False\n\n    def schedule(self):\n        # Scheduler implemented seperately in starrail/bin as of version 1.0.0\n        ...\n\n\n\n    # =============================================\n    # ===========| UTILITY FUNCTIONS | ============\n    # =============================================\n\n    def show_status(self, live=False):\n        aprint(\"Loading status for the Honkai: Star Rail process...\")\n        \n        def print_status():\n            starrail_proc = self.get_starrail_process()\n            headers = [Printer.to_lightblue(title) for title in [\"Title\", \"HSR Real-time Status\"]]\n            \n            data_dict = {\n                \"Status\"            : \"N/A\",\n                \"Process ID\"        : \"N/A\",\n                \"Started On\"        : \"N/A\",\n                \"CPU Percent\"       : \"N/A\",\n                \"CPU Affinity\"      : \"N/A\",\n                \"IO Operations\"     : \"N/A\",\n                \"RAM Usage\"         : \"N/A\"\n            }\n            \n            if starrail_proc != None: # Is running\n                data_dict[\"Status\"]          = bool_to_str(starrail_proc.is_running())\n                data_dict[\"Process ID\"]      = starrail_proc.pid\n                data_dict[\"Started On\"]      = DatetimeHandler.epoch_to_time_str(starrail_proc.create_time())\n                data_dict[\"CPU Percent\"]     = f\"{starrail_proc.cpu_percent(1)}%\"\n                data_dict[\"CPU Affinity\"]    = \",\".join([str(e) for e in starrail_proc.cpu_affinity()])\n                data_dict[\"IO Operations\"]   = f\"Writes: {starrail_proc.io_counters().write_count}, Reads: {starrail_proc.io_counters().read_count}\"\n                data_dict[\"RAM Usage\"]       = f\"{round(psutil.virtual_memory()[3]/1000000000, 4)} GB\"\n                \n            else:\n                data_dict[\"Status\"] = bool_to_str(False)\n                # All other options remain as N/A\n            \n            data_list = [[Printer.to_lightpurple(k), v] for k, v in data_dict.items()]\n            table = tabulate.tabulate(data_list, headers=headers)\n            print(\"\\n\" + table + \"\\n\")\n            \n            if live:\n                print(CURSOR_UP_ANSI * (len(data_list)+5))\n        \n        try:\n            if live:\n                # TODO: Force CLI mode before allowing live status\n                while True:\n                    print_status()\n            else:\n                print_status()\n        except KeyboardInterrupt:\n            aprint(\"Status reading stopped.\")\n            raise SRExit()\n    \n            \n    def show_config(self):\n        # print(Printer.to_lightpurple(\"\\n - Game Configuration Table -\"))\n        headers = [Printer.to_lightblue(title) for title in [\"Title\", \"Details\", \"Relevant Command\"]]\n        data = [\n            [\"Game Version\", self.fetch_game_version(), color_cmd(\"starrail version\")],\n            [\"Game Executable\", os.path.normpath(self.config.game_path), color_cmd(\"starrail start/stop\")],\n            [\"Game Screenshots\", self.__get_screenshot_path(), color_cmd(\"starrail screenshots\")],\n            [\"Game Logs\", self.__get_log_path(), color_cmd(\"starrail game-logs\")],\n            [\"Game (.exe) SHA256\", HashCalculator.SHA256(self.config.game_path) if self.config.game_path.exists() else None, \"\"]\n        ]\n        for row in data:\n            row[0] = Printer.to_lightpurple(row[0])\n        \n        table = tabulate.tabulate(data, headers=headers)\n        print(\"\\n\" + table + \"\\n\")\n        \n    def screenshots(self):\n        aprint(\"Opening the screenshots directory...\", end=\"\\r\")\n        screenshot_path = self.__get_screenshot_path()\n\n        if not os.path.isdir(screenshot_path):\n            aprint(f\"No screenshots taken.{\" \"*20}\")\n            return\n\n        os.startfile(screenshot_path)\n        aprint(f\"Screenshots directory opened in the File Explorer ({Printer.to_lightgrey(screenshot_path)}).\")\n\n    def logs(self):\n        aprint(\"Opening the logs directory...\", end=\"\\r\")\n        logs_path = self.__get_log_path()\n        \n        if not os.path.isdir(logs_path):\n            aprint(\"No logs directory available locally.\")\n            return\n\n        os.startfile(logs_path)\n        aprint(f\"Logs directory opened in the File Explorer ({Printer.to_lightgrey(logs_path)}).\")\n\n    def show_pulls(self):\n        aprint(\"Showing the pull history page...\", end=\"\\r\")\n        cache_urls = self.webcache_controller.get_events_cache()\n        if cache_urls == None:\n            aprint(\"No pull history cache found locally.\")\n            return\n        \n        for url in cache_urls:\n            webbrowser.open(url)\n            \n        aprint(\"All pages opened successfully.      \")\n        \n    def verbose_play_time(self):\n        sr_proc = self.get_starrail_process()\n        if sr_proc == None:\n            aprint(\"Honkai: Star Rail is currently not running.\")\n            return\n        \n        ctime = sr_proc.create_time()\n        time_delta = datetime.now() - DatetimeHandler.epoch_to_datetime(ctime)    \n        aprint(f\"{Printer.to_lightblue('Session Time:')} {DatetimeHandler.seconds_to_time_str(time_delta.seconds)}\")\n\n\n    # =============================================\n    # ==========| WEBCACHE FUNCTIONS | ============\n    # =============================================\n\n    def webcache_announcements(self):\n        aprint(\"Decoding announcement webcache...\")\n        cache_urls = self.webcache_controller.get_announcements_cache()\n        \n        if cache_urls == None:\n            aprint(\"No announcement webcache found.\")\n        else:\n            self.__print_cached_urls(cache_urls)\n\n    def webcache_events(self):\n        aprint(\"Decoding events/pulls webcache...\")\n        cache_urls = self.webcache_controller.get_events_cache()\n        \n        if cache_urls == None:\n            aprint(\"No events webcache found.\")\n        else:\n            self.__print_cached_urls(cache_urls)\n\n    def webcache_all(self):\n        e_cache_urls = self.webcache_controller.get_events_cache()\n        a_cache_urls = self.webcache_controller.get_announcements_cache()\n\n        if e_cache_urls == None or len(e_cache_urls) == 0 and \\\n            a_cache_urls == None or len(a_cache_urls) == 0:\n            aprint(\"No available webcache found.\")\n            return\n        else:\n            print(\"\")\n\n        new_line = False\n        if e_cache_urls != None:\n            new_line = True\n            print(Printer.to_lightblue(\" - Events/Pulls Web Cache -\"))\n            self.__print_cached_urls(e_cache_urls)\n        \n        if a_cache_urls != None:\n            if new_line:\n                print(\"\")\n            print(Printer.to_lightblue(\" - Announcements Web Cache -\"))\n            self.__print_cached_urls(a_cache_urls)\n    \n    def __print_cached_urls(self, url_list):\n        for idx, url in enumerate(url_list):\n            print(f\"[{Printer.to_lightpurple(f'URL {idx+1}')}] \" + url)\n\n\n    # =================================================\n    # ========| STREAMING ASSETS FUNCTIONS | ==========\n    # =================================================\n    \n    def streaming_assets(self):\n        aprint(\"Decoding streaming assets...\")\n        \n        headers = [Printer.to_lightblue(title) for title in [\"Title\", \"Details (binary)\"]]\n        \n        sa_binary_dict = self.streaming_assets_controller.get_sa_binary_version()\n        sa_client_dict = self.streaming_assets_controller.get_sa_client_config()\n        sa_dev_dict = self.streaming_assets_controller.get_sa_dev_config()\n        \n        master_dict = merge_dicts(sa_binary_dict, sa_client_dict, sa_dev_dict)\n        \n        if len(master_dict) > 0:\n            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\"]\n            if \"Other\" in master_dict.keys():\n                data = master_dict[\"Other\"]\n                master_list.append([Printer.to_lightpurple(\"Other\"), \"\\n\".join(data) if isinstance(data, list) else data])\n            \n            table = tabulate.tabulate(master_list, headers)\n            print(\"\\n\" + table + \"\\n\")\n        \n\n\n    # =============================================\n    # =========| PATH HELPER FUNCTIONS | ==========\n    # =============================================\n\n    def __get_screenshot_path(self):\n        return os.path.normpath(os.path.join(self.config.innr_path, \"StarRail_Data\", \"ScreenShots\"))\n\n    def __get_log_path(self):\n        return os.path.normpath(os.path.join(self.config.innr_path, \"logs\"))\n\n    def __get_game_config_path(self):\n        return os.path.normpath(os.path.join(self.config.innr_path, \"config.ini\"))\n    \n    def fetch_game_version(self):\n        config = configparser.ConfigParser()\n        config.read(self.__get_game_config_path())\n        \n        game_version = \"N/A\"\n\n        try:\n            game_version = config.get('general', 'game_version')\n        except configparser.NoSectionError:\n            try:\n                game_version = config.get('General', 'game_version')\n            except configparser.NoSectionError:\n                pass\n\n        return game_version\n    \n    \n    \n    # =============================================\n    # ======| START/STOP HELPER FUNCTIONS | =======\n    # =============================================\n    \n    def wait_to_start(self, timeout = 30) -> bool:\n        starting_time = time.time()\n        while time.time() - starting_time < timeout:\n            if self.is_running():\n                return True\n            time.sleep(0.5)\n        return False\n\n    def is_running(self) -> bool:\n        return self.get_starrail_process() != None\n    \n    def is_focused(self) -> bool:\n        hsr_proc = self.get_starrail_process()\n        if hsr_proc == None:\n            return False\n        \n        focused_pid = ProcessHandler.get_focused_pid()\n        if hsr_proc.pid == focused_pid:\n            return True\n        return False\n    \n    def get_starrail_process(self) -> psutil.Process:\n        EXE_BASENAME = os.path.basename(self.config.game_path)\n        \n        for p in psutil.process_iter(['pid', 'name', 'exe']):\n            # name == EXE_BASENAME is for optimization only (proceed only if filename is the same)\n            if p.info[\"name\"] == EXE_BASENAME:\n\n                    try:\n                        starrail_proc = psutil.Process(p.info['pid'])\n                        if self.proc_is_starrail(starrail_proc):\n                            \n                            # If the cached PID isn't the current PID, reset it\n                            if self.config.instance_pid != starrail_proc.pid:\n                                self.config.instance_pid = starrail_proc.pid\n                                self.config.save_current_config()\n                            \n                            return starrail_proc\n                    \n                    except psutil.NoSuchProcess:\n                        continue\n        return None\n    \n    def proc_is_starrail(self, starrail_proc: psutil.Process):\n        return starrail_proc.is_running() and Path(starrail_proc.exe()) == Path(self.config.game_path)\n\n\n\n    "
  },
  {
    "path": "src/starrail/controllers/streaming_assets_controller.py",
    "content": "import os\nimport re\nimport sys\nfrom pathlib import Path\n\nfrom enum import Enum\nfrom starrail.config.config_handler import StarRailConfig\n\nfrom starrail.utils.utils import aprint, Printer\nfrom starrail.constants import WEBCACHE_IGNORE_FILETYPES, GAME_FILE_PATH, GAME_FILE_PATH_NEW\nfrom starrail.utils.binary_decoder import StarRailBinaryDecoder\n\n\nSUBMODULE_NAME = \"SR-SAC\"\n\n\nclass StarRailStreamingAssetsBinaryFile(Enum):\n    SA_BinaryVersion    = \"BinaryVersion.bytes\"\n    SA_ClientConfig     = \"ClientConfig.bytes\"\n    SA_DevConfig        = \"DevConfig.bytes\"   \n\n\nclass StarRailStreamingAssetsController:\n    def __init__(self, starrail_config: StarRailConfig):\n        self.starrail_config = starrail_config\n        self.binary_decoder = StarRailBinaryDecoder()\n        \n    # =============================================\n    # ============| DRIVER FUNCTIONS | ============\n    # =============================================\n    \n    def get_decoded_streaming_assets(self, sa_binary_file: StarRailStreamingAssetsBinaryFile):\n        decoded_strings = self.decode_streaming_assets(sa_binary_file)\n        if decoded_strings == None:\n            return None\n        \n        filtered_dict = self.parse_webcache(decoded_strings, sa_binary_file)\n        return filtered_dict\n        \n\n    def get_sa_binary_version(self):\n        return self.get_decoded_streaming_assets(StarRailStreamingAssetsBinaryFile.SA_BinaryVersion)\n    \n    def get_sa_client_config(self):\n        return self.get_decoded_streaming_assets(StarRailStreamingAssetsBinaryFile.SA_ClientConfig)\n    \n    def get_sa_dev_config(self):\n        return self.get_decoded_streaming_assets(StarRailStreamingAssetsBinaryFile.SA_DevConfig)\n    \n    \n    # =============================================\n    # ==========| SUBDRIVER FUNCTIONS | ===========\n    # =============================================\n    \n    def decode_streaming_assets(self, sa_binary_file: StarRailStreamingAssetsBinaryFile):\n        file_path = os.path.join(self.starrail_config.innr_path, \"StarRail_Data\", \"StreamingAssets\", sa_binary_file.value)\n        if not os.path.isfile(file_path):\n            aprint(Printer.to_lightred(f\"Decoder cannot locate streaming assets file '{file_path}'.\"))\n            return\n        \n        aprint(f\"Decoding {Printer.to_lightgrey(file_path)} ...\", submodule_name=SUBMODULE_NAME)\n        \n        try:\n            return self.binary_decoder.decode_raw_binary_file(file_path)\n        except PermissionError as ex:\n            aprint(f\"{Printer.to_lightred('Permission denied.')}\")\n        return None\n    \n    \n    def parse_webcache(self, decoded_strings, sa_binary_file: StarRailStreamingAssetsBinaryFile):\n        data_dict = dict()\n        \n        if sa_binary_file == StarRailStreamingAssetsBinaryFile.SA_BinaryVersion:\n            for string in decoded_strings:\n                string = string.strip()\n                \n                if re.search(\"PRODWin[0-9]{1}.[0-9]{1}.[0-9]{1}\", string) != None:\n                    data_dict[\"Detailed Version\"] = string\n                \n                elif re.search(\"V[0-9]{1}.[0-9]{1}\", string) != None:\n                    data_dict[\"Version\"] = string\n                \n                elif re.search(\"[0-9]{8}-[0-9]{4}\", string) != None:\n                    data_dict[\"Datetime String\"] = string\n                    \n                else:\n                    try:\n                        endpoints = data_dict[\"Other\"]\n                        endpoints.append(string)\n                        data_dict[\"Other\"] = endpoints\n                    except KeyError:\n                        data_dict[\"Other\"] = [string]\n        \n        \n        if sa_binary_file == StarRailStreamingAssetsBinaryFile.SA_ClientConfig:\n            for string in decoded_strings:\n                string = string.strip()\n                \n                match = re.search(\"https.*\", string)\n                if string.startswith(\"com.\"):\n                    data_dict[\"Application Identifier\"] = string\n                \n                \n                elif match != None:\n                    try:\n                        endpoints = data_dict[\"Server Endpoints\"]\n                        endpoints.append(match.group(0))\n                        data_dict[\"Service Endpoints\"] = endpoints\n                    except KeyError:\n                        data_dict[\"Service Endpoints\"] = [match.group(0)]\n                \n                else:\n                    try:\n                        endpoints = data_dict[\"Unknown\"]\n                        endpoints.append(string)\n                        data_dict[\"Unknown\"] = endpoints\n                    except KeyError:\n                        data_dict[\"Unknown\"] = [string]  \n                        \n\n        if sa_binary_file == StarRailStreamingAssetsBinaryFile.SA_DevConfig:\n            for string in decoded_strings:\n                string = string.strip()\n                \n                if re.search(\"V[0-9]{1}.[0-9]{1}\", string) != None:\n                    \n                    if \"EngineRelease\" in string:\n                        data_dict[\"Engine Version\"] = string\n                    else:\n                        data_dict[\"Unknown Version\"] = string\n                \n                else:\n                    try:\n                        endpoints = data_dict[\"Unknown\"]\n                        endpoints.append(string)\n                        data_dict[\"Unknown\"] = endpoints\n                    except KeyError:\n                        data_dict[\"Unknown\"] = [string]\n        \n        \n        if len(data_dict) > 0:\n            return data_dict\n        return None\n    "
  },
  {
    "path": "src/starrail/controllers/web_controller.py",
    "content": "\nimport webbrowser\nfrom starrail.constants import HOMEPAGE_URL, HOMEPAGE_URL_CN, HOYOLAB_URL, YOUTUBE_URL, BILIBILI_URL\nfrom starrail.utils.utils import aprint, Printer\n\nclass StarRailWebController:\n    def __init__(self):\n        pass\n    \n    def homepage(self, cn=False):\n        if cn:\n            aprint(f\"Opening Honkai: Star Rail's official home page (CN)...\\n - {Printer.to_lightgrey(HOMEPAGE_URL_CN)}\")\n            webbrowser.open(HOMEPAGE_URL_CN)\n        else:\n            aprint(f\"Opening Honkai: Star Rail's official home page...\\n - {Printer.to_lightgrey(HOMEPAGE_URL)}\")\n            webbrowser.open(HOMEPAGE_URL)\n        \n    def hoyolab(self):\n        aprint(f\"Opening Honkai: Star Rail's HoyoLab page...\\n - {Printer.to_lightgrey(HOYOLAB_URL)}\")\n        webbrowser.open(HOYOLAB_URL)\n        \n    def youtube(self):\n        aprint(f\"Opening Honkai: Star Rail's Youtube page...\\n - {Printer.to_lightgrey(YOUTUBE_URL)}\")\n        webbrowser.open(YOUTUBE_URL)\n        \n    def bilibili(self):\n        aprint(f\"Opening Honkai: Star Rail's BiliBili page...\\n - {Printer.to_lightgrey(BILIBILI_URL)}\")\n        webbrowser.open(BILIBILI_URL)"
  },
  {
    "path": "src/starrail/controllers/webcache_controller.py",
    "content": "\nimport os\nimport re\nimport sys\nimport shutil\nfrom pathlib import Path\n\nfrom enum import Enum\nfrom starrail.config.config_handler import StarRailConfig\n\nfrom starrail.utils.utils import aprint, Printer\nfrom starrail.constants import WEBCACHE_IGNORE_FILETYPES\nfrom starrail.utils.binary_decoder import StarRailBinaryDecoder\n\n\nSUBMODULE_NAME = \"SR-WCC\"\n\n\nclass StarRailWebCacheBinaryFile(Enum):\n    WEBCACHE_DATA0 = \"data_0\"\n    WEBCACHE_DATA1 = \"data_1\"   # Anncouncements\n    WEBCACHE_DATA2 = \"data_2\"   # Events/Pulls\n\n\nclass StarRailWebCacheController:\n    def __init__(self, starrail_config: StarRailConfig):\n        self.starrail_config = starrail_config\n        self.binary_decoder = StarRailBinaryDecoder()\n        \n    # =============================================\n    # ============| DRIVER FUNCTIONS | ============\n    # =============================================\n    \n    def get_decoded_webcache(self, webcache_binary_file: StarRailWebCacheBinaryFile):\n        decoded_strings = self.decode_webcache(webcache_binary_file)\n        if decoded_strings == None:\n            return None\n        \n        filtered_urls = self.parse_webcache(decoded_strings, webcache_binary_file)\n        return filtered_urls\n        \n    def get_announcements_cache(self):\n        return self.get_decoded_webcache(StarRailWebCacheBinaryFile.WEBCACHE_DATA1)\n    \n    def get_events_cache(self):\n        return self.get_decoded_webcache(StarRailWebCacheBinaryFile.WEBCACHE_DATA2)\n    \n    \n    # =============================================\n    # ==========| SUBDRIVER FUNCTIONS | ===========\n    # =============================================\n\n    def decode_webcache(self, webcache_binary_file: StarRailWebCacheBinaryFile):\n\n        # Get webCache path (varying webcache versioning)\n        def get_webcache_path():\n            webcache_path = \"\"\n            webcache_semi_path = Path(os.path.join(self.starrail_config.innr_path, \"StarRail_Data\", \"webCaches\"))\n            \n            try:\n                version_dirs = [d for d in webcache_semi_path.iterdir() if d.is_dir()]\n                if len(version_dirs) > 0:\n                    version_dir = version_dirs[0]\n                    webcache_path = os.path.join(self.starrail_config.innr_path, \"StarRail_Data\", \"webCaches\", version_dir, \"Cache\", \"Cache_Data\", webcache_binary_file.value)\n                    return webcache_path\n                else:\n                    return None\n            except Exception as ex:\n                return None\n\n        file_path = get_webcache_path()\n        if file_path == None or not os.path.isfile(file_path):\n            return None\n        \n        aprint(f\"Decoding {webcache_binary_file.value} ({Printer.to_lightgrey(file_path)}) ...\", submodule_name=SUBMODULE_NAME)\n        \n        try:\n            return self.binary_decoder.decode_raw_binary_file(file_path)\n        except PermissionError:\n            aprint(f\"{Printer.to_lightred('Web cache is LOCKED.')} Web cache is only available when the game is not running.\", submodule_name=SUBMODULE_NAME)\n        return None\n    \n    \n    def parse_webcache(self, decoded_strings, webcache_file: StarRailWebCacheBinaryFile):\n        if webcache_file == StarRailWebCacheBinaryFile.WEBCACHE_DATA0:\n            pass\n        if webcache_file == StarRailWebCacheBinaryFile.WEBCACHE_DATA1:\n            return self.filter_urls(\"webstatic.mihoyo.com/hkrpg/announcement\", decoded_strings)\n        if webcache_file == StarRailWebCacheBinaryFile.WEBCACHE_DATA2:\n            return self.filter_urls(\"webstatic.mihoyo.com/hkrpg/event\", decoded_strings)\n        return None\n    \n    # =============================================\n    # ============| HELPER FUNCTIONS | ============\n    # =============================================\n    \n    def filter_urls(self, target_sequence: str, decoded_strings):\n        # Filter out URLs without the target sequence and end with the ignoring file types\n        filtered_urls = []\n        for url in decoded_strings:\n            url = url.strip()\n            if target_sequence in url and not self.should_ignore(url):\n                match = re.search(\"https.*\", url)\n                if match != None:\n                    filtered_urls.append(match.group(0)) \n        return filtered_urls\n    \n    \n    def should_ignore(self, url: str):\n        # Should ignore current URL because of its file type\n        for ignoring_file_type in WEBCACHE_IGNORE_FILETYPES:\n            if url.endswith(ignoring_file_type):\n                return True\n        return False"
  },
  {
    "path": "src/starrail/data/textfiles/disclaimer.txt",
    "content": "=====================================================================\n================== | STARRAIL PACKAGE DISCLAIMER | ==================\n=====================================================================\n\nThe \"starrail\" Python 3 module is an external CLI tool designed \nto automate the gameplay of Honkai Star Rail. It is designed solely \ninteracts with the game through the existing user interface, and it \nabides by the Fair Gaming Declaration set forth by COGNOSPHERE PTE. \nLTD. The package is designed to provide a streamlined and efficient \nway for users to interact with the game through features already \nprovided within the game, and it does not, in any way, intend\nto damage the balance of the game or provide any unfair advantages.\nThe package does NOT modify any files in any way.\n\nThe creator(s) of this package has no relationship with MiHoYo, the\ngame's developer. The use of this package is entirely at the user's\nown risk, and the creator accepts no responsibility for any damage or\nloss caused by the package's use. It is the user's responsibility to\nensure that they use the package according to Honkai Star Rail's Fair\nGaming Declaration, and the creator accepts no responsibility for any\nconsequences resulting from its misuse, including game account\npenalties, suspension, or bans.\n\nPlease note that according to MiHoYo's Honkai: Star Rail Fair Gaming\nDeclaration (https://hsr.hoyoverse.com/en-us/news/111244):\n\n    \"It is strictly forbidden to use external plug-ins, game\n    accelerators/boosters, scripts, or any other third-party tools\n    that damage the balance of the game. Once discovered, COGNOSPHERE\n    PTE. LTD. (referred to as \"we\" henceforth) will take appropriate\n    actions depending on the severity and frequency of the offenses.\n    These actions include removing rewards obtained through such\n    infringements, suspending the game account, or permanently\n    banning the game account. Therefore, the user of this package\n    must be aware that the use of this package may result in the\n    above actions being taken against their game account by MiHoYo.\"\n\nBy using this package, the user agrees to ALL terms and conditions\nand acknowledges that the creator will not be held liable for any\nnegative outcomes that may occur as a result of its use.\n"
  },
  {
    "path": "src/starrail/data/textfiles/webcache_explain.txt",
    "content": "Web cache for Honkai: Star Rail stores recent web data. \nYou can open URLs to view announcements, events, or pull status, \n    allowing quick access without loading into the game."
  },
  {
    "path": "src/starrail/entrypoints/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/entrypoints/entrypoint_handler.py",
    "content": "import readline\nimport argparse\nimport getpass\nimport webbrowser\nimport tabulate\nimport subprocess\n\nfrom starrail.constants import BASENAME, AUTHOR, VERSION, VERSION_DESC, AUTHOR_DETAIL, REPOSITORY, CURSOR_UP_ANSI, REPOSITORY, STARRAIL_DIRECTORY, ISSUES\nfrom starrail.utils.utils import *\nfrom starrail.utils.binary_decoder import  StarRailBinaryDecoder\nfrom starrail.utils.game_detector import StarRailGameDetector\nfrom starrail.utils.perm_elevate import StarRailPermissionsHandler\nfrom starrail.exceptions.exceptions import SRExit, StarRailBaseException\n\nfrom starrail.controllers.star_rail_app import HonkaiStarRail\nfrom starrail.controllers.web_controller import StarRailWebController\nfrom starrail.controllers.automation_controller import StarRailAutomationController\nfrom starrail.controllers.c_click_controller import ContinuousClickController\n\nfrom starrail.bin.loader.loader import Loader\nfrom starrail.bin.scheduler.starrail_scheduler import StarRailScheduler, OperationTypes\n\n\nclass StarRailEntryPointHandler:\n    def __init__(self):\n        self.star_rail              = HonkaiStarRail()\n        self.web_controller         = StarRailWebController()\n        self.scheduler              = StarRailScheduler(self.star_rail) # Only the start/stop is used from the starrail instance\n        self.automation_controller  = StarRailAutomationController(self.star_rail)\n    \n    \n    # ================================================\n    # ==================| PROJECT | ==================\n    # ================================================\n    def about(self, args):\n        headers = [Printer.to_lightblue(title) for title in [\"Title\", \"Description\"]]\n        data = [\n            [\"Module\",      BASENAME],\n            [\"Version\",     f\"{VERSION_DESC}-{VERSION}\"],\n            [\"Author\",      AUTHOR],\n            [\"Repository\",  REPOSITORY],\n            [\"Directory\",   STARRAIL_DIRECTORY]\n        ]\n        for row in data:\n            row[0] = Printer.to_lightpurple(row[0])\n        print(\"\\n\" + tabulate.tabulate(data, headers=headers) + \"\\n\")\n    \n    def version(self, args):\n        headers = [Printer.to_lightblue(title) for title in [\"Program\", \"Description\", \"Version\"]]\n        data = [\n            [\"Honkai: Star Rail\", \"Game\", self.star_rail.fetch_game_version()],\n            [BASENAME, \"Module\", f\"{VERSION_DESC}-{VERSION}\"]\n        ]\n        for row in data:\n            row[0] = Printer.to_lightpurple(row[0])\n        print(\"\\n\" + tabulate.tabulate(data, headers=headers) + \"\\n\")\n\n    def author(self, args):\n        aprint(AUTHOR_DETAIL)\n    \n    def repo(self, args):\n        aprint(REPOSITORY)\n        if args.open:\n            webbrowser.open(REPOSITORY)\n\n    \n    # =================================================\n    # ==================| GAME INFO | =================\n    # =================================================\n    \n    def show_status(self, args):\n        self.star_rail.show_status(args.live)\n    \n    def show_config(self, args):\n        self.star_rail.show_config()\n        \n    def show_details(self, args):\n        self.star_rail.streaming_assets()\n    \n    def play_time(self, args):\n        self.star_rail.verbose_play_time()    \n    \n    # =================================================\n    # ===============| LAUNCH DRIVER | ================\n    # =================================================\n    \n    def start(self, args):\n        self.star_rail.start()\n        \n    def stop(self, args):\n        self.star_rail.terminate()\n        \n    def schedule(self, args):\n        \n        def verbose_add_usage():\n            aprint(f\"To schedule a new game start, run:\\n{color_cmd('starrail schedule add --action start --time 10:30', True)}\")\n        \n        def verbose_general_usage():\n            headers = [Printer.to_lightpurple(title) for title in [\"Example Command\", \"Description\"]]\n            data = [\n                [color_cmd(\"schedule add --time 10:30 --action start\"), \"Schedule Honkai Star Rail to START at 10:30 AM\"],\n                [color_cmd(\"schedule add --time 15:30 --action stop\"),  \"Schedule Honkai Star Rail to STOP  at 3:30 PM\"],\n                [color_cmd(\"schedule remove\"), \"Remove an existing scheduled job\"], \n                [color_cmd(\"schedule show\"), \"Show all scheduled jobs and their details\"],\n                [color_cmd(\"schedule clear\"), \"Cancel all schedule jobs (irreversible)\"]\n            ]\n            \n            tab = tabulate.tabulate(data, headers)\n            print(\"\\n\" + tab + \"\\n\")\n\n        \n        if args.subcommand == \"add\":\n            op_type = None\n            if args.action.lower().strip() == \"start\":\n                op_type = OperationTypes.START\n            elif args.action.lower().strip() == \"stop\":\n                op_type = OperationTypes.END\n            else:\n                verbose_add_usage()\n                return\n                \n            if not args.time:\n                verbose_add_usage()\n                return\n                \n            self.scheduler.add_new_schedule(args.time, op_type)\n            \n        elif args.subcommand == \"remove\":\n            self.scheduler.remove_schedule()\n            \n        elif args.subcommand == \"show\":\n            self.scheduler.show_schedules()\n        \n        elif args.subcommand == \"clear\":\n            self.scheduler.clear_schedules()\n        \n        elif args.subcommand == \"help\":\n            verbose_general_usage()\n\n        else:\n            if args.subcommand != None:\n                aprint(Printer.to_lightred(f\"Unknown scheduling action: '{args.subcommand}'\"))\n            verbose_general_usage()\n        \n        \n    # =================================================\n    # =================| AUTOMATION | =================\n    # =================================================\n    \n    def automation(self, args):\n        \n        def force_admin():\n            if not is_admin():\n                aprint(f\"{Printer.to_lightred('Admin permission required for automation')}.\\nWould you like to elevate the permissions to admin? [y/n] \", end=\"\")\n                if input(\"\").strip().lower() == \"y\":\n                    StarRailPermissionsHandler.elevate()\n                    raise SRExit()\n                else:\n                    raise SRExit()\n        \n        if args.action == \"record\":\n            force_admin()\n            self.automation_controller.record_sequences()\n        \n        elif args.action == \"run\":\n            force_admin()\n            self.automation_controller.run_requence()\n\n        elif args.action == \"show\":\n            self.automation_controller.show_sequences()\n            \n        elif args.action == \"remove\":\n            self.automation_controller.delete_sequence()\n\n        elif args.action == \"clear\":\n            self.automation_controller.clear_sequences()\n\n        else:\n            if args.action != None:\n                aprint(Printer.to_lightred(f\"Unknown automation action: '{args.action}'\"))\n            self.automation_controller.verbose_general_usage()\n        \n    def click_continuously(self, args):\n        def force_admin(args):\n            if not is_admin():\n                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=\"\")\n                if input(\"\").strip().lower() == \"y\":\n                    StarRailPermissionsHandler.elevate([\n                        \"click\",\n                        \"--clicks\", args.clicks,\n                        \"--interval\", args.interval,\n                        \"--randomize\", args.randomize,\n                        \"--hold\", args.hold,\n                        \"--delay\", args.delay,\n                        \"--quiet\" if args.quiet else \"\"\n                    ])\n                    raise SRExit()\n                else:\n                    raise SRExit()\n        \n        cc_controller = ContinuousClickController()\n        force_admin(args)\n        cc_controller.click_continuously(args.clicks, args.interval, args.randomize, args.hold, args.delay, args.quiet)\n        \n    # =================================================\n    # ==================| KEY URLS | ==================\n    # =================================================\n    \n    def homepage(self, args):\n        self.web_controller.homepage(args.cn)\n        \n    def hoyolab(self, args):\n        self.web_controller.hoyolab()\n        \n    def youtube(self, args):\n        self.web_controller.youtube()\n        \n    def bilibili(self, args):\n        self.web_controller.bilibili()\n        \n    # ===================================================\n    # ===============| UTILITY FUNCTIONS | ==============\n    # ===================================================\n\n    def screenshots(self, args):\n        self.star_rail.screenshots()\n\n    def game_logs(self, args):\n        self.star_rail.logs()\n\n    def decode(self, args):\n        ascii_binary_decoder = StarRailBinaryDecoder()\n        ascii_binary_decoder.user_decode(args.path, args.min_length)\n\n    def pulls(self, args):\n        self.star_rail.show_pulls()\n\n    def webcache(self, args):\n        if not args.quiet:\n            print_webcache_explanation()\n        \n        if args.announcements:\n            self.star_rail.webcache_announcements()\n        elif args.events:\n            self.star_rail.webcache_events()\n        else:\n            self.star_rail.webcache_all()\n            \n\n\n    # =================================================\n    # =============| BLOCKING FUNCTIONS | =============\n    # =================================================\n    \n    def configure(self, args):\n        \n        if self.star_rail.config.full_configured():\n            aprint(Printer.to_lightgreen(\"Configuration Already Completed!\"))\n            return\n        \n        # os.system(\"cls\")\n        \n        if self.star_rail.config.disclaimer_configured() == False:\n            # print(Printer.to_skyblue(\"\\n\\n - STEP 1 OF 2. DISCLAIMER AGREEMENT - \\n\"))\n            step_two_text = \" - STEP 1 OF 2. DISCLAIMER AGREEMENT - \"\n            print(Printer.to_skyblue(f\"\\n\\n {'='*len(step_two_text)}\"))\n            print(Printer.to_skyblue(f\" {step_two_text}\"))\n            print(Printer.to_skyblue(f\" {'='*len(step_two_text)}\\n\"))\n            print_disclaimer()\n            \n            print(Printer.to_lightred(\"[IMPORTANT] Please read the disclaimer above before continuing!\"))\n            if input(\"AGREE? [y/n] \").lower() == \"y\":\n                self.star_rail.config.disclaimer = True\n                self.star_rail.config.save_current_config()\n            else:\n                return\n        \n        if self.star_rail.config.path_configured() == False:\n            step_two_text = \"- STEP 2 OF 2. SET UP GAME PATH (StarRail.exe) -\"\n            print(Printer.to_skyblue(f\"\\n\\n {'='*len(step_two_text)}\"))\n            print(Printer.to_skyblue(f\" {step_two_text}\"))\n            print(Printer.to_skyblue(f\" {'='*len(step_two_text)}\\n\"))\n            \n            logt = f\"Auto Detecting Honkai: Star Rail ({Printer.to_lightgrey('this may take a while')})...\"\n            with Loader(logt, end=None):\n                star_rail_game_detector = StarRailGameDetector()\n                game_path = star_rail_game_detector.find_game()\n                \n                if game_path == None:\n                    print(CURSOR_UP_ANSI, flush=True)\n                    aprint(Printer.to_lightred(\"Cannot locate game Honkai: Star Rail.\") + \" \"*15 + f\"\\nFile issue at '{ISSUES}' for more help.\")\n                    raise SRExit()\n\n                self.star_rail.config.set_path(game_path)\n                self.star_rail.config.save_current_config()\n\n                print(CURSOR_UP_ANSI, flush=True)\n                aprint(\"Game found at: \" + game_path + \" \"*10)\n            \n        aprint(\"Configuration Complete!\")\n        \n\n    # ===================================================\n    # ===============| HELPER FUNCTIONS | ===============\n    # ===================================================\n    \n    def elevate(self, args):\n        StarRailPermissionsHandler.elevate(args.arguments)\n\n\n    # =================================================\n    # ===============| CLI DRIVERS | ==================\n    # =================================================\n    \n    def print_title(self):\n        \n        # columns, _ = shutil.get_terminal_size(fallback=(80, 20))\n        # bar = '▬' * (columns//1 - 12)\n        # bar = Printer.to_lightblue(f\"▷ ▷ ▷ {bar} ◁ ◁ ◁\")\n        # print(bar)\n        \n        title = Printer.to_lightblue(\nr\"\"\"\n ____  _             ____       _ _    ____ _     ___ \n/ ___|| |_ __ _ _ __|  _ \\ __ _(_) |  / ___| |   |_ _|\n\\___ \\| __/ _` | '__| |_) / _` | | | | |   | |    | | \n ___) | || (_| | |  |  _ < (_| | | | | |___| |___ | | \n|____/ \\__\\__,_|_|  |_| \\_\\__,_|_|_|  \\____|_____|___|\n\"\"\")\n        \n        desc = Printer.to_purple(\"\"\"A lightweight Command Line Utility For Honkai: Star Rail!\"\"\")\n        postfix = Printer.to_lightgrey(REPOSITORY)\n        author = Printer.to_lightgrey(f\"By {AUTHOR}\")\n        \n        print(center_text(title))\n        print_centered(f\"{desc}\\n{postfix}\\n{author}\\n\")\n    \n    def print_init_help(self):\n        access_time = colored(f\"Access Time: {DatetimeHandler.get_datetime_str()}\", \"dark_grey\")\n        username = getpass.getuser()\n        isadmin = \"ADMIN\" if is_admin() else \"USER\"\n        \n        quit_cmd = Printer.to_purple(\"exit\")\n        cls_cmd = Printer.to_purple(\"clear\")\n        help_cmd = Printer.to_purple(\"help\")\n        \n        dev_str = \"\"\n        # if DEVELOPMENT:\n        #     dev_str = f\"({Printer.to_lightred(\"Dev=True\")}) {Printer.to_lightgrey(\"Development commands available.\")}\\n\"\n            \n        welcome_str = f\"Welcome to the {BASENAME} Environment ({VERSION_DESC}-{VERSION})\"\n        exit_str = f\"Type '{quit_cmd}' to quit {SHORTNAME} CLI\"\n        cls_str = f\"Type '{cls_cmd}' to clear terminal\"\n        help_str = f\"Type '{help_cmd}' to display commands list\"\n    \n        print(f\"{dev_str}{welcome_str}\\n  {help_str}\\n  {exit_str}\\n  {cls_str}\\n\")\n    \n    def clear_screen(self):\n        os.system('cls')\n    \n    def check_custom_commands(self, user_input: str):\n        # Return 0 to continue loop, 1 to short-circit loop and 'continue' loop, 2 to exit loop\n\n        if user_input.lower() in ['exit', 'quit']:\n            self.clear_screen()\n            return 2\n        \n        if user_input.lower() in ['clear', \"cls\", \"reset\"]:\n            self.clear_screen()\n            self.print_title()\n            \n            exit_cmd = color_cmd(\"exit\", with_quotes=True)\n            print(f\"Terminal cleared. Type {exit_cmd} to quit.\")\n            return 1\n        \n        if user_input.strip() == '':\n            return 1\n    \n        return 0\n    \n    def setup_key_bindings(self):\n        readline.parse_and_bind(r'\"\\C-w\": backward-kill-word')\n        readline.parse_and_bind(r'\"\\C-a\": beginning-of-line')\n        readline.parse_and_bind(r'\"\\C-e\": end-of-line')\n        readline.parse_and_bind(r'\"\\C-u\": unix-line-discard')\n        readline.parse_and_bind(r'\"\\C-k\": kill-line')\n        readline.parse_and_bind(r'\"\\C-y\": yank')\n        readline.parse_and_bind(r'\"\\C-b\": backward-char')\n        readline.parse_and_bind(r'\"\\C-f\": forward-char')\n        readline.parse_and_bind(r'\"\\C-p\": previous-history')\n        readline.parse_and_bind(r'\"\\C-n\": next-history')\n        readline.parse_and_bind(r'\"\\C-b\": backward-word')\n        readline.parse_and_bind(r'\"\\C-f\": forward-word')\n\n    def start_cli(self, parser: argparse.ArgumentParser):\n        prefilled = False\n        \n        # =============| SET CLI MODE |==============\n        constants.CLI_MODE = True\n        \n        # ============| SETUP READLINE |=============\n        self.setup_key_bindings()\n\n        # ==============| PRINT TITLE |==============\n        self.clear_screen()\n        self.print_title()\n        self.print_init_help()\n        \n        cli_env_alert = False\n        # ================| CLI LOOP |================\n        while True:\n            try:\n                starrail_cli = colored(f\"{DatetimeHandler.get_time_str()} StarRail\", \"dark_grey\")\n                print(f\"[{starrail_cli}] > \", end=\"\", flush=True)\n                \n                user_input = input().strip()\n                continue_loop = self.check_custom_commands(user_input)\n                if continue_loop == 1:\n                    continue\n                elif continue_loop == 2:\n                    break\n                \n                # Verify that the stdout and stderr aren't closed\n                if sys.stdout.closed or sys.stderr.closed:\n                    text = \"STDOUT and STDERR\" if sys.stdout.closed and sys.stderr.closed \\\n                        else \"STDOUT\" if sys.stdout.closed \\\n                        else \"STDERR\"\n                    aprint(f\"System {text} has been closed unexpectedly. Exiting {BASENAME} environment...\")\n                    sys.exit()\n                \n                # =============| PARSE ARGUMENT |=============\n                if user_input.startswith(COMMAND):\n                    user_input = user_input.lstrip(COMMAND).strip() \n                    \n                    if not cli_env_alert:\n                        print(Printer.to_lightgrey(f\"Currently in the StarRail CLI environment. You may call `{user_input}` directly without the `{COMMAND}` prefix.\"))\n                        cli_env_alert = not cli_env_alert\n                    \n                args = parser.parse_args(user_input.split())\n                if hasattr(args, 'func'):\n                    try:\n                        args.func(args)\n                    except SRExit:\n                        continue\n\n                else:\n                    parser.print_help()\n            \n        # ==============| ON EXCEPTION |==============\n            except KeyboardInterrupt:\n                # print(\"\\nNote: Type 'exit' to quit.\")\n                print(\"\")\n                continue\n            except SystemExit:\n                continue\n            \n\n    # ================================================\n    # ==================| FIREFLY | ==================\n    # ================================================\n    \n    def firefly(self, args):\n        lines = [\n            \"   I dreamed of a scorched earth.\",\n            \"   A new shoot sprouted from the earth.\",\n            \"   It bloomed in the morning sun\",\n            \"   ... and whispered to me.\",\n            \"   Like fyreflies to a flame...\",\n            \"   life begets death.\",\n            \"\",\n            \"                  -- Firefly S.A.M.\"\n        ]\n        text = \"\\n\".join(lines)\n        \n        firefly_colors = [\n            Printer.to_pale_yellow,\n            Printer.to_light_blue,\n            Printer.to_teal,\n            Printer.to_turquoise,\n            # Printer.to_dark_teal,\n            Printer.to_turquoise,\n            Printer.to_teal,\n            Printer.to_light_blue,\n            Printer.to_pale_yellow,\n        ]\n\n        result = \"\"\n        color_index = 0\n        for char in text:\n            if char != \"\\n\":\n                color_func = firefly_colors[color_index % len(firefly_colors)]\n                result += color_func(char)\n                color_index += 1\n            else:\n                result += \"\\n\"\n        print(\"\\n\" + result)\n            \n\n"
  },
  {
    "path": "src/starrail/entrypoints/entrypoints.py",
    "content": "import os\nimport sys\nimport time\nimport argparse\nstart_time = time.time()\n\nfrom starrail.entrypoints.entrypoint_handler import StarRailEntryPointHandler\nfrom starrail.entrypoints.help_format_handler import HelpFormatHandler\nfrom starrail.utils.utils import aprint, verify_platform, is_admin, Printer, color_cmd\nfrom starrail.constants import COMMAND, DEVELOPMENT\nfrom starrail.exceptions.exceptions import StarRailOSNotSupported, SRExit\n\n\nclass StarRailArgParser(argparse.ArgumentParser):\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.groups = []\n\n    def add_group(self, title, description=None):\n        group = {'title': title, 'description': description, 'parsers': []}\n        self.groups.append(group)\n        return group\n\n    def add_parser_to_group(self, group, parser):\n        group['parsers'].append(parser)\n        \n    def error(self, message):\n        if \"invalid choice:\" in message:\n            command = message.split(\"'\")[1]\n            helpt = Printer.to_purple(\"starrail help\")\n            aprint(f\"Command not recognized: {command}\\nType '{helpt}' for the commands list\")\n        else:\n            parts = message.split(\"'\")\n            if len(parts) > 1:\n                command = parts[1]\n                helpt = Printer.to_purple(f\"{command} --help\")\n                aprint(f\"{message.capitalize()}\\nType '{helpt}' for its arguments list\")\n            else:\n                helpt = Printer.to_purple(\"<command> --help\")\n                aprint(f\"{message.capitalize()}\\nType '{helpt}' for its arguments list\")\n        \n        self.exit(2)\n\n\ndef start_starrail():\n    os.system(\"\")  # Enables ANSI escape characters in terminal\n    \n    entrypoint_handler = StarRailEntryPointHandler()\n    help_format_handler = HelpFormatHandler()\n    \n    parser = StarRailArgParser(prog=COMMAND, description=\"StarRail CLI Module\")\n    subparsers = parser.add_subparsers(dest='command', help='commands')\n\n    help_parser = subparsers.add_parser('help', help='Show this help message and exit')\n    help_parser.set_defaults(func=lambda args: help_format_handler.print_help(args, parser))\n\n\n    # ================================================\n    # ==================| PROJECT | ==================\n    # ================================================\n    \n    about_group = parser.add_group('Package Info', 'Get information about the starrail package.')\n    \n    abt_parser = subparsers.add_parser('about', help='Verbose all information about this module', description='Verbose all information about this module')\n    abt_parser.set_defaults(func=entrypoint_handler.about)\n    parser.add_parser_to_group(about_group, abt_parser)\n    \n    version_parser = subparsers.add_parser('version', help='Verbose game AND module version', description='Verbose game AND module version')\n    version_parser.set_defaults(func=entrypoint_handler.version)\n    parser.add_parser_to_group(about_group, version_parser)\n    \n    repo_parser = subparsers.add_parser('repo', help='Verbose module repository link', description='Verbose module repository link')\n    repo_parser.add_argument('--open', '-o', action='store_true', default=False, help='Open repository in web.')\n    repo_parser.set_defaults(func=entrypoint_handler.repo)\n    parser.add_parser_to_group(about_group, repo_parser)\n    \n    author_parser = subparsers.add_parser('author', help='Verbose module author', description='Verbose module author')\n    author_parser.set_defaults(func=entrypoint_handler.author)\n    parser.add_parser_to_group(about_group, author_parser)\n    \n    \n    # ================================================\n    # =================| GAME INFO | =================\n    # ================================================\n    \n    game_info_group = parser.add_group('Game Info', 'Get static and realtime information about the local installation of Honkai: Star Rail.')\n    \n    show_status = subparsers.add_parser('status', help='Show real-time game status (game process)', description='Show real-time game status (game process)')\n    show_status.add_argument('--live', '-l', action='store_true', default=False, help='Show live game status (non-stop).')\n    show_status.set_defaults(func=entrypoint_handler.show_status)\n    parser.add_parser_to_group(game_info_group, show_status)\n    \n    show_config = subparsers.add_parser('config', help='Show game configuration in the starrail module', description='Show game configuration in the starrail module')\n    show_config.set_defaults(func=entrypoint_handler.show_config)\n    parser.add_parser_to_group(game_info_group, show_config)\n\n    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')\n    details_config.set_defaults(func=entrypoint_handler.show_details)\n    parser.add_parser_to_group(game_info_group, details_config)\n\n    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')\n    pt_config.set_defaults(func=entrypoint_handler.play_time)\n    parser.add_parser_to_group(game_info_group, pt_config)\n\n\n    # =================================================\n    # ===============| LAUNCH DRIVER | ================\n    # =================================================\n\n    apps_group = parser.add_group('Start/Stop Commands', 'Start/Stop or schedule Honkai: Star Rail application from the CLI.')\n    \n    start_parser = subparsers.add_parser('start', help='Start the Honkai: Star Rail application', description='Start the Honkai: Star Rail application')\n    start_parser.set_defaults(func=entrypoint_handler.start)\n    parser.add_parser_to_group(apps_group, start_parser)\n    \n    stop_parser = subparsers.add_parser('stop', help='Terminate the Honkai: Star Rail application', description='Terminate the Honkai: Star Rail application')\n    stop_parser.set_defaults(func=entrypoint_handler.stop)\n    parser.add_parser_to_group(apps_group, stop_parser)\n    \n    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')\n    schedule_parser.add_argument('subcommand', nargs='?', default=None, help='add / remove / show')\n    schedule_parser.add_argument('--action', type=str, default=\"start\", help='Specify start or stop (only needed for schedule add)')\n    schedule_parser.add_argument('--time', type=str, help='Specify the scheduled time (only needed for schedule add) (i.e. 10:30)')\n    schedule_parser.set_defaults(func=entrypoint_handler.schedule)\n    parser.add_parser_to_group(apps_group, schedule_parser)\n\n    \n    # =================================================\n    # =================| AUTOMATION | =================\n    # =================================================\n    \n    auto_group = parser.add_group('Automation Commands', 'Simple automation for automating Honkai: Star Rail\\'s gameplay.')\n    \n    auto_parser = subparsers.add_parser('automation', help='', description='')\n    auto_parser.add_argument('action', nargs='?', default=None, help='record / run / show / remove / clear')\n    auto_parser.set_defaults(func=entrypoint_handler.automation)\n    parser.add_parser_to_group(auto_group, auto_parser)\n    \n    click_parser = subparsers.add_parser('click', help='Continuously click mouse based on given interval.', description='Continuously click mouse based on given interval.')\n    click_parser.add_argument('--clicks', '-c', type=int, default=-1, help='Number of clicks. Leave empty (default) to run forever')\n    click_parser.add_argument('--interval', '-i', type=float, default=1, help='Interval delay (seconds) between clicks')\n    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.')\n    click_parser.add_argument('--hold', type=float, default=0.1, help='Delay (seconds) between click press and release')\n    click_parser.add_argument('--delay', '-d', type=float, default=3, help='Delay (seconds) before the clicks start')\n    click_parser.add_argument('--quiet', '-q', action='store_true', default=False, help='Run without verbosing progress')\n    click_parser.set_defaults(func=entrypoint_handler.click_continuously)\n    parser.add_parser_to_group(auto_group, click_parser)\n    \n    \n    # =================================================\n    # ==================| KEY URLS | ==================\n    # =================================================\n    \n    url_group = parser.add_group('Official Pages', \"Access official Honkai: Star Rail's web pages from the CLI\")\n    \n    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')\n    homepage_parser.add_argument('-cn', action='store_true', default=False, help='Open CN version of the home page.')\n    homepage_parser.set_defaults(func=entrypoint_handler.homepage)\n    parser.add_parser_to_group(url_group, homepage_parser)\n    \n    hoyolab_parser = subparsers.add_parser('hoyolab', help='Open the HoyoLab page of Honkai: Star Rail', description='Open the HoyoLab page of Honkai: Star Rail')\n    hoyolab_parser.set_defaults(func=entrypoint_handler.hoyolab)\n    parser.add_parser_to_group(url_group, hoyolab_parser)\n    \n    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')\n    youtube_parser.set_defaults(func=entrypoint_handler.youtube)\n    parser.add_parser_to_group(url_group, youtube_parser)\n    \n    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')\n    bilibili_parser.set_defaults(func=entrypoint_handler.bilibili)\n    parser.add_parser_to_group(url_group, bilibili_parser)\n    \n    \n    # =================================================\n    # =============| UTILITY FUNCTIONS | ==============\n    # =================================================\n\n    utility_group = parser.add_group('Utility Commands', 'Honkai: Star Rail utility features directly from the CLI.')\n\n    sc_config = subparsers.add_parser('screenshots', help='Open the screenshots directory in File Explorer', description='Open the screenshots directory in File Explorer')\n    sc_config.set_defaults(func=entrypoint_handler.screenshots)\n    parser.add_parser_to_group(utility_group, sc_config)\n    \n    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')\n    logs_config.set_defaults(func=entrypoint_handler.game_logs)\n    parser.add_parser_to_group(utility_group, logs_config)\n    \n    decode_config = subparsers.add_parser('decode', help='Decode ASCII-based binary files', description='Decode ASCII-based binary files')\n    decode_config.add_argument('--path', type=str, default=None, help='Path of the binary file to decode.')\n    decode_config.add_argument('--min-length', type=int, default=8, help='Minimum ASCII length to be considered as a valid string.')\n    decode_config.set_defaults(func=entrypoint_handler.decode)\n    parser.add_parser_to_group(utility_group, decode_config)\n    \n    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')\n    pulls_config.set_defaults(func=entrypoint_handler.pulls)\n    parser.add_parser_to_group(utility_group, pulls_config)\n    \n    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)')\n    cache_announcement_config.add_argument('--announcements', '-a', action='store_true', default=False, help='Show cached announcements.')\n    cache_announcement_config.add_argument('--events', '-e', action='store_true', default=False, help='Show cached events.')\n    cache_announcement_config.add_argument('--quiet', '-q', action='store_true', default=False, help='Do not verbose web cache explanation.')\n    cache_announcement_config.add_argument('--open', action='store_true', default=False, help='Open all web cache URLs found.')\n    cache_announcement_config.set_defaults(func=entrypoint_handler.webcache)\n    parser.add_parser_to_group(utility_group, cache_announcement_config)\n    \n\n\n    # =================================================\n    # ==========| BASE CONFIGURATION CMD | ============\n    # =================================================\n    \n    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.')\n    \n    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).\")\n    sync_parser.set_defaults(func=entrypoint_handler.configure)\n    parser.add_parser_to_group(app_utility_group, sync_parser)\n    \n    \n    # =================================================\n    # ==============| HELPER FUNCTIONS | ==============\n    # =================================================\n    \n    module_utility_group = parser.add_group('Helper Commands', '')\n    \n    elev_parser = subparsers.add_parser('elevate', help=f'Request {COMMAND} to be ran as admin.', description=f'Request {COMMAND} to be ran as admin.')\n    elev_parser.add_argument('arguments', nargs='*', default=[], help=f'Any arguments to be followed after `{COMMAND}`')\n    elev_parser.set_defaults(func=entrypoint_handler.elevate)\n    parser.add_parser_to_group(module_utility_group, elev_parser)\n    \n    \n    # ================================================\n    # ==================| FIREFLY | ==================\n    # ================================================\n    ff_group = parser.add_group('???', 'What could this be?')\n    \n    ff_parser = subparsers.add_parser('FIREFLY')\n    ff_parser.set_defaults(func=entrypoint_handler.firefly)\n    parser.add_parser_to_group(ff_group, ff_parser)\n    \n    \n    # ===========================================================================================\n    # >>> BLOCKING FUNCTIONS\n    # ===========================================================================================\n    if verify_platform() == False:\n        raise StarRailOSNotSupported()\n    \n    module_fully_configured = entrypoint_handler.star_rail.config.full_configured()\n    if not module_fully_configured:\n        # If the module is not fully configured, then force user to first configure the module\n        def blocked_func(args):\n            text = color_cmd('starrail configure')\n            aprint(f\"The StarRail module is not fully configured to run on this machine.\\nTo configure the module, run `{text}`\")\n            raise SRExit()\n\n        for name, subparser in subparsers.choices.items():\n            if name not in [\"configure\", \"version\", \"author\", \"repo\"]:\n                subparser.set_defaults(func=blocked_func)\n    \n    \n    # isadmin = is_admin()\n    # if not isadmin:\n    #     # Certain commands require admin permissions to execute\n    #     def blocked_func(args):\n    #         elevate_cmd = color_cmd(\"amiya elevate\", with_quotes=True)\n    #         aprint(f\"Insufficient permission. Run {elevate_cmd} to elevate permissions first.\")\n    #         raise SRExit()\n\n    #     for name, subparser in subparsers.choices.items():\n    #         if name in [\"record-auto\", \"run-auto\"]:\n    #             subparser.set_defaults(func=blocked_func)\n    \n    \n    # ===========================================================================================\n    # >>> PARSER DRIVER\n    # ===========================================================================================\n    \n    # Check if no command line arguments are provided\n    if len(sys.argv) == 1:\n        aprint(\"Loading starrail CLI environment...\")\n        entrypoint_handler.start_cli(parser)\n    else:\n        # Normal command line execution\n        args = parser.parse_args()\n        if hasattr(args, 'func'):\n            try:\n                args.func(args)\n            except KeyboardInterrupt:\n                aprint(\"Keyboard Interrupt! StarRail Exiting.\")\n            except SRExit:\n                exit()\n        else:\n            parser.print_help()\n"
  },
  {
    "path": "src/starrail/entrypoints/help_format_handler.py",
    "content": "import argparse\nfrom starrail.utils.utils import *\n\nclass HelpFormatHandler:\n    def print_help(self, args, parser):\n        for group in parser.groups:\n            \n            if group['description']:\n                title = Printer.to_lightred(u\"\\u2606 \" + group['title'])\n                description = Printer.to_lightgrey(\" : \" + group['description'])\n                print(f\"\\n{title}{description}\")\n            else:   \n                title = Printer.to_lightred(u\"\\u2606 \" + group['title'])\n                print(f\"\\n{title}\")\n                \n            for subparser in group['parsers']:\n                prog_cmd = Printer.to_lightblue(subparser.prog)\n                print(f\"  {prog_cmd}: {subparser.description or 'No description available.'}\")\n                for action in subparser._actions:\n                    if action.option_strings:\n                        print(f\"    {Printer.to_purple(', '.join(action.option_strings))}: {Printer.to_lightgrey(action.help)}\")\n                    else:\n                        print(f\"    {Printer.to_purple(action.dest)}: {Printer.to_lightgrey(action.help)}\")\n                print(\"\")"
  },
  {
    "path": "src/starrail/exceptions/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/exceptions/exceptions.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nclass StarRailBaseException(Exception):\n    __module__ = 'builtins'\n    def __init__(self, message):\n        super().__init__(message)\n\nclass StarRailModuleException(Exception):\n    __module__ = 'builtins'\n    def __init__(self, err_code):\n        self.message = f\"\\nAn unexpected exception has occurred ({err_code}). Please seek help at https://github.com/ReZeroE/StarRail/issues.\"\n        super().__init__(self.message)\n        \nclass StarRailOSNotSupported(Exception):\n    __module__ = 'builtins'\n    def __init__(self):\n        super().__init__(f\"\\nThe starrail package only supports Windows installations of Honkai Star Rail.\")\n\nclass StarRailConfigNotExistsException(Exception):\n    __module__ = 'builtins'\n    def __init__(self, config_path):\n        super().__init__(f\"\\nThe StarRail config file cannot be found at {config_path} (should be caught accordingly).\")\n        \n        \n# 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).\n# This error is caught and replaced with 'continue' in the CLI env and 'exit()' in the normal mode. \nclass SRExit(Exception):\n    __module__ = 'builtins'\n    def __init__(self, message=\"Module internal exit requested (should be caught accordingly).\"):\n        super().__init__(message)\n"
  },
  {
    "path": "src/starrail/utils/__init__.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE."
  },
  {
    "path": "src/starrail/utils/binary_decoder.py",
    "content": "\n\nimport re\nimport os\nimport tabulate\nfrom starrail.utils.utils import *\n\nSUBMODULE_NAME = \"SR-DB\"\n\nclass StarRailBinaryDecoder:\n    def __init__(self):\n        self.decoded_count = 0\n    \n    def decode_raw_binary_file(self, file_path, min_length=8):\n        \"\"\"\n        Extract readable strings from a binary file.\n        Expecting length > given min_length parameter.\n        \n        :param file_path: Path to the binary file\n        :param min_length: Minimum length of strings to extract\n        :return: list of extracted strings\n        \"\"\"\n        with open(file_path, 'rb') as file:\n            binary_content = file.read()\n        \n        # Use regular expression to find readable strings\n        pattern = re.compile(b'[ -~]{%d,}' % min_length)\n        strings = pattern.findall(binary_content)\n        \n        # Decode bytes to strings\n        decoded_strings = [s.decode('utf-8', errors='ignore') for s in strings]\n        self.decoded_count += 1\n        return decoded_strings\n\n    def user_decode(self, file_path=None, min_length=8):\n        if not file_path:\n            aprint(\"Binary File Path: \", end=\"\")\n            user_input = input(\"\").strip().lower().replace(\"\\\"\", \"\")\n            if not os.path.isfile(user_input):\n                aprint(Printer.to_lightred(f\"Invalid path: {user_input}\"))\n                return\n            file_path = user_input\n        \n        results = []\n        try:\n            results = self.decode_raw_binary_file(file_path, min_length)\n        except Exception as ex:\n            aprint(f\"File cannot be decoded ({ex}).\", submodule_name=SUBMODULE_NAME)\n            return\n        \n        if len(results) == 0:\n            aprint(\"No results are found.\", submodule_name=SUBMODULE_NAME)\n            return\n        \n        headers = [Printer.to_purple(title) for title in [\"Index\", \"Content\"]]\n        data = [[Printer.to_lightblue(idx), content] for idx, content in enumerate(results)]\n        print(tabulate.tabulate(data, headers))"
  },
  {
    "path": "src/starrail/utils/game_detector.py",
    "content": "import os\nimport sys\nimport string\nfrom pathlib import Path\nfrom concurrent import futures\nfrom multiprocessing import Manager\nimport queue\n\nfrom starrail.constants import GAME_FILENAME, GAME_FILE_PATH, GAME_FILE_PATH_NEW, MIN_WEAK_MATCH_EXE_SIZE\nfrom starrail.exceptions.exceptions import *\n\n\nclass StarRailGameDetector:\n    \"\"\"\n    Honkai Star Rail Game Detector - Finds the game on local drives\n    \"\"\"\n    def __init__(self):\n        \n        # Weak Match\n        # Implemented in 1.0.2, weak match will return valid game path based solely on the filename\n        # of the game (StarRail.exe) if no game path can be matched. This is not the best idea to go\n        # about resolving the issue with Hoyo having different directory structures for different \n        # game versions, but it is the best one I can think of right now to futue-proof another directory\n        # structure change.\n\n        manager = Manager()\n        self.weak_matches = manager.Queue()  # Use Manager's Queue\n\n\n    def get_local_drives(self):\n        available_drives = ['%s:' % d for d in string.ascii_uppercase if os.path.exists('%s:' % d)]\n        return available_drives\n\n\n    def find_game_in_path(self, path, name, stop_flag):\n        for root, _, files in os.walk(path):\n            if stop_flag.value: # Check the shared flag value.\n                return None\n            \n            # If game file in the directory\n            if name in files:\n                abs_path = os.path.join(root, name)\n                self.weak_matches.put(abs_path)\n\n                # Check if the game's entire path is in the path found\n                if os.path.normpath(GAME_FILE_PATH) in abs_path or os.path.normpath(GAME_FILE_PATH_NEW) in abs_path:\n                    return os.path.join(root, name)\n        \n        return None\n\n\n    def find_game(self, paths=[], name=GAME_FILENAME):\n        for p in paths:\n            if not os.path.exists(p):\n                raise StarRailBaseException(f\"Path does not exist. [{p}]\")\n\n        if len(paths) == 0:\n            paths = self.get_local_drives()\n\n        paths = [f\"{path}\\\\\" if path.endswith(\":\") and len(path) == 2 else path for path in paths]\n\n        worker_threads = 1\n        if os.cpu_count() > 1:\n            worker_threads = os.cpu_count() - 1\n\n        with futures.ProcessPoolExecutor(max_workers=worker_threads) as executor, Manager() as manager:\n            stop_flag = manager.Value('b', False)   # Create a boolean shared flag.\n            future_to_path = {executor.submit(self.find_game_in_path, path, name, stop_flag): path for path in paths}\n            for future in futures.as_completed(future_to_path):\n                result = future.result()\n                if result is not None:\n                    stop_flag.value = True # Set the flag to true when a result is found.\n                    return result\n\n        # No match is found, then:\n        while not self.weak_matches.empty():\n            path = self.weak_matches.get()\n            if self.is_file_over_size(path):\n                return path\n\n        # No match and no valid weak match\n        return None\n\n\n    def is_file_over_size(self, file_path):\n        if not os.path.isfile(file_path):\n            return False\n\n        megabytes = MIN_WEAK_MATCH_EXE_SIZE * 1024 * 1024\n        file_size = os.path.getsize(file_path)\n        return file_size > megabytes"
  },
  {
    "path": "src/starrail/utils/json_handler.py",
    "content": "import os\nimport json\nfrom abc import ABC\nfrom starrail.exceptions.exceptions import StarRailBaseException\n\nclass JSONConfigHandler(ABC):\n    def __init__(self, config_abs_path, config_type=dict):\n        self.config_file = config_abs_path\n        self.config_type = config_type\n\n    def LOAD_CONFIG(self):\n        try:\n            with open(self.config_file, \"r\") as rf:\n                config = json.load(rf)\n                return config\n        except FileNotFoundError:\n            return None\n        except json.JSONDecodeError:\n            self.SAVE_CONFIG([] if self.config_type == list else dict())\n            return None\n\n    def SAVE_CONFIG(self, json_payload) -> bool:\n        try:\n            with open(self.config_file, \"w\") as wf:\n                json.dump(json_payload, wf, indent=4)\n        except Exception as ex:\n            StarRailBaseException(f\"Config file '{self.config_file}' cannot be created due to an unknown error ({ex}).\")\n        return True\n\n    def DELETE_CONFIG(self):\n        if not self.CONFIG_EXISTS():\n            return False\n        try:\n            os.remove(self.config_file)\n        except:\n            return False\n        return True\n\n    def CONFIG_EXISTS(self):\n        return os.path.isfile(self.config_file)\n    \n    def VALIDATE_CONFIG(self):\n        ..."
  },
  {
    "path": "src/starrail/utils/perm_elevate.py",
    "content": "import subprocess\nfrom starrail.utils.utils import *\nimport signal\nimport time\nimport psutil\n\nclass StarRailPermissionsHandler:\n    @staticmethod\n    def elevate(arguments: list = []):\n        ARGUMENTS = \" \".join([str(arg) for arg in arguments])\n        try:\n            # \\k keeps the terminal open after the user exits the starrail cli env\n            cmd_command = f'cmd /k \"{COMMAND} {ARGUMENTS}\"'\n            result = subprocess.run([\"powershell\", \"-Command\", f'Start-Process cmd -ArgumentList \\'/k {cmd_command}\\' -Verb RunAs'])\n            \n            if result.returncode == 0:\n                print(\"\")\n                aprint(Printer.to_lightgreen(f\"Admin permission granted.\") + f\"\\nPlease use the new terminal with the {SHORTNAME}-CLI that opened.\")\n            else:\n                aprint(f\"Command 'starrail elevate' permission denied.\", log_type=LogType.ERROR)\n            \n        except Exception as e:\n            aprint(f\"Failed to elevate privileges: {e}\")\n        \n        time.sleep(3)\n        cmd_proc_id = psutil.Process(os.getppid()).ppid()\n        kill_command = f'taskkill /F /PID {cmd_proc_id}'\n        os.system(kill_command)\n        \n    \n    @staticmethod\n    def elevate_post_cli(follow_up_cmd: str):\n        try:\n            # \\k keeps the terminal open after the user exits the starrail cli env\n            cmd_command_1 = f'cmd /k \"{COMMAND}\"'\n            combined_commands = f\"{cmd_command_1} && {follow_up_cmd}\"\n            result = subprocess.run([\n                \"powershell\", \n                \"-Command\", \n                f'Start-Process cmd -ArgumentList \\'/k \"{combined_commands}\"\\' -Verb RunAs'\n            ])\n            \n            if result.returncode == 0:\n                aprint(f\"Permissions granted. Please use the new terminal with the {SHORTNAME}-CLI that opened.\")\n            else:\n                aprint(f\"Command 'starrail elevate' permission denied.\", log_type=LogType.ERROR)\n            \n        except Exception as e:\n            aprint(f\"Failed to elevate privileges: {e}\")"
  },
  {
    "path": "src/starrail/utils/process_handler.py",
    "content": "import psutil\nimport time\nimport ctypes\nimport subprocess\n\nfrom starrail.utils.utils import *\n\nclass ProcessHandler:\n    \n    @staticmethod\n    def get_focused_pid():\n        user32 = ctypes.windll.user32\n        kernel32 = ctypes.windll.kernel32\n\n        hwnd = user32.GetForegroundWindow()\n        pid = ctypes.c_ulong()\n        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))\n        return pid.value\n    \n    \n    @staticmethod\n    def get_related_processes(process_pid: int):\n        process = psutil.Process(process_pid)\n        if not process.is_running():\n            return None, None\n        \n        parent_procs: list[psutil.Process] = []\n        children_procs: list[psutil.Process] = []\n               \n        try:\n            parent_procs = process.parents()\n            children_procs = process.children(recursive=True)\n        except:\n            pass\n\n        return parent_procs, children_procs\n    \n    \n    @staticmethod\n    def kill_pid(pid, verbose_failure=True, is_child=False):\n        \n        process_info = f\"process {pid}\" if not is_child else f\"child process {pid}\"\n        try:\n            proc = psutil.Process(pid)\n            if proc.is_running():\n                proc.kill(); time.sleep(0.1)\n                \n                aprint(f\"Process {proc.name()} (PID {proc.pid}) terminated successfully.\", submodule_name=\"ProcHandler\")\n                return True\n        \n        except psutil.NoSuchProcess:\n            if verbose_failure:\n                aprint(f\"Unabled to terminate {process_info} because it's already closed.\", log_type=LogType.ERROR, submodule_name=\"ProcHandler\")\n        except Exception as ex:\n            if verbose_failure:\n                aprint(f\"Unabled to terminate {process_info} ({ex}).\", log_type=LogType.ERROR, submodule_name=\"ProcHandler\")\n        return False\n\n    \n    @staticmethod\n    def kill_pid_and_residual(pid):\n        try:\n            proc = psutil.Process(pid)\n            child_procs = []\n            try:\n               child_procs = proc.children(recursive=True)\n            except:\n                pass\n        except psutil.NoSuchProcess:\n            aprint(f\"Unabled to terminate root process {pid} because it's already closed.\", log_type=LogType.WARNING, submodule_name=\"ProcHandler\")\n            return\n        \n        ProcessHandler.kill_pid(pid, verbose_failure=True)\n        \n        for cproc in child_procs:\n            if cproc.is_running():\n                ProcessHandler.kill_pid(cproc.pid, verbose_failure=False, is_child=True)\n                time.sleep(0.5)"
  },
  {
    "path": "src/starrail/utils/utils.py",
    "content": "# SPDX-License-Identifier: MIT\n# MIT License\n#\n# Copyright (c) 2024 Kevin L.\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nimport os\nimport re\nimport sys\nimport string\nimport shutil\nfrom datetime import datetime\nfrom enum import Enum\nimport pyautogui\nimport platform\nfrom concurrent import futures\nfrom multiprocessing import Manager\nimport hashlib\nimport readline\n\nfrom termcolor import colored\nfrom starrail import constants\nfrom starrail.constants import BASENAME, SHORTNAME, COMMAND, GAME_FILENAME, DATETIME_FORMAT, TIME_FORMAT\nfrom starrail.exceptions.exceptions import *\n\n\n# =================================================\n# ==============| CUSTOM PRINTER | ================\n# =================================================\n\nclass Printer:\n    @staticmethod\n    def hex_text(text, hex_color):\n        \n        def hex_to_rgb(hex_color):\n            hex_color = hex_color.lstrip('#')\n            return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))\n\n        rgb = hex_to_rgb(hex_color)\n        escape_seq = f\"\\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m\" # ANSI escape code for 24-bit (true color): \\x1b[38;2;<r>;<g>;<b>m\n        return f\"{escape_seq}{text}\\x1b[0m\"\n\n    @staticmethod\n    def to_purple(text):\n        return Printer.hex_text(text, \"#a471bf\")\n    \n    @staticmethod\n    def to_lightpurple(text):\n        return Printer.hex_text(text, \"#c38ef5\")\n        \n    @staticmethod\n    def to_skyblue(text):\n        return Printer.hex_text(text, \"#6dcfd1\")\n        \n    @staticmethod\n    def to_lightgrey(text):\n        return Printer.hex_text(text, \"#8a8a8a\")\n\n    @staticmethod\n    def to_blue(text):\n        return Printer.hex_text(text, \"#3c80f0\")\n\n    @staticmethod\n    def to_lightblue(text):\n        return Printer.hex_text(text, \"#8ab1f2\")\n    \n    @staticmethod\n    def to_darkblue(text):\n        return Printer.hex_text(text, \"#2a9bc3\")\n    \n    @staticmethod\n    def to_lightgreen(text):\n        return Printer.hex_text(text, \"#74d47b\")\n    \n    @staticmethod\n    def to_lightred(text):\n        return Printer.hex_text(text, \"#f27e82\")\n    \n    def to_githubblack(text):\n        return Printer.hex_text(text, \"#121212\") # C9A2FF, 7A7A7A\n    \n    # Firefly Colors\n    \n    @staticmethod\n    def to_pale_yellow(text):\n        return Printer.hex_text(text, \"#f3f2c9\")\n\n    @staticmethod\n    def to_light_blue(text):\n        return Printer.hex_text(text, \"#b9d8d6\")\n\n    @staticmethod\n    def to_teal(text):\n        return Printer.hex_text(text, \"#7ba7a8\")\n\n    @staticmethod\n    def to_turquoise(text):\n        return Printer.hex_text(text, \"#4a8593\")\n\n    @staticmethod\n    def to_dark_teal(text):\n        return Printer.hex_text(text, \"#2f6b72\")\n\n\n\n\nclass LogType(Enum):\n    NORMAL  = \"white\"\n    SUCCESS = \"green\"\n    WARNING = \"yellow\"\n    ERROR   = \"red\"\n\ndef __get_colored_prefix():\n    # return f\"[{colored(BASENAME, \"cyan\")}] \"\n    return f\"[{Printer.to_purple(SHORTNAME)}] \"\n\ndef __get_colored_submodule(submodule_name):\n    colored_submodule_name = Printer.to_purple(submodule_name)\n    return f\"[{colored_submodule_name}] \"\n\ndef atext(text: str, log_type: LogType = LogType.NORMAL) -> str:\n    rtext = colored(text, log_type.value)\n    return f\"{__get_colored_prefix()}{rtext}\"\n\ndef aprint(\n    text: str, \n    log_type: LogType   = LogType.NORMAL, \n    end: str            = \"\\n\", \n    submodule_name: str = \"\", \n    new_line_no_prefix  = True, \n    file                = sys.stdout, \n    flush               = True\n):\n    # The new_line_no_prefix param coupled with \\n in the text param will put the\n    # text after the new line character on the next line, but without a prefix.\n    if \"\\n\" in text and new_line_no_prefix == True:\n        text = text.replace(\"\\n\", f\"\\n           \")\n    \n    # Set colored submodule name if available\n    colored_submodule_name = \"\"\n    if submodule_name:\n        colored_submodule_name = __get_colored_submodule(submodule_name)\n    \n    # Get colored prefix and text\n    colored_text = colored(text, log_type.value)\n    colored_prefix = __get_colored_prefix()\n    \n    # Put all together and print\n    final_text = f\"{colored_prefix}{colored_submodule_name}{colored_text}\"\n    print(final_text, end=end, file=file, flush=flush)\n\n\ndef get_prefix_space():\n    return \" \" * (len(SHORTNAME)+2)\n\ndef color_cmd(text: str, with_quotes: bool = False):\n    text = text.lower()\n    \n    if constants.CLI_MODE == True:\n        text = text.replace(COMMAND, \"\").strip()\n    else:\n        if not text.startswith(COMMAND):\n            text = f\"{COMMAND} {text}\"\n            \n    colored_cmd = colored(text, \"light_cyan\")\n    \n    if with_quotes:\n        return f\"'{colored_cmd}'\"\n    return colored_cmd\n\ndef bool_to_str(boolean: bool, true_text=\"Running\", false_text=\"Not Running\"):\n    CHECKMARK = \"\\u2713\"\n    CROSSMARK = \"\\u2717\"\n    if boolean:\n        return Printer.to_lightgreen(f\"{CHECKMARK} {true_text}\")\n    return Printer.to_lightred(f\"{CROSSMARK} {false_text}\")\n\n\n\nclass StarRailGameDetector:\n    \"\"\"\n    Honkai Star Rail Game Detector - Finds the game on local drives\n    \"\"\"\n    def get_local_drives(self):\n        available_drives = ['%s:' % d for d in string.ascii_uppercase if os.path.exists('%s:' % d)]\n        return available_drives\n\n    def find_game_in_path(self, path, name, stop_flag):\n        for root, _, files in os.walk(path):\n            if stop_flag.value: # Check the shared flag value.\n                return None\n            if name in files:\n                return os.path.join(root, name)\n        return None\n\n    def find_game(self, paths=[], name=GAME_FILENAME):\n        for p in paths:\n            if not os.path.exists(p):\n                raise StarRailBaseException(f\"Path does not exist. [{p}]\")\n\n        if len(paths) == 0:\n            paths = self.get_local_drives()\n\n        paths = [f\"{path}\\\\\" if path.endswith(\":\") and len(path) == 2 else path for path in paths]\n\n        worker_threads = 1\n        if os.cpu_count() > 1:\n            worker_threads = os.cpu_count() - 1\n\n        with futures.ProcessPoolExecutor(max_workers=worker_threads) as executor, Manager() as manager:\n            stop_flag = manager.Value('b', False)   # Create a boolean shared flag.\n            future_to_path = {executor.submit(self.find_game_in_path, path, name, stop_flag): path for path in paths}\n            for future in futures.as_completed(future_to_path):\n                result = future.result()\n                if result is not None:\n                    stop_flag.value = True # Set the flag to true when a result is found.\n                    return result\n        return None\n\n\nclass StarRailScreenshotController:\n    \"\"\"\n    Honkai Star Rail Screenshot Controller - Takes and stores in-game screenshots for processing\n    \"\"\"\n    def __init__(self):\n        self.__screenshot_abspath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), \"_data\", \"images\", \"screenshots\", \"screenshot.png\")\n    \n    def get_screenshot_path(self):\n        return self.__screenshot_abspath\n    \n    def take_screenshot(self):\n        myScreenshot = pyautogui.screenshot()\n        myScreenshot.save(self.__screenshot_abspath)\n        assert(os.path.isfile(self.__screenshot_abspath))\n\n\ndef verify_platform():\n    \"\"\"\n    Verifies whether the OS is supported by the package.\n    Package starrail only support Windows installations of Honkai Star Rail.\n    \n    :return: true if running on Windows, false otherwise\n    :rtype: bool\n    \"\"\"\n    return os.name == \"nt\"\n\n\ndef is_admin() -> bool:\n    try:\n        # For Windows\n        if platform.system().lower() == \"windows\":\n            import ctypes\n            return ctypes.windll.shell32.IsUserAnAdmin() != 0\n\n        # For Linux and MacOS\n        else:\n            return os.getuid() == 0  # os.getuid() returns '0' if running as root\n\n    except Exception as e:\n        aprint(f\"Error checking administrative privileges: {e}\", log_type=LogType.ERROR)\n        return False\n\n\ndef print_disclaimer():\n    DISCLAIMER_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), \"data\", \"textfiles\", \"disclaimer.txt\")\n    with open(DISCLAIMER_PATH, \"r\") as rf:\n        lines = rf.readlines()\n    print(\"\".join(lines) + \"\\n\")\n\ndef print_webcache_explanation():\n    print(Printer.to_lightred(\"\\n   What is Web Cache?\"))\n    print(f\"{Printer.to_lightred(' > ') + 'Web cache for Honkai: Star Rail stores recent web data.'}\")\n    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\")\n    \n\n\n# =================================================\n# ============| CENTER TEXT HELPER | ==============\n# =================================================\n\n# DON\"T CHANGE THE FOLLOWING TWO CENTER TEXT FUNCTIONS. I GOT THESE TO WORK AFTER HOURS. BOTH ARE NEEDED!\n\ndef center_text(text: str):\n    terminal_width, terminal_height = shutil.get_terminal_size((80, 20))  # Default size\n    \n    lines = text.split('\\n')\n    max_width = max(len(line) for line in lines)\n    left_padding = (terminal_width - max_width) // 2\n    \n    new_text = []\n    for line in lines:\n        new_text.append(' ' * left_padding + line)\n    return \"\\n\".join(new_text) \n        \ndef print_centered(text: str):\n    def strip_ansi_codes(s):\n        return re.sub(r'\\x1B[@-_][0-?]*[ -/]*[@-~]', '', s)\n    \n    terminal_width = os.get_terminal_size().columns\n    lines = text.split('\\n')\n    for line in lines:\n        line_without_ansi = strip_ansi_codes(line)\n        leading_spaces = (terminal_width - len(line_without_ansi)) // 2\n        print(' ' * leading_spaces + line.strip())\n        \n        \nclass DatetimeHandler:\n    @staticmethod\n    def get_datetime():\n        return datetime.now().replace(microsecond=0)\n\n    def get_datetime_str():\n        return datetime.now().strftime(DATETIME_FORMAT)\n\n    def get_time_str():\n        return datetime.now().strftime(TIME_FORMAT)\n\n    @staticmethod\n    def datetime_to_str(datetime: datetime):\n        return datetime.strftime(DATETIME_FORMAT)\n    \n    @staticmethod\n    def str_to_datetime(datetime_str: str):\n        return datetime.strptime(datetime_str, DATETIME_FORMAT)\n    \n    @staticmethod\n    def epoch_to_time_str(epoch_time: float):\n        return datetime.fromtimestamp(epoch_time).strftime(DATETIME_FORMAT)\n\n    @staticmethod\n    def epoch_to_datetime(epoch_time: float):\n        return datetime.fromtimestamp(epoch_time)\n    \n    @staticmethod\n    def seconds_to_time_str(seconds: int):\n        hours, remainder = divmod(seconds, 3600)\n        minutes, seconds = divmod(remainder, 60)\n        \n        str_ret = \"\"\n        if hours > 0:\n            str_ret += f\"{hours} {'Hours' if hours > 1 else 'Hour'} \"\n        if minutes > 0:\n            str_ret += f\"{minutes} {'Minutes' if minutes > 1 else 'Minute'} \"\n        str_ret += f\"{seconds} {'Seconds' if seconds > 1 else 'Second'}\"\n    \n        return str_ret\n    \nclass HashCalculator:\n    @staticmethod\n    def SHA256(file_path: str):\n        sha256_hash = hashlib.sha256()\n        with open(file_path, \"rb\") as f:\n            for byte_block in iter(lambda: f.read(4096), b\"\"):\n                sha256_hash.update(byte_block)\n        return sha256_hash.hexdigest()\n    \n    \n    \ndef merge_dicts(*dicts):\n    result = {}\n    for d in dicts:\n        if d == None:\n            continue\n        \n        for key, value in d.items():\n            if key in result:\n                if isinstance(result[key], (list, tuple)):\n                    if isinstance(value, (list, tuple)):\n                        result[key].extend(value)\n                    else:\n                        result[key].append(value)\n                else:\n                    if isinstance(value, (list, tuple)):\n                        result[key] = [result[key]] + list(value)\n                    else:\n                        result[key] = [result[key], value]\n            else:\n                result[key] = value if not isinstance(value, (list, tuple)) else list(value)\n    return result\n\n"
  },
  {
    "path": "tests/test_starrail.py",
    "content": "# # SPDX-License-Identifier: MIT\n# # MIT License\n# #\n# # Copyright (c) 2024 Kevin L.\n# #\n# # Permission is hereby granted, free of charge, to any person obtaining a copy\n# # of this software and associated documentation files (the \"Software\"), to deal\n# # in the Software without restriction, including without limitation the rights\n# # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# # copies of the Software, and to permit persons to whom the Software is\n# # furnished to do so, subject to the following conditions:\n# #\n# # The above copyright notice and this permission notice shall be included in all\n# # copies or substantial portions of the Software.\n# #\n# # THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# # SOFTWARE.\n\n# import subprocess\n# import psutil\n# import pytest\n# import time\n# import os\n# import pyautogui\n\n# # ===============================================\n# # ==== | THE FOLLOWING IS FOR TESTING ONLY | ====\n# # ===============================================\n# if __name__ == \"__main__\":\n#     pass"
  },
  {
    "path": "tests/verify_package.py",
    "content": "import os\n\n'''\nInternal script to verify that all python directory is contains __init__.py\n'''\n\ndef check_init_files(build_dir=os.path.join(os.path.dirname(os.path.dirname(__file__)), \"src\")):\n    paths = []\n    for root, dirs, files in os.walk(build_dir):\n        has_python_files = any(file.endswith('.py') for file in files)\n        if has_python_files and '__init__.py' not in files:\n            paths.append(root)\n            \n    if len(paths) == 0:\n        print(\"No missing __init__ files.\")\n        return\n    for path in paths:\n        print(f\"Missing __init__ file at {path}\")\n\n\nif __name__ == \"__main__\":\n    check_init_files()"
  }
]