mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
b1c1239165
A Jenkinsfile builds, on push (only what changed) or on demand: - the PC patcher zip (MelonLoader, the mod, config template, Steam stub), - the full PC client (stock build + patcher + config), - the Quest APK and .xapk. Stock builds come out of the restic build archive on the FTP: restic through its rclone backend, picked by snapshot tag per run and cached. The FTP login and restic password are Jenkins credentials, and the repository folders are environment settings, so nothing about the archive is fixed in the repo. ci/jenkins/Dockerfile is the Jenkins image with the toolchain added (.NET SDK, Android NDK and build-tools, apktool, Python with LIEF, restic, rclone), for the existing Jenkins service. The build scripts move from PowerShell to Python so they run the same on Windows, Linux and in CI: - DorkNet.Quest/scripts/build.py replaces build.ps1 + package.ps1; it can use a committed hook table (checked against the APK's libil2cpp.so) instead of dump.cs, and takes a persistent keystore. - tools/build_pc.py replaces build-patcher-zip.ps1 (now with the 2023 install layout) and adds the full-client build. Setup steps are in ci/jenkins/README.md.
260 lines
11 KiB
Python
260 lines
11 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
|
|
|
|
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) -> None:
|
|
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()
|