Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions coworld/tools/build_wasm.nim
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions coworld/wasm.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions examples/gods_of_the_arena/bots.nim
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import
replays,
terrains

when defined(coworld):
when defined(coworldWasm):
import polyworld/coworld_wasm
elif defined(coworld):
import polyworld/coworld

type
Expand Down Expand Up @@ -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)
Expand All @@ -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) =
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion examples/gods_of_the_arena/config.nims
Original file line number Diff line number Diff line change
@@ -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")
20 changes: 16 additions & 4 deletions examples/gods_of_the_arena/game.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")))
Expand All @@ -117,7 +121,7 @@ var options* =

var run*: Game

block:
proc initializeGame() =
startGameProfile()
var
replayMode = options.replayPath.len > 0
Expand Down Expand Up @@ -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:
Expand All @@ -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() =
Expand Down
83 changes: 83 additions & 0 deletions examples/gods_of_the_arena/wasm_runner.nim
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions src/polyworld/controllers.nim
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import
std/strutils,
cli, configs

when defined(coworld):
when defined(coworldWasm):
import coworld_wasm
elif defined(coworld):
import coworld

type
Expand Down Expand Up @@ -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)
Expand All @@ -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")
70 changes: 70 additions & 0 deletions src/polyworld/coworld_wasm.nim
Original file line number Diff line number Diff line change
@@ -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")
Loading