├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── Utility ├── __init__.py ├── debug_cmd.py ├── devmode_cmd.py ├── helpers_compile.py ├── helpers_debug.py ├── helpers_decompile.py ├── helpers_exec.py ├── helpers_package.py ├── helpers_path.py ├── helpers_symlink.py ├── helpers_time.py ├── helpers_type_hints.py ├── helpers_venv.py ├── injector.py └── process_module.py ├── assets └── .gitkeep ├── bundle_build.py ├── cleanup.py ├── compile.py ├── debug_setup.py ├── debug_teardown.py ├── decompile.py ├── devmode.py ├── fix_tuning_names.py ├── protoc └── .gitkeep ├── pycdc └── .gitkeep ├── settings.py.orig ├── src ├── helpers │ ├── __init__.py │ └── injector.py └── main.py ├── sync_packages.py └── type_hints.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # All of .idea 5 | .idea/ 6 | 7 | # CMake 8 | cmake-build-*/ 9 | 10 | # File-based project format 11 | *.iws 12 | 13 | # IntelliJ 14 | out/ 15 | 16 | # JIRA plugin 17 | atlassian-ide-plugin.xml 18 | 19 | # Crashlytics plugin (for Android Studio and IntelliJ) 20 | com_crashlytics_export_strings.xml 21 | crashlytics.properties 22 | crashlytics-build.properties 23 | fabric.properties 24 | 25 | ################################################################################ 26 | 27 | # Byte-compiled / optimized / DLL files 28 | __pycache__/ 29 | *.py[cod] 30 | *$py.class 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | share/python-wheels/ 50 | *.egg-info/ 51 | .installed.cfg 52 | *.egg 53 | MANIFEST 54 | 55 | # PyInstaller 56 | # Usually these files are written by a python script from a template 57 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 58 | *.manifest 59 | *.spec 60 | 61 | # Installer logs 62 | pip-log.txt 63 | pip-delete-this-directory.txt 64 | 65 | # Unit test / coverage reports 66 | htmlcov/ 67 | .tox/ 68 | .nox/ 69 | .coverage 70 | .coverage.* 71 | .cache 72 | nosetests.xml 73 | coverage.xml 74 | *.cover 75 | *.py,cover 76 | .hypothesis/ 77 | .pytest_cache/ 78 | cover/ 79 | 80 | # Translations 81 | *.mo 82 | *.pot 83 | 84 | # Django stuff: 85 | *.log 86 | local_settings.py 87 | db.sqlite3 88 | db.sqlite3-journal 89 | 90 | # Flask stuff: 91 | instance/ 92 | .webassets-cache 93 | 94 | # Scrapy stuff: 95 | .scrapy 96 | 97 | # Sphinx documentation 98 | docs/_build/ 99 | 100 | # PyBuilder 101 | .pybuilder/ 102 | target/ 103 | 104 | # Jupyter Notebook 105 | .ipynb_checkpoints 106 | 107 | # IPython 108 | profile_default/ 109 | ipython_config.py 110 | 111 | # pyenv 112 | # For a library or package, you might want to ignore these files since the code is 113 | # intended to run in multiple environments; otherwise, check them in: 114 | # .python-version 115 | 116 | # pipenv 117 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 118 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 119 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 120 | # install all needed dependencies. 121 | #Pipfile.lock 122 | 123 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 124 | __pypackages__/ 125 | 126 | # Celery stuff 127 | celerybeat-schedule 128 | celerybeat.pid 129 | 130 | # SageMath parsed files 131 | *.sage.py 132 | 133 | # Environments 134 | .env 135 | .venv 136 | env/ 137 | venv/ 138 | ENV/ 139 | env.bak/ 140 | venv.bak/ 141 | 142 | # Spyder project settings 143 | .spyderproject 144 | .spyproject 145 | 146 | # Rope project settings 147 | .ropeproject 148 | 149 | # mkdocs documentation 150 | /site 151 | 152 | # mypy 153 | .mypy_cache/ 154 | .dmypy.json 155 | dmypy.json 156 | 157 | # Pyre type checker 158 | .pyre/ 159 | 160 | # pytype static type analyzer 161 | .pytype/ 162 | 163 | # Cython debug symbols 164 | cython_debug/ 165 | 166 | ################################################################################ 167 | 168 | # Settings are specific to each computer, we only want the settings template 169 | # to be checked in 170 | Utility/virtual_env 171 | settings.py 172 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "unpyc37"] 2 | path = unpyc37 3 | url = https://github.com/andrew-tavera/unpyc37 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is my update of https://github.com/junebug12851/Sims4ScriptingBPProj, a Sims 4 Script mod template project. 2 | The first major change is that decompilation of the game source is parallelized, uses multiple decompilers for 3 | improved success chances, and keeps the best decompilation result, including any prior versions. 4 | 5 | The second major change is the addition of type_hints.py, which generates run-time python type stubs for the 6 | decompiled source files! Uses mypy's stubgen for most files, but extracts and uses protoc for the protobufs. 7 | 8 | ## A Copy of the Original's README follows: 9 | # Sims 4 Scripting Boilerplate Project 10 | 11 | This is a bolerplate project, sort of like a pre-made template and it represents one project so you can copy it for new 12 | projects. To get started just download, do some super quick setup, and you're ready to go. It can decompile the game 13 | libraries and compile your mod into 2 different flavors and don't forget to check back for occasional updates. 14 | Read more below. 15 | 16 | I also wrote a full [tutorial here](https://medium.com/@junebug12851/the-sims-4-modern-python-modding-part-5-project-template-c9ffee48ab4e) 17 | and a separate [debug tutorial](https://medium.com/@junebug12851/the-sims-4-modern-python-modding-debugging-3736b37dbd9f) 18 | covering the new automated debug capability. 19 | 20 | ## What this is 21 | This is inspired by [andrew's tutorial](https://sims4studio.com/thread/15145/started-python-scripting) which is very 22 | popular and has helped many people, including me. I've modeled it after andrew's but want to go a different direction 23 | with it entirely. 24 | 25 | 1. I've decided to make this into a proper open source project though on Github with 26 | issue trackers, pull requests, version control etc... and I heavily documented the code. 27 | 2. I want this to be super easy and convenient to use but I want you to be able to understand it and have a place to ask 28 | questions. I want to try my best to make it easily adaptable to the future so that when this goes out of date it can be 29 | upgraded. 30 | 3. I want it to be editor agnostic. Not everyone wants to use PyCharm, it's nice, but it's also heavy and big and 31 | sometimes just more complicated than it needs to be. Some people don't use IDE's at all. Whatever works for you, I want 32 | this project to fit that. 33 | 34 | ## Why I've waited so long before making this 35 | In [part 1](https://levelup.gitconnected.com/the-sims-4-modern-python-modding-part-1-setup-83d1a100c5f6) of my tutorial 36 | I described using Windows Powershell because the internet is full of old tutorials that have scripts which do everything 37 | for you. While it sounds nice, it means you don't understand how those scripts work and by 2020 most of them are 5-6 38 | years out-of-date and some don't even run anymore. Other imposed a certain workflow which I have disagreements with and 39 | you may as well or down the road. 40 | 41 | I wanted to avoid also making another hands-off script that will become out of date and that doesn't teach you the rest 42 | of the modding part which is packaging your mod and decompiling libraries but I get it, Power Shell scripts aren't that 43 | great workflow-wise so I've come to a compromise. 44 | 45 | The takeaway point is that if I make a script that does everything for you then you're never going to learn how to do it 46 | yourself and this is only going to act as a crutch down the line and hamper you instead of helping you. But given it's 47 | importance to have I decided to make it anyways but keep it as well-written, documented, and detailed as much as 48 | possible as a compromise. 49 | 50 | ## What can it do 51 | This provides several scripts you can run and use in your workflow and mod 52 | 53 | ### compile.py 54 | This compiles and packages your `src` folder and creates a `build` folder containing your packaged mod ready for 55 | deployment. It then copies your packaged mod to the games Mods folder under it's own sub-folder 56 | `Mods/YourName_ProjectName/YourName_ProjectName.ts4script`. 57 | 58 | **Update:** 59 | The old behaviour was to create 2 mod files, a `slim` version and a `full` version. It no longer does this and opts to 60 | only build a full version. The reason why is new information was learned about The Sims 4 loading process and it's 61 | highly discouraged to only include compiled python files. I wrote a tutorial 62 | [about it here](https://medium.com/swlh/the-sims-4-modern-python-modding-ultimate-loading-guide-77ce1b68f1e7) detailing 63 | the reasoning behind this change. 64 | 65 | ### decompile.py 66 | I put a lot of work into this and this is a big area where mine greatly differs from `andrew`. It leverages the latest 67 | decompiler that has the highest success rate currently [uncompyle6](https://pypi.org/project/uncompyle6/) and goes 68 | through all the code in your game folder, decompiling them one-by-one placing them into a global projects folder. 69 | 70 | Throughout the process it prints a pretty progress meter and at the end of each module it decompiled it shows the 71 | success and fail stats as well as how long it took. It does this again at the end of the whole decompilation. It also 72 | clears out the old decompiled files for you and overall makes everything very smooth and simple. 73 | 74 | ### debug_setup.py and debug_teardown.py 75 | 76 | These create and remove a debugging environment so that you can debug your game with a real debugger. The only downside 77 | is that it requires PyCharm Pro, which is a paid program that costs money. There's no other known way to do this. If 78 | you have PyCharm Pro then this will access the debugging capability in it and create 2 mods. 79 | 80 | * `pycharm-debug-capability.ts4script` which gives the Sims 4 capability to debug by connecting to PyCharm Pro 81 | * `pycharm-debug-cmd.ts4script` which creates a cheat code `pycharm.debug` you can enter in-game which will active 82 | debugging for the rest of the game. 83 | 84 | Both the cheatcode and `debug_setup.py` give clear and well-written instructions informing you of what to do and how 85 | to set it up or what to expect. I've also written a 86 | [tutorial](https://medium.com/analytics-vidhya/the-sims-4-modern-python-modding-debugging-3736b37dbd9f) on how to 87 | use it. 88 | 89 | As the instructions say, run `debug_teardown.py` when not debugging because it can otherwise slow down your game. 90 | Sigma1202 is the person who discovered this, I just made it into a script. 91 | 92 | ### devmode.py 93 | 94 | This enters into a special mode called "Dev Mode", it clears out compiled code and links your src folder to the 95 | Mod Folder. When Dev Mode is activated, you don't need to compile anymore. If you run `compile.py` though it will exit 96 | Dev Mode and do a normal compile. 97 | 98 | When inside of Dev Mode you can enter the cheat `devmode.reload [path.to.module]`, it'll reload the file live while 99 | the game is running so it doesn't need to be closed and re-opened. For example, to reload main.py enter 100 | `devmode.reload main`. You can also enter paths to folders which will reload the entire folder or just not specify a 101 | path which will reload the entire project. 102 | 103 | This only works in devmode. 104 | 105 | ### fix_tuning_names.py 106 | 107 | This expects you to have extracted the tuning files from `Sims 4 Studio` with the `Sub-Folders` option checked. What 108 | this does is go through each and every tuning file and rename it to a much cleaner and better name. 109 | 110 | For example: 111 | 112 | ``` 113 | From: "03B33DDF!00000000!0D94E80BE40B3604.sims.loan_tuning.Tuning.xml" 114 | To: "sims_loan_tuning.xml" 115 | ``` 116 | 117 | Vastly prettier and cleaner don't you agree? 118 | 119 | ### sync_packages.py 120 | 121 | Running this script searches the top-level assets folder for any `.package` files and then copies them to your 122 | Mod Name Folder alongside your scripts. It's automatically run with `compile.py` and `devmode.py` and you can run it 123 | anytime yourself. 124 | 125 | ### bundle_build.py 126 | 127 | Zips up the build artifacts in a way that can be sent to Sims 4 Players or Mod Websites. It nests all the build 128 | artifacts in a subfolder named `CreatorName_ProjectName`. This way the player can directly unzip your mod into the Mods 129 | folder and it will all be self-contained in it's own folder. 130 | 131 | ### cleanup.py 132 | 133 | Removes all build artifacts 134 | 135 | * The build folder 136 | * The Mod Name folder in Mods 137 | * Debug functionality 138 | 139 | When completed, all traces of anything built by the project template for your mod will be removed leaving a fresh slate. 140 | This is common when you just want to clean everything up, especially after your all done developing and want to 141 | essentially "Un-Build" and "Un-Make" everything. 142 | 143 | ### src/helpers/injector.py 144 | 145 | This uses the popular injector, brought to my attention by LeRoiDesVampires and TURBOSPOOK. It's widely used in the Sims 146 | modding community across mods and tools. Reference it in your code to automate replacing functions in-game in a much 147 | prettier way with less coding. Optional to use. 148 | 149 | ## How to get started with this 150 | 151 | 1. Download it to your computer wherever you like, this will be your project folder for one project. 152 | 2. Rename the folder to the name of your project. 153 | 3. Copy settings.py.orig to another file called settings.py, this will become your personal settings 154 | 3. Change the settings to match your username or display name and where different folders are on your computer. 155 | 4. If you don't already have the library decompiled, run `decompile.py` which will take a long time 156 | 5. Using your favorite editor whether it be `Sublime`, `Notepad++`, `Visual Studio Code`, `PyCharm`, or wherever begin 157 | adding files to the `src` folder. 158 | 6. Run `compile.py` and test it out. Keep making changes until you're happy then your done. If you want to publish your 159 | work you can publish slim, full, or both version. 160 | 161 | ## Settings 162 | 163 | The toolkit has many settings found in `settings.py` and many of them you won't need to change but it's important that 164 | be the first place you go to before you start running scripts. 165 | 166 | `creator_name` 167 | 168 | This is the name you want prepended to the mod name. 169 | 170 | `mods_folder` 171 | 172 | This is where your sims 4 mods folder is location. It defaults to `Documents/Electronic Arts/The Sims 4/Mods`. It 173 | automatically finds your Documents folder most of the time. 174 | 175 | **Note for Windows Users:** if you have moved your Documents folder to another drive then settings will not be 176 | able to correctly locate it and you'll run into issues. In this case you would need to set it's location. 177 | 178 | **Note for non-windows users** you may have to change this 179 | 180 | `projects_folder` 181 | 182 | This is where you have all of your Sims 4 Projects, it defaults to `Documents/Sims 4 Projects` again auto-finding your 183 | Documents folder. 184 | 185 | **Note for Windows Users:** if you have moved your Documents folder to another drive then settings will not be 186 | able to correctly locate it and you'll run into issues. In this case you would need to set it's location. 187 | 188 | **Note for non-windows users** you may have to change this 189 | 190 | `game_folder` 191 | 192 | This is where your game is installed. It defaults to `C:\Program Files (x86)\Origin Games\The Sims 4`. If this is not 193 | your location you need to change this. 194 | 195 | **Note for non-windows users** you may have to change this 196 | 197 | There are many other settings related to the project but you generally won't need to change them. I suggest going 198 | through the file though and making sure everything is how you want it. 199 | 200 | ## License 201 | 202 | Licensed [Apache2](https://www.apache.org/licenses/LICENSE-2.0), basically do whatever you want to do as long as you 203 | credit me back and don't try to pose as me. 204 | 205 | ## Contributing 206 | 207 | Contributions are welcome, just fork and send a pull request 208 | -------------------------------------------------------------------------------- /Utility/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /Utility/debug_cmd.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import sims4.commands 16 | 17 | 18 | @sims4.commands.Command('pycharm.debug', command_type=sims4.commands.CommandType.Live) 19 | def _pycharm_debug(_connection: int = None) -> None: 20 | """ 21 | This creates an in-game cheat code 'pycharm.debug', when entered it sets up and connects the debugger to PyCharm Pro 22 | 23 | :param _connection: A special number provided automatically by the game 24 | :return: Nothing 25 | """ 26 | 27 | # Access the cheat console using the access number provided by the game and inform the user what to do 28 | output = sims4.commands.CheatOutput(_connection) 29 | output("Debug Initiated") 30 | output("There are now many red error messages in PyCharm, ignore them, this is normal.") 31 | output("Please open your PyCharm Pro editor now and click resume...") 32 | 33 | # Initiate the connection, the debugger will pause 2 lines down from here until the user resumes manually 34 | import pydevd_pycharm 35 | pydevd_pycharm.settrace('localhost', port=5678, stdoutToServer=True, stderrToServer=True) 36 | 37 | # Inform the user the debugger is ready and setup 38 | output("") 39 | output("The debugger has been successfully setup, your ready to start adding breakpoints and begin debugging") 40 | -------------------------------------------------------------------------------- /Utility/devmode_cmd.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import fnmatch 15 | 16 | from pathlib import Path 17 | 18 | import os 19 | import sims4.commands 20 | from sims4.reload import reload_file 21 | 22 | 23 | def reload_folder(path: str) -> None: 24 | """ 25 | Reloads all the python files in a folder and all sub-folders 26 | 27 | :param path: Folder to reload 28 | :return: Nothing 29 | """ 30 | 31 | for root, dirs, files in os.walk(path): 32 | for filename in fnmatch.filter(files, "*.py"): 33 | reload_file(root + os.sep + filename) 34 | 35 | 36 | @sims4.commands.Command('devmode.reload', command_type=sims4.commands.CommandType.Live) 37 | def _devmode_reload(module: str = "", _connection: int = None) -> None: 38 | """ 39 | Provides functionality to reload a module while in devmode 40 | 41 | Type in: 42 | devmode.reload [path.of.module] to reload the module 43 | 44 | :param module: Path of the module to reload 45 | :param _connection: Provided by the game 46 | :return: Nothing 47 | """ 48 | 49 | # Get ability to write to the cheat console and build path to project folder 50 | output = sims4.commands.CheatOutput(_connection) 51 | project_folder = str(Path(__file__).parent.parent) 52 | 53 | # Stop here if a module path wasn't given, in this case reload the whole project 54 | if not module: 55 | reload_folder(os.path.join(project_folder, "Scripts")) 56 | output("Reloaded entire project") 57 | return 58 | 59 | # Convert module path to a path and build a reload path 60 | sub_path = module.replace(".", os.sep) 61 | reload_path = os.path.join(project_folder, "Scripts", sub_path) 62 | 63 | # If it's a folder that exists reload the whole folder 64 | if os.path.exists(reload_path): 65 | if os.path.isdir(reload_path): 66 | reload_folder(reload_path) 67 | print("Reloaded Folder: " + sub_path) 68 | return 69 | else: 70 | print("Unknown file to reload" + sub_path) 71 | return 72 | 73 | # Assume it's a python file 74 | 75 | # If it doesn't exist then warn the user and stop here 76 | if not os.path.exists(reload_path + ".py"): 77 | output("Error: The file or folder doesn't exist to reload") 78 | output(sub_path + "[.py]") 79 | return 80 | 81 | # Issue the reloading and notify user 82 | reload_file(reload_path + ".py") 83 | output("Reloaded: " + sub_path + ".py") 84 | -------------------------------------------------------------------------------- /Utility/helpers_compile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # import core packages 16 | import fnmatch, logging, os, shutil 17 | from zipfile import PyZipFile, ZIP_DEFLATED 18 | 19 | from settings import devmode_parity 20 | from Utility.helpers_path import ensure_path_created, get_rel_path, remove_dir, remove_file 21 | from Utility.helpers_symlink import symlink_exists_win, symlink_remove_win 22 | 23 | 24 | def compile_slim(src_dir: str, zf: PyZipFile) -> None: 25 | """ 26 | Compiles all the .py to .pyc and writes them to the zip. 27 | 28 | :param src_dir: source folder 29 | :param zf: Zip File Handle 30 | :return: Nothing 31 | """ 32 | 33 | if devmode_parity: 34 | zf.writepy(src_dir) 35 | if not os.path.exists(os.path.join(src_dir, "__init__.py")): 36 | for entry in os.scandir(src_dir): 37 | if not entry.is_dir() or entry.name == "__pycache__": 38 | continue 39 | zf.writepy(entry.path) 40 | if not os.path.exists(os.path.join(entry.path, "__init__.py")): 41 | relative_entry = get_rel_path(entry.path, os.path.dirname(src_dir)) 42 | logging.warning( 43 | f"Since '{relative_entry}' does not contain an '__init__.py', its contents will be written to " 44 | f"the base of the zip (i.e. with the folder removed), and any files in its sub-directories " 45 | f"will not be written! This is the only way to maintain parity with devmode." 46 | ) 47 | else: 48 | for sub_entry in os.scandir(entry.path): 49 | if not sub_entry.is_dir() or sub_entry.name == "__pycache__": 50 | continue 51 | if not os.path.exists(os.path.join(sub_entry.path, "__init__.py")): 52 | relative_entry = get_rel_path(sub_entry.path, os.path.dirname(src_dir)) 53 | logging.warning( 54 | f"Since '{relative_entry}' does not contain an '__init__.py', " 55 | f"its contents will not be compiled!" 56 | ) 57 | else: 58 | logging.warning("Since devmode_parity is off, code may not behave the same way as in devmode! Please test!") 59 | for folder, subs, files in os.walk(src_dir): 60 | for filename in fnmatch.filter(files, '*[!p][!y][!c]'): 61 | zf.writepy(folder + os.sep + filename, basename=get_rel_path(folder, src_dir)) 62 | 63 | 64 | def compile_full(src_dir: str, zf: PyZipFile) -> None: 65 | """ 66 | Compiles a full mod - contains all files in source including python files which it then compiles 67 | Modified from andrew's code. 68 | https://sims4studio.com/thread/15145/started-python-scripting 69 | 70 | :param src_dir: source folder 71 | :param zf: Zip File Handle 72 | :return: Nothing 73 | """ 74 | 75 | compile_slim(src_dir, zf) 76 | for folder, subs, files in os.walk(src_dir): 77 | for filename in fnmatch.filter(files, '*[!p][!y][!c]'): 78 | rel_path = get_rel_path(folder + os.sep + filename, src_dir) 79 | zf.write(folder + os.sep + filename, rel_path) 80 | 81 | 82 | def compile_src(creator_name: str, src_dir: str, build_dir: str, mods_dir: str, mod_name: str = "Untitled") -> None: 83 | """ 84 | Packages your mod into a proper mod file. It creates only a full mod file which contains all the files 85 | in the source folder unchanged along with the compiled python versions next to uncompiled ones. 86 | 87 | Modified from andrew's code. 88 | https://sims4studio.com/thread/15145/started-python-scripting 89 | 90 | :param creator_name: The creators name 91 | :param src_dir: Source dir for the mod files 92 | :param build_dir: Place to put the mod files 93 | :param mods_dir: Place to an extra copy of the slim mod file for testing 94 | :param mod_name: Name to call the mod 95 | :return: Nothing 96 | """ 97 | 98 | # Prepend creator name to mod name 99 | mod_name = creator_name + '_' + mod_name 100 | mods_sub_dir = os.path.join(mods_dir, mod_name) 101 | 102 | # Create ts4script paths 103 | ts4script_full_build_path = os.path.join(build_dir, mod_name + '.ts4script') 104 | ts4script_mod_path = os.path.join(mods_sub_dir, mod_name + '.ts4script') 105 | 106 | print("Clearing out old builds...") 107 | 108 | # Delete Mods/sub-folder/Scripts and devmode.ts4script and re-create build 109 | is_devmode = symlink_exists_win("", mods_dir, mod_name) 110 | symlink_remove_win("", mods_dir, mod_name) 111 | 112 | for root, dirs, files in os.walk(mods_sub_dir): 113 | for filename in fnmatch.filter(files, "*.ts4script"): 114 | remove_file(root + os.sep + filename) 115 | 116 | if is_devmode: 117 | print("Exiting Dev Mode...") 118 | 119 | remove_dir(build_dir) 120 | 121 | ensure_path_created(build_dir) 122 | ensure_path_created(mods_sub_dir) 123 | 124 | print("Re-building mod...") 125 | 126 | # Compile the mod 127 | zf = PyZipFile(ts4script_full_build_path, mode='w', compression=ZIP_DEFLATED, allowZip64=True, optimize=2) 128 | compile_full(src_dir, zf) 129 | zf.close() 130 | 131 | # Copy it over to the mods folder 132 | shutil.copyfile(ts4script_full_build_path, ts4script_mod_path) 133 | 134 | print("Made .ts4script in build/ and the mod folder") 135 | 136 | print("----------") 137 | print("Complete") 138 | -------------------------------------------------------------------------------- /Utility/helpers_debug.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import contextlib, fnmatch, os, shutil, tempfile 15 | from pathlib import Path 16 | from zipfile import PyZipFile, ZipFile, ZIP_STORED 17 | 18 | from Utility.helpers_path import ensure_path_created, get_sys_folder, get_rel_path, remove_dir, remove_file 19 | from Utility.helpers_venv import Venv 20 | 21 | 22 | # Thank you to Sigma1202 from https://www.youtube.com/watch?v=RBnS8m0174U 23 | # for coming up with this process 24 | 25 | # This follows Sigma1202 process 26 | # 27 | # PyCharm Professional provides an ability to insert debug capability into an external system and then tap into that 28 | # ability. 29 | # 30 | # This is a 2 part process: 31 | # Part 1) Install the debugging capability, this is located in an egg file, we must modify the egg and then properly 32 | # install it as a mod so the game will load it and gain the ability to debug with PyCharm Pro 33 | # Part 2) We must get the game to reach out to our editor, to do this Sigma1202 has come up with the idea of a cheat 34 | # command. This command makes the game reach out to PyCharm Pro, initiate the debugging connection, and then you're 35 | # ready to start debugging. 36 | # 37 | # The command I developed is 'pycharm.debug' and I've hopefully made all of this very easy for the end user. 38 | 39 | 40 | def debug_ensure_pycharm_debug_package_installed() -> None: 41 | """ 42 | Ensures the debugging package is installed as requested by PyCharm Pro 43 | 44 | :return: Nothing 45 | """ 46 | 47 | d = os.path.dirname(os.path.realpath(__file__)) 48 | venv = Venv(os.path.join(d, "virtual_env")) 49 | venv.run() 50 | print("Making sure you have the debugging package installed...") 51 | venv.install("pydevd-pycharm~=202.7319.64") 52 | 53 | 54 | def install_debug_mod(mod_src: str, mods_dir: str, mod_name: str, mod_folder_name: str) -> None: 55 | """ 56 | Compiles and installs a cheat code mod for debug purposes 57 | 58 | :param mod_src: Path to the source of the mod 59 | :param mods_dir: Path to the users mod folder 60 | :param mod_name: Name of the mod 61 | :param mod_folder_name: Name of mod Subfolder 62 | :return: Nothing 63 | """ 64 | 65 | print("Compiling and installing the cheatcode mod...") 66 | 67 | # Get destination file path 68 | mods_sub_dir = os.path.join(mods_dir, mod_folder_name) 69 | mod_path = os.path.join(mods_sub_dir, mod_name + '.ts4script') 70 | 71 | ensure_path_created(mods_sub_dir) 72 | 73 | # Create mod at destination and compile to it 74 | zf = PyZipFile(mod_path, mode='w', compression=ZIP_STORED, allowZip64=True, optimize=2) 75 | zf.writepy(mod_src) 76 | zf.close() 77 | 78 | 79 | def debug_install_egg(egg_path: str, mods_dir, dest_name: str, mod_folder_name: str) -> None: 80 | """ 81 | Copies the debug egg provided by Pycharm Pro which adds the capability to make debugging happen inside 82 | PyCharm Pro. A bit of work goes into this, so it'll run much slower. 83 | 84 | :param egg_path: Path to the debug egg 85 | :param mods_dir: Path to the mods folder 86 | :param dest_name: Name of the mod 87 | :param mod_folder_name: Name of mod Subfolder 88 | :return: 89 | """ 90 | 91 | print("Re-packaging and installing the debugging capability mod...") 92 | # Get egg filename and path 93 | filename = Path(egg_path).name 94 | mods_sub_dir = os.path.join(mods_dir, mod_folder_name) 95 | mod_path = os.path.join(mods_sub_dir, dest_name + ".ts4script") 96 | 97 | ensure_path_created(mods_sub_dir) 98 | 99 | # Get python ctypes folder 100 | sys_ctypes_folder = os.path.join(get_sys_folder(), "Lib", "ctypes") 101 | 102 | # Create temp directory 103 | tmp_dir = tempfile.TemporaryDirectory() 104 | tmp_egg = tmp_dir.name + os.sep + filename 105 | 106 | # Remove old mod in mods folder there, if it exists 107 | remove_file(mod_path) 108 | 109 | # Copy egg to temp path 110 | shutil.copyfile(egg_path, tmp_egg) 111 | 112 | # TODO: simplify 113 | # Extract egg 114 | # This step is a bit redundant but I need to copy over everything but one folder into the zip file and I don't 115 | # know how to do that in python so I copy over the zip, extract it, copy in the whole folder, delete the one 116 | # sub-folder, then re-zip everything up. It's a pain but it's what I know how to do now and Google's not much help 117 | zin = ZipFile(tmp_egg) 118 | zin.extractall(tmp_dir.name) 119 | zin.close() 120 | 121 | # Remove archive 122 | remove_file(tmp_egg) 123 | 124 | # Copy ctype folder to extracted archive 125 | shutil.copytree(sys_ctypes_folder, tmp_dir.name + os.sep + "ctypes") 126 | 127 | # Remove that one folder 128 | remove_dir(tmp_dir.name + os.sep + "ctypes" + os.sep + "__pycache__") 129 | 130 | # Grab a handle on the egg 131 | zout = ZipFile(mod_path, mode='w', compression=ZIP_STORED, allowZip64=True) 132 | 133 | # Add all the files in the tmp directory to the zip file 134 | for folder, subs, files in os.walk(tmp_dir.name): 135 | for file in files: 136 | archive_path = get_rel_path(folder + os.sep + file, tmp_dir.name) 137 | zout.write(folder + os.sep + file, archive_path) 138 | zout.close() 139 | 140 | # There's a temporary directory bug that causes auto-cleanup to sometimes fail 141 | # We're preventing crash messages from flooding the screen to keep things tidy 142 | with contextlib.suppress(Exception): 143 | tmp_dir.cleanup() 144 | 145 | 146 | def remove_debug_mods(mods_dir: str, mod_folder_name: str) -> None: 147 | """ 148 | Removes any .ts4script in the given folder 149 | 150 | :param mods_dir: Path to the users mod folder 151 | :param mod_folder_name: Name of mod Subfolder 152 | :return: Nothing 153 | """ 154 | 155 | mods_sub_dir = os.path.join(mods_dir, mod_folder_name) 156 | if os.path.exists(mods_sub_dir): 157 | for root, dirs, files in os.walk(mods_sub_dir): 158 | for filename in fnmatch.filter(files, "*.ts4script"): 159 | remove_file(root + os.sep + filename) 160 | 161 | 162 | def debug_teardown(mods_dir: str, mod_folder_name: str) -> None: 163 | """ 164 | Deletes the 2 mods, they technically cause the running game to slow down 165 | 166 | :param mods_dir: Path to mods directory 167 | :param mod_folder_name: Name of mod Subfolder 168 | :return: Nothing 169 | """ 170 | 171 | print("Removing the debugging mod files...") 172 | mods_sub_dir = os.path.join(mods_dir, mod_folder_name) 173 | remove_dir(mods_sub_dir) 174 | -------------------------------------------------------------------------------- /Utility/helpers_decompile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # core imports 16 | import contextlib, fnmatch, os, shutil, tempfile, traceback 17 | from pathlib import Path 18 | from typing import Tuple 19 | from zipfile import PyZipFile 20 | 21 | from ctypes import Structure, c_uint 22 | from multiprocessing import Pool 23 | from multiprocessing.sharedctypes import Value 24 | from subprocess import CompletedProcess 25 | 26 | # Helpers 27 | from Utility.helpers_exec import exec_cli 28 | from Utility.helpers_path import ensure_path_created, get_default_executable_extension, get_file_stem, get_rel_path,\ 29 | replace_extension 30 | from Utility.helpers_time import get_minutes, get_time, get_time_str 31 | from Utility import process_module 32 | from Utility.helpers_venv import Venv 33 | from settings import num_threads 34 | 35 | # Globals 36 | script_package_types = ['*.zip', '*.ts4script'] 37 | python_compiled_ext = "*.pyc" 38 | 39 | 40 | class Stats(Structure): 41 | _fields_ = [('suc_count', c_uint), ('fail_count', c_uint), ('count', c_uint), ('col_count', c_uint)] 42 | 43 | 44 | class TotalStats(Structure): 45 | _fields_ = [('suc_count', c_uint), ('fail_count', c_uint), ('count', c_uint), ('minutes', c_uint)] 46 | 47 | 48 | # Global counts and timings for all the tasks 49 | totals = Value(TotalStats, 0, 0, 0, 0) 50 | # TODO: use https://github.com/greyblue9/unpyc37-3.10 instead if it returns to supporting Python 3.7 51 | unpyc3_path = os.path.join(Path(__file__).resolve().parent.parent, "unpyc37", "unpyc3.py") 52 | # Add it yourself by building https://github.com/zrax/pycdc 53 | pycdc_path = os.path.join(Path(__file__).resolve().parent.parent, "pycdc", "pycdc") + get_default_executable_extension() 54 | 55 | 56 | def decompile_pre() -> None: 57 | """ 58 | Here we ensure our decompilers are installed and install them if not 59 | We do this first because installation can create an error 60 | 61 | :return: Nothing 62 | """ 63 | 64 | d = os.path.dirname(os.path.realpath(__file__)) 65 | venv = Venv(os.path.join(d, "virtual_env")) 66 | venv.run() 67 | 68 | print("Checking for decompilers and installing if needed...") 69 | venv.install("decompyle3") 70 | assert (os.path.isfile(unpyc3_path)) 71 | venv.install("uncompyle6") 72 | 73 | 74 | def print_progress(stats: Stats, total: TotalStats, success: bool): 75 | # Print progress 76 | # Prints a single dot on the same line which gives a nice clean progress report 77 | # Tally number of files and successful / failed files 78 | if success: 79 | print(".", end="") 80 | stats.suc_count += 1 81 | total.suc_count += 1 82 | else: 83 | print("x", end="") 84 | stats.fail_count += 1 85 | total.fail_count += 1 86 | 87 | stats.count += 1 88 | total.count += 1 89 | 90 | # Insert a new progress line every 80 characters 91 | stats.col_count += 1 92 | if stats.col_count >= 80: 93 | stats.col_count = 0 94 | print("") 95 | 96 | 97 | def print_summary(stats: Stats): 98 | print(f"S: {stats.suc_count} [{round((stats.suc_count / stats.count) * 100, 2)}%], ", end="") 99 | print(f"F: {stats.fail_count} [{round((stats.fail_count / stats.count) * 100, 2)}%], ", end="") 100 | print(f"T: {stats.count}, ", end="") 101 | 102 | 103 | def stdout_decompile(cmd: str, args: [str], dest_path: str) -> Tuple[bool, CompletedProcess]: 104 | """ 105 | A helper for decompilers that write to stdout instead of a file 106 | :param cmd: the base command to run 107 | :param args: the args to give the command 108 | :param dest_path: the path that the code should be written to 109 | :return: tuple of (True iff the command succeeded completely, the CompletedProcess object 110 | """ 111 | success, result = exec_cli(cmd, args) 112 | if len(result.stdout) > 0: 113 | try: 114 | with open(dest_path, "w", encoding="utf-8") as file: 115 | file.write(result.stdout) 116 | except Exception: 117 | traceback.print_exc() 118 | print(f"command was {cmd}, {args}, {dest_path}") 119 | success = False 120 | return success, result 121 | 122 | 123 | def decompile_worker(src_file: str, dest_path: str): 124 | dest_lines = [0, 0] 125 | dest_files = [dest_path] 126 | for _ in dest_lines[1:]: 127 | handle, filename = tempfile.mkstemp("", os.path.basename(dest_path), dir=os.path.dirname(dest_path), text=True) 128 | os.close(handle) 129 | dest_files.append(filename) 130 | 131 | which = 0 132 | 133 | def file_to_write(): 134 | nonlocal which 135 | which = min(range(len(dest_lines)), key=dest_lines.__getitem__) 136 | # Fix decompyle3 erroring when the output file doesn't already exist, for some reason 137 | dest = dest_files[which] 138 | Path(dest).touch() 139 | return dest 140 | 141 | success: bool 142 | result = None 143 | _all_errors = "" 144 | 145 | def update_line_count(): 146 | nonlocal _all_errors 147 | # TODO: more accurately count "real" lines of code (e.g. exclude byte code and infinitely repeating statements) 148 | if os.path.isfile(dest_files[which]): 149 | with open(dest_files[which], "rbU") as fi: 150 | dest_lines[which] = sum(1 for _ in fi) 151 | if result and not success: 152 | _all_errors += result.stderr + "\n"; 153 | 154 | try: 155 | update_line_count() 156 | 157 | success, result = stdout_decompile("python3", [unpyc3_path, src_file], file_to_write()) 158 | update_line_count() 159 | if not success: 160 | success, result = exec_cli("decompyle3", ["--verify", "syntax", "-o", file_to_write(), src_file]) 161 | update_line_count() 162 | if not success and os.path.isfile(pycdc_path): 163 | success, result = stdout_decompile(pycdc_path, [src_file], file_to_write()) 164 | update_line_count() 165 | if not success: 166 | success, result = exec_cli("uncompyle6", ["-o", file_to_write(), src_file]) 167 | update_line_count() 168 | 169 | if not success: 170 | print(_all_errors) 171 | which = max(range(len(dest_lines)), key=dest_lines.__getitem__) 172 | if which != 0: 173 | shutil.copyfile(dest_files[which], dest_files[0]) 174 | finally: 175 | for file in dest_files[1:]: 176 | os.remove(file) 177 | print_progress(process_module.stats, process_module.total_stats, success) 178 | 179 | 180 | def init_process(stats, total): 181 | # I don't fully understand why this is necessary, but thanks to https://stackoverflow.com/a/1721911 182 | process_module.stats = stats 183 | process_module.total_stats = total 184 | 185 | 186 | def decompile_dir(src_dir: str, dest_dir: str, zip_name: str) -> None: 187 | """ 188 | Decompiles a directory of compiled python files to a different directory 189 | Modified from andrew's code. 190 | https://sims4studio.com/thread/15145/started-python-scripting 191 | 192 | :param src_dir: Path of dir to decompile 193 | :param dest_dir: Path of dir to send decompiled files to 194 | :param zip_name: Original filename of what's being decompiled (For progress output purposes) 195 | :return: Nothing 196 | """ 197 | 198 | # Begin clock 199 | time_start = get_time() 200 | 201 | if not os.path.isfile(pycdc_path): 202 | print(f"Add pycdc at {pycdc_path} for rarer decompiles!") 203 | print("You can build it from https://github.com/zrax/pycdc") 204 | 205 | print("Decompiling " + zip_name) 206 | 207 | # Local counts for this one task 208 | task_stats = Value(Stats, 0, 0, 0, 0) 209 | 210 | to_decompile = [] 211 | 212 | # TODO: remove files from dest that have no corresponding src (e.g. source files deleted from the game) 213 | # Go through each compiled python file in the folder 214 | for root, dirs, files in os.walk(src_dir): 215 | for filename in fnmatch.filter(files, python_compiled_ext): 216 | 217 | # Get details about the source file 218 | src_file = str(os.path.join(root, filename)) 219 | src_file_rel_path = get_rel_path(src_file, src_dir) 220 | 221 | # Create destination file path 222 | dest_path = replace_extension(dest_dir + os.path.sep + src_file_rel_path, "py") 223 | 224 | # And ensures the folders exist so there's no error 225 | # Make sure to strip off the file name at the end 226 | ensure_path_created(str(Path(dest_path).parent)) 227 | 228 | to_decompile.append((src_file, dest_path)) 229 | 230 | with Pool(num_threads, init_process, (task_stats, totals)) as pool: 231 | pool.starmap(decompile_worker, to_decompile) 232 | 233 | time_end = get_time() 234 | elapsed_minutes = get_minutes(time_end, time_start) 235 | totals.minutes += elapsed_minutes 236 | 237 | # Print a newline and then a compact completion message giving successful, failed, and total count stats and timing 238 | print("") 239 | print("") 240 | print("Completed") 241 | print_summary(task_stats) 242 | print(get_time_str(elapsed_minutes)) 243 | print("") 244 | 245 | 246 | def decompile_zip(src_dir: str, zip_name: str, dst_dir: str) -> None: 247 | """ 248 | Copies a zip file to a temporary folder, extracts it, and then decompiles it to the projects folder 249 | Modified from andrew's code. 250 | https://sims4studio.com/thread/15145/started-python-scripting 251 | 252 | :param src_dir: Source directory for zip file 253 | :param zip_name: zip filename 254 | :param dst_dir: Destination for unzipped files 255 | :return: Nothing 256 | """ 257 | 258 | # Create paths and directories 259 | file_stem = get_file_stem(zip_name) 260 | 261 | src_zip = os.path.join(src_dir, zip_name) 262 | dst_dir = os.path.join(dst_dir, file_stem) 263 | 264 | tmp_dir = tempfile.TemporaryDirectory() 265 | tmp_zip = os.path.join(tmp_dir.name, zip_name) 266 | 267 | # Copy zip to temp path 268 | shutil.copyfile(src_zip, tmp_zip) 269 | 270 | # Grab handle to zip file and extract all contents to the same folder 271 | zip_file = PyZipFile(tmp_zip) 272 | zip_file.extractall(tmp_dir.name) 273 | 274 | # Decompile the directory 275 | decompile_dir(tmp_dir.name, dst_dir, zip_name) 276 | 277 | # There's a temporary directory bug that causes auto-cleanup to sometimes fail 278 | # We're preventing crash messages from flooding the screen to keep things tidy 279 | with contextlib.suppress(Exception): 280 | tmp_dir.cleanup() 281 | 282 | 283 | def decompile_zips(src_dir: str, dst_dir: str) -> None: 284 | """ 285 | Decompiles a folder of zip files to a destination folder 286 | Modified from andrew's code. 287 | https://sims4studio.com/thread/15145/started-python-scripting 288 | 289 | :param src_dir: Directory to search for and decompile zip files 290 | :param dst_dir: Directory to send decompiled files to 291 | :return: Nothing 292 | """ 293 | for root, dirs, files in os.walk(src_dir): 294 | for ext_filter in script_package_types: 295 | for filename in fnmatch.filter(files, ext_filter): 296 | decompile_zip(root, filename, dst_dir) 297 | 298 | 299 | def decompile_print_totals() -> None: 300 | print("Results") 301 | 302 | # Fix Bug #1 303 | # https://github.com/junebug12851/Sims4ScriptingBPProj/issues/1 304 | try: 305 | print(f"S: {totals.suc_count} [{round((totals.suc_count / totals.count) * 100, 2)}%], ", end="") 306 | print(f"F: {totals.fail_count} [{round((totals.fail_count / totals.count) * 100, 2)}%], ", end="") 307 | print(f"T: {totals.count}, ", end="") 308 | print(get_time_str(totals.minutes)) 309 | except Exception: 310 | print("No files were processed, an error has occurred. Is the path to the game folder correct?") 311 | 312 | print("") 313 | -------------------------------------------------------------------------------- /Utility/helpers_exec.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from subprocess import run, CompletedProcess, TimeoutExpired 16 | import os.path, traceback 17 | from typing import Tuple, Union 18 | from Utility.helpers_path import get_sys_path, get_sys_scripts_folder, get_full_filepath 19 | 20 | from settings import decompiler_timeout 21 | 22 | 23 | def exec_cli(package: str, args: [str], **kwargs) -> Tuple[bool, Union[CompletedProcess, TimeoutExpired, None]]: 24 | """ 25 | Executes the cli version of an installed python package 26 | 27 | :param package: Package name to execute 28 | :param args: Arguments to provide to the package 29 | :return: Returns tuple of (boolean indicating success, the CompletedProcess object) 30 | """ 31 | # TODO: log stderr to a different file for each decompiler 32 | if os.path.isfile(package): 33 | cmd = package 34 | elif package == "python3": 35 | cmd = get_sys_path() 36 | else: 37 | cmd = get_full_filepath(get_sys_scripts_folder(), package) 38 | try: 39 | # TODO: make timeout scale with input file size? 40 | kwargs.setdefault("capture_output", True) 41 | kwargs.setdefault("timeout", decompiler_timeout) 42 | result = run([cmd, *args], text=True, encoding="utf-8", **kwargs) 43 | except TimeoutExpired as e: 44 | return False, e 45 | except Exception: 46 | traceback.print_exc() 47 | print(f"run was [{cmd}, {args}]") 48 | return False, None 49 | return (not str(result.stderr)) and (result.returncode == 0), result 50 | -------------------------------------------------------------------------------- /Utility/helpers_package.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from subprocess import call, run, CompletedProcess, DEVNULL, TimeoutExpired 16 | import os.path, traceback 17 | from typing import Tuple, Union 18 | from Utility.helpers_path import get_sys_path, get_sys_scripts_folder, get_full_filepath 19 | 20 | from settings import decompiler_timeout 21 | 22 | 23 | def install_package(package: str) -> None: 24 | """ 25 | This installs a package if it doesn't exist 26 | 27 | Thank you bradgonesurfing 28 | https://stackoverflow.com/questions/57593111/how-to-call-pip-from-a-python-script-and-make-it-install-locally-to-that-script 29 | 30 | :param package: Package name to ensure is installed 31 | :return: Nothing 32 | """ 33 | # noinspection PyBroadException 34 | try: 35 | __import__(package) 36 | except Exception: 37 | cmd = get_sys_path() 38 | args = "-m pip install " + package 39 | call(cmd + " " + args, 40 | stdout=DEVNULL, 41 | stderr=DEVNULL) 42 | 43 | 44 | def exec_package(package: str, args: [str]) -> Tuple[bool, Union[CompletedProcess, TimeoutExpired, None]]: 45 | """ 46 | Executes the cli version of an installed python package 47 | 48 | :param package: Package name to execute 49 | :param args: Arguments to provide to the package 50 | :return: Returns tuple of (boolean indicating success, the CompletedProcess object) 51 | """ 52 | # TODO: log stderr to a different file for each decompiler 53 | if os.path.isfile(package): 54 | cmd = package 55 | elif package == "python3": 56 | cmd = get_sys_path() 57 | else: 58 | cmd = get_full_filepath(get_sys_scripts_folder(), package) 59 | try: 60 | # TODO: make timeout scale with input file size? 61 | result = run([cmd, *args], capture_output=True, text=True, encoding="utf-8", timeout=decompiler_timeout) 62 | except TimeoutExpired as e: 63 | return False, e 64 | except Exception: 65 | traceback.print_exc() 66 | print(f"run was [{cmd}, {args}]") 67 | return False, None 68 | return (not str(result.stderr)) and (result.returncode == 0), result 69 | -------------------------------------------------------------------------------- /Utility/helpers_path.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import contextlib, glob, os, shutil, sys 16 | from pathlib import Path 17 | 18 | 19 | def get_rel_path(path: str, common_base: str) -> str: 20 | """ 21 | Returns path with common parent stripped out 22 | 23 | :param path: Path to strip 24 | :param common_base: common parent to strip out 25 | :return: Path with common parent stripped out 26 | """ 27 | return str(Path(path).relative_to(common_base)) 28 | 29 | 30 | def get_file_stem(file: str) -> str: 31 | """ 32 | Returns file stem 33 | 34 | :param file: Filename with or without path 35 | :return: just the filename without extension 36 | """ 37 | return Path(file).stem 38 | 39 | 40 | def replace_extension(file: str, new_ext: str) -> str: 41 | """ 42 | Replaces an extension from a path to another extension 43 | 44 | :param file: File path 45 | :param new_ext: New extension to replace with 46 | :return: New file extension 47 | """ 48 | 49 | p = Path(file) 50 | return str(p.parent) + os.path.sep + p.stem + "." + new_ext 51 | 52 | 53 | def get_default_executable_extension() -> str: 54 | """ 55 | Returns the default executable extension for the OS (might need some work) 56 | 57 | :return: the default executable extension for the OS 58 | """ 59 | return Path(sys.executable).suffix 60 | 61 | 62 | def get_sys_path() -> str: 63 | """ 64 | Returns absolute path to python executable 65 | 66 | :return: Absolute path to Python executable 67 | """ 68 | return sys.executable 69 | 70 | 71 | def get_sys_folder() -> str: 72 | """ 73 | Returns folder the python executable is in 74 | 75 | :return: Absolute path to Python folder 76 | """ 77 | return str(Path(get_sys_path()).parent) 78 | 79 | 80 | def get_sys_scripts_folder() -> str: 81 | """ 82 | Returns the system scripts folder 83 | 84 | :return: Absolute path to Python scripts folder 85 | """ 86 | path = get_sys_folder() 87 | if path.endswith('Scripts'): 88 | return path 89 | else: 90 | os.path.join(get_sys_folder(), 'Scripts') 91 | 92 | 93 | def get_full_filepath(folder: str, base_name: str) -> str: 94 | """ 95 | This gets an absolute path to a file of an unknown extension 96 | 97 | Thank you Blender 98 | https://stackoverflow.com/questions/19824598/open-existing-file-of-unknown-extension?rq=1 99 | 100 | :param folder: Absolute path of file 101 | :param base_name: Name of file with unknown extension 102 | :return: Absolute path to file with extension 103 | """ 104 | search = os.path.join(folder, base_name + '.*') 105 | try: 106 | return glob.glob(search)[0] 107 | except IndexError: 108 | raise FileNotFoundError(search) 109 | 110 | 111 | def ensure_path_created(path: str) -> None: 112 | """ 113 | Ensures folders are created and exist usually before doing work inside them 114 | Thanks to Blair Conrad & Boris 115 | https://stackoverflow.com/questions/273192/how-can-i-safely-create-a-nested-directory 116 | 117 | :param path: The path to ensure exists 118 | :return: Nothing 119 | """ 120 | Path(path).mkdir(parents=True, exist_ok=True) 121 | 122 | 123 | def remove_dir(path: str) -> None: 124 | """ 125 | Removes all folders and files in a directory 126 | Thank you Varun 127 | https://thispointer.com/python-how-to-delete-a-directory-recursively-using-shutil-rmtree/#:~:text=Delete%20all%20files%20in%20a,contents%20of%20a%20directory%20i.e.&text=It%20accepts%203%20arguments%20ignore_errors%2C%20onerror%20and%20path. 128 | 129 | :param path: Path to recursively remove 130 | :return: Nothing 131 | """ 132 | 133 | # Uncomment if you want to turn on verification 134 | # a = input("Are you sure you want to remove the dir: " + path + " [yes/no]: ") 135 | # if a.lower() != "yes": 136 | # sys.exit(1) 137 | 138 | # Remove folder and don't error out if it doesn't exist 139 | with contextlib.suppress(FileNotFoundError): 140 | shutil.rmtree(path, ignore_errors=True) 141 | 142 | 143 | def remove_file(path: str) -> None: 144 | """ 145 | Removes a single file 146 | 147 | :param path: File to remove 148 | :return: Nothing 149 | """ 150 | 151 | # Uncomment if you want to turn on verification 152 | # a = input("Are you sure you want to remove the file: " + path + " [yes/no]: ") 153 | # if a.lower() != "yes": 154 | # sys.exit(1) 155 | 156 | # Remove file and don't error out if it doesn't exist 157 | with contextlib.suppress(FileNotFoundError): 158 | os.remove(path) 159 | -------------------------------------------------------------------------------- /Utility/helpers_symlink.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from Utility.helpers_path import remove_dir, ensure_path_created 15 | from pathlib import Path 16 | 17 | import os 18 | from subprocess import run 19 | 20 | 21 | def get_scripts_path(creator_name: str, mods_dir: str, mod_name: str = "Untitled") -> str: 22 | """ 23 | This builds a path to the Scripts folder inside the Mod Folder 24 | 25 | :param creator_name: Creator Name 26 | :param mods_dir: Path to the Mods Folder 27 | :param mod_name: Name of Mod 28 | :return: Path to Scripts folder inside Mod Name Folder 29 | """ 30 | 31 | # creator_name can be omitted, if it's not then prefix it 32 | if creator_name: 33 | mod_name = creator_name + '_' + mod_name 34 | 35 | # Build absolute path to mod name folder 36 | mods_sub_dir = os.path.join(mods_dir, mod_name) 37 | 38 | # Return path to Scripts folder inside Mod name Folder 39 | return os.path.join(mods_sub_dir, "Scripts") 40 | 41 | 42 | def exec_cmd(cmd: str, args: str) -> bool: 43 | """ 44 | This executes a system command and returns whether it was successful or not 45 | 46 | :param cmd: Command to execute 47 | :param args: Any arguments to command 48 | :return: Successful or not 49 | """ 50 | 51 | # Result object of the command 52 | # If an error occurs, this will be the value used 53 | result = None 54 | 55 | try: 56 | # Run the command and capture output 57 | result = run(cmd + " " + args, 58 | capture_output=True, 59 | text=True, 60 | shell=True) 61 | except: 62 | pass 63 | 64 | # If the command completely crashed then return false 65 | if result is None: 66 | return False 67 | 68 | # Otherwise return false if stderr contains error messages and/or the return code is not 0 69 | return (not str(result.stderr)) and (result.returncode == 0) 70 | 71 | 72 | def symlink_exists_win(creator_name: str, mods_dir: str, mod_name: str = "Untitled") -> bool: 73 | """ 74 | Checks to see if a Scripts folder or file exists inside the Mod Folder 75 | 76 | :param creator_name: Creator Name 77 | :param mods_dir: Path to the Mods Folder 78 | :param mod_name: Name of Mod 79 | :return: Whether a "Scripts" file or folder does exist in the Mod Folder 80 | """ 81 | 82 | scripts_path = get_scripts_path(creator_name, mods_dir, mod_name) 83 | return os.path.exists(scripts_path) 84 | 85 | 86 | def symlink_remove_win(creator_name: str, mods_dir: str, mod_name: str = "Untitled", remove_whole_dir=False) -> None: 87 | """ 88 | Safely removes /Mods/ModName/Scripts 89 | 90 | This is very critical! In order to use symlinks on Windows without requiring admin privs we have to use 91 | "Directory Junctions", it's a special type of symlink intended for different purposes but works for our case. 92 | However, Python doesn't support Directory Junctions; in fact, it can't tell the difference between a directory 93 | junction and a real folder - it thinks they're the same. 94 | 95 | This means if you don't safely remove the Scripts directory junction, the original source code files the dev is 96 | working on will be wiped out irrecoverably when doing a re-compile or a re-devmode-setup. In other words, the dev 97 | will forever lose all the files they were working on as part of their project unless they had a backup elsewhere. 98 | Their hard work vanishes before their eyes just like that. 99 | 100 | This is unacceptable, to have a safety process in check, this function removes the mod folder, safely removing 101 | the scripts folder beforehand. If it's unable to it creates a crash so the caller won't proceed. 102 | 103 | Always use this function to remove the Mod Name Folder 104 | 105 | :param creator_name: Creator Name 106 | :param mods_dir: Path to the Mods Folder 107 | :param mod_name: Name of Mod 108 | :param remove_whole_dir: whether to really remove the whole Mod Name folder vs just the symlink inside it 109 | :return: Nothing 110 | """ 111 | 112 | # Build paths 113 | scripts_path = get_scripts_path(creator_name, mods_dir, mod_name) 114 | mod_folder_path = str(Path(scripts_path).parent) 115 | 116 | # Check whether the Scripts folder exists 117 | exists = symlink_exists_win(creator_name, mods_dir, mod_name) 118 | 119 | # Delete the Scripts folder and check whether it was successful 120 | success = exec_cmd("rmdir", '"' + scripts_path + '"') 121 | 122 | # If the Scripts folder exists but could not be deleted then print an error message and raise an exception 123 | if exists and not success: 124 | print("") 125 | print("Error: Scripts folder exists but can't be removed... Did you create a Scripts folder inside the Mod " 126 | "Folder at: ") 127 | print(scripts_path) 128 | print("If so, please manually delete it and try again.") 129 | print("") 130 | raise 131 | 132 | if remove_whole_dir: 133 | # Otherwise remove the directory 134 | remove_dir(mod_folder_path) 135 | 136 | 137 | def symlink_create_win(creator_name: str, src_dir: str, mods_dir: str, mod_name: str = "Untitled") -> None: 138 | """ 139 | Creates a symlink, it first wipes out the mod that may be there. When entering devmode, you don't compile anymore, 140 | so any compiled code needs to be removed. 141 | 142 | :param creator_name: Creator Name 143 | :param src_dir: Path to the source folder in this project 144 | :param mods_dir: Path to the Mods Folder 145 | :param mod_name: Name of Mod 146 | :return: Nothing 147 | """ 148 | 149 | # Build paths 150 | scripts_path = get_scripts_path(creator_name, mods_dir, mod_name) 151 | 152 | # Safely remove the symlink 153 | symlink_remove_win(creator_name, mods_dir, mod_name) 154 | 155 | # Create Scripts Folder as a Directory Junction 156 | exec_cmd("mklink", 157 | '/J ' + 158 | '"' + scripts_path + '" ' 159 | '"' + src_dir + '"') 160 | 161 | print("") 162 | print("Dev Mode is activated, you no longer have to compile after each change, run devmode.reload [path.of.module]") 163 | print("to reload individual files while the game is running. To exit dev mode, simply run 'compile.py' which will") 164 | print("return things to normal.") 165 | print("It's recommended to test a compiled version before final release after working in Dev Mode") 166 | print("") 167 | -------------------------------------------------------------------------------- /Utility/helpers_time.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | 17 | 18 | def get_time() -> datetime: 19 | """ 20 | Returns the current time 21 | Thank you kite 22 | https://www.kite.com/python/answers/how-to-calculate-a-time-difference-in-minutes-in-python#:~:text=Subtract%20one%20datetime%20object%20from,the%20time%20difference%20in%20minutes. 23 | 24 | :return: A datetime object containing the current time 25 | """ 26 | return datetime.datetime.today() 27 | 28 | 29 | def get_minutes(time_end: datetime, time_start: datetime) -> int: 30 | """ 31 | Converts 2 dates to minutes 32 | Thank you kite 33 | https://www.kite.com/python/answers/how-to-calculate-a-time-difference-in-minutes-in-python#:~:text=Subtract%20one%20datetime%20object%20from,the%20time%20difference%20in%20minutes. 34 | 35 | :param time_end: End timestamp 36 | :param time_start: Start timestamp 37 | :return: Minutes between them 38 | """ 39 | time_delta = (time_end - time_start) 40 | total_seconds = time_delta.total_seconds() 41 | return int(total_seconds / 60) 42 | 43 | 44 | def get_minutes_remain(minutes: int) -> int: 45 | """ 46 | Returns minutes remaining after converting to hours 47 | 48 | :param minutes: Total minutes before converting to hours 49 | :return: minutes after converting to hours 50 | """ 51 | return minutes % 60 52 | 53 | 54 | def get_hours(minutes: int) -> int: 55 | """ 56 | Converts minutes to hours 57 | 58 | :param minutes: Minutes before conversion 59 | :return: Hours 60 | """ 61 | return int(minutes / 60) 62 | 63 | 64 | def get_time_str(minutes: int) -> str: 65 | """ 66 | Returns a printable timestamp of the difference in hours and minutes from an unconverted minutes 67 | 68 | :param minutes: Unconverted minutes 69 | :return: Printable string 70 | """ 71 | 72 | hours = get_hours(minutes) 73 | minutes_remain = get_minutes_remain(minutes) 74 | minutes_str = "" 75 | 76 | if minutes_remain < 10: 77 | minutes_str = "0" + str(minutes_remain) 78 | else: 79 | minutes_str = str(minutes_remain) 80 | 81 | return str(hours) + ":" + minutes_str 82 | -------------------------------------------------------------------------------- /Utility/helpers_type_hints.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # core imports 16 | import contextlib, fnmatch, io, os, shutil, sys, tempfile, traceback 17 | from multiprocessing import Pool 18 | from pathlib import Path 19 | 20 | # Helpers 21 | from Utility.helpers_debug import install_debug_mod 22 | from Utility.helpers_exec import exec_cli 23 | from Utility.helpers_path import ensure_path_created, get_default_executable_extension, get_rel_path 24 | from Utility.helpers_venv import Venv 25 | from Utility.injector import inject, inject_to 26 | from settings import num_threads 27 | 28 | protoc_path = os.path.join( 29 | Path(__file__).resolve().parent.parent, "protoc", "protoc") + get_default_executable_extension() 30 | 31 | 32 | def type_hints_pre() -> None: 33 | """ 34 | Here we ensure needed packages are installed and install them if not 35 | We do this first because installation can create an error 36 | 37 | :return: Nothing 38 | """ 39 | 40 | d = os.path.dirname(os.path.realpath(__file__)) 41 | venv = Venv(os.path.join(d, "virtual_env")) 42 | venv.run() 43 | print("Checking for packages and installing if needed...") 44 | venv.install("six") 45 | venv.install("mypy") 46 | 47 | 48 | def find_protos(src_dir: str, dst_dir: str) -> bool: 49 | """ 50 | Generates a FileDescriptorSet containing every proto definition in the game. 51 | 52 | :param src_dir: Directory to search for protobuf _pb2.py files 53 | :param dst_dir: Directory to put proto data in 54 | :return: True iff the proto scanning worked 55 | """ 56 | 57 | # TODO: try https://stackoverflow.com/questions/19418655/restoring-proto-file-from-descriptor-string-possible ? 58 | import importlib, os, pkgutil 59 | 60 | os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION'] = 'python' 61 | os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION_VERSION'] = '2' 62 | 63 | sys.path.insert(1, os.path.join(src_dir, "generated")) 64 | # use our decompiled version of the game's own protobuf library - it's protobuf 2.4.1, so we can't even install it 65 | # on python 3.7 since its setup.py uses a no ( ) print statement 66 | sys.path.insert(1, os.path.join(src_dir, "core")) 67 | 68 | encoder_py = os.path.join(src_dir, "core", "google", "protobuf", "internal", "encoder.py") 69 | backup = encoder_py + '.backup' 70 | success: bool = False 71 | try: 72 | if os.path.exists(backup): 73 | shutil.copyfile(backup, encoder_py) 74 | else: 75 | shutil.copyfile(encoder_py, backup) 76 | # Replace all instances of " chr" in encoder.py with a bytes-returning version 77 | with open(encoder_py, 'r+') as file: 78 | encoder_py_contents = file.read() 79 | file.seek(0) 80 | file.write('import six\r\n' + encoder_py_contents.replace(' chr', ' six.int2byte')) 81 | file.truncate() 82 | 83 | import google.protobuf.internal.encoder 84 | import google.protobuf.internal.wire_format 85 | 86 | # Fix the StringEncoder not encoding the strings to bytes 87 | @inject_to(google.protobuf.internal.encoder, "StringEncoder") 88 | def on_string_encoder(original, field_number, is_repeated, is_packed): 89 | tag = google.protobuf.internal.encoder.TagBytes( 90 | field_number, google.protobuf.internal.wire_format.WIRETYPE_LENGTH_DELIMITED) 91 | local_encode_varint = google.protobuf.internal.encoder._EncodeVarint 92 | local_len = len 93 | 94 | encoder = original(field_number, is_repeated, is_packed) 95 | 96 | def fixed_encoder(orig2, write, value): 97 | if is_repeated: 98 | for element in value: 99 | encoded = element.encode('utf-8') 100 | write(tag) 101 | local_encode_varint(write, local_len(encoded)) 102 | write(encoded) 103 | else: 104 | return orig2(write, value.encode('utf-8')) 105 | return inject(encoder, fixed_encoder) 106 | 107 | # Handle the bytes that EncodeVarint will now return 108 | @inject_to(google.protobuf.internal.encoder, "_VarintBytes") 109 | def on_varint_bytes(_original, value): 110 | pieces = [] 111 | google.protobuf.internal.encoder._EncodeVarint(pieces.append, value) 112 | return b''.join(pieces) 113 | 114 | import google.protobuf.internal.python_message 115 | 116 | # Suppress the assertion errors caused by the game's version of protobuf not 117 | # having the fix for https://github.com/protocolbuffers/protobuf/issues/2533 118 | @inject_to(google.protobuf.internal.python_message, "_AddStaticMethods") 119 | def on_add_static_methods(original, cls): 120 | original(cls) 121 | orig2 = cls.RegisterExtension 122 | 123 | def on_register_extension(extension_handle): 124 | with contextlib.suppress(AssertionError): 125 | return orig2(extension_handle) 126 | cls.RegisterExtension = on_register_extension 127 | 128 | # Use BytesIO instead of StringIO since all the encoders now write bytes 129 | @inject_to(google.protobuf.internal.python_message, "_AddSerializePartialToStringMethod") 130 | def on_add_static_methods(original, message_descriptor, cls): 131 | original(message_descriptor, cls) 132 | 133 | def on_serialize_partial_to_string(self): 134 | out = io.BytesIO() 135 | self._InternalSerialize(out.write) 136 | # counteract the .encode('latin-1') that SerializeToString will call 137 | return str(out.getvalue(), 'latin-1') 138 | cls.SerializePartialToString = on_serialize_partial_to_string 139 | 140 | import google.protobuf.descriptor_pb2 141 | 142 | # Set allow_alias when needed so that protoc > 2.4.1 can work 143 | # (we need the --descriptor_set_in flag which is a later addition) 144 | @inject_to(google.protobuf.descriptor_pb2.EnumDescriptorProto, "_InternalParse") 145 | def on_enum_internal_parse(original, self, buffer, pos, end): 146 | ret = original(self, buffer, pos, end) 147 | self.value: RepeatedCompositeFieldContainer 148 | val: google.protobuf.descriptor_pb2.EnumValueDescriptorProto 149 | seen = set() 150 | need_aa = False 151 | for val in self.value: 152 | val.number: int 153 | if val.number in seen: 154 | need_aa = True 155 | break 156 | seen.add(val.number) 157 | if need_aa: 158 | self.options: google.protobuf.descriptor_pb2.EnumOptions 159 | # set allow_alias = True for versions that actually understand it 160 | google.protobuf.descriptor._ParseOptions(self.options, '\020\001'.encode('latin1')) 161 | return ret 162 | 163 | # in protobuf after 2.4.1, extension fields are no longer allowed to be 'required' 164 | @inject_to(google.protobuf.descriptor_pb2.FieldDescriptorProto, "_InternalParse") 165 | def on_field_internal_parse(original, self, buffer, pos, end): 166 | self: google.protobuf.descriptor_pb2.FieldDescriptorProto 167 | ret = original(self, buffer, pos, end) 168 | if self.extendee != '' and self.label == self.LABEL_REQUIRED: 169 | self.label = self.LABEL_OPTIONAL 170 | return ret 171 | 172 | from google.protobuf import descriptor 173 | from google.protobuf.internal.containers import RepeatedCompositeFieldContainer 174 | 175 | fds = google.protobuf.descriptor_pb2.FileDescriptorSet() 176 | fds.file: RepeatedCompositeFieldContainer 177 | fds.file[-1]: google.protobuf.descriptor_pb2.FileDescriptorProto 178 | 179 | import protocolbuffers 180 | 181 | name: str 182 | for _, name, _ in pkgutil.walk_packages(google.__path__, google.__name__ + '.'): 183 | if not name.endswith('_pb2'): 184 | continue 185 | # print(name) 186 | module = importlib.import_module(name) 187 | fds.file.add() 188 | module.DESCRIPTOR: descriptor.FileDescriptor 189 | module.DESCRIPTOR.CopyToProto(fds.file[-1]) 190 | if fds.file[-1].name == "": 191 | print(f"Failed to extract any data from serialized_pb: {fds.file[-1]}") 192 | return False 193 | # print(fds.file[-1]) 194 | 195 | for _, name, _ in pkgutil.walk_packages(protocolbuffers.__path__, protocolbuffers.__name__ + "."): 196 | # print(name) 197 | module = importlib.import_module(name) 198 | fds.file.add() 199 | module.DESCRIPTOR: descriptor.FileDescriptor 200 | module.DESCRIPTOR.CopyToProto(fds.file[-1]) 201 | if fds.file[-1].name == "": 202 | print(f"Failed to extract any data from serialized_pb: {fds.file[-1]}") 203 | return False 204 | # print(fds.file[-1]) 205 | 206 | serialized: bytes = fds.SerializeToString() 207 | open(os.path.join(dst_dir, 'protos.txt'), 'w').write(str(fds)) 208 | open(os.path.join(dst_dir, 'protos.pb'), 'wb').write(serialized) 209 | print("Protobuf FileDescriptorSet generated!") 210 | success = True 211 | finally: 212 | shutil.copyfile(backup, encoder_py) 213 | os.remove(backup) 214 | return success 215 | 216 | 217 | def make_proto_finder(dst_dir: str, mods_dir: str, mod_folder_name: str) -> None: 218 | """ 219 | Generates a mod that will generate a FileDescriptorSet on save load. 220 | 221 | :param dst_dir: Directory to put proto data in 222 | :param mods_dir: Path to the users mod folder 223 | :param mod_folder_name: Name of mod Subfolder 224 | :return: Nothing 225 | """ 226 | 227 | script_template = f""" 228 | import importlib, os, pkgutil 229 | from functools import wraps 230 | 231 | 232 | def inject(target_function, new_function): 233 | 234 | @wraps(target_function) 235 | def _inject(*args, **kwargs): 236 | return new_function(target_function, *args, **kwargs) 237 | 238 | return _inject 239 | 240 | 241 | def inject_to(target_object, target_function_name): 242 | 243 | def _inject_to(new_function): 244 | target_function = getattr(target_object, target_function_name) 245 | setattr(target_object, target_function_name, inject(target_function, new_function)) 246 | return new_function 247 | 248 | return _inject_to 249 | 250 | 251 | import google, protocolbuffers 252 | import google.protobuf.descriptor_pb2 253 | 254 | # Set allow_alias when needed so that protoc > 2.4.1 can work 255 | # (we need the --descriptor_set_in flag which is a later addition) 256 | @inject_to(google.protobuf.descriptor_pb2.EnumDescriptorProto, "MergeFromString") 257 | def on_internal_parse(original, self, serialized): 258 | ret = original(self, serialized) 259 | self.value: RepeatedCompositeFieldContainer 260 | val: google.protobuf.descriptor_pb2.EnumValueDescriptorProto 261 | seen = set() 262 | need_aa = False 263 | for val in self.value: 264 | val.number: int 265 | if val.number in seen: 266 | need_aa = True 267 | break 268 | seen.add(val.number) 269 | if need_aa: 270 | self.options: google.protobuf.descriptor_pb2.EnumOptions 271 | # set allow_alias = True for versions that actually understand it 272 | google.protobuf.descriptor._ParseOptions(self.options, ('\020\001').encode('latin1')) 273 | return ret 274 | 275 | 276 | # in protobuf after 2.4.1, extension fields are no longer allowed to be 'required' 277 | @inject_to(google.protobuf.descriptor_pb2.FieldDescriptorProto, "MergeFromString") 278 | def on_field_internal_parse(original, self, serialized): 279 | self: google.protobuf.descriptor_pb2.FieldDescriptorProto 280 | ret = original(self, serialized) 281 | if self.extendee != '' and self.label == self.LABEL_REQUIRED: 282 | self.label = self.LABEL_OPTIONAL 283 | return ret 284 | 285 | 286 | d = '{dst_dir}' 287 | fds = google.protobuf.descriptor_pb2.FileDescriptorSet() 288 | 289 | name: str 290 | for _, name, _ in pkgutil.walk_packages(protocolbuffers.__path__, protocolbuffers.__name__ + "."): 291 | module = importlib.import_module(name) 292 | fds.file.add() 293 | module.DESCRIPTOR.CopyToProto(fds.file[-1]) 294 | 295 | for _, name, _ in pkgutil.walk_packages(google.__path__, google.__name__ + '.'): 296 | if not name.endswith('_pb2'): 297 | continue 298 | module = importlib.import_module(name) 299 | fds.file.add() 300 | module.DESCRIPTOR.CopyToProto(fds.file[-1]) 301 | 302 | open(os.path.join(d, 'inner_protos.txt'), 'w').write(str(fds)) 303 | open(os.path.join(d, 'inner_protos.pb'), 'wb').write(fds.SerializeToString()) 304 | """ 305 | 306 | with tempfile.TemporaryDirectory() as folder: 307 | script_path = os.path.join(folder, "proto_finder.py") 308 | with open(script_path, 'w') as file: 309 | file.write(script_template) 310 | install_debug_mod(script_path, mods_dir, "proto_finder", mod_folder_name) 311 | 312 | 313 | def proto_type_hints(src_dir: str, dst_dir: str, mods_dir: str, mod_folder_name: str) -> bool: 314 | """ 315 | Generates a FileDescriptorSet containing every proto definition in the game, or generates a mod 316 | that will do so. 317 | 318 | :param src_dir: Directory to search for protobuff _pb2.py files 319 | :param dst_dir: Directory to put proto data in 320 | :param mods_dir: Path to the users mod folder 321 | :param mod_folder_name: Name of mod Subfolder 322 | :return: True iff the proto scanning worked, False if the mod had to be generated 323 | """ 324 | ensure_path_created(dst_dir) 325 | 326 | fds = os.path.join(dst_dir, 'protos.pb') 327 | have_fds = False 328 | try: 329 | if find_protos(src_dir, dst_dir): 330 | have_fds = True 331 | except (Exception, TypeError): 332 | print(traceback.format_exc()) 333 | if not have_fds: 334 | have_fds = os.path.exists(fds) and os.stat(fds).st_size > 100 335 | if not have_fds: 336 | print("Failed to scan the proto definitions from outside, inserting a mod to do so...") 337 | make_proto_finder(dst_dir, mods_dir, mod_folder_name) 338 | return False 339 | else: 340 | if not os.path.isfile(protoc_path): 341 | print(f'Need a protoc executable at {protoc_path} to continue!') 342 | print('You can download one from https://github.com/protocolbuffers/protobuf/releases (need 3.20.0+)') 343 | return False 344 | stubs = os.path.abspath(os.path.join(dst_dir, '..', 'stubs')) 345 | ensure_path_created(stubs) 346 | did_anything: bool = False 347 | for root, dirs, files in os.walk(src_dir): 348 | base_pyi_path = os.path.join(stubs, get_rel_path(root, src_dir)) 349 | any_pb2: bool = False 350 | all_pb2: bool = True 351 | 352 | for filename in files: 353 | if filename == '__init__.py': 354 | continue 355 | if not filename.endswith('_pb2.py'): 356 | all_pb2 = False 357 | continue 358 | any_pb2 = True 359 | with open(os.path.join(root, filename), 'r', encoding='utf-8') as file: 360 | line = 'not empty' 361 | while line and not line.startswith('DESCRIPTOR'): 362 | line = file.readline() 363 | if line.startswith('DESCRIPTOR'): 364 | proto_name = line.split("'")[1] 365 | else: 366 | proto_name = filename.replace("_pb2.py", ".proto") 367 | 368 | pyi_path = base_pyi_path 369 | proto_dir = os.path.normpath(os.path.dirname(proto_name)) 370 | if proto_dir != '.': 371 | if not pyi_path.endswith(proto_dir): 372 | print(f'{pyi_path} vs {proto_dir}') 373 | assert(pyi_path.endswith(proto_dir)) 374 | pyi_path = pyi_path[:-len(proto_dir)] 375 | 376 | ensure_path_created(pyi_path) 377 | success, result = exec_cli( 378 | protoc_path, ["--descriptor_set_in", fds, '--pyi_out', pyi_path, proto_name]) 379 | if not success: 380 | print(protoc_path, ["--descriptor_set_in", fds, '--pyi_out', pyi_path, proto_name]) 381 | print(result.stderr) 382 | all_pb2 = False 383 | return False 384 | 385 | if all_pb2 and any_pb2: 386 | open(os.path.join(base_pyi_path, '__init__.pyi'), 'w').close() 387 | did_anything = True 388 | if did_anything: 389 | print("Stubs for all protobufs generated!") 390 | return did_anything 391 | 392 | 393 | def type_hint_worker(src_file: str, dest_path: str): 394 | success, result = exec_cli("stubgen", ["-v", src_file, "-o", dest_path, "--include-private"]) 395 | if not success: 396 | success, result = exec_cli("stubgen", ["-v", src_file, "-o", dest_path, "--include-private", "--parse-only"]) 397 | if not success: 398 | print("stubgen", ["-v", src_file, "-o", dest_path, "--include-private", "--parse-only"]) 399 | print(result.stderr) 400 | 401 | 402 | def generate_type_hints(src_dir: str) -> bool: 403 | """ 404 | Generates typehints for all python files in src_dir except protobuffs. 405 | 406 | :param src_dir: Directory to search for python files 407 | :return: True iff the proto scanning worked, False if the mod had to be generated 408 | """ 409 | 410 | stubs = os.path.join(src_dir, 'stubs') 411 | ensure_path_created(stubs) 412 | 413 | sys.path.insert(1, os.path.join(src_dir, "base")) 414 | sys.path.insert(1, os.path.join(src_dir, "core")) 415 | sys.path.insert(1, os.path.join(src_dir, "generated")) 416 | sys.path.insert(1, os.path.join(src_dir, "simulation")) 417 | 418 | blacklist = ["generated", "proto", "stubs"] 419 | print("Using stubgen to generate python stubs! This will take a while!") 420 | 421 | # whether all the code in the src_dir can actually run 422 | all_code_can_run = False 423 | if all_code_can_run: 424 | from mypy.stubgen import parse_options, generate_stubs 425 | for scan in os.scandir(src_dir): 426 | if scan.is_dir(): 427 | if scan.name in blacklist: 428 | continue 429 | out = os.path.join(stubs, scan.name) 430 | # print("stubgen", ["-o", out, scan.path, "-v", "--include-private", "--ignore-errors"]) 431 | options = parse_options(["-o", out, scan.path, "-v", "--include-private", "--ignore-errors"]) 432 | try: 433 | generate_stubs(options) 434 | except Exception: 435 | pass 436 | # _success, _result = exec_cli("stubgen", ["-o", out, scan.path, "--include-private", "--ignore-errors"], 437 | # capture_output=False, timeout=None) 438 | else: 439 | work = [] 440 | for root, dirs, files in os.walk(src_dir): 441 | if root.endswith("__pycache__"): 442 | continue 443 | base_pyi_path = os.path.join(stubs, get_rel_path(root, src_dir)) 444 | 445 | for filename in fnmatch.filter(files, '*.py'): 446 | if filename.endswith("_pb2.py"): 447 | continue 448 | filepath = os.path.join(root, filename) 449 | pyi_path = base_pyi_path 450 | out = os.path.join(pyi_path, filename.replace(".py", ".pyi")) 451 | if not os.path.exists(out): 452 | while os.path.exists(os.path.join(pyi_path.replace(stubs, src_dir), "__init__.py")): 453 | pyi_path = os.path.dirname(pyi_path) 454 | # print(f'{filepath}, {pyi_path}') 455 | work.append((filepath, pyi_path)) 456 | with Pool(num_threads) as pool: 457 | pool.starmap(type_hint_worker, work) 458 | return True 459 | -------------------------------------------------------------------------------- /Utility/helpers_venv.py: -------------------------------------------------------------------------------- 1 | import os, subprocess, sys, venv 2 | 3 | 4 | # Inspired by https://stackoverflow.com/a/57604352/7376471, but using the built-in "venv" package instead 5 | class Venv: 6 | def __init__(self, virtual_dir): 7 | self.virtual_dir = virtual_dir 8 | self.virtual_python = os.path.join(self.virtual_dir, "Scripts", "python.exe") 9 | 10 | def install_virtual_env(self): 11 | if not os.path.exists(self.virtual_python): 12 | build = venv.EnvBuilder(symlinks=True, upgrade=True, with_pip=True) 13 | build.create(self.virtual_dir) 14 | print("created virtual environment: " + self.virtual_dir) 15 | else: 16 | print("found virtual python: " + self.virtual_python) 17 | 18 | def is_venv(self): 19 | return sys.prefix==self.virtual_dir 20 | 21 | def restart_under_venv(self): 22 | print("Restarting under virtual environment " + self.virtual_dir + ", " + __file__) 23 | subprocess.call([self.virtual_python] + sys.argv) 24 | exit(0) 25 | 26 | def install(self, package): 27 | os.environ["PIP_REQUIRE_VIRTUALENV"] = "true" 28 | subprocess.call([self.virtual_python, "-m", "pip", "install", package, "--upgrade"]) 29 | 30 | def run(self): 31 | if not self.is_venv(): 32 | self.install_virtual_env() 33 | self.restart_under_venv() 34 | else: 35 | print("Running under virtual environment") 36 | self.install("pip") 37 | -------------------------------------------------------------------------------- /Utility/injector.py: -------------------------------------------------------------------------------- 1 | # decompyle3 version 3.9.0 2 | # Python bytecode version base 3.7.0 (3394) 3 | # Decompiled from: Python 3.7.0 (v3.7.0:1bf9cc5093, Jun 27 2018, 04:59:51) [MSC v.1914 64 bit (AMD64)] 4 | # Embedded file name: G:\python scripting\Sims 4 Python Script Workspace (3.7)\Sims 4 Python Script Workspace\My Script Mods\TS4HeightSlider6_original\Scripts\injector.py 5 | # Compiled at: 2018-11-16 08:03:56 6 | # Size of source mod 2**32: 834 bytes 7 | from functools import wraps 8 | import inspect 9 | 10 | def inject(target_function, new_function): 11 | 12 | @wraps(target_function) 13 | def _inject(*args, **kwargs): 14 | return new_function(target_function, *args, **kwargs) 15 | 16 | return _inject 17 | 18 | 19 | def inject_to(target_object, target_function_name): 20 | 21 | def _inject_to(new_function): 22 | target_function = getattr(target_object, target_function_name) 23 | setattr(target_object, target_function_name, inject(target_function, new_function)) 24 | return new_function 25 | 26 | return _inject_to 27 | 28 | 29 | def is_injectable(target_function, new_function): 30 | target_argspec = inspect.getfullargspec(target_function) 31 | new_argspec = inspect.getfullargspec(new_function) 32 | return len(target_argspec.args) == len(new_argspec.args) - 1 33 | -------------------------------------------------------------------------------- /Utility/process_module.py: -------------------------------------------------------------------------------- 1 | stats = None 2 | total_stats = None 3 | -------------------------------------------------------------------------------- /assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mycroftjr/Sims4ScriptingTemplate/a856c3fffd160a11c849a2ae87b7db775e0e5b07/assets/.gitkeep -------------------------------------------------------------------------------- /bundle_build.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import contextlib, os, shutil, tempfile 15 | from settings import build_path, creator_name, project_name 16 | from Utility.helpers_path import ensure_path_created, get_rel_path, remove_file 17 | from zipfile import ZipFile, ZIP_DEFLATED 18 | 19 | # Build paths and create temp directory 20 | folder_name = creator_name + "_" + project_name 21 | bundle_path = build_path + os.sep + folder_name + ".zip" 22 | tmp_dir = tempfile.TemporaryDirectory() 23 | tmp_dst_path = tmp_dir.name + os.sep + folder_name 24 | 25 | # Ensure build directory is created 26 | ensure_path_created(build_path) 27 | 28 | # Remove existing bundle 29 | remove_file(bundle_path) 30 | 31 | # Copy build files to tmp dir 32 | shutil.copytree(build_path, tmp_dst_path) 33 | 34 | # Zip up bundled folder 35 | zf = ZipFile(bundle_path, mode='w', compression=ZIP_DEFLATED, allowZip64=True, compresslevel=9) 36 | for root, dirs, files in os.walk(tmp_dst_path): 37 | for filename in files: 38 | rel_path = get_rel_path(root + os.sep + filename, tmp_dst_path) 39 | zf.write(root + os.sep + filename, rel_path) 40 | zf.close() 41 | 42 | # There's a temporary directory bug that causes auto-cleanup to sometimes fail 43 | # We're preventing crash messages from flooding the screen to keep things tidy 44 | with contextlib.suppress(Exception): 45 | tmp_dir.cleanup() 46 | 47 | print(f"Created final mod zip at: {bundle_path}") 48 | -------------------------------------------------------------------------------- /cleanup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import shutil 15 | from Utility.helpers_debug import debug_teardown 16 | from Utility.helpers_path import remove_dir 17 | from Utility.helpers_symlink import symlink_remove_win 18 | from settings import mods_folder, debug_mod_subfolder, creator_name, project_name, build_path 19 | 20 | print("Removing Debug Setup...") 21 | debug_teardown(mods_folder, debug_mod_subfolder) 22 | 23 | print("Removing Mod Folder in Mods...") 24 | symlink_remove_win(creator_name, mods_folder, project_name, True) 25 | 26 | print("Removing Build folder...") 27 | remove_dir(build_path) 28 | 29 | print("") 30 | print("Complete... All build artifacts have been removed!") 31 | -------------------------------------------------------------------------------- /compile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Helpers 16 | from Utility.helpers_compile import compile_src 17 | from settings import mods_folder, src_path, creator_name, build_path, project_name 18 | import traceback 19 | 20 | try: 21 | compile_src(creator_name, src_path, build_path, mods_folder, project_name) 22 | exec(open("sync_packages.py").read()) 23 | exec(open("bundle_build.py").read()) 24 | except Exception as e: 25 | traceback.print_exc() 26 | -------------------------------------------------------------------------------- /debug_setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Make sure you Open PyCharm Pro and create a configuration using the "Python Debug Server" template 16 | # Using these settings 17 | # host: localhost 18 | # port: 5678 19 | 20 | # Before running game make sure you select the debug profile and run debug in the editor beforehand 21 | # Run debug_teardown.py when done to uninstall the debug capability as it can slow down the game 22 | 23 | from Utility.helpers_debug import debug_ensure_pycharm_debug_package_installed, install_debug_mod, debug_install_egg, \ 24 | debug_teardown 25 | from settings import mods_folder, debug_eggs_path, debug_cmd_mod_src_path, debug_cmd_mod_name, debug_capability_name, \ 26 | debug_mod_subfolder 27 | 28 | # Ensure PyCharm Pro debug package is installed 29 | debug_ensure_pycharm_debug_package_installed() 30 | 31 | # Install the debug mod and egg 32 | # The mod creates a cheat "pycharm.debug" which activates the debug process 33 | # The egg injects the code into the game so that the debug process can happen 34 | debug_teardown(mods_folder, debug_mod_subfolder) 35 | install_debug_mod(debug_cmd_mod_src_path, mods_folder, debug_cmd_mod_name, debug_mod_subfolder) 36 | debug_install_egg(debug_eggs_path, mods_folder, debug_capability_name, debug_mod_subfolder) 37 | 38 | print("") 39 | print("Complete!") 40 | print("") 41 | print("Step 1: Create a 'Python Debug Server' configuration In PyCharm Pro from the template using") 42 | print(" IDE host name: localhost") 43 | print(" port: 5678") 44 | print("Step 2: Select debug profile and begin debugging") 45 | print("Step 3: Load up a playable lot in the game") 46 | print("Step 4: Enter the cheatcode 'pycharm.debug'") 47 | print("Step 5: Switch windows to the debugger and hit resume") 48 | print("Step 6: The game and debugger are now connected, you're ready to start debugging!") 49 | print("") 50 | print("When you're done debugging, run 'debug_teardown.py' to uninstall the debugging capability. Otherwise leaving") 51 | print("it in just makes your game slower") 52 | -------------------------------------------------------------------------------- /debug_teardown.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from Utility.helpers_debug import debug_teardown 16 | from settings import mods_folder, debug_mod_subfolder 17 | 18 | debug_teardown(mods_folder, debug_mod_subfolder) 19 | 20 | print("Complete!") 21 | -------------------------------------------------------------------------------- /decompile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Helpers 16 | import multiprocessing 17 | 18 | from Utility.helpers_decompile import decompile_pre, decompile_zips, decompile_print_totals 19 | 20 | from Utility.helpers_path import ensure_path_created 21 | from settings import gameplay_folder_data, gameplay_folder_game, projects_python_path 22 | 23 | if __name__ == "__main__": 24 | multiprocessing.freeze_support() 25 | 26 | # Make sure the python folder exists 27 | ensure_path_created(projects_python_path) 28 | 29 | # Do a pre-setup 30 | decompile_pre() 31 | 32 | # Decompile all zips to the python projects folder 33 | print("") 34 | print("Beginning decompilation") 35 | print("This may take a while! Some files may not decompile properly which is normal.") 36 | print("") 37 | 38 | decompile_zips(gameplay_folder_data, projects_python_path) 39 | decompile_zips(gameplay_folder_game, projects_python_path) 40 | 41 | # Print final statistics 42 | decompile_print_totals() 43 | -------------------------------------------------------------------------------- /devmode.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from Utility.helpers_debug import install_debug_mod, remove_debug_mods 15 | from Utility.helpers_symlink import symlink_create_win, symlink_exists_win 16 | from settings import mods_folder, src_path, creator_name, project_name, devmode_cmd_mod_src_path, devmode_cmd_mod_name 17 | 18 | is_devmode = symlink_exists_win(creator_name, mods_folder, project_name) 19 | 20 | if is_devmode: 21 | print("You're already in Dev Mode") 22 | raise SystemExit(1) 23 | 24 | try: 25 | remove_debug_mods(mods_folder, creator_name + "_" + project_name) 26 | install_debug_mod(devmode_cmd_mod_src_path, mods_folder, devmode_cmd_mod_name, creator_name + "_" + project_name) 27 | exec(open("sync_packages.py").read()) 28 | symlink_create_win(creator_name, src_path, mods_folder, project_name) 29 | except Exception: 30 | print("An error occurred!") 31 | -------------------------------------------------------------------------------- /fix_tuning_names.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import fnmatch 16 | import os 17 | 18 | from settings import projects_tuning_path 19 | 20 | # For pretty progress and results 21 | col_count = 0 22 | suc_count = 0 23 | fail_count = 0 24 | skip_count = 0 25 | count = 0 26 | 27 | failed_filename_list = [] 28 | 29 | 30 | def loop_end() -> None: 31 | """ 32 | Has completed one iteration of a loop, this handles pretty progress and output 33 | 34 | :return: Nothing 35 | """ 36 | 37 | global count 38 | global col_count 39 | 40 | count += 1 41 | col_count += 1 42 | if col_count >= 80: 43 | col_count = 0 44 | print("") 45 | 46 | 47 | def attempt_rename(from_path: str, to_folder: str, to_file_stem: str) -> None: 48 | """ 49 | This attempts a rename, and if the file exists, tries to append increasing letters to the end to make the rename 50 | happen. If the rename still cannot happen it throws an error. 51 | 52 | :param from_path: Path to file which needs renaming 53 | :param to_folder: Path to same folder file is in 54 | :param to_file_stem: New name of file without extension 55 | 56 | :return: Nothing 57 | """ 58 | 59 | # Suffixes to append to the filename, in order, when a rename fails 60 | attempts = ['', '_a', '_b', '_c', '_d'] 61 | 62 | # Whether it was successful, a failed rename means we need to throw an error 63 | success = False 64 | 65 | # Loop through each of the suffixes, the first suffix is empty because we always try no suffix first 66 | # and attempt the rename 67 | for attempt in attempts: 68 | try: 69 | os.rename(from_path, to_folder + os.sep + to_file_stem + attempt + ".xml") 70 | success = True 71 | break 72 | except: 73 | pass 74 | 75 | # Throw error if success is still false meaning we went through all the possible suffix options and it still didn't 76 | # work 77 | if not success: 78 | raise NameError("Failed to rename file") 79 | 80 | 81 | def begin_fix() -> None: 82 | """ 83 | The function that does everything, loops through a Tunings folder and renames the tuning files to be in plain 84 | English. 85 | 86 | :return: Nothing 87 | """ 88 | 89 | global suc_count 90 | global fail_count 91 | global skip_count 92 | global failed_filename_list 93 | 94 | print("Fixing filenames...") 95 | print("") 96 | 97 | # Go through all the files in all the folders in the Tuning folder 98 | for folder, subs, files in os.walk(projects_tuning_path): 99 | for filename in fnmatch.filter(files, '*.xml'): 100 | 101 | # Break it up into pieces. The files are separated by dots 102 | # This goes from 103 | # "03B33DDF!00000000!0D94E80BE40B3604.sims.loan_tuning.Tuning.xml" 104 | # to 105 | # ["03B33DDF!00000000!0D94E80BE40B3604", "sims", "loan_tuning", "Tuning", "xml"] 106 | new_filename = filename.split(".") 107 | 108 | # Do a check to see if this file is already fixed 109 | # A fixed file will only have one dot, the extension. Skip if it's already fixed 110 | if len(new_filename) <= 2: 111 | print("_", end="") 112 | skip_count += 1 113 | loop_end() 114 | continue 115 | 116 | # This magic mangles the split filename to go from 117 | # "03B33DDF!00000000!0D94E80BE40B3604.sims.loan_tuning.Tuning.xml" 118 | # to 119 | # "sims_loan_tuning.xml" 120 | # Much prettier don't you agree? 121 | new_filename.pop(0) 122 | new_filename.pop() 123 | new_filename.pop() 124 | new_filename = "_".join(new_filename) 125 | 126 | # This does the renaming, if the renamer function fails after all renaming attempts then chalk it up 127 | # to a failure and report it 128 | try: 129 | attempt_rename(folder + os.sep + filename, folder, new_filename) 130 | print(".", end="") 131 | suc_count += 1 132 | except: 133 | print("x", end="") 134 | fail_count += 1 135 | failed_filename_list.append(folder + os.sep + filename) 136 | 137 | loop_end() 138 | 139 | # The nice pretty results output 140 | print("") 141 | print("") 142 | print("Completed") 143 | print("S: " + str(suc_count) + " [" + str(round((suc_count/count) * 100, 2)) + "%], ", end="") 144 | print("F: " + str(fail_count) + " [" + str(round((fail_count/count) * 100, 2)) + "%], ", end="") 145 | print("X: " + str(skip_count) + " [" + str(round((skip_count / count) * 100, 2)) + "%], ", end="") 146 | print("T: " + str(count)) 147 | 148 | # and the list of files that failed to rename if there are any 149 | if len(failed_filename_list) > 0: 150 | print("") 151 | print("Failed to rename files:") 152 | print("") 153 | print("\n".join(failed_filename_list)) 154 | print("") 155 | 156 | 157 | # A confirmation to make sure the user has done what this scripts expects them to have done 158 | print("This requires using Sims 4 Studio to export all Tuning files using sub-folders at the currently") 159 | print("configured location: " + projects_tuning_path) 160 | answer = input("Have you done this? [y/n]: ") 161 | 162 | if answer is "y": 163 | begin_fix() 164 | -------------------------------------------------------------------------------- /protoc/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mycroftjr/Sims4ScriptingTemplate/a856c3fffd160a11c849a2ae87b7db775e0e5b07/protoc/.gitkeep -------------------------------------------------------------------------------- /pycdc/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mycroftjr/Sims4ScriptingTemplate/a856c3fffd160a11c849a2ae87b7db775e0e5b07/pycdc/.gitkeep -------------------------------------------------------------------------------- /settings.py.orig: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | from pathlib import Path 17 | 18 | # #################################################### 19 | # Settings you can change 20 | # #################################################### 21 | 22 | # Your Name 23 | creator_name = 'My Creator Name' 24 | 25 | # Sims 4 mod folder location 26 | mods_folder = os.path.expanduser( 27 | os.path.join('~', 'Documents', 'Electronic Arts', 'The Sims 4', 'Mods') 28 | ) 29 | 30 | # Location of folder to contain your projects 31 | projects_folder = os.path.expanduser( 32 | os.path.join('~', 'Documents', 'Sims 4 Projects') 33 | ) 34 | 35 | # Location of the game folder 36 | game_folder = os.path.join('C:', os.sep, 'Program Files', 'EA Games', 'The Sims 4') 37 | 38 | # The number of threads to use during decompilation 39 | num_threads = 10 40 | 41 | # The number of seconds each decompiler will have on each file before being deemed a failure 42 | decompiler_timeout = 180.0 43 | 44 | # Instead of compiling everything, should project structure be observed to make sure the code will work the same way as in devmode? 45 | devmode_parity = True 46 | 47 | # ONLY FOR USERS OF PYCHARM PRO: Location of PyCharm Professional for debug setup 48 | # You do not need to use PyCharm at all or the professional version, ignore this setting if you are not 49 | pycharm_pro_folder = os.path.join('C:', os.sep, 'Program Files', 'JetBrains', 'PyCharm 2020.2.2') 50 | 51 | # #################################################### 52 | # Settings that you can but generally won't change 53 | # #################################################### 54 | 55 | # Folder within this project that contains your python/script files 56 | src_subpath = "src" 57 | 58 | # Folder within this project that your mods will be built to 59 | build_subpath = "build" 60 | 61 | # To hold asset files like xml tuning files and packages 62 | assets_subpath = "assets" 63 | 64 | # Subpath inside the projects folder to place decompiled python files 65 | projects_python_subpath = "__util" + os.sep + "Python" 66 | projects_tuning_subpath = "__util" + os.sep + "Tuning" 67 | 68 | # The name of this project, by default it's setup to use the folder name containing the project 69 | project_name = Path(__file__).parent.stem 70 | 71 | # Dev Mode 72 | devmode_cmd_mod_src = "Utility/devmode_cmd.py" 73 | devmode_cmd_mod_name = "devmode-cmd" 74 | 75 | # The name of the mod which will start the Pycharm debugging when entered into the game 76 | # ONLY FOR PYCHARM PRO USERS: If you're not using PyCharm Pro don't worry about this setting 77 | debug_cmd_mod_name = "pycharm-debug-cmd" 78 | debug_cmd_mod_src = "Utility/debug_cmd.py" 79 | debug_capability_name = "pycharm-debug-capability" 80 | debug_mod_subfolder = "PyCharmPro_Debug" 81 | 82 | # #################################################### 83 | # Settings that don't make any sense for you to change 84 | # #################################################### 85 | 86 | # The project folder path itself 87 | root_path = str(Path(__file__).parent) 88 | 89 | # These paths are calculated from the above information 90 | src_path = os.path.join(root_path, src_subpath) 91 | build_path = os.path.join(root_path, build_subpath) 92 | assets_path = os.path.join(root_path, assets_subpath) 93 | devmode_cmd_mod_src_path = os.path.join(Path(__file__).parent, devmode_cmd_mod_src) 94 | projects_python_path = os.path.join(projects_folder, projects_python_subpath) 95 | projects_tuning_path = os.path.join(projects_folder, projects_tuning_subpath) 96 | debug_cmd_mod_src_path = os.path.join(Path(__file__).parent, debug_cmd_mod_src) 97 | 98 | # Sims 4 Data and Game Folders 99 | gameplay_folder_data = os.path.join(game_folder, 'Data', 'Simulation', 'Gameplay') 100 | gameplay_folder_game = os.path.join(game_folder, 'Game', 'Bin', 'Python') 101 | debug_eggs_path = os.path.join(pycharm_pro_folder, "debug-eggs", "pydevd-pycharm.egg") 102 | -------------------------------------------------------------------------------- /src/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mycroftjr/Sims4ScriptingTemplate/a856c3fffd160a11c849a2ae87b7db775e0e5b07/src/helpers/__init__.py -------------------------------------------------------------------------------- /src/helpers/injector.py: -------------------------------------------------------------------------------- 1 | # This is public domain, it's widely used in the modding community and has no known license and no known original 2 | # author. Thank you LeRoiDesVampires and TURBOSPOOK for bringing it to my attention 3 | 4 | import inspect 5 | from functools import wraps 6 | 7 | 8 | def inject(target_object, target_function_name, safe=False): 9 | if safe and not hasattr(target_object, target_function_name): 10 | def _self_wrap(wrap_function): 11 | return wrap_function 12 | 13 | return _self_wrap 14 | 15 | def _wrap_original_function(original_function, new_function): 16 | @wraps(original_function) 17 | def _wrapped_function(*args, **kwargs): 18 | if type(original_function) is property: 19 | return new_function(original_function.fget, *args, **kwargs) 20 | else: 21 | return new_function(original_function, *args, **kwargs) 22 | 23 | if inspect.ismethod(original_function): 24 | return classmethod(_wrapped_function) 25 | elif type(original_function) is property: 26 | return property(_wrapped_function) 27 | else: 28 | return _wrapped_function 29 | 30 | def _injected(wrap_function): 31 | original_function = getattr(target_object, target_function_name) 32 | setattr(target_object, target_function_name, _wrap_original_function(original_function, wrap_function)) 33 | 34 | return wrap_function 35 | 36 | return _injected 37 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import sims4.commands 16 | 17 | # This is just a Hello World script, you can play around with it, do a test compile/decompile/debug, or just delete it 18 | # and start from scratch. The world is yours. 19 | 20 | 21 | @sims4.commands.Command('hellow', command_type=sims4.commands.CommandType.Live) 22 | def _hellow(_connection=None): 23 | output = sims4.commands.CheatOutput(_connection) 24 | output("This is my first script mod") 25 | -------------------------------------------------------------------------------- /sync_packages.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 June Hanabi 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # This installs, updates, and removes package files in the projects folder to match the assets folder 16 | import fnmatch 17 | 18 | import os 19 | import shutil 20 | from settings import assets_path, mods_folder, creator_name, project_name, build_path 21 | from Utility.helpers_path import ensure_path_created, remove_file 22 | 23 | mod_name_folder_path = mods_folder + os.sep + creator_name + "_" + project_name 24 | 25 | ensure_path_created(mod_name_folder_path) 26 | file_list_failed = [] 27 | 28 | 29 | def remove_tl_packages(path: str) -> int: 30 | count = 0 31 | 32 | # Remove existing package files 33 | for root, dirs, files in os.walk(path): 34 | for filename in fnmatch.filter(files, "*.package"): 35 | remove_file(root + os.sep + filename) 36 | count += 1 37 | 38 | # Only cover the top-level folder 39 | break 40 | return count 41 | 42 | 43 | def copy_tl_packages(src: str, dest: str) -> int: 44 | count = 0 45 | 46 | # Copy new assets 47 | for root, dirs, files in os.walk(src): 48 | for filename in files: 49 | try: 50 | shutil.copy(root + os.sep + filename, 51 | dest + os.sep + filename) 52 | count += 1 53 | except Exception: 54 | file_list_failed.append(root + os.sep + filename) 55 | 56 | # Only cover the top-level folder 57 | break 58 | 59 | return count 60 | 61 | 62 | files_removed = remove_tl_packages(mod_name_folder_path) 63 | remove_tl_packages(build_path) 64 | 65 | files_added = copy_tl_packages(assets_path, mod_name_folder_path) 66 | copy_tl_packages(assets_path, build_path) 67 | 68 | file_difference = files_added - files_removed 69 | 70 | print("Synced packages:" + 71 | " +" + str(files_added) + 72 | " -" + str(files_removed) + 73 | " ~" + str(file_difference)) 74 | 75 | if len(file_list_failed) > 0: 76 | print("") 77 | print("Failed to copy these files, make sure the packages are named uniquely") 78 | print("") 79 | print("\n".join(file_list_failed)) 80 | -------------------------------------------------------------------------------- /type_hints.py: -------------------------------------------------------------------------------- 1 | import multiprocessing, os 2 | 3 | from Utility.helpers_type_hints import generate_type_hints, proto_type_hints, type_hints_pre 4 | from settings import mods_folder, projects_python_path 5 | 6 | if __name__ == "__main__": 7 | multiprocessing.freeze_support() 8 | 9 | type_hints_pre() 10 | 11 | if proto_type_hints(projects_python_path, os.path.join(projects_python_path, "proto"), mods_folder, "proto_finder"): 12 | generate_type_hints(projects_python_path) 13 | --------------------------------------------------------------------------------