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:
parent
dd1e852356
commit
18755d9057
14 changed files with 723 additions and 0 deletions
9
changes/devlink.md
Normal file
9
changes/devlink.md
Normal 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`.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
17
packages/ludic.devlink/README.md
Normal file
17
packages/ludic.devlink/README.md
Normal 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).
|
||||
13
packages/ludic.devlink/index.ludic
Normal file
13
packages/ludic.devlink/index.ludic
Normal 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"
|
||||
57
packages/ludic.devlink/late.ludic
Normal file
57
packages/ludic.devlink/late.ludic
Normal 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
|
||||
}
|
||||
5
packages/ludic.devlink/package.ludic
Normal file
5
packages/ludic.devlink/package.ludic
Normal 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
|
||||
67
packages/ludic.devlink/port.ludic
Normal file
67
packages/ludic.devlink/port.ludic
Normal 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 }
|
||||
29
packages/ludic.devlink/state.ludic
Normal file
29
packages/ludic.devlink/state.ludic
Normal 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
|
||||
}
|
||||
30
packages/ludic.devlink/system.ludic
Normal file
30
packages/ludic.devlink/system.ludic
Normal 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 }
|
||||
151
packages/ludic.devlink/tests/devlink_test.ludic
Normal file
151
packages/ludic.devlink/tests/devlink_test.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
86
packages/ludic.devlink/verbs.ludic
Normal file
86
packages/ludic.devlink/verbs.ludic
Normal 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))
|
||||
}
|
||||
87
packages/ludic.devlink/verbs_world.ludic
Normal file
87
packages/ludic.devlink/verbs_world.ludic
Normal 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) }
|
||||
}
|
||||
93
packages/ludic.devlink/wire_read.ludic
Normal file
93
packages/ludic.devlink/wire_read.ludic
Normal 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
|
||||
}
|
||||
78
packages/ludic.devlink/wire_write.ludic
Normal file
78
packages/ludic.devlink/wire_write.ludic
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue