The netdebug backend (bin/app/src/net.rs, feature enable-netdebug,
dev builds only) serves ZeroMQ REQ/REP on :9484 and PUB on :9485.
Requests are 2 frames [cmd:1][payload], replies [errc:1][body], with
darkfi-serial encoding inside frames. The Python client pydrk
(bin/app/pydrk/) mirrors the codec (serial.py), the command/error
tables (api.py), and shape builders (vector_shape.py).
The scene graph is now a tree of SceneNode addressed by /-separated
paths (ScenePath), looked up from sg_root by walking child names.
Nodes are built by Rust factories (create_layer, create_vector_art,
... in src/app/node.rs) that attach the factory's properties, then
.setup(|me| Layer::new(me, renderer, redraw)).await installs the pimpl,
then parent.link(node) attaches it. The old central node registry is
gone, which is why the id-based AddNode/LinkNode/... arms in
net.rs are commented out — they reference a scene_graph object that
no longer exists. SceneNode::link() asserts the child has no parent
yet, and the pimpl types clean up GPU draw calls in Drop
(VectorArt::drop calls replace_draw_calls).
Only a subset of Command arms are live today: Hello, GetChildren,
GetProperties, GetPropertyValue, SetPropertyValue (incl. expr
compile and full VectorShape push), GetSignals, RegisterSlot,
GetSlots, GetMethods, GetMethod, CallMethod.
The existing entry points are example scripts (bin/app/script/) and the
pydrk library — no CLI exists. pydrk has no packaging; it is run with
cwd bin/app. Its sole dependency is pyzmq. Self-testing convention is an
if __name__ == "__main__": block (see vector_shape.py).
Goals:
Layer and VectorArt nodes with full
factory properties and live pimpls, so shapes drawn over the wire show
up and clean up correctly.Non-Goals:
readline +
argparse + shlex; pyzmq stays the only third-party import).Text, Edit,
ChatView, plugins, ...). Only Layer and VectorArt.EventLoop in pydrk/event.py stays as is).AddNode carries (parent_path: String, name: String,
node_type: SceneNodeType) and replies the new node_id: u32. The server
resolves the parent, builds the node, links it, and registers it — one
round trip, no dangling state.
Alternative considered: resurrect the old two-step
AddNode(name, type) -> id + LinkNode(child_id, parent_id). That needs
a server-side id -> node registry to keep dangling nodes addressable,
which is exactly the machinery that was deleted with the central
registry. The CLI never wants a dangling node. Atomic attach is simpler
and matches the path-addressed model.
Layer and VectorArt factories are exposedBoth pimpls take only (SceneNodeWeak, Renderer, RedrawTrigger) — the
two handles the adapter can carry. The AddNode arm (conceptually):
Command::AddNode => {
let parent_path: ScenePath = String::decode(&mut cur).unwrap().parse()?;
let node_name = String::decode(&mut cur).unwrap()?;
let node_type = SceneNodeType::decode(&mut cur).unwrap();
debug!(target: "req", "{cmd:?}({parent_path}, {node_name}, {node_type:?})");
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 node = match node_type {
SceneNodeType::Layer => {
create_layer(&node_name)
.setup(|me| Layer::new(me, self.renderer.clone(), self.redraw.clone()))
.await
}
SceneNodeType::VectorArt => {
create_vector_art(&node_name)
.setup(|me| VectorArt::new(me, self.renderer.clone(), self.redraw.clone()))
.await
}
_ => return Err(Error::UnsupportedNodeType),
};
self.redraw.make_guard(gfxtag!("ZeroMQAdapter::AddNode"));
parent.link(node.clone());
node.id.encode(&mut reply).unwrap();
}
Notes: SceneNode::setup must run before link (it asserts
strong_count == 1), same ordering as every schema call site. After
link, the pimpl's start(ex) is spawned (self.ex.spawn(...)) so
OnModify handlers (redraw on property change) are armed exactly like
window-owned nodes — copy the pattern from src/ui/win/mod.rs
(obj.start(ex.clone()).await). Factories and pimpls are imported from
where the schema uses them (crate::app::node::{create_layer,
create_vector_art}, crate::ui::{Layer, VectorArt}).
Other node types fail with a new Error::UnsupportedNodeType (D4). The
client additionally rejects unknown type strings locally before sending
(friendlier message, no round trip).
RemoveNode carries (node_path: String). Flow: reject / (the root
has no parent, so removal is meaningless) with Error::NodeNotRemovable;
look up the node; node.unlink(); self.redraw.trigger(). When the
parent drops its last Arc, the pimpl Drop impls clear GPU draw calls
and the OnModify tasks die with the node.
This is a debugging tool, so removal is deliberately NOT restricted:
built-in nodes are removable exactly like wire-created ones, giving the
tool full access to the scene graph. The safety story is that netdebug
is dev-only and every change is runtime-only — restarting the app
restores the schema-built tree. Removing nodes the render pass depends
on (e.g. /window) can leave a blank window or, in the worst case, an
app panic; that is an accepted, documented trade-off for a debug tool,
and the app is simply restarted.
Alternative considered: restrict removal to wire-created nodes (a
wire_nodes id set in the adapter). Rejected per requirements — the
point of the tool is to experiment on the real tree, including taking
subtrees away.
Error::UnsupportedNodeType = 50 and Error::NodeNotRemovable = 51 (the
latter used only to reject removing the scene root) in src/error.rs
(the enum ends at 49). pydrk mirrors them: ErrorCode entries + exc.py
classes + _make_request match arms. Existing errors cover everything
else (sibling conflict → NodeSiblingNameConflict, missing parent →
NodeNotFound).
RendererZeroMQAdapter::new(sg_root, renderer, redraw, ex) — main.rs already
has app.renderer in scope where the adapter is spawned (currently
main.rs:196-207), so this is a one-line call-site change plus the
struct field.
add_node(parent_path, name, node_type) -> int and
remove_node(node_path) in api.py, encoded per D1/D3.get_info,
get_parents, link_node, unlink_node, rename_node,
scan_dangling, lookup_node_id, add_property, unregister_slot,
lookup_slot_id) and the vertex()/face() legacy mesh helpers.
They misparse the empty errc=0 replies and only encode the removed
registry world.Fixed: get_method() result decoding. The server encodes
Option<Vec<CallArg>> (net.rs method.result.encode(...));
api.py currently decodes a bare array, which silently truncates for
Some(...) results. Read the option tag first:
args = serial.decode_arr(cur, read_arg)
results = serial.decode_opt(cur, lambda cur: serial.decode_arr(cur, read_arg))
bin/app/pydrk/cli.py argparse subcommands + handler functions + REPL + completer
bin/app/pydrk/__main__.py import cli; cli.main()
main() builds a parser with --addr/--port and one subparser per
command. With a subcommand: run the handler once, print errors as
error: <name>, sys.exit(1). With no subcommand: enter the shell.cmd_ls(api, args), cmd_set(api, args)
... shared by both modes. The shell re-parses each input line with
shlex.split and dispatches to the same per-command arg parsers, so
usage/help stays single-sourced in argparse.set, get and show share one positional grammar with optional parts:
set [path] PROP [idx] VAL, get [path] PROP [idx], show [path] PROP.
Positionals are parsed right-to-left so no flags are needed for the
common cases (VAL is always last; an integer right before it is the
index; what is left at the front is the path, joined with / when it
spans several tokens). Flags (--expr) are stripped first:
def parse_set_args(tokens, default_path):
value = tokens.pop()
idx = 0
if tokens and tokens[-1].isdigit():
idx = int(tokens.pop())
if not tokens:
raise UsageError("missing property name")
prop = tokens.pop()
path = resolve_path(default_path, "/".join(tokens)) if tokens else default_path
return (path, prop, idx, value)
parse_get_args/parse_show_args are the same idea without VAL
(property names are never integers, so the right-to-left split is
unambiguous; the one-shot mode passes default_path="/"). show prints
one property's metadata block (same fields as props, plus depends)
followed by its per-index values — the "everything about this property"
view. set fetches api.get_properties(path) once, finds the property,
and encodes by its declared type:
| declared type | encoding | value parsing |
|---|---|---|
| bool | set_property_bool |
true/false |
| uint32 / scene_node_id | set_property_u32 / set_property_node_id |
int(token, 0) |
| float32 | set_property_f32 |
float(token) |
| str | set_property_str |
token as-is (quote in shell for spaces) |
| enum | set_property_enum |
must be in enum_items, else local error |
null literal null |
set_property_null |
n/a |
The --expr flag switches to set_property_expr and sends the value as
expr source (the server compiles with the const-free compiler; w/h
are the available globals). Enum membership and numeric parsing are
validated client-side so mistakes produce a local usage error instead of
a wire round trip. This "ask the server for the type" approach means
juniors never have to know the type — set alpha 0.5 just works.
State: Shell class holding the Api, cwd: list[str] (tokens, [] =
root), and the completion cache. Prompt: pydrk:/window/content>
(rendered from cwd). Builtins: cd (no arg → /; .. pops; otherwise
resolve and verify with api.get_children(parent) before committing),
pwd, exit/quit (and EOF). Everything else dispatches through the
same handlers as one-shot mode.
Path resolution (pure function, unit-tested):
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)
Absolute arguments (leading /) pass through unchanged; all others are
taken relative to cwd. The cd of a resolvable-but-childless path is
still valid (leaves can be cwd for set/get); cd into a
non-resolvable path fails with node_not_found and leaves cwd alone —
verified by looking the path up (api.get_children of the parent, or a
cheap api.get_properties(path)).
Line tokenization is shlex.split so values containing spaces can be
quoted: set nick "hello world".
A Completer class registered with readline:
get/set/show first argument: complete from the union of the cwd
node's property names (api.get_properties, cached) and its child node
paths — both are valid leading tokens under the optional-path grammar
of design D8./), resolve the dir part against cwd, fetch
api.get_children (cached), return name + "/" matches. Matches come
from the live app, so freshly mknode-ed nodes complete immediately.path -> [child names / prop names] cleared at
every prompt redraw (i.e. after each executed command), so mutations
are picked up without staleness bugs. One REQ per uncached directory
per line — the REQ/REP socket is lockstep anyway.import readline is wrapped in try/except; without it the shell runs
without completion (e.g. exotic platforms). Dev target is Linux.No rlcompleter/prompt_toolkit: zero new dependencies, and
Completer needs app state (cwd, live children) that the generic
completer doesn't have.
set-shape composes vector_shape.VectorShape from flagsEach primitive flag is action="append" and carries its colors inline as
trailing R G B A float args (no hidden color state):
--box X1 Y1 X2 Y2 R G B A
--gbox X1 Y1 X2 Y2 R G B A R G B A (top color, bottom color)
--vgradient X1 Y1 X2 Y2 R G B A R G B A STRIPS GAMMA
--outline X1 Y1 X2 Y2 BORDERPX R G B A
--line X1 Y1 X2 Y2 THICKNESS R G B A
--glow CX CY W H SEGMENTS R G B A
Coordinates are passed through to vector_shape as-is: plain numbers
are normalized to float literals, anything else (e.g. w/2, h - 10)
is expr source — exactly what VectorShape._coord already does. Flags
apply in command-line order via shape.join(...). Client-side guard:
len(shape.verts) < 65536 before sending (indices are u16 on the wire).
Then shape.set(api, path, prop_name) pushes it. Example:
python -m pydrk set-shape /window/content/dbg/art1 --box 0 0 w 10 1 0 0 1
make compile-dev after every Rust task (per bin/app/AGENTS.md).python -m pydrk.cli --selftest — an if __name__ == "__main__"-style
assert block, same convention as python -m pydrk.vector_shape.make dev in one terminal, CLI in another; each
task lists the exact commands and expected output. The drawing tasks
are verified by looking at the window.app: or app/netdebug:.rmnode --help.OnModify holds only weak refs; if anything is found
holding a strong ref, removal degrades to "hidden but alive", which is
still safe — escalate before shipping if observed.sexpr_global_not_found) is still
surfaced because it fails at compile time, before eval.None. The netdebug backend is feature-gated out of release builds; dev workflows rebuild both peers from the same tree.
/debug parent layer be created at app boot to host wire
nodes by default? Deferred: users can mknode anywhere under
/window/content today; adding a fixed parent is cosmetic and can be
a follow-up.pydrk/event.py's EventLoop keyboard path be repointed to a
live node path? Deferred: out of scope for this change (PUB-side
tooling unchanged).