├── .github └── workflows │ └── release.yaml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── mapper └── __init__.py └── modimporter.py /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: "Tag / Release name (leave empty for dry-run)" 8 | required: false 9 | 10 | env: 11 | python-version: "3.8" 12 | name: modimporter 13 | artifacts-content-type: application/zip 14 | 15 | jobs: 16 | tag-and-release: 17 | name: Tag and create release 18 | runs-on: ubuntu-latest 19 | outputs: 20 | upload_url: ${{ steps.release.outputs.upload_url }} 21 | steps: 22 | - name: Checkout files 23 | uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 24 | 25 | - name: Tag 26 | if: github.event.inputs.tag 27 | run: | 28 | git tag ${{ github.event.inputs.tag }} 29 | git push origin --tags 30 | 31 | - name: Create release 32 | if: github.event.inputs.tag 33 | id: release 34 | uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e 35 | with: 36 | release_name: ${{ github.event.inputs.tag }} 37 | tag_name: ${{ github.event.inputs.tag }} 38 | body_path: ${{ env.release-notes }} 39 | commitish: main 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | archive-and-upload-python-artifacts: 44 | name: Archive and upload Python artifacts 45 | needs: tag-and-release 46 | runs-on: ubuntu-latest 47 | env: 48 | artifacts-python: modimporter-python.zip 49 | files-bundled: "sjson mapper LICENSE README.md" 50 | steps: 51 | - name: Checkout files 52 | uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 53 | with: 54 | ref: ${{ github.event.inputs.tag || github.sha }} 55 | submodules: true 56 | 57 | - name: Consolidate Python artifacts in a zip 58 | run: | 59 | rm -r sjson/.git 60 | zip ${{ env.artifacts-python }} -r ${{ env.name }}.py ${{ env.files-bundled }} 61 | 62 | - name: Upload artifacts to workflow 63 | uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 64 | with: 65 | name: ${{ env.artifacts-python }} 66 | path: ${{ env.artifacts-python }} 67 | retention-days: 1 68 | 69 | - name: Upload artifacts to release 70 | if: needs.tag-and-release.outputs.upload_url 71 | uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 72 | with: 73 | upload_url: ${{ needs.tag-and-release.outputs.upload_url }} 74 | asset_path: ${{ env.artifacts-python }} 75 | asset_name: ${{ env.artifacts-python }} 76 | asset_content_type: ${{ env.artifacts-content-type }} 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | build-and-upload-binaries: 81 | name: Build and upload binaries 82 | needs: tag-and-release 83 | runs-on: ${{ matrix.os }} 84 | strategy: 85 | matrix: 86 | os: [windows-latest, macos-latest, ubuntu-20.04] 87 | include: 88 | - os: windows-latest 89 | pip-cache-path: ~\AppData\Local\pip\Cache 90 | artifacts: modimporter-windows.zip 91 | pyinstaller-version: "4.4" 92 | - os: macos-latest 93 | pip-cache-path: ~/Library/Caches/pip 94 | artifacts: modimporter-macos.zip 95 | pyinstaller-version: "4.7" 96 | - os: ubuntu-20.04 97 | pip-cache-path: ~/.cache/pip 98 | artifacts: modimporter-linux.zip 99 | pyinstaller-version: "4.7" 100 | steps: 101 | - name: Checkout files 102 | uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 103 | with: 104 | ref: ${{ github.event.inputs.tag || github.sha }} 105 | submodules: true 106 | 107 | - name: Set up Python 108 | uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984 109 | with: 110 | python-version: ${{ env.python-version }} 111 | 112 | - name: Retrieve pip dependencies from cache 113 | uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 114 | with: 115 | path: | 116 | ${{ env.pythonLocation }}\lib\site-packages 117 | ${{ matrix.pip-cache-path }} 118 | key: ${{ runner.os }}-pip-cache-${{ matrix.pyinstaller-version }} 119 | 120 | - name: Install pip dependencies 121 | run: python -m pip install pyinstaller==${{ matrix.pyinstaller-version }} 122 | 123 | - name: Build binaries with PyInstaller 124 | run: python -m PyInstaller --onefile ${{ env.name }}.py --name ${{ env.name }} 125 | 126 | - name: Consolidate artifacts in a zip 127 | if: startsWith(runner.os, 'Windows') 128 | run: Compress-Archive dist/${{ env.name }}.exe ${{ matrix.artifacts }} 129 | 130 | - name: Consolidate artifacts in a zip 131 | if: startsWith(runner.os, 'macOS') || startsWith(runner.os, 'Linux') 132 | run: | 133 | mv dist/${{ env.name }} . 134 | zip ${{ matrix.artifacts }} -r ${{ env.name }} 135 | 136 | - name: Upload artifacts to workflow 137 | uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 138 | with: 139 | name: ${{ matrix.artifacts }} 140 | path: ${{ matrix.artifacts }} 141 | retention-days: 1 142 | 143 | - name: Upload artifacts to release 144 | if: needs.tag-and-release.outputs.upload_url 145 | uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 146 | with: 147 | upload_url: ${{ needs.tag-and-release.outputs.upload_url }} 148 | asset_path: ${{ matrix.artifacts }} 149 | asset_name: ${{ matrix.artifacts }} 150 | asset_content_type: ${{ env.artifacts-content-type }} 151 | env: 152 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # PyInstaller 7 | build/ 8 | dist/ 9 | *.spec 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "sjson"] 2 | path = sjson 3 | url = https://github.com/SGG-Modding/sjson.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andre Issa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mod Importer 2 | 3 | For SuperGiantGames's games (To be replaced by SGGMI) 4 | 5 | https://www.nexusmods.com/hades/mods/26 6 | 7 | ## Development 8 | 9 | ### Release workflow 10 | 11 | New releases can be created from GitHub Actions using the release workflow 12 | available [here](https://github.com/SGG-Modding/sgg-mod-modimporter/actions/workflows/release.yaml). 13 | 14 | The release workflow takes a tag / release name as input parameter to tag the 15 | repository, create a new release, build binaries, and upload them to the 16 | release. 17 | 18 | If the tag / release name is omitted and left blank, the workflow will run in 19 | dry-run mode (no tag / release, only binaries build) for testing purposes. 20 | 21 | ### Build binaries locally 22 | 23 | - Install [PyInstaller](https://pypi.org/project/pyinstaller/): 24 | 25 | ```bat 26 | python -m pip install pyinstaller==4.0 27 | ``` 28 | 29 | > Note that we use version 4.0 instead of the latest version to avoid getting 30 | > flagged by too many antivirus solutions due to PyInstaller's 31 | > pre-compiled bootloader. Older versions are less susceptible to this as AV 32 | > solutions had more time to properly recognize and whitelist them, in particular 33 | > from Microsoft antivirus (which is the single most important one not to get 34 | > flagged by). 35 | 36 | - Build binaries: 37 | 38 | ```bat 39 | python -m PyInstaller --onefile modimporter.py --name modimporter 40 | ``` 41 | -------------------------------------------------------------------------------- /mapper/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import struct 3 | 4 | inputFileContent = "" 5 | 6 | FORMATTER_32_BIT = '{:032b}' 7 | FORMATTER_8_BIT = '{:08b}' 8 | 9 | DATA_TYPES = ["Text", "Obstacle", "Unit", "Prefab", "Weapon", "Unknown", "Projectile", "Count", "Animation", "Component"] 10 | 11 | f = None 12 | 13 | #read a 4 byte int 14 | def ReadInt32(): 15 | intBytes = f.read(4) 16 | 17 | return int.from_bytes(intBytes, "little", signed=True) 18 | 19 | #read a 4 byte uint 20 | def ReadUInt32(): 21 | intBytes = f.read(4) 22 | 23 | return int.from_bytes(intBytes, "little", signed=False) 24 | 25 | #read 1 byte and if its not 0 return ture 26 | def ReadBoolean(): 27 | boolByte = f.read(1) 28 | 29 | return boolByte != b"\0" 30 | 31 | #read 4 bytes and use struct to pack them into a float 32 | def ReadSingle(): 33 | floatBytes = f.read(4) 34 | 35 | return struct.unpack('f', floatBytes)[0] 36 | 37 | #read a color which consists of 4 (R, G, B, A) 1 byte Uint numbers 38 | def ReadColor(): 39 | newColor = {"R" : 0, "G" : 0, "B" : 0, "A" : 0} 40 | 41 | newColor["R"] = int.from_bytes(f.read(1), "big", signed=False) 42 | newColor["G"] = int.from_bytes(f.read(1), "little", signed=False) 43 | newColor["B"] = int.from_bytes(f.read(1), "little", signed=False) 44 | newColor["A"] = int.from_bytes(f.read(1), "little", signed=False) 45 | 46 | return newColor 47 | 48 | #read a string which consists of a 4 byte uint length before the characters then the number of characters given by the length bytes 49 | def ReadString(): 50 | newString = "" 51 | 52 | stringLength = int.from_bytes(f.read(4), "little", signed=False) 53 | for i in range(stringLength): 54 | newString = newString + f.read(1).decode('utf-8') 55 | 56 | return newString 57 | 58 | #read a string with a bool flag before it, where if the flag is false it is a null string 59 | def ReadStringAllowNull(): 60 | doReadString = ReadBoolean() 61 | 62 | if doReadString: 63 | return ReadString() 64 | 65 | #read a nullable boolean, that for some reason is 4 bytes long but only the first byte is read, work in progress to figure out how 0,1, and 2 maps to true, false, and undefined 66 | #this program assumes they are in the order of 0 is true, 1 is undefined, and 2 is false 67 | def ReadTriBoolean(): 68 | newBool = f.read(4) 69 | 70 | if newBool[0] == 0: 71 | return True 72 | elif newBool[0] == 2: 73 | return False 74 | 75 | return None 76 | 77 | #read 4 bytes (only use first like tribool) that correspond to data type, work in progress to figure out how this works 78 | #possible values are Text, Obstacle, Unit, Prefab, Weapon, Unknown, Projectile, Count, Animation, and Component 79 | #this program assumes that they are in order, so 0 is Text, 1 is Obstacle, 2 is Unit, etc. 80 | def ReadDataType(): 81 | intBytes = f.read(4) 82 | 83 | return DATA_TYPES[intBytes[0]] 84 | 85 | #write a 4 byte int 86 | def WriteInt32(number): 87 | #turn number into binary 88 | bR = number.to_bytes(4, byteorder='big') 89 | bR = [bR[3], bR[2], bR[1], bR[0]] 90 | 91 | return bytes(bR) 92 | 93 | #write a 1 byte bool 94 | def WriteBoolean(value): 95 | binaryByte = [int(value == True)] 96 | #turn bool into binary 97 | return bytes(binaryByte) 98 | 99 | #write a tri bool, which is a bool that can be undefined, and has 3 null bytes that aren't used but must be there after its first byte, 100 | #currently in progress of finding how 0, 1, and 2 maps to true, false, and undefined 101 | #this program assumes they are in the order of 0 is true, 1 is undefined, and 2 is false 102 | def WriteTriBoolean(value): 103 | boolByte = 1 104 | if value == True: 105 | boolByte = 0 106 | elif value == False: 107 | boolByte = 2 108 | 109 | binaryBytes = [boolByte, 0, 0, 0] 110 | 111 | return bytes(binaryBytes) 112 | 113 | #write a float using struct 114 | def WriteSingle(inp): 115 | if inp != 0: 116 | binaryRep =''.join('{:0>8b}'.format(c) for c in struct.pack('!f', inp)) 117 | #store binary as 2 chunks of 16 118 | sections = ["",""] 119 | for i in range(8, 33, 8): 120 | byte = binaryRep[i-8:i] 121 | sections[(i - 1) // 16] += byte 122 | 123 | ret = b"" 124 | #f.write(chr(int(bytes[1][0:8], 2))) #1,2 125 | #write in order of D C B A 126 | for section in reversed(sections): 127 | sectionBytes = [int(section[8:16], 2), int(section[0:8], 2)] 128 | ret = ret + bytes(sectionBytes) 129 | 130 | return ret 131 | else: 132 | #Empty float print all null bytes 133 | emptyBytes = [0, 0, 0, 0] 134 | return bytes(emptyBytes) 135 | 136 | #collect R,G,B, and A and write them into the binary 137 | def WriteColor(colorTable): 138 | r = colorTable["R"] 139 | g = colorTable["G"] 140 | b = colorTable["B"] 141 | a = colorTable["A"] 142 | colorBytes = [r, g, b, a] 143 | return bytes(colorBytes) 144 | 145 | #write a string 146 | def WriteString(string): 147 | #add length of string to array 148 | bR = len(string).to_bytes(4, byteorder='big') 149 | stringBytes = [bR[3], bR[2], bR[1], bR[0]] 150 | #add each character to array to be converted 151 | for c in string: 152 | stringBytes.append(ord(c)) 153 | #write converting each into bytes representation 154 | return bytes(stringBytes) 155 | 156 | #write a string that has a bool flag before it to show if the string is null or not 157 | def WriteStringAllowNull(string): 158 | #if string is null print null (shows engine no string values to read) 159 | if string == None or string == "": 160 | return WriteBoolean(False) 161 | #if string is not null print true to show to read string and then read string like normal 162 | else: 163 | return WriteBoolean(True) + WriteString(string) 164 | 165 | #write 4 bytes (only use first like tribool) that correspond to data type, work in progress to figure out how this works 166 | #possible values are Text, Obstacle, Unit, Prefab, Weapon, Unknown, Projectile, Count, Animation, and Component 167 | #this program assumes that they are in order, so 0 is Text, 1 is Obstacle, 2 is Unit, etc. 168 | def WriteDataType(type): 169 | dataTypeBytes = [DATA_TYPES.index(type), 0, 0, 0] 170 | return bytes(dataTypeBytes) 171 | 172 | 173 | #read a binary file and write it to JSON 174 | def DecodeBinaries(inputFilePath): 175 | global f 176 | f = open(inputFilePath, "rb") 177 | f.read(4) #read SGB1, whatever it is 178 | f.read(4) #always going be 12, put need to read it to get it out of the way 179 | obstacleCount = ReadUInt32() 180 | obstacleTable = {"Obstacles": []} 181 | for i in range(obstacleCount): 182 | ReadBoolean() #read do create flag 183 | newObstacle = {} 184 | newObstacle["ActivateAtRange"] = ReadBoolean() 185 | newObstacle["ActivationRange"] = ReadSingle() 186 | newObstacle["Active"] = ReadBoolean() 187 | newObstacle["AllowMovementReaction"] = ReadBoolean() 188 | newObstacle["Ambient"] = ReadSingle() 189 | newObstacle["Angle"] = ReadSingle() 190 | 191 | newObstacle["AttachedIDs"] = [] 192 | attachedIdLength = ReadInt32() 193 | for x in range(attachedIdLength): 194 | newObstacle["AttachedIDs"].append(ReadInt32()) 195 | 196 | newObstacle["AttachToID"] = ReadInt32() 197 | newObstacle["CausesOcculsion"] = ReadBoolean() 198 | newObstacle["Clutter"] = ReadBoolean() 199 | newObstacle["Collision"] = ReadBoolean() 200 | 201 | newObstacle["Color"] = ReadColor() 202 | newObstacle["Comments"] = ReadStringAllowNull() 203 | 204 | newObstacle["CreatesShadows"] = ReadTriBoolean() 205 | newObstacle["DataType"] = ReadDataType() 206 | newObstacle["DrawVfxOnTop"] = ReadTriBoolean() 207 | 208 | newObstacle["FlipHorizontal"] = ReadBoolean() 209 | newObstacle["FlipVertical"] = ReadBoolean() 210 | 211 | newObstacle["GroupNames"] = [] 212 | groupNamesLength = ReadInt32() 213 | for x in range(groupNamesLength): 214 | ReadSingle() #for whatever reason the engine reads 4 bytes and just ... does nothing with them 215 | isStringNull = ReadBoolean() 216 | if not isStringNull: 217 | newObstacle["GroupNames"].append("") 218 | else: 219 | newObstacle["GroupNames"].append(ReadString()) 220 | 221 | newObstacle["HelpTextID"] = ReadStringAllowNull() 222 | newObstacle["Hue"] = ReadSingle() 223 | newObstacle["Saturation"] = ReadSingle() 224 | newObstacle["Value"] = ReadSingle() 225 | newObstacle["Id"] = ReadInt32() 226 | newObstacle["IgnoreGridManager"] = ReadBoolean() 227 | newObstacle["Invert"] = ReadBoolean() 228 | 229 | newObstacle["Location"] = {"X": ReadSingle(), "Y": ReadSingle()} 230 | 231 | newObstacle["Name"] = ReadStringAllowNull() 232 | 233 | newObstacle["OffsetZ"] = ReadSingle() 234 | newObstacle["ParallaxAmount"] = ReadSingle() 235 | 236 | newObstacle["Points"] = [] 237 | pointsLength = ReadInt32() 238 | for x in range(pointsLength): 239 | newObstacle["Points"].append({"X": ReadSingle(), "Y": ReadSingle()}) 240 | 241 | newObstacle["Scale"] = ReadSingle() 242 | newObstacle["SkewAngle"] = ReadSingle() 243 | newObstacle["SkewScale"] = ReadSingle() 244 | newObstacle["SortIndex"] = ReadInt32() 245 | newObstacle["StopsLight"] = ReadTriBoolean() 246 | newObstacle["Tallness"] = ReadSingle() 247 | newObstacle["UseBoundsForSortArea"] = ReadTriBoolean() 248 | 249 | obstacleTable["Obstacles"].append(newObstacle) 250 | 251 | f.close() 252 | 253 | return obstacleTable["Obstacles"] 254 | 255 | # jsonString = json.dumps(obstacleTable, sort_keys=True, indent=4) 256 | # with open(outputFilePath, "w+") as oid: 257 | # oid.write(jsonString) 258 | 259 | #read a json file and write it to binaries 260 | def EncodeBinaries(data): 261 | obstacles = data 262 | 263 | binary_data = b"" 264 | 265 | binary_data = binary_data + b"SGB1" #write whatever this is 266 | binary_data = binary_data + WriteInt32(12) #write the version number, this is always 12 267 | binary_data = binary_data + WriteInt32(len(obstacles)) 268 | 269 | for item in obstacles: 270 | binary_data = binary_data + WriteBoolean(True) 271 | 272 | binary_data = binary_data + WriteBoolean(item["ActivateAtRange"]) 273 | binary_data = binary_data + WriteSingle(item["ActivationRange"]) 274 | binary_data = binary_data + WriteBoolean(item["Active"]) 275 | 276 | binary_data = binary_data + WriteBoolean(item["AllowMovementReaction"]) 277 | binary_data = binary_data + WriteSingle(item["Ambient"]) 278 | binary_data = binary_data + WriteSingle(item["Angle"]) 279 | 280 | binary_data = binary_data + WriteInt32(len(item["AttachedIDs"])) 281 | 282 | for attachedID in item["AttachedIDs"]: 283 | binary_data = binary_data + WriteInt32(attachedID) 284 | binary_data = binary_data + WriteInt32(item["AttachToID"]) 285 | 286 | binary_data = binary_data + WriteBoolean(item["CausesOcculsion"]) 287 | binary_data = binary_data + WriteBoolean(item["Clutter"]) 288 | binary_data = binary_data + WriteBoolean(item["Collision"]) 289 | 290 | binary_data = binary_data + WriteColor(item["Color"]) 291 | 292 | binary_data = binary_data + WriteStringAllowNull(item["Comments"]) 293 | 294 | binary_data = binary_data + WriteTriBoolean(item["CreatesShadows"]) 295 | binary_data = binary_data + WriteDataType(item["DataType"]) 296 | binary_data = binary_data + WriteTriBoolean(item["DrawVfxOnTop"]) 297 | 298 | binary_data = binary_data + WriteBoolean(item["FlipHorizontal"]) 299 | binary_data = binary_data + WriteBoolean(item["FlipVertical"]) 300 | 301 | binary_data = binary_data + WriteInt32(len(item["GroupNames"])) 302 | for group in item["GroupNames"]: 303 | binary_data = binary_data + b'\x00\x00\x00\x00' 304 | binary_data = binary_data + WriteStringAllowNull(group) 305 | 306 | binary_data = binary_data + WriteStringAllowNull(item["HelpTextID"]) 307 | 308 | binary_data = binary_data + WriteSingle(item["Hue"]) 309 | binary_data = binary_data + WriteSingle(item["Saturation"]) 310 | binary_data = binary_data + WriteSingle(item["Value"]) 311 | 312 | binary_data = binary_data + WriteInt32(item["Id"]) 313 | 314 | binary_data = binary_data + WriteBoolean(item["IgnoreGridManager"]) 315 | binary_data = binary_data + WriteBoolean(item["Invert"]) 316 | 317 | binary_data = binary_data + WriteSingle(item["Location"]["X"]) 318 | binary_data = binary_data + WriteSingle(item["Location"]["Y"]) 319 | 320 | binary_data = binary_data + WriteStringAllowNull(item["Name"]) 321 | 322 | binary_data = binary_data + WriteSingle(item["OffsetZ"]) 323 | binary_data = binary_data + WriteSingle(item["ParallaxAmount"]) 324 | 325 | binary_data = binary_data + WriteInt32(len(item["Points"])) 326 | for point in item["Points"]: 327 | binary_data = binary_data + WriteSingle(point["X"]) 328 | binary_data = binary_data + WriteSingle(point["Y"]) 329 | 330 | binary_data = binary_data + WriteSingle(item["Scale"]) 331 | binary_data = binary_data + WriteSingle(item["SkewAngle"]) 332 | binary_data = binary_data + WriteSingle(item["SkewScale"]) 333 | 334 | binary_data = binary_data + WriteInt32(item["SortIndex"]) 335 | 336 | binary_data = binary_data + WriteTriBoolean(item["StopsLight"]) 337 | 338 | binary_data = binary_data + WriteSingle(item["Tallness"]) 339 | 340 | binary_data = binary_data + WriteTriBoolean(item["UseBoundsForSortArea"]) 341 | 342 | return binary_data -------------------------------------------------------------------------------- /modimporter.py: -------------------------------------------------------------------------------- 1 | # Mod Importer for SuperGiant Games' Games 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import platform 7 | import sys 8 | from collections import defaultdict 9 | from collections import deque 10 | from pathlib import Path 11 | 12 | import logging 13 | from collections import OrderedDict 14 | from shutil import copyfile 15 | from datetime import datetime 16 | 17 | import csv 18 | import xml.etree.ElementTree as xml 19 | 20 | import json 21 | 22 | # Logging configuration 23 | logging.basicConfig( 24 | format='%(message)s', 25 | handlers=[ 26 | logging.FileHandler("modimporter.log.txt", mode='w'), 27 | logging.StreamHandler(), 28 | ], 29 | ) 30 | LOGGER = logging.getLogger('modimporter') 31 | LOGGER.setLevel(logging.INFO) 32 | 33 | can_mapper = False 34 | try: 35 | import mapper 36 | can_mapper = True 37 | except ModuleNotFoundError: 38 | LOGGER.error("Mapper python module not found! Map changes will be skipped!") 39 | LOGGER.error("Mapper module should be available in the same place as the importer\n") 40 | can_sjson = False 41 | try: 42 | import sjson 43 | can_sjson = True 44 | except ModuleNotFoundError: 45 | LOGGER.error("SJSON python module not found! SJSON changes will be skipped!") 46 | LOGGER.error("SJSON module should be available in the same place as the importer\n") 47 | 48 | 49 | # if we are on MacOS and running PyInstaller executable, force working directory 50 | # to be the one containing the executable 51 | # this is a kludge around MacOS calling executables from the user home rather 52 | # than the current directory when double-clicked on from Finder 53 | # has to be done here due to the rest of the script mixing in `os.path` and 54 | # `pathlib` operations outside of the actual `__main__` 55 | if platform.system() == 'Darwin' and getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 56 | parent_dir = Path(sys.argv[0]).parent 57 | os.chdir(parent_dir) 58 | LOGGER.info(f"Running MacOS executable from Finder: forced working directory to {parent_dir}") 59 | 60 | 61 | ## Global Settings 62 | 63 | clean_only = False #uninstall option, ignores mod folder 64 | 65 | game_aliases = {"Resources":"Hades", #alias for temporary mac support 66 | "Content":"Hades"} #alias for temporary windows store support 67 | 68 | modsdir = "Mods" 69 | modsrel = ".." 70 | gamerel = ".." 71 | scope = "Content" 72 | bakdir = "Backup" 73 | baktype = "" 74 | modfile = "modfile.txt" 75 | mlcom_start = "-:" 76 | mlcom_end = ":-" 77 | comment = "::" 78 | linebreak = ";" 79 | delimiter = "," 80 | 81 | modified = "MODIFIED" 82 | modified_modrep = " by Mod Importer @ " 83 | modified_lua = "-- "+modified+" " 84 | modified_xml = "" 85 | modified_sjson = "/* "+modified+" */" 86 | modified_csv = modified 87 | modified_map = modified 88 | 89 | default_to = {"Hades":["Scripts/RoomManager.lua"], 90 | "Hades II Technical Test":["Scripts/RoomLogic.lua"], 91 | "Hades II":["Scripts/RoomLogic.lua"], 92 | "Hades2":["Scripts/RoomLogic.lua"], 93 | "Pyre":["Scripts/Campaign.lua","Scripts/MPScripts.lua"], 94 | "Transistor":["Scripts/AllCampaignScripts.txt"]} 95 | default_priority = 100 96 | 97 | kwrd_to = ["To"] 98 | kwrd_load = ["Load"] 99 | kwrd_priority = ["Priority"] 100 | kwrd_include = ["Include"] 101 | kwrd_import = ["Import"] 102 | kwrd_topimport = ["Top","Import"] 103 | kwrd_xml = ["XML"] 104 | kwrd_sjson = ["SJSON"] 105 | kwrd_replace = ["Replace"] 106 | kwrd_csv = ["CSV"] 107 | kwrd_map = ["Map"] 108 | 109 | reserved_sequence = "_sequence" 110 | reserved_append = "_append" 111 | reserved_replace = "_replace" 112 | reserved_delete = "_delete" 113 | reserved_search = "_search" 114 | 115 | ## Data Functionality 116 | 117 | DNE = () 118 | 119 | def safeget(data,key): 120 | if isinstance(data,list): 121 | if isinstance(key,int): 122 | if key < len(data) and key >= 0: 123 | return data[key] 124 | return DNE 125 | if isinstance(data,OrderedDict): 126 | return data.get(key,DNE) 127 | if isinstance(data,xml.ElementTree): 128 | root = data.getroot() 129 | if root: 130 | return root.get(key,DNE) 131 | if isinstance(data,xml.Element): 132 | return data.get(key,DNE) 133 | return DNE 134 | 135 | def safepairs(data): 136 | it = DNE 137 | if isinstance(data,list): 138 | it = enumerate(data) 139 | if isinstance(data,OrderedDict): 140 | it = data.items() 141 | return it 142 | 143 | def clearDNE(data): 144 | if isinstance(data,OrderedDict): 145 | for k,v in data.copy().items(): 146 | if v is DNE: 147 | del data[k] 148 | continue 149 | data[k] = clearDNE(v) 150 | if isinstance(data,list): 151 | L = [] 152 | for i,v in enumerate(data): 153 | if v is DNE: 154 | continue 155 | L.append(clearDNE(v)) 156 | data = L 157 | return data 158 | 159 | ### LUA import statement adding 160 | 161 | def addimport(base,path): 162 | with open(base,'a',encoding='utf-8') as basefile: 163 | basefile.write("\nImport "+"\""+modsrel+"/"+path+"\"") 164 | 165 | def addtopimport(base,path): 166 | with open(base,'r+',encoding='utf-8') as basefile: 167 | lines = basefile.readlines() 168 | lines.insert(0, "Import "+"\""+modsrel+"/"+path+"\"\n") 169 | basefile.seek(0) 170 | basefile.truncate() 171 | basefile.writelines(lines) 172 | 173 | ### CSV mapping 174 | 175 | def readcsv(filename): 176 | with open(filename,'r',newline='',encoding='utf-8-sig') as file: 177 | return list(csv.reader(file)) 178 | 179 | 180 | def writecsv(filename,content): 181 | with open(filename,'w',newline='',encoding='utf-8-sig') as file: 182 | writer = csv.writer(file,quoting=csv.QUOTE_MINIMAL) 183 | for row in content: 184 | writer.writerow(row) 185 | 186 | def csvmap(indata,mapdata): 187 | target = [0,0] 188 | current = [0,0] 189 | append = False 190 | replace = False 191 | for row in mapdata: 192 | if len(row) == 2 and row[0][0] == '<' and row[-1][-1] == '>': 193 | target[0] = int(row[0][1:]) 194 | target[1] = int(row[-1][:-1]) 195 | current[0] = target[0] 196 | append = False 197 | replace = False 198 | continue 199 | if len(row) == 1 and row[0] == reserved_append: 200 | append = True 201 | replace = False 202 | if len(row) == 1 and row[0] == reserved_replace: 203 | append = False 204 | replace = True 205 | if append: 206 | indata.append(row) 207 | else: 208 | if replace: 209 | indata[current[0]]=row 210 | else: 211 | current[1] = target[1] 212 | for value in row: 213 | if value == reserved_delete: 214 | indata[current[0]][current[1]] = "" 215 | elif value != "": 216 | indata[current[0]][current[1]] = value 217 | current[1] += 1 218 | current[0] += 1 219 | return indata 220 | 221 | 222 | def mergecsv(infile,mapfile): 223 | indata = readcsv(infile) 224 | if mapfile: 225 | mapdata = readcsv(mapfile) 226 | else: 227 | mapdata = DNE 228 | indata = csvmap(indata,mapdata) 229 | writecsv(infile,indata) 230 | 231 | ### XML mapping 232 | 233 | def readxml(filename): 234 | try: 235 | return xml.parse(filename) 236 | except xml.ParseError: 237 | return DNE 238 | 239 | def writexml(filename,content,start=None): 240 | if not isinstance(filename,str): 241 | return 242 | if not isinstance(content, xml.ElementTree): 243 | return 244 | content.write(filename) 245 | 246 | #indentation styling 247 | data = "" 248 | if start: 249 | data = start 250 | with open(filename,'r',encoding='utf-8-sig') as file: 251 | i = 0 252 | for line in file: 253 | nl = False 254 | if len(line.replace('\t','').replace(' ',''))>1: 255 | q=True 256 | p='' 257 | for s in line: 258 | if s == '\"': 259 | q = not q 260 | if p == '<' and q: 261 | if s == '/': 262 | i-=1 263 | data = data[:-1] 264 | else: 265 | i+=1 266 | data+=p 267 | if s == '>' and p == '/' and q: 268 | i-=1 269 | if p in (' ') or (s=='>' and p == '\"') and q: 270 | data+='\n'+'\t'*(i-(s=='/')) 271 | if s not in (' ','\t','<') or not q: 272 | data+=s 273 | p=s 274 | open(filename,'w',encoding='utf-8').write(data) 275 | 276 | def xmlmap(indata,mapdata): 277 | if mapdata is DNE: 278 | return indata 279 | if type(indata)==type(mapdata): 280 | if isinstance(mapdata,dict): 281 | for k,v in mapdata.items(): 282 | indata[k] = xmlmap(indata.get(k),v) 283 | return indata 284 | if isinstance(mapdata,xml.ElementTree): 285 | root = xmlmap(indata.getroot(),mapdata.getroot()) 286 | if root: 287 | indata._setroot(root) 288 | return indata 289 | elif isinstance(mapdata,xml.Element): 290 | mtags = dict() 291 | for v in mapdata: 292 | if not mtags.get(v.tag,False): 293 | mtags[v.tag]=True 294 | for tag in mtags: 295 | mes = mapdata.findall(tag) 296 | ies = indata.findall(tag) 297 | for i,me in enumerate(mes): 298 | ie = safeget(ies,i) 299 | if ie is DNE: 300 | indata.append(me) 301 | continue 302 | if me.get(reserved_delete,None) not in (None,'0','false','False'): 303 | indata.remove(ie) 304 | continue 305 | if me.get(reserved_replace,None) not in (None,'0','false','False'): 306 | ie.text = me.text 307 | ie.tail = me.tail 308 | ie.attrib = me.attrib 309 | del ie.attrib[reserved_replace] 310 | continue 311 | ie.text = xmlmap(ie.text,me.text) 312 | ie.tail = xmlmap(ie.tail,me.tail) 313 | ie.attrib = xmlmap(ie.attrib,me.attrib) 314 | xmlmap(ie,me) 315 | return indata 316 | return mapdata 317 | else: 318 | return mapdata 319 | return mapdata 320 | 321 | def mergexml(infile,mapfile): 322 | start = "" 323 | with open(infile,'r',encoding='utf-8-sig') as file: 324 | for line in file: 325 | if line[:5] == "\n": 326 | start = line 327 | break 328 | indata = readxml(infile) 329 | if mapfile: 330 | mapdata = readxml(mapfile) 331 | else: 332 | mapdata = DNE 333 | indata = xmlmap(indata,mapdata) 334 | writexml(infile,indata,start) 335 | 336 | 337 | ### Map Binaries mapping 338 | 339 | if can_mapper: 340 | def cachemapchange(cache, mapfile): 341 | map_json = [] 342 | with open(mapfile, "r") as f: 343 | map_json = json.load(f) 344 | 345 | if reserved_append in map_json: 346 | if reserved_append not in cache: 347 | cache[reserved_append] = [] 348 | 349 | for json_dict in map_json[reserved_append]: 350 | cache[reserved_append].append(json_dict) 351 | 352 | if reserved_delete in map_json: 353 | if reserved_delete not in cache: 354 | cache[reserved_delete] = [] 355 | 356 | #merge delete values into the cache[reserved_delete] 357 | for id in map_json[reserved_delete]: 358 | if id not in cache[reserved_delete]: 359 | cache[reserved_delete].append(id) 360 | 361 | if reserved_replace in map_json: 362 | if reserved_replace not in cache: 363 | cache[reserved_replace] = {} 364 | 365 | #merge replace values into the cache[reserved_replace] 366 | for json_dict in map_json[reserved_replace]: 367 | id = json_dict["Id"] 368 | 369 | if not id in cache[reserved_replace]: 370 | cache[reserved_replace][id] = {} 371 | 372 | for key in json_dict: 373 | if key == "Id": 374 | continue 375 | value = json_dict[key] 376 | 377 | cache[reserved_replace][id][key] = value 378 | 379 | def findjsonindex(id, json): 380 | count = 0 381 | for json_dict in json: 382 | if json_dict["Id"] == id: 383 | return count 384 | count = count + 1 385 | 386 | return -1 387 | 388 | def changemap(infile, mapchanges): 389 | in_json = mapper.DecodeBinaries(infile) 390 | 391 | if reserved_append in mapchanges: 392 | for json_dict in mapchanges[reserved_append]: 393 | in_json.append(json_dict) 394 | 395 | if reserved_delete in mapchanges: 396 | for id in mapchanges[reserved_delete]: 397 | json_id = findjsonindex(id, in_json) 398 | if json_id >= 0: 399 | del in_json[json_id] 400 | 401 | if reserved_replace in mapchanges: 402 | for id in mapchanges[reserved_replace]: 403 | changes = mapchanges[reserved_replace][id] 404 | 405 | json_id = findjsonindex(id, in_json) 406 | 407 | if json_id == -1: 408 | LOGGER.error("Cannot find", id, "in map file", infile, "when trying to replace values") 409 | for key in changes: 410 | value = changes[key] 411 | 412 | in_json[json_id][key] = value 413 | id = "" 414 | 415 | map_binaries = mapper.EncodeBinaries(in_json) 416 | 417 | with open(infile, "wb") as f: 418 | f.write(map_binaries) 419 | 420 | ### SJSON mapping 421 | 422 | if can_sjson: 423 | def readsjson(filename): 424 | try: 425 | return sjson.loads(open(filename,'r',encoding='utf-8-sig').read()) 426 | except sjson.ParseException as e: 427 | LOGGER.error(repr(e)) 428 | return DNE 429 | 430 | def writesjson(filename,content): 431 | if not isinstance(filename,str): 432 | return 433 | if isinstance(content,OrderedDict): 434 | content = sjson.dumps(content, 2) 435 | else: 436 | content = "" 437 | with open(filename,'w',encoding='utf-8') as f: 438 | f.write(content) 439 | 440 | def sjsonsearch(indata,queries): 441 | def pred(dat,mat): 442 | if (it := safepairs(mat)) is not DNE: 443 | return all(pred(dat[k],v) for k,v in it) 444 | return dat == mat 445 | for matdata,mapdata in queries: 446 | for k,v in ((k,v) for k,v in safepairs(indata) if pred(v,matdata)): 447 | indata[k] = sjsonmap(v,mapdata) 448 | return indata 449 | 450 | 451 | def sjsonmap(indata,mapdata): 452 | if mapdata is DNE: 453 | return indata 454 | elif mapdata==reserved_delete: 455 | return DNE 456 | elif safeget(mapdata,reserved_sequence): 457 | S = [] 458 | for k,v in mapdata.items(): 459 | try: 460 | d = int(k)-len(S) 461 | if d>=0: 462 | S.extend([DNE]*(d+1)) 463 | S[int(k)]=v 464 | except ValueError: 465 | continue 466 | mapdata = S 467 | if type(indata)==type(mapdata): 468 | if isinstance(mapdata,list) and safeget(mapdata,0) == reserved_append: 469 | for i in range(1,len(mapdata)): 470 | indata.append(mapdata[i]) 471 | return indata 472 | if isinstance(mapdata,list): 473 | if safeget(mapdata,0)==reserved_search: 474 | search = mapdata[1] 475 | return sjsonsearch(indata,zip(search[::2],search[1::2])) 476 | if safeget(mapdata,0)==reserved_replace: 477 | del mapdata[0] 478 | return mapdata 479 | indata.extend([DNE]*(len(mapdata)-len(indata))) 480 | elif isinstance(mapdata,dict): 481 | if search := safeget(mapdata,reserved_search): 482 | return sjsonsearch(indata,zip(search[::2],search[1::2])) 483 | if safeget(mapdata,reserved_replace): 484 | del mapdata[reserved_replace] 485 | return mapdata 486 | if (it := safepairs(mapdata)) is not DNE: 487 | for k,v in it: 488 | indata[k] = sjsonmap(safeget(indata,k),v) 489 | return indata 490 | return mapdata 491 | 492 | def mergesjson(infile,mapfile): 493 | indata = readsjson(infile) 494 | if mapfile: 495 | mapdata = readsjson(mapfile) 496 | else: 497 | mapdata = DNE 498 | indata = sjsonmap(indata,mapdata) 499 | indata = clearDNE(indata) 500 | writesjson(infile,indata) 501 | 502 | ## FILE/MOD CONTROL 503 | 504 | mode_lua = 1 505 | mode_lua_alt = 2 506 | mode_xml = 3 507 | mode_sjson = 4 508 | mode_replace = 5 509 | mode_csv = 6 510 | mode_map = 7 511 | 512 | class modcode(): 513 | def __init__(self,src,data,mode,key,**load): 514 | self.src = src 515 | self.data = data 516 | self.mode = mode 517 | self.key = key 518 | self.ep = load["ep"] 519 | 520 | def __repr__(self) -> str: 521 | return f"{self.data} {self.ep}" 522 | 523 | def strup(string): 524 | return string[0].upper()+string[1:] 525 | 526 | selffile = "".join(os.path.realpath(__file__).replace("\\","/").split(".")[:-1])+".py" 527 | gamedir = os.path.join(os.path.realpath(gamerel), '').replace("\\","/")[:-1] 528 | game = strup(gamedir.split("/")[-1]) 529 | game = game_aliases.get(game,game) 530 | 531 | def in_directory(file,nobackup=True): 532 | ##if file.find(".pkg") == -1: 533 | ## if not os.path.isfile(file): 534 | ## return False 535 | file = os.path.realpath(file).replace("\\","/") 536 | if file == selffile: 537 | return False 538 | if nobackup: 539 | if os.path.commonprefix([file, gamedir+"/"+scope+"/"+bakdir]) == gamedir+"/"+scope+"/"+bakdir: 540 | return False 541 | return os.path.commonprefix([file, gamedir+"/"+scope]) == gamedir+"/"+scope 542 | 543 | def valid_scan(file): 544 | if os.path.exists(file): 545 | if os.path.isdir(file): 546 | return True 547 | return False 548 | 549 | def splitlines(body): 550 | glines = map(lambda s: s.strip().split("\""),body.split("\n")) 551 | lines = [] 552 | li = -1 553 | mlcom = False 554 | def gp(group,lines,li,mlcom,even,lcom): 555 | if mlcom: 556 | tgroup = group.split(mlcom_end,1) 557 | if len(tgroup)==1: #still commented, carry on 558 | even = not even 559 | return (lines,li,mlcom,even,lcom) 560 | else: #comment ends, if a quote, even is disrupted 561 | even = False 562 | mlcom = False 563 | group = tgroup[1] 564 | if not mlcom: 565 | if even: 566 | lines[li]+="\""+group+"\"" 567 | else: 568 | tgroup = group.split(comment,1) 569 | if len(tgroup) == 2: 570 | lcom = True 571 | tline = tgroup[0].split(mlcom_start,1) 572 | tgroup = tline[0].split(linebreak) 573 | lines[li]+=tgroup[0] #uncommented line 574 | for g in tgroup[1:]: #new uncommented lines 575 | lines.append(g) 576 | li+=1 577 | if len(tline)>1: #comment begins 578 | mlcom = True 579 | lines,li,mlcom,even,lcom = gp(tline[1],lines,li,mlcom,even,lcom) 580 | return (lines,li,mlcom,even,lcom) 581 | for groups in glines: 582 | lcom = False 583 | even = False 584 | li += 1 585 | lines.append("") 586 | for group in groups: 587 | lines,li,mlcom,even,lcom = gp(group,lines,li,mlcom,even,lcom) 588 | if lcom: 589 | break 590 | even = not even 591 | return lines 592 | 593 | def tokenise(line): 594 | groups = line.strip().split("\"") 595 | for i,group in enumerate(groups): 596 | if i%2: 597 | groups[i] = [group] 598 | else: 599 | groups[i] = group.replace(" ",delimiter).split(delimiter) 600 | tokens = [] 601 | for group in groups: 602 | for x in group: 603 | if x != '': 604 | tokens.append(x) 605 | return tokens 606 | 607 | ## FILE/MOD LOADING 608 | 609 | def startswith(tokens,keyword,n): 610 | return tokens[:len(keyword)] == keyword and len(tokens)>=len(keyword)+1 611 | 612 | def loadcommand(reldir,tokens,to,n,mode,**load): 613 | for path in to: 614 | if in_directory(path): 615 | args = [tokens[i::n] for i in range(n)] 616 | for i in range(len(args[-1])): 617 | sources = [reldir+"/"+arg[i].replace("\"","").replace("\\","/") for arg in args] 618 | paths = [] 619 | 620 | num = -1 621 | for source in sources: 622 | LOGGER.info(source) 623 | if valid_scan(source): 624 | tpath = [] 625 | for file in os.scandir(source): 626 | file = file.path.replace("\\","/") 627 | if in_directory(file): 628 | tpath.append(file) 629 | paths.append(tpath) 630 | if num > len(tpath) or num < 0: 631 | num = len(tpath) 632 | elif in_directory(source): 633 | paths.append(source) 634 | if paths: 635 | for j in range(abs(num)): 636 | sources = [x[j] if isinstance(x,list) else x for x in paths] 637 | codeargs = ('\n'.join(sources),tuple(sources),mode,path) 638 | load["ep"] = load.get("ep",default_priority) 639 | if load.get("reverse",False): 640 | load["ep"] = -load["ep"] 641 | codes[path].appendleft(modcode(*codeargs,**load)) 642 | else: 643 | codes[path].append(modcode(*codeargs,**load)) 644 | 645 | def loadmodfile(filename,echo=True): 646 | if in_directory(filename): 647 | 648 | try: 649 | file = open(filename,'r',encoding='utf-8-sig') 650 | except IOError: 651 | return 652 | if echo: 653 | LOGGER.info(filename) 654 | 655 | reldir = "/".join(filename.split("/")[:-1]) 656 | ep = 100 657 | to = default_to[game] 658 | 659 | with file: 660 | for line in splitlines(file.read()): 661 | tokens = tokenise(line) 662 | if len(tokens)==0: 663 | continue 664 | 665 | elif startswith(tokens,kwrd_to,0): 666 | to = [s.replace("\\","/") for s in tokens[1:]] 667 | if len(to) == 0: 668 | to = default_to[game] 669 | elif startswith(tokens,kwrd_load,0): 670 | n = len(kwrd_load)+len(kwrd_priority) 671 | if tokens[len(kwrd_load):n] == kwrd_priority: 672 | if len(tokens)>n: 673 | try: 674 | ep = int(tokens[n]) 675 | except ValueError: 676 | pass 677 | else: 678 | ep = default_priority 679 | elif startswith(tokens,kwrd_priority,0): 680 | n = len(kwrd_priority) 681 | if tokens[:n] == kwrd_priority: 682 | if len(tokens)>n: 683 | try: 684 | ep = int(tokens[n]) 685 | except ValueError: 686 | pass 687 | else: 688 | ep = default_priority 689 | elif startswith(tokens,kwrd_include,1): 690 | for s in tokens[1:]: 691 | path = reldir+"/"+s.replace("\"","").replace("\\","/") 692 | if valid_scan(path): 693 | for file in os.scandir(path): 694 | loadmodfile(file.path.replace("\\","/"),echo) 695 | else: 696 | loadmodfile(path,echo) 697 | elif startswith(tokens,kwrd_replace,1): 698 | loadcommand(reldir,tokens[len(kwrd_replace):],to,1,mode_replace,ep=ep) 699 | elif startswith(tokens,kwrd_import,1): 700 | loadcommand(reldir,tokens[len(kwrd_import):],to,1,mode_lua,ep=ep) 701 | elif startswith(tokens,kwrd_topimport,1): 702 | loadcommand(reldir,tokens[len(kwrd_topimport):],to,1,mode_lua_alt,ep=ep,reverse=True) 703 | elif startswith(tokens,kwrd_xml,1): 704 | loadcommand(reldir,tokens[len(kwrd_xml):],to,1,mode_xml,ep=ep) 705 | elif startswith(tokens,kwrd_csv,1): 706 | loadcommand(reldir,tokens[len(kwrd_csv):],to,1,mode_csv,ep=ep) 707 | elif can_sjson and startswith(tokens,kwrd_sjson,1): 708 | loadcommand(reldir,tokens[len(kwrd_sjson):],to,1,mode_sjson,ep=ep) 709 | elif can_mapper and startswith(tokens, kwrd_map,1): 710 | loadcommand(reldir, tokens[len(kwrd_map):],to,1,mode_map,ep=ep) 711 | else: 712 | raise Exception(f"Improper command from {filename}:\n\t{line}") 713 | 714 | def isedited(base): 715 | if not (".lua" in base or ".xml" in base or ".sjson" in base or ".csv" in base): 716 | return True 717 | with open(base,'r',encoding='utf-8-sig') as basefile: 718 | for line in basefile: 719 | if modified+modified_modrep in line: 720 | return True 721 | return False 722 | 723 | def sortmods(mods): 724 | return sorted(mods,key=lambda x: x.ep) 725 | 726 | def makeedit(base,mods,echo=True): 727 | Path("/".join(base.split("/")[:-1])).mkdir(parents=True, exist_ok=True) 728 | Path(bakdir+"/"+"/".join(base.split("/")[:-1])).mkdir(parents=True, exist_ok=True) 729 | if not os.path.exists(base): 730 | open(bakdir+"/"+base+baktype+".del","w").close() 731 | else: 732 | bakpath = bakdir+"/"+base+baktype 733 | if isedited(base) and in_directory(bakpath,False) and os.path.exists(bakpath): 734 | copyfile(bakpath,base) 735 | else: 736 | copyfile(base,bakpath) 737 | if echo: 738 | i=0 739 | LOGGER.info("\n"+base) 740 | try: 741 | cached_map_changes = {} 742 | for mod in mods: 743 | if mod.mode == mode_replace: 744 | copyfile(mod.data[0],base) 745 | elif mod.mode == mode_lua: 746 | addimport(base,mod.data[0]) 747 | elif mod.mode == mode_lua_alt: 748 | addtopimport(base,mod.data[0]) 749 | elif mod.mode == mode_xml: 750 | mergexml(base,mod.data[0]) 751 | elif mod.mode == mode_sjson: 752 | mergesjson(base,mod.data[0]) 753 | elif mod.mode == mode_map: 754 | cachemapchange(cached_map_changes, mod.data[0]) 755 | # mergemap(base,) 756 | if echo: 757 | k = i+1 758 | for s in mod.src.split('\n'): 759 | i+=1 760 | LOGGER.info(" #"+str(i)+" +"*(k=i)+5-len(str(i)))+s) 761 | 762 | if len(cached_map_changes) > 0: 763 | changemap(base, cached_map_changes) 764 | except Exception as e: 765 | copyfile(bakdir+"/"+base+baktype,base) 766 | raise RuntimeError("Encountered uncaught exception while implementing mod changes") from e 767 | 768 | modifiedstr = "" 769 | if mods[0].mode in {mode_lua,mode_lua_alt}: 770 | modifiedstr = "\n"+modified_lua 771 | elif mods[0].mode == mode_xml: 772 | modifiedstr = "\n"+modified_xml 773 | elif mods[0].mode == mode_sjson: 774 | modifiedstr = "\n"+modified_sjson 775 | elif mods[0].mode == mode_csv: 776 | modifiedstr = "\n"+modified_csv 777 | elif mods[0].mode == mode_map: 778 | modifiedstr = modified_map 779 | with open(base,'a',encoding='utf-8') as basefile: 780 | basefile.write(modifiedstr.replace(modified,modified+modified_modrep+str(datetime.now()))) 781 | 782 | def cleanup(folder=bakdir,echo=True): 783 | if valid_scan(folder): 784 | empty = True 785 | for content in os.scandir(folder): 786 | if cleanup(content,echo): 787 | empty = False 788 | if empty: 789 | os.rmdir(folder) 790 | return False 791 | return True 792 | path = folder.path[len(bakdir)+1:] 793 | if path.find(".del") == len(path)-len(".del"): 794 | path = path[:-len(".del")] 795 | if echo: 796 | LOGGER.info(path) 797 | if os.path.exists(path): 798 | os.remove(path) 799 | if os.path.exists(folder.path): 800 | os.remove(folder.path) 801 | return False 802 | if os.path.isfile(path): 803 | if isedited(path): 804 | if echo: 805 | LOGGER.info(path) 806 | copyfile(folder.path,path) 807 | os.remove(folder.path) 808 | return False 809 | os.remove(folder.path) 810 | return False 811 | return True 812 | 813 | def start(): 814 | global codes 815 | codes = defaultdict(deque) 816 | 817 | LOGGER.info("Cleaning edits... (if there are issues validate/reinstall files)\n") 818 | Path(bakdir).mkdir(parents=True, exist_ok=True) 819 | cleanup() 820 | 821 | if clean_only: 822 | LOGGER.info( "Finished cleaning, skipping edits.\n" ) 823 | return 824 | 825 | LOGGER.info("\nReading mod files...\n") 826 | Path(modsdir).mkdir(parents=True, exist_ok=True) 827 | for mod in os.scandir(modsdir): 828 | loadmodfile(mod.path.replace("\\","/")+"/"+modfile) 829 | 830 | LOGGER.info("\nModified files for "+game+" mods:") 831 | for base, mods in codes.items(): 832 | LOGGER.debug(f"mods: {mods}") 833 | mods = sortmods(mods) 834 | LOGGER.debug(f"sorted: {mods}") 835 | makeedit(base,mods) 836 | 837 | bs = len(codes) 838 | ms = sum(map(len,codes.values())) 839 | 840 | LOGGER.info("\n"+str(bs)+" base file"+"s"*(bs!=1)+" import"+"s"*(bs==1)+" a total of "+str(ms)+" mod file"+"s"*(ms!=1)+".\n") 841 | 842 | if __name__ == '__main__': 843 | parser = argparse.ArgumentParser() 844 | parser.add_argument( '--game', '-g', choices=[game for game in default_to], help="select game mode" ) 845 | parser.add_argument( '--clean', '-c', action='store_true', help="clean only (uninstall mods)" ) 846 | parser.add_argument('--verbose', '-v', action='store_true', help="verbose mode (default log level if not provided: info, '-v': debug)") 847 | parser.add_argument('--quiet', '-q', action='store_true', help="quiet mode (only log errors, overrides --verbose if present)") 848 | parser.add_argument('--no-input', action='store_false', default=True, dest='input', help="do not prompt for input when done (default: prompt for input)") 849 | args = parser.parse_args() 850 | # --game 851 | game = args.game or game 852 | # --clean 853 | if args.clean is not None: 854 | clean_only = args.clean 855 | # --quiet / --verbose 856 | if args.quiet: 857 | LOGGER.setLevel(logging.ERROR) 858 | elif args.verbose: 859 | LOGGER.setLevel(logging.DEBUG) 860 | try: 861 | start() 862 | except PermissionError as e: 863 | LOGGER.error(e) 864 | msg = """ 865 | [Recommended solution] 866 | Right-click on Hades directory > Properties > Uncheck "Read-only" > Apply > re-run `modimporter`. 867 | 868 | [If it does not work] 869 | Re-run `modimporter` as administrator. 870 | """ 871 | LOGGER.error(msg) 872 | except Exception as e: 873 | LOGGER.error("There was a critical error, now attempting to display the error") 874 | ##LOGGER.error("(Run this program again in a terminal that does not close or check the log file if this doesn't work)") 875 | logging.getLogger("MainExceptions").exception(e) 876 | ##raise RuntimeError("Encountered uncaught exception during program") from e 877 | if args.input: 878 | input("Press ENTER/RETURN to end program...") 879 | --------------------------------------------------------------------------------