1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
# /python_scripts/lib/jumperless_mcp/effects.py — Jumperless MCP visual effects library.
# Importable as `from jumperless_mcp.effects import ...` when installed at
# /python_scripts/lib/jumperless_mcp/ (sys.path includes /python_scripts/lib/).
# Depends on FONT from jumperless_mcp.font (imported via __init__.py).
#
# Version: 0.2.12+20260512 — minimalist positive-space marquee (letters + chevrons only)
#
# pyright: reportUndefinedVariable=false, reportAttributeAccessIssue=false
#
# Pyright type-checks against CPython, which doesn't match this file's
# MicroPython runtime context on the Jumperless V5 (firmware 5.6.6.2).
# Specifically:
# - FONT is imported from jumperless_mcp.font (not exec'd into globals)
# - overlay_set_pixel / overlay_clear_all are MicroPython builtins exposed by
# the firmware's modjumperless.c bindings (not Python stdlib)
# - time.sleep_ms is a MicroPython extension to the time module (CPython has
# time.sleep(seconds) but not sleep_ms(ms))
# Suppressing reportUndefinedVariable and reportAttributeAccessIssue at file
# level so the LSP isn't noisy on what's actually correct device-side code.
=
# noqa: E402
# Explicit imports of firmware-injected builtins. Previously these were
# accessible bare-name because effects.py was loaded via exec(jfs.read(...))
# in the global script scope, where firmware globals are visible. As of v0.2
# we load via `import jumperless_mcp.effects` which puts us in a module-
# level namespace — firmware globals don't auto-resolve. Pull them in
# explicitly here so the bare-name call sites below keep working.
#
# pause_core2 added v0.2.5 for atomic-frame rendering (anti-flicker). Core 2
# is the LED-refresh core; pausing it during paint guarantees the renderer
# never observes intermediate frame states. See
# project_jumperless_atomic_frame_pattern.md memory file for the recipe.
# pause_core2 is a stateless volatile bool toggle (NOT a refcount) — nested
# pauses do NOT compose; the inner unpause defeats the outer pause. So we
# wrap at frame-transition boundaries only, not inside helper functions.
#
# overlay_set added v0.2.6 for bulk-write marquee rendering (anti-deceleration).
# overlay_set("name", sr, sc, w, h, colors_list) updates the named overlay
# in place via single memcpy — one FFI call replaces ~80 per-pixel calls,
# cutting frame paint cost from ~8ms to ~2-6ms. Also single markDirty per
# frame (vs 2 from clear_all+addOverlay) → autosave debounce stays settled
# longer → fewer visible 200ms autosave pauses during marquee.
#
# CRITICAL coord-system note (compiled firmware 5.6.6.2):
# overlay_set's dim constraints are SWAPPED from source comments — width
# caps at 10 (= vertical-hole-position axis), height caps at 30 (= horizontal
# column-strip axis). Colors layout: colors[(x-1)*10 + (y-1)] where x is the
# physical horizontal column (1-30) and y is the vertical hole (1-10).
# See project_jumperless_atomic_frame_pattern.md "Live-probe answers" section.
# noqa: E402
= 0xFC4C00 # PMS 165 C — NASA Worm logo orange
"""Paint text glyphs onto the breadboard overlay starting at (x_origin, y_top).
2x vertical stretch — each font row paints 2 breadboard rows.
Glyphs are 3 cols + 1 col gap = 4-col pitch."""
=
=
=
= +
continue
= + * 2
= + * 2 + 1
+= 4
"""Negative-space marquee: banner region fills with `color`, letter shapes
are unlit (0x000000) pixels. The whole banner+text scrolls as a unit.
Text is always laid out left-to-right in natural reading order regardless
of `direction`. The `direction` parameter only controls the sweep direction
(handled by marquee_scroll) — it does NOT affect character layout here.
Mid-scroll the viewer sees the text left-to-right correctly for both L2R
and R2L sweeps.
Rendering is fill-then-carve: paint the full banner rectangle with `color`,
then overwrite letter pixels with black. As of v0.2.5 this function does NOT
pause core 2 itself — the CALLER is responsible for wrapping the call in
pause_core2(True)/pause_core2(False) if atomic-frame rendering is desired.
`marquee_scroll` does this automatically per frame; direct callers of
`marquee()` should wrap themselves if they care about flicker."""
=
= * 4
# Compact banner: color in (x_origin - 5) to (x_origin + text_width + 5)
= - 5
= + + 5
=
=
# Carve letters as black holes
=
=
=
= +
continue
= + * 2
= + * 2 + 1
+= 4
# Pre-built blank-frame template for fast slice-assign reset.
# MicroPython slice-assign from a list is C-level memcpy — measured 145x
# faster than `for i in range(300): colors[i] = 0` (Python loop).
# Specifically: slice-assign = ~100us, Python loop = ~15ms per 300-element pass.
#
# v0.2.10: use TRUE transparent (0) instead of FORCE_BLACK (0x010101). The
# transparent pixels show the underlying LED state through. As long as the
# overlay is RECREATED each frame (via overlay_clear + overlay_set), no
# trails accumulate — the previous frame's color array is gone, so the
# renderer-skips-zero behavior just shows the underlying default state
# (= unlit black for fresh sessions with no nets). v0.2.8/0.2.9's force-black
# tint is replaced by genuine black, but at the cost of 2 markDirty per frame
# (vs v0.2.9's 1) — autosave behavior matches v0.2.5's profile.
= * 300
# Pre-computed lit-pixel coordinates for each font glyph (v0.2.11).
# Maps character → tuple of (col_offset, row_offset) where the glyph has a
# lit pixel. Iterating these directly is much faster than the previous
# 3×5 nested bit-check loop (which iterated 15 positions per char regardless
# of how many bits were set, with a Python-level bitmask test on each).
# Computed once at module import via the helper below.
=
=
=
return
=
"""Populate the existing 300-element `colors` list with one marquee frame.
Mutates in place (no per-frame allocation) — caller owns the buffer.
## Layout (v0.2.7 — corrected from v0.2.6)
The MicroPython binding at `modjumperless.c:5695-5698` SWAPS args before
forwarding to C: `overlay_set(name, A, B, C, D, colors)` in Python maps to
`jl_overlay_set(name, row=B, col=A, width=D, height=C, colors)`. So:
- A (python arg 2) = startCol (horizontal start)
- B (python arg 3) = startRow (vertical start)
- C (python arg 4) = height (vertical extent, ≤10, controls C-level outer loop)
- D (python arg 5) = width (horizontal extent, ≤30, controls C-level inner loop)
C-level renderer iterates `colors[r * width + c]` where `r` advances vertical
and `c` advances horizontal. With width=30, height=10 the layout is
row-major: `colors[(y-1) * 30 + (x-1)]` = pixel at (horizontal=x, vertical=y).
## v0.2.10: transparent non-banner + overlay_clear-per-frame
Earlier versions used FORCE_BLACK (0x010101) for non-banner pixels to
avoid trails (the renderer at `GraphicOverlays.cpp:319` skips color==0,
so persistent-overlay transparent pixels keep their previous LED-buffer
value, leaving banner trails). But FORCE_BLACK is perceptibly dim-white
under low-ambient-light conditions.
v0.2.10 keeps the colors-array layout with TRUE transparent (0) for
non-banner + carve positions, AND removes the overlay each frame via
`overlay_clear("_MARQUEE_")` BEFORE re-creating via `overlay_set`. This
matches v0.2.5's pattern (overlay_clear_all + recreate) — fresh overlay
each frame means the renderer's skip-zero behavior just shows the
underlying unlit-LED state (= true black for fresh sessions).
Trade-off: 2 markDirty per frame (overlay_clear + overlay_set's
addOverlay) vs v0.2.9's 1. Autosave triggers proportionally more often.
For ceremony-scale marquees this is acceptable; for sustained animation
the v0.2.9 FORCE_BLACK approach may be preferable.
## v0.2.11: Python-work optimizations
Two changes:
1. Banner fill: row-major C-level slice-assign instead of nested Python
loop. 10 slice-assigns × C-level memcpy vs up to 300 list-element
writes. Banner-fill cost drops from ~3-5ms to ~150us in measurement.
2. Letter carving: iterate pre-computed _FONT_CARVE points (only the
LIT pixels per glyph) instead of the 3×5 bit-check loop. Saves the
Python-level bitmask comparison on every iteration regardless of
whether the bit is set."""
= * 4
= - 5
= + + 5
=
=
# Zero the whole buffer via slice-assign from the template.
# C-level memcpy — ~100us total vs ~15ms for a Python for-loop.
=
# Banner fill: per-row C-level slice-assign across all 10 vertical rows.
# One small list allocation per frame for the fill_row template, then
# 10 slice-assigns. Each slice-assign is a C-level memcpy.
= - + 1
= *
= * 30 +
=
# Carve letter pixels back to transparent (0).
# Iterates only LIT pixels per glyph from the pre-computed _FONT_CARVE.
=
=
= +
continue
= + * 2
= + 1
= 0
= 0
+= 4
"""Populate the existing 300-element `colors` list with one positive-space
minimalist marquee frame. Mutates in place — caller owns the buffer.
## Positive-space rationale (v0.2.12)
v0.2.11 (_fill_marquee_frame) paints a full-width banner in `color` then
carves letters back to transparent. That produces a "negative-space" effect:
the banner is the signal, letters are the holes.
This function inverts the philosophy — the buffer starts fully transparent
(all zeros) and we paint ONLY:
1. Letter glyph pixels in `color`, using the pre-computed `_FONT_CARVE`
table (same table as the carve step in v0.2.11, now used for additive
painting instead of subtractive clearing).
2. Two 3-pixel direction-of-travel chevrons (one leading, one trailing)
in a quarter-brightness dimmed version of `color`.
All other pixels stay 0 (transparent). Combined with the caller's
`overlay_clear("_MARQUEE_") + overlay_set(...)` per-frame pattern (preserved
from v0.2.10/0.2.11), transparent pixels show the underlying unlit LED state
— no trails, no force-black needed.
## Chevron geometry
Each chevron is 3 pixels forming a `>` or `<` arrow, centered on breadboard
rows 4-6 (apex at row 5). Vertical center of the 10-row board is row 5.5;
rows 4/5/6 give a symmetric bracket around it.
L2R (rightward) chevron `>` at column cx:
(cx, 4) ← top arm
(cx+1, 5) ← apex (middle)
(cx, 6) ← bottom arm
R2L (leftward) chevron `<` at column cx:
(cx+1, 4) ← top arm
(cx, 5) ← apex (middle)
(cx+1, 6) ← bottom arm
## Chevron placement
For L2R (text moves right):
text_right = x_origin + text_width - 1
leading chevron cx = x_origin + text_width + 1 (1-col gap past text end)
trailing chevron cx = x_origin - 3 (2-col gap before text start)
For R2L (text moves left):
leading edge is LEFT; trailing edge is RIGHT.
leading chevron cx = x_origin - 3 (ahead of text on the left)
trailing chevron cx = x_origin + text_width + 1 (behind text on the right)
Chevron pixels clipped to x ∈ [1, 30] (off-screen positions silently skipped).
## Chevron color
Quarter-brightness dim of `color` via bit-shift: `(color >> 2) & 0x3F3F3F`.
Preserves hue, drops each channel to 25% intensity. For NASA_ORANGE
(0xFC4C00) this gives 0x3F1300 — visibly related but clearly subordinate."""
= * 4
= & 0x3F3F3F
# Zero the whole buffer via slice-assign from the template (C-level memcpy).
=
# --- Letter glyph pixels (additive, positive-space) ---
=
=
= +
continue
= + * 2
= + 1
=
=
+= 4
# --- Direction-of-travel chevrons ---
# Determine leading/trailing column origins based on direction.
= + + 1 # ahead: right side
= - 3 # behind: left side
# "R2L"
= - 3 # ahead: left side
= + + 1 # behind: right side
"""Paint a 3-pixel chevron at (cx, cy) into the colors buffer.
pointing_right=True → `>` shape; False → `<` shape.
cy is the top row (apex is cy+1, bottom is cy+2).
Clips to x ∈ [1, 30] silently."""
# >: top=(cx,cy), apex=(cx+1,cy+1), bottom=(cx,cy+2)
=
# <: top=(cx+1,cy), apex=(cx,cy+1), bottom=(cx+1,cy+2)
=
=
# Leading chevron points in direction of travel; trailing points away.
=
"""High-level marquee: handles the per-frame loop + paint + sleep.
Sweeps x_origin from off-screen-leading-edge to off-screen-trailing.
For L2R sweep, word order is reversed in the rendered banner so the
FIRST word of the input text enters the visible area first (matching
the direction of travel at the word-block level). Letter order within
each word remains natural in both directions.
For R2L sweep, both word order AND letter order are natural — this
matches the typical news-ticker reading experience.
Example: marquee_scroll("MCP CONNECTED", color, "L2R")
render_text = "CONNECTED MCP"
As x_origin sweeps left→right, the rightmost chars enter first:
frame ~0: "P" (of MCP) peeks in at col 30
frame ~4: "MCP" fully visible
frame ~8: space between words
frame ~16: "CONNECTED" fully visible
→ Reading order during entry: MCP first, then CONNECTED.
v0.2.5: Each frame's paint wrapped in pause_core2 for atomic frame swapping.
Eliminates the visible tear + 1-frame blink that produced the v0.2.4 flicker.
v0.2.6: Attempted bulk overlay_set with `colors[(x-1)*10+y]` layout.
Broken — produced VERTICAL scroll and a TypeError on long marquees.
Root cause: the MicroPython binding at modjumperless.c:5695-5698 swaps
args before forwarding to C, so the colors-array layout I assumed was
wrong. Live-verified via 4-corner scout.
v0.2.7: Fixed bulk overlay_set with correct row-major layout
(`colors[(y-1)*30 + (x-1)]`, width=30, height=10 — covers full board
in one call, no wrap). Colors buffer is pre-allocated ONCE and mutated
in place per frame (single 1.2KB allocation total, vs ~100 per ceremony).
Each frame: zero + fill + carve → single overlay_set FFI call. Per-frame
paint cost: ~3-6ms (down from ~8ms in v0.2.5). Plus only ONE markDirty
per frame so autosave debounce stays settled longer → fewer visible
200ms autosave pauses during marquee.
v0.2.12: Switched from negative-space (_fill_marquee_frame) to positive-
space (_build_minimalist_frame) rendering. Instead of a full-width colored
banner with letters carved as dark holes, we now paint ONLY the letter glyph
pixels (in `color`) plus two 3-pixel direction-of-travel chevrons (in a
quarter-brightness dimmed version of `color`). All other pixels remain
transparent (0). The overlay_clear("_MARQUEE_") + overlay_set per-frame
pattern is preserved — fresh overlay each frame, no trails, true black
behind the letters. Visual result: floating glowing text with subtle `>` or
`<` motion indicators; nothing else lit on the board during the scroll."""
# Word-reverse so word entry order matches direction of travel.
=
# "R2L"
=
= * 4
# x_origin sweeps from -text_width (off-left) to 36 (5 cols past right edge)
=
# "R2L"
=
# One-time pre-loop: wipe stale overlays + pre-allocate the colors buffer.
= * 300 # mutated in place each frame; no per-frame allocation
# v0.2.10: overlay_clear before overlay_set so the renderer's
# skip-zero behavior shows the underlying unlit state (true black)
# at non-banner/carve positions instead of the previous frame's
# color. This mimics v0.2.5's overlay_clear_all + recreate pattern
# but scoped to just _MARQUEE_ (preserves other overlays).
# See _fill_marquee_frame docstring for the args-swap explanation.
# (name, h_start=1, v_start=1, v_extent=10, h_extent=30, colors)
"""Paint 4 corner L-brackets at the breadboard corners as a persistent
indicator. 3-pixel arms per corner, 5 unique pixels each, 20 LEDs total.
Decoration only — users can paint over freely.
MEDIUM-B: does NOT call overlay_clear_all() internally. Caller is
responsible for clearing previous overlay state if desired. The ceremony
scripts call overlay_clear_all() explicitly before corner_frame().
v0.2.5: Wraps the 20-pixel paint in pause_core2(True/False) for atomic
appearance. Without pause, core 2 may observe the partial bracket state
mid-paint, producing a brief asymmetric appearance during the ~5ms paint
window. With pause, the brackets appear simultaneously."""
"""Animated edge wipe along rows 1 + 10 of the breadboard.
Used as ceremony's transition from idle to active state.
direction 'L2R' or 'R2L' determines sweep direction.
v0.2.5: Per-frame 2-pixel paint wrapped in pause_core2 for visual
symmetry — both top and bottom edge pixels appear together rather than
one render tick apart. The two pixels per frame is small enough that
pause is barely measurable but keeps the visual contract clean."""
=
# "R2L"
=
# Settle so last frame fully renders