smodusermc's picture
download
raw
14 kB
#!/usr/bin/env python3
"""Play the archived games on your own computer.
Why this is needed: browsers refuse to let a page opened straight from disk (file://...) load its game data, so a game
started by double-clicking its index.html stops at its loading screen (for example "0% ... Loading..."). This script
serves the archive at http://127.0.0.1 the way the original site served it (same headers), and opens a list of games.
How to use:
1. Download the bucket's `site` folder (all of it, or only the games you want) and put this file next to it.
Optionally also download `_reports/inventory.json` into a `_reports` folder next to it, for proper game names.
2. Run: python3 serve.py (Windows: double-click serve.bat, or run py serve.py)
3. Your browser opens the game list. Stop the server with Ctrl+C.
Download one game and play it (no other tools needed; files are checked by SHA-256):
python3 serve.py --get "subway surfers" (a name, part of a name, or a catalog id such as 516)
Options: --port 8000 --no-browser --host 127.0.0.1 --get NAME_OR_ID
Needs only Python 3.8 or newer; no extra packages.
Some games load a public library from a CDN (for example the Ruffle Flash player). --get also downloads the stored copies
(_vendor/), and the server points the page at them, so those games work offline too."""
import argparse, hashlib, html, json, mimetypes, os, re, sys, time, urllib.error, urllib.request, webbrowser
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, unquote, quote
HERE = os.path.dirname(os.path.abspath(__file__))
SITE = os.path.join(HERE, 'site')
VENDOR = os.path.join(HERE, '_vendor') # stored copies of public CDN files (fetched by --get), used when present
VENDOR_ALIASES = {
"unpkg.com/@ruffle-rs/ruffle": "unpkg.com/@ruffle-rs/ruffle@0.6.0/ruffle.js",
"unpkg.com/@ruffle-rs/ruffle/core.ruffle.c80159b526e567babaf5.js": "unpkg.com/@ruffle-rs/ruffle@0.6.0/core.ruffle.c80159b526e567babaf5.js",
"unpkg.com/@ruffle-rs/ruffle/826bb0938097485a2c9d.wasm": "unpkg.com/@ruffle-rs/ruffle@0.6.0/826bb0938097485a2c9d.wasm"
}
URL_RE = re.compile(r'(?:https?:)?//([A-Za-z0-9.-]+\.[A-Za-z]{2,})(/[^"\'\s<>()]*)')
TYPES = {'.wasm': 'application/wasm', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json',
'.unityweb': 'application/octet-stream', '.data': 'application/octet-stream', '.mem': 'application/octet-stream',
'.pck': 'application/octet-stream', '.bundle': 'application/octet-stream', '.bank': 'application/octet-stream',
'.gz': 'application/gzip', '.br': 'application/octet-stream', '.swf': 'application/x-shockwave-flash',
'.webm': 'video/webm', '.mp4': 'video/mp4', '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
'.wav': 'audio/wav', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon', '.xml': 'application/xml',
'.txt': 'text/plain', '.zip': 'application/zip'}
for ext, typ in TYPES.items():
mimetypes.add_type(typ, ext)
BUCKET = 'https://huggingface.co/buckets/smodusermc/offline-games/resolve/'
CAPTURED = ('partial_static_capture', 'static_capture_unverified')
def fetch(path, dest=None, tries=7):
"""GET a bucket file (public). Returns bytes, or (temp_path, sha256) when dest is given. Retries on rate limits."""
url = BUCKET + quote(path)
for k in range(tries):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'offline-games-serve/1.0'})
with urllib.request.urlopen(req, timeout=90) as r:
if dest is None: return r.read()
tmp = dest + '.part'; h = hashlib.sha256()
with open(tmp, 'wb') as fh:
while True:
chunk = r.read(1 << 20)
if not chunk: break
fh.write(chunk); h.update(chunk)
return tmp, h.hexdigest()
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503, 504) and k < tries - 1: time.sleep(min(60, 2 ** k)); continue
raise
except (urllib.error.URLError, TimeoutError, ConnectionError):
if k < tries - 1: time.sleep(min(60, 2 ** k)); continue
raise
def sha256_of(path):
h = hashlib.sha256()
with open(path, 'rb') as fh:
for chunk in iter(lambda: fh.read(1 << 20), b''): h.update(chunk)
return h.hexdigest()
def get_game(query):
"""Download one catalog entry's files (from its manifest) next to this script; return its page path."""
inv_path = os.path.join(HERE, '_reports', 'inventory.json')
if not os.path.exists(inv_path):
os.makedirs(os.path.dirname(inv_path), exist_ok=True)
with open(inv_path, 'wb') as fh: fh.write(fetch('_reports/inventory.json'))
with open(inv_path, encoding='utf-8') as fh: inv = [r for r in json.load(fh) if r.get('status') in CAPTURED]
q = query.strip().lower()
hits = [r for r in inv if str(r['id']) == q] or [r for r in inv if r['name'].lower() == q] or [r for r in inv if q in r['name'].lower()]
if len(hits) != 1:
print('No game matches that.' if not hits else 'Several games match; use the id or a longer name:')
for r in hits[:30]: print(f" {r['id']:>4} {r['name']}")
sys.exit(1)
game = hits[0]; ident = game['id']
man = json.loads(fetch(f'_reports/games/{ident:04d}.json'))
files = [f for f in man['files'] if f['path'].startswith(('site/', '_vendor/'))]
total = sum(f['bytes'] for f in files); done = 0
print(f"{game['name']} (id {ident}): {len(files)} files, {total / 1e6:,.1f} MB")
for n, f in enumerate(files, 1):
dest = os.path.join(HERE, *f['path'].split('/'))
if os.path.isfile(dest) and os.path.getsize(dest) == f['bytes'] and (not f.get('sha256') or sha256_of(dest) == f['sha256']):
done += f['bytes']; continue
os.makedirs(os.path.dirname(dest), exist_ok=True)
tmp, digest = fetch(f['path'], dest)
if os.path.getsize(tmp) != f['bytes'] or (f.get('sha256') and digest != f['sha256']):
os.remove(tmp); sys.exit(f"Checksum mismatch for {f['path']}; try again.")
os.replace(tmp, dest); done += f['bytes']
print(f" [{n}/{len(files)}] {done / 1e6:,.1f} / {total / 1e6:,.1f} MB {f['path']}")
print('All files present and checked (SHA-256).')
return urlparse(game['entry']).path
def vendor_local(host, path):
rel = host + '/' + unquote(path.split('#')[0].split('?')[0]).lstrip('/')
for cand in (rel, VENDOR_ALIASES.get(rel.rstrip('/'))):
if cand and os.path.isfile(os.path.join(VENDOR, *cand.split('/'))): return '/__vendor/' + quote(cand)
return None
def rewrite_html(raw):
"""Point CDN URLs in a page at the local _vendor copies, only where such a copy exists (so offline play works)."""
if not os.path.isdir(VENDOR): return raw
text = raw.decode('utf-8', 'surrogateescape')
return URL_RE.sub(lambda m: vendor_local(m[1], m[2]) or m[0], text).encode('utf-8', 'surrogateescape')
def game_list():
games = []
inv = os.path.join(HERE, '_reports', 'inventory.json')
if os.path.exists(inv):
with open(inv, encoding='utf-8') as fh:
for r in json.load(fh):
if r.get('status') in ('partial_static_capture', 'static_capture_unverified'):
path = urlparse(r['entry']).path
if os.path.isfile(os.path.join(SITE, unquote(path).lstrip('/'))):
games.append((r['name'], path))
if not games: # no inventory: list what is on disk
for sub in ('games', 'gamefile'):
base = os.path.join(SITE, sub)
if not os.path.isdir(base): continue
for name in sorted(os.listdir(base)):
full = os.path.join(base, name)
if os.path.isdir(full) and os.path.isfile(os.path.join(full, 'index.html')):
games.append((name, f'/{sub}/{quote(name)}/index.html'))
elif name.endswith('.html'):
games.append((name[:-5], f'/{sub}/{quote(name)}'))
return sorted(games, key=lambda g: g[0].lower())
def list_page():
items = '\n'.join(f'<li><a href="{html.escape(p)}">{html.escape(n)}</a></li>' for n, p in game_list())
return f"""<!doctype html><meta charset="utf-8"><title>Offline games</title>
<style>body{{font:16px system-ui,sans-serif;margin:2em;max-width:60em}}input{{font-size:1.1em;padding:.4em;width:100%;box-sizing:border-box}}
ul{{columns:3 16em;padding-left:1.2em}}li{{margin:.25em 0}}</style>
<h1>Offline games</h1><p>Served from <code>{html.escape(SITE)}</code>. Click a game; use the browser's Back button to return.</p>
<input id="q" placeholder="Search" autofocus><ul id="l">{items}</ul>
<script>q.oninput=()=>{{const t=q.value.toLowerCase();for(const li of l.children)li.style.display=li.textContent.toLowerCase().includes(t)?'':'none'}}</script>"""
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=SITE, **kw)
def end_headers(self):
# The same isolation headers as the original site; some builds need them (SharedArrayBuffer).
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'credentialless')
self.send_header('Cross-Origin-Resource-Policy', 'cross-origin')
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Cache-Control', 'no-cache')
super().end_headers()
def do_GET(self):
path = urlparse(self.path).path
if path in ('/__games', '/__games/') or (path == '/' and not os.path.isfile(os.path.join(SITE, 'index.html'))):
body = list_page().encode('utf-8')
self.send_response(200); self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body); return
if path.startswith('/__vendor/'):
fs = os.path.join(VENDOR, *unquote(path[len('/__vendor/'):]).split('/'))
if not os.path.isfile(fs): self.send_error(404); return
with open(fs, 'rb') as fh: body = fh.read()
self.send_response(200); self.send_header('Content-Type', self.guess_type(fs))
self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body); return
fs = self.translate_path(self.path)
if os.path.isdir(fs) and path.endswith('/') and os.path.isfile(os.path.join(fs, 'index.html')): fs = os.path.join(fs, 'index.html')
if fs.lower().endswith(('.html', '.htm')) and os.path.isfile(fs) and os.path.isdir(VENDOR):
with open(fs, 'rb') as fh: body = rewrite_html(fh.read())
self.send_response(200); self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body); return
rng = self.headers.get('Range')
m = re.match(r'bytes=(\d*)-(\d*)$', rng or '')
if m and os.path.isfile(fs):
size = os.path.getsize(fs)
start = int(m[1]) if m[1] else max(0, size - int(m[2] or 0))
end = min(int(m[2]), size - 1) if (m[1] and m[2]) else size - 1
if start >= size:
self.send_response(416); self.send_header('Content-Range', f'bytes */{size}'); self.end_headers(); return
self.send_response(206)
self.send_header('Content-Type', self.guess_type(fs)); self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
self.send_header('Content-Length', str(end - start + 1)); self.end_headers()
with open(fs, 'rb') as fh:
fh.seek(start); left = end - start + 1
while left > 0:
chunk = fh.read(min(1 << 20, left))
if not chunk: break
self.wfile.write(chunk); left -= len(chunk)
return
super().do_GET()
seen404 = set()
def log_message(self, fmt, *args):
if len(args) > 1 and str(args[1]) == '404':
req = str(args[0])
if req not in Handler.seen404:
Handler.seen404.add(req)
sys.stderr.write(f'not found: {req} (not downloaded, or missing on the original site too; many games ask for optional files)\n')
def main():
try: sys.stdout.reconfigure(line_buffering=True)
except Exception: pass
ap = argparse.ArgumentParser(description='Serve the offline games archive locally.')
ap.add_argument('--port', type=int, default=8000); ap.add_argument('--host', default='127.0.0.1')
ap.add_argument('--no-browser', action='store_true')
ap.add_argument('--get', metavar='NAME_OR_ID', help='download one game (checked by SHA-256), then serve and open it')
a = ap.parse_args()
start = get_game(a.get) if a.get else ''
if not os.path.isdir(SITE):
sys.exit(f'No "site" folder next to this script ({HERE}). Download the bucket\'s site/ folder first.')
srv = None
for port in range(a.port, a.port + 20):
try: srv = ThreadingHTTPServer((a.host, port), Handler); break
except OSError: continue
if not srv: sys.exit('No free port found.')
url = f'http://{a.host}:{srv.server_port}/'
print(f'Serving {SITE}\nGame list: {url}' + (f'\nThis game: {url.rstrip("/")}{start}' if start else '') + '\nPress Ctrl+C to stop.')
if start: url = url.rstrip('/') + start
if not a.no_browser:
try: webbrowser.open(url)
except Exception: pass
try: srv.serve_forever()
except KeyboardInterrupt: print('\nStopped.')
if __name__ == '__main__':
main()

Xet Storage Details

Size:
14 kB
·
Xet hash:
0ba27f66eb8dc1b983e70e2c7d4c0eba26f4d9d17029f9f05d755bf1340861b1

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.