ludic.devlink: the interface's verbs (ui_screen, ui_model, ui_tree, ui_override) through a DevlinkUi port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 22:48:34 +03:00
parent 9f07fc8a62
commit 5bc2342174
8 changed files with 203 additions and 1 deletions

View file

@ -11,7 +11,12 @@ An editor's live link into a running dev build: the wire protocol v1 frozen in
`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.
- **The interface's verbs** are `DevlinkUi` members, the same default: `ui_screen` answers the screen's root
class and every component up; `ui_model "<Class>" "<out>"` and `ui_tree "<out>"` have the game write the
first instance's model, or the whole tree, to that file (no `..`) and answer `ok "<out>"`, so a datagram
stays small; `ui_override "<path>" "<file>"` has the interface read a template or stylesheet from that file
and reload it keeping state (`""` for the file clears one, `"" ""` clears all). All answer at once.
- **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).
Tests: `ludic test packages/ludic.devlink` (a fake transport, world and interface; each answer byte for byte).

View file

@ -4,10 +4,12 @@ module ludic_devlink uses ludic_base
numbers float
import "ludic.base"
import "port.ludic"
import "ui.ludic"
import "state.ludic"
import "wire_read.ludic"
import "wire_write.ludic"
import "verbs.ludic"
import "verbs_world.ludic"
import "verbs_ui.ludic"
import "late.ludic"
import "system.ludic"

View file

@ -26,4 +26,5 @@ export state DevlinkState {
dl_pend_port: int = 0
dl_pend_t: float = 0.0
dl_cam: DevlinkCam = new DevlinkCam
dl_ui: DevlinkUiScreen = new DevlinkUiScreen
}

View file

@ -0,0 +1,90 @@
# devlink_ui_test.ludic - the ui verbs against a fake transport and a fake interface: each answer byte for
# byte, a path with .. refused, an instance that is not there, the overrides set and cleared, not offered
import "ludic.devlink"
import "ludic.base"
program DevlinkUiTest {
numbers float
state FakeUiState {
inbox: string = ""
sent: string = ""
offered: bool = true
last: string = "" # what the interface was last asked, "a|b"
}
function fk_open(port: int) -> int { return 3 }
function fk_recv(fake_ui_st: mut FakeUiState, h: int, b: []byte, cap: int) -> int {
let s = fake_ui_st.inbox
fake_ui_st.inbox = ""
for i in 0 .. len(s) { b[i] = s[i] }
return len(s)
}
function fk_ip(h: int) -> int { return 2130706433 }
function fk_port(h: int) -> int { return 5555 }
@alloc_ok("a test: the answer kept to compare")
function fk_send(fake_ui_st: mut FakeUiState, h: int, ip: int, port: int, b: []byte, n: int) -> int {
fake_ui_st.sent = text_of(b, n)
return n
}
function fk_loop(ip: int) -> bool { return true }
function fk_on() -> bool { return true }
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 }
bind DevlinkWorld { enabled: fn fk_on }
function fu_screen(fake_ui_st: FakeUiState, s: DevlinkUiScreen) -> int {
if not fake_ui_st.offered { return DL_UNBOUND }
s.root = "PauseMenu"
s.components = "Screen,Heading,Footer"
return 0
}
@alloc_ok("a test: what the interface was asked")
function fu_model(fake_ui_st: mut FakeUiState, cls: string, path: string) -> int {
fake_ui_st.last = cls + "|" + path
if cls == "Ghost" { return -1 }
return 0
}
function fu_tree(fake_ui_st: mut FakeUiState, path: string) -> int {
fake_ui_st.last = path
return 0
}
@alloc_ok("a test: what the interface was asked")
function fu_override(fake_ui_st: mut FakeUiState, path: string, file: string) -> int {
fake_ui_st.last = path + "|" + file
return 0
}
bind DevlinkUi { screen: fn fu_screen, model: fn fu_model, tree: fn fu_tree, override: fn fu_override }
function ask(devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState, line: string) -> string {
fake_ui_st.inbox = line
fake_ui_st.sent = ""
devlink_tick(devlink_st, tick_new(0.016, 0, 0.0))
return fake_ui_st.sent
}
test "ui_screen names the root and every component; unbound says not offered" (devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState) {
expect(ask(devlink_st, fake_ui_st, "1 ui_screen") == "1 ok \"PauseMenu\" \"Screen,Heading,Footer\"")
expect(ask(devlink_st, fake_ui_st, "2 ui_screen extra") == "2 err bad_args \"bad arguments\"")
fake_ui_st.offered = false
expect(ask(devlink_st, fake_ui_st, "3 ui_screen") == "3 err unbound \"not offered\"")
}
test "ui_model and ui_tree write where they are told, never past .., and say a missing instance" (devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState) {
expect(ask(devlink_st, fake_ui_st, "4 ui_model \"PauseMenu\" \"/tmp/m.json\"") == "4 ok \"/tmp/m.json\"")
expect(fake_ui_st.last == "PauseMenu|/tmp/m.json")
expect(ask(devlink_st, fake_ui_st, "5 ui_model \"Ghost\" \"/tmp/g.json\"") == "5 err failed \"no such instance\"")
expect(ask(devlink_st, fake_ui_st, "6 ui_model \"PauseMenu\" \"/tmp/../etc/x\"") == "6 err bad_args \"a path without ..\"")
expect(ask(devlink_st, fake_ui_st, "7 ui_model \"PauseMenu\"") == "7 err bad_args \"bad arguments\"")
expect(ask(devlink_st, fake_ui_st, "8 ui_model \"\" \"/tmp/m.json\"") == "8 err bad_args \"a class and a path\"")
expect(ask(devlink_st, fake_ui_st, "9 ui_tree \"/tmp/tree.json\"") == "9 ok \"/tmp/tree.json\"")
expect(ask(devlink_st, fake_ui_st, "10 ui_tree \"\"") == "10 err bad_args \"a path without ..\"")
}
test "ui_override sets one, clears one with \"\", clears all with \"\" \"\"; a file past .. is refused" (devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState) {
expect(ask(devlink_st, fake_ui_st, "11 ui_override \"src/ui/menus/PauseMenu.xml\" \"/tmp/p.xml\"") == "11 ok")
expect(fake_ui_st.last == "src/ui/menus/PauseMenu.xml|/tmp/p.xml")
expect(ask(devlink_st, fake_ui_st, "12 ui_override \"src/ui/menus/PauseMenu.xml\" \"\"") == "12 ok")
expect(fake_ui_st.last == "src/ui/menus/PauseMenu.xml|")
expect(ask(devlink_st, fake_ui_st, "13 ui_override \"\" \"\"") == "13 ok")
expect(fake_ui_st.last == "|")
expect(ask(devlink_st, fake_ui_st, "14 ui_override \"\" \"/tmp/p.xml\"") == "14 err bad_args \"a file without .., or \\\"\\\" \\\"\\\" to clear all\"")
expect(ask(devlink_st, fake_ui_st, "15 ui_override \"a.xml\" \"../b.xml\"") == "15 err bad_args \"a file without .., or \\\"\\\" \\\"\\\" to clear all\"")
}
}

View file

@ -0,0 +1,17 @@
# ui.ludic - what the link asks the game's interface (ludic.ui): which screen is up, a component's model
# or the whole tree written to a file, and a template or stylesheet read from another file for a while.
# Every member defaults to "not offered"; the big answers go through a file, as a shot does.
export property DevlinkUiScreen {
root: string = "" # the screen's root class
components: string = "" # every component up, comma-separated
}
export port DevlinkUi {
screen: fn(DevlinkUiScreen) -> int = fn dlu_screen
model: fn(string, string) -> int = fn dlu_s2 # class, out path -> 0 written, -1 no such instance
tree: fn(string) -> int = fn dlu_s1 # out path -> 0 written, -1 failed
override: fn(string, string) -> int = fn dlu_s2 # path, file ("" clears it; "" "" clears all) -> 0, -1
}
function dlu_screen(s: DevlinkUiScreen) -> int { return DL_UNBOUND }
function dlu_s1(a: string) -> int { return DL_UNBOUND }
function dlu_s2(a: string, b: string) -> int { return DL_UNBOUND }

View file

@ -39,6 +39,10 @@ function dl_verb(st: mut DevlinkState, id: int) -> void {
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 if dl_word(st, "ui_screen") { dl_ui_screen(st, id) }
else if dl_word(st, "ui_model") { dl_ui_model(st, id) }
else if dl_word(st, "ui_tree") { dl_ui_tree(st, id) }
else if dl_word(st, "ui_override") { dl_ui_override(st, id) }
else { dl_err(st, id, "unknown_verb", "no such verb") }
}

View file

@ -0,0 +1,76 @@
# verbs_ui.ludic - ui_screen, ui_model, ui_tree and ui_override: answered at once, the big ones in a file
function dl_ui_screen(st: mut DevlinkState, id: int) -> void {
if dl_bad_args(st, id) { return }
let s = st.dl_ui
if DevlinkUi.screen(s) == DL_UNBOUND {
dl_err(st, id, "unbound", "not offered")
return
}
dl_ok(st, id)
dl_b(st, 32)
dl_q(st, s.root)
dl_b(st, 32)
dl_q(st, s.components)
}
# a file the game writes: a path with no "..", as a shot's
function dl_out_path(st: DevlinkState, n: int) -> bool {
if n < 1 or n > len(st.dl_path) { return false }
for i in 0 .. n - 1 { if st.dl_str[i] == 46 and st.dl_str[i + 1] == 46 { return false } }
return true
}
# the answer to a member that wrote a file: ok and the path, not offered, or what went wrong
function dl_wrote(st: mut DevlinkState, id: int, r: int, n: int, why: string) -> void {
if r == DL_UNBOUND { dl_err(st, id, "unbound", "not offered") }
else if r < 0 { dl_err(st, id, "failed", why) }
else {
dl_ok(st, id)
dl_b(st, 32)
dl_q_bytes(st, st.dl_str, n)
}
}
function dl_ui_model(st: mut DevlinkState, id: int) -> void {
let nc = dl_quoted(st)
if st.dl_bad or nc < 1 {
dl_err(st, id, "bad_args", "a class and a path")
return
}
let cls = dl_text(st, nc)
let n = dl_quoted(st)
if dl_bad_args(st, id) { return }
if not dl_out_path(st, n) {
dl_err(st, id, "bad_args", "a path without ..")
return
}
dl_wrote(st, id, DevlinkUi.model(cls, dl_text(st, n)), n, "no such instance")
}
function dl_ui_tree(st: mut DevlinkState, id: int) -> void {
let n = dl_quoted(st)
if dl_bad_args(st, id) { return }
if not dl_out_path(st, n) {
dl_err(st, id, "bad_args", "a path without ..")
return
}
dl_wrote(st, id, DevlinkUi.tree(dl_text(st, n)), n, "not written")
}
# "" for the file clears that path's override; "" "" clears every one
function dl_ui_override(st: mut DevlinkState, id: int) -> void {
let np = dl_quoted(st)
if st.dl_bad {
dl_err(st, id, "bad_args", "a path and a file")
return
}
let path = dl_text(st, np)
let nf = dl_quoted(st)
if dl_bad_args(st, id) { return }
let file_ok = nf == 0 or dl_out_path(st, nf)
if not file_ok or (np == 0 and nf > 0) {
dl_err(st, id, "bad_args", "a file without .., or \"\" \"\" to clear all")
return
}
let r = DevlinkUi.override(path, dl_text(st, nf))
if r < 0 and r != DL_UNBOUND { dl_err(st, id, "failed", "not overridden") } else { dl_answer(st, id, r) }
}