diff --git a/coworld/tools/build_wasm.nim b/coworld/tools/build_wasm.nim new file mode 100644 index 00000000..d8480502 --- /dev/null +++ b/coworld/tools/build_wasm.nim @@ -0,0 +1,18 @@ +import std/[os, strutils], tooling, sync_dependencies + +proc main() = + require(NimVersion == "2.2.10", "The Wasm runner requires Nim 2.2.10") + require(command(["emcc", "--version"]).splitLines()[0].contains(" 6.0.9 ("), + "The Wasm runner requires Emscripten 6.0.9") + let dependencies = getEnv("POLYWORLD_DEPS", Root / "tmp/coworld/deps") + putEnv("POLYWORLD_DEPS", dependencies) + syncDependencies() + let output = Root / "tmp/coworld/wasm" + createDir(output) + run([getCurrentCompilerExe(), "c", "-d:coworldWasm", + "-o:" & output / "gota.mjs", + Root / "examples/gods_of_the_arena/wasm_runner.nim"]) + echo "Wasm runner: ", output + +when isMainModule: + runTool(main) diff --git a/coworld/wasm.md b/coworld/wasm.md new file mode 100644 index 00000000..1c7943f5 --- /dev/null +++ b/coworld/wasm.md @@ -0,0 +1,41 @@ +# Headless Wasm runner + +The experimental Gods of the Arena runner executes the same game and BASIC VM as the native Coworld build. +The host supplies config and ordered policy source bytes. The module has no filesystem, HTTP server, browser assets, +or network grants. Each attempt owns a fresh module instance; discard it after completion or failure. + +Build with Nim 2.2.10 and Emscripten 6.0.9. Dependencies come from `coworld/dependencies.lock`: + +```sh +nim c -o:tmp/build_wasm coworld/tools/build_wasm.nim +# Activate Emscripten for the build tool, for example with mise: +mise exec emsdk@6.0.9 -- ./tmp/build_wasm +``` + +The output is `tmp/coworld/wasm/gota.mjs` and `gota.wasm`. Import the Wasm as a compiled `WebAssembly.Module` and pass +an `instantiateWasm` callback to the JavaScript factory. Cloudflare does not permit runtime compilation from fetched bytes. +Only the immutable compiled module may be shared across attempts. + +The build uses ARC and Emscripten's default allocator directly. Nim allocation tracing is disabled because its counters +require Nim's allocator. This affects runtime instrumentation, not game behavior. Linear memory grows from 16 MiB to a +96 MiB cap. That cap leaves room for the host but does not guarantee a complete Worker fits its isolate limit. + +## ABI version 1 + +All byte buffers are UTF-8 unless described as binary. Call the factory once to initialize the Nim runtime. + +| Export | Contract | +| --- | --- | +| `pw_alloc(length)` / `pw_free(pointer)` | Allocate/free an input buffer, up to 4 MiB. A zero pointer means allocation failed. | +| `pw_initialize(pointer, length)` | Initialize exactly one attempt from `{"config":"JSON config","policies":["source", ...]}`. Returns 0 or -1. | +| `pw_advance(ticks)` | Advance 1–256 existing simulation ticks. Returns 0 while running, 1 when finished, or -1 on failure. | +| `pw_finalize()` | Encode results, replay, and diagnostics after completion. Returns 0 or -1. | +| `pw_output(kind)` / `pw_output_length(kind)` | Borrow a buffer: 0 results JSON, 1 binary replay, 2 private diagnostics JSON, 3 error text. | + +A borrowed view becomes invalid when memory grows or the instance is discarded. Read artifacts after finalization. +The host must validate schemas, policy hashes and sizes before initialization, enforce its deadline between tick batches, +yield to its event loop, validate outputs, and publish private logs/replay before the results completion marker. +The game retains existing compilation limits, instruction budgets, map generation, replay encoding, and scoring. + +A Wasm trap or exhausted memory can bypass the return-code contract. Treat it as an attempt failure and discard the instance. +Do not reset an instance or silently rerun a failed attempt. Caller retries need a new attempt ID. diff --git a/examples/gods_of_the_arena/bots.nim b/examples/gods_of_the_arena/bots.nim index 286851ba..dd4b2e46 100644 --- a/examples/gods_of_the_arena/bots.nim +++ b/examples/gods_of_the_arena/bots.nim @@ -13,7 +13,9 @@ import replays, terrains -when defined(coworld): +when defined(coworldWasm): + import polyworld/coworld_wasm +elif defined(coworld): import polyworld/coworld type @@ -864,7 +866,7 @@ proc loadBots*( continue let source = sources[i] let program = - when defined(coworld): + when defined(coworld) or defined(coworldWasm): compilePlayer(source, schema, limits, int(i)) else: compile(source, schema, limits) @@ -880,7 +882,7 @@ proc loadBots*( limits: limits, ready: true ) - when defined(coworld): + when defined(coworld) or defined(coworldWasm): game.heroVms[i].output = playerPrinter(int(i)) proc runHeroScript(game: Game, index: int) = @@ -952,7 +954,7 @@ proc runHeroScript(game: Game, index: int) = except BasicError as error: vm.failed = true vm.lastError = error.msg - when defined(coworld): + when defined(coworld) or defined(coworldWasm): playerError(index, error.msg) else: echo "hero ", hero.id, " BASIC error: ", error.msg diff --git a/examples/gods_of_the_arena/config.nims b/examples/gods_of_the_arena/config.nims index 4ac9f297..9f557ea5 100644 --- a/examples/gods_of_the_arena/config.nims +++ b/examples/gods_of_the_arena/config.nims @@ -1,3 +1,22 @@ import "../../src/polyworld/emscripten.nims" -setupEmscripten(thisDir(), "gota") +when not defined(coworldWasm): + setupEmscripten(thisDir(), "gota") + +when defined(coworldWasm): + when defined(coworld): + error("Choose either the native Coworld runner or the Wasm runner.") + switch("define", "emscripten") + switch("define", "headless") + switch("define", "useMalloc") + switch("undef", "nimTypeNames") + switch("threads", "off") + switch("os", "linux") + switch("cpu", "wasm32") + switch("cc", "clang") + switch("clang.exe", "emcc") + switch("clang.linkerexe", "emcc") + switch("gc", "arc") + switch("exceptions", "goto") + switch("define", "noSignalHandler") + switch("passL", "-O3 -s MODULARIZE=1 -s EXPORT_ES6=1 -s ENVIRONMENT=worker -s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=16777216 -s MAXIMUM_MEMORY=100663296 -s EXPORTED_FUNCTIONS=_main,_pw_alloc,_pw_free,_pw_initialize,_pw_advance,_pw_finalize,_pw_output,_pw_output_length -s EXPORTED_RUNTIME_METHODS=HEAPU8 -s FILESYSTEM=0") diff --git a/examples/gods_of_the_arena/game.nim b/examples/gods_of_the_arena/game.nim index cdebf0be..bc2c898e 100644 --- a/examples/gods_of_the_arena/game.nim +++ b/examples/gods_of_the_arena/game.nim @@ -14,7 +14,9 @@ import controls, replays -when defined(coworld): +when defined(coworldWasm): + import polyworld/coworld_wasm as coworld +elif defined(coworld): import polyworld/coworld var matchConfig = GotaConfig(seed: ArenaSeed) @@ -107,7 +109,9 @@ proc parseGameOptions(): GameOptions = ) var options* = - when defined(coworld): + when defined(coworldWasm): + GameOptions() + elif defined(coworld): block: let hosted = coworldOptions(10) matchConfig = parseConfig(readLocal(getEnv("COGAME_CONFIG_URI"))) @@ -117,7 +121,7 @@ var options* = var run*: Game -block: +proc initializeGame() = startGameProfile() var replayMode = options.replayPath.len > 0 @@ -154,7 +158,7 @@ block: currentSetup(run, uint32(options.maximumTicks)), gameMap.preset ) run.recorder.data.config = - when defined(coworld): + when defined(coworld) or defined(coworldWasm): coworld.config.withMapPreset(gameMap.preset) else: block: @@ -165,6 +169,14 @@ block: run.recorder.data.config.validateConfig(HeroClassCount) run.replayPlayer = ReplayPlayer(data: run.recorder.data) +when defined(coworldWasm): + proc initializeHosted*(configBytes: string, sources: seq[string]) = + matchConfig = parseConfig(configBytes) + options = coworld.initialize(configBytes, sources, HeroClassCount) + initializeGame() +else: + initializeGame() + proc advanceGame*() = ## Advances one tick, including live BASIC decisions. tickWorld(run, proc() = diff --git a/examples/gods_of_the_arena/wasm_runner.nim b/examples/gods_of_the_arena/wasm_runner.nim new file mode 100644 index 00000000..1b59d646 --- /dev/null +++ b/examples/gods_of_the_arena/wasm_runner.nim @@ -0,0 +1,83 @@ +import jsony, game, sim, scores, replays +import polyworld/[coworld_wasm, metrics] + +type Inputs = object + config: string + policies: seq[string] + +var + initialized, finalized: bool + outputs: array[4, string] + +proc pw_alloc(length: cint): pointer {.exportc, cdecl.} = + if length < 0 or length > 4 * 1024 * 1024: + return nil + alloc(length) + +proc pw_free(buffer: pointer) {.exportc, cdecl.} = + dealloc(buffer) + +proc pw_initialize(buffer: pointer, length: cint): cint {.exportc, cdecl.} = + try: + if initialized: + raise newException(ValueError, "An instance can initialize only one attempt") + initialized = true + if buffer == nil or length <= 0 or length > 4 * 1024 * 1024: + raise newException(ValueError, "Invalid input buffer") + var bytes = newString(length) + copyMem(bytes[0].addr, buffer, length) + let inputs = bytes.fromJson(Inputs) + initializeHosted(inputs.config, inputs.policies) + startReplayRecording(uint32(options.maximumTicks)) + return 0 + except CatchableError as error: + outputs[3] = error.msg + return -1 + +proc pw_advance(ticks: cint): cint {.exportc, cdecl.} = + try: + if not initialized or finalized or outputs[3].len > 0 or ticks < 1 or ticks > 256: + raise newException(ValueError, "Invalid advance") + for _ in 0 ..< ticks: + if run.finished(): + return 1 + advanceGame() + if run.recordingError.len > 0: + raise newException(ValueError, run.recordingError) + return (if run.finished(): 1 else: 0) + except CatchableError as error: + outputs[3] = error.msg + return -1 + +proc pw_finalize(): cint {.exportc, cdecl.} = + try: + if not initialized or finalized or outputs[3].len > 0 or not run.finished(): + raise newException(ValueError, "Invalid finalize") + finalized = true + run.sampleMetrics(true) + run.recorder.data.metrics = run.history.replayMetrics() + outputs[1] = encodeReplay(run.recorder.data) + let xp = run.world.totalXp() + outputs[0] = "{\"scores\":" & scores(xp, int(run.world.tick)).toJson() & + ",\"ticks\":" & $run.world.tick & ",\"seed\":" & $options.seed & + ",\"outcome\":" & run.world.outcome().toJson() & + ",\"banked_gold\":[],\"returned\":[],\"total_xp\":" & xp.toJson() & "}" + finishLogs() + return 0 + except CatchableError as error: + outputs[3] = error.msg + return -1 + +proc pw_output(kind: cint): pointer {.exportc, cdecl.} = + if kind == 2: + outputs[2] = logs.toJson() + if kind >= 0 and kind < outputs.len and outputs[kind].len > 0: + outputs[kind][0].addr + else: + nil + +proc pw_output_length(kind: cint): cint {.exportc, cdecl.} = + if kind >= 0 and kind < outputs.len: + cint(outputs[kind].len) + else: + 0 diff --git a/src/polyworld/controllers.nim b/src/polyworld/controllers.nim index 9e75316b..467ebe9f 100644 --- a/src/polyworld/controllers.nim +++ b/src/polyworld/controllers.nim @@ -7,7 +7,9 @@ import std/strutils, cli, configs -when defined(coworld): +when defined(coworldWasm): + import coworld_wasm +elif defined(coworld): import coworld type @@ -61,7 +63,7 @@ proc expandBotSources*( var next = 0 for group in groups: let source = - when defined(coworld): + when defined(coworld) or defined(coworldWasm): readPlayerSource(group.path) else: readFile(group.path) @@ -72,7 +74,7 @@ proc expandBotSources*( fail("too many bots to expand") result[next] = source inc next - when not defined(coworld): + when not (defined(coworld) or defined(coworldWasm)): for i, kind in kinds: if kind == BotController and result[i].len == 0: fail("bot files do not fill every slot") diff --git a/src/polyworld/coworld_wasm.nim b/src/polyworld/coworld_wasm.nim new file mode 100644 index 00000000..6f85416e --- /dev/null +++ b/src/polyworld/coworld_wasm.nim @@ -0,0 +1,70 @@ +import std/strutils, jsony, bassy, cli + +type + PlayerDiagnostics* = object + text*: string + failed*: bool + +var + config*: GameConfig + sources: seq[string] + logs*: seq[PlayerDiagnostics] + +const + PlayerLogLimit = 10 * 1024 * 1024 + Truncation = "\n[Player log truncated at 10 MiB.]\n" + +proc playerLog(slot: int, text: string) = + if logs[slot].text.len >= PlayerLogLimit: + return + let remaining = PlayerLogLimit - Truncation.len - logs[slot].text.len + if text.len <= remaining: + logs[slot].text.add text + else: + if remaining > 0: + logs[slot].text.add text[0 ..< remaining] + logs[slot].text.add Truncation + +proc playerError*(slot: int, message: string) = + logs[slot].failed = true + playerLog(slot, "\nBASIC error: " & message & "\n") + +proc playerPrinter*(slot: int): PrintProc = + result = proc(event: PrintEvent) = + case event.kind + of TextPrint: playerLog(slot, event.text) + of ValuePrint: playerLog(slot, $event.value) + of FixedPrint: playerLog(slot, $event.fixedValue) + of NewlinePrint: playerLog(slot, "\n") + +proc compilePlayer*(source: string, host: Host, limits: Limits, slot: int): Program = + try: + result = compile(source, host, limits) + except BasicError as error: + playerError(slot, error.msg) + raise newException(ValueError, "BASIC compilation failed for player slot " & $slot) + +proc readPlayerSource*(slot: string): string = + sources[parseInt(slot)] + +proc initialize*(bytes: string, policies: seq[string], slotCount: int): GameOptions = + config = bytes.fromJson(GameConfig) + if policies.len != slotCount or config.players.len != slotCount: + raise newException(ValueError, "Coworld roster does not match the game") + if config.maxTicks <= 0 or config.maxTicks > DefaultDurationTicks or + config.spawnIntervalTicks <= 0: + raise newException(ValueError, "Coworld tick limits are invalid") + sources = policies + logs.setLen(slotCount) + result = GameOptions(seed: config.seed, maximumTicks: config.maxTicks, + seconds: config.maxTicks div SharedTickRate, + spawnIntervalTicks: config.spawnIntervalTicks) + for slot, source in sources: + if source.len > 256 * 1024: + raise newException(ValueError, "Player source exceeds 256 KiB") + result.botGroups.add BotGroup(path: $slot, count: 1) + playerLog(slot, "Player slot " & $slot & " started.\n") + +proc finishLogs*() = + for slot in 0 ..< logs.len: + playerLog(slot, "\nPlayer slot " & $slot & " completed.\n")