From e1246d9b0a4628fc997e6ab5f2031371bc661e4c Mon Sep 17 00:00:00 2001 From: jdflyer Date: Sat, 15 Jan 2022 17:59:59 -0700 Subject: [PATCH] Shiftable --- Makefile | 11 +- rel/d/a/e/d_a_e_wb/d_a_e_wb.cpp | 6 +- src/DynamicLink.cpp | 2 +- tools/extract_game_assets.py | 66 ++++- tools/package_game_assets.py | 433 ++++++++++++++++++++++++++++++++ tools/tp.py | 2 +- 6 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 tools/package_game_assets.py diff --git a/Makefile b/Makefile index 6ee58131ab..b3a8a8bcc8 100644 --- a/Makefile +++ b/Makefile @@ -125,6 +125,7 @@ clean_rels: tools: $(ELF2DOL) assets: + @mkdir -p game @cd game; $(PYTHON) ../tools/extract_game_assets.py ../$(IMAGENAME) docs: @@ -149,6 +150,14 @@ $(DOL_SHIFT): $(ELF_SHIFT) | tools shift: dirs $(DOL_SHIFT) +game: | shift rels + @mkdir -p game + @$(PYTHON) tools/package_game_assets.py game $(BUILD_DIR) + +rungame: game + @echo If you are playing on a shifted game make sure Hyrule Field Speed hack is disabled in dolphin! + dolphin-emu $(BUILD_DIR)/game/sys/main.dol + # $(BUILD_DIR)/%.o: %.cpp @mkdir -p $(@D) @@ -170,4 +179,4 @@ include tools/elf2dol/Makefile ### Debug Print ### print-% : ; $(info $* is a $(flavor $*) variable set to [$($*)]) @true -.PHONY: default all dirs clean tools docs shift print-% +.PHONY: default all dirs clean tools docs shift game rungame print-% diff --git a/rel/d/a/e/d_a_e_wb/d_a_e_wb.cpp b/rel/d/a/e/d_a_e_wb/d_a_e_wb.cpp index b149a30ec2..246722a94c 100644 --- a/rel/d/a/e/d_a_e_wb/d_a_e_wb.cpp +++ b/rel/d/a/e/d_a_e_wb/d_a_e_wb.cpp @@ -277,7 +277,7 @@ extern "C" static void cMtx_XrotM__FPA4_fs(); extern "C" static void JMAFastSqrt__Ff(); extern "C" static void dComIfGp_particle_set__FUlUsPC4cXyzPC5csXyzPC4cXyz(); extern "C" void cancelOriginalDemo__9daHorse_cFv(); -extern "C" void __ct__10JAISoundIDFUl(); +extern "C" void __ct__10JAISoundIDFUl(u32* this_replacement,u32 param_0); extern "C" static void dComIfGp_getVibration__Fv(); extern "C" void __ct__4cXyzFfff(); extern "C" void onDemoJumpDistance__9daHorse_cFff(); @@ -4970,8 +4970,8 @@ asm void daHorse_c::cancelOriginalDemo() { #pragma pop /* 807E27D0-807E27D8 -00001 0008+00 0/0 0/0 0/0 .text __ct__10JAISoundIDFUl */ -JAISoundID::JAISoundID(u32 param_0) { - *(u32*)this = (u32)(param_0); +extern "C" void __ct__10JAISoundIDFUl(u32* this_replacement,u32 param_0) { + *(u32*)this_replacement = (u32)(param_0); } /* 807E27D8-807E27E8 010438 0010+00 1/1 0/0 0/0 .text dComIfGp_getVibration__Fv */ diff --git a/src/DynamicLink.cpp b/src/DynamicLink.cpp index cbdda00e69..097227d06d 100644 --- a/src/DynamicLink.cpp +++ b/src/DynamicLink.cpp @@ -372,7 +372,7 @@ bool DynamicModuleControl::do_load() { } } if(mModule==NULL&&sFileCache!=NULL) { - mModule = (OSModuleInfo*)sFileCache->getResource(0x72656C73,buffer); + mModule = (OSModuleInfo*)sFileCache->getResource(0x72656C73/*rels*/,buffer); if(mModule!=NULL) { mSize = 0; mResourceType = 11; diff --git a/tools/extract_game_assets.py b/tools/extract_game_assets.py index 77eca329ed..44124d3d3c 100644 --- a/tools/extract_game_assets.py +++ b/tools/extract_game_assets.py @@ -7,6 +7,13 @@ Usage: `python tools/extract_game_assets.py` """ fstInfoPosition = 0x424 +bootPosition = 0x0 +bootSize = 0x440 +bi2Position = 0x440 +bi2Size = 0x2000 +apploaderPosition = 0x2440 +dolInfoPosition = 0x420 + numFileEntries = 0 """ @@ -94,7 +101,7 @@ Write the current folder to disk and return it's name/last entry number def writeFolder(parsedFstBin, i): - folderPath = i["folderName"] + "/" + folderPath = i["folderName"] + "/" lastEntryNumber = i["lastEntryNumber"] if i["parentFolderEntryNumber"] == 0: @@ -125,6 +132,9 @@ def writeAssets(parsedFstBin, handler): # Write the folder structure and files to disc j = 0 folderStack = [] + if not os.path.exists("./files/"): + os.makedirs("./files/") + os.chdir('./files/') folderStack.append({"folderName": "./", "lastEntryNumber": numFileEntries}) for i in parsedFstBin: j += 1 @@ -143,6 +153,38 @@ def writeAssets(parsedFstBin, handler): while folderStack[-1]["lastEntryNumber"] == j + 1: folderStack.pop() +def writeSys(boot,bi2,apploader,dol,fst): + if not os.path.exists("./sys/"): + os.makedirs("./sys/") + open("./sys/boot.bin","wb").write(boot) + open("./sys/bi2.bin","wb").write(bi2) + open("./sys/apploader.img","wb").write(apploader) + open("./sys/main.dol","wb").write(dol) + open("./sys/fst.bin","wb").write(fst) + +def getDolInfo(disc): + disc.seek(dolInfoPosition) + dolOffset = int.from_bytes(bytearray(disc.read(4)), byteorder="big") + dolSize = 0 + for i in range(7): + disc.seek(dolOffset+(i*4)) + segmentOffset = int.from_bytes(bytearray(disc.read(4)), byteorder="big") + disc.seek(dolOffset+0x90+(i*4)) + segmentSize = int.from_bytes(bytearray(disc.read(4)), byteorder="big") + if (segmentOffset+segmentSize)>dolSize: + dolSize = segmentOffset + segmentSize + + for i in range(11): + disc.seek(dolOffset+0x1c+(i*4)) + dataOffset = int.from_bytes(bytearray(disc.read(4)), byteorder="big") + disc.seek(dolOffset+0xac+(i*4)) + dataSize = int.from_bytes(bytearray(disc.read(4)), byteorder="big") + if (dataOffset+dataSize)>dolSize: + dolSize = dataOffset + dataSize + + return dolOffset, dolSize + + def extract(path): with open(path, "rb") as f: @@ -150,10 +192,30 @@ def extract(path): f.seek(fstInfoPosition) fstOffset, fstSize = getFstInfo(f, fstInfoPosition) + f.seek(bootPosition) + bootBytes = bytearray(f.read(bootSize)) + + f.seek(bi2Position) + bi2Bytes = bytearray(f.read(bi2Size)) + + f.seek(apploaderPosition+0x14) + apploaderSize = int.from_bytes(bytearray(f.read(4)), byteorder="big") + f.seek(apploaderPosition+0x18) + trailerSize = int.from_bytes(bytearray(f.read(4)), byteorder="big") + apploaderMainSize = 0x20 + apploaderSize + trailerSize + f.seek(apploaderPosition) + apploaderBytes = bytearray(f.read(apploaderMainSize)) + dolOffset, dolSize = getDolInfo(f) + f.seek(dolOffset) + dolBytes = bytearray(f.read(dolSize)) + + # Seek to fst.bin and retrieve it f.seek(fstOffset) fstBinBytes = bytearray(f.read(fstSize)) + writeSys(bootBytes,bi2Bytes,apploaderBytes,dolBytes,fstBinBytes) + # Parse fst.bin parsedFstBin = parseFstBin(fstBinBytes) @@ -162,7 +224,7 @@ def extract(path): def main(): - extract(sys.argv[1], "rb") + extract(sys.argv[1]) if __name__ == "__main__": diff --git a/tools/package_game_assets.py b/tools/package_game_assets.py new file mode 100644 index 0000000000..5ca061f275 --- /dev/null +++ b/tools/package_game_assets.py @@ -0,0 +1,433 @@ +import os +import sys +import shutil +import extract_game_assets +from pathlib import Path +import hashlib +import struct + + +def sha1_from_data(data): + sha1 = hashlib.sha1() + sha1.update(data) + + return sha1.hexdigest().upper() + +def copy(path,destPath): + for root,dirs,files in os.walk(str(path)): + for file in files: + outputDir = destPath/Path(str(root)) + #print(str(outputDir.absolute())+file) + if not outputDir.absolute().exists(): + os.makedirs(outputDir.absolute()) + outputFile = Path(str(outputDir.absolute())+"/"+str(file)) + inFile = Path(str(Path(root).absolute())+"/"+str(file)) + if not outputFile.exists(): + print(str(inFile)+" -> "+str(outputFile)) + shutil.copyfile(inFile,outputFile) + else: + inf = open(inFile,"rb") + inSum = sha1_from_data(bytearray(inf.read())) + outf = open(outputFile,"rb") + outSum = sha1_from_data(bytearray(outf.read())) + if inSum!=outSum: + print(str(inFile)+" -> "+str(outputFile)) + shutil.copyfile(inFile,outputFile) + + +aMemRels = """d_a_alldie.rel +d_a_andsw2.rel +d_a_bd.rel +d_a_canoe.rel +d_a_cstaf.rel +d_a_demo_item.rel +d_a_door_bossl1.rel +d_a_econt.rel +d_a_e_dn.rel +d_a_e_fm.rel +d_a_e_ga.rel +d_a_e_hb.rel +d_a_e_nest.rel +d_a_e_rd.rel +d_a_fr.rel +d_a_grass.rel +d_a_kytag05.rel +d_a_kytag10.rel +d_a_kytag11.rel +d_a_kytag14.rel +d_a_mg_fish.rel +d_a_npc_besu.rel +d_a_npc_fairy_seirei.rel +d_a_npc_fish.rel +d_a_npc_henna.rel +d_a_npc_kakashi.rel +d_a_npc_kkri.rel +d_a_npc_kolin.rel +d_a_npc_maro.rel +d_a_npc_taro.rel +d_a_npc_tkj.rel +d_a_obj_bhashi.rel +d_a_obj_bkdoor.rel +d_a_obj_bosswarp.rel +d_a_obj_cboard.rel +d_a_obj_digplace.rel +d_a_obj_eff.rel +d_a_obj_fmobj.rel +d_a_obj_gptaru.rel +d_a_obj_hhashi.rel +d_a_obj_kanban2.rel +d_a_obj_kbacket.rel +d_a_obj_kgate.rel +d_a_obj_klift00.rel +d_a_obj_ktonfire.rel +d_a_obj_ladder.rel +d_a_obj_lv2candle.rel +d_a_obj_magne_arm.rel +d_a_obj_metalbox.rel +d_a_obj_mgate.rel +d_a_obj_nameplate.rel +d_a_obj_ornament_cloth.rel +d_a_obj_rope_bridge.rel +d_a_obj_stick.rel +d_a_obj_stonemark.rel +d_a_obj_swallshutter.rel +d_a_obj_swpropeller.rel +d_a_obj_swpush5.rel +d_a_obj_yobikusa.rel +d_a_scene_exit2.rel +d_a_shop_item.rel +d_a_sq.rel +d_a_swc00.rel +d_a_tag_ajnot.rel +d_a_tag_attack_item.rel +d_a_tag_cstasw.rel +d_a_tag_gstart.rel +d_a_tag_hinit.rel +d_a_tag_hjump.rel +d_a_tag_hstop.rel +d_a_tag_lv2prchk.rel +d_a_tag_magne.rel +d_a_tag_mhint.rel +d_a_tag_mstop.rel +d_a_tag_spring.rel +d_a_tag_statue_evt.rel +d_a_ykgr.rel""" + +mMemRels = """d_a_andsw.rel +d_a_arrow.rel +d_a_bg.rel +d_a_bg_obj.rel +d_a_boomerang.rel +d_a_crod.rel +d_a_demo00.rel +d_a_disappear.rel +d_a_dmidna.rel +d_a_door_dbdoor00.rel +d_a_door_knob00.rel +d_a_door_shutter.rel +d_a_door_spiral.rel +d_a_dshutter.rel +d_a_ep.rel +d_a_hitobj.rel +d_a_kytag00.rel +d_a_kytag04.rel +d_a_kytag17.rel +d_a_mg_rod.rel +d_a_midna.rel +d_a_nbomb.rel +d_a_obj_brakeeff.rel +d_a_obj_burnbox.rel +d_a_obj_carry.rel +d_a_obj_ito.rel +d_a_obj_life_container.rel +d_a_obj_movebox.rel +d_a_obj_swpush.rel +d_a_obj_timer.rel +d_a_obj_yousei.rel +d_a_path_line.rel +d_a_scene_exit.rel +d_a_set_bgobj.rel +d_a_spinner.rel +d_a_suspend.rel +d_a_swhit0.rel +d_a_tag_allmato.rel +d_a_tag_attention.rel +d_a_tag_camera.rel +d_a_tag_chkpoint.rel +d_a_tag_event.rel +d_a_tag_evt.rel +d_a_tag_evtarea.rel +d_a_tag_evtmsg.rel +d_a_tag_howl.rel +d_a_tag_kmsg.rel +d_a_tag_lantern.rel +d_a_tag_mist.rel +d_a_tag_msg.rel +d_a_tag_push.rel +d_a_tag_telop.rel +d_a_tbox.rel +d_a_tbox2.rel +d_a_vrbox.rel +d_a_vrbox2.rel +f_pc_profile_lst.rel""" + +#Because libarc is only geared toward reading from arcs I'm writing my own arc writer in this file + +class HEADER: + RARC : int + length : int + headerLength : int + fileDataOffset : int + fileDataLen : int + fileDataLen2 : int + unk1 : int + unk2 : int + +class INFO: + numNodes : int + firstNodeOffset : int + totalDirNum : int + firstDirOffset : int + stringTableLen : int + stringTableOffset : int + numDirsThatAreFiles : int + unk1 : int + unk2 : int + +class NODE: + NAME : int + stringTableOffset: int + hash : int + numDirs : int + firstDirIndex : int + +class DIRECTORY: + dirIndex : int + stringHash : int + type : int + stringOffset : int + fileOffset : int + fileLength : int + unk1 : int + +def computeHash(string): + hash = 0 + for char in string: + hash = hash*3 + hash = hash + ord(char) + + if hash>65535: + hash=hash%65535 + return hash + +def addFile(index,sizeIndex,dirs,name,stringTable,paths,data): + file = DIRECTORY() + file.dirIndex = index + file.stringHash = computeHash(name) + file.type = 0xA500 + file.stringOffset = stringTable.find(name) + path = None + for relPath in paths: + if str(relPath).find(name)!=-1: + path = relPath + file.fileLength = os.path.getsize(path) + file.fileOffset = sizeIndex + sizeIndex = sizeIndex + file.fileLength + 1 + file.unk1 = 0 + fileData = open(path,"rb") + data += fileData.read(file.fileLength) + fileData.close() + dirs.append(file) + + return dirs,data,sizeIndex + + +def copyRelFiles(buildPath,aMemList,mMemList): + relArcPaths = [] + for root,dirs,files in os.walk(str(buildPath/"rel")): + for file in files: + if file.find(".rel")!=-1: + relArcFound = False + for rel in aMemList: + if rel==file: + relArcFound = True + for rel in mMemList: + if rel==file: + relArcFound = True + fullPath = Path(root+"/"+file) + print(str(fullPath)+" -> "+str(buildPath/"game/files/rel/Final/Release"/file)) + shutil.copy(fullPath,buildPath/"game/files/rel/Final/Release/") #We're copying uncompressed rels here, we should compress in the future! + if relArcFound==True: + relArcPaths.append(fullPath) + #print(relArcPaths) + + #After writing this all I found out we don't actually need RELS.arc to load rels lol, keeping it here for the future in case we want to match RELS.arc + + arcHeader = HEADER() + arcHeader.RARC = 0x52415243 + arcHeader.headerLength = 0x20 + arcHeader.unk1 = 0 + arcHeader.unk2 = 0 + infoBlock = INFO() + infoBlock.numNodes = 5 + infoBlock.numDirsThatAreFiles = 142 + rootNode = NODE() + rootNode.NAME = 0x524F4F54 + rootNode.numDirs = 4 + rootNode.firstDirIndex = 0 + rootNode.hash = computeHash("rels") + aMemNode = NODE() + aMemNode.NAME = 0x414d454d + aMemNode.hash = computeHash("amem") + aMemNode.numDirs = 79 + aMemNode.firstDirIndex = 4 + mMemNode = NODE() + mMemNode.hash = computeHash("mmem") + mMemNode.NAME = 0x4d4d454d + mMemNode.numDirs = 59 + mMemNode.firstDirIndex = 83 + + stringTable = "\x2E\0\x2E\x2E\0rels\0amem\0" + for rel in aMemList: + stringTable = stringTable+rel+'\0' + stringTable = stringTable+"mmem\0" + for rel in mMemList: + stringTable = stringTable+rel+'\0' + + rootNode.stringTableOffset = stringTable.find("rels") + aMemNode.stringTableOffset = stringTable.find("amem") + mMemNode.stringTableOffset = stringTable.find("mmem") + + aMemDir = DIRECTORY() + aMemDir.dirIndex = 0xFFFF + aMemDir.type = 0x200 + aMemDir.stringOffset = stringTable.find("amem") + aMemDir.stringHash = computeHash("amem") + aMemDir.fileOffset = 1 + aMemDir.fileLength = 0x10 + aMemDir.unk1 = 0 + + mMemDir = DIRECTORY() + mMemDir.dirIndex = 0xFFFF + mMemDir.type = 0x200 + mMemDir.stringOffset = stringTable.find("mmem") + mMemDir.stringHash = computeHash("mmem") + mMemDir.fileOffset = 2 + mMemDir.fileLength = 0x10 + mMemDir.unk1 = 0 + + unkDir = DIRECTORY() + unkDir.dirIndex = 0xFFFF + unkDir.stringHash = 0x2E + unkDir.type = 0x200 + unkDir.stringOffset = 0 + unkDir.fileOffset = 0 + unkDir.fileLength = 0x10 + unkDir.unk1 = 0 + + unkDir2 = DIRECTORY() + unkDir2.dirIndex = 0xFFFF + unkDir2.stringHash = 0xB8 + unkDir2.type = 0x200 + unkDir2.stringOffset = 2 + unkDir2.fileOffset = 0xFFFFFFFF + unkDir2.fileLength = 0x10 + unkDir2.unk1 = 0 + + dirs = [aMemDir,mMemDir,unkDir,unkDir2] + + data = bytearray() + + dirIndex = 4 + sizeIndex = 0 + for rel in aMemList: + retdirs,retdata,retSize = addFile(dirIndex,sizeIndex,dirs,rel,stringTable,relArcPaths,data) + dirIndex = dirIndex+1 + sizeIndex = retSize + dirs = retdirs + data = retdata + for rel in mMemList: + retdirs,retdata,retSize = addFile(dirIndex,sizeIndex,dirs,rel,stringTable,relArcPaths,data) + dirIndex = dirIndex+1 + sizeIndex = retSize + #print(hex(dirIndex)) + dirs = retdirs + data = retdata + + arcHeader.length = sizeIndex+len(stringTable)+0x20+0x20+0x30+(len(dirs)*0x14)+len(data) + arcHeader.fileDataOffset=0x14E0 + arcHeader.fileDataLen=len(data) + arcHeader.fileDataLen2=arcHeader.fileDataLen + + infoBlock.firstNodeOffset = 0x20 + infoBlock.firstDirOffset = 0x60 + infoBlock.stringTableLen = len(stringTable) + infoBlock.stringTableOffset = 0xB80 + infoBlock.unk1 = 0x100 + infoBlock.unk2 = 0 + infoBlock.totalDirNum = len(dirs) + + + outputArcFile = open(buildPath/"game/files/RELS.arc","wb") + outputArcFile.seek(0) + + outputArcFile.write(struct.pack(">IIIIIIII",arcHeader.RARC,arcHeader.length,arcHeader.headerLength,arcHeader.fileDataOffset,arcHeader.fileDataLen,arcHeader.fileDataLen2,arcHeader.unk1,arcHeader.unk2)) + outputArcFile.write(struct.pack(">IIIIIIHHI",infoBlock.numNodes,infoBlock.firstNodeOffset,infoBlock.totalDirNum,infoBlock.firstDirOffset,infoBlock.stringTableLen,infoBlock.stringTableOffset,infoBlock.numDirsThatAreFiles,infoBlock.unk1,infoBlock.unk2)) + outputArcFile.write(struct.pack(">IIHHI",rootNode.NAME,rootNode.stringTableOffset,rootNode.hash,rootNode.numDirs,rootNode.firstDirIndex)) + outputArcFile.write(struct.pack(">IIHHI",aMemNode.NAME,aMemNode.stringTableOffset,aMemNode.hash,aMemNode.numDirs,aMemNode.firstDirIndex)) + outputArcFile.write(struct.pack(">IIHHI",mMemNode.NAME,mMemNode.stringTableOffset,mMemNode.hash,mMemNode.numDirs,mMemNode.firstDirIndex)) + outputArcFile.write(bytearray(16)) + for dir in dirs: + outputArcFile.write(struct.pack(">HHHHIII",dir.dirIndex,dir.stringHash,dir.type,dir.stringOffset,dir.fileOffset,dir.fileLength,dir.unk1)) + unkData = [0x8A ,0xCF ,0x7F ,0xA5 ,0x00 ,0x09 ,0x36 ,0x00 ,0x0D ,0x08 ,0xA0 ,0x00 ,0x00 ,0x0C ,0xD8 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x8B ,0x9B ,0xF7 ,0xA5 ,0x00 ,0x09 ,0x45 ,0x00 ,0x0D ,0x15 ,0x80 ,0x00 ,0x00 ,0x21 ,0xED ,0x00 ,0x00 ,0x00 ,0x00 ,0xFF ,0xFF ,0x00 ,0x2E ,0x02 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x02 ,0x00 ,0x00 ,0x00 ,0x10 ,0x00 ,0x00 ,0x00 ,0x00 ,0xFF ,0xFF ,0x00 ,0xB8 ,0x02 ,0x00 ,0x00 ,0x02 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x10 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00, 0x00] + outputArcFile.write(bytearray(unkData)) + strBytearray = bytearray() + strBytearray.extend(map(ord,stringTable)) + outputArcFile.write(strBytearray) + outputArcFile.write(bytearray(6)) + outputArcFile.write(data) + + outputArcFile.truncate() + outputArcFile.close() + + + + + + + + + + + + + +def main(gamePath,buildPath): + if not gamePath.exists(): + gamePath.mkdir(parents=True, exist_ok=True) + + iso = Path("gz2e01.iso") + if not iso.exists() or not iso.is_file(): + print("gz2e01.iso doesn't exist in project directory!") + sys.exit(1) + + if not (gamePath/"files").exists() or not (gamePath/"sys").exists(): + print("ISO is not extracted; extracting...") + previousDir = os.getcwd() + os.chdir(str(gamePath.absolute())) + extract_game_assets.extract("../" + str(iso)) + os.chdir(previousDir) + + print("Copying game files...") + copy(gamePath,buildPath.absolute()) + + print(str(buildPath/"main_shift.dol")+" -> "+str(buildPath/"game/sys/main.dol")) + shutil.copyfile(buildPath/"main_shift.dol",buildPath/"game/sys/main.dol") + + copyRelFiles(buildPath,aMemRels.splitlines(),mMemRels.splitlines()) + + + + +if __name__ == "__main__": + main(Path(sys.argv[1]),Path(sys.argv[2])) \ No newline at end of file diff --git a/tools/tp.py b/tools/tp.py index ff70a71a41..64c0eed53a 100644 --- a/tools/tp.py +++ b/tools/tp.py @@ -351,7 +351,7 @@ def check(debug, rels, game_path, build_path): CONSOLE.print(text) try: - check_sha1(game_path, build_path, rels) + check_sha1(game_path/"files", build_path, rels) text = Text(" OK") text.stylize("bold green") CONSOLE.print(text)