/ resource_pack / build.py
build.py
1 from pathlib import Path 2 from enum import Enum 3 from pydantic import BaseModel 4 from PIL import Image 5 import json, datetime, os, subprocess, hashlib, io, shutil, collections, re 6 7 HIDE_HEADERS = True 8 HIDE_SKIPPED = True 9 10 INPUT_PATH = Path("./content").resolve() 11 BUILD_PATH = Path("./build"); BUILD_PATH.mkdir(parents=True,exist_ok=True); BUILD_PATH = BUILD_PATH.resolve() 12 OUTPUT_PATH = Path("./dist"); OUTPUT_PATH.mkdir(parents=True,exist_ok=True); OUTPUT_PATH = OUTPUT_PATH.resolve() 13 14 BLOCKSIZE = 1024**2 15 16 BEDROCK_ID_MAP = {} 17 18 class Platform(Enum): 19 JAVA = "Java" 20 BEDROCK = "Bedrock" 21 22 class ResourcePack(BaseModel): 23 name: str 24 uuid: str 25 uuid2: str 26 platform: Platform 27 28 buildpath: Path 29 distpath: Path 30 31 modified: bool = False 32 33 def create_java_pack(name, buildpath, distpath) -> ResourcePack: 34 print("Defined Pack:", name) 35 if not HIDE_HEADERS: print("Build:", buildpath.relative_to(os.getcwd())) 36 if not HIDE_HEADERS: print("Dist:", distpath.relative_to(os.getcwd())) 37 if not HIDE_HEADERS: print("Platform:", Platform.JAVA) 38 39 pack = ResourcePack(name=name, uuid="", uuid2="", platform=Platform.JAVA, 40 buildpath=buildpath, distpath=distpath) 41 42 if not buildpath.exists(): buildpath.mkdir(parents=True, exist_ok=True) 43 44 if not HIDE_HEADERS: print("Writing metadata...") 45 with open(pack.buildpath / "pack.mcmeta", "w") as f: 46 json.dump({ 47 "pack": { 48 "pack_format": 12, 49 "description": f"{name} | CCMCC Resources" 50 } 51 }, f) 52 53 if not HIDE_HEADERS: print("Done.\n") 54 return pack 55 56 def create_bedrock_pack(name, uuid, uuid2, buildpath, distpath) -> ResourcePack: 57 print("Defined Pack:", name, f"({uuid} / {uuid2})") 58 if not HIDE_HEADERS: print("Build:", buildpath.relative_to(os.getcwd())) 59 if not HIDE_HEADERS: print("Dist:", distpath.relative_to(os.getcwd())) 60 if not HIDE_HEADERS: print("Platform:", Platform.BEDROCK) 61 62 pack = ResourcePack(name=name, uuid=uuid, uuid2=uuid2, platform=Platform.BEDROCK, 63 buildpath=buildpath, distpath=distpath) 64 65 if not buildpath.exists(): buildpath.mkdir(parents=True, exist_ok=True) 66 67 # Generate version number automatically 68 now = datetime.datetime.now() 69 # td = now - datetime.datetime(2019, 11, 17, 8, 0, 0) 70 td = (now - datetime.datetime(now.year, 1, 1)) 71 major = now.year 72 minor = td.days 73 patch = td.seconds 74 75 if not HIDE_HEADERS: print("Writing metadata...") 76 with open(pack.buildpath / "manifest.json", "w") as f: 77 json.dump({ 78 "format_version": 2, 79 "header": { 80 "description": f"CCMCC Resources ({name})", 81 "name": "CCMCC Resources ({name})", 82 "uuid": uuid, 83 "version": [major, minor, patch], 84 "min_engine_version": [1, 19, 0] 85 }, 86 "modules": [ 87 { 88 "description": "CCMCC Resources ({name})", 89 "type": "resources", 90 "uuid": uuid2, 91 "version": [major, minor, patch] 92 } 93 ] 94 }, f) 95 96 if not HIDE_HEADERS: print("Done.\n") 97 return pack 98 99 def save_pack_windows(pack): 100 print("Writing Pack:", pack.name, f"({pack.uuid} / {pack.uuid2})") 101 if not HIDE_HEADERS: print("Content:", pack.buildpath.relative_to(os.getcwd())) 102 if not HIDE_HEADERS: print("Target:", pack.distpath.relative_to(os.getcwd())) 103 EXECUTABLE = r"C:\Program Files\7-Zip\7z.exe" #r"D:\Program Files\7-Zip\7z.exe" 104 args = ["u", "-tzip", "-y", str(pack.distpath.resolve())] 105 for f in pack.buildpath.iterdir(): 106 args.append(str(f)) 107 if not HIDE_HEADERS: print("Command:", EXECUTABLE, " ".join(args)) 108 109 if not HIDE_HEADERS: print("Executing...") 110 result = subprocess.run([EXECUTABLE] + args) 111 if not HIDE_HEADERS: print("Completed with return code", result.returncode) 112 assert result.returncode == 0 113 if not HIDE_HEADERS: print("Done.\n") 114 115 116 class PackFileWriteManager: 117 def __init__(self, pack: ResourcePack, relpath: str, mode: str): 118 self.pack = pack 119 self.relpath = relpath 120 self.mode = mode 121 122 self.fp = None 123 124 def __enter__(self): 125 self.path = self.pack.buildpath / self.relpath 126 self.path.parent.mkdir(parents=True, exist_ok=True) 127 128 if "b" in self.mode: 129 self.fp = io.BytesIO() 130 else: 131 self.fp = io.StringIO() 132 133 return self.fp 134 135 def __exit__(self, *args): 136 if args[0] != None: 137 return 138 139 self.fp.seek(0) 140 data = self.fp.read() 141 142 if type(data) is str: data = data.encode("utf-8") 143 144 myhash = hashlib.sha1(data).hexdigest() 145 146 if self.path.exists(): 147 otherhasher = hashlib.sha1() 148 with open(self.path, "rb") as f: 149 while (x := f.read(BLOCKSIZE)): 150 otherhasher.update(x) 151 otherhash = otherhasher.hexdigest() 152 else: 153 otherhash = "" 154 155 if myhash == otherhash: 156 if not HIDE_SKIPPED: print("Skipping", self.path, " (Not Modified)") 157 else: 158 print("Writing", self.path) 159 self.fp.seek(0) 160 with open(self.path, self.mode, newline=None if "b" in self.mode else "") as f: 161 f.write(self.fp.read()) 162 self.pack.modified = True 163 164 def copy_into_pack(source: Path, pack: ResourcePack, dest: str): 165 target = pack.buildpath / dest 166 167 sourcehasher = hashlib.sha1() 168 with open(source, "rb") as f: 169 while (x := f.read(BLOCKSIZE)): sourcehasher.update(x) 170 hash1 = sourcehasher.hexdigest() 171 172 if target.exists(): 173 desthasher = hashlib.sha1() 174 with open(target, "rb") as f: 175 while (x := f.read(BLOCKSIZE)): desthasher.update(x) 176 hash2 = desthasher.hexdigest() 177 else: 178 hash2 = "" 179 target.parent.mkdir(parents=True, exist_ok=True) 180 181 if hash1 == hash2: 182 if not HIDE_SKIPPED: print("Skipping", target, "(Not Modified)") 183 else: 184 print("Copying", target) 185 target.unlink(missing_ok=True) 186 shutil.copy2(source, target) 187 pack.modified = True 188 189 190 def copy_music_bedrock(source: Path, pack: ResourcePack, dest: str, volume: float, pack_to_take_hash_from: ResourcePack): 191 target = pack.buildpath / dest 192 193 sourcehasher = hashlib.sha1() 194 with open(source, "rb") as f: 195 while (x := f.read(BLOCKSIZE)): sourcehasher.update(x) 196 hash1 = sourcehasher.hexdigest() 197 198 hash_target = pack_to_take_hash_from.buildpath / "assets/minecraft" / dest 199 if hash_target.exists(): 200 desthasher = hashlib.sha1() 201 with open(hash_target, "rb") as f: 202 while (x := f.read(BLOCKSIZE)): desthasher.update(x) 203 hash2 = desthasher.hexdigest() 204 else: 205 hash2 = "" 206 target.parent.mkdir(parents=True, exist_ok=True) 207 208 if hash1 == hash2: 209 if not HIDE_SKIPPED: print("Skipping", target, "(Not Modified)") 210 else: 211 print("Copying (volume=" + str(volume), target) 212 target.unlink(missing_ok=True) 213 #shutil.copy2(source, target) 214 os.system(f"ffmpeg -i \"{source}\" -filter:a \"volume={volume/2}\" -ar 22k \"{target}\"") 215 pack.modified = True 216 217 218 def get_bedrock_id(x): 219 return BEDROCK_ID_MAP.get(x, x) 220 221 CONCRETE_RE = re.compile(r"(.+?)_(concrete(_.+)?)(\..*)") 222 TERACOTTA_RE = re.compile(r"(.+?)_((glazed_)?terracotta)") 223 def get_bedrock_block_path(x): 224 if "deepslate" in x and not "reinforced" in x: 225 return f"deepslate/{x}" 226 elif "candle" in x: 227 return f"candles/{x}" 228 elif "warped" in x or "crimson" in x: 229 return f"huge_fungus/{x}" 230 elif "golden" in x: return x.replace("golden", "gold") 231 elif "dried_kelp_block" in x: return x.replace("dried_kelp_block", "dried_kelp", 1) 232 elif (cm := CONCRETE_RE.match(x)): return cm.group(2) + "_" + cm.group(1) + cm.group(4) 233 elif (cm := TERACOTTA_RE.match(x)): return cm.group(2) + "_" + cm.group(1) 234 elif "dark_prismarine" in x: return x.replace("dark_prismarine", "prismarine_dark") 235 elif "prismarine" in x and not "bricks" in x: return x.replace("prismarine", "prismarine_rough") 236 elif "end_stone_bricks" in x: return x.replace("_stone", "") 237 elif "red_nether_bricks" in x: return x.replace("bricks", "brick") 238 elif "command_block_minecart" in x: return x.replace("command_block_minecart","minecart_command_block") 239 else: 240 return x 241 242 243 # Create Packs 244 if not HIDE_HEADERS: print() 245 jmain_pack = create_java_pack("Java Full", BUILD_PATH / "javafull", OUTPUT_PATH / "CCMCC2_java.zip") 246 bmusic_pack = create_bedrock_pack("Bedrock Music", "0e05de52-1647-11ed-832a-8627fd233021", "3a7a491c-1721-11ed-837f-8627fd233021", BUILD_PATH / "bdmusic", OUTPUT_PATH / "CCMCC2_bedrock_music.mcpack") 247 bother_pack = create_bedrock_pack("Bedrock Other", "23dc5450-1647-11ed-9db1-8627fd233021", "3cd8f60f-1721-11ed-850e-8627fd233021", BUILD_PATH / "bdother", OUTPUT_PATH / "CCMCC2_bedrock_other.mcpack") 248 ALL_PACKS = [jmain_pack, bmusic_pack, bother_pack] 249 250 # Language 251 if not HIDE_HEADERS: print("Stage 1: Language files") 252 LANG_PATH = INPUT_PATH / "lang" 253 254 for lang_file in LANG_PATH.iterdir(): 255 lang_name = lang_file.with_suffix("").name 256 if not HIDE_HEADERS: print("Processing Language:",lang_name) 257 258 # Process 259 with open(lang_file) as f: 260 data = json.load(f) 261 262 block_names = {block_id: bname for block_id, bname in data.get("blocks", {}).items()} 263 item_names = {block_id: bname for block_id, bname in data.get("items", {}).items()} 264 #ccmcc_strings = {f"string.ccmcc.{sid}": fstr for sid, fstr in data.get("strings", {}).items()} 265 other_definitions = {definition: value for definition, value in data.get("other", {}).items()} 266 267 # Write java 268 if not HIDE_HEADERS: print("Writing java...") 269 with PackFileWriteManager(jmain_pack, f"assets/minecraft/lang/{lang_name.lower()}.json", "w") as java_file: 270 data = {} 271 for id, bname in block_names.items(): 272 data[f"block.minecraft.{id}"] = bname 273 for id, bname in item_names.items(): 274 data[f"item.minecraft.{id}"] = bname 275 #data.update(ccmcc_strings) 276 data.update(other_definitions) 277 json.dump(data, java_file) 278 279 280 # Write bedrock 281 if not HIDE_HEADERS: print("Writing bedrock...") 282 with PackFileWriteManager(bother_pack, f"texts/{lang_name}.lang", "w") as bedrock_file: 283 for id, bname in block_names.items(): 284 bedrock_file.write(f"tile.{get_bedrock_id(id)}.name={bname}\n") 285 for id, bname in item_names.items(): 286 bedrock_file.write(f"item.{get_bedrock_id(id)}.name={bname}\n") 287 #for k, v in ccmcc_strings.items(): bedrock_file.write(f"{k}={v}\n") 288 for k, v in other_definitions.items(): bedrock_file.write(f"{k}={v}\n") 289 290 # Items 291 if not HIDE_HEADERS: print() 292 if not HIDE_HEADERS: print("Stage 2: Item Textures") 293 ITEM_PATH = INPUT_PATH / "item" 294 295 for item_tx in ITEM_PATH.iterdir(): 296 copy_into_pack(item_tx, jmain_pack, f"assets/minecraft/textures/item/{item_tx.name}") 297 copy_into_pack(item_tx, bother_pack, f"textures/items/{get_bedrock_block_path(item_tx.name)}") 298 299 # Armour Models 300 if not HIDE_HEADERS: print() 301 if not HIDE_HEADERS: print("Stage 3: Armour Models") 302 ARMOUR_PATH = INPUT_PATH / "armour" 303 304 for arm in ARMOUR_PATH.iterdir(): 305 copy_into_pack(arm, jmain_pack, f"assets/minecraft/textures/models/armor/{arm.name}") 306 copy_into_pack(arm, bother_pack, f"textures/models/armor/{arm.name.replace('layer_','')}") 307 308 # Blocks 309 if not HIDE_HEADERS: print() 310 if not HIDE_HEADERS: print("Stage 4: Block Textures") 311 BLOCK_PATH = INPUT_PATH / "block" 312 313 for blk in BLOCK_PATH.iterdir(): 314 copy_into_pack(blk, jmain_pack, f"assets/minecraft/textures/block/{blk.name}") 315 316 if blk.name.startswith("dried_kelp_side"): 317 # Manual override for kelp block 318 copy_into_pack(blk, bother_pack, f"textures/blocks/dried_kelp_side_a{blk.suffix}") 319 copy_into_pack(blk, bother_pack, f"textures/blocks/dried_kelp_side_b{blk.suffix}") 320 else: 321 copy_into_pack(blk, bother_pack, f"textures/blocks/{get_bedrock_block_path(blk.name)}") 322 323 # Symbols 324 if not HIDE_HEADERS: print() 325 if not HIDE_HEADERS: print("Stage 5: Symbols") 326 SYMBOL_PATH = INPUT_PATH / "symbol" 327 328 jsymbols_used = {} 329 bsymbols_used = {} 330 BEDROCK_PREFIX = "bedrock_" 331 for symbol in SYMBOL_PATH.iterdir(): 332 codepoint = symbol.with_suffix("").name 333 if codepoint.startswith(BEDROCK_PREFIX): continue 334 335 # Java 336 jsymbols_used[int(codepoint, base=16)] = symbol 337 copy_into_pack(symbol, jmain_pack, f"assets/minecraft/textures/customsymbol/{int(codepoint, base=16)}.png") 338 339 # Bedrock 340 firsthalf = codepoint[:2] 341 secondhalf = int(codepoint[2:], base=16) 342 if firsthalf not in bsymbols_used: bsymbols_used[firsthalf] = {} 343 344 bedrock_name = symbol.with_name(BEDROCK_PREFIX + symbol.name) 345 if bedrock_name.exists(): bsymbols_used[firsthalf][secondhalf] = bedrock_name 346 else: bsymbols_used[firsthalf][secondhalf] = symbol 347 348 # Symbols: Finalise Java 349 if not HIDE_HEADERS: print("Writing Java metadata...") 350 with PackFileWriteManager(jmain_pack, "assets/minecraft/font/default.json", "w") as f: 351 data = {"providers": [] 352 } 353 354 for codepoint, symbol in jsymbols_used.items(): 355 data["providers"].append({ 356 "type": "bitmap", 357 "file": f"minecraft:customsymbol/{codepoint}.png", 358 "ascent": 7, 359 "height": 7, 360 "chars": [chr(codepoint)] 361 }) 362 363 json.dump(data, f) 364 365 # Symbols: Finalise Bedrock 366 PIXELS_PER_GLYPH = 32 367 TARGET_SIZE = (7, 7) 368 for section, data in bsymbols_used.items(): 369 if not HIDE_HEADERS: print(f"Writing Bedrock glyphs... ({section})") 370 file_path = f"font/glyph_{section}.png" 371 372 img = Image.new("RGBA", (PIXELS_PER_GLYPH*16, PIXELS_PER_GLYPH*16), color=(0,0,0,0)) 373 374 # Place glyphs 375 for index, file in data.items(): 376 xidx = index % 16 377 yidx = index // 16 378 379 xoffset = xidx * PIXELS_PER_GLYPH 380 yoffset = yidx * PIXELS_PER_GLYPH 381 382 with Image.open(file) as glyph: 383 if glyph.size == TARGET_SIZE: glyph_scaled = glyph 384 else: glyph_scaled = glyph.resize(TARGET_SIZE) 385 386 # Centre glyph 387 xoffset += (PIXELS_PER_GLYPH - glyph_scaled.size[0])//2 388 yoffset += (PIXELS_PER_GLYPH - glyph_scaled.size[1])//2 389 390 # Place glyph on image 391 img.paste(glyph_scaled, (xoffset, yoffset)) 392 393 # Write 394 with PackFileWriteManager(bother_pack, file_path, "wb") as f: 395 img.save(f, "png") 396 397 398 # Sounds 399 if not HIDE_HEADERS: print() 400 if not HIDE_HEADERS: print("Stage 6: Sounds") 401 SOUND_PATH = INPUT_PATH / "sound" 402 403 USELESS_MC_MUSIC = ["music.game", "music.overworld.dripstone_caves", "music.overworld.frozen_peaks", "music.overworld.grove", "music.jagged_peaks", "music.lush_caves", "music.meadow", 404 "music.snowy_slopes", "music.stony_peaks", "music.under_water", "music.creative", "music.credits", "music.end", "music.dragon"] 405 406 jsounddata = {x: {"sounds": [], "replace": True} for x in USELESS_MC_MUSIC} 407 bmusicdata = {x: {"category": "neutral", "sounds": []} for x in USELESS_MC_MUSIC} 408 bsounddata = {} 409 410 with open(SOUND_PATH / "sounds.json") as f: 411 sounds = json.load(f) 412 413 for sound in sounds: 414 stype = sound["type"] # music, player, or world (not implemented) 415 assert stype in ["music", "player"] 416 417 s_id = sound["id"] # ID for the sound 418 s_volume = sound["volume"] 419 420 if stype == "music": 421 intro_file = sound["intro"] 422 if intro_file is not None: 423 intro_file = SOUND_PATH / intro_file 424 copy_music_bedrock(intro_file, bmusic_pack, "sounds/" + sound["intro"], s_volume, jmain_pack) 425 426 copy_into_pack(intro_file, jmain_pack, "assets/minecraft/sounds/" + sound["intro"]) 427 jsounddata[s_id + ".intro"] = {"sounds": [{ 428 "name": sound["intro"].rpartition(".")[0], 429 "stream": True, 430 "volume": s_volume 431 }]} 432 433 #copy_into_pack(intro_file, bmusic_pack, "sounds/" + sound["intro"]) 434 bmusicdata[s_id + ".intro"] = { 435 "category": "music", 436 "sounds": ["sounds/" + sound["intro"].rpartition(".")[0]], 437 "stream": True, 438 "volume": 1#s_volume 439 } 440 441 loop_file = sound["loop"] 442 if loop_file is not None: 443 loop_file = SOUND_PATH / loop_file 444 copy_music_bedrock(loop_file, bmusic_pack, "sounds/" + sound["loop"], s_volume, jmain_pack) 445 446 copy_into_pack(loop_file, jmain_pack, "assets/minecraft/sounds/" + sound["loop"]) 447 jsounddata[s_id + ".loop"] = {"sounds": [{ 448 "name": sound["loop"].rpartition(".")[0], 449 "stream": True, 450 "volume": s_volume 451 }]} 452 453 #copy_into_pack(loop_file, bmusic_pack, "sounds/" + sound["loop"]) 454 bmusicdata[s_id + ".loop"] = { 455 "category": "music", 456 "sounds": ["sounds/" + sound["loop"].rpartition(".")[0]], 457 "stream": True, 458 "volume": 1#s_volume 459 } 460 else: 461 sound_file = SOUND_PATH / sound["file"] 462 copy_into_pack(sound_file, jmain_pack, "assets/minecraft/sounds/" + sound["file"]) 463 jsounddata[s_id] = {"sounds": [{ 464 "name": sound["file"].rpartition(".")[0], 465 "preload": True, 466 "attenuation_distance": 128, 467 "volume": s_volume 468 }]} 469 470 copy_into_pack(sound_file, bother_pack, "sounds/" + sound["file"]) 471 bsounddata[s_id] = { 472 "category": "record", 473 "sounds": ["sounds/" + sound["file"].rpartition(".")[0]], 474 "preload": True, 475 "is3d": False, 476 "is3D": False, 477 "interruptible": False, 478 "min_distance": 128.0, 479 "max_distance": 1024.0, 480 "volume": s_volume 481 } 482 483 484 # Sounds: Write Java data 485 if not HIDE_HEADERS: print("Writing Java data...") 486 with PackFileWriteManager(jmain_pack, "assets/minecraft/sounds.json", "w") as f: 487 json.dump(jsounddata, f) 488 489 # Sounds: Write bedrock data 490 if not HIDE_HEADERS: print("Writing bedrock data...") 491 # "format_version": "1.14.0", "sound_definitions": {} 492 with PackFileWriteManager(bmusic_pack, "sounds/sound_definitions.json", "w") as f: 493 json.dump({"format_version": "1.14.0", "sound_definitions": bmusicdata}, f) 494 with PackFileWriteManager(bother_pack, "sounds/sound_definitions.json", "w") as f: 495 json.dump({"format_version": "1.14.0", "sound_definitions": bsounddata}, f) 496 497 498 # Other Includes 499 if not HIDE_HEADERS: print("Stage 255: Other Includes") 500 JINCLUDE_PATH = INPUT_PATH / "include" / "jmain" 501 BINCLUDE_PATH = INPUT_PATH / "include" / "bother" 502 503 def walk(start): 504 walk_queue = collections.deque([start]) 505 while walk_queue: 506 root = walk_queue.popleft() 507 dirs = [] 508 files = [] 509 for target in root.iterdir(): 510 if target.is_dir(): 511 dirs.append(target) 512 513 if target.is_file(): 514 files.append(target) 515 516 yield root, dirs, files 517 walk_queue.extend(dirs) 518 519 if not HIDE_HEADERS: print("Java Includes:") 520 for root, dirs, files in walk(JINCLUDE_PATH): 521 for f in files: 522 if f.suffix == ".pdn": continue 523 copy_into_pack(f, jmain_pack, str(f.relative_to(JINCLUDE_PATH))) 524 525 if not HIDE_HEADERS: print("Bedrock Includes:") 526 for root, dirs, files in walk(BINCLUDE_PATH): 527 for f in files: 528 if f.suffix == ".pdn": continue 529 copy_into_pack(f, bother_pack, str(f.relative_to(BINCLUDE_PATH))) 530 531 # Save Packs 532 if not HIDE_HEADERS: print() 533 for pack in ALL_PACKS: 534 if not pack.modified: 535 print(f"Skipping {pack.name}: Not modified.") 536 if not HIDE_HEADERS: print() 537 continue 538 539 save_pack_windows(pack) 540 541 if pack.platform is Platform.JAVA: 542 # Provide SHA-1 543 with open(pack.distpath, "rb") as f: 544 sha = hashlib.sha1() 545 while (x := f.read(BLOCKSIZE)): sha.update(x) 546 digest = sha.hexdigest() 547 548 with open(str(pack.distpath) + "-sha1.txt", "w") as f: 549 f.write(digest) 550 551 print("*"*26) 552 print(" "*6 + "BUILD COMPLETE" + " "*6) 553 print("*"*26) 554 input("Press ENTER to exit.")