the files
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""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]()
|
||||
Reference in New Issue
Block a user