Skip to content

Instantly share code, notes, and snippets.

@ckx
Created May 9, 2025 04:23
Show Gist options
  • Save ckx/c41c1a57882d7ea41194ddff2beea2a3 to your computer and use it in GitHub Desktop.
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).
#!/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