Skip to content

feat(capi): Strand 2 custom-rendering API (RFC 0013)#51

Open
willwade wants to merge 4 commits into
mainfrom
feat/strand2-custom-rendering
Open

feat(capi): Strand 2 custom-rendering API (RFC 0013)#51
willwade wants to merge 4 commits into
mainfrom
feat/strand2-custom-rendering

Conversation

@willwade

Copy link
Copy Markdown

Implements Strand 2 from governance RFC 0013 (dasher-project/governance#20): a frontend can query the visible node tree each frame and render it itself — 3D cubes, VR/spatial layouts, custom visualisations, accessibility views — instead of consuming the flat int[] command buffer. Strands coexist.

This is the proper fix for cube mode (issue #49): frontends render real 3D cubes from node data + their own graphics pipeline, replacing the withdrawn opcode-7 experiment (#50, closed unmerged).

New C API (dasher.h)

dasher_set_visible_nodes_enabled(ctx, 1)            // opt-in; OFF by default
dasher_get_visible_nodes(ctx, out_nodes, max, &strs, &str_count)
dasher_get_viewport(ctx, &vp)
  • dasher_node_info: struct_size, Dasher-Y range, symbol (-1 for group/control), has_children, depth (tree depth — cube-extrusion source), is_game_node, clipped screen bounds, fill_argb/outline_argb, label_index.
  • dasher_viewport: crosshair (fixed at origin), visible Y range, canvas size.
  • Both structs carry a caller-set struct_size field for ABI evolution (per the RFC).
  • Returned pointers are engine-owned, valid until the next dasher_frame / dasher_get_visible_nodes call.

Opt-in by default: Strand 1 frontends that never call dasher_set_visible_nodes_enabled pay zero overhead — the per-node recording only runs when capture is on.

How it's captured

Recording happens inside CDasherViewSquare::NewRender/DisjointRender, at each node actually drawn. So the captured node set matches exactly what the command buffer draws for the same frame (Strand 1/Strand 2 parity). The bounds use the same clip formula as the OVERLAPPING_RECTANGLE draw (the parity reference shape).

Minor core additions:

  • CDasherNode::IsSymbolNode() virtual — GetAlphSymbol() throws on the base class (only CSymbolNode overrides), so the C boundary gates on IsSymbolNode() rather than RTTI or try/catch.
  • CDasherView gains a VisibleNode struct + SetVisibleNodeCapture / IsVisibleNodeCaptureEnabled / GetVisibleNodes virtuals (default no-ops; only CDasherViewSquare records).

Tests (test_strand2_nodes.cpp)

  • disabled by default; enabling works
  • depth-first preorder invariant on depths (depth[i] <= depth[i-1]+1, first node depth 0)
  • Strand 1 vs Strand 2 parity: every captured node's screen bounds matches an opcode-4 filled rectangle from dasher_frame() for the same frame (the key invariant)
  • labels populated (group + symbol nodes); label indices index the strings array
  • symbol field sane (-1 for group/control nodes, valid index otherwise) and symbol nodes appear when the tree is zoomed past the top-level groups
  • viewport matches engine constants (crosshair 2048,2048) + canvas size
  • struct_size ABI guard rejects undersized caller structs
  • bounds monotone within the canvas

Verification

  • clang-format clean (--Werror dry-run)
  • Debug build: strand2/cube/circle/draw-snapshot/draw-command tests pass
  • Strand 1 golden tests (draw_snapshot, draw_command) unchanged — capture is off by default, so the command-buffer path is untouched

Open follow-ups (not in this PR)

  • Non-node colours (background, crosshair, game-guide): the RFC's remaining open question (Q2) on a separate dasher_get_palette_colors. v1 ships per-node colours only.
  • C_API.md docs for the new functions (will add before merge unless reviewers want it here).

willwade added 3 commits July 25, 2026 21:04
Adds the second rendering strand from governance RFC 0013: a frontend can query the visible node tree each frame and render it itself (3D cubes, VR/spatial, custom visualisations, accessibility) instead of consuming the flat int[] command buffer. Strands coexist — a frontend can use the command buffer for most rendering and drop into Strand 2 for specific features.

New C API: dasher_set_visible_nodes_enabled (opt-in; off by default so Strand 1 frontends pay zero overhead), dasher_get_visible_nodes (depth-first visible node tree with pre-computed screen bounds, colours, labels, depth, game flag), dasher_get_viewport (crosshair + visible Y range + canvas size). dasher_node_info / dasher_viewport carry a caller-set struct_size field for ABI evolution.

Capture happens inside CDasherViewSquare::NewRender/DisjointRender at each node actually drawn, so the captured set matches exactly what the command buffer draws for the same frame (Strand 1/Strand 2 parity). This is the proper fix for cube mode (issue #49) — frontends now render real 3D cubes from node data + their own graphics pipeline, replacing the withdrawn opcode-7 experiment (PR #50).

Minor core additions: CDasherNode::IsSymbolNode() virtual (GetAlphSymbol() throws on the base class, so the C boundary gates on IsSymbolNode rather than using RTTI); CDasherView gains VisibleNode struct + SetVisibleNodeCapture/IsVisibleNodeCaptureEnabled/GetVisibleNodes virtuals.

Tests (test_strand2_nodes.cpp): disabled-by-default + enable; depth-first preorder invariant; Strand 1/Strand 2 parity (every captured node bound matches an opcode-4 rect from the same frame); labels/symbols populated; viewport matches constants + canvas size; struct_size ABI guard rejects undersized caller structs; bounds are monotone.

Signed-off-by: will wade <willwade@gmail.com>
Adds a Custom Rendering section to docs/C_API.md covering dasher_set_visible_nodes_enabled / dasher_get_visible_nodes / dasher_get_viewport, the dasher_node_info / dasher_viewport structs, the opt-in capture model, Strand 1/2 parity, and a render example.

Signed-off-by: will wade <willwade@gmail.com>
…Rule 4)

GetAlphSymbol() throws on the base CDasherNode class and the IsSymbolNode() fast-path guard is not a substitute for the C-boundary exception guard — a thrown exception crossing extern "C" aborts the process (review feedback on #51). Wrap the whole body of dasher_get_visible_nodes (and, for consistency, dasher_get_viewport and dasher_set_visible_nodes_enabled) in try { ... } catch (...) { return -1; } per CONTRIBUTING Rule 4. IsSymbolNode() is kept as the common no-throw fast path; the catch is the safety net.

Test: add a long-driven-session regression that asserts dasher_get_visible_nodes always returns a sane value and never lets an exception escape — would SIGABRT without the guard.
Signed-off-by: will wade <willwade@gmail.com>
@willwade

Copy link
Copy Markdown
Author

Good catch — fixed. You're right that the IsSymbolNode() guard is a fast-path optimization, not a substitute for the Rule 4 boundary guard.

Change: wrapped the entire body of dasher_get_visible_nodes in try { ... } catch (...) { return -1; }, and did the same for dasher_get_viewport and dasher_set_visible_nodes_enabled for uniform Rule 4 compliance. IsSymbolNode() stays as the common no-throw fast path; the catch is the safety net for any accessor that throws (GetAlphSymbol on an unexpected node type, a bad_alloc in the label-string copy, etc.). No exception can now cross extern "C" from these entry points.

Test added: strand2/never throws across the C boundary on a long session — drives 60 deep frames with capture on and queries every frame; asserts a sane return each time. Without the guard, a thrown GetAlphSymbol would SIGABRT the test process; with it, the function returns -1 and the run completes.

All 8 strand2 tests pass locally (227 assertions). CI re-running on the push; leaving the PR open for the Dasher-Apple team review as requested (no merge).

…herNode*)

Root cause of the recurring crash (review feedback on #51): VisibleNode stored a raw CDasherNode* captured during Render() and dasher_get_visible_nodes dereferenced it after the frame returned. The model can free/mutate nodes between the capture and the query, so those pointers dangled — the IsSymbolNode/GetAlphSymbol try/catch only moved the crash one accessor along (getLabel/GetFlag on a freed node).

Fix: make VisibleNode a fully self-contained snapshot. CaptureNode now resolves every value — symbol (via IsSymbolNode + GetAlphSymbol), has_children, is_game_node, fill/outline colours, label text (copied), depth, clipped screen bounds — while the node is guaranteed alive, during the render pass. The C API query just copies pre-resolved fields; no CDasherNode* is stored or dereferenced, so node lifetime between frame and query no longer matters.

Verified under ASan + UBSan (all 9 strand2 tests clean, no heap-use-after-free). Added regression test: drive, then dasher_reset() (frees the node tree the snapshot came from), then query — must not crash. The boundary try/catch is retained as defense-in-depth.

Signed-off-by: will wade <willwade@gmail.com>
@willwade

Copy link
Copy Markdown
Author

You were right — that was the real root cause, and the try/catch only moved the crash one accessor down. Fixed properly this time.

Root cause: VisibleNode stored a raw CDasherNode* captured during Render(), and dasher_get_visible_nodes dereferenced it after the frame returned. The model frees/mutates nodes between the capture and the query, so those pointers dangled — the GetAlphSymbol catch stopped the SIGABRT but the next access (getLabel / GetFlag on a freed node) crashed.

Fix: VisibleNode is now a fully self-contained snapshot — no CDasherNode*. CaptureNode resolves every value while the node is guaranteed alive, during the render pass:

  • symbol via IsSymbolNode() + GetAlphSymbol() (with its own defensive catch)
  • has_children, is_game_node
  • fill / outline colours (copied)
  • label text (copied into a std::string)
  • depth, clipped screen bounds

The C API query just copies these pre-resolved fields into dasher_node_info. No CDasherNode* is stored or dereferenced at query time, so what the model does to its node tree between dasher_frame() and dasher_get_visible_nodes() no longer matters.

Verification:

  • All 9 strand2 tests pass under ASan + UBSan — no heap-use-after-free (the definitive check for the bug you hit).
  • Added regression test strand2/snapshot survives node lifetime changes (no dangling deref): drives the engine, then calls dasher_reset() (which frees the node tree the snapshot came from), then queries — must not crash.
  • The boundary try/catch on dasher_get_visible_nodes is kept as defense-in-depth (Rule 4), but it should no longer be reachable for this bug.

CI re-running; PR still open for the Dasher-Apple review (no merge).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant