Просмотр исходного кода

app: add pydrk utility for working with the scenegraph realtime and interactively

x 2 недель назад
Родитель
Сommit
2b4a35b4c6

+ 1 - 1
bin/app/pydrk/__init__.py

@@ -1,6 +1,6 @@
 from .api import (Api, ErrorCode, SceneNodeType,
 from .api import (Api, ErrorCode, SceneNodeType,
                   PropertyType, PropertySubType, CallArgType, Property,
                   PropertyType, PropertySubType, CallArgType, Property,
-                  Expr, vertex, face)
+                  Expr)
 from .event import EventLoop, make_sub_socket
 from .event import EventLoop, make_sub_socket
 from .print_tree import print_tree
 from .print_tree import print_tree
 from .vector_shape import VectorShape
 from .vector_shape import VectorShape

+ 4 - 0
bin/app/pydrk/__main__.py

@@ -0,0 +1,4 @@
+from pydrk import cli
+
+if __name__ == "__main__":
+    cli.main()

+ 64 - 164
bin/app/pydrk/api.py

@@ -209,6 +209,8 @@ class ErrorCode:
     CONTACT_NOT_FOUND = 47
     CONTACT_NOT_FOUND = 47
     SERIAL_ERR = 48
     SERIAL_ERR = 48
     TURSO_ERR = 49
     TURSO_ERR = 49
+    UNSUPPORTED_NODE_TYPE = 50
+    NODE_NOT_REMOVABLE = 51
 
 
     @staticmethod
     @staticmethod
     def to_str(errc):
     def to_str(errc):
@@ -297,42 +299,51 @@ class ErrorCode:
                 return "serial_err"
                 return "serial_err"
             case ErrorCode.TURSO_ERR:
             case ErrorCode.TURSO_ERR:
                 return "turso_err"
                 return "turso_err"
+            case ErrorCode.UNSUPPORTED_NODE_TYPE:
+                return "unsupported_node_type"
+            case ErrorCode.NODE_NOT_REMOVABLE:
+                return "node_not_removable"
             case _:
             case _:
                 return "unknown"
                 return "unknown"
 
 
-def vertex(x, y, r, g, b, a, u, v):
-    buf = bytearray()
-    serial.write_f32(buf, x)
-    serial.write_f32(buf, y)
-    serial.write_f32(buf, r)
-    serial.write_f32(buf, g)
-    serial.write_f32(buf, b)
-    serial.write_f32(buf, a)
-    serial.write_f32(buf, u)
-    serial.write_f32(buf, v)
-    return buf
-
-def face(idx1, idx2, idx3):
-    buf = bytearray()
-    serial.write_u32(buf, idx1)
-    serial.write_u32(buf, idx2)
-    serial.write_u32(buf, idx3)
-    return buf
-
 class Api:
 class Api:
 
 
     def __init__(self, addr="127.0.0.1", port=9484):
     def __init__(self, addr="127.0.0.1", port=9484):
-        context = zmq.Context()
-        self.socket = context.socket(zmq.REQ)
+        self.addr = addr
+        self.port = port
+        self.context = zmq.Context()
+        self.socket = self._make_socket()
+
+    def _make_socket(self):
+        socket = self.context.socket(zmq.REQ)
         #self.socket.setsockopt(zmq.IPV6, True)
         #self.socket.setsockopt(zmq.IPV6, True)
-        self.socket.connect(f"tcp://{addr}:{port}")
+        # Fail fast with zmq.error.Again when no app is listening, so the
+        # CLI can report the endpoint it tried instead of hanging forever.
+        socket.setsockopt(zmq.RCVTIMEO, 3000)
+        # Discard undelivered messages at exit instead of blocking on
+        # context teardown when no app ever answered.
+        socket.setsockopt(zmq.LINGER, 0)
+        socket.connect(f"tcp://{self.addr}:{self.port}")
+        return socket
+
+    def _reset_socket(self):
+        # A REQ socket whose reply timed out is stuck mid-request and
+        # rejects further sends; replace it so later requests work.
+        try:
+            self.socket.close(linger=0)
+        except zmq.error.ZMQError:
+            pass
+        self.socket = self._make_socket()
 
 
     def _make_request(self, cmd, payload):
     def _make_request(self, cmd, payload):
         req_cmd = bytearray()
         req_cmd = bytearray()
         serial.write_u8(req_cmd, cmd)
         serial.write_u8(req_cmd, cmd)
-        self.socket.send_multipart([req_cmd, payload])
-
-        errc, reply = self.socket.recv_multipart()
+        try:
+            self.socket.send_multipart([req_cmd, payload])
+            errc, reply = self.socket.recv_multipart()
+        except zmq.error.ZMQError:
+            self._reset_socket()
+            raise
         errc = int.from_bytes(errc, "little")
         errc = int.from_bytes(errc, "little")
         cursor = serial.Cursor(reply)
         cursor = serial.Cursor(reply)
         match errc:
         match errc:
@@ -422,6 +433,10 @@ class Api:
                 raise exc.SerialErr
                 raise exc.SerialErr
             case ErrorCode.TURSO_ERR:
             case ErrorCode.TURSO_ERR:
                 raise exc.TursoErr
                 raise exc.TursoErr
+            case ErrorCode.UNSUPPORTED_NODE_TYPE:
+                raise exc.UnsupportedNodeType
+            case ErrorCode.NODE_NOT_REMOVABLE:
+                raise exc.NodeNotRemovable
             case _:
             case _:
                 raise exc.UnknownError(f"unknown error code: {errc}")
                 raise exc.UnknownError(f"unknown error code: {errc}")
         return cursor
         return cursor
@@ -430,14 +445,6 @@ class Api:
         response = self._make_request(Command.HELLO, bytearray())
         response = self._make_request(Command.HELLO, bytearray())
         return serial.decode_str(response)
         return serial.decode_str(response)
 
 
-    def get_info(self, node_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        cur = self._make_request(Command.GET_INFO, req)
-        name = serial.decode_str(cur)
-        type = serial.read_u8(cur)
-        return (name, type)
-
     def get_children(self, node_path):
     def get_children(self, node_path):
         req = bytearray()
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, node_path)
@@ -451,19 +458,6 @@ class Api:
             children.append((child_name, child_id, child_type))
             children.append((child_name, child_id, child_type))
         return children
         return children
 
 
-    def get_parents(self, node_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        cur = self._make_request(Command.GET_PARENTS, req)
-        parents_len = serial.decode_varint(cur)
-        parents = []
-        for _ in range(parents_len):
-            parent_name = serial.decode_str(cur)
-            parent_id = serial.read_u32(cur)
-            parent_type = serial.read_u8(cur)
-            parents.append((parent_name, parent_id, parent_type))
-        return parents
-
     def get_properties(self, node_path):
     def get_properties(self, node_path):
         req = bytearray()
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, node_path)
@@ -537,7 +531,7 @@ class Api:
             case _:
             case _:
                 raise Exception("unknown property type returned")
                 raise Exception("unknown property type returned")
 
 
-    def get_property_value(self, node_path, prop_name):
+    def get_property_value_full(self, node_path, prop_name):
         req = bytearray()
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, node_path)
         serial.encode_str(req, prop_name)
         serial.encode_str(req, prop_name)
@@ -548,116 +542,31 @@ class Api:
             prop_status = serial.read_u8(cur)
             prop_status = serial.read_u8(cur)
             match prop_status:
             match prop_status:
                 case PropertyStatus.NULL:
                 case PropertyStatus.NULL:
-                    return None
+                    return (PropertyStatus.NULL, None)
                 case PropertyStatus.EXPR:
                 case PropertyStatus.EXPR:
-                    return Expr(serial.decode_str(cur))
+                    return (PropertyStatus.EXPR, Expr(serial.decode_str(cur)))
                 case PropertyStatus.UNSET | PropertyStatus.OK:
                 case PropertyStatus.UNSET | PropertyStatus.OK:
-                    return Api.read_prop_val(cur, prop_type)
+                    return (prop_status, Api.read_prop_val(cur, prop_type))
 
 
-        vals = serial.decode_arr(cur, prop_read_fn)
-        return vals
+        return serial.decode_arr(cur, prop_read_fn)
 
 
-    def add_node(self, node_name, node_type):
+    def get_property_value(self, node_path, prop_name):
+        vals = self.get_property_value_full(node_path, prop_name)
+        return [val for (_, val) in vals]
+
+    def add_node(self, parent_path, name, node_type):
         req = bytearray()
         req = bytearray()
-        serial.encode_str(req, node_name)
+        serial.encode_str(req, parent_path)
+        serial.encode_str(req, name)
         serial.write_u8(req, int(node_type))
         serial.write_u8(req, int(node_type))
         cur = self._make_request(Command.ADD_NODE, req)
         cur = self._make_request(Command.ADD_NODE, req)
         node_id = serial.read_u32(cur)
         node_id = serial.read_u32(cur)
         return node_id
         return node_id
 
 
-    def remove_node(self, node_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        self._make_request(Command.REMOVE_NODE, req)
-
-    def rename_node(self, node_id, node_name):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, node_name)
-        self._make_request(Command.RENAME_NODE, req)
-
-    def scan_dangling(self):
-        cur = self._make_request(Command.SCAN_DANGLING, bytearray())
-        dangling_len = serial.decode_varint(cur)
-        dangling = []
-        for _ in range(dangling_len):
-            node_id = serial.read_u32(cur)
-            dangling.append(node_id)
-        return dangling
-
-    def lookup_node_id(self, node_path):
+    def remove_node(self, node_path):
         req = bytearray()
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, node_path)
-        try:
-            cur = self._make_request(Command.LOOKUP_NODE_ID, req)
-        except exc.NodeNotFound:
-            return None
-        return serial.read_u32(cur)
-
-    def add_property(self, node_id, prop):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, prop.name)
-        serial.write_u8(req, int(prop.type))
-        serial.write_u8(req, int(prop.subtype))
-        serial.write_u32(req, int(prop.array_len))
-
-        def write_defaults(by):
-            assert prop.defaults is not None
-            defaults_len = len(prop.defaults)
-            serial.encode_varint(req, defaults_len)
-            for default in prop.defaults:
-                match prop.type:
-                    case PropertyType.UINT32:
-                        serial.write_u32(req, default)
-                    case PropertyType.FLOAT32:
-                        serial.write_f32(req, default)
-                    case PropertyType.STR:
-                        serial.encode_str(req, default)
-                    case _:
-                        raise exc.PropertyWrongType
-
-        serial.encode_opt(req, prop.defaults, write_defaults)
-
-        serial.encode_str(req, prop.ui_name)
-        serial.encode_str(req, prop.desc)
-        serial.write_u8(req, int(prop.is_null_allowed))
-        serial.write_u8(req, int(prop.is_expr_allowed))
-
-        def write_mxx(v, by):
-            assert v is not None
-            match prop.type:
-                case PropertyType.UINT32:
-                    serial.write_u32(req, v)
-                case PropertyType.FLOAT32:
-                    serial.write_f32(req, v)
-                case _:
-                    raise exc.PropertyWrongType
-
-        write_min = lambda by: write_mxx(prop.min_val, by)
-        write_max = lambda by: write_mxx(prop.max_val, by)
-
-        serial.encode_opt(req, prop.min_val, write_min)
-        serial.encode_opt(req, prop.max_val, write_max)
-
-        serial.encode_varint(req, len(prop.enum_items))
-        for enum_item in prop.enum_items:
-            if prop.type != PropertyType.ENUM:
-                raise exc.PropertyWrongType
-            serial.encode_str(req, enum_item)
-        self._make_request(Command.ADD_PROPERTY, req)
-
-    def link_node(self, child_id, parent_id):
-        req = bytearray()
-        serial.write_u32(req, child_id)
-        serial.write_u32(req, parent_id)
-        self._make_request(Command.LINK_NODE, req)
-
-    def unlink_node(self, child_id, parent_id):
-        req = bytearray()
-        serial.write_u32(req, child_id)
-        serial.write_u32(req, parent_id)
-        self._make_request(Command.UNLINK_NODE, req)
+        self._make_request(Command.REMOVE_NODE, req)
 
 
     def set_property_null(self, node_path, prop_name, i):
     def set_property_null(self, node_path, prop_name, i):
         req = bytearray()
         req = bytearray()
@@ -712,6 +621,15 @@ class Api:
         serial.encode_str(req, val)
         serial.encode_str(req, val)
         self._make_request(Command.SET_PROPERTY_VALUE, req)
         self._make_request(Command.SET_PROPERTY_VALUE, req)
 
 
+    def set_property_node_id(self, node_path, prop_name, i, val):
+        req = bytearray()
+        serial.encode_str(req, node_path)
+        serial.encode_str(req, prop_name)
+        serial.write_u32(req, i)
+        serial.write_u8(req, PropertyType.SCENE_NODE_ID)
+        serial.write_u32(req, val)
+        self._make_request(Command.SET_PROPERTY_VALUE, req)
+
     def set_property_expr(self, node_path, prop_name, i, expr_str):
     def set_property_expr(self, node_path, prop_name, i, expr_str):
         req = bytearray()
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, node_path)
@@ -761,24 +679,6 @@ class Api:
         slot_id = serial.read_u32(cur)
         slot_id = serial.read_u32(cur)
         return slot_id
         return slot_id
 
 
-    def unregister_slot(self, node_id, sig_name, slot_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, sig_name)
-        serial.write_u32(req, slot_id)
-        self._make_request(Command.UNREGISTER_SLOT, req)
-
-    def lookup_slot_id(self, node_id, sig_name, slot_name):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, sig_name)
-        serial.encode_str(req, slot_name)
-        try:
-            cur = self._make_request(Command.LOOKUP_SLOT_ID, req)
-        except exc.SlotNotFound:
-            return None
-        return serial.read_u32(cur)
-
     def get_slots(self, node_path, sig_name):
     def get_slots(self, node_path, sig_name):
         req = bytearray()
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, node_path)
@@ -818,7 +718,7 @@ class Api:
             return (arg_name, arg_desc, arg_type)
             return (arg_name, arg_desc, arg_type)
 
 
         args = serial.decode_arr(cur, read_arg)
         args = serial.decode_arr(cur, read_arg)
-        results = serial.decode_arr(cur, read_arg)
+        results = serial.decode_opt(cur, lambda cur: serial.decode_arr(cur, read_arg))
 
 
         return (args, results)
         return (args, results)
 
 

+ 1127 - 0
bin/app/pydrk/cli.py

@@ -0,0 +1,1127 @@
+"""Command-line interface for driving a running app's scene graph over
+the netdebug ZeroMQ backend. Run from `bin/app` as `python -m pydrk ...`.
+With no subcommand an interactive shell is started."""
+
+import argparse
+import math
+import re
+import shlex
+import sys
+
+import zmq
+
+from . import exc, serial
+from .api import (
+    Api,
+    CallArgType,
+    Expr,
+    PropertyStatus,
+    PropertySubType,
+    PropertyType,
+    SceneNodeType,
+)
+from .print_tree import print_tree
+from .vector_shape import VectorShape
+
+
+class UsageError(Exception):
+    pass
+
+
+PYDRK_ERRORS = tuple(
+    obj for obj in vars(exc).values() if isinstance(obj, type) and issubclass(obj, Exception)
+)
+
+
+def error_name(err):
+    if isinstance(err, exc.UnknownError):
+        return str(err)
+    name = type(err).__name__
+    name = name.replace("ID", "Id")
+    return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
+
+
+NODE_TYPE_NAMES = {
+    getattr(SceneNodeType, name): name.lower()
+    for name in dir(SceneNodeType)
+    if name.isupper()
+}
+
+
+def resolve_path(cwd, arg):
+    if arg.startswith("/"):
+        tokens = arg.split("/")
+    else:
+        tokens = cwd + arg.split("/")
+    out = []
+    for token in tokens:
+        if token in ("", "."):
+            continue
+        if token == "..":
+            if out:
+                out.pop()
+            continue
+        out.append(token)
+    return "/" + "/".join(out)
+
+
+def format_value(val):
+    if val is None:
+        return "null"
+    if isinstance(val, Expr):
+        return f'"{val}"'
+    if isinstance(val, bool):
+        return "true" if val else "false"
+    if isinstance(val, str):
+        return f'"{val}"'
+    return str(val)
+
+
+def prop_summary(api, path, prop):
+    if prop.type == PropertyType.VECTOR_SHAPE:
+        return "<shape>"
+    vals = api.get_property_value(path, prop.name)
+    formatted = [format_value(v) for v in vals]
+    if len(formatted) == 1:
+        return formatted[0]
+    return "[" + ", ".join(formatted) + "]"
+
+
+def format_status_value(status, val):
+    match status:
+        case PropertyStatus.EXPR:
+            return f'expr "{val}"'
+        case PropertyStatus.NULL:
+            return "null"
+        case PropertyStatus.UNSET:
+            return "unset"
+        case _:
+            return f"value {format_value(val)}"
+
+
+def parse_get_args(tokens, cwd):
+    tokens = list(tokens)
+    idx = None
+    if tokens and tokens[-1].isdigit():
+        idx = int(tokens.pop())
+    if not tokens:
+        raise UsageError("usage: get [path] PROP [idx]")
+    prop_name = tokens.pop()
+    if tokens:
+        path = resolve_path(cwd, "/".join(tokens))
+    else:
+        path = "/" + "/".join(cwd)
+    return (path, prop_name, idx)
+
+
+def parse_show_args(tokens, cwd):
+    tokens = list(tokens)
+    if not tokens:
+        raise UsageError("usage: show [path] PROP")
+    prop_name = tokens.pop()
+    if tokens:
+        path = resolve_path(cwd, "/".join(tokens))
+    else:
+        path = "/" + "/".join(cwd)
+    return (path, prop_name)
+
+
+def parse_set_args(tokens, cwd):
+    tokens = list(tokens)
+    if not tokens:
+        raise UsageError("usage: set [path] PROP [idx] VAL")
+    value = tokens.pop()
+    idx = 0
+    if tokens and tokens[-1].isdigit():
+        idx = int(tokens.pop())
+    if not tokens:
+        raise UsageError("missing property name in: set [path] PROP [idx] VAL")
+    prop_name = tokens.pop()
+    if tokens:
+        path = resolve_path(cwd, "/".join(tokens))
+    else:
+        path = "/" + "/".join(cwd)
+    return (path, prop_name, idx, value)
+
+
+def parse_uint32(token):
+    try:
+        val = int(token, 0)
+    except ValueError:
+        raise UsageError(f"invalid uint32 value: {token}")
+    if not 0 <= val <= 0xFFFFFFFF:
+        raise UsageError(f"uint32 value out of range: {token}")
+    return val
+
+
+def encode_set_value(api, path, prop, token, index):
+    if token == "null":
+        api.set_property_null(path, prop.name, index)
+        return
+    match prop.type:
+        case PropertyType.BOOL:
+            if token == "true":
+                api.set_property_bool(path, prop.name, index, True)
+            elif token == "false":
+                api.set_property_bool(path, prop.name, index, False)
+            else:
+                raise UsageError(f"invalid bool value: {token} (use true/false)")
+        case PropertyType.UINT32:
+            api.set_property_u32(path, prop.name, index, parse_uint32(token))
+        case PropertyType.SCENE_NODE_ID:
+            api.set_property_node_id(path, prop.name, index, parse_uint32(token))
+        case PropertyType.FLOAT32:
+            try:
+                val = float(token)
+            except ValueError:
+                raise UsageError(f"invalid float32 value: {token}")
+            api.set_property_f32(path, prop.name, index, val)
+        case PropertyType.STR:
+            api.set_property_str(path, prop.name, index, token)
+        case PropertyType.ENUM:
+            if prop.enum_items is None or token not in prop.enum_items:
+                raise UsageError(f"invalid enum item: {token} (not in {prop.enum_items})")
+            api.set_property_enum(path, prop.name, index, token)
+        case _:
+            raise UsageError(f"cannot set properties of type {PropertyType.to_str(prop.type)}")
+
+
+def prop_meta_lines(prop):
+    array_len = "unbounded" if prop.array_len == 0 else str(prop.array_len)
+    lines = [
+        f"{prop.name}:",
+        f"  type: {PropertyType.to_str(prop.type)}",
+        f"  subtype: {PropertySubType.to_str(prop.subtype)}",
+        f"  array_len: {array_len}",
+        f"  null_allowed: {'yes' if prop.is_null_allowed else 'no'}",
+        f"  expr_allowed: {'yes' if prop.is_expr_allowed else 'no'}",
+    ]
+    if prop.min_val is not None and prop.max_val is not None:
+        lines.append(f"  range: [{format_value(prop.min_val)}, {format_value(prop.max_val)}]")
+    if prop.enum_items is not None:
+        lines.append(f"  enum_items: [" + ", ".join(prop.enum_items) + "]")
+    if prop.ui_name:
+        lines.append(f"  ui_name: {prop.ui_name}")
+    if prop.desc:
+        lines.append(f"  desc: {prop.desc}")
+    return lines
+
+
+def run_command(api, handler, args, cwd):
+    try:
+        handler(api, args, cwd)
+    except UsageError as err:
+        print(f"error: {err}", file=sys.stderr)
+        sys.exit(1)
+    except PYDRK_ERRORS as err:
+        print(f"error: {error_name(err)}", file=sys.stderr)
+        sys.exit(1)
+    except zmq.error.Again:
+        print(f"error: no reply from {api.addr}:{api.port}", file=sys.stderr)
+        sys.exit(1)
+    except zmq.error.ZMQError as err:
+        print(f"error: {err}", file=sys.stderr)
+        sys.exit(1)
+
+
+COMMAND_PARSERS = {}
+
+MAIN_PARSER = argparse.ArgumentParser()
+
+
+SHELL_BUILTINS = ("cd", "pwd", "exit", "quit")
+
+
+BUILTIN_HELP = {
+    "cd": "cd [path]           change the working node (no arg = /, .. pops one)",
+    "pwd": "pwd                 print the working node path",
+    "exit": "exit | quit         leave the shell (Ctrl-D also works)",
+    "quit": "exit | quit         leave the shell (Ctrl-D also works)",
+    "help": "help [command]      show overall or per-command help",
+}
+
+
+def print_help(command=None):
+    if command is None:
+        MAIN_PARSER.print_help()
+        print()
+        print("shell builtins (interactive mode only):")
+        for name in ("cd", "pwd", "exit", "help"):
+            print(f"  {BUILTIN_HELP[name]}")
+    elif command in COMMAND_PARSERS:
+        COMMAND_PARSERS[command].print_help()
+    elif command in BUILTIN_HELP:
+        print(BUILTIN_HELP[command])
+    else:
+        raise UsageError(f"unknown command: {command}")
+
+
+def cmd_help(api, args, cwd):
+    print_help(getattr(args, "topic", None))
+
+
+class ShellExit(Exception):
+    pass
+
+
+class Shell:
+    def __init__(self, api):
+        self.api = api
+        self.cwd = []
+
+    def prompt(self):
+        return f"pydrk:/{'/'.join(self.cwd)}> "
+
+    def run(self):
+        setup_completion(self)
+        while True:
+            try:
+                line = input(self.prompt())
+            except EOFError:
+                print()
+                return
+            except KeyboardInterrupt:
+                print()
+                continue
+            try:
+                self.execute(line)
+            except ShellExit:
+                return
+            clear_completion_cache()
+
+    def execute(self, line):
+        try:
+            tokens = shlex.split(line)
+        except ValueError as err:
+            print(f"error: {err}", file=sys.stderr)
+            return
+        if not tokens:
+            return
+
+        cmd = tokens[0]
+        if cmd in ("exit", "quit"):
+            raise ShellExit
+        if cmd == "pwd":
+            print("/" + "/".join(self.cwd))
+            return
+        if cmd == "cd":
+            self.cd(tokens[1:])
+            return
+
+        parser = COMMAND_PARSERS.get(cmd)
+        if parser is None:
+            print(f"error: unknown command: {cmd}", file=sys.stderr)
+            return
+        try:
+            args = parser.parse_args(tokens[1:])
+        except SystemExit:
+            return
+        try:
+            args.func(self.api, args, self.cwd)
+        except UsageError as err:
+            print(f"error: {err}", file=sys.stderr)
+        except PYDRK_ERRORS as err:
+            print(f"error: {error_name(err)}", file=sys.stderr)
+        except zmq.error.Again:
+            print(f"error: no reply from {self.api.addr}:{self.api.port}", file=sys.stderr)
+        except zmq.error.ZMQError as err:
+            print(f"error: {err}", file=sys.stderr)
+
+    def cd(self, tokens):
+        path = resolve_path(self.cwd, tokens[0] if tokens else "/")
+        if path != "/":
+            parent, _, name = path.rpartition("/")
+            try:
+                children = self.api.get_children(parent or "/")
+            except UsageError as err:
+                print(f"error: {err}", file=sys.stderr)
+                return
+            except PYDRK_ERRORS as err:
+                print(f"error: {error_name(err)}", file=sys.stderr)
+                return
+            except zmq.error.Again:
+                print(f"error: no reply from {self.api.addr}:{self.api.port}", file=sys.stderr)
+                return
+            except zmq.error.ZMQError as err:
+                print(f"error: {err}", file=sys.stderr)
+                return
+            if not any(child_name == name for (child_name, _, _) in children):
+                print(f"error: node_not_found: {path}", file=sys.stderr)
+                return
+        self.cwd = [token for token in path.split("/") if token]
+
+
+class Completer:
+    def __init__(self, shell):
+        self.shell = shell
+        self.cache = {}
+
+    def clear_cache(self):
+        self.cache.clear()
+
+    def cached_children(self, path):
+        if path not in self.cache:
+            try:
+                self.cache[path] = [name for (name, _, _) in self.shell.api.get_children(path)]
+            except Exception:
+                self.cache[path] = []
+        return self.cache[path]
+
+    def cached_props(self, path):
+        key = "props:" + path
+        if key not in self.cache:
+            try:
+                self.cache[key] = [prop.name for prop in self.shell.api.get_properties(path)]
+            except Exception:
+                self.cache[key] = []
+        return self.cache[key]
+
+    def path_matches(self, token, text):
+        # `token` is the whitespace-delimited word up to the cursor,
+        # including any already-typed path components. `text` is what
+        # readline wants replaced: readline's default completer delimiters
+        # include "/", so text may be only the fragment after the last
+        # slash. Matches are returned with the shared head stripped so
+        # they align with readline's replacement window.
+        strip = len(token) - len(text)
+        idx = token.rfind("/")
+        if idx == -1:
+            head, prefix, prefix_part = "", token, ""
+        else:
+            head, prefix = token[:idx], token[idx + 1:]
+            prefix_part = token[: idx + 1]
+        if token.startswith("/"):
+            base = resolve_path(self.shell.cwd, head if head else "/")
+        else:
+            base = resolve_path(self.shell.cwd, head if head else ".")
+        return [
+            (prefix_part + name + "/")[strip:]
+            for name in self.cached_children(base)
+            if name.startswith(prefix)
+        ]
+
+    def matches(self, text):
+        import readline
+
+        buf = readline.get_line_buffer()
+        begidx = readline.get_begidx()
+
+        if begidx == 0:
+            words = sorted(set(COMMAND_PARSERS) | set(SHELL_BUILTINS))
+            return [word for word in words if word.startswith(text)]
+
+        typed = buf[:begidx]
+        head_tokens = typed.split()
+        cmd = head_tokens[0] if head_tokens else ""
+        token = typed[typed.rfind(" ") + 1 :] + text
+
+        if cmd in ("get", "set", "show") and len(head_tokens) == 1:
+            cwd_path = "/" + "/".join(self.shell.cwd)
+            found = []
+            if "/" not in token:
+                found += [name + "/" for name in self.cached_children(cwd_path)]
+                found += [name + " " for name in self.cached_props(cwd_path)]
+                return sorted(set(m for m in found if m.startswith(text)))
+            return sorted(set(self.path_matches(token, text)))
+
+        return sorted(set(self.path_matches(token, text)))
+
+    def complete(self, text, state):
+        found = self.matches(text)
+        return found[state] if state < len(found) else None
+
+
+_ACTIVE_COMPLETER = None
+
+
+def setup_completion(shell):
+    global _ACTIVE_COMPLETER
+    try:
+        import readline
+    except ImportError:
+        return
+    readline.parse_and_bind("tab: complete")
+    _ACTIVE_COMPLETER = Completer(shell)
+    readline.set_completer(_ACTIVE_COMPLETER.complete)
+
+
+def clear_completion_cache():
+    if _ACTIVE_COMPLETER is not None:
+        _ACTIVE_COMPLETER.clear_cache()
+
+
+def shell_main(args):
+    api = Api(args.addr, args.port)
+    Shell(api).run()
+
+
+def build_parser():
+    global MAIN_PARSER
+    endpoint_args = argparse.ArgumentParser(add_help=False)
+    endpoint_args.add_argument("--addr", default=argparse.SUPPRESS)
+    endpoint_args.add_argument("--port", type=int, default=argparse.SUPPRESS)
+
+    parser = argparse.ArgumentParser(prog="pydrk", description="Drive a running app over the netdebug backend")
+    parser.add_argument("--addr", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=9484)
+    parser.add_argument("--selftest", action="store_true", help="run built-in checks and exit")
+
+    sub = parser.add_subparsers(dest="command", metavar="<command>")
+
+    p = sub.add_parser("ping", parents=[endpoint_args], help="connectivity check")
+    p.set_defaults(func=cmd_ping)
+
+    p = sub.add_parser("ls", parents=[endpoint_args], help="list a node's children and properties")
+    p.add_argument("path", nargs="?", default=".")
+    p.set_defaults(func=cmd_ls)
+
+    p = sub.add_parser("tree", parents=[endpoint_args], help="recursively print a node's descendants")
+    p.add_argument("path", nargs="?", default=".")
+    p.add_argument("--depth", type=int, default=None)
+    p.set_defaults(func=cmd_tree)
+
+    p = sub.add_parser("props", parents=[endpoint_args], help="list a node's property metadata")
+    p.add_argument("path", nargs="?", default=".")
+    p.set_defaults(func=cmd_props)
+
+    p = sub.add_parser("get", parents=[endpoint_args], help="print a property's values")
+    p.add_argument("positionals", nargs="*", metavar="[path] PROP [idx]")
+    p.set_defaults(func=cmd_get)
+
+    p = sub.add_parser("show", parents=[endpoint_args], help="print everything about one property")
+    p.add_argument("positionals", nargs="*", metavar="[path] PROP")
+    p.set_defaults(func=cmd_show)
+
+    p = sub.add_parser("set", parents=[endpoint_args], help="set a property value")
+    p.add_argument("positionals", nargs="*", metavar="[path] PROP [idx] VAL")
+    p.add_argument("--expr", action="store_true", help="send VAL as expr source to compile server-side")
+    p.set_defaults(func=cmd_set)
+
+    p = sub.add_parser("methods", parents=[endpoint_args], help="list a node's methods")
+    p.add_argument("path")
+    p.set_defaults(func=cmd_methods)
+
+    p = sub.add_parser("signals", parents=[endpoint_args], help="list a node's signals")
+    p.add_argument("path")
+    p.set_defaults(func=cmd_signals)
+
+    p = sub.add_parser("mknode", parents=[endpoint_args], help="create and attach a node")
+    p.add_argument("parent_path")
+    p.add_argument("name")
+    p.add_argument("type")
+    p.set_defaults(func=cmd_mknode)
+
+    p = sub.add_parser(
+        "rmnode",
+        parents=[endpoint_args],
+        help="remove a node subtree (runtime-only, undone by restarting the app)",
+    )
+    p.add_argument("path")
+    p.set_defaults(func=cmd_rmnode)
+
+    p = sub.add_parser("set-shape", parents=[endpoint_args], help="push vector shape data")
+    p.add_argument("path")
+    p.add_argument("--prop", default="shape")
+    p.add_argument("--index", type=int, default=0)
+    p.add_argument(
+        "--box",
+        nargs=8,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--gbox",
+        nargs=12,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "R", "G", "B", "A", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--vgradient",
+        nargs=14,
+        action=ShapePrimAction,
+        metavar=(
+            "X1", "Y1", "X2", "Y2", "R", "G", "B", "A", "R", "G", "B", "A", "STRIPS", "GAMMA",
+        ),
+    )
+    p.add_argument(
+        "--outline",
+        nargs=9,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "BORDERPX", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--line",
+        nargs=9,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "THICKNESS", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--glow",
+        nargs=9,
+        action=ShapePrimAction,
+        metavar=("CX", "CY", "W", "H", "SEGMENTS", "R", "G", "B", "A"),
+    )
+    p.set_defaults(func=cmd_set_shape)
+
+    p = sub.add_parser("call", parents=[endpoint_args], help="call a node method")
+    p.add_argument("path")
+    p.add_argument("method")
+    p.add_argument("args", nargs="*", metavar="ARG")
+    p.set_defaults(func=cmd_call)
+
+    p = sub.add_parser("help", parents=[endpoint_args], help="show overall or per-command help")
+    p.add_argument("topic", nargs="?", default=None)
+    p.set_defaults(func=cmd_help)
+
+    COMMAND_PARSERS.clear()
+    COMMAND_PARSERS.update(sub.choices)
+    MAIN_PARSER = parser
+
+    return parser
+
+
+def cmd_ping(api, args, cwd):
+    print(api.hello())
+
+
+def cmd_ls(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for (name, node_id, node_type) in api.get_children(path):
+        print(f"{name} {node_id} {NODE_TYPE_NAMES.get(node_type, '?')}")
+    for prop in api.get_properties(path):
+        prop_type = PropertyType.to_str(prop.type)
+        print(f"{prop.name}: {prop_type} = {prop_summary(api, path, prop)}")
+
+
+def cmd_tree(api, args, cwd):
+    print_tree(api, resolve_path(cwd, args.path), args.depth)
+
+
+def cmd_props(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for prop in api.get_properties(path):
+        for line in prop_meta_lines(prop):
+            print(line)
+
+
+def print_prop_values(api, path, prop_name, idx):
+    vals = api.get_property_value_full(path, prop_name)
+    for i, (status, val) in enumerate(vals):
+        if idx is not None and i != idx:
+            continue
+        print(f"{i}: {format_status_value(status, val)}")
+
+
+def cmd_get(api, args, cwd):
+    path, prop_name, idx = parse_get_args(args.positionals, cwd)
+    print_prop_values(api, path, prop_name, idx)
+
+
+def find_prop(api, path, prop_name):
+    for prop in api.get_properties(path):
+        if prop.name == prop_name:
+            return prop
+    raise exc.PropertyNotFound
+
+
+def cmd_show(api, args, cwd):
+    path, prop_name = parse_show_args(args.positionals, cwd)
+    prop = find_prop(api, path, prop_name)
+    for line in prop_meta_lines(prop):
+        print(line)
+    if prop.depends:
+        depends = ", ".join(f"({i}, {name})" for (i, name) in prop.depends)
+        print(f"  depends: [{depends}]")
+    print_prop_values(api, path, prop_name, None)
+
+
+def cmd_set(api, args, cwd):
+    path, prop_name, idx, value = parse_set_args(args.positionals, cwd)
+    if args.expr:
+        api.set_property_expr(path, prop_name, idx, value)
+        return
+    prop = find_prop(api, path, prop_name)
+    encode_set_value(api, path, prop, value, idx)
+
+
+def format_signature(method_name, args, results):
+    arg_strs = [f"{name}: {CallArgType.to_str(typ)}" for (name, _, typ) in args]
+    result_strs = [f"{name}: {CallArgType.to_str(typ)}" for (name, _, typ) in (results or [])]
+    return f"{method_name}(" + ", ".join(arg_strs) + ") -> (" + ", ".join(result_strs) + ")"
+
+
+def cmd_methods(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for method_name in api.get_methods(path):
+        method_args, results = api.get_method(path, method_name)
+        print(format_signature(method_name, method_args, results))
+
+
+def cmd_signals(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for sig_name in api.get_signals(path):
+        print(sig_name)
+
+
+NODE_TYPES = {
+    "layer": SceneNodeType.LAYER,
+    "vector_art": SceneNodeType.VECTOR_ART,
+}
+
+
+def cmd_mknode(api, args, cwd):
+    node_type = NODE_TYPES.get(args.type)
+    if node_type is None:
+        raise UsageError(f"unsupported node type: {args.type} (supported: {', '.join(NODE_TYPES)})")
+    parent_path = resolve_path(cwd, args.parent_path)
+    node_id = api.add_node(parent_path, args.name, node_type)
+    path = parent_path.rstrip("/") + "/" + args.name
+    print(f"id={node_id} path={path}")
+
+
+def cmd_rmnode(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    api.remove_node(path)
+
+
+SHAPE_MAX_VERTS = 65536
+
+
+class ShapePrimAction(argparse.Action):
+    # argparse "append" actions keep one list per flag, losing the order
+    # between different flags. This action records (flag, values) pairs in
+    # true command-line order so primitives join as given.
+    def __call__(self, parser, namespace, values, option_string=None):
+        prims = list(getattr(namespace, "prims", []))
+        prims.append(((option_string or "").lstrip("-"), values))
+        namespace.prims = prims
+
+
+def coord_arg(token):
+    try:
+        return float(token)
+    except ValueError:
+        return token
+
+
+def num_arg(token, what):
+    try:
+        return float(token)
+    except ValueError:
+        raise UsageError(f"invalid {what} value: {token}")
+
+
+def int_arg(token, what):
+    try:
+        return int(token, 0)
+    except ValueError:
+        raise UsageError(f"invalid {what} value: {token}")
+
+
+def parse_shape_color_args(vals, count):
+    if len(vals) != count:
+        raise UsageError(f"expected {count} color values (R G B A), got {' '.join(vals)}")
+    try:
+        return [float(v) for v in vals]
+    except ValueError:
+        raise UsageError(f"invalid color value: {' '.join(vals)}")
+
+
+def build_shape(prims):
+    shape = VectorShape()
+    for (name, vals) in prims:
+        match name:
+            case "box":
+                shape.add_filled_box(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    parse_shape_color_args(vals[4:], 4),
+                )
+            case "gbox":
+                top = parse_shape_color_args(vals[4:8], 4)
+                bottom = parse_shape_color_args(vals[8:], 4)
+                shape.add_gradient_box(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    [top, top, bottom, bottom],
+                )
+            case "vgradient":
+                top = parse_shape_color_args(vals[4:8], 4)
+                bottom = parse_shape_color_args(vals[8:12], 4)
+                strips = int_arg(vals[12], "strips")
+                gamma = num_arg(vals[13], "gamma")
+                if strips <= 0:
+                    raise UsageError(f"strips must be positive, got {strips}")
+                shape.add_smooth_vertical_gradient(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    top,
+                    bottom,
+                    strips,
+                    gamma,
+                )
+            case "outline":
+                shape.add_outline(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    coord_arg(vals[4]),
+                    parse_shape_color_args(vals[5:], 4),
+                )
+            case "line":
+                coords = []
+                for token in vals[:4]:
+                    if not isinstance(coord_arg(token), float):
+                        raise UsageError(f"line coordinates must be plain numbers, got {token}")
+                    coords.append(float(token))
+                thickness = num_arg(vals[4], "thickness")
+                shape.add_line(
+                    coords[0],
+                    coords[1],
+                    coords[2],
+                    coords[3],
+                    thickness,
+                    parse_shape_color_args(vals[5:], 4),
+                )
+            case "glow":
+                segments = int_arg(vals[4], "segments")
+                if segments <= 0:
+                    raise UsageError(f"segments must be positive, got {segments}")
+                shape.add_radial_glow(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    segments,
+                    0.0,
+                    2.0 * math.pi,
+                    parse_shape_color_args(vals[5:], 4),
+                )
+            case _:
+                raise UsageError(f"unknown shape primitive: {name}")
+    if len(shape.verts) >= SHAPE_MAX_VERTS:
+        raise UsageError(
+            f"shape has {len(shape.verts)} vertices, exceeding the 16-bit index limit of {SHAPE_MAX_VERTS - 1}"
+        )
+    return shape
+
+
+def cmd_set_shape(api, args, cwd):
+    prims = getattr(args, "prims", None) or []
+    if not prims:
+        raise UsageError("no shape primitives given (use --box, --gbox, --vgradient, --outline, --line, --glow)")
+    shape = build_shape(prims)
+    path = resolve_path(cwd, args.path)
+    shape.set(api, path, args.prop, args.index)
+
+
+def parse_bool(token):
+    if token == "true":
+        return True
+    if token == "false":
+        return False
+    raise UsageError(f"invalid bool value: {token} (use true/false)")
+
+
+def encode_call_arg(buf, arg_type, token, arg_name):
+    match arg_type:
+        case CallArgType.UINT32:
+            serial.write_u32(buf, parse_uint32(token))
+        case CallArgType.UINT64:
+            try:
+                val = int(token, 0)
+            except ValueError:
+                raise UsageError(f"invalid uint64 value for {arg_name}: {token}")
+            if not 0 <= val <= 0xFFFFFFFFFFFFFFFF:
+                raise UsageError(f"uint64 value out of range for {arg_name}: {token}")
+            serial.write_u64(buf, val)
+        case CallArgType.FLOAT32:
+            serial.write_f32(buf, num_arg(token, arg_name))
+        case CallArgType.BOOL:
+            serial.write_u8(buf, int(parse_bool(token)))
+        case CallArgType.STR:
+            serial.encode_str(buf, token)
+        case CallArgType.HASH:
+            token = token.strip().lower()
+            if len(token) != 64:
+                raise UsageError(f"invalid hash for {arg_name}: expected 64 hex chars, got {token}")
+            try:
+                buf += bytes.fromhex(token)
+            except ValueError:
+                raise UsageError(f"invalid hash hex for {arg_name}: {token}")
+        case _:
+            raise UsageError(f"unsupported argument type: {CallArgType.to_str(arg_type)}")
+
+
+CALL_RESULT_SIZES = {
+    CallArgType.UINT32: 4,
+    CallArgType.UINT64: 8,
+    CallArgType.FLOAT32: 4,
+    CallArgType.BOOL: 1,
+}
+
+
+def decode_call_result(cur, typ):
+    match typ:
+        case CallArgType.STR:
+            return serial.decode_str(cur)
+        case CallArgType.HASH:
+            return cur.read(32).hex()
+        case _:
+            data = cur.read(CALL_RESULT_SIZES[typ])
+            return f"0x{data.hex()}"
+
+
+def cmd_call(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    method_args, results = api.get_method(path, args.method)
+    if len(args.args) != len(method_args):
+        sig = format_signature(args.method, method_args, results)
+        raise UsageError(f"wrong number of arguments for {sig}, got {len(args.args)}")
+    buf = bytearray()
+    for (name, _, typ), token in zip(method_args, args.args):
+        encode_call_arg(buf, typ, token, name)
+    result = api.call_method(path, args.method, bytes(buf))
+    if result is None or not results:
+        print("void")
+        return
+    cur = serial.Cursor(result)
+    outs = []
+    for (name, _, typ) in results:
+        try:
+            outs.append(decode_call_result(cur, typ))
+        except Exception:
+            outs.append(f"0x{cur.remain_data().hex()}")
+            break
+    print(" ".join(outs))
+
+
+def run_selftests():
+    from .api import Expr, Property, PropertyStatus, PropertyType
+
+    assert format_value(None) == "null"
+    assert format_value(Expr("w/2")) == '"w/2"'
+    assert format_value(True) == "true"
+    assert format_value(False) == "false"
+    assert format_value(1.0) == "1.0"
+    assert format_value(10) == "10"
+    assert format_value("hello world") == '"hello world"'
+
+    assert format_status_value(PropertyStatus.EXPR, Expr("w/2")) == 'expr "w/2"'
+    assert format_status_value(PropertyStatus.NULL, None) == "null"
+    assert format_status_value(PropertyStatus.UNSET, 1.0) == "unset"
+    assert format_status_value(PropertyStatus.OK, 1.0) == "value 1.0"
+
+    assert NODE_TYPE_NAMES[SceneNodeType.LAYER] == "layer"
+    assert NODE_TYPE_NAMES[SceneNodeType.VECTOR_ART] == "vector_art"
+    assert NODE_TYPE_NAMES[SceneNodeType.PLUGIN_ROOT] == "plugin_root"
+
+    assert resolve_path([], "/") == "/"
+    assert resolve_path([], "") == "/"
+    assert resolve_path([], "..") == "/"
+    assert resolve_path(["a", "b"], "../..") == "/"
+    assert resolve_path(["a", "b"], "../../../setting") == "/setting"
+    assert resolve_path([], "//window") == "/window"
+    assert resolve_path([], "/window/content") == "/window/content"
+    assert resolve_path(["window"], "content") == "/window/content"
+    assert resolve_path(["window", "content"], "..") == "/window"
+    assert resolve_path(["window"], "../setting") == "/setting"
+    assert resolve_path(["window"], "./content/.") == "/window/content"
+    assert resolve_path([], "window//content/") == "/window/content"
+
+    assert parse_get_args(["alpha"], []) == ("/", "alpha", None)
+    assert parse_get_args(["/window/content", "alpha"], []) == ("/window/content", "alpha", None)
+    assert parse_get_args(["window", "content", "alpha"], []) == ("/window/content", "alpha", None)
+    assert parse_get_args(["rect", "2"], []) == ("/", "rect", 2)
+    assert parse_get_args(["rect", "2"], ["window", "content"]) == ("/window/content", "rect", 2)
+    for bad in ([], ["2"]):
+        try:
+            parse_get_args(bad, [])
+            raise AssertionError(f"parse_get_args({bad}) should have raised")
+        except UsageError:
+            pass
+
+    assert parse_show_args(["alpha"], []) == ("/", "alpha")
+    assert parse_show_args(["window", "content", "alpha"], []) == ("/window/content", "alpha")
+    assert parse_show_args(["alpha"], ["window", "content"]) == ("/window/content", "alpha")
+
+    assert parse_set_args(["is_visible", "false"], []) == ("/", "is_visible", 0, "false")
+    assert parse_set_args(["rect", "2", "w/2"], []) == ("/", "rect", 2, "w/2")
+    assert parse_set_args(["/window/content", "rect", "2", "w/2"], []) == (
+        "/window/content",
+        "rect",
+        2,
+        "w/2",
+    )
+    assert parse_set_args(["window", "content", "rect", "2", "1.0"], []) == (
+        "/window/content",
+        "rect",
+        2,
+        "1.0",
+    )
+    assert parse_set_args(["rect", "w/2"], ["window"]) == ("/window", "rect", 0, "w/2")
+    for bad in ([], ["false"], ["2", "false"]):
+        try:
+            parse_set_args(bad, [])
+            raise AssertionError(f"parse_set_args({bad}) should have raised")
+        except UsageError:
+            pass
+
+    assert parse_uint32("42") == 42
+    assert parse_uint32("0x10") == 16
+    for bad in ("-1", "x", "4294967296"):
+        try:
+            parse_uint32(bad)
+            raise AssertionError(f"parse_uint32({bad}) should have raised")
+        except UsageError:
+            pass
+
+    shape = build_shape([("box", ["0", "0", "w", "10", "1", "0", "0", "1"])])
+    assert len(shape.verts) == 4 and len(shape.indices) == 6
+    assert shape.verts[1][0] == "w" and shape.verts[3][1] == "10.0"
+
+    shape = build_shape([("gbox", ["0", "0", "w", "h", "1", "1", "1", "1", "0", "0", "0", "1"])])
+    assert len(shape.verts) == 4
+    assert shape.verts[0][2] == [1.0, 1.0, 1.0, 1.0]
+    assert shape.verts[2][2] == [0.0, 0.0, 0.0, 1.0]
+
+    shape = build_shape(
+        [("vgradient", ["0", "0", "w", "h", "1", "1", "1", "1", "0", "0", "0", "1", "8", "0.45"])]
+    )
+    assert len(shape.verts) == 8 * 4 and len(shape.indices) == 8 * 6
+
+    shape = build_shape([("outline", ["0", "0", "w", "h", "2.0", "0", "0", "0", "1"])])
+    assert len(shape.verts) == 16 and len(shape.indices) == 24
+
+    shape = build_shape([("line", ["0", "0", "10", "0", "4", "1", "1", "1", "1"])])
+    assert len(shape.verts) == 4 and len(shape.indices) == 6
+
+    shape = build_shape([("glow", ["w/2", "h/2", "w", "h", "12", "1", "0", "0", "1"])])
+    assert len(shape.verts) == 14 and len(shape.indices) == 36
+    assert shape.verts[1][0] == "(w/2 + (w * 0.5))"
+
+    shape = build_shape(
+        [
+            ("box", ["0", "0", "1", "1", "1", "1", "1", "1"]),
+            ("outline", ["0", "0", "1", "1", "1", "0", "0", "0", "1"]),
+        ]
+    )
+    assert len(shape.verts) == 4 + 16
+
+    parser = build_parser()
+    assert MAIN_PARSER is parser
+    assert "help" in COMMAND_PARSERS
+    for name in ("cd", "pwd", "exit", "quit", "help"):
+        assert name in BUILTIN_HELP
+    assert set(SHELL_BUILTINS) <= set(BUILTIN_HELP)
+    args = parser.parse_args(
+        ["set-shape", "/x", "--box", "0", "0", "w", "10", "1", "0", "0", "1"]
+    )
+    assert args.prims == [("box", ["0", "0", "w", "10", "1", "0", "0", "1"])]
+    args = parser.parse_args(
+        [
+            "set-shape", "/x",
+            "--box", "0", "0", "1", "1", "1", "1", "1", "1",
+            "--outline", "0", "0", "1", "1", "1", "0", "0", "0", "1",
+        ]
+    )
+    assert [name for (name, _) in args.prims] == ["box", "outline"]
+
+    for bad in (
+        ["line", ["0", "0", "w", "0", "4", "1", "1", "1", "1"]],
+        ["vgradient", ["0", "0", "1", "1", "1", "1", "1", "1", "0", "0", "0", "1", "0", "0.45"]],
+        ["glow", ["1", "1", "1", "1", "-3", "1", "0", "0", "1"]],
+        ["box", ["0", "0", "1", "1", "x", "0", "0", "1"]],
+    ):
+        try:
+            build_shape([bad])
+            raise AssertionError(f"build_shape({bad}) should have raised")
+        except UsageError:
+            pass
+
+    try:
+        build_shape([("vgradient", ["0", "0", "1", "1", "1", "1", "1", "1", "0", "0", "0", "1", "20000", "1"])])
+        raise AssertionError("oversized shape should have raised")
+    except UsageError as err:
+        assert "exceeding" in str(err)
+
+    assert coord_arg("w/2") == "w/2"
+    assert coord_arg("3.5") == 3.5
+
+    buf = bytearray()
+    encode_call_arg(buf, CallArgType.UINT32, "0x10", "n")
+    encode_call_arg(buf, CallArgType.STR, "hello", "s")
+    encode_call_arg(buf, CallArgType.HASH, "00" * 32, "h")
+    encode_call_arg(buf, CallArgType.BOOL, "true", "b")
+    encode_call_arg(buf, CallArgType.FLOAT32, "1.5", "f")
+    assert bytes(buf) == bytes.fromhex("10000000") + b"\x05hello" + bytes(32) + b"\x01" + bytes.fromhex(
+        "0000c03f"
+    )
+
+    cur = serial.Cursor(bytes(buf))
+    assert serial.read_u32(cur) == 16
+    assert serial.decode_str(cur) == "hello"
+    assert cur.read(32) == bytes(32)
+
+    for bad in (
+        (CallArgType.BOOL, "yes"),
+        (CallArgType.HASH, "1234"),
+        (CallArgType.UINT64, "-1"),
+    ):
+        try:
+            encode_call_arg(bytearray(), bad[0], bad[1], "x")
+            raise AssertionError(f"encode_call_arg{bad} should have raised")
+        except UsageError:
+            pass
+
+    prop = Property(
+        "alpha",
+        PropertyType.FLOAT32,
+        0,
+        "Alpha",
+        "Layer transparency",
+        False,
+        False,
+        1,
+        0.0,
+        1.0,
+        None,
+        [],
+    )
+    lines = prop_meta_lines(prop)
+    assert lines[0] == "alpha:"
+    assert "  type: float32" in lines
+    assert "  array_len: 1" in lines
+    assert "  range: [0.0, 1.0]" in lines
+
+    prop = prop._replace(array_len=0, min_val=None, max_val=None)
+    assert "  array_len: unbounded" in prop_meta_lines(prop)
+    assert not any(line.startswith("  range:") for line in prop_meta_lines(prop))
+
+
+def main(argv=None):
+    parser = build_parser()
+    args = parser.parse_args(argv)
+
+    if args.selftest:
+        run_selftests()
+        print("cli self-test OK")
+        return
+
+    if args.command is None:
+        shell_main(args)
+        return
+
+    api = Api(args.addr, args.port)
+    run_command(api, args.func, args, [])
+
+
+if __name__ == "__main__":
+    main()

+ 4 - 0
bin/app/pydrk/exc.py

@@ -82,5 +82,9 @@ class SerialErr(Exception):
     pass
     pass
 class TursoErr(Exception):
 class TursoErr(Exception):
     pass
     pass
+class UnsupportedNodeType(Exception):
+    pass
+class NodeNotRemovable(Exception):
+    pass
 class UnknownError(Exception):
 class UnknownError(Exception):
     pass
     pass

+ 1 - 1
bin/app/pydrk/print_tree.py

@@ -88,7 +88,7 @@ def print_node_info(api, parent_path, depth, indent):
         args, results = api.get_method(parent_path, method_name)
         args, results = api.get_method(parent_path, method_name)
 
 
         args = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in args]
         args = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in args]
-        results = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in results]
+        results = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in (results or [])]
 
 
         method_str = f"{method_name}(" + ", ".join(args) + ") -> (" + ", ".join(results) + ")"
         method_str = f"{method_name}(" + ", ".join(args) + ") -> (" + ", ".join(results) + ")"
         print(f"{ws}{method_str}")
         print(f"{ws}{method_str}")

+ 24 - 4
bin/app/pydrk/vector_shape.py

@@ -13,7 +13,21 @@ import math
 def _coord(x):
 def _coord(x):
     if isinstance(x, str):
     if isinstance(x, str):
         return x
         return x
-    return repr(float(x))
+    x = float(x)
+    neg = math.copysign(1.0, x) < 0
+    if neg:
+        x = -x
+    s = repr(x)
+    if "e" in s or "E" in s:
+        # The app-side expr tokenizer does not accept scientific
+        # notation (glow trig produces values like 6.1e-17), so render
+        # tiny/huge floats as plain decimals.
+        s = f"{x:.30f}".rstrip("0")
+        if s.endswith("."):
+            s += "0"
+    # The tokenizer also has no unary minus, so negative constants
+    # (outline borders, glow trig) are rendered as subtraction.
+    return f"(0 - {s})" if neg else s
 
 
 def _mul(a, b):
 def _mul(a, b):
     return f"({_coord(a)} * {_coord(b)})"
     return f"({_coord(a)} * {_coord(b)})"
@@ -141,6 +155,12 @@ class VectorShape:
 
 
 # python -m pydrk.vector_shape
 # python -m pydrk.vector_shape
 if __name__ == "__main__":
 if __name__ == "__main__":
+    assert _coord(6.123233995736766e-17) == f"{6.123233995736766e-17:.30f}".rstrip("0")
+    assert _coord(-1.2246467991473532e-16) == f"(0 - {f'{1.2246467991473532e-16:.30f}'.rstrip('0')})"
+    assert _coord(0.0) == "0.0"
+    assert _coord(10) == "10.0"
+    assert _coord(-4.0) == "(0 - 4.0)"
+
     shape = VectorShape()
     shape = VectorShape()
     shape.add_filled_box("w/2", 0, "w", 10, [1., 0., 0., 1.])
     shape.add_filled_box("w/2", 0, "w", 10, [1., 0., 0., 1.])
     assert len(shape.verts) == 4 and len(shape.indices) == 6
     assert len(shape.verts) == 4 and len(shape.indices) == 6
@@ -157,13 +177,13 @@ if __name__ == "__main__":
     shape.add_outline("x1", "y1", "x2", "y2", 2.0, [0., 0., 0., 1.])
     shape.add_outline("x1", "y1", "x2", "y2", 2.0, [0., 0., 0., 1.])
     assert len(shape.verts) == 16 and len(shape.indices) == 24
     assert len(shape.verts) == 16 and len(shape.indices) == 24
     assert shape.verts[1][0] == "(x1 + 2.0)"
     assert shape.verts[1][0] == "(x1 + 2.0)"
-    assert shape.verts[8][0] == "(x2 + -2.0)"
-    assert shape.verts[12][1] == "(y2 + -2.0)"
+    assert shape.verts[8][0] == "(x2 + (0 - 2.0))"
+    assert shape.verts[12][1] == "(y2 + (0 - 2.0))"
 
 
     shape = VectorShape()
     shape = VectorShape()
     shape.add_line(0., 0., 10., 0., 4., [1., 1., 1., 1.])
     shape.add_line(0., 0., 10., 0., 4., [1., 1., 1., 1.])
     assert len(shape.verts) == 4 and len(shape.indices) == 6
     assert len(shape.verts) == 4 and len(shape.indices) == 6
-    assert shape.verts[0][1] == "2.0" and shape.verts[2][1] == "-2.0"
+    assert shape.verts[0][1] == "2.0" and shape.verts[2][1] == "(0 - 2.0)"
 
 
     shape = VectorShape()
     shape = VectorShape()
     shape.add_radial_glow("w/2", "h/2", "w", "h", 12, 0., math.pi * 2., [1., 0., 0., 1.])
     shape.add_radial_glow("w/2", "h/2", "w", "h", 12, 0., math.pi * 2., [1., 0., 0., 1.])

+ 6 - 0
bin/app/src/error.rs

@@ -146,6 +146,12 @@ pub enum Error {
 
 
     #[error("SQL database error")]
     #[error("SQL database error")]
     TursoErr = 49,
     TursoErr = 49,
+
+    #[error("Unsupported node type")]
+    UnsupportedNodeType = 50,
+
+    #[error("Node not removable")]
+    NodeNotRemovable = 51,
 }
 }
 
 
 impl From<kvdb_overlay::Error> for Error {
 impl From<kvdb_overlay::Error> for Error {

+ 2 - 1
bin/app/src/main.rs

@@ -197,10 +197,11 @@ impl God {
         {
         {
             let sg_root = sg_root.clone();
             let sg_root = sg_root.clone();
             let ex = bg_ex.clone();
             let ex = bg_ex.clone();
+            let renderer = app.renderer.clone();
             let redraw = app.redraw_trigger.clone();
             let redraw = app.redraw_trigger.clone();
             let zmq_task = bg_ex.spawn(async {
             let zmq_task = bg_ex.spawn(async {
                 i!("Enabled net debugging backend in this build");
                 i!("Enabled net debugging backend in this build");
-                let zmq_rpc = ZeroMQAdapter::new(sg_root, redraw, ex).await;
+                let zmq_rpc = ZeroMQAdapter::new(sg_root, renderer, redraw, ex).await;
                 zmq_rpc.run().await;
                 zmq_rpc.run().await;
             });
             });
             bg_runtime.push_task(zmq_task);
             bg_runtime.push_task(zmq_task);

+ 106 - 16
bin/app/src/net.rs

@@ -22,15 +22,48 @@ use std::{io::Cursor, sync::Arc};
 use zeromq::{Socket, SocketRecv, SocketSend};
 use zeromq::{Socket, SocketRecv, SocketSend};
 
 
 use crate::{
 use crate::{
+    app::node::{create_layer, create_vector_art},
     error::{Error, Result},
     error::{Error, Result},
     expr::{decompile, Compiler},
     expr::{decompile, Compiler},
-    gfx::gfxtag,
+    gfx::{gfxtag, Renderer},
     prop::{PropertyType, Role},
     prop::{PropertyType, Role},
-    scene::{SceneNodeId, SceneNodePtr, ScenePath, Slot},
-    ui::{RedrawTrigger, ShapeVertex, VectorShape},
+    scene::{Pimpl, SceneNodeId, SceneNodePtr, SceneNodeType, ScenePath, Slot},
+    ui::{
+        get_ui_object3, get_ui_object_ptr, Layer, RedrawTrigger, ShapeVertex, VectorArt,
+        VectorShape,
+    },
     ExecutorPtr,
     ExecutorPtr,
 };
 };
 
 
+/// Stop the UI tasks and clear the buffers of a subtree about to be
+/// removed at runtime, mirroring Window::stop(). Only pimpl types with a
+/// UIObject mapping are touched; others (Window, Setting, plugins, Null)
+/// keep their tasks until process exit.
+fn stop_ui_subtree(node: &SceneNodePtr) {
+    if matches!(
+        node.pimpl(),
+        Pimpl::Layer(_) |
+            Pimpl::ScrollLayer(_) |
+            Pimpl::VectorArt(_) |
+            Pimpl::Text(_) |
+            Pimpl::TextScramble(_) |
+            Pimpl::Edit(_) |
+            Pimpl::ChatView(_) |
+            Pimpl::Image(_) |
+            Pimpl::Video(_) |
+            Pimpl::Button(_) |
+            Pimpl::EmojiPicker(_) |
+            Pimpl::Shortcut(_) |
+            Pimpl::Menu(_) |
+            Pimpl::TokenTable(_)
+    ) {
+        get_ui_object3(node).stop();
+    }
+    for child in node.get_children() {
+        stop_ui_subtree(&child);
+    }
+}
+
 const USE_IPV6: bool = true;
 const USE_IPV6: bool = true;
 
 
 #[derive(Debug, SerialDecodable)]
 #[derive(Debug, SerialDecodable)]
@@ -79,6 +112,7 @@ pub struct ZeroMQAdapter {
     slot_recvr: Option<mpsc::Receiver<(Vec<u8>, Vec<u8>)>>,
     slot_recvr: Option<mpsc::Receiver<(Vec<u8>, Vec<u8>)>>,
     */
     */
     sg_root: SceneNodePtr,
     sg_root: SceneNodePtr,
+    renderer: Renderer,
     redraw: RedrawTrigger,
     redraw: RedrawTrigger,
     ex: ExecutorPtr,
     ex: ExecutorPtr,
 
 
@@ -87,7 +121,12 @@ pub struct ZeroMQAdapter {
 }
 }
 
 
 impl ZeroMQAdapter {
 impl ZeroMQAdapter {
-    pub async fn new(sg_root: SceneNodePtr, redraw: RedrawTrigger, ex: ExecutorPtr) -> Arc<Self> {
+    pub async fn new(
+        sg_root: SceneNodePtr,
+        renderer: Renderer,
+        redraw: RedrawTrigger,
+        ex: ExecutorPtr,
+    ) -> Arc<Self> {
         let mut zmq_rep = zeromq::RepSocket::new();
         let mut zmq_rep = zeromq::RepSocket::new();
         if USE_IPV6 {
         if USE_IPV6 {
             zmq_rep.bind("tcp://[::]:9484").await.unwrap();
             zmq_rep.bind("tcp://[::]:9484").await.unwrap();
@@ -104,6 +143,7 @@ impl ZeroMQAdapter {
 
 
         Arc::new(Self {
         Arc::new(Self {
             sg_root,
             sg_root,
+            renderer,
             redraw,
             redraw,
             ex,
             ex,
             zmq_rep: Mutex::new(zmq_rep),
             zmq_rep: Mutex::new(zmq_rep),
@@ -349,21 +389,71 @@ impl ZeroMQAdapter {
                 }
                 }
             }
             }
             Command::AddNode => {
             Command::AddNode => {
-                /*
-                let node_name = String::decode(&mut cur).unwrap();
-                let node_type = SceneNodeType::decode(&mut cur).unwrap();
-                debug!(target: "req", "{:?}({}, {:?})", cmd, node_name, node_type);
+                let parent_path: ScenePath = String::decode(&mut cur)?.parse()?;
+                let node_name = String::decode(&mut cur)?;
+                let node_type = SceneNodeType::decode(&mut cur)?;
+                debug!(target: "req", "{cmd:?}({parent_path}, {node_name}, {node_type:?})");
 
 
-                let node_id = scene_graph.add_node(&node_name, node_type).id;
-                node_id.encode(&mut reply).unwrap();
-                */
+                let parent = self.sg_root.lookup_node(parent_path).ok_or(Error::NodeNotFound)?;
+
+                if parent.get_children().iter().any(|c| c.name == node_name) {
+                    return Err(Error::NodeSiblingNameConflict)
+                }
+
+                let renderer = self.renderer.clone();
+                let redraw = self.redraw.clone();
+                let node = match node_type {
+                    SceneNodeType::Layer => {
+                        create_layer(&node_name)
+                            .setup(|me| Layer::new(me, renderer.clone(), redraw.clone()))
+                            .await
+                    }
+                    SceneNodeType::VectorArt => {
+                        create_vector_art(&node_name)
+                            .setup(|me| VectorArt::new(me, renderer.clone(), redraw.clone()))
+                            .await
+                    }
+                    _ => return Err(Error::UnsupportedNodeType),
+                };
+
+                // Hold the guard over the link so the triggered pass sees
+                // the attached node.
+                let _atom = self.redraw.make_guard(gfxtag!("ZeroMQAdapter::AddNode"));
+                parent.link(node.clone());
+                node.id.encode(&mut reply).unwrap();
+
+                // Arm the pimpl's OnModify handlers (redraw on property
+                // change) exactly like window-owned nodes. The task keeps a
+                // strong ref so an immediate RemoveNode cannot drop the node
+                // out from under start().
+                let node2 = node.clone();
+                let ex2 = self.ex.clone();
+                self.ex
+                    .spawn(async move {
+                        let obj = get_ui_object_ptr(&node2);
+                        obj.start(ex2).await
+                    })
+                    .detach();
             }
             }
             Command::RemoveNode => {
             Command::RemoveNode => {
-                /*
-                let node_id = SceneNodeId::decode(&mut cur).unwrap();
-                debug!(target: "req", "{:?}({})", cmd, node_id);
-                scene_graph.remove_node(node_id)?;
-                */
+                let node_path: ScenePath = String::decode(&mut cur)?.parse()?;
+                debug!(target: "req", "{cmd:?}({node_path})");
+
+                let node = self.sg_root.lookup_node(node_path).ok_or(Error::NodeNotFound)?;
+
+                // The scene root has no parent, removal is meaningless.
+                if Arc::ptr_eq(&node, &self.sg_root) {
+                    return Err(Error::NodeNotRemovable)
+                }
+
+                // Tear down the subtree's UI tasks and buffers before
+                // unlinking, mirroring Window::stop(). Pimpl types without
+                // a UIObject mapping (Window, Setting, plugins, ...) keep
+                // their tasks until process exit.
+                stop_ui_subtree(&node);
+
+                node.unlink();
+                self.redraw.trigger();
             }
             }
             Command::RenameNode => {
             Command::RenameNode => {
                 /*
                 /*

+ 16 - 3
bin/app/src/ui/mod.rs

@@ -312,14 +312,27 @@ impl<T: Send + Sync + 'static> OnModify<T> {
                 } else {
                 } else {
                     t!(
                     t!(
                         "Property {:?} modified -> triggering {:?} [depend_idx={idx}, role={role:?}]",
                         "Property {:?} modified -> triggering {:?} [depend_idx={idx}, role={role:?}]",
-                        prop_weak.upgrade().unwrap(),
+                        prop_weak.upgrade(),
                         prop
                         prop
                     );
                     );
                 }
                 }
 
 
                 let Some(self_) = me.upgrade() else {
                 let Some(self_) = me.upgrade() else {
-                    // Should not happen
-                    panic!("{:?} self destroyed before modify_task was stopped!", prop);
+                    // Normally unreachable: an owner is dropped only after
+                    // stop() cleared its modify tasks, so an alive task
+                    // implies an alive owner. Runtime node removal
+                    // (netdebug rmnode) breaks that: stop() merely drops
+                    // the Task handles, and a future that is mid-poll on
+                    // a worker thread keeps running until its next yield,
+                    // which can carry it past this upgrade after the last
+                    // Arc is gone (this future holds only weak refs).
+                    // With no owner there is nothing left to notify, so
+                    // exit quietly instead of panicking.
+                    warn!(
+                        target: "scene::on_modify",
+                        "Property {:?} owner destroyed before modify_task was stopped", prop
+                    );
+                    return
                 };
                 };
 
 
                 //debug!(target: "app", "property modified");
                 //debug!(target: "app", "property modified");

+ 25 - 25
openspec/changes/app-pydrk-cli/tasks.md

@@ -4,7 +4,7 @@ Work happens in `bin/app/`. For live tests run `make dev` in a second
 terminal and keep it running; every `python -m pydrk ...` line below is
 terminal and keep it running; every `python -m pydrk ...` line below is
 run from `bin/app`.
 run from `bin/app`.
 
 
-- [ ] 1.1 Create `pydrk/cli.py` (argparse `main()`, global `--addr`/`--port`
+- [x] Create `pydrk/cli.py` (argparse `main()`, global `--addr`/`--port`
   defaulting to `127.0.0.1:9484`, subcommand dispatch, top-level
   defaulting to `127.0.0.1:9484`, subcommand dispatch, top-level
   try/except printing `error: <name>` and exiting 1) and
   try/except printing `error: <name>` and exiting 1) and
   `pydrk/__main__.py` (`from pydrk import cli; cli.main()`). Implement
   `pydrk/__main__.py` (`from pydrk import cli; cli.main()`). Implement
@@ -12,23 +12,23 @@ run from `bin/app`.
   `hello` against the running app; `python -m pydrk ping --port 9999`
   `hello` against the running app; `python -m pydrk ping --port 9999`
   prints an error naming `127.0.0.1:9999` and exits non-zero. Commit as
   prints an error naming `127.0.0.1:9999` and exits non-zero. Commit as
   `app: add pydrk CLI skeleton with ping`.
   `app: add pydrk CLI skeleton with ping`.
-- [ ] 1.2 Implement `ls [path]`: child rows as `name <id> type` (type via
+- [x] Implement `ls [path]`: child rows as `name <id> type` (type via
   `SceneNodeType` names) followed by property rows `name: type = value`
   `SceneNodeType` names) followed by property rows `name: type = value`
   (value from `get_property_value`, exprs shown as their source,
   (value from `get_property_value`, exprs shown as their source,
   `<shape>` placeholder for shapes). Verify: `python -m pydrk ls /`
   `<shape>` placeholder for shapes). Verify: `python -m pydrk ls /`
   lists `setting` and `window` plus the root's properties;
   lists `setting` and `window` plus the root's properties;
   `python -m pydrk ls /nope` prints `node_not_found`. Commit as
   `python -m pydrk ls /nope` prints `node_not_found`. Commit as
   `app: pydrk CLI ls command`.
   `app: pydrk CLI ls command`.
-- [ ] 1.3 Implement `tree [path] [--depth N]` by wiring
+- [x] Implement `tree [path] [--depth N]` by wiring
   `pydrk.print_tree.print_tree` into the CLI. Verify: `python -m pydrk
   `pydrk.print_tree.print_tree` into the CLI. Verify: `python -m pydrk
   tree / --depth 2` prints two levels with properties, signals and
   tree / --depth 2` prints two levels with properties, signals and
   methods. Commit as `app: pydrk CLI tree command`.
   methods. Commit as `app: pydrk CLI tree command`.
-- [ ] 1.4 Implement `props <path>`: one block per property showing name,
+- [x] Implement `props <path>`: one block per property showing name,
   type, subtype, array_len (mark unbounded when 0), null/expr allowance,
   type, subtype, array_len (mark unbounded when 0), null/expr allowance,
   min/max range when present, enum items when present, ui_name and desc.
   min/max range when present, enum items when present, ui_name and desc.
   Verify: `python -m pydrk props /window/content` shows `alpha` with its
   Verify: `python -m pydrk props /window/content` shows `alpha` with its
   `[0.0, 1.0]` range. Commit as `app: pydrk CLI props command`.
   `[0.0, 1.0]` range. Commit as `app: pydrk CLI props command`.
-- [ ] 1.5 Implement `get [path] PROP [idx]` with the shared positional
+- [x] Implement `get [path] PROP [idx]` with the shared positional
   grammar from design D8 (right-to-left parse via `parse_get_args`, path
   grammar from design D8 (right-to-left parse via `parse_get_args`, path
   optional defaulting to `/` in one-shot mode, trailing integer = index):
   optional defaulting to `/` in one-shot mode, trailing integer = index):
   one line per index annotated `value`/`expr`/`null`/`unset`, only the
   one line per index annotated `value`/`expr`/`null`/`unset`, only the
@@ -37,7 +37,7 @@ run from `bin/app`.
   /window/content alpha` prints `0: value 1.0`; `python -m pydrk get
   /window/content alpha` prints `0: value 1.0`; `python -m pydrk get
   /window/content rect 2` prints only index 2. Commit as
   /window/content rect 2` prints only index 2. Commit as
   `app: pydrk CLI get command`.
   `app: pydrk CLI get command`.
-- [ ] 1.6 Implement `show [path] PROP`: the full single-property view
+- [x] Implement `show [path] PROP`: the full single-property view
   from design D8 — metadata block (name, type, subtype, array_len,
   from design D8 — metadata block (name, type, subtype, array_len,
   null/expr allowance, min/max range, enum items, ui_name, desc,
   null/expr allowance, min/max range, enum items, ui_name, desc,
   depends) followed by the per-index values with statuses. Verify: `python
   depends) followed by the per-index values with statuses. Verify: `python
@@ -45,7 +45,7 @@ run from `bin/app`.
   `[0.0, 1.0]` range and then `0: value 1.0`; `python -m pydrk show
   `[0.0, 1.0]` range and then `0: value 1.0`; `python -m pydrk show
   /window/content no_such_prop` prints `property_not_found`. Commit as
   /window/content no_such_prop` prints `property_not_found`. Commit as
   `app: pydrk CLI show command`.
   `app: pydrk CLI show command`.
-- [ ] 1.7 Fix `Api.get_method()` in `pydrk/api.py`: decode the results as
+- [x] Fix `Api.get_method()` in `pydrk/api.py`: decode the results as
   `Option<Vec<CallArg>>` (read the u8 tag, then the array only when
   `Option<Vec<CallArg>>` (read the u8 tag, then the array only when
   some) per design D6. Implement `methods <path>` (name + arg/result
   some) per design D6. Implement `methods <path>` (name + arg/result
   signature per method) and `signals <path>` (signal names). Verify:
   signature per method) and `signals <path>` (signal names). Verify:
@@ -53,7 +53,7 @@ run from `bin/app`.
   with its `str` result signature, and `python -m pydrk tree /plugin/drk`
   with its `str` result signature, and `python -m pydrk tree /plugin/drk`
   no longer truncates method results. Commit as
   no longer truncates method results. Commit as
   `app: fix pydrk get_method result decoding, add methods/signals commands`.
   `app: fix pydrk get_method result decoding, add methods/signals commands`.
-- [ ] 1.8 Add `--selftest` handling in `cli.py`: a `run_selftests()`
+- [x] Add `--selftest` handling in `cli.py`: a `run_selftests()`
   function with assert-based checks of the pure helpers introduced so
   function with assert-based checks of the pure helpers introduced so
   far (path/type/value formatting, `parse_get_args`), so `python -m
   far (path/type/value formatting, `parse_get_args`), so `python -m
   pydrk.cli --selftest` prints `cli self-test OK` without a running app.
   pydrk.cli --selftest` prints `cli self-test OK` without a running app.
@@ -61,7 +61,7 @@ run from `bin/app`.
 
 
 ## 2. Typed property setting (Python only)
 ## 2. Typed property setting (Python only)
 
 
-- [ ] 2.1 Implement the typed value table from design D8: a pure
+- [x] Implement the typed value table from design D8: a pure
   `encode_set_value(api, path, prop_meta, token, index)` helper that
   `encode_set_value(api, path, prop_meta, token, index)` helper that
   looks up the property via `get_properties` and dispatches to the right
   looks up the property via `get_properties` and dispatches to the right
   `Api.set_property_*` call (bool/uint32/float32/str/enum/scene_node_id,
   `Api.set_property_*` call (bool/uint32/float32/str/enum/scene_node_id,
@@ -74,7 +74,7 @@ run from `bin/app`.
   restores it; `python -m pydrk get /window/content/chat is_visible`
   restores it; `python -m pydrk get /window/content/chat is_visible`
   round-trips both values. Add `parse_set_args` cases to `--selftest`.
   round-trips both values. Add `parse_set_args` cases to `--selftest`.
   Commit as `app: pydrk CLI typed set command`.
   Commit as `app: pydrk CLI typed set command`.
-- [ ] 2.2 Add `--expr` to `set` (sends via `set_property_expr`). Verify
+- [x] Add `--expr` to `set` (sends via `set_property_expr`). Verify
   live: `python -m pydrk set /window/content rect 2 "w/2" --expr`
   live: `python -m pydrk set /window/content rect 2 "w/2" --expr`
   exits 0 and `python -m pydrk get /window/content rect 2` shows
   exits 0 and `python -m pydrk get /window/content rect 2` shows
   `2: expr "w/2"`. Commit as `app: pydrk CLI set --expr`.
   `2: expr "w/2"`. Commit as `app: pydrk CLI set --expr`.
@@ -89,22 +89,22 @@ run from `bin/app`.
 
 
 ## 3. netdebug backend: node creation and removal (Rust)
 ## 3. netdebug backend: node creation and removal (Rust)
 
 
-- [ ] 3.1 Add `Error::UnsupportedNodeType = 50` and
+- [x] Add `Error::UnsupportedNodeType = 50` and
   `Error::NodeNotRemovable = 51` (used to reject removing the scene
   `Error::NodeNotRemovable = 51` (used to reject removing the scene
   root) to `bin/app/src/error.rs` following the existing variant style.
   root) to `bin/app/src/error.rs` following the existing variant style.
   Verify: `make compile-dev` succeeds. Commit as
   Verify: `make compile-dev` succeeds. Commit as
   `app: add netdebug error codes for node create/remove`.
   `app: add netdebug error codes for node create/remove`.
-- [ ] 3.2 Mirror the two codes in pydrk: `ErrorCode` constants, `exc.py`
+- [x] Mirror the two codes in pydrk: `ErrorCode` constants, `exc.py`
   exception classes, `_make_request` match arms raising them. Verify:
   exception classes, `_make_request` match arms raising them. Verify:
   `python -m pydrk.cli --selftest` and `python -m pydrk ping` still
   `python -m pydrk.cli --selftest` and `python -m pydrk ping` still
   work. Commit as `app: pydrk error codes for node create/remove`.
   work. Commit as `app: pydrk error codes for node create/remove`.
-- [ ] 3.3 Thread the renderer into the adapter per design D5: add the
+- [x] Thread the renderer into the adapter per design D5: add the
   `renderer: Renderer` field to `ZeroMQAdapter`, change
   `renderer: Renderer` field to `ZeroMQAdapter`, change
   `ZeroMQAdapter::new` to take it, update the call site in `main.rs`
   `ZeroMQAdapter::new` to take it, update the call site in `main.rs`
   (it already has `app.renderer` in scope). Verify: `make compile-dev`
   (it already has `app.renderer` in scope). Verify: `make compile-dev`
   succeeds and `python -m pydrk ping` still works. Commit as
   succeeds and `python -m pydrk ping` still works. Commit as
   `app/netdebug: pass renderer into ZeroMQAdapter`.
   `app/netdebug: pass renderer into ZeroMQAdapter`.
-- [ ] 3.4 Implement the `AddNode` arm per design D2: decode
+- [x] Implement the `AddNode` arm per design D2: decode
   `(parent_path, name, node_type)`; look up the parent; reject duplicate
   `(parent_path, name, node_type)`; look up the parent; reject duplicate
   sibling names with `NodeSiblingNameConflict`; match `Layer` and
   sibling names with `NodeSiblingNameConflict`; match `Layer` and
   `VectorArt` through `create_layer`/`create_vector_art` +
   `VectorArt` through `create_layer`/`create_vector_art` +
@@ -112,7 +112,7 @@ run from `bin/app`.
   types with `UnsupportedNodeType`; reply the id. Verify: `make
   types with `UnsupportedNodeType`; reply the id. Verify: `make
   compile-dev` succeeds. Commit as
   compile-dev` succeeds. Commit as
   `app/netdebug: path-based AddNode for layer and vector_art`.
   `app/netdebug: path-based AddNode for layer and vector_art`.
-- [ ] 3.5 Implement the `RemoveNode` arm per design D3: decode
+- [x] Implement the `RemoveNode` arm per design D3: decode
   `(node_path)`; reject `/` with `NodeNotRemovable`; look up the node;
   `(node_path)`; reject `/` with `NodeNotRemovable`; look up the node;
   `unlink()`; `redraw.trigger()`. No restrictions on which nodes are
   `unlink()`; `redraw.trigger()`. No restrictions on which nodes are
   removable — full scene-graph access is intentional for this debugging
   removable — full scene-graph access is intentional for this debugging
@@ -121,7 +121,7 @@ run from `bin/app`.
 
 
 ## 4. Node lifecycle commands (Python)
 ## 4. Node lifecycle commands (Python)
 
 
-- [ ] 4.1 Add `Api.add_node(parent_path, name, node_type)` to `api.py`
+- [x] Add `Api.add_node(parent_path, name, node_type)` to `api.py`
   and the `mknode <parent_path> <name> <type>` subcommand accepting only
   and the `mknode <parent_path> <name> <type>` subcommand accepting only
   `layer`/`vector_art` (anything else fails locally with
   `layer`/`vector_art` (anything else fails locally with
   `unsupported node type`), printing `id=... path=...`. Verify live
   `unsupported node type`), printing `id=... path=...`. Verify live
@@ -132,7 +132,7 @@ run from `bin/app`.
   factory properties including `shape`; `python -m pydrk mknode
   factory properties including `shape`; `python -m pydrk mknode
   /window/content dbg layer` again prints `node_sibling_name_conflict`.
   /window/content dbg layer` again prints `node_sibling_name_conflict`.
   Commit as `app: pydrk CLI mknode command`.
   Commit as `app: pydrk CLI mknode command`.
-- [ ] 4.2 Add `Api.remove_node(node_path)` and the `rmnode <path>`
+- [x] Add `Api.remove_node(node_path)` and the `rmnode <path>`
   subcommand (full graph access, documented in `--help` as runtime-only).
   subcommand (full graph access, documented in `--help` as runtime-only).
   Verify live: create `dbg` as above then `python -m pydrk rmnode
   Verify live: create `dbg` as above then `python -m pydrk rmnode
   /window/content/dbg` — `ls /window/content` no longer lists it and the
   /window/content/dbg` — `ls /window/content` no longer lists it and the
@@ -141,7 +141,7 @@ run from `bin/app`.
   `python -m pydrk rmnode /` prints `node_not_removable` and `python -m
   `python -m pydrk rmnode /` prints `node_not_removable` and `python -m
   pydrk ls /` still lists everything. Commit as
   pydrk ls /` still lists everything. Commit as
   `app: pydrk CLI rmnode command`.
   `app: pydrk CLI rmnode command`.
-- [ ] 4.3 Delete the dead client methods from `api.py` listed in design
+- [x] Delete the dead client methods from `api.py` listed in design
   D6 plus the legacy `vertex()`/`face()` helpers. Verify: `grep -rn
   D6 plus the legacy `vertex()`/`face()` helpers. Verify: `grep -rn
   "link_node\|scan_dangling\|add_property" bin/app/pydrk bin/app/script`
   "link_node\|scan_dangling\|add_property" bin/app/pydrk bin/app/script`
   is empty, `python -m pydrk.cli --selftest` passes, and the commands
   is empty, `python -m pydrk.cli --selftest` passes, and the commands
@@ -150,7 +150,7 @@ run from `bin/app`.
 
 
 ## 5. Shape data (Python)
 ## 5. Shape data (Python)
 
 
-- [ ] 5.1 Implement `set-shape <path> [--prop NAME] [--index N]` with the
+- [x] Implement `set-shape <path> [--prop NAME] [--index N]` with the
   `--box` flag from design D11 (argparse `nargs=8`, `action="append"`),
   `--box` flag from design D11 (argparse `nargs=8`, `action="append"`),
   building on `pydrk.vector_shape.VectorShape`, with the
   building on `pydrk.vector_shape.VectorShape`, with the
   `< 65536` vertex guard. Verify live: `mknode /window/content dbg
   `< 65536` vertex guard. Verify live: `mknode /window/content dbg
@@ -172,7 +172,7 @@ run from `bin/app`.
 
 
 ## 6. Method calls (Python)
 ## 6. Method calls (Python)
 
 
-- [ ] 6.1 Implement `call <path> <method> [ARGS...]`: fetch the
+- [x] Implement `call <path> <method> [ARGS...]`: fetch the
   signature with `Api.get_method`, encode each positional arg per its
   signature with `Api.get_method`, encode each positional arg per its
   declared type (`uint32`/`uint64`/`float32`/`bool`/`str`; `hash` as
   declared type (`uint32`/`uint64`/`float32`/`bool`/`str`; `hash` as
   64-char hex → 32 bytes), reject wrong arg counts or unparseable
   64-char hex → 32 bytes), reject wrong arg counts or unparseable
@@ -186,12 +186,12 @@ run from `bin/app`.
 
 
 ## 7. Interactive shell
 ## 7. Interactive shell
 
 
-- [ ] 7.1 Implement `resolve_path(cwd_tokens, arg)` exactly per design
+- [x] Implement `resolve_path(cwd_tokens, arg)` exactly per design
   D9 plus its `--selftest` cases (absolute paths, `..`, `.`, empty,
   D9 plus its `--selftest` cases (absolute paths, `..`, `.`, empty,
   relative tokens, leading/trailing slashes). Verify: `python -m
   relative tokens, leading/trailing slashes). Verify: `python -m
   pydrk.cli --selftest` passes with no app running. Commit as
   pydrk.cli --selftest` passes with no app running. Commit as
   `app: pydrk CLI path resolution helper`.
   `app: pydrk CLI path resolution helper`.
-- [ ] 7.2 Implement the shell per design D9: entered when `python -m
+- [x] Implement the shell per design D9: entered when `python -m
   pydrk` runs with no subcommand; prompt `pydrk:/window/content> `;
   pydrk` runs with no subcommand; prompt `pydrk:/window/content> `;
   `shlex.split` line tokenization; dispatch each line to the same
   `shlex.split` line tokenization; dispatch each line to the same
   per-command handlers as one-shot mode, with the optional-path grammar
   per-command handlers as one-shot mode, with the optional-path grammar
@@ -205,7 +205,7 @@ run from `bin/app`.
   prints `false`, `set is_visible true`, `cd ..`, `pwd`, `get
   prints `false`, `set is_visible true`, `cd ..`, `pwd`, `get
   no_such_prop` prints `property_not_found` and the shell survives,
   no_such_prop` prints `property_not_found` and the shell survives,
   `exit`. Commit as `app: pydrk interactive shell mode`.
   `exit`. Commit as `app: pydrk interactive shell mode`.
-- [ ] 7.3 Implement the readline completer per design D10: command-name
+- [x] Implement the readline completer per design D10: command-name
   completion for the first token, live child-path completion (dir part +
   completion for the first token, live child-path completion (dir part +
   prefix, `api.get_children`, per-prompt cache cleared after each
   prefix, `api.get_children`, per-prompt cache cleared after each
   executed command), completion of the first positional argument of
   executed command), completion of the first positional argument of
@@ -219,7 +219,7 @@ run from `bin/app`.
 
 
 ## 8. Final verification
 ## 8. Final verification
 
 
-- [ ] 8.1 Run the full junior walkthrough end-to-end against a fresh
+- [x] Run the full junior walkthrough end-to-end against a fresh
   `make dev` instance: `ping`; `ls /`; `tree / --depth 2`; enter the
   `make dev` instance: `ping`; `ls /`; `tree / --depth 2`; enter the
   shell; `cd /window/content`; `mknode dbg layer` style flow for layer +
   shell; `cd /window/content`; `mknode dbg layer` style flow for layer +
   vector_art (via subcommand or shell); set `rect` and `is_visible`;
   vector_art (via subcommand or shell); set `rect` and `is_visible`;
@@ -227,7 +227,7 @@ run from `bin/app`.
   layer and confirm the window redraws clean with no leftover geometry.
   layer and confirm the window redraws clean with no leftover geometry.
   Fix anything broken found during the walkthrough and amend the
   Fix anything broken found during the walkthrough and amend the
   selftest. Commit as `app: pydrk CLI end-to-end walkthrough fixes`.
   selftest. Commit as `app: pydrk CLI end-to-end walkthrough fixes`.
-- [ ] 8.2 Final gates: `make compile-dev` succeeds with no warnings
+- [x] Final gates: `make compile-dev` succeeds with no warnings
   introduced; `python -m pydrk.cli --selftest` and `python -m
   introduced; `python -m pydrk.cli --selftest` and `python -m
   pydrk.vector_shape` pass; `python -m pydrk ping` works; `git status`
   pydrk.vector_shape` pass; `python -m pydrk ping` works; `git status`
   shows a clean tree after the last commit. Confirm the spec scenarios
   shows a clean tree after the last commit. Confirm the spec scenarios