Files
DorkNet/tools/build_pc.py
T

261 lines
12 KiB
Python

#!/usr/bin/env python3
"""Build the DorkNet PC client packages.
patcher dorknet-clientpatch-<VersionKey>.zip, what the DorkNet launcher
installs: MelonLoader.zip, DorkNet.ClientMod.dll (built from this
repo), the config template, the Goldberg Steam stub and a
manifest.json saying where each piece goes. No game files.
client a ready-to-run client: a stock Rec Room build with a patcher zip
applied the way the launcher applies it, plus your config. It
contains the game's own files, so keep it private.
Runs on Windows, Linux and macOS. `patcher` needs the .NET SDK (the mod
builds against MelonLoader's own DLLs, so no Rec Room install is needed).
py -3 tools/build_pc.py patcher --version-key march_2023_03_21
py -3 tools/build_pc.py client --base-client RecRoom-2023-03-21.zip \\
--patcher-zip dist/dorknet-clientpatch-march_2023_03_21.zip \\
--config dorknet-clientmod.json
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import NoReturn
REPO = Path(__file__).resolve().parent.parent
CLIENT_MOD = REPO / "DorkNet.ClientMod"
# Where each build expects the mod's config and the Steam DLL the game actually
# loads (see the per-branch build layout notes in the README):
# 2020.03 Recroom_Release.exe is the game; Steam DLL in its Data/Plugins.
# 2020.12 RecRoom.exe is the game (Recroom_Release.exe is the EAC wrapper);
# MelonLoader keeps UserData under MelonLoader/.
# 2023.03 RecRoom.exe + RecRoom_Data; MelonLoader 0.6 keeps UserData at the
# install root.
LAYOUTS = {
"march_2020_03_10": ("MelonLoader/UserData/dorknet-clientmod.json", "Recroom_Release_Data/Plugins/steam_api64.dll"),
"december_2020_12_18": ("MelonLoader/UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
"march_2023_03_21": ("UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
}
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def zip_dir(src: Path, out: Path) -> None:
"""Zip the contents of src (not src itself) into out."""
out.parent.mkdir(parents=True, exist_ok=True)
out.unlink(missing_ok=True)
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as z:
for f in sorted(src.rglob("*")):
if f.is_file():
z.write(f, f.relative_to(src).as_posix())
# ---- patcher --------------------------------------------------------------
def cmd_patcher(a: argparse.Namespace) -> None:
if a.version_key not in LAYOUTS:
fail(f"unknown version key '{a.version_key}'. Known: {', '.join(LAYOUTS)}")
config_dest, steam_dest = LAYOUTS[a.version_key]
out_dir = Path(a.output_dir) if a.output_dir else REPO / "dist"
with tempfile.TemporaryDirectory(prefix=f"dorknet-patcher-{a.version_key}-") as tmp:
stage = Path(tmp) / "stage"
stage.mkdir()
# 1. MelonLoader. Downloaded first so its DLLs can serve as the mod's
# compile-time references: no local Rec Room install needed.
url = f"https://github.com/LavaGang/MelonLoader/releases/download/{a.melonloader_version}/MelonLoader.x64.zip"
ml_zip = stage / "MelonLoader.zip"
step(f"downloading {url}")
with urllib.request.urlopen(url) as r, open(ml_zip, "wb") as f:
shutil.copyfileobj(r, f)
if ml_zip.stat().st_size < 1 << 20:
fail("MelonLoader download failed or produced an unexpectedly small file")
ml_extract = Path(tmp) / "melonloader"
with zipfile.ZipFile(ml_zip) as z:
z.extractall(ml_extract)
# The 0.6.x release zip has a top-level MelonLoader/ folder holding
# net6, net35, Dependencies, Documentation.
ml_ref = ml_extract / "MelonLoader"
if not (ml_ref / "net6" / "MelonLoader.dll").exists():
fail("extracted MelonLoader is missing net6/MelonLoader.dll; the release layout may have changed")
# 2. the mod
step("dotnet build DorkNet.ClientMod (Release)")
r = subprocess.run(["dotnet", "build", str(CLIENT_MOD / "DorkNet.ClientMod.csproj"),
"-c", "Release", "--nologo", f"-p:MelonLoaderDir={ml_ref}"])
if r.returncode:
fail("ClientMod build failed")
dlls = sorted((CLIENT_MOD / "bin" / "Release").rglob("DorkNet.ClientMod.dll"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not dlls:
fail("DorkNet.ClientMod.dll not found after the build")
shutil.copy2(dlls[0], stage / "DorkNet.ClientMod.dll")
print(f" built {dlls[0]}")
# 3. config template + 4. Goldberg Steam stub (App ID 471710, the same
# for every build). The stub replaces Valve's steam_api64.dll so the
# game runs without Steam.
for src, what in ((CLIENT_MOD / "dorknet-clientmod.json.template", "config template"),
(CLIENT_MOD / "steam-stub" / "steam_api64.dll", "Steam stub DLL"),
(CLIENT_MOD / "steam-stub" / "steam_appid.txt", "Steam appid file")):
shutil.copy2(need(src, what), stage / src.name)
# 5. manifest.json, read by the launcher and by `client` below.
manifest = {
"$schema_version": 1,
"loader_archive": "MelonLoader.zip",
"plugin_dll": "DorkNet.ClientMod.dll",
"plugin_dest": "Mods",
"config_template": "dorknet-clientmod.json.template",
"config_dest": config_dest,
"old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"],
"steam_stub": {
# 2020.12+ is an EAC build: the real Unity binary is RecRoom.exe
# (it loads RecRoom_Data/Plugins/x86_64/steam_api64.dll), NOT
# Recroom_Release.exe (the EAC launcher wrapper, whose data dir
# the real game never opens).
"api_dll": "steam_api64.dll",
"api_dest": steam_dest,
"api_backup_suffix": ".steam-original",
"appid_file": "steam_appid.txt",
"appid_dest": "steam_appid.txt",
},
}
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
# 6. bundle
out = out_dir / f"dorknet-clientpatch-{a.version_key}.zip"
zip_dir(stage, out)
print()
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB)")
# ---- client ---------------------------------------------------------------
def cmd_client(a: argparse.Namespace) -> None:
base = need(a.base_client, "stock client")
patcher_zip = need(a.patcher_zip, "patcher zip")
config = need(a.config, "client config") if a.config else None
with tempfile.TemporaryDirectory(prefix="dorknet-pc-client-") as tmp:
tmp = Path(tmp)
install, patcher = tmp / "install", tmp / "patcher"
patcher.mkdir()
# 1. stock client: a zip or a folder, the install at its root or
# inside one wrapping folder.
step(f"unpacking stock client {base}")
if base.is_dir():
unpacked = base
else:
unpacked = tmp / "base"
with zipfile.ZipFile(base) as z:
z.extractall(unpacked)
root = unpacked
top = list(root.iterdir())
if len(top) == 1 and top[0].is_dir():
root = top[0]
games = [p.name for p in root.glob("*.exe") if (root / f"{p.stem}_Data").is_dir()]
if not games:
fail(f"no game exe with a matching _Data folder at the root of {base}")
print(f" game: {', '.join(games)}")
shutil.copytree(root, install, ignore=shutil.ignore_patterns(".DepotDownloader"))
# 2. apply the patcher the way the launcher does, from its manifest.
step(f"applying {patcher_zip}")
with zipfile.ZipFile(patcher_zip) as z:
z.extractall(patcher)
m = json.loads(need(patcher / "manifest.json", "patcher manifest.json").read_text(encoding="utf-8"))
with zipfile.ZipFile(need(patcher / m["loader_archive"], "MelonLoader archive")) as z:
z.extractall(install)
print(" MelonLoader -> install root")
plugin_dir = install / m["plugin_dest"]
plugin_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(patcher / m["plugin_dll"], plugin_dir / m["plugin_dll"])
print(f" {m['plugin_dll']} -> {m['plugin_dest']}/")
config_dest = install / m["config_dest"]
config_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(config or patcher / m["config_template"], config_dest)
print(f" config -> {m['config_dest']}{'' if config else ' (template, unedited)'}")
stub = m.get("steam_stub")
if stub:
api_dest = install / stub["api_dest"]
if api_dest.exists():
backup = api_dest.with_name(api_dest.name + stub["api_backup_suffix"])
if not backup.exists():
shutil.copy2(api_dest, backup)
api_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(patcher / stub["api_dll"], api_dest)
shutil.copy2(patcher / stub["appid_file"], install / stub["appid_dest"])
print(f" Steam stub -> {stub['api_dest']}")
for old in m.get("old_plugin_paths") or []:
p = install / old
if p.exists():
p.unlink()
print(f" removed old {old}")
# 3. zip
key = patcher_zip.stem.removeprefix("dorknet-clientpatch-")
out = Path(a.output_zip) if a.output_zip else REPO / "dist" / f"dorknet-client-{key}.zip"
step(f"zipping -> {out}")
zip_dir(install, out)
print()
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB). It contains game files: keep it private.")
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description="Build the DorkNet PC client packages.")
sub = p.add_subparsers(dest="cmd", required=True)
pz = sub.add_parser("patcher", help="dorknet-clientpatch-<VersionKey>.zip (no game files)")
pz.add_argument("--version-key", required=True, choices=sorted(LAYOUTS),
help="the build the zip is for; picks the install layout")
pz.add_argument("--melonloader-version", default="v0.6.6",
help="MelonLoader release tag to bundle (default: v0.6.6, what the mod compiles against)")
pz.add_argument("--output-dir", help="where the zip goes (default: <repo>/dist)")
pz.set_defaults(func=cmd_patcher)
c = sub.add_parser("client", help="stock build + patcher zip + config -> ready-to-run client zip")
c.add_argument("--base-client", required=True, help="the stock client, as a .zip or a folder")
c.add_argument("--patcher-zip", required=True, help="dorknet-clientpatch-<VersionKey>.zip from `patcher`")
c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)")
c.add_argument("--output-zip", help="default: <repo>/dist/dorknet-client-<VersionKey>.zip")
c.set_defaults(func=cmd_client)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()