the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,34 @@
@echo off
setlocal
title Campus launcher - debug build
REM Builds the DEBUG launcher for testing: bin\Debug\Campus-Debug.exe
REM - its own settings / session / log folder (%LOCALAPPDATA%\CampusLauncher-Debug), so it never touches
REM the real launcher's sign-in or settings
REM - never self-updates (it says what the server has and carries on)
REM - the window title, version line and log say DEBUG; the log also goes to Visual Studio's Output window
REM - can run next to the real launcher
REM - does NOT change the version, the launcher key or anything in Deploy\ - nothing here is shipped.
REM The release is still built by build_release.bat (Release configuration) and sent out with PushUpdates.bat.
REM Extra arguments are passed to the launcher: build_debug.bat run --server http://127.0.0.1:8080
REM (also: --data <folder>, --page play|customize|settings, --nofind)
set "ROOT=%~dp0.."
set "MSB=D:\VisualStudio\18\Enterprise\MSBuild\Current\Bin\amd64\MSBuild.exe"
"%MSB%" "%ROOT%\Launcher.vcxproj" /t:Build /p:Configuration=Debug /p:Platform=x64 /m /nologo /v:minimal
if errorlevel 1 (
echo.
echo === DEBUG BUILD FAILED ===
endlocal
exit /b 1
)
echo.
echo === DONE: %ROOT%\bin\Debug\Campus-Debug.exe ===
if /i "%~1"=="run" (
shift
start "" "%ROOT%\bin\Debug\Campus-Debug.exe" %1 %2 %3 %4 %5 %6 %7 %8 %9
)
endlocal
exit /b 0
@@ -0,0 +1,67 @@
@echo off
setlocal EnableDelayedExpansion
title Campus launcher - release build
REM Makes the next release of bin\Campus.exe:
REM 1. raises the version by one (src\Version.h: 1.01 -> 1.02); put back if anything fails
REM 2. builds the exe (static C++ runtime - that one file is all anyone needs)
REM 3. builds the Campus patch (CampusPatch2025\2025Patch.sln)
REM 4. stages the exe in Deploy\launcher-app and the patch in Deploy\launcher-patch
REM UploadUpdate.bat puts both on the server: everyone's launcher updates itself to the new exe, and
REM every Play downloads the newest patch. The launcher may stay open: its exe is moved aside to
REM bin\Campus.old.exe (the new one deletes it when it starts).
REM Only the patch changed? "python tools\release_app.py patch" then UploadUpdate.bat is enough.
set "ROOT=%~dp0.."
set "MSB=D:\VisualStudio\18\Enterprise\MSBuild\Current\Bin\amd64\MSBuild.exe"
set "BUMPED="
where python >nul 2>&1 || (echo [x] python is not on PATH & goto :fail)
REM Make a free slot for the previous build. If something is still holding Campus.old.exe (a
REM previous auto-update's old process hasn't let go yet), slide it to Campus.old2.exe /
REM Campus.old3.exe / ... .old9.exe - RemoveOldExe() on the next launcher start sweeps all of
REM them. Only bail out if there is nowhere left to park the stale copy.
if exist "%ROOT%\bin\Campus.old.exe" (
del /q "%ROOT%\bin\Campus.old.exe" 2>nul
if exist "%ROOT%\bin\Campus.old.exe" (
set "PARKED="
for %%N in (2 3 4 5 6 7 8 9) do (
if not defined PARKED (
if not exist "%ROOT%\bin\Campus.old%%N.exe" (
ren "%ROOT%\bin\Campus.old.exe" "Campus.old%%N.exe" 2>nul
if not exist "%ROOT%\bin\Campus.old.exe" set "PARKED=1"
)
)
)
if not defined PARKED (
echo [x] bin\Campus.old.exe is locked and every parking slot is taken. Close every running Campus launcher first.
goto :fail
)
)
)
if exist "%ROOT%\bin\Campus.exe" (
move /y "%ROOT%\bin\Campus.exe" "%ROOT%\bin\Campus.old.exe" >nul || (echo [x] Can't move bin\Campus.exe aside - close every running Campus launcher first. & goto :fail)
)
python "%ROOT%\tools\release_app.py" bump || goto :fail
set "BUMPED=1"
REM Rotate the launcher-launch secret - every release gets its own key, so an exfiltrated one is
REM short-lived. The server reads the same key from Data/Launcher/app/<sha>.launchkey, which
REM "release_app.py stage" uploads next to the exe below.
python "%ROOT%\tools\release_app.py" newlaunchkey || goto :fail
"%MSB%" "%ROOT%\Launcher.vcxproj" /t:Rebuild /p:Configuration=Release /p:Platform=x64 /m /nologo /v:minimal || goto :fail
"%MSB%" "%ROOT%\..\2025Patch.sln" /t:Build /p:Configuration=Release /p:Platform=x64 /m /nologo /v:minimal || goto :fail
python "%ROOT%\tools\release_app.py" stage || goto :fail
python "%ROOT%\tools\release_app.py" patch || goto :fail
echo.
echo === DONE: %ROOT%\bin\Campus.exe - run UploadUpdate.bat to release it. ===
endlocal
exit /b 0
:fail
if defined BUMPED python "%ROOT%\tools\release_app.py" unbump
if not exist "%ROOT%\bin\Campus.exe" if exist "%ROOT%\bin\Campus.old.exe" move /y "%ROOT%\bin\Campus.old.exe" "%ROOT%\bin\Campus.exe" >nul
echo.
echo === BUILD FAILED ===
endlocal
exit /b 1
@@ -0,0 +1,84 @@
"""Builds HeroMask.png: where Hero.jpg is backdrop or logo letters (white) - the parts the launcher
repaints in the player's gradient (App::PaintHero). Run after changing Hero.jpg:
python tools/make_hero_mask.py
The characters' skin and red hair are as saturated as the purple->orange backdrop, so colour alone
can't separate them. Instead:
1. fit a smooth model of the backdrop (least squares, cubic in x/y) to clearly-backdrop pixels,
2. a pixel is backdrop only if it matches that model in chroma AND hue, at any brightness (the
backdrop in shadow round the logo is still backdrop; red hair is a few degrees off in hue,
skin is off in chroma),
3. keep only the big connected backdrop region(s) - an isolated patch inside a character (a hat
band, a highlight) is never backdrop - and fill small specks in the backdrop,
4. add the logo's letters: the saturated pixels its white outline encloses.
"""
import os
import numpy as np
from PIL import Image, ImageFilter
from scipy import ndimage
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
im = np.asarray(Image.open(os.path.join(ROOT, 'Hero.jpg')).convert('RGB')).astype(np.float32) / 255
H, W, _ = im.shape
ys, xs = np.mgrid[0:H, 0:W]
X, Y = xs / W, ys / H
def feats(x, y):
return np.stack([np.ones_like(x), x, y, x * x, x * y, y * y, x ** 3, y ** 3, x * x * y, x * y * y], -1)
def hue(rgb):
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
mx, mn = rgb.max(-1), rgb.min(-1)
d = np.maximum(mx - mn, 1e-6)
h = np.where(mx == r, ((g - b) / d) % 6, np.where(mx == g, (b - r) / d + 2, (r - g) / d + 4)) * 60
return h
F = feats(X, Y).reshape(-1, 10)
P = im.reshape(-1, 3)
mx, mn = P.max(1), P.min(1)
s = np.where(mx > 0, (mx - mn) / np.maximum(mx, 1e-6), 0)
sel = (s > 0.55) & (mx > 0.5) & ((Y < 0.33).reshape(-1) | ((X > 0.33) & (X < 0.70) & (Y > 0.66)).reshape(-1))
for _ in range(5):
coef, *_ = np.linalg.lstsq(F[sel], P[sel], rcond=None)
err = np.abs(F @ coef - P).max(1)
sel = (err < 0.07) & (s > 0.45)
model = np.clip((F @ coef).reshape(H, W, 3), 0, 1)
# compare colour apart from brightness: the backdrop in shadow (round the logo's outline, behind
# the characters) is still backdrop. Hue catches the red hair, chroma catches skin.
vim, vmod = im.max(2), np.maximum(model.max(2), 1e-6)
chroma_err = np.abs(im / np.maximum(vim, 1e-6)[..., None] - model / vmod[..., None]).max(2)
ratio = vim / vmod
dh = np.abs(hue(model) - hue(im))
dh = np.minimum(dh, 360 - dh)
bg = (1 - np.clip((chroma_err - 0.07) / 0.08, 0, 1)) * (1 - np.clip((dh - 4) / 5, 0, 1)) * np.clip((ratio - 0.30) / 0.15, 0, 1) * (1 - np.clip((ratio - 1.12) / 0.1, 0, 1))
# connected regions: keep the big backdrop, fill specks
solid = bg > 0.5
lab, n = ndimage.label(solid)
sizes = ndimage.sum(solid, lab, range(1, n + 1))
keep = np.isin(lab, 1 + np.nonzero(sizes > 0.02 * H * W)[0])
holes, hn = ndimage.label(~keep)
hsizes = ndimage.sum(~keep, holes, range(1, hn + 1))
keep |= np.isin(holes, 1 + np.nonzero(hsizes < 400)[0])
bg = np.where(keep, np.maximum(bg, 0.0), 0.0)
bg = np.where(keep, np.clip(bg * 1.5, 0, 1), 0.0)
# the logo's letters: what its white outline encloses (filling the outline's holes), so nothing that
# merely touches the logo from outside - the girl's red hair does, right of the "S" - counts
smap, vmap = s.reshape(H, W), mx.reshape(H, W)
box = (X > 0.24) & (X < 0.78) & (Y > 0.33) & (Y < 0.67)
white = box & (smap < 0.15) & (vmap > 0.82)
white = ndimage.binary_closing(white, iterations=2)
inside = ndimage.binary_fill_holes(white) & ~white
logo = np.where(inside, np.clip((smap - 0.35) / 0.25, 0, 1), 0)
m = np.maximum(bg, logo)
mask = Image.fromarray((m * 255).astype(np.uint8), 'L')
mask = mask.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(1.0)) # cover the backdrop's fringe
mask.save(os.path.join(ROOT, 'HeroMask.png'), optimize=True)
print('HeroMask.png', mask.size)
+193
View File
@@ -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]()