ludic.devlink: an editor's live link into a running dev build (protocol v1, stage 1)

A package on ludic.base alone. DevlinkNet is the transport (a game binds Udp, the test a fake);
DevlinkWorld's members are the verbs, each defaulting to "not offered" (err unbound): ping, hello (with
Build.schema_hash as 16 hex digits), cam_get / cam_set / cam_release, goto, map_load, time, weather, shot,
pause / resume / step. One request a frame, parsed in place from a fixed 8 KB buffer and answered into
another; map_load, shot and step answer later by id, one at a time, 5 s at most. Loopback senders only,
opened only when enabled() (dev_tools), co-op refuses everything but ping and hello. tests/devlink_test:
each answer byte for byte, bad arguments, a stranger dropped, co-op, the late answers and their timeout,
a closed build that never opens. Compiles; not run (the Master runs the package tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 19:09:59 +03:00
parent dd1e852356
commit 18755d9057
14 changed files with 723 additions and 0 deletions

9
changes/devlink.md Normal file
View file

@ -0,0 +1,9 @@
bump: minor
type: feature
**`ludic.devlink`: an editor's live link into a running dev build** (protocol v1, frozen with Ludic
Studio). Loopback UDP through the `DevlinkNet` port, one request a frame parsed in place from one fixed
8 KB buffer and answered into another; every verb a `DevlinkWorld` member defaulting to "not offered":
`ping`, `hello` (the build's schema hash as 16 hex digits), `cam_get` / `cam_set` / `cam_release`, `goto`,
`map_load`, `time`, `weather`, `shot`, `pause` / `resume` / `step`, the slow three answered later by id.
A socket is opened only when `enabled()` says so (a game binds `dev_tools`), a sender off this machine is
dropped, and a connected co-op session refuses everything but `ping` and `hello`.

View file

@ -23,6 +23,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |
| [ludic.fire](ludic.fire/README.md) | a camp fire: fuel, the rain on it, warmth at a distance, whether it will cook |
| [ludic.zones](ludic.zones/README.md) | where the world is simulated: a disc per player from their fog, merged into clusters |
| [ludic.devlink](ludic.devlink/README.md) | an editor's live link into a running dev build: loopback UDP, one request a frame, verbs as ports |
| [ludic.fishing](ludic.fishing/README.md) | a rod at the water: cast, bite, the reel's fight, the landing; species as an open registry |
| [ludic.hints](ludic.hints/README.md) | a rail of what is true now, most urgent first, each a card that teaches it and can be muted by key |
| [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language |

View file

@ -0,0 +1,17 @@
# ludic.devlink
An editor's live link into a running dev build: the wire protocol v1 frozen in
`game-editor/proposals/2026-09-29-devlink/protocol-v1.md`. Uses `ludic.base` and nothing else.
- **Transport** is the `DevlinkNet` port (a game binds `Udp.*`, a test a fake). One request a frame, read
from one fixed 8 KB buffer and answered into another; nothing is made per frame. Only a loopback sender
is answered (`DevlinkNet.loopback`); the socket opens only when `DevlinkWorld.enabled()` says so (a
game binds `dev_tools`), so a release build never opens it.
- **Verbs** are `DevlinkWorld` members, every default "not offered" (`err unbound`): `ping`, `hello`,
`cam_get` / `cam_set` / `cam_release`, `goto`, `map_load`, `time`, `weather`, `shot`, `pause` / `resume`,
`step`. `map_load`, `shot` and `step` answer later by id (5 s at most, one at a time). While
`coop()` says a session is connected, everything but `ping` and `hello` is refused.
- **Call `devlink_tick`** once a frame from something that runs while the game is paused (not from a
system the pause stops), or `resume` could never arrive.
Tests: `ludic test packages/ludic.devlink` (a fake transport and world; each answer byte for byte).

View file

@ -0,0 +1,13 @@
# ludic.devlink - one request a frame from an editor on this machine: parsed in place from one fixed 8 KB
# buffer and answered into another, each verb a port member the game binds; the slow ones answer later
module ludic_devlink uses ludic_base
numbers float
import "ludic.base"
import "port.ludic"
import "state.ludic"
import "wire_read.ludic"
import "wire_write.ludic"
import "verbs.ludic"
import "verbs_world.ludic"
import "late.ludic"
import "system.ludic"

View file

@ -0,0 +1,57 @@
# late.ludic - one slow request at a time (a map load, a shot, steps), answered by id when it is done or 5 s on
function dl_busy(st: mut DevlinkState, id: int) -> bool {
if st.dl_pend == DL_NONE { return false }
dl_err(st, id, "refused", "busy with an earlier request")
return true
}
function dl_park(st: mut DevlinkState, id: int, kind: int) -> void {
st.dl_pend = kind
st.dl_pend_id = id
st.dl_pend_ip = st.dl_ip
st.dl_pend_port = st.dl_port
st.dl_pend_t = 0.0
st.dl_o = 0 # nothing now: the answer comes when it is done
}
# the waiting request's answer into dl_out once it is done: true when there is one to send
export function devlink_late(st: mut DevlinkState, dt: float) -> bool {
if st.dl_pend == DL_NONE { return false }
st.dl_pend_t = st.dl_pend_t + dt
let id = st.dl_pend_id
var done = true
if st.dl_pend == DL_MAP { done = dl_late_map(st, id) }
else if st.dl_pend == DL_SHOT { done = dl_late_shot(st, id) }
else if DevlinkWorld.steps_left() <= 0 { dl_ok(st, id) }
else { done = false }
if not done and st.dl_pend_t >= DL_LATE_S {
dl_err(st, id, "failed", "timed out")
done = true
}
if done { st.dl_pend = DL_NONE }
return done
}
function dl_late_map(st: mut DevlinkState, id: int) -> bool {
let s = DevlinkWorld.map_state()
if s == 0 { return false }
if s > 0 { dl_ok(st, id) } else { dl_err(st, id, "failed", "the map did not load") }
return true
}
function dl_late_shot(st: mut DevlinkState, id: int) -> bool {
let s = DevlinkWorld.shot_state()
if s == 0 { return false }
if s < 0 {
dl_err(st, id, "failed", "the shot was not written")
return true
}
dl_ok(st, id)
dl_b(st, 32)
dl_q_bytes(st, st.dl_path, st.dl_path_n)
dl_b(st, 32)
dl_i(st, DevlinkWorld.shot_w())
dl_b(st, 32)
dl_i(st, DevlinkWorld.shot_h())
return true
}

View file

@ -0,0 +1,5 @@
# ludic.devlink - a running dev build driven by an editor over a loopback UDP line protocol (v1, frozen in
# game-editor/proposals/2026-09-29-devlink/protocol-v1.md). Uses ludic.base and nothing else.
package "ludic.devlink"
version "0.1.0"
kind source

View file

@ -0,0 +1,67 @@
# port.ludic - what the link asks the game, every member defaulting to "not offered": an unbound build
# answers ping and hello and nothing else. DevlinkNet is the transport (Udp in a game, a fake in a test).
export const DL_UNBOUND: int = -2 # a member the game left at its default
export property DevlinkCam {
x: float = 0.0
y: float = 0.0
z: float = 0.0
yaw: float = 0.0
pitch: float = 0.0
}
export port DevlinkNet {
open: fn(int) -> int = fn dln_none1 # port -> a handle, < 0 none
recv: fn(int, []byte, int) -> int = fn dln_none3 # handle, buffer, cap -> bytes (0 none)
from_ip: fn(int) -> int = fn dln_none1 # the last datagram's sender
from_port: fn(int) -> int = fn dln_none1
send: fn(int, int, int, []byte, int) -> int = fn dln_none5 # handle, ip, port, bytes, n
loopback: fn(int) -> bool = fn dln_no1 # is this sender this machine
}
export port DevlinkWorld {
enabled: fn() -> bool = fn dlw_no # a dev build (dev_tools); a release never opens
port: fn() -> int = fn dlw_port
game: fn() -> string = fn dlw_game
build: fn() -> string = fn dlw_zero
schema: fn() -> long = fn dlw_schema # the build's schema hash (Build.schema_hash), 0 unknown
map: fn() -> string = fn dlw_blank
offers: fn() -> string = fn dlw_blank # the verbs bound, comma-separated
coop: fn() -> bool = fn dlw_no # a co-op session is connected
camera_get: fn(DevlinkCam) -> int = fn dlw_cam
camera_set: fn(float, float, float, float, float) -> int = fn dlw_n5
camera_release: fn() -> int = fn dlw_n0
teleport: fn(float, float, float, bool, DevlinkCam) -> int = fn dlw_tp # x, z, yaw, yaw given, where it stood
load_map: fn(string) -> int = fn dlw_s # 0 started, -1 no such map
map_state: fn() -> int = fn dlw_n0 # 1 loaded, 0 loading, -1 failed
set_time: fn(float, int) -> int = fn dlw_ti # hour, day (-1 keep)
set_weather: fn(string) -> int = fn dlw_s # 0 set, -1 no such weather
screenshot: fn(string) -> int = fn dlw_s # the frame now being made, written there
shot_state: fn() -> int = fn dlw_n0 # 1 written, 0 not yet, -1 failed
shot_w: fn() -> int = fn dlw_z0
shot_h: fn() -> int = fn dlw_z0
pause: fn(bool) -> int = fn dlw_b
step: fn(int) -> int = fn dlw_i
steps_left: fn() -> int = fn dlw_z0
}
function dln_none1(a: int) -> int { return -1 }
function dln_none3(a: int, b: []byte, c: int) -> int { return 0 }
function dln_none5(a: int, b: int, c: int, d: []byte, e: int) -> int { return -1 }
function dln_no1(a: int) -> bool { return false }
function dlw_no() -> bool { return false }
function dlw_port() -> int { return 47200 }
function dlw_game() -> string { return "ludic" } # not drawn
function dlw_zero() -> string { return "0" }
function dlw_schema() -> long {
let z: long = 0
return z
}
function dlw_blank() -> string { return "" }
function dlw_cam(c: DevlinkCam) -> int { return DL_UNBOUND }
function dlw_n5(a: float, b: float, c: float, d: float, e: float) -> int { return DL_UNBOUND }
function dlw_n0() -> int { return DL_UNBOUND }
function dlw_z0() -> int { return 0 }
function dlw_tp(x: float, z: float, yaw: float, has: bool, c: DevlinkCam) -> int { return DL_UNBOUND }
function dlw_s(s: string) -> int { return DL_UNBOUND }
function dlw_ti(h: float, d: int) -> int { return DL_UNBOUND }
function dlw_b(b: bool) -> int { return DL_UNBOUND }
function dlw_i(n: int) -> int { return DL_UNBOUND }

View file

@ -0,0 +1,29 @@
# state.ludic - the socket, the two buffers and the one late answer in flight; made once, reused every frame
export const DL_BUF: int = 8192
export const DL_LATE_S: float = 5.0 # a late answer gives up after this long
export const DL_NONE: int = 0 # what is waiting to be answered
export const DL_MAP: int = 1
export const DL_SHOT: int = 2
export const DL_STEP: int = 3
export state DevlinkState {
dl_h: int = -1
dl_tried: bool = false
dl_in: []byte = buffer(DL_BUF)
dl_out: []byte = buffer(DL_BUF)
dl_str: []byte = buffer(DL_BUF) # a quoted argument, unescaped
dl_path: []byte = buffer(1024) # the waiting shot's path, for its answer
dl_path_n: int = 0
dl_n: int = 0 # bytes in dl_in
dl_at: int = 0 # the reader's cursor
dl_bad: bool = false # the reader met something it could not read
dl_o: int = 0 # bytes in dl_out
dl_ip: int = 0
dl_port: int = 0
dl_pend: int = 0
dl_pend_id: int = 0
dl_pend_ip: int = 0
dl_pend_port: int = 0
dl_pend_t: float = 0.0
dl_cam: DevlinkCam = new DevlinkCam
}

View file

@ -0,0 +1,30 @@
# system.ludic - a frame of the link, first thing in PH_INPUT: a waiting answer sent when it is done, then
# at most one request read, answered and sent. A build whose `enabled` says no never opens the socket.
export function devlink_system() -> System {
let s = system_new("devlink", PH_INPUT)
s.tick = fn devlink_tick
return s
}
export function devlink_tick(devlink_st: mut DevlinkState, t: Tick) -> void {
if not DevlinkWorld.enabled() { return }
if devlink_st.dl_h < 0 {
if devlink_st.dl_tried { return }
devlink_st.dl_tried = true
devlink_st.dl_h = DevlinkNet.open(DevlinkWorld.port())
if devlink_st.dl_h < 0 { return }
}
let h = devlink_st.dl_h
if devlink_late(devlink_st, t.dt) { DevlinkNet.send(h, devlink_st.dl_pend_ip, devlink_st.dl_pend_port, devlink_st.dl_out, devlink_st.dl_o) }
let n = DevlinkNet.recv(h, devlink_st.dl_in, DL_BUF)
if n <= 0 { return }
devlink_st.dl_ip = DevlinkNet.from_ip(h)
devlink_st.dl_port = DevlinkNet.from_port(h)
if not DevlinkNet.loopback(devlink_st.dl_ip) { return } # only this machine
devlink_st.dl_n = n
devlink_handle(devlink_st)
if devlink_st.dl_o > 0 { DevlinkNet.send(h, devlink_st.dl_ip, devlink_st.dl_port, devlink_st.dl_out, devlink_st.dl_o) }
}
# whether a request is waiting on a slow answer (a map load, a shot, steps)
export function devlink_waiting(devlink_st: DevlinkState) -> bool { return devlink_st.dl_pend != DL_NONE }

View file

@ -0,0 +1,151 @@
# devlink_test.ludic - the v1 protocol against a fake transport and a fake world: each answer byte for byte,
# bad arguments, a stranger dropped, co-op refused, the late answers and their timeout, a closed build
import "ludic.devlink"
import "ludic.base"
program DevlinkTest {
numbers float
state FakeLinkState {
on: bool = true
opened: int = 0
inbox: string = ""
from: int = 2130706433 # 127.0.0.1
sent: string = ""
coop: bool = false
cx: float = 0.0
cyaw: float = 0.0
map_st: int = 0
shot_st: int = 0
steps: int = 0
last: string = "" # the last text a verb was handed
}
function fk_open(fake_link_st: mut FakeLinkState, port: int) -> int {
fake_link_st.opened += 1
return 7
}
function fk_recv(fake_link_st: mut FakeLinkState, h: int, b: []byte, cap: int) -> int {
let n = len(fake_link_st.inbox)
for i in 0 .. n { b[i] = fake_link_st.inbox[i] }
fake_link_st.inbox = ""
return n
}
function fk_ip(fake_link_st: FakeLinkState, h: int) -> int { return fake_link_st.from }
function fk_port(h: int) -> int { return 5555 }
function fk_send(fake_link_st: mut FakeLinkState, h: int, ip: int, port: int, b: []byte, n: int) -> int {
fake_link_st.sent = text_of(b, n)
return n
}
function fk_loop(ip: int) -> bool { return ip == 2130706433 }
bind DevlinkNet { open: fn fk_open, recv: fn fk_recv, from_ip: fn fk_ip, from_port: fn fk_port, send: fn fk_send, loopback: fn fk_loop }
function fw_on(fake_link_st: FakeLinkState) -> bool { return fake_link_st.on }
function fw_game() -> string { return "maroon-lake" }
function fw_map() -> string { return "maroon" }
function fw_schema() -> long {
let h: long = 81985529216486895
return h
}
function fw_offers() -> string { return "cam_get,cam_set,goto,map_load,shot,step" }
function fw_coop(fake_link_st: FakeLinkState) -> bool { return fake_link_st.coop }
function fw_cam_get(c: DevlinkCam) -> int {
c.x = 1038.5
c.y = -38.0
c.z = -405.25
c.yaw = 124.0
c.pitch = -10.0
return 0
}
function fw_cam_set(fake_link_st: mut FakeLinkState, x: float, y: float, z: float, yaw: float, pitch: float) -> int {
fake_link_st.cx = x
fake_link_st.cyaw = yaw
return 0
}
function fw_tp(x: float, z: float, yaw: float, has: bool, c: DevlinkCam) -> int {
c.x = x
c.y = 2919.5
c.z = z
return 0
}
function fw_load(fake_link_st: mut FakeLinkState, key: string) -> int {
fake_link_st.last = key
if key == "nowhere" { return -1 }
return 0
}
function fw_map_st(fake_link_st: FakeLinkState) -> int { return fake_link_st.map_st }
function fw_weather(fake_link_st: mut FakeLinkState, key: string) -> int {
fake_link_st.last = key
return 0
}
function fw_shot(fake_link_st: mut FakeLinkState, path: string) -> int {
fake_link_st.last = path
return 0
}
function fw_shot_st(fake_link_st: FakeLinkState) -> int { return fake_link_st.shot_st }
function fw_w() -> int { return 1920 }
function fw_h() -> int { return 1080 }
function fw_step(fake_link_st: mut FakeLinkState, n: int) -> int {
fake_link_st.steps = n
return 0
}
function fw_left(fake_link_st: FakeLinkState) -> int { return fake_link_st.steps }
bind DevlinkWorld { enabled: fn fw_on, game: fn fw_game, schema: fn fw_schema, map: fn fw_map, offers: fn fw_offers, coop: fn fw_coop, camera_get: fn fw_cam_get, camera_set: fn fw_cam_set, teleport: fn fw_tp, load_map: fn fw_load, map_state: fn fw_map_st, set_weather: fn fw_weather, screenshot: fn fw_shot, shot_state: fn fw_shot_st, shot_w: fn fw_w, shot_h: fn fw_h, step: fn fw_step, steps_left: fn fw_left }
# one frame with `line` waiting (or nothing), and what the link sent (or "")
function ask(devlink_st: mut DevlinkState, fake_link_st: mut FakeLinkState, line: string, dt: float) -> string {
fake_link_st.inbox = line
fake_link_st.sent = ""
devlink_tick(devlink_st, tick_new(dt, 0, 0.0))
return fake_link_st.sent
}
test "ping and hello, and the answers to the camera, byte for byte" (devlink_st: mut DevlinkState, fake_link_st: mut FakeLinkState) {
expect(ask(devlink_st, fake_link_st, "1 ping", 0.016) == "1 ok")
expect(ask(devlink_st, fake_link_st, "2 hello\n", 0.016) == "2 ok 1 \"maroon-lake\" \"0\" \"0123456789abcdef\" \"maroon\" \"cam_get,cam_set,goto,map_load,shot,step\"")
expect(ask(devlink_st, fake_link_st, "3 cam_get", 0.016) == "3 ok 1038.500 -38.000 -405.250 124.000 -10.000")
expect(ask(devlink_st, fake_link_st, "4 cam_set 12.5 3 -7 90.25 -5", 0.016) == "4 ok")
expect(fake_link_st.cx == 12.5 and fake_link_st.cyaw == 90.25)
expect(ask(devlink_st, fake_link_st, "5 goto 100 -200.5", 0.016) == "5 ok 100.000 2919.500 -200.500")
expect_eq(fake_link_st.opened, 1)
}
test "bad arguments, an unknown verb, a verb not offered, a stranger and co-op" (devlink_st: mut DevlinkState, fake_link_st: mut FakeLinkState) {
expect(ask(devlink_st, fake_link_st, "6 cam_set 1 2", 0.016) == "6 err bad_args \"bad arguments\"")
expect(ask(devlink_st, fake_link_st, "7 cam_set 1 2 3 4 5x", 0.016) == "7 err bad_args \"bad arguments\"")
expect(ask(devlink_st, fake_link_st, "8 fly", 0.016) == "8 err unknown_verb \"no such verb\"")
expect(ask(devlink_st, fake_link_st, "9 cam_release", 0.016) == "9 err unbound \"not offered\"")
expect(ask(devlink_st, fake_link_st, "10 time 25", 0.016) == "10 err bad_args \"an hour is 0 to 24\"")
expect(ask(devlink_st, fake_link_st, "hello", 0.016) == "") # no id: nothing to answer
fake_link_st.from = 167772161 # 10.0.0.1
expect(ask(devlink_st, fake_link_st, "11 ping", 0.016) == "")
fake_link_st.from = 2130706433
fake_link_st.coop = true
expect(ask(devlink_st, fake_link_st, "12 cam_get", 0.016) == "12 err refused \"co-op session connected\"")
expect(ask(devlink_st, fake_link_st, "13 ping", 0.016) == "13 ok")
}
test "map_load, shot and step answer later by id; a second slow one is refused; five seconds is a timeout" (devlink_st: mut DevlinkState, fake_link_st: mut FakeLinkState) {
expect(ask(devlink_st, fake_link_st, "20 map_load \"nowhere\"", 0.016) == "20 err failed \"no such map\"")
expect(ask(devlink_st, fake_link_st, "21 map_load \"lamar\"", 0.016) == "")
expect(fake_link_st.last == "lamar")
expect(ask(devlink_st, fake_link_st, "22 step 3", 0.016) == "22 err refused \"busy with an earlier request\"")
expect(ask(devlink_st, fake_link_st, "", 0.016) == "")
fake_link_st.map_st = 1
expect(ask(devlink_st, fake_link_st, "", 0.016) == "21 ok")
expect(ask(devlink_st, fake_link_st, "23 shot \"../x.ppm\"", 0.016) == "23 err bad_args \"a .ppm path without ..\"")
expect(ask(devlink_st, fake_link_st, "24 shot \"/tmp/a b\\\"c.ppm\" 640 360", 0.016) == "")
expect(fake_link_st.last == "/tmp/a b\"c.ppm")
fake_link_st.shot_st = 1
expect(ask(devlink_st, fake_link_st, "", 0.016) == "24 ok \"/tmp/a b\\\"c.ppm\" 1920 1080")
expect(ask(devlink_st, fake_link_st, "25 step 2", 0.016) == "")
fake_link_st.steps = 0
expect(ask(devlink_st, fake_link_st, "", 0.016) == "25 ok")
fake_link_st.map_st = 0
expect(ask(devlink_st, fake_link_st, "26 map_load \"lamar\"", 0.016) == "")
expect(ask(devlink_st, fake_link_st, "", 6.0) == "26 err failed \"timed out\"")
}
test "a build that is not a dev build never opens the socket" (devlink_st: mut DevlinkState, fake_link_st: mut FakeLinkState) {
fake_link_st.on = false
expect(ask(devlink_st, fake_link_st, "1 ping", 0.016) == "")
expect_eq(fake_link_st.opened, 0)
}
}

View file

@ -0,0 +1,86 @@
# verbs.ludic - one request read and answered (or parked to answer later): dl_o bytes of dl_out to send
export function devlink_handle(st: mut DevlinkState) -> void {
st.dl_at = 0
st.dl_o = 0
st.dl_bad = false
let id = dl_int(st)
if st.dl_bad or id < 0 { return } # no id, nothing to answer to
if dl_word(st, "ping") { dl_ok(st, id) }
else if dl_word(st, "hello") { dl_hello(st, id) }
else if DevlinkWorld.coop() { dl_err(st, id, "refused", "co-op session connected") }
else { dl_verb(st, id) }
}
function dl_hello(st: mut DevlinkState, id: int) -> void {
dl_ok(st, id)
dl_s(st, " 1 ")
dl_q(st, DevlinkWorld.game())
dl_b(st, 32)
dl_q(st, DevlinkWorld.build())
dl_b(st, 32)
dl_b(st, 34)
dl_hex(st, DevlinkWorld.schema())
dl_b(st, 34)
dl_b(st, 32)
dl_q(st, DevlinkWorld.map())
dl_b(st, 32)
dl_q(st, DevlinkWorld.offers())
}
function dl_verb(st: mut DevlinkState, id: int) -> void {
if dl_word(st, "cam_get") { dl_cam_get(st, id) }
else if dl_word(st, "cam_set") { dl_cam_set(st, id) }
else if dl_word(st, "cam_release") { dl_answer(st, id, DevlinkWorld.camera_release()) }
else if dl_word(st, "goto") { dl_goto(st, id) }
else if dl_word(st, "map_load") { dl_map(st, id) }
else if dl_word(st, "time") { dl_time(st, id) }
else if dl_word(st, "weather") { dl_weather(st, id) }
else if dl_word(st, "shot") { dl_shot(st, id) }
else if dl_word(st, "pause") { dl_answer(st, id, DevlinkWorld.pause(true)) }
else if dl_word(st, "resume") { dl_answer(st, id, DevlinkWorld.pause(false)) }
else if dl_word(st, "step") { dl_step(st, id) }
else { dl_err(st, id, "unknown_verb", "no such verb") }
}
# a member's answer: ok, or not offered
function dl_answer(st: mut DevlinkState, id: int, r: int) -> void {
if r == DL_UNBOUND { dl_err(st, id, "unbound", "not offered") } else { dl_ok(st, id) }
}
function dl_bad_args(st: mut DevlinkState, id: int) -> bool {
if not st.dl_bad and dl_done(st) { return false }
dl_err(st, id, "bad_args", "bad arguments")
return true
}
function dl_cam_get(st: mut DevlinkState, id: int) -> void {
if dl_bad_args(st, id) { return }
let c = st.dl_cam
if DevlinkWorld.camera_get(c) == DL_UNBOUND {
dl_err(st, id, "unbound", "not offered")
return
}
dl_ok(st, id)
dl_xyz(st, c.x, c.y, c.z)
dl_b(st, 32)
dl_f(st, c.yaw)
dl_b(st, 32)
dl_f(st, c.pitch)
}
function dl_xyz(st: mut DevlinkState, x: float, y: float, z: float) -> void {
dl_b(st, 32)
dl_f(st, x)
dl_b(st, 32)
dl_f(st, y)
dl_b(st, 32)
dl_f(st, z)
}
function dl_cam_set(st: mut DevlinkState, id: int) -> void {
let x = dl_float(st)
let y = dl_float(st)
let z = dl_float(st)
let yaw = dl_float(st)
let pitch = dl_float(st)
if dl_bad_args(st, id) { return }
dl_answer(st, id, DevlinkWorld.camera_set(x, y, z, yaw, pitch))
}

View file

@ -0,0 +1,87 @@
# verbs_world.ludic - the verbs that move the player or the world: goto, map_load, time, weather, shot, step
function dl_goto(st: mut DevlinkState, id: int) -> void {
let x = dl_float(st)
let z = dl_float(st)
var yaw = 0.0
var has = false
if not dl_done(st) {
yaw = dl_float(st)
has = true
}
if dl_bad_args(st, id) { return }
let c = st.dl_cam
if DevlinkWorld.teleport(x, z, yaw, has, c) == DL_UNBOUND {
dl_err(st, id, "unbound", "not offered")
return
}
dl_ok(st, id)
dl_xyz(st, c.x, c.y, c.z)
}
# a quoted argument as text for the game (made per request, never per frame)
@alloc_ok("one request's key or path: once per request, in a dev build")
function dl_text(st: DevlinkState, n: int) -> string { return text_of(st.dl_str, n) }
function dl_map(st: mut DevlinkState, id: int) -> void {
let n = dl_quoted(st)
if dl_bad_args(st, id) { return }
if dl_busy(st, id) { return }
let r = DevlinkWorld.load_map(dl_text(st, n))
if r == DL_UNBOUND { dl_err(st, id, "unbound", "not offered") }
else if r < 0 { dl_err(st, id, "failed", "no such map") }
else { dl_park(st, id, DL_MAP) }
}
function dl_time(st: mut DevlinkState, id: int) -> void {
let h = dl_float(st)
var day = -1
if not dl_done(st) { day = dl_int(st) }
if dl_bad_args(st, id) or h < 0.0 or h > 24.0 {
if st.dl_o == 0 { dl_err(st, id, "bad_args", "an hour is 0 to 24") }
return
}
dl_answer(st, id, DevlinkWorld.set_time(h, day))
}
function dl_weather(st: mut DevlinkState, id: int) -> void {
let n = dl_quoted(st)
if dl_bad_args(st, id) { return }
let r = DevlinkWorld.set_weather(dl_text(st, n))
if r == -1 { dl_err(st, id, "bad_args", "unknown weather") } else { dl_answer(st, id, r) }
}
# a .ppm and no "..": the one file an editor may name; w and h, when given, are the frame's own
function dl_shot(st: mut DevlinkState, id: int) -> void {
let n = dl_quoted(st)
if not dl_done(st) {
dl_int(st)
dl_int(st)
}
if dl_bad_args(st, id) { return }
if n < 5 or n > len(st.dl_path) or not dl_ppm(st, n) {
dl_err(st, id, "bad_args", "a .ppm path without ..")
return
}
if dl_busy(st, id) { return }
for i in 0 .. n { st.dl_path[i] = st.dl_str[i] }
st.dl_path_n = n
let r = DevlinkWorld.screenshot(dl_text(st, n))
if r == DL_UNBOUND { dl_err(st, id, "unbound", "not offered") } else { dl_park(st, id, DL_SHOT) }
}
function dl_ppm(st: DevlinkState, n: int) -> bool {
let b = st.dl_str
if b[n - 4] != 46 or b[n - 3] != 112 or b[n - 2] != 112 or b[n - 1] != 109 { return false }
for i in 0 .. n - 1 { if b[i] == 46 and b[i + 1] == 46 { return false } }
return true
}
function dl_step(st: mut DevlinkState, id: int) -> void {
let n = dl_int(st)
if dl_bad_args(st, id) or n < 1 or n > 600 {
if st.dl_o == 0 { dl_err(st, id, "bad_args", "n is 1 to 600") }
return
}
if dl_busy(st, id) { return }
let r = DevlinkWorld.step(n)
if r == DL_UNBOUND { dl_err(st, id, "unbound", "not offered") } else { dl_park(st, id, DL_STEP) }
}

View file

@ -0,0 +1,93 @@
# wire_read.ludic - reading a request in place: numbers, words and quoted strings from dl_in, nothing made
function dl_skip(st: mut DevlinkState) -> void { while st.dl_at < st.dl_n and (st.dl_in[st.dl_at] == 32 or st.dl_in[st.dl_at] == 10 or st.dl_in[st.dl_at] == 13) { st.dl_at += 1 } }
function dl_done(st: mut DevlinkState) -> bool {
dl_skip(st)
return st.dl_at >= st.dl_n
}
# the word at the cursor is `w` (and ends there): then past it
function dl_word(st: mut DevlinkState, w: string) -> bool {
dl_skip(st)
let n = len(w)
if st.dl_at + n > st.dl_n { return false }
for i in 0 .. n { if st.dl_in[st.dl_at + i] != w[i] { return false } }
if st.dl_at + n < st.dl_n and st.dl_in[st.dl_at + n] != 32 and st.dl_in[st.dl_at + n] != 10 and st.dl_in[st.dl_at + n] != 13 { return false }
st.dl_at += n
return true
}
function dl_int(st: mut DevlinkState) -> int {
dl_skip(st)
var neg = false
if st.dl_at < st.dl_n and st.dl_in[st.dl_at] == 45 {
neg = true
st.dl_at += 1
}
var v = 0
var digits = 0
while st.dl_at < st.dl_n and st.dl_in[st.dl_at] >= 48 and st.dl_in[st.dl_at] <= 57 and digits < 10 {
v = v * 10 + (st.dl_in[st.dl_at] - 48)
st.dl_at += 1
digits += 1
}
if digits == 0 or (st.dl_at < st.dl_n and st.dl_in[st.dl_at] > 32) { st.dl_bad = true }
if neg { return -v }
return v
}
function dl_float(st: mut DevlinkState) -> float {
dl_skip(st)
var neg = false
if st.dl_at < st.dl_n and st.dl_in[st.dl_at] == 45 {
neg = true
st.dl_at += 1
}
var v = 0.0
var digits = 0
while st.dl_at < st.dl_n and st.dl_in[st.dl_at] >= 48 and st.dl_in[st.dl_at] <= 57 {
v = v * 10.0 + float(st.dl_in[st.dl_at] - 48)
st.dl_at += 1
digits += 1
}
if st.dl_at < st.dl_n and st.dl_in[st.dl_at] == 46 {
st.dl_at += 1
var f = 0.1
while st.dl_at < st.dl_n and st.dl_in[st.dl_at] >= 48 and st.dl_in[st.dl_at] <= 57 {
v = v + f * float(st.dl_in[st.dl_at] - 48)
f = f * 0.1
st.dl_at += 1
digits += 1
}
}
if digits == 0 or (st.dl_at < st.dl_n and st.dl_in[st.dl_at] > 32) { st.dl_bad = true }
if neg { return -v }
return v
}
# a quoted string into dl_str, unescaped: its length, or -1 (dl_bad) when it is not one
function dl_quoted(st: mut DevlinkState) -> int {
dl_skip(st)
if st.dl_at >= st.dl_n or st.dl_in[st.dl_at] != 34 {
st.dl_bad = true
return -1
}
st.dl_at += 1
var n = 0
while st.dl_at < st.dl_n and st.dl_in[st.dl_at] != 34 {
var c = st.dl_in[st.dl_at]
if c == 92 and st.dl_at + 1 < st.dl_n {
st.dl_at += 1
c = st.dl_in[st.dl_at]
}
st.dl_str[n] = c
n += 1
st.dl_at += 1
}
if st.dl_at >= st.dl_n {
st.dl_bad = true
return -1
}
st.dl_at += 1
return n
}

View file

@ -0,0 +1,78 @@
# wire_write.ludic - an answer written in place into dl_out: text, numbers and quoted strings, nothing made
function dl_b(st: mut DevlinkState, c: int) -> void {
if st.dl_o < DL_BUF {
st.dl_out[st.dl_o] = c
st.dl_o += 1
}
}
function dl_s(st: mut DevlinkState, s: string) -> void { for i in 0 .. len(s) { dl_b(st, s[i]) } }
function dl_i(st: mut DevlinkState, v: int) -> void {
var n = v
if n < 0 {
dl_b(st, 45)
n = -n
}
var div = 1
while div <= n / 10 { div = div * 10 }
while div > 0 {
dl_b(st, 48 + (n / div) % 10)
div = div / 10
}
}
# three decimals, which is a millimetre and a thousandth of a degree
function dl_f(st: mut DevlinkState, v: float) -> void {
var m = int(Math.floor(Math.abs(v) * 1000.0 + 0.5))
if v < 0.0 and m > 0 { dl_b(st, 45) }
dl_i(st, m / 1000)
dl_b(st, 46)
let f = m % 1000
dl_b(st, 48 + f / 100)
dl_b(st, 48 + (f / 10) % 10)
dl_b(st, 48 + f % 10)
}
# a hash as sixteen lower-case hex digits, or "0" when there is none
function dl_hex(st: mut DevlinkState, v: long) -> void {
let zero: long = 0
if v == zero {
dl_b(st, 48)
return
}
for i in 0 .. 16 {
let d = int((v >> (60 - i * 4)) & 15)
if d < 10 { dl_b(st, 48 + d) } else { dl_b(st, 87 + d) }
}
}
function dl_q(st: mut DevlinkState, s: string) -> void {
dl_b(st, 34)
for i in 0 .. len(s) {
if s[i] == 34 or s[i] == 92 { dl_b(st, 92) }
dl_b(st, s[i])
}
dl_b(st, 34)
}
function dl_q_bytes(st: mut DevlinkState, b: []byte, n: int) -> void {
dl_b(st, 34)
for i in 0 .. n {
if b[i] == 34 or b[i] == 92 { dl_b(st, 92) }
dl_b(st, b[i])
}
dl_b(st, 34)
}
function dl_ok(st: mut DevlinkState, id: int) -> void {
st.dl_o = 0
dl_i(st, id)
dl_s(st, " ok")
}
function dl_err(st: mut DevlinkState, id: int, code: string, msg: string) -> void {
st.dl_o = 0
dl_i(st, id)
dl_s(st, " err ")
dl_s(st, code)
dl_b(st, 32)
dl_q(st, msg)
}