Buckets:
| #!/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.