The engine now runs on a plain Web Worker (no SDL, no PROXY_TO_PTHREAD: in a pthread Emscripten proxies every filesystem syscall to the main browser thread, where synchronous XHR - and thus FS.createLazyFile - is forbidden). A page/worker pair transfers an OffscreenCanvas and forwards DOM input; assets load lazily from an embedded manifest, so entering a map fetches only that map and its tiles. Presentation: ra3.webgpu (WebGPU via Emscripten's emdawnwebgpu port, WGSL shaders, the default on wasm) and ra3.wasmgl (WebGL2, GLSL ES). The legacy SDL ra3.webgl backend is removed.
133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Static file server with HTTP Range support, for the OpenRA3 wasm build.
|
|
|
|
Emscripten's lazy file reads stream assets back as byte ranges, so the server
|
|
must honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
|
|
|
|
python3 apps/web/serve.py --root build/wasm/bin --port 8199
|
|
# then open http://localhost:8199/index.html
|
|
|
|
Serve ``--root`` containing ``index.html``, ``openra3.js``, ``openra3.wasm``,
|
|
``openra3.worker.js`` and ``assets.manifest.json``. The lazy asset tree is
|
|
mounted from ``--assets`` (its files are then fetched from ``/assets/<rel>`` on
|
|
demand); without ``--assets`` the tree is expected under ``--root`` itself.
|
|
"""
|
|
|
|
import argparse
|
|
import functools
|
|
import os
|
|
import re
|
|
import sys
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
_RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
|
|
|
|
|
class RangeHandler(SimpleHTTPRequestHandler):
|
|
# Directory served for ``/assets/...`` (set in main); None means "under root".
|
|
assets_dir = None
|
|
|
|
def translate_path(self, path):
|
|
if self.assets_dir:
|
|
prefix = "/assets/"
|
|
if path == "/assets" or path.startswith(prefix):
|
|
rel = path[len(prefix):] if path.startswith(prefix) else ""
|
|
rel = rel.split("?", 1)[0].split("#", 1)[0]
|
|
parts = [p for p in rel.split("/") if p and p != ".."]
|
|
return os.path.join(self.assets_dir, *parts)
|
|
return super().translate_path(path)
|
|
|
|
# Cross-origin isolation (kept for SharedArrayBuffer-style features and the
|
|
# WebGPU/pthread paths). no-store keeps the dev loop honest: a stale
|
|
# openra3.js/.wasm in the browser cache hides new builds (the worker's
|
|
# importScripts especially).
|
|
def end_headers(self):
|
|
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
|
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
|
|
self.send_header("Cache-Control", "no-store, max-age=0")
|
|
super().end_headers()
|
|
|
|
def send_head(self):
|
|
header = self.headers.get("Range")
|
|
if not header:
|
|
return super().send_head()
|
|
|
|
path = self.translate_path(self.path)
|
|
if os.path.isdir(path):
|
|
return super().send_head()
|
|
try:
|
|
handle = open(path, "rb")
|
|
except OSError:
|
|
self.send_error(404, "File not found")
|
|
return None
|
|
|
|
match = _RANGE.match(header.strip())
|
|
if not match:
|
|
handle.close()
|
|
return super().send_head()
|
|
|
|
size = os.fstat(handle.fileno()).st_size
|
|
start = int(match.group(1)) if match.group(1) else 0
|
|
end = int(match.group(2)) if match.group(2) else size - 1
|
|
if start > end or start >= size:
|
|
handle.close()
|
|
self.send_error(416, "Requested Range Not Satisfiable")
|
|
return None
|
|
end = min(end, size - 1)
|
|
|
|
self.send_response(206)
|
|
self.send_header("Content-Type", self.guess_type(path))
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
self.send_header("Content-Length", str(end - start + 1))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
handle.seek(start)
|
|
self._remaining = end - start + 1
|
|
return handle
|
|
|
|
def copyfile(self, source, outputfile):
|
|
remaining = getattr(self, "_remaining", None)
|
|
if remaining is None:
|
|
return super().copyfile(source, outputfile)
|
|
try:
|
|
while remaining > 0:
|
|
chunk = source.read(min(65536, remaining))
|
|
if not chunk:
|
|
break
|
|
outputfile.write(chunk)
|
|
remaining -= len(chunk)
|
|
finally:
|
|
self._remaining = None
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server")
|
|
parser.add_argument("--root", default="build/wasm/bin", help="directory to serve")
|
|
parser.add_argument("--assets", default=None, help="directory served for /assets (the lazy asset tree)")
|
|
parser.add_argument("--port", type=int, default=8199)
|
|
parser.add_argument("--bind", default="127.0.0.1")
|
|
args = parser.parse_args()
|
|
|
|
if not os.path.isdir(args.root):
|
|
print(f"root not found: {args.root}", file=sys.stderr)
|
|
return 1
|
|
if args.assets and not os.path.isdir(args.assets):
|
|
print(f"assets not found: {args.assets}", file=sys.stderr)
|
|
return 1
|
|
RangeHandler.assets_dir = os.path.abspath(args.assets) if args.assets else None
|
|
handler = functools.partial(RangeHandler, directory=args.root)
|
|
server = ThreadingHTTPServer((args.bind, args.port), handler)
|
|
print(f"serving {args.root} at http://{args.bind}:{args.port}/index.html")
|
|
if RangeHandler.assets_dir:
|
|
print(f" /assets -> {RangeHandler.assets_dir}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|