194 lines
11 KiB
Python
194 lines
11 KiB
Python
"""Campus launcher releases: the version number, the staged exe and the staged patch.
|
|
|
|
release_app.py bump raise src\\Version.h by one: 1.01 -> 1.02 ... 1.99 -> 2.00
|
|
release_app.py unbump undo that (the build failed, so that number wasn't released)
|
|
release_app.py stage copy bin\\Campus.exe to Deploy\\launcher-app\\<sha256>.exe and write
|
|
latest.json (version, build, sha256, size) next to it
|
|
release_app.py patch [beta|dev|2026] (the channel is for developer accounts only - PushChannel.bat; no channel = public;
|
|
"2026" = the Rec Room 2026 client's patch, CampusPatch2025\\x64\\Release\\2026Patch.dll, with its own secret)
|
|
copy the built CampusPatch.dll (CampusPatch2025\\x64\\Release) to
|
|
Deploy\\launcher-patch\\<sha256>.dll and write latest.json (sha256, size)
|
|
release_app.py newkey [2026] write a new patch secret to PatchProofKey.h (then build + "patch" stages it);
|
|
"2026" = the 2026Patch project's own key
|
|
release_app.py game [zip] stage the whole game for the launcher's INSTALL / UPDATE: the zip
|
|
(default D:\\Puck\\Campus.zip) as Deploy\\launcher-game\\<sha256>.zip (a
|
|
hard link - no 4 GB copy) + latest.json (sha256, size, unpacked)
|
|
|
|
UploadUpdate.bat sends what's staged (only what the VPS lacks) and then the latest.json files:
|
|
running launchers see the higher build and update themselves, and every Play downloads the newest
|
|
patch (src\\Update.h, src\\Game.h).
|
|
"""
|
|
import hashlib, json, os, re, sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # ...\CampusPatch2025\Launcher
|
|
VERSION_H = os.path.join(ROOT, 'src', 'Version.h')
|
|
DEPLOY = r'D:\Puck\Campus2025Server-2025\Deploy'
|
|
APP_STAGE = os.path.join(DEPLOY, 'launcher-app')
|
|
PATCH_STAGE = os.path.join(DEPLOY, 'launcher-patch')
|
|
GAME_STAGE = os.path.join(DEPLOY, 'launcher-game')
|
|
GAME_ZIP = r'D:\Puck\Campus.zip'
|
|
PATCH_KEY_H = os.path.join(os.path.dirname(ROOT), '2025Patch', 'src', 'RR', 'Patching', 'PatchProofKey.h')
|
|
PATCH26_KEY_H = os.path.join(os.path.dirname(ROOT), '2026Patch', 'src', 'RR', 'Patching', 'PatchProofKey.h') # the 2026 client's patch has its own secret
|
|
LAUNCHER_KEY_H = os.path.join(ROOT, 'src', 'LauncherKey.h')
|
|
PATCH_BUILDS =[os.path.join(os.path.dirname(ROOT), 'x64', 'Release', n) for n in ('2025Patch.dll', 'CampusPatch.dll')]
|
|
PATCH_BUILDS_DEV = [os.path.join(os.path.dirname(ROOT), 'x64', 'Dev', '2025Patch.dll')] # the Dev configuration (the dev channel)
|
|
PATCH_BUILDS_2026 = [os.path.join(os.path.dirname(ROOT), 'x64', 'Release', '2026Patch.dll')] # the Rec Room 2026 client's patch (the 2026 channel)
|
|
|
|
|
|
def read_version():
|
|
s = open(VERSION_H, encoding='utf-8').read()
|
|
major = int(re.search(r'#define LV_VERSION_MAJOR (\d+)', s).group(1))
|
|
minor = int(re.search(r'#define LV_VERSION_MINOR (\d+)', s).group(1))
|
|
return s, major, minor
|
|
|
|
|
|
def bump(step=1):
|
|
s, major, minor = read_version()
|
|
minor += step
|
|
if minor > 99: major, minor = major + 1, 0
|
|
if minor < 0: major, minor = major - 1, 99
|
|
s = re.sub(r'#define LV_VERSION_MAJOR \d+', f'#define LV_VERSION_MAJOR {major}', s)
|
|
s = re.sub(r'#define LV_VERSION_MINOR \d+', f'#define LV_VERSION_MINOR {minor}', s)
|
|
s = re.sub(r'#define LV_VERSION_STR "[^"]*"', f'#define LV_VERSION_STR "{major}.{minor:02d}"', s)
|
|
s = re.sub(r'#define LV_VERSION_BUILD \d+', f'#define LV_VERSION_BUILD {major * 100 + minor}', s)
|
|
open(VERSION_H, 'w', encoding='utf-8', newline='').write(s)
|
|
print(f'launcher version {major}.{minor:02d}')
|
|
|
|
|
|
def put(stage, data, ext, info):
|
|
os.makedirs(stage, exist_ok=True)
|
|
sha = info['sha256']
|
|
for f in os.listdir(stage): # only the newest goes up
|
|
if f.endswith(ext) and f != sha + ext: os.remove(os.path.join(stage, f))
|
|
open(os.path.join(stage, sha + ext), 'wb').write(data)
|
|
open(os.path.join(stage, 'latest.json'), 'w', encoding='utf-8').write(json.dumps(info, indent=1))
|
|
|
|
|
|
def read_launcher_key():
|
|
m = re.search(r'#define CAMPUS_LAUNCHER_LAUNCH_KEY "([^"]+)"', open(LAUNCHER_KEY_H, encoding='utf-8').read())
|
|
if not m:
|
|
sys.exit('[x] no key in ' + LAUNCHER_KEY_H + ' - run "release_app.py newlaunchkey"')
|
|
return m.group(1)
|
|
|
|
|
|
def newlaunchkey():
|
|
import secrets
|
|
key = secrets.token_urlsafe(32)
|
|
open(LAUNCHER_KEY_H, 'w', encoding='utf-8', newline='').write(
|
|
'#pragma once\n'
|
|
'// GENERATED by CampusPatch2025\\Launcher\\tools\\release_app.py newlaunchkey (build_release.bat\n'
|
|
'// makes a new one for every launcher release). The server reads the same key from\n'
|
|
'// Data/Launcher/app/<sha256>.launchkey, uploaded next to this build (release_app.py stage).\n'
|
|
'// Never commit or paste it.\n'
|
|
'//\n'
|
|
'// Used by App::MintLaunchToken() to sign the CAMPUS_LAUNCH_TOKEN env var passed to the game.\n'
|
|
'// The patch forwards it to the server with /api/patch/proof; a login without a fresh launcher\n'
|
|
'// proof is refused and the player\'s Steam id is yanked from the normal-player whitelist.\n'
|
|
'#define CAMPUS_LAUNCHER_LAUNCH_KEY "' + key + '"\n')
|
|
print('new launcher-launch secret written to LauncherKey.h (rebuild the launcher to use it)')
|
|
|
|
|
|
def stage():
|
|
_, major, minor = read_version()
|
|
data = open(os.path.join(ROOT, 'bin', 'Campus.exe'), 'rb').read()
|
|
sha = hashlib.sha256(data).hexdigest()
|
|
info = {'version': f'{major}.{minor:02d}', 'build': major * 100 + minor, 'sha256': sha, 'size': len(data)}
|
|
put(APP_STAGE, data, '.exe', info)
|
|
# The launcher-launch secret for THIS build goes up alongside the exe. The server reads it as
|
|
# Data/Launcher/app/<sha>.launchkey; if an older key sits next to a newer build it would be
|
|
# tried first, so sweep every .launchkey that isn't ours.
|
|
for f in os.listdir(APP_STAGE):
|
|
if f.endswith('.launchkey') and f != sha + '.launchkey':
|
|
os.remove(os.path.join(APP_STAGE, f))
|
|
open(os.path.join(APP_STAGE, sha + '.launchkey'), 'w', encoding='utf-8', newline='').write(read_launcher_key())
|
|
print(f'staged launcher {info["version"]} ({sha[:16]}..., {len(data)} bytes) in {APP_STAGE} + .launchkey')
|
|
|
|
|
|
def read_key(key_h=PATCH_KEY_H):
|
|
m = re.search(r'#define CAMPUS_PATCH_PROOF_KEY "([^"]+)"', open(key_h, encoding='utf-8').read())
|
|
if not m:
|
|
sys.exit('[x] no key in ' + key_h + ' - run "release_app.py newkey"')
|
|
return m.group(1)
|
|
|
|
|
|
def newkey(which=''):
|
|
import secrets
|
|
if which not in ('', '2026'): sys.exit('[x] newkey takes no argument (the 2025 patch) or 2026')
|
|
key_h = PATCH26_KEY_H if which == '2026' else PATCH_KEY_H
|
|
key = secrets.token_urlsafe(32)
|
|
open(key_h, 'w', encoding='utf-8', newline='').write(
|
|
'#pragma once\n'
|
|
'// GENERATED by CampusPatch2025\\Launcher\\tools\\release_app.py newkey (ReleaseGame.bat makes a new one\n'
|
|
'// for every patch release). The server reads the same key from Data/Launcher/patch/<sha256>.key,\n'
|
|
'// uploaded next to this build. Never commit or paste it.\n'
|
|
'#define CAMPUS_PATCH_PROOF_KEY "' + key + '"\n')
|
|
print(f'new patch secret written to {key_h} (rebuild the patch to use it)')
|
|
|
|
|
|
def patch(channel=''):
|
|
# the 2025Patch project builds 2025Patch.dll; older builds were renamed CampusPatch.dll - take the newest
|
|
# The 2026 channel is the 2026Patch project's DLL (a different game client) with its own secret.
|
|
if channel not in ('', 'beta', 'dev', '2026'): sys.exit('[x] the channel is beta, dev or 2026 (or nothing for public)')
|
|
builds = PATCH_BUILDS_DEV if channel == 'dev' else PATCH_BUILDS_2026 if channel == '2026' else PATCH_BUILDS
|
|
built = [p for p in builds if os.path.exists(p)]
|
|
if not built:
|
|
sys.exit(r'[x] no built patch in CampusPatch2025\x64\Dev - build 2025Patch.sln (Dev x64) first' if channel == 'dev'
|
|
else r'[x] no built patch in CampusPatch2025\x64\Release - build 2026Patch.sln (Release x64) first' if channel == '2026'
|
|
else r'[x] no built patch in CampusPatch2025\x64\Release - build 2025Patch.sln (Release x64) first')
|
|
newest = max(built, key=os.path.getmtime)
|
|
data = open(newest, 'rb').read()
|
|
sha = hashlib.sha256(data).hexdigest()
|
|
stage_dir = os.path.join(PATCH_STAGE, channel) if channel else PATCH_STAGE # beta / dev: Deploy\launcher-patch\<channel>
|
|
put(stage_dir, data, '.dll', {'sha256': sha, 'size': len(data)})
|
|
# this build's secret goes up with it (the server checks the patch's proofs with it)
|
|
for f in os.listdir(stage_dir):
|
|
if f.endswith('.key') and f != sha + '.key':
|
|
os.remove(os.path.join(stage_dir, f))
|
|
open(os.path.join(stage_dir, sha + '.key'), 'w', encoding='utf-8', newline='').write(read_key(PATCH26_KEY_H if channel == '2026' else PATCH_KEY_H))
|
|
print(f'staged the {channel or "public"} Campus patch ({sha[:16]}..., {len(data)} bytes) in {stage_dir}')
|
|
|
|
|
|
def game(zip_path=GAME_ZIP):
|
|
import zipfile
|
|
if not os.path.exists(zip_path):
|
|
sys.exit('[x] no game zip at ' + zip_path)
|
|
with zipfile.ZipFile(zip_path) as z:
|
|
infos = z.infolist()
|
|
if not any(i.filename.lower().endswith('recroom_release.exe') for i in infos):
|
|
sys.exit('[x] ' + zip_path + " has no Recroom_Release.exe in it - that isn't the game")
|
|
unpacked = sum(i.file_size for i in infos)
|
|
h = hashlib.sha256()
|
|
with open(zip_path, 'rb') as f:
|
|
for block in iter(lambda: f.read(8 << 20), b''):
|
|
h.update(block)
|
|
sha = h.hexdigest()
|
|
size = os.path.getsize(zip_path)
|
|
os.makedirs(GAME_STAGE, exist_ok=True)
|
|
for f in os.listdir(GAME_STAGE): # only the newest goes up
|
|
if f.endswith('.zip') and f != sha + '.zip':
|
|
os.remove(os.path.join(GAME_STAGE, f))
|
|
dst = os.path.join(GAME_STAGE, sha + '.zip')
|
|
if not os.path.exists(dst):
|
|
try:
|
|
os.link(zip_path, dst) # same drive: a hard link, no copy
|
|
except OSError:
|
|
import shutil
|
|
shutil.copyfile(zip_path, dst)
|
|
open(os.path.join(GAME_STAGE, 'latest.json'), 'w', encoding='utf-8').write(
|
|
json.dumps({'sha256': sha, 'size': size, 'unpacked': unpacked}, indent=1))
|
|
print(f'staged the game ({sha[:16]}..., {size / 1e9:.2f} GB, {unpacked / 1e9:.2f} GB unpacked) in {GAME_STAGE}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
cmd = sys.argv[1]
|
|
if cmd == 'game':
|
|
game(sys.argv[2] if len(sys.argv) > 2 else GAME_ZIP)
|
|
else:
|
|
if cmd == 'patch':
|
|
patch(sys.argv[2] if len(sys.argv) > 2 else '')
|
|
else:
|
|
if cmd == 'newkey':
|
|
newkey(sys.argv[2] if len(sys.argv) > 2 else '')
|
|
else:
|
|
{'bump': bump, 'unbump': lambda: bump(-1), 'stage': stage, 'newlaunchkey': newlaunchkey}[cmd]()
|