A Tektronix 4010/4014 direct-view bistable storage tube display emulator in Rust. It plays back the plot files that 1970s mainframe and minicomputer graphics packages (PLOT10, DISSPLA, ICEMDDN, ...) pushed down a serial line to a Tek terminal - the files shown in Tektronix 4052 Vector Graphics Computer - PLOT10 Demo.
What makes the picture look like the real thing is the tube model, not the line drawing: a vector is stroked with a hot write beam that flashes almost white green and then settles into the medium acid green stored image that stays on the flood-gun lit target until the whole screen is erased.
Measured on a stroke, by --probe: #E6FFF4 while the write beam is still on
it (hot, almost white) and #0EBD59 once it has settled into the stored image
(medium acid green). Both frames above are the same file: the first is a
frame from --png-seq while the beam is still racing around the picture (the
face was drawn last and is still hot), the second is the same file after the
flash has settled.
More renders in docs/: dissspla.png (the DISSPLA demo from the video),
gplanet.png, wizard.png, usmap.png, spock.png (4014 grayscale point
plot), spacelab.png (ICEMDDN CAD, matches the reference render),
ic-mandala.png.
Where the project stands - what was last measured, what is waiting on a decision, and
what is next - is the tracked note docs/CONTINUE.md. It is kept
in the repository rather than beside it, so a checkout carries the record, and the
verification pass below is written up there with the output it produced.
Prebuilt Windows binaries are published in
kevinelong/tek-tube-releases:
the latest release carries a zip holding tek-tube.exe (Windows 10/11, x64, one file, no
installer to run and no runtime to install), the optional Start Menu setup (setup.cmd,
setup.ps1, uninstall.cmd, uninstall.ps1 - see The Windows setup below) and the
licence and notices as text, alongside SHA256SUMS.txt. Unzip it and run it from a shell:
tek-tube.exe path\to\05_snoopy.plt
tek-tube.exe --shell powershell.exe # the tube *is* the terminalEvery other target (macOS, Linux, anything else) builds from this repository - see
Build and run below. tek-tube --help prints the option list, samples/ holds
plot files to play, and docs/ holds what the output should look like.
The protocol (src/protocol.rs)
- alpha (text) mode,
GSgraph mode,FSpoint plot,ESC FSspecial point plot with per-point intensity/focus,RSincremental plot - coordinates transmitted high-Y, low-Y, high-X, low-X, 5 bits per character,
with the byte ranges (
0x20..0x3F,0x40..0x5F,0x60..0x7F) telling the terminal which field arrived, so unchanged fields may be omitted - Enhanced Graphics Module 12 bit addressing: the extra byte between high-Y and low-Y adds quarter-dot resolution (4096x3120 target on the 4014, versus 1024x780 visible on the 4010)
- escape set: form feed (erase), the four font sizes (
ESC 8,9,:,;), the 3x8 line-style/mode table (ESC \`` ..ESC w`) for solid, dotted, dash-dot, short and long dashes, defocussed writing and non-storing write-through ("dark mode") drawing, ANSI CSI/OSC swallowing
The tube (src/tube.rs)
-
three light producing populations per addressable spot:
stored(bistable charge - the image that remains),fresh(the write beam's flash, decaying with a ~0.45 s time constant) andunder(write-through vectors, which are never stored and therefore vanish unless the host keeps refreshing them). All three are emitted light, and the whole chain is linear until the last step: the populations are summed as light, halation is added there, the flood gun's glow is summed in, and only then is the display transfer (light^(1/2.2)) applied per channel. Scattering and averaging therefore happen in light, not in display bytes -
chromaticity lives in linear light: each emitter has a fixed linear red:green:blue ratio and the transfer's exponent turns it into the ratio a camera records. That is why the stored image (about 0.002:1:0.06 in light) reads as blue/green 0.47 on screen while the flood gun's much bluer glow (0.011:1:0.95) reads as 0.98. The two emitters genuinely have different linear colours, and a single "phosphor colour" cannot produce both - the stored stroke is markedly greener than the field it sits on
-
the flood gun, which is what an unwritten storage target actually looks like: not black but a dark, uneven green field.
flood_field()is two fine octaves of value noise (cells of 6 and 2 dots, not clouds), a slow gradient, a radial shelf, per pixel grain and sparse specks, andambient_at()scales it by the measured level. The gradient, the shelf, the grain and the speck rate come from photographs of real hardware. The field is quiet: a blank target varies by about 4 % in total, and only ~1 % of that is at 8 to 16 dot blocks. Earlier cells of 8 and 18 dots gave three times the measured coarse contrast, which read as blotchy clouds.docs/field-texture.pngputs the flattest patch of the model's field next to the flattest patch of the museum photograph at the same physical scale, as displayed and with the contrast stretched 10x so the texture is visible at all:flat patch total std 2 dot 4 dot 8 dot 16 dot per-pixel tek-tube 2.2 % 1.5 % 1.2 % 0.9 % 0.6 % 1.8 % museum photo 2.1 % 1.5 % 1.1 % 0.6 % 0.3 % 1.8 % -
halation: light scattering through phosphor and glass, as five summed scales - 0.12 at 5 dots, 0.05 at 24, 0.03 at 90, 0.012 at 220, 0.006 at 420
- approximating the measured, heavy tailed falloff of about 5 % / 1.7 % / 0.9 % / 0.3 % of a stroke's light at 5 / 37 / 70 / 98 dots. The radii are quoted in 4010 dots, i.e. as a physical distance on the face, so the halo comes out the same size on a 4010 target and on a 4014 one. Each weight is the fraction of the source light that scale contributes to a convolution, so its local effect depends on how dense the source is: an isolated stroke only picks up a few percent of its own light, while a filled region picks up most of the 0.22 sum. Because every scale is far wider than it is detailed, the whole sum is evaluated on one half resolution grid - one down-sample, a sliding window box blur per scale, one up-sample - which is photometrically identical to five full resolution convolutions (max 8 of 255 on 0.04 % of pixels) at a quarter of the cost
-
the beam is a Gaussian current density and every stroke deposits coverage (
exp(-d^2/2 sigma^2)for the distance to the stroke, evaluated over the scan lines it can touch) rather than lighting whole pixels. This is what makes it a vector display: no staircase on diagonals, no dot-quantising of thin lines, sub-dot stroke positions are honoured, and glyph outlines are stroked as curves. Line weight is--beam(default 0.5, a fine glowing hairline; 0.8 is heavier) -
the flash appears because the beam's own emission is summed on top of the charge it deposits: a just written stroke carries 0.44 of stored light plus 0.56 of beam light, which lands at full scale - white - and fades to the stored green with the
--glowtime constant.--sat,--store-gainand--fresh-gainexpose the constants -
advance()feeds the serial input in ~16 sub-steps per interval and ages the phosphor between them, so a stroke written early in a frame has cooled by the time the frame ends while one written at the end of the frame is still hot. (Feeding the whole batch and then ageing it by the whole interval made every stroke in the batch share the age of the last one, which wiped out the beam's trail at any frame rate below ~1000 fps) -
decay()costs one multiply: the transient populations are stored at a scale factor and it is the scale that decays exponentially. Sweeping a megapixel array for every sub-step would otherwise dominate the runtime -
defocussed writing (ESC h..o) widens the profile and drops the peak accordingly, dashes are cut along the length of the stroke, point plot honours the 4014 intensity table
Text is drawn as stroked vectors through the same beam and phosphor model
(the real terminal had no character ROM in the beam path), using the
Hershey vector fonts (permissively licensed, with an acknowledgement and
no-NTIS-format condition - see NOTICE.md).
The model is calibrated against photographs of real terminals, not against other emulators.
docs/compare-real-photo.png is a three panel figure: a museum 4010-1
photographed while running this very DISSPLA demo (isco14.plt), the same file
in this emulator, and the difference. The two are aligned by scaling the
emulator's drawing to the photo's drawing, because the photo's crop rectangle
includes the CRT's mounting, not just the addressable area:
| real 4010-1 (photo) | tek-tube | |
|---|---|---|
| blank target (median) | #0D4C49 |
#0A4B4A |
| stored stroke | #00AE54 (0,174,84) typical, brightest 241 |
#0EBD59 (14,189,89), brightest 198 |
| stroke blue/green, red/green | 0.48, 0.00 (red clipped in the photo) | 0.47, 0.07 |
| field blue/green, red/green | 0.96, 0.17 | 0.99, 0.13 |
| field / stroke, display value | 0.44 | 0.40 |
| whole-screen mean green | 93.7 | 88.4 |
| aligned mean green | 112.4 | 97.5 |
| aligned stroke colour over green 150 | #00B255 |
#0DA254 |
| aligned fraction of dots over green 120 / 150 / 180 | 0.371 / 0.272 / 0.122 | 0.206 / 0.070 / 0.035 |
So the levels agree to within a few percent - the blank target, the stored stroke, the field-to-stroke ratio and the hue of both emitters - and the emulator draws the same picture in the same place. What does not agree is the area of stroke: the photo has two to three times as many bright dots.
That gap is in the capture, not something this model should copy. The
museum photo is a hand held shot of a CRT: 1 addressable dot is about 0.31
photo pixels, a real 4010's focused spot is about 1.6 dots, and yet the
strokes in that photo are several pixels wide with the same peak level as an
unblurred stroke. The tube's own halation is additive - it puts a few percent
of a stroke's light into the surrounding few dots, which is what the model
does - so the wide, bright strokes in the picture are the camera's lens, the
glass and the file's compression, not the tube. Fitting the model until it
reproduced them is where an earlier version of this project went wrong: it
grew a "camera" stage whose constants were fitted through two implementation
bugs (halation was applied twice, and its radii were divided by the target's
dot scale), so the emulator needed more and more fake blur to reach the
recording. Both bugs are fixed, the halo is now the size the hardware
measurements say, and --camera is explicitly a lens post-process, not part
of the tube.
docs/compare-video-gplanet.png has three panels: a frame of the source 4052
recording, the same file in the emulator, and the emulator through the lens
model. Alignment is by the graphics bounding box. The recording's file names
are printed above its graphics area by the author's program, which is how
gplanet.plt (byte-identical to samples/16_gplanet.plt), isco14.plt and
bugss45.plt were identified.
| recording | device model | + lens model | |
|---|---|---|---|
| mean green | 90.3 | 91.2 | 43.9 |
| dots over green 120 | 0.351 | 0.292 | 0.120 |
| dots over green 180 | 0.225 | 0.035 | 0.015 |
docs/compare-video-detail.png zooms into one region of gplanet.plt: the
recording mid-draw and stored on the left, the device model at the same point
of the plot and then settled on the right. The model's mid-draw panel is the
clearest view of the write beam's trail - strokes enter white-hot and cool to
the stored green behind the beam.
docs/compare-video-isco14.png is the same three-way comparison for the
complete isco14.plt frame (recording, device model, lens model). The fade
figures show the flash decaying while a file is still being drawn:
fade-in-progress.png and fade-half-drawn-map.png freeze a partly drawn
file with a live beam, fade-sequence.png tiles six successive frames of the
same file, and fade-detail-2x.png magnifies a stroke of it. The flood gun
field itself is easiest to see in reference-real-4010.jpg
(ref/tek4010_screen.jpg in the parent directory is the photograph the
measurements in the table above come from).
The device model lands on the recording's overall level and keeps the drawing's structure, including the merging of crowded strokes. The recording still has far more saturated dots, and its background is black where the tube's is a lit green: that is the phone camera's contrast crush, which the lens model reproduces. That model is a lens - its contrast curve was fitted to the previous, mis-scaled device pipeline and has not been recalibrated, so it now comes out about twice too dark; treat it as a diagnostic, not as part of the tube.
cargo build --release # binary in target/release/tek-tube
cargo test # 128 tests, 2 ignored (--release runs the same 128)Play a file in a window (ESC or the close button quits):
tek-tube samples/05_snoopy.plt # 19200 baud, live drawing
tek-tube --baud 2400 samples/16_gplanet.plt # slower line, longer flash
tek-tube --device 4010 samples/test.plt # 11 inch 4010 instead of 4014Draw to a PNG instead of a window, watching the flash settle:
tek-tube samples/05_snoopy.plt --png snoopy.png --baud 0 --settle 2.0
tek-tube samples/05_snoopy.plt --png-seq frames/f --frames 12 --frame-dt 0.4 --baud 2400--png runs the whole file instantly and then ages the phosphor by --settle
seconds, so the result is what the terminal would show a moment later.
--png-at SECONDS freezes the drawing at an exact point of the playback
(stepping internally at 120 Hz), which is how the half drawn frames with a live
write-beam flash are made. --png-seq writes numbered frames as the file
plays.
The window is the render, one buffer pixel per screen pixel: the process declares per monitor DPI awareness, so a 1024x780 buffer occupies 1024x780 physical pixels at any scaling. Without that declaration Windows stretches the window by the monitor's scale factor - on a 150 % display the same buffer is bilinearly blown up to 1536x1170 before it is shown - which turns the strokes and every speck of phosphor grain into fog. A settled target is not recomposed at all, so an idle screen costs nothing and runs at the display's rate - the cursor is a steady mark in the frame rather than a transient that would keep the tube alive (see terminal mode below); while a file is being drawn, one frame costs about 16 ms at 1024x780 in a release build and about 71 ms in the debug one.
Everything except the target scan scales with the output pixels, so a finer
window is proportionally slower. Measured through render_into on 2026-10-03,
flood field warm, best of five, the same code both ways:
--shrink |
window | release | debug |
|---|---|---|---|
| 4 (default) | 1024x780 | 16.0 ms | 71.4 ms |
| 2 | 2048x1560 | 32.1 ms | 191.4 ms |
| 1 | 4096x3120 | 114.3 ms | 708.0 ms |
The 38 / 164 / 974 ms this table carried before 2026-10-03 could not be reproduced in either profile here, so they are replaced rather than relabelled.
--shrink 2 and --shrink 1 are still for --png/--png-seq output rather
than for a live window - the live window is sharp because it is pixel exact, not
because it is large - but in release --shrink 2 is no longer a slideshow.
Fullscreen is the default - borderless (WS_POPUP, no caption, no frame) -
and F11 toggles it while running; --windowed starts in a titled frame
instead. A window cannot change its own decorations, so F11 rebuilds the window
rather than restyling it, and the shell and the tube simply carry on: nothing is
lost but the image, which the pen redraws (see below). ESC quits.
F2 opens the options panel: the variables a terminal run honours, adjustable while it runs - the pen rate, the phosphor, the trail, the cell size, the face. A slider follows the mouse at once, but the tube is recomposed only once the dragging has settled, a quarter of a second after the last change: a fullscreen frame is ~35 ms and recomposing on every mouse move would spend a core showing the same picture with one number different. Only two rows need the page written again rather than recomposed - the cell size and the face - and the terminal does that by clearing its record and writing the page in the new rules. Neither ends anything: a face change costs a page, not a session. A click steps a list once, however long the button is held, and flips a box once with it: the window hands the panel the button's state every frame, so a press is taken from its edge - a real click lasts several frames, and acting on the state walked a list six entries, which read as a random choice rather than the next one. Only a slider follows the button while it is down, which is what a drag is. The wheel nudges the row under the pointer. The panel is drawn over the frame that is already there, so it stays with the mouse while the costly half waits.
Rows are sliders, boxes that toggle, lists that step, and presets: wyse and
tek as bundles of rows, showing custom when the machine matches neither.
They can be saved into numbered slots - slot names one, save writes the
current rows into it - and the saved ones live in a plain-text file under the
user's config directory, never in the repository. The preset chooser is a view
of the rows below it rather than a value of its own, which is why walking it
applies each preset as it passes: it cannot honestly show a preset the machine is
not in. slot just names where a save goes and does nothing on its own.
A few rows are launch rows
(device, zoom and shrink in the plot path): they cannot take effect on a
running window, so they are honoured by re-executing the program with the options
the panel currently holds. Only a press does that - Enter on the row, or a
click - and the note line says the session ends before it does; the wheel walks the
list and changes nothing but the number shown, so browsing the targets or the zoom
levels cannot restart the program under a hand that was only looking.
The face used to be a launch row too, for the same reason the target is - it is
fixed when the tube is built - and the cost was the whole session for a change that
is only how glyphs are stroked. It is a rendering row now: the tube is configured
with the new face, the record is cleared and the page is written again, and the shell
underneath never notices. Measured through the terminal harness with no window
(TEK_REFONT), on one page and one session: mono -> simplex moves the lit pixels
21594 -> 37525, and asking for the same face twice moves them 21594 -> 20704, so the
number follows the face and not the rewrite; a command typed after four face changes
was answered by the shell it was typed into.
For a plot file the window is the whole primary monitor and the face is rendered
at the largest integer down-sample that fits the screen, then centred without
any scaling (ScaleMode::Center), so nothing is resampled and the rest of the
screen is black. On a 2560x1440 screen that is --shrink 3, a 1365x1040 face.
The face is 4:3 and the screen is not, so filling it would mean either distorting
the face or scaling it up; scaling up is what made it look like fog, so the face
is left alone. An explicit --shrink overrides the choice.
In terminal mode there is no such compromise, because there the tube is the
window: the page keeps the face's own 74x35 shape and the cell grows with the
window. A 2560x1440 screen is a 93x32 page of 27x45 dot cells - the same
letters, more of them - and the stroke weight is derived from the cell the tube
is actually showing rather than from --cell, or the text would go spindly as
the window grew.
A 4010 is a terminal - it takes a byte stream and draws characters in alpha mode and vectors in graph mode - so the stream can come from a pipe instead of a file:
tek-tube - --device 4010 < plotfile # or, on the other end of a pipe:
Get-Process | Format-Table | Out-String | tek-tube -
python tools/tekplot.py | tek-tube -Line oriented shell output renders as it stands, columns intact, because a table
is only text and spaces: docs/pipe-text.png is
Get-Process | Format-Table | Out-String drawn straight onto the tube.
tools/tekplot.py is a worked example of the graphics side - ASCII text, then
GS and a 436 segment sine curve, then CR back to alpha mode for the caption
- which the tube draws as one stream (
docs/pipe-plot.png). Any program that can write PLOT10 or 4010 protocol can drive it this way, and that is what the hardware was bought for.
Driving a modern interactive shell is what does not work, for reasons that are the hardware's rather than the emulator's:
- The VT parser covers what a terminal needs, measured rather than assumed.
Cursor addressing (absolute and relative), the erase family, insert and delete
of lines and characters, scroll regions, the alternate screen, cursor
save/restore, intensity SGR, cursor reports, and now the two ways line art
arrives: the DEC special graphics set (
ESC ( 0) and the UTF-8 box-drawing characters, both drawn as strokes on the tube's own grid so frames join across cells. Cursor keys follow DECCKM. What it does not cover is anything that needs colour (one phosphor), reverse video (you cannot un-write stored charge) or mouse reporting - and an application leaning on those is untested ground. Colour escapes are swallowed, which is the right answer for a monochrome green tube, and SGR intensity maps to stored charge: a storage tube cannot go above full charge, so only "dim" is expressible. - There is no keyboard path on a pipe - a real 4010 sends keystrokes back up the line and a plot file has nobody to send them to. Terminal mode has one; see below.
- A storage tube cannot scroll. Past its 35 row page the only thing to do is
erase the whole screen (
--autoclear, on by default), so a 60 line listing keeps only its last 25 lines - measured: 001-035 erased, 036-060 present.--no-autoclearclamps at the bottom row and overwrites it instead. -reads to end of file before it draws, so a long running producer shows nothing until it exits.
That is why these terminals were bought to plot with, not to type into. Making
it interactive anyway is what --shell does - see docs/terminal-mode.md for
the design, the measured limits it has to work around, and what is reused.
--shell CMD runs a shell under a pseudoconsole and renders the session on the
tube instead of playing plot files:
tek-tube --shell powershell.exe --device 4010The page advertised to the shell is 37x18 by default: cells at twice the
4010's own size, which is about 40 characters a line and as coarse as a shell
still reads well on this face. --cell 1 gives the 4010's own 74x35 (cells of
13.8 x 22.3 dots, a 0.62 aspect that suits the 4:3 face where an 80x24 grid
would be a tall thin 0.39). Either way the page is the tube's, not the
terminal's invention. Text is stroked through the same beam, halation and flood
field as everything else, so glyphs glow and settle exactly like vectors do.
docs/terminal-session.png is a real session replayed through the renderer,
docs/terminal-live.png is the live window, and docs/font-compare.png shows
the available faces at both sizes.
The cursor is a steady mark, because a flash cannot be one. A storage tube stores what the beam writes and the stored charge is permanent, so a cursor is never committed - and it cannot be a flash either. Drawn transiently it faded to nothing within a second of the shell going quiet, and the tube then sat there with no cursor at all until the next key: the one thing a terminal must not do. Writing it again on a clock is the other obvious answer and it is the wrong one - a flash that is refreshed never lets the tube's aliveness check clear, so a 1024x780 frame would be recomposed some 58 times a second, forever, for one underline. The hardware's own hosts wrote a cursor as stored ink and redrew the page to move it, which is a steady mark, and that is what this is - a stroke, not a bar:
- stroked with the same width and the same Gaussian beam profile the terminal's glyphs
are stroked with, so it is as thick and as soft as the letters beside it rather than a
crisp rectangle of a width nobody chose: across its own width the light falls away
89, 90, 123, 164, 211, 250, 255, 251, 212, 164, 124, 88, 89- six dots of glow either side of the centre line; - drawn at the level a just written stroke reaches, with its light in the fresh channel too, because that is the channel the colour pass reads for the hot chromaticity - so the underline is white-green like fresh ink and not a flat bar of settled green, which is what a host keeping its cursor written would leave;
- not charge in any population, so erasing the target does not blink it and moving it leaves no trail of itself;
- and not a transient, so it does not make the tube alive: an idle prompt still stops recomposing when the trail has decayed, and what stays on the screen is the frame the cursor is already in.
Measured through the harness on an idle tube (TEK_CURSOR=1, a cold start with no
script typed, the rendered frame's green channel sampled ~1400 times over 6 s, against
a blank cell at the same height as the floor the flood gun sets):
| cursor | dimmest | brightest | blank cell |
|---|---|---|---|
| this (a stroked mark at fresh-ink level) | 255 | 255 | 76 |
| the first attempt (a rectangle of settled green) | 188 | 188 | 76 |
before (a transient, never rewritten - v0.3.1) |
77 | 206 | 76 |
The old cursor fell to the floor within a second and stayed there, which is the "cursor fades away completely" this answers; the new one does not move at all. It costs nothing: a 25 s idle session draws ~840 frames against 873 for the transient version, both stopping once the trail's own light has decayed (~14 s in), and the mark is simply part of whatever frame is drawn next.
The font is a stroke font, because it has to be. A storage tube stores
whatever the beam traces, and the 4010 stroked its characters rather than
scanning a character ROM - which is also why slow text on real hardware was
genuinely written. The default is now Hershey Simplex Monospaced
(futuram): the same letterforms as the classic plotter face, but fixed pitch,
which is what a terminal is. --font simplex gives the proportional original
and --font gothic the heavier face.
Two findings from comparing them:
--font gothic(Hershey Gothic) is not usable as a terminal face at any size. It is a bold triplex outline design - each stroke is drawn three times over - so at a terminal cell it fills in and collides, measuring about 25 % more ink than the others. It remains useful for plot-mode alpha text.- The glyph scaler used to assume a 22-unit-wide glyph, which is true of Simplex and false of Gothic, so wide faces overflowed their cells. It now scales by each font's own maximum width, so a wide face shrinks uniformly instead of colliding.
The image cools from the flash to the stored green, never to nothing. With the pen running the image used to fade all the way back to the background: the commit condition refused to settle anything while the pen was busy, and a slow pen is always busy, so nothing ever resolved. A commit now settles exactly what the pen has drawn so far rather than waiting for a full pass or drawing ahead of it, so each letter cools to the stored level as it lands.
Nothing is drawn twice. The stored image is what the pen has drawn,
never what the screen says now. Taking the character from the screen put a glyph
into the image before the beam had reached it, and the beam then traced it again
over the top: that is the doubled drawing. Measured on the live window at
--write-rate 400, after a cls followed by 400 lines of output - the shell
finished at t=0.06 s, and the old code was still writing the page at t=7.5 s,
filling it in from the top row down at about half a second a row while the
commits wiped the image back to whatever the pen had managed so far. Content
appeared, vanished, and was drawn again over itself.
The other half of it is that a wholesale change is not something to trace at all. A clear, an alt-screen switch or a resize changes every cell at once, and tracing a 37x18 page takes minutes at any rate slow enough to watch to be worth watching. The screen says when that has happened - it reports the change rather than leaving the renderer to infer it from the share of changed cells, which does not catch a scroll on a sparse page: a listing scrolling past changes every row but only three or four cells in each, which on a 93x32 page is a few per cent. A quarter of the cells changing remains as a backstop for anything else that redraws most of the page.
A scroll is neither - it is a move, and it is the one page-wide change worth
showing. The committed image is a bitmap, so it can travel: a scroll shifts the
stored charge, the beam's glow and the record up the face by the lines that
scrolled off (Tube::shift_rows), the record is synced so nothing is retraced,
and the page is settled in one pass. Nothing is drawn character by character for
it. The display then slides the move into place at SLIDE_RATE (30 rows a
second, capped at one page, so a two hundred line listing is one screenful rising
into view rather than a two hundred line movement). Measured on a fullscreen
93x32 page running dir C:\Windows: the topmost line goes 27, 22, 18, 1, 0 over
about a second while the ink climbs 31k, 83k, 124k, 281k, with no blank frame -
the page moves, and nothing is rewritten by the beam to make it move.
Only incremental changes are traced, which is what a pen is for. Same
measurement with the fix: the page is complete and the beam idle within a frame
of the shell going quiet. The recorded frames are kept beside the source, in
C:\Users\kevin\tek\cls3 - outside this checkout.
Two prompts, and a page that outlived its erase. Two separate faults both
landed on the same symptom - a second PS C:\Users\kevin> after an echo or a
cls - and both were measured on the live window against this machine's
PowerShell before and after:
- One press of Enter put three carriage returns on the wire. Windows
reports a real key press twice:
WM_KEYDOWNas a key event, theWM_CHARtranslated from it as a character event, and a secondWM_CHARafter the key is released. Both paths translated the same key, so the shell ran the command plus two empty ones. Measured:echo hiwas followed by three prompts. The key event is the report that carries the modifiers, so the key path now owns every control key and the character path sends text only (window_inputinsrc/terminal.rs), which is one prompt, and the twoWM_CHARs landing in different frames no longer matters. The cost is a pasted line break, which arrives as a character with no key event - this is a terminal with no bracketed paste, and one doubled Enter per press is a worse trade than that. - A page replacement cleared the stored charge but not the beam's glow. A
scroll, a clear or an alt screen switch replaces the image, and the previous
page is still in the transient population, so it stayed lit for the whole
trail: after a
clsthe old prompt and the erased tail of the old command line were still glowing under the new one (measured: 10 ghost cells, and the dumped stream showed theecho hiline's own cells at row 17).commit_tonow erases the transient with the stored image on a replacement - and only there: on the pen's own path the flash is the writing, cooling into the stored image. Measured after: the only lit cell the screen does not have is the cursor.
The frame is split across the cores where it can be. The render is a
per-pixel map plus separable blurs, and a fullscreen 2560x1440 tube is 3.7M
pixels a frame. The splitter itself measures 5.5x on 8 threads (37.8 ms ->
6.9 ms for a 2560x1440 row pass), and that number is asserted by
the_split_uses_more_than_one_thread, which is the test that would catch a
splitter quietly running everything on one thread. What is split is every pass
that writes a row at a time: the transfer, the colour pass, the halation's
down-sample, its up-sample, and the horizontal half of each box blur. The split
is bit for bit what the serial code produced - a row of the destination is only
written from its own rows of the source, so no sum is reordered - which
splitting_a_pass_does_not_change_a_single_value holds to.
The vertical half was the hold-out, and is now split too. Its running sum
goes down a column, so its unit of work is a column, and one thread's writes
would interleave with the next's w elements apart - which is exactly what the
borrow checker will not express. Rather than reach for unsafe, the column
results are copied into a buffer laid out the other way round, which turns the
pass into two passes over independent rows, both of which par_rows splits. It
costs one more buffer and one more read of the data, and it takes off one core the
half that was 28% of the frame.
Measured on the running terminal - debug build, 1024x780, from the exit summary - 173 frames at 51.4 ms each, 19 fps while drawing: against 89.6 ms windowed before the vertical split and 245.7 ms before any of it. The same windowed summary in the release build on 2026-10-03: 46 frames at 17.3 ms each, 58 fps while drawing, three times the debug rate and in the profile this work was for.
Tried, measured and rejected twice: blocking the vertical pass. The step that follows from the memory story is to stop walking a row-major buffer with a stride at all - give each thread a run of columns, rows outer, so every cache line is consumed whole. First measured in the debug build: blocked 16 columns by 32 rows, 77.7 ms a frame against 49.4 ms, a 57% regression, because the per-element cost of the blocked loops (array indexing, iterator machinery) dwarfs the cache effect they buy. The reading at the time was that a release build would show the opposite, and a release build was exactly what this machine would not produce.
It can now (see the note at the end), and the prediction does not hold. The same idea, rebuilt and measured 2026-10-03, bit for bit the same output at every setting, tile widths of 8, 16, 32 and 64 columns, at the real render's grid sizes and radii:
| halation grid | split | blocked, best tile | |
|---|---|---|---|
| 512x390 (terminal 1024x780) | 3.38 ms | 3.33 ms (16) | 1.01x |
683x520 (plot --shrink 3) |
3.56 ms | 3.59 ms (64) | 0.99x |
| 1280x720 (terminal 2560x1440) | 5.01 ms | 5.06 ms (16) | 0.99x |
and the whole frame through render_into, split against blocked at --shrink
4 / 2 / 1: 15.97 / 32.08 / 114.25 ms against 16.25 / 33.44 / 110.92 ms. No
tile width wins anywhere, and the one number that favours blocking is 3%, at the
one shape that is stills-only. The debug build loses with the same code by the
same margin as the original attempt (0.74x to 0.80x), so what fails is the idea,
not one implementation of it.
Why the paper analysis does not bite: the walk's read amplification is real, but the halation grid is 3.7 MB at 1280x720 and 12.8 MB at 2048x1560, and this machine's last level cache is 36 MB, so the stride mostly hits L3 rather than the memory system the argument was about. The change is not shipped, and the question is closed: this is no longer "the first thing to try if a release build ever appears".
The terminal's exit summary reports the frame rate it actually achieved, so "is it smooth" is a measurement rather than an impression.
Letter spacing. A plotter font has a huge width range - in Hershey Mono
i is 2 units, W is 20 and @ is 28 - and since every glyph shares one
scale, the widest one set it and every ordinary letter sat filling half its
cell. The gap is 1 - 0.95 * W / W_max, so it is fixed by the font's range
alone and no page geometry or scaling can close it. The fix is to narrow the
range: glyphs wider than 70 % of the widest are condensed to that cap (narrow
ones are never stretched), and the fit is computed from the cap. Measured at
the default size: H went from 13 px of ink in a 27 px cell to 18 px, so its gap
closed from 14 px to 9 px, W from 9 px to 5 px, and the cap height grew from 19
to 27 px - closer letters and bigger text for free, with the pitch still exactly
one cell. docs/gap-compare.png is before and after. Glyphs also fill 85 % of
the cell height and 95 % of its width; those two numbers are what set the gaps.
Stroke weight. A beam is measured in 4010 dots, which says nothing about
how large a glyph is - so doubling the cell size halved the stroke weight
relative to the glyph and the text went spindly (measured: at the doubled size
only 68 pixels of the page reached full ink, against 4,216 once fixed, because
most stroke cores fell below the display threshold entirely). Terminal mode now
scales the beam by the cell, and then by 1.5, because a terminal glyph is far
smaller than the lettering inside a plot file and at a hairline weight it reads
as too light next to one. docs/beam-compare.png is the before and after.
--beam still scales it if you want heavier or finer.
An OCR-A-style stroke face was prototyped (five glyphs, hand-authored, rendered
at the new default size: docs/font-ocra-probe.png) to judge whether the
letterforms are worth chasing. The mechanism works - hand-authored strokes draw
through the tube like any other font - but a first pass without iteration is
visibly crude, and it has to be normalised to the same cap height and cell width
as the rest or it comes out 40 % oversized, as that probe did. It is not
shipped; the probe was removed rather than left as a half-font that draws
nothing for most characters.
Ghosting, if you want it. A raster terminal writes the new screen over the
old one, and the old one's light is still on the face: replace a screenful at a
time and the previous screenful stays readable underneath the new one, in the
same rows. Measured from a Wyse-60 running ls -l /etc over a serial link: the
screen is completely static between writes (consecutive 33 ms frames correlate at
zero displacement), and what looks like motion is the previous page's phosphor
still lit through the new one, fading in a fraction of a second. A storage tube
has no such thing - its charge is permanent and a commit replaces the image
rather than merging into it - so this one does not ghost on its own. --ghost F
captures the page a scroll replaces as a fading layer for F seconds
(Tube::capture_ghost, decayed like the transients and summed into the light, so
it haloes like everything else). Default 0: no ghost, which is what the hardware
being emulated does. --slide R sets the rows a second the display slides a
scroll at (default 30); --slide 0 makes a scroll land in one step, which is how
a raster terminal moves - its screen is static between writes and the movement
the eye reads is the residual image, not a redraw. The closest match to that
Wyse-60 clip is --ghost 1.0 --slide 0 --write-rate 0: no beam writer, page
changes land in one step, and the page before each of them stays lit while it
fades. An erase leaves the ghost too, because blanking a cell does not switch
off the phosphor that was in it.
--scan D puts the raster scan's own structure back: a raster terminal lights
the screen a line at a time, so a lit area is striped rather than flat, and that
stripe is most of what makes a photograph of one read as a CRT. Default 0, a
flat face, which is what a vector display emits. Two knobs belong with it for
the same reason: --ambient 0 takes the flood gun's glow away, because a raster
display's background is black rather than a lit field, and --trail should be
short (0.2 s), because what a raster terminal leaves behind is the previous
page - which is --ghost - not a glow around everything it has just written.
--baud N gives the link a speed. In terminal mode the screen is fed at N/10
characters a second, so a listing arrives at the rate a serial terminal's did
instead of at disk speed: measured through the driver, --baud 9600 took 5,253
bytes in 5.47 s (exactly 960 a second) while the whole 6,550 byte flood had
already been read into the backlog. It is unpaced unless given, because plot
playback has always had a default line speed and a terminal should not silently
inherit one.
--dots N gives the glyphs a character ROM: the same letterforms drawn as filled
dots on a cell-aligned grid N dots tall, instead of traced strokes (the column
count follows from keeping the dots square, so the grid is the same shape at any
cell size). A stroke face suits this, because its glyphs are one stroke wide by
construction: sampled onto the grid they are one dot wide, which is what a ROM
font is. The spot has to be wider than the pitch or the dots read as a chain of
beads rather than a solid letter - measured at three quarters of the pitch it
reads as a terminal, at a bit over a third it reads as knitting.
--wyse is all of the above in one flag, at the values the measurements
settled on: black face, 15 dot face, scan stripes, page-at-a-time changes with
the old page left glowing, no beam writer, and a 19,200 baud link. It is exactly
equivalent to
--write-rate 0 --slide 0 --ghost 0.1 --scan 0.5 --dots 15 --ambient 0 --trail 0.2 --baud 19200and any of those can still be overridden by a later flag on the same command line.
The image works the way a storage tube forces it to. A storage tube cannot
erase one character, so content that is still moving is written into the
transient population only: it flashes hot and decays, and because each frame
lands on the previous frame's decaying glow, anything that moved leaves a trail.
When the screen has been quiet for 0.3 s the current screen is committed: the
stored image is erased and the screen is re-stroked into it without lighting the
beam, so the smear resolves to a crisp stored image. The commit replaces
rather than merges, which is what stops scrolled-away text from ghosting for
ever. --trail F sets how long the transient lingers: a plot file's default is
0.3 s and the terminal's is 1.1 s, a quick cool from the near-white flash to the
stored acid green that settles the hue in well under a second; --trail 0 is a
plain storage tube with no smear at all, and longer values leave the writing
visibly fading for a second, which reads as slow and steps visibly at any
window's frame rate.
-
Ctrl+Shift+Q quits, or just exit the shell. ESC has to reach the shell, so it can no longer be the quit key, and F11 - which the shell has no use for - toggles fullscreen. Both are consumed by the window and never reach the shell. F2 opens the options panel; the mouse drives it and the shell never sees that either. While it is open the wheel belongs to the panel, so the scrollback is not scrolled underneath it.
-
Keys without a character are translated to VT sequences (arrows, F1-F12, Home/End/PgUp/PgDn, Insert/Delete, Ctrl+letter); printable text goes through minifb's Unicode input, so keyboard layout and AltGr are handled for you. Control characters come from the key path only - the character path's copy of them was the doubled Enter described above.
-
CSI 6n- the console host asking where the cursor is - is answered from the screen's real cursor position. Answering it with a fixed value lets the session run but corrupts PSReadLine's redraw into overlapping text. -
--vt-replay FILE --png OUTrenders a captured stream, which is how the renderer is checked without a window. -
TEK_DUMP=pathwrites everything the shell sends topath. Replaying a dump is what settles "is this the parser or the live path?": if the page comes out right from the bytes, the fault is in the live drawing, not the parse. That is how the duplicated-prompt-after-F11 fault was found - and how the pen was cleared of it. -
--write-rate DOTSis how fast the beam draws, in target dots per second, and it is on by default at 2500: a letter is about 60 dots, so that is about 42 letters a second, and--write-rate 0draws the page instantly as it always did. The pen walks the page in reading order and traces only cells whose character has changed, so typing one letter traces one letter. The number is calibrated against the thing it sits beside, measured from the sample files: a plot file's beam sweeps at 4,587 to 15,193 of the 4010's dots per second at the default 19,200 baud (11,096 for05_snoopy, 15,193 for09_wizard, 5,129 for16_gplanet), and at 401 dots/s on a point plot, which turns every byte into a dot rather than a stroke. 4000 is ten times the 400 the pen was being run at and sits inside that band;--statsnow prints the figure for whatever stream is played, so the two can be compared directly. A scroll, a clear or a resize is replaced rather than traced (see above), and the image is settled from what the pen has drawn - never from what the screen says now. -
Resizing the window adds or removes whole cells: the page is recomputed from the window size at the same cell ratio, the tube is rebuilt to match, and the shell is told, so nothing is stretched. The visible page is then blanked, because the screen model keeps no reflow: the shell works out which line it is on from the geometry it has been given and repaints its whole buffer, so leaving the old rows in place made that repaint land beside them. Measured: an F11 with a command on screen produced the block of output twice and a half-overwritten prompt. The old lines are still in the scrollback.
-
Scrollback: Shift+PageUp / Shift+PageDown or the mouse wheel read back through the lines that have scrolled off, and the window title says how far back you are. Any new output returns to the live page. A full clear drops the history, as the hardware's erase does.
-
The alternate screen works, so full-screen programs get a page of their own; the trail is shortened to 0.25 s while one is active, since a program that repaints every cell would otherwise smear into mud.
The stroke font is ASCII, so a character the font has no glyph for draws as
? rather than silently vanishing. Until 2026-10-02 the release build on this
machine was subject to the Smart App Control lottery described below; Smart App
Control is now off and a release build runs, so the trail can be judged in the
profile it was tuned for. (The debug build renders about four times slower, and
its trail therefore decays about four times slower in wall clock terms, which
makes it look heavier than it is.)
Useful verification flags:
tek-tube samples/isco10.plt --png out.png --stats --dump-chars --probe 1600,1500--statsprints what was decoded (N vectors, N points, N characters, stray bytes, address range, whether 12 bit addressing was used)--dump-charsprints every alpha character with the cell it was drawn into--probe X,Yprints the colour of one target dot, e.g.#0EBD59for a settled stored stroke
Full option list: tek-tube --help.
tools/setup.cmd installs the Windows build without an administrator: it copies
tek-tube.exe into %LOCALAPPDATA%\Programs\tek-tube, asks which shells the Start Menu
should offer - the ones Windows Terminal would list on the same machine: Windows
PowerShell, PowerShell, Command Prompt, Git Bash, and one entry per WSL distribution - and
writes the answer to %APPDATA%\tek-tube\shells.txt. It then creates a tek terminal
entry for the default shell and one entry per shell, and an uninstall.cmd that removes
them again.
tools\setup.cmd # ask
tools\setup.cmd -List # what is on this machine
tools\setup.cmd -Shells "Windows PowerShell,Git Bash" -Default "Git Bash"
tools\setup.cmd -InstallDir C:\tools\tek-tube -NoShortcuts # install, no Start Menu
tools\uninstall.cmd # remove (-Purge: the settings too)An entry passes a shell name, not a command line: tek terminal (Git Bash) runs
tek-tube.exe --shell "Git Bash", and the plain tek terminal passes no --shell at all,
so it follows the default line in that file. Which shell an entry runs, and what command
a name means, is therefore an edit to shells.txt - and --list-shells prints what is
configured:
tek-tube.exe --list-shells
C:\Users\kevin\AppData\Roaming\tek-tube\shells.txt
* Windows PowerShell = C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
Git Bash = "C:\Program Files\Git\bin\bash.exe" --login -iThe file is read by the program, not only by the setup: a tek-tube.exe with no arguments
- a shortcut, a double click, the taskbar - runs the shell
defaultnames, before falling back to Windows PowerShell and thencmd.exe. A command line in it is quoted the way Windows quotes one, which is what makes the Git Bash entry above work at all: the program lives underProgram Files.
Started that way it used to put a second black window on the screen, and the reason is
worth writing down: the executable is built for the Windows console subsystem - it has to
be, or a shell that starts tek-tube --png out.png would not wait for the file to be written
and its report would go nowhere - and Windows gives a console program a console of its own
when it is started without one. That window is the program's, not the shell's: the shell
runs under a pseudoconsole and has no window at all, and the only thing the console ever
showed was the session report. The program now hides a console that is its own and leaves
one alone that a shell provided, which GetConsoleProcessList distinguishes by returning one
entry. So: launched from the Start Menu there is no console window, and launched from a shell
the report still appears in that shell's terminal, where it was asked for.
| device | addressable | visible | notes |
|---|---|---|---|
--device 4014 (default) |
4096x4096 | 4096x3120 | Enhanced Graphics Module, 12 bit, quarter dot precision |
--device 4010 |
1024x1024 | 1024x780 | 10 bit, the extra bits are ignored as on an un-enhanced terminal |
The 4014 target is a superset: a 4010 file's 10 bit coordinate maps to the same
physical spot (x4 with the two extra bits clear), so the default renders either
kind of file at the finer resolution. Output area-averages the emitted light
down (--shrink, default 4 for the 4014) so the picture is 1024x780 whatever
the target, and can be magnified with --zoom.
What to run, and what it proves. Every command below was re-run on 2026-10-15 and the
observed output - counts, colours, frame numbers - recorded in
docs/CONTINUE.md; the figures quoted in this section are current as
of that pass.
cargo build --release # the release profile builds clean
cargo test # 128 tests, 2 ignored (--release runs the same 128)
# headless render: the flags the "Useful verification flags" example in
# Terminal mode uses - decoded stream, one probed dot, the written picture
target/release/tek-tube.exe samples/isco10.plt --png out.png \
--stats --dump-chars --probe 1600,1500
# the shell/session check: a real shell driven through the terminal's own frame
# loop with no window at all. That driver is the `harness` module in
# src/main.rs; it is #[ignore]d, so it is named, and TEK_SCRIPT is typed into
# the shell. It prints the frames drawn, the commits echoed and the bytes read.
TEK_SCRIPT="echo TEKMARK;ver" TEK_SECONDS=3 \
cargo test --release -- --ignored --nocapture terminal_loopcargo test covers: the coordinate encoding from the real manual example
(23,142) = $n W, field omission, quad-dot addressing with the extra byte on
the 4014 and its absence on the 4010, alpha layout/cell sizes, form feed,
the line style/mode escape table, point plot and special point plot with the
4014 intensity table, incremental plot, glyph baseline/cap-height/descender
geometry from the Hershey data, the tube's flash-to-stored fade (hot almost
white -> saturated acid green -> unchanged after any further wait), erase,
write-through decay, dashing, anti-aliasing (monotone soft edge profile next to
a diagonal stroke, and a stroke placed between two dots lighting both of them
equally), output row orientation, the flood gun's field shape, and an
end-to-end test that decodes samples/05_snoopy.plt and requires the outline
to land inside its address range and leave green stored charge behind.
Two of those tests exist because the model got them wrong: one requires that a
stroke written at the end of a frame is hotter than one written at the start of
it (advance ageing a whole batch at once fails it), and one requires that the
halo past a straight edge comes out the same number of output pixels on a 4010
and on a 4014 (dividing the radii by the target's scale factor fails it).
Renders were also compared against the reference render of the same file
shipped with the Tek4010 emulator (samples/icemddn_spacelab.png): the
orientation/overlap metric is highest for an unrotated match, which caught an
upside-down output bug during development.
tests/release_tooling.rs covers the Windows release surface rather than the tube: the four
scripts the published archive ships (setup.cmd, setup.ps1, uninstall.cmd, uninstall.ps1)
must exist, must be CRLF, and each .cmd must reach its .ps1 sibling through %~dp0 so the
pair travels together in the archive. It also requires uninstall.cmd's self-delete line to still
be present and still come before the script exits - that ordering is what lets cmd delete the file
it is reading - and LICENSE.md / NOTICE.md to be present, because the archive carries the
licence as text rather than a link. RELEASING.md (which is in the releases repository, where the
archive is assembled) and this repository's .gitattributes promised all of that and nothing
checked it.
samples/ holds demo files used for testing, collected by Chuck Forsberg and
redistributed by the
rricharz/Tek4010 project in its
pltfiles/ directory. That collection is not under the GPL of that
project and is meant for personal use; the files here are test inputs only and
are not covered by this project's licence. icemddn_spacelab.png is the
reference render of icemddn_spacelab.plt from the same directory.
Files that map onto the video: 05_snoopy.plt (Snoopy and the Red Baron),
16_gplanet.plt (G Planet, from the 4014 program manager's demo tape),
drawing.plt and icemddn_spacelab.plt (ICEMDDN CAD), isco10.plt (DISSPLA
demo), 13_bugss14.plt, bugss45.plt (montages), 09_wizard.plt (the Tek
wizard), 07_usmap.plt, test.plt (bar chart), animation.plt,
spock.plt (4014 grayscale point plot).
Resolved: Smart App Control was turned off on 2026-10-02 and release builds now run; the section below is kept as the record of what the block was and what the retry loop cost. See the resolution at the end.
On this machine a freshly built release binary was refused with os error 4551
("An Application Control policy has blocked this file"). It is not a bug in the
program and it is not something a local code-signing trick can fix. Diagnosed:
- Smart App Control is in Enforcement, as the Microsoft-signed WDAC base
policy
VerifiedAndReputableDesktop({0283AC0F-FFF1-49AE-ADA1-8A933130CAD6}).VerifiedAndReputablePolicyStateis1underHKLM\SYSTEM\CurrentControlSet\Control\CI\Policy, andVerifiedAndReputablePolicyStateMinValueSeenis1under...\CI\Protected. AppLocker is not involved (no deny events, no rules) and the machine is not enterprise managed (Windows 11 Home, not domain or Entra joined). - The verdict is keyed on the file's flat SHA-256, so it is per build: the debug build's hash drew an allowing verdict and runs, three release hashes in a row drew blocking verdicts and stayed blocked, and copying or renaming a blocked binary changes nothing because the hash is the same.
- Event 3077 names the reason and it is the signing level, not a temporary
cloud check: "did not meet the Enterprise signing level requirements or
violated code integrity policy (Policy ID
{0283ac0f-fff1-49ae-ada1-8a933130cad6})". So the honest reading of the lottery is that some unsigned builds happen to satisfy the policy's reputation path and most do not - and on 2026-10-02 nineteen fresh hashes in a row were refused, release and debug, across six codegen flag sets, which is what makes the signing routes below the options worth actually considering rather than the retry loop. - You can watch it happen, no admin needed:
Get-WinEvent -LogName Microsoft-Windows-CodeIntegrity/Operational | Where-Object Id -in 3077,3118 | Format-List- event 3077 names the file and the policy, 3118 carries the hash and Defender's trust fields.
What actually changes the outcome:
- Turn Smart App Control off (Windows Security, requires elevation) - the route taken here on 2026-10-02. Free and definite, but it is a machine-wide security change and Microsoft's docs disagree on whether it can be turned back on: the consumer FAQ now says yes, the developer doc still calls Off a one-way operation, and this machine carries the registry marker that gates re-enabling. The Settings page did offer the choice, and the state change survived the restart (see the resolution below).
- Sign every build with a certificate from a CA in the Microsoft Trusted Root Program (Azure Artifact Signing from about $10/month, or a conventional OV/EV certificate, with an RFC3161 timestamp so an expired signature does not revert to unsigned). This is the only option that keeps Smart App Control on and also makes a distributed build work. A self-signed certificate does not: Smart App Control only considers certificates issued by trusted providers, so installing your own root leaves the file untrusted.
- Rebuild and hope the new hash is allowed. Free, no security change, unreliable: measured here, 11 of 14 fresh hashes of the same program were allowed, but 3 of 3 release rebuilds were blocked.
- Defender exclusions cannot help (the block is a Code Integrity policy decision, not an antivirus one) and neither can an AppLocker rule (AppLocker is not the enforcing mechanism).
The debug build and the test binaries were never affected, so cargo build and
cargo test always worked; only the release binary was at the mercy of this.
Smart App Control was turned off (Windows Security, elevation required) and the
machine restarted. The registry now reads VerifiedAndReputablePolicyState = 0
under HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy, with SAC_PreviousState = 1 and SAC_EnforcementReason = 6, and
VerifiedAndReputablePolicyStateMinValueSeen under ...\CI\Protected has fallen
from 1 to 0. Since that boot the CodeIntegrity log holds no event 3077 at all -
nothing has been refused, only the periodic 3089/3033 policy heartbeats - and a
freshly built release binary runs: cargo build --release, a headless --png
render of samples/isco10.plt, a live --shell session, and cargo test --release all work. The nineteen-in-a-row refusals above are therefore the
recorded cost of the lottery, not a lasting property of the machine.
Nothing has been tested back the other way. Microsoft's consumer FAQ says Smart App Control can be turned on again, the developer doc still calls Off a one-way operation, and this machine carries the marker that gates re-enabling; treat it as one-way in practice.
Source-available under the Functional Source License 1.1
(LICENSE.md, FSL-1.1-MIT): use it for anything except a product or
service that competes with tek-tube, and each version converts to the MIT licence two
years after it is published. Running it, reading it, modifying it, teaching with it and
using it inside a company are all inside the grant. Copyright 2026 Kevin Ernest Long.
The third-party material named in NOTICE.md - the Hershey vector fonts, the sample plot
files and the reference photographs - keeps its own terms and is not covered by that
licence.