From 5bc2342174cf88177398cae888e34fcdfbfb2f92 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 22:48:34 +0300 Subject: [PATCH] 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 --- changes/devlink-ui.md | 7 ++ packages/ludic.devlink/README.md | 7 +- packages/ludic.devlink/index.ludic | 2 + packages/ludic.devlink/state.ludic | 1 + .../ludic.devlink/tests/devlink_ui_test.ludic | 90 +++++++++++++++++++ packages/ludic.devlink/ui.ludic | 17 ++++ packages/ludic.devlink/verbs.ludic | 4 + packages/ludic.devlink/verbs_ui.ludic | 76 ++++++++++++++++ 8 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 changes/devlink-ui.md create mode 100644 packages/ludic.devlink/tests/devlink_ui_test.ludic create mode 100644 packages/ludic.devlink/ui.ludic create mode 100644 packages/ludic.devlink/verbs_ui.ludic diff --git a/changes/devlink-ui.md b/changes/devlink-ui.md new file mode 100644 index 00000000..c06c2143 --- /dev/null +++ b/changes/devlink-ui.md @@ -0,0 +1,7 @@ +bump: minor +type: feature +**`ludic.devlink`: the interface's verbs.** A `DevlinkUi` port, every member defaulting to "not offered": +`ui_screen` (the screen's root class and its components), `ui_model "" ""` and `ui_tree ""` +(the game writes a component's model or the whole tree to a file, no `..`, and the answer names it, so a +datagram stays small) and `ui_override "" ""` (a template or stylesheet read from another file +and reloaded keeping state; `""` clears one, `"" ""` all). Answered at once; nothing made per frame. diff --git a/packages/ludic.devlink/README.md b/packages/ludic.devlink/README.md index 2d139f33..972558ad 100644 --- a/packages/ludic.devlink/README.md +++ b/packages/ludic.devlink/README.md @@ -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 "" ""` and `ui_tree ""` have the game write the + first instance's model, or the whole tree, to that file (no `..`) and answer `ok ""`, so a datagram + stays small; `ui_override "" ""` 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). diff --git a/packages/ludic.devlink/index.ludic b/packages/ludic.devlink/index.ludic index 473895bc..be7a2ed6 100644 --- a/packages/ludic.devlink/index.ludic +++ b/packages/ludic.devlink/index.ludic @@ -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" diff --git a/packages/ludic.devlink/state.ludic b/packages/ludic.devlink/state.ludic index bda7fd0e..f33a80b9 100644 --- a/packages/ludic.devlink/state.ludic +++ b/packages/ludic.devlink/state.ludic @@ -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 } diff --git a/packages/ludic.devlink/tests/devlink_ui_test.ludic b/packages/ludic.devlink/tests/devlink_ui_test.ludic new file mode 100644 index 00000000..9eb1d8e8 --- /dev/null +++ b/packages/ludic.devlink/tests/devlink_ui_test.ludic @@ -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\"") + } +} diff --git a/packages/ludic.devlink/ui.ludic b/packages/ludic.devlink/ui.ludic new file mode 100644 index 00000000..b5c37509 --- /dev/null +++ b/packages/ludic.devlink/ui.ludic @@ -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 } diff --git a/packages/ludic.devlink/verbs.ludic b/packages/ludic.devlink/verbs.ludic index 83b9023f..3dcbb7d4 100644 --- a/packages/ludic.devlink/verbs.ludic +++ b/packages/ludic.devlink/verbs.ludic @@ -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") } } diff --git a/packages/ludic.devlink/verbs_ui.ludic b/packages/ludic.devlink/verbs_ui.ludic new file mode 100644 index 00000000..0ddb2851 --- /dev/null +++ b/packages/ludic.devlink/verbs_ui.ludic @@ -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) } +}