ADR-0035: Trackpoint Cart-FFI Surface — v0.1 Cursor + Event API
Context
Section titled “Context”ADR-0032 committed 2× holykeebs trackpoint modules (Sprintek SK8707-01, one per index finger) as v0.1 hardware. The two PS/2 streams aggregate at the master KB2040 over the standard QMK split-keyboard pointing-device transport and emit on the master’s USB HID interface as one cursor — the Pi sees a single mouse device. ADR-0032 §5 explicitly deferred cart-FFI exposure: v0.1 routed the cursor to the nOSh runtime only (nEmacs click-to-position, REPL output click-to-copy, future Bare Deck Terminal cursor nav), with no Lisp-visible pointer primitives.
That deferral closed on 2026-06-07: Josh decided to expose the cursor to carts in v0.1. This ADR codifies the surface — three primitives, a visibility default, a reservation policy, and the seam where REPL/nEmacs integration gets revisited later.
Forcing functions
Section titled “Forcing functions”- Cart authors need a stable cursor surface at v0.1. Without a cart-facing primitive, every cart that wants a cursor either (a) does nothing (operator sees the runtime cursor disappear when a cart loads), or (b) requires per-cart runtime hooks. Both options foreclose cart-side UI that uses the cursor (maps, selectable stat sheets, hover-preview lists, list-as-cursor-targets) — exactly the patterns
trackpoint-module.md§“What KN-86 buys from this addition” cites as the operator-facing reason for trackpoints. Deferring the FFI to v2 means deferring those patterns to v2 — that is the cost ADR-0032 §5 was paying. - The hardware is committed. ADR-0032 §1 BOM is going to holykeebs as soon as the F1 task lands. Carts authored against v0.1 ship into hands that have the cursor whether the FFI exists or not; not landing the FFI means cart authors and operators see the cursor disconnect from cart UI at launch.
- The launch-title slate gets to use it.
docs/software/cartridges/design-bibles/launch-titles-capability.mdis mid-flight; landing the cursor surface now lets launch carts opt into pointer-driven UI before their gameplay loops finalize. Late v0.1 FFI additions force re-spec of cart UI patterns; earlier is cheaper. - The surface is small. Three primitives, one manifest flag, one reservation rule. No new wire-protocol changes (the cursor is already on
/dev/input/event*via QMK HID). The ADR ships a contract; bridge implementation is one bridge file plus tests.
Constraints
Section titled “Constraints”- Two physical trackpoints aggregate to one logical cursor.
ADR-0032§4 commits to “one cursor at the Pi” via QMK split-pointing-device transport. v0.1 cart-FFI exposes the merged cursor only. Per-trackpoint differentiation (pointer-a/pointer-b) is queued as a v2 question pertrackpoint-module.md§“What this leaves open.” - Cell coords, not pixels. Cartridges author against a 128-column × 75-row text grid (
CLAUDE.mdCanonical Hardware Specification;ADR-0027). All existing display primitives (text-puts,text-cursor,text-invert,draw-bordered-box) take cell coords. The cursor FFI follows the same convention; pixel coords would break the existing authoring model and would also misrepresent the cursor’s behavior (the renderer snaps to cells regardless). - Row 0 and Row 74 are firmware-owned.
CLAUDE.mdSpec Hygiene Rule 5: cartridges never draw on Row 0 (firmware status bar) or Row 74 (firmware action bar). The cursor — a cart-FFI primitive — must respect the same reservation. - CIPHER-LINE is OLED-exclusive.
CLAUDE.mdSpec Hygiene Rule 6 +ADR-0015: cart content never reaches the auxiliary OLED via main-grid primitives. The cursor is a main-grid concept; it does not render on CIPHER-LINE, and there is no CIPHER-LINE cursor primitive in v0.1. - Click semantics are firmware-deferred per ADR-0032 §4. This ADR commits to the cart-FFI shape for a click event; the exact tap-vs-drag threshold (ms) lives in the QMK PS/2-mouse driver and ships from QMK stock for v0.1. Threshold tuning is ADR-0032 F4, not this ADR.
- REPL/nEmacs cursor model stays per
ADR-0016. Click-to-position innEmacsand click-to-copy in REPL are runtime features, not cart-FFI features. This ADR explicitly does not changeADR-0016; a follow-up ADR will revisit the seam (see §Follow-on work). CLAUDE.mdSpec Hygiene Rules 1, 2, 3 apply. New canonical values land in this ADR + ADR-0005’s Tier 1 catalog once; cascade grep sweep is a hard PR requirement.
Decision
Section titled “Decision”Cartridges access the merged trackpoint cursor through three Tier 1 NoshAPI primitives added to ADR-0005: (cursor-position), (on-trackpoint-move handler), (on-trackpoint-click handler). All three return / accept cell coordinates on the 128×75 main grid, never pixels. Cursor visibility defaults to visible; carts hide it via both a manifest declaration (:pointer-hidden) for the static default and a runtime FFI ((cursor-visible! bool)) for dynamic toggling. The cursor is clamped to the cartridge content area only (cols 0–127, rows 1–73); movement into Row 0, Row 74, or off-grid clamps at the boundary — wrapping is rejected for predictability. The CIPHER-LINE 256×64 auxiliary OLED carries no cursor in v0.1.
Concrete commitments:
1. The three Tier 1 primitives
Section titled “1. The three Tier 1 primitives”All three land in ADR-0005 §“Tier 1: All-Carts Primitives” under a new sub-section “Pointing / Trackpoint” placed after §“Procedural Generation (LFSR)” and before §“Deck State Access.” They are available to every cart at every phase, with no mission-context requirement.
| Lisp name | Signature | Returns | Semantic contract | Raises |
|---|---|---|---|---|
cursor-position | () | (col row) — 2-element list | Query current cursor cell on the main 128×75 grid. col: 0–127. row: 1–73 (cursor cannot stand on Row 0 or Row 74 per §3 reservation; query during a clamped state returns the clamped position, not the pre-clamp position). Cheap — O(1), reads from the runtime’s cursor-state record. Returns the same (col row) shape on every host. | — (never raises; if the cursor is hidden, returns its last-known logical position, not nil — visibility and logical position are independent state) |
on-trackpoint-move | (handler) | Unit | Register a Lisp lambda invoked when the cursor’s cell position changes — i.e. the cursor crosses a cell boundary, not on every pixel-precision motion event. Handler signature: (handler col row delta-col delta-row) where (col, row) is the new cell and (delta-col, delta-row) is the integer cell delta from the previous position (can be negative; can be > 1 if the cursor moved faster than the per-tick sampling). Only one handler per cart; re-registering replaces. Pass nil to unregister. Handler runs on the runtime event loop, same dispatch-latency contract as ADR-0005’s existing handler model (5 ms target, 10 ms ceiling). Handler exceptions are silently logged and the cursor continues — the handler does not block runtime input dispatch. | :invalid-handler if handler is non-nil and not a callable lambda. |
on-trackpoint-click | (handler) | Unit | Register a Lisp lambda invoked when the operator clicks. Handler signature: (handler col row) where (col, row) is the cell under the cursor at click time. Click semantics (tap-vs-drag threshold, button mapping) are firmware-defined per ADR-0032 §4 and not re-specified here; v0.1 fires on-trackpoint-click exactly when the QMK PS/2-mouse driver emits a primary-button click. Right-click and middle-click are deferred to v0.2 per ADR-0032 §4 — the v0.1 surface is one click event, no button index argument. Only one handler per cart; re-registering replaces. Pass nil to unregister. Same dispatch latency contract and exception handling as on-trackpoint-move. | :invalid-handler if handler is non-nil and not a callable lambda. |
Why cell coords (not pixels). Every existing display primitive in ADR-0005 Tier 1 uses cell coords (text-puts, text-cursor, text-invert, draw-bordered-box, draw-threat-bar, draw-progress-bar). The cursor renders on the main grid as a cell-aligned amber block; the operator perceives it snapping to cells. Returning pixel coords would force every cart that wants to compose with text primitives to convert through the native 8×8 cell geometry — error-prone and tied to a display-profile constant that the cell abstraction explicitly factors out. Half-block sub-pixel cursor access (the 128×150 half-block canvas per ADR-0027) is out of scope; v0.1 cursor is text-mode only.
Why : raises only for handler validation. cursor-position cannot meaningfully fail — the cursor always has a logical position, even when hidden. Returning a guaranteed 2-element list keeps cart code free of nil-guards in the common case. on-trackpoint-* raises only on bad input (non-callable handler).
2. Visibility default — VISIBLE, with hide via manifest and runtime FFI
Section titled “2. Visibility default — VISIBLE, with hide via manifest and runtime FFI”The cursor is visible by default. Carts that want to hide it can do so two ways:
(a) Cart manifest declaration — sets the cart-load default. A new flag in the cart’s manifest (the CART_CAPABILITIES block per ADR-0006 2026-04-24 amendment, or the manifest’s static config block — exact key location is an implementation detail for the ADR-0006 follow-on):
:pointer-hidden trueWhen set, the cursor is hidden the moment the cart loads, before any cart code runs. The operator still has a logical cursor position (still updates from trackpoint motion); only the visual rendering is suppressed. Use this when the cart’s UI is keyboard-only and the cursor would be visually noisy — e.g. a phase-screen cart that owns the full main grid for ASCII art.
(b) Runtime FFI — (cursor-visible! bool). A fourth primitive lands alongside the three above:
| Lisp name | Signature | Returns | Semantic contract | Raises |
|---|---|---|---|---|
cursor-visible! | (visible) | Unit | Show (#t) or hide (#f) the cursor. Overrides the manifest default for the lifetime of the cart load. Independent of cursor position — toggling visibility does not move the cursor. | :invalid-bool if visible is not #t or #f |
The runtime FFI lets a cart toggle visibility per phase / per mode without re-loading. Example: a map-view phase shows the cursor; a cinematic clip phase hides it; the next interactive phase shows it again.
Why both, not one. The manifest is the declarative default — captured in the cart’s static contract, visible to the cart-bundle inspector, no runtime cost. The FFI is the imperative override — handles per-phase or per-mode toggles that can’t be expressed at cart-load time. Manifest-only is too coarse (forces re-spec for any cart whose visibility varies); FFI-only loses the declarative default (cart code must run before the cursor’s initial state is set, which means a frame of “wrong” cursor at every cart load). Both is the minimum surface that covers both modes cleanly. Cost: one manifest key, one FFI primitive — total surface delta over a single-mechanism design: trivial.
Cart-load sequence for the visibility default: (1) cart manifest parses → :pointer-hidden flag captured at parse time, before any Lisp executes; (2) cart’s first Lisp form runs with cursor in the manifest-declared state; (3) any (cursor-visible! ...) call mutates the runtime state for that cart load only; (4) cart unload restores runtime default (visible) for the next cart.
3. Reservation policy — main 128×75 grid, content rows only, clamp at boundaries
Section titled “3. Reservation policy — main 128×75 grid, content rows only, clamp at boundaries”The cursor renders on the main 128×75 grid only. It does not render on the CIPHER-LINE auxiliary OLED (which has no cursor concept in v0.1) and it does not render on the firmware-owned rows. Specifically:
| Region | Cursor rendered? | Cursor logical position allowed? |
|---|---|---|
| Row 0 (firmware status bar) | No — reserved per CLAUDE.md Spec Hygiene Rule 5 | No — clamped to Row 1 |
| Rows 1–73 (cartridge content area) | Yes (when visible) | Yes — full range |
| Row 74 (firmware action bar) | No — reserved per CLAUDE.md Spec Hygiene Rule 5 | No — clamped to Row 73 |
| Columns 0–127 | Yes (when visible) | Yes — full range |
| Columns < 0 or ≥ 128 | N/A | No — clamped to 0 or 127 |
| CIPHER-LINE 256×64 OLED | No — auxiliary surface has no cursor in v0.1 per ADR-0015 | N/A |
Clamp, not wrap. When the operator moves the trackpoint past a boundary, the cursor clamps — it stops at the boundary and stays there until the operator moves it inward. Wrapping (cursor warps from col 127 to col 0) is rejected as the v0.1 behavior. Clamping is predictable for cart authors (no spooky discontinuities in delta-col / delta-row) and matches the operator’s spatial intuition (the cursor reads as a physical thing on a physical surface). Wrap is the right behavior for some specific UIs (e.g. a circular menu); a cart that wants wrap can implement it on top of clamp by reading position in its on-trackpoint-move handler and calling its own logic. The reverse — implementing clamp on top of wrap — is harder and requires every cart to fight the FFI.
delta-col / delta-row semantics during clamp. If the operator pushes the trackpoint against a boundary, on-trackpoint-move does not re-fire for motion-events that don’t change the cell position. The handler observes deltas only when the cursor crosses cell boundaries. Pushing against the clamp produces zero further events until the cursor moves inward. This avoids handler spam during sustained against-boundary push.
Cursor-on-Row-0/74 query behavior. A cart that explicitly calls cursor-position while the cursor is clamped at the Row 1 / Row 73 boundary sees the clamped position. The pre-clamp logical position is not preserved across queries — clamp is enforced at update time, not at query time.
4. CIPHER-LINE — no cursor
Section titled “4. CIPHER-LINE — no cursor”The CIPHER-LINE auxiliary 256×64 OLED (ADR-0015) is a separate display with its own 4-row layout, its own FFI surface (cipher-*, aux-*), and its own ownership model (firmware-managed CIPHER voice + runtime status surfaces). The trackpoint cursor does not appear on it. There is no v0.1 cipher-cursor-position, no on-cipher-trackpoint-*, no auxiliary cursor concept. The cursor is a main-grid primitive.
This preserves CLAUDE.md Spec Hygiene Rule 6 (CIPHER is OLED-exclusive with the Null-cart sanctioned exception) and Rule 5 row-reservation symmetry — the auxiliary display has its own row reservations that the cursor would also need to respect, and adding that surface area for v0.1 is not warranted given no cart has identified a CIPHER-LINE cursor use case.
5. REPL / nEmacs interaction — out of scope, follow-up flagged
Section titled “5. REPL / nEmacs interaction — out of scope, follow-up flagged”Cursor integration with the player-facing Lisp REPL and the nEmacs structural editor (ADR-0016) is explicitly out of scope for this ADR. ADR-0016 §3 (context-polymorphic dispatch) and §4 (CIPHER-LINE-hosted modeline/palette) define the REPL/nEmacs input model in keyboard terms; the trackpoint cursor changes nothing about that model in v0.1.
What this means concretely:
- In REPL mode, the cursor is visible (per the global default) and
cursor-position/on-trackpoint-*are available — but the REPL itself does not interpret cursor events. Clicking inside the REPL output area does not (in v0.1) copy the clicked line to the input prompt; that’s a future-ADR feature. The cursor is informational, not interactive, in the REPL surface. - In nEmacs mode, the cursor is visible — but clicking does not (in v0.1) position the editing cursor. Editing cursor position remains driven by Lisp primitives per
ADR-0016§3. - Cart context inside REPL/nEmacs — n/a. Carts run in mission context, not in REPL/nEmacs editing context, so the FFI is irrelevant when the operator is in the editor.
Follow-up: a future ADR (placeholder: ADR-XXXX, opened post-v0.1 bring-up) revisits REPL/nEmacs cursor integration. Likely scope: click-to-position in nEmacs, click-to-copy in REPL, scroll wheel handling. That ADR will amend ADR-0016. Not blocking v0.1.
6. Mission Runner / scripted missions
Section titled “6. Mission Runner / scripted missions”The three primitives are Tier 1 (all-carts, no mission-context restriction). They are available inside scripted-mission Lisp per ADR-0007 under the same Tier 1 grant as text-* and gfx-*. No new scripted-mission primitives are added.
7. No host-difference between emulator and device
Section titled “7. No host-difference between emulator and device”Both kn86emu (SDL3 desktop) and the device (Pi Zero 2 W + USB HID cursor) expose identical FFI. The emulator translates SDL3 mouse events to the same cursor-state record the device builds from /dev/input/event*. The cart-facing contract is host-independent per docs/software/api-reference/nosh-api/primitives-by-category.md §“Runtime Surface Split.” The constrained termbox2 host (kn86-nosh-tb per ADR-0027) does not expose these primitives in v0.1 — it’s a separate cell-API path; cursor exposure there is a separate decision tracked with the rest of kn86-nosh-tb’s FFI gap.
Options Considered
Section titled “Options Considered”Option A: Three primitives (cell coords) + manifest + FFI hide (ACCEPTED)
Section titled “Option A: Three primitives (cell coords) + manifest + FFI hide (ACCEPTED)”Described above. cursor-position, on-trackpoint-move, on-trackpoint-click in cell coords; visibility default visible; both manifest (:pointer-hidden) and runtime FFI (cursor-visible!); clamp at boundaries; no CIPHER-LINE cursor.
Option B: Defer cart-FFI to v2 — keep ADR-0032 §5 deferral (REJECTED)
Section titled “Option B: Defer cart-FFI to v2 — keep ADR-0032 §5 deferral (REJECTED)”Don’t add any cart-facing pointer FFI in v0.1. Cursor stays runtime-only (nEmacs click-to-position, REPL click-to-copy when those features land). Cart authors who want cursor-driven UI wait for v2.
Rejected because: the hardware ships in v0.1 hands. Operators will see the cursor existing globally but invisible / inactive the moment any cart loads. Launch carts can’t opt into cursor-driven UI. The “wait for v2” framing was the right framing in ADR-0032 §5 when the v0.1 surface was still being scoped tight; the surface is small enough that the v2 deferral now reads as gratuitous abstinence rather than scope discipline. Three primitives + a manifest flag + a clamp rule is a one-day FFI ship.
Option C: Pixel coords, not cell coords (REJECTED)
Section titled “Option C: Pixel coords, not cell coords (REJECTED)”Expose (cursor-position-pixel) → (x y) in half-block sub-pixel coords on the 128×150 half-block canvas per ADR-0027. Handlers receive sub-pixel deltas.
Rejected because: breaks composition with every existing display primitive. A cart that wants to invert the cell under the cursor would do (text-invert col row 1) — but sub-pixel-mode cursor gives x/y, and the cart must map the sub-row back to its containing cell row to land on the cell to invert. That math is the renderer’s job, not the cart’s. Sub-pixel coords also commit cart code to a specific canvas geometry; the cell abstraction is the stable one across hypothetical future display profiles. Half-block sub-pixel cursor access (where sub-pixel coords would be the right answer) is out of scope for v0.1 — no cart has identified a half-block cursor use case, and adding the surface speculatively bloats the API.
Option D: Two physical pointers, exposed individually as (pointer-a) / (pointer-b) (REJECTED)
Section titled “Option D: Two physical pointers, exposed individually as (pointer-a) / (pointer-b) (REJECTED)”Skip cursor merging and expose each trackpoint independently. (pointer-a-position) returns the left-half trackpoint’s logical position; (pointer-b-position) returns the right. on-trackpoint-a-move / on-trackpoint-b-move handlers per side.
Rejected for v0.1, queued for v2: trackpoint-module.md §“What this leaves open” already flags this as a v2 question. Two-pointer exposure requires (a) defeating QMK’s split-pointing-device transport (which merges to one HID interface by design), or (b) layering a custom HID descriptor on top, or (c) routing the slave’s trackpoint through a different channel entirely. All three are real engineering work, all three require firmware-side decisions ADR-0032 §4 deferred to bring-up, and none of them have a v0.1 cart use case driving them. v0.1 ships the merged cursor; v2 can split if a cart demonstrates the need.
Option E: Single mechanism for hide — manifest only, no runtime FFI (REJECTED)
Section titled “Option E: Single mechanism for hide — manifest only, no runtime FFI (REJECTED)”Cart hides cursor via :pointer-hidden manifest flag only; no cursor-visible! FFI. Carts that want per-phase visibility re-architect to load sub-carts (one with :pointer-hidden true, one with :pointer-hidden false).
Rejected because: sub-cart hot-swap is a heavy mechanism for a 1-bit toggle. The whole point of the manifest is to capture the initial state declaratively; per-phase toggles want a runtime knob. Both-mechanism cost is small (one extra primitive in a 60-primitive surface, one extra branch in the runtime cursor-state mutator). The savings of dropping cursor-visible! doesn’t justify the cart-author tax of “you can’t change cursor visibility within a single cart load.”
Option F: Single mechanism for hide — FFI only, no manifest flag (REJECTED)
Section titled “Option F: Single mechanism for hide — FFI only, no manifest flag (REJECTED)”No manifest declaration; cart hides cursor at startup by calling (cursor-visible! #f) as its first Lisp form.
Rejected because: there’s a frame (~1 redraw tick) of “cursor visible” between cart load and the cart’s first form executing. For carts that want the cursor hidden at load (cinematic cold-open, full-grid ASCII art), that frame is a visible flicker. Declarative manifest avoids it cleanly. Also: the manifest is the right place for static cart contracts (e.g., capabilities per the 2026-04-24 ADR-0006 amendment); cursor visibility default belongs in the same shape.
Option G: Wrap at grid boundaries instead of clamp (REJECTED)
Section titled “Option G: Wrap at grid boundaries instead of clamp (REJECTED)”When the cursor reaches col 79 / row 23 (top of content area), wrapping moves it to col 0 / row 1.
Rejected because: unpredictable delta-col / delta-row semantics — a single trackpoint push could produce a delta of -79 (cursor wrapped) instead of +1 (clamped at boundary). Cart handlers would have to special-case the wrap. The operator’s spatial intuition — “the cursor is a thing on a physical screen” — fights wrap. Cart authors who want wrap can implement it trivially on top of clamp; the reverse is hard. Clamp wins on the predictability dimension.
Option H: Mid-rejected — Include a click-button index in on-trackpoint-click (REJECTED)
Section titled “Option H: Mid-rejected — Include a click-button index in on-trackpoint-click (REJECTED)”Handler signature (handler col row button) where button is 0/1/2 for primary/right/middle.
Rejected for v0.1: ADR-0032 §4 explicitly defers right-click and middle-click to v0.2 (with [SHIFT]+stem-press as the leading right-click candidate). Exposing a button index now commits the surface to a binding that doesn’t exist yet. v0.1 fires the handler exactly when QMK emits a primary-button click; v0.2 amendment can add the index argument additively per ADR-0005’s “future fields are additive” convention.
Trade-off Analysis
Section titled “Trade-off Analysis”Against Option B (defer to v2): Option A wins on launch-window cart UX. The v0.1 hardware ships with a cursor; v0.1 carts deserve to use it. The FFI surface is small enough that deferral reads as scope discipline only against a much larger imagined surface — for three primitives, deferral is just delay.
Against Option C (pixel coords): Option A wins on composition with the rest of ADR-0005. Cell coords are the stable abstraction across the entire main-grid primitive set; pixel coords would be an outlier.
Against Option D (pointer-a / pointer-b): Option A wins on firmware simplicity (QMK split-pointing-device transport merges at the master, free) and on contract simplicity (one cursor matches operator perception in v0.1). v2 can split if needed.
Against Options E and F (single hide mechanism): Option A wins on covering both modes cleanly with negligible additional surface (one manifest key + one primitive).
Against Option G (wrap): Option A wins on predictable handler semantics and operator spatial intuition.
Against Option H (include button index in v0.1): Option A wins on not committing to v0.1 to a binding that doesn’t exist; v0.2 can additively extend.
Cost the chosen option pays:
- Three primitives + one toggle = four new Tier 1 cells in ADR-0005. Modest API growth (57 → 60 primitives, plus
cursor-visible!to 61); the trade for shipping a real cart-facing cursor surface in v0.1 is worth it. (See §Decision for the count rationale —cursor-position,on-trackpoint-move,on-trackpoint-clickare the “three” cited in the brief;cursor-visible!is a fourth primitive that lives in the same sub-section.) - One manifest flag commits ADR-0006’s manifest schema to a new key. Additive change; no breakage of existing carts.
- Clamp behavior is opinionated. Carts that want wrap implement it on top. Acceptable; wrap is the less-common case.
- Right-click / middle-click are deferred to v0.2. v0.1 cart authors only get one click button. Acceptable; ADR-0032 §4 already deferred those at the firmware level.
- No per-trackpoint differentiation in v0.1. Carts that want dual-pointer UX wait for v2. Acceptable; no current cart needs it.
- REPL / nEmacs cursor integration is deferred. v0.1 operators see the cursor in REPL/nEmacs but it’s informational only. Acceptable; the follow-up ADR is queued.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Launch carts can opt into cursor-driven UI. Map cells, selectable stat sheets, hover-preview lists, list-as-cursor-targets — all the patterns
trackpoint-module.md§“What KN-86 buys” cited as the operator-facing reason for trackpoints — become available to v0.1 launch titles. - Cell-coord contract composes cleanly with existing primitives. A cart that wants to highlight the cell under the cursor does
(let ((pos (cursor-position))) (text-invert (car pos) (cadr pos) 1))— no math, no conversion. - Visibility default + manifest + FFI covers both modes. Carts that want the cursor get it for free (default visible). Carts that want it hidden have a declarative path (manifest) and a per-phase path (FFI). No cart is stuck.
- Clamp at boundaries removes spec ambiguity around firmware-owned rows. Cart authors don’t have to wonder what happens if the cursor crosses Row 0; the answer is “it doesn’t.”
- No host-difference between emulator and device. Cart code authored against
kn86emuruns unchanged on the prototype. - Forward-compatible with v2 dual-pointer. When
(pointer-a)/(pointer-b)lands in v2, the existingcursor-position/on-trackpoint-*primitives remain valid as the “merged view” — no breaking changes to v0.1 carts.
Negative / Accepted costs
Section titled “Negative / Accepted costs”- Four new Tier 1 primitives (three move/click/position + one visibility toggle). Modest surface growth. Acceptable.
- One new manifest flag (
:pointer-hidden). Implementation is anADR-0006follow-on; not blocking this ADR. - REPL / nEmacs cursor integration deferred to a future ADR. Operators see cursor in those surfaces but can’t click-to-position / click-to-copy in v0.1. Acceptable; the keyboard-driven editing model is the canon per
ADR-0016, and click integration is enhancement, not gating. - Right-click / middle-click absent from v0.1 FFI. Cart authors only get one click event. Acceptable; ADR-0032 §4 already deferred the firmware side.
- Per-trackpoint differentiation absent from v0.1. Carts that imagine dual-pointer UX wait for v2. Acceptable; no current cart needs it.
Follow-on work this ADR creates
Section titled “Follow-on work this ADR creates”- F1 — Bridge implementation. Wire
cursor-position,on-trackpoint-move,on-trackpoint-click,cursor-visible!intokn86-emulator/src/nosh_lisp_bridge.cagainst the existing cursor-state record. Add tests atkn86-emulator/tests/test_nosh_lisp_trackpoint.ccovering: position query (visible + hidden), move handler fire on cell crossing, move handler no-fire on clamped-against-boundary push, click handler fire, handler replacement, handler unregister via nil,:invalid-handlerraise. Owner: C Engineer. Blocked by: nothing — emulator side can ship now; device-side validation follows F3. - F2 — Cart manifest
:pointer-hiddenflag. ExtendADR-0006cart format with the manifest key (location TBD by the ADR-0006 follow-on author — likely theCART_CAPABILITIESblock or a newCART_MANIFEST_FLAGSstatic-data subsection). Update cart-bundle tooling (tools/cart-bundle.pyor equivalent) to accept the flag and the runtime loader to honor it. Owner: C Engineer + Platform Engineering. Blocked by: F1 (so the runtime can act on the parsed flag). - F3 — Device-side validation. Once the trackpoint hardware bring-up completes per
ADR-0032F5, validate that the cart-FFI handlers fire correctly under real hardware events on the Pi (latency, clamping behavior, click event correctness). Owner: QA + Platform Engineering. Blocked by: ADR-0032 F5. - F4 — Launch-title cursor adoption review. Walk the four launch titles (
docs/software/cartridges/design-bibles/launch-titles-capability.md) and decide per-cart: does it adopt the cursor? Does it set:pointer-hidden truefor any phase? File per-cart sub-tasks. Owner: Gameplay Design. Blocked by: F1 (so cart authors can try the FFI in emulator). - F5 — REPL / nEmacs cursor follow-up ADR. Open a placeholder ADR-XXXX for click-to-position in nEmacs, click-to-copy in REPL, scroll-wheel handling. Owner: PM (drafts) + Josh (decides). Not blocking v0.1.
- F6 — Cart-FFI primitives reference doc. Update
docs/software/api-reference/nosh-api/primitives-by-category.md— already part of this ADR’s PR per §Documentation Updates below.
Documentation Updates (REQUIRED — part of the decision, not aspirational)
Section titled “Documentation Updates (REQUIRED — part of the decision, not aspirational)”This ADR lands in a single PR (this one) including the following updates per CLAUDE.md Spec Hygiene Rule 3:
-
docs/adr/ADR-0035-trackpoint-cart-ffi.md— this file. -
docs/adr/README.md— append ADR-0035 to the top of the chronological index. Update ADR-0005, ADR-0032 status notes to reflect amendment / supersedure by ADR-0035. -
docs/adr/ADR-0005-ffi-surface.md— add the “Pointing / Trackpoint” sub-section under §“Tier 1: All-Carts Primitives” with the four primitives (cursor-position,on-trackpoint-move,on-trackpoint-click,cursor-visible!). Add an amendment-log entry dated 2026-06-07 per ADR-0005’s existing amendment-log convention. -
docs/adr/ADR-0032-sweep-peripheral-commitment.md— frontmatter gains a “Followed by” link to ADR-0035. §5 (“cart-FFI exposure — none in v0.1”) gains a single line at the end noting that the deferral closed on 2026-06-07 per ADR-0035; pre-amendment text preserved as design history. -
docs/software/api-reference/nosh-api/primitives-by-category.md— add a new “Pointing / Trackpoint” category section between the existing “Procedural generation (LFSR)” and “State: deck-state read access (Tier 1)” sections, with the four primitives in the table format used elsewhere in the doc. - Project-wide grep sweep for stale claims:
rg -i "trackpoint|cursor-position|on-trackpoint" docs/— every hit must either (a) already reflect this ADR, (b) be design-history within an ADR or_archive/, or (c) be updated in this PR. The trackpoint hits indocs/influences/synthesis.md§10,docs/influences/prototype/trackpoint-module.md, anddocs/influences/prototype/holykeebs-buyers-guide.mdall pre-date this ADR; they describe the hardware decision (ADR-0032), not the cart-FFI, and remain accurate as written. The one forward-looking claim indocs/influences/prototype/trackpoint-module.md§“What this leaves open” — “v0.1 is firmware-only” and “a future cart-FFI primitive(pointer-state)returning(x y buttons)for cart-side custom handling is a v2 question” — is superseded by this ADR for the v0.1 timing claim; update the bullet with a back-pointer.rg -i "pointer-hidden|cursor-visible" docs/— should be empty before this PR; clean after.
-
docs/influences/prototype/trackpoint-module.md§“What this leaves open” — annotate the “Cart-FFI exposure of pointer events” bullet with: Closed byADR-0035(2026-06-07): cart-FFI exposure committed for v0.1; three primitives + visibility toggle in cell coords on the main 128×75 grid. Dual-pointer differentiation remains a v2 question.
A PR that lands this ADR without ticking the above fails review per CLAUDE.md Spec Hygiene Rule 3.
Implementation Queue
Section titled “Implementation Queue”Tracked in Notion Tasks DB under the KN86 tag:
| Task | Owner | Priority | Depends on |
|---|---|---|---|
Wire cursor-position / on-trackpoint-move / on-trackpoint-click / cursor-visible! into nosh_lisp_bridge.c (F1) | C Engineer | P0 | ADR merge |
Cart manifest :pointer-hidden flag — extend ADR-0006 cart format + loader + tooling (F2) | C Engineer + Platform Engineering | P0 | F1 |
| Device-side validation under real trackpoint hardware (F3) | QA + Platform Engineering | P1 | ADR-0032 F5 |
| Launch-title cursor adoption review (F4) | Gameplay Design | P1 | F1 |
| REPL / nEmacs cursor follow-up ADR (F5) | PM (draft); Josh (accept) | P2 | None — independent |
kn86-nosh-tb cursor exposure decision | Platform Engineering | P2 | ADR-0027 status |
Amendment Log
Section titled “Amendment Log”2026-06-12 — Grid coordinates reconciled to the 128×75 canon (ADR-0027)
Section titled “2026-06-12 — Grid coordinates reconciled to the 128×75 canon (ADR-0027)”What changed. This ADR was drafted against the 80×25 text grid, which was the canonical grid at drafting time. ADR-0027 was ratified 2026-06-07 (the same day this ADR was accepted) and made 128 columns × 75 rows the canonical grid: Row 0 status / rows 1–73 content / Row 74 action, native 8×8 cells, half-block 128×150 pseudo-pixel canvas (replacing ADR-0014’s 960×600 BITMAP / 12×24-cell model). All cursor-coordinate references in this ADR are reconciled to that canon:
(cursor-position)returnscol0–127,row1–73 (was 0–79 / 1–23).- The cursor clamps at Row 0 / Row 74 (was Row 24); content rows are 1–73 (was 1–23); columns are 0–127 (was 0–79).
- The reservation-policy table (§3) and the “Clamp, not wrap” / query-behavior prose now reference the 128×75 grid.
- Option C (pixel coords) is re-cast against the half-block 128×150 canvas rather than the retired 960×600 framebuffer.
- The §“Related” ADR-0014 reference is replaced with ADR-0027 (128×75 canon).
- The matching ADR-0005 amendment entry (Tier 1 “Pointing / Trackpoint”) is reconciled in the same PR.
Implementation note. Emulator code reconciles to 128×75 at the nOSh re-flow (tracked deviation, per CLAUDE.md Spec Hygiene). The cart-FFI contract — cell coordinates, clamp-not-wrap, content-rows-only — is unchanged in shape; only the numeric grid bounds move.
Authority trail. ADR-0027 §“Decision” items 3–5 (128×75 grid, half-block canvas, constrained cell API); CLAUDE.md Canonical Hardware Specification (updated 2026-06-07).
Narrative (for the design history)
Section titled “Narrative (for the design history)”ADR-0032 committed the trackpoint hardware on 2026-06-07 and explicitly deferred the cart-FFI to “v2,” reasoning that the v0.1 cursor would stay runtime-only (nEmacs, REPL, Bare Deck Terminal) and that cart-side pointer access deserved a more considered surface. A few hours later, looking at the deferral with fresh eyes, that framing read as gratuitous: the surface is small, the hardware is shipping, and launch carts deserve to use the cursor that operators can physically push around. So the cart-FFI shipped in the same v0.1 timeline. Three primitives — cursor-position to read the cell, on-trackpoint-move to react to crossings, on-trackpoint-click to react to taps — plus a visibility toggle (manifest declaration for the load-time default, FFI for per-phase overrides) and a clamp rule (cursor stays in the cartridge content area; never leaks onto firmware rows). Pixel coords got rejected because they don’t compose with the rest of the main-grid primitive set; per-trackpoint differentiation got deferred to v2 because no v0.1 cart needs it; REPL/nEmacs cursor integration got deferred to a follow-up ADR because ADR-0016 defines the editor input model in keyboard terms and changing that is its own decision. A future reader should care because this ADR sets the pattern for how to react to a too-conservative deferral on a small FFI surface: when the surface fits in a one-line table summary and the hardware is shipping into operator hands, ship the FFI instead of waiting for v2.