"""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\\.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\\.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\\.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/.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/.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/.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\ 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]()