Created
May 9, 2025 04:23
-
-
Save ckx/c41c1a57882d7ea41194ddff2beea2a3 to your computer and use it in GitHub Desktop.
Automated patch-server deployment from a git repo for any Elurair compatible Action MMORPG game client (rAthena/Hercules).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
## Documentation in repo wiki or last resort is https://ckx.mikomiko.org/2025/05/08/elurair_deploy/ | |
import zipfile | |
import subprocess | |
import shutil | |
import os | |
from datetime import datetime | |
from pathlib import Path | |
from enum import Enum | |
class WriteMode(Enum): | |
APPEND = 1 | |
OVERWRITE = 2 | |
# ------------ Config ------------ | |
## Primary paths | |
RGX_PATH = Path("/home/<user>/rgx/") # Main working directory. | |
WWW_PATH = Path("/home/<user>/www/<webdir>/") # Root directory to serve patches out of | |
REPO_PATH = RGX_PATH / "roguenarok-client" # Path to cloned client repository | |
ARCHIVE_STORE_PATH = RGX_PATH / "archive_store" # Working directory, where we keep previous commit grfs, make zip files, etc | |
CLIENT_SUBDIR_PATH = "RO" # subdirectory for actual client files under the REPO_PATH, set it to "" if you use root dir of repo. | |
CLIENT_RO_PATH = Path(REPO_PATH / CLIENT_SUBDIR_PATH) | |
LOGFILE = RGX_PATH / "patcher_deploy.log" # logfile | |
# ----- Patcher Contexts ----- | |
## Each Patcher name corresponds to an Elurair Patch block, which I refer to as a "patch context" | |
# -- Incremental diff patch contexts (Write) | |
## Main | |
### Updates rgx.grf | |
PATCH_MAIN = "main" | |
PATCH_MAIN_GRF = "rgx.grf" | |
PATCH_MAIN_PATH = WWW_PATH / PATCH_MAIN # patcher's www subdir | |
PATCH_MAIN_TXT = PATCH_MAIN_PATH / "patch_main.txt" | |
PATCH_MAIN_WRITEMODE = WriteMode.APPEND | |
## System | |
### Updates LUA & other plain text files | |
### Includes files with extensions in system_ext | |
### Excludes filenames listed in system_exclude. | |
PATCH_SYSTEM = "system" | |
PATCH_SYSTEM_PATH = WWW_PATH / PATCH_SYSTEM | |
PATCH_SYSTEM_TXT = PATCH_SYSTEM_PATH / "patch_system.txt" | |
PATCH_SYSTEM_WRITEMODE = WriteMode.APPEND | |
system_ext = [".lua", ".lub", ".txt", ".ini", ".xml"] | |
system_exclude = ["itemInfo_EN.lua"] | |
## Data | |
### Updates BGM, font, and other asset/data binary files. | |
### All files extensions that are not explicitly handled end up here. | |
### Common ones you might see: ".mp3", ".otf", ".ttf", ".png", ".jpg", ".bmp", ".gif", ".wav" | |
### Excludes filenames listed in data_exclude. | |
PATCH_DATA = "data" | |
PATCH_DATA_PATH = WWW_PATH / PATCH_DATA | |
PATCH_DATA_TXT = PATCH_DATA_PATH / "patch_data.txt" | |
PATCH_DATA_WRITEMODE = WriteMode.APPEND | |
data_exclude = [] | |
# -- Newest-only patchers (overwrite patch_txt) | |
## Client | |
### Updates exe files (client files & opensetup) | |
### Includes files with extensions in client_ext | |
### Excludes filenames listed in client_exclude. | |
PATCH_CLIENT = "client" | |
PATCH_CLIENT_PATH = WWW_PATH / PATCH_CLIENT | |
PATCH_CLIENT_TXT = PATCH_CLIENT_PATH / "patch_client.txt" | |
PATCH_CLIENT_WRITEMODE = WriteMode.OVERWRITE | |
client_ext = [".exe"] | |
client_exclude = ["freyja-patcher.exe"] # patcher patches itself in another way. | |
## ItemInfo.LUA | |
### Updates itemInfo.LUA. Separated from System because the file is large. | |
PATCH_ITEMINFO = "iteminfo" | |
PATCH_ITEMINFO_IDENTIFIER = "itemInfo" | |
PATCH_ITEMINFO_PATH = WWW_PATH / PATCH_ITEMINFO | |
PATCH_ITEMINFO_TXT = PATCH_ITEMINFO_PATH / "patch_iteminfo.txt" | |
PATCH_ITEMINFO_WRITEMODE = WriteMode.OVERWRITE | |
# --------------- Config End --------------- | |
# --------------- Utility --------------- | |
## Helpers for string appending and w/e | |
LAST_GRF = "_last.grf" ## {grf}_last.grf // GRF name gets this appended to it | |
PATCH_STR = "_patch_" # {grf}_patch_{commit_hash}.gpf | |
## Globals | |
HEAD = "" # most recent commit HEAD | |
LAST_HEAD = "" # previous commit aka HEAD@{1} | |
# Write to log file | |
def write_log(line): | |
print(line) | |
timestamp = datetime.now().strftime("[%H:%M:%S] ") | |
with open(LOGFILE, "a") as log_file: | |
log_file.write(f"{timestamp} {line}\n") | |
return 1 | |
# | |
def write_cmd(cmd): | |
if isinstance(cmd, str): | |
line = ">> " + cmd | |
else: | |
line = ">> " + " ".join(cmd) | |
write_log(line) | |
return 1 | |
def run_git_command(args, cwd): | |
write_cmd(["git"] + args) | |
result = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True) | |
result.check_returncode() | |
write_log(result.stdout.strip()) | |
return result.stdout.strip() | |
# --------------- Updates --------------- | |
## Stuff that generates stuff, heavy lifting. | |
# Reads a "patch_xxx.txt" file, then appends a line with appropriate version number + provided filename | |
## arg1 - patch_xxx.txt full path | |
## arg2 - filename to append to patch_xxx.txt | |
def append_patch_entry(patch_file: Path, filename: str): | |
if patch_file.exists(): | |
lines = patch_file.read_text().splitlines() | |
else: | |
lines = [] | |
numbers = [] | |
for line in lines: | |
parts = line.strip().split() | |
if parts and parts[0].isdigit(): | |
numbers.append(int(parts[0])) | |
next_num = max(numbers, default=0) + 1 | |
patch_line = f"{next_num}{' ' * 4}{filename}" | |
with patch_file.open("a") as f: | |
f.write(patch_line + "\n") | |
# Reads a "patch_xxx.txt" file, then overwrites contents with a line with appropriate version number + provided filename | |
# Used if we dont want users to patch over the same file over and over for non-diff updates. they will only get newest. | |
## arg1 - patch_xxx.txt full path | |
## arg2 - filename to append to patch_xxx.txt | |
def overwite_patch_entry(patch_file: Path, filename: str): | |
if patch_file.exists(): | |
lines = patch_file.read_text().splitlines() | |
numbers = [] | |
for line in lines: | |
parts = line.strip().split() | |
if parts and parts[0].isdigit(): | |
numbers.append(int(parts[0])) | |
next_num = max(numbers, default=0) + 1 | |
else: | |
next_num = 1 | |
patch_line = f"{next_num}{' ' * 4}{filename}" | |
patch_file.write_text(patch_line + "\n") | |
# Reads a patch.txt then either overwrites or appends its contents with a line that has version number + provided filename | |
## arg1 - patch_xxx.txt full path | |
## arg2 - filename to append to patch_xxx.txt | |
## arg3 - append vs overwrite | |
def write_patch_entry(patch_file: Path, filename: str, mode: WriteMode): | |
if filename == "": | |
return 0 | |
write_log(f"{patch_file}: Writing {filename} in {mode.name} mode") | |
if mode == WriteMode.APPEND: | |
append_patch_entry(patch_file, filename) | |
elif mode == WriteMode.OVERWRITE: | |
overwite_patch_entry(patch_file, filename) | |
else: | |
msg = f"Invalid PatchWriteMode: {mode}" | |
write_log(msg) | |
raise ValueError(msg) | |
write_log(f"{patch_file}: Updated successful") | |
return 1 | |
# Move recently committed grf (arg1) to archive_store/rgx.grf, create a diff patch between rgx.grf & archive_store/rgx_last.grf, | |
# move the generated gpf to patch_dir (arg2), delete rgx_last.grf and rename rgx.grf to rgx_last.grf | |
### arg1 - base_grf (updated grf) path from repo parent dir | |
### arg2 - patch directory to move generated patch file to | |
def create_gpf_patch(base_grf: Path, patch_dir: Path): | |
# figure out paths, copy grf over | |
## NOTE: we can't use old_head to determine last_grf because there are commits where grf is not updated. | |
new_grf = ARCHIVE_STORE_PATH / base_grf.name ## Where the grf ends up in our working directory | |
last_grf = ARCHIVE_STORE_PATH / Path(f"{base_grf.stem}_last.grf") # the previously updated version of this grf | |
gpf = Path(f"{base_grf.stem}{PATCH_STR}{HEAD}.gpf") | |
gpf_path_src = ARCHIVE_STORE_PATH / gpf | |
gpf_path_dst = patch_dir / gpf | |
write_log(f"Generating GPF for {base_grf.name}.") | |
write_log(f"Copying base grf ({base_grf}) to archive_store ({new_grf})...") | |
shutil.copy2(base_grf, new_grf) | |
# setup rsuts | |
rsuts = [ | |
"docker", "run", "--rm", | |
"-v", f"{os.environ['HOME']}/rgx:/mnt", | |
"-w", "/mnt/archive_store", # set the docker container's working directory, make sure rsuts.exe exists here | |
"wine32", "rsuts.exe" | |
] | |
rsuts_args = ["diff", str(gpf.name), str(new_grf.name), str(last_grf.name)] | |
# generate patch | |
write_log(f"Running {rsuts} {rsuts_args}...") | |
write_log("--- rsuts begin ---") | |
rsuts_run = subprocess.run(rsuts+rsuts_args, capture_output=True, text=True, encoding="latin1") | |
write_log(rsuts_run.stdout) | |
if not gpf_path_src.exists(): | |
write_log(f"{gpf_path_src} not found, failed to generate file?") | |
return "" | |
write_log("--- rsuts done ---\n") | |
## move patch file | |
write_log(f"Moving {gpf_path_src} to patch dir {gpf_path_dst}...") | |
shutil.move(gpf_path_src, gpf_path_dst) | |
if not gpf_path_dst.exists(): | |
write_log(f"Failed to move {gpf_path_src} to {gpf_path_dst}.") | |
return "" | |
## cleanup, delete old last grf, move new one in | |
if last_grf.exists(): | |
write_log(f"Deleting old _last grf @ {last_grf}...") | |
last_grf.unlink() | |
else: | |
write_log(f"Failed to delete {last_grf}, it didn't exist?") | |
write_log(f"Moving {new_grf.name} to {last_grf.name}...") | |
shutil.move(new_grf, last_grf) | |
write_log(f"Done generating {gpf_path_dst}") | |
return gpf | |
# arg1 files - list of string or path of files to include | |
# arg2 zip_name - output zip name, path will always be in ARCHIVE_STORE_PATH | |
# arg3 patch_dir - path for www patch_dir that zip should end up in | |
# [arg3] - base directory for relative paths, if none the parent of each file is used individually | |
# returns string name of generated zip archive | |
def create_zip_archive(files, zip_name, patch_dir, base_dir=None): | |
if not files: | |
return "" | |
zip_name = f"{Path(zip_name).stem}_{HEAD}.zip" | |
zip_path = Path(ARCHIVE_STORE_PATH / zip_name) | |
write_log(f"--- Attempting to pack archive {zip_name} ---") | |
write_log("Files: " + " | ".join(str(s) for s in files)) | |
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: | |
for file in files: | |
file = Path(file) | |
if base_dir: | |
arcname = file.relative_to(base_dir) | |
else: | |
arcname = file.relative_to(file.parent) | |
zipf.write(file, arcname) | |
zip_dst = Path(patch_dir / zip_name) | |
write_log(f"Packaged archive at {zip_name}, moving to {patch_dir}...") | |
shutil.move(zip_path, zip_dst) | |
write_log(f"Done creating {zip_dst}") | |
return str(zip_dst.name) | |
# --------------- Main --------------- | |
def main(): | |
global HEAD | |
global LAST_HEAD | |
# setup some general data | |
write_log(f"-------- DEPLOY PATCHER {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} --------") | |
Path(ARCHIVE_STORE_PATH).mkdir(parents=True, exist_ok=True) | |
Path(PATCH_MAIN_PATH).mkdir(parents=True, exist_ok=True) | |
Path(PATCH_SYSTEM_PATH).mkdir(parents=True, exist_ok=True) | |
Path(PATCH_DATA_PATH).mkdir(parents=True, exist_ok=True) | |
Path(PATCH_ITEMINFO_PATH).mkdir(parents=True, exist_ok=True) | |
Path(PATCH_CLIENT_PATH).mkdir(parents=True, exist_ok=True) | |
system_files = [] | |
data_files = [] | |
client_files = [] | |
iteminfo_files = [] | |
write_log(f"\n--- git begin ---") | |
# setup git data, do git pull | |
try: | |
last_head_full = run_git_command(["rev-parse", "HEAD"], cwd=REPO_PATH) | |
LAST_HEAD = last_head_full[0:7] | |
run_git_command(["pull", "origin", "main"], cwd=REPO_PATH) | |
head_full = run_git_command(["rev-parse", "HEAD"], cwd=REPO_PATH) | |
HEAD = head_full[0:7] | |
write_log(f"\nLAST_HEAD: {LAST_HEAD}\nHEAD: {HEAD}") | |
except subprocess.CalledProcessError as e: | |
write_log(f"Subprocess error: {e.stderr}") | |
exit(1) | |
# get changed files | |
diff_output = run_git_command(["diff", "--name-only", "--diff-filter=ACMR", LAST_HEAD, HEAD], cwd=REPO_PATH) | |
changed_files = diff_output.splitlines() | |
write_log(f"--- git end ---\n") | |
# Route our changed files to patch the appropriate thing | |
for file in changed_files: | |
## Skip irrelevant files | |
if not file.startswith(str(CLIENT_SUBDIR_PATH)): | |
continue | |
## Patch main GRF | |
if file == f"{CLIENT_SUBDIR_PATH}/{PATCH_MAIN_GRF}": | |
gpf = create_gpf_patch(CLIENT_RO_PATH / PATCH_MAIN_GRF, WWW_PATH / PATCH_MAIN_PATH) | |
write_patch_entry(WWW_PATH / PATCH_MAIN_PATH / PATCH_MAIN_TXT, gpf, PATCH_MAIN_WRITEMODE) | |
continue | |
file_relative_path = Path(file).relative_to(str(CLIENT_SUBDIR_PATH)) | |
## System_files list | |
if (any(file.lower().endswith(ext) for ext in system_ext) and Path(file).name.lower() not in [name.lower() for name in system_exclude]): | |
system_files.append(CLIENT_RO_PATH / file_relative_path) | |
continue | |
## client files | |
if (any(file.lower().endswith(ext) for ext in client_ext) and Path(file).name.lower() not in [name.lower() for name in client_exclude]): | |
client_files.append(CLIENT_RO_PATH / file_relative_path) | |
continue | |
## iteminfo | |
if PATCH_ITEMINFO_IDENTIFIER.lower() in Path(file).name.lower(): | |
iteminfo_files.append(CLIENT_RO_PATH / Path(file).relative_to(str(CLIENT_SUBDIR_PATH))) | |
continue | |
## data_files (everything else / unhandled) | |
data_files.append(CLIENT_RO_PATH / file_relative_path) | |
continue | |
# end patcher routing | |
# Package up archives | |
if system_files: | |
sys_zip = create_zip_archive(system_files, "system.zip", WWW_PATH / PATCH_SYSTEM_PATH, CLIENT_RO_PATH) | |
write_patch_entry(WWW_PATH / PATCH_SYSTEM_PATH / PATCH_SYSTEM_TXT, sys_zip, PATCH_SYSTEM_WRITEMODE) | |
if client_files: | |
client_zip = create_zip_archive(client_files, "client.zip", WWW_PATH / PATCH_CLIENT_PATH, CLIENT_RO_PATH) | |
write_patch_entry(WWW_PATH / PATCH_CLIENT_PATH / PATCH_CLIENT_TXT, client_zip, PATCH_CLIENT_WRITEMODE) | |
if iteminfo_files: | |
iinfo_zip = create_zip_archive(iteminfo_files, "iteminfo.zip", WWW_PATH / PATCH_ITEMINFO_PATH, CLIENT_RO_PATH) | |
write_patch_entry(WWW_PATH / PATCH_ITEMINFO_PATH / PATCH_ITEMINFO_TXT, iinfo_zip, PATCH_ITEMINFO_WRITEMODE) | |
if data_files: | |
data_zip = create_zip_archive(data_files, "data.zip", WWW_PATH / PATCH_DATA_PATH, CLIENT_RO_PATH) | |
write_patch_entry(WWW_PATH / PATCH_DATA_PATH / PATCH_DATA_TXT, data_zip, PATCH_DATA_WRITEMODE) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment