From 18755d90577be249e176844b474e66cc2cb12bcf Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 19:09:59 +0300 Subject: [PATCH] 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 --- changes/devlink.md | 9 ++ packages/README.md | 1 + packages/ludic.devlink/README.md | 17 ++ packages/ludic.devlink/index.ludic | 13 ++ packages/ludic.devlink/late.ludic | 57 +++++++ packages/ludic.devlink/package.ludic | 5 + packages/ludic.devlink/port.ludic | 67 ++++++++ packages/ludic.devlink/state.ludic | 29 ++++ packages/ludic.devlink/system.ludic | 30 ++++ .../ludic.devlink/tests/devlink_test.ludic | 151 ++++++++++++++++++ packages/ludic.devlink/verbs.ludic | 86 ++++++++++ packages/ludic.devlink/verbs_world.ludic | 87 ++++++++++ packages/ludic.devlink/wire_read.ludic | 93 +++++++++++ packages/ludic.devlink/wire_write.ludic | 78 +++++++++ 14 files changed, 723 insertions(+) create mode 100644 changes/devlink.md create mode 100644 packages/ludic.devlink/README.md create mode 100644 packages/ludic.devlink/index.ludic create mode 100644 packages/ludic.devlink/late.ludic create mode 100644 packages/ludic.devlink/package.ludic create mode 100644 packages/ludic.devlink/port.ludic create mode 100644 packages/ludic.devlink/state.ludic create mode 100644 packages/ludic.devlink/system.ludic create mode 100644 packages/ludic.devlink/tests/devlink_test.ludic create mode 100644 packages/ludic.devlink/verbs.ludic create mode 100644 packages/ludic.devlink/verbs_world.ludic create mode 100644 packages/ludic.devlink/wire_read.ludic create mode 100644 packages/ludic.devlink/wire_write.ludic diff --git a/changes/devlink.md b/changes/devlink.md new file mode 100644 index 00000000..67355f35 --- /dev/null +++ b/changes/devlink.md @@ -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`. diff --git a/packages/README.md b/packages/README.md index 5bbcd517..5fb9e962 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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 | diff --git a/packages/ludic.devlink/README.md b/packages/ludic.devlink/README.md new file mode 100644 index 00000000..2d139f33 --- /dev/null +++ b/packages/ludic.devlink/README.md @@ -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). diff --git a/packages/ludic.devlink/index.ludic b/packages/ludic.devlink/index.ludic new file mode 100644 index 00000000..473895bc --- /dev/null +++ b/packages/ludic.devlink/index.ludic @@ -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" diff --git a/packages/ludic.devlink/late.ludic b/packages/ludic.devlink/late.ludic new file mode 100644 index 00000000..7580785d --- /dev/null +++ b/packages/ludic.devlink/late.ludic @@ -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 +} diff --git a/packages/ludic.devlink/package.ludic b/packages/ludic.devlink/package.ludic new file mode 100644 index 00000000..96fdb768 --- /dev/null +++ b/packages/ludic.devlink/package.ludic @@ -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 diff --git a/packages/ludic.devlink/port.ludic b/packages/ludic.devlink/port.ludic new file mode 100644 index 00000000..fbc0e267 --- /dev/null +++ b/packages/ludic.devlink/port.ludic @@ -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 } diff --git a/packages/ludic.devlink/state.ludic b/packages/ludic.devlink/state.ludic new file mode 100644 index 00000000..bda7fd0e --- /dev/null +++ b/packages/ludic.devlink/state.ludic @@ -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 +} diff --git a/packages/ludic.devlink/system.ludic b/packages/ludic.devlink/system.ludic new file mode 100644 index 00000000..e269156a --- /dev/null +++ b/packages/ludic.devlink/system.ludic @@ -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 } diff --git a/packages/ludic.devlink/tests/devlink_test.ludic b/packages/ludic.devlink/tests/devlink_test.ludic new file mode 100644 index 00000000..40e18bc4 --- /dev/null +++ b/packages/ludic.devlink/tests/devlink_test.ludic @@ -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) + } +} diff --git a/packages/ludic.devlink/verbs.ludic b/packages/ludic.devlink/verbs.ludic new file mode 100644 index 00000000..83b9023f --- /dev/null +++ b/packages/ludic.devlink/verbs.ludic @@ -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)) +} diff --git a/packages/ludic.devlink/verbs_world.ludic b/packages/ludic.devlink/verbs_world.ludic new file mode 100644 index 00000000..d5ece9c1 --- /dev/null +++ b/packages/ludic.devlink/verbs_world.ludic @@ -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) } +} diff --git a/packages/ludic.devlink/wire_read.ludic b/packages/ludic.devlink/wire_read.ludic new file mode 100644 index 00000000..031e253a --- /dev/null +++ b/packages/ludic.devlink/wire_read.ludic @@ -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 +} diff --git a/packages/ludic.devlink/wire_write.ludic b/packages/ludic.devlink/wire_write.ludic new file mode 100644 index 00000000..b0db1701 --- /dev/null +++ b/packages/ludic.devlink/wire_write.ludic @@ -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) +}